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

+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)
}
}
}