feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)

主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
This commit is contained in:
AskaEth committed 2026-10-05 22:38:20 +08:00
1 parent 236b202fda
commit 69a3b73777
24 files changed
+3251 -171

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
package search
import (
"context"
"net/http"
"net/url"
)
// aliyunIQSProfile 是阿里云信息查询服务(AI 联网搜索,IQS;design/27 §2.1,分册 15)。
//
// 接入形态对本仓最友好:**GET `/search/genericSearch` + `X-API-Key`(免签名、免 SDK)**
// ⇒ 标准库即可,恒不破「零新依赖」。
//
// 分册 15 确证的两个硬约束:
//
// - 官方原文:**默认返回网页 10 条,暂不支持调整** ⇒ 本 provider 忽略 Query.Count
// (本地截断仍生效,见 io 层的 Finalize)。
// - `mainText` 默认只给**前 500 字符**(要 3000 字需联系客户经理开通)⇒ 正文偏短是
// 上游口径,不是本仓截断。
type aliyunIQS struct{ cfg Config }
// aliyunIQSPath 是联网搜索端点路径(分册 15 官方原文:GET /search/genericSearch?query=<q>)。
const aliyunIQSPath = "/search/genericSearch"
// newAliyunIQS 造一家阿里云 IQS provider。
func newAliyunIQS(cfg Config) Provider { return &aliyunIQS{cfg: cfg} }
// Name 实现 Provider。
func (p *aliyunIQS) Name() string { return NameAliyunIQS }
// Search 实现 Provider。
func (p *aliyunIQS) Search(ctx context.Context, q Query) ([]Result, error) {
target := baseURLJoin(p.cfg.BaseURL, aliyunIQSPath) + "?query=" + url.QueryEscape(q.Text)
data, err := do(ctx, p.cfg, request{
Method: http.MethodGet,
URL: target,
Headers: map[string]string{"X-API-Key": p.cfg.APIKey},
})
if err != nil {
return nil, err
}
return extractResults(data, aliyunIQSSpec())
}
// aliyunIQSSpec 是 IQS 的响应形状(分册 15「响应字段」官方原文确证):
// `pageItems[].{title,link,snippet,mainText,markdownText,publishTime,score}`。
// 正文优先取 `mainText`(纯文本,更适合进提示词),其次 `markdownText`。
func aliyunIQSSpec() spec {
return spec{
Arrays: []string{"pageItems"},
Title: []string{"title"},
Link: []string{"link"},
Snippet: []string{"snippet"},
Content: []string{"mainText", "markdownText"},
Published: []string{"publishTime"},
Score: []string{"score"},
}
}
+98
View File
@@ -0,0 +1,98 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// baiduQianfanProfile 是百度·千帆 AI 搜索(design/27 §2.1;分册 16 官方原文确证)。
//
// 接口形态:**POST `/v2/ai_search/web_search` + `Authorization: Bearer <API_KEY>`**
// ⇒ 标准库即可。
//
// 分册 16 确证的口径:
//
// - 请求必须走 `messages` 数组,且**百度搜索仅支持单轮输入**(多轮则以最后一条
// content 为查询)⇒ 本 provider 只放一条 user 消息。
// - `search_source` 固定值 `baidu_search_v2`。
// - `resource_type_filter[].top_k` 上限:**网页 50**(默认 20)。
// - 计费:标准版 0.036 元/次、增强版 0.072 元/次;每月免费 1500 次(约每天 50 次)。
//
// ⚠️ 诚实边界(分册 16「未确证」):**响应结构未逐字取全** ⇒ 用共享提取器按多组候选键
// 容错,不把猜测的字段当事实;拿到真实 key 后按官方文档收窄。
type baiduQianfan struct{ cfg Config }
// baiduQianfanPath 是百度搜索端点路径
// (分册 16 官方原文:POST /v2/ai_search/web_search,HOST qianfan.baidubce.com)。
const baiduQianfanPath = "/v2/ai_search/web_search"
// baiduSearchSourceV2 是搜索引擎版本固定值(分册 16 官方原文:固定值 `baidu_search_v2`)。
const baiduSearchSourceV2 = "baidu_search_v2"
// baiduDefaultTopK / baiduMaxTopK 是网页模态的 top_k 口径
// (分册 16 官方原文:默认 `[{"type":"web","top_k":20}]`,网页 top_k 上限 50)。
const (
baiduDefaultTopK = 20
baiduMaxTopK = 50
)
// newBaiduQianfan 造一家百度千帆 provider。
func newBaiduQianfan(cfg Config) Provider { return &baiduQianfan{cfg: cfg} }
// Name 实现 Provider。
func (p *baiduQianfan) Name() string { return NameBaiduQianfan }
// Search 实现 Provider。
func (p *baiduQianfan) Search(ctx context.Context, q Query) ([]Result, error) {
payload := map[string]any{
"messages": []map[string]string{{"role": "user", "content": q.Text}},
// 单轮输入:百度搜索只认最后一条 content,这里就只放一条。
"search_source": baiduSearchSourceV2,
"resource_type_filter": []map[string]any{
{"type": "web", "top_k": baiduTopK(q.Count)},
},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: baidu_qianfan: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, baiduQianfanPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, baiduQianfanSpec())
}
// baiduTopK 把调用方要的条数收进官方允许区间(分册 16:网页 top_k 上限 50,默认 20)。
func baiduTopK(n int) int {
if n <= 0 {
return baiduDefaultTopK
}
if n > baiduMaxTopK {
return baiduMaxTopK
}
return n
}
// baiduQianfanSpec 是百度千帆响应的**候选**形状。
//
// TODO(未确证):分册 16 只逐字取到了请求参数;响应结构**未确证**。
// 这里的候选键是容错解析(尽量认得出来),拿到真实 key 后必须按官方文档复核并收窄。
func baiduQianfanSpec() spec {
return spec{
Arrays: []string{"references", "search_results", "data.references", "results"},
Title: []string{"title", "Title"},
Link: []string{"url", "Url", "link"},
Snippet: []string{"snippet", "summary", "abstract"},
Content: []string{"content", "Content"},
Published: []string{"date", "publish_time", "publishTime"},
Score: []string{"score", "rank"},
}
}
+89
View File
@@ -0,0 +1,89 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// bochaProfile 是博查 Bocha(design/27 §2 A 档「中文优先」;分册 02 官方原文确证)。
//
// POST `/v1/web-search` + `Authorization: Bearer`;响应 `webPages.value[]` 的
// `name/url/snippet/summary/datePublished` —— **与 SearXNG 字段近乎同名**,归一化最省事。
//
// 两个诚实边界(分册 02):
//
// - 官方文档域名为 `api.bocha.cn`,而官网首页 curl 样例仍用 `api.bochaai.com`,
// 两者关系官方未说明 ⇒ 默认取**文档现行口径** `api.bocha.cn`(见 config 的默认值),
// 两域都可由 `WEB_SEARCH_BOCHA_BASE_URL` 覆盖。
// - 中文检索质量**未实测**(无 key)。
type bocha struct{ cfg Config }
// bochaPath 是 Web Search 端点路径(分册 02:POST https://api.bocha.cn/v1/web-search)。
const bochaPath = "/v1/web-search"
// bochaDefaultCount / bochaMaxCount 是请求条数口径(分册 02 官方原文:
// 「返回条数,可填范围 1-50,单次最大 50,默认 10」)。
const (
bochaMaxCount = 50
bochaDefaultCount = 10
)
// newBocha 造一家博查 provider。
func newBocha(cfg Config) Provider { return &bocha{cfg: cfg} }
// Name 实现 Provider。
func (p *bocha) Name() string { return NameBocha }
// Search 实现 Provider。
func (p *bocha) Search(ctx context.Context, q Query) ([]Result, error) {
// summary=true 换取中文摘要(分册 02:默认 false 不给 summary);
// freshness 用官方推荐的 noLimit(官方原文:「推荐使用 noLimit。搜索算法会自动
// 进行时间范围的改写,效果更佳」)。
payload := map[string]any{
"query": q.Text,
"summary": true,
"freshness": "noLimit",
"count": bochaClampCount(q.Count),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: bocha: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, bochaPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, bochaSpec())
}
// bochaClampCount 把调用方要的条数收进官方允许区间(分册 02:1-50,默认 10)。
func bochaClampCount(n int) int {
if n <= 0 {
return bochaDefaultCount
}
if n > bochaMaxCount {
return bochaMaxCount
}
return n
}
// bochaSpec 是博查的响应形状(分册 02「响应字段」官方原文确证)。
// `summary` 只在请求里 `summary=true` 时才有 —— 我们恒置 true。
func bochaSpec() spec {
return spec{
Arrays: []string{"webPages.value"},
Title: []string{"name"},
Link: []string{"url"},
Snippet: []string{"snippet"},
Content: []string{"summary"},
Published: []string{"datePublished"},
// 官方字段表未列评分(`id` 是排序 ID、不是分数)⇒ 不给 Score 候选。
}
}
+16
View File
@@ -0,0 +1,16 @@
// Package search 实现 web-search 工具的「搜索后端(provider)」层:
// 一 provider 一文件、统一窄接口、统一归一化结果
// (DevDocs/design/27-网络搜索与检索提供商.md §4,照 Open WebUI 的 provider 范式,不自创)。
//
// 三条不变量(design/27 §1):
//
// - **能力 ≠ 单一后端**:能力面对模型只有一把工具(`web-search`),后端由配置选定;
// 换 provider 不改工具 schema。
// - **provider 是一等公民、各自独立配置**:未配置的家**不出现**在注册表里(fail-closed),
// 绝不「挂着报错」。
// - **诚实**:provider 报错与空结果必须如实区分(空结果是空结果,不是成功);
// 密钥只从配置读,**绝不进日志/响应/提示词**。
//
// provider 只负责「发命令、取回 JSON、按 spec 归一化」:**不做域名过滤/去重/排序**
// —— 那些统一在归一化之后做一次(见 Finalize)。零新依赖:一律标准库 `net/http`。
package search
+125
View File
@@ -0,0 +1,125 @@
package search
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
// 本包的哨兵错误:把「上游为什么不行」如实分成几类
// (design/27 §1.4/§3:provider 报错与空结果必须如实区分)。
// 调用方一律用 errors.Is 判定。
var (
// ErrUnknownProvider 表示名字不在本期接入表里(写错了,或本期没做,如 tavily)。
ErrUnknownProvider = errors.New("search: unknown provider")
// ErrNotConfigured 表示这家 provider 没配齐(缺 key / 缺 base URL)。
ErrNotConfigured = errors.New("search: provider not configured")
// ErrUnauthorized 表示上游拒了凭据(401/403)。
ErrUnauthorized = errors.New("search: upstream rejected credentials")
// ErrRateLimited 表示被上游限流(429)。
ErrRateLimited = errors.New("search: upstream rate limited")
// ErrQuotaExceeded 表示额度用尽/欠费/未开通(含 403 + 业务码)。
ErrQuotaExceeded = errors.New("search: upstream quota exceeded")
// ErrNetwork 表示网络层失败(连不上、DNS、超时)——它**是**「该家不可用」的一种。
ErrNetwork = errors.New("search: network error")
// ErrUpstream 表示上游 5xx 或其他无法进一步分类的失败状态。
ErrUpstream = errors.New("search: upstream error")
// ErrResponseTooLarge 表示上游响应超过本仓的读取上限(不静默截断成半截 JSON)。
ErrResponseTooLarge = errors.New("search: response exceeds size limit")
// ErrBadResponse 表示响应不是预期结构(JSON 解不开,或按 spec 压根找不到结果数组)。
ErrBadResponse = errors.New("search: malformed upstream response")
// ErrNoResults 表示上游正常应答但**没有任何结果**。
//
// 它是「空」不是「成功」,也**不是**「该家不可用」:降级链遇到它要停下并如实
// 报「查到了 0 条」,而不是前进一格假装换一家就有了(design/27 §1.4/§3)。
ErrNoResults = errors.New("search: no results")
)
// Unavailable 报告一个错误是否属于「该家不可用」——只有这类错误才让降级链前进一格
// (design/27 §3:无 key / 网络失败 / 额度错误…)。
//
// 空结果(ErrNoResults)与上下文取消/超时**不在**其列:前者是「空」不是「不可用」,
// 后者是预算已经花光,换一家也不会有时间。
func Unavailable(err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, ErrNoResults):
return false
case errors.Is(err, ErrNotConfigured),
errors.Is(err, ErrUnauthorized),
errors.Is(err, ErrRateLimited),
errors.Is(err, ErrQuotaExceeded),
errors.Is(err, ErrNetwork),
errors.Is(err, ErrUpstream),
errors.Is(err, ErrResponseTooLarge),
errors.Is(err, ErrBadResponse):
return true
default:
return false
}
}
// classifyStatus 把上游的失败状态码归成本仓的哨兵错误。
// 先看业务错误码(各家官方文档确证的那几个,见 upstreamCodeClass),再看 HTTP 状态。
func classifyStatus(status int, code string) error {
if e, ok := upstreamCodeClass(code); ok {
return e
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
// 403 既可能是鉴权也可能是额度,业务错误码(若上游给了)已在上面先行判定。
return ErrUnauthorized
case status == http.StatusPaymentRequired:
return ErrQuotaExceeded
case status == http.StatusTooManyRequests:
return ErrRateLimited
default:
// 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。
return ErrUpstream
}
}
// upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。
//
// 来源:阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html;
// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节)。
// 只收录确证过的条目 —— 未确证的错误码一律不猜。
func upstreamCodeClass(code string) (error, bool) {
switch strings.ToLower(strings.TrimSpace(code)) {
case "retrieval.throttling.user", "retrieval.testuserqueryperdayexceeded":
return ErrRateLimited, true
case "retrieval.arrears", "retrieval.notactivate", "retrieval.testuserperiodexpired":
return ErrQuotaExceeded, true
case "retrieval.notauthorised":
return ErrUnauthorized, true
default:
return nil, false
}
}
// codePaths 是上游错误体里「业务错误码」的候选位置(点分路径,按序取第一个非空字符串)。
// 各家形状不一,这里给的是容错候选;取不到就当没有错误码(按状态码分类)。
func codePaths() []string {
return []string{"code", "error.code", "error_code", "ErrorCode", "ResponseMetadata.Error.Code"}
}
// upstreamCode 尽力从错误响应体里取出业务错误码。取不到(不是 JSON、没有该字段)返回空串。
func upstreamCode(data []byte) string {
if len(data) == 0 {
return ""
}
var root any
if err := json.Unmarshal(data, &root); err != nil {
return ""
}
for _, p := range codePaths() {
if v, ok := resolvePath(root, p); ok {
if s := scalarString(v); s != "" {
return s
}
}
}
return ""
}
+237
View File
@@ -0,0 +1,237 @@
package search
import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
)
// spec 描述「怎么把一家 provider 的 JSON 归一到 []Result」:每个字段给一串**候选键名**
// (按序取第一个非空值),结果数组给一串**候选路径**(点分,按序取第一个是数组的)。
//
// 六家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。
// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度)用多组候选键容错,
// 而不是把某个猜测的字段当事实写死(design/27 §7 的诚实边界)。
type spec struct {
// Arrays 是结果数组的候选路径(点分)。例:{"webPages.value", "results"}。
Arrays []string
// 以下每个字段是一组候选键名(同一层对象内),按序取第一个非空值。
Title []string
Link []string
Snippet []string
Content []string
Published []string
Score []string
}
// extractResults 把上游 JSON 归一化成 []Result。
//
// 「空」与「解析不了」必须分开(design/27 §1.4):候选路径命中了数组(哪怕是空数组)
// ⇒ 返回(可能为空的)切片、无错误;一条候选路径都不是数组 ⇒ ErrBadResponse。
func extractResults(data []byte, s spec) ([]Result, error) {
var root any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("%w: %w", ErrBadResponse, err)
}
items, ok := findArray(root, s.Arrays)
if !ok {
return nil, fmt.Errorf("%w: no results array", ErrBadResponse)
}
out := make([]Result, 0, len(items))
for _, item := range items {
obj, ok := item.(map[string]any)
if !ok {
// 非对象条目跳过:容错,不让一条脏数据毁掉整次检索。
continue
}
out = append(out, Result{
Link: pickString(obj, s.Link),
Title: pickString(obj, s.Title),
Snippet: pickString(obj, s.Snippet),
Content: pickString(obj, s.Content),
Published: pickString(obj, s.Published),
Score: pickFloat(obj, s.Score),
})
}
for i := range out {
// 既有 SearXNG 口径(WS1-A 之前就在):正文缺了就回落摘要,别让 content 空着。
if out[i].Content == "" {
out[i].Content = out[i].Snippet
}
}
return out, nil
}
// resolvePath 按点分路径取嵌套值;任一段不是对象或键不存在时返回 ok=false。
func resolvePath(root any, path string) (any, bool) {
cur := root
for _, seg := range strings.Split(path, ".") {
obj, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = obj[seg]
if !ok {
return nil, false
}
}
return cur, true
}
// findArray 依次尝试候选路径,返回第一个确实是数组的值。
func findArray(root any, paths []string) ([]any, bool) {
for _, p := range paths {
v, ok := resolvePath(root, p)
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr, true
}
}
return nil, false
}
// pickString 按候选键顺序取第一个**非空**标量值(空串不算命中,好让后面的候选有机会)。
func pickString(obj map[string]any, keys []string) string {
for _, k := range keys {
v, ok := obj[k]
if !ok || v == nil {
continue
}
if s := scalarString(v); s != "" {
return s
}
}
return ""
}
// pickFloat 按候选键顺序取第一个能当数字用的值(数字或数字字符串)。
func pickFloat(obj map[string]any, keys []string) float64 {
for _, k := range keys {
v, ok := obj[k]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case float64:
return t
case string:
if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil {
return f
}
}
}
return 0
}
// scalarString 把 JSON 标量转成字符串(非标量返回空串)。时间戳(毫秒数字)按整数值原样输出。
func scalarString(v any) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case bool:
return strconv.FormatBool(t)
default:
return ""
}
}
// Options 是**归一化之后统一做一次**的后处理开关
// (design/27 §4:域名过滤/去重/排序集中在这里,各 provider 一律不许自己过滤)。
type Options struct {
// Limit 是返回条数上限(≤0 = 不截断)。
Limit int
// AllowDomains 非空时只保留这些域名(含子域)。
AllowDomains []string
// BlockDomains 是排除的域名(含子域);优先于 AllowDomains。
BlockDomains []string
}
// Finalize 对归一化后的结果做一次统一后处理:
// 丢弃空 link → 域名过滤 → 去重(按规范化 link)→ 按 Score 降序**稳定**排序 → 截断到 Limit。
//
// 排序用 SliceStable:分数相同(多数上游不给分)时保持上游给出的原始顺序,不制造随机。
func Finalize(results []Result, opt Options) []Result {
out := make([]Result, 0, len(results))
seen := make(map[string]struct{}, len(results))
for _, r := range results {
r.Link = strings.TrimSpace(r.Link)
if r.Link == "" {
// 没有链接的结果没法被模型引用,也不算一条检索结果。
continue
}
if !domainAllowed(r.Link, opt.AllowDomains, opt.BlockDomains) {
continue
}
key := canonicalLink(r.Link)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, r)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
if opt.Limit > 0 && len(out) > opt.Limit {
out = out[:opt.Limit]
}
return out
}
// domainAllowed 按 allow/block 名单判定一条结果的域名是否保留。
// 解析不出主机时**保留**(不是「过滤失败」——域名过滤不该把正常结果误杀)。
func domainAllowed(link string, allow, block []string) bool {
u, err := url.Parse(link)
if err != nil {
return true
}
host := strings.ToLower(u.Hostname())
if host == "" {
return true
}
if matchesDomain(host, block) {
return false
}
if len(allow) == 0 {
return true
}
return matchesDomain(host, allow)
}
// matchesDomain 报告 host 是否命中名单里的某一项(同名或为其子域)。
// 名单项容忍大小写、首尾空白与前导点(如 ".example.com")。
func matchesDomain(host string, list []string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
return false
}
for _, raw := range list {
entry := strings.ToLower(strings.Trim(strings.TrimSpace(raw), "."))
if entry == "" {
continue
}
if h == entry || strings.HasSuffix(h, "."+entry) {
return true
}
}
return false
}
// canonicalLink 是去重键:去 fragment、scheme/host 小写、path 去尾斜杠。
// 刻意**保留 query** —— 查询串不同的地址通常是不同页面,过度归并会吞掉真结果。
func canonicalLink(link string) string {
u, err := url.Parse(strings.TrimSpace(link))
if err != nil {
return strings.ToLower(strings.TrimSpace(link))
}
u.Fragment = ""
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Path = strings.TrimSuffix(u.Path, "/")
return u.String()
}
+137
View File
@@ -0,0 +1,137 @@
package search
import "testing"
// TestFinalize_Pipeline 是「归一化之后**统一做一次**」的判据(design/27 §4):
// 空 link 丢弃 → 域名过滤 → 去重 → 按分数稳定排序 → 截断。
func TestFinalize_Pipeline(t *testing.T) {
t.Parallel()
in := []Result{
{Link: "https://a.example/1#frag", Title: "A1", Score: 0.2},
{Link: "https://a.example/1", Title: "A1 副本", Score: 0.9}, // 去重被丢(同一地址)
{Link: "https://b.example/2", Title: "B2", Score: 0.9},
{Link: " ", Title: "没有链接"},
{Link: "https://blocked.example/3", Title: "被排除的域名"},
{Link: "https://c.example/4/", Title: "C4", Score: 1.5},
}
got := Finalize(in, Options{BlockDomains: []string{"blocked.example"}})
if len(got) != 3 {
t.Fatalf("条数 = %d, want 3: %+v", len(got), got)
}
// 去重保留**先出现的那一条**(A1,分 0.2);Score 降序;同分保持原序(稳定排序)。
if got[0].Title != "C4" || got[1].Title != "B2" || got[2].Title != "A1" {
t.Fatalf("排序/去重结果 = %q,%q,%q; want C4,B2,A1(分数降序 + 同分稳定 + 重复地址保留首条)",
got[0].Title, got[1].Title, got[2].Title)
}
if got[2].Link != "https://a.example/1#frag" {
t.Fatalf("去重应保留先出现的那一条: %+v", got[2])
}
if got[1].Link != "https://b.example/2" {
t.Fatalf("B2 的链接被改动: %q", got[1].Link)
}
}
// TestFinalize_LimitAndAllowDomains 覆盖截断与域名白名单(域名过滤只在归一化后做一次)。
func TestFinalize_LimitAndAllowDomains(t *testing.T) {
t.Parallel()
in := []Result{
{Link: "https://a.example/1", Title: "A"},
{Link: "https://sub.a.example/2", Title: "A 子域"},
{Link: "https://other.example/3", Title: "O"},
}
allow := Finalize(in, Options{AllowDomains: []string{".a.example"}})
if len(allow) != 2 {
t.Fatalf("白名单结果 = %+v, want 2 条(含子域)", allow)
}
limited := Finalize(in, Options{Limit: 1})
if len(limited) != 1 {
t.Fatalf("Limit=1 应截断成 1 条, got %d", len(limited))
}
none := Finalize(in, Options{AllowDomains: []string{"nope.example"}})
if len(none) != 0 {
t.Fatalf("白名单不命中时应为空, got %+v", none)
}
}
// TestFinalize_ToleratesUnparsableLink 解析不出主机的链接**保留**(域名过滤不该误杀)。
func TestFinalize_ToleratesUnparsableLink(t *testing.T) {
t.Parallel()
got := Finalize([]Result{{Link: "not a url", Title: "X"}}, Options{AllowDomains: []string{"a.example"}})
if len(got) != 1 {
t.Fatalf("解析不出主机的链接应被保留(不是过滤失败): %+v", got)
}
}
// TestCanonicalLink 覆盖去重键的口径:去 fragment、scheme/host 小写、path 去尾斜杠,
// **保留 query**(查询串不同通常是不同页面,过度归并会吞掉真结果)。
func TestCanonicalLink(t *testing.T) {
t.Parallel()
same := [][2]string{
{"https://A.example/p/", "https://a.example/p"},
{"https://a.example/p#sec", "https://a.example/p"},
}
for _, pair := range same {
if canonicalLink(pair[0]) != canonicalLink(pair[1]) {
t.Errorf("应视为同一条: %q vs %q", pair[0], pair[1])
}
}
if canonicalLink("https://a.example/p?x=1") == canonicalLink("https://a.example/p?x=2") {
t.Error("query 不同不该被去重合并")
}
}
// TestScalarStringAndPick 覆盖容错提取的取值口径(字符串/数字/布尔;空串不算命中)。
func TestScalarStringAndPick(t *testing.T) {
t.Parallel()
obj := map[string]any{
"empty": "",
"title": "标题",
"num": float64(1700000000000),
"flag": true,
"nested": map[string]any{"x": 1},
"score": "0.75",
"badnum": "abc",
}
if got := pickString(obj, []string{"empty", "title"}); got != "标题" {
t.Errorf("空串不该算命中, got %q", got)
}
if got := pickString(obj, []string{"num"}); got != "1700000000000" {
t.Errorf("数字应原样字符串化, got %q", got)
}
if got := pickString(obj, []string{"flag"}); got != "true" {
t.Errorf("布尔应字符串化, got %q", got)
}
if got := pickString(obj, []string{"nested"}); got != "" {
t.Errorf("非标量应返回空串, got %q", got)
}
if got := pickFloat(obj, []string{"score"}); got != 0.75 {
t.Errorf("数字字符串应能当分数用, got %v", got)
}
if got := pickFloat(obj, []string{"badnum"}); got != 0 {
t.Errorf("非数字字符串应返回 0, got %v", got)
}
if got := pickFloat(obj, []string{"missing"}); got != 0 {
t.Errorf("缺键应返回 0, got %v", got)
}
}
// TestResolvePath 覆盖点分路径解析(含中间不是对象的情况)。
func TestResolvePath(t *testing.T) {
t.Parallel()
var root any
mustUnmarshal(t, `{"a":{"b":[1,2]}}`, &root)
if v, ok := resolvePath(root, "a.b"); !ok {
t.Fatal("a.b 应能解析")
} else if arr, ok := v.([]any); !ok || len(arr) != 2 {
t.Fatalf("a.b = %v", v)
}
if _, ok := resolvePath(root, "a.c"); ok {
t.Error("不存在的键不该命中")
}
if _, ok := resolvePath(root, "a.b.c"); ok {
t.Error("路径穿过数组时不该命中")
}
if arr, ok := findArray(root, []string{"nope", "a.b"}); !ok || len(arr) != 2 {
t.Errorf("findArray 应回退到后面的候选路径: %v %v", arr, ok)
}
}
+99
View File
@@ -0,0 +1,99 @@
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
}
+77
View File
@@ -0,0 +1,77 @@
package search
import (
"context"
"net/http"
)
// Provider 是一种搜索后端的**窄接口**(design/27 §4)。实现必须并发安全:
// 工具循环可能从多个 goroutine 调它。
type Provider interface {
// Name 返回该 provider 的配置名(即 WEB_SEARCH_PROVIDER / WEB_SEARCH_FALLBACK 里的取值)。
Name() string
// Search 执行一次检索,返回**已归一化**的结果(可能为空)。
//
// 返回空切片(len==0)**不是错误**:它表示「上游正常应答但什么都没查到」,
// 由调用方按「空结果 ≠ 成功」处置(见 ErrNoResults 的注释)。
Search(ctx context.Context, q Query) ([]Result, error)
}
// Query 是一次检索的输入。工具 schema 仍是 `query`/`count`(design/27 §4:不破坏契约)。
//
// 域名过滤**不在这里**:按 design/27 §4,域名过滤/去重/排序统一在归一化之后做一次
// (见 Finalize 的 Options),各家 provider 一律不许自己过滤。
type Query struct {
// Text 是检索词。
Text string
// Count 是调用方期望的结果条数(>0 才有意义)。provider 支持就透传,
// 不支持(如阿里云 IQS 默认 10 条不可调)就忽略 —— 本地截断仍然生效。
Count int
}
// Result 是所有 provider **统一归一化**后的结果(design/27 §4)。
type Result struct {
Link string
Title string
Snippet string
Content string
Published string
Score float64
}
// 本期内置的 provider 名(对外配置值 WEB_SEARCH_PROVIDER 的合法取值之一)。
const (
// NameSearXNG 是自建 SearXNG(A 档、零 key、本地兜底)。
NameSearXNG = "searxng"
// NameZhipu 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选)。
NameZhipu = "zhipu"
// NameAliyunIQS 是阿里云信息查询服务 IQS(HTTP 免签名免 SDK)。
NameAliyunIQS = "aliyun_iqs"
// NameBocha 是博查 Bocha(中文优先)。
NameBocha = "bocha"
// NameVolcengine 是火山引擎·豆包搜索 SearchInfinity。
NameVolcengine = "volcengine"
// NameBaiduQianfan 是百度千帆 AI 搜索。
NameBaiduQianfan = "baidu_qianfan"
)
// Config 是构造一家 provider 所需的一切接线(密钥/地址/客户端/响应上限/URL 守卫)。
//
// 它由组合根(plugins/tools/io 的 searchWires)填好 —— provider 自己不读配置、
// 不读环境变量、不开新的 HTTP 客户端(单二进制、零新依赖:design/27 §1.5)。
type Config struct {
// APIKey 是上游凭据。**绝不进日志/响应/提示词**。
APIKey string
// BaseURL 是上游基地址(不含各家自己的路径),可被各自的环境变量覆盖。
BaseURL string
// Client 是这一套工具共用的 HTTP 客户端(已带 SSRF 重定向守卫与超时)。
Client *http.Client
// MaxBytes 是单次响应体读取上限(字节);≤0 用包内默认。
MaxBytes int64
// Guard 是**组合根注入的 SSRF 守卫**:发请求前对最终地址做一次判定
// (scheme / host / 解析结果)。它复用 plugins/tools/io 的既有守卫与本工具自己
// 那份白名单,**不改** http / web-fetch 共用的白名单。
//
// 为 nil 时不做判定(search 包的夹具测试直接用 httptest 客户端)。
Guard func(ctx context.Context, rawURL string) error
}
+380
View File
@@ -0,0 +1,380 @@
package search
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// testKey 是夹具里用的假密钥。它只出现在**请求头**里:任何日志/错误/响应里出现它
// 都是缺陷(密钥不泄漏,design/27 §1.5)。
const testKey = "test-api-key-do-not-log"
// mustUnmarshal 解一份 JSON 到 out,失败即 Fatal。
func mustUnmarshal(t *testing.T, raw string, out any) {
t.Helper()
if err := json.Unmarshal([]byte(raw), out); err != nil {
t.Fatalf("unmarshal %q: %v", raw, err)
}
}
// fixtureServer 造一个「记录请求 + 回固定 JSON」的测试服务器。
func fixtureServer(t *testing.T, fixture string) (*httptest.Server, *captured) {
t.Helper()
c := &captured{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
c.set(r, raw)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fixture))
}))
t.Cleanup(srv.Close)
return srv, c
}
// captured 记录最后一次收到的请求(测试同步:handler 先于 Search 返回执行完)。
type captured struct {
req *http.Request
body string
}
func (c *captured) set(r *http.Request, body []byte) {
// 只留断言需要的部分:URL/Method/Header(不含 Body 读句柄)。
clone := r.Clone(r.Context())
c.req = clone
c.body = string(body)
}
// providerCase 是「一家 provider 的 httptest 夹具」:断言请求形态 + 响应归一化。
type providerCase struct {
name string
newP func(Config) Provider
// method / path 是期望的请求形态。
method string
path string
// authHeader / authValue 是期望的认证头(authHeader 为空 = 该家不用 key,如 searxng)。
authHeader string
authValue string
// fixture 是上游返回的 JSON;want* 是归一化后应得到的第一条结果。
fixture string
wantTitle string
wantLink string
wantContent string
// checkRequest 做该家特有的断言(请求体字段等)。
checkRequest func(t *testing.T, c *captured)
}
// searchProviderCaseTable 是六家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。
func searchProviderCaseTable() []providerCase {
return []providerCase{
searxngCase(), zhipuCase(), aliyunCase(), bochaCase(), volcengineCase(), baiduCase(),
}
}
// searxngCase 是 searxng 的夹具(GET + q/format=json,无认证头)。
func searxngCase() providerCase {
return providerCase{
name: "searxng", newP: newSearXNG,
// searxng 的基地址就是端点本身(无固定路径)⇒ 请求落在 "/" 上,
// 查询串才是它的语义(见 checkRequest)。带路径的端点由 io 包既有测试钉住。
method: http.MethodGet, path: "/",
fixture: `{"results":[{"title":"SearX 结果","url":"https://s.example/1","content":"正文","score":0.5}]}`,
wantTitle: "SearX 结果",
wantLink: "https://s.example/1",
wantContent: "正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
if got := c.req.URL.Query().Get("q"); got != "昔涟" {
t.Errorf("searxng q = %q, want 昔涟", got)
}
if got := c.req.URL.Query().Get("format"); got != "json" {
t.Errorf("searxng format = %q, want json", got)
}
if c.req.Header.Get("Authorization") != "" {
t.Errorf("searxng 不该带认证头: %q", c.req.Header.Get("Authorization"))
}
},
}
}
// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query)。
func zhipuCase() providerCase {
return providerCase{
name: "zhipu", newP: newZhipu,
method: http.MethodPost, path: zhipuPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"search_result":[{"title":"智谱结果","link":"https://z.example/1","content":"智谱正文"}]}`,
wantTitle: "智谱结果",
wantLink: "https://z.example/1",
wantContent: "智谱正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
// 只发官方文档确证过的字段(search_engine / search_query)。
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["search_query"] != "昔涟" {
t.Errorf("zhipu body search_query = %v", body["search_query"])
}
if body["search_engine"] != zhipuEngineStd {
t.Errorf("zhipu body search_engine = %v, want %s", body["search_engine"], zhipuEngineStd)
}
},
}
}
// aliyunCase 是阿里云 IQS 的夹具(GET + X-API-Key + query 查询串)。
func aliyunCase() providerCase {
return providerCase{
name: "aliyun_iqs", newP: newAliyunIQS,
method: http.MethodGet, path: aliyunIQSPath, authHeader: "X-API-Key", authValue: testKey,
fixture: `{"pageItems":[{"title":"IQS 结果","link":"https://a.example/1","snippet":"摘要",` +
`"mainText":"IQS 正文","publishTime":1700000000000,"score":0.9}]}`,
wantTitle: "IQS 结果",
wantLink: "https://a.example/1",
wantContent: "IQS 正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
if got := c.req.URL.Query().Get("query"); got != "昔涟" {
t.Errorf("aliyun query = %q, want 昔涟", got)
}
},
}
}
// bochaCase 是博查的夹具(POST + Bearer;summary=true / count 夹在官方区间)。
func bochaCase() providerCase {
return providerCase{
name: "bocha", newP: newBocha,
method: http.MethodPost, path: bochaPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1",` +
`"snippet":"摘要","summary":"博查总结","datePublished":"2025-01-01"}]}}`,
wantTitle: "博查结果",
wantLink: "https://b.example/1",
wantContent: "博查总结",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["query"] != "昔涟" {
t.Errorf("bocha body query = %v", body["query"])
}
if body["summary"] != true {
t.Errorf("bocha body summary = %v, want true(中文摘要)", body["summary"])
}
if body["count"] != float64(bochaDefaultCount) {
t.Errorf("bocha body count = %v, want %d", body["count"], bochaDefaultCount)
}
},
}
}
// volcengineCase 是火山·豆包搜索的夹具(POST + Bearer;请求字段是大写驼峰)。
func volcengineCase() providerCase {
return providerCase{
name: "volcengine", newP: newVolcengine,
method: http.MethodPost, path: volcenginePath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"Result":{"WebResults":[{"Title":"火山结果","Url":"https://v.example/1",` +
`"Snippet":"摘要","Content":"火山正文","PublishTime":1700000000000,"RankScore":0.8}]}}`,
wantTitle: "火山结果",
wantLink: "https://v.example/1",
wantContent: "火山正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["Query"] != "昔涟" {
t.Errorf("volcengine body Query = %v", body["Query"])
}
if body["SearchType"] != volcengineSearchTypeWeb {
t.Errorf("volcengine body SearchType = %v", body["SearchType"])
}
},
}
}
// baiduCase 是百度千帆的夹具(POST + Bearer;单轮 messages + search_source)。
func baiduCase() providerCase {
return providerCase{
name: "baidu_qianfan", newP: newBaiduQianfan,
method: http.MethodPost, path: baiduQianfanPath, authHeader: "Authorization", authValue: "Bearer " + testKey,
fixture: `{"references":[{"title":"百度结果","url":"https://q.example/1",` +
`"snippet":"摘要","content":"百度正文"}]}`,
wantTitle: "百度结果",
wantLink: "https://q.example/1",
wantContent: "百度正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["search_source"] != baiduSearchSourceV2 {
t.Errorf("baidu body search_source = %v, want %s", body["search_source"], baiduSearchSourceV2)
}
msgs, ok := body["messages"].([]any)
if !ok || len(msgs) != 1 {
t.Fatalf("百度搜索仅支持单轮输入,messages = %v", body["messages"])
}
first, _ := msgs[0].(map[string]any)
if first["content"] != "昔涟" || first["role"] != "user" {
t.Errorf("baidu messages[0] = %v", first)
}
},
}
}
// TestProviders_FixturePerProvider 是验收 ①:六家各一条 httptest 夹具(不打真上游)。
func TestProviders_FixturePerProvider(t *testing.T) {
t.Parallel()
for _, tc := range searchProviderCaseTable() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, cap := fixtureServer(t, tc.fixture)
p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
got, err := p.Search(context.Background(), Query{Text: "昔涟"})
if err != nil {
t.Fatalf("%s Search: %v", tc.name, err)
}
if cap.req == nil {
t.Fatalf("%s 没发出任何请求", tc.name)
}
if cap.req.Method != tc.method {
t.Errorf("method = %q, want %q", cap.req.Method, tc.method)
}
if cap.req.URL.Path != tc.path {
t.Errorf("path = %q, want %q", cap.req.URL.Path, tc.path)
}
if tc.authHeader != "" {
if gotAuth := cap.req.Header.Get(tc.authHeader); gotAuth != tc.authValue {
t.Errorf("认证头 %s = %q, want %q", tc.authHeader, gotAuth, tc.authValue)
}
}
if len(got) != 1 {
t.Fatalf("%s 结果条数 = %d, want 1(归一化失败): %+v", tc.name, len(got), got)
}
r0 := got[0]
if r0.Title != tc.wantTitle || r0.Link != tc.wantLink || r0.Content != tc.wantContent {
t.Errorf("%s 归一化结果 = %+v, want title=%q link=%q content=%q",
tc.name, r0, tc.wantTitle, tc.wantLink, tc.wantContent)
}
if tc.checkRequest != nil {
tc.checkRequest(t, cap)
}
})
}
}
// TestProviders_AllNormalizeToSameResult 是验收 ②:六家输出**归一化成同一 Result**
// (同一结构体、同一语义),且共用同一套后处理(Finalize)。
func TestProviders_AllNormalizeToSameResult(t *testing.T) {
t.Parallel()
for _, tc := range searchProviderCaseTable() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tc.fixture)
p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
got, err := p.Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("Search: %v", err)
}
for i, r := range got {
if r.Link == "" {
t.Errorf("[%d] Link 为空 —— 没链接的结果不算结果: %+v", i, r)
}
if r.Title == "" {
t.Errorf("[%d] Title 为空(夹具里给了标题): %+v", i, r)
}
}
if final := Finalize(got, Options{Limit: 1}); len(final) != 1 {
t.Fatalf("Finalize 后条数 = %d, want 1", len(final))
}
})
}
}
// TestProviders_EmptyBodyIsEmptyNotError 钉住六家对「200 + 空数组」的处置一致:
// 不是错误,而是 0 条(由上层按「空结果 ≠ 成功」处置)。
func TestProviders_EmptyBodyIsEmptyNotError(t *testing.T) {
t.Parallel()
for _, tc := range searchProviderCaseTable() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv, _ := fixtureServer(t, tc.emptyFixture())
p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
got, err := p.Search(context.Background(), Query{Text: "q"})
if err != nil {
t.Fatalf("空结果不是错误: %v", err)
}
if len(got) != 0 {
t.Fatalf("条数 = %d, want 0", len(got))
}
})
}
}
// emptyFixture 返回该家「0 条结果」的合法应答(候选数组为空)。
func (tc providerCase) emptyFixture() string {
switch tc.name {
case "searxng":
return `{"results":[]}`
case "zhipu":
return `{"search_result":[]}`
case "aliyun_iqs":
return `{"pageItems":[]}`
case "bocha":
return `{"webPages":{"value":[]}}`
case "volcengine":
return `{"Result":{"WebResults":[]}}`
default:
return `{"references":[]}`
}
}
// TestExtractResults_MissingFieldsTolerated 钉住「缺字段的容错」:
// 缺 content 回落 snippet;全缺也不 panic、不报错;非对象条目跳过。
func TestExtractResults_MissingFieldsTolerated(t *testing.T) {
t.Parallel()
data := []byte(`{"results":[
{"title":"只有标题","url":"https://x.example/1"},
{"url":"https://x.example/2","snippet":"只有摘要"},
"not-an-object",
{"title":"只有正文","url":"https://x.example/3","content":"正文"}
]}`)
got, err := extractResults(data, searxngSpec())
if err != nil {
t.Fatalf("缺字段不该报错: %v", err)
}
if len(got) != 3 {
t.Fatalf("条数 = %d, want 3(非对象条目应被跳过)", len(got))
}
if got[0].Content != "" || got[0].Title != "只有标题" {
t.Errorf("缺 content/正文时应原样留空: %+v", got[0])
}
if got[1].Content != "只有摘要" {
t.Errorf("content 缺了必须回落 snippet: %+v", got[1])
}
if got[2].Content != "正文" {
t.Errorf("正文应原样带出: %+v", got[2])
}
}
// TestExtractResults_EmptyVsMalformed 钉住「空」与「解析不了」必须分开(design/27 §1.4)。
func TestExtractResults_EmptyVsMalformed(t *testing.T) {
t.Parallel()
empty, err := extractResults([]byte(`{"results":[]}`), searxngSpec())
if err != nil {
t.Fatalf("空数组不是错误: %v", err)
}
if len(empty) != 0 {
t.Fatalf("空数组应归一化成 0 条, got %d", len(empty))
}
for _, bad := range []string{`not json`, `{"data":{}}`, `[]`, `{}`} {
if _, err := extractResults([]byte(bad), searxngSpec()); !errors.Is(err, ErrBadResponse) {
t.Errorf("输入 %q 应报 ErrBadResponse, got %v", bad, err)
}
}
}
+159
View File
@@ -0,0 +1,159 @@
package search
import (
"context"
"net/http"
"sort"
"strings"
)
// Wire 是组合根给**一家** provider 的接线值(来自 env 配置)。
//
// Configured 由组合根**显式**判定后给出(判定口径见 Configured 函数):false ⇒
// 这家不进注册表、不出现在可用清单里(design/27 §1.2 的 fail-closed)。刻意不用
// 「APIKey 是否为空」在这里自动推断,是为了让 searxng 这种「零 key、看 base URL」
// 的家也走同一条判定,且判定口径只有一处。
type Wire struct {
// Name 是 provider 名(必须是本期内置的接入位之一,否则被忽略)。
Name string
// APIKey 是上游凭据(searxng 不用)。
APIKey string
// BaseURL 是上游基地址。
BaseURL string
// Configured 报告这家在当前配置下是否已配置齐。
Configured bool
}
// Registry 是**已配置且已登记**的 provider 集合(值语义,由 Build 构造)。
//
// 为什么用注入的 Registry 而不是包级 `Register(provider)` 全局注册表
// (design/27 §4 提到的写法):AGENTS §10 禁包级全局变量,且包级可变注册表会让
// 并行测试(t.Parallel)互相污染。这里把它做成一个由组合根构造、注入工具的值,
// 能力等价(同样的「按名字选一家」+「可用清单」),但没有全局可变状态。
type Registry struct {
providers map[string]Provider
order []string
}
// Lookup 按名字取一家已登记的 provider(名字大小写与首尾空白不敏感)。
// 未配置/未登记的家返回 ok=false。
func (r *Registry) Lookup(name string) (Provider, bool) {
if r == nil {
return nil, false
}
p, ok := r.providers[strings.ToLower(strings.TrimSpace(name))]
return p, ok
}
// Names 返回**已配置且已登记**的 provider 名(稳定顺序 = 接线表给出的顺序)。
// 它就是「可用清单」,未配置的家不会出现在这里。
func (r *Registry) Names() []string {
if r == nil {
return nil
}
out := make([]string, len(r.order))
copy(out, r.order)
return out
}
// definition 是一家 provider 的**接入位**:名字 + 是否需要 key + 构造函数。
// 新增一家 = 新建一个文件(design/27 §4 一 provider 一文件)并在此表加一行。
type definition struct {
name string
needKey bool
new func(Config) Provider
}
// definitions 返回本期内置的全部接入位(**含未配置的家**)。顺序即「可用清单」的展示顺序。
//
// 它每次返回新建的切片(不是包级变量),保持包内无全局状态。
func definitions() []definition {
return []definition{
{name: NameSearXNG, needKey: false, new: newSearXNG},
{name: NameZhipu, needKey: true, new: newZhipu},
{name: NameAliyunIQS, needKey: true, new: newAliyunIQS},
{name: NameBocha, needKey: true, new: newBocha},
{name: NameVolcengine, needKey: true, new: newVolcengine},
{name: NameBaiduQianfan, needKey: true, new: newBaiduQianfan},
}
}
// lookupDef 找一家接入位(名字去空白、大小写不敏感 —— 配置里写 `Zhipu` 也认)。
func lookupDef(name string) (definition, bool) {
want := strings.ToLower(strings.TrimSpace(name))
for _, d := range definitions() {
if d.name == want {
return d, true
}
}
return definition{}, false
}
// Known 报告 name 是否是本期**已登记的接入位**(含未配置的家)。
//
// 它的用处是把两种失败分开报错:名字不认识(写错了 / 本期没做,如 tavily)⇒
// ErrUnknownProvider;名字认识但没配齐(缺 key)⇒ ErrNotConfigured。
// 两者都不许静默回落到兜底(design/27 §3 的语义硬约束)。
func Known(name string) bool {
_, ok := lookupDef(name)
return ok
}
// KnownNames 返回本期全部接入位名(含未配置的家),稳定顺序。
func KnownNames() []string {
defs := definitions()
out := make([]string, 0, len(defs))
for _, d := range defs {
out = append(out, d.name)
}
sort.Strings(out)
return out
}
// Configured 报告一家 provider 在给到的接线值下是否**配置齐**:
// searxng 无 key、看 base URL;云家看 API key(design/27 §3:未配置 ⇒ 该家不出现)。
// 名字不是接入位时返回 false。
func Configured(name, apiKey, baseURL string) bool {
def, ok := lookupDef(name)
if !ok {
return false
}
if def.needKey {
return strings.TrimSpace(apiKey) != ""
}
return strings.TrimSpace(baseURL) != ""
}
// Build 按接线表构造注册表:**只登记 Configured 的家**,名字不是接入位的直接忽略。
//
// guard 是组合根注入的 SSRF 守卫(可为 nil,夹具测试不判定);client / maxBytes 由
// 各家共用(同一份连接池与读取上限)。
//
// 调用方要区分「未注册」与「未配置」时用 Known / Configured 自行判定后报错
// (注册表本身只承载「能用哪些」这一个事实)。
func Build(wires []Wire, client *http.Client, maxBytes int64, guard Guard) *Registry {
reg := &Registry{providers: make(map[string]Provider, len(wires))}
for _, w := range wires {
if !w.Configured {
continue
}
def, ok := lookupDef(w.Name)
if !ok {
continue
}
cfg := Config{APIKey: w.APIKey, BaseURL: w.BaseURL, Client: client, MaxBytes: maxBytes, Guard: guard}
p := def.new(cfg)
if p == nil {
continue
}
if _, dup := reg.providers[def.name]; dup {
continue
}
reg.providers[def.name] = p
reg.order = append(reg.order, def.name)
}
return reg
}
// Guard 是发请求前的地址判定(组合根注入的 SSRF 守卫;见 Config.Guard)。
type Guard func(ctx context.Context, rawURL string) error
+256
View File
@@ -0,0 +1,256 @@
package search
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// TestRegistry_KnownAndKnownNames 覆盖「哪些名字是本期接入位」:认识的家(含未配置的)
// 与不认识的家必须分得开 —— 这正是启动期「未注册 vs 未配置」两种报错的判据。
func TestRegistry_KnownAndKnownNames(t *testing.T) {
t.Parallel()
for _, name := range []string{
NameSearXNG, NameZhipu, NameAliyunIQS, NameBocha, NameVolcengine, NameBaiduQianfan,
"Zhipu", " bocha ",
} {
if !Known(name) {
t.Errorf("Known(%q) = false, want true(大小写/空白应不敏感)", name)
}
}
for _, name := range []string{"", "tavily", "exa", "firecrawl", "serper", "serpapi", "nope"} {
if Known(name) {
t.Errorf("Known(%q) = true, want false(本期未登记)", name)
}
}
names := KnownNames()
if len(names) != 6 {
t.Fatalf("KnownNames() = %v, want 六家", names)
}
}
// TestRegistry_Configured 钉住「配齐」的判定口径:searxng 无 key、看 base URL;
// 云家看 API key。它是「未配置的家不出现在可用清单里」的唯一判据。
func TestRegistry_Configured(t *testing.T) {
t.Parallel()
cases := []struct {
name string
provider string
key, base string
wantConfigured bool
}{
{"searxng 有端点无 key 也算配置齐", NameSearXNG, "", "http://127.0.0.1:38471", true},
{"searxng 没端点 = 未配置", NameSearXNG, "whatever", " ", false},
{"云家有 key = 配置齐", NameZhipu, "k", "", true},
{"云家没 key = 未配置", NameZhipu, " ", "https://open.bigmodel.cn", false},
{"未登记的名字一律未配置", "tavily", "k", "https://api.tavily.com", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Configured(tc.provider, tc.key, tc.base); got != tc.wantConfigured {
t.Fatalf("Configured(%q,%q,%q) = %v, want %v", tc.provider, tc.key, tc.base, got, tc.wantConfigured)
}
})
}
}
// TestRegistry_BuildOnlyRegistersConfigured 钉住 fail-closed:未配置的家根本不进注册表,
// 因而**不出现在可用清单里**(design/27 §1.2)。
func TestRegistry_BuildOnlyRegistersConfigured(t *testing.T) {
t.Parallel()
wires := []Wire{
{Name: NameBocha, APIKey: "k", BaseURL: "https://api.bocha.cn", Configured: true},
{Name: NameZhipu, APIKey: "", BaseURL: "https://open.bigmodel.cn", Configured: false},
{Name: "tavily", APIKey: "k", BaseURL: "https://api.tavily.com", Configured: true},
{Name: NameBocha, APIKey: "k2", BaseURL: "https://api.bocha.cn", Configured: true}, // 重复
}
reg := Build(wires, &http.Client{}, defaultMaxBytes, nil)
names := reg.Names()
if len(names) != 1 || names[0] != NameBocha {
t.Fatalf("可用清单 = %v, want [bocha](未配置的家与未登记的名字都不得出现)", names)
}
if _, ok := reg.Lookup(NameZhipu); ok {
t.Fatal("缺 key 的家不得出现在注册表里")
}
if _, ok := reg.Lookup("tavily"); ok {
t.Fatal("未登记的名字不得出现在注册表里")
}
if p, ok := reg.Lookup(" BOCHA "); !ok || p.Name() != NameBocha {
t.Fatalf("Lookup 应大小写/空白不敏感, got %v %v", p, ok)
}
}
// TestRegistry_EmptyAndNilSafe 覆盖零值路径:空注册表 / nil 注册表不得 panic。
func TestRegistry_EmptyAndNilSafe(t *testing.T) {
t.Parallel()
reg := Build(nil, &http.Client{}, 0, nil)
if len(reg.Names()) != 0 {
t.Fatalf("空接线表应得到空注册表: %v", reg.Names())
}
if _, ok := reg.Lookup("zhipu"); ok {
t.Fatal("空注册表不该命中任何名字")
}
var nilReg *Registry
if _, ok := nilReg.Lookup("zhipu"); ok {
t.Fatal("nil 注册表应安全返回 false")
}
if len(nilReg.Names()) != 0 {
t.Fatal("nil 注册表应返回空清单")
}
}
// TestProviders_ErrorClassification 是验收 ⑤:鉴权 / 限流 / 额度 / 上游 各一条。
// 用同一家 provider + 不同应答,确认分类**如实区分**(错误分类决定降级链是否前进)。
func TestProviders_ErrorClassification(t *testing.T) {
t.Parallel()
cases := []struct {
name string
status int
body string
wantErr error
}{
{"鉴权失败 401", http.StatusUnauthorized, `{"error":"bad key"}`, ErrUnauthorized},
{"限流 429", http.StatusTooManyRequests, `{"error":"too many"}`, ErrRateLimited},
{"额度/欠费(403 + 官方错误码)", http.StatusForbidden,
`{"code":"Retrieval.Arrears","message":"账户欠费"}`, ErrQuotaExceeded},
{"未开通(403 + 官方错误码)", http.StatusForbidden,
`{"code":"Retrieval.NotActivate"}`, ErrQuotaExceeded},
{"上游 500", http.StatusInternalServerError, `{}`, ErrUpstream},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
p := newZhipu(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want errors.Is(..., %v)", err, tc.wantErr)
}
})
}
}
// TestProviders_TransportFailures 覆盖非 HTTP 状态类的失败:网络 / 读取上限 /
// 没有客户端 / 注入守卫被调用。它们同样要**如实分类**,不许混成一团。
func TestProviders_TransportFailures(t *testing.T) {
t.Parallel()
t.Run("网络失败", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close() // 关掉之后再打 ⇒ 连接被拒
p := newZhipu(Config{APIKey: testKey, BaseURL: url, Client: &http.Client{}})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrNetwork) {
t.Fatalf("err = %v, want ErrNetwork", err)
}
})
t.Run("响应超上限", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"search_result":[{"title":"很长"}]}`))
}))
defer srv.Close()
p := newZhipu(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 8})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrResponseTooLarge) {
t.Fatalf("err = %v, want ErrResponseTooLarge", err)
}
})
t.Run("没有 HTTP 客户端", func(t *testing.T) {
t.Parallel()
p := newZhipu(Config{APIKey: testKey, BaseURL: "https://open.bigmodel.cn"})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
})
t.Run("注入的 SSRF 守卫被调用", func(t *testing.T) {
t.Parallel()
called := false
blocked := errors.New("blocked by guard")
p := newZhipu(Config{
APIKey: testKey, BaseURL: "https://open.bigmodel.cn", Client: &http.Client{},
Guard: func(context.Context, string) error { called = true; return blocked },
})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, blocked) {
t.Fatalf("err = %v, want wrapped guard error", err)
}
if !called {
t.Fatal("注入的守卫没被调用 —— SSRF 判定成了死代码")
}
})
}
// TestClassifyStatus 是状态码分类的表驱动(含业务码优先于状态码)。
func TestClassifyStatus(t *testing.T) {
t.Parallel()
cases := []struct {
status int
code string
wantErr error
}{
{http.StatusUnauthorized, "", ErrUnauthorized},
{http.StatusForbidden, "", ErrUnauthorized},
{http.StatusPaymentRequired, "", ErrQuotaExceeded},
{http.StatusTooManyRequests, "", ErrRateLimited},
{http.StatusInternalServerError, "", ErrUpstream},
{http.StatusBadRequest, "", ErrUpstream},
// 业务码优先:403 也可能是额度(阿里云官方错误码表确证)。
{http.StatusForbidden, "Retrieval.Arrears", ErrQuotaExceeded},
{http.StatusForbidden, "retrieval.throttling.user", ErrRateLimited},
{http.StatusForbidden, "Retrieval.NotAuthorised", ErrUnauthorized},
}
for _, tc := range cases {
if got := classifyStatus(tc.status, tc.code); !errors.Is(got, tc.wantErr) {
t.Errorf("classifyStatus(%d,%q) = %v, want %v", tc.status, tc.code, got, tc.wantErr)
}
}
}
// TestUpstreamCode 覆盖业务错误码的候选位置抽取。
func TestUpstreamCode(t *testing.T) {
t.Parallel()
cases := []struct {
body string
want string
}{
{`{"code":"Retrieval.Arrears"}`, "Retrieval.Arrears"},
{`{"error":{"code":"abc"}}`, "abc"},
{`{"ErrorCode":"xyz"}`, "xyz"},
{`{"message":"no code here"}`, ""},
{`not json`, ""},
{``, ""},
}
for _, tc := range cases {
if got := upstreamCode([]byte(tc.body)); got != tc.want {
t.Errorf("upstreamCode(%q) = %q, want %q", tc.body, got, tc.want)
}
}
}
// TestUnavailable 钉住降级链的判据:**空结果不是「不可用」**(不该前进一格)。
func TestUnavailable(t *testing.T) {
t.Parallel()
for _, err := range []error{
ErrNotConfigured, ErrUnauthorized, ErrRateLimited, ErrQuotaExceeded,
ErrNetwork, ErrUpstream, ErrResponseTooLarge, ErrBadResponse,
} {
if !Unavailable(err) {
t.Errorf("Unavailable(%v) = false, want true(该家不可用 ⇒ 前进一格)", err)
}
}
for _, err := range []error{nil, ErrNoResults, context.Canceled, context.DeadlineExceeded} {
if Unavailable(err) {
t.Errorf("Unavailable(%v) = true, want false(不是「不可用」)", err)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package search
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// searxngProfile 是自建 SearXNG(A 档、零 key、**本地兜底**;design/27 §2 与分册 08)。
//
// 协议(WS1-A 已落地,这里逐字保留其语义):GET,`{query}` 占位符优先,否则追加
// `q=<q>&format=json`;响应是 `{"results":[{title,url,content,snippet,score}]}`。
// 它是「底线」不是「主力」:上游可达性决定覆盖面(分册 08 实测:mwmbl 稳定、
// bing 静默返空、sogou 会 CAPTCHA)。
type searxng struct{ cfg Config }
// newSearXNG 造一家 SearXNG provider。BaseURL 为空也能造出来 —— 工具层会以
// 「no search endpoint configured」如实报错(WS1-A 的既有行为,不在这里提前崩)。
func newSearXNG(cfg Config) Provider { return &searxng{cfg: cfg} }
// Name 实现 Provider。
func (p *searxng) Name() string { return NameSearXNG }
// Search 实现 Provider。
func (p *searxng) Search(ctx context.Context, q Query) ([]Result, error) {
target, err := buildSearxngURL(p.cfg.BaseURL, q.Text)
if err != nil {
return nil, err
}
data, err := do(ctx, p.cfg, request{Method: http.MethodGet, URL: target})
if err != nil {
return nil, err
}
return extractResults(data, searxngSpec())
}
// searxngSpec 是 SearXNG 的结果形状(分册 08:`results[].{url,title,content,score}`)。
// 只列确证过的键;没确证的(publishedDate 之类)不写死。
func searxngSpec() spec {
return spec{
Arrays: []string{"results"},
Title: []string{"title"},
Link: []string{"url"},
Snippet: []string{"snippet"},
Content: []string{"content"},
Score: []string{"score"},
}
}
// buildSearxngURL 用一个端点与查询词拼出请求地址(WS1-A 的原逻辑,逐字保留):
// 端点里含 `{query}` 占位符就替换,否则追加 `q=` 与 `format=json`。
func buildSearxngURL(endpoint, query string) (string, error) {
if strings.TrimSpace(endpoint) == "" {
return "", fmt.Errorf("search: searxng: no search endpoint configured")
}
q := url.QueryEscape(query)
if strings.Contains(endpoint, "{query}") {
return strings.ReplaceAll(endpoint, "{query}", q), nil
}
sep := "?"
if strings.Contains(endpoint, "?") {
sep = "&"
}
return endpoint + sep + "q=" + q + "&format=json", nil
}
+95
View File
@@ -0,0 +1,95 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// volcengineProfile 是火山引擎·豆包搜索(SearchInfinity,design/27 §2.1;分册 13 官方原文确证)。
//
// 接口形态:**POST `/search_api/web_search` + `Authorization: Bearer <API_KEY>`**
// ⇒ 标准库即可,无需签名/SDK(不必走 IAM AK/SK 的 TOP 网关那条路)。
// 请求字段是**大写驼峰**(Query/SearchType/Count/Filter),与其它家不同 —— 这是上游口径。
//
// 分册 13 确证的口径:`Query` 1~100 字符且**不支持多词搜索**;账号维度默认 10 QPS;
// 每账号每月 500 次免费(自 2026-03-17 起对个人账号生效);按量 0.020 元/次。
//
// ⚠️ 诚实边界(分册 13「未确证」):**响应字段未逐字取全**(含「如意」结构化卡)
// ⇒ 用共享提取器按多组候选键容错,不把猜测的字段当事实;拿到真实 key 后按官方文档收窄。
type volcengine struct{ cfg Config }
// volcenginePath 是 API Key 接入的端点路径
// (分册 13 官方原文:POST https://open.feedcoopapi.com/search_api/web_search)。
const volcenginePath = "/search_api/web_search"
// volcengineSearchTypeWeb 是搜索类型枚举(分册 13 官方原文:目前支持 `web`,需开通 web搜索)。
const volcengineSearchTypeWeb = "web"
// volcengineDefaultCount / volcengineMaxCount 是条数口径
// (分册 13 官方原文:Count 最多 50 条,默认 10 条)。
const (
volcengineDefaultCount = 10
volcengineMaxCount = 50
)
// newVolcengine 造一家火山·豆包搜索 provider。
func newVolcengine(cfg Config) Provider { return &volcengine{cfg: cfg} }
// Name 实现 Provider。
func (p *volcengine) Name() string { return NameVolcengine }
// Search 实现 Provider。
func (p *volcengine) Search(ctx context.Context, q Query) ([]Result, error) {
payload := map[string]any{
"Query": q.Text,
"SearchType": volcengineSearchTypeWeb,
"Count": volcengineCount(q.Count),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: volcengine: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, volcenginePath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, volcengineSpec())
}
// volcengineCount 把调用方要的条数收进官方允许区间(分册 13:最多 50,默认 10)。
func volcengineCount(n int) int {
if n <= 0 {
return volcengineDefaultCount
}
if n > volcengineMaxCount {
return volcengineMaxCount
}
return n
}
// volcengineSpec 是火山·豆包搜索响应的**候选**形状。
//
// TODO(未确证):分册 13 只逐字取到了请求字段;响应结构(外层 Result.WebResults 与
// 每条结果的 Title/Url/Snippet/Summary/PublishTime/RankScore 等)**未确证**。
// 这里按可信度从高到低列候选键做容错解析 —— 它是「尽量认得出来」,不是「已确证」;
// 拿到真实 key 后必须按官方文档复核并删掉不成立的候选。
func volcengineSpec() spec {
return spec{
Arrays: []string{"Result.WebResults", "WebResults", "results", "Result.Results"},
Title: []string{"Title", "title"},
Link: []string{"Url", "url", "Link"},
Snippet: []string{
"Snippet", "snippet", "Summary", "summary",
},
Content: []string{"Content", "content", "Summary", "summary"},
Published: []string{"PublishTime", "publishTime", "PublishTimeMs"},
Score: []string{"RankScore", "rankScore", "Score", "score"},
}
}
+74
View File
@@ -0,0 +1,74 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。
//
// 认证:`Authorization: Bearer <api_key>`;纯 REST(POST + JSON)⇒ 零新依赖。
// 官方**定价已确证**(分册 11 的官方定价页逐字):Search-Std 0.01 元/次、
// Search-Pro 0.03、Search-Pro-Sogou 0.05、Search-Pro-Quark 0.05。
//
// ⚠️ 诚实边界(分册 11「未确证」):**完整请求/响应 JSON schema 未取到**,只确证了
// 字段名(请求侧 search_engine/search_query/…,响应侧 title/link/content)。
// 因此这里:① 请求只发确证过的两个字段;② 响应用共享提取器按**多组候选键**容错,
// 不把任何猜测的字段当事实。拿到真实 key 后应按官方 API Reference 复核并收窄候选键。
type zhipu struct{ cfg Config }
// zhipuPath 是联网搜索端点路径(分册 11 官方原文:POST https://open.bigmodel.cn/api/paas/v4/web_search)。
const zhipuPath = "/api/paas/v4/web_search"
// zhipuEngineStd 是默认搜索型号。取 `search_std`(自研基础搜索,官方定价页确证
// 0.01 元/次,四档里最便宜)。⚠️ `search_engine` 是否可选官方未明说(分册 11),
// 显式写死一档以固定成本;要换档请改这里或后续开放为配置项。
const zhipuEngineStd = "search_std"
// newZhipu 造一家智谱 provider。
func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} }
// Name 实现 Provider。
func (p *zhipu) Name() string { return NameZhipu }
// Search 实现 Provider。
func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) {
// TODO(未确证):响应可能很大(自带网页正文,分册 11 提示注意本仓 1 MiB 上限)。
// 官方另有 `content_size` 可限正文长度,但其取值域未确证 ⇒ 不写死,留待拿到
// 真实 key 后按官方文档补上(当前超限会如实返回 ErrResponseTooLarge)。
payload := map[string]any{
"search_engine": zhipuEngineStd,
"search_query": q.Text,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: zhipu: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, zhipuPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, zhipuSpec())
}
// zhipuSpec 是智谱响应的候选形状。官方示例以「工具块」给出 WebBrowserOutput{title,link,content}
// (分册 11),故 title/link/content 是确证键;外层数组键名未确证 ⇒ 给多组候选。
func zhipuSpec() spec {
return spec{
Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"},
Title: []string{"title", "name"},
Link: []string{"link", "url"},
Snippet: []string{"snippet", "summary", "abstract"},
Content: []string{"content", "text"},
// 官方示例未见发布时间/评分字段 ⇒ 只给通用候选,取不到就是空。
Published: []string{"publish_date", "published", "date"},
Score: []string{"score", "rank"},
}
}