thread-master/internal/library/viral/discover.go

234 lines
6.0 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
package viral
import (
"context"
"fmt"
"strings"
"haixun-backend/internal/library/placement"
)
const (
defaultLimitPerKeyword = 15
missionLimitPerKeyword = 10
maxKeywords = 6
maxMergedPosts = 60
missionMaxMergedPosts = 40
missionQualityTarget = 12 // stop scanning extra keywords once enough quality posts
)
type DiscoverInput struct {
Keywords []string
Exclusions []string
Member placement.MemberContext
Crawler placement.CrawlerSearchFn
Limit int // per keyword; 0 = default
MaxMerged int // total cap; 0 = default
MissionScan bool // leaner defaults to save search API quota
}
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 {
if input.MissionScan {
perKeyword = missionLimitPerKeyword
} else {
perKeyword = defaultLimitPerKeyword
}
}
maxMerged := input.MaxMerged
if maxMerged <= 0 {
if input.MissionScan {
maxMerged = missionMaxMergedPosts
} else {
maxMerged = maxMergedPosts
}
}
merged := map[string]placement.ScanCandidate{}
relaxed := map[string]placement.ScanCandidate{}
total := len(keywords)
pathLabel := input.Member.DiscoverPathLabel()
var lastErr error
keywordsAttempted := 0
for i, keyword := range keywords {
if input.MissionScan && countMissionQuality(merged) >= missionQualityTarget {
if progress != nil {
progress(fmt.Sprintf("已收足 %d 篇品質候選,略過剩餘標籤以節省搜尋次數", missionQualityTarget), 10+(i*70)/max(total, 1))
}
break
}
if progress != nil {
pct := 10 + (i*70)/total
progress(fmt.Sprintf("掃描「%s」%s…", keyword, pathLabel), pct)
}
limit := perKeyword
if input.MissionScan && len(merged) > 0 {
limit = min(perKeyword, 8)
}
posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
Query: keyword,
Keyword: keyword,
Limit: limit,
Member: input.Member,
Crawler: input.Crawler,
})
if err != nil {
lastErr = err
if progress != nil {
progress(fmt.Sprintf("「%s」搜尋略過%s", keyword, shortenDiscoverErr(err)), 10+(i*70)/max(total, 1))
}
continue
}
keywordsAttempted++
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)
candidate := placement.ScanCandidate{
Permalink: post.Permalink,
ExternalID: post.ExternalID,
Author: post.Author,
AuthorVerified: post.AuthorVerified,
FollowerCount: post.FollowerCount,
Text: post.Text,
SearchTag: keyword,
Source: post.Source,
LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount,
EngagementScore: score,
PlacementScore: score,
Priority: PriorityLabel(score),
}
if input.MissionScan {
if PassesMissionQualityCandidate(
post.Text, post.LikeCount, post.ReplyCount, score,
post.AuthorVerified, post.FollowerCount, input.Exclusions,
) {
mergeCandidate(merged, key, candidate)
continue
}
if PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
mergeCandidate(relaxed, key, candidate)
}
continue
}
if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
continue
}
mergeCandidate(merged, key, candidate)
}
}
if input.MissionScan && len(merged) == 0 && len(relaxed) > 0 {
merged = relaxed
if progress != nil {
progress("未取得藍勾等延伸資料,改以互動門檻收斂爆款候選", 82)
}
}
out := candidatesFromMap(merged)
sortByEngagement(out)
if len(out) > maxMerged {
out = out[:maxMerged]
}
if len(out) == 0 {
if keywordsAttempted == 0 && lastErr != nil {
return nil, fmt.Errorf("所有標籤搜尋均失敗:%w", lastErr)
}
}
if progress != nil {
progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85)
}
return out, nil
}
func mergeCandidate(merged map[string]placement.ScanCandidate, key string, candidate placement.ScanCandidate) {
if prev, ok := merged[key]; !ok {
merged[key] = candidate
} else if candidate.EngagementScore > prev.EngagementScore {
merged[key] = MergeAuthorSignals(candidate, prev)
} else {
merged[key] = MergeAuthorSignals(prev, candidate)
}
}
func candidatesFromMap(merged map[string]placement.ScanCandidate) []placement.ScanCandidate {
out := make([]placement.ScanCandidate, 0, len(merged))
for _, item := range merged {
out = append(out, item)
}
return out
}
func shortenDiscoverErr(err error) string {
msg := strings.TrimSpace(err.Error())
if len(msg) > 80 {
return msg[:80] + "…"
}
return msg
}
func normalizeKeywords(raw []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(raw))
for _, item := range raw {
kw := DiscoverKeywordFromTag(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 countMissionQuality(merged map[string]placement.ScanCandidate) int {
n := 0
for _, item := range merged {
if PassesMissionQualityCandidate(
item.Text, item.LikeCount, item.ReplyCount, item.EngagementScore,
item.AuthorVerified, item.FollowerCount, nil,
) {
n++
}
}
return n
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
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]
}
}
}
}