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