dsh-read-url 1.9.2 → 1.9.4

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.4] - 2026-11-15
4
+
5
+ ### 修复
6
+
7
+ - Windows 系统代理映射按请求的 HTTP/HTTPS 协议选择,缓存保留完整映射。环境变量同时配置两种代理时优先选择目标协议,单一代理配置保留兼容回退。
8
+ - 链接扫描和站点读取保留 HTML 提供方的原文截断提示,刷新至完整目标后清除旧提示,刷新失败时保留当前页面提示。
9
+ - 短前言或过早的句子边界不会耗尽整次正文读取,干净正文优先使用窗口后半段的段落或句子边界,并保留准确续读坐标。
10
+ - 含原始 HTML 的窗口保留早期对齐,避免扩展短首段时引入更多超长属性残留,字面代码示例按原文续读。
11
+ - 双语文档更新 DSH 0.2.2 HTTP 提供方的内网访问行为。
12
+
13
+ ### 验证
14
+
15
+ - 435 个单元断言、15 个真实 Chromium SPA 断言、70 项对抗探针通过,新增 22 条单元检查。分协议代理选择、截断提示及短前言问题在 v1.9.2/v1.9.3 同输入复现。
16
+ - 本机 DSH 桌面 0.2.2-rc.1 的 Electron 44.7.0 / Node 24.21.0 通过 17 项服务和 3 项作用域检查。官方 0.2.2-rc.3 通过 17 项服务、9 项 DeepSeek 提供方与 5 项 HTTP 提供方受控检查,发布版 patch 通过加载器 4 项和临时 profile 2 项检查。
17
+ - 官方 npm latest 为 0.2.2-rc.1、next 为 0.2.2-rc.3。八个接触包、Cordis/Loader 与四个桌面加载文件完成字节差异审计,交叉核对截至 0.2.2-alpha.3 的社区升级卡。新版工具执行器的调整已纳入集成测试,插件消费接口保持兼容。
18
+ - 干净正文随机续读及 emoji 边界检查通过,固定 800 字符窗口的截断耗时不随全文大小增长。原始 HTML 的长属性扫描限制继续保留,部分站点仍有既有残留标签边界。
19
+ - 核心零运行时依赖,四工具 description/schema 为 1,159/1,876 字符。受控服务检查未覆盖真实模型会话或外部 DeepSeek API 服务。
20
+ - 最终 152 站为 104 OK / 21 THIN / 4 EMPTY / 23 ERR / 0 THREW,OK 站零噪声,三个已知 NOISE 站点同输入对照 v1.9.3 一致。错误为 7 个 HTTP 错误、3 个预期类型拒绝、13 个连接/DNS 失败。
21
+
22
+ ## [1.9.3] - 2026-10-19
23
+
24
+ ### 修复
25
+
26
+ - Markdown 按配对块处理嵌套容器与列表,保留段落间距、父级编号和子列表缩进;代码围栏/引用与后文正确分隔,属性与递归深度继续有界。
27
+ - 数值配置经范围限制后向下取整,避免小数超时触发 `AbortSignal.timeout` 错误或小数分页上限多抓一页。
28
+ - 代理回退遵守 `no_proxy`/`NO_PROXY` 的域名、IP、端口和 CIDR 边界,避免绕过代理的地址被直连与 curl 重复请求;curl 禁用 URL glob 展开,原样处理方括号查询和花括号路径。
29
+ - curl 启用受支持的 HTTP 响应解压,避免 gzip 字节进入正文;单独检查解压后字节限额,兼容旧版 curl 的传输大小语义,stdout 超限返回明确错误。
30
+ - 失败诊断不再输出代理配置地址,避免用户名、密码进入工具结果或模型上下文。
31
+ - 提供方明确标记为纯文本时,链接和站点读取不再解析正文中的 HTML 示例或跟随示例跳转;保留原文、输出预算与提供方截断提示。
32
+ - 质询页识别的空白段使用明确长度上限,消除相邻无界空白造成的平方回溯。
33
+ - 双语说明更新 DeepSeek Fetch API 能力边界,并将早期官方工具对比明确标为历史快照。
34
+
35
+ ### 验证
36
+
37
+ - 413 个零依赖单元断言、15 个真实 Chromium SPA 断言及 70 项对抗探针通过;新增 38 条单元检查、6 条探针,覆盖列表内代码围栏闭合及引用块边界。原始缺陷对照 v1.9.1/v1.9.2 复现。
38
+ - 最终源码在本机桌面 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/模型会话。
39
+ - 本机桌面升级为 0.2.1-rc.2(Electron 44.7.0 / Node 24.21.0)后,相同冻结源码在实际内置运行时另通过 17 项集成、3 项工具作用域与 15 项 Chromium SPA 检查。
40
+ - 已审计八个接触包、Cordis/Loader 与四个桌面加载文件;社区升级卡截至 0.2.0-rc.2,新版由官方源码交叉核验。发布版 patch 另通过 4 项加载器及 2 项临时 profile 检查。
41
+ - 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 个网络失败。
42
+ - Bilibili 视频页在修复 gzip 解压后由乱码变为正常正文;实际 curl 8.19/8.21 共 12 个正常/超限/重定向/取消场景通过,包含旧版 curl 解压后越界保护。
43
+ - 核心零运行时依赖;四工具 description/schema 保持 1,159/1,876 字符,属性长度与递归上限保留。
44
+
3
45
  ## [1.9.2] - 2026-10-03
