主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
238 lines
7.0 KiB
Go
238 lines
7.0 KiB
Go
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()
|
||
}
|