haixunMaster/haixun-backend/internal/library/viral/reference_accounts.go

201 lines
5.5 KiB
Go

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 "本次海巡高互動作者"
}