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)。
This commit is contained in:
1 parent
8e3311cea2
commit
ce9af42945
21 files changed
+1219
-36
No files matched your search
@@ -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":[]}`
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user