587f1e6a07
8080 is taken several times over on this box, and the container name is the only thing addressing it, so the port is ours to pick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
7.4 KiB
Go
230 lines
7.4 KiB
Go
// Package websearch reads a self-hosted SearXNG instance.
|
|
//
|
|
// Why this exists at all: "never phones home" stopped being a hard constraint
|
|
// on 2026-07-31. A 1.7B does not know enough to answer a world question, and
|
|
// reading beats recalling at that size. SearXNG is the reading surface for
|
|
// anything the offline ZIMs do not hold, and it is off unless configured.
|
|
//
|
|
// What is NOT here, on purpose:
|
|
//
|
|
// - No query rewriting. SearXNG ranks with real engines, so the Russian
|
|
// question goes out as he asked it. That is the whole reason it sits ahead
|
|
// of Kiwix, whose keyword ranker needs kiwix.Rewriter to see anything.
|
|
// - No page fetching. A snippet per result is the evidence; following a link
|
|
// is crawl.Crawler's job and carries robots and allowlist rules with it.
|
|
// - No cache and no retries. Boring on purpose, same posture as kiwix.Client.
|
|
//
|
|
// Only the query string leaves this process. This package cannot read the
|
|
// store, so his notes, facts, persona block and history cannot travel with a
|
|
// search even by accident. The personal boundary in the query chain is what
|
|
// keeps a question ABOUT him from becoming a query at all.
|
|
package websearch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Result is one search hit, already reduced to what a phraser can read.
|
|
type Result struct {
|
|
Title string
|
|
URL string
|
|
Content string // the engine's snippet, plain text
|
|
Engine string // which upstream engine produced it, e.g. "duckduckgo"
|
|
}
|
|
|
|
// Response is one search. Answers comes from SearXNG's answerer plugins and
|
|
// from instant answers upstream; it is a direct reply to the question and is
|
|
// worth more than any snippet, so it is kept separate rather than mixed in.
|
|
type Response struct {
|
|
Answers []string
|
|
Results []Result
|
|
}
|
|
|
|
// Empty reports whether the search found nothing usable. The caller passes the
|
|
// turn on when it does — an empty search is not a failure worth announcing.
|
|
func (r Response) Empty() bool { return len(r.Answers) == 0 && len(r.Results) == 0 }
|
|
|
|
// DefaultTimeout — the whole request. SearXNG fans out to upstream engines and
|
|
// waits on the slowest, so this is longer than a LAN call but short enough that
|
|
// a dead engine does not hold a voice turn open.
|
|
const DefaultTimeout = 8 * time.Second
|
|
|
|
// maxBodyBytes caps the JSON read. A 20-result reply is tens of kilobytes; this
|
|
// is slack for a wide one and a hard stop against a misconfigured endpoint.
|
|
const maxBodyBytes = 4 << 20
|
|
|
|
// Client is a SearXNG HTTP client.
|
|
type Client struct {
|
|
base string
|
|
language string
|
|
engines string
|
|
http *http.Client
|
|
}
|
|
|
|
// Options are the per-instance knobs, all optional.
|
|
type Options struct {
|
|
// Language — SearXNG's `language` parameter, e.g. "ru" or "auto". Empty ⇒
|
|
// the instance default.
|
|
Language string
|
|
// Engines — comma-separated engine names to restrict the search to. Empty ⇒
|
|
// whatever the instance has enabled.
|
|
Engines string
|
|
// Timeout — per-request budget. 0 ⇒ DefaultTimeout.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// New makes a client for a SearXNG base URL like http://searxng:9563.
|
|
//
|
|
// The instance must have the JSON format enabled (`search.formats: [html,
|
|
// json]` in its settings.yml); a stock install answers 403 to format=json and
|
|
// every search will fail with that status.
|
|
func New(baseURL string, opt Options) *Client {
|
|
t := opt.Timeout
|
|
if t <= 0 {
|
|
t = DefaultTimeout
|
|
}
|
|
return &Client{
|
|
base: strings.TrimRight(baseURL, "/"),
|
|
language: strings.TrimSpace(opt.Language),
|
|
engines: strings.TrimSpace(opt.Engines),
|
|
http: &http.Client{Timeout: t},
|
|
}
|
|
}
|
|
|
|
// Search runs one query and returns up to limit results plus any instant
|
|
// answers. The query goes out verbatim.
|
|
func (c *Client) Search(ctx context.Context, query string, limit int) (Response, error) {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return Response{}, fmt.Errorf("websearch: empty query")
|
|
}
|
|
q := url.Values{}
|
|
q.Set("q", query)
|
|
q.Set("format", "json")
|
|
if c.language != "" {
|
|
q.Set("language", c.language)
|
|
}
|
|
if c.engines != "" {
|
|
q.Set("engines", c.engines)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Response{}, fmt.Errorf("websearch: http %d (json format enabled in searxng?)", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
return ParseResponse(body, limit)
|
|
}
|
|
|
|
// wire mirrors just the fields of the SearXNG JSON reply we read.
|
|
type wire struct {
|
|
Answers []json.RawMessage `json:"answers"`
|
|
Results []struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Content string `json:"content"`
|
|
Engine string `json:"engine"`
|
|
} `json:"results"`
|
|
}
|
|
|
|
// ParseResponse turns a SearXNG JSON reply into a Response, keeping at most
|
|
// limit results. Exported so the parser is testable from a captured reply with
|
|
// no instance running.
|
|
func ParseResponse(body []byte, limit int) (Response, error) {
|
|
var doc wire
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
return Response{}, fmt.Errorf("websearch: bad json: %w", err)
|
|
}
|
|
if limit <= 0 {
|
|
limit = 5
|
|
}
|
|
out := Response{}
|
|
for _, raw := range doc.Answers {
|
|
if s := answerText(raw); s != "" {
|
|
out.Answers = append(out.Answers, s)
|
|
}
|
|
}
|
|
for _, r := range doc.Results {
|
|
title := clean(r.Title)
|
|
content := clean(r.Content)
|
|
if title == "" && content == "" {
|
|
// A hit with no text is a link with nothing to read. It cannot be
|
|
// evidence, and counting it toward the limit would push a usable
|
|
// snippet out of the reply.
|
|
continue
|
|
}
|
|
out.Results = append(out.Results, Result{
|
|
Title: title,
|
|
URL: strings.TrimSpace(r.URL),
|
|
Content: content,
|
|
Engine: strings.TrimSpace(r.Engine),
|
|
})
|
|
if len(out.Results) == limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// answerText reads one entry of `answers`. SearXNG changed its shape: older
|
|
// versions emit a bare string, newer ones an object with an `answer` field.
|
|
// Both are in the wild depending on when the instance was pulled, so both are
|
|
// read rather than pinning a version we do not control.
|
|
func answerText(raw json.RawMessage) string {
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return clean(s)
|
|
}
|
|
var obj struct {
|
|
Answer string `json:"answer"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
|
return clean(obj.Answer)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Snippets renders the response as evidence lines for a phraser: instant
|
|
// answers first, then "Title — snippet" per result.
|
|
//
|
|
// Answers lead because they are a reply to the question, where a result is a
|
|
// page that might contain one. The URL is deliberately left out: it is not
|
|
// evidence, and piper reads one out character by character.
|
|
func (r Response) Snippets() []string {
|
|
out := make([]string, 0, len(r.Answers)+len(r.Results))
|
|
out = append(out, r.Answers...)
|
|
for _, res := range r.Results {
|
|
switch {
|
|
case res.Content == "":
|
|
out = append(out, res.Title)
|
|
case res.Title == "":
|
|
out = append(out, res.Content)
|
|
default:
|
|
out = append(out, res.Title+" — "+res.Content)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// clean collapses whitespace. Snippets arrive with newlines and runs of spaces
|
|
// from the upstream page, and piper reads a reply built out of them badly.
|
|
func clean(s string) string { return strings.Join(strings.Fields(s), " ") }
|