haixunMaster/haixun-backend/internal/library/placement/scrape_replies.go

134 lines
3.2 KiB
Go
Raw Permalink Normal View History

2026-06-24 10:02:42 +00:00
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)
}