234 lines
6.0 KiB
Go
234 lines
6.0 KiB
Go
package viral
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"haixun-backend/internal/library/placement"
|
||
)
|
||
|
||
const (
|
||
defaultLimitPerKeyword = 15
|
||
missionLimitPerKeyword = 10
|
||
maxKeywords = 6
|
||
maxMergedPosts = 60
|
||
missionMaxMergedPosts = 40
|
||
missionQualityTarget = 12 // stop scanning extra keywords once enough quality posts
|
||
)
|
||
|
||
type DiscoverInput struct {
|
||
Keywords []string
|
||
Exclusions []string
|
||
Member placement.MemberContext
|
||
Crawler placement.CrawlerSearchFn
|
||
Limit int // per keyword; 0 = default
|
||
MaxMerged int // total cap; 0 = default
|
||
MissionScan bool // leaner defaults to save search API quota
|
||
}
|
||
|
||
type ProgressFn func(message string, pct int)
|
||
|
||
// RunDiscover searches Threads for viral candidates across keywords, ranked by engagement.
|
||
func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn) ([]placement.ScanCandidate, error) {
|
||
keywords := normalizeKeywords(input.Keywords)
|
||
if len(keywords) == 0 {
|
||
return nil, fmt.Errorf("請提供至少一個爆款掃描關鍵字")
|
||
}
|
||
perKeyword := input.Limit
|
||
if perKeyword <= 0 {
|
||
if input.MissionScan {
|
||
perKeyword = missionLimitPerKeyword
|
||
} else {
|
||
perKeyword = defaultLimitPerKeyword
|
||
}
|
||
}
|
||
maxMerged := input.MaxMerged
|
||
if maxMerged <= 0 {
|
||
if input.MissionScan {
|
||
maxMerged = missionMaxMergedPosts
|
||
} else {
|
||
maxMerged = maxMergedPosts
|
||
}
|
||
}
|
||
|
||
merged := map[string]placement.ScanCandidate{}
|
||
relaxed := map[string]placement.ScanCandidate{}
|
||
total := len(keywords)
|
||
pathLabel := input.Member.DiscoverPathLabel()
|
||
var lastErr error
|
||
keywordsAttempted := 0
|
||
|
||
for i, keyword := range keywords {
|
||
if input.MissionScan && countMissionQuality(merged) >= missionQualityTarget {
|
||
if progress != nil {
|
||
progress(fmt.Sprintf("已收足 %d 篇品質候選,略過剩餘標籤以節省搜尋次數", missionQualityTarget), 10+(i*70)/max(total, 1))
|
||
}
|
||
break
|
||
}
|
||
if progress != nil {
|
||
pct := 10 + (i*70)/total
|
||
progress(fmt.Sprintf("掃描「%s」(%s)…", keyword, pathLabel), pct)
|
||
}
|
||
limit := perKeyword
|
||
if input.MissionScan && len(merged) > 0 {
|
||
limit = min(perKeyword, 8)
|
||
}
|
||
posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
|
||
Query: keyword,
|
||
Keyword: keyword,
|
||
Limit: limit,
|
||
Member: input.Member,
|
||
Crawler: input.Crawler,
|
||
})
|
||
if err != nil {
|
||
lastErr = err
|
||
if progress != nil {
|
||
progress(fmt.Sprintf("「%s」搜尋略過:%s", keyword, shortenDiscoverErr(err)), 10+(i*70)/max(total, 1))
|
||
}
|
||
continue
|
||
}
|
||
keywordsAttempted++
|
||
for _, post := range posts {
|
||
key := strings.TrimSpace(post.Permalink)
|
||
if key == "" {
|
||
key = strings.TrimSpace(post.ExternalID)
|
||
}
|
||
if key == "" {
|
||
continue
|
||
}
|
||
score := ScorePost(post.LikeCount, post.ReplyCount)
|
||
candidate := placement.ScanCandidate{
|
||
Permalink: post.Permalink,
|
||
ExternalID: post.ExternalID,
|
||
Author: post.Author,
|
||
AuthorVerified: post.AuthorVerified,
|
||
FollowerCount: post.FollowerCount,
|
||
Text: post.Text,
|
||
SearchTag: keyword,
|
||
Source: post.Source,
|
||
LikeCount: post.LikeCount,
|
||
ReplyCount: post.ReplyCount,
|
||
EngagementScore: score,
|
||
PlacementScore: score,
|
||
Priority: PriorityLabel(score),
|
||
}
|
||
if input.MissionScan {
|
||
if PassesMissionQualityCandidate(
|
||
post.Text, post.LikeCount, post.ReplyCount, score,
|
||
post.AuthorVerified, post.FollowerCount, input.Exclusions,
|
||
) {
|
||
mergeCandidate(merged, key, candidate)
|
||
continue
|
||
}
|
||
if PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
|
||
mergeCandidate(relaxed, key, candidate)
|
||
}
|
||
continue
|
||
}
|
||
if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
|
||
continue
|
||
}
|
||
mergeCandidate(merged, key, candidate)
|
||
}
|
||
}
|
||
|
||
if input.MissionScan && len(merged) == 0 && len(relaxed) > 0 {
|
||
merged = relaxed
|
||
if progress != nil {
|
||
progress("未取得藍勾等延伸資料,改以互動門檻收斂爆款候選", 82)
|
||
}
|
||
}
|
||
|
||
out := candidatesFromMap(merged)
|
||
sortByEngagement(out)
|
||
if len(out) > maxMerged {
|
||
out = out[:maxMerged]
|
||
}
|
||
if len(out) == 0 {
|
||
if keywordsAttempted == 0 && lastErr != nil {
|
||
return nil, fmt.Errorf("所有標籤搜尋均失敗:%w", lastErr)
|
||
}
|
||
}
|
||
if progress != nil {
|
||
progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func mergeCandidate(merged map[string]placement.ScanCandidate, key string, candidate placement.ScanCandidate) {
|
||
if prev, ok := merged[key]; !ok {
|
||
merged[key] = candidate
|
||
} else if candidate.EngagementScore > prev.EngagementScore {
|
||
merged[key] = MergeAuthorSignals(candidate, prev)
|
||
} else {
|
||
merged[key] = MergeAuthorSignals(prev, candidate)
|
||
}
|
||
}
|
||
|
||
func candidatesFromMap(merged map[string]placement.ScanCandidate) []placement.ScanCandidate {
|
||
out := make([]placement.ScanCandidate, 0, len(merged))
|
||
for _, item := range merged {
|
||
out = append(out, item)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func shortenDiscoverErr(err error) string {
|
||
msg := strings.TrimSpace(err.Error())
|
||
if len(msg) > 80 {
|
||
return msg[:80] + "…"
|
||
}
|
||
return msg
|
||
}
|
||
|
||
func normalizeKeywords(raw []string) []string {
|
||
seen := map[string]struct{}{}
|
||
out := make([]string, 0, len(raw))
|
||
for _, item := range raw {
|
||
kw := DiscoverKeywordFromTag(item)
|
||
if kw == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[kw]; ok {
|
||
continue
|
||
}
|
||
seen[kw] = struct{}{}
|
||
out = append(out, kw)
|
||
if len(out) >= maxKeywords {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func countMissionQuality(merged map[string]placement.ScanCandidate) int {
|
||
n := 0
|
||
for _, item := range merged {
|
||
if PassesMissionQualityCandidate(
|
||
item.Text, item.LikeCount, item.ReplyCount, item.EngagementScore,
|
||
item.AuthorVerified, item.FollowerCount, nil,
|
||
) {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func sortByEngagement(items []placement.ScanCandidate) {
|
||
for i := 0; i < len(items); i++ {
|
||
for j := i + 1; j < len(items); j++ {
|
||
if items[j].EngagementScore > items[i].EngagementScore {
|
||
items[i], items[j] = items[j], items[i]
|
||
}
|
||
}
|
||
}
|
||
}
|