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

186 lines
7.7 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"
"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"},
}
}