Files
Cyrene_v2/plugins/tools/io/search/providers_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

457 lines
17 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"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// testKey 是夹具里用的假密钥。它只出现在**请求头**里:任何日志/错误/响应里出现它
// 都是缺陷(密钥不泄漏,design/27 §1.5)。
const testKey = "test-api-key-do-not-log"
// mustUnmarshal 解一份 JSON 到 out,失败即 Fatal。
func mustUnmarshal(t *testing.T, raw string, out any) {
t.Helper()
if err := json.Unmarshal([]byte(raw), out); err != nil {
t.Fatalf("unmarshal %q: %v", raw, err)
}
}
// fixtureServer 造一个「记录请求 + 回固定 JSON」的测试服务器。
func fixtureServer(t *testing.T, fixture string) (*httptest.Server, *captured) {
t.Helper()
c := &captured{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
c.set(r, raw)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fixture))
}))
t.Cleanup(srv.Close)
return srv, c
}
// captured 记录最后一次收到的请求(测试同步:handler 先于 Search 返回执行完)。
type captured struct {
req *http.Request
body string
}
func (c *captured) set(r *http.Request, body []byte) {
// 只留断言需要的部分:URL/Method/Header(不含 Body 读句柄)。
clone := r.Clone(r.Context())
c.req = clone
c.body = string(body)
}
// providerCase 是「一家 provider 的 httptest 夹具」:断言请求形态 + 响应归一化。
type providerCase struct {
name string
newP func(Config) Provider
// method / path 是期望的请求形态。
method string
path string
// authHeader / authValue 是期望的认证头(authHeader 为空 = 该家不用 key,如 searxng)。
authHeader string
authValue string
// fixture 是上游返回的 JSON;want* 是归一化后应得到的第一条结果。
fixture string
wantTitle string
wantLink string
wantContent string
// checkRequest 做该家特有的断言(请求体字段等)。
checkRequest func(t *testing.T, c *captured)
}
// searchProviderCaseTable 是八家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。
func searchProviderCaseTable() []providerCase {
return []providerCase{
searxngCase(), zhipuCase(), aliyunCase(), bochaCase(), volcengineCase(), baiduCase(),
tencentWSACase(), metasoCase(),
}
}
// searxngCase 是 searxng 的夹具(GET + q/format=json,无认证头)。
func searxngCase() providerCase {
return providerCase{
name: "searxng", newP: newSearXNG,
// searxng 的基地址就是端点本身(无固定路径)⇒ 请求落在 "/" 上,
// 查询串才是它的语义(见 checkRequest)。带路径的端点由 io 包既有测试钉住。
method: http.MethodGet, path: "/",
fixture: `{"results":[{"title":"SearX 结果","url":"https://s.example/1","content":"正文","score":0.5}]}`,
wantTitle: "SearX 结果",
wantLink: "https://s.example/1",
wantContent: "正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
if got := c.req.URL.Query().Get("q"); got != "昔涟" {
t.Errorf("searxng q = %q, want 昔涟", got)
}
if got := c.req.URL.Query().Get("format"); got != "json" {
t.Errorf("searxng format = %q, want json", got)
}
if c.req.Header.Get("Authorization") != "" {
t.Errorf("searxng 不该带认证头: %q", c.req.Header.Get("Authorization"))
}
},
}
}
// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query/search_intent/content_size)。
func zhipuCase() providerCase {
return providerCase{
name: "zhipu", newP: newZhipu,
method: http.MethodPost, path: zhipuPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"search_result":[{"title":"智谱结果","link":"https://z.example/1","content":"智谱正文"}]}`,
wantTitle: "智谱结果",
wantLink: "https://z.example/1",
wantContent: "智谱正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
// 只发官方文档确证过的字段:search_engine / search_query / search_intent / content_size,
// 外加**条件透传**的 count(仅当 Count 落在官方区间 1–50;本夹具 Query 未给 Count ⇒ 不发)。
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["search_query"] != "昔涟" {
t.Errorf("zhipu body search_query = %v", body["search_query"])
}
if body["search_engine"] != zhipuEngineStd {
t.Errorf("zhipu body search_engine = %v, want %s", body["search_engine"], zhipuEngineStd)
}
if body["search_intent"] != false {
t.Errorf("zhipu body search_intent = %v, want false(官方标 required)", body["search_intent"])
}
if body["content_size"] != zhipuContentSize {
t.Errorf("zhipu body content_size = %v, want %s", body["content_size"], zhipuContentSize)
}
},
}
}
// aliyunCase 是阿里云 IQS 的夹具(GET + X-API-Key + query 查询串)。
func aliyunCase() providerCase {
return providerCase{
name: "aliyun_iqs", newP: newAliyunIQS,
method: http.MethodGet, path: aliyunIQSPath, authHeader: "X-API-Key", authValue: testKey,
fixture: `{"pageItems":[{"title":"IQS 结果","link":"https://a.example/1","snippet":"摘要",` +
`"mainText":"IQS 正文","publishTime":1700000000000,"score":0.9}]}`,
wantTitle: "IQS 结果",
wantLink: "https://a.example/1",
wantContent: "IQS 正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
if got := c.req.URL.Query().Get("query"); got != "昔涟" {
t.Errorf("aliyun query = %q, want 昔涟", got)
}
},
}
}
// bochaCase 是博查的夹具(POST + Bearer;summary=true / count 夹在官方区间)。
func bochaCase() providerCase {
return providerCase{
name: "bocha", newP: newBocha,
method: http.MethodPost, path: bochaPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1",` +
`"snippet":"摘要","summary":"博查总结","datePublished":"2025-01-01"}]}}`,
wantTitle: "博查结果",
wantLink: "https://b.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("bocha body query = %v", body["query"])
}
if body["summary"] != true {
t.Errorf("bocha body summary = %v, want true(中文摘要)", body["summary"])
}
if body["count"] != float64(bochaDefaultCount) {
t.Errorf("bocha body count = %v, want %d", body["count"], bochaDefaultCount)
}
},
}
}
// volcengineCase 是火山·豆包搜索的夹具(POST + Bearer;请求字段是大写驼峰)。
func volcengineCase() providerCase {
return providerCase{
name: "volcengine", newP: newVolcengine,
method: http.MethodPost, path: volcenginePath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"Result":{"WebResults":[{"Title":"火山结果","Url":"https://v.example/1",` +
`"Snippet":"摘要","Content":"火山正文","PublishTime":1700000000000,"RankScore":0.8}]}}`,
wantTitle: "火山结果",
wantLink: "https://v.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("volcengine body Query = %v", body["Query"])
}
if body["SearchType"] != volcengineSearchTypeWeb {
t.Errorf("volcengine body SearchType = %v", body["SearchType"])
}
},
}
}
// baiduCase 是百度千帆的夹具(POST + Bearer;单轮 messages + search_source)。
func baiduCase() providerCase {
return providerCase{
name: "baidu_qianfan", newP: newBaiduQianfan,
method: http.MethodPost, path: baiduQianfanPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"references":[{"title":"百度结果","url":"https://q.example/1",` +
`"snippet":"摘要","content":"百度正文"}]}`,
wantTitle: "百度结果",
wantLink: "https://q.example/1",
wantContent: "百度正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["search_source"] != baiduSearchSourceV2 {
t.Errorf("baidu body search_source = %v, want %s", body["search_source"], baiduSearchSourceV2)
}
msgs, ok := body["messages"].([]any)
if !ok || len(msgs) != 1 {
t.Fatalf("百度搜索仅支持单轮输入,messages = %v", body["messages"])
}
first, _ := msgs[0].(map[string]any)
if first["content"] != "昔涟" || first["role"] != "user" {
t.Errorf("baidu messages[0] = %v", first)
}
},
}
}
// 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() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, cap := fixtureServer(t, tc.fixture)
p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
got, err := p.Search(context.Background(), Query{Text: "昔涟"})
if err != nil {
t.Fatalf("%s Search: %v", tc.name, err)
}
if cap.req == nil {
t.Fatalf("%s 没发出任何请求", tc.name)
}
if cap.req.Method != tc.method {
t.Errorf("method = %q, want %q", cap.req.Method, tc.method)
}
if cap.req.URL.Path != tc.path {
t.Errorf("path = %q, want %q", cap.req.URL.Path, tc.path)
}
if tc.authHeader != "" {
if gotAuth := cap.req.Header.Get(tc.authHeader); gotAuth != tc.authValue {
t.Errorf("认证头 %s = %q, want %q", tc.authHeader, gotAuth, tc.authValue)
}
}
if len(got) != 1 {
t.Fatalf("%s 结果条数 = %d, want 1(归一化失败): %+v", tc.name, len(got), got)
}
r0 := got[0]
if r0.Title != tc.wantTitle || r0.Link != tc.wantLink || r0.Content != tc.wantContent {
t.Errorf("%s 归一化结果 = %+v, want title=%q link=%q content=%q",
tc.name, r0, tc.wantTitle, tc.wantLink, tc.wantContent)
}
if tc.checkRequest != nil {
tc.checkRequest(t, cap)
}
})
}
}
// TestProviders_AllNormalizeToSameResult 是验收 ②:八家输出**归一化成同一 Result**
// (同一结构体、同一语义),且共用同一套后处理(Finalize)。
func TestProviders_AllNormalizeToSameResult(t *testing.T) {
t.Parallel()
for _, tc := range searchProviderCaseTable() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tc.fixture)
p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
got, err := p.Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("Search: %v", err)
}
for i, r := range got {
if r.Link == "" {
// 各家夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、
// 不是本判据的对象):夹具里链接丢了就说明归一化把 link 认错了。
t.Errorf("[%d] Link 为空(夹具里给了链接): %+v", i, r)
}
if r.Title == "" {
t.Errorf("[%d] Title 为空(夹具里给了标题): %+v", i, r)
}
}
if final := Finalize(got, Options{Limit: 1}); len(final) != 1 {
t.Fatalf("Finalize 后条数 = %d, want 1", len(final))
}
})
}
}
// TestProviders_EmptyBodyIsEmptyNotError 钉住各家对「200 + 空数组」的处置一致:
// 不是错误,而是 0 条(由上层按「空结果 ≠ 成功」处置)。
func TestProviders_EmptyBodyIsEmptyNotError(t *testing.T) {
t.Parallel()
for _, tc := range searchProviderCaseTable() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tc.emptyFixture())
p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
got, err := p.Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("空结果不是错误: %v", err)
}
if len(got) != 0 {
t.Fatalf("条数 = %d, want 0", len(got))
}
})
}
}
// emptyFixture 返回该家「0 条结果」的合法应答(候选数组为空)。
func (tc providerCase) emptyFixture() string {
switch tc.name {
case "searxng":
return `{"results":[]}`
case "zhipu":
return `{"search_result":[]}`
case "aliyun_iqs":
return `{"pageItems":[]}`
case "bocha":
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":[]}`
}
}
// TestExtractResults_MissingFieldsTolerated 钉住「缺字段的容错」:
// 缺 content 回落 snippet;全缺也不 panic、不报错;非对象条目跳过。
func TestExtractResults_MissingFieldsTolerated(t *testing.T) {
t.Parallel()
data := []byte(`{"results":[
{"title":"只有标题","url":"https://x.example/1"},
{"url":"https://x.example/2","snippet":"只有摘要"},
"not-an-object",
{"title":"只有正文","url":"https://x.example/3","content":"正文"}
]}`)
got, err := extractResults(data, searxngSpec())
if err != nil {
t.Fatalf("缺字段不该报错: %v", err)
}
if len(got) != 3 {
t.Fatalf("条数 = %d, want 3(非对象条目应被跳过)", len(got))
}
if got[0].Content != "" || got[0].Title != "只有标题" {
t.Errorf("缺 content/正文时应原样留空: %+v", got[0])
}
if got[1].Content != "只有摘要" {
t.Errorf("content 缺了必须回落 snippet: %+v", got[1])
}
if got[2].Content != "正文" {
t.Errorf("正文应原样带出: %+v", got[2])
}
}
// TestExtractResults_EmptyVsMalformed 钉住「空」与「解析不了」必须分开(design/27 §1.4)。
func TestExtractResults_EmptyVsMalformed(t *testing.T) {
t.Parallel()
empty, err := extractResults([]byte(`{"results":[]}`), searxngSpec())
if err != nil {
t.Fatalf("空数组不是错误: %v", err)
}
if len(empty) != 0 {
t.Fatalf("空数组应归一化成 0 条, got %d", len(empty))
}
for _, bad := range []string{`not json`, `{"data":{}}`, `[]`, `{}`} {
if _, err := extractResults([]byte(bad), searxngSpec()); !errors.Is(err, ErrBadResponse) {
t.Errorf("输入 %q 应报 ErrBadResponse, got %v", bad, err)
}
}
}