接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
186 lines
7.7 KiB
Go
186 lines
7.7 KiB
Go
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"},
|
||
}
|
||
}
|