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

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