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

+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":[]}`
}