dsh-free-search 0.9.9 → 0.9.11
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 +40 -4
- package/lib/client.js +1 -1
- package/lib/index.js +190 -8
- package/package.json +1 -1
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 次免费额度) |
|
|
@@ -392,6 +393,20 @@ Search engine test:
|
|
|
392
393
|
- **不可信边界与清洗**:严格遵守 `<untrusted-web-content>` 数据边界,正文 snippet 统一清洗。
|
|
393
394
|
- ⚠️ 注:多源并发会消耗更多 API 配额,建议在需要多角度核验时按需使用。
|
|
394
395
|
|
|
396
|
+
#### 输出契约(meta.uncertainty)
|
|
397
|
+
|
|
398
|
+
`advanced_search` 与 `multi_search` 的输出多了一个可选字段 `meta.uncertainty`,用来告诉 agent「这次结果有多可信」,不影响原有 `provider` / `content` / `sources`:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
{ "provider": "multi", "sources": [ ... ],
|
|
402
|
+
"meta": { "uncertainty": { "level": "medium", "notes": ["只有 2 个来源"] } } }
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
- `level`:`high` / `medium` / `low`,按来源数量、站点是否同一家、带时间过滤却无发布时间、是否包含聚合站、是否有引擎失败等启发式判定;
|
|
406
|
+
- `notes`:具体原因(中文);`high` 时通常为空;
|
|
407
|
+
- 渲染文本在非 `high` 时会追加一行 `Confidence: …`。
|
|
408
|
+
|
|
409
|
+
这是**小步版**:只新增字段,旧解析不受影响。
|
|
395
410
|
#### 抓取网页内容(web_fetch)
|
|
396
411
|
|
|
397
412
|
搜索到 URL 后,可以让 agent **读取网页全文**(如"打开第一个链接看看内容")。`web_fetch` 工具已启用(官方 `dsh-web-fetch-http` provider):
|
|
@@ -472,7 +487,7 @@ Search engine test:
|
|
|
472
487
|
|
|
473
488
|
#### 离线自检断言(tools/)
|
|
474
489
|
|
|
475
|
-
仓库自带 **
|
|
490
|
+
仓库自带 **23 个**零依赖断言脚本(合计 **550 项**),**不需要 API key、不联网、不需要测试框架**,Node ≥ 20 即可跑:
|
|
476
491
|
|
|
477
492
|
| 脚本 | 覆盖 | 运行(项数) |
|
|
478
493
|
|---|---|---|
|
|
@@ -492,6 +507,9 @@ Search engine test:
|
|
|
492
507
|
| `assert-killtree-win32.mjs` | Windows 子进程树终止(#74):不再依赖 `taskkill` | `node tools/assert-killtree-win32.mjs`(8) |
|
|
493
508
|
| `assert-killtree-portable.mjs` | 跨平台 `killTree` 便携版契约 | `node tools/assert-killtree-portable.mjs`(5) |
|
|
494
509
|
| `assert-parallel-mcp.mjs` | Parallel MCP 引擎:25 s 超时/调用方取消覆盖**响应体读取**、共享 SSE 解析器(通知/多行帧)、6 类错误路径、REST 精确时间过滤 | `node tools/assert-parallel-mcp.mjs`(15) |
|
|
510
|
+
| `assert-uncertainty.mjs` | 输出契约:0/1 源为 low、2 源与同站为 medium、3 个不同域为 high、时间过滤无日期、失败备注、聚合站提示 | `node tools/assert-uncertainty.mjs`(8) |
|
|
511
|
+
| `assert-multikey.mjs` | 多 key 字段:单 key 不变、逗号/分号/换行分隔、空白项丢弃、空输入为空数组 | `node tools/assert-multikey.mjs`(3) |
|
|
512
|
+
| `assert-firecrawl-keyless.mjs` | Firecrawl keyless 出口判定:403 + VPN/proxy 文案才算拒绝、普通 403 与 401/429/500 不算、冷却初始未触发 | `node tools/assert-firecrawl-keyless.mjs`(5) |
|
|
495
513
|
| `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
514
|
| `assert-fetch-abort.mjs` | 抓取中断(#87):压缩响应中途断连必须 reject(不再永久 pending)、明文断连同样 reject、abort 能打断停滞的 gzip 流、complete 响应与重定向仍正常 | `node tools/assert-fetch-abort.mjs`(6) |
|
|
497
515
|
| `assert-bridge-access.mjs` | 桥接 Host 围栏(#84):loopback 放行、remote peer 打 loopback Host 拒绝、`--trusted-host`(无端口匹配任意端口 / 带端口精确匹配)、`--public-url`、绑定地址、跨站与异源拒绝 | `node tools/assert-bridge-access.mjs`(11) |
|
|
@@ -547,6 +565,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
547
565
|
- **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
566
|
- **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
567
|
- **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.
|
|
568
|
+
- **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
569
|
- **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
570
|
- **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
571
|
- **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 +601,7 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
|
|
|
582
601
|
| `exa` | Exa | Free | **Usable without a key** (anonymous MCP); configure a key for higher quota |
|
|
583
602
|
| `tavily` | Tavily | Free | **Usable without a key** (keyless anonymous); configure a key for higher quota |
|
|
584
603
|
| `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);
|
|
604
|
+
| `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
605
|
| `parallel` | Parallel | Free | **Works without a key** (official MCP anonymous quota); a key raises limits and enables precise time filtering |
|
|
587
606
|
| `perplexity` | Perplexity | Paid | Requires `PERPLEXITY_API_KEY` |
|
|
588
607
|
| `serpbase` | SerpBase | Paid | Requires `SERPBASE_API_KEY` (serpbase.dev, 100 free queries on signup) |
|
|
@@ -908,6 +927,20 @@ When you need **cross-source verification** to prevent biases or fail-safes from
|
|
|
908
927
|
- **Untrusted Boundary**: Enforces the `<untrusted-web-content>` safety wrapper and trims snippets.
|
|
909
928
|
- ⚠️ Note: Running multiple engines in parallel consumes more search quota; use on demand when high source diversity is needed.
|
|
910
929
|
|
|
930
|
+
#### Output contract (meta.uncertainty)
|
|
931
|
+
|
|
932
|
+
`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:
|
|
933
|
+
|
|
934
|
+
```json
|
|
935
|
+
{ "provider": "multi", "sources": [ ... ],
|
|
936
|
+
"meta": { "uncertainty": { "level": "medium", "notes": ["only two sources"] } } }
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
- `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;
|
|
940
|
+
- `notes`: readable reasons; usually empty at `high`;
|
|
941
|
+
- the rendered text appends a `Confidence: …` line whenever the level is not `high`.
|
|
942
|
+
|
|
943
|
+
This is the **small step**: additive fields only, existing parsers keep working.
|
|
911
944
|
#### Fetch Webpage Content (`web_fetch`)
|
|
912
945
|
|
|
913
946
|
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 +1021,7 @@ Restart `dsh web` after switching to apply changes.
|
|
|
988
1021
|
|
|
989
1022
|
#### Offline self-check assertions (`tools/`)
|
|
990
1023
|
|
|
991
|
-
**Twenty** zero-dependency assertion scripts ship with the repo (**
|
|
1024
|
+
**Twenty-three** zero-dependency assertion scripts ship with the repo (**550 assertions** total): **no API keys, no network, no test framework**, Node ≥ 20:
|
|
992
1025
|
|
|
993
1026
|
| Script | Covers | Run (count) |
|
|
994
1027
|
|---|---|---|
|
|
@@ -1008,6 +1041,9 @@ Restart `dsh web` after switching to apply changes.
|
|
|
1008
1041
|
| `assert-killtree-win32.mjs` | Windows process-tree termination (#74): no longer relies on `taskkill` | `node tools/assert-killtree-win32.mjs` (8) |
|
|
1009
1042
|
| `assert-killtree-portable.mjs` | portable `killTree` contract | `node tools/assert-killtree-portable.mjs` (5) |
|
|
1010
1043
|
| `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) |
|
|
1044
|
+
| `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) |
|
|
1045
|
+
| `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) |
|
|
1046
|
+
| `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
1047
|
| `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
1048
|
| `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
1049
|
| `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
|
@@ -426,7 +426,7 @@ window.__ModuleLoader__.load({
|
|
|
426
426
|
};
|
|
427
427
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
428
428
|
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
|
|
429
|
-
const PLUGIN_VERSION = "0.9.
|
|
429
|
+
const PLUGIN_VERSION = "0.9.11";
|
|
430
430
|
const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
|
|
431
431
|
// 右侧浮动目录(#85):条目顺序即页面顺序,anchor 是页面里对应 field 的 id。
|
|
432
432
|
const NAV_ITEMS = [
|
package/lib/index.js
CHANGED
|
@@ -41,6 +41,101 @@ 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
|
+
export function assessUncertainty({ sources = [], timeRange = false, note = "" } = {}) {
|
|
80
|
+
const list = Array.isArray(sources) ? sources : [];
|
|
81
|
+
const notes = [];
|
|
82
|
+
const domains = new Set();
|
|
83
|
+
for (const source of list) {
|
|
84
|
+
try {
|
|
85
|
+
domains.add(new URL(String(source.url ?? "")).hostname.replace(/^www\./i, "").toLowerCase());
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
let level = "high";
|
|
89
|
+
if (list.length === 0) {
|
|
90
|
+
level = "low";
|
|
91
|
+
notes.push("没有返回结果");
|
|
92
|
+
} else if (list.length === 1) {
|
|
93
|
+
level = "low";
|
|
94
|
+
notes.push("只有 1 个来源");
|
|
95
|
+
} else if (domains.size === 1) {
|
|
96
|
+
level = "medium";
|
|
97
|
+
notes.push("所有结果来自同一站点");
|
|
98
|
+
} else if (list.length === 2) {
|
|
99
|
+
level = "medium";
|
|
100
|
+
notes.push("只有 2 个来源");
|
|
101
|
+
}
|
|
102
|
+
if (timeRange && list.every((source) => !source.publishedAt)) {
|
|
103
|
+
if (level === "high") level = "medium";
|
|
104
|
+
notes.push("带时间过滤但结果没有发布时间");
|
|
105
|
+
}
|
|
106
|
+
if (note && /fail|error|fallback|skipped|failed/i.test(note)) {
|
|
107
|
+
level = "low";
|
|
108
|
+
notes.push("部分引擎失败,结果可能不完整");
|
|
109
|
+
}
|
|
110
|
+
if ([...domains].some((domain) => UNCERTAINTY_AGGREGATORS.test(domain))) {
|
|
111
|
+
notes.push("包含聚合站内容,建议二次核对");
|
|
112
|
+
if (level === "high") level = "medium";
|
|
113
|
+
}
|
|
114
|
+
return { level, notes };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// advanced_search / multi_search 的输出 schema 片段:只新增可选字段,不改既有结构。
|
|
118
|
+
const UNCERTAINTY_SCHEMA = {
|
|
119
|
+
type: "object",
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
properties: {
|
|
122
|
+
uncertainty: {
|
|
123
|
+
type: "object",
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
properties: {
|
|
126
|
+
level: { type: "string" },
|
|
127
|
+
notes: { type: "array", items: { type: "string" } },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
/** 一个字段/环境变量里可以放多把 key:逗号、分号或换行分隔。 */
|
|
133
|
+
export function splitApiKeys(raw) {
|
|
134
|
+
return String(raw ?? "").split(/[\n,;]+/).map((key) => key.trim()).filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
export function markFirecrawlKeylessBlocked(proxy = proxyFingerprint()) {
|
|
137
|
+
firecrawlKeylessBlock = { proxy, until: Date.now() + FIRECRAWL_KEYLESS_COOLDOWN_MS };
|
|
138
|
+
}
|
|
44
139
|
const PARALLEL_URL = "https://api.parallel.ai/v1/search";
|
|
45
140
|
const PARALLEL_MCP_URL = "https://search.parallel.ai/mcp";
|
|
46
141
|
const KEENABLE_URL = "https://api.keenable.ai/v1/search";
|
|
@@ -256,7 +351,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
|
|
|
256
351
|
}
|
|
257
352
|
|
|
258
353
|
// 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
|
|
259
|
-
const PLUGIN_VERSION = "0.9.
|
|
354
|
+
const PLUGIN_VERSION = "0.9.11";
|
|
260
355
|
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
261
356
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
262
357
|
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
@@ -1532,6 +1627,12 @@ async function searchFirecrawl(query, maxResults, apiKey, timeRange, signal) {
|
|
|
1532
1627
|
}
|
|
1533
1628
|
if (!response.ok) {
|
|
1534
1629
|
const detail = await response.text().catch(() => "");
|
|
1630
|
+
if (!apiKey && isFirecrawlKeylessRefusal(response.status, detail)) {
|
|
1631
|
+
markFirecrawlKeylessBlocked();
|
|
1632
|
+
throw new Error(
|
|
1633
|
+
"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)"
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1535
1636
|
if (response.status === 401) {
|
|
1536
1637
|
throw new Error("Firecrawl API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
1537
1638
|
}
|
|
@@ -3587,17 +3688,49 @@ function apply(ctx, config) {
|
|
|
3587
3688
|
};
|
|
3588
3689
|
|
|
3589
3690
|
// key 优先级:credentials(.credentials.yaml 凭据中心,官方推荐)> settings 的 free-search.<x>ApiKey(遗留兼容)> 环境变量
|
|
3691
|
+
// 多 key:同一个字段/环境变量里用逗号(或分号/换行)分隔多个 key。
|
|
3692
|
+
// 命中 auth/quota 失败时整条链重试一次并轮换到下一把,而不是直接判死。
|
|
3693
|
+
const keyRings = new Map();
|
|
3694
|
+
const ringFor = (envName, raw) => {
|
|
3695
|
+
const keys = splitApiKeys(raw);
|
|
3696
|
+
const signature = keys.join("\u0000");
|
|
3697
|
+
const previous = keyRings.get(envName);
|
|
3698
|
+
if (!previous || previous.signature !== signature) {
|
|
3699
|
+
keyRings.set(envName, { keys, index: 0, signature });
|
|
3700
|
+
}
|
|
3701
|
+
return keyRings.get(envName);
|
|
3702
|
+
};
|
|
3703
|
+
const maxKeyRingLength = () => {
|
|
3704
|
+
let max = 1;
|
|
3705
|
+
for (const ring of keyRings.values()) max = Math.max(max, ring.keys.length);
|
|
3706
|
+
return max;
|
|
3707
|
+
};
|
|
3708
|
+
const rotateAllKeyRings = () => {
|
|
3709
|
+
for (const ring of keyRings.values()) {
|
|
3710
|
+
if (ring.keys.length > 1) ring.index = (ring.index + 1) % ring.keys.length;
|
|
3711
|
+
}
|
|
3712
|
+
};
|
|
3713
|
+
const isKeyRotationCandidate = (error) => {
|
|
3714
|
+
const failure = classifyFailure(error);
|
|
3715
|
+
return failure.class === "auth" || failure.class === "quota";
|
|
3716
|
+
};
|
|
3590
3717
|
const resolveApiKey = async (envName, settingsKey) => {
|
|
3718
|
+
let raw = "";
|
|
3591
3719
|
const credentials = getCredentials();
|
|
3592
3720
|
if (credentials) {
|
|
3593
3721
|
try {
|
|
3594
3722
|
const resolved = await credentials.resolve(envName);
|
|
3595
|
-
if (resolved?.value)
|
|
3723
|
+
if (resolved?.value) raw = resolved.value;
|
|
3596
3724
|
} catch {}
|
|
3597
3725
|
}
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3726
|
+
if (!raw) {
|
|
3727
|
+
const cfg = current();
|
|
3728
|
+
if (settingsKey && cfg[settingsKey]) raw = cfg[settingsKey];
|
|
3729
|
+
else raw = process.env[envName] ?? "";
|
|
3730
|
+
}
|
|
3731
|
+
if (!raw) return "";
|
|
3732
|
+
const ring = ringFor(envName, raw);
|
|
3733
|
+
return ring.keys[ring.index % ring.keys.length] ?? "";
|
|
3601
3734
|
};
|
|
3602
3735
|
// 豆包搜索:主 key 名 DOUBAO_SEARCH_API_KEY;官方示例也用 WEB_SEARCH_API_KEY,作为回退
|
|
3603
3736
|
const resolveDoubaoKey = async () => {
|
|
@@ -3626,6 +3759,20 @@ function apply(ctx, config) {
|
|
|
3626
3759
|
},
|
|
3627
3760
|
// 单 query 结果缓存:key=query+maxResults+timeRangeLabel+preferred,Map 天然 LRU
|
|
3628
3761
|
async search(request, signal) {
|
|
3762
|
+
// 轮换时机:首次失败后 keyRings 已经填充,此时再判断有几把 key。
|
|
3763
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
3764
|
+
try {
|
|
3765
|
+
return await provider.searchOnce(request, signal);
|
|
3766
|
+
} catch (error) {
|
|
3767
|
+
const keys = maxKeyRingLength();
|
|
3768
|
+
if (keys <= 1 || attempt >= keys - 1 || !isKeyRotationCandidate(error)) throw error;
|
|
3769
|
+
rotateAllKeyRings();
|
|
3770
|
+
logger.warn(`free-search: retrying with the next API key (${error?.message ?? error})`);
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
throw new Error("all search engines failed");
|
|
3774
|
+
},
|
|
3775
|
+
async searchOnce(request, signal) {
|
|
3629
3776
|
// 公共咽喉校验:web_search / advanced_search / raw-search 三条路径都经过这里
|
|
3630
3777
|
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.trim().length === 0) {
|
|
3631
3778
|
throw new Error("query is required");
|
|
@@ -3783,6 +3930,11 @@ function apply(ctx, config) {
|
|
|
3783
3930
|
} else if (engine === "firecrawl") {
|
|
3784
3931
|
// firecrawl:无 key 也可用(官方免 key 匿名额度),有 key 走账号档(更高限额)
|
|
3785
3932
|
const key = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey");
|
|
3933
|
+
if (!key && isFirecrawlKeylessBlocked()) {
|
|
3934
|
+
throw new Error(
|
|
3935
|
+
"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"
|
|
3936
|
+
);
|
|
3937
|
+
}
|
|
3786
3938
|
result = await searchFirecrawl(request.query, request.maxResults, key, timeRange, effSignal);
|
|
3787
3939
|
} else if (engine === "parallel") {
|
|
3788
3940
|
// parallel:有 key 走 REST(支持 after_date 精确过滤),无 key 走 keyless MCP(免费匿名额度)
|
|
@@ -4288,6 +4440,9 @@ function apply(ctx, config) {
|
|
|
4288
4440
|
}
|
|
4289
4441
|
case "firecrawl": {
|
|
4290
4442
|
const key = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey");
|
|
4443
|
+
if (!key && isFirecrawlKeylessBlocked()) {
|
|
4444
|
+
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");
|
|
4445
|
+
}
|
|
4291
4446
|
return await searchFirecrawl(q, n, key, tr);
|
|
4292
4447
|
}
|
|
4293
4448
|
case "parallel": {
|
|
@@ -5077,6 +5232,7 @@ function apply(ctx, config) {
|
|
|
5077
5232
|
},
|
|
5078
5233
|
},
|
|
5079
5234
|
},
|
|
5235
|
+
meta: UNCERTAINTY_SCHEMA,
|
|
5080
5236
|
},
|
|
5081
5237
|
},
|
|
5082
5238
|
render(args, value) {
|
|
@@ -5084,7 +5240,12 @@ function apply(ctx, config) {
|
|
|
5084
5240
|
const note = value.content ? String(value.content).trim() : "";
|
|
5085
5241
|
// 空结果时区分「真的没搜到」与「引擎出错」:有 note(失败原因等)就展示,而不是谎报 No results
|
|
5086
5242
|
const body = lines.length > 0 ? `${lines.join("\n")}${note ? `\n\n${note}` : ""}` : note || "No results found.";
|
|
5087
|
-
|
|
5243
|
+
const uncertainty = value.meta?.uncertainty;
|
|
5244
|
+
const confidence =
|
|
5245
|
+
uncertainty && uncertainty.level !== "high"
|
|
5246
|
+
? `\n\nConfidence: ${uncertainty.level}${Array.isArray(uncertainty.notes) && uncertainty.notes.length > 0 ? ` — ${uncertainty.notes.join("; ")}` : ""}`
|
|
5247
|
+
: "";
|
|
5248
|
+
return [{ type: "text", text: `Search (${value.provider}${args.timeRange ? `, timeRange=${args.timeRange}` : ""}):\n${wrapUntrustedBlock(body)}${confidence}` }];
|
|
5088
5249
|
},
|
|
5089
5250
|
},
|
|
5090
5251
|
async execute(args) {
|
|
@@ -5111,6 +5272,13 @@ function apply(ctx, config) {
|
|
|
5111
5272
|
}
|
|
5112
5273
|
return source;
|
|
5113
5274
|
}),
|
|
5275
|
+
meta: {
|
|
5276
|
+
uncertainty: assessUncertainty({
|
|
5277
|
+
sources: result.sources ?? [],
|
|
5278
|
+
timeRange: Boolean(request.timeRange),
|
|
5279
|
+
note: typeof result.content === "string" ? result.content : "",
|
|
5280
|
+
}),
|
|
5281
|
+
},
|
|
5114
5282
|
};
|
|
5115
5283
|
},
|
|
5116
5284
|
finalizeContent(exec, result) {
|
|
@@ -5173,6 +5341,7 @@ function apply(ctx, config) {
|
|
|
5173
5341
|
},
|
|
5174
5342
|
},
|
|
5175
5343
|
},
|
|
5344
|
+
meta: UNCERTAINTY_SCHEMA,
|
|
5176
5345
|
},
|
|
5177
5346
|
},
|
|
5178
5347
|
render(args, value) {
|
|
@@ -5183,7 +5352,12 @@ function apply(ctx, config) {
|
|
|
5183
5352
|
const note = value.content ? String(value.content).trim() : "";
|
|
5184
5353
|
// 空结果时区分「真的没搜到」与「引擎出错」:有 note(失败原因等)就展示,而不是谎报 No results
|
|
5185
5354
|
const body = lines.length > 0 ? `${lines.join("\n")}${note ? `\n\n${note}` : ""}` : note || "No results found.";
|
|
5186
|
-
|
|
5355
|
+
const uncertainty = value.meta?.uncertainty;
|
|
5356
|
+
const confidence =
|
|
5357
|
+
uncertainty && uncertainty.level !== "high"
|
|
5358
|
+
? `\n\nConfidence: ${uncertainty.level}${Array.isArray(uncertainty.notes) && uncertainty.notes.length > 0 ? ` — ${uncertainty.notes.join("; ")}` : ""}`
|
|
5359
|
+
: "";
|
|
5360
|
+
return [{ type: "text", text: `Multi-Search (${value.provider}):\n${wrapUntrustedBlock(body)}${confidence}` }];
|
|
5187
5361
|
},
|
|
5188
5362
|
},
|
|
5189
5363
|
async execute(args) {
|
|
@@ -5196,7 +5370,15 @@ function apply(ctx, config) {
|
|
|
5196
5370
|
// where SearchProvider may use `truncated`. The tool schema is strict
|
|
5197
5371
|
// (additionalProperties: false), so expose only declared tool fields here.
|
|
5198
5372
|
const { truncated: _truncated, ...toolResult } = result;
|
|
5199
|
-
return
|
|
5373
|
+
return {
|
|
5374
|
+
...toolResult,
|
|
5375
|
+
meta: {
|
|
5376
|
+
uncertainty: assessUncertainty({
|
|
5377
|
+
sources: toolResult.sources ?? [],
|
|
5378
|
+
note: typeof toolResult.content === "string" ? toolResult.content : "",
|
|
5379
|
+
}),
|
|
5380
|
+
},
|
|
5381
|
+
};
|
|
5200
5382
|
},
|
|
5201
5383
|
finalizeContent(exec, result) {
|
|
5202
5384
|
const text = result.content;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
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",
|