dsh-free-search 0.9.9 → 0.9.12

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,6 +32,7 @@ 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
36
  - **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
36
37
  - **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
37
38
  - **全局引擎开关与回退优先级** —— 设置页可勾选/取消引擎(取消后全局生效:普通搜索 / Auto / `advanced_search` / `multi_search` / 引擎测试都不再用它),并可用 ↑↓ 调整全局回退顺序(一键恢复默认)
@@ -67,7 +68,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
67
68
  | `exa` | Exa | 免费 | **无 key 也可用**(MCP 匿名),配 key 提升额度 |
68
69
  | `tavily` | Tavily | 免费 | **无 key 也可用**(keyless 匿名),配 key 提升额度 |
69
70
  | `keenable` | Keenable | 免费 | **无 key 也可用**(MCP 匿名),配 key 提升额度 |
70
- | `firecrawl` | Firecrawl | 免费 | **无 key 也可用**(官方免 key 匿名额度),配 key 提升限额 |
71
+ | `firecrawl` | Firecrawl | 免费 | **无 key 也可用**(官方免 key 匿名额度),配 key 提升限额。注意:keyless 档按**出口 IP** 判定,VPN/代理出口会被 403 拒绝;插件会冷却 6 小时并说明原因(有 key 不受影响) |
71
72
  | `parallel` | Parallel | 免费 | **无 key 也可用**(官方 MCP 匿名额度),配 key 提升额度并支持精确时间过滤 |
72
73
  | `perplexity` | Perplexity | 付费 | 需 `PERPLEXITY_API_KEY` |
73
74
  | `serpbase` | SerpBase | 付费 | 需 `SERPBASE_API_KEY`(serpbase.dev,注册送 100 次免费额度) |
@@ -304,7 +305,9 @@ DSH 0.1.7-rc.1 起,配置跟随 profile 的插件条目保存:设置页与 `
304
305
  # profiles/<profile>/cordis.patch.yml 中该条目的 config:
305
306
  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
306
307
  fallbackOn: [quota, auth, bot-wall, transient, invalid-response, unknown] # 允许触发回退的失败类别;[] = 任何失败都立即中止(默认全部)
307
- lang: zh # 搜索语言池/本地化(zh / en / ru …,不再被设置页强行改写)
308
+ lang: zh # 搜索语言池/本地化(zh / en / ru …,不再被设置页强行改写)
309
+ # lang 决定:Bing 的 mkt/Accept-Language、Wikipedia 子域(ja → ja.wikipedia.org)、地理编码语言;
310
+ # 我们自己渲染的文案只有中文/英文两套,其它语言(ja/ru/de…)一律走英文,避免回答前先塞一段中文(#92)。
308
311
  uiLang: zh # 设置页界面语言(zh / en);留空则跟随 lang
309
312
  bingMarket: zh-CN # Bing 市场;留空 = 自动(不发送 mkt)
310
313
  fetchProviderTakeover: true # false = 保留你显式 pin 的 fetchProvider: http
@@ -392,6 +395,20 @@ Search engine test:
392
395
  - **不可信边界与清洗**:严格遵守 `<untrusted-web-content>` 数据边界,正文 snippet 统一清洗。
393
396
  - ⚠️ 注:多源并发会消耗更多 API 配额,建议在需要多角度核验时按需使用。
394
397
 
398
+ #### 输出契约(meta.uncertainty)
399
+
400
+ `advanced_search` 与 `multi_search` 的输出多了一个可选字段 `meta.uncertainty`,用来告诉 agent「这次结果有多可信」,不影响原有 `provider` / `content` / `sources`:
401
+
402
+ ```json
403
+ { "provider": "multi", "sources": [ ... ],
404
+ "meta": { "uncertainty": { "level": "medium", "notes": ["只有 2 个来源"] } } }
405
+ ```
406
+
407
+ - `level`:`high` / `medium` / `low`,按来源数量、站点是否同一家、带时间过滤却无发布时间、是否包含聚合站、是否有引擎失败等启发式判定;
408
+ - `notes`:具体原因(中文);`high` 时通常为空;
409
+ - 渲染文本在非 `high` 时会追加一行 `Confidence: …`。
410
+
411
+ 这是**小步版**:只新增字段,旧解析不受影响。
395
412
  #### 抓取网页内容(web_fetch)
396
413
 
397
414
  搜索到 URL 后,可以让 agent **读取网页全文**(如"打开第一个链接看看内容")。`web_fetch` 工具已启用(官方 `dsh-web-fetch-http` provider):
@@ -472,7 +489,7 @@ Search engine test:
472
489
 
473
490
  #### 离线自检断言(tools/)
474
491
 
475
- 仓库自带 **20 个**零依赖断言脚本(合计 **534 项**),**不需要 API key、不联网、不需要测试框架**,Node ≥ 20 即可跑:
492
+ 仓库自带 **24 个**零依赖断言脚本(合计 **555 项**),**不需要 API key、不联网、不需要测试框架**,Node ≥ 20 即可跑:
476
493
 
477
494
  | 脚本 | 覆盖 | 运行(项数) |
478
495
  |---|---|---|
@@ -492,6 +509,10 @@ Search engine test:
492
509
  | `assert-killtree-win32.mjs` | Windows 子进程树终止(#74):不再依赖 `taskkill` | `node tools/assert-killtree-win32.mjs`(8) |
493
510
  | `assert-killtree-portable.mjs` | 跨平台 `killTree` 便携版契约 | `node tools/assert-killtree-portable.mjs`(5) |
494
511
  | `assert-parallel-mcp.mjs` | Parallel MCP 引擎:25 s 超时/调用方取消覆盖**响应体读取**、共享 SSE 解析器(通知/多行帧)、6 类错误路径、REST 精确时间过滤 | `node tools/assert-parallel-mcp.mjs`(15) |
512
+ | `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`(5) |
513
+ | `assert-uncertainty.mjs` | 输出契约:0/1 源为 low、2 源与同站为 medium、3 个不同域为 high、时间过滤无日期、失败备注、聚合站提示 | `node tools/assert-uncertainty.mjs`(8) |
514
+ | `assert-multikey.mjs` | 多 key 字段:单 key 不变、逗号/分号/换行分隔、空白项丢弃、空输入为空数组 | `node tools/assert-multikey.mjs`(3) |
515
+ | `assert-firecrawl-keyless.mjs` | Firecrawl keyless 出口判定:403 + VPN/proxy 文案才算拒绝、普通 403 与 401/429/500 不算、冷却初始未触发 | `node tools/assert-firecrawl-keyless.mjs`(5) |
495
516
  | `assert-fetch-charset.mjs` | 抓取编码(#88):`charset=gbk` / `gb2312` 按声明解码、无声明时嗅探 `<meta charset>` 与 http-equiv 形式、默认 UTF-8、未知 label 兜底 UTF-8 不抛错 | `node tools/assert-fetch-charset.mjs`(6) |
496
517
  | `assert-fetch-abort.mjs` | 抓取中断(#87):压缩响应中途断连必须 reject(不再永久 pending)、明文断连同样 reject、abort 能打断停滞的 gzip 流、complete 响应与重定向仍正常 | `node tools/assert-fetch-abort.mjs`(6) |
497
518
  | `assert-bridge-access.mjs` | 桥接 Host 围栏(#84):loopback 放行、remote peer 打 loopback Host 拒绝、`--trusted-host`(无端口匹配任意端口 / 带端口精确匹配)、`--public-url`、绑定地址、跨站与异源拒绝 | `node tools/assert-bridge-access.mjs`(11) |
@@ -547,6 +568,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
547
568
  - **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
548
569
  - **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)
549
570
  - **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.
571
+ - **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
550
572
  - **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)
551
573
  - **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)
552
574
  - **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)
@@ -582,7 +604,7 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
582
604
  | `exa` | Exa | Free | **Usable without a key** (anonymous MCP); configure a key for higher quota |
583
605
  | `tavily` | Tavily | Free | **Usable without a key** (keyless anonymous); configure a key for higher quota |
584
606
  | `keenable` | Keenable | Free | **Usable without a key** (anonymous MCP); configure a key for higher quota |
585
- | `firecrawl` | Firecrawl | Free | **Usable without a key** (official keyless anonymous quota); configure a key for higher limits |
607
+ | `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) |
586
608
  | `parallel` | Parallel | Free | **Works without a key** (official MCP anonymous quota); a key raises limits and enables precise time filtering |
