package search import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) const defaultBaseURL = "https://api.exa.ai/search" type Hit struct { Title string `json:"title"` URL string `json:"url"` Snippet string `json:"snippet"` } type Client interface { Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) } // FakeClient for tests / no platform key. type FakeClient struct { Fail bool } func (f *FakeClient) Search(_ context.Context, apiKey, query string, limit int) ([]Hit, error) { if f.Fail || apiKey == "" { return nil, fmt.Errorf("search failed: no key") } if limit <= 0 { limit = 3 } var hits []Hit for i := 0; i < limit; i++ { hits = append(hits, Hit{ Title: fmt.Sprintf("Result %d for %s", i+1, strings.TrimSpace(query)), URL: fmt.Sprintf("https://example.com/r/%d", i+1), Snippet: "fake exa snippet", }) } return hits, nil } // ExaClient calls api.exa.ai (real web search). type ExaClient struct { BaseURL string HTTP *http.Client // IncludeDomains optional host filter (e.g. threads.net). IncludeDomains []string } // NewExa creates a ready HTTP client. func NewExa() *ExaClient { return &ExaClient{ BaseURL: defaultBaseURL, HTTP: &http.Client{Timeout: 25 * time.Second}, } } // NewExaThreads prefers results from Threads domains. func NewExaThreads() *ExaClient { c := NewExa() c.IncludeDomains = []string{"threads.net", "threads.com"} return c } func (c *ExaClient) Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) { apiKey = strings.TrimSpace(apiKey) query = strings.TrimSpace(query) if apiKey == "" || strings.HasPrefix(apiKey, "fake") { return nil, fmt.Errorf("search failed: no key") } if query == "" { return nil, fmt.Errorf("search query required") } if limit <= 0 { limit = 5 } if limit > 20 { limit = 20 } base := c.BaseURL if base == "" { base = defaultBaseURL } httpClient := c.HTTP if httpClient == nil { httpClient = &http.Client{Timeout: 25 * time.Second} } body := map[string]any{ "query": query, "type": "auto", "numResults": limit, "userLocation": "TW", "contents": map[string]any{ "highlights": true, "text": map[string]any{"maxCharacters": 400}, }, } if len(c.IncludeDomains) > 0 { body["includeDomains"] = c.IncludeDomains } payload, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, 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", apiKey) 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, truncateRunes(string(raw), 120)) } var parsed struct { Results []struct { Title string `json:"title"` URL string `json:"url"` Highlights []string `json:"highlights"` Text string `json:"text"` Author string `json:"author"` } `json:"results"` } if err := json.Unmarshal(raw, &parsed); err != nil { return nil, err } out := make([]Hit, 0, len(parsed.Results)) for _, item := range parsed.Results { u := strings.TrimSpace(item.URL) if u == "" { continue } snippet := firstNonEmpty(item.Highlights...) if snippet == "" { snippet = strings.TrimSpace(item.Text) } if snippet == "" { snippet = strings.TrimSpace(item.Title) } title := strings.TrimSpace(item.Title) if title == "" { title = strings.TrimSpace(item.Author) } if title == "" { title = u } out = append(out, Hit{ Title: title, URL: u, Snippet: truncateRunes(snippet, 200), }) if len(out) >= limit { break } } return out, nil } func firstNonEmpty(ss ...string) string { for _, s := range ss { if t := strings.TrimSpace(s); t != "" { return t } } return "" } func truncateRunes(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }