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
111 lines
2.7 KiB
Go
111 lines
2.7 KiB
Go
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
|
|
}
|
|
|