- Rename cmd/searxng-go to cmd/kafka - Remove all SearXNG references from source comments while keeping "SearXNG-compatible API" in user-facing docs - Update binary paths in README, CLAUDE.md, and Dockerfile - Update log message to "kafka starting" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
195 lines
5.1 KiB
Go
195 lines
5.1 KiB
Go
package engines
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
|
)
|
|
|
|
// BraveEngine implements the `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; the reference
|
|
// implementation 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")
|
|
}
|
|
}
|
|
|
|
// The reference implementation 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
|
|
}
|
|
|