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 ← 零签名,标准库即可 ⇒ 不破「零新依赖」 // // 🔴 两条必须照抄的官方口径(都对本实现有决定性影响): // // - **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 } }