Files
sherlock/cmd/searxng-mcp/client.go
T
amacocian 59eaf9e47d
Release / release (push) Failing after 6s
SearXNG support
2026-06-14 21:33:22 +02:00

125 lines
3.9 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var ErrUnauthorized = errors.New("searxng: bearer rejected")
type searxngAPI struct {
baseURL string
token func(context.Context) (string, error)
client *http.Client
}
type searchParams struct {
Query string
Categories string
Engines string
Language string
TimeRange string
SafeSearch *int
Page int
Limit int
}
type searxngSearchResponse struct {
Query string `json:"query"`
NumberOfResults int `json:"number_of_results"`
Results []searxngResult `json:"results"`
Suggestions []string `json:"suggestions,omitempty"`
Answers []string `json:"answers,omitempty"`
Corrections []string `json:"corrections,omitempty"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content,omitempty"`
Engine string `json:"engine,omitempty"`
Engines []string `json:"engines,omitempty"`
Category string `json:"category,omitempty"`
Score float64 `json:"score,omitempty"`
PublishedDate string `json:"publishedDate,omitempty"`
ImgSrc string `json:"img_src,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
}
func (s *searxngAPI) search(ctx context.Context, p searchParams) (searxngSearchResponse, error) {
q := url.Values{
"format": []string{"json"},
"q": []string{p.Query},
}
setIf(q, "categories", p.Categories)
setIf(q, "engines", p.Engines)
setIf(q, "language", p.Language)
setIf(q, "time_range", p.TimeRange)
if p.SafeSearch != nil {
q.Set("safesearch", strconv.Itoa(*p.SafeSearch))
}
if p.Page > 0 {
q.Set("pageno", strconv.Itoa(p.Page))
}
full := s.baseURL + "/search?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return searxngSearchResponse{}, err
}
tok, err := s.token(ctx)
if err != nil {
return searxngSearchResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return searxngSearchResponse{}, err
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
if err := checkStatus(resp, req); err != nil {
return searxngSearchResponse{}, err
}
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
return searxngSearchResponse{}, fmt.Errorf("searxng: %s returned %q, not JSON; ensure SearXNG enables format=json and Caddy is not serving a login page", req.URL.Path, ct)
}
var out searxngSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return searxngSearchResponse{}, fmt.Errorf("searxng: decode JSON search response: %w", err)
}
if p.Limit > 0 && len(out.Results) > p.Limit {
out.Results = out.Results[:p.Limit]
}
return out, nil
}
func checkStatus(resp *http.Response, req *http.Request) error {
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w by %s (try `sherlock logout searxng` and retry; Caddy must accept the OAuth bearer)", ErrUnauthorized, req.URL.Host)
}
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect {
return fmt.Errorf("searxng: %s %s redirected to %q; Caddy is likely requiring browser-session auth instead of accepting the OAuth bearer", req.Method, req.URL.Path, resp.Header.Get("Location"))
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("searxng: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func setIf(q url.Values, k, v string) {
if v != "" {
q.Set(k, v)
}
}