dsh-free-search 0.9.13 → 0.9.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,12 +32,12 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
32
32
  - **多引擎可选**:DuckDuckGo(html/lite)、Bing、SearXNG(元搜索,支持自定义实例)、AnySearch、Exa、Tavily、Keenable、Firecrawl、Parallel、Perplexity、SerpBase、Serply、DeepSeek 官方、DeepSeek 原生搜索 API、You.com、百度千帆、Kimi、阿里云百炼、火山联网搜索(豆包搜索)、知乎全网/站内(`zhihu_global`/`zhihu_site`,经知乎开放平台 MCP)、OpenAI / Gemini / Claude 模型内置搜索
33
33
  - **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换;入口在左侧「插件」页的组件行配置(`plugins.row.config`,DSH 0.1.7-rc.1+)。远程访问(VPS / 反向代理 / 自定义域名)下也能用:桥接接口沿用宿主的 Host 围栏,`--trusted-host` 与 `--public-url` 声明的地址会放行(#84)
34
34
  - **右侧浮动目录** —— 卡片右侧的页面留白里浮出「引擎 / 回退 / 语言池 / 搜索行为 / 抓取 / 性能」六个快捷跳转,滚动自动高亮当前项、点击平滑跳转;不占用正文宽度(正文布局与 0.9.6 一致),右侧留白不足时自动隐藏
35
- - **多 key 轮换** —— 任何 key 字段(或对应环境变量)都可以用逗号 / 分号 / 换行分隔多把 key;遇到 401/402/429(鉴权失败、额度耗尽)先轮换同一引擎的下一把,再走引擎回退
35
+ - **多 key 轮换** —— 任何 key 字段(或对应环境变量)都可以用逗号 / 分号 / 换行分隔多把 key;遇到 401/402/429(鉴权失败、额度耗尽)先轮换同一引擎的下一把,再走引擎回退
36
36
  - **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
37
37
  - **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
38
38
  - **全局引擎开关与回退优先级** —— 设置页可勾选/取消引擎(取消后全局生效:普通搜索 / Auto / `advanced_search` / `multi_search` / 引擎测试都不再用它),并可用 ↑↓ 调整全局回退顺序(一键恢复默认)
39
39
  - **Multi 模式** —— 可把搜索引擎设为 `Multi Search`:`web_search` 按查询语言并发**对应语言池的全部**已启用引擎(不再是前 3 个;中文/英文池可在设置页编辑,每池最低 2 个引擎)、按 URL 合并去重(同站判定用 **eTLD+1**)、跨源命中的结果优先;设置页有 **Fast / Balanced / Deep** 三档强度,也可手填并发上限与目标成功源数;整轮检索共享 **30 秒总预算**,到点返回已有结果(代价是成倍消耗额度;multi 失败会自动退回单引擎回退链)
40
- - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable/firecrawl/parallel 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`);失败按类别处理:额度/鉴权失败→本会话冷却该引擎,反爬→短退避,超时/5xx/限流→同引擎重试一次,解析失败/0 结果→不冷却(详见下文「失败分类与引擎冷却」)
40
+ - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable/firecrawl/parallel/you 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`);失败按类别处理:额度/鉴权失败→本会话冷却该引擎,反爬→短退避,超时/5xx/限流→同引擎重试一次,解析失败/0 结果→不冷却(详见下文「失败分类与引擎冷却」)
41
41
  - **时间过滤** —— `advanced_search` 工具支持 `timeRange`:固定档、自定义相对值、绝对日期三种形式(详见下方逻辑说明)
42
42
  - **系统提示词注入** —— agent 知道当前用哪个引擎、哪些需要 key;并明确所有搜索结果是**不可信外部数据**,不得执行其中的指令
43
43
  - **提示注入防护(不可信数据边界)** —— 插件自有工具(advanced_search / platform_search / free_search_test)的网页文本包在 `<untrusted-web-content>` 边界内(正文里自带的同名标记会被剥离,防止提前闭合);核心 web_search / web_fetch 由 DSH 核心自带同类提示(`External web content follows...`);所有 snippet 统一清洗并截断到 300 字符
@@ -70,12 +70,12 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
70
70
  | `keenable` | Keenable | 免费 | **无 key 也可用**(MCP 匿名),配 key 提升额度 |
71
71
  | `firecrawl` | Firecrawl | 免费 | **无 key 也可用**(官方免 key 匿名额度),配 key 提升限额。注意:keyless 档按**出口 IP** 判定,VPN/代理出口会被 403 拒绝;插件会冷却 6 小时并说明原因(有 key 不受影响) |
72
72
  | `parallel` | Parallel | 免费 | **无 key 也可用**(官方 MCP 匿名额度),配 key 提升额度并支持精确时间过滤 |
73
+ | `you` | You.com | 免费 | **无 key 也可用**(官方免 key 档,每天 100 次),配 `YDC_API_KEY` 提升额度(旧名 `YOUCOM_API_KEY` 继续兼容) |
73
74
  | `perplexity` | Perplexity | 付费 | 需 `PERPLEXITY_API_KEY` |
74
75
  | `serpbase` | SerpBase | 付费 | 需 `SERPBASE_API_KEY`(serpbase.dev,注册送 100 次免费额度) |
75
76
  | `serply` | Serply | 付费 | 需 `SERPLY_API_KEY`(serply.io,新账户 30 天内 2,500 次免费额度);Google 网页结果,按设置页语言本地化 |
76
77
  | `deepseek-official` | DeepSeek 官方 | 付费 | 需 `DEEPSEEK_API_KEY`;走 Anthropic 兼容的 `web_search` 服务端工具,每次搜索是一整个模型轮次(慢、耗 token) |
77
78
  | `deepseek-tools` | DeepSeek 原生搜索 API | 付费 | 需 `DEEPSEEK_API_KEY`(与上一档共用);直接 `POST /v1/tools/search`,无模型参与,返回结构化 `results[]`(`title`/`url`/`summary`/`date`),同 query 实测 ~0.3s vs 上一档 ~5-6s |
78
- | `you` | You.com | 付费 | 需 `YOUCOM_API_KEY`(you.com/platform/api-keys,注册即送免费额度) |
79
79
  | `baidu` | 百度千帆 AI 搜索 | 额度 | 需 `BAIDU_API_KEY`(千帆 AI 搜索,每日赠 50 次,超额按量后付费);中文全网,支持时间过滤 |
80
80
  | `kimi` | Kimi(Moonshot)联网搜索 | 付费 | 需 `MOONSHOT_API_KEY`(basic 约 ¥0.01/次),返回带正文 chunks 的中文结果;**无免费额度**,适合显式选用或作质量升级池,不建议放进默认并发池 |
81
81
  | `kimi` | Kimi(Moonshot)联网搜索 | 付费 | 需 `MOONSHOT_API_KEY`(basic 约 ¥0.01/次),返回带正文 chunks 的中文结果 |
@@ -110,9 +110,9 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
110
110
  | 档位 | 引擎 | 超出后 |
111
111
  |---|---|---|
112
112
  | ① 无限免 key | `ddg` / `ddg-lite` / `bing` / `searxng` / `anysearch` | — |
113
- | ② 匿名额度(不配 key 也能跑) | `exa` / `tavily` / `keenable` / `firecrawl` / `parallel` | 配 key 提额 |
113
+ | ② 匿名额度(不配 key 也能跑) | `exa` / `tavily` / `keenable` / `firecrawl` / `parallel` / `you`(每天 100 次) | 配 key 提额 |
114
114
  | ③ 免费额度(需 key) | `zhihu_global` / `zhihu_site`(每日 5,000 次试用)、`doubao`(每月 500 次)、`baidu`(每日 50 次)、`aliyun`(新用户 200 次包) | 按量后付费 |
115
- | ④ 付费升级(需 key 才有产出) | `serpbase`(注册送 100 次)/ `serply`(30 天 2,500 次)/ `you` / `perplexity` / `deepseek-official` / `deepseek-tools` / `kimi` | 按量 / 按次 |
115
+ | ④ 付费升级(需 key 才有产出) | `serpbase`(注册送 100 次)/ `serply`(30 天 2,500 次)/ `perplexity` / `deepseek-official` / `deepseek-tools` / `kimi` | 按量 / 按次 |
116
116
  | ⑤ 按次计费(模型内置,显式-only) | `openai` / `gemini` / `claude` | 按 token + 每次搜索 |
117
117
 
118
118
  选默认池的经验:**① 打底 + ③ 打主力**;② 可以进但要知道它在烧匿名额度;④ 只在你确实需要该源时显式选用;⑤ 永不入池。完整的逐引擎额度事实与默认池提案见 [docs/engine-quota-tiers.md](./docs/engine-quota-tiers.md)。
@@ -124,6 +124,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
124
124
  - **Tavily**:通过 `x-tavily-access-mode: keyless` 头走 keyless 匿名额度,不配 key 即可用;配置 `TAVILY_API_KEY` 后走账号档,额度更高、结果质量更稳定。
125
125
  - **Keenable**:无 key 时走其公开 MCP 端点(`api.keenable.ai/mcp`)匿名调用;配置 `KEENABLE_API_KEY` 后走 REST API(`api.keenable.ai/v1/search`),额度更高、按组织限流。
126
126
  - **Firecrawl**:其 `/v2/search` 端点**无需 key** 即可使用(官方文档明确说明,有匿名限流);配置 `FIRECRAWL_API_KEY` 后可提高限额。支持 `tbs` 时间过滤(`qdr:h/d/w/m/y` 与自定义日期区间)。
127
+ - **You.com**:无 key 时走官方免 key 档(`api.you.com/v1/agents/search`),每天 100 次免费搜索,无需注册;配置 `YDC_API_KEY`(旧名 `YOUCOM_API_KEY` 继续兼容)后走官方 Search API(`ydc-index.io/v1/search`),额度更高。
127
128
 
128
129
  ### 安装
129
130
 
@@ -205,7 +206,7 @@ DSH 0.2.1 设置 → 网页搜索 → **DeepSeek 搜索 API** 开关会和本插
205
206
  配置页提供:
206
207
 
207
208
  - **Search engine**:下拉框切换引擎,保存即生效
208
- - **API keys**:为 Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek / SerpBase / Serply / You.com 填写 key(密码框,保存后只显示"已配置";Exa / Tavily / Keenable / Firecrawl / Parallel 不填也可免 key 使用)
209
+ - **API keys**:为 Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek / SerpBase / Serply / You.com 填写 key(密码框,保存后只显示"已配置";Exa / Tavily / Keenable / Firecrawl / Parallel / You.com 不填也可免 key 使用)
209
210
  - **推荐**:付费引擎 key 建议写入 harness 凭据中心 `~/.dsh/.credentials.yaml`(如 `DEEPSEEK_API_KEY: sk-...`,与官方 LLM provider 一致,一处管理所有 key)。插件读取优先级:凭据中心 > 设置页 > 环境变量,设置页填的 key 仅作为遗留兼容。