4
46
 
5
47
  ### 修复
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 |
@@ -202,7 +204,7 @@ Values are coerced and clamped to sane ranges at load — quoted numbers in YAML
202
204
  - https://raw.githubusercontent.com
203
205
  ```
204
206
 
205
- Listed origins use direct fetch immediately, bypassing the host web provider **and its SSRF checks**. Add only origins you trust; this also permits their DNS/hosts mappings to local or private addresses. The option is not a tool argument. Unlisted initial URLs keep the existing provider selection and fallback behavior; a provider refusal does not enable this exception automatically.
207
+ Listed origins use direct fetch immediately, bypassing the host web provider and its access policy. Add only origins you trust, including their DNS/hosts mappings to local or private addresses. DSH 0.2.2's HTTP provider already supports local and private destinations. Other providers may apply different access policies. The option is configured in the profile. Unlisted initial URLs retain provider selection and fallback behavior. A provider refusal does not automatically enable explicit direct retrieval.
206
208
 
207
209
  Matching is exact after URL normalization: scheme, hostname and port must match; subdomains are separate. At most 32 entries of 2,048 characters each are accepted. Only an HTTP(S) origin with an optional trailing `/` is valid: credentials, paths, queries, fragments, wildcards and ambiguous syntax cause a configuration error. Unlike numeric options, invalid entries do not fall back to defaults.
208
210
 
@@ -339,6 +341,23 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
339
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.
340
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**.
341
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
+
353
+ ### v1.9.4 Maintenance validation (2026-11-15)
354
+
355
+ - **435 unit assertions, 15 real Chromium SPA assertions and 70 adversarial probes** passed. The 22 new unit checks cover protocol-specific proxy selection, HTML-provider truncation hints and continuation after short prose prefixes. Core runtime dependencies remain zero and fixed tool input remains **1,159 description characters / 1,876 schema characters**.
356
+ - Official npm channels report `latest=0.2.2-rc.1` and `next=0.2.2-rc.3`, both release candidates. The [November 13 release](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.3) was audited using byte differences across eight contact packages, Cordis/Loader and four Desktop loading files. Community upgrade cards currently reach 0.2.2-alpha.3. The checked plugin interfaces remain compatible.
357
+ - The installed Desktop is **0.2.2-rc.1 / Electron 44.7.0 / Node 24.21.0**. Its bundled runtime passed **17 service checks and 3 tool-scope checks**, alongside the 15 SPA assertions. Official 0.2.2-rc.3 passed **17 service checks, 9 DeepSeek provider checks and 5 HTTP-provider checks** with controlled responses. The published plugin patch also passed 4 loader and 2 temporary-profile checks. Full model conversations and Desktop UI interactions were outside these checks.
358
+ - Clean prose uses a paragraph or sentence boundary in the latter half of the requested window, avoiding tiny heading-only responses. Windows containing raw HTML retain earlier alignment. Literal examples remain available through exact-offset continuation. The 1,000-character attribute limit remains deliberate, so some pages with long attributes still expose residual markup or return a short initial window.
359
+ - The final 152-site sweep completed with **104 OK / 21 THIN / 4 EMPTY / 23 ERR / 0 THREW**, with zero noise in OK results. The three known NOISE pages matched v1.9.3 on identical input. The 23 errors comprised 7 HTTP errors, 3 expected content-type rejections and 13 connection/DNS failures. Continuation and the five-page crawl completed successfully.
360
+
342
361
  ## Roadmap
343
362
 
344
363
  - [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 容器 + 噪音剥离 |
@@ -202,7 +204,7 @@ chars 800+800/12398 · cached
202
204
  - https://raw.githubusercontent.com
203
205
  ```
204
206
 
