2026-06-26 08:37:04 +00:00
|
|
|
package viral
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-06-28 08:28:42 +00:00
|
|
|
"math"
|
2026-06-26 08:37:04 +00:00
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
"haixun-backend/internal/library/clock"
|
2026-06-26 08:37:04 +00:00
|
|
|
"haixun-backend/internal/library/placement"
|
2026-06-28 08:28:42 +00:00
|
|
|
libthreads "haixun-backend/internal/library/threadsapi"
|
2026-06-26 08:37:04 +00:00
|
|
|
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
RefAccountMinBestEngagement = 50
|
|
|
|
|
RefAccountMinBestLikes = 18
|
|
|
|
|
RefAccountMinTotalEngagement = 80
|
|
|
|
|
RefVerifiedMinBestEngagement = 35
|
|
|
|
|
RefVerifiedMinBestLikes = 10
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
// ReferenceRankWeights controls the weighted sort key applied to ranked
|
|
|
|
|
// reference authors. Defaults preserve historical precedence (verified first,
|
|
|
|
|
// then follower count, then total engagement, then best single-post
|
|
|
|
|
// engagement) and introduce topic relevance as a tie-breaker multiplier.
|
|
|
|
|
type ReferenceRankWeights struct {
|
|
|
|
|
VerifiedW int
|
|
|
|
|
FollowerW int
|
|
|
|
|
TotalEngagementW int
|
|
|
|
|
BestEngagementW int
|
|
|
|
|
TopicRelevanceW int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DefaultReferenceRankWeights returns the canonical weights used by
|
|
|
|
|
// BuildReferenceAccountsFromScan. Callers may pass a customised copy via
|
|
|
|
|
// ReferenceAccountInput in a future iteration; Phase 1 keeps it internal.
|
|
|
|
|
func DefaultReferenceRankWeights() ReferenceRankWeights {
|
|
|
|
|
return ReferenceRankWeights{
|
|
|
|
|
VerifiedW: 4,
|
|
|
|
|
FollowerW: 2,
|
|
|
|
|
TotalEngagementW: 1,
|
|
|
|
|
BestEngagementW: 1,
|
|
|
|
|
TopicRelevanceW: 2,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rankScore converts aggregated author signals into a weighted integer sort
|
|
|
|
|
// key. Follower count is log-scaled so 10M-follower mega accounts do not
|
|
|
|
|
// dominate niche candidates with high topic relevance.
|
|
|
|
|
func (w ReferenceRankWeights) rankScore(item referenceAuthorAgg) int {
|
|
|
|
|
score := 0
|
|
|
|
|
if item.verified {
|
|
|
|
|
score += w.VerifiedW * 1000
|
|
|
|
|
}
|
|
|
|
|
followerBucket := 0
|
|
|
|
|
switch {
|
|
|
|
|
case item.followerCount >= 1_000_000:
|
|
|
|
|
followerBucket = 4
|
|
|
|
|
case item.followerCount >= 100_000:
|
|
|
|
|
followerBucket = 3
|
|
|
|
|
case item.followerCount >= 10_000:
|
|
|
|
|
followerBucket = 2
|
|
|
|
|
case item.followerCount >= 1_000:
|
|
|
|
|
followerBucket = 1
|
|
|
|
|
}
|
|
|
|
|
score += w.FollowerW * followerBucket * 100
|
|
|
|
|
score += w.TotalEngagementW * item.totalEngagement
|
|
|
|
|
score += w.BestEngagementW * item.bestEngagement
|
|
|
|
|
score += w.TopicRelevanceW * item.topicHits * 50
|
|
|
|
|
return score
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
type ReferenceAccountInput struct {
|
2026-06-28 08:28:42 +00:00
|
|
|
SeedQuery string
|
|
|
|
|
Label string
|
|
|
|
|
Posts []placement.ScanCandidate
|
|
|
|
|
Limit int
|
|
|
|
|
ExcludedUsernames []string
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type referenceAuthorAgg struct {
|
|
|
|
|
username string
|
|
|
|
|
verified bool
|
|
|
|
|
followerCount int
|
|
|
|
|
totalEngagement int
|
|
|
|
|
bestEngagement int
|
|
|
|
|
bestLikes int
|
|
|
|
|
bestReplies int
|
|
|
|
|
postCount int
|
2026-06-28 08:28:42 +00:00
|
|
|
topicHits int
|
2026-06-26 08:37:04 +00:00
|
|
|
sampleText string
|
|
|
|
|
sampleSearchTag string
|
2026-06-28 08:28:42 +00:00
|
|
|
samplePermalink string
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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{}
|
2026-06-28 08:28:42 +00:00
|
|
|
terms := normalisedTopicTerms(in.SeedQuery, in.Label)
|
|
|
|
|
weights := DefaultReferenceRankWeights()
|
|
|
|
|
now := clock.NowUnixNano()
|
|
|
|
|
topicDenom := math.Max(1, float64(len(terms)))
|
2026-06-26 08:37:04 +00:00
|
|
|
for _, post := range in.Posts {
|
|
|
|
|
user := strings.TrimSpace(post.Author)
|
|
|
|
|
if user == "" || !isValidUsername(user) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
if isExcluded(in.ExcludedUsernames, user) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
hits := topicTopicHits(post, terms)
|
|
|
|
|
if hits == 0 {
|
2026-06-26 08:37:04 +00:00
|
|
|
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
|
2026-06-28 08:28:42 +00:00
|
|
|
if hits > prev.topicHits {
|
|
|
|
|
prev.topicHits = hits
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
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)
|
2026-06-28 08:28:42 +00:00
|
|
|
prev.samplePermalink = strings.TrimSpace(post.Permalink)
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-06-28 08:28:42 +00:00
|
|
|
si := weights.rankScore(ranked[i])
|
|
|
|
|
sj := weights.rankScore(ranked[j])
|
|
|
|
|
if si != sj {
|
|
|
|
|
return si > sj
|
|
|
|
|
}
|
|
|
|
|
// stable historical tie-breakers (verified first, follower,
|
|
|
|
|
// total engagement, best engagement) preserved for determinism.
|
2026-06-26 08:37:04 +00:00
|
|
|
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",
|
2026-06-28 08:28:42 +00:00
|
|
|
MatchedSource: []string{"scan"},
|
2026-06-26 08:37:04 +00:00
|
|
|
Confidence: conf,
|
2026-06-28 08:28:42 +00:00
|
|
|
Status: missionentity.SimilarAccountStatusRecommended,
|
|
|
|
|
TopicRelevance: float64(item.topicHits) / topicDenom,
|
|
|
|
|
LastSeenAt: now,
|
|
|
|
|
ProfileURL: libthreads.ProfileURLFromPermalink(item.samplePermalink, item.username),
|
2026-06-26 08:37:04 +00:00
|
|
|
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 {
|
2026-06-28 08:28:42 +00:00
|
|
|
return topicTopicHits(post, normalisedTopicTerms(seed, label)) > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// topicTopicHits counts how many normalised topic terms appear (case-folded
|
|
|
|
|
// substring match against the post's text and search tag). Returning a hit
|
|
|
|
|
// count (instead of a boolean) lets the ranking weight reward posts that are
|
|
|
|
|
// relevant across multiple seed/label tokens — a coarse but dependency-free
|
|
|
|
|
// CJK-friendly proxy for topic similarity.
|
|
|
|
|
func topicTopicHits(post placement.ScanCandidate, terms []string) int {
|
2026-06-26 08:37:04 +00:00
|
|
|
if len(terms) == 0 {
|
2026-06-28 08:28:42 +00:00
|
|
|
text := strings.TrimSpace(post.Text)
|
|
|
|
|
tag := strings.TrimSpace(post.SearchTag)
|
|
|
|
|
if text == "" && tag == "" {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return 1
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
text := strings.ToLower(strings.TrimSpace(post.Text))
|
|
|
|
|
tag := strings.ToLower(strings.TrimSpace(post.SearchTag))
|
|
|
|
|
hits := 0
|
2026-06-26 08:37:04 +00:00
|
|
|
for _, term := range terms {
|
2026-06-28 08:28:42 +00:00
|
|
|
if term == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
if strings.Contains(text, term) || strings.Contains(tag, term) {
|
2026-06-28 08:28:42 +00:00
|
|
|
hits++
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
return hits
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
// normalisedTopicTerms lowercases and de-spaces the seed-query and label while
|
|
|
|
|
// also exposing coarse tokens split on whitespace and punctuation. It remains
|
|
|
|
|
// dependency-free and CJK-friendly, but avoids matching only a long exact phrase.
|
|
|
|
|
func normalisedTopicTerms(seed, label string) []string {
|
2026-06-26 08:37:04 +00:00
|
|
|
out := []string{}
|
2026-06-28 08:28:42 +00:00
|
|
|
terms := missionTopicMatchTerms(seed, label, nil)
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
for _, term := range append(append([]string{}, terms.Anchors...), terms.Terms...) {
|
|
|
|
|
if term == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := seen[term]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[term] = struct{}{}
|
|
|
|
|
out = append(out, term)
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatReferenceReason(item referenceAuthorAgg) string {
|
|
|
|
|
if item.sampleText != "" {
|
|
|
|
|
return item.sampleText
|
|
|
|
|
}
|
|
|
|
|
if item.sampleSearchTag != "" {
|
|
|
|
|
return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag)
|
|
|
|
|
}
|
|
|
|
|
return "本次海巡高互動作者"
|
|
|
|
|
}
|