Files
Cyrene_v2/plugins/tools/io/search/http.go
T
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

100 lines
3.5 KiB
Go

package search
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
)
// defaultMaxBytes 是单次响应体的兜底读取上限(1 MiB,与 io 包的 http 响应上限同口径)。
// 组合根总会传入显式值;这里只保证 Config.MaxBytes 为零值时不是「无限读」。
const defaultMaxBytes = 1 << 20
// request 描述一次上游调用。provider 只填这些,其余(读取上限、错误分类)走共享通路
// —— 各家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。
type request struct {
// Method 是 HTTP 方法(http.MethodGet / http.MethodPost)。
Method string
// URL 是完整目标地址(provider 自己拼 base + path + query)。
URL string
// Headers 是附加请求头(含认证头)。**绝不写进日志**。
Headers map[string]string
// Body 是请求体(非空时默认 Content-Type: application/json)。
Body []byte
}
// do 发一次请求并返回读取上限内的响应体:
//
// - SSRF 守卫(组合根注入)先判最终地址 ⇒ 被拦时如实报错(不静默放行);
// - 网络层失败 ⇒ ErrNetwork(含 context 超时/取消,调用方可用 errors.Is 区分);
// - 非 2xx ⇒ 按业务错误码 + 状态码分类(classifyStatus);
// - 超过 MaxBytes ⇒ ErrResponseTooLarge(不静默截断成半截 JSON)。
//
// 报文正文与认证头**都不进日志**(本函数不记日志,日志由调用方在工具层记)。
func do(ctx context.Context, cfg Config, r request) ([]byte, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("search: no http client: %w", ErrNotConfigured)
}
if cfg.Guard != nil {
if err := cfg.Guard(ctx, r.URL); err != nil {
return nil, fmt.Errorf("search: blocked upstream url: %w", err)
}
}
var body io.Reader
if len(r.Body) > 0 {
body = bytes.NewReader(r.Body)
}
req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body)
if err != nil {
return nil, fmt.Errorf("search: build request: %w", err)
}
for k, v := range r.Headers {
req.Header.Set(k, v)
}
if len(r.Body) > 0 && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := cfg.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrNetwork, err)
}
defer resp.Body.Close()
data, err := readCapped(resp.Body, cfg.MaxBytes)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
// 错误体可能带业务错误码(如阿里云 Retrieval.Arrears);错误体本身不入错误文案
// 以外的去处,也不会被当成结果。
return nil, fmt.Errorf("search: upstream status %d: %w", resp.StatusCode, classifyStatus(resp.StatusCode, upstreamCode(data)))
}
return data, nil
}
// readCapped 读回最多 max 字节;超限返回 ErrResponseTooLarge。
func readCapped(r io.Reader, max int64) ([]byte, error) {
if max <= 0 {
max = defaultMaxBytes
}
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, fmt.Errorf("search: read response: %w", err)
}
if int64(len(data)) > max {
return nil, fmt.Errorf("%w: exceeds the %d-byte limit", ErrResponseTooLarge, max)
}
return data, nil
}
// baseURLJoin 把基地址与各家固定路径拼起来(容忍基地址尾部的 "/")。
func baseURLJoin(baseURL, path string) string {
trimmed := baseURL
for len(trimmed) > 0 && trimmed[len(trimmed)-1] == '/' {
trimmed = trimmed[:len(trimmed)-1]
}
return trimmed + path
}