feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
This commit is contained in:
AskaEth committed 2026-10-07 13:42:41 +08:00
1 parent 8e3311cea2
commit ce9af42945
21 files changed
+1219 -36

No files matched your search

+15 -4
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.86`**(上一版 `v0.85`,其条目见 §6;`v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **契约版本:`v0.87`**(上一版 `v0.86`,其条目见 §6;`v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -23,6 +23,7 @@
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
> **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。
> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。
> **v0.85 是「整理不再只产建议」版(已实现并推库 `6d92536`;**新增 1 条 writeAuth 端点 + 一处行为变更(整理默认自动落地)+ `link` 候选停产;无破坏性变更**)**:① **新增端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)** —— 按**动作**批量把 `pending` 候选标成 `rejected`(**幂等、只改候选状态、绝不碰记忆行**)。`action` **必填**且是**闭集**(`merge|link|archive|reclassify|keep`,未知值 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400`。`200 {data:{action,limit,total,rejected,truncated}}`(`total` 是命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 `truncated:true`)。审计 `memory.tidy.reject`(只带计数)。语义见 **§4.15(e)**。② 🔴 **行为变更(默认生效):整理在轮末会「自动落地」两类高置信动作**,不再只产建议(主人 2026-10-07 口径「该删就删」)——**可自动的只有**:`merge`(**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」)、`archive`(理由 = `one_off_event` **且不像习惯**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、**受限来源(群聊/陌生人)**。六条护栏:软动作(正文不改、行不删)/ **每轮上限 50**(硬顶 500,超限如实 `truncated`)/ 审计 `memory.tidy.apply`(`auto=true`)/ **可 revert**(回滚路径与人工应用同一条)/ 幂等 / **一键关回只产建议**(`$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`,**零新 env 键**)。⚠️ **面板侧的候选列表仍可能显示历史 `link` 行**,直到用 B25 清掉。③ **`link` 候选停产**:`link` 在 `design/23 §14` 里被列为**未细化**(既无落点也无数据模型),且 `applyable()` 只认 merge/archive/reclassify ⇒ **应用 link 恒 `skipped`,产出它只是往待办里灌噪音**。现在规则层不再产出(判 `keep`);`TidyLink` 常量保留**只为读历史行**。存量用 B25 清(主会话已执行)。④ **计数**:`writeAuth` 段 24 → **25**、管理路由总数 78 → **79**(`14 + 25 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 **27**,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **25**)。⑤ ⚠️ **诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**(不能重启后端);`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(本次不改变任何判定结论,升版会让既有候选按幂等键复制出重复行)。
@@ -1254,7 +1255,7 @@
| `plugin.testProvider` | 测试连通(**真发一次最小检索**) | write(table `rowActions`) | `{"row":{"provider":"bocha"}}`(亦可给 `{"values":{…}}` 或顶层 `provider`) | `{"ok":true,"provider":"bocha","count":1,"latencyMs":123}`;**没通 = 非 2xx**(见错误表),**不在 200 里回 `ok:false`** |
| `plugin.reauthProvider` | 重配凭据:把该条带回表单 | write(table `rowActions`) | `{"row":{"provider":"bocha"}}` | `{"values":{"provider":"bocha","apiKey":"****cdef","baseUrl":"https://api.bocha.cn","enabled":true,"primary":"bocha","fallback":["searxng"]}}`(**`apiKey` 只回掩码**;前端对 secret 字段一律不回填输入框,**空 = 不改动**)。⚠️ **v0.80 起 `values` 另带两个文件级字段** `primary`(字符串)/ `fallback`(字符串数组)—— **不带它们**的话,面板「清空降级链」在客户端三态写回里会落成「缺位 = 不改动」(**清不掉**) |
`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan`),否则 `400 unknown_provider`。
`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan` / **`tencent_wsa`** / **`metaso`**),否则 `400 unknown_provider`。🆕 **v0.87:名单由六家扩到八家**(新增腾讯云·元宝搜索与秘塔搜索),**端点形状与错误码一字未改**。
**(v0.80 新增)两个文件级字段的三态写回**(与条目字段同一套哨兵语义):`values.primary` **缺位** ⇒ 保留盘上值;给字符串(含空串 / `off` / `none`)⇒ 采用 —— **空串与 `off` / `none` 都是「关掉这把工具」的哨兵**(`off` / `none` 大小写与空白不敏感),`WEB_SEARCH_PROVIDER` 与面板**共用同一套关闭语义**(不另造第二套)。`values.primary` 指名一家**已登记但未配齐**的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒,因为那份配置会让后端下次起不来)。`values.fallback` **缺位** ⇒ 保留;给数组(或逗号 / 顿号 / 分号 / 换行分隔的字符串)⇒ 采用(去空白、去重、丢空项);元素**未登记** ⇒ `400 unknown_provider`;给非数组 / 非字符串 ⇒ `400 invalid_payload`(**不静默当空数组** —— 那等于一次保存悄悄清空降级链)。降级链**允许**放本期已登记但**尚未配齐**的家(运行期会跳过它)。
@@ -1486,7 +1487,17 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.86(当前契约版本)
### v0.87(当前契约版本)
**「搜索 provider 扩到八家」版 —— 名单扩充,端点形状与错误码一字未改**(切片 `agent-389`,本次提交;依据:主人「我看之前不是走有头CDP扒了好几家搜索提供商的接口方式在本地参考文档吗?为什么不都做一下适配?」+「优先只做国内能用到的几家」。)
- **新增两家已登记接入位**:`tencent_wsa`(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**)与 `metaso`(秘塔搜索,`POST {base}/api/v1/search`)。
- **契约面影响(只有两处,且都是名单扩充)**:① `plugin.saveProvider` 的 `values.provider` 合法名单 **六家 → 八家**(见 §4.14 的三态写回那段);② `plugin.providers` 候选新增两条 `{value,label}`(中文名见 §6 本版上方那条的逐字表)。**§2.0 计数不变**(无新端点,维持 `14 + 25 + 40 = 79`)。
- **前端零改动**:候选是数据驱动的(`optionsFrom`),中文名只是 `label`。
- ⚠️ **诚实边界(如实,别当已验)**:① **腾讯成功响应未真机实测**(无 key)—— 依据是官方文档 + 本机无 key 探测(缺 `Authorization` 也回 **HTTP 200** + `Response.Error`);② **秘塔响应 schema 全部来自第三方来源且互相打架** ⇒ 代码走**宽松解析**(多字段名回退),**未真机验证**,拿到真 key 后必须按真实报文收窄;③ 两家的请求体都**只发官方确证为必选的字段**(腾讯只发 `Query`),条数靠本地截断。
- **零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`;`.env` / `.env.example` **未动**(`.env` 冻结口径不变)。
### v0.86
**「搜索结果条数上限可配」版 —— 面板多一个 number 字段 + 两个动作各多一个键**(切片 `bff57bd` + `c68387a`;依据:主人 2026-10-07 口径「返回五条结果会不会太少了?」+ 上一片登记的「`maxResults` 在面板上根本改不了」那条真缺陷。)
@@ -1510,7 +1521,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
**「显示名 + 段落标签硬约束」版 —— 一处形状变更(插件动作响应)+ 一处新增产出侧硬约束 + 一条模型面登记**(依据:主人报障「下拉里全给英文名,你甚至不愿意带个括号把中文名写上去」;安卓端登记 `<p><p>正文</p>` 畸形;`design/28 §1 M5 / §8 B5` 的浏览器降级档。切片:显示名 `c46053e`、段落修复 `8051906`、provider 配置面去 env 化 `bc1dee3`、搜索 linkless 修复 `2c56bc9`。)
- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)`。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。
- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)` / **`腾讯云·元宝搜索(tencent_wsa)`** / **`秘塔搜索 Metaso(metaso)`**(🆕 **v0.87 起八家**)。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。
- **② 新增产出侧硬约束:助手文本不得出现未配对的段落标签**(落单 `<p …>` / `</p>`)。见 **§4.16**。修的是安卓登记的 `<p><p>正文</p>`;**两半根因都在产出侧**(规整器原样放过 + 在「末段漏 `</p>`」时亲手制造),另修一个幂等漏洞。**历史行不回填**、消费方**仍应保留**容错。
- **③ 模型面登记(非客户端契约面)**:`web-fetch` 的浏览器渲染降级档已接线,返回体用 `fetchedBy`(`direct` / `browser`)**如实标注**正文实际来自哪条路;两条档的失败各自留痕,**绝不静默替换**,被 SSRF 拒绝的目标**不换路**。
- **④ provider 配置面「去 env 化」(对客户端零形状影响,登记供跨端对照)**:新增 provider 不再需要新的 `.env` 键 —— 面板文件 `$DATA_DIR/config/plugins/web-search.json` **是权威**,`.env` 只在文件缺失/为空时**播种**(文件一旦存在,「文件里没有的家」**不从 env 复活** —— 这是既有回归用例钉住的口径,本次未改);各家「没配 baseUrl 就回落代码默认值」的默认值表移进接入位表(`search.Descriptor.DefaultBaseURL`),**逐字未变**。
@@ -0,0 +1,91 @@
package main
// 本文件钉住本片新增的两家(`tencent_wsa` / `metaso`)在**组合根里真的接通了**:
// 它们**没有 `.env` 字段**(`.env` 已冻结),因此凭据与地址只能来自面板文件
// `$DATA_DIR/config/plugins/web-search.json`,而组合根**零改动**地按接入位表把它们投影出来
// (`resolveSearchProviders` 逐家走 `search.Descriptors()`)。
//
// 为什么值得端到端一条:`cmd/cyrene/websearch_providers.go` 里**一行都没改**——这正是
// 「按接入位表投影」的设计意图;没有这条用例,「零改动」就只是一句没有证据的话。
//
// 夹具(假上游、`wsSurfaceApp`、`wsExecuteSearch`)在 websearch_testenv_test.go /
// websearch_projection_test.go;`.env` 字段表的完整性由 wire_web_search_*_test.go 钉住。
import (
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// TestWebSearchPanelOnlyProvidersWiredEndToEnd 是判据:面板文件里配好一家**没有 env 字段**
// 的家 ⇒ 工具进注册面 ⇒ 真的把请求打到面板里那个地址、带上面板里那把 key,且结果里如实
// 标注 provider。
func TestWebSearchPanelOnlyProvidersWiredEndToEnd(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path string
fixture string
}{
{
name: search.NameTencentWSA,
path: "/SearchPro",
// Pages 的元素是 JSON **字符串**(官方 Array of String 形状)。
fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` +
`\"passage\":\"元宝摘要\"}"],"Query":"昔涟","RequestId":"r"}}`,
},
{
name: search.NameMetaso,
path: "/api/v1/search",
fixture: `{"webpages":[{"title":"秘塔结果","link":"https://m.example/1","snippet":"秘塔摘要"}]}`,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
const key = "test-panel-only-key"
up := newWSFakeUpstream(t, http.StatusOK, tc.fixture)
// `.env` 只给「谁当主家」:这两家在 `.env` 里没有字段,凭据与地址只能来自面板文件。
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+tc.name)
// 前提:它确实不在 `.env` 字段表里(否则这条用例会退化成既有 6 家的回归)。
for _, p := range searchProviderVector(cfg.WebSearch) {
if strings.EqualFold(p.Name, tc.name) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", tc.name)
}
}
// 前提:接入位表里确有它(否则组合根根本投影不出来)。
if !search.Known(tc.name) {
t.Fatalf("前提被破坏:%s 应已在接入位表里登记", tc.name)
}
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: tc.name,
Providers: []runtimeconfig.ProviderEntry{
{Provider: tc.name, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
})
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("面板文件里配好的 %s 应让工具进注册面,实际工具面 = %v", tc.name, toolNames(a))
}
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != tc.name {
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", got["provider"], tc.name)
}
select {
case r := <-up.hits:
if r.URL.Path != tc.path {
t.Errorf("请求路径 = %q, want %q(各家固定路径)", r.URL.Path, tc.path)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+key {
t.Errorf("Authorization = %q,want 面板文件里那把 key(凭据必须以文件为准)", got)
}
default:
t.Fatalf("%s 一个请求都没收到 —— 面板里的配置没接到 provider 上", tc.name)
}
})
}
}
+10 -5
View File
@@ -35,11 +35,15 @@ import (
//
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
//
// ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`):
// `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 ——
// 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。
func wsFakeNewProviderDef() search.Descriptor {
return search.Descriptor{
Name: "tencent_wsa",
Name: "fake_next_provider",
NeedKey: true,
DefaultBaseURL: "https://wsa.tencentcloudapi.com",
DefaultBaseURL: "https://fake-next.example",
}
}
@@ -233,11 +237,12 @@ func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
// (测试专用的假接入位 `tencent_wsa`)在文件里有 key ⇒ 照样被投影出生效配置。
// (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。
//
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动。
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动
// —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。
//
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
// 走完:注册表按名字查的正是同一张接入位表。
@@ -252,7 +257,7 @@ func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
}
}
// 前提二:它确实还没登记(登记工作是下一片的:加接入位文件 + 表里一行)。
// 前提二:它确实还没登记(假接入位的全部意义就在于此)。
if search.Known(fake.Name) {
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
}
+6 -2
View File
@@ -15,7 +15,8 @@ package main
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
//
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
// 都不用改**,`.env` 一个键都不用加。
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
import (
"strings"
@@ -55,7 +56,10 @@ type searchProviderWire struct {
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
//
// ⚠️ 它**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// 这条口径由 `wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
+12 -2
View File
@@ -73,8 +73,18 @@ func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) {
if f.Provider != search.NameBocha {
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
}
if len(f.Providers) != len(search.KnownNames()) {
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
// 播种内容 = `.env` 字段表那张表(searxng + 5 家云家)。**没有 env 字段的家**
// (tencent_wsa / metaso)不进播种:`.env` 已冻结(不加键),它们只从面板文件配。
seededNames := envSearchProviders(first.WebSearch)
if len(f.Providers) != len(seededNames) {
t.Fatalf("播种必须列全 `.env` 字段表里的家:%d 条, want %d", len(f.Providers), len(seededNames))
}
for _, absent := range []string{search.NameTencentWSA, search.NameMetaso} {
for _, e := range f.Providers {
if strings.EqualFold(e.Provider, absent) {
t.Fatalf("没有 env 字段的家 %s 不得被播种(它只从面板文件配)", absent)
}
}
}
seeded := false
for _, e := range f.Providers {
+7 -3
View File
@@ -112,7 +112,10 @@ func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) {
if !strings.Contains(out, search.NameBocha) {
t.Fatalf("可用清单里应有 bocha: %q", out)
}
for _, absent := range []string{search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan} {
for _, absent := range []string{
search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan,
search.NameTencentWSA, search.NameMetaso,
} {
if strings.Contains(out, absent) {
t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out)
}
@@ -170,16 +173,17 @@ func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) {
}
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
// 八家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
func TestWireIOTools_ProviderVectorSummary(t *testing.T) {
t.Parallel()
want := map[string]bool{
search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true,
search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true,
search.NameTencentWSA: true, search.NameMetaso: true,
}
got := search.KnownNames()
if len(got) != len(want) {
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan)",
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan/tencent_wsa/metaso)",
got, len(want))
}
for _, n := range got {
+2 -2
View File
@@ -49,8 +49,8 @@ const (
)
// webSearchProviderRe 是 provider 名的字符集。本仓内置名全是小写字母 + 下划线
// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan),限定字符集是为了
// 让名字能直接当端点段、文件名与日志字段用。
// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan / tencent_wsa /
// metaso),限定字符集是为了让名字能直接当端点段、文件名与日志字段用。
//
// 它**不**判定「这个名字是否已登记」—— 那是 `plugins/tools/io/search` 的事实,本包不去
// 反向依赖插件包(组合根负责核对并记 WARN)。
+6 -1
View File
@@ -10,7 +10,12 @@ import (
// 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。
type WebSearchConfig struct {
// Provider 选谁当后端(design/27 §3):`searxng`(默认)/ `zhipu` / `aliyun_iqs` /
// `bocha` / `volcengine` / `baidu_qianfan`。
// `bocha` / `volcengine` / `baidu_qianfan` / `tencent_wsa` / `metaso`。
//
// ⚠️ 后两家(`tencent_wsa` / `metaso`)**没有对应的 env 字段**(`.env` 已冻结:不加键)
// —— 它们只在 `$DATA_DIR/config/plugins/web-search.json` 里配(组合根按插件侧的接入位表
// 逐家投影,见 cmd/cyrene/websearch_providers.go)。本结构里的逐家字段仍然**只**服务
// 已有的 6 家播种/覆盖。
//
// **语义硬约束**(design/27 §3,由组合根的 io 工具集在启动期执行):
// - 指向**未登记**(如本期未做的 tavily)或**未配置**(缺 key/端点)的名字
+2 -1
View File
@@ -22,7 +22,8 @@
// the host is on an explicit allowlist).
// - web-search: queries the configured search **provider** through the registry in
// plugins/tools/io/search (searxng / zhipu / aliyun_iqs / bocha / volcengine /
// baidu_qianfan; one provider per file, design/27 §4). The model sees exactly one
// baidu_qianfan / tencent_wsa / metaso; one provider per file, design/27 §4). The
// model sees exactly one
// tool whose schema never changes with the backend. A configured fallback chain is
// tried in order, and only an *unavailable* provider (missing key, auth / rate
// limit / quota / network failure) advances the chain — an empty result set does
+8 -3
View File
@@ -133,9 +133,14 @@ func upstreamCodeClass(code string) (error, bool) {
// 搜索专用码 1701(并发达上限)/1702/1703 只登记、不在此映射:官方未给它们的 HTTP 状态码,
// 保守交给状态码兜底(429 未收录码 ⇒ rate_limited,其余 4xx/5xx ⇒ upstream)。
//
// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。当前六家里只有智谱用数字业务码
// (阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来出现第二家用数字码的家,必须改成
// 「按 provider 分表」**,不能靠码值碰运气。
// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。走本函数的家里只有智谱用数字
// 业务码(阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来有第二家走本函数的家也用
// 数字码,必须改成「按 provider 分表」**,不能靠码值碰运气。
//
// 已经有第二家用数字码的家族:秘塔(`errCode=2005` 等)与腾讯云(大写点分码)。它们**不经过
// 本函数**——那两家的错误随 **HTTP 200** 返回,classifyStatus(只处理非 2xx)根本看不到,
// 因此各自在 provider 文件里维护自己的码表(`metasoCodeClass` / `tencentCodeClass`)。
// 这正是「按 provider 分表」的落地形态:**共用表保持最小、各家码表就近**。
func zhipuCodeClass(code string) (error, bool) {
switch strings.TrimSpace(code) {
case "1000", "1001", "1003":
+4 -2
View File
@@ -12,8 +12,10 @@ import (
// spec 描述「怎么把一家 provider 的 JSON 归一到 []Result」:每个字段给一串**候选键名**
// (按序取第一个非空值),结果数组给一串**候选路径**(点分,按序取第一个是数组的)。
//
// 六家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。
// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度)用多组候选键容错,
// 各家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。
// 特例:腾讯云元宝搜索的 `Pages` 是「JSON 字符串数组」,本提取器吃不下(它只认对象数组),
// 那一家自带一次**二次解析**后仍复用本文件的字段挑选器(见 tencent_wsa.go 的 tencentParse)。
// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度/秘塔)用多组候选键容错,
// 而不是把某个猜测的字段当事实写死(design/27 §7 的诚实边界)。
type spec struct {
// Arrays 是结果数组的候选路径(点分)。例:{"webPages.value", "results"}。
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const defaultMaxBytes = 1 << 20
// request 描述一次上游调用。provider 只填这些,其余(读取上限、错误分类)走共享通路
// —— 六家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。
// —— 各家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。
type request struct {
// Method 是 HTTP 方法(http.MethodGet / http.MethodPost)。
Method string
+185
View File
@@ -0,0 +1,185 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
// metasoProfile 是秘塔搜索 Metaso(design/27 §2.1;官方材料见
// DevDocs/reference/search-providers/metaso/)。
//
// 走开放平台的搜索端点(**不是**站点自己的 `/search/v2`,那是另一套字段):
//
// POST https://metaso.cn/api/v1/search
// Authorization: Bearer <API KEY>
//
// 请求字段取**官方前端产物**里确证的那几个(`client-bundle-evidence.md` §4 的组装函数原文):
//
// q 查询词(必填)
// scope "webpage"(官方枚举 webpage/document/scholar/image/video/podcast)
// size 结果数量,官方前端发的是**字符串**(默认 "10")
//
// 刻意**不发 `mode`**:官方前端会发 `mode:"search"`,而第三方誊抄的官方 cURL 样例里没有它
// ⇒「是否必需」**未确证**,拿不准的字段不发(响应侧的 `errCode` 也读不出「缺 mode」)。
//
// 🔴 **错误也走 HTTP 200**:`{"errCode":2005,"errMsg":"API密钥无效"}`(本机实测;缺 key 与
// 错 key **同码**)⇒ 必须读响应体的 `errCode` 才能判失败(见 metasoResponseError)。
//
// 🔴 **响应 schema 只有第三方来源、且互相打架**(条目级字段 `date` vs `displayDate`;顶层
// `webpages` 三方一致但**无官方来源**)⇒ 本家只能**宽松解析**(多字段名回退,见 metasoSpec),
// 候选键**未经实测**。拿到真 key 后必须按真实报文核对并收窄候选键。
type metaso struct{ cfg Config }
// metasoPath 是开放平台的搜索端点路径(官方 JS 产物:POST /api/v1/search)。
const metasoPath = "/api/v1/search"
// metasoScopeWebpage 是固定的搜索范围「网页」。
//
// 固定它的理由:响应侧的结果数组键**随 scope 变**(webpage→webpages),本工具是网页搜索,
// 只有固定 scope=webpage 才能让请求与解析两侧对上(第三方的 SCOPE_RESULT_MAPPING 原文如此)。
const metasoScopeWebpage = "webpage"
// metasoDefaultSize 是 `size` 的官方前端默认值(**字符串** "10",见文件头)。
const metasoDefaultSize = "10"
// metasoMaxSize 是 `size` 的钳制上限。
//
// ⚠️ **未确证**:官方页面/JS 产物**没写**上限,1–100 这个区间来自第三方实现
// (`metaso-search-mcp`:`Math.min(Math.max(size,1),100)`)。这里按第三方的保守值钳制
// ——宁可少要几条(本地截断照常生效),也不把越界值发上去换一个参数错。
const metasoMaxSize = 100
// metasoUnauthorizedCode 是凭据问题的业务码(本机实测:无 key 与错 key 都是 2005)。
const metasoUnauthorizedCode = "2005"
// metasoAcceptHeader 是官方 cURL 模板四段头之一(搜索端点固定 `application/json`)。
const metasoAcceptHeader = "application/json"
// newMetaso 造一家秘塔搜索 provider。
func newMetaso(cfg Config) Provider { return &metaso{cfg: cfg} }
// Name 实现 Provider。
func (p *metaso) Name() string { return NameMetaso }
// Search 实现 Provider。
func (p *metaso) Search(ctx context.Context, q Query) ([]Result, error) {
payload := map[string]any{
"q": q.Text,
"scope": metasoScopeWebpage,
"size": metasoSize(q.Count),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: metaso: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, metasoPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey, "Accept": metasoAcceptHeader},
Body: body,
})
if err != nil {
return nil, err
}
return metasoParse(data)
}
// metasoSize 把调用方要的条数收进 [1,100] 并**转成字符串**(官方前端发的是字符串)。
// 未给条数(≤0)⇒ 官方默认值 "10"。
func metasoSize(n int) string {
if n <= 0 {
return metasoDefaultSize
}
if n > metasoMaxSize {
n = metasoMaxSize
}
return strconv.Itoa(n)
}
// metasoParse 先判错、再把报文交给共享提取器归一化。
//
// 判错必须在提取之前:失败报文里**没有**结果数组,直接用共享提取器会把它读成
// 「结构不对」(ErrBadResponse),而真相是「凭据无效」——那正是降级链该前进一格的那种失败。
func metasoParse(data []byte) ([]Result, error) {
var root any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("%w: %w", ErrBadResponse, err)
}
if err := metasoResponseError(root); err != nil {
return nil, err
}
// 判错之后复用共享提取器:同一套多候选键容错 + 「Content 空 ⇒ 回落 Snippet」的口径。
return extractResults(data, metasoSpec())
}
// metasoResponseError 读顶层 `errCode`(HTTP 200 上判失败的**唯一**途径)。
// 缺位或 0 ⇒ 这次调用成功(成功响应里 errCode 长什么样**未实测**,因此对 0/缺位都放行)。
func metasoResponseError(root any) error {
v, ok := resolvePath(root, "errCode")
if !ok {
return nil
}
code := scalarString(v)
if code == "" || code == "0" {
return nil
}
sentinel := metasoCodeClass(code)
return fmt.Errorf("%w (code=%s)", sentinel, code)
}
// metasoCodeClass 把秘塔的错误码归成本仓哨兵。
//
// 已确证的只有一条:**2005 = 「API密钥无效」**(本机实测,缺 key 与错 key 同码)。官方
// 错误码表未公开,**欠费/限流/未开通的码一个都没取到** ⇒ 一律不猜,落到 ErrUpstream
// (仍然是「不可用」,降级链照常前进一格,不会把失败说成空结果)。
//
// `401`/`403` 是站点自身接口的码(`/api/my-info` 的「需要登录」实测为 401),这里一并
// 认作凭据问题:开放 API 若沿用同一套码,归类也是对的。
func metasoCodeClass(code string) error {
switch strings.TrimSpace(code) {
case metasoUnauthorizedCode, "401", "403":
return ErrUnauthorized
default:
return ErrUpstream
}
}
// metasoSpec 是秘塔搜索响应的**候选**形状 —— ⚠️ **全部未经实测**(无官方 schema)。
//
// 已知事实只有三条:① 顶层**没有 `data` 信封**(第三方三个实现一致);② 结果数组的键随
// scope 变(本地固定 scope=webpage ⇒ 主候选 `webpages`);③ 条目级字段名**三方不一致**
// (`date` vs `displayDate`)。因此候选键给多组回退,且**不把任何一个猜测当事实**:
//
// Arrays: webpages(主候选)→ data.webpages / documents / data.documents /
// results / data.results / data(逐级容错)
// Title: title / name
// Link: link / url
// Snippet: snippet / summary / passage / abstract
// Content: rawContent(`includeRawContent=true` 时的正文;本工具不请求该开关 ⇒ 通常为空)
// → content / markdown / text
// Published: date / displayDate / published / publishDate
// Score: score(第三方实测是**字符串** ⇒ pickFloat 认得数字字符串)
//
// 刻意**不把 `position` / `rank` 当 Score**:它们是**排序位次**(1,2,3…),而 Finalize 按
// Score **降序**排 —— 拿位次当分数会把最相关的一条排到最后。宁可没有分数(保持上游原始
// 顺序,SliceStable)。
func metasoSpec() spec {
return spec{
Arrays: []string{
"webpages", "data.webpages",
"documents", "data.documents",
"results", "data.results",
"data",
},
Title: []string{"title", "name"},
Link: []string{"link", "url"},
Snippet: []string{"snippet", "summary", "passage", "abstract"},
Content: []string{"rawContent", "content", "markdown", "text"},
Published: []string{"date", "displayDate", "published", "publishDate"},
Score: []string{"score"},
}
}
+311
View File
@@ -0,0 +1,311 @@
package search
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
// 本文件钉住秘塔搜索 provider 的**请求面**、**宽松解析**与**错误映射**
// (依据:DevDocs/reference/search-providers/metaso 的官方 JS 产物 + 本机无 key 实测)。
//
// ⚠️ 与其它家不同的一条前提:**响应 schema 只有第三方来源、且三方互相打架**
// (条目级 `date` vs `displayDate`;顶层 `webpages` 无官方来源)⇒ 本家的解析是**宽松解析**
// (多字段名回退),用例也只能钉住「这些候选键都能出结果」与「字段全缺时不 panic」,
// **不能**钉住「官方就是这些字段」。拿到真 key 后必须按真实报文收窄。
//
// 全部打 httptest,**不打真实网络**(无 API KEY ⇒ 成功响应真机未实测;错误码只确证了 2005)。
// metasoOK 是第三方实测形状的应答(`third-party-response-schema.md` 的 zod schema 逐字段照抄:
// 顶层**没有 data 信封**、`score` 是字符串、`rawContent` 是开启抓原文后的正文)。
const metasoOK = `{
"credits": 3,
"searchParameters": {"q": "昔涟", "size": 10, "includeSummary": false},
"webpages": [
{"title": "昔涟是什么", "link": "https://m.example/1", "score": "0.93",
"snippet": "秘塔摘要", "position": 1, "rawContent": "秘塔正文原文", "date": "2025-06-01"},
{"title": "第二条", "link": "https://m.example/2", "position": 2, "snippet": "第二摘要"}
],
"total": 2
}`
// TestMetasoRequestShape 钉住请求面:POST `/api/v1/search` + Bearer + Accept,请求体是
// 官方前端那三个字段(`q` / `scope=webpage` / `size` **字符串**),且 `mode` 不发
// (是否必需未确证)。
func TestMetasoRequestShape(t *testing.T) {
t.Parallel()
cases := []struct {
name string
count int
wantSize string
wantQuery string
}{
{"未给条数 ⇒ 官方默认 \"10\"", 0, metasoDefaultSize, "昔涟"},
{"给了条数 ⇒ 原样成字符串", 5, "5", "昔涟"},
{"越界条数 ⇒ 钳到未确证的上限 100", 1000, "100", "昔涟"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, cap := fixtureServer(t, metasoOK)
if _, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: tc.wantQuery, Count: tc.count}); err != nil {
t.Fatalf("Search: %v", err)
}
if cap.req == nil {
t.Fatal("没发出任何请求")
}
if cap.req.Method != http.MethodPost {
t.Errorf("method = %q, want POST", cap.req.Method)
}
if cap.req.URL.Path != metasoPath {
t.Errorf("path = %q, want %q", cap.req.URL.Path, metasoPath)
}
if got := cap.req.Header.Get("Authorization"); got != "Bearer "+testKey {
t.Errorf("Authorization = %q, want Bearer <key>", got)
}
if got := cap.req.Header.Get("Accept"); got != metasoAcceptHeader {
t.Errorf("Accept = %q, want %q", got, metasoAcceptHeader)
}
var body map[string]any
mustUnmarshal(t, cap.body, &body)
if body["q"] != tc.wantQuery {
t.Errorf("body q = %v, want %q", body["q"], tc.wantQuery)
}
if body["scope"] != metasoScopeWebpage {
t.Errorf("body scope = %v, want %q", body["scope"], metasoScopeWebpage)
}
if body["size"] != tc.wantSize {
t.Errorf("body size = %v (%T), want 字符串 %q", body["size"], body["size"], tc.wantSize)
}
if _, ok := body["mode"]; ok {
t.Error("body 不该带 mode:官方前端发、第三方 cURL 不发,是否必需未确证 ⇒ 不发")
}
})
}
}
// TestMetasoResponseNormalizesLooseFields 覆盖「宽松解析」的主路径:第三方实测字段
// (title/link/snippet/rawContent/date/score 字符串)都能出结果。
func TestMetasoResponseNormalizesLooseFields(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, metasoOK)
got, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "昔涟"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("条数 = %d, want 2: %+v", len(got), got)
}
first := got[0]
if first.Title != "昔涟是什么" || first.Link != "https://m.example/1" {
t.Errorf("Title/Link = %q/%q", first.Title, first.Link)
}
if first.Snippet != "秘塔摘要" {
t.Errorf("Snippet = %q, want 第三方实测的 snippet", first.Snippet)
}
if first.Content != "秘塔正文原文" {
t.Errorf("Content = %q, want rawContent(抓原文开关打开时的正文)", first.Content)
}
if first.Published != "2025-06-01" {
t.Errorf("Published = %q, want 第三方实测的 date", first.Published)
}
if first.Score != 0.93 {
t.Errorf("Score = %v, want 0.93(第三方实测 score 是字符串,pickFloat 应认)", first.Score)
}
// position=1 是**排序位次**,不是相关性分数 ⇒ 不许被当成 Score(否则降序排会把首条排到末位)。
if got[1].Score != 0 {
t.Errorf("第二条 Score = %v, want 0(position 不是分数,不许投影)", got[1].Score)
}
}
// TestMetasoResponseLooseArrayAndKeyFallbacks 是验收「宽松解析:多种字段名都能出结果」:
// 结果数组与条目字段名都按多组候选回退(因为 response schema 无官方来源)。
func TestMetasoResponseLooseArrayAndKeyFallbacks(t *testing.T) {
t.Parallel()
cases := []struct {
name string
fixture string
}{
{"顶层 webpages(主候选)", `{"webpages":[{"title":"T","link":"https://x/1","snippet":"S"}]}`},
{"data 信封里的 webpages", `{"data":{"webpages":[{"title":"T","link":"https://x/1","snippet":"S"}]}}`},
{"documents(scope 变体的容错)", `{"documents":[{"title":"T","link":"https://x/1","snippet":"S"}]}`},
{"results 通用键", `{"results":[{"title":"T","link":"https://x/1","snippet":"S"}]}`},
{"字段名变体:name/url/summary", `{"webpages":[{"name":"T","url":"https://x/1","summary":"S"}]}`},
{"字段名变体:passage/content", `{"webpages":[{"title":"T","link":"https://x/1","passage":"S","content":"C"}]}`},
{"字段名变体:displayDate", `{"webpages":[{"title":"T","link":"https://x/1","displayDate":"2025-01-02"}]}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tc.fixture)
got, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("条数 = %d, want 1(候选键应能命中): %+v", len(got), got)
}
if got[0].Title != "T" || got[0].Link != "https://x/1" {
t.Errorf("归一化结果 = %+v, want title=T link=https://x/1", got[0])
}
})
}
}
// TestMetasoAllFieldsMissingDoesNotPanic 是验收「字段全缺时不 panic」:条目是空对象 ⇒
// 归一化成一条**四字段全空**的行,由共享后处理(Finalize)丢弃 —— 不 panic、不报错。
func TestMetasoAllFieldsMissingDoesNotPanic(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, `{"webpages":[{}, {"unknown": 1}]}`)
got, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("字段全缺不该报错(那会让上游的脏数据变成该家不可用): %v", err)
}
if len(got) != 2 {
t.Fatalf("条数 = %d, want 2", len(got))
}
for i, r := range got {
if r.Link != "" || r.Title != "" || r.Snippet != "" || r.Content != "" {
t.Errorf("[%d] 空条目应四字段全空: %+v", i, r)
}
}
if final := Finalize(got, Options{Limit: 5}); len(final) != 0 {
t.Fatalf("Finalize 后条数 = %d, want 0(空壳行不算结果)", len(final))
}
}
// TestMetasoEmptyIsNotAnError 钉住「空结果不是错误」。
func TestMetasoEmptyIsNotAnError(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, `{"credits":0,"webpages":[]}`)
got, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("空 webpages 不是错误: %v", err)
}
if len(got) != 0 {
t.Fatalf("条数 = %d, want 0", len(got))
}
}
// TestMetasoErrorsOnHTTP200 钉住错误路径:**HTTP 200 + 顶层 `errCode`**
// (本机实测:缺 key 与错 key 都是 2005)。只确证了 2005 一条 ⇒ 其它码一律归 upstream,
// 但**都必须报失败**(绝不能把失败读成「零结果」)。
func TestMetasoErrorsOnHTTP200(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
wantSent error
wantCode string
}{
{"凭据无效 2005(字符串)", `{"errCode":2005,"errMsg":"API密钥无效"}`, ErrUnauthorized, ProbeUnauthorized},
{"凭据无效 2005(数字也要认)", `{"errCode":"2005","errMsg":"API密钥无效"}`, ErrUnauthorized, ProbeUnauthorized},
{"站点登录码 401", `{"errCode":401,"errMsg":"需要登录"}`, ErrUnauthorized, ProbeUnauthorized},
{"未确证的码 ⇒ upstream(不猜欠费/限流)", `{"errCode":9999,"errMsg":"unknown"}`, ErrUpstream, ProbeUpstream},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tc.body)
got, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err == nil {
t.Fatalf("HTTP 200 带 errCode 必须报失败,却拿到 %d 条结果", len(got))
}
if !errors.Is(err, tc.wantSent) {
t.Errorf("errors.Is(err, %v) = false: %v", tc.wantSent, err)
}
if code := ProbeErrorCode(err); code != tc.wantCode {
t.Errorf("探针错误码 = %q, want %q", code, tc.wantCode)
}
// errMsg 是任意上游文本 ⇒ 不许进错误文案(它会进日志与面板响应)。
if strings.Contains(err.Error(), "API密钥无效") {
t.Errorf("错误文案带上了上游 errMsg: %v", err)
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("错误文案泄漏了凭据: %v", err)
}
})
}
}
// TestMetasoZeroErrCodeIsNotAnError 钉住成功侧不误杀:`errCode` 缺位或为 0 时按成功处理
// (成功响应里 errCode 长什么样**未实测** ⇒ 两种形态都放行)。
func TestMetasoZeroErrCodeIsNotAnError(t *testing.T) {
t.Parallel()
for _, fixture := range []string{
`{"errCode":0,"webpages":[{"title":"T","link":"https://x/1"}]}`,
`{"errCode":"0","webpages":[{"title":"T","link":"https://x/1"}]}`,
`{"webpages":[{"title":"T","link":"https://x/1"}]}`,
} {
srv, _ := fixtureServer(t, fixture)
got, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("errCode=%q 的应答不该当失败: %v", fixture, err)
}
if len(got) != 1 {
t.Fatalf("条数 = %d, want 1(输入 %s)", len(got), fixture)
}
}
}
// TestMetasoMissingArrayIsBadResponse 钉住「空」与「解析不了」分开:一个候选数组都没有
// (响应形状变了 / 是别的 scope 的报文)⇒ ErrBadResponse,不是静默 0 条。
func TestMetasoMissingArrayIsBadResponse(t *testing.T) {
t.Parallel()
for _, body := range []string{`not json`, `{}`, `[]`, `{"credits":1}`} {
srv, _ := fixtureServer(t, body)
_, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if !errors.Is(err, ErrBadResponse) {
t.Errorf("输入 %s 应报 ErrBadResponse, got %v", body, err)
}
}
}
// TestMetasoOverLimitResponseRejected 是验收 ④:响应体超过本仓读取上限 ⇒
// ErrResponseTooLarge(**不静默截断成半截 JSON**)。
func TestMetasoOverLimitResponseRejected(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, metasoOK)
_, err := newMetaso(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 16,
}).Search(context.Background(), Query{Text: "q"})
if !errors.Is(err, ErrResponseTooLarge) {
t.Fatalf("err = %v, want ErrResponseTooLarge", err)
}
}
// TestMetasoSpecArraysStayOrdered 钉住候选数组的**顺序**(主候选必须是 webpages),
// 并钉住 Score 候选里**没有** position/rank(它们是排序位次,降序排会把最相关的一条排到最后)。
func TestMetasoSpecArraysStayOrdered(t *testing.T) {
t.Parallel()
s := metasoSpec()
if len(s.Arrays) == 0 || s.Arrays[0] != "webpages" {
t.Fatalf("Arrays[0] = %v, want webpages(本地固定 scope=webpage,主候选必须与它对上)", s.Arrays)
}
for _, key := range s.Score {
if key == "position" || key == "rank" {
t.Errorf("Score 候选里有 %q:那是排序位次、不是相关性分数", key)
}
}
}
+2
View File
@@ -108,6 +108,8 @@ func providerLabels() map[string]string {
NameBocha: "博查 Bocha(bocha)",
NameVolcengine: "火山引擎·豆包搜索(volcengine)",
NameBaiduQianfan: "百度千帆 AI 搜索(baidu_qianfan)",
NameTencentWSA: "腾讯云·元宝搜索(tencent_wsa)",
NameMetaso: "秘塔搜索 Metaso(metaso)",
}
}
+4
View File
@@ -53,6 +53,10 @@ const (
NameVolcengine = "volcengine"
// NameBaiduQianfan 是百度千帆 AI 搜索。
NameBaiduQianfan = "baidu_qianfan"
// NameTencentWSA 是腾讯云·元宝搜索(联网搜索 API / WSA,服务 API KEY 方式)。
NameTencentWSA = "tencent_wsa"
// NameMetaso 是秘塔搜索 Metaso(开放平台 /api/v1/search)。
NameMetaso = "metaso"
)
// Config 是构造一家 provider 所需的一切接线(密钥/地址/客户端/响应上限/URL 守卫)。
+72 -5
View File
@@ -68,10 +68,11 @@ type providerCase struct {
checkRequest func(t *testing.T, c *captured)
}
// searchProviderCaseTable 是六家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。
// searchProviderCaseTable 是八家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。
func searchProviderCaseTable() []providerCase {
return []providerCase{
searxngCase(), zhipuCase(), aliyunCase(), bochaCase(), volcengineCase(), baiduCase(),
tencentWSACase(), metasoCase(),
}
}
@@ -231,7 +232,69 @@ func baiduCase() providerCase {
}
}
// TestProviders_FixturePerProvider 是验收 ①:六家各一条 httptest 夹具(不打真上游)。
// tencentWSACase 是腾讯云·元宝搜索的夹具(POST + Bearer;`Pages` 是 **JSON 字符串**数组)。
func tencentWSACase() providerCase {
return providerCase{
name: NameTencentWSA, newP: newTencentWSA,
method: http.MethodPost, path: tencentWSAPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
// Pages 的元素是一段 JSON 文本(官方 Array of String 形状)⇒ 二次解析才能取到字段。
fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` +
`\"passage\":\"元宝摘要\",\"content\":\"元宝正文\",\"date\":\"2025-06-01\",\"score\":0.9}"],` +
`"Query":"昔涟","Version":"standard","RequestId":"r-1"}}`,
wantTitle: "元宝结果",
wantLink: "https://t.example/1",
wantContent: "元宝正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["Query"] != "昔涟" {
t.Errorf("tencent_wsa body Query = %v", body["Query"])
}
// 只发官方确证为必选的 Query:Cnt/Industry 是尊享版门槛、Site 是域名过滤
// (design/27 §4:过滤统一在 Finalize 做)、Mode/Freshness 版本口径未确证。
for _, absent := range []string{"Cnt", "Industry", "Site", "Mode", "Freshness"} {
if _, ok := body[absent]; ok {
t.Errorf("tencent_wsa 不该发 %s(版本门槛/过滤参数,见 provider 注释)", absent)
}
}
},
}
}
// metasoCase 是秘塔搜索的夹具(POST + Bearer + Accept;`size` 是**字符串**)。
func metasoCase() providerCase {
return providerCase{
name: NameMetaso, newP: newMetaso,
method: http.MethodPost, path: metasoPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"credits":1,"webpages":[{"title":"秘塔结果","link":"https://m.example/1",` +
`"snippet":"秘塔摘要","rawContent":"秘塔正文","date":"2025-06-01","score":"0.9"}]}`,
wantTitle: "秘塔结果",
wantLink: "https://m.example/1",
wantContent: "秘塔正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["q"] != "昔涟" {
t.Errorf("metaso body q = %v", body["q"])
}
if body["scope"] != metasoScopeWebpage {
t.Errorf("metaso body scope = %v, want %s", body["scope"], metasoScopeWebpage)
}
// 官方前端发的是**字符串**("10")⇒ 这里也必须是字符串,便于与 Playground 对拍。
if body["size"] != metasoDefaultSize {
t.Errorf("metaso body size = %v (%T), want 字符串 %q",
body["size"], body["size"], metasoDefaultSize)
}
if got := c.req.Header.Get("Accept"); got != metasoAcceptHeader {
t.Errorf("metaso Accept = %q, want %q", got, metasoAcceptHeader)
}
},
}
}
// TestProviders_FixturePerProvider 是验收 ①:八家各一条 httptest 夹具(不打真上游)。
func TestProviders_FixturePerProvider(t *testing.T) {
t.Parallel()
for _, tc := range searchProviderCaseTable() {
@@ -273,7 +336,7 @@ func TestProviders_FixturePerProvider(t *testing.T) {
}
}
// TestProviders_AllNormalizeToSameResult 是验收 ②:六家输出**归一化成同一 Result**
// TestProviders_AllNormalizeToSameResult 是验收 ②:八家输出**归一化成同一 Result**
// (同一结构体、同一语义),且共用同一套后处理(Finalize)。
func TestProviders_AllNormalizeToSameResult(t *testing.T) {
t.Parallel()
@@ -289,7 +352,7 @@ func TestProviders_AllNormalizeToSameResult(t *testing.T) {
}
for i, r := range got {
if r.Link == "" {
// 六家的夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、
// 各家夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、
// 不是本判据的对象):夹具里链接丢了就说明归一化把 link 认错了。
t.Errorf("[%d] Link 为空(夹具里给了链接): %+v", i, r)
}
@@ -304,7 +367,7 @@ func TestProviders_AllNormalizeToSameResult(t *testing.T) {
}
}
// TestProviders_EmptyBodyIsEmptyNotError 钉住六家对「200 + 空数组」的处置一致:
// TestProviders_EmptyBodyIsEmptyNotError 钉住各家对「200 + 空数组」的处置一致:
// 不是错误,而是 0 条(由上层按「空结果 ≠ 成功」处置)。
func TestProviders_EmptyBodyIsEmptyNotError(t *testing.T) {
t.Parallel()
@@ -338,6 +401,10 @@ func (tc providerCase) emptyFixture() string {
return `{"webPages":{"value":[]}}`
case "volcengine":
return `{"Result":{"WebResults":[]}}`
case NameTencentWSA:
return `{"Response":{"Pages":[],"Query":"q","RequestId":"r"}}`
case NameMetaso:
return `{"webpages":[],"credits":0}`
default:
return `{"references":[]}`
}
+5
View File
@@ -85,6 +85,11 @@ func definitions() []definition {
{name: NameBocha, needKey: true, defaultBaseURL: "https://api.bocha.cn", new: newBocha},
{name: NameVolcengine, needKey: true, defaultBaseURL: "https://open.feedcoopapi.com", new: newVolcengine},
{name: NameBaiduQianfan, needKey: true, defaultBaseURL: "https://qianfan.baidubce.com", new: newBaiduQianfan},
// 下面两家**没有对应的 `.env` 字段**(`.env` 已冻结):它们的 key / 基地址只从
// `$DATA_DIR/config/plugins/web-search.json` 取(组合根按本表逐家投影,故组合根零改动)。
// 默认基地址取官方文档给出的服务域名。
{name: NameTencentWSA, needKey: true, defaultBaseURL: "https://api.wsa.cloud.tencent.com", new: newTencentWSA},
{name: NameMetaso, needKey: true, defaultBaseURL: "https://metaso.cn", new: newMetaso},
}
}
+10 -5
View File
@@ -14,6 +14,7 @@ func TestRegistry_KnownAndKnownNames(t *testing.T) {
t.Parallel()
for _, name := range []string{
NameSearXNG, NameZhipu, NameAliyunIQS, NameBocha, NameVolcengine, NameBaiduQianfan,
NameTencentWSA, NameMetaso,
"Zhipu", " bocha ",
} {
if !Known(name) {
@@ -26,8 +27,8 @@ func TestRegistry_KnownAndKnownNames(t *testing.T) {
}
}
names := KnownNames()
if len(names) != 6 {
t.Fatalf("KnownNames() = %v, want 六家", names)
if len(names) != 8 {
t.Fatalf("KnownNames() = %v, want 八家", names)
}
}
@@ -103,9 +104,10 @@ func TestRegistry_EmptyAndNilSafe(t *testing.T) {
}
}
// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**逐字等于
// `.env` 的标签默认值(`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的
// 基础键)。
// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**:
// 「有 `.env` 字段的家」那几个值**逐字等于** `.env` 的标签默认值
// (`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的基础键);
// 没有 `.env` 字段的家(tencent_wsa / metaso)只在这里给出唯一出处。
//
// 为什么值得一条逐字表:接入位表是本片新增的「这家没配 baseUrl ⇒ 回落哪个地址」的
// **唯一出处**,而 `.env` 侧那几个键仍然存在、仍然参与播种。两处一旦漂移,同一家 provider
@@ -118,6 +120,9 @@ func TestRegistry_DefaultBaseURLMatchesEnvDefaults(t *testing.T) {
NameBocha: "https://api.bocha.cn",
NameVolcengine: "https://open.feedcoopapi.com",
NameBaiduQianfan: "https://qianfan.baidubce.com",
// 没有 `.env` 字段的两家:默认地址只来自本表(登记在 registry.go 的接入位表里)。
NameTencentWSA: "https://api.wsa.cloud.tencent.com",
NameMetaso: "https://metaso.cn",
}
for name, base := range want {
if got := DefaultBaseURL(name); got != base {
+219
View File
@@ -0,0 +1,219 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// tencentWSAProfile 是腾讯云·元宝搜索(联网搜索 API / WSA,design/27 §2.1;官方原文见
// DevDocs/reference/search-providers/tencent-wsa/)。
//
// 走的是**服务 API KEY 方式**(不是 TC3 签名那条路,那条需要 AK/SK 派生签名):
//
// POST https://api.wsa.cloud.tencent.com/SearchPro
// Authorization: Bearer <API KEY> ← 零签名,标准库即可 ⇒ 不破「零新依赖」
//
// 🔴 两条必须照抄的官方口径(都对本实现有决定性影响):
//
// - **HTTP 状态码恒为 200**:官方 `api-response-format.md` 原文「目前只要请求被服务端
// 正常处理了,响应的 HTTP 状态码均为200」;本机无 key 实测逐字复现(分册
// `live-probe-2026-10-07.md`:缺 Authorization 也是 200 + `Response.Error`)。
// ⇒ **不能靠状态码判成败**,必须读响应体的 `Response.Error.Code`(见 tencentResponseError)。
// - **`Pages` 是「JSON 字符串」数组**(官方类型:`Array of String`,每个元素是一段 JSON
// 文本)⇒ 必须**二次解析**才能拿到 title/url/passage/…。共享提取器 `extractResults` 只能
// 吃「对象数组」,因此本家自带解析(见 tencentParse),但字段挑选与「Content 空 ⇒ 回落
// Snippet」的既有口径仍复用包内同一套函数。
//
// ⚠️ 诚实边界(**没有 API KEY ⇒ 成功响应未实测**,只有官方示例可依):
//
// - 请求只发官方确证为「必选」的 `Query`。其余字段各有明确理由不发:
// `Site` 是域名过滤(design/27 §4:过滤统一在 Finalize 做,provider 不许自己过滤);
// `Cnt`/`Industry` 官方标「仅限尊享版使用」(发上去会让轻量/标准版直接报参数错);
// `Mode` 与 `Freshness`/`FromTime`/`ToTime` 的版本门槛在两页官方文档里**口径不一致**
// (README「已知未确证」)⇒ 一律不发,条数交给本地截断。
// - `content`(动态摘要)是**尊享版字段**(官方原文:「极速版不返回该字段」);拿不到时
// 由包内既有单向回落把 `passage`(标准摘要)填进 Content。
type tencentWSA struct{ cfg Config }
// tencentWSAPath 是服务 API KEY 方式的端点路径(官方原文:接口路径(URI):/SearchPro)。
const tencentWSAPath = "/SearchPro"
// tencentResponseErrorPath 是上游错误信封的位置(官方原文:Error 字段连同其内部的 Code 与
// Message 在调用失败时**必定**返回)。
const tencentResponseErrorPath = "Response.Error"
// tencentPagesPath 是结果数组的路径(官方原文:`Pages`,Array of String)。
const tencentPagesPath = "Response.Pages"
// newTencentWSA 造一家腾讯云·元宝搜索 provider。
func newTencentWSA(cfg Config) Provider { return &tencentWSA{cfg: cfg} }
// Name 实现 Provider。
func (p *tencentWSA) Name() string { return NameTencentWSA }
// Search 实现 Provider。
func (p *tencentWSA) Search(ctx context.Context, q Query) ([]Result, error) {
body, err := json.Marshal(map[string]any{"Query": q.Text})
if err != nil {
return nil, fmt.Errorf("search: tencent_wsa: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, tencentWSAPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return tencentParse(data)
}
// tencentParse 把上游报文归一化成 []Result。
//
// 顺序是「先判错、再取值」:这一家**错误也走 HTTP 200**,先解 `Response.Error` 才不会把
// 一次失败读成「上游正常应答但零结果」(那会是一句谎报)。
func tencentParse(data []byte) ([]Result, error) {
var root any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("%w: %w", ErrBadResponse, err)
}
if err := tencentResponseError(root); err != nil {
return nil, err
}
raw, ok := resolvePath(root, tencentPagesPath)
if !ok {
return nil, fmt.Errorf("%w: tencent_wsa: no %s", ErrBadResponse, tencentPagesPath)
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%w: tencent_wsa: %s is not an array", ErrBadResponse, tencentPagesPath)
}
s := tencentPageSpec()
out := make([]Result, 0, len(items))
for _, item := range items {
obj, ok := tencentPageObject(item)
if !ok {
// 单条脏数据(解析不出来的页元素)跳过:不毁掉整次检索。
continue
}
out = append(out, tencentResult(obj, s))
}
for i := range out {
// 包内既有口径(与 extractResults 同一条):正文缺了就回落摘要,别让 Content 空着。
if out[i].Content == "" {
out[i].Content = out[i].Snippet
}
}
return out, nil
}
// tencentPageObject 把一个 `Pages` 元素还原成对象。
//
// 官方形状是**一段 JSON 文本**(`Array of String`)⇒ 这里做二次解析;也容忍它已经是对象
// 的实现(网关/上游版本差异),两种都认不出来就跳过这一条。
func tencentPageObject(raw any) (map[string]any, bool) {
switch v := raw.(type) {
case map[string]any:
return v, true
case string:
var obj map[string]any
if err := json.Unmarshal([]byte(v), &obj); err != nil {
return nil, false
}
return obj, true
default:
return nil, false
}
}
// tencentPageSpec 是 `Pages` 元素内层对象的字段(官方原文逐字确证,两条接入路线同构):
//
// title(结果标题)/ url(源 URL)/ passage(标准摘要)/ content(动态摘要,尊享版字段)/
// date(内容发布时间)/ score(相关性得分 0~1,越接近 1 越相关)
//
// 刻意**不投影** `site`(网站名称)/ `favicon` / `pics` / `deeplinks` / `authority_level`:
// `Result` 没有对应的位,而把 `site` 塞进 `Snippet`(正文摘录位)会让「摘要」变成站点名
// —— 与 zhipuSpec 的同一条理由(往语义位塞另一类事实)。
func tencentPageSpec() spec {
return spec{
Title: []string{"title"},
Link: []string{"url"},
Snippet: []string{"passage"},
Content: []string{"content"},
Published: []string{"date"},
Score: []string{"score"},
}
}
// tencentResult 按 tencentPageSpec 从一条 Pages 对象里取值(复用包内统一的挑选器)。
func tencentResult(obj map[string]any, s spec) Result {
return Result{
Title: pickString(obj, s.Title),
Link: pickString(obj, s.Link),
Snippet: pickString(obj, s.Snippet),
Content: pickString(obj, s.Content),
Published: pickString(obj, s.Published),
Score: pickFloat(obj, s.Score),
}
}
// tencentResponseError 读 `Response.Error`:返回 nil 表示这次调用成功。
//
// 它是这一家判失败的**唯一**途径(HTTP 恒 200,见文件头「两条必须照抄的官方口径」)。
// 官方原文:Error 字段一出现就代表调用失败,且调用失败时 Code 与 Message 必定返回。
func tencentResponseError(root any) error {
if _, ok := resolvePath(root, tencentResponseErrorPath); !ok {
return nil
}
code := ""
if v, ok := resolvePath(root, tencentResponseErrorPath+".Code"); ok {
code = scalarString(v)
}
return tencentStatusError(code)
}
// tencentStatusError 把上游错误码包成本仓哨兵错误。
//
// ⚠️ 只带 **code**、**绝不带上游 Message**:Message 是任意上游文本,某些上游会把请求
// URL/凭据回显进去,而这段文案会被工具层记进日志(与 errors.go 的 classifyStatus 同口径)。
// 取不到码(Error 在但 Code 缺)时按 upstream 归类,仍然**不会当成空结果**。
func tencentStatusError(code string) error {
sentinel := tencentCodeClass(code)
if c := strings.TrimSpace(code); c != "" {
return fmt.Errorf("%w (code=%s)", sentinel, c)
}
return sentinel
}
// tencentCodeClass 把腾讯云侧的错误码归成本仓哨兵(大小写不敏感,前缀匹配点分码)。
//
// 依据官方错误码表(`api-error-codes.md` 公共码 + `api-searchpro-apikey.md` 产品码):
//
// UnauthorizedOperation / AuthFailure.* ⇒ 凭据问题。官方表把 UnauthorizedOperation 解释为
// 「请检查服务密钥 API KEY 是否正确」;**无 key 与
// key 无效同码**(本机实测),因此只能归成一类。
// ResourceUnavailable ⇒ 欠费(官方表:「请检查账号是否欠费」)。
// ResourceNotFound ⇒ 用户资源未开通(官方表:「请联系主账号开通服务」)。
// RequestLimitExceeded[.*] ⇒ 频率超限。
// LimitExceeded ⇒ 超过配额限制(与「频率」不同,归额度类)。
// 其余(含取不到码) ⇒ upstream(不可进一步分类,降级链照常前进一格)。
//
// 这张表**只在本家使用**:腾讯云的错误码随 HTTP 200 返回,errors.go 的 classifyStatus
// (只处理非 2xx)根本看不到它们,把点分大写码混进那张所有家共用的数字码表只会制造碰撞。
func tencentCodeClass(code string) error {
c := strings.ToLower(strings.TrimSpace(code))
switch {
case strings.HasPrefix(c, "unauthorizedoperation"), strings.HasPrefix(c, "authfailure"):
return ErrUnauthorized
case c == "resourceunavailable", c == "resourcenotfound", c == "limitexceeded":
return ErrQuotaExceeded
case strings.HasPrefix(c, "requestlimitexceeded"):
return ErrRateLimited
default:
return ErrUpstream
}
}
+247
View File
@@ -0,0 +1,247 @@
package search
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
// 本文件钉住腾讯云·元宝搜索 provider 的**请求面**、**Pages 二次解析**与**错误映射**
// (依据:DevDocs/reference/search-providers/tencent-wsa 的官方原文 + 无 key 本机实测
// `live-probe-2026-10-07.md`)。
//
// 两条本家特有的口径,各有用例守着:
//
// - `Pages` 是 **JSON 字符串数组**(官方类型 `Array of String`)⇒ 必须二次解析;
// - **HTTP 状态码恒为 200**,失败信息在 `Response.Error.Code` ⇒ 判错只能读响应体
// (用例里那个假上游**确实回的是 200**,这就是「读响应体」这条路径的证据)。
//
// 全部打 httptest,**不打真实网络**(无 API KEY ⇒ 成功响应真机未实测)。
// tencentWSAOK 是官方示例形状的应答(`api-searchpro-apikey.md` 输出示例逐字段照抄):
// `Pages` 的元素是**一段 JSON 文本**,不是对象。
const tencentWSAOK = `{
"Response": {
"Pages": [
"{\"passage\":\"三星堆名字的由来 三星堆遗址位于四川省广汉市西北部\",\"score\":0.897802,\"date\":\"2024-06-07 19:00:51\",\"title\":\"三星堆名字的由来\",\"url\":\"https://www.163.com/dy/article/J43Q2STU0553FV3Z.html\",\"site\":\"网易\",\"images\":[],\"favicon\":\"http://img01.sogoucdn.com/app/a/200913/x.png\"}",
"{\"passage\":\"一、地理环境因素\",\"score\":0.8887853,\"date\":\"2024-09-03 12:59:00\",\"title\":\"三星堆名字的由来_腾讯新闻\",\"url\":\"https://news.qq.com/rain/a/20240903A048N700\",\"site\":\"腾讯网\"}"
],
"Query": "三星堆的由来",
"Version": "standard",
"RequestId": "6f8df221-9a68-4ea2-90d6-a9590cae244c"
}
}`
// TestTencentWSARequestShape 钉住请求面:POST `/SearchPro` + Bearer,请求体只带官方必选的
// `Query`(版本门槛参数与域名过滤参数一律不发 —— 理由见 provider 文件头)。
func TestTencentWSARequestShape(t *testing.T) {
t.Parallel()
srv, cap := fixtureServer(t, tencentWSAOK)
if _, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "昔涟", Count: 20}); err != nil {
t.Fatalf("Search: %v", err)
}
if cap.req == nil {
t.Fatal("没发出任何请求")
}
if cap.req.Method != http.MethodPost {
t.Errorf("method = %q, want POST", cap.req.Method)
}
if cap.req.URL.Path != tencentWSAPath {
t.Errorf("path = %q, want %q", cap.req.URL.Path, tencentWSAPath)
}
if got := cap.req.Header.Get("Authorization"); got != "Bearer "+testKey {
t.Errorf("Authorization = %q, want Bearer <key>", got)
}
var body map[string]any
mustUnmarshal(t, cap.body, &body)
if body["Query"] != "昔涟" {
t.Errorf("body Query = %v, want 昔涟", body["Query"])
}
if len(body) != 1 {
t.Errorf("body = %v,只许发官方必选的 Query(Cnt 是尊享版门槛、Site 是域名过滤)", body)
}
}
// TestTencentWSAPagesAreJSONStrings 是验收 ①:`Pages` 的**二次解析**。官方把每条结果编码成
// 一段 JSON 字符串 ⇒ 拿对象数组直接喂共享提取器会得到 ErrBadResponse;本家的解析能吃下它,
// 并把 title/url/passage/date/score 归一化到位。
func TestTencentWSAPagesAreJSONStrings(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tencentWSAOK)
got, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "三星堆的由来"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("结果条数 = %d, want 2(Pages 里两条 JSON 文本): %+v", len(got), got)
}
first := got[0]
if first.Title != "三星堆名字的由来" {
t.Errorf("Title = %q", first.Title)
}
if first.Link != "https://www.163.com/dy/article/J43Q2STU0553FV3Z.html" {
t.Errorf("Link = %q", first.Link)
}
if !strings.HasPrefix(first.Snippet, "三星堆名字的由来 三星堆遗址") {
t.Errorf("Snippet = %q(官方 passage = 标准摘要)", first.Snippet)
}
if first.Content == "" {
t.Error("Content 为空:官方这条没有 content(动态摘要)⇒ 包内既有口径应回落 passage")
}
if first.Published != "2024-06-07 19:00:51" {
t.Errorf("Published = %q, want 官方 date 原样", first.Published)
}
if first.Score <= 0 {
t.Errorf("Score = %v, want 0.897802(官方 score 0~1)", first.Score)
}
// site(网站名称)不许被投影进 Snippet:那是「正文摘录」的语义位(同 zhipuSpec 的理由)。
if strings.Contains(first.Snippet, "网易") {
t.Errorf("Snippet = %q 里出现了站点名「网易」(官方 site 字段)—— 站点名不进 Snippet", first.Snippet)
}
}
// TestTencentWSAContentFallsBackToPassage 钉住「尊享版的 content 拿不到时不空着」:
// 官方 `content` 是尊享版字段(极速版不返回)⇒ 缺失时 Content 回落标准摘要 passage。
func TestTencentWSAContentFallsBackToPassage(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, `{"Response":{"Pages":["{\"title\":\"t\",\"url\":\"https://x/1\",\"passage\":\"标准摘要\"}"]}}`)
got, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("条数 = %d, want 1", len(got))
}
if got[0].Content != "标准摘要" {
t.Errorf("Content = %q, want 回落 passage(官方 content 是尊享版字段,可能缺)", got[0].Content)
}
}
// TestTencentWSADirtyPageElementIsSkipped 钉住容错:解析不出来的页元素(不是 JSON、也不是
// 对象)跳过,不毁掉整次检索。
func TestTencentWSADirtyPageElementIsSkipped(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, `{"Response":{"Pages":["not-json",{"title":"对象形态也认"},"{\"title\":\"正常\"}"]}}`)
got, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("单条脏数据不该让整次检索报错: %v", err)
}
if len(got) != 2 {
t.Fatalf("条数 = %d, want 2(坏的那条应被跳过): %+v", len(got), got)
}
}
// TestTencentWSAErrorsOnHTTP200 是验收 ②:**HTTP 200 + `Response.Error`** 这条路径
// (假上游回的确实就是 200 —— 它的 handler 从不写状态码)。三个官方码各一条,
// 且分类要一路走到探针词汇(面板「测试连通」据此说话)。
func TestTencentWSAErrorsOnHTTP200(t *testing.T) {
t.Parallel()
cases := []struct {
name string
code string
wantSent error
wantCode string
}{
{"key 无效 / 无 key(同码)", "UnauthorizedOperation", ErrUnauthorized, ProbeUnauthorized},
{"欠费", "ResourceUnavailable", ErrQuotaExceeded, ProbeQuotaExceeded},
{"频率超限", "RequestLimitExceeded", ErrRateLimited, ProbeRateLimited},
{"点分限流码(前缀匹配)", "RequestLimitExceeded.UinLimitExceeded", ErrRateLimited, ProbeRateLimited},
{"未开通服务", "ResourceNotFound", ErrQuotaExceeded, ProbeQuotaExceeded},
{"超出配额", "LimitExceeded", ErrQuotaExceeded, ProbeQuotaExceeded},
{"鉴权前缀码", "AuthFailure.SignatureFailure", ErrUnauthorized, ProbeUnauthorized},
{"表里没有的码 ⇒ upstream", "InternalError", ErrUpstream, ProbeUpstream},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t,
`{"Response":{"Error":{"Code":"`+tc.code+`","Message":"`+testKey+` 不应进文案"},"RequestId":"r"}}`)
got, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err == nil {
t.Fatalf("HTTP 200 带 Response.Error 必须报失败,却拿到 %d 条结果", len(got))
}
if !errors.Is(err, tc.wantSent) {
t.Errorf("errors.Is(err, %v) = false: %v", tc.wantSent, err)
}
if code := ProbeErrorCode(err); code != tc.wantCode {
t.Errorf("探针错误码 = %q, want %q", code, tc.wantCode)
}
if !strings.Contains(err.Error(), tc.code) {
t.Errorf("错误文案没带上原始业务码 %q(运维没法从日志定位): %v", tc.code, err)
}
// 上游 Message 是任意文本(可能回显凭据)⇒ 不许进错误文案。
if strings.Contains(err.Error(), testKey) {
t.Fatalf("错误文案带上了上游 Message / 凭据: %v", err)
}
})
}
}
// TestTencentWSAEmptyPagesIsNotAnError 是验收 ③:`Pages: []` 是「上游正常应答但零结果」,
// **不是错误**(由上层按「空结果 ≠ 成功」处置),也不是结构错。
func TestTencentWSAEmptyPagesIsNotAnError(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, `{"Response":{"Pages":[],"Query":"q","RequestId":"r"}}`)
got, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("空 Pages 不是错误: %v", err)
}
if len(got) != 0 {
t.Fatalf("条数 = %d, want 0", len(got))
}
}
// TestTencentWSAMissingPagesIsBadResponse 钉住「空」与「解析不了」分开:连 `Pages` 都没有
// (响应形状变了)⇒ ErrBadResponse,而不是静默当成 0 条。
func TestTencentWSAMissingPagesIsBadResponse(t *testing.T) {
t.Parallel()
for _, body := range []string{`{"Response":{"Query":"q"}}`, `{"Response":{"Pages":"不是数组"}}`, `not json`, `{}`} {
srv, _ := fixtureServer(t, body)
_, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(),
}).Search(context.Background(), Query{Text: "q"})
if !errors.Is(err, ErrBadResponse) {
t.Errorf("输入 %q 应报 ErrBadResponse, got %v", body, err)
}
}
}
// TestTencentWSAOverLimitResponseRejected 是验收 ④:响应体超过本仓读取上限 ⇒
// ErrResponseTooLarge(**不静默截断成半截 JSON**)。
func TestTencentWSAOverLimitResponseRejected(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tencentWSAOK)
_, err := newTencentWSA(Config{
APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 16,
}).Search(context.Background(), Query{Text: "q"})
if !errors.Is(err, ErrResponseTooLarge) {
t.Fatalf("err = %v, want ErrResponseTooLarge", err)
}
}
// TestTencentWSACodeClassIsLocal 钉住这张码表**只服务本家**:既有的公共码表
// (errors.go 的 upstreamCodeClass,被所有家共用)不许认腾讯云的大写点分码 —— 认了会让
// 「按码值碰运气」的隐患跨家扩散。
func TestTencentWSACodeClassIsLocal(t *testing.T) {
t.Parallel()
for _, code := range []string{"UnauthorizedOperation", "ResourceUnavailable", "RequestLimitExceeded"} {
if _, ok := upstreamCodeClass(code); ok {
t.Errorf("upstreamCodeClass(%q) 不该认识腾讯云的业务码(它随 HTTP 200 返回,只在本家映射)", code)
}
}
}