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
@@ -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"},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user