diff --git a/search/client.go b/search/client.go new file mode 100644 index 0000000..03fbf21 --- /dev/null +++ b/search/client.go @@ -0,0 +1,148 @@ +// Package search provides a client for the produktor SearXNG instance +// at https://search.produktor.io. +package search + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const DefaultBaseURL = "https://search.produktor.io" + +// Client queries the produktor SearXNG instance. +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +// NewClient returns a client with sensible defaults. +func NewClient() *Client { + return &Client{ + BaseURL: DefaultBaseURL, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Options are optional SearXNG query parameters. +type Options struct { + Categories string // e.g. "general", "images" + Engines string // e.g. "duckduckgo,wikipedia" + Language string // e.g. "en", "de" + Page int // 1-based page number + SafeSearch int // 0=off, 1=moderate, 2=strict +} + +// Result is one search hit. +type Result struct { + URL string `json:"url"` + Title string `json:"title"` + Content string `json:"content"` + Engine string `json:"engine"` + Engines []string `json:"engines"` + Score float64 `json:"score"` + Category string `json:"category"` + PublishedDate *string `json:"publishedDate"` + Thumbnail string `json:"thumbnail"` +} + +// Response is the JSON envelope from /search?format=json. +type Response struct { + Query string `json:"query"` + Results []Result `json:"results"` + Suggestions []string `json:"suggestions"` + Answers []any `json:"answers"` + Corrections []any `json:"corrections"` + Infoboxes []any `json:"infoboxes"` + UnresponsiveEngines [][]string `json:"unresponsive_engines"` +} + +// Search runs a query and decodes the JSON response. +func (c *Client) Search(ctx context.Context, query string, opts *Options) (*Response, error) { + if strings.TrimSpace(query) == "" { + return nil, fmt.Errorf("search: empty query") + } + + base := strings.TrimRight(c.baseURL(), "/") + u, err := url.Parse(base + "/search") + if err != nil { + return nil, fmt.Errorf("search: parse url: %w", err) + } + + q := u.Query() + q.Set("q", query) + q.Set("format", "json") + if opts != nil { + if opts.Categories != "" { + q.Set("categories", opts.Categories) + } + if opts.Engines != "" { + q.Set("engines", opts.Engines) + } + if opts.Language != "" { + q.Set("language", opts.Language) + } + if opts.Page > 0 { + q.Set("pageno", fmt.Sprintf("%d", opts.Page)) + } + if opts.SafeSearch > 0 { + q.Set("safesearch", fmt.Sprintf("%d", opts.SafeSearch)) + } + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("search: new request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "go-produktor-search/1.0") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("search: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, fmt.Errorf("search: read body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("search: HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + var out Response + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("search: decode json: %w", err) + } + return &out, nil +} + +func (c *Client) baseURL() string { + if c.BaseURL != "" { + return c.BaseURL + } + return DefaultBaseURL +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +}