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
193
internal/contracts/main_result.go
Normal file
193
internal/contracts/main_result.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package contracts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// MainResult represents one element of SearXNG's `results` array.
|
||||
//
|
||||
// SearXNG returns many additional keys beyond what templates use. To keep the
|
||||
// contract stable for proxying/merging, we preserve all unknown keys in
|
||||
// `raw` and re-emit them via MarshalJSON.
|
||||
type MainResult struct {
|
||||
raw map[string]any
|
||||
|
||||
// Common fields used by SearXNG templates (RSS uses: title, url, content, pubdate).
|
||||
Template string `json:"template"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
URL *string `json:"url"`
|
||||
Pubdate *string `json:"pubdate"`
|
||||
|
||||
Engine string `json:"engine"`
|
||||
Score float64 `json:"score"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
|
||||
Positions []int `json:"positions"`
|
||||
Engines []string `json:"engines"`
|
||||
|
||||
// These fields exist in SearXNG's MainResult base; keep them so downstream
|
||||
// callers can generate richer output later.
|
||||
OpenGroup bool `json:"open_group"`
|
||||
CloseGroup bool `json:"close_group"`
|
||||
|
||||
// parsed_url in SearXNG is emitted as a tuple; we preserve it as-is.
|
||||
ParsedURL any `json:"parsed_url"`
|
||||
}
|
||||
|
||||
func (mr *MainResult) UnmarshalJSON(data []byte) error {
|
||||
// Preserve the full object.
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
|
||||
var m map[string]any
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mr.raw = m
|
||||
|
||||
// Fill the typed/common fields (best-effort; don't fail if types differ).
|
||||
mr.Template = stringOrEmpty(m["template"])
|
||||
mr.Title = stringOrEmpty(m["title"])
|
||||
mr.Content = stringOrEmpty(m["content"])
|
||||
mr.Engine = stringOrEmpty(m["engine"])
|
||||
mr.Category = stringOrEmpty(m["category"])
|
||||
mr.Priority = stringOrEmpty(m["priority"])
|
||||
|
||||
if s, ok := stringOrNullable(m["url"]); ok {
|
||||
mr.URL = &s
|
||||
}
|
||||
if s, ok := stringOrNullable(m["pubdate"]); ok {
|
||||
mr.Pubdate = &s
|
||||
}
|
||||
|
||||
mr.Score = floatOrZero(m["score"])
|
||||
|
||||
if v, ok := sliceOfStrings(m["engines"]); ok {
|
||||
mr.Engines = v
|
||||
}
|
||||
if v, ok := sliceOfInts(m["positions"]); ok {
|
||||
mr.Positions = v
|
||||
}
|
||||
|
||||
if v, ok := boolOrFalse(m["open_group"]); ok {
|
||||
mr.OpenGroup = v
|
||||
}
|
||||
if v, ok := boolOrFalse(m["close_group"]); ok {
|
||||
mr.CloseGroup = v
|
||||
}
|
||||
|
||||
mr.ParsedURL = m["parsed_url"]
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mr MainResult) MarshalJSON() ([]byte, error) {
|
||||
// If we came from upstream JSON, preserve all keys exactly.
|
||||
if mr.raw != nil {
|
||||
return json.Marshal(mr.raw)
|
||||
}
|
||||
|
||||
// Otherwise, marshal the known fields.
|
||||
m := map[string]any{
|
||||
"template": mr.Template,
|
||||
"title": mr.Title,
|
||||
"content": mr.Content,
|
||||
"url": mr.URL,
|
||||
"pubdate": mr.Pubdate,
|
||||
"engine": mr.Engine,
|
||||
"score": mr.Score,
|
||||
"category": mr.Category,
|
||||
"priority": mr.Priority,
|
||||
"positions": mr.Positions,
|
||||
"engines": mr.Engines,
|
||||
"open_group": mr.OpenGroup,
|
||||
"close_group": mr.CloseGroup,
|
||||
"parsed_url": mr.ParsedURL,
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func stringOrEmpty(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func stringOrNullable(v any) (string, bool) {
|
||||
if v == nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func floatOrZero(v any) float64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case float32:
|
||||
return float64(t)
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func boolOrFalse(v any) (bool, bool) {
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
func sliceOfStrings(v any) ([]string, bool) {
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func sliceOfInts(v any) ([]int, bool) {
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]int, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
switch t := item.(type) {
|
||||
case float64:
|
||||
out = append(out, int(t))
|
||||
case int:
|
||||
out = append(out, t)
|
||||
case json.Number:
|
||||
i64, err := t.Int64()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, int(i64))
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue