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

+219
View File
@@ -0,0 +1,219 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// tencentWSAProfile 是腾讯云·元宝搜索(联网搜索 API / WSA,design/27 §2.1;官方原文见
// DevDocs/reference/search-providers/tencent-wsa/)。
//
// 走的是**服务 API KEY 方式**(不是 TC3 签名那条路,那条需要 AK/SK 派生签名):
//
// POST https://api.wsa.cloud.tencent.com/SearchPro
// Authorization: Bearer <API KEY> ← 零签名,标准库即可 ⇒ 不破「零新依赖」
//
// 🔴 两条必须照抄的官方口径(都对本实现有决定性影响):
//
// - **HTTP 状态码恒为 200**:官方 `api-response-format.md` 原文「目前只要请求被服务端
// 正常处理了,响应的 HTTP 状态码均为200」;本机无 key 实测逐字复现(分册
// `live-probe-2026-10-07.md`:缺 Authorization 也是 200 + `Response.Error`)。
// ⇒ **不能靠状态码判成败**,必须读响应体的 `Response.Error.Code`(见 tencentResponseError)。
// - **`Pages` 是「JSON 字符串」数组**(官方类型:`Array of String`,每个元素是一段 JSON
// 文本)⇒ 必须**二次解析**才能拿到 title/url/passage/…。共享提取器 `extractResults` 只能
// 吃「对象数组」,因此本家自带解析(见 tencentParse),但字段挑选与「Content 空 ⇒ 回落
// Snippet」的既有口径仍复用包内同一套函数。
//
// ⚠️ 诚实边界(**没有 API KEY ⇒ 成功响应未实测**,只有官方示例可依):
//
// - 请求只发官方确证为「必选」的 `Query`。其余字段各有明确理由不发:
// `Site` 是域名过滤(design/27 §4:过滤统一在 Finalize 做,provider 不许自己过滤);
// `Cnt`/`Industry` 官方标「仅限尊享版使用」(发上去会让轻量/标准版直接报参数错);
// `Mode` 与 `Freshness`/`FromTime`/`ToTime` 的版本门槛在两页官方文档里**口径不一致**
// (README「已知未确证」)⇒ 一律不发,条数交给本地截断。
// - `content`(动态摘要)是**尊享版字段**(官方原文:「极速版不返回该字段」);拿不到时
// 由包内既有单向回落把 `passage`(标准摘要)填进 Content。
type tencentWSA struct{ cfg Config }
// tencentWSAPath 是服务 API KEY 方式的端点路径(官方原文:接口路径(URI):/SearchPro)。
const tencentWSAPath = "/SearchPro"
// tencentResponseErrorPath 是上游错误信封的位置(官方原文:Error 字段连同其内部的 Code 与
// Message 在调用失败时**必定**返回)。
const tencentResponseErrorPath = "Response.Error"
// tencentPagesPath 是结果数组的路径(官方原文:`Pages`,Array of String)。
const tencentPagesPath = "Response.Pages"
// newTencentWSA 造一家腾讯云·元宝搜索 provider。
func newTencentWSA(cfg Config) Provider { return &tencentWSA{cfg: cfg} }
// Name 实现 Provider。
func (p *tencentWSA) Name() string { return NameTencentWSA }
// Search 实现 Provider。
func (p *tencentWSA) Search(ctx context.Context, q Query) ([]Result, error) {
body, err := json.Marshal(map[string]any{"Query": q.Text})
if err != nil {
return nil, fmt.Errorf("search: tencent_wsa: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, tencentWSAPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return tencentParse(data)
}
// tencentParse 把上游报文归一化成 []Result。
//
// 顺序是「先判错、再取值」:这一家**错误也走 HTTP 200**,先解 `Response.Error` 才不会把
// 一次失败读成「上游正常应答但零结果」(那会是一句谎报)。
func tencentParse(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 := tencentResponseError(root); err != nil {
return nil, err
}
raw, ok := resolvePath(root, tencentPagesPath)
if !ok {
return nil, fmt.Errorf("%w: tencent_wsa: no %s", ErrBadResponse, tencentPagesPath)
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%w: tencent_wsa: %s is not an array", ErrBadResponse, tencentPagesPath)
}
s := tencentPageSpec()
out := make([]Result, 0, len(items))
for _, item := range items {
obj, ok := tencentPageObject(item)
if !ok {
// 单条脏数据(解析不出来的页元素)跳过:不毁掉整次检索。
continue
}
out = append(out, tencentResult(obj, s))
}
for i := range out {
// 包内既有口径(与 extractResults 同一条):正文缺了就回落摘要,别让 Content 空着。
if out[i].Content == "" {
out[i].Content = out[i].Snippet
}
}
return out, nil
}
// tencentPageObject 把一个 `Pages` 元素还原成对象。
//
// 官方形状是**一段 JSON 文本**(`Array of String`)⇒ 这里做二次解析;也容忍它已经是对象
// 的实现(网关/上游版本差异),两种都认不出来就跳过这一条。
func tencentPageObject(raw any) (map[string]any, bool) {
switch v := raw.(type) {
case map[string]any:
return v, true
case string:
var obj map[string]any
if err := json.Unmarshal([]byte(v), &obj); err != nil {
return nil, false
}
return obj, true
default:
return nil, false
}
}
// tencentPageSpec 是 `Pages` 元素内层对象的字段(官方原文逐字确证,两条接入路线同构):
//
// title(结果标题)/ url(源 URL)/ passage(标准摘要)/ content(动态摘要,尊享版字段)/
// date(内容发布时间)/ score(相关性得分 0~1,越接近 1 越相关)
//
// 刻意**不投影** `site`(网站名称)/ `favicon` / `pics` / `deeplinks` / `authority_level`:
// `Result` 没有对应的位,而把 `site` 塞进 `Snippet`(正文摘录位)会让「摘要」变成站点名
// —— 与 zhipuSpec 的同一条理由(往语义位塞另一类事实)。
func tencentPageSpec() spec {
return spec{
Title: []string{"title"},
Link: []string{"url"},
Snippet: []string{"passage"},
Content: []string{"content"},
Published: []string{"date"},
Score: []string{"score"},
}
}
// tencentResult 按 tencentPageSpec 从一条 Pages 对象里取值(复用包内统一的挑选器)。
func tencentResult(obj map[string]any, s spec) Result {
return Result{
Title: pickString(obj, s.Title),
Link: pickString(obj, s.Link),
Snippet: pickString(obj, s.Snippet),
Content: pickString(obj, s.Content),
Published: pickString(obj, s.Published),
Score: pickFloat(obj, s.Score),
}
}
// tencentResponseError 读 `Response.Error`:返回 nil 表示这次调用成功。
//
// 它是这一家判失败的**唯一**途径(HTTP 恒 200,见文件头「两条必须照抄的官方口径」)。
// 官方原文:Error 字段一出现就代表调用失败,且调用失败时 Code 与 Message 必定返回。
func tencentResponseError(root any) error {
if _, ok := resolvePath(root, tencentResponseErrorPath); !ok {
return nil
}
code := ""
if v, ok := resolvePath(root, tencentResponseErrorPath+".Code"); ok {
code = scalarString(v)
}
return tencentStatusError(code)
}
// tencentStatusError 把上游错误码包成本仓哨兵错误。
//
// ⚠️ 只带 **code**、**绝不带上游 Message**:Message 是任意上游文本,某些上游会把请求
// URL/凭据回显进去,而这段文案会被工具层记进日志(与 errors.go 的 classifyStatus 同口径)。
// 取不到码(Error 在但 Code 缺)时按 upstream 归类,仍然**不会当成空结果**。
func tencentStatusError(code string) error {
sentinel := tencentCodeClass(code)
if c := strings.TrimSpace(code); c != "" {
return fmt.Errorf("%w (code=%s)", sentinel, c)
}
return sentinel
}
// tencentCodeClass 把腾讯云侧的错误码归成本仓哨兵(大小写不敏感,前缀匹配点分码)。
//
// 依据官方错误码表(`api-error-codes.md` 公共码 + `api-searchpro-apikey.md` 产品码):
//
// UnauthorizedOperation / AuthFailure.* ⇒ 凭据问题。官方表把 UnauthorizedOperation 解释为
// 「请检查服务密钥 API KEY 是否正确」;**无 key 与
// key 无效同码**(本机实测),因此只能归成一类。
// ResourceUnavailable ⇒ 欠费(官方表:「请检查账号是否欠费」)。
// ResourceNotFound ⇒ 用户资源未开通(官方表:「请联系主账号开通服务」)。
// RequestLimitExceeded[.*] ⇒ 频率超限。
// LimitExceeded ⇒ 超过配额限制(与「频率」不同,归额度类)。
// 其余(含取不到码) ⇒ upstream(不可进一步分类,降级链照常前进一格)。
//
// 这张表**只在本家使用**:腾讯云的错误码随 HTTP 200 返回,errors.go 的 classifyStatus
// (只处理非 2xx)根本看不到它们,把点分大写码混进那张所有家共用的数字码表只会制造碰撞。
func tencentCodeClass(code string) error {
c := strings.ToLower(strings.TrimSpace(code))
switch {
case strings.HasPrefix(c, "unauthorizedoperation"), strings.HasPrefix(c, "authfailure"):
return ErrUnauthorized
case c == "resourceunavailable", c == "resourcenotfound", c == "limitexceeded":
return ErrQuotaExceeded
case strings.HasPrefix(c, "requestlimitexceeded"):
return ErrRateLimited
default:
return ErrUpstream
}
}