Files
Cyrene_v2/plugins/tools/io/search/metaso_test.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
2026-10-07 13:42:41 +08:00

312 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}