dsh-read-url 1.9.1 → 1.9.3
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/CHANGELOG.md +40 -0
- package/README.md +29 -2
- package/README.zh.md +29 -2
- package/index.js +145 -42
- package/package.json +1 -1
- package/proxy-fallback.js +104 -20
- package/spa.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.3] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- Markdown 按配对块处理嵌套容器与列表,保留段落间距、父级编号和子列表缩进;代码围栏/引用与后文正确分隔,属性与递归深度继续有界。
|
|
8
|
+
- 数值配置经范围限制后向下取整,避免小数超时触发 `AbortSignal.timeout` 错误或小数分页上限多抓一页。
|
|
9
|
+
- 代理回退遵守 `no_proxy`/`NO_PROXY` 的域名、IP、端口和 CIDR 边界,避免绕过代理的地址被直连与 curl 重复请求;curl 禁用 URL glob 展开,原样处理方括号查询和花括号路径。
|
|
10
|
+
- curl 启用受支持的 HTTP 响应解压,避免 gzip 字节进入正文;单独检查解压后字节限额,兼容旧版 curl 的传输大小语义,stdout 超限返回明确错误。
|
|
11
|
+
- 失败诊断不再输出代理配置地址,避免用户名、密码进入工具结果或模型上下文。
|
|
12
|
+
- 提供方明确标记为纯文本时,链接和站点读取不再解析正文中的 HTML 示例或跟随示例跳转;保留原文、输出预算与提供方截断提示。
|
|
13
|
+
- 质询页识别的空白段使用明确长度上限,消除相邻无界空白造成的平方回溯。
|
|
14
|
+
- 双语说明更新 DeepSeek Fetch API 能力边界,并将早期官方工具对比明确标为历史快照。
|
|
15
|
+
|
|
16
|
+
### 验证
|
|
17
|
+
|
|
18
|
+
- 413 个零依赖单元断言、15 个真实 Chromium SPA 断言及 70 项对抗探针通过;新增 38 条单元检查、6 条探针,覆盖列表内代码围栏闭合及引用块边界。原始缺陷对照 v1.9.1/v1.9.2 复现。
|
|
19
|
+
- 最终源码在本机桌面 0.2.0-rc.2(Electron 44.0.0 / Node 24.18.1)通过 17 项集成与 15 项 Chromium SPA 检查;官方 DSH 0.2.1-rc.2 通过 17 项服务集成及 9 项实际 DeepSeek Fetch 适配器检查。适配器传输使用受控响应,未覆盖外部 API 实际服务或完整 UI/模型会话。
|
|
20
|
+
- 本机桌面升级为 0.2.1-rc.2(Electron 44.7.0 / Node 24.21.0)后,相同冻结源码在实际内置运行时另通过 17 项集成、3 项工具作用域与 15 项 Chromium SPA 检查。
|
|
21
|
+
- 已审计八个接触包、Cordis/Loader 与四个桌面加载文件;社区升级卡截至 0.2.0-rc.2,新版由官方源码交叉核验。发布版 patch 另通过 4 项加载器及 2 项临时 profile 检查。
|
|
22
|
+
- 2026-10-19 发布前 152 站:105 OK / 21 THIN / 4 EMPTY / 22 ERR / 0 THREW,OK 站零噪声。相同 HTML/DOM 对照 v1.9.2 的 text 全文/续读一致,Markdown 结构修复无可见文字丢失或重复;波动站为验证页或旧版同现的响应边界。22 个错误分别为 13 个 HTTP 错误、3 个预期类型拒绝、6 个网络失败。
|
|
23
|
+
- Bilibili 视频页在修复 gzip 解压后由乱码变为正常正文;实际 curl 8.19/8.21 共 12 个正常/超限/重定向/取消场景通过,包含旧版 curl 解压后越界保护。
|
|
24
|
+
- 核心零运行时依赖;四工具 description/schema 保持 1,159/1,876 字符,属性长度与递归上限保留。
|
|
25
|
+
|
|
26
|
+
## [1.9.2] - 2026-10-03
|
|
27
|
+
|
|
28
|
+
### 修复
|
|
29
|
+
|
|
30
|
+
- Windows 代理回退优先使用系统目录中的 PowerShell/curl,避免桌面进程的精简 PATH 导致代理发现或请求失败;系统工具不可用时保留原有回退与取消行为。
|
|
31
|
+
- JSON-LD 按真实 `type` 属性读取,支持等号空格、无引号值及引号内的 `>`;忽略 `data-type`、注释、普通脚本及 raw-text 元素中的伪 JSON-LD。
|
|
32
|
+
- JSON-LD 作者解析限制递归深度,正文数组仅拼接字符串项,避免深层数组触发栈溢出或非文本值污染正文;保留标签长度、JSON 大小及递归上限。
|
|
33
|
+
- 双语安装说明补充桌面版插件入口、独立 profile、内置运行时及本地链接代码重启加载方式。
|
|
34
|
+
|
|
35
|
+
### 验证
|
|
36
|
+
|
|
37
|
+
- 375 个零依赖单元断言、15 个真实 Chromium SPA 断言、64 项对抗探针通过;新增 23 条单元检查和 5 条 JSON-LD 探针。JSON-LD 原始缺陷在 v1.9.0/v1.9.1 同输入均复现;Windows 空 PATH 的真实代理请求对照 v1.9.1 确认修复。
|
|
38
|
+
- DSH 桌面版 0.2.0-rc.2 的实际 Electron 44.0.0 / Node 24.18.1 通过 17 项官方服务集成、3 项工具作用域、2 项本地代理 A/B 及 15 项 Chromium SPA 检查。极简 preset 继承全局插件工具;服务响应使用受控数据,未覆盖完整模型会话。
|
|
39
|
+
- DSH 0.1.7-rc.2 → 0.2.0-rc.2 的 8 个接触包与 4 个桌面加载链文件完成字节差异审计,并交叉核对社区 0.2.0-rc.1 升级卡;已核查接口无需适配修改。0.2.1-alpha.1 未审计。
|
|
40
|
+
- 2026-10-03 的 152 站回归:104 OK / 20 THIN / 4 EMPTY / 24 ERR / 0 THREW,OK 站零噪声。已知 NOISE 与状态波动站使用相同 HTML/DOM 和可选依赖对照 v1.9.1,全文续读与 800 字输出一致;新增订阅站 HTTP 403 在新旧版本均复现。
|
|
41
|
+
- 核心仍为零运行时依赖;四工具 description/schema 为 1,159/1,876 字符。
|
|
42
|
+
|
|
3
43
|
## [1.9.1] - 2026-09-28
|
|
4
44
|
|
|
5
45
|
### 修复
|
package/README.md
CHANGED
|
@@ -22,10 +22,12 @@ Continue at `charsStart + charsReturned`; the returned `charsStart` may exceed t
|
|
|
22
22
|
|
|
23
23
|
## Why
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
This plugin keeps URL-reading output compact: **cleaned body + essential metadata** from HTML, paragraph-aligned truncation, continuation and a session cache. It also preserves plain text or Markdown returned by the configured DSH provider. DSH 0.2.1-rc.2 includes a DeepSeek Fetch API provider, so available source content and metadata depend on the selected provider.
|
|
26
26
|
|
|
27
27
|
### Competitor comparison (measured from source/docs, 2026-08-15)
|
|
28
28
|
|
|
29
|
+
This is a historical snapshot; it does not describe the newer DeepSeek Fetch API provider.
|
|
30
|
+
|
|
29
31
|
| Capability | Official `tool-web` web_fetch | dsh-webfetch | dsh-scrape-webpage | **dsh-read-url** |
|
|
30
32
|
|---|---|---|---|---|
|
|
31
33
|
| Body cleaning (container-level) | ❌ whole page | ⚠️ tag-level, nav/footer leak in | ⚠️ custom, noisy | ✅ article/main containers + noise stripping |
|
|
@@ -55,8 +57,16 @@ Implemented per official docs (`docs/capability-seams.md`, `docs/cordis-primer.m
|
|
|
55
57
|
|
|
56
58
|
## Install
|
|
57
59
|
|
|
60
|
+
### DSH Desktop
|
|
61
|
+
|
|
62
|
+
Open **Plugins → Add plugin**, enter `dsh-read-url` (npm package), the GitHub repository URL, or the local project directory, and install it. In the plugin details, check the displayed version and that the component is **running**.
|
|
63
|
+
|
|
64
|
+
Desktop uses its own `desktop` profile and bundled DSH runtime. CLI profile installations and global CLI upgrades are separate from Desktop. When configuring this plugin manually, use the Desktop profile's `cordis.patch.yml`. A local directory installation links to that source directory; restart Desktop after changing its code to load a fresh module.
|
|
65
|
+
|
|
66
|
+
### CLI / local development
|
|
67
|
+
|
|
58
68
|
```bash
|
|
59
|
-
# From GitHub
|
|
69
|
+
# From GitHub in the web profile
|
|
60
70
|
npx @deepseek-ai/dsh plugin --profile web add github:2672243194/dsh-read-url
|
|
61
71
|
|
|
62
72
|
# Local development
|
|
@@ -323,6 +333,23 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
|
|
|
323
333
|
- [DSH 0.1.7-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2) compatibility was checked against official npm package byte differences and community upgrade notes (available through rc.1). Both published v1.9.0 and this maintenance code passed **17 actual official-service integration checks each**; the published patch passed **4 loader checks** covering loading, execution, configuration remount and removal. Web-provider/runtime contracts remain compatible; no adaptation change is needed for the checked interfaces. These checks used controlled provider responses, not a complete host UI/model session.
|
|
324
334
|
- Core runtime dependencies remain zero. Model input stays at **1,159 description characters / 1,876 schema characters**; site body summaries remain opt-in and respect `maxCharsPerPage` without an extra 80-character render cutoff.
|
|
325
335
|
|
|
336
|
+
### v1.9.2 Desktop maintenance validation (2026-10-03)
|
|
337
|
+
|
|
338
|
+
- **375 zero-dependency unit assertions, 15 real Chromium SPA assertions and 64 adversarial probes** passed. Coverage includes Windows proxy fallback with a minimal PATH, real JSON-LD attributes, comment/raw-text isolation, bounded scans and deeply nested data.
|
|
339
|
+
- The installed **DSH Desktop 0.2.0-rc.2** runtime (Electron 44.0.0 / Node 24.18.1) passed **17 official-service integration checks, 3 tool-scope checks, 2 local proxy A/B checks and 15 Chromium SPA checks**. The minimal preset inherits the four globally registered plugin tools. Service responses were controlled fixtures; these checks do not cover a full model conversation.
|
|
340
|
+
- [DSH 0.2.0-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2) was audited against 0.1.7-rc.2 using byte differences across eight contact packages and four Desktop loading files, with the [community 0.2.0-rc.1 upgrade card](https://github.com/oh-my-dsh/dsh-plugin-upgrade-skill/blob/main/skills/plugin-upgrade/references/v0.2.0-rc.1.md) as a cross-check. The verified interfaces require no adaptation. The newer 0.2.1-alpha.1 was not audited.
|
|
341
|
+
- The 152-site sweep returned **104 OK / 20 THIN / 4 EMPTY / 24 ERR / 0 THREW**, with no noise in OK results. Known NOISE and fluctuating sites matched v1.9.1 on identical HTML/DOM and optional dependencies, including full continuation and 800-character output. The newly failing feed returned HTTP 403 in both versions.
|
|
342
|
+
- JSON-LD metadata/body extraction accepts valid attribute variants and rejects lookalike attributes. Author recursion is bounded; body arrays retain only strings. Core runtime dependencies remain zero, with fixed model input of **1,159 description characters / 1,876 schema characters**.
|
|
343
|
+
|
|
344
|
+
### v1.9.3 Desktop maintenance validation (2026-10-19)
|
|
345
|
+
|
|
346
|
+
- **413 zero-dependency unit assertions, 15 real Chromium SPA assertions and 70 adversarial probes** passed. Fixes cover nested Markdown containers/lists, fractional numeric configuration, literal text-provider content, proxy diagnostics, `NO_PROXY`, curl URL globbing/decompression and bounded challenge detection. Reproductions against v1.9.1/v1.9.2 confirm existing defects; regression tests also cover code-fence and quote boundaries within lists.
|
|
347
|
+
- [DSH 0.2.1-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-rc.2), published October 17, was audited against 0.2.0-rc.2 across eight contact packages, Cordis/Loader and four Desktop loading files. The final plugin passed **17 official-service integration checks and 9 DeepSeek Fetch provider checks**. The published v1.9.2 patch also passed 4 loader and 2 temporary-profile checks. The [community upgrade card](https://github.com/oh-my-dsh/dsh-plugin-upgrade-skill/blob/main/skills/plugin-upgrade/references/v0.2.0-rc.2.md) currently stops at 0.2.0-rc.2; newer behavior was checked directly against official packages.
|
|
348
|
+
- After upgrading Desktop to **0.2.1-rc.2 / Electron 44.7.0 / Node 24.21.0**, its actual bundled runtime passed **17 integration checks, 3 tool-scope checks and 15 Chromium SPA checks** against the same final plugin. The prior 0.2.0-rc.2 runtime also passed 17 integration and 15 SPA checks. Service responses were controlled; these checks do not cover a full model conversation or UI interaction.
|
|
349
|
+
- The release 152-site sweep returned **105 OK / 21 THIN / 4 EMPTY / 22 ERR / 0 THREW**, with no noise in OK results. Errors comprised 13 HTTP refusals/missing pages, 3 expected content-type rejections and 6 network failures. Saved HTML/DOM replays matched v1.9.2 in full text and continuation; Markdown restored structure without losing or duplicating visible text. Dynamic verification pages matched on identical input. The previously garbled Bilibili video page returned readable content after gzip decompression. Real curl 8.19/8.21 passed 12 decompression, size-limit, redirect and cancellation scenarios.
|
|
350
|
+
- The DeepSeek Fetch provider returns Markdown as `body.kind=text`. Single-page reading preserves that text; links/site operations report that HTML navigation was not scanned, rather than interpreting HTML examples as links, redirects or SPA scripts. The provider does not expose the target's final URL or status, so HTML metadata, automatic pagination and site discovery are not equivalent to raw HTML retrieval.
|
|
351
|
+
- Nested Markdown blocks retain paragraph boundaries, list nesting, parent numbering and separate code/quote endings from following text. Attribute scans and the 100-level recursion limit remain bounded. Proxy retrieval decompresses supported HTTP encodings and checks the decoded byte budget even with older curl versions. Core runtime dependencies remain zero; fixed model input remains **1,159 description characters / 1,876 schema characters**.
|
|
352
|
+
|
|
326
353
|
## Roadmap
|
|
327
354
|
|
|
328
355
|
- [x] Single-page continuation (`offset` parameter)
|
package/README.zh.md
CHANGED
|
@@ -22,10 +22,12 @@ DeepSeek Harness 的 URL 阅读插件:抓取任意 URL——**网页(HTML)
|
|
|
22
22
|
|
|
23
23
|
## 为什么做它
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
本插件控制 URL 阅读的输出量:从 HTML 提取**净化后的正文 + 必要元数据**,提供段落截断、续读和会话缓存;也保留 DSH 配置的提供方返回的纯文本或 Markdown。DSH 0.2.1-rc.2 已包含 DeepSeek Fetch API 提供方,可获得的原文和元数据取决于所选提供方。
|
|
26
26
|
|
|
27
27
|
### 同类插件对比(2026-08-15 实测源码/文档)
|
|
28
28
|
|
|
29
|
+
以下是历史快照,不代表新版 DeepSeek Fetch API 提供方的行为。
|
|
30
|
+
|
|
29
31
|
| 能力 | 官方 `tool-web` web_fetch | dsh-webfetch | dsh-scrape-webpage | **dsh-read-url** |
|
|
30
32
|
|---|---|---|---|---|
|
|
31
33
|
| 正文净化(容器级提取) | ❌ 整页渲染 | ⚠️ 标签级去噪,nav/footer 仍混入 | ⚠️ 自研,含噪音 | ✅ article/main 容器 + 噪音剥离 |
|
|
@@ -55,8 +57,16 @@ DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净
|
|
|
55
57
|
|
|
56
58
|
## 安装
|
|
57
59
|
|
|
60
|
+
### DSH 桌面版
|
|
61
|
+
|
|
62
|
+
打开 **插件 → 添加插件**,输入 `dsh-read-url`(npm 包名)、GitHub 仓库地址或本地项目目录后安装。在插件详情检查版本,以及组件是否显示 **运行中**。
|
|
63
|
+
|
|
64
|
+
桌面版使用独立的 `desktop` profile 和内置 DSH 运行时。CLI 各 profile 的安装及全局 CLI 升级与桌面版分别管理。手动配置本插件时,应使用桌面 profile 的 `cordis.patch.yml`。本地目录安装会链接源目录,修改代码后应重启桌面版,以加载新的模块。
|
|
65
|
+
|
|
66
|
+
### CLI / 本地开发
|
|
67
|
+
|
|
58
68
|
```bash
|
|
59
|
-
# 从 GitHub
|
|
69
|
+
# 从 GitHub 安装到 web profile
|
|
60
70
|
npx @deepseek-ai/dsh plugin --profile web add github:2672243194/dsh-read-url
|
|
61
71
|
|
|
62
72
|
# 本地开发
|
|
@@ -323,6 +333,23 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
|
|
|
323
333
|
- [DSH 0.1.7-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2) 已完成官方 npm 接触包字节差异核查,并与社区升级卡交叉验证(卡片目前到 rc.1)。发布版 v1.9.0 与当前维护代码各通过 **17 项真实官方服务集成检查**;发布版 patch 另通过 **4 项加载器检查**,覆盖加载、调用、配置重挂和卸载。Web provider/runtime 契约兼容,已核查接口无需适配修改;测试使用受控 provider 响应,未覆盖完整宿主 UI/模型会话。
|
|
324
334
|
- 核心仍为零运行时依赖,模型固定输入仍为 **description 1,159 字符 / schema 1,876 字符**;站点正文摘要仍默认关闭,启用后遵循 `maxCharsPerPage`,不再额外截成 80 字符。
|
|
325
335
|
|
|
336
|
+
### v1.9.2 桌面版维护验证(2026-10-03)
|
|
337
|
+
|
|
338
|
+
- **375 个零依赖单元断言、15 个真实 Chromium SPA 断言、64 项对抗探针**通过。覆盖 Windows 精简 PATH 下的代理回退、JSON-LD 真实属性、注释/raw-text 隔离、有界扫描与深层数据。
|
|
339
|
+
- 已安装的 **DSH 桌面版 0.2.0-rc.2** 运行时(Electron 44.0.0 / Node 24.18.1)通过 **17 项官方服务集成、3 项工具作用域、2 项本地代理 A/B 及 15 项 Chromium SPA 检查**。极简 preset 继承全局注册的四个插件工具;服务响应使用受控数据,未覆盖完整模型会话。
|
|
340
|
+
- [DSH 0.2.0-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2) 已对照 0.1.7-rc.2 完成八个接触包与四个桌面加载链文件的字节差异审计,并交叉核对[社区 0.2.0-rc.1 升级卡](https://github.com/oh-my-dsh/dsh-plugin-upgrade-skill/blob/main/skills/plugin-upgrade/references/v0.2.0-rc.1.md)。已核查接口无需适配修改;更新的 0.2.1-alpha.1 未审计。
|
|
341
|
+
- 152 站结果为 **104 OK / 20 THIN / 4 EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。已知 NOISE 与状态波动站使用相同 HTML/DOM 和可选依赖对照 v1.9.1,全文续读及 800 字输出一致;新增失败的订阅站在新旧版本均返回 HTTP 403。
|
|
342
|
+
- JSON-LD 元数据/正文支持合法属性变体并排除伪属性;作者递归有界,正文数组仅保留字符串。核心仍为零运行时依赖,模型固定输入为 **description 1,159 字符 / schema 1,876 字符**。
|
|
343
|
+
|
|
344
|
+
### v1.9.3 桌面版维护验证(2026-10-19)
|
|
345
|
+
|
|
346
|
+
- **413 个零依赖单元断言、15 个真实 Chromium SPA 断言、70 项对抗探针**通过。修复覆盖 Markdown 嵌套容器/列表、小数配置、提供方纯文本、代理诊断、`NO_PROXY`、curl URL 展开/解压及有界质询页识别;v1.9.1/v1.9.2 对照确认原始缺陷既有,回归测试另覆盖列表内代码围栏闭合及引用块边界。
|
|
347
|
+
- 10 月 17 日发布的 [DSH 0.2.1-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-rc.2) 已对照 0.2.0-rc.2 审计八个接触包、Cordis/Loader 及四个桌面加载链文件。最终插件通过 **17 项官方服务集成和 9 项 DeepSeek Fetch 提供方检查**;已发布 v1.9.2 的 patch 另通过 4 项加载器和 2 项临时 profile 检查。[社区升级卡](https://github.com/oh-my-dsh/dsh-plugin-upgrade-skill/blob/main/skills/plugin-upgrade/references/v0.2.0-rc.2.md)目前仅到 0.2.0-rc.2,新版行为直接用官方包核验。
|
|
348
|
+
- 桌面升级至 **0.2.1-rc.2 / Electron 44.7.0 / Node 24.21.0** 后,实际内置运行时对相同最终插件通过 **17 项集成、3 项工具作用域和 15 项 Chromium SPA 检查**;升级前的 0.2.0-rc.2 运行时也已通过 17 项集成与 15 项 SPA。服务响应使用受控数据,未覆盖完整模型会话或 UI 交互。
|
|
349
|
+
- 发布前 152 站为 **105 OK / 21 THIN / 4 EMPTY / 22 ERR / 0 THREW**,OK 站零噪声;错误包括 13 个 HTTP 拒绝/页面不存在、3 个预期类型拒绝及 6 个网络失败。保存的同一 HTML/DOM 对照 v1.9.2,text 全文与续读一致,Markdown 恢复结构且无可见文字丢失或重复;动态验证页同输入结果一致。此前乱码的 Bilibili 视频页经 gzip 解压后返回正常正文。实际 curl 8.19/8.21 共通过 12 个解压、限额、跳转及取消场景。
|
|
350
|
+
- DeepSeek Fetch 提供方以 `body.kind=text` 返回 Markdown。单页读取保留原文;链接/站点工具明确提示未扫描 HTML 导航,避免把 HTML 示例当作链接、跳转或 SPA 脚本。该提供方不暴露目标最终 URL 和状态,因此 HTML 元数据、自动分页和站点发现不能视为与原始 HTML 抓取等价。
|
|
351
|
+
- Markdown 嵌套块保留段落边界、列表层级、父级编号,代码块/引用与后文正确分隔;属性扫描和 100 层递归上限继续有界。代理读取解压受支持的 HTTP 编码,并对旧版 curl 同样执行解压后字节限额。核心仍为零运行时依赖,模型固定输入仍为 **description 1,159 字符 / schema 1,876 字符**。
|
|
352
|
+
|
|
326
353
|
## Roadmap
|
|
327
354
|
|
|
328
355
|
- [x] 单页多段续读(`offset` 参数)
|
package/index.js
CHANGED
|
@@ -100,6 +100,10 @@ function allowedDirectUrl(url, origins) {
|
|
|
100
100
|
const DIRECT_BOUNDARY_ERROR = 'Direct fetch stopped: target is outside directFetchOrigins or contains credentials'
|
|
101
101
|
const DIRECT_SPA_HINT = '已配置直连仅支持静态读取,未启用浏览器渲染'
|
|
102
102
|
|
|
103
|
+
function providerTextHint(page) {
|
|
104
|
+
return '提供方返回纯文本,未扫描 HTML 链接或跳转' + (page.sourceTruncated ? ';提供方已截断原文,当前内容不是完整页面' : '')
|
|
105
|
+
}
|
|
106
|
+
|
|
103
107
|
function getDecoder(enc) {
|
|
104
108
|
let d = decoders.get(enc)
|
|
105
109
|
if (!d) {
|
|
@@ -328,7 +332,7 @@ async function raceFetch(url, externalSignal, cfg, proxy) {
|
|
|
328
332
|
|
|
329
333
|
async function fetchPage(url, externalSignal, cfg) {
|
|
330
334
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
331
|
-
const proxy = await detectProxy()
|
|
335
|
+
const proxy = await detectProxy(url)
|
|
332
336
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
333
337
|
if (proxy) {
|
|
334
338
|
const winner = await raceFetch(url, externalSignal, cfg, proxy)
|
|
@@ -336,7 +340,7 @@ async function fetchPage(url, externalSignal, cfg) {
|
|
|
336
340
|
const dErr = winner.failures.find((f) => f.label === 'direct')
|
|
337
341
|
const pErr = winner.failures.find((f) => f.label === 'proxy')
|
|
338
342
|
const px = pErr ? `,代理返回 ${pErr.error}` : ',代理未连通'
|
|
339
|
-
return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}
|
|
343
|
+
return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}(直连失败;已尝试代理${px})` }
|
|
340
344
|
}
|
|
341
345
|
// No proxy configured: plain direct connect (the original behaviour).
|
|
342
346
|
const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
|
|
@@ -955,18 +959,78 @@ export function inlineMd(html, depth = 0) {
|
|
|
955
959
|
return img.restore(out.trim())
|
|
956
960
|
}
|
|
957
961
|
|
|
958
|
-
|
|
962
|
+
// Pair structural blocks in one forward pass so nested siblings keep their
|
|
963
|
+
// own boundaries. Raw/code bodies and quoted attribute values are opaque.
|
|
964
|
+
function markdownBlockPairs(html) {
|
|
965
|
+
const pairs = new Map()
|
|
966
|
+
const stacks = new Map()
|
|
967
|
+
const structural = new Set(['div', 'section', 'article', 'main', 'blockquote', 'ul', 'ol', 'li'])
|
|
968
|
+
const opaque = new Set(['pre', 'code', 'script', 'style', 'textarea', 'template', 'noscript'])
|
|
969
|
+
const re = /<!--|<(\/?)([a-z][a-z0-9:-]{0,63})(?=[\s/>])/gi
|
|
970
|
+
let m
|
|
971
|
+
while ((m = re.exec(html))) {
|
|
972
|
+
if (!m[2]) {
|
|
973
|
+
const end = html.indexOf('-->', re.lastIndex)
|
|
974
|
+
if (end < 0) break
|
|
975
|
+
re.lastIndex = end + 3
|
|
976
|
+
continue
|
|
977
|
+
}
|
|
978
|
+
let quote = '', end = -1
|
|
979
|
+
const limit = Math.min(html.length, m.index + 1002)
|
|
980
|
+
for (let i = re.lastIndex; i < limit; i++) {
|
|
981
|
+
const ch = html[i]
|
|
982
|
+
if (quote) {
|
|
983
|
+
if (ch === quote) quote = ''
|
|
984
|
+
} else if (ch === '"' || ch === "'") quote = ch
|
|
985
|
+
else if (ch === '<') break
|
|
986
|
+
else if (ch === '>') { end = i + 1; break }
|
|
987
|
+
}
|
|
988
|
+
if (end < 0) continue
|
|
989
|
+
const attrs = html.slice(re.lastIndex, end - 1)
|
|
990
|
+
re.lastIndex = end
|
|
991
|
+
const tag = m[2].toLowerCase()
|
|
992
|
+
if (!m[1] && opaque.has(tag)) {
|
|
993
|
+
const close = new RegExp(`</${tag}\\s{0,1000}>`, 'gi')
|
|
994
|
+
close.lastIndex = re.lastIndex
|
|
995
|
+
if (!close.exec(html)) break
|
|
996
|
+
re.lastIndex = close.lastIndex
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
999
|
+
if (!structural.has(tag)) continue
|
|
1000
|
+
if (!m[1]) {
|
|
1001
|
+
if (!stacks.has(tag)) stacks.set(tag, [])
|
|
1002
|
+
stacks.get(tag).push({ tag, attrs, index: m.index, start: re.lastIndex })
|
|
1003
|
+
} else if (!attrs.trim()) {
|
|
1004
|
+
const opening = stacks.get(tag)?.pop()
|
|
1005
|
+
if (opening && m.index - opening.start <= 3145728) pairs.set(opening.index, { ...opening, close: m.index, end: re.lastIndex })
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return pairs
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function blockMd(html, depth = 0, escapeText = false) {
|
|
959
1012
|
if (depth > MD_MAX_DEPTH) {
|
|
960
|
-
|
|
1013
|
+
const text = decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
1014
|
+
return escapeText ? escInline(text) : text
|
|
961
1015
|
}
|
|
962
1016
|
const img = imgsToMarkdown(html)
|
|
963
1017
|
html = stripUnmatchedOpeners(img.html)
|
|
1018
|
+
const blocks = markdownBlockPairs(html)
|
|
964
1019
|
let out = ''
|
|
965
1020
|
const re = /<(?:br|hr)\b[^>]{0,1000}>|<([a-zA-Z0-9]{1,100})((?:"[^"]{0,1000}"|'[^']{0,1000}'|[^'">]){0,1000})>([\s\S]{0,3145728}?)<\/\1>|<[^>]{1,1000}>|([^<]{1,3145728})/gi
|
|
966
1021
|
let m
|
|
967
1022
|
while ((m = re.exec(html))) {
|
|
1023
|
+
const block = blocks.get(m.index)
|
|
1024
|
+
if (block) {
|
|
1025
|
+
m[0] = html.slice(m.index, block.end)
|
|
1026
|
+
m[1] = block.tag
|
|
1027
|
+
m[2] = block.attrs
|
|
1028
|
+
m[3] = html.slice(block.start, block.close)
|
|
1029
|
+
re.lastIndex = block.end
|
|
1030
|
+
}
|
|
968
1031
|
if (m[4] !== undefined) {
|
|
969
|
-
|
|
1032
|
+
const text = decodeTextEntities(m[4])
|
|
1033
|
+
out += escapeText ? escInline(text) : text
|
|
970
1034
|
continue
|
|
971
1035
|
}
|
|
972
1036
|
if (!m[1]) {
|
|
@@ -988,11 +1052,11 @@ export function blockMd(html, depth = 0) {
|
|
|
988
1052
|
const t = inlineMd(inner, depth + 1)
|
|
989
1053
|
if (t) out += `\n\n${t}`
|
|
990
1054
|
} else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
|
|
991
|
-
const t = blockMd(inner, depth + 1)
|
|
992
|
-
if (t) out += `\n\n${t}`
|
|
1055
|
+
const t = blockMd(inner, depth + 1, escapeText)
|
|
1056
|
+
if (t) out += `\n\n${t}\n\n`
|
|
993
1057
|
} else if (tag === 'blockquote') {
|
|
994
|
-
const t = blockMd(inner, depth + 1).trim()
|
|
995
|
-
out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
|
|
1058
|
+
const t = blockMd(inner, depth + 1, escapeText).trim()
|
|
1059
|
+
out += `\n\n> ${t.replace(/\n/g, '\n> ')}\n\n`
|
|
996
1060
|
} else if (tag === 'pre') {
|
|
997
1061
|
// Strip wrapper tags FIRST, then decode entities — a literal <div>
|
|
998
1062
|
// in the code must become <div> in the fenced block, but a real nested
|
|
@@ -1003,22 +1067,31 @@ export function blockMd(html, depth = 0) {
|
|
|
1003
1067
|
const lang =
|
|
1004
1068
|
/<code\b[^>]{0,1000}class=["'][^"']{0,1000}(?:language|lang)-([\w#+.-]{1,80})(?:[\s"'])/i.exec(inner)?.[1] || ''
|
|
1005
1069
|
const fence = backtickFence(code, 3)
|
|
1006
|
-
out += `\n\n${fence}${lang}\n${code}\n${fence}`
|
|
1070
|
+
out += `\n\n${fence}${lang}\n${code}\n${fence}\n\n`
|
|
1007
1071
|
} else if (tag === 'code') {
|
|
1008
1072
|
out += img.protect(inlineCode(inner))
|
|
1009
1073
|
} else if (tag === 'ul' || tag === 'ol') {
|
|
1010
|
-
// Without a single </li> nothing can pair, so the item scan is skipped.
|
|
1011
1074
|
const items = []
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1075
|
+
const itemBlocks = block ? blocks : markdownBlockPairs(inner)
|
|
1076
|
+
const itemOffset = block ? block.start : 0
|
|
1077
|
+
const liRe = /<li(?=[\s>])/gi
|
|
1078
|
+
let lm, idx = 1
|
|
1079
|
+
while ((lm = liRe.exec(inner))) {
|
|
1080
|
+
const item = itemBlocks.get(itemOffset + lm.index)
|
|
1081
|
+
if (!item) continue
|
|
1082
|
+
liRe.lastIndex = item.end - itemOffset
|
|
1083
|
+
const body = inner.slice(item.start - itemOffset, item.close - itemOffset)
|
|
1084
|
+
const t = /<(?:ul|ol|div|section|article|main|p|pre|blockquote|h[1-6]|table)(?=[\s>])/i.test(body)
|
|
1085
|
+
? blockMd(body, depth + 1, true).replace(/\n{3,}/g, '\n\n').trim()
|
|
1086
|
+
: inlineMd(body, depth + 1)
|
|
1087
|
+
const prefix = tag === 'ol' ? `${idx++}. ` : '- '
|
|
1088
|
+
const indent = ' '.repeat(prefix.length)
|
|
1089
|
+
const lines = t.split('\n')
|
|
1090
|
+
items.push(/^<(?:ul|ol)(?=[\s>])/i.test(body.trimStart())
|
|
1091
|
+
? prefix.trimEnd() + '\n' + lines.map(line => line ? indent + line : line).join('\n')
|
|
1092
|
+
: prefix + lines.map((line, i) => i && line ? indent + line : line).join('\n'))
|
|
1020
1093
|
}
|
|
1021
|
-
if (items.length) out += `\n\n${items.join('\n')}`
|
|
1094
|
+
if (items.length) out += `\n\n${items.join('\n')}\n\n`
|
|
1022
1095
|
} else if (tag === 'li') {
|
|
1023
1096
|
out += `\n- ${inlineMd(inner, depth + 1)}`
|
|
1024
1097
|
} else if (tag === 'br') {
|
|
@@ -1061,7 +1134,7 @@ export function blockMd(html, depth = 0) {
|
|
|
1061
1134
|
const t = inlineMd(m[0], depth + 1)
|
|
1062
1135
|
if (t) out += t
|
|
1063
1136
|
} else {
|
|
1064
|
-
const t = blockMd(inner, depth + 1)
|
|
1137
|
+
const t = blockMd(inner, depth + 1, escapeText)
|
|
1065
1138
|
if (t) out += t
|
|
1066
1139
|
}
|
|
1067
1140
|
}
|
|
@@ -1116,7 +1189,39 @@ function extractMeta(html) {
|
|
|
1116
1189
|
// Bounded block size keeps a hostile giant script tag from dragging the scan.
|
|
1117
1190
|
// 100k covers real article-list JSON-LD (news sites often embed dozens of
|
|
1118
1191
|
// items); metadata sits in the first node anyway.
|
|
1119
|
-
|
|
1192
|
+
function* jsonLdBlocks(html) {
|
|
1193
|
+
const open = /<!--|<([a-z][a-z0-9:-]{0,63})(?=[\s>])/gi
|
|
1194
|
+
const rawNames = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext'])
|
|
1195
|
+
let match
|
|
1196
|
+
while ((match = open.exec(html))) {
|
|
1197
|
+
if (!match[1]) {
|
|
1198
|
+
const end = html.indexOf('-->', open.lastIndex)
|
|
1199
|
+
if (end < 0) break
|
|
1200
|
+
open.lastIndex = end + 3
|
|
1201
|
+
continue
|
|
1202
|
+
}
|
|
1203
|
+
const tag = navigationTags(html.slice(match.index, match.index + 1002), /^<[a-z][a-z0-9:-]{0,63}(?=[\s>])/gi).next().value
|
|
1204
|
+
if (!tag) continue
|
|
1205
|
+
const end = match.index + tag.end
|
|
1206
|
+
open.lastIndex = end
|
|
1207
|
+
const name = match[1].toLowerCase()
|
|
1208
|
+
if (!rawNames.has(name)) continue
|
|
1209
|
+
if (name === 'plaintext') break
|
|
1210
|
+
const close = new RegExp(`</${name}\\s{0,1000}>`, 'gi')
|
|
1211
|
+
close.lastIndex = end
|
|
1212
|
+
const closing = close.exec(html)
|
|
1213
|
+
if (!closing) break
|
|
1214
|
+
// Raw-text bodies and quoted attributes cannot introduce script elements.
|
|
1215
|
+
open.lastIndex = close.lastIndex
|
|
1216
|
+
if (name !== 'script' || tag.attrs.type?.trim().toLowerCase() !== 'application/ld+json' || closing.index - end > 100000) continue
|
|
1217
|
+
try {
|
|
1218
|
+
yield JSON.parse(html.slice(end, closing.index))
|
|
1219
|
+
} catch {
|
|
1220
|
+
// Invalid data blocks do not prevent reading later JSON-LD blocks.
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1120
1225
|
function jsonLdMeta(html) {
|
|
1121
1226
|
// Recursively collect every node: nested structures are common (ItemList →
|
|
1122
1227
|
// mainEntity → Article, WebPage → mainEntityOfPage), so a flat top-level
|
|
@@ -1136,20 +1241,15 @@ function jsonLdMeta(html) {
|
|
|
1136
1241
|
if (v && typeof v === 'object') collect(v, out, depth + 1)
|
|
1137
1242
|
}
|
|
1138
1243
|
}
|
|
1139
|
-
const authorOf = (a) => {
|
|
1244
|
+
const authorOf = (a, depth = 0) => {
|
|
1245
|
+
if (depth > 20) return ''
|
|
1140
1246
|
if (typeof a === 'string') return a
|
|
1141
|
-
if (Array.isArray(a)) return authorOf(a[0])
|
|
1142
|
-
if (a && typeof a === 'object') return typeof a.name === 'string' ? a.name : authorOf(a.name)
|
|
1247
|
+
if (Array.isArray(a)) return authorOf(a[0], depth + 1)
|
|
1248
|
+
if (a && typeof a === 'object') return typeof a.name === 'string' ? a.name : authorOf(a.name, depth + 1)
|
|
1143
1249
|
return ''
|
|
1144
1250
|
}
|
|
1145
1251
|
const clean = (s) => (s ? decodeTextEntities(s).slice(0, 40) : '')
|
|
1146
|
-
for (const
|
|
1147
|
-
let json
|
|
1148
|
-
try {
|
|
1149
|
-
json = JSON.parse(m[1])
|
|
1150
|
-
} catch {
|
|
1151
|
-
continue
|
|
1152
|
-
}
|
|
1252
|
+
for (const json of jsonLdBlocks(html)) {
|
|
1153
1253
|
const nodes = []
|
|
1154
1254
|
collect(json, nodes)
|
|
1155
1255
|
for (const n of nodes) {
|
|
@@ -1165,15 +1265,9 @@ function jsonLdMeta(html) {
|
|
|
1165
1265
|
// (articleBody) while the visible HTML is an anti-bot shell or a thin
|
|
1166
1266
|
// fragment — a fallback body source the DOM extractors cannot reach. Returns
|
|
1167
1267
|
// the first string articleBody found (array values joined), stripped to
|
|
1168
|
-
// paragraphs via textLines.
|
|
1268
|
+
// paragraphs via textLines. Metadata and body share the bounded block scan.
|
|
1169
1269
|
function jsonLdArticleBody(html) {
|
|
1170
|
-
for (const
|
|
1171
|
-
let json
|
|
1172
|
-
try {
|
|
1173
|
-
json = JSON.parse(m[1])
|
|
1174
|
-
} catch {
|
|
1175
|
-
continue
|
|
1176
|
-
}
|
|
1270
|
+
for (const json of jsonLdBlocks(html)) {
|
|
1177
1271
|
const nodes = []
|
|
1178
1272
|
const collect = (node, depth = 0) => {
|
|
1179
1273
|
if (!node || typeof node !== 'object' || depth > 20) return
|
|
@@ -1189,7 +1283,7 @@ function jsonLdArticleBody(html) {
|
|
|
1189
1283
|
}
|
|
1190
1284
|
collect(json)
|
|
1191
1285
|
for (const n of nodes) {
|
|
1192
|
-
const raw = Array.isArray(n.articleBody) ? n.articleBody.join('\n\n') : n.articleBody
|
|
1286
|
+
const raw = Array.isArray(n.articleBody) ? n.articleBody.filter(part => typeof part === 'string').join('\n\n') : n.articleBody
|
|
1193
1287
|
if (typeof raw === 'string' && raw.trim()) return textLines(raw).slice(0, 200000)
|
|
1194
1288
|
}
|
|
1195
1289
|
}
|
|
@@ -2176,6 +2270,7 @@ function readLinksTool(ctx, cfg) {
|
|
|
2176
2270
|
const page = await fetchResource(ctx, url, signal, cfg, route)
|
|
2177
2271
|
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2178
2272
|
if (page.error) return { error: page.error }
|
|
2273
|
+
if (page.kind === 'text') return { url: page.finalUrl || url, count: 0, links: [], spaHint: providerTextHint(page) }
|
|
2179
2274
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2180
2275
|
let finalUrl = page.finalUrl || url
|
|
2181
2276
|
// Same meta-refresh shell following as read_url: a link hop serves a
|
|
@@ -2401,6 +2496,14 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2401
2496
|
if (depth > 0 && !sameHost(pageUrl, host)) {
|
|
2402
2497
|
return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
|
|
2403
2498
|
}
|
|
2499
|
+
if (page.kind === 'text') {
|
|
2500
|
+
if (depth === 0) host = hostOf(pageUrl)
|
|
2501
|
+
return {
|
|
2502
|
+
url: pageUrl, depth, title: '', chars: html.length,
|
|
2503
|
+
text: includeContent ? smartTruncate(html, perMax).text : undefined,
|
|
2504
|
+
links: [], route, spaHint: providerTextHint(page),
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2404
2507
|
// Same shell-following as read_url: a meta-refresh stub would otherwise
|
|
2405
2508
|
// be crawled AS the page (title-less hop text pollutes results and the
|
|
2406
2509
|
// real content is never reached). Fails open — fetch problems keep the
|
|
@@ -2553,7 +2656,7 @@ export function apply(ctx, config) {
|
|
|
2553
2656
|
const cfg = { ...DEFAULTS, ...(config || {}) }
|
|
2554
2657
|
const clamp = (v, d, min, max) => {
|
|
2555
2658
|
const n = Number(v)
|
|
2556
|
-
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
|
|
2659
|
+
return Number.isFinite(n) ? Math.floor(Math.min(max, Math.max(min, n))) : d
|
|
2557
2660
|
}
|
|
2558
2661
|
cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
|
|
2559
2662
|
cfg.maxBytes = clamp(cfg.maxBytes, DEFAULTS.maxBytes, 65536, 16 * 1024 * 1024)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "DeepSeek Harness URL reader: fetch any webpage (HTML/JSON/RSS/Atom), auto-detect encoding (UTF-16 BOM/GBK/Big5/Shift-JIS), extract clean main content with auto-pagination, output compact text/Markdown to save tokens. Zero runtime dependencies, no API key, drop-in. Follows meta-refresh shells, reads JSON-LD metadata, honors base href.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/proxy-fallback.js
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
// proxy-fallback.js —
|
|
1
|
+
// proxy-fallback.js — optional proxy channel for direct webpage retrieval
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// fetch
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// (
|
|
8
|
-
// once through it via the system `curl` (ships with Windows 10+/macOS/Linux;
|
|
9
|
-
// zero npm deps). The proxy URL is passed explicitly with `-x`, so we don't
|
|
10
|
-
// depend on curl's env-var reading.
|
|
3
|
+
// When the host web provider is absent, configured proxy retrieval races
|
|
4
|
+
// native fetch and the first success wins. Proxy discovery uses standard
|
|
5
|
+
// HTTP_PROXY/HTTPS_PROXY env vars, then the Windows system proxy registry.
|
|
6
|
+
// NO_PROXY exclusions retain only the native fetch. Proxy requests use system
|
|
7
|
+
// curl (Windows 10+/macOS/Linux; zero npm deps) with an explicit `-x` URL.
|
|
11
8
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// caller reports the ORIGINAL direct-connect error with a clear note, so the
|
|
15
|
-
// model/user can act (turn the proxy on, or accept the network boundary).
|
|
9
|
+
// Successful proxy responses use the direct channel's body shape. HTTP and
|
|
10
|
+
// content errors remain diagnostic; unavailable curl or proxies return null.
|
|
16
11
|
import { execFile, execFileSync } from 'node:child_process'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { win32 } from 'node:path'
|
|
14
|
+
import { BlockList, isIP } from 'node:net'
|
|
15
|
+
|
|
16
|
+
// Desktop launches can inherit a reduced PATH. Prefer the Windows-shipped
|
|
17
|
+
// utilities when present; other platforms and custom installations use PATH.
|
|
18
|
+
function systemExecutable(name) {
|
|
19
|
+
if (process.platform !== 'win32') return name
|
|
20
|
+
const parts = name === 'powershell'
|
|
21
|
+
? ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe']
|
|
22
|
+
: ['System32', 'curl.exe']
|
|
23
|
+
for (const root of [process.env.SystemRoot, process.env.WINDIR]) {
|
|
24
|
+
if (typeof root !== 'string' || !win32.isAbsolute(root)) continue
|
|
25
|
+
const executable = win32.join(root, ...parts)
|
|
26
|
+
if (existsSync(executable)) return executable
|
|
27
|
+
}
|
|
28
|
+
return name
|
|
29
|
+
}
|
|
17
30
|
|
|
18
31
|
// Some servers omit the Content-Type header entirely; the content-type gate
|
|
19
32
|
// then has nothing to test and a binary body (PDF/PNG/zip) would flow into
|
|
@@ -59,8 +72,7 @@ const SYS_PROXY_RETRY_MS = 60 * 1000
|
|
|
59
72
|
|
|
60
73
|
// Windows system proxy from the registry (HKCU Internet Settings). PowerShell
|
|
61
74
|
// is used because `reg.exe` is frequently blocked by security policy; the
|
|
62
|
-
// read is synchronous and cached
|
|
63
|
-
// direct-connect failure, so the ~1s cost is invisible on the happy path).
|
|
75
|
+
// read is synchronous and cached to avoid repeated discovery during reads.
|
|
64
76
|
// A FAILED read is cached as '' but re-tried after 60s — an environment that
|
|
65
77
|
// transiently blocks the PowerShell child (EDR, policy) should not starve
|
|
66
78
|
// the whole session of the proxy.
|
|
@@ -71,7 +83,7 @@ function readWindowsSystemProxy() {
|
|
|
71
83
|
sysProxyCheckedAt = now
|
|
72
84
|
try {
|
|
73
85
|
const out = execFileSync(
|
|
74
|
-
'powershell',
|
|
86
|
+
systemExecutable('powershell'),
|
|
75
87
|
[
|
|
76
88
|
'-NoProfile', '-Command',
|
|
77
89
|
"(Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings').ProxyEnable, (Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings').ProxyServer",
|
|
@@ -90,8 +102,72 @@ function readWindowsSystemProxy() {
|
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
// Match curl's domain/IP exclusions before starting a redundant direct curl
|
|
106
|
+
// request. Port-qualified hosts and wildcard suffixes are also accepted.
|
|
107
|
+
function bypassesProxy(url) {
|
|
108
|
+
const exclusions = process.env.no_proxy || process.env.NO_PROXY
|
|
109
|
+
if (!url || !exclusions) return false
|
|
110
|
+
let target
|
|
111
|
+
try { target = new URL(url) } catch { return false }
|
|
112
|
+
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
|
|
113
|
+
const ipVersion = isIP(hostname)
|
|
114
|
+
const port = target.port || (target.protocol === 'https:' ? '443' : '80')
|
|
115
|
+
for (let entry of exclusions.split(',')) {
|
|
116
|
+
entry = entry.trim().toLowerCase()
|
|
117
|
+
if (!entry) continue
|
|
118
|
+
if (entry === '*') return true
|
|
119
|
+
const slash = entry.indexOf('/')
|
|
120
|
+
if (slash >= 0) {
|
|
121
|
+
const address = entry.slice(0, slash)
|
|
122
|
+
const prefix = entry.slice(slash + 1)
|
|
123
|
+
if (!ipVersion || isIP(address) !== ipVersion || !/^\d{1,3}$/.test(prefix)) continue
|
|
124
|
+
try {
|
|
125
|
+
const block = new BlockList()
|
|
126
|
+
const family = ipVersion === 6 ? 'ipv6' : 'ipv4'
|
|
127
|
+
block.addSubnet(address, Number(prefix), family)
|
|
128
|
+
if (block.check(hostname, family)) return true
|
|
129
|
+
} catch {}
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
let entryPort = ''
|
|
133
|
+
if (entry.startsWith('[')) {
|
|
134
|
+
const end = entry.indexOf(']')
|
|
135
|
+
if (end < 0) continue
|
|
136
|
+
const tail = entry.slice(end + 1)
|
|
137
|
+
if (tail && !/^:\d{1,5}$/.test(tail)) continue
|
|
138
|
+
entryPort = tail.slice(1)
|
|
139
|
+
entry = entry.slice(1, end)
|
|
140
|
+
} else if (!isIP(entry) && entry.indexOf(':') >= 0) {
|
|
141
|
+
const colon = entry.lastIndexOf(':')
|
|
142
|
+
entryPort = entry.slice(colon + 1)
|
|
143
|
+
entry = entry.slice(0, colon)
|
|
144
|
+
if (entry.includes(':') || !/^\d{1,5}$/.test(entryPort)) continue
|
|
145
|
+
}
|
|
146
|
+
if (entryPort && Number(entryPort) !== Number(port)) continue
|
|
147
|
+
if (isIP(entry)) {
|
|
148
|
+
if (!ipVersion) continue
|
|
149
|
+
try {
|
|
150
|
+
const canonical = new URL(isIP(entry) === 6 ? `http://[${entry}]` : `http://${entry}`).hostname
|
|
151
|
+
.replace(/^\[|\]$/g, '')
|
|
152
|
+
if (canonical === hostname) return true
|
|
153
|
+
} catch {}
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (ipVersion) continue
|
|
157
|
+
if (entry.startsWith('*.')) {
|
|
158
|
+
if (hostname.endsWith(entry.slice(1).replace(/\.$/, ''))) return true
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
else if (entry.startsWith('.')) entry = entry.slice(1)
|
|
162
|
+
entry = entry.replace(/\.$/, '')
|
|
163
|
+
if (entry && (hostname === entry || hostname.endsWith(`.${entry}`))) return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
93
168
|
// The user's proxy, in priority order: env vars, then Windows system proxy.
|
|
94
|
-
export async function detectProxy() {
|
|
169
|
+
export async function detectProxy(url) {
|
|
170
|
+
if (bypassesProxy(url)) return ''
|
|
95
171
|
const env =
|
|
96
172
|
process.env.HTTPS_PROXY ||
|
|
97
173
|
process.env.https_proxy ||
|
|
@@ -108,11 +184,14 @@ export async function detectProxy() {
|
|
|
108
184
|
// `proxyOverride` lets the caller pass an already-detected proxy (the race
|
|
109
185
|
// path probes once up front) instead of re-detecting here.
|
|
110
186
|
export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride) {
|
|
111
|
-
|
|
187
|
+
if (bypassesProxy(url)) return null
|
|
188
|
+
const proxy = proxyOverride || (await detectProxy(url))
|
|
112
189
|
if (!proxy) return null
|
|
113
190
|
const maxSec = Math.max(10, Math.ceil(cfg.timeoutMs / 1000) + 5)
|
|
114
191
|
const args = [
|
|
115
192
|
'-sS',
|
|
193
|
+
'--globoff',
|
|
194
|
+
'--compressed', // request and decode supported HTTP content encodings
|
|
116
195
|
'-L', // follow redirects, like the direct fetch path
|
|
117
196
|
'--max-time', String(maxSec),
|
|
118
197
|
'--max-filesize', String(cfg.maxBytes), // unit is BYTES, not KB
|
|
@@ -127,7 +206,7 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
127
206
|
]
|
|
128
207
|
try {
|
|
129
208
|
const buf = await new Promise((resolve, reject) => {
|
|
130
|
-
execFile('curl', args, { maxBuffer: cfg.maxBytes + 8192, encoding: 'buffer', windowsHide: true, signal: externalSignal || undefined }, (err, stdout) => {
|
|
209
|
+
execFile(systemExecutable('curl'), args, { maxBuffer: cfg.maxBytes + 8192, encoding: 'buffer', windowsHide: true, signal: externalSignal || undefined }, (err, stdout) => {
|
|
131
210
|
if (err) return reject(err)
|
|
132
211
|
resolve(stdout)
|
|
133
212
|
})
|
|
@@ -137,6 +216,9 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
137
216
|
if (nl < 0) return null // no trailing metadata — abnormal response, treat as failure
|
|
138
217
|
const meta = buf.subarray(nl + 1).toString('latin1').trim()
|
|
139
218
|
const body = buf.subarray(0, nl)
|
|
219
|
+
// Older curl versions bound the compressed transfer, not the decoded
|
|
220
|
+
// body. Do not let the metadata allowance enlarge the content budget.
|
|
221
|
+
if (body.length > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
|
|
140
222
|
const parts = meta.split(' ')
|
|
141
223
|
const codeStr = parts.shift()
|
|
142
224
|
const finalUrlEff = parts.length > 1 ? parts.pop() : url
|
|
@@ -157,7 +239,9 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
157
239
|
} catch (err) {
|
|
158
240
|
// curl error 63 = exceeded --max-filesize (page too large, not a proxy
|
|
159
241
|
// problem — report it as such instead of "proxy unreachable")
|
|
160
|
-
if (err && err.code === 63
|
|
242
|
+
if (err && (err.code === 63 || (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && err.message.startsWith('stdout')))) {
|
|
243
|
+
return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
|
|
244
|
+
}
|
|
161
245
|
// curl missing, proxy down, or cancelled — let the caller report the
|
|
162
246
|
// original direct-connect error (the proxy attempt is best-effort)
|
|
163
247
|
return null
|
package/spa.js
CHANGED
|
@@ -28,7 +28,7 @@ export function looksLikeSpa(html) {
|
|
|
28
28
|
// stabilizes while the challenge sits in "Verification successful. Waiting
|
|
29
29
|
// for ... to respond" — the poll loop returned that transitional page and
|
|
30
30
|
// the caller mistook it for an improvement over static extraction.
|
|
31
|
-
const CHALLENGE_RE = /just a moment|performing security verification|verifying (you are|your) human|cf-chl|cf_chl|cdn-cgi\/challenge-platform|attention required!\s
|
|
31
|
+
const CHALLENGE_RE = /just a moment|performing security verification|verifying (you are|your) human|cf-chl|cf_chl|cdn-cgi\/challenge-platform|attention required!\s{0,1000}(?:[|·]\s{0,1000})?cloudflare|enable javascript and cookies to continue/i
|
|
32
32
|
|
|
33
33
|
export function looksLikeChallenge(html) {
|
|
34
34
|
return typeof html === 'string' && CHALLENGE_RE.test(html)
|