210
211
  - **Test engine**:直测当前引擎可用性(不走回退链,付费引擎无 key 会明确报错)
211
212
  - **Use Bing default**:把当前搜索引擎切回稳定的免费 Bing;`Discard` 只撤销尚未保存的编辑
@@ -305,9 +306,10 @@ DSH 0.1.7-rc.1 起,配置跟随 profile 的插件条目保存:设置页与 `
305
306
  # profiles/<profile>/cordis.patch.yml 中该条目的 config:
306
307
  provider: bing # ddg / ddg-lite / bing / searxng / anysearch / exa / tavily / keenable / firecrawl / parallel / perplexity / serpbase / serply / deepseek-official / deepseek-tools / you / baidu / kimi / aliyun / doubao / zhihu_global / zhihu_site / openai / gemini / claude / auto / multi
307
308
  fallbackOn: [quota, auth, bot-wall, transient, invalid-response, unknown] # 允许触发回退的失败类别;[] = 任何失败都立即中止(默认全部)
308
- lang: zh # 搜索语言池/本地化(zh / en / ru …,不再被设置页强行改写)
309
- # lang 决定:Bing 的 mkt/Accept-Language、Wikipedia 子域(ja → ja.wikipedia.org)、地理编码语言;
310
- # 我们自己渲染的文案只有中文/英文两套,其它语言(ja/ru/de…)一律走英文,避免回答前先塞一段中文(#92)。
309
+ lang: zh # 搜索语言池/本地化(zh / en / ru …,不再被设置页强行改写)
310
+ # lang 决定:Bing 的 mkt/Accept-Language、Wikipedia 子域(ja → ja.wikipedia.org)、地理编码语言;
311
+ # 我们自己渲染的文案只有中文/英文两套,其它语言(ja/ru/de…)一律走英文,避免回答前先塞一段中文(#92);
312
+ # weather 例外,另有日文(ja 得到日文天气,ru/de… 仍走英文)。
311
313
  # web_fetch(fs-quality-fetch)的 Accept-Language 同样跟随 lang,不再固定中文(#92)。
312
314
  uiLang: zh # 设置页界面语言(zh / en);留空则跟随 lang
313
315
  bingMarket: zh-CN # Bing 市场;留空 = 自动(不发送 mkt)
@@ -510,12 +512,12 @@ Search engine test:
510
512
  | `assert-killtree-win32.mjs` | Windows 子进程树终止(#74):不再依赖 `taskkill` | `node tools/assert-killtree-win32.mjs`(8) |
511
513
  | `assert-killtree-portable.mjs` | 跨平台 `killTree` 便携版契约 | `node tools/assert-killtree-portable.mjs`(5) |
512
514
  | `assert-parallel-mcp.mjs` | Parallel MCP 引擎:25 s 超时/调用方取消覆盖**响应体读取**、共享 SSE 解析器(通知/多行帧)、6 类错误路径、REST 精确时间过滤 | `node tools/assert-parallel-mcp.mjs`(15) |
513
- | `assert-output-language.mjs` | 语言分支(#92):`searchLanguage` 支持 zh/en/ja/ru/de/es/fr/ko 其余回退 en、`outputLanguage` 只输出 zh/en、天气文案 ja→英文、置信度说明随语言、失败备注随语言 | `node tools/assert-output-language.mjs`(8) |
514
- | `assert-uncertainty.mjs` | 输出契约:0/1 源为 low、2 源与同站为 medium、3 个不同域为 high、时间过滤无日期、失败备注、聚合站提示 | `node tools/assert-uncertainty.mjs`(8) |
515
- | `assert-multikey.mjs` | 多 key 字段:单 key 不变、逗号/分号/换行分隔、空白项丢弃、空输入为空数组 | `node tools/assert-multikey.mjs`(3) |
516
- | `assert-firecrawl-keyless.mjs` | Firecrawl keyless 出口判定:403 + VPN/proxy 文案才算拒绝、普通 403 与 401/429/500 不算、冷却初始未触发 | `node tools/assert-firecrawl-keyless.mjs`(5) |
517
- | `assert-fetch-charset.mjs` | 抓取编码(#88):`charset=gbk` / `gb2312` 按声明解码、无声明时嗅探 `<meta charset>` 与 http-equiv 形式、默认 UTF-8、未知 label 兜底 UTF-8 不抛错 | `node tools/assert-fetch-charset.mjs`(6) |
518
- | `assert-fetch-abort.mjs` | 抓取中断(#87):压缩响应中途断连必须 reject(不再永久 pending)、明文断连同样 reject、abort 能打断停滞的 gzip 流、complete 响应与重定向仍正常 | `node tools/assert-fetch-abort.mjs`(6) |
515
+ | `assert-output-language.mjs` | 语言分支(#92):`searchLanguage` 支持 zh/en/ja/ru/de/es/fr/ko 其余回退 en、`outputLanguage` 只输出 zh/en、天气文案 zh/en/ja(其它回退英文,含 WMO 描述与正文)、置信度说明随语言、失败备注随语言 | `node tools/assert-output-language.mjs`(10) |
516
+ | `assert-uncertainty.mjs` | 输出契约:0/1 源为 low、2 源与同站为 medium、3 个不同域为 high、时间过滤无日期、失败备注、聚合站提示 | `node tools/assert-uncertainty.mjs`(8) |
517
+ | `assert-multikey.mjs` | 多 key 字段:单 key 不变、逗号/分号/换行分隔、空白项丢弃、空输入为空数组 | `node tools/assert-multikey.mjs`(3) |
518
+ | `assert-firecrawl-keyless.mjs` | Firecrawl keyless 出口判定:403 + VPN/proxy 文案才算拒绝、普通 403 与 401/429/500 不算、冷却初始未触发 | `node tools/assert-firecrawl-keyless.mjs`(5) |
519
+ | `assert-fetch-charset.mjs` | 抓取编码(#88):`charset=gbk` / `gb2312` 按声明解码、无声明时嗅探 `<meta charset>` 与 http-equiv 形式、默认 UTF-8、未知 label 兜底 UTF-8 不抛错 | `node tools/assert-fetch-charset.mjs`(6) |
520
+ | `assert-fetch-abort.mjs` | 抓取中断(#87):压缩响应中途断连必须 reject(不再永久 pending)、明文断连同样 reject、abort 能打断停滞的 gzip 流、complete 响应与重定向仍正常 | `node tools/assert-fetch-abort.mjs`(6) |
519
521
  | `assert-bridge-access.mjs` | 桥接 Host 围栏(#84):loopback 放行、remote peer 打 loopback Host 拒绝、`--trusted-host`(无端口匹配任意端口 / 带端口精确匹配)、`--public-url`、绑定地址、跨站与异源拒绝 | `node tools/assert-bridge-access.mjs`(11) |
520
522
  | `assert-provider-takeover.mjs` | provider 接管:旧属性 API 与 rc.2 的 `config.*` ref 两套兼容、`settings.mutate(web)` 单批写入、显式指定的 provider 不被抢占、`fetchProviderTakeover: false` 时保留显式 `http`、describe 噪声不误报、嵌套 config 变化与 web 重建分别成行、关闭 DSH 搜索开关后重新接管且不抢 `deepseek-search-api`、apply 重入/重复注册不再中断、重复日志去重 | `node tools/assert-provider-takeover.mjs`(16) |
521
523
 
@@ -569,12 +571,12 @@ This plugin provides multiple free search engines with automatic fallback, compl
569
571
  - **Multi-Engine Support** — DuckDuckGo (HTML / Lite), Bing, AnySearch AI, SearXNG (meta-search with custom instances), Exa, Tavily, Keenable, Firecrawl, Parallel, Perplexity, SerpBase, Serply, DeepSeek Official, You.com, Baidu Qianfan, Kimi, Aliyun Bailian, Volcano Doubao, Zhihu web-wide/site search (`zhihu_global`/`zhihu_site`, via the Zhihu open-platform MCP), and the model-based OpenAI / Gemini / Claude searches
570
572
  - **Web Settings UI** — Engine switching, API key configuration (keys masked as "configured" in the UI), and a Chinese/English toggle; the entry is the component-row config (`plugins.row.config`) on the sidebar Plugins page (DSH 0.1.7-rc.1+). Remote access (VPS, reverse proxy, custom domain) works too: the bridge reuses the host's own Host fence, so addresses declared with `--trusted-host` or `--public-url` are accepted (#84)
571
573
  - **Right-hand section rail** — A floating list (Engines / Fallback / Language pools / Search behaviour / Fetch / Performance) sits in the page margin to the right of the card: it highlights the section you are in while scrolling and jumps smoothly on click. It is rendered in a portal and never narrows the card, so the body layout is unchanged; it hides itself when the margin is too tight.
572
- - **Multi-key rotation** — Any key field (or its env var) accepts several keys separated by commas, semicolons or newlines; on 401/402/429 (bad key, quota exhausted) the same engine retries with the next key before the engine-level fallback runs
574
+ - **Multi-key rotation** — Any key field (or its env var) accepts several keys separated by commas, semicolons or newlines; on 401/402/429 (bad key, quota exhausted) the same engine retries with the next key before the engine-level fallback runs
573
575
  - **Popup Switch Command** — Type `/free-search-engine` in the chat: a picker opens with all engines; click one to switch (equivalent to the settings page + save)
574
576
  - **Engine Testing** — `free_search_test` for the agent to check all engines in one call; the settings UI also has a "Test engine" button that tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error)
575
577
  - **Global engine enable/disable & fallback priority** — uncheck engines you never want (applies everywhere: web_search fallback, Auto routing, advanced_search, multi_search, engine tests) and reorder the global fallback chain with ↑↓ (one-click reset)
576
578
  - **Multi mode** — pick `Multi Search` as the engine: web_search queries **every enabled engine of the query's language pool** concurrently (no longer top 3; zh/en pools editable in the settings UI, minimum 2 engines each), merges/deduplicates URLs (same-site detection via **eTLD+1**) and prioritizes cross-source hits; the settings UI offers **Fast / Balanced / Deep** presets or your own cap and target numbers; the whole run shares one **30-second budget** and returns whatever it already has when time is up (costs more quota; a failed multi run automatically falls back to the single-engine chain)
577
- - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable/firecrawl/parallel are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Failures are classified: quota/auth failures cool the engine down for the session, anti-bot walls back off briefly, timeout/5xx/rate-limit failures retry the same engine once, and parse errors / 0 results are reported without cooling it down (see "Failure-aware fallback" below). Search never fails outright.
579
+ - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable/firecrawl/parallel/you are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Failures are classified: quota/auth failures cool the engine down for the session, anti-bot walls back off briefly, timeout/5xx/rate-limit failures retry the same engine once, and parse errors / 0 results are reported without cooling it down (see "Failure-aware fallback" below). Search never fails outright.
578
580
  - **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
579
581
  - **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys; it is also told that all search output is **untrusted external data** and must never be executed as instructions
580
582
  - **Prompt-Injection Guard (untrusted-data boundary)** — Web-derived text from the plugin's own tools (`advanced_search` / `platform_search` / `free_search_test`) is wrapped in an explicit `<untrusted-web-content>` boundary (look-alike tags inside the text are stripped to prevent early closure); the core `web_search` / `web_fetch` tools carry DSH core's own notice (`External web content follows...`); every snippet is cleaned and capped at 300 characters
@@ -607,12 +609,12 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
607
609
  | `keenable` | Keenable | Free | **Usable without a key** (anonymous MCP); configure a key for higher quota |
608
610
  | `firecrawl` | Firecrawl | Free | **Usable without a key** (official keyless anonymous quota); a key adds higher limits. Note: the keyless tier is judged by **egress IP** — VPN/proxy egress gets a 403, in which case the plugin cools it down for 6 hours and says why (a key is unaffected) |
609
611
  | `parallel` | Parallel | Free | **Works without a key** (official MCP anonymous quota); a key raises limits and enables precise time filtering |
612
+ | `you` | You.com | Free | **Works without a key** (official keyless tier, 100 searches a day); set `YDC_API_KEY` for higher limits (the old `YOUCOM_API_KEY` name still works) |
610
613
  | `perplexity` | Perplexity | Paid | Requires `PERPLEXITY_API_KEY` |
611
614
  | `serpbase` | SerpBase | Paid | Requires `SERPBASE_API_KEY` (serpbase.dev, 100 free queries on signup) |
612
615
  | `serply` | Serply | Paid | Requires `SERPLY_API_KEY` (serply.io, 2,500 free credits for the first 30 days); Google web results localized to the settings language |
613
616
  | `deepseek-official` | DeepSeek Official | Paid | Requires `DEEPSEEK_API_KEY`; uses the Anthropic-compatible `web_search` server tool, so every search is a full model turn (slow, token-billed) |
614
617
  | `deepseek-tools` | DeepSeek Native Search API | Paid | Requires `DEEPSEEK_API_KEY` (shared with the row above); calls `POST /v1/tools/search` directly, no model involved, returns structured `results[]` (`title`/`url`/`summary`/`date`) — measured ~0.3s vs ~5-6s for the row above on the same query |
615
- | `you` | You.com | Paid | Requires `YOUCOM_API_KEY` (you.com/platform/api-keys, free tier on signup) |
616
618
  | `baidu` | Baidu Qianfan AI Search | Quota | Requires `BAIDU_API_KEY` (Qianfan AI Search, 50 free calls/day then pay-as-you-go); Chinese web-wide, supports time filtering |
617
619
  | `kimi` | Kimi (Moonshot) Web Search | Paid | Requires `MOONSHOT_API_KEY` (basic ~¥0.01/call); returns Chinese results with body chunks; **no free quota**, so prefer explicit selection or the escalation pool over the default concurrent pool |
618
620
  | `kimi` | Kimi (Moonshot) Web Search | Paid | Requires `MOONSHOT_API_KEY` (basic ~¥0.01/call); returns Chinese results with body chunks |
@@ -627,7 +629,7 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
627
629
  | `claude` | Claude server-side web_search | Paid | Requires `ANTHROPIC_API_KEY`; results come from `web_search_tool_result`. **Billed per search, explicit selection only**; default `claude-sonnet-5-5`, model/endpoint configurable (`claudeModel` / `claudeBaseUrl`) |
628
630
 
629
631
  - **Default engine is `bing`** (free and most stable), ready to use out of the box after installation.
630
- - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — the configured engine first, then other engines (exa/tavily/keenable/firecrawl/parallel are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright because of engine issues.
632
+ - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — the configured engine first, then other engines (exa/tavily/keenable/firecrawl/parallel/you are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright because of engine issues.
631
633
  - **Official Links in Settings**: Free engines display "Visit Website →", while paid engines display "Get API Key →" (opens in a new tab):
632
634
  - Exa: <https://dashboard.exa.ai/api-keys>
633
635
  - Tavily: <https://app.tavily.com/home>
@@ -648,9 +650,9 @@ The free/paid binary is not what decides **who belongs in a default pool** — t
648
650
  | Tier | Engines | Past the allowance |
649
651
  |---|---|---|
650
652
  | ① Unlimited, no key | `ddg` / `ddg-lite` / `bing` / `searxng` / `anysearch` | — |
651
- | ② Anonymous quota (runs without a key) | `exa` / `tavily` / `keenable` / `firecrawl` / `parallel` | key raises the quota |
653
+ | ② Anonymous quota (runs without a key) | `exa` / `tavily` / `keenable` / `firecrawl` / `parallel` / `you` (100/day) | key raises the quota |
652
654
  | ③ Free quota (key required) | `zhihu_global` / `zhihu_site` (5,000/day trial), `doubao` (500/month), `baidu` (50/day), `aliyun` (200-call starter pack) | pay-as-you-go |
653
- | ④ Paid upgrade (needs a key to return anything) | `serpbase` (100 free on signup) / `serply` (2,500 for 30 days) / `you` / `perplexity` / `deepseek-official` / `deepseek-tools` / `kimi` | per call / per query |
655
+ | ④ Paid upgrade (needs a key to return anything) | `serpbase` (100 free on signup) / `serply` (2,500 for 30 days) / `perplexity` / `deepseek-official` / `deepseek-tools` / `kimi` | per call / per query |
654
656
  | ⑤ Per-call billing (model built-in, explicit-only) | `openai` / `gemini` / `claude` | per token + per search |
655
657
 
656
658
  Rule of thumb for a default pool: **① as the floor + ③ as the workhorse**; ② is allowed but remember it burns anonymous quota; ④ only when you actually want that source; ⑤ never enters a pool. Per-engine facts and a default-pool proposal live in [docs/engine-quota-tiers.md](./docs/engine-quota-tiers.md).
@@ -662,6 +664,7 @@ Rule of thumb for a default pool: **① as the floor + ③ as the workhorse**;
662
664
  - **Tavily**: offers keyless anonymous quota via the `x-tavily-access-mode: keyless` header — it works without a key; configuring `TAVILY_API_KEY` switches to the account tier for higher quota and more stable results.
663
665
  - **Keenable**: without a key it is called via its public MCP endpoint (`api.keenable.ai/mcp`); configuring `KEENABLE_API_KEY` switches to the REST API (`api.keenable.ai/v1/search`) for higher quota and organization-scoped rate limits.
664
666
  - **Firecrawl**: its `/v2/search` endpoint works **without a key** out of the box (the official docs state "No API key needed to get started", with anonymous rate limits); configuring `FIRECRAWL_API_KEY` raises the limits. Supports `tbs` time filtering (`qdr:h/d/w/m/y` and custom date ranges).
667
+ - **You.com**: without a key it calls the official keyless tier (`api.you.com/v1/agents/search`), 100 free searches a day with no signup; setting `YDC_API_KEY` (the old `YOUCOM_API_KEY` name still works) switches to the Search API (`ydc-index.io/v1/search`) for higher limits.
665
668
 
666
669
  ### Installation
667
670
 
@@ -743,7 +746,7 @@ After installation, open the config page (DSH 0.1.7-rc.1+):
743
746
  The config page provides:
744
747
 
745
748
  - **Search engine**: Select an engine from the dropdown; changes take effect immediately upon saving.
746
- - **API keys**: Enter keys for Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek (password fields; displayed as "configured" once saved; Exa / Tavily / Keenable / Firecrawl / Parallel work without a key too).
749
+ - **API keys**: Enter keys for Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek / You.com (password fields; displayed as "configured" once saved; Exa / Tavily / Keenable / Firecrawl / Parallel / You.com work without a key too).
747
750
  - **Recommended**: store paid-engine keys in the harness credential center `~/.dsh/.credentials.yaml` (e.g. `DEEPSEEK_API_KEY: sk-...`, same as the official LLM providers — one place for all keys). Resolution order: credentials center > settings page > environment variable; the settings-page fields remain for backward compatibility.
748
751
  - **Test engine**: Tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error).
749
752
  - **Use Bing default**: stage a switch back to the stable free Bing engine; `Discard` only cancels unsaved edits
@@ -843,9 +846,10 @@ The old `free-search:` section of `~/.dsh/settings.yaml` is **not** imported by
843
846
  # config of that entry in profiles/<profile>/cordis.patch.yml:
844
847
  provider: bing # ddg / ddg-lite / bing / searxng / anysearch / exa / tavily / keenable / firecrawl / parallel / perplexity / serpbase / serply / deepseek-official / deepseek-tools / you / baidu / kimi / aliyun / doubao / zhihu_global / zhihu_site / openai / gemini / claude / auto / multi
845
848
  fallbackOn: [quota, auth, bot-wall, transient, invalid-response, unknown] # failure classes allowed to trigger fallback; [] = abort on any failure (default: all)
846
- lang: zh # search language pools / localization (zh / en / ru …; no longer rewritten by the settings page)
847
- # lang drives Bing's mkt/Accept-Language, the Wikipedia subdomain (ja → ja.wikipedia.org) and the geocoding language;
848
- # text this plugin renders itself only exists in Chinese and English, so other languages (ja/ru/de…) get English rather than a wall of Chinese (#92).
849
+ lang: zh # search language pools / localization (zh / en / ru …; no longer rewritten by the settings page)
850
+ # lang drives Bing's mkt/Accept-Language, the Wikipedia subdomain (ja → ja.wikipedia.org) and the geocoding language;
851
+ # text this plugin renders itself only exists in Chinese and English, so other languages (ja/ru/de…) get English rather than a wall of Chinese (#92);
852
+ # weather also has Japanese (ja gets Japanese weather text, ru/de… still get English).
849
853
  # web_fetch (fs-quality-fetch) sends the same Accept-Language instead of a fixed Chinese one (#92).
850
854
  uiLang: zh # settings UI language (zh / en); empty = follow lang
851
855
  bingMarket: zh-CN # Bing market; empty = automatic (no mkt sent)
@@ -1048,12 +1052,12 @@ Restart `dsh web` after switching to apply changes.
1048
1052
  | `assert-killtree-win32.mjs` | Windows process-tree termination (#74): no longer relies on `taskkill` | `node tools/assert-killtree-win32.mjs` (8) |
1049
1053
  | `assert-killtree-portable.mjs` | portable `killTree` contract | `node tools/assert-killtree-portable.mjs` (5) |
1050
1054
  | `assert-parallel-mcp.mjs` | Parallel MCP engine: the 25 s timeout / caller abort covers the **body read**, shared SSE parser (notifications, multiline frames), six error paths, REST precise time filtering | `node tools/assert-parallel-mcp.mjs` (15) |
1051
- | `assert-output-language.mjs` | language branches (#92): `searchLanguage` passes zh/en/ja/ru/de/es/fr/ko through and falls back to en, `outputLanguage` only returns zh/en, weather text is English for ja, and both confidence and failure notes follow the language | `node tools/assert-output-language.mjs` (8) |
1052
- | `assert-uncertainty.mjs` | output contract: 0/1 source is low, two sources or a single site is medium, three distinct domains is high, time filter without dates, failure notes, aggregator caveat | `node tools/assert-uncertainty.mjs` (8) |
1053
- | `assert-multikey.mjs` | multi-key fields: a single key is unchanged, comma/semicolon/newline separate keys, blank entries are dropped, empty input yields none | `node tools/assert-multikey.mjs` (3) |
1054
- | `assert-firecrawl-keyless.mjs` | Firecrawl keyless egress refusal: only a 403 with the VPN/proxy wording counts, plain 403 and 401/429/500 do not, and the cooldown starts inactive | `node tools/assert-firecrawl-keyless.mjs` (5) |
1055
- | `assert-fetch-charset.mjs` | fetch encoding (#88): `charset=gbk` / `gb2312` decoded as declared, `<meta charset>` and the http-equiv form sniffed when the header omits it, UTF-8 by default, and an unknown label falls back to UTF-8 instead of throwing | `node tools/assert-fetch-charset.mjs` (6) |
1056
- | `assert-fetch-abort.mjs` | fetch interruption (#87): a compressed response cut mid-body rejects instead of hanging forever, identity truncation rejects too, abort breaks a stalled gzip stream, and complete responses plus redirects still work | `node tools/assert-fetch-abort.mjs` (6) |
1055
+ | `assert-output-language.mjs` | language branches (#92): `searchLanguage` passes zh/en/ja/ru/de/es/fr/ko through and falls back to en, `outputLanguage` only returns zh/en, weather text has zh/en/ja (WMO descriptions and body; others fall back to English), and both confidence and failure notes follow the language | `node tools/assert-output-language.mjs` (10) |
1056
+ | `assert-uncertainty.mjs` | output contract: 0/1 source is low, two sources or a single site is medium, three distinct domains is high, time filter without dates, failure notes, aggregator caveat | `node tools/assert-uncertainty.mjs` (8) |
1057
+ | `assert-multikey.mjs` | multi-key fields: a single key is unchanged, comma/semicolon/newline separate keys, blank entries are dropped, empty input yields none | `node tools/assert-multikey.mjs` (3) |
1058
+ | `assert-firecrawl-keyless.mjs` | Firecrawl keyless egress refusal: only a 403 with the VPN/proxy wording counts, plain 403 and 401/429/500 do not, and the cooldown starts inactive | `node tools/assert-firecrawl-keyless.mjs` (5) |
1059
+ | `assert-fetch-charset.mjs` | fetch encoding (#88): `charset=gbk` / `gb2312` decoded as declared, `<meta charset>` and the http-equiv form sniffed when the header omits it, UTF-8 by default, and an unknown label falls back to UTF-8 instead of throwing | `node tools/assert-fetch-charset.mjs` (6) |
1060
+ | `assert-fetch-abort.mjs` | fetch interruption (#87): a compressed response cut mid-body rejects instead of hanging forever, identity truncation rejects too, abort breaks a stalled gzip stream, and complete responses plus redirects still work | `node tools/assert-fetch-abort.mjs` (6) |
1057
1061
  | `assert-bridge-access.mjs` | bridge Host fence (#84): loopback allowed, a remote peer behind a loopback Host refused, `--trusted-host` (port-less matches any port, explicit port matches exactly), `--public-url`, the bind address, cross-site and foreign-Origin refused | `node tools/assert-bridge-access.mjs` (11) |
1058
1062
  | `assert-provider-takeover.mjs` | provider takeover: both the legacy property API and the rc.2 `config.*` refs, a single batched `settings.mutate(web)`, explicit foreign providers left untouched, an explicit `http` kept when `fetchProviderTakeover` is off, describe()-triggered events ignored when nothing changed, nested config changes and web rebuilds reported separately, re-takeover after DSH's search switch is turned off without fighting an explicit `deepseek-search-api`, apply re-entry / duplicate registrations tolerated, and repeated logs deduplicated | `node tools/assert-provider-takeover.mjs` (16) |
1059
1063
 
package/lib/client.js CHANGED
@@ -168,7 +168,7 @@ window.__ModuleLoader__.load({
168
168
  const NS = "web-search-free";
169
169
  const I18N = {
170
170
  zh: {
171
- description: "免费搜索 —— 无需 API key(Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl / Parallel),支持 You.com(API KEY)",
171
+ description: "免费搜索 —— 无需 API key(Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl / Parallel / You.com)",
172
172
  unsaved: "未保存",
173
173
  searchEngine: "搜索引擎",
174
174
  visit: "访问官网 →",
@@ -185,7 +185,7 @@ window.__ModuleLoader__.load({
185
185
  deepseekPh: (c) => c ? "DeepSeek API 密钥(已配置)" : "DeepSeek API 密钥(sk-...)",
186
186
  serpbasePh: (c) => c ? "SerpBase API 密钥(已配置)" : "SerpBase API 密钥(serpbase.dev 获取)",
187
187
  serplyPh: (c) => c ? "Serply API 密钥(已配置)" : "Serply API 密钥(serply.io 获取)",
188
- youcomPh: (c) => c ? "You.com API 密钥(已配置)" : "You.com API 密钥(you.com/platform/api-keys 获取)",
188
+ youcomPh: (c) => c ? "You.com API 密钥(已配置)" : "You.com API 密钥(可选,不填也可免费使用,每天 100 次;YDC_API_KEY)",
189
189
  baiduPh: (c) => c ? "Baidu API 密钥(已配置)" : "百度千帆 API 密钥(BAIDU_API_KEY)",
190
190
  kimiPh: (c) => c ? "Kimi API 密钥(已配置)" : "Kimi API 密钥(MOONSHOT_API_KEY)",
191
191
  aliyunPh: (c) => c ? "Aliyun API 密钥(已配置)" : "阿里云 DashScope 密钥(DASHSCOPE_API_KEY)",
@@ -296,7 +296,7 @@ window.__ModuleLoader__.load({
296
296
  starCta: "行,给你一颗 Star",
297
297
  },
298
298
  en: {
299
- description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl / Parallel), plus You.com (API KEY)",
299
+ description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl / Parallel / You.com)",
300
300
  unsaved: "unsaved",
301
301
  searchEngine: "Search engine",
302
302
  visit: "Visit website →",
@@ -313,7 +313,7 @@ window.__ModuleLoader__.load({
313
313
  deepseekPh: (c) => c ? "DeepSeek API key (configured)" : "DeepSeek API key (sk-...)",
314
314
  serpbasePh: (c) => c ? "SerpBase API key (configured)" : "SerpBase API key (from serpbase.dev)",
315
315
  serplyPh: (c) => c ? "Serply API key (configured)" : "Serply API key (from serply.io)",
316
- youcomPh: (c) => c ? "You.com API key (configured)" : "You.com API key (from you.com/platform/api-keys)",
316
+ youcomPh: (c) => c ? "You.com API key (configured)" : "You.com API key (optional, free without: 100 searches a day; YDC_API_KEY)",
317
317
  baiduPh: (c) => c ? "Baidu API key (configured)" : "Baidu Qianfan API key (BAIDU_API_KEY)",
318
318
  kimiPh: (c) => c ? "Kimi API key (configured)" : "Kimi API key (MOONSHOT_API_KEY)",
319
319
  aliyunPh: (c) => c ? "Aliyun API key (configured)" : "Aliyun DashScope API key (DASHSCOPE_API_KEY)",
@@ -426,7 +426,7 @@ window.__ModuleLoader__.load({
426
426
  };
427
427
  const tt = (lang) => I18N[String(lang ?? "").trim().toLowerCase() === "zh" ? "zh" : "en"];
428
428
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
429
- const PLUGIN_VERSION = "0.9.13";
429
+ const PLUGIN_VERSION = "0.9.15";
430
430
  const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
431
431
  // 右侧浮动目录(#85):条目顺序即页面顺序,anchor 是页面里对应 field 的 id。
432
432
  const NAV_ITEMS = [
@@ -465,7 +465,7 @@ window.__ModuleLoader__.load({
465
465
  { id: "serply", label: "Serply · Google", badge: "API KEY", link: "https://serply.io" },
466
466
  { id: "deepseek-official", label: "DeepSeek Official", badge: "API KEY", link: "https://platform.deepseek.com/api_keys" },
467
467
  { id: "deepseek-tools", label: "DeepSeek Tools API", badge: "API KEY", link: "https://api-docs.deepseek.com/zh-cn/guides/search" },
468
- { id: "you", label: "You.com", badge: "API KEY", link: "https://you.com/platform/api-keys" },
468
+ { id: "you", label: "You.com", badge: "FREE", link: "https://you.com/platform/api-keys" },
469
469
  { id: "baidu", label: "Baidu · 百度千帆", badge: "API KEY", link: "https://console.bce.baidu.com/qianfan" },
470
470
  { id: "kimi", label: "Kimi · Moonshot", badge: "API KEY", link: "https://platform.moonshot.cn" },
471
471
  { id: "aliyun", label: "Aliyun · 百炼", badge: "API KEY", link: "https://bailian.console.aliyun.com" },
@@ -755,7 +755,7 @@ window.__ModuleLoader__.load({
755
755
  // #68 密钥状态可视化:引擎 id → 配置中心键名(少数引擎不同名),三态:已配置/可选/缺失
756
756
  const CONFIGURED_KEY_ID = { "deepseek-official": "deepseek", "deepseek-tools": "deepseek", you: "youcom", zhihu_global: "zhihu", zhihu_site: "zhihu" };
757
757
  const NO_KEY_ENGINES = ["ddg", "ddg-lite", "bing"];
758
- const OPTIONAL_KEY_ENGINES = ["anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "searxng"];
758
+ const OPTIONAL_KEY_ENGINES = ["anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "searxng", "you"];
759
759
  const hasConfiguredKey = (id) => {
760
760
  const k = CONFIGURED_KEY_ID[id] ?? id;
761
761
  return Boolean(keysConfigured[k] || credConfigured[k]);
@@ -122,33 +122,33 @@ function browserDumpDom(browserPath, url, { timeoutMs = 25000, signal } = {}) {
122
122
  });
123
123
  }
124
124
 
125
- // #88:按声明的 charset 解码。GBK/GB2312/Big5/Shift_JIS 老站很常见,
126
- // 一律当 UTF-8 会得到整页 \uFFFD,而且不会报错、不会降级,模型直接吃一页乱码。
127
- function parseCharset(contentType) {
128
- const match = /charset\s*=\s*"?([^";\s]+)"?/i.exec(String(contentType || ""));
129
- return match ? match[1].trim().toLowerCase() : "";
130
- }
131
-
132
- function sniffHtmlCharset(buffer) {
133
- const head = buffer.subarray(0, 2048).toString("latin1");
134
- let match = /<meta[^>]+charset\s*=\s*["']?\s*([\w.:-]+)/i.exec(head);
135
- if (match) return match[1].toLowerCase();
136
- match = /<meta[^>]+content\s*=\s*["'][^"']*charset\s*=\s*([\w.:-]+)/i.exec(head);
137
- return match ? match[1].toLowerCase() : "";
138
- }
139
-
140
- function decodeBody(buffer, contentType) {
141
- let label = parseCharset(contentType);
142
- if (!label && /html|xml/i.test(String(contentType || ""))) label = sniffHtmlCharset(buffer);
143
- if (!label) label = "utf-8";
144
- try {
145
- return new TextDecoder(label, { fatal: false }).decode(buffer);
146
- } catch {
147
- // 无法识别的 label(或该 Node 没编进对应编码表):退回 UTF-8,不抛错。
148
- return buffer.toString("utf8");
149
- }
150
- }
151
-
125
+ // #88:按声明的 charset 解码。GBK/GB2312/Big5/Shift_JIS 老站很常见,
126
+ // 一律当 UTF-8 会得到整页 \uFFFD,而且不会报错、不会降级,模型直接吃一页乱码。
127
+ function parseCharset(contentType) {
128
+ const match = /charset\s*=\s*"?([^";\s]+)"?/i.exec(String(contentType || ""));
129
+ return match ? match[1].trim().toLowerCase() : "";
130
+ }
131
+
132
+ function sniffHtmlCharset(buffer) {
133
+ const head = buffer.subarray(0, 2048).toString("latin1");
134
+ let match = /<meta[^>]+charset\s*=\s*["']?\s*([\w.:-]+)/i.exec(head);
135
+ if (match) return match[1].toLowerCase();
136
+ match = /<meta[^>]+content\s*=\s*["'][^"']*charset\s*=\s*([\w.:-]+)/i.exec(head);
137
+ return match ? match[1].toLowerCase() : "";
138
+ }
139
+
140
+ function decodeBody(buffer, contentType) {
141
+ let label = parseCharset(contentType);
142
+ if (!label && /html|xml/i.test(String(contentType || ""))) label = sniffHtmlCharset(buffer);
143
+ if (!label) label = "utf-8";
144
+ try {
145
+ return new TextDecoder(label, { fatal: false }).decode(buffer);
146
+ } catch {
147
+ // 无法识别的 label(或该 Node 没编进对应编码表):退回 UTF-8,不抛错。
148
+ return buffer.toString("utf8");
149
+ }
150
+ }
151
+
152
152
  /** GET 跟随重定向(≤5 跳),返回最终 URL / 状态 / 正文 / 内容类型;支持 gzip/br/deflate 解压。 */
153
153
  export function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}, transport = https) {
154
154
  return new Promise((resolve, reject) => {
package/lib/index.js CHANGED
@@ -9,7 +9,7 @@ import { mcpCall, mcpContentText, parseMcpResponseText } from "./mcp_adapter.js"
9
9
  import { registerResearchTool } from "./research.js";
10
10
  import { registerDomainSearchTool } from "./vertical.js";
11
11
  import { createFetchProvider } from "./fetch_provider.js";
12
- import { fetchWeather, sanitizeWeatherResult, WEATHER_OUTPUT_SCHEMA } from "./weather.js";
12
+ import { fetchWeather, formatWeather, sanitizeWeatherResult, WEATHER_OUTPUT_SCHEMA } from "./weather.js";
13
13
  import { setActiveBlockedDomains, getActiveBlockedDomains, isDomainBlocked, applyBlockedFilterToResult } from "./domain_filter.js";
14
14
  import { stripTrackingParams, normalizeUrlForDedupe } from "./url_norm.js";
15
15
  import { selectEngines, planTopUp, resolveMinSources, describeTarget, credentialRequirement, partitionByCredentials, rankMergedResults, domainOf } from "./multi_policy.js";
@@ -155,6 +155,9 @@ const KEENABLE_MCP_URL = "https://api.keenable.ai/mcp";
155
155
  const SERPBASE_URL = "https://api.serpbase.dev/google/search";
156
156
  const SERPLY_URL = "https://api.serply.io/v1/search";
157
157
  const YOUCOM_URL = "https://ydc-index.io/v1/search";
158
+ // You.com 免 key 档:同样的 query / count 参数与 results.web[] 响应,每天 100 次免费搜索
159
+ const YOUCOM_FREE_URL = "https://api.you.com/v1/agents/search";
160
+ const YOUCOM_KEYS_LINK = "https://you.com/platform/api-keys";
158
161
  const TINYFISH_URL = "https://api.search.tinyfish.ai";
159
162
  const JINA_URL = "https://s.jina.ai/";
160
163
  const DOUBAO_SEARCH_URL = "https://open.feedcoopapi.com/search_api/web_search";
@@ -399,7 +402,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
399
402
  }
400
403
 
401
404
  // 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
402
- const PLUGIN_VERSION = "0.9.13";
405
+ const PLUGIN_VERSION = "0.9.15";
403
406
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
404
407
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
405
408
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -2255,16 +2258,17 @@ async function searchSerply(query, maxResults, apiKey, options, signal) {
2255
2258
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
2256
2259
  }
2257
2260
 
2258
- // You.com: requires YOUCOM_API_KEY (get a free key at https://you.com/platform/api-keys)
2261
+ // You.com:无 key 走免 key 档(api.you.com/v1/agents/search,每天 100 次),有 key 走官方 Search API(X-API-Key)。
2262
+ // key 读取 YDC_API_KEY(标准名),旧名 YOUCOM_API_KEY 继续兼容,见 resolveYoucomApiKey。
2259
2263
  async function searchYoucom(query, maxResults, apiKey, signal) {
2260
- if (!apiKey) throw new Error("You.com search requires YOUCOM_API_KEY");
2261
- // 官方 Search API(keyed tier):GET + query 参数,X-API-Key 认证;count 为每段条数上限
2264
+ // 两档参数相同:GET + query 参数;count 为每段条数上限
2262
2265
  const params = new URLSearchParams({ query, count: String(Math.min(maxResults ?? 10, 20)) });
2263
- const response = await fetch(`${YOUCOM_URL}?${params}`, {
2266
+ const keyed = Boolean(apiKey);
2267
+ const response = await fetch(`${keyed ? YOUCOM_URL : YOUCOM_FREE_URL}?${params}`, {
2264
2268
  method: "GET",
2265
2269
  redirect: "error",
2266
2270
  headers: {
2267
- "X-API-Key": apiKey,
2271
+ ...(keyed ? { "X-API-Key": apiKey } : {}),
2268
2272
  accept: "application/json",
2269
2273
  "user-agent": "deepseek-harness/free-search",
2270
2274
  },
@@ -2272,13 +2276,16 @@ async function searchYoucom(query, maxResults, apiKey, signal) {
2272
2276
  });
2273
2277
  if (!response.ok) {
2274
2278
  const detail = await response.text().catch(() => "");
2275
- if (response.status === 401 || response.status === 403) {
2279
+ if (keyed && (response.status === 401 || response.status === 403)) {
2276
2280
  throw new Error(`You.com API key is invalid (HTTP ${response.status}) - update it in Settings > Plugins > Free Search`);
2277
2281
  }
2278
2282
  if (response.status === 402) {
2279
2283
  throw new Error(`You.com quota/billing error (HTTP 402) - check usage at you.com/platform/api-keys. ${detail.slice(0, 200)}`);
2280
2284
  }
2281
2285
  if (response.status === 429) {
2286
+ if (!keyed) {
2287
+ throw new Error(`You.com free tier rate limit exceeded (HTTP 429) - set YDC_API_KEY for higher limits: ${YOUCOM_KEYS_LINK}`);
2288
+ }
2282
2289
  throw new Error("You.com rate limit exceeded (HTTP 429) - check your plan at you.com/platform/api-keys");
2283
2290
  }
2284
2291
  throw new Error(`You.com API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
