Thin HTTP client for https://search.produktor.io/search with typed results and optional query parameters. Co-authored-by: Cursor <cursoragent@cursor.com>
149 lines
3.7 KiB
Go
149 lines
3.7 KiB
Go
// 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] + "..."
|
|
}
|