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

129 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]
}
}
}
}