haixunMaster/haixun-backend/internal/library/viral/discover.go

129 lines
3.2 KiB
Go
Raw Normal View History

2026-06-24 10:02:42 +00:00
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]
}
}
}
}