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 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
- DSH agents can search (getting links and snippets) but lack the step of "reading a URL into clean body text". The official `tool-web` `web_fetch` does a **whole-page turndown conversion** (nav/ads/sidebars all preserved) with a default cap of 200,000 characters — a token black hole. This plugin returns only what the model actually needs: **cleaned body + essential metadata**, truncated by default.
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 (recommended, easy updates)
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
- DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净正文"这一步。官方 `tool-web` 的 `web_fetch` 是**整页 turndown 转换**(导航/广告/侧栏全保留),默认上限 20 万字符——token 黑洞。本插件只返回模型真正需要的:**净化后的正文 + 必要元数据**,并默认截断。
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'}(直连失败;已尝试代理 ${proxy}${px})` }
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
- export function blockMd(html, depth = 0) {
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
- return decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
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
- out += decodeTextEntities(m[4])
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 &lt;div&gt;
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
- if (/<\/li\b/i.test(inner)) {
1013
- const liRe = /<li\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/li>/gi
1014
- let lm, idx = 1
1015
- while ((lm = liRe.exec(inner))) {
1016
- const t = inlineMd(lm[1], depth + 1)
1017
- items.push(tag === 'ol' ? `${idx}. ${t}` : `- ${t}`)
1018
- idx++
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
- const LDJSON_RE = /<script[^>]{0,1000}type=["']application\/ld\+json["'][^>]{0,1000}>([\s\S]{0,100000}?)<\/script\s*>/gi
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 m of html.matchAll(LDJSON_RE)) {
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. Block scan is the same bounded LDJSON_RE.
1268
+ // paragraphs via textLines. Metadata and body share the bounded block scan.
1169
1269
  function jsonLdArticleBody(html) {
1170
- for (const m of html.matchAll(LDJSON_RE)) {
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.1",
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 — retry a failed direct fetch through the user's proxy
1
+ // proxy-fallback.js — optional proxy channel for direct webpage retrieval
2
2
  //
3
- // Design: direct connect first, proxy only as fallback. When the direct
4
- // fetch fails with a CONNECTION-class error (blocked network, refused,
5
- // DNS miss, connect timeout), we look up the user's own proxy — first the
6
- // standard HTTP_PROXY/HTTPS_PROXY env vars, then the Windows system proxy
7
- // (registry) where proxy apps like Clash actually persist it — and retry
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
- // Transparency: a successful proxied read returns exactly like a direct one
13
- // (the caller never knows); a failed proxied attempt returns null and the
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 after the first call (it only runs after a
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
- const proxy = proxyOverride || (await detectProxy())
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) return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
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*[|·]?\s*cloudflare|enable javascript and cookies to continue/i
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)