package usecase import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) const exaSearchURL = "https://api.exa.ai/search" // ThreadSearchProvider finds public Threads posts for a Scout scan. type ThreadSearchProvider interface { SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error) } type ThreadSearchResult struct { URL string Title string Snippet string // PublishedAt unix nanoseconds when known (Exa publishedDate / crawler parse). PublishedAt int64 // MatchedQuery is set by fan-out search to the query that found this hit. MatchedQuery string // Track: top | recent | both — crawler dual-track; empty for Exa/API. Track string // SerpRank is 1-based rank within the source SERP track (crawler); 0 = unknown. SerpRank int } // ExaThreadsProvider searches only Threads-owned domains through Exa. type ExaThreadsProvider struct { APIKey string BaseURL string HTTP *http.Client } func NewExaThreadsProvider(apiKey string) *ExaThreadsProvider { return &ExaThreadsProvider{ APIKey: strings.TrimSpace(apiKey), BaseURL: exaSearchURL, HTTP: &http.Client{Timeout: 25 * time.Second}, } } func newDefaultExaThreadsProvider() *ExaThreadsProvider { key := os.Getenv("EXA_API_KEY") if strings.TrimSpace(key) == "" { key = os.Getenv("EXA_KEY") } return NewExaThreadsProvider(key) } func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error) { if p == nil || strings.TrimSpace(p.APIKey) == "" { return nil, fmt.Errorf("exa Threads search is not configured") } if limit <= 0 { limit = 10 } if limit > 20 { limit = 20 } // 單次呼叫:建議只傳 1 條完整 query(fan-out 在上層) clean := nonEmptyTerms(terms) if len(clean) == 0 { return nil, fmt.Errorf("exa Threads search query required") } query := clean[0] if len(clean) > 1 { // 相容舊呼叫:仍可 join,但 fan-out 路徑不會走這裡 query = strings.Join(clean, " ") } // 近 30 天,減少舊硬廣/過期活動 startPublished := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339) payload, err := json.Marshal(map[string]any{ "query": query, "type": "auto", "numResults": limit, "includeDomains": []string{"threads.net", "threads.com"}, "startPublishedDate": startPublished, "contents": map[string]any{ "highlights": true, "text": map[string]any{"maxCharacters": 400}, }, }) if err != nil { return nil, err } baseURL := p.BaseURL if baseURL == "" { baseURL = exaSearchURL } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", p.APIKey) httpClient := p.HTTP if httpClient == nil { httpClient = &http.Client{Timeout: 25 * time.Second} } res, err := httpClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("exa status %d: %s", res.StatusCode, truncate(string(raw), 120)) } var response struct { Results []struct { Title string `json:"title"` URL string `json:"url"` PublishedDate string `json:"publishedDate"` Highlights []string `json:"highlights"` Text string `json:"text"` } `json:"results"` } if err := json.Unmarshal(raw, &response); err != nil { return nil, err } results := make([]ThreadSearchResult, 0, len(response.Results)) for _, hit := range response.Results { url := strings.TrimSpace(hit.URL) if !isThreadsURL(url) { continue } snippet := firstNonEmpty(hit.Highlights) if snippet == "" { snippet = strings.TrimSpace(hit.Text) } if snippet == "" { snippet = strings.TrimSpace(hit.Title) } results = append(results, ThreadSearchResult{ URL: url, Title: strings.TrimSpace(hit.Title), Snippet: truncate(snippet, 400), PublishedAt: parsePublishedDateNano(hit.PublishedDate), }) } return results, nil } func parsePublishedDateNano(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } // Exa 常見 RFC3339 / date-only layouts := []string{ time.RFC3339, time.RFC3339Nano, "2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05Z", "2006-01-02", } for _, layout := range layouts { if t, err := time.Parse(layout, raw); err == nil { return t.UTC().UnixNano() } } return 0 } func isThreadsURL(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } host := strings.ToLower(u.Hostname()) return host == "threads.net" || strings.HasSuffix(host, ".threads.net") || host == "threads.com" || strings.HasSuffix(host, ".threads.com") } func nonEmptyTerms(terms []string) []string { out := make([]string, 0, len(terms)) for _, term := range terms { if term = strings.TrimSpace(term); term != "" { out = append(out, term) } } return out } func firstNonEmpty(values []string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" }