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

+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"},
}
}