- Rename cmd/searxng-go to cmd/kafka - Remove all SearXNG references from source comments while keeping "SearXNG-compatible API" in user-facing docs - Update binary paths in README, CLAUDE.md, and Dockerfile - Update log message to "kafka starting" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
4.8 KiB
Go
223 lines
4.8 KiB
Go
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 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("kafka search: " + q)
|
|
escapedDesc := xmlEscape("Search results for \"" + q + "\" - kafka")
|
|
escapedQueryTerms := xmlEscape(q)
|
|
|
|
link := "/search?q=" + url.QueryEscape(q)
|
|
opensearchQuery := fmt.Sprintf(`<opensearch:Query role="request" searchTerms="%s" startPage="1" />`, escapedQueryTerms)
|
|
|
|
// The 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 ""
|
|
}
|
|
}
|
|
|