219 lines
5.8 KiB
Go
219 lines
5.8 KiB
Go
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
|
|
}
|