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 +42 -0
- package/README.md +21 -2
- package/README.zh.md +21 -2
- package/index.js +121 -28
- package/package.json +1 -1
- package/proxy-fallback.js +104 -27
- package/spa.js +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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'}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <div>
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
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 >=
|
|
1433
|
-
|
|
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
|
-
...(
|
|
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.
|
|
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 —
|
|
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'
|
|
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
|
|
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
|
-
//
|
|
101
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|