Files
Cyrene_v2/plugins/tools/io/search/extract.go
T
AskaEth 69a3b73777 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),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

238 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}