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