Files
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

220 lines
9.6 KiB
Go
Raw Permalink 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"
"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
}
}