package brave import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) const defaultBaseURL = "https://api.search.brave.com/res/v1/web/search" type Mode string const ( ModeKnowledgeExpand Mode = "knowledge_expand" ModeThreadsDiscover Mode = "threads_discover" ) type SearchResult struct { Title string `json:"title"` Snippet string `json:"snippet"` URL string `json:"url"` } 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: 20 * time.Second, }, } } func (c *Client) Enabled() bool { return c != nil && c.apiKey != "" } type SearchOptions struct { Query string Limit int Mode Mode Country string SearchLang 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 BreakerOpen() { return out, fmt.Errorf("brave search temporarily paused after repeated failures") } if out.Query == "" { return out, fmt.Errorf("brave search query is required") } limit := opts.Limit if limit <= 0 { limit = 5 } if limit > 20 { limit = 20 } country := strings.TrimSpace(opts.Country) if country == "" { country = "tw" } searchLang := strings.TrimSpace(opts.SearchLang) if searchLang == "" { searchLang = "zh-hant" } u, err := url.Parse(c.baseURL) if err != nil { return out, err } q := u.Query() q.Set("q", out.Query) q.Set("count", fmt.Sprintf("%d", limit)) q.Set("country", country) q.Set("search_lang", searchLang) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return out, err } req.Header.Set("Accept", "application/json") req.Header.Set("X-Subscription-Token", c.apiKey) res, err := c.http.Do(req) if err != nil { recordBreakerFailure() return out, nil } defer res.Body.Close() if res.StatusCode != http.StatusOK { if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 { recordBreakerFailure() } return out, nil } body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return out, nil } var payload struct { Web struct { Results []struct { Title string `json:"title"` Description string `json:"description"` URL string `json:"url"` } `json:"results"` } `json:"web"` } if err := json.Unmarshal(body, &payload); err != nil { return out, nil } threadsOnly := opts.Mode == ModeThreadsDiscover for _, item := range payload.Web.Results { rawURL := strings.TrimSpace(item.URL) if rawURL == "" { continue } if threadsOnly && !isThreadsURL(rawURL) { continue } out.Results = append(out.Results, SearchResult{ Title: strings.TrimSpace(item.Title), Snippet: strings.TrimSpace(item.Description), URL: rawURL, }) if len(out.Results) >= limit { break } } out.Status = "success" recordBreakerSuccess() return out, nil } func isThreadsURL(raw string) bool { lower := strings.ToLower(raw) return strings.Contains(lower, "threads.com") || strings.Contains(lower, "threads.net") }