587
609
  | `perplexity` | Perplexity | Paid | Requires `PERPLEXITY_API_KEY` |
588
610
  | `serpbase` | SerpBase | Paid | Requires `SERPBASE_API_KEY` (serpbase.dev, 100 free queries on signup) |
@@ -820,7 +842,9 @@ The old `free-search:` section of `~/.dsh/settings.yaml` is **not** imported by
820
842
  # config of that entry in profiles/<profile>/cordis.patch.yml:
821
843
  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
822
844
  fallbackOn: [quota, auth, bot-wall, transient, invalid-response, unknown] # failure classes allowed to trigger fallback; [] = abort on any failure (default: all)
823
- lang: zh # search language pools / localization (zh / en / ru …; no longer rewritten by the settings page)
845
+ lang: zh # search language pools / localization (zh / en / ru …; no longer rewritten by the settings page)
846
+ # lang drives Bing's mkt/Accept-Language, the Wikipedia subdomain (ja → ja.wikipedia.org) and the geocoding language;
847
+ # 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).
824
848
  uiLang: zh # settings UI language (zh / en); empty = follow lang
825
849
  bingMarket: zh-CN # Bing market; empty = automatic (no mkt sent)
826
850
  fetchProviderTakeover: true # false = keep the fetchProvider: http you pinned yourself
@@ -908,6 +932,20 @@ When you need **cross-source verification** to prevent biases or fail-safes from
908
932
  - **Untrusted Boundary**: Enforces the `<untrusted-web-content>` safety wrapper and trims snippets.
909
933
  - ⚠️ Note: Running multiple engines in parallel consumes more search quota; use on demand when high source diversity is needed.
910
934
 
935
+ #### Output contract (meta.uncertainty)
936
+
937
+ `advanced_search` and `multi_search` now return an optional `meta.uncertainty` field describing how much the result should be trusted; the existing `provider` / `content` / `sources` shape is unchanged:
938
+
939
+ ```json
940
+ { "provider": "multi", "sources": [ ... ],
941
+ "meta": { "uncertainty": { "level": "medium", "notes": ["only two sources"] } } }
942
+ ```
943
+
944
+ - `level`: `high` / `medium` / `low`, derived from the number of sources, whether they all come from one site, a time filter without publish dates, aggregator hosts, and whether any engine failed;
945
+ - `notes`: readable reasons; usually empty at `high`;
946
+ - the rendered text appends a `Confidence: …` line whenever the level is not `high`.
947
+
948
+ This is the **small step**: additive fields only, existing parsers keep working.
911
949
  #### Fetch Webpage Content (`web_fetch`)
