接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
312 lines
13 KiB
Go
312 lines
13 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|