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 } // 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 = 5 } if limit > 20 { limit = 20 } query := strings.Join(nonEmptyTerms(terms), " ") if query == "" { return nil, fmt.Errorf("exa Threads search query required") } payload, err := json.Marshal(map[string]any{ "query": query, "type": "auto", "numResults": limit, "includeDomains": []string{"threads.net", "threads.com"}, "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"` 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)}) } return results, nil } 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 "" }