205
- 命中的来源立即直连,跳过宿主 web provider **及其 SSRF 检查**。仅添加你信任的来源;其 DNS/hosts 映射到本机或私有地址也会被允许。此选项不开放为工具参数。未列出的初始 URL 保留既有 provider 选择与回退行为;provider 拒绝不会自动启用此例外。
207
+ 命中的来源立即直连,跳过宿主 web provider 及其访问策略。仅添加你信任的来源,包括其 DNS/hosts 到本机或私有地址的映射。DSH 0.2.2 的 HTTP 提供方已支持本机和内网地址,其他提供方可采用不同访问策略。此选项由 profile 配置。未列出的初始 URL 保留既有 provider 选择与回退行为,provider 拒绝不会自动启用显式直连。
206
208
 
207
209
  按 URL 规范化后的协议、域名、端口精确匹配,子域名需单独配置。最多 32 项,每项不超过 2,048 字符。仅接受 HTTP(S) origin,可带一个结尾 `/`;凭据、路径、查询、片段、通配符和歧义写法都会触发配置错误。此选项与数字选项不同,非法项不会静默回退默认值。
208
210
 
@@ -339,6 +341,23 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
339
341
  - 152 站结果为 **104 OK / 20 THIN / 4 EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。已知 NOISE 与状态波动站使用相同 HTML/DOM 和可选依赖对照 v1.9.1,全文续读及 800 字输出一致;新增失败的订阅站在新旧版本均返回 HTTP 403。
340
342
  - JSON-LD 元数据/正文支持合法属性变体并排除伪属性;作者递归有界,正文数组仅保留字符串。核心仍为零运行时依赖,模型固定输入为 **description 1,159 字符 / schema 1,876 字符**。
341
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
+
353
+ ### v1.9.4 维护验证(2026-11-15)
354
+
355
+ - **435 个单元断言、15 个真实 Chromium SPA 断言、70 项对抗探针**通过。新增 22 条单元检查覆盖分协议代理选择、HTML 提供方截断提示及短前言后的续读。核心零运行时依赖,工具固定输入仍为 **description 1,159 字符 / schema 1,876 字符**。
356
+ - 官方 npm 频道为 `latest=0.2.2-rc.1`、`next=0.2.2-rc.3`,均为候选版本。[11 月 13 日发布的 rc.3](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.3) 已通过八个接触包、Cordis/Loader 及四个桌面加载文件的字节差异审计。社区升级卡目前到 0.2.2-alpha.3,已核查的插件接口保持兼容。
357
+ - 本机桌面为 **0.2.2-rc.1 / Electron 44.7.0 / Node 24.21.0**,内置运行时通过 **17 项服务集成、3 项工具作用域**检查,并通过上述 15 项 SPA 断言。官方 0.2.2-rc.3 通过 **17 项服务、9 项 DeepSeek 提供方和 5 项 HTTP 提供方**受控检查,已发布插件 patch 另通过 4 项加载器和 2 项临时 profile 检查。验证范围未覆盖完整模型会话和桌面 UI 交互。
358
+ - 干净正文在请求窗口的后半段选择段落或句子边界,避免只返回极短标题。含原始 HTML 的窗口保留早期对齐,字面示例可按精确 offset 继续读取。属性长度继续限制为 1,000 字符,部分超长属性页面仍可能残留标签或返回较短的首段。
359
+ - 最终 152 站为 **104 OK / 21 THIN / 4 EMPTY / 23 ERR / 0 THREW**,OK 站零噪声。三个已知 NOISE 页面在同一输入下与 v1.9.3 一致。23 个错误包括 7 个 HTTP 错误、3 个预期类型拒绝及 13 个连接/DNS 失败,续读和五页站点抓取完成。
360
+
342
361
  ## Roadmap
343
362
 
344
363
  - [x] 单页多段续读(`offset` 参数)
