Merge pull request #6 from eSlider/feat/produktor-search

feat(search): add produktor SearXNG JSON client
This commit is contained in:
2026-07-23 14:47:02 +01:00
committed by GitHub
co-authored by GitHub
+148
View File
@@ -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] + "..."
}