package viral import ( "context" "fmt" "strings" "haixun-backend/internal/library/placement" ) const ( defaultLimitPerKeyword = 15 maxKeywords = 6 maxMergedPosts = 60 ) type DiscoverInput struct { Keywords []string Exclusions []string Member placement.MemberContext Crawler placement.CrawlerSearchFn Limit int // per keyword; 0 = default } 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 { perKeyword = defaultLimitPerKeyword } merged := map[string]placement.ScanCandidate{} total := len(keywords) for i, keyword := range keywords { if progress != nil { pct := 10 + (i*70)/total progress(fmt.Sprintf("掃描關鍵字「%s」…", keyword), pct) } posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{ Query: keyword, Keyword: keyword, Limit: perKeyword, Member: input.Member, Crawler: input.Crawler, }) if err != nil { return nil, fmt.Errorf("關鍵字「%s」:%w", keyword, err) } 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) if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) { continue } candidate := placement.ScanCandidate{ Permalink: post.Permalink, ExternalID: post.ExternalID, Author: post.Author, Text: post.Text, SearchTag: keyword, Source: post.Source, LikeCount: post.LikeCount, ReplyCount: post.ReplyCount, EngagementScore: score, PlacementScore: score, Priority: PriorityLabel(score), } if prev, ok := merged[key]; !ok || candidate.EngagementScore > prev.EngagementScore { merged[key] = candidate } } } out := make([]placement.ScanCandidate, 0, len(merged)) for _, item := range merged { out = append(out, item) } sortByEngagement(out) if len(out) > maxMergedPosts { out = out[:maxMergedPosts] } if progress != nil { progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85) } return out, nil } func normalizeKeywords(raw []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(raw)) for _, item := range raw { kw := strings.TrimSpace(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 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] } } } }