dsh-free-search 0.9.6 → 0.9.8
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 +64 -34
- package/lib/client.js +152 -1
- package/lib/fetch_provider.js +102 -14
- package/lib/index.js +196 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
|
|
|
30
30
|
|
|
31
31
|
- **零成本** —— 多个免费引擎,无需 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
|
-
- **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换;入口在左侧「插件」页的组件行配置(`plugins.row.config`,DSH 0.1.7-rc.1
|
|
33
|
+
- **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换;入口在左侧「插件」页的组件行配置(`plugins.row.config`,DSH 0.1.7-rc.1+)。远程访问(VPS / 反向代理 / 自定义域名)下也能用:桥接接口沿用宿主的 Host 围栏,`--trusted-host` 与 `--public-url` 声明的地址会放行(#84)
|
|
34
|
+
- **右侧浮动目录** —— 卡片右侧的页面留白里浮出「引擎 / 回退 / 语言池 / 搜索行为 / 抓取 / 性能」六个快捷跳转,滚动自动高亮当前项、点击平滑跳转;不占用正文宽度(正文布局与 0.9.6 一致),右侧留白不足时自动隐藏
|
|
34
35
|
- **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
|
|
35
36
|
- **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
|
|
36
37
|
- **全局引擎开关与回退优先级** —— 设置页可勾选/取消引擎(取消后全局生效:普通搜索 / Auto / `advanced_search` / `multi_search` / 引擎测试都不再用它),并可用 ↑↓ 调整全局回退顺序(一键恢复默认)
|
|
@@ -138,33 +139,45 @@ dsh web
|
|
|
138
139
|
|
|
139
140
|
#### 接管行为与验证
|
|
140
141
|
|
|
141
|
-
|
|
142
|
+
插件加载后**自动接管搜索**:
|
|
142
143
|
|
|
143
144
|
- `web.searchProvider` 未设置,或仍是 DSH 出厂默认的官方搜索 `deepseek-official` 时,自动切换为本插件的 provider(id 固定为 `ddg`);
|
|
144
145
|
- 如果(你或别的插件)已显式选择其他 provider,本插件不抢占,只在启动日志输出 WARN 与切换用的 YAML。
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
**接管会写进 profile(DSH 0.2.x)**:从 `0.2.0-rc.2` 起 provider 选择存放在 config 的 volatile ref 里,只能经 `settings.mutate("web", …)` 写入,落盘结果就是 `profiles/<profile>/cordis.patch.yml` 多出:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- id: web
|
|
151
|
+
config:
|
|
152
|
+
searchProvider: ddg
|
|
153
|
+
fetchProvider: fs-quality-fetch
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
所以**停用或卸载本插件之前,请先删掉 `- id: web` 这两行**(或整行删掉让 DSH 回到出厂默认)。否则配置仍指向 `ddg` 而没有任何东西注册它,每次搜索都会失败:
|
|
147
157
|
|
|
148
158
|
```
|
|
149
159
|
Error: configured web provider "ddg" is not registered
|
|
150
160
|
```
|
|
151
161
|
|
|
152
|
-
|
|
162
|
+
更早的 DSH(0.1.x)由 patch 静态覆盖 config,不需要也更不该手写这段,这里不再赘述。
|
|
153
163
|
|
|
154
|
-
|
|
164
|
+
#### DSH 的「DeepSeek 搜索 API」开关
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
DSH 0.2.1 设置 → 网页搜索 → **DeepSeek 搜索 API** 开关会和本插件互相影响,行为如下:
|
|
167
|
+
|
|
168
|
+
| 操作 | 结果 |
|
|
169
|
+
|---|---|
|
|
170
|
+
| 打开开关 | `web.searchProvider` 写成 `deepseek-search-api`(属于「显式选择其他 provider」),**本插件主动让路**,搜索走官方直连 API;抓取同时变成 `deepseek-fetch-api` |
|
|
171
|
+
| 关闭开关 | DSH 会把整行 `- id: web` 删掉,provider 回到出厂默认 `deepseek-official` —— 这是**模型搜索**(一次完整 aux 请求,实测 ~12s、按 token 计费),不是免费引擎 |
|
|
172
|
+
| 关闭后 | **0.9.8 起**:插件检测到 `deepseek-official` 会在运行时补一次接管(日志 `DSH's search switch put the web providers back to the official defaults — re-took …`),通常几秒内恢复免费链路;若没恢复,完整重启 DSH |
|
|
173
|
+
|
|
174
|
+
想换引擎请用 `/free-search-engine` 或本插件设置页,**不要动那个开关**。打开开关再关掉,官方直连会退回模型搜索,直到插件重新接管或重启。
|
|
175
|
+
|
|
176
|
+
> 如果你在 0.1.x 时代按旧文档手写过 `- id: web`,现在可以删掉:0.2.x 由运行时接管写入。手写时注意 `fetchProvider` 应该是 `fs-quality-fetch`(本插件注册的抓取 provider),不是 `http`。
|
|
164
177
|
|
|
165
178
|
- `searchProvider: ddg` —— 本插件的 **provider id(固定值)**,不是"使用 DuckDuckGo 引擎";用哪个引擎由设置页的 `provider` 字段决定(`bing`/`baidu`/`auto`/…)。
|
|
166
|
-
- `fetchProvider:
|
|
167
|
-
-
|
|
179
|
+
- `fetchProvider: fs-quality-fetch` —— 本插件的抓取 provider(直接 → exa contents → firecrawl → crawl4ai)。
|
|
180
|
+
- 想改用官方搜索:**先删掉 `- id: web` 的两行,再在插件管理器里停用本插件**。
|
|
168
181
|
|
|
169
182
|
#### 姊妹插件:dsh-preset-workbench(预设工作台)
|
|
170
183
|
|
|
@@ -459,7 +472,7 @@ Search engine test:
|
|
|
459
472
|
|
|
460
473
|
#### 离线自检断言(tools/)
|
|
461
474
|
|
|
462
|
-
仓库自带 **
|
|
475
|
+
仓库自带 **19 个**零依赖断言脚本(合计 **528 项**),**不需要 API key、不联网、不需要测试框架**,Node ≥ 20 即可跑:
|
|
463
476
|
|
|
464
477
|
| 脚本 | 覆盖 | 运行(项数) |
|
|
465
478
|
|---|---|---|
|
|
@@ -479,7 +492,9 @@ Search engine test:
|
|
|
479
492
|
| `assert-killtree-win32.mjs` | Windows 子进程树终止(#74):不再依赖 `taskkill` | `node tools/assert-killtree-win32.mjs`(8) |
|
|
480
493
|
| `assert-killtree-portable.mjs` | 跨平台 `killTree` 便携版契约 | `node tools/assert-killtree-portable.mjs`(5) |
|
|
481
494
|
| `assert-parallel-mcp.mjs` | Parallel MCP 引擎:25 s 超时/调用方取消覆盖**响应体读取**、共享 SSE 解析器(通知/多行帧)、6 类错误路径、REST 精确时间过滤 | `node tools/assert-parallel-mcp.mjs`(15) |
|
|
482
|
-
| `assert-
|
|
495
|
+
| `assert-fetch-abort.mjs` | 抓取中断(#87):压缩响应中途断连必须 reject(不再永久 pending)、明文断连同样 reject、abort 能打断停滞的 gzip 流、complete 响应与重定向仍正常 | `node tools/assert-fetch-abort.mjs`(6) |
|
|
496
|
+
| `assert-bridge-access.mjs` | 桥接 Host 围栏(#84):loopback 放行、remote peer 打 loopback Host 拒绝、`--trusted-host`(无端口匹配任意端口 / 带端口精确匹配)、`--public-url`、绑定地址、跨站与异源拒绝 | `node tools/assert-bridge-access.mjs`(11) |
|
|
497
|
+
| `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) |
|
|
483
498
|
|
|
484
499
|
任何一个失败会以非 0 退出码结束并列出失败项;把某个旧版本文件路径作为参数传进去,可以看到修复前必红(用来证明断言不是空转)。改动发布相关逻辑后建议先跑一遍再发版。
|
|
485
500
|
|
|
@@ -529,7 +544,8 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
529
544
|
|
|
530
545
|
- **Zero Cost** — Multiple free engines with no API key or registration required
|
|
531
546
|
- **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
|
|
532
|
-
- **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+)
|
|
547
|
+
- **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)
|
|
548
|
+
- **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.
|
|
533
549
|
- **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)
|
|
534
550
|
- **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)
|
|
535
551
|
- **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)
|
|
@@ -638,33 +654,45 @@ dsh web
|
|
|
638
654
|
|
|
639
655
|
#### Takeover behavior and verification
|
|
640
656
|
|
|
641
|
-
The plugin **takes over search
|
|
657
|
+
The plugin **takes over search** once loaded:
|
|
642
658
|
|
|
643
659
|
- When `web.searchProvider` is unset, or still the shipped default `deepseek-official`, it switches to this plugin's provider (id: `ddg`);
|
|
644
660
|
- If you (or another plugin) explicitly selected a different provider, it does not steal it — it only logs a WARN with a copy-pasteable YAML snippet.
|
|
645
661
|
|
|
646
|
-
**
|
|
662
|
+
**The takeover is persisted into the profile (DSH 0.2.x).** Since `0.2.0-rc.2` the provider selection lives in volatile config refs and can only be written through `settings.mutate("web", …)`, which lands in `profiles/<profile>/cordis.patch.yml` as:
|
|
663
|
+
|
|
664
|
+
```yaml
|
|
665
|
+
- id: web
|
|
666
|
+
config:
|
|
667
|
+
searchProvider: ddg
|
|
668
|
+
fetchProvider: fs-quality-fetch
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
So **remove those `- id: web` lines before disabling or uninstalling this plugin** (or delete the whole row and let DSH fall back to its defaults). Otherwise the config still points at `ddg` while nothing registers it, and every search fails with:
|
|
647
672
|
|
|
648
673
|
```
|
|
649
674
|
Error: configured web provider "ddg" is not registered
|
|
650
675
|
```
|
|
651
676
|
|
|
652
|
-
|
|
677
|
+
Older DSH (0.1.x) used a static patch layer for this; writing that row by hand there is neither needed nor recommended.
|
|
653
678
|
|
|
654
|
-
|
|
679
|
+
#### DSH's "DeepSeek search API" switch
|
|
655
680
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
681
|
+
DSH 0.2.1 has Settings → Web search → **DeepSeek search API**, which interacts with this plugin:
|
|
682
|
+
|
|
683
|
+
| Action | Result |
|
|
684
|
+
|---|---|
|
|
685
|
+
| Turn the switch on | `web.searchProvider` becomes `deepseek-search-api` (an explicit choice), so **this plugin steps aside** and search goes through the official direct API; fetch becomes `deepseek-fetch-api` as well |
|
|
686
|
+
| Turn the switch off | DSH deletes the whole `- id: web` row, so the provider falls back to the shipped default `deepseek-official` — that is **model search** (a full auxiliary request, ~12 s in practice, billed per token), not the free engines |
|
|
687
|
+
| After turning it off | **Since 0.9.8**: the plugin notices `deepseek-official` and re-takes at runtime (log line `DSH's search switch put the web providers back to the official defaults — re-took …`), normally within seconds; if it does not, restart DSH |
|
|
688
|
+
|
|
689
|
+
Use `/free-search-engine` or this plugin's settings page to change engines, **not that switch**. Turning it on and then off drops you from the official direct API back to model search until the plugin re-takes over or DSH restarts.
|
|
690
|
+
|
|
691
|
+
> If you hand-wrote `- id: web` back in the 0.1.x era, you can drop it now: 0.2.x writes it at runtime. If you do declare it by hand, `fetchProvider` should be `fs-quality-fetch` (the fetch provider this plugin registers), not `http`.
|
|
664
692
|
|
|
665
693
|
- `searchProvider: ddg` — this plugin's **fixed provider id**, not "use the DuckDuckGo engine"; the engine is chosen by the `provider` field in settings (`bing` / `baidu` / `auto` / …).
|
|
666
|
-
- `fetchProvider:
|
|
667
|
-
- To go back to official search: disable this plugin
|
|
694
|
+
- `fetchProvider: fs-quality-fetch` — this plugin's fetch provider (direct → exa contents → firecrawl → crawl4ai).
|
|
695
|
+
- To go back to official search: **remove those `- id: web` lines first, then disable this plugin** in the plugin manager.
|
|
668
696
|
|
|
669
697
|
#### Sister Plugin: dsh-preset-workbench
|
|
670
698
|
|
|
@@ -959,7 +987,7 @@ Restart `dsh web` after switching to apply changes.
|
|
|
959
987
|
|
|
960
988
|
#### Offline self-check assertions (`tools/`)
|
|
961
989
|
|
|
962
|
-
**
|
|
990
|
+
**Nineteen** zero-dependency assertion scripts ship with the repo (**528 assertions** total): **no API keys, no network, no test framework**, Node ≥ 20:
|
|
963
991
|
|
|
964
992
|
| Script | Covers | Run (count) |
|
|
965
993
|
|---|---|---|
|
|
@@ -979,7 +1007,9 @@ Restart `dsh web` after switching to apply changes.
|
|
|
979
1007
|
| `assert-killtree-win32.mjs` | Windows process-tree termination (#74): no longer relies on `taskkill` | `node tools/assert-killtree-win32.mjs` (8) |
|
|
980
1008
|
| `assert-killtree-portable.mjs` | portable `killTree` contract | `node tools/assert-killtree-portable.mjs` (5) |
|
|
981
1009
|
| `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) |
|
|
982
|
-
| `assert-
|
|
1010
|
+
| `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) |
|
|
1011
|
+
| `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) |
|
|
1012
|
+
| `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) |
|
|
983
1013
|
|
|
984
1014
|
Any failure exits non-zero with the failing items listed. Passing the path of an older file shows the pre-fix failures (proving the assertions are not vacuous). Worth running before a release.
|
|
985
1015
|
|
package/lib/client.js
CHANGED
|
@@ -5,6 +5,7 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
|
|
7
7
|
let react = require("react");
|
|
8
|
+
let react_dom = require("react-dom");
|
|
8
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
10
|
|
|
10
11
|
//#region css
|
|
@@ -29,6 +30,13 @@ window.__ModuleLoader__.load({
|
|
|
29
30
|
"@keyframes dshfsFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}",
|
|
30
31
|
".dshfs-body{flex-direction:column;gap:20px;padding:0 20px 20px;display:flex;animation:dshfsFadeIn 240ms cubic-bezier(.4,0,.2,1) forwards}",
|
|
31
32
|
"@media (prefers-reduced-motion: reduce){.dshfs-body{animation:none}}",
|
|
33
|
+
".dshfs-rail{display:none;position:fixed;top:50%;transform:translateY(-50%);flex-direction:column;gap:2px;max-height:70vh;overflow:auto;z-index:5;padding:2px}",
|
|
34
|
+
".dshfs-railItem{display:flex;align-items:center;gap:6px;padding:3px 2px;border:0;background:none;font:inherit;font-size:12px;line-height:1.5;color:inherit;opacity:.42;text-align:left;cursor:pointer;transition:opacity 120ms}",
|
|
35
|
+
".dshfs-railItem:hover{opacity:.85}",
|
|
36
|
+
".dshfs-railItemActive{opacity:.95;font-weight:600}",
|
|
37
|
+
".dshfs-railDot{flex:none;width:4px;height:4px;border-radius:50%;background:currentColor;opacity:.45}",
|
|
38
|
+
".dshfs-railItemActive .dshfs-railDot{opacity:1;background:#0071e3}",
|
|
39
|
+
"@media (prefers-color-scheme: dark){.dshfs-railItemActive .dshfs-railDot{background:#0a84ff}}",
|
|
32
40
|
".dshfs-footer{justify-content:space-between;align-items:center;gap:12px;display:flex;flex-wrap:wrap;padding-top:4px}",
|
|
33
41
|
".dshfs-footerLeft{display:flex;align-items:center;gap:12px;flex-wrap:wrap;min-width:0}",
|
|
34
42
|
".dshfs-footerRight{display:flex;align-items:center;gap:10px;flex-wrap:wrap}",
|
|
@@ -267,6 +275,13 @@ window.__ModuleLoader__.load({
|
|
|
267
275
|
marketAuto: "自动 —— 不发送 mkt(推荐用于非上列地区)",
|
|
268
276
|
fetchTakeoverLabel: "自动接管 web_fetch",
|
|
269
277
|
fetchTakeoverHint: "开启时:web.fetchProvider 未设置或仍是出厂默认 http 会接管为 fs-quality-fetch。如果你刻意 pin 了 http(例如只想用内置抓取),关掉此项。",
|
|
278
|
+
navLabel: "本页导航",
|
|
279
|
+
navEngines: "引擎",
|
|
280
|
+
navFallback: "回退",
|
|
281
|
+
navPools: "语言池",
|
|
282
|
+
navBehavior: "搜索行为",
|
|
283
|
+
navFetch: "抓取",
|
|
284
|
+
navPerformance: "性能",
|
|
270
285
|
marketZhCN: "zh-CN —— 中国大陆(默认)",
|
|
271
286
|
marketZhTW: "zh-TW —— 台湾",
|
|
272
287
|
marketEnUS: "en-US —— 美国",
|
|
@@ -388,6 +403,13 @@ window.__ModuleLoader__.load({
|
|
|
388
403
|
marketAuto: "Automatic — send no mkt (recommended outside the listed regions)",
|
|
389
404
|
fetchTakeoverLabel: "Take over web_fetch automatically",
|
|
390
405
|
fetchTakeoverHint: "On: when web.fetchProvider is unset or still the factory default http, it is taken over as fs-quality-fetch. Turn this off if you deliberately pinned http (e.g. you only want the built-in fetcher).",
|
|
406
|
+
navLabel: "On this page",
|
|
407
|
+
navEngines: "Engines",
|
|
408
|
+
navFallback: "Fallback",
|
|
409
|
+
navPools: "Language pools",
|
|
410
|
+
navBehavior: "Search behaviour",
|
|
411
|
+
navFetch: "Fetch",
|
|
412
|
+
navPerformance: "Performance",
|
|
391
413
|
marketZhCN: "zh-CN - China (default)",
|
|
392
414
|
marketZhTW: "zh-TW - Taiwan",
|
|
393
415
|
marketEnUS: "en-US - United States",
|
|
@@ -404,8 +426,27 @@ window.__ModuleLoader__.load({
|
|
|
404
426
|
};
|
|
405
427
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
406
428
|
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
|
|
407
|
-
const PLUGIN_VERSION = "0.9.
|
|
429
|
+
const PLUGIN_VERSION = "0.9.8";
|
|
408
430
|
const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
|
|
431
|
+
// 右侧浮动目录(#85):条目顺序即页面顺序,anchor 是页面里对应 field 的 id。
|
|
432
|
+
const NAV_ITEMS = [
|
|
433
|
+
{ id: "engines", anchor: "dshfs-anchor-engines" },
|
|
434
|
+
{ id: "fallback", anchor: "dshfs-anchor-fallback" },
|
|
435
|
+
{ id: "pools", anchor: "dshfs-anchor-pools" },
|
|
436
|
+
{ id: "behavior", anchor: "dshfs-anchor-behavior" },
|
|
437
|
+
{ id: "fetch", anchor: "dshfs-anchor-fetch" },
|
|
438
|
+
{ id: "performance", anchor: "dshfs-anchor-performance" },
|
|
439
|
+
];
|
|
440
|
+
// 目录跟着真正滚动的祖先走:DSH 页面通常不是 window 在滚。
|
|
441
|
+
const findScrollParent = (element) => {
|
|
442
|
+
let node = element && element.parentElement ? element.parentElement : null;
|
|
443
|
+
while (node && node !== document.body && node !== document.documentElement) {
|
|
444
|
+
const style = window.getComputedStyle(node);
|
|
445
|
+
if (/(auto|scroll|overlay)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) return node;
|
|
446
|
+
node = node.parentElement;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
};
|
|
409
450
|
const ENGINES = [
|
|
410
451
|
{ id: "auto", label: "Auto · 智能路由", badge: "AUTO", link: null },
|
|
411
452
|
{ id: "multi", label: "Multi Search · 多引擎", badge: "MULTI", link: null },
|
|
@@ -601,6 +642,9 @@ window.__ModuleLoader__.load({
|
|
|
601
642
|
// 页面自带标题/面包屑,卡片固定展开、表头不再可折叠。
|
|
602
643
|
const pageMode = !!(props && props.page);
|
|
603
644
|
const [open, setOpen] = react.useState(pageMode);
|
|
645
|
+
const [railActive, setRailActive] = react.useState(NAV_ITEMS[0].id);
|
|
646
|
+
const bodyRef = react.useRef(null);
|
|
647
|
+
const railRef = react.useRef(null);
|
|
604
648
|
const [state, setState] = react.useState({ status: "loading" });
|
|
605
649
|
const [provider, setProvider] = react.useState("bing");
|
|
606
650
|
const [disabledEngines, setDisabledEngines] = react.useState([]);
|
|
@@ -1019,9 +1063,86 @@ window.__ModuleLoader__.load({
|
|
|
1019
1063
|
}
|
|
1020
1064
|
};
|
|
1021
1065
|
|
|
1066
|
+
// 浮动目录(#85):放在卡片右侧的页面留白里,绝对定位不参与卡片布局,
|
|
1067
|
+
// 所以正文宽度和 0.9.8 完全一致;留白不足(窄窗口/卡片贴边)时直接隐藏。
|
|
1068
|
+
react.useEffect(() => {
|
|
1069
|
+
if (!open) return undefined;
|
|
1070
|
+
const place = () => {
|
|
1071
|
+
const body = bodyRef.current;
|
|
1072
|
+
const rail = railRef.current;
|
|
1073
|
+
if (!body || !rail) return;
|
|
1074
|
+
const space = window.innerWidth - body.getBoundingClientRect().right;
|
|
1075
|
+
if (space < 120) {
|
|
1076
|
+
rail.style.display = "none";
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
rail.style.display = "flex";
|
|
1080
|
+
const width = rail.getBoundingClientRect().width || 66;
|
|
1081
|
+
rail.style.right = Math.max(12, Math.round(space / 2 - width / 2)) + "px";
|
|
1082
|
+
};
|
|
1083
|
+
place();
|
|
1084
|
+
window.addEventListener("resize", place);
|
|
1085
|
+
return () => window.removeEventListener("resize", place);
|
|
1086
|
+
}, [open, state.status]);
|
|
1087
|
+
|
|
1088
|
+
// 当前分组:分组顶部越过 35% 激活线就算当前;滚到底高亮最后一组。
|
|
1089
|
+
react.useEffect(() => {
|
|
1090
|
+
if (!open) return undefined;
|
|
1091
|
+
const scroller = findScrollParent(bodyRef.current);
|
|
1092
|
+
const target = scroller ?? (typeof window === "undefined" ? null : window);
|
|
1093
|
+
if (!target) return undefined;
|
|
1094
|
+
const compute = () => {
|
|
1095
|
+
const viewportHeight = scroller ? scroller.clientHeight : window.innerHeight;
|
|
1096
|
+
const atBottom = scroller
|
|
1097
|
+
? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 8
|
|
1098
|
+
: window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8;
|
|
1099
|
+
if (atBottom) {
|
|
1100
|
+
setRailActive(NAV_ITEMS[NAV_ITEMS.length - 1].id);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const viewportTop = scroller ? scroller.getBoundingClientRect().top : 0;
|
|
1104
|
+
const line = viewportTop + viewportHeight * 0.35;
|
|
1105
|
+
let current = NAV_ITEMS[0].id;
|
|
1106
|
+
for (const item of NAV_ITEMS) {
|
|
1107
|
+
const section = typeof document === "undefined" ? null : document.getElementById(item.anchor);
|
|
1108
|
+
if (section && section.getBoundingClientRect().top <= line) current = item.id;
|
|
1109
|
+
}
|
|
1110
|
+
setRailActive(current);
|
|
1111
|
+
};
|
|
1112
|
+
let frame = 0;
|
|
1113
|
+
const onScroll = () => {
|
|
1114
|
+
if (frame) return;
|
|
1115
|
+
frame = requestAnimationFrame(() => {
|
|
1116
|
+
frame = 0;
|
|
1117
|
+
compute();
|
|
1118
|
+
});
|
|
1119
|
+
};
|
|
1120
|
+
compute();
|
|
1121
|
+
target.addEventListener("scroll", onScroll, { passive: true });
|
|
1122
|
+
return () => {
|
|
1123
|
+
target.removeEventListener("scroll", onScroll);
|
|
1124
|
+
if (frame) cancelAnimationFrame(frame);
|
|
1125
|
+
};
|
|
1126
|
+
}, [open, state.status]);
|
|
1127
|
+
|
|
1022
1128
|
if (state.status === "loading") return null;
|
|
1023
1129
|
const ready = state.status === "ready";
|
|
1024
1130
|
const t = tt(uiLang || (lang === "en" ? "en" : "zh"));
|
|
1131
|
+
const navLabels = {
|
|
1132
|
+
engines: t.navEngines,
|
|
1133
|
+
fallback: t.navFallback,
|
|
1134
|
+
pools: t.navPools,
|
|
1135
|
+
behavior: t.navBehavior,
|
|
1136
|
+
fetch: t.navFetch,
|
|
1137
|
+
performance: t.navPerformance,
|
|
1138
|
+
};
|
|
1139
|
+
const goToSection = (id) => {
|
|
1140
|
+
const item = NAV_ITEMS.find((entry) => entry.id === id);
|
|
1141
|
+
const section = item && typeof document !== "undefined" ? document.getElementById(item.anchor) : null;
|
|
1142
|
+
if (!section) return;
|
|
1143
|
+
setRailActive(id);
|
|
1144
|
+
section.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1145
|
+
};
|
|
1025
1146
|
const title = "Free Search";
|
|
1026
1147
|
const description = t.description;
|
|
1027
1148
|
const enabledEngineCount = ENGINES.filter((e) => e.id !== "auto" && e.id !== "multi" && !disabledEngines.includes(e.id)).length;
|
|
@@ -1056,6 +1177,7 @@ window.__ModuleLoader__.load({
|
|
|
1056
1177
|
const candidates = ENGINE_IDS.filter((id) => !pool.includes(id) && !EXPLICIT_ONLY_ENGINES.includes(id));
|
|
1057
1178
|
return react_jsx_runtime.jsx("div", {
|
|
1058
1179
|
className: "dshfs-field",
|
|
1180
|
+
id: poolKey === "zh" ? "dshfs-anchor-pools" : undefined,
|
|
1059
1181
|
children: [
|
|
1060
1182
|
react_jsx_runtime.jsx("div", {
|
|
1061
1183
|
className: "dshfs-label",
|
|
@@ -1216,9 +1338,34 @@ window.__ModuleLoader__.load({
|
|
|
1216
1338
|
open
|
|
1217
1339
|
? react_jsx_runtime.jsx("div", {
|
|
1218
1340
|
className: "dshfs-body",
|
|
1341
|
+
ref: bodyRef,
|
|
1219
1342
|
children: [
|
|
1343
|
+
// 目录走 portal 挂到 document.body:卡片的 backdrop-filter / 动画 transform
|
|
1344
|
+
// 会成为 fixed 的包含块,留在卡片里就永远贴不到页面右侧的留白。
|
|
1345
|
+
typeof document === "undefined"
|
|
1346
|
+
? null
|
|
1347
|
+
: react_dom.createPortal(
|
|
1348
|
+
react_jsx_runtime.jsx("nav", {
|
|
1349
|
+
className: "dshfs-rail",
|
|
1350
|
+
ref: railRef,
|
|
1351
|
+
"aria-label": t.navLabel,
|
|
1352
|
+
children: NAV_ITEMS.map((item) =>
|
|
1353
|
+
react_jsx_runtime.jsx("button", {
|
|
1354
|
+
type: "button",
|
|
1355
|
+
className: "dshfs-railItem" + (railActive === item.id ? " dshfs-railItemActive" : ""),
|
|
1356
|
+
onClick: () => goToSection(item.id),
|
|
1357
|
+
children: [
|
|
1358
|
+
react_jsx_runtime.jsx("span", { className: "dshfs-railDot" }),
|
|
1359
|
+
react_jsx_runtime.jsx("span", { children: navLabels[item.id] }),
|
|
1360
|
+
],
|
|
1361
|
+
}, item.id)
|
|
1362
|
+
),
|
|
1363
|
+
}),
|
|
1364
|
+
document.body
|
|
1365
|
+
),
|
|
1220
1366
|
react_jsx_runtime.jsx("div", {
|
|
1221
1367
|
className: "dshfs-field",
|
|
1368
|
+
id: "dshfs-anchor-engines",
|
|
1222
1369
|
children: [
|
|
1223
1370
|
react_jsx_runtime.jsx("div", {
|
|
1224
1371
|
className: "dshfs-label",
|
|
@@ -1305,6 +1452,7 @@ react_jsx_runtime.jsx("div", {
|
|
|
1305
1452
|
}),
|
|
1306
1453
|
react_jsx_runtime.jsx("div", {
|
|
1307
1454
|
className: "dshfs-field",
|
|
1455
|
+
id: "dshfs-anchor-fallback",
|
|
1308
1456
|
children: [
|
|
1309
1457
|
react_jsx_runtime.jsx("div", {
|
|
1310
1458
|
className: "dshfs-label",
|
|
@@ -1478,6 +1626,7 @@ react_jsx_runtime.jsx("div", {
|
|
|
1478
1626
|
}),
|
|
1479
1627
|
react_jsx_runtime.jsx("div", {
|
|
1480
1628
|
className: "dshfs-field",
|
|
1629
|
+
id: "dshfs-anchor-behavior",
|
|
1481
1630
|
children: [
|
|
1482
1631
|
react_jsx_runtime.jsx("div", {
|
|
1483
1632
|
className: "dshfs-label",
|
|
@@ -1572,6 +1721,7 @@ react_jsx_runtime.jsx("div", {
|
|
|
1572
1721
|
}),
|
|
1573
1722
|
react_jsx_runtime.jsx("div", {
|
|
1574
1723
|
className: "dshfs-field",
|
|
1724
|
+
id: "dshfs-anchor-fetch",
|
|
1575
1725
|
children: [
|
|
1576
1726
|
react_jsx_runtime.jsx("label", {
|
|
1577
1727
|
className: "dshfs-check",
|
|
@@ -1677,6 +1827,7 @@ react_jsx_runtime.jsx("div", {
|
|
|
1677
1827
|
}),
|
|
1678
1828
|
react_jsx_runtime.jsx("div", {
|
|
1679
1829
|
className: "dshfs-field",
|
|
1830
|
+
id: "dshfs-anchor-performance",
|
|
1680
1831
|
children: [
|
|
1681
1832
|
react_jsx_runtime.jsx("div", {
|
|
1682
1833
|
className: "dshfs-label",
|
package/lib/fetch_provider.js
CHANGED
|
@@ -123,18 +123,70 @@ function browserDumpDom(browserPath, url, { timeoutMs = 25000, signal } = {}) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/** GET 跟随重定向(≤5 跳),返回最终 URL / 状态 / 正文 / 内容类型;支持 gzip/br/deflate 解压。 */
|
|
126
|
-
function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
|
|
126
|
+
export function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}, transport = https) {
|
|
127
127
|
return new Promise((resolve, reject) => {
|
|
128
|
+
let settled = false;
|
|
129
|
+
let activeReq = null;
|
|
130
|
+
let activeRes = null;
|
|
131
|
+
let activeStream = null;
|
|
132
|
+
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
135
|
+
};
|
|
136
|
+
const settle = (ok, value) => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
cleanup();
|
|
140
|
+
if (ok) resolve(value);
|
|
141
|
+
else reject(value);
|
|
142
|
+
};
|
|
143
|
+
const destroyActive = () => {
|
|
144
|
+
for (const target of [activeStream, activeRes, activeReq]) {
|
|
145
|
+
try {
|
|
146
|
+
target?.destroy?.(new Error("aborted"));
|
|
147
|
+
} catch {
|
|
148
|
+
// 忽略清理失败
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const onAbort = () => {
|
|
153
|
+
destroyActive();
|
|
154
|
+
settle(false, signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (signal) {
|
|
158
|
+
if (signal.aborted) {
|
|
159
|
+
settle(false, new Error("aborted"));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
163
|
+
}
|
|
164
|
+
|
|
128
165
|
const hop = (current, left) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
166
|
+
if (settled) return;
|
|
167
|
+
let target;
|
|
168
|
+
try {
|
|
169
|
+
target = new URL(current);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
settle(false, error);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const req = transport.request(
|
|
175
|
+
{
|
|
176
|
+
hostname: target.hostname,
|
|
177
|
+
port: target.port || (target.protocol === "http:" ? 80 : 443),
|
|
178
|
+
path: target.pathname + target.search,
|
|
179
|
+
method: "GET",
|
|
180
|
+
headers,
|
|
181
|
+
timeout: timeoutMs,
|
|
182
|
+
},
|
|
132
183
|
(res) => {
|
|
184
|
+
activeRes = res;
|
|
133
185
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && left > 0) {
|
|
134
186
|
res.resume();
|
|
135
|
-
|
|
136
|
-
return hop(next, left - 1);
|
|
187
|
+
return hop(new URL(res.headers.location, current).toString(), left - 1);
|
|
137
188
|
}
|
|
189
|
+
|
|
138
190
|
const enc = String(res.headers["content-encoding"] || "").toLowerCase();
|
|
139
191
|
let stream = res;
|
|
140
192
|
try {
|
|
@@ -144,17 +196,53 @@ function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
|
|
|
144
196
|
} catch {
|
|
145
197
|
stream = res;
|
|
146
198
|
}
|
|
199
|
+
activeStream = stream;
|
|
200
|
+
|
|
147
201
|
const chunks = [];
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
202
|
+
// 压缩响应的解压流感知不到上游 res 的销毁:pipe 不转发 error/close,
|
|
203
|
+
// 对端中途断连时解压流既不 end 也不 error,Promise 会永久挂起(#87)。
|
|
204
|
+
// 因此 body 的失败必须从 res 侧判定,并主动销毁解压流。
|
|
205
|
+
const bodyGone = (reason) => {
|
|
206
|
+
if (settled) return;
|
|
207
|
+
try {
|
|
208
|
+
stream.destroy?.();
|
|
209
|
+
} catch {
|
|
210
|
+
// 忽略
|
|
211
|
+
}
|
|
212
|
+
settle(false, reason instanceof Error ? reason : new Error(String(reason)));
|
|
213
|
+
};
|
|
214
|
+
res.on("aborted", () => bodyGone(new Error("connection aborted while reading the response body")));
|
|
215
|
+
res.on("error", (error) => bodyGone(error));
|
|
216
|
+
res.on("close", () => {
|
|
217
|
+
if (!res.complete) bodyGone(new Error("connection closed before the response body completed"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
221
|
+
stream.on("end", () =>
|
|
222
|
+
settle(true, {
|
|
223
|
+
url: current,
|
|
224
|
+
status: res.statusCode,
|
|
225
|
+
text: Buffer.concat(chunks).toString("utf8"),
|
|
226
|
+
contentType: res.headers["content-type"] || "",
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
stream.on("error", (error) => settle(false, error));
|
|
151
230
|
}
|
|
152
231
|
);
|
|
153
|
-
|
|
154
|
-
req.on("
|
|
232
|
+
activeReq = req;
|
|
233
|
+
req.on("timeout", () => {
|
|
234
|
+
req.destroy(new Error(`timeout after ${timeoutMs}ms`));
|
|
235
|
+
settle(false, new Error(`timeout after ${timeoutMs}ms`));
|
|
236
|
+
});
|
|
237
|
+
req.on("error", (error) => settle(false, error));
|
|
155
238
|
req.end();
|
|
156
239
|
};
|
|
157
|
-
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
hop(url, 5);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
settle(false, error);
|
|
245
|
+
}
|
|
158
246
|
});
|
|
159
247
|
}
|
|
160
248
|
|
|
@@ -222,7 +310,7 @@ export function createFetchProvider({ resolveApiKey, logger }) {
|
|
|
222
310
|
const firecrawlKey = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey").catch(() => undefined);
|
|
223
311
|
if (firecrawlKey) {
|
|
224
312
|
try {
|
|
225
|
-
const md = await callFirecrawl(url, firecrawlKey);
|
|
313
|
+
const md = await callFirecrawl(url, firecrawlKey, signal);
|
|
226
314
|
if (md.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: md }, truncated: false };
|
|
227
315
|
errors.push("firecrawl: short content");
|
|
228
316
|
} catch (e) { errors.push(`firecrawl: ${e.message}`); }
|
|
@@ -231,7 +319,7 @@ export function createFetchProvider({ resolveApiKey, logger }) {
|
|
|
231
319
|
const crawl4aiKey = await resolveApiKey("CRAWL4AI_API_KEY", undefined).catch(() => undefined);
|
|
232
320
|
if (crawl4aiKey) {
|
|
233
321
|
try {
|
|
234
|
-
const text = await callCrawl4ai(url, crawl4aiKey);
|
|
322
|
+
const text = await callCrawl4ai(url, crawl4aiKey, signal);
|
|
235
323
|
if (text.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: text }, truncated: false };
|
|
236
324
|
errors.push("crawl4ai: short content");
|
|
237
325
|
} catch (e) { errors.push(`crawl4ai: ${e.message}`); }
|
package/lib/index.js
CHANGED
|
@@ -256,7 +256,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
// 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
|
|
259
|
-
const PLUGIN_VERSION = "0.9.
|
|
259
|
+
const PLUGIN_VERSION = "0.9.8";
|
|
260
260
|
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
261
261
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
262
262
|
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
@@ -2847,26 +2847,64 @@ async function searchDoubao(query, maxResults, apiKey, timeRange, signal) {
|
|
|
2847
2847
|
//#region bridge
|
|
2848
2848
|
const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
2849
2849
|
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2850
|
+
// #84:桥接接口的 Host 围栏。
|
|
2851
|
+
// DSH 宿主对 /api 已经做了同样的 Browser-trust 围栏并完成鉴权:放行 loopback、
|
|
2852
|
+
// 监听绑定的 IP 字面量,以及 --trusted-host / --public-url 声明的 authority。
|
|
2853
|
+
// 插件此前只认 loopback,于是 VPS / 反向代理 / 自定义域名下配置页整体 403、表单全灰。
|
|
2854
|
+
// 这里沿用宿主的信任集合,而不是把写接口无差别放开。
|
|
2855
|
+
const bridgeTrust = { hosts: [], publicHost: "" };
|
|
2856
|
+
|
|
2857
|
+
function parseAuthority(authority) {
|
|
2856
2858
|
try {
|
|
2857
|
-
|
|
2859
|
+
return new URL("http://" + authority);
|
|
2858
2860
|
} catch {
|
|
2859
|
-
return
|
|
2861
|
+
return null;
|
|
2860
2862
|
}
|
|
2861
|
-
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
function isLoopbackAddress(address) {
|
|
2866
|
+
const value = String(address ?? "");
|
|
2867
|
+
return value === "127.0.0.1" || value === "::1" || value === "::ffff:127.0.0.1" || /^127\./.test(value);
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
function isLoopbackHostname(hostname) {
|
|
2871
|
+
if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
|
|
2872
|
+
const parts = hostname.split(".");
|
|
2873
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// 与宿主的 isTrustedAuthority 同规则:无端口条目匹配任意端口,带端口条目精确匹配。
|
|
2877
|
+
function matchesTrustedAuthority(hostUrl, entry) {
|
|
2878
|
+
const entryUrl = parseAuthority(entry);
|
|
2879
|
+
if (entryUrl === null) return false;
|
|
2880
|
+
return entryUrl.port === "" ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function isBridgeRequestAllowed(request, trust = bridgeTrust) {
|
|
2884
|
+
const host = request.headers.host;
|
|
2885
|
+
if (typeof host !== "string") return false;
|
|
2886
|
+
const hostUrl = parseAuthority(host);
|
|
2887
|
+
if (hostUrl === null) return false;
|
|
2888
|
+
// 跨站发起的请求一律拒绝(防 confused deputy);同源请求才继续看 Host。
|
|
2862
2889
|
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2863
2890
|
const origin = request.headers.origin;
|
|
2864
|
-
if (origin
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2891
|
+
if (origin !== undefined) {
|
|
2892
|
+
try {
|
|
2893
|
+
if (new URL(origin).host !== hostUrl.host) return false;
|
|
2894
|
+
} catch {
|
|
2895
|
+
return false;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
if (isLoopbackHostname(hostUrl.hostname)) return isLoopbackAddress(request.socket.remoteAddress);
|
|
2899
|
+
if (trust.hosts.some((entry) => matchesTrustedAuthority(hostUrl, entry))) return true;
|
|
2900
|
+
if (trust.publicHost && matchesTrustedAuthority(hostUrl, trust.publicHost)) return true;
|
|
2901
|
+
// 直接绑定非 loopback 地址(--host 192.168.x.x / 公网 IP)时,Host 就是绑定地址。
|
|
2902
|
+
const localAddress = request.socket.localAddress;
|
|
2903
|
+
if (typeof localAddress === "string" && localAddress !== "0.0.0.0" && localAddress !== "::") {
|
|
2904
|
+
const local = parseAuthority(localAddress.includes(":") ? `[${localAddress}]` : localAddress);
|
|
2905
|
+
if (local !== null && local.hostname === hostUrl.hostname) return true;
|
|
2869
2906
|
}
|
|
2907
|
+
return false;
|
|
2870
2908
|
}
|
|
2871
2909
|
|
|
2872
2910
|
function writeJson(res, status, body) {
|
|
@@ -3116,8 +3154,8 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
3116
3154
|
};
|
|
3117
3155
|
|
|
3118
3156
|
const guard = (req, res) => {
|
|
3119
|
-
if (!
|
|
3120
|
-
writeJson(res, 403, { error: "
|
|
3157
|
+
if (!isBridgeRequestAllowed(req)) {
|
|
3158
|
+
writeJson(res, 403, { error: "requests must come from the local host or a configured trusted host" });
|
|
3121
3159
|
return false;
|
|
3122
3160
|
}
|
|
3123
3161
|
if (req.method !== "POST") {
|
|
@@ -3434,6 +3472,46 @@ function resolveConfig(config) {
|
|
|
3434
3472
|
return out;
|
|
3435
3473
|
}
|
|
3436
3474
|
|
|
3475
|
+
// #82 诊断:深度比较 web 条目的 config,返回 `a.b: 旧 → 新` 形式的路径(最多 8 条)。
|
|
3476
|
+
// 只用于日志,不参与任何逻辑;嵌套对象会展开,数组按整体比较。
|
|
3477
|
+
function changedConfigPaths(previous, next, prefix = "", out = []) {
|
|
3478
|
+
const left = previous !== null && typeof previous === "object" ? previous : {};
|
|
3479
|
+
const right = next !== null && typeof next === "object" ? next : {};
|
|
3480
|
+
for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
|
|
3481
|
+
if (out.length >= 8) break;
|
|
3482
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
3483
|
+
const before = left[key];
|
|
3484
|
+
const after = right[key];
|
|
3485
|
+
const nested =
|
|
3486
|
+
before !== null &&
|
|
3487
|
+
after !== null &&
|
|
3488
|
+
typeof before === "object" &&
|
|
3489
|
+
typeof after === "object" &&
|
|
3490
|
+
!Array.isArray(before) &&
|
|
3491
|
+
!Array.isArray(after);
|
|
3492
|
+
if (nested) {
|
|
3493
|
+
changedConfigPaths(before, after, path, out);
|
|
3494
|
+
continue;
|
|
3495
|
+
}
|
|
3496
|
+
if (JSON.stringify(before ?? null) !== JSON.stringify(after ?? null)) {
|
|
3497
|
+
out.push(`${path}: ${previewConfigValue(before)} → ${previewConfigValue(after)}`);
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
return out;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
function previewConfigValue(value) {
|
|
3504
|
+
if (value === undefined) return "undefined";
|
|
3505
|
+
let text;
|
|
3506
|
+
try {
|
|
3507
|
+
text = JSON.stringify(value);
|
|
3508
|
+
} catch {
|
|
3509
|
+
text = String(value);
|
|
3510
|
+
}
|
|
3511
|
+
if (text === undefined) text = "undefined";
|
|
3512
|
+
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3437
3515
|
function apply(ctx, config) {
|
|
3438
3516
|
// 重入保护:同一个 ctx 上 apply 只跑一次。rc.2 的 boot 里出现过未 dispose 的重入,
|
|
3439
3517
|
// 会撞上下面的重复注册(provider / route),导致 entry 反复失败把 boot 拖死。
|
|
@@ -3460,6 +3538,25 @@ function apply(ctx, config) {
|
|
|
3460
3538
|
// credentials 服务可能晚于本插件挂载(跨 bundle 顺序),运行期动态获取而不是 apply 时缓存
|
|
3461
3539
|
const getCredentials = () => ctx.get("credentials");
|
|
3462
3540
|
|
|
3541
|
+
// #84:读取宿主的远程访问信任集合(--trusted-host / --public-url),供桥接 Host 围栏使用。
|
|
3542
|
+
// webStartup 只在 dsh web 下提供;拿不到就维持 loopback-only 的老行为。
|
|
3543
|
+
ctx.inject(["webStartup"], (sctx) => {
|
|
3544
|
+
try {
|
|
3545
|
+
const startup = sctx.get("webStartup");
|
|
3546
|
+
if (!startup) return;
|
|
3547
|
+
if (Array.isArray(startup.trustedHosts)) {
|
|
3548
|
+
bridgeTrust.hosts = startup.trustedHosts
|
|
3549
|
+
.filter((entry) => typeof entry === "string" && entry.trim())
|
|
3550
|
+
.map((entry) => entry.trim());
|
|
3551
|
+
}
|
|
3552
|
+
if (typeof startup.publicUrl === "string" && startup.publicUrl) {
|
|
3553
|
+
try {
|
|
3554
|
+
bridgeTrust.publicHost = new URL(startup.publicUrl).host;
|
|
3555
|
+
} catch {}
|
|
3556
|
+
}
|
|
3557
|
+
} catch {}
|
|
3558
|
+
});
|
|
3559
|
+
|
|
3463
3560
|
// 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
|
|
3464
3561
|
let refreshPrompt = null;
|
|
3465
3562
|
// Multi Search 执行器在 runEngineTest 定义后初始化;provider.search 运行时再调用。
|
|
@@ -3950,33 +4047,79 @@ function apply(ctx, config) {
|
|
|
3950
4047
|
);
|
|
3951
4048
|
});
|
|
3952
4049
|
// Re-render the dynamic system-prompt engine list whenever this entry's config changes.
|
|
3953
|
-
// #82
|
|
3954
|
-
|
|
4050
|
+
// #82:dsh-settings 在 describe() 里也会 emit `settings/document-updated`(raw 快照变了就发),
|
|
4051
|
+
// 所以「收到事件」≠「有人写入」。事件只当触发信号,并且必须**推迟到 emit 栈之外**再读配置:
|
|
4052
|
+
// 在 emit 的同步栈里再调 describe() 会和设置文档的写入路径打架(0.9.8 首版实测把 web 服务卡死)。
|
|
4053
|
+
// 读到值后再对 web 条目的 uid + 整份 config 做深度比对:没变(describe 噪声)不记,变了才记一条。
|
|
4054
|
+
const snapshotWebEntry = () => {
|
|
3955
4055
|
try {
|
|
3956
|
-
|
|
4056
|
+
const descriptor = sctx.settings.describe({ redactSecrets: true }).find((entry) => String(entry.ns) === "web");
|
|
4057
|
+
if (!descriptor) return null;
|
|
4058
|
+
return { uid: descriptor.schema?.uid ?? null, config: descriptor.value ?? {} };
|
|
3957
4059
|
} catch {
|
|
3958
4060
|
return null;
|
|
3959
4061
|
}
|
|
3960
|
-
}
|
|
4062
|
+
};
|
|
4063
|
+
let lastWebEntry = null; // 首次事件只用来建立基线,不当成「变化」
|
|
4064
|
+
let webDiagLogged = false;
|
|
4065
|
+
let webDiagQueued = false;
|
|
4066
|
+
// #86:DSH 设置里的「DeepSeek 搜索 API」开关关掉时会把整行 `- id: web` 删掉,
|
|
4067
|
+
// provider 回到出厂默认 `deepseek-official`(模型搜索,更慢更贵),而插件只在加载时接管过一次。
|
|
4068
|
+
// 这里在 web 配置变化后补一次运行时接管;只认「出厂默认」,绝不抢显式选择的 `deepseek-search-api`。
|
|
4069
|
+
let reclaimAttempted = false;
|
|
4070
|
+
const reclaimOfficialDefaults = (next) => {
|
|
4071
|
+
if (reclaimAttempted) return;
|
|
4072
|
+
const config = next.config ?? {};
|
|
4073
|
+
const ops = [];
|
|
4074
|
+
if (config.searchProvider === "deepseek-official") takeOverOps(ctx.web, "searchProvider", provider.id, ops);
|
|
4075
|
+
if (
|
|
4076
|
+
(config.fetchProvider === "http" || config.fetchProvider === undefined) &&
|
|
4077
|
+
current().fetchProviderTakeover !== false
|
|
4078
|
+
) {
|
|
4079
|
+
takeOverOps(ctx.web, "fetchProvider", fetchProvider.id, ops);
|
|
4080
|
+
}
|
|
4081
|
+
if (ops.length === 0) return;
|
|
4082
|
+
reclaimAttempted = true;
|
|
4083
|
+
const fields = [...new Set(ops.map((op) => String(op.path?.[0] ?? "?")))].join(" + ");
|
|
4084
|
+
Promise.resolve(sctx.settings.mutate("web", ops)).then(
|
|
4085
|
+
() =>
|
|
4086
|
+
logger.info(
|
|
4087
|
+
`free-search: DSH's search switch put the web providers back to the official defaults — re-took ${fields}.`
|
|
4088
|
+
),
|
|
4089
|
+
(error) =>
|
|
4090
|
+
logger.warn(
|
|
4091
|
+
`free-search: could not re-take the web providers after the switch was turned off (${error?.message ?? error}); restart DSH to re-apply`
|
|
4092
|
+
)
|
|
4093
|
+
);
|
|
4094
|
+
};
|
|
4095
|
+
const runWebDiag = () => {
|
|
4096
|
+
const next = snapshotWebEntry();
|
|
4097
|
+
const previous = lastWebEntry;
|
|
4098
|
+
if (next !== null) {
|
|
4099
|
+
lastWebEntry = next;
|
|
4100
|
+
reclaimOfficialDefaults(next);
|
|
4101
|
+
}
|
|
4102
|
+
if (previous === null || next === null || webDiagLogged) return;
|
|
4103
|
+
const changed = changedConfigPaths(previous.config, next.config);
|
|
4104
|
+
const rebuilt = previous.uid !== next.uid;
|
|
4105
|
+
if (!rebuilt && changed.length === 0) return;
|
|
4106
|
+
const parts = [];
|
|
4107
|
+
if (rebuilt) parts.push(`the web service was rebuilt (entry uid ${previous.uid} → ${next.uid})`);
|
|
4108
|
+
if (changed.length > 0) parts.push(`config ${changed.join(", ")}`);
|
|
4109
|
+
webDiagLogged = true;
|
|
4110
|
+
logger.info(
|
|
4111
|
+
`free-search: the web entry changed — ${parts.join("; ")}. A provider write rebuilds the web service and re-applies this plugin (only the first change per process is logged).`
|
|
4112
|
+
);
|
|
4113
|
+
};
|
|
3961
4114
|
sctx.on("settings/document-updated", (ns) => {
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
const keys = [...new Set([...Object.keys(previous), ...Object.keys(next)])];
|
|
3971
|
-
const changed = keys.filter((key) => JSON.stringify(next[key] ?? null) !== JSON.stringify(previous[key] ?? null));
|
|
3972
|
-
logOnce(
|
|
3973
|
-
logger,
|
|
3974
|
-
"info",
|
|
3975
|
-
`free-search: the web config was updated (changed: ${changed.join(", ") || "none visible"}) — a provider write rebuilds the web service and re-applies this plugin`
|
|
3976
|
-
);
|
|
3977
|
-
lastWebConfig = next;
|
|
3978
|
-
}
|
|
3979
|
-
} catch {}
|
|
4115
|
+
if (String(ns) === "web" && !webDiagQueued) {
|
|
4116
|
+
webDiagQueued = true;
|
|
4117
|
+
setTimeout(() => {
|
|
4118
|
+
webDiagQueued = false;
|
|
4119
|
+
try {
|
|
4120
|
+
runWebDiag();
|
|
4121
|
+
} catch {}
|
|
4122
|
+
}, 0);
|
|
3980
4123
|
}
|
|
3981
4124
|
if (String(ns) === FREE_SEARCH_NS && typeof refreshPrompt === "function") refreshPrompt();
|
|
3982
4125
|
});
|
|
@@ -4091,7 +4234,19 @@ function apply(ctx, config) {
|
|
|
4091
4234
|
return;
|
|
4092
4235
|
}
|
|
4093
4236
|
void Promise.resolve(sctx.settings.mutate("web", ops)).catch((error) => {
|
|
4094
|
-
|
|
4237
|
+
const message = error?.message ?? String(error);
|
|
4238
|
+
// #82:profile 的 package.json 原子写锁超时是另一类失败——不是 provider 冲突,
|
|
4239
|
+
// 而是同一 profile 被并发写入,写不进配置 → 运行态和配置态对不上 → 下一轮 apply 再来一次。
|
|
4240
|
+
if (/timed out waiting for the writer lock/i.test(message)) {
|
|
4241
|
+
logOnce(
|
|
4242
|
+
logger,
|
|
4243
|
+
"warn",
|
|
4244
|
+
"free-search: could not write the web provider selection — the profile's package.json writer lock timed out " +
|
|
4245
|
+
`(${message}). Another writer is holding the same profile; this plugin retries on the next apply.`
|
|
4246
|
+
);
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
logger.warn(`free-search: failed to take over the web providers (${message})`);
|
|
4095
4250
|
});
|
|
4096
4251
|
});
|
|
4097
4252
|
|
|
@@ -5106,6 +5261,7 @@ function apply(ctx, config) {
|
|
|
5106
5261
|
|
|
5107
5262
|
export {
|
|
5108
5263
|
ALL_ENGINES,
|
|
5264
|
+
isBridgeRequestAllowed,
|
|
5109
5265
|
EXPLICIT_ONLY_ENGINES,
|
|
5110
5266
|
assertSchemasterySupportsVolatile,
|
|
5111
5267
|
COOLDOWN_SESSION_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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",
|