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