package search import ( "context" "fmt" "net/http" "net/url" "strings" ) // searxngProfile 是自建 SearXNG(A 档、零 key、**本地兜底**;design/27 §2 与分册 08)。 // // 协议(WS1-A 已落地,这里逐字保留其语义):GET,`{query}` 占位符优先,否则追加 // `q=&format=json`;响应是 `{"results":[{title,url,content,snippet,score}]}`。 // 它是「底线」不是「主力」:上游可达性决定覆盖面(分册 08 实测:mwmbl 稳定、 // bing 静默返空、sogou 会 CAPTCHA)。 type searxng struct{ cfg Config } // newSearXNG 造一家 SearXNG provider。BaseURL 为空也能造出来 —— 工具层会以 // 「no search endpoint configured」如实报错(WS1-A 的既有行为,不在这里提前崩)。 func newSearXNG(cfg Config) Provider { return &searxng{cfg: cfg} } // Name 实现 Provider。 func (p *searxng) Name() string { return NameSearXNG } // Search 实现 Provider。 func (p *searxng) Search(ctx context.Context, q Query) ([]Result, error) { target, err := buildSearxngURL(p.cfg.BaseURL, q.Text) if err != nil { return nil, err } data, err := do(ctx, p.cfg, request{Method: http.MethodGet, URL: target}) if err != nil { return nil, err } return extractResults(data, searxngSpec()) } // searxngSpec 是 SearXNG 的结果形状(分册 08:`results[].{url,title,content,score}`)。 // 只列确证过的键;没确证的(publishedDate 之类)不写死。 func searxngSpec() spec { return spec{ Arrays: []string{"results"}, Title: []string{"title"}, Link: []string{"url"}, Snippet: []string{"snippet"}, Content: []string{"content"}, Score: []string{"score"}, } } // buildSearxngURL 用一个端点与查询词拼出请求地址(WS1-A 的原逻辑,逐字保留): // 端点里含 `{query}` 占位符就替换,否则追加 `q=` 与 `format=json`。 func buildSearxngURL(endpoint, query string) (string, error) { if strings.TrimSpace(endpoint) == "" { return "", fmt.Errorf("search: searxng: no search endpoint configured") } q := url.QueryEscape(query) if strings.Contains(endpoint, "{query}") { return strings.ReplaceAll(endpoint, "{query}", q), nil } sep := "?" if strings.Contains(endpoint, "?") { sep = "&" } return endpoint + sep + "q=" + q + "&format=json", nil }