package viral import ( "fmt" "sort" "strings" "haixun-backend/internal/library/placement" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" ) const ( RefAccountMinBestEngagement = 50 RefAccountMinBestLikes = 18 RefAccountMinTotalEngagement = 80 RefVerifiedMinBestEngagement = 35 RefVerifiedMinBestLikes = 10 ) type ReferenceAccountInput struct { SeedQuery string Label string Posts []placement.ScanCandidate Limit int } type referenceAuthorAgg struct { username string verified bool followerCount int totalEngagement int bestEngagement int bestLikes int bestReplies int postCount int sampleText string sampleSearchTag string } // BuildReferenceAccountsFromScan lists authors from patrol posts that match the // mission topic and pass quality gates. Verified/follower are optional bonuses; // if strict gates yield nothing, falls back to baseline viral engagement. func BuildReferenceAccountsFromScan(in ReferenceAccountInput) []missionentity.SimilarAccount { out := buildReferenceAccountsFromScan(in, true) if len(out) > 0 { return out } return buildReferenceAccountsFromScan(in, false) } func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool) []missionentity.SimilarAccount { limit := in.Limit if limit <= 0 { limit = MaxSimilarAccounts } byUser := map[string]referenceAuthorAgg{} for _, post := range in.Posts { user := strings.TrimSpace(post.Author) if user == "" || !isValidUsername(user) { continue } if !postTopicRelevant(post, in.SeedQuery, in.Label) { continue } if strictQuality { if !PassesMissionQualityCandidate( post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore, post.AuthorVerified, post.FollowerCount, nil, ) { continue } } else if !PassesViralCandidate( post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore, nil, ) { continue } key := strings.ToLower(user) prev := byUser[key] prev.username = user if post.AuthorVerified { prev.verified = true } if post.FollowerCount > prev.followerCount { prev.followerCount = post.FollowerCount } prev.postCount++ prev.totalEngagement += post.EngagementScore if post.EngagementScore > prev.bestEngagement { prev.bestEngagement = post.EngagementScore prev.bestLikes = post.LikeCount prev.bestReplies = post.ReplyCount text := strings.TrimSpace(post.Text) if len([]rune(text)) > 80 { text = string([]rune(text)[:80]) } prev.sampleText = text prev.sampleSearchTag = strings.TrimSpace(post.SearchTag) } byUser[key] = prev } ranked := make([]referenceAuthorAgg, 0, len(byUser)) for _, item := range byUser { if qualifiesReferenceAuthor(item, strictQuality) { ranked = append(ranked, item) } } sort.Slice(ranked, func(i, j int) bool { if ranked[i].verified != ranked[j].verified { return ranked[i].verified } if ranked[i].followerCount != ranked[j].followerCount { return ranked[i].followerCount > ranked[j].followerCount } if ranked[i].totalEngagement != ranked[j].totalEngagement { return ranked[i].totalEngagement > ranked[j].totalEngagement } return ranked[i].bestEngagement > ranked[j].bestEngagement }) if len(ranked) > limit { ranked = ranked[:limit] } out := make([]missionentity.SimilarAccount, 0, len(ranked)) for _, item := range ranked { conf := "medium" if item.verified { conf = "high" } else if item.bestEngagement >= HotEngagementScore || item.totalEngagement >= 120 { conf = "high" } out = append(out, missionentity.SimilarAccount{ Username: item.username, Reason: formatReferenceReason(item), Source: "scan", Confidence: conf, ProfileURL: "https://www.threads.net/@" + item.username, AuthorVerified: item.verified, FollowerCount: item.followerCount, EngagementScore: item.bestEngagement, LikeCount: item.bestLikes, ReplyCount: item.bestReplies, PostCount: item.postCount, }) } return out } func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool { if item.postCount == 0 { return false } if item.verified { return item.bestLikes >= RefVerifiedMinBestLikes && (item.bestEngagement >= RefVerifiedMinBestEngagement || item.totalEngagement >= 60) } if strictQuality { if item.bestLikes < RefAccountMinBestLikes { return false } return item.bestEngagement >= RefAccountMinBestEngagement || item.totalEngagement >= RefAccountMinTotalEngagement } return item.bestLikes >= 8 && item.bestEngagement >= MinEngagementScore } func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool { text := strings.ToLower(strings.TrimSpace(post.Text)) tag := strings.ToLower(strings.TrimSpace(post.SearchTag)) terms := topicTerms(seed, label) if len(terms) == 0 { return text != "" || tag != "" } for _, term := range terms { term = strings.ToLower(term) if strings.Contains(text, term) || strings.Contains(tag, term) { return true } } return false } func topicTerms(seed, label string) []string { out := []string{} if s := strings.TrimSpace(seed); s != "" { out = append(out, s) } if l := strings.TrimSpace(label); l != "" { out = append(out, l) } return out } func formatReferenceReason(item referenceAuthorAgg) string { if item.sampleText != "" { return item.sampleText } if item.sampleSearchTag != "" { return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag) } return "本次海巡高互動作者" }