Merge pull request #6 from eSlider/feat/produktor-search
feat(search): add produktor SearXNG JSON client
This commit is contained in:
@@ -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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user