package/index.js CHANGED
@@ -99,6 +99,11 @@ function allowedDirectUrl(url, origins) {
99
99
 
100
100
  const DIRECT_BOUNDARY_ERROR = 'Direct fetch stopped: target is outside directFetchOrigins or contains credentials'
101
101
  const DIRECT_SPA_HINT = '已配置直连仅支持静态读取,未启用浏览器渲染'
102
+ const SOURCE_TRUNCATED_HINT = '提供方已截断原文,当前内容不是完整页面'
103
+
104
+ function providerTextHint(page) {
105
+ return '提供方返回纯文本,未扫描 HTML 链接或跳转' + (page.sourceTruncated ? ';提供方已截断原文,当前内容不是完整页面' : '')
106
+ }
102
107
 
103
108
  function getDecoder(enc) {
104
109
  let d = decoders.get(enc)
@@ -328,7 +333,7 @@ async function raceFetch(url, externalSignal, cfg, proxy) {
328
333
 
329
334
  async function fetchPage(url, externalSignal, cfg) {
330
335
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
331
- const proxy = await detectProxy()
336
+ const proxy = await detectProxy(url)
332
337
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
333
338
  if (proxy) {
334
339
  const winner = await raceFetch(url, externalSignal, cfg, proxy)
@@ -336,7 +341,7 @@ async function fetchPage(url, externalSignal, cfg) {
336
341
  const dErr = winner.failures.find((f) => f.label === 'direct')
337
342
  const pErr = winner.failures.find((f) => f.label === 'proxy')
338
343
  const px = pErr ? `,代理返回 ${pErr.error}` : ',代理未连通'
339
- return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}(直连失败;已尝试代理 ${proxy}${px})` }
344
+ return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}(直连失败;已尝试代理${px})` }
340
345
  }
341
346
  // No proxy configured: plain direct connect (the original behaviour).
342
347
  const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
@@ -955,18 +960,78 @@ export function inlineMd(html, depth = 0) {
955
960
  return img.restore(out.trim())
956
961
  }
957
962
 
958
- export function blockMd(html, depth = 0) {
963
+ // Pair structural blocks in one forward pass so nested siblings keep their
964
+ // own boundaries. Raw/code bodies and quoted attribute values are opaque.
965
+ function markdownBlockPairs(html) {
966
+ const pairs = new Map()
967
+ const stacks = new Map()
968
+ const structural = new Set(['div', 'section', 'article', 'main', 'blockquote', 'ul', 'ol', 'li'])
969
+ const opaque = new Set(['pre', 'code', 'script', 'style', 'textarea', 'template', 'noscript'])
970
+ const re = /<!--|<(\/?)([a-z][a-z0-9:-]{0,63})(?=[\s/>])/gi
971
+ let m
972
+ while ((m = re.exec(html))) {
973
+ if (!m[2]) {
974
+ const end = html.indexOf('-->', re.lastIndex)
975
+ if (end < 0) break
976
+ re.lastIndex = end + 3
977
+ continue
978
+ }
979
+ let quote = '', end = -1
980
+ const limit = Math.min(html.length, m.index + 1002)
981
+ for (let i = re.lastIndex; i < limit; i++) {
982
+ const ch = html[i]
983
+ if (quote) {
984
+ if (ch === quote) quote = ''
985
+ } else if (ch === '"' || ch === "'") quote = ch
986
+ else if (ch === '<') break
987
+ else if (ch === '>') { end = i + 1; break }
988
+ }
989
+ if (end < 0) continue
990
+ const attrs = html.slice(re.lastIndex, end - 1)
991
+ re.lastIndex = end
992
+ const tag = m[2].toLowerCase()
993
+ if (!m[1] && opaque.has(tag)) {
994
+ const close = new RegExp(`</${tag}\\s{0,1000}>`, 'gi')
995
+ close.lastIndex = re.lastIndex
996
+ if (!close.exec(html)) break
997
+ re.lastIndex = close.lastIndex
998
+ continue
999
+ }
1000
+ if (!structural.has(tag)) continue
1001
+ if (!m[1]) {
1002
+ if (!stacks.has(tag)) stacks.set(tag, [])
1003
+ stacks.get(tag).push({ tag, attrs, index: m.index, start: re.lastIndex })
1004
+ } else if (!attrs.trim()) {
1005
+ const opening = stacks.get(tag)?.pop()
1006
+ if (opening && m.index - opening.start <= 3145728) pairs.set(opening.index, { ...opening, close: m.index, end: re.lastIndex })
1007
+ }
1008
+ }
1009
+ return pairs
1010
+ }
1011
+
1012
+ export function blockMd(html, depth = 0, escapeText = false) {
959
1013
  if (depth > MD_MAX_DEPTH) {
960
- return decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
1014
+ const text = decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
1015
+ return escapeText ? escInline(text) : text
961
1016
  }
962
1017
  const img = imgsToMarkdown(html)
963
1018
  html = stripUnmatchedOpeners(img.html)
1019
+ const blocks = markdownBlockPairs(html)
964
1020
  let out = ''
965
1021
  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
1022
  let m
967
1023
  while ((m = re.exec(html))) {
1024
+ const block = blocks.get(m.index)
1025
+ if (block) {
1026
+ m[0] = html.slice(m.index, block.end)
1027
+ m[1] = block.tag
1028
+ m[2] = block.attrs
1029
+ m[3] = html.slice(block.start, block.close)
1030
+ re.lastIndex = block.end
1031
+ }
968
1032
  if (m[4] !== undefined) {
969
- out += decodeTextEntities(m[4])
1033
+ const text = decodeTextEntities(m[4])
1034
+ out += escapeText ? escInline(text) : text
970
1035
  continue
971
1036
  }
972
1037
  if (!m[1]) {
@@ -988,11 +1053,11 @@ export function blockMd(html, depth = 0) {
988
1053
  const t = inlineMd(inner, depth + 1)
989
1054
  if (t) out += `\n\n${t}`
990
1055
  } else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
991
- const t = blockMd(inner, depth + 1)
992
- if (t) out += `\n\n${t}`
1056
+ const t = blockMd(inner, depth + 1, escapeText)
1057
+ if (t) out += `\n\n${t}\n\n`
993
1058
  } else if (tag === 'blockquote') {
994
- const t = blockMd(inner, depth + 1).trim()
995
- out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
1059
+ const t = blockMd(inner, depth + 1, escapeText).trim()
1060
+ out += `\n\n> ${t.replace(/\n/g, '\n> ')}\n\n`
996
1061
  } else if (tag === 'pre') {
997
1062
  // Strip wrapper tags FIRST, then decode entities — a literal &lt;div&gt;
998
1063
  // in the code must become <div> in the fenced block, but a real nested
@@ -1003,22 +1068,31 @@ export function blockMd(html, depth = 0) {
1003
1068
  const lang =
1004
1069
  /<code\b[^>]{0,1000}class=["'][^"']{0,1000}(?:language|lang)-([\w#+.-]{1,80})(?:[\s"'])/i.exec(inner)?.[1] || ''
1005
1070
  const fence = backtickFence(code, 3)
1006
- out += `\n\n${fence}${lang}\n${code}\n${fence}`
1071
+ out += `\n\n${fence}${lang}\n${code}\n${fence}\n\n`
1007
1072
  } else if (tag === 'code') {
1008
1073
  out += img.protect(inlineCode(inner))
1009
1074
  } else if (tag === 'ul' || tag === 'ol') {
1010
- // Without a single </li> nothing can pair, so the item scan is skipped.
1011
1075
  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
- }
1076
+ const itemBlocks = block ? blocks : markdownBlockPairs(inner)
1077
+ const itemOffset = block ? block.start : 0
1078
+ const liRe = /<li(?=[\s>])/gi
1079
+ let lm, idx = 1
1080
+ while ((lm = liRe.exec(inner))) {
1081
+ const item = itemBlocks.get(itemOffset + lm.index)
1082
+ if (!item) continue
1083
+ liRe.lastIndex = item.end - itemOffset
1084
+ const body = inner.slice(item.start - itemOffset, item.close - itemOffset)
1085
+ const t = /<(?:ul|ol|div|section|article|main|p|pre|blockquote|h[1-6]|table)(?=[\s>])/i.test(body)
1086
+ ? blockMd(body, depth + 1, true).replace(/\n{3,}/g, '\n\n').trim()
1087
+ : inlineMd(body, depth + 1)
1088
+ const prefix = tag === 'ol' ? `${idx++}. ` : '- '
1089
+ const indent = ' '.repeat(prefix.length)
1090
+ const lines = t.split('\n')
1091
+ items.push(/^<(?:ul|ol)(?=[\s>])/i.test(body.trimStart())
1092
+ ? prefix.trimEnd() + '\n' + lines.map(line => line ? indent + line : line).join('\n')
1093
+ : prefix + lines.map((line, i) => i && line ? indent + line : line).join('\n'))
1020
1094
  }
1021
- if (items.length) out += `\n\n${items.join('\n')}`
1095
+ if (items.length) out += `\n\n${items.join('\n')}\n\n`
1022
1096
  } else if (tag === 'li') {
1023
1097
  out += `\n- ${inlineMd(inner, depth + 1)}`
1024
1098
  } else if (tag === 'br') {
@@ -1061,7 +1135,7 @@ export function blockMd(html, depth = 0) {
1061
1135
  const t = inlineMd(m[0], depth + 1)
1062
1136
  if (t) out += t
1063
1137
  } else {
1064
- const t = blockMd(inner, depth + 1)
1138
+ const t = blockMd(inner, depth + 1, escapeText)
1065
1139
  if (t) out += t
1066
1140
  }
1067
1141
  }
@@ -1422,15 +1496,21 @@ export function smartTruncate(text, maxChars, offset = 0) {
1422
1496
  }
1423
1497
  let end = Math.min(total, start + maxChars)
1424
1498
  if (end < total && maxChars > 0) {
1499
+ // Prefer useful prose boundaries; raw HTML permits earlier boundaries
1500
+ // to avoid returning unparsed attributes.
1501
+ const rawTag = /<\/?[a-z][a-z0-9:-]{0,63}(?=[\s/>])/i.test(text.slice(start, end))
1502
+ const minEnd = start + (rawTag ? 1 : Math.ceil(maxChars / 2))
1425
1503
  const window = text.slice(start, end + 2)
1426
1504
  let boundary = window.lastIndexOf('\n\n', end - start)
1427
1505
  if (boundary >= 0) boundary += start
1428
1506
  while (boundary > start && text[boundary - 1] === '\n') boundary--
1429
- if (boundary > start) end = boundary
1507
+ if (boundary >= minEnd) end = boundary
1430
1508
  else {
1431
1509
  // Prefer the last complete sentence inside the current window.
1432
- for (let i = end - 1; i >= start; i--) {
1433
- if ('.!?\n。!?'.includes(text[i])) {
1510
+ for (let i = end - 1; i >= minEnd - 1; i--) {
1511
+ const ch = text[i]
1512
+ // Rejected paragraph separators are not sentence boundaries either.
1513
+ if ('.!?。!?'.includes(ch) || (ch === '\n' && text[i - 1] !== '\n' && text[i + 1] !== '\n')) {
1434
1514
  end = i + 1
1435
1515
  break
1436
1516
  }
@@ -1901,7 +1981,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1901
1981
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1902
1982
  if (payload.error) return failWith(payload.error)
1903
1983
  const resultUrl = payload.finalUrl || url
1904
- const truncatedHint = '提供方已截断原文,当前内容不是完整页面'
1984
+ const truncatedHint = SOURCE_TRUNCATED_HINT
1905
1985
  let sourceHint = payload.sourceTruncated ? truncatedHint : ''
1906
1986
 
1907
1987
  if (payload.kind === 'text') {
@@ -2197,6 +2277,7 @@ function readLinksTool(ctx, cfg) {
2197
2277
  const page = await fetchResource(ctx, url, signal, cfg, route)
2198
2278
  if (signal && signal.aborted) return { error: 'cancelled' }
2199
2279
  if (page.error) return { error: page.error }
2280
+ if (page.kind === 'text') return { url: page.finalUrl || url, count: 0, links: [], spaHint: providerTextHint(page) }
2200
2281
  let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2201
2282
  let finalUrl = page.finalUrl || url
2202
2283
  // Same meta-refresh shell following as read_url: a link hop serves a
@@ -2205,6 +2286,7 @@ function readLinksTool(ctx, cfg) {
2205
2286
  if (signal && signal.aborted) return { error: 'cancelled' }
2206
2287
  html = followed.html
2207
2288
  finalUrl = followed.finalUrl
2289
+ let sourceTruncated = followed.sourceTruncated ?? page.sourceTruncated
2208
2290
  let links = extractLinks(html, limit, finalUrl)
2209
2291
  // SPA fallback: a JS-only page yields no links from its static HTML;
2210
2292
  // render it and re-extract when the static result looks empty. Any
@@ -2221,10 +2303,11 @@ function readLinksTool(ctx, cfg) {
2221
2303
  if (rl.length > links.length) {
2222
2304
  links = rl
2223
2305
  finalUrl = rr.finalUrl || finalUrl
2306
+ sourceTruncated = false
2224
2307
  }
2225
2308
  }
2226
2309
  }
2227
- const spaHint = [route.hint, route.restricted && needsRender ? DIRECT_SPA_HINT : ''].filter(Boolean).join(';')
2310
+ const spaHint = [sourceTruncated ? SOURCE_TRUNCATED_HINT : '', route.hint, route.restricted && needsRender ? DIRECT_SPA_HINT : ''].filter(Boolean).join(';')
2228
2311
  return { url: finalUrl, count: links.length, links, ...(spaHint ? { spaHint } : {}) }
2229
2312
  },
2230
2313
  }
@@ -2422,6 +2505,14 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2422
2505
  if (depth > 0 && !sameHost(pageUrl, host)) {
2423
2506
  return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
2424
2507
  }
2508
+ if (page.kind === 'text') {
2509
+ if (depth === 0) host = hostOf(pageUrl)
2510
+ return {
2511
+ url: pageUrl, depth, title: '', chars: html.length,
2512
+ text: includeContent ? smartTruncate(html, perMax).text : undefined,
2513
+ links: [], route, spaHint: providerTextHint(page),
2514
+ }
2515
+ }
2425
2516
  // Same shell-following as read_url: a meta-refresh stub would otherwise
2426
2517
  // be crawled AS the page (title-less hop text pollutes results and the
2427
2518
  // real content is never reached). Fails open — fetch problems keep the
@@ -2430,12 +2521,14 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2430
2521
  if (externalSignal && externalSignal.aborted) return null
2431
2522
  html = followed.html
2432
2523
  pageUrl = followed.finalUrl
2524
+ const sourceTruncated = followed.sourceTruncated ?? page.sourceTruncated
2433
2525
  if (depth === 0) host = hostOf(pageUrl)
2434
2526
  else if (!sameHost(pageUrl, host)) {
2435
2527
  return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
2436
2528
  }
2437
2529
  const ex = extract(html, 'text')
2438
2530
  const links = extractLinks(html, 100, pageUrl)
2531
+ const spaHint = [sourceTruncated ? SOURCE_TRUNCATED_HINT : '', route.hint].filter(Boolean).join(';')
2439
2532
  return {
2440
2533
  url: pageUrl,
2441
2534
  depth,
@@ -2444,7 +2537,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2444
2537
  text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
2445
2538
  links,
2446
2539
  route,
2447
- ...(route.hint ? { spaHint: route.hint } : {}),
2540
+ ...(spaHint ? { spaHint } : {}),
2448
2541
  }
2449
2542
  }),
2450
2543
  )
@@ -2574,7 +2667,7 @@ export function apply(ctx, config) {
2574
2667
  const cfg = { ...DEFAULTS, ...(config || {}) }
2575
2668
  const clamp = (v, d, min, max) => {
2576
2669
  const n = Number(v)
2577
- return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
2670
+ return Number.isFinite(n) ? Math.floor(Math.min(max, Math.max(min, n))) : d
2578
2671
  }
2579
2672
  cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
2580
2673
  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.2",
3
+ "version": "1.9.4",
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,21 +1,17 @@
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'
17
12
  import { existsSync } from 'node:fs'
18
13
  import { win32 } from 'node:path'
14
+ import { BlockList, isIP } from 'node:net'
19
15
 
20
16
  // Desktop launches can inherit a reduced PATH. Prefer the Windows-shipped
21
17
  // utilities when present; other platforms and custom installations use PATH.
@@ -76,8 +72,7 @@ const SYS_PROXY_RETRY_MS = 60 * 1000
76
72
 
77
73
  // Windows system proxy from the registry (HKCU Internet Settings). PowerShell
78
74
  // is used because `reg.exe` is frequently blocked by security policy; the
79
- // read is synchronous and cached after the first call (it only runs after a
80
- // 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.
81
76
  // A FAILED read is cached as '' but re-tried after 60s — an environment that
82
77
  // transiently blocks the PowerShell child (EDR, policy) should not starve
83
78
  // the whole session of the proxy.
@@ -97,9 +92,8 @@ function readWindowsSystemProxy() {
97
92
  )
98
93
  const [enable, server] = out.trim().split(/\r?\n/).map((s) => s.trim())
99
94
  if (enable !== '1' || !server) return sysProxyCache
100
- // ProxyServer may be "host:port" or "http=...;https=host:port"
101
- const httpsPart = server.split(';').map((s) => s.trim()).find((s) => /^https=/i.test(s))
102
- sysProxyCache = (httpsPart ? httpsPart.replace(/^https=/i, '') : server) || ''
95
+ // Cache the complete map so HTTP and HTTPS share discovery, not selection.
96
+ sysProxyCache = server
103
97
  return sysProxyCache
104
98
  } catch {
105
99
  sysProxyCache = ''
@@ -107,15 +101,90 @@ function readWindowsSystemProxy() {
107
101
  }
108
102
  }
109
103
 
104
+ // Match curl's domain/IP exclusions before starting a redundant direct curl
105
+ // request. Port-qualified hosts and wildcard suffixes are also accepted.
106
+ function bypassesProxy(url) {
107
+ const exclusions = process.env.no_proxy || process.env.NO_PROXY
108
+ if (!url || !exclusions) return false
109
+ let target
110
+ try { target = new URL(url) } catch { return false }
111
+ const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
112
+ const ipVersion = isIP(hostname)
113
+ const port = target.port || (target.protocol === 'https:' ? '443' : '80')
114
+ for (let entry of exclusions.split(',')) {
115
+ entry = entry.trim().toLowerCase()
116
+ if (!entry) continue
117
+ if (entry === '*') return true
118
+ const slash = entry.indexOf('/')
119
+ if (slash >= 0) {
120
+ const address = entry.slice(0, slash)
121
+ const prefix = entry.slice(slash + 1)
122
+ if (!ipVersion || isIP(address) !== ipVersion || !/^\d{1,3}$/.test(prefix)) continue
123
+ try {
124
+ const block = new BlockList()
125
+ const family = ipVersion === 6 ? 'ipv6' : 'ipv4'
126
+ block.addSubnet(address, Number(prefix), family)
127
+ if (block.check(hostname, family)) return true
128
+ } catch {}
129
+ continue
130
+ }
131
+ let entryPort = ''
132
+ if (entry.startsWith('[')) {
133
+ const end = entry.indexOf(']')
134
+ if (end < 0) continue
135
+ const tail = entry.slice(end + 1)
136
+ if (tail && !/^:\d{1,5}$/.test(tail)) continue
137
+ entryPort = tail.slice(1)
138
+ entry = entry.slice(1, end)
139
+ } else if (!isIP(entry) && entry.indexOf(':') >= 0) {
140
+ const colon = entry.lastIndexOf(':')
141
+ entryPort = entry.slice(colon + 1)
142
+ entry = entry.slice(0, colon)
143
+ if (entry.includes(':') || !/^\d{1,5}$/.test(entryPort)) continue
144
+ }
145
+ if (entryPort && Number(entryPort) !== Number(port)) continue
146
+ if (isIP(entry)) {
147
+ if (!ipVersion) continue
148
+ try {
149
+ const canonical = new URL(isIP(entry) === 6 ? `http://[${entry}]` : `http://${entry}`).hostname
150
+ .replace(/^\[|\]$/g, '')
151
+ if (canonical === hostname) return true
152
+ } catch {}
153
+ continue
154
+ }
155
+ if (ipVersion) continue
156
+ if (entry.startsWith('*.')) {
157
+ if (hostname.endsWith(entry.slice(1).replace(/\.$/, ''))) return true
158
+ continue
159
+ }
160
+ else if (entry.startsWith('.')) entry = entry.slice(1)
161
+ entry = entry.replace(/\.$/, '')
162
+ if (entry && (hostname === entry || hostname.endsWith(`.${entry}`))) return true
163
+ }
164
+ return false
165
+ }
166
+
110
167
  // The user's proxy, in priority order: env vars, then Windows system proxy.
111
- export async function detectProxy() {
112
- const env =
113
- process.env.HTTPS_PROXY ||
114
- process.env.https_proxy ||
115
- process.env.HTTP_PROXY ||
116
- process.env.http_proxy
168
+ export async function detectProxy(url) {
169
+ if (bypassesProxy(url)) return ''
170
+ let protocol = ''
171
+ try { protocol = new URL(url).protocol.slice(0, -1) } catch {}
172
+ const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy
173
+ const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy
174
+ // Prefer the requested protocol; retain a sole configured proxy as fallback.
175
+ const env = protocol === 'http' ? httpProxy || httpsProxy : httpsProxy || httpProxy
117
176
  if (env) return env
118
- if (process.platform === 'win32') return readWindowsSystemProxy()
177
+ if (process.platform === 'win32') {
178
+ const server = readWindowsSystemProxy()
179
+ const entries = server.split(';').map(entry => entry.trim())
180
+ if (!entries.some(entry => /^[a-z]{1,16}\s{0,16}=/i.test(entry))) return server
181
+ const proxies = new Map()
182
+ for (const entry of entries) {
183
+ const equal = entry.indexOf('=')
184
+ if (equal > 0) proxies.set(entry.slice(0, equal).trim().toLowerCase(), entry.slice(equal + 1).trim())
185
+ }
186
+ return protocol ? proxies.get(protocol) || '' : proxies.get('https') || proxies.get('http') || ''
187
+ }
119
188
  return ''
120
189
  }
121
190
 
@@ -125,11 +194,14 @@ export async function detectProxy() {
125
194
  // `proxyOverride` lets the caller pass an already-detected proxy (the race
126
195
  // path probes once up front) instead of re-detecting here.
127
196
  export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride) {
128
- const proxy = proxyOverride || (await detectProxy())
197
+ if (bypassesProxy(url)) return null
198
+ const proxy = proxyOverride || (await detectProxy(url))
129
199
  if (!proxy) return null
130
200
  const maxSec = Math.max(10, Math.ceil(cfg.timeoutMs / 1000) + 5)
131
201
  const args = [
132
202
  '-sS',
203
+ '--globoff',
204
+ '--compressed', // request and decode supported HTTP content encodings
133
205
  '-L', // follow redirects, like the direct fetch path
134
206
  '--max-time', String(maxSec),
135
207
  '--max-filesize', String(cfg.maxBytes), // unit is BYTES, not KB
@@ -154,6 +226,9 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
154
226
  if (nl < 0) return null // no trailing metadata — abnormal response, treat as failure
155
227
  const meta = buf.subarray(nl + 1).toString('latin1').trim()
156
228
  const body = buf.subarray(0, nl)
229
+ // Older curl versions bound the compressed transfer, not the decoded
230
+ // body. Do not let the metadata allowance enlarge the content budget.
231
+ if (body.length > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
157
232
  const parts = meta.split(' ')
158
233
  const codeStr = parts.shift()
159
234
  const finalUrlEff = parts.length > 1 ? parts.pop() : url
@@ -174,7 +249,9 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
174
249
  } catch (err) {
175
250
  // curl error 63 = exceeded --max-filesize (page too large, not a proxy
176
251
  // problem — report it as such instead of "proxy unreachable")
177
- if (err && err.code === 63) return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
252
+ if (err && (err.code === 63 || (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && err.message.startsWith('stdout')))) {
253
+ return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
254
+ }
178
255
  // curl missing, proxy down, or cancelled — let the caller report the
179
256
  // original direct-connect error (the proxy attempt is best-effort)
180
257
  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)