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:
parent
7783367c71
commit
dc44837219
32 changed files with 3330 additions and 0 deletions
223
internal/search/response.go
Normal file
223
internal/search/response.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue