package search import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" ) // SearXNG implements Provider using a self-hosted SearXNG instance. // Suitable for airgapped deployments. Requires a configured endpoint. type SearXNG struct { endpoint string // base URL, e.g. "https://search.internal.example.com" apiKey string // optional API key (sent as X-API-Key header) } func (s *SearXNG) Name() string { return "searxng" } // Configure sets the SearXNG endpoint and optional API key. func (s *SearXNG) Configure(endpoint, apiKey string) { s.endpoint = endpoint s.apiKey = apiKey } // Endpoint returns the configured endpoint (for admin display). func (s *SearXNG) Endpoint() string { return s.endpoint } func (s *SearXNG) Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error) { if s.endpoint == "" { return nil, fmt.Errorf("SearXNG endpoint not configured") } if maxResults <= 0 { maxResults = 5 } if maxResults > 10 { maxResults = 10 } // Build request URL: /search?q=...&format=json u, err := url.Parse(s.endpoint) if err != nil { return nil, fmt.Errorf("invalid SearXNG endpoint: %w", err) } u.Path = "/search" q := u.Query() q.Set("q", query) q.Set("format", "json") u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (Web Search Tool)") if s.apiKey != "" { req.Header.Set("X-API-Key", s.apiKey) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("SearXNG request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("SearXNG returned HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) if err != nil { return nil, fmt.Errorf("read SearXNG response: %w", err) } return parseSearXNGJSON(body, maxResults) } // SearXNG JSON response format type searxngResponse struct { Results []struct { Title string `json:"title"` URL string `json:"url"` Content string `json:"content"` // snippet } `json:"results"` } func parseSearXNGJSON(data []byte, maxResults int) ([]SearchResult, error) { var resp searxngResponse if err := json.Unmarshal(data, &resp); err != nil { return nil, fmt.Errorf("parse SearXNG response: %w", err) } results := make([]SearchResult, 0, maxResults) for _, r := range resp.Results { if len(results) >= maxResults { break } if r.URL == "" { continue } results = append(results, SearchResult{ Title: r.Title, URL: r.URL, Snippet: r.Content, }) } return results, nil } func init() { RegisterProvider(&SearXNG{}) }