From ce9af42945b2a6f86951823039a000fae3a68c93 Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Wed, 7 Oct 2026 13:42:41 +0800
Subject: [PATCH] =?UTF-8?q?feat(search):=20=E6=96=B0=E5=A2=9E=E8=85=BE?=
=?UTF-8?q?=E8=AE=AF=E4=BA=91=C2=B7=E5=85=83=E5=AE=9D=E6=90=9C=E7=B4=A2?=
=?UTF-8?q?=E4=B8=8E=E7=A7=98=E5=A1=94=E6=90=9C=E7=B4=A2=E4=B8=A4=E5=AE=B6?=
=?UTF-8?q?=20provider?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
---
Docs/api-client.md | 19 +-
cmd/cyrene/websearch_new_providers_test.go | 91 ++++++
cmd/cyrene/websearch_projection_test.go | 15 +-
cmd/cyrene/websearch_providers.go | 8 +-
cmd/cyrene/wire_web_search_panel_test.go | 14 +-
cmd/cyrene/wire_web_search_providers_test.go | 10 +-
internal/config/runtimeconfig/websearch.go | 4 +-
internal/config/websearch.go | 7 +-
plugins/tools/io/doc.go | 3 +-
plugins/tools/io/search/errors.go | 11 +-
plugins/tools/io/search/extract.go | 6 +-
plugins/tools/io/search/http.go | 2 +-
plugins/tools/io/search/metaso.go | 185 +++++++++++
plugins/tools/io/search/metaso_test.go | 311 +++++++++++++++++++
plugins/tools/io/search/paneldata.go | 2 +
plugins/tools/io/search/provider.go | 4 +
plugins/tools/io/search/providers_test.go | 77 ++++-
plugins/tools/io/search/registry.go | 5 +
plugins/tools/io/search/registry_test.go | 15 +-
plugins/tools/io/search/tencent_wsa.go | 219 +++++++++++++
plugins/tools/io/search/tencent_wsa_test.go | 247 +++++++++++++++
21 files changed, 1219 insertions(+), 36 deletions(-)
create mode 100644 cmd/cyrene/websearch_new_providers_test.go
create mode 100644 plugins/tools/io/search/metaso.go
create mode 100644 plugins/tools/io/search/metaso_test.go
create mode 100644 plugins/tools/io/search/tencent_wsa.go
create mode 100644 plugins/tools/io/search/tencent_wsa_test.go
diff --git a/Docs/api-client.md b/Docs/api-client.md
index 5e6ac8b..83d305e 100644
--- a/Docs/api-client.md
+++ b/Docs/api-client.md
@@ -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" \
**「显示名 + 段落标签硬约束」版 —— 一处形状变更(插件动作响应)+ 一处新增产出侧硬约束 + 一条模型面登记**(依据:主人报障「下拉里全给英文名,你甚至不愿意带个括号把中文名写上去」;安卓端登记 `
正文
` 畸形;`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` 索引显示、查不到回落原值。
- **② 新增产出侧硬约束:助手文本不得出现未配对的段落标签**(落单 `` / `
`)。见 **§4.16**。修的是安卓登记的 `正文
`;**两半根因都在产出侧**(规整器原样放过 + 在「末段漏 ``」时亲手制造),另修一个幂等漏洞。**历史行不回填**、消费方**仍应保留**容错。
- **③ 模型面登记(非客户端契约面)**:`web-fetch` 的浏览器渲染降级档已接线,返回体用 `fetchedBy`(`direct` / `browser`)**如实标注**正文实际来自哪条路;两条档的失败各自留痕,**绝不静默替换**,被 SSRF 拒绝的目标**不换路**。
- **④ provider 配置面「去 env 化」(对客户端零形状影响,登记供跨端对照)**:新增 provider 不再需要新的 `.env` 键 —— 面板文件 `$DATA_DIR/config/plugins/web-search.json` **是权威**,`.env` 只在文件缺失/为空时**播种**(文件一旦存在,「文件里没有的家」**不从 env 复活** —— 这是既有回归用例钉住的口径,本次未改);各家「没配 baseUrl 就回落代码默认值」的默认值表移进接入位表(`search.Descriptor.DefaultBaseURL`),**逐字未变**。
diff --git a/cmd/cyrene/websearch_new_providers_test.go b/cmd/cyrene/websearch_new_providers_test.go
new file mode 100644
index 0000000..9f14841
--- /dev/null
+++ b/cmd/cyrene/websearch_new_providers_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/cmd/cyrene/websearch_projection_test.go b/cmd/cyrene/websearch_projection_test.go
index 75fba0f..a76f51f 100644
--- a/cmd/cyrene/websearch_projection_test.go
+++ b/cmd/cyrene/websearch_projection_test.go
@@ -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)
}
diff --git a/cmd/cyrene/websearch_providers.go b/cmd/cyrene/websearch_providers.go
index babdb6b..57ce1cb 100644
--- a/cmd/cyrene/websearch_providers.go
+++ b/cmd/cyrene/websearch_providers.go
@@ -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 {
diff --git a/cmd/cyrene/wire_web_search_panel_test.go b/cmd/cyrene/wire_web_search_panel_test.go
index ff8042c..fef2db3 100644
--- a/cmd/cyrene/wire_web_search_panel_test.go
+++ b/cmd/cyrene/wire_web_search_panel_test.go
@@ -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 {
diff --git a/cmd/cyrene/wire_web_search_providers_test.go b/cmd/cyrene/wire_web_search_providers_test.go
index 30bebe1..c6ad4b3 100644
--- a/cmd/cyrene/wire_web_search_providers_test.go
+++ b/cmd/cyrene/wire_web_search_providers_test.go
@@ -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 {
diff --git a/internal/config/runtimeconfig/websearch.go b/internal/config/runtimeconfig/websearch.go
index 289d42e..72ce21b 100644
--- a/internal/config/runtimeconfig/websearch.go
+++ b/internal/config/runtimeconfig/websearch.go
@@ -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)。
diff --git a/internal/config/websearch.go b/internal/config/websearch.go
index ac6f158..5421005 100644
--- a/internal/config/websearch.go
+++ b/internal/config/websearch.go
@@ -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/端点)的名字
diff --git a/plugins/tools/io/doc.go b/plugins/tools/io/doc.go
index daa6c67..32e9087 100644
--- a/plugins/tools/io/doc.go
+++ b/plugins/tools/io/doc.go
@@ -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
diff --git a/plugins/tools/io/search/errors.go b/plugins/tools/io/search/errors.go
index 5c21286..61ffd07 100644
--- a/plugins/tools/io/search/errors.go
+++ b/plugins/tools/io/search/errors.go
@@ -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":
diff --git a/plugins/tools/io/search/extract.go b/plugins/tools/io/search/extract.go
index 44f359e..0d147a6 100644
--- a/plugins/tools/io/search/extract.go
+++ b/plugins/tools/io/search/extract.go
@@ -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"}。
diff --git a/plugins/tools/io/search/http.go b/plugins/tools/io/search/http.go
index 3424c63..924a951 100644
--- a/plugins/tools/io/search/http.go
+++ b/plugins/tools/io/search/http.go
@@ -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
diff --git a/plugins/tools/io/search/metaso.go b/plugins/tools/io/search/metaso.go
new file mode 100644
index 0000000..5189054
--- /dev/null
+++ b/plugins/tools/io/search/metaso.go
@@ -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
+//
+// 请求字段取**官方前端产物**里确证的那几个(`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"},
+ }
+}
diff --git a/plugins/tools/io/search/metaso_test.go b/plugins/tools/io/search/metaso_test.go
new file mode 100644
index 0000000..eb7eb54
--- /dev/null
+++ b/plugins/tools/io/search/metaso_test.go
@@ -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 ", 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)
+ }
+ }
+}
diff --git a/plugins/tools/io/search/paneldata.go b/plugins/tools/io/search/paneldata.go
index 481db1c..c29ffe4 100644
--- a/plugins/tools/io/search/paneldata.go
+++ b/plugins/tools/io/search/paneldata.go
@@ -108,6 +108,8 @@ func providerLabels() map[string]string {
NameBocha: "博查 Bocha(bocha)",
NameVolcengine: "火山引擎·豆包搜索(volcengine)",
NameBaiduQianfan: "百度千帆 AI 搜索(baidu_qianfan)",
+ NameTencentWSA: "腾讯云·元宝搜索(tencent_wsa)",
+ NameMetaso: "秘塔搜索 Metaso(metaso)",
}
}
diff --git a/plugins/tools/io/search/provider.go b/plugins/tools/io/search/provider.go
index 644734c..0b2ce3d 100644
--- a/plugins/tools/io/search/provider.go
+++ b/plugins/tools/io/search/provider.go
@@ -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 守卫)。
diff --git a/plugins/tools/io/search/providers_test.go b/plugins/tools/io/search/providers_test.go
index bb3b87d..edcd945 100644
--- a/plugins/tools/io/search/providers_test.go
+++ b/plugins/tools/io/search/providers_test.go
@@ -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":[]}`
}
diff --git a/plugins/tools/io/search/registry.go b/plugins/tools/io/search/registry.go
index c015701..32ace26 100644
--- a/plugins/tools/io/search/registry.go
+++ b/plugins/tools/io/search/registry.go
@@ -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},
}
}
diff --git a/plugins/tools/io/search/registry_test.go b/plugins/tools/io/search/registry_test.go
index 6b14577..62a7b21 100644
--- a/plugins/tools/io/search/registry_test.go
+++ b/plugins/tools/io/search/registry_test.go
@@ -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 {
diff --git a/plugins/tools/io/search/tencent_wsa.go b/plugins/tools/io/search/tencent_wsa.go
new file mode 100644
index 0000000..aec118d
--- /dev/null
+++ b/plugins/tools/io/search/tencent_wsa.go
@@ -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 ← 零签名,标准库即可 ⇒ 不破「零新依赖」
+//
+// 🔴 两条必须照抄的官方口径(都对本实现有决定性影响):
+//
+// - **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
+ }
+}
diff --git a/plugins/tools/io/search/tencent_wsa_test.go b/plugins/tools/io/search/tencent_wsa_test.go
new file mode 100644
index 0000000..1625a9d
--- /dev/null
+++ b/plugins/tools/io/search/tencent_wsa_test.go
@@ -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 ", 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)
+ }
+ }
+}