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