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