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

111
internal/search/service.go Normal file
View file

@ -0,0 +1,111 @@
package search
import (
"context"
"net/http"
"time"
"github.com/ashie/gosearch/internal/engines"
"github.com/ashie/gosearch/internal/contracts"
"github.com/ashie/gosearch/internal/upstream"
)
type ServiceConfig struct {
UpstreamURL string
HTTPTimeout time.Duration
}
type Service struct {
upstreamClient *upstream.Client
planner *engines.Planner
localEngines map[string]engines.Engine
}
func NewService(cfg ServiceConfig) *Service {
timeout := cfg.HTTPTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
httpClient := &http.Client{Timeout: timeout}
var up *upstream.Client
if cfg.UpstreamURL != "" {
c, err := upstream.NewClient(cfg.UpstreamURL, timeout)
if err == nil {
up = c
}
}
return &Service{
upstreamClient: up,
planner: engines.NewPlannerFromEnv(),
localEngines: engines.NewDefaultPortedEngines(httpClient),
}
}
func (s *Service) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
localEngines, upstreamEngines, _ := s.planner.Plan(req)
responses := make([]contracts.SearchResponse, 0, 2)
upstreamSet := map[string]bool{}
for _, e := range upstreamEngines {
upstreamSet[e] = true
}
for _, engineName := range localEngines {
eng, ok := s.localEngines[engineName]
if !ok {
continue
}
r, err := eng.Search(ctx, req)
if err != nil {
// MVP: fail fast so the client sees a real error.
return SearchResponse{}, err
}
responses = append(responses, r)
// Some engines (notably qwant due to anti-bot protections) can return
// zero local results depending on client/IP. If upstream SearXNG is
// configured, let it attempt the same engine as a fallback.
if shouldFallbackToUpstream(engineName, r) && !upstreamSet[engineName] {
upstreamEngines = append(upstreamEngines, engineName)
upstreamSet[engineName] = true
}
}
if s.upstreamClient != nil && len(upstreamEngines) > 0 {
r, err := s.upstreamClient.SearchJSON(ctx, req, upstreamEngines)
if err != nil {
return SearchResponse{}, err
}
responses = append(responses, r)
}
if len(responses) == 0 {
return SearchResponse{
Query: req.Query,
NumberOfResults: 0,
Results: []MainResult{},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
UnresponsiveEngines: [][2]string{},
}, nil
}
merged := MergeResponses(responses)
if merged.Query == "" {
merged.Query = req.Query
}
return merged, nil
}
func shouldFallbackToUpstream(engineName string, r contracts.SearchResponse) bool {
if engineName != "qwant" {
return false
}
return len(r.Results) == 0 && len(r.Answers) == 0 && len(r.Infoboxes) == 0
}