912
950
 
913
951
  After searching, the agent can **read full webpage content** (e.g., *"Open the first link and summarize it"*). The `web_fetch` tool is enabled by default (official `dsh-web-fetch-http` provider):
@@ -988,7 +1026,7 @@ Restart `dsh web` after switching to apply changes.
988
1026
 
989
1027
  #### Offline self-check assertions (`tools/`)
990
1028
 
991
- **Twenty** zero-dependency assertion scripts ship with the repo (**534 assertions** total): **no API keys, no network, no test framework**, Node ≥ 20:
1029
+ **Twenty-four** zero-dependency assertion scripts ship with the repo (**555 assertions** total): **no API keys, no network, no test framework**, Node ≥ 20:
992
1030
 
993
1031
  | Script | Covers | Run (count) |
994
1032
  |---|---|---|
@@ -1008,6 +1046,10 @@ Restart `dsh web` after switching to apply changes.
1008
1046
  | `assert-killtree-win32.mjs` | Windows process-tree termination (#74): no longer relies on `taskkill` | `node tools/assert-killtree-win32.mjs` (8) |
1009
1047
  | `assert-killtree-portable.mjs` | portable `killTree` contract | `node tools/assert-killtree-portable.mjs` (5) |
1010
1048
  | `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) |
1049
+ | `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` (5) |
1050
+ | `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) |
1051
+ | `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) |
1052
+ | `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) |
1011
1053
  | `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) |
1012
1054
  | `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) |
