package exa import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) const defaultBaseURL = "https://api.exa.ai/search" type Mode string const ( ModeKnowledgeExpand Mode = "knowledge_expand" ModeThreadsDiscover Mode = "threads_discover" ) type SearchResult struct { Title string Snippet string URL string PublishedDate string Author string HighlightScore float64 } type SearchResponse struct { Results []SearchResult Query string Status string // success | unavailable } type Client struct { apiKey string baseURL string http *http.Client } func NewClient(apiKey string) *Client { return &Client{ apiKey: strings.TrimSpace(apiKey), baseURL: defaultBaseURL, http: &http.Client{ Timeout: 25 * time.Second, }, } } func (c *Client) Enabled() bool { return c != nil && c.apiKey != "" } type SearchOptions struct { Query string Limit int Mode Mode UserLocation string StartPublishedDate string } func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) { out := SearchResponse{Query: strings.TrimSpace(opts.Query), Status: "unavailable"} if !c.Enabled() { return out, nil } if out.Query == "" { return out, fmt.Errorf("exa search query is required") } limit := opts.Limit if limit <= 0 { limit = 5 } if limit > 20 { limit = 20 } userLocation := strings.TrimSpace(opts.UserLocation) if userLocation == "" { userLocation = "TW" } body := map[string]any{ "query": out.Query, "type": "auto", "numResults": limit, "userLocation": userLocation, "contents": map[string]any{ "highlights": true, }, } if opts.Mode == ModeThreadsDiscover { body["includeDomains"] = []string{"threads.net", "threads.com"} } if start := strings.TrimSpace(opts.StartPublishedDate); start != "" { body["startPublishedDate"] = start } payload, err := json.Marshal(body) if err != nil { return out, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload)) if err != nil { return out, err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", c.apiKey) res, err := c.http.Do(req) if err != nil { return out, nil } defer res.Body.Close() if res.StatusCode != http.StatusOK { return out, nil } raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return out, nil } var parsed struct { Results []struct { Title string `json:"title"` URL string `json:"url"` PublishedDate string `json:"publishedDate"` Author string `json:"author"` Highlights []string `json:"highlights"` HighlightScores []float64 `json:"highlightScores"` } `json:"results"` } if err := json.Unmarshal(raw, &parsed); err != nil { return out, nil } threadsOnly := opts.Mode == ModeThreadsDiscover for _, item := range parsed.Results { rawURL := strings.TrimSpace(item.URL) if rawURL == "" { continue } if threadsOnly && !isThreadsURL(rawURL) { continue } snippet := firstHighlight(item.Highlights) if snippet == "" { snippet = strings.TrimSpace(item.Title) } score := 0.0 if len(item.HighlightScores) > 0 { score = item.HighlightScores[0] } out.Results = append(out.Results, SearchResult{ Title: strings.TrimSpace(item.Title), Snippet: snippet, URL: rawURL, PublishedDate: strings.TrimSpace(item.PublishedDate), Author: strings.TrimSpace(item.Author), HighlightScore: score, }) if len(out.Results) >= limit { break } } out.Status = "success" return out, nil } func firstHighlight(items []string) string { for _, item := range items { if trimmed := strings.TrimSpace(item); trimmed != "" { return trimmed } } return "" } func isThreadsURL(raw string) bool { lower := strings.ToLower(raw) return strings.Contains(lower, "threads.com") || strings.Contains(lower, "threads.net") }