@@ -2301,6 +2308,23 @@ async function searchYoucom(query, maxResults, apiKey, signal) {
2301
2308
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
2302
2309
  }
2303
2310
 
2311
+ // You.com key 解析(纯函数,便于断言):凭据中心 YDC_API_KEY → 凭据中心 YOUCOM_API_KEY → 设置页 youcomApiKey
2312
+ // → 环境变量 YDC_API_KEY → 环境变量 YOUCOM_API_KEY。与其它引擎一样凭据中心优先;旧名只是别名,已有配置不受影响。
2313
+ const YOUCOM_KEY_REFS = ["YDC_API_KEY", "YOUCOM_API_KEY"];
2314
+ async function resolveYoucomApiKey({ resolveCredential, settingsValue, env } = {}) {
2315
+ if (resolveCredential) {
2316
+ for (const ref of YOUCOM_KEY_REFS) {
2317
+ const value = await resolveCredential(ref);
2318
+ if (value) return value;
2319
+ }
2320
+ }
2321
+ if (settingsValue) return settingsValue;
2322
+ for (const ref of YOUCOM_KEY_REFS) {
2323
+ if (env?.[ref]) return env[ref];
2324
+ }
2325
+ return "";
2326
+ }
2327
+
2304
2328
  // TinyFish Search(api.search.tinyfish.ai):GET + X-API-Key;免费 12000 请求/天。
2305
2329
  // 支持 after_date / before_date(YYYY-MM-DD)时间过滤。
2306
2330
  async function searchTinyfish(query, maxResults, apiKey, timeRange, signal) {
@@ -3430,6 +3454,7 @@ const KEY_REF_MAP = {
3430
3454
  serpbaseApiKey: "SERPBASE_API_KEY",
3431
3455
  serplyApiKey: "SERPLY_API_KEY",
3432
3456
  deepseekApiKey: "DEEPSEEK_API_KEY",
3457
+ // 设置页写入的凭据槽位保持旧名,已保存的 key 不受影响;读取时 YDC_API_KEY 优先(见 resolveYoucomApiKey)
3433
3458
  youcomApiKey: "YOUCOM_API_KEY",
3434
3459
  baiduApiKey: "BAIDU_API_KEY",
3435
3460
  kimiApiKey: "MOONSHOT_API_KEY",
@@ -3699,23 +3724,23 @@ function apply(ctx, config) {
3699
3724
  // credentials 服务可能晚于本插件挂载(跨 bundle 顺序),运行期动态获取而不是 apply 时缓存
3700
3725
  const getCredentials = () => ctx.get("credentials");
3701
3726
 
3702
- // #84:读取宿主的远程访问信任集合(--trusted-host / --public-url),供桥接 Host 围栏使用。
3703
- // webStartup 只在 dsh web 下提供;拿不到就维持 loopback-only 的老行为。
3704
- ctx.inject(["webStartup"], (sctx) => {
3705
- try {
3706
- const startup = sctx.get("webStartup");
3707
- if (!startup) return;
3708
- if (Array.isArray(startup.trustedHosts)) {
3709
- bridgeTrust.hosts = startup.trustedHosts
3710
- .filter((entry) => typeof entry === "string" && entry.trim())
3711
- .map((entry) => entry.trim());
3712
- }
3713
- if (typeof startup.publicUrl === "string" && startup.publicUrl) {
3714
- try {
3715
- bridgeTrust.publicHost = new URL(startup.publicUrl).host;
3716
- } catch {}
3717
- }
3718
- } catch {}
3727
+ // #84:读取宿主的远程访问信任集合(--trusted-host / --public-url),供桥接 Host 围栏使用。
3728
+ // webStartup 只在 dsh web 下提供;拿不到就维持 loopback-only 的老行为。
3729
+ ctx.inject(["webStartup"], (sctx) => {
3730
+ try {
3731
+ const startup = sctx.get("webStartup");
3732
+ if (!startup) return;
3733
+ if (Array.isArray(startup.trustedHosts)) {
3734
+ bridgeTrust.hosts = startup.trustedHosts
3735
+ .filter((entry) => typeof entry === "string" && entry.trim())
3736
+ .map((entry) => entry.trim());
3737
+ }
3738
+ if (typeof startup.publicUrl === "string" && startup.publicUrl) {
3739
+ try {
3740
+ bridgeTrust.publicHost = new URL(startup.publicUrl).host;
3741
+ } catch {}
3742
+ }
3743
+ } catch {}
3719
3744
  });
3720
3745
 
3721
3746
  // 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
@@ -3808,6 +3833,21 @@ function apply(ctx, config) {
3808
3833
  return resolveApiKey("GOOGLE_API_KEY", undefined);
3809
3834
  };
3810
3835
  const resolveClaudeKey = () => resolveApiKey("ANTHROPIC_API_KEY", "claudeApiKey");
3836
+ // You.com:标准名 YDC_API_KEY,旧名 YOUCOM_API_KEY 作为别名继续生效(不配 key 走免 key 档)
3837
+ const resolveYoucomKey = () =>
3838
+ resolveYoucomApiKey({
3839
+ resolveCredential: async (ref) => {
3840
+ const credentials = getCredentials();
3841
+ if (!credentials) return "";
3842
+ try {
3843
+ return (await credentials.resolve(ref))?.value ?? "";
3844
+ } catch {
3845
+ return "";
3846
+ }
3847
+ },
3848
+ settingsValue: current().youcomApiKey,
3849
+ env: process.env,
3850
+ });
3811
3851
 
3812
3852
  // 总控 provider:按 settings 的 provider 字段路由到任意引擎。
3813
3853
  // 任何引擎失败(缺 key / 401 / 限流 / 网络)都会自动轮流尝试下一个引擎,
@@ -4055,14 +4095,8 @@ function apply(ctx, config) {
4055
4095
  }
4056
4096
  result = await searchSerply(request.query, request.maxResults, key, cfg, effSignal);
4057
4097
  } else if (engine === "you") {
4058
- // you.com:必须配置 YOUCOM_API_KEY(无 key 跳过)
4059
- const key = await resolveApiKey("YOUCOM_API_KEY", "youcomApiKey");
4060
- if (!key) {
4061
- lastError = new Error("You.com requires YOUCOM_API_KEY (get one at you.com/platform/api-keys)");
4062
- if (engine === preferred) preferredFailure = "YOUCOM_API_KEY is not configured";
4063
- logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
4064
- continue; // 无 key 跳过
4065
- }
4098
+ // you.com:有 key(YDC_API_KEY / 旧名 YOUCOM_API_KEY)走官方 Search API,无 key 走免 key 档(每天 100 次)
4099
+ const key = await resolveYoucomKey();
4066
4100
  result = await searchYoucom(request.query, request.maxResults, key, effSignal);
4067
4101
  } else if (engine === "tinyfish") {
4068
4102
  // tinyfish:必须配置 TINYFISH_API_KEY(无 key 跳过);免费 12000 请求/天
@@ -4599,8 +4633,7 @@ function apply(ctx, config) {
4599
4633
  return await searchClaude(q, n, key, current());
4600
4634
  }
4601
4635
  case "you": {
4602
- const key = await resolveApiKey("YOUCOM_API_KEY", "youcomApiKey");
4603
- if (!key) return { ok: false, error: "YOUCOM_API_KEY not configured" };
4636
+ const key = await resolveYoucomKey();
4604
4637
  return await searchYoucom(q, n, key);
4605
4638
  }
4606
4639
  default:
@@ -5216,27 +5249,12 @@ function apply(ctx, config) {
5216
5249
  output: {
5217
5250
  schema: WEATHER_OUTPUT_SCHEMA,
5218
5251
  render(args, value) {
5219
- const lang = outputLanguage(configLanguage(current()));
5220
- const c = value.current ?? {};
5221
- const lines = [];
5222
- lines.push(lang === "en" ? `Weather for ${value.location}:` : `${value.location} 天气:`);
5223
- lines.push(
5224
- lang === "en"
5225
- ? `Now: ${c.temperature}°C (feels ${c.apparent}°C), ${c.text}, humidity ${c.humidity}%, wind ${c.wind} km/h`
5226
- : `当前:${c.temperature}°C(体感 ${c.apparent}°C),${c.text},湿度 ${c.humidity}%,风速 ${c.wind} km/h`
5227
- );
5228
- for (const d of value.daily ?? []) {
5229
- lines.push(
5230
- lang === "en"
5231
- ? `${d.date}: ${d.min}~${d.max}°C, ${d.text}${d.precipitationProbability !== undefined ? `, precip ${d.precipitationProbability}%` : ""}`
5232
- : `${d.date}:${d.min}~${d.max}°C,${d.text}${d.precipitationProbability !== undefined ? `,降水概率 ${d.precipitationProbability}%` : ""}`
5233
- );
5234
- }
5235
- return [{ type: "text", text: wrapUntrustedBlock(lines.join("\n")) }];
5252
+ // 正文 zh / en / ja 三套(其它语言回退 en),与 fetchWeather 生成的天气描述同一语言
5253
+ return [{ type: "text", text: wrapUntrustedBlock(formatWeather(value, configLanguage(current()))) }];
5236
5254
  },
5237
5255
  },
5238
5256
  async execute(args) {
5239
- // 天气正文只提供 zh/en(回退 en),地理编码用真实搜索语言(ja 就查日文地名)。
5257
+ // 天气正文 zh/en/ja(其它回退 en,见 textLanguage),地理编码用真实搜索语言(ja 就查日文地名)。
5240
5258
  const lang = searchLanguage(configLanguage(current()));
5241
5259
  const location = String(args.location ?? "").trim();
5242
5260
  if (!location) throw new Error("weather: location is required");
@@ -5585,6 +5603,9 @@ export {
5585
5603
  searchTinyfish,
5586
5604
  searchJina,
5587
5605
  searchYoucom,
5606
+ resolveYoucomApiKey,
5607
+ YOUCOM_FREE_URL,
5608
+ YOUCOM_URL,
5588
5609
  searchZhihuGlobal,
5589
5610
  searchZhihuSite,
5590
5611
  ZHIHU_MCP_URL,
@@ -208,7 +208,7 @@ export function rankMergedResults(items, { appliesFreshness = false } = {}) {
208
208
  * `if (!key) return { error: "<ENV> not configured" }` 的引擎**,与代码自身对
209
209
  * 「缺 key」的声明严格一致。
210
210
  * - 免 key 引擎 ddg/ddg-lite/bing/searxng 不收;
211
- * - 有免 key 兜底的 anysearch(key 可空)/ exa、parallel(回落 MCP)不收;
211
+ * - 有免 key 兜底的 anysearch(key 可空)/ exa、parallel(回落 MCP)/ you(无 key 走免 key 档)不收;
212
212
  * - tavily/keenable/firecrawl 分支没有 not configured 检查(缺 key 时会带着空串去请求,
213
213
  * 以鉴权失败收场),暂不收——那属于另一个行为变更,需单独决定。
214
214
  * settings 字段必须带上:只在环境变量里找 key 会漏掉「仅填在设置页」的用户。
@@ -236,7 +236,6 @@ export const REQUIRED_CREDENTIALS = {
236
236
  { env: "GOOGLE_API_KEY", settings: null },
237
237
  ],
238
238
  claude: [{ env: "ANTHROPIC_API_KEY", settings: "claudeApiKey" }],
239
- you: [{ env: "YOUCOM_API_KEY", settings: "youcomApiKey" }],
240
239
  };
241
240
 
242
241
  /** 该引擎需要哪些凭据描述符;不需要凭据(免 key / 有兜底)时返回 null。 */
package/lib/weather.js CHANGED
@@ -1,183 +1,214 @@
1
- // Weather(Open-Meteo:geocoding + forecast,全部免 key)
2
- // 与 lib/index.js 的 weather 工具配套。这里同时导出 output schema 与 sanitizeWeatherResult(),
3
- // 使 tools/assert-weather-contract.mjs 能离线校验「返回值 ↔ schema」逐字段对齐——
4
- // 0.8.4/0.8.5 曾因 schema 漏声明 latitude/longitude/timezone/current.code/daily[].code
5
- // 导致 additionalProperties:false 校验失败、工具 100% 不可用(issue #73)。
6
-
7
- const OPEN_METEO_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
8
- const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
9
-
10
- // WMO weather code → 描述(中文/英文)
11
- const WMO_CODES = {
12
- 0: ["晴", "Clear sky"],
13
- 1: ["大部晴朗", "Mainly clear"],
14
- 2: ["多云", "Partly cloudy"],
15
- 3: ["阴", "Overcast"],
16
- 45: ["雾", "Fog"],
17
- 48: ["雾凇", "Depositing rime fog"],
18
- 51: ["毛毛雨(弱)", "Light drizzle"],
19
- 53: ["毛毛雨", "Moderate drizzle"],
20
- 55: ["毛毛雨(强)", "Dense drizzle"],
21
- 56: ["冻毛毛雨(弱)", "Light freezing drizzle"],
22
- 57: ["冻毛毛雨(强)", "Dense freezing drizzle"],
23
- 61: ["小雨", "Slight rain"],
24
- 63: ["中雨", "Moderate rain"],
25
- 65: ["大雨", "Heavy rain"],
26
- 66: ["冻雨(弱)", "Light freezing rain"],
27
- 67: ["冻雨(强)", "Heavy freezing rain"],
28
- 71: ["小雪", "Slight snow"],
29
- 73: ["中雪", "Moderate snow"],
30
- 75: ["大雪", "Heavy snow"],
31
- 77: ["雪粒", "Snow grains"],
32
- 80: ["阵雨(弱)", "Slight rain showers"],
33
- 81: ["阵雨", "Moderate rain showers"],
34
- 82: ["阵雨(强)", "Violent rain showers"],
35
- 85: ["阵雪(弱)", "Slight snow showers"],
36
- 86: ["阵雪(强)", "Heavy snow showers"],
37
- 95: ["雷暴", "Thunderstorm"],
38
- 96: ["雷暴伴小冰雹", "Thunderstorm with slight hail"],
39
- 99: ["雷暴伴大冰雹", "Thunderstorm with heavy hail"],
40
- };
41
- /** 天气文案只有 zh / en 两套;其它语言(ja/ru/…)走英文,避免整段中文(#92)。 */
42
- export function textLanguage(lang) {
43
- return String(lang ?? "").trim().toLowerCase() === "zh" ? "zh" : "en";
44
- }
45
-
46
- export function wmoText(code, lang) {
47
- const textLang = textLanguage(lang);
48
- const hit = WMO_CODES[code];
49
- if (!hit) return textLang === "zh" ? "未知" : "Unknown";
50
- return (textLang === "zh" ? hit[0] : hit[1]) + ` (WMO ${code})`;
51
- }
52
-
53
- export async function geocodeLocation(name, signal, language = "en") {
54
- const params = new URLSearchParams({ name, count: "1", language: String(language ?? "en") || "en", format: "json" });
55
- const response = await fetch(`${OPEN_METEO_GEOCODING_URL}?${params}`, {
56
- method: "GET",
57
- redirect: "error",
58
- headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
59
- signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
60
- });
61
- if (!response.ok) throw new Error(`geocoding API error (HTTP ${response.status})`);
62
- const data = await response.json();
63
- const hit = data.results?.[0];
64
- if (!hit) return null;
65
- return {
66
- name: hit.name,
67
- admin: hit.admin1 ?? "",
68
- country: hit.country ?? "",
69
- latitude: hit.latitude,
70
- longitude: hit.longitude,
71
- };
72
- }
73
-
74
- export async function fetchWeather(location, days, lang, signal) {
75
- const geo = await geocodeLocation(location, signal, lang);
76
- if (!geo) throw new Error(`could not geocode location "${location}" — try a nearby larger city`);
77
- const params = new URLSearchParams({
78
- latitude: String(geo.latitude),
79
- longitude: String(geo.longitude),
80
- current: "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
81
- daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
82
- timezone: "auto",
83
- forecast_days: String(Math.min(Math.max(Number(days) || 3, 1), 7)),
84
- });
85
- const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, {
86
- method: "GET",
87
- redirect: "error",
88
- headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
89
- signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
90
- });
91
- if (!response.ok) throw new Error(`forecast API error (HTTP ${response.status})`);
92
- const data = await response.json();
93
- const cur = data.current ?? {};
94
- const daily = data.daily ?? {};
95
- return {
96
- location: [geo.name, geo.admin, geo.country].filter(Boolean).join(", "),
97
- latitude: geo.latitude,
98
- longitude: geo.longitude,
99
- current: {
100
- temperature: cur.temperature_2m,
101
- apparent: cur.apparent_temperature,
102
- humidity: cur.relative_humidity_2m,
103
- wind: cur.wind_speed_10m,
104
- code: cur.weather_code,
105
- text: wmoText(cur.weather_code, lang),
106
- },
107
- daily: (daily.time ?? []).map((date, i) => ({
108
- date,
109
- min: daily.temperature_2m_min?.[i],
110
- max: daily.temperature_2m_max?.[i],
111
- code: daily.weather_code?.[i],
112
- text: wmoText(daily.weather_code?.[i], lang),
113
- precipitationProbability: daily.precipitation_probability_max?.[i],
114
- })),
115
- timezone: data.timezone ?? "",
116
- };
117
- }
118
-
119
- // weather 工具的 output schema。与 fetchWeather 返回值逐字段对齐:
120
- // 任何一边增删字段都必须同步,否则 additionalProperties:false 会让工具调用失败(#73)。
121
- export const WEATHER_OUTPUT_SCHEMA = {
122
- type: "object",
123
- additionalProperties: false,
124
- properties: {
125
- location: { type: "string" },
126
- latitude: { type: "number" },
127
- longitude: { type: "number" },
128
- timezone: { type: "string" },
129
- current: {
130
- type: "object",
131
- additionalProperties: false,
132
- properties: {
133
- temperature: { type: "number" },
134
- apparent: { type: "number" },
135
- humidity: { type: "number" },
136
- wind: { type: "number" },
137
- code: { type: "number" },
138
- text: { type: "string" },
139
- },
140
- },
141
- daily: {
142
- type: "array",
143
- items: {
144
- type: "object",
145
- additionalProperties: false,
146
- properties: {
147
- date: { type: "string" },
148
- min: { type: "number" },
149
- max: { type: "number" },
150
- code: { type: "number" },
151
- text: { type: "string" },
152
- precipitationProbability: { type: "number" },
153
- },
154
- },
155
- },
156
- },
157
- };
158
-
159
- // lossless JSON 不允许 undefined 字段:按"存在且有值"构造对象(与其它工具的做法一致)。
160
- export function sanitizeWeatherResult(r) {
161
- return {
162
- location: r.location,
163
- ...(r.latitude !== undefined ? { latitude: r.latitude } : {}),
164
- ...(r.longitude !== undefined ? { longitude: r.longitude } : {}),
165
- ...(r.timezone ? { timezone: r.timezone } : {}),
166
- current: {
167
- ...(r.current?.temperature !== undefined ? { temperature: r.current.temperature } : {}),
168
- ...(r.current?.apparent !== undefined ? { apparent: r.current.apparent } : {}),
169
- ...(r.current?.humidity !== undefined ? { humidity: r.current.humidity } : {}),
170
- ...(r.current?.wind !== undefined ? { wind: r.current.wind } : {}),
171
- ...(r.current?.code !== undefined ? { code: r.current.code } : {}),
172
- ...(r.current?.text ? { text: r.current.text } : {}),
173
- },
174
- daily: (r.daily ?? []).map((d) => ({
175
- date: d.date,
176
- ...(d.min !== undefined ? { min: d.min } : {}),
177
- ...(d.max !== undefined ? { max: d.max } : {}),
178
- ...(d.code !== undefined ? { code: d.code } : {}),
179
- ...(d.text ? { text: d.text } : {}),
180
- ...(d.precipitationProbability !== undefined ? { precipitationProbability: d.precipitationProbability } : {}),
181
- })),
182
- };
183
- }
1
+ // Weather(Open-Meteo:geocoding + forecast,全部免 key)
2
+ // 与 lib/index.js 的 weather 工具配套。这里同时导出 output schema 与 sanitizeWeatherResult(),
3
+ // 使 tools/assert-weather-contract.mjs 能离线校验「返回值 ↔ schema」逐字段对齐——
4
+ // 0.8.4/0.8.5 曾因 schema 漏声明 latitude/longitude/timezone/current.code/daily[].code
5
+ // 导致 additionalProperties:false 校验失败、工具 100% 不可用(issue #73)。
6
+
7
+ const OPEN_METEO_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
8
+ const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
9
+
10
+ // WMO weather code → 描述(中文/英文/日文)。
11
+ // 日文按日本气象厅「国際式の天気記号」的 ww 表(与 Open-Meteo 的 code 同一套编号):
12
+ // https://www.jma.go.jp/jma/kishou/know/kurashi/symbols.html
13
+ // 「X,弱」→ 弱いX,「X,並」「X,並又は強」→ X,「X,強」→ 強いX,「激しい」→ 激しいX。
14
+ // 0–3 在 ww 表里是「云的变化」而非云量,按气象厅预报用语(快晴 / 晴れ / 曇り)与天气预报画面的
15
+ // 天気テロップ「晴時々曇 = PARTLY CLOUDY」对应。96/99 的强弱在 ww 表里指雷电而非冰雹。
16
+ const WMO_CODES = {
17
+ 0: ["晴", "Clear sky", "快晴"],
18
+ 1: ["大部晴朗", "Mainly clear", "晴れ"],
19
+ 2: ["多云", "Partly cloudy", "晴れ時々曇り"],
20
+ 3: ["阴", "Overcast", "曇り"],
21
+ 45: ["雾", "Fog", "霧"],
22
+ 48: ["雾凇", "Depositing rime fog", "霧(霧氷を伴う)"],
23
+ 51: ["毛毛雨(弱)", "Light drizzle", "弱い霧雨"],
24
+ 53: ["毛毛雨", "Moderate drizzle", "霧雨"],
25
+ 55: ["毛毛雨(强)", "Dense drizzle", "強い霧雨"],
26
+ 56: ["冻毛毛雨(弱)", "Light freezing drizzle", "弱い着氷性の霧雨"],
27
+ 57: ["冻毛毛雨(强)", "Dense freezing drizzle", "着氷性の霧雨"],
28
+ 61: ["小雨", "Slight rain", "弱い雨"],
29
+ 63: ["中雨", "Moderate rain", "雨"],
30
+ 65: ["大雨", "Heavy rain", "強い雨"],
31
+ 66: ["冻雨(弱)", "Light freezing rain", "弱い着氷性の雨"],
32
+ 67: ["冻雨(强)", "Heavy freezing rain", "着氷性の雨"],
33
+ 71: ["小雪", "Slight snow", "弱い雪"],
34
+ 73: ["中雪", "Moderate snow", "雪"],
35
+ 75: ["大雪", "Heavy snow", "強い雪"],
36
+ 77: ["雪粒", "Snow grains", "霧雪"],
37
+ 80: ["阵雨(弱)", "Slight rain showers", "弱いにわか雨"],
38
+ 81: ["阵雨", "Moderate rain showers", "にわか雨"],
39
+ 82: ["阵雨(强)", "Violent rain showers", "激しいにわか雨"],
40
+ 85: ["阵雪(弱)", "Slight snow showers", "弱いにわか雪"],
41
+ 86: ["阵雪(强)", "Heavy snow showers", "にわか雪"],
42
+ 95: ["雷暴", "Thunderstorm", "雷雨"],
43
+ 96: ["雷暴伴小冰雹", "Thunderstorm with slight hail", "雷雨(ひょうを伴う)"],
44
+ 99: ["雷暴伴大冰雹", "Thunderstorm with heavy hail", "激しい雷雨(ひょうを伴う)"],
45
+ };
46
+ const TEXT_LANGUAGES = ["zh", "en", "ja"];
47
+ const UNKNOWN_TEXT = { zh: "未知", en: "Unknown", ja: "不明" };
48
+
49
+ /** 天气文案有 zh / en / ja 三套;其它语言(ru/de/…)走英文,避免整段中文(#92)。 */
50
+ export function textLanguage(lang) {
51
+ const tag = String(lang ?? "").trim().toLowerCase();
52
+ return TEXT_LANGUAGES.includes(tag) ? tag : "en";
53
+ }
54
+
55
+ export function wmoText(code, lang) {
56
+ const textLang = textLanguage(lang);
57
+ const hit = WMO_CODES[code];
58
+ if (!hit) return UNKNOWN_TEXT[textLang];
59
+ return hit[TEXT_LANGUAGES.indexOf(textLang)] + ` (WMO ${code})`;
60
+ }
61
+
62
+ /** weather 工具的正文(render 用)。lang 同 textLanguage(),description 由 fetchWeather 按同一语言生成。 */
63
+ export function formatWeather(value, lang) {
64
+ const textLang = textLanguage(lang);
65
+ const c = value.current ?? {};
66
+ const pp = (d, fmt) => (d.precipitationProbability !== undefined ? fmt(d.precipitationProbability) : "");
67
+ const lines = [];
68
+ if (textLang === "zh") {
69
+ lines.push(`${value.location} 天气:`);
70
+ lines.push(`当前:${c.temperature}°C(体感 ${c.apparent}°C),${c.text},湿度 ${c.humidity}%,风速 ${c.wind} km/h`);
71
+ for (const d of value.daily ?? []) lines.push(`${d.date}:${d.min}~${d.max}°C,${d.text}${pp(d, (p) => `,降水概率 ${p}%`)}`);
72
+ } else if (textLang === "ja") {
73
+ lines.push(`${value.location} の天気:`);
74
+ lines.push(`現在:${c.temperature}°C(体感 ${c.apparent}°C)、${c.text}、湿度 ${c.humidity}%、風速 ${c.wind} km/h`);
75
+ for (const d of value.daily ?? []) lines.push(`${d.date}:${d.min}〜${d.max}°C、${d.text}${pp(d, (p) => `、降水確率 ${p}%`)}`);
76
+ } else {
77
+ lines.push(`Weather for ${value.location}:`);
78
+ lines.push(`Now: ${c.temperature}°C (feels ${c.apparent}°C), ${c.text}, humidity ${c.humidity}%, wind ${c.wind} km/h`);
79
+ for (const d of value.daily ?? []) lines.push(`${d.date}: ${d.min}~${d.max}°C, ${d.text}${pp(d, (p) => `, precip ${p}%`)}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+
84
+ export async function geocodeLocation(name, signal, language = "en") {
85
+ const params = new URLSearchParams({ name, count: "1", language: String(language ?? "en") || "en", format: "json" });
86
+ const response = await fetch(`${OPEN_METEO_GEOCODING_URL}?${params}`, {
87
+ method: "GET",
88
+ redirect: "error",
89
+ headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
90
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
91
+ });
92
+ if (!response.ok) throw new Error(`geocoding API error (HTTP ${response.status})`);
93
+ const data = await response.json();
94
+ const hit = data.results?.[0];
95
+ if (!hit) return null;
96
+ return {
97
+ name: hit.name,
98
+ admin: hit.admin1 ?? "",
99
+ country: hit.country ?? "",
100
+ latitude: hit.latitude,
101
+ longitude: hit.longitude,
102
+ };
103
+ }
104
+
105
+ export async function fetchWeather(location, days, lang, signal) {
106
+ const geo = await geocodeLocation(location, signal, lang);
107
+ if (!geo) throw new Error(`could not geocode location "${location}" — try a nearby larger city`);
108
+ const params = new URLSearchParams({
109
+ latitude: String(geo.latitude),
110
+ longitude: String(geo.longitude),
111
+ current: "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
112
+ daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
113
+ timezone: "auto",
114
+ forecast_days: String(Math.min(Math.max(Number(days) || 3, 1), 7)),
115
+ });
116
+ const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, {
117
+ method: "GET",
118
+ redirect: "error",
119
+ headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
120
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
121
+ });
122
+ if (!response.ok) throw new Error(`forecast API error (HTTP ${response.status})`);
123
+ const data = await response.json();
124
+ const cur = data.current ?? {};
125
+ const daily = data.daily ?? {};
126
+ return {
127
+ location: [geo.name, geo.admin, geo.country].filter(Boolean).join(", "),
128
+ latitude: geo.latitude,
129
+ longitude: geo.longitude,
130
+ current: {
131
+ temperature: cur.temperature_2m,
132
+ apparent: cur.apparent_temperature,
133
+ humidity: cur.relative_humidity_2m,
134
+ wind: cur.wind_speed_10m,
135
+ code: cur.weather_code,
136
+ text: wmoText(cur.weather_code, lang),
137
+ },
138
+ daily: (daily.time ?? []).map((date, i) => ({
139
+ date,
140
+ min: daily.temperature_2m_min?.[i],
141
+ max: daily.temperature_2m_max?.[i],
142
+ code: daily.weather_code?.[i],
143
+ text: wmoText(daily.weather_code?.[i], lang),
144
+ precipitationProbability: daily.precipitation_probability_max?.[i],
145
+ })),
146
+ timezone: data.timezone ?? "",
147
+ };
148
+ }
149
+
150
+ // weather 工具的 output schema。与 fetchWeather 返回值逐字段对齐:
151
+ // 任何一边增删字段都必须同步,否则 additionalProperties:false 会让工具调用失败(#73)。
152
+ export const WEATHER_OUTPUT_SCHEMA = {
153
+ type: "object",
154
+ additionalProperties: false,
155
+ properties: {
156
+ location: { type: "string" },
157
+ latitude: { type: "number" },
158
+ longitude: { type: "number" },
159
+ timezone: { type: "string" },
160
+ current: {
161
+ type: "object",
162
+ additionalProperties: false,
163
+ properties: {
164
+ temperature: { type: "number" },
165
+ apparent: { type: "number" },
166
+ humidity: { type: "number" },
167
+ wind: { type: "number" },
168
+ code: { type: "number" },
169
+ text: { type: "string" },
170
+ },
171
+ },
172
+ daily: {
173
+ type: "array",
174
+ items: {
175
+ type: "object",
176
+ additionalProperties: false,
177
+ properties: {
178
+ date: { type: "string" },
179
+ min: { type: "number" },
180
+ max: { type: "number" },
181
+ code: { type: "number" },
182
+ text: { type: "string" },
183
+ precipitationProbability: { type: "number" },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ };
189
+
190
+ // lossless JSON 不允许 undefined 字段:按"存在且有值"构造对象(与其它工具的做法一致)。
191
+ export function sanitizeWeatherResult(r) {
192
+ return {
193
+ location: r.location,
194
+ ...(r.latitude !== undefined ? { latitude: r.latitude } : {}),
195
+ ...(r.longitude !== undefined ? { longitude: r.longitude } : {}),
196
+ ...(r.timezone ? { timezone: r.timezone } : {}),
197
+ current: {
198
+ ...(r.current?.temperature !== undefined ? { temperature: r.current.temperature } : {}),
199
+ ...(r.current?.apparent !== undefined ? { apparent: r.current.apparent } : {}),
200
+ ...(r.current?.humidity !== undefined ? { humidity: r.current.humidity } : {}),
201
+ ...(r.current?.wind !== undefined ? { wind: r.current.wind } : {}),
202
+ ...(r.current?.code !== undefined ? { code: r.current.code } : {}),
203
+ ...(r.current?.text ? { text: r.current.text } : {}),
204
+ },
205
+ daily: (r.daily ?? []).map((d) => ({
206
+ date: d.date,
207
+ ...(d.min !== undefined ? { min: d.min } : {}),
208
+ ...(d.max !== undefined ? { max: d.max } : {}),
209
+ ...(d.code !== undefined ? { code: d.code } : {}),
210
+ ...(d.text ? { text: d.text } : {}),
211
+ ...(d.precipitationProbability !== undefined ? { precipitationProbability: d.precipitationProbability } : {}),
212
+ })),
213
+ };
214
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.9.13",
4
- "description": "Free web search for DeepSeek Harness: 27 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek (official + native tools/search)/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude model built-in search, explicit-only) + auto smart routing + global engine enable/disable & fallback priority + editable zh/en language pools + multi mode (parallel full language-pool merge) + multi_search cross-source tool + failure-aware fallback (quota/auth cooldown, transient retry, fallbackOn) + time filtering + platform search + domain blocklist (blockedDomains) + web_fetch, with web settings UI.",
3
+ "version": "0.9.15",
4
+ "description": "Free web search for DeepSeek Harness: 27 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel/You.com keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek (official + native tools/search)/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude model built-in search, explicit-only) + auto smart routing + global engine enable/disable & fallback priority + editable zh/en language pools + multi mode (parallel full language-pool merge) + multi_search cross-source tool + failure-aware fallback (quota/auth cooldown, transient retry, fallbackOn) + time filtering + platform search + domain blocklist (blockedDomains) + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {