package placement import ( "context" "strings" libthreads "haixun-backend/internal/library/threadsapi" ) const replyFetchTopPosts = 10 // ReplyCandidate is a normalized reply attached to a scan post. type ReplyCandidate struct { ExternalID string Author string Text string Permalink string LikeCount int PostedAt string } type ScrapeRepliesInput struct { Posts []ScanCandidate Member MemberContext RepliesPerPost int MaxPosts int } // AttachReplies fetches replies for top-priority posts when scrape is enabled. func AttachReplies(ctx context.Context, input ScrapeRepliesInput) []ScanCandidate { if len(input.Posts) == 0 { return input.Posts } perPost := input.RepliesPerPost if perPost <= 0 { perPost = 10 } if perPost > 25 { perPost = 25 } maxPosts := input.MaxPosts if maxPosts <= 0 { maxPosts = replyFetchTopPosts } targets := pickReplyTargets(input.Posts, maxPosts) if len(targets) == 0 { return input.Posts } client := libthreads.NewClient(input.Member.ThreadsAPIAccessToken) byKey := make(map[string][]ReplyCandidate, len(targets)) for _, target := range targets { externalID := strings.TrimSpace(target.ExternalID) if externalID == "" { continue } var replies []ReplyCandidate if input.Member.ApiConnected && input.Member.ThreadsAPIAccessToken != "" { items, err := client.MediaReplies(ctx, externalID, perPost) if err == nil { for _, item := range items { text := strings.TrimSpace(item.Text) if text == "" { continue } replies = append(replies, ReplyCandidate{ ExternalID: strings.TrimSpace(item.ID), Author: strings.TrimSpace(item.Username), Text: text, Permalink: strings.TrimSpace(item.Permalink), LikeCount: item.LikeCount, PostedAt: strings.TrimSpace(item.Timestamp), }) } } } key := candidateKey(target) byKey[key] = replies } out := make([]ScanCandidate, 0, len(input.Posts)) for _, post := range input.Posts { key := candidateKey(post) if replies, ok := byKey[key]; ok && len(replies) > 0 { post.Replies = replies } out = append(out, post) } return out } func pickReplyTargets(posts []ScanCandidate, maxPosts int) []ScanCandidate { ranked := append([]ScanCandidate(nil), posts...) for i := 0; i < len(ranked); i++ { for j := i + 1; j < len(ranked); j++ { if replyTargetRank(ranked[j]) > replyTargetRank(ranked[i]) { ranked[i], ranked[j] = ranked[j], ranked[i] } } } out := make([]ScanCandidate, 0, maxPosts) for _, post := range ranked { if strings.TrimSpace(post.ExternalID) == "" && strings.TrimSpace(post.Permalink) == "" { continue } out = append(out, post) if len(out) >= maxPosts { break } } return out } func replyTargetRank(post ScanCandidate) int { switch post.Priority { case "gold": return 300 + post.PlacementScore case "recent": return 200 + post.PlacementScore case "relevant": return 100 + post.PlacementScore default: return post.PlacementScore } } func candidateKey(post ScanCandidate) string { if id := strings.TrimSpace(post.ExternalID); id != "" { return "id:" + id } return "url:" + strings.TrimSpace(post.Permalink) }