feat: build Go-based SearXNG-compatible search service

Implement an API-first Go rewrite with local engine adapters, upstream fallback, and Nix-based tooling so searches can run without matching the original UI while preserving response compatibility.

Made-with: Cursor
This commit is contained in:
Franz Kafka 2026-03-20 20:34:08 +01:00
parent 7783367c71
commit dc44837219
32 changed files with 3330 additions and 0 deletions

223
internal/search/response.go Normal file
View file

@ -0,0 +1,223 @@
package search
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"net/url"
"encoding/xml"
"strconv"
"strings"
)
func WriteSearchResponse(w http.ResponseWriter, format OutputFormat, resp SearchResponse) error {
switch format {
case FormatJSON:
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(resp)
case FormatCSV:
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
if err := writeCSV(w, resp); err != nil {
return err
}
return nil
case FormatRSS:
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if err := writeRSS(w, resp); err != nil {
return err
}
return nil
case FormatHTML:
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte("format=html not implemented yet"))
return nil
default:
return fmt.Errorf("unsupported format: %s", format)
}
}
// csvRowHeader matches the SearXNG CSV writer key order.
var csvRowHeader = []string{"title", "url", "content", "host", "engine", "score", "type"}
func writeCSV(w http.ResponseWriter, resp SearchResponse) error {
cw := csv.NewWriter(w)
defer cw.Flush()
if err := cw.Write(csvRowHeader); err != nil {
return err
}
for _, r := range resp.Results {
urlStr := ""
if r.URL != nil {
urlStr = *r.URL
}
host := hostFromURL(urlStr)
scoreStr := strconv.FormatFloat(r.Score, 'f', -1, 64)
row := []string{
r.Title,
urlStr,
r.Content,
host,
r.Engine,
scoreStr,
"result",
}
if err := cw.Write(row); err != nil {
return err
}
}
for _, ans := range resp.Answers {
title := asString(ans["title"])
urlStr := asString(ans["url"])
content := asString(ans["content"])
engine := asString(ans["engine"])
scoreStr := scoreString(ans["score"])
host := hostFromURL(urlStr)
row := []string{
title,
urlStr,
content,
host,
engine,
scoreStr,
"answer",
}
if err := cw.Write(row); err != nil {
return err
}
}
for _, s := range resp.Suggestions {
row := []string{s, "", "", "", "", "", "suggestion"}
if err := cw.Write(row); err != nil {
return err
}
}
for _, c := range resp.Corrections {
row := []string{c, "", "", "", "", "", "correction"}
if err := cw.Write(row); err != nil {
return err
}
}
return nil
}
func writeRSS(w http.ResponseWriter, resp SearchResponse) error {
q := resp.Query
escapedTitle := xmlEscape("SearXNG search: " + q)
escapedDesc := xmlEscape("Search results for \"" + q + "\" - SearXNG")
escapedQueryTerms := xmlEscape(q)
link := "/search?q=" + url.QueryEscape(q)
opensearchQuery := fmt.Sprintf(`<opensearch:Query role="request" searchTerms="%s" startPage="1" />`, escapedQueryTerms)
// SearXNG template uses the number of results for both totalResults and itemsPerPage.
nr := resp.NumberOfResults
var items bytes.Buffer
for _, r := range resp.Results {
title := xmlEscape(r.Title)
urlStr := ""
if r.URL != nil {
urlStr = *r.URL
}
linkEsc := xmlEscape(urlStr)
desc := xmlEscape(r.Content)
pub := ""
if r.Pubdate != nil && strings.TrimSpace(*r.Pubdate) != "" {
pub = "<pubDate>" + xmlEscape(*r.Pubdate) + "</pubDate>"
}
items.WriteString(
fmt.Sprintf(
`<item><title>%s</title><type>result</type><link>%s</link><description>%s</description>%s</item>`,
title,
linkEsc,
desc,
pub,
),
)
}
xml := fmt.Sprintf(
`<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
<rss version="2.0"
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>%s</title>
<link>%s</link>
<description>%s</description>
<opensearch:totalResults>%d</opensearch:totalResults>
<opensearch:startIndex>1</opensearch:startIndex>
<opensearch:itemsPerPage>%d</opensearch:itemsPerPage>
<atom:link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml"/>
%s
%s
</channel>
</rss>
`,
escapedTitle,
xmlEscape(link),
escapedDesc,
nr,
nr,
opensearchQuery,
items.String(),
)
_, err := w.Write([]byte(xml))
return err
}
func xmlEscape(s string) string {
var b bytes.Buffer
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
func hostFromURL(urlStr string) string {
if strings.TrimSpace(urlStr) == "" {
return ""
}
u, err := url.Parse(urlStr)
if err != nil {
return ""
}
return u.Host
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func scoreString(v any) string {
switch t := v.(type) {
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(t), 'f', -1, 64)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case json.Number:
if f, err := t.Float64(); err == nil {
return strconv.FormatFloat(f, 'f', -1, 64)
}
return ""
default:
return ""
}
}