1013
1055
  | `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) |
package/lib/client.js CHANGED
@@ -424,9 +424,9 @@ window.__ModuleLoader__.load({
424
424
  starCta: "Give a star",
425
425
  },
426
426
  };
427
- const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
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.9";
429
+ const PLUGIN_VERSION = "0.9.12";
430
430
  const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
431
431
  // 右侧浮动目录(#85):条目顺序即页面顺序,anchor 是页面里对应 field 的 id。
432
432
  const NAV_ITEMS = [
@@ -1127,7 +1127,7 @@ window.__ModuleLoader__.load({
1127
1127
 
1128
1128
  if (state.status === "loading") return null;
1129
1129
  const ready = state.status === "ready";
1130
- const t = tt(uiLang || (lang === "en" ? "en" : "zh"));
1130
+ const t = tt(uiLang || (String(lang ?? "").trim().toLowerCase() === "zh" ? "zh" : "en"));
1131
1131
  const navLabels = {
1132
1132
  engines: t.navEngines,
1133
1133
  fallback: t.navFallback,
@@ -2157,7 +2157,7 @@ react_jsx_runtime.jsx("div", {
2157
2157
  .map((e) => ({
2158
2158
  id: e.id,
2159
2159
  label: `${e.label}${e.badge === "AUTO" ? " · 智能路由" : e.badge === "MULTI" ? " · 并发合并" : e.badge === "FREE" ? " · 免费" : " · API Key"}`,
2160
- detail: e.id === current ? (view?.value?.lang === "en" ? "current" : "当前") : undefined,
2160
+ detail: e.id === current ? (String(view?.value?.lang ?? "").trim().toLowerCase() === "zh" ? "当前" : "current") : undefined,
2161
2161
  active: e.id === current,
2162
2162
  }));
2163
2163
  },
package/lib/index.js CHANGED
@@ -41,6 +41,113 @@ const DDG_LITE_URL = "https://lite.duckduckgo.com/lite/";
41
41
  const BING_URL = "https://www.bing.com/search";
42
42
  const TAVILY_URL = "https://api.tavily.com/search";
43
43
  const FIRECRAWL_URL = "https://api.firecrawl.dev/v2/search";
44
+ // Firecrawl 的 keyless 档按出口 IP 判定:VPN/代理出口会被 403 拒绝。
45
+ // 这个判定不随请求变化,遇到一次就冷却 6 小时,避免回退链每次都白等一次往返。
46
+ // 冷却按「代理指纹」记账:换一套代理配置就视为换了出口,旧冷却自动失效。
47
+ const FIRECRAWL_KEYLESS_COOLDOWN_MS = 6 * 60 * 60 * 1000;
48
+ let firecrawlKeylessBlock = null; // { proxy, until }
49
+
50
+ function proxyFingerprint() {
51
+ const names = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"];
52
+ return names.map((name) => String(process.env[name] ?? "")).join("|");
53
+ }
54
+
55
+ /** 该错误是不是 Firecrawl 对 keyless 档的出口 IP 拒绝(而非 key 失效/限流)。 */
56
+ export function isFirecrawlKeylessRefusal(status, body) {
57
+ if (status !== 403) return false;
58
+ return /vpn|proxy|without an api key|api key from here/i.test(String(body || ""));
59
+ }
60
+
61
+ /** 当前代理配置是否仍处于冷却中;代理配置变了就清掉旧记录。 */
62
+ export function isFirecrawlKeylessBlocked(now = Date.now(), proxy = proxyFingerprint()) {
63
+ if (firecrawlKeylessBlock === null) return false;
64
+ if (firecrawlKeylessBlock.proxy !== proxy) {
65
+ firecrawlKeylessBlock = null;
66
+ return false;
67
+ }
68
+ if (now >= firecrawlKeylessBlock.until) {
69
+ firecrawlKeylessBlock = null;
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+
75
+ // 输出契约(小步版):给 advanced_search / multi_search 增加 meta.uncertainty。
76
+ // 只做启发式提示,不改变结果本身;agent 可以据此决定是否二次核对。
77
+ const UNCERTAINTY_AGGREGATORS = /(^|\.)(zhihu|sohu|sina|163|qq|toutiao|baijiahao|csdn|jianshu|medium|substack|reddit|quora|stackexchange)\./i;
78
+
79
+ // 置信度说明的文案表:只有 zh / en 两套,其它语言回退 en(#92)。
80
+ const UNCERTAINTY_NOTES = {
81
+ empty: { zh: "没有返回结果", en: "no results returned" },
82
+ single: { zh: "只有 1 个来源", en: "only one source" },
83
+ sameSite: { zh: "所有结果来自同一站点", en: "all results come from a single site" },
84
+ two: { zh: "只有 2 个来源", en: "only two sources" },
85
+ noDates: { zh: "带时间过滤但结果没有发布时间", en: "time filter applied but the results carry no publish dates" },
86
+ partial: { zh: "部分引擎失败,结果可能不完整", en: "some engines failed; the result may be incomplete" },
87
+ aggregator: { zh: "包含聚合站内容,建议二次核对", en: "includes aggregator content; worth a second look" },
88
+ };
89
+
90
+ export function assessUncertainty({ sources = [], timeRange = false, note: rawNote = "", lang } = {}) {
91
+ const note = (key) => UNCERTAINTY_NOTES[key][outputLanguage(lang) === "zh" ? "zh" : "en"];
92
+ const list = Array.isArray(sources) ? sources : [];
93
+ const notes = [];
94
+ const domains = new Set();
95
+ for (const source of list) {
96
+ try {
97
+ domains.add(new URL(String(source.url ?? "")).hostname.replace(/^www\./i, "").toLowerCase());
98
+ } catch {}
99
+ }
100
+ let level = "high";
101
+ if (list.length === 0) {
102
+ level = "low";
103
+ notes.push(note("empty"));
104
+ } else if (list.length === 1) {
105
+ level = "low";
106
+ notes.push(note("single"));
107
+ } else if (domains.size === 1) {
108
+ level = "medium";
109
+ notes.push(note("sameSite"));
110
+ } else if (list.length === 2) {
111
+ level = "medium";
112
+ notes.push(note("two"));
113
+ }
114
+ if (timeRange && list.every((source) => !source.publishedAt)) {
115
+ if (level === "high") level = "medium";
116
+ notes.push(note("noDates"));
117
+ }
118
+ if (rawNote && /fail|error|fallback|skipped|failed/i.test(rawNote)) {
119
+ level = "low";
120
+ notes.push(note("partial"));
121
+ }
122
+ if ([...domains].some((domain) => UNCERTAINTY_AGGREGATORS.test(domain))) {
123
+ notes.push(note("aggregator"));
124
+ if (level === "high") level = "medium";
125
+ }
126
+ return { level, notes };
127
+ }
128
+
129
+ // advanced_search / multi_search 的输出 schema 片段:只新增可选字段,不改既有结构。
130
+ const UNCERTAINTY_SCHEMA = {
131
+ type: "object",
132
+ additionalProperties: false,
133
+ properties: {
134
+ uncertainty: {
135
+ type: "object",
136
+ additionalProperties: false,
137
+ properties: {
138
+ level: { type: "string" },
139
+ notes: { type: "array", items: { type: "string" } },
140
+ },
141
+ },
142
+ },
143
+ };
144
+ /** 一个字段/环境变量里可以放多把 key:逗号、分号或换行分隔。 */
145
+ export function splitApiKeys(raw) {
146
+ return String(raw ?? "").split(/[\n,;]+/).map((key) => key.trim()).filter(Boolean);
147
+ }
148
+ export function markFirecrawlKeylessBlocked(proxy = proxyFingerprint()) {
149
+ firecrawlKeylessBlock = { proxy, until: Date.now() + FIRECRAWL_KEYLESS_COOLDOWN_MS };
150
+ }
44
151
  const PARALLEL_URL = "https://api.parallel.ai/v1/search";
45
152
  const PARALLEL_MCP_URL = "https://search.parallel.ai/mcp";
46
153
  const KEENABLE_URL = "https://api.keenable.ai/v1/search";
@@ -55,10 +162,27 @@ const ZHIHU_MCP_URL = "https://developer.zhihu.com/api/mcp/v1";
55
162
  const USER_AGENT =
56
163
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
57
164
  const ACCEPT_LANG = "zh-CN,zh;q=0.9,en;q=0.8";
165
+ // 当前请求生效的 Accept-Language:由 lang(或显式 bingMarket)决定,搜索前设置(#92)。
166
+ let activeAcceptLanguage = ACCEPT_LANG;
167
+ export function setActiveAcceptLanguage(value) {
168
+ if (typeof value === "string" && value.trim() !== "") activeAcceptLanguage = value.trim();
169
+ }
58
170
 
59
171
  // 语言 → Bing 本地化档案:{ market, acceptLang }。
60
172
  // lang 字段(设置页可切换)驱动 Bing 的 mkt + Accept-Language,让非中文用户
61
173
  // 也能拿到本地化结果(俄语搜俄文等)。bingMarket 显式设置时优先于映射。
174
+ // #92:区分「搜索语言」与「输出语言」。
175
+ // - searchLanguage:交给 provider 的参数(Wikipedia 主机、geocoding、Accept-Language)——支持的语言原样透传,未知回退 en;
176
+ // - outputLanguage:我们自己渲染的文案(天气、置信度说明)——只有 zh/en 两套文案,其它语言回退 en,
177
+ // 否则日语/俄语用户会在回答前拿到一整段中文。
178
+ export function searchLanguage(lang) {
179
+ const tag = String(lang ?? "").trim().toLowerCase();
180
+ return Object.prototype.hasOwnProperty.call(LANG_PROFILES, tag) ? tag : "en";
181
+ }
182
+
183
+ export function outputLanguage(lang) {
184
+ return searchLanguage(lang) === "zh" ? "zh" : "en";
185
+ }
62
186
  const LANG_PROFILES = {
63
187
  zh: { market: "zh-CN", acceptLang: "zh-CN,zh;q=0.9,en;q=0.8" },
64
188
  en: { market: "en-US", acceptLang: "en-US,en;q=0.9" },
@@ -256,7 +380,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
256
380
  }
257
381
 
258
382
  // 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
259
- const PLUGIN_VERSION = "0.9.9";
383
+ const PLUGIN_VERSION = "0.9.12";
260
384
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
261
385
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
262
386
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -476,7 +600,7 @@ async function fetchHtml(url, signal, acceptLang) {
476
600
  const onAbort = () => controller.abort();
477
601
  signal?.addEventListener("abort", onAbort);
478
602
  response = await fetch(url, {
479
- headers: { "user-agent": USER_AGENT, "accept-language": acceptLang ?? ACCEPT_LANG },
603
+ headers: { "user-agent": USER_AGENT, "accept-language": acceptLang ?? activeAcceptLanguage },
480
604
  signal: controller.signal,
481
605
  redirect: "follow",
482
606
  });
@@ -731,7 +855,19 @@ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
731
855
  // AnySearch: 免费匿名额度(无 key),可选 Bearer key 提额;结构化 JSON 结果
732
856
  // 401/403:本进程忽略该 key 值(不删除存储),改回匿名免费;不自动清空用户配置
733
857
  let ignoredAnysearchKey = "";
734
- const ANYSEARCH_KEY_INVALID_NOTE = "AnySearch key 无效,本次已忽略该 key,改回免费匿名";
858
+ // 当前请求生效的输出语言(zh / en),由 lang 推导;只有我们自己渲染的文案用它(#92)。
859
+ let activeOutputLanguage = "zh";
860
+ export function setActiveOutputLanguage(value) {
861
+ activeOutputLanguage = String(value ?? "").trim().toLowerCase() === "zh" ? "zh" : "en";
862
+ }
863
+ export function getActiveOutputLanguage() {
864
+ return activeOutputLanguage;
865
+ }
866
+ const ANYSEARCH_KEY_INVALID_NOTE_ZH = "AnySearch key 无效,本次已忽略该 key,改回免费匿名档";
867
+ const ANYSEARCH_KEY_INVALID_NOTE_EN = "AnySearch key was rejected; ignored it and used the anonymous tier";
868
+ function anysearchKeyNote() {
869
+ return activeOutputLanguage === "zh" ? ANYSEARCH_KEY_INVALID_NOTE_ZH : ANYSEARCH_KEY_INVALID_NOTE_EN;
870
+ }
735
871
 
736
872
  async function searchAnysearch(query, maxResults, signal, apiKey) {
737
873
  const trimmedKey = typeof apiKey === "string" ? apiKey.trim() : "";
@@ -769,7 +905,7 @@ async function searchAnysearch(query, maxResults, signal, apiKey) {
769
905
  signal?.removeEventListener("abort", onAbort);
770
906
  }
771
907
  if (response.status === 401 || response.status === 403) {
772
- throw new Error(keyRejected ? `${ANYSEARCH_KEY_INVALID_NOTE} (HTTP ${response.status})` : `AnySearch API error (HTTP ${response.status})`);
908
+ throw new Error(keyRejected ? `${anysearchKeyNote()} (HTTP ${response.status})` : `AnySearch API error (HTTP ${response.status})`);
773
909
  }
774
910
  if (!response.ok) throw new Error(`AnySearch API error (HTTP ${response.status})`);
775
911
  const data = await response.json();
@@ -784,7 +920,7 @@ async function searchAnysearch(query, maxResults, signal, apiKey) {
784
920
  ...(r.snippet ? { snippet: String(r.snippet).slice(0, 300) } : {}),
785
921
  })),
786
922
  truncated: false,
787
- ...(keyRejected || keyIgnoredSticky ? { content: ANYSEARCH_KEY_INVALID_NOTE } : {}),
923
+ ...(keyRejected || keyIgnoredSticky ? { content: anysearchKeyNote() } : {}),
788
924
  };
789
925
  }
790
926
 
@@ -1016,7 +1152,7 @@ async function searchStackOverflow(query, maxResults, signal) {
1016
1152
  }
1017
1153
 
1018
1154
  async function searchWikipedia(query, maxResults, signal, lang) {
1019
- const host = lang === "en" ? "en.wikipedia.org" : "zh.wikipedia.org";
1155
+ const host = `${searchLanguage(lang)}.wikipedia.org`;
1020
1156
  const response = await fetch(
1021
1157
  `https://${host}/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&srlimit=${maxResults ?? 5}`,
