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 }