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
195
internal/engines/braveapi.go
Normal file
195
internal/engines/braveapi.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package engines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ashie/gosearch/internal/contracts"
|
||||
)
|
||||
|
||||
// BraveEngine implements the SearXNG `braveapi` engine (Brave Web Search API).
|
||||
//
|
||||
// Config / gating:
|
||||
// - BRAVE_API_KEY: required to call Brave
|
||||
// - BRAVE_ACCESS_TOKEN (optional): if set, the request must include a token
|
||||
// that matches the env var (via Authorization Bearer, X-Search-Token,
|
||||
// X-Brave-Access-Token, or form field `token`).
|
||||
type BraveEngine struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
accessGateToken string
|
||||
resultsPerPage int
|
||||
}
|
||||
|
||||
func (e *BraveEngine) Name() string { return "braveapi" }
|
||||
|
||||
func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
|
||||
if e == nil || e.client == nil {
|
||||
return contracts.SearchResponse{}, errors.New("brave engine not initialized")
|
||||
}
|
||||
|
||||
// Gate / config checks should not be treated as fatal errors; SearXNG
|
||||
// treats misconfigured engines as unresponsive.
|
||||
if strings.TrimSpace(e.apiKey) == "" {
|
||||
return contracts.SearchResponse{
|
||||
Query: req.Query,
|
||||
NumberOfResults: 0,
|
||||
Results: []contracts.MainResult{},
|
||||
Answers: []map[string]any{},
|
||||
Corrections: []string{},
|
||||
Infoboxes: []map[string]any{},
|
||||
Suggestions: []string{},
|
||||
UnresponsiveEngines: [][2]string{{e.Name(), "missing_api_key"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if gate := strings.TrimSpace(e.accessGateToken); gate != "" {
|
||||
if strings.TrimSpace(req.AccessToken) == "" || req.AccessToken != gate {
|
||||
return contracts.SearchResponse{
|
||||
Query: req.Query,
|
||||
NumberOfResults: 0,
|
||||
Results: []contracts.MainResult{},
|
||||
Answers: []map[string]any{},
|
||||
Corrections: []string{},
|
||||
Infoboxes: []map[string]any{},
|
||||
Suggestions: []string{},
|
||||
UnresponsiveEngines: [][2]string{{e.Name(), "unauthorized"}},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(req.Query)
|
||||
if q == "" {
|
||||
return contracts.SearchResponse{Query: req.Query}, nil
|
||||
}
|
||||
|
||||
offset := 0
|
||||
if req.Pageno > 1 {
|
||||
offset = (req.Pageno - 1) * e.resultsPerPage
|
||||
}
|
||||
|
||||
args := url.Values{}
|
||||
args.Set("q", q)
|
||||
args.Set("count", fmt.Sprintf("%d", e.resultsPerPage))
|
||||
args.Set("offset", fmt.Sprintf("%d", offset))
|
||||
|
||||
if req.TimeRange != nil {
|
||||
switch *req.TimeRange {
|
||||
case "day":
|
||||
args.Set("time_range", "past_day")
|
||||
case "week":
|
||||
args.Set("time_range", "past_week")
|
||||
case "month":
|
||||
args.Set("time_range", "past_month")
|
||||
case "year":
|
||||
args.Set("time_range", "past_year")
|
||||
}
|
||||
}
|
||||
|
||||
// SearXNG's python checks `if params["safesearch"]:` which treats any
|
||||
// non-zero (moderate/strict) as strict.
|
||||
if req.Safesearch > 0 {
|
||||
args.Set("safesearch", "strict")
|
||||
}
|
||||
|
||||
endpoint := "https://api.search.brave.com/res/v1/web/search?" + args.Encode()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return contracts.SearchResponse{}, err
|
||||
}
|
||||
httpReq.Header.Set("X-Subscription-Token", e.apiKey)
|
||||
|
||||
resp, err := e.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return contracts.SearchResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
|
||||
return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status=%d body=%q", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var api struct {
|
||||
Web struct {
|
||||
Results []struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Age string `json:"age"`
|
||||
Thumbnail struct {
|
||||
Src string `json:"src"`
|
||||
} `json:"thumbnail"`
|
||||
} `json:"results"`
|
||||
} `json:"web"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
|
||||
return contracts.SearchResponse{}, err
|
||||
}
|
||||
|
||||
results := make([]contracts.MainResult, 0, len(api.Web.Results))
|
||||
for _, r := range api.Web.Results {
|
||||
urlPtr := strings.TrimSpace(r.URL)
|
||||
if urlPtr == "" {
|
||||
continue
|
||||
}
|
||||
pub := parseBraveAge(r.Age)
|
||||
|
||||
results = append(results, contracts.MainResult{
|
||||
Template: "default.html",
|
||||
Title: r.Title,
|
||||
Content: r.Description,
|
||||
URL: &urlPtr,
|
||||
Pubdate: pub,
|
||||
Engine: e.Name(),
|
||||
Score: 0,
|
||||
Category: "general",
|
||||
Priority: "",
|
||||
Positions: nil,
|
||||
Engines: []string{e.Name()},
|
||||
})
|
||||
}
|
||||
|
||||
return contracts.SearchResponse{
|
||||
Query: req.Query,
|
||||
NumberOfResults: len(results),
|
||||
Results: results,
|
||||
Answers: []map[string]any{},
|
||||
Corrections: []string{},
|
||||
Infoboxes: []map[string]any{},
|
||||
Suggestions: []string{},
|
||||
UnresponsiveEngines: [][2]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseBraveAge(ageRaw string) *string {
|
||||
ageRaw = strings.TrimSpace(ageRaw)
|
||||
if ageRaw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Brave sometimes returns RFC3339-like timestamps for `age`.
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, ageRaw); err == nil {
|
||||
s := t.Format("2006-01-02 15:04:05-0700")
|
||||
return &s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue