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" DiscoverExa DiscoverChannel = "exa" 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 { Text string Permalink string ExternalID string Author string PostedAt string AuthorVerified bool FollowerCount int LikeCount int ReplyCount int Source DiscoverChannel } // 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). func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, DiscoverChannel, error) { m := req.Member 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 } if m.SearchSourceMode == SearchSourceCrawler { if err != nil { return nil, DiscoverCrawler, err } return posts, DiscoverCrawler, nil } } if m.AllowsThreadsAPI { if !m.ApiConnected { 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 } } } } if m.AllowsBrave { 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()) } 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 } return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode) }