feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。 - 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次** - 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义) - 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」) - 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉) - searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变) - 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
This commit is contained in:
1 parent
236b202fda
commit
69a3b73777
24 files changed
+3251
-171
No files matched your search
@@ -0,0 +1,67 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// searxngProfile 是自建 SearXNG(A 档、零 key、**本地兜底**;design/27 §2 与分册 08)。
|
||||
//
|
||||
// 协议(WS1-A 已落地,这里逐字保留其语义):GET,`{query}` 占位符优先,否则追加
|
||||
// `q=<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
|
||||
}
|
||||
Reference in new issue
Block a user