Files
Cyrene_v2/plugins/tools/io/search/extract.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
2026-10-07 13:42:41 +08:00

269 lines
9.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)。
// 特例:腾讯云元宝搜索的 `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()
}