接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
201 lines
11 KiB
Go
201 lines
11 KiB
Go
package main
|
||
|
||
// 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线
|
||
// 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。
|
||
//
|
||
// 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …,
|
||
// 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的
|
||
// 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。
|
||
//
|
||
// 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表):
|
||
//
|
||
// - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`;
|
||
// - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配;
|
||
// - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次
|
||
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
|
||
//
|
||
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
|
||
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
|
||
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// effectiveWebSearchConfig 是一次装配的**生效配置**,分两层:
|
||
//
|
||
// - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。
|
||
// 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面
|
||
// (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。
|
||
// - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。
|
||
//
|
||
// 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要
|
||
// 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成
|
||
// 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。
|
||
type effectiveWebSearchConfig struct {
|
||
// Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。
|
||
Scalar config.WebSearchConfig
|
||
// Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。
|
||
Providers []searchProviderWire
|
||
}
|
||
|
||
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
|
||
// searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。
|
||
type searchProviderWire struct {
|
||
Name string
|
||
APIKey string
|
||
BaseURL string
|
||
}
|
||
|
||
// searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表,
|
||
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
|
||
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
|
||
//
|
||
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
|
||
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
|
||
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
|
||
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
|
||
// 这条口径由 `wire_web_search_panel_test.go` 的
|
||
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
|
||
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
|
||
return []searchProviderWire{
|
||
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
|
||
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
|
||
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
|
||
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
|
||
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
|
||
}
|
||
}
|
||
|
||
// envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT)
|
||
// 打头,其余各家跟随 —— 顺序与既有播种逐字一致。
|
||
func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire {
|
||
out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1)
|
||
out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)})
|
||
return append(out, searchProviderVector(ws)...)
|
||
}
|
||
|
||
// effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。
|
||
//
|
||
// 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家
|
||
// provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。
|
||
func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire {
|
||
return resolveSearchProviders(search.Descriptors(), f)
|
||
}
|
||
|
||
// resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**:
|
||
//
|
||
// 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准:
|
||
// 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」
|
||
// 会变成一句假话);
|
||
// 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处);
|
||
// 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦
|
||
// 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。
|
||
//
|
||
// ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该
|
||
// 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它:
|
||
// ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒
|
||
// 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot
|
||
// ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」
|
||
// 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。
|
||
//
|
||
// 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根
|
||
// 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。
|
||
//
|
||
// defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动
|
||
// 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。
|
||
func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire {
|
||
out := make([]searchProviderWire, 0, len(defs))
|
||
for _, d := range defs {
|
||
// searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl),
|
||
// 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。
|
||
if strings.EqualFold(d.Name, search.NameSearXNG) {
|
||
continue
|
||
}
|
||
entry, found := findProviderEntry(f, d.Name)
|
||
if !found || !entry.IsEnabled() {
|
||
out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)})
|
||
continue
|
||
}
|
||
out = append(out, searchProviderWire{
|
||
Name: d.Name,
|
||
APIKey: strings.TrimSpace(entry.APIKey),
|
||
BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址
|
||
// → 空。
|
||
//
|
||
// 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义,
|
||
// 回落一个本机地址会让「清空地址」变成假话。
|
||
func baseURLChain(fromEntry, accessSlotDefault string) string {
|
||
if s := strings.TrimSpace(fromEntry); s != "" {
|
||
return s
|
||
}
|
||
return accessSlotDefault
|
||
}
|
||
|
||
// resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表
|
||
// 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。
|
||
//
|
||
// 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env
|
||
// 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。
|
||
func resolveBaseURL(name, fromEntry string) string {
|
||
return baseURLChain(fromEntry, search.DefaultBaseURL(name))
|
||
}
|
||
|
||
// searchProviderSpecs 把生效配置翻译成各家接线。
|
||
//
|
||
// searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」);
|
||
// 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定
|
||
// (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。
|
||
//
|
||
// **密钥只在这里流转,绝不写日志。**
|
||
func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec {
|
||
endpoint := ws.ResolveEndpoint()
|
||
specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1)
|
||
specs = append(specs, iotools.SearchProviderSpec{
|
||
Name: search.NameSearXNG,
|
||
BaseURL: endpoint.URL,
|
||
Configured: endpoint.Enabled,
|
||
})
|
||
for _, p := range providers {
|
||
specs = append(specs, iotools.SearchProviderSpec{
|
||
Name: p.Name,
|
||
APIKey: p.APIKey,
|
||
BaseURL: p.BaseURL,
|
||
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
|
||
})
|
||
}
|
||
return specs
|
||
}
|
||
|
||
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
|
||
// **不含任何密钥** —— 只回名字。
|
||
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
||
out := make([]string, 0, len(specs))
|
||
for _, s := range specs {
|
||
if s.Configured {
|
||
out = append(out, s.Name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串
|
||
// (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。
|
||
func seedEndpoint(endpoint string) string {
|
||
if config.IsWebSearchOffSentinel(endpoint) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(endpoint)
|
||
}
|