package search import ( "encoding/json" "fmt" "net/url" "sort" "strconv" "strings" ) // spec 描述「怎么把一家 provider 的 JSON 归一到 []Result」:每个字段给一串**候选键名** // (按序取第一个非空值),结果数组给一串**候选路径**(点分,按序取第一个是数组的)。 // // 各家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。 // 特例:腾讯云元宝搜索的 `Pages` 是「JSON 字符串数组」,本提取器吃不下(它只认对象数组), // 那一家自带一次**二次解析**后仍复用本文件的字段挑选器(见 tencent_wsa.go 的 tencentParse)。 // 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度/秘塔)用多组候选键容错, // 而不是把某个猜测的字段当事实写死(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)→ 按 Score 降序 // **稳定**排序 → 截断到 Limit。 // // 排序用 SliceStable:分数相同(多数上游不给分)时保持上游给出的原始顺序,不制造随机。 // // 🔴 「上游没给链接」**不是**「不是一条结果」(2026-10-07 真机缺陷的修复,回归用例 // `plugins/tools/io/web_search_zhipu_linkless_test.go`): // // 智谱 `search_std` 的 `search_result[].link` 在真机上**经常整批是空串**(实测同一 // 查询连打六次,10 条的 link 全为空,而 title/content 齐全)。旧口径「空 link ⇒ 丢弃」 // 会把上游明明返回的 10 条**整批丢光**,工具层于是报 `ErrNoResults` —— 而面板探针 // (走 Probe,不过 Finalize)报的是 `{ok:true, count:1}`:同一份上游、同一份解析代码 // 给出两个相反的结论,且工具层那句「一家都没查到」是**谎报**。 // // 因此:没有链接的条目**照原样保留**(title/content 对模型仍然有用,链接空着就空着, // 绝不编一个出来);只有 link/title/content/snippet **全空**的空壳行才不算结果。 // 无链接的条目**不参与按 link 的去重** —— 没有链接就没有判同的身份,宁可重复展示, // 也不静默吞掉上游给的结果。 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 emptyResult(r) { // 四个可读字段全空的空壳行:没有任何东西可以给模型看,不算一条结果。 continue } if !domainAllowed(r.Link, opt.AllowDomains, opt.BlockDomains) { continue } if r.Link != "" { 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 } // emptyResult 报告一条结果是否**四个可读字段全空**(链接 / 标题 / 正文 / 摘要都没有)。 // // 判据只放「全空」这一种:任一项非空都算有内容(含「只有标题、没有链接」—— 智谱真机 // 就是这种形态)。刻意**不**把 Published/Score 算进来:只有发布时间的行给不了模型任何 // 可读内容,是上游的空壳而不是一条结果。 func emptyResult(r Result) bool { return r.Link == "" && strings.TrimSpace(r.Title) == "" && strings.TrimSpace(r.Content) == "" && strings.TrimSpace(r.Snippet) == "" } // 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() }