2026-06-26 08:37:04 +00:00
|
|
|
|
package placement
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
libkg "haixun-backend/internal/library/knowledge"
|
|
|
|
|
|
"haixun-backend/internal/library/websearch"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
relevanceLimitPerTag = 12
|
|
|
|
|
|
recencyLimitPerTag = 8
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type ScanCandidate struct {
|
2026-06-27 16:03:28 +00:00
|
|
|
|
Permalink string
|
|
|
|
|
|
ExternalID string
|
|
|
|
|
|
Author string
|
|
|
|
|
|
AuthorID string
|
|
|
|
|
|
AuthorAvatar string
|
|
|
|
|
|
Text string
|
|
|
|
|
|
SearchTag string
|
|
|
|
|
|
QueryDimension QueryDimension
|
2026-07-01 08:42:51 +00:00
|
|
|
|
RecencyDays int
|
2026-06-27 16:03:28 +00:00
|
|
|
|
GraphNodeID string
|
|
|
|
|
|
ProductFitScore int
|
|
|
|
|
|
Source DiscoverChannel
|
|
|
|
|
|
HasRelevance bool
|
|
|
|
|
|
HasRecency bool
|
|
|
|
|
|
Priority string
|
|
|
|
|
|
AuthorVerified bool
|
|
|
|
|
|
FollowerCount int
|
|
|
|
|
|
AuthorFollowers int
|
|
|
|
|
|
LikeCount int
|
|
|
|
|
|
ReplyCount int
|
|
|
|
|
|
EngagementScore int
|
|
|
|
|
|
PlacementScore int
|
|
|
|
|
|
SolvedByProduct bool
|
|
|
|
|
|
PostedAt string
|
|
|
|
|
|
Replies []ReplyCandidate
|
|
|
|
|
|
Embedding []float32
|
|
|
|
|
|
SemanticScore int
|
|
|
|
|
|
EngagementPredicted int
|
|
|
|
|
|
AudienceQualityScore int
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DualTrackInput struct {
|
|
|
|
|
|
Nodes []libkg.Node
|
|
|
|
|
|
PatrolKeywords []string
|
|
|
|
|
|
Exclusions []string
|
2026-07-01 08:42:51 +00:00
|
|
|
|
PatrolContext PostScanContext
|
2026-06-26 08:37:04 +00:00
|
|
|
|
Member MemberContext
|
|
|
|
|
|
WebSearch websearch.Client
|
|
|
|
|
|
Crawler CrawlerSearchFn
|
|
|
|
|
|
Limit int // max queries budget; 0 = default
|
|
|
|
|
|
OnCheckpoint func(candidates []ScanCandidate) error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DualTrackProgress func(message string, pct int)
|
|
|
|
|
|
|
|
|
|
|
|
// CollectTagQueries builds crawl jobs from selected graph nodes.
|
|
|
|
|
|
func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQuery {
|
|
|
|
|
|
out := make([]TagQuery, 0, len(nodes)*4)
|
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
|
|
if !node.SelectedForScan {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
fit := node.ProductFitScore
|
|
|
|
|
|
derived := node.DerivedTags
|
|
|
|
|
|
if len(derived.Relevance) == 0 && len(derived.Recency) == 0 {
|
|
|
|
|
|
derived = libkg.DerivePatrolTagsForNode(node, libkg.PatrolTagInput{})
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tag := range derived.Relevance {
|
|
|
|
|
|
tag = strings.TrimSpace(tag)
|
|
|
|
|
|
if tag == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
q := BuildRelevanceQuery(provider, tag)
|
|
|
|
|
|
if q == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, TagQuery{
|
|
|
|
|
|
Tag: tag,
|
|
|
|
|
|
Query: q,
|
|
|
|
|
|
Dimension: QueryRelevance,
|
|
|
|
|
|
GraphNodeID: node.ID,
|
|
|
|
|
|
ProductFitScore: fit,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tag := range derived.Recency {
|
|
|
|
|
|
tag = strings.TrimSpace(tag)
|
|
|
|
|
|
if tag == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays)
|
|
|
|
|
|
if q7 != "" {
|
|
|
|
|
|
out = append(out, TagQuery{
|
|
|
|
|
|
Tag: tag,
|
|
|
|
|
|
Query: q7,
|
|
|
|
|
|
Dimension: QueryRecency,
|
|
|
|
|
|
GraphNodeID: node.ID,
|
|
|
|
|
|
ProductFitScore: fit,
|
|
|
|
|
|
RecencyDays: IdealMaxPostAgeDays,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
|
|
|
|
|
|
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
|
2026-07-01 08:42:51 +00:00
|
|
|
|
queries := ResolveTagQueries(
|
|
|
|
|
|
input.Nodes,
|
|
|
|
|
|
input.PatrolKeywords,
|
|
|
|
|
|
input.Member.WebSearchProviderEnum(),
|
|
|
|
|
|
PatrolTagInputFromScanContext(input.PatrolContext),
|
|
|
|
|
|
)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if len(queries) == 0 {
|
|
|
|
|
|
if len(input.PatrolKeywords) > 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
|
|
|
|
|
}
|
|
|
|
|
|
selected := 0
|
|
|
|
|
|
for _, node := range input.Nodes {
|
|
|
|
|
|
if node.SelectedForScan {
|
|
|
|
|
|
selected++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if selected > 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("已勾選節點但沒有可用的海巡 tag,請重新擴展圖譜或手動編輯 tag")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("請先勾選要海巡的節點並儲存")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
merged := map[string]*ScanCandidate{}
|
|
|
|
|
|
order := make([]string, 0, 64)
|
|
|
|
|
|
|
|
|
|
|
|
runQuery := func(tq TagQuery, limit int) error {
|
|
|
|
|
|
posts, channel, err := discoverForQuery(ctx, input, tq, limit)
|
|
|
|
|
|
if err != nil {
|
2026-07-01 08:42:51 +00:00
|
|
|
|
if onProgress != nil {
|
|
|
|
|
|
onProgress(fmt.Sprintf("略過「%s」:%s", tq.Tag, err.Error()), -1)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(posts) == 0 {
|
|
|
|
|
|
return nil
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
for _, post := range posts {
|
|
|
|
|
|
if MatchesExclusion(post.Text, input.Exclusions) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
if LooksLikeCasualChat(post.Text) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
postedAt := strings.TrimSpace(post.PostedAt)
|
|
|
|
|
|
if !PassesDiscoverFilters(post.Text, tq.Tag, postedAt, tq.Dimension, tq.ProductFitScore, tq.RecencyDays, input.PatrolContext) {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
bodyFit := ScorePostBodyProductFit(post.Text, input.PatrolContext)
|
|
|
|
|
|
semanticScore := LocalIntentScore(post.Text, input.PatrolContext)
|
|
|
|
|
|
effectiveFit := bodyFit
|
|
|
|
|
|
if tq.ProductFitScore > 0 && bodyFit > 0 {
|
|
|
|
|
|
effectiveFit = (tq.ProductFitScore + bodyFit*2) / 3
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
key := post.Permalink
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
existing, ok := merged[key]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
priority := "relevant"
|
|
|
|
|
|
if tq.Dimension == QueryRecency {
|
|
|
|
|
|
priority = "recent"
|
|
|
|
|
|
}
|
|
|
|
|
|
extID := post.ExternalID
|
|
|
|
|
|
if extID == "" {
|
|
|
|
|
|
if parsed, ok := ParseThreadsPostFromWebResult(post.Text, "", post.Permalink); ok {
|
|
|
|
|
|
extID = parsed.ExternalID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
semP := &semanticScore
|
2026-06-26 08:37:04 +00:00
|
|
|
|
merged[key] = &ScanCandidate{
|
|
|
|
|
|
Permalink: post.Permalink,
|
|
|
|
|
|
ExternalID: extID,
|
|
|
|
|
|
Author: post.Author,
|
|
|
|
|
|
AuthorVerified: post.AuthorVerified,
|
|
|
|
|
|
FollowerCount: post.FollowerCount,
|
|
|
|
|
|
Text: post.Text,
|
|
|
|
|
|
SearchTag: tq.Tag,
|
|
|
|
|
|
QueryDimension: tq.Dimension,
|
2026-07-01 08:42:51 +00:00
|
|
|
|
RecencyDays: recencyDaysForCandidate(tq),
|
2026-06-26 08:37:04 +00:00
|
|
|
|
GraphNodeID: tq.GraphNodeID,
|
2026-07-01 08:42:51 +00:00
|
|
|
|
ProductFitScore: effectiveFit,
|
2026-06-26 08:37:04 +00:00
|
|
|
|
Source: channel,
|
|
|
|
|
|
HasRelevance: tq.Dimension == QueryRelevance,
|
|
|
|
|
|
HasRecency: tq.Dimension == QueryRecency,
|
|
|
|
|
|
Priority: priority,
|
|
|
|
|
|
LikeCount: post.LikeCount,
|
|
|
|
|
|
ReplyCount: post.ReplyCount,
|
2026-07-01 08:42:51 +00:00
|
|
|
|
SemanticScore: semanticScore,
|
|
|
|
|
|
PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil),
|
|
|
|
|
|
SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext),
|
|
|
|
|
|
PostedAt: postedAt,
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
order = append(order, key)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if tq.Dimension == QueryRelevance {
|
|
|
|
|
|
existing.HasRelevance = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if tq.Dimension == QueryRecency {
|
|
|
|
|
|
existing.HasRecency = true
|
2026-07-01 08:42:51 +00:00
|
|
|
|
existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore {
|
|
|
|
|
|
if effectiveFit > existing.ProductFitScore {
|
|
|
|
|
|
existing.ProductFitScore = effectiveFit
|
|
|
|
|
|
}
|
|
|
|
|
|
if semanticScore > existing.SemanticScore {
|
|
|
|
|
|
existing.SemanticScore = semanticScore
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.SolvedByProduct = PostSolvedByProduct(existing.Text, existing.ProductFitScore, input.PatrolContext)
|
|
|
|
|
|
semP := existing.SemanticScore
|
|
|
|
|
|
semPtr := &semP
|
|
|
|
|
|
if semP <= 0 {
|
|
|
|
|
|
semPtr = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.PlacementScore = computePlacementScore(existing.Text, existing.ProductFitScore, existing.HasRecency, nil, semPtr, nil)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
|
|
|
|
|
|
existing.PostedAt = strings.TrimSpace(post.PostedAt)
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
total := len(queries)
|
|
|
|
|
|
for i, tq := range queries {
|
|
|
|
|
|
if onProgress != nil {
|
|
|
|
|
|
pct := 10 + ((i + 1) * 75 / max(total, 1))
|
|
|
|
|
|
onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct)
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
limit := perTagDiscoverLimit(total, tq.Dimension)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if err := runQuery(tq, limit); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.OnCheckpoint != nil {
|
2026-07-01 08:42:51 +00:00
|
|
|
|
snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if err := input.OnCheckpoint(snapshot); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.Member.AllowsCrawler && input.Member.BrowserConnected && i < total-1 {
|
|
|
|
|
|
if err := politeDiscoverPause(ctx); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:42:51 +00:00
|
|
|
|
cascadeExtra := 0
|
|
|
|
|
|
for _, st := range buildTagCascadeStates(queries) {
|
|
|
|
|
|
for countCandidatesForTag(merged, st.Tag) < MinRecencyCandidatesPerTag {
|
|
|
|
|
|
if cascadeExtra >= MaxRecencyCascadeQueries {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
days := nextRecencyCascadeWindow(st.RanWindows)
|
|
|
|
|
|
if days == 0 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
tq, ok := buildRecencyCascadeQuery(st, days, input.Member.WebSearchProviderEnum())
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
st.RanWindows[days] = true
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if onProgress != nil {
|
|
|
|
|
|
onProgress(fmt.Sprintf("近 %d 天結果不足,改搜近 %d 天:%s", previousRecencyWindow(days), days, st.Tag), -1)
|
|
|
|
|
|
}
|
|
|
|
|
|
limit := perTagDiscoverLimit(total, QueryRecency)
|
|
|
|
|
|
if err := runQuery(tq, limit); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
st.RanWindows[days] = true
|
|
|
|
|
|
cascadeExtra++
|
|
|
|
|
|
if input.OnCheckpoint != nil {
|
|
|
|
|
|
snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
|
|
|
|
|
|
if err := input.OnCheckpoint(snapshot); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.Member.AllowsCrawler && input.Member.BrowserConnected {
|
|
|
|
|
|
if err := politeDiscoverPause(ctx); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if cascadeExtra >= MaxRecencyCascadeQueries {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out := snapshotMergedCandidates(merged, order, true, input.PatrolContext)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if onProgress != nil {
|
|
|
|
|
|
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) {
|
2026-07-01 08:42:51 +00:00
|
|
|
|
apiKeyword := tq.Tag
|
|
|
|
|
|
if shaped := libkg.ThreadsAPIKeyword(tq.Tag); shaped != "" {
|
|
|
|
|
|
apiKeyword = shaped
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
req := DiscoverRequest{
|
|
|
|
|
|
Query: tq.Query,
|
2026-07-01 08:42:51 +00:00
|
|
|
|
Keyword: apiKeyword,
|
2026-06-26 08:37:04 +00:00
|
|
|
|
Recency: tq.Dimension == QueryRecency,
|
|
|
|
|
|
Limit: limit,
|
|
|
|
|
|
Member: input.Member,
|
|
|
|
|
|
Crawler: input.Crawler,
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
merged := map[string]DiscoverPost{}
|
|
|
|
|
|
channel := DiscoverChannel("")
|
|
|
|
|
|
posts, primaryChannel, err := Discover(ctx, req)
|
|
|
|
|
|
for _, post := range posts {
|
|
|
|
|
|
key := strings.TrimSpace(post.Permalink)
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if existing, ok := merged[key]; ok {
|
|
|
|
|
|
post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
|
|
|
|
|
|
}
|
|
|
|
|
|
merged[key] = post
|
|
|
|
|
|
}
|
|
|
|
|
|
if primaryChannel != "" {
|
|
|
|
|
|
channel = primaryChannel
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
|
|
|
|
|
|
webEnabled := input.WebSearch != nil && input.WebSearch.Enabled()
|
|
|
|
|
|
if webEnabled && input.Member.AllowsBrave {
|
|
|
|
|
|
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
|
|
|
|
|
|
if werr != nil && len(merged) == 0 && err != nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
return nil, "", err
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
for _, post := range webPosts {
|
|
|
|
|
|
key := strings.TrimSpace(post.Permalink)
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
merged[key] = post
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(merged) > 0 && channel == "" {
|
|
|
|
|
|
channel = input.Member.WebSearchDiscoverChannel()
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if len(merged) == 0 {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", err
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
if !webEnabled {
|
|
|
|
|
|
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out := make([]DiscoverPost, 0, len(merged))
|
|
|
|
|
|
for _, post := range merged {
|
|
|
|
|
|
out = append(out, post)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(out) > limit {
|
|
|
|
|
|
out = out[:limit]
|
|
|
|
|
|
}
|
|
|
|
|
|
if channel == "" && len(out) > 0 {
|
|
|
|
|
|
channel = DiscoverThreadsAPI
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
return out, channel, nil
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
|
|
|
|
|
|
res, err := client.Search(ctx, websearch.SearchOptions{
|
|
|
|
|
|
Query: tq.Query,
|
|
|
|
|
|
Limit: limit,
|
|
|
|
|
|
Mode: websearch.ModeThreadsDiscover,
|
|
|
|
|
|
Country: member.BraveCountry,
|
|
|
|
|
|
SearchLang: member.BraveSearchLang,
|
|
|
|
|
|
UserLocation: member.ExaUserLocation,
|
|
|
|
|
|
StartPublishedDate: PublishedAfterForRecency(member.WebSearchProviderEnum(), tq.RecencyDays),
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if res.Status != "success" || len(res.Results) == 0 {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
source := member.WebSearchDiscoverChannel()
|
|
|
|
|
|
out := make([]DiscoverPost, 0, len(res.Results))
|
|
|
|
|
|
for _, item := range res.Results {
|
|
|
|
|
|
parsed, ok := ParseThreadsPostFromWebResult(item.Title, item.Snippet, item.URL)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, DiscoverPost{
|
|
|
|
|
|
Text: parsed.Text,
|
|
|
|
|
|
Permalink: parsed.Permalink,
|
|
|
|
|
|
ExternalID: parsed.ExternalID,
|
|
|
|
|
|
Author: parsed.Author,
|
|
|
|
|
|
Source: source,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:42:51 +00:00
|
|
|
|
func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool, ctx PostScanContext) []ScanCandidate {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
out := make([]ScanCandidate, 0, len(order))
|
|
|
|
|
|
for _, key := range order {
|
|
|
|
|
|
item := merged[key]
|
2026-07-01 08:42:51 +00:00
|
|
|
|
finalizeScanCandidate(item, ctx)
|
|
|
|
|
|
if applyFinalFilter && !passesFinalStrictFilter(item) {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, *item)
|
|
|
|
|
|
}
|
2026-07-01 08:42:51 +00:00
|
|
|
|
if applyFinalFilter && len(out) == 0 && len(order) > 0 {
|
|
|
|
|
|
for _, key := range order {
|
|
|
|
|
|
item := merged[key]
|
|
|
|
|
|
finalizeScanCandidate(item, ctx)
|
|
|
|
|
|
if !PassesRelaxedFinalFilters(item.Text, item.ProductFitScore, ctx) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, *item)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if applyFinalFilter && len(out) == 0 && len(order) > 0 {
|
|
|
|
|
|
for _, key := range order {
|
|
|
|
|
|
item := merged[key]
|
|
|
|
|
|
finalizeScanCandidate(item, ctx)
|
|
|
|
|
|
if !PassesIngestFallbackFilters(item.Text, item.ProductFitScore, ctx) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, *item)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:42:51 +00:00
|
|
|
|
func passesFinalStrictFilter(item *ScanCandidate) bool {
|
|
|
|
|
|
if item == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if item.ProductFitScore < minPostProductFitPatrol && item.Priority != "gold" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return item.SolvedByProduct
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func finalizeScanCandidate(item *ScanCandidate, ctx PostScanContext) {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if item == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 {
|
|
|
|
|
|
item.Priority = "gold"
|
|
|
|
|
|
} else if item.HasRecency {
|
|
|
|
|
|
item.Priority = "recent"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
item.Priority = "relevant"
|
|
|
|
|
|
}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
var engP, semP, qualP *int
|
|
|
|
|
|
if item.EngagementPredicted > 0 {
|
|
|
|
|
|
v := item.EngagementPredicted
|
|
|
|
|
|
engP = &v
|
|
|
|
|
|
}
|
|
|
|
|
|
if item.SemanticScore > 0 {
|
|
|
|
|
|
v := item.SemanticScore
|
|
|
|
|
|
semP = &v
|
|
|
|
|
|
}
|
|
|
|
|
|
if item.AudienceQualityScore > 0 {
|
|
|
|
|
|
v := item.AudienceQualityScore
|
|
|
|
|
|
qualP = &v
|
|
|
|
|
|
}
|
|
|
|
|
|
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP)
|
2026-07-01 08:42:51 +00:00
|
|
|
|
item.SolvedByProduct = PostSolvedByProduct(item.Text, item.ProductFitScore, ctx)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 16:03:28 +00:00
|
|
|
|
func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
score := 30 + productFit/4
|
|
|
|
|
|
if HasPlacementIntent(text) {
|
|
|
|
|
|
score += 20
|
|
|
|
|
|
}
|
|
|
|
|
|
if LooksLikeRecommendationPost(text) {
|
|
|
|
|
|
score += 12
|
|
|
|
|
|
}
|
|
|
|
|
|
if recent {
|
|
|
|
|
|
score += 15
|
|
|
|
|
|
}
|
|
|
|
|
|
if productFit >= 60 {
|
|
|
|
|
|
score += 8
|
|
|
|
|
|
}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
if predictedEngagement != nil && *predictedEngagement > 60 {
|
|
|
|
|
|
score += 10
|
|
|
|
|
|
}
|
|
|
|
|
|
if semanticScore != nil && *semanticScore > 50 {
|
|
|
|
|
|
score += 10
|
|
|
|
|
|
}
|
|
|
|
|
|
if audienceQuality != nil && *audienceQuality > 60 {
|
|
|
|
|
|
score += 5
|
|
|
|
|
|
}
|
|
|
|
|
|
if score > 100 {
|
|
|
|
|
|
return 100
|
|
|
|
|
|
}
|
|
|
|
|
|
return score
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func computeAudienceQuality(followerCount int, authorVerified bool, likeCount int, replyCount int) int {
|
|
|
|
|
|
score := 30
|
|
|
|
|
|
if authorVerified {
|
|
|
|
|
|
score += 20
|
|
|
|
|
|
}
|
|
|
|
|
|
if followerCount > 10000 {
|
|
|
|
|
|
score += 15
|
|
|
|
|
|
} else if followerCount > 1000 {
|
|
|
|
|
|
score += 10
|
|
|
|
|
|
} else if followerCount > 100 {
|
|
|
|
|
|
score += 5
|
|
|
|
|
|
}
|
|
|
|
|
|
totalEngagement := likeCount + replyCount*3
|
|
|
|
|
|
if followerCount > 0 && totalEngagement > 0 {
|
|
|
|
|
|
engagementRate := (totalEngagement * 100) / followerCount
|
|
|
|
|
|
if engagementRate > 5 {
|
|
|
|
|
|
score += 15
|
|
|
|
|
|
} else if engagementRate > 2 {
|
|
|
|
|
|
score += 10
|
|
|
|
|
|
} else if engagementRate > 1 {
|
|
|
|
|
|
score += 5
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if score > 100 {
|
|
|
|
|
|
return 100
|
|
|
|
|
|
}
|
|
|
|
|
|
return score
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func max(a, b int) int {
|
|
|
|
|
|
if a > b {
|
|
|
|
|
|
return a
|
|
|
|
|
|
}
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:42:51 +00:00
|
|
|
|
func recencyDaysForCandidate(tq TagQuery) int {
|
|
|
|
|
|
if tq.Dimension != QueryRecency {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if tq.RecencyDays > 0 {
|
|
|
|
|
|
return tq.RecencyDays
|
|
|
|
|
|
}
|
|
|
|
|
|
return IdealMaxPostAgeDays
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func mergeRecencyDays(existing, incoming int) int {
|
|
|
|
|
|
if incoming <= 0 {
|
|
|
|
|
|
return existing
|
|
|
|
|
|
}
|
|
|
|
|
|
if existing <= 0 {
|
|
|
|
|
|
return incoming
|
|
|
|
|
|
}
|
|
|
|
|
|
if incoming < existing {
|
|
|
|
|
|
return incoming
|
|
|
|
|
|
}
|
|
|
|
|
|
return existing
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func previousRecencyWindow(days int) int {
|
|
|
|
|
|
prev := IdealMaxPostAgeDays
|
|
|
|
|
|
for _, window := range RecencyCascadeDays() {
|
|
|
|
|
|
if window == days {
|
|
|
|
|
|
return prev
|
|
|
|
|
|
}
|
|
|
|
|
|
prev = window
|
|
|
|
|
|
}
|
|
|
|
|
|
return IdealMaxPostAgeDays
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func perTagDiscoverLimit(totalQueries int, dimension QueryDimension) int {
|
|
|
|
|
|
limit := relevanceLimitPerTag
|
|
|
|
|
|
if dimension == QueryRecency {
|
|
|
|
|
|
limit = recencyLimitPerTag
|
|
|
|
|
|
}
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case totalQueries > 18:
|
|
|
|
|
|
return max(7, limit-3)
|
|
|
|
|
|
case totalQueries > 14:
|
|
|
|
|
|
return max(8, limit-2)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return limit
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
|
func politeDiscoverPause(ctx context.Context) error {
|
|
|
|
|
|
wait := 2*time.Second + jitterDuration(2*time.Second)
|
|
|
|
|
|
timer := time.NewTimer(wait)
|
|
|
|
|
|
defer timer.Stop()
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|