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

+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()
}