129 lines
3.2 KiB
Go
129 lines
3.2 KiB
Go
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]
|
||
}
|
||
}
|
||
}
|
||
}
|