2026-06-24 10:02:42 +00:00
|
|
|
|
package placement
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// DiscoverChannel identifies which backend fulfilled a placement discover query.
|
|
|
|
|
|
type DiscoverChannel string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
DiscoverThreadsAPI DiscoverChannel = "threads_api"
|
|
|
|
|
|
DiscoverBrave DiscoverChannel = "brave"
|
2026-06-25 08:20:03 +00:00
|
|
|
|
DiscoverExa DiscoverChannel = "exa"
|
2026-06-24 10:02:42 +00:00
|
|
|
|
DiscoverCrawler DiscoverChannel = "crawler"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// DiscoverRequest is used by scan jobs; expand-graph only uses Brave knowledge_expand.
|
|
|
|
|
|
type DiscoverRequest struct {
|
|
|
|
|
|
Query string
|
|
|
|
|
|
Keyword string // plain tag for crawler; optional
|
|
|
|
|
|
Recency bool
|
|
|
|
|
|
Limit int
|
|
|
|
|
|
Member MemberContext
|
|
|
|
|
|
Crawler CrawlerSearchFn
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DiscoverPost struct {
|
2026-06-25 08:20:03 +00:00
|
|
|
|
Text string
|
|
|
|
|
|
Permalink string
|
|
|
|
|
|
ExternalID string
|
|
|
|
|
|
Author string
|
|
|
|
|
|
PostedAt string
|
|
|
|
|
|
AuthorVerified bool
|
|
|
|
|
|
FollowerCount int
|
|
|
|
|
|
LikeCount int
|
|
|
|
|
|
ReplyCount int
|
|
|
|
|
|
Source DiscoverChannel
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 08:20:03 +00:00
|
|
|
|
// Discover runs keyword discovery respecting search_source_mode and available connections.
|
|
|
|
|
|
// Crawler-first modes skip Threads API when the browser session returns posts (saves API quota).
|
2026-06-24 10:02:42 +00:00
|
|
|
|
func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, DiscoverChannel, error) {
|
|
|
|
|
|
m := req.Member
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if !m.HasDiscoverPath() {
|
|
|
|
|
|
return nil, "", discoverMissingPathError(m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ShouldTryCrawlerFirst(m) {
|
|
|
|
|
|
posts, err := runCrawlerDiscover(ctx, req)
|
|
|
|
|
|
if err == nil && len(posts) > 0 {
|
|
|
|
|
|
return posts, DiscoverCrawler, nil
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if m.SearchSourceMode == SearchSourceCrawler {
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, DiscoverCrawler, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return posts, DiscoverCrawler, nil
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if m.AllowsThreadsAPI {
|
|
|
|
|
|
if !m.ApiConnected {
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if !m.CrawlerFallbackAllowed() {
|
|
|
|
|
|
return nil, "", fmt.Errorf("正式模式需先完成 Threads API 連線")
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
posts, err := keywordSearchViaThreadsAPI(ctx, req)
|
|
|
|
|
|
if err == nil && len(posts) > 0 {
|
|
|
|
|
|
return posts, DiscoverThreadsAPI, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if m.CrawlerFallbackAllowed() {
|
|
|
|
|
|
cPosts, cErr := runCrawlerDiscover(ctx, req)
|
|
|
|
|
|
if cErr == nil && len(cPosts) > 0 {
|
|
|
|
|
|
return cPosts, DiscoverCrawler, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
|
|
|
|
|
|
// Optional API field gaps must not fail the whole patrol; return empty for this keyword.
|
|
|
|
|
|
return []DiscoverPost{}, DiscoverThreadsAPI, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if m.AllowsBrave {
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if m.WebSearchAPIKey() == "" {
|
|
|
|
|
|
if m.CrawlerFallbackAllowed() {
|
|
|
|
|
|
posts, err := runCrawlerDiscover(ctx, req)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
return posts, DiscoverCrawler, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", m.WebSearchProviderLabel())
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
2026-06-25 08:20:03 +00:00
|
|
|
|
return nil, m.WebSearchDiscoverChannel(), fmt.Errorf("web search threads discover delegated to worker")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if m.CrawlerFallbackAllowed() {
|
|
|
|
|
|
posts, err := runCrawlerDiscover(ctx, req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, DiscoverCrawler, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return posts, DiscoverCrawler, nil
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
|
2026-06-25 09:34:28 +00:00
|
|
|
|
}
|