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 }