主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
96 lines
3.6 KiB
Go
96 lines
3.6 KiB
Go
package search
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
)
|
||
|
||
// volcengineProfile 是火山引擎·豆包搜索(SearchInfinity,design/27 §2.1;分册 13 官方原文确证)。
|
||
//
|
||
// 接口形态:**POST `/search_api/web_search` + `Authorization: Bearer <API_KEY>`**
|
||
// ⇒ 标准库即可,无需签名/SDK(不必走 IAM AK/SK 的 TOP 网关那条路)。
|
||
// 请求字段是**大写驼峰**(Query/SearchType/Count/Filter),与其它家不同 —— 这是上游口径。
|
||
//
|
||
// 分册 13 确证的口径:`Query` 1~100 字符且**不支持多词搜索**;账号维度默认 10 QPS;
|
||
// 每账号每月 500 次免费(自 2026-03-17 起对个人账号生效);按量 0.020 元/次。
|
||
//
|
||
// ⚠️ 诚实边界(分册 13「未确证」):**响应字段未逐字取全**(含「如意」结构化卡)
|
||
// ⇒ 用共享提取器按多组候选键容错,不把猜测的字段当事实;拿到真实 key 后按官方文档收窄。
|
||
type volcengine struct{ cfg Config }
|
||
|
||
// volcenginePath 是 API Key 接入的端点路径
|
||
// (分册 13 官方原文:POST https://open.feedcoopapi.com/search_api/web_search)。
|
||
const volcenginePath = "/search_api/web_search"
|
||
|
||
// volcengineSearchTypeWeb 是搜索类型枚举(分册 13 官方原文:目前支持 `web`,需开通 web搜索)。
|
||
const volcengineSearchTypeWeb = "web"
|
||
|
||
// volcengineDefaultCount / volcengineMaxCount 是条数口径
|
||
// (分册 13 官方原文:Count 最多 50 条,默认 10 条)。
|
||
const (
|
||
volcengineDefaultCount = 10
|
||
volcengineMaxCount = 50
|
||
)
|
||
|
||
// newVolcengine 造一家火山·豆包搜索 provider。
|
||
func newVolcengine(cfg Config) Provider { return &volcengine{cfg: cfg} }
|
||
|
||
// Name 实现 Provider。
|
||
func (p *volcengine) Name() string { return NameVolcengine }
|
||
|
||
// Search 实现 Provider。
|
||
func (p *volcengine) Search(ctx context.Context, q Query) ([]Result, error) {
|
||
payload := map[string]any{
|
||
"Query": q.Text,
|
||
"SearchType": volcengineSearchTypeWeb,
|
||
"Count": volcengineCount(q.Count),
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: volcengine: encode request: %w", err)
|
||
}
|
||
data, err := do(ctx, p.cfg, request{
|
||
Method: http.MethodPost,
|
||
URL: baseURLJoin(p.cfg.BaseURL, volcenginePath),
|
||
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
|
||
Body: body,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return extractResults(data, volcengineSpec())
|
||
}
|
||
|
||
// volcengineCount 把调用方要的条数收进官方允许区间(分册 13:最多 50,默认 10)。
|
||
func volcengineCount(n int) int {
|
||
if n <= 0 {
|
||
return volcengineDefaultCount
|
||
}
|
||
if n > volcengineMaxCount {
|
||
return volcengineMaxCount
|
||
}
|
||
return n
|
||
}
|
||
|
||
// volcengineSpec 是火山·豆包搜索响应的**候选**形状。
|
||
//
|
||
// TODO(未确证):分册 13 只逐字取到了请求字段;响应结构(外层 Result.WebResults 与
|
||
// 每条结果的 Title/Url/Snippet/Summary/PublishTime/RankScore 等)**未确证**。
|
||
// 这里按可信度从高到低列候选键做容错解析 —— 它是「尽量认得出来」,不是「已确证」;
|
||
// 拿到真实 key 后必须按官方文档复核并删掉不成立的候选。
|
||
func volcengineSpec() spec {
|
||
return spec{
|
||
Arrays: []string{"Result.WebResults", "WebResults", "results", "Result.Results"},
|
||
Title: []string{"Title", "title"},
|
||
Link: []string{"Url", "url", "Link"},
|
||
Snippet: []string{
|
||
"Snippet", "snippet", "Summary", "summary",
|
||
},
|
||
Content: []string{"Content", "content", "Summary", "summary"},
|
||
Published: []string{"PublishTime", "publishTime", "PublishTimeMs"},
|
||
Score: []string{"RankScore", "rankScore", "Score", "score"},
|
||
}
|
||
}
|