Files
AskaEth 69a3b73777 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),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

90 lines
2.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// bochaProfile 是博查 Bocha(design/27 §2 A 档「中文优先」;分册 02 官方原文确证)。
//
// POST `/v1/web-search` + `Authorization: Bearer`;响应 `webPages.value[]` 的
// `name/url/snippet/summary/datePublished` —— **与 SearXNG 字段近乎同名**,归一化最省事。
//
// 两个诚实边界(分册 02):
//
// - 官方文档域名为 `api.bocha.cn`,而官网首页 curl 样例仍用 `api.bochaai.com`,
// 两者关系官方未说明 ⇒ 默认取**文档现行口径** `api.bocha.cn`(见 config 的默认值),
// 两域都可由 `WEB_SEARCH_BOCHA_BASE_URL` 覆盖。
// - 中文检索质量**未实测**(无 key)。
type bocha struct{ cfg Config }
// bochaPath 是 Web Search 端点路径(分册 02:POST https://api.bocha.cn/v1/web-search)。
const bochaPath = "/v1/web-search"
// bochaDefaultCount / bochaMaxCount 是请求条数口径(分册 02 官方原文:
// 「返回条数,可填范围 1-50,单次最大 50,默认 10」)。
const (
bochaMaxCount = 50
bochaDefaultCount = 10
)
// newBocha 造一家博查 provider。
func newBocha(cfg Config) Provider { return &bocha{cfg: cfg} }
// Name 实现 Provider。
func (p *bocha) Name() string { return NameBocha }
// Search 实现 Provider。
func (p *bocha) Search(ctx context.Context, q Query) ([]Result, error) {
// summary=true 换取中文摘要(分册 02:默认 false 不给 summary);
// freshness 用官方推荐的 noLimit(官方原文:「推荐使用 noLimit。搜索算法会自动
// 进行时间范围的改写,效果更佳」)。
payload := map[string]any{
"query": q.Text,
"summary": true,
"freshness": "noLimit",
"count": bochaClampCount(q.Count),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: bocha: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, bochaPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, bochaSpec())
}
// bochaClampCount 把调用方要的条数收进官方允许区间(分册 02:1-50,默认 10)。
func bochaClampCount(n int) int {
if n <= 0 {
return bochaDefaultCount
}
if n > bochaMaxCount {
return bochaMaxCount
}
return n
}
// bochaSpec 是博查的响应形状(分册 02「响应字段」官方原文确证)。
// `summary` 只在请求里 `summary=true` 时才有 —— 我们恒置 true。
func bochaSpec() spec {
return spec{
Arrays: []string{"webPages.value"},
Title: []string{"name"},
Link: []string{"url"},
Snippet: []string{"snippet"},
Content: []string{"summary"},
Published: []string{"datePublished"},
// 官方字段表未列评分(`id` 是排序 ID、不是分数)⇒ 不给 Score 候选。
}
}