1022
1158
  {
@@ -1189,7 +1325,7 @@ async function fetchVideoHtml(url, signal, extraHeaders) {
1189
1325
  response = await fetch(url, {
1190
1326
  headers: {
1191
1327
  "user-agent": USER_AGENT,
1192
- "accept-language": ACCEPT_LANG,
1328
+ "accept-language": activeAcceptLanguage,
1193
1329
  accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
1194
1330
  ...(extraHeaders ?? {}),
1195
1331
  },
@@ -1532,6 +1668,12 @@ async function searchFirecrawl(query, maxResults, apiKey, timeRange, signal) {
1532
1668
  }
1533
1669
  if (!response.ok) {
1534
1670
  const detail = await response.text().catch(() => "");
1671
+ if (!apiKey && isFirecrawlKeylessRefusal(response.status, detail)) {
1672
+ markFirecrawlKeylessBlocked();
1673
+ throw new Error(
1674
+ "Firecrawl keyless tier is blocked from this network (VPN/proxy egress) — set FIRECRAWL_API_KEY to use Firecrawl; cooling down for 6h (cleared automatically if you switch proxy)"
1675
+ );
1676
+ }
1535
1677
  if (response.status === 401) {
1536
1678
  throw new Error("Firecrawl API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
1537
1679
  }
@@ -3587,17 +3729,49 @@ function apply(ctx, config) {
3587
3729
  };
3588
3730
 
3589
3731
  // key 优先级:credentials(.credentials.yaml 凭据中心,官方推荐)> settings 的 free-search.<x>ApiKey(遗留兼容)> 环境变量
3732
+ // 多 key:同一个字段/环境变量里用逗号(或分号/换行)分隔多个 key。
3733
+ // 命中 auth/quota 失败时整条链重试一次并轮换到下一把,而不是直接判死。
3734
+ const keyRings = new Map();
3735
+ const ringFor = (envName, raw) => {
3736
+ const keys = splitApiKeys(raw);
3737
+ const signature = keys.join("\u0000");
3738
+ const previous = keyRings.get(envName);
3739
+ if (!previous || previous.signature !== signature) {
3740
+ keyRings.set(envName, { keys, index: 0, signature });
3741
+ }
3742
+ return keyRings.get(envName);
3743
+ };
3744
+ const maxKeyRingLength = () => {
3745
+ let max = 1;
3746
+ for (const ring of keyRings.values()) max = Math.max(max, ring.keys.length);
3747
+ return max;
3748
+ };
3749
+ const rotateAllKeyRings = () => {
3750
+ for (const ring of keyRings.values()) {
3751
+ if (ring.keys.length > 1) ring.index = (ring.index + 1) % ring.keys.length;
3752
+ }
3753
+ };
3754
+ const isKeyRotationCandidate = (error) => {
3755
+ const failure = classifyFailure(error);
3756
+ return failure.class === "auth" || failure.class === "quota";
3757
+ };
3590
3758
  const resolveApiKey = async (envName, settingsKey) => {
3759
+ let raw = "";
3591
3760
  const credentials = getCredentials();
3592
3761
  if (credentials) {
3593
3762
  try {
3594
3763
  const resolved = await credentials.resolve(envName);
3595
- if (resolved?.value) return resolved.value;
3764
+ if (resolved?.value) raw = resolved.value;
3596
3765
  } catch {}
3597
3766
  }
3598
- const cfg = current();
3599
- if (settingsKey && cfg[settingsKey]) return cfg[settingsKey];
3600
- return process.env[envName] ?? "";
3767
+ if (!raw) {
3768
+ const cfg = current();
3769
+ if (settingsKey && cfg[settingsKey]) raw = cfg[settingsKey];
3770
+ else raw = process.env[envName] ?? "";
3771
+ }
3772
+ if (!raw) return "";
3773
+ const ring = ringFor(envName, raw);
3774
+ return ring.keys[ring.index % ring.keys.length] ?? "";
3601
3775
  };
3602
3776
  // 豆包搜索:主 key 名 DOUBAO_SEARCH_API_KEY;官方示例也用 WEB_SEARCH_API_KEY,作为回退
3603
3777
  const resolveDoubaoKey = async () => {
@@ -3626,6 +3800,20 @@ function apply(ctx, config) {
3626
3800
  },
3627
3801
  // 单 query 结果缓存:key=query+maxResults+timeRangeLabel+preferred,Map 天然 LRU
3628
3802
  async search(request, signal) {
3803
+ // 轮换时机:首次失败后 keyRings 已经填充,此时再判断有几把 key。
3804
+ for (let attempt = 0; attempt < 10; attempt++) {
3805
+ try {
3806
+ return await provider.searchOnce(request, signal);
3807
+ } catch (error) {
3808
+ const keys = maxKeyRingLength();
3809
+ if (keys <= 1 || attempt >= keys - 1 || !isKeyRotationCandidate(error)) throw error;
3810
+ rotateAllKeyRings();
3811
+ logger.warn(`free-search: retrying with the next API key (${error?.message ?? error})`);
3812
+ }
3813
+ }
3814
+ throw new Error("all search engines failed");
3815
+ },
3816
+ async searchOnce(request, signal) {
3629
3817
  // 公共咽喉校验:web_search / advanced_search / raw-search 三条路径都经过这里
3630
3818
  if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.trim().length === 0) {
3631
3819
  throw new Error("query is required");
@@ -3633,6 +3821,11 @@ function apply(ctx, config) {
3633
3821
  const cfg = current();
3634
3822
  // #72:本次请求生效的域名黑名单(模块级活动值;各结果咽喉点过滤读取它)
3635
3823
  setActiveBlockedDomains(cfg.blockedDomains);
3824
+ // #92:Accept-Language 跟随 lang(显式 bingMarket 优先),不再固定中文
3825
+ setActiveAcceptLanguage(
3826
+ (cfg.bingMarket && MARKET_TO_LANG[cfg.bingMarket]) || LANG_PROFILES[searchLanguage(cfg.lang)].acceptLang
3827
+ );
3828
+ setActiveOutputLanguage(outputLanguage(cfg.lang));
3636
3829
  const disabledEngines = normalizeDisabledEngines(cfg.disabledEngines);
3637
3830
  const fallbackOnSet = new Set(normalizeFallbackOn(cfg.fallbackOn));
3638
3831
  const disabledSet = new Set(disabledEngines);
@@ -3783,6 +3976,11 @@ function apply(ctx, config) {
3783
3976
  } else if (engine === "firecrawl") {
3784
3977
  // firecrawl:无 key 也可用(官方免 key 匿名额度),有 key 走账号档(更高限额)
3785
3978
  const key = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey");
3979
+ if (!key && isFirecrawlKeylessBlocked()) {
3980
+ throw new Error(
3981
+ "Firecrawl keyless tier is blocked from this network (VPN/proxy egress, remembered for 6h; switch proxy to retry) — configure FIRECRAWL_API_KEY or use another engine"
3982
+ );
3983
+ }
3786
3984
  result = await searchFirecrawl(request.query, request.maxResults, key, timeRange, effSignal);
3787
3985
  } else if (engine === "parallel") {
3788
3986
  // parallel:有 key 走 REST(支持 after_date 精确过滤),无 key 走 keyless MCP(免费匿名额度)
@@ -4288,6 +4486,9 @@ function apply(ctx, config) {
4288
4486
  }
4289
4487
  case "firecrawl": {
4290
4488
  const key = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey");
4489
+ if (!key && isFirecrawlKeylessBlocked()) {
4490
+ throw new Error("Firecrawl keyless tier is blocked from this network (VPN/proxy egress, remembered for 6h) — configure FIRECRAWL_API_KEY or use another engine");
4491
+ }
4291
4492
  return await searchFirecrawl(q, n, key, tr);
4292
4493
  }
4293
4494
  case "parallel": {
@@ -4993,7 +5194,7 @@ function apply(ctx, config) {
4993
5194
  output: {
4994
5195
  schema: WEATHER_OUTPUT_SCHEMA,
4995
5196
  render(args, value) {
4996
- const lang = current().lang === "en" ? "en" : "zh";
5197
+ const lang = outputLanguage(current().lang);
4997
5198
  const c = value.current ?? {};
4998
5199
  const lines = [];
4999
5200
  lines.push(lang === "en" ? `Weather for ${value.location}:` : `${value.location} 天气:`);
@@ -5013,7 +5214,8 @@ function apply(ctx, config) {
5013
5214
  },
5014
5215
  },
5015
5216
  async execute(args) {
5016
- const lang = current().lang === "en" ? "en" : "zh";
5217
+ // 天气正文只提供 zh/en(回退 en),地理编码用真实搜索语言(ja 就查日文地名)。
5218
+ const lang = searchLanguage(current().lang);
5017
5219
  const location = String(args.location ?? "").trim();
5018
5220
  if (!location) throw new Error("weather: location is required");
5019
5221
  return sanitizeWeatherResult(await fetchWeather(location, args.days, lang, undefined));
@@ -5077,6 +5279,7 @@ function apply(ctx, config) {
5077
5279
  },
5078
5280
  },
5079
5281
  },
5282
+ meta: UNCERTAINTY_SCHEMA,
5080
5283
  },
5081
5284
  },
5082
5285
  render(args, value) {
@@ -5084,7 +5287,12 @@ function apply(ctx, config) {
5084
5287
  const note = value.content ? String(value.content).trim() : "";
5085
5288
  // 空结果时区分「真的没搜到」与「引擎出错」:有 note(失败原因等)就展示,而不是谎报 No results
5086
5289
  const body = lines.length > 0 ? `${lines.join("\n")}${note ? `\n\n${note}` : ""}` : note || "No results found.";
5087
- return [{ type: "text", text: `Search (${value.provider}${args.timeRange ? `, timeRange=${args.timeRange}` : ""}):\n${wrapUntrustedBlock(body)}` }];
5290
+ const uncertainty = value.meta?.uncertainty;
5291
+ const confidence =
5292
+ uncertainty && uncertainty.level !== "high"
5293
+ ? `\n\nConfidence: ${uncertainty.level}${Array.isArray(uncertainty.notes) && uncertainty.notes.length > 0 ? ` — ${uncertainty.notes.join("; ")}` : ""}`
5294
+ : "";
5295
+ return [{ type: "text", text: `Search (${value.provider}${args.timeRange ? `, timeRange=${args.timeRange}` : ""}):\n${wrapUntrustedBlock(body)}${confidence}` }];
5088
5296
  },
5089
5297
  },
5090
5298
  async execute(args) {
@@ -5111,6 +5319,14 @@ function apply(ctx, config) {
5111
5319
  }
5112
5320
  return source;
5113
5321
  }),
5322
+ meta: {
5323
+ uncertainty: assessUncertainty({
5324
+ sources: result.sources ?? [],
5325
+ timeRange: Boolean(request.timeRange),
5326
+ note: typeof result.content === "string" ? result.content : "",
5327
+ lang: current().lang,
5328
+ }),
5329
+ },
5114
5330
  };
5115
5331
  },
5116
5332
  finalizeContent(exec, result) {
@@ -5173,6 +5389,7 @@ function apply(ctx, config) {
5173
5389
  },
5174
5390
  },
5175
5391
  },
5392
+ meta: UNCERTAINTY_SCHEMA,
5176
5393
  },
5177
5394
  },
5178
5395
  render(args, value) {
@@ -5183,7 +5400,12 @@ function apply(ctx, config) {
5183
5400
  const note = value.content ? String(value.content).trim() : "";
5184
5401
  // 空结果时区分「真的没搜到」与「引擎出错」:有 note(失败原因等)就展示,而不是谎报 No results
5185
5402
  const body = lines.length > 0 ? `${lines.join("\n")}${note ? `\n\n${note}` : ""}` : note || "No results found.";
5186
- return [{ type: "text", text: `Multi-Search (${value.provider}):\n${wrapUntrustedBlock(body)}` }];
5403
+ const uncertainty = value.meta?.uncertainty;
5404
+ const confidence =
5405
+ uncertainty && uncertainty.level !== "high"
5406
+ ? `\n\nConfidence: ${uncertainty.level}${Array.isArray(uncertainty.notes) && uncertainty.notes.length > 0 ? ` — ${uncertainty.notes.join("; ")}` : ""}`
5407
+ : "";
5408
+ return [{ type: "text", text: `Multi-Search (${value.provider}):\n${wrapUntrustedBlock(body)}${confidence}` }];
5187
5409
  },
5188
5410
  },
5189
5411
  async execute(args) {
@@ -5196,7 +5418,16 @@ function apply(ctx, config) {
5196
5418
  // where SearchProvider may use `truncated`. The tool schema is strict
5197
5419
  // (additionalProperties: false), so expose only declared tool fields here.
5198
5420
  const { truncated: _truncated, ...toolResult } = result;
5199
- return toolResult;
5421
+ return {
5422
+ ...toolResult,
5423
+ meta: {
5424
+ uncertainty: assessUncertainty({
5425
+ sources: toolResult.sources ?? [],
5426
+ note: typeof toolResult.content === "string" ? toolResult.content : "",
5427
+ lang: current().lang,
5428
+ }),
5429
+ },
5430
+ };
5200
5431
  },
5201
5432
  finalizeContent(exec, result) {
5202
5433
  const text = result.content;
package/lib/weather.js CHANGED
@@ -38,14 +38,20 @@ const WMO_CODES = {
38
38
  96: ["雷暴伴小冰雹", "Thunderstorm with slight hail"],
39
39
  99: ["雷暴伴大冰雹", "Thunderstorm with heavy hail"],
40
40
  };
41
- function wmoText(code, lang) {
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);
42
48
  const hit = WMO_CODES[code];
43
- if (!hit) return lang === "en" ? "Unknown" : "未知";
44
- return (lang === "en" ? hit[1] : hit[0]) + ` (WMO ${code})`;
49
+ if (!hit) return textLang === "zh" ? "未知" : "Unknown";
50
+ return (textLang === "zh" ? hit[0] : hit[1]) + ` (WMO ${code})`;
45
51
  }
46
52
 
47
- export async function geocodeLocation(name, signal) {
48
- const params = new URLSearchParams({ name, count: "1", language: "zh", format: "json" });
53
+ export async function geocodeLocation(name, signal, language = "en") {
54
+ const params = new URLSearchParams({ name, count: "1", language: String(language ?? "en") || "en", format: "json" });
49
55
  const response = await fetch(`${OPEN_METEO_GEOCODING_URL}?${params}`, {
50
56
  method: "GET",
51
57
  redirect: "error",
@@ -66,7 +72,7 @@ export async function geocodeLocation(name, signal) {
66
72
  }
67
73
 
68
74
  export async function fetchWeather(location, days, lang, signal) {
69
- const geo = await geocodeLocation(location, signal);
75
+ const geo = await geocodeLocation(location, signal, lang);
70
76
  if (!geo) throw new Error(`could not geocode location "${location}" — try a nearby larger city`);
71
77
  const params = new URLSearchParams({
72
78
  latitude: String(geo.latitude),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.9.9",
3
+ "version": "0.9.12",
4
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.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",