thread-master/backend/internal/library/viral/audience.go

219 lines
5.8 KiB
Go
Raw Permalink Normal View History

2026-06-28 08:28:42 +00:00
package viral
import (
"sort"
"strings"
"unicode/utf8"
"haixun-backend/internal/library/placement"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
)
const MaxAudienceSamples = 15
type AudienceOpts struct {
Max int
ExcludeAuthors []string
Now int64
}
type audienceAgg struct {
username string
samplePostID string
sampleText string
replyLikeCount int
appearances int
lastPostedAt string
}
// BuildAudienceSamplesFromReplies aggregates reply authors into potential
// audience samples. Low-quality replies and known similar-account authors are
// filtered out.
func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample {
if len(replies) == 0 {
return nil
}
max := opts.Max
if max <= 0 {
max = MaxAudienceSamples
}
exclude := map[string]struct{}{}
for _, author := range opts.ExcludeAuthors {
key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(author, "@")))
if key != "" {
exclude[key] = struct{}{}
}
}
byUser := map[string]audienceAgg{}
for _, reply := range replies {
user := strings.TrimSpace(reply.Author)
if user == "" || !isValidUsername(user) {
continue
}
if _, ok := exclude[strings.ToLower(user)]; ok {
continue
}
text := strings.TrimSpace(reply.Text)
if isLowQualityReply(text) {
continue
}
key := strings.ToLower(user)
prev := byUser[key]
prev.username = user
prev.appearances++
if reply.LikeCount > prev.replyLikeCount {
prev.replyLikeCount = reply.LikeCount
prev.sampleText = text
prev.samplePostID = strings.TrimSpace(reply.ExternalID)
prev.lastPostedAt = strings.TrimSpace(reply.PostedAt)
} else if prev.sampleText == "" {
prev.sampleText = text
prev.samplePostID = strings.TrimSpace(reply.ExternalID)
prev.lastPostedAt = strings.TrimSpace(reply.PostedAt)
}
byUser[key] = prev
}
ranked := make([]audienceAgg, 0, len(byUser))
for _, item := range byUser {
ranked = append(ranked, item)
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].appearances != ranked[j].appearances {
return ranked[i].appearances > ranked[j].appearances
}
if ranked[i].replyLikeCount != ranked[j].replyLikeCount {
return ranked[i].replyLikeCount > ranked[j].replyLikeCount
}
return ranked[i].lastPostedAt > ranked[j].lastPostedAt
})
if len(ranked) > max {
ranked = ranked[:max]
}
now := opts.Now
out := make([]missionentity.AudienceSample, 0, len(ranked))
for _, item := range ranked {
out = append(out, missionentity.AudienceSample{
Username: item.username,
SamplePostID: item.samplePostID,
SampleText: item.sampleText,
ReplyLikeCount: item.replyLikeCount,
Appearances: item.appearances,
FirstSeenAt: now,
LastSeenAt: now,
})
}
return out
}
// MergeAudienceSamples merges previous and newly built audience samples keyed
// by lowercase username. New samples refresh statistics; pinned samples are
// preserved even when not seen in the latest scan.
func MergeAudienceSamples(prev, next []missionentity.AudienceSample) []missionentity.AudienceSample {
if len(prev) == 0 {
return append([]missionentity.AudienceSample(nil), next...)
}
if len(next) == 0 {
return prioritizePinnedAudienceSamples(append([]missionentity.AudienceSample(nil), prev...))
}
byUser := map[string]missionentity.AudienceSample{}
newOrder := make([]string, 0, len(next))
for _, item := range next {
key := strings.ToLower(strings.TrimSpace(item.Username))
if key == "" {
continue
}
if _, ok := byUser[key]; !ok {
newOrder = append(newOrder, key)
}
byUser[key] = mergeAudienceRecord(byUser[key], item)
}
prevOrder := make([]string, 0, len(prev))
for _, item := range prev {
key := strings.ToLower(strings.TrimSpace(item.Username))
if key == "" {
continue
}
if _, ok := byUser[key]; ok {
merged := mergeAudienceRecord(item, byUser[key])
byUser[key] = merged
continue
}
byUser[key] = item
prevOrder = append(prevOrder, key)
}
out := make([]missionentity.AudienceSample, 0, len(newOrder)+len(prevOrder))
for _, key := range newOrder {
if sample, ok := byUser[key]; ok {
out = append(out, sample)
}
}
for _, key := range prevOrder {
if sample, ok := byUser[key]; ok {
out = append(out, sample)
}
}
return prioritizePinnedAudienceSamples(out)
}
func mergeAudienceRecord(prev, next missionentity.AudienceSample) missionentity.AudienceSample {
out := next
if prev.FirstSeenAt > 0 {
out.FirstSeenAt = prev.FirstSeenAt
}
if prev.Status == missionentity.AudienceSampleStatusPinned || prev.Status == missionentity.AudienceSampleStatusExcluded {
out.Status = prev.Status
}
if out.LastSeenAt == 0 {
out.LastSeenAt = prev.LastSeenAt
}
return out
}
func prioritizePinnedAudienceSamples(samples []missionentity.AudienceSample) []missionentity.AudienceSample {
if len(samples) == 0 {
return nil
}
pinned := make([]missionentity.AudienceSample, 0)
rest := make([]missionentity.AudienceSample, 0, len(samples))
for _, item := range samples {
if item.Status == missionentity.AudienceSampleStatusPinned {
pinned = append(pinned, item)
} else {
rest = append(rest, item)
}
}
out := append(pinned, rest...)
if len(out) > MaxAudienceSamples {
remaining := MaxAudienceSamples - len(pinned)
if remaining < 0 {
return pinned[:MaxAudienceSamples]
}
out = append(pinned, rest[:remaining]...)
}
return out
}
func isLowQualityReply(text string) bool {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return true
}
if utf8.RuneCountInString(trimmed) < 5 {
return true
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
fields := strings.Fields(trimmed)
if len(fields) <= 1 {
return true
}
}
return false
}