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