dsh-read-url 1.9.2 → 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 +23 -0
- package/README.md +12 -1
- package/README.zh.md +12 -1
- package/index.js +104 -22
- package/package.json +1 -1
- package/proxy-fallback.js +85 -18
- package/spa.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
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
|
+
|
|
3
26
|
## [1.9.2] - 2026-10-03
|
|
4
27
|
|
|
5
28
|
### 修复
|
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 |
|
|
@@ -339,6 +341,15 @@ 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
|
+
|
|
342
353
|
## Roadmap
|
|
343
354
|
|
|
344
355
|
- [x] Single-page continuation (`offset` parameter)
|
package/README.zh.md
CHANGED
|
@@ -22,10 +22,12 @@ DeepSeek Harness 的 URL 阅读插件:抓取任意 URL——**网页(HTML)
|
|
|
22
22
|
|
|
23
23
|
## 为什么做它
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
本插件控制 URL 阅读的输出量:从 HTML 提取**净化后的正文 + 必要元数据**,提供段落截断、续读和会话缓存;也保留 DSH 配置的提供方返回的纯文本或 Markdown。DSH 0.2.1-rc.2 已包含 DeepSeek Fetch API 提供方,可获得的原文和元数据取决于所选提供方。
|
|
26
26
|
|
|
27
27
|
### 同类插件对比(2026-08-15 实测源码/文档)
|
|
28
28
|
|
|
29
|
+
以下是历史快照,不代表新版 DeepSeek Fetch API 提供方的行为。
|
|
30
|
+
|
|
29
31
|
| 能力 | 官方 `tool-web` web_fetch | dsh-webfetch | dsh-scrape-webpage | **dsh-read-url** |
|
|
30
32
|
|---|---|---|---|---|
|
|
31
33
|
| 正文净化(容器级提取) | ❌ 整页渲染 | ⚠️ 标签级去噪,nav/footer 仍混入 | ⚠️ 自研,含噪音 | ✅ article/main 容器 + 噪音剥离 |
|
|
@@ -339,6 +341,15 @@ 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
|
+
|
|
342
353
|
## Roadmap
|
|
343
354
|
|
|
344
355
|
- [x] 单页多段续读(`offset` 参数)
|
package/index.js
CHANGED
|
@@ -100,6 +100,10 @@ function allowedDirectUrl(url, origins) {
|
|
|
100
100
|
const DIRECT_BOUNDARY_ERROR = 'Direct fetch stopped: target is outside directFetchOrigins or contains credentials'
|
|
101
101
|
const DIRECT_SPA_HINT = '已配置直连仅支持静态读取,未启用浏览器渲染'
|
|
102
102
|
|
|
103
|
+
function providerTextHint(page) {
|
|
104
|
+
return '提供方返回纯文本,未扫描 HTML 链接或跳转' + (page.sourceTruncated ? ';提供方已截断原文,当前内容不是完整页面' : '')
|
|
105
|
+
}
|
|
106
|
+
|
|
103
107
|
function getDecoder(enc) {
|
|
104
108
|
let d = decoders.get(enc)
|
|
105
109
|
if (!d) {
|
|
@@ -328,7 +332,7 @@ async function raceFetch(url, externalSignal, cfg, proxy) {
|
|
|
328
332
|
|
|
329
333
|
async function fetchPage(url, externalSignal, cfg) {
|
|
330
334
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
331
|
-
const proxy = await detectProxy()
|
|
335
|
+
const proxy = await detectProxy(url)
|
|
332
336
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
333
337
|
if (proxy) {
|
|
334
338
|
const winner = await raceFetch(url, externalSignal, cfg, proxy)
|
|
@@ -336,7 +340,7 @@ async function fetchPage(url, externalSignal, cfg) {
|
|
|
336
340
|
const dErr = winner.failures.find((f) => f.label === 'direct')
|
|
337
341
|
const pErr = winner.failures.find((f) => f.label === 'proxy')
|
|
338
342
|
const px = pErr ? `,代理返回 ${pErr.error}` : ',代理未连通'
|
|
339
|
-
return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}
|
|
343
|
+
return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}(直连失败;已尝试代理${px})` }
|
|
340
344
|
}
|
|
341
345
|
// No proxy configured: plain direct connect (the original behaviour).
|
|
342
346
|
const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
|
|
@@ -955,18 +959,78 @@ export function inlineMd(html, depth = 0) {
|
|
|
955
959
|
return img.restore(out.trim())
|
|
956
960
|
}
|
|
957
961
|
|
|
958
|
-
|
|
962
|
+
// Pair structural blocks in one forward pass so nested siblings keep their
|
|
963
|
+
// own boundaries. Raw/code bodies and quoted attribute values are opaque.
|
|
964
|
+
function markdownBlockPairs(html) {
|
|
965
|
+
const pairs = new Map()
|
|
966
|
+
const stacks = new Map()
|
|
967
|
+
const structural = new Set(['div', 'section', 'article', 'main', 'blockquote', 'ul', 'ol', 'li'])
|
|
968
|
+
const opaque = new Set(['pre', 'code', 'script', 'style', 'textarea', 'template', 'noscript'])
|
|
969
|
+
const re = /<!--|<(\/?)([a-z][a-z0-9:-]{0,63})(?=[\s/>])/gi
|
|
970
|
+
let m
|
|
971
|
+
while ((m = re.exec(html))) {
|
|
972
|
+
if (!m[2]) {
|
|
973
|
+
const end = html.indexOf('-->', re.lastIndex)
|
|
974
|
+
if (end < 0) break
|
|
975
|
+
re.lastIndex = end + 3
|
|
976
|
+
continue
|
|
977
|
+
}
|
|
978
|
+
let quote = '', end = -1
|
|
979
|
+
const limit = Math.min(html.length, m.index + 1002)
|
|
980
|
+
for (let i = re.lastIndex; i < limit; i++) {
|
|
981
|
+
const ch = html[i]
|
|
982
|
+
if (quote) {
|
|
983
|
+
if (ch === quote) quote = ''
|
|
984
|
+
} else if (ch === '"' || ch === "'") quote = ch
|
|
985
|
+
else if (ch === '<') break
|
|
986
|
+
else if (ch === '>') { end = i + 1; break }
|
|
987
|
+
}
|
|
988
|
+
if (end < 0) continue
|
|
989
|
+
const attrs = html.slice(re.lastIndex, end - 1)
|
|
990
|
+
re.lastIndex = end
|
|
991
|
+
const tag = m[2].toLowerCase()
|
|
992
|
+
if (!m[1] && opaque.has(tag)) {
|
|
993
|
+
const close = new RegExp(`</${tag}\\s{0,1000}>`, 'gi')
|
|
994
|
+
close.lastIndex = re.lastIndex
|
|
995
|
+
if (!close.exec(html)) break
|
|
996
|
+
re.lastIndex = close.lastIndex
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
999
|
+
if (!structural.has(tag)) continue
|
|
1000
|
+
if (!m[1]) {
|
|
1001
|
+
if (!stacks.has(tag)) stacks.set(tag, [])
|
|
1002
|
+
stacks.get(tag).push({ tag, attrs, index: m.index, start: re.lastIndex })
|
|
1003
|
+
} else if (!attrs.trim()) {
|
|
1004
|
+
const opening = stacks.get(tag)?.pop()
|
|
1005
|
+
if (opening && m.index - opening.start <= 3145728) pairs.set(opening.index, { ...opening, close: m.index, end: re.lastIndex })
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return pairs
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function blockMd(html, depth = 0, escapeText = false) {
|
|
959
1012
|
if (depth > MD_MAX_DEPTH) {
|
|
960
|
-
|
|
1013
|
+
const text = decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
1014
|
+
return escapeText ? escInline(text) : text
|
|
961
1015
|
}
|
|
962
1016
|
const img = imgsToMarkdown(html)
|
|
963
1017
|
html = stripUnmatchedOpeners(img.html)
|
|
1018
|
+
const blocks = markdownBlockPairs(html)
|
|
964
1019
|
let out = ''
|
|
965
1020
|
const re = /<(?:br|hr)\b[^>]{0,1000}>|<([a-zA-Z0-9]{1,100})((?:"[^"]{0,1000}"|'[^']{0,1000}'|[^'">]){0,1000})>([\s\S]{0,3145728}?)<\/\1>|<[^>]{1,1000}>|([^<]{1,3145728})/gi
|
|
966
1021
|
let m
|
|
967
1022
|
while ((m = re.exec(html))) {
|
|
1023
|
+
const block = blocks.get(m.index)
|
|
1024
|
+
if (block) {
|
|
1025
|
+
m[0] = html.slice(m.index, block.end)
|
|
1026
|
+
m[1] = block.tag
|
|
1027
|
+
m[2] = block.attrs
|
|
1028
|
+
m[3] = html.slice(block.start, block.close)
|
|
1029
|
+
re.lastIndex = block.end
|
|
1030
|
+
}
|
|
968
1031
|
if (m[4] !== undefined) {
|
|
969
|
-
|
|
1032
|
+
const text = decodeTextEntities(m[4])
|
|
1033
|
+
out += escapeText ? escInline(text) : text
|
|
970
1034
|
continue
|
|
971
1035
|
}
|
|
972
1036
|
if (!m[1]) {
|
|
@@ -988,11 +1052,11 @@ export function blockMd(html, depth = 0) {
|
|
|
988
1052
|
const t = inlineMd(inner, depth + 1)
|
|
989
1053
|
if (t) out += `\n\n${t}`
|
|
990
1054
|
} else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
|
|
991
|
-
const t = blockMd(inner, depth + 1)
|
|
992
|
-
if (t) out += `\n\n${t}`
|
|
1055
|
+
const t = blockMd(inner, depth + 1, escapeText)
|
|
1056
|
+
if (t) out += `\n\n${t}\n\n`
|
|
993
1057
|
} else if (tag === 'blockquote') {
|
|
994
|
-
const t = blockMd(inner, depth + 1).trim()
|
|
995
|
-
out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
|
|
1058
|
+
const t = blockMd(inner, depth + 1, escapeText).trim()
|
|
1059
|
+
out += `\n\n> ${t.replace(/\n/g, '\n> ')}\n\n`
|
|
996
1060
|
} else if (tag === 'pre') {
|
|
997
1061
|
// Strip wrapper tags FIRST, then decode entities — a literal <div>
|
|
998
1062
|
// in the code must become <div> in the fenced block, but a real nested
|
|
@@ -1003,22 +1067,31 @@ export function blockMd(html, depth = 0) {
|
|
|
1003
1067
|
const lang =
|
|
1004
1068
|
/<code\b[^>]{0,1000}class=["'][^"']{0,1000}(?:language|lang)-([\w#+.-]{1,80})(?:[\s"'])/i.exec(inner)?.[1] || ''
|
|
1005
1069
|
const fence = backtickFence(code, 3)
|
|
1006
|
-
out += `\n\n${fence}${lang}\n${code}\n${fence}`
|
|
1070
|
+
out += `\n\n${fence}${lang}\n${code}\n${fence}\n\n`
|
|
1007
1071
|
} else if (tag === 'code') {
|
|
1008
1072
|
out += img.protect(inlineCode(inner))
|
|
1009
1073
|
} else if (tag === 'ul' || tag === 'ol') {
|
|
1010
|
-
// Without a single </li> nothing can pair, so the item scan is skipped.
|
|
1011
1074
|
const items = []
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1075
|
+
const itemBlocks = block ? blocks : markdownBlockPairs(inner)
|
|
1076
|
+
const itemOffset = block ? block.start : 0
|
|
1077
|
+
const liRe = /<li(?=[\s>])/gi
|
|
1078
|
+
let lm, idx = 1
|
|
1079
|
+
while ((lm = liRe.exec(inner))) {
|
|
1080
|
+
const item = itemBlocks.get(itemOffset + lm.index)
|
|
1081
|
+
if (!item) continue
|
|
1082
|
+
liRe.lastIndex = item.end - itemOffset
|
|
1083
|
+
const body = inner.slice(item.start - itemOffset, item.close - itemOffset)
|
|
1084
|
+
const t = /<(?:ul|ol|div|section|article|main|p|pre|blockquote|h[1-6]|table)(?=[\s>])/i.test(body)
|
|
1085
|
+
? blockMd(body, depth + 1, true).replace(/\n{3,}/g, '\n\n').trim()
|
|
1086
|
+
: inlineMd(body, depth + 1)
|
|
1087
|
+
const prefix = tag === 'ol' ? `${idx++}. ` : '- '
|
|
1088
|
+
const indent = ' '.repeat(prefix.length)
|
|
1089
|
+
const lines = t.split('\n')
|
|
1090
|
+
items.push(/^<(?:ul|ol)(?=[\s>])/i.test(body.trimStart())
|
|
1091
|
+
? prefix.trimEnd() + '\n' + lines.map(line => line ? indent + line : line).join('\n')
|
|
1092
|
+
: prefix + lines.map((line, i) => i && line ? indent + line : line).join('\n'))
|
|
1020
1093
|
}
|
|
1021
|
-
if (items.length) out += `\n\n${items.join('\n')}`
|
|
1094
|
+
if (items.length) out += `\n\n${items.join('\n')}\n\n`
|
|
1022
1095
|
} else if (tag === 'li') {
|
|
1023
1096
|
out += `\n- ${inlineMd(inner, depth + 1)}`
|
|
1024
1097
|
} else if (tag === 'br') {
|
|
@@ -1061,7 +1134,7 @@ export function blockMd(html, depth = 0) {
|
|
|
1061
1134
|
const t = inlineMd(m[0], depth + 1)
|
|
1062
1135
|
if (t) out += t
|
|
1063
1136
|
} else {
|
|
1064
|
-
const t = blockMd(inner, depth + 1)
|
|
1137
|
+
const t = blockMd(inner, depth + 1, escapeText)
|
|
1065
1138
|
if (t) out += t
|
|
1066
1139
|
}
|
|
1067
1140
|
}
|
|
@@ -2197,6 +2270,7 @@ function readLinksTool(ctx, cfg) {
|
|
|
2197
2270
|
const page = await fetchResource(ctx, url, signal, cfg, route)
|
|
2198
2271
|
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2199
2272
|
if (page.error) return { error: page.error }
|
|
2273
|
+
if (page.kind === 'text') return { url: page.finalUrl || url, count: 0, links: [], spaHint: providerTextHint(page) }
|
|
2200
2274
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2201
2275
|
let finalUrl = page.finalUrl || url
|
|
2202
2276
|
// Same meta-refresh shell following as read_url: a link hop serves a
|
|
@@ -2422,6 +2496,14 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2422
2496
|
if (depth > 0 && !sameHost(pageUrl, host)) {
|
|
2423
2497
|
return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
|
|
2424
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
|
+
}
|
|
2425
2507
|
// Same shell-following as read_url: a meta-refresh stub would otherwise
|
|
2426
2508
|
// be crawled AS the page (title-less hop text pollutes results and the
|
|
2427
2509
|
// real content is never reached). Fails open — fetch problems keep the
|
|
@@ -2574,7 +2656,7 @@ export function apply(ctx, config) {
|
|
|
2574
2656
|
const cfg = { ...DEFAULTS, ...(config || {}) }
|
|
2575
2657
|
const clamp = (v, d, min, max) => {
|
|
2576
2658
|
const n = Number(v)
|
|
2577
|
-
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
|
|
2578
2660
|
}
|
|
2579
2661
|
cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
|
|
2580
2662
|
cfg.maxBytes = clamp(cfg.maxBytes, DEFAULTS.maxBytes, 65536, 16 * 1024 * 1024)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "DeepSeek Harness URL reader: fetch any webpage (HTML/JSON/RSS/Atom), auto-detect encoding (UTF-16 BOM/GBK/Big5/Shift-JIS), extract clean main content with auto-pagination, output compact text/Markdown to save tokens. Zero runtime dependencies, no API key, drop-in. Follows meta-refresh shells, reads JSON-LD metadata, honors base href.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/proxy-fallback.js
CHANGED
|
@@ -1,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.
|
|
@@ -107,8 +102,72 @@ function readWindowsSystemProxy() {
|
|
|
107
102
|
}
|
|
108
103
|
}
|
|
109
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
|
+
|
|
110
168
|
// The user's proxy, in priority order: env vars, then Windows system proxy.
|
|
111
|
-
export async function detectProxy() {
|
|
169
|
+
export async function detectProxy(url) {
|
|
170
|
+
if (bypassesProxy(url)) return ''
|
|
112
171
|
const env =
|
|
113
172
|
process.env.HTTPS_PROXY ||
|
|
114
173
|
process.env.https_proxy ||
|
|
@@ -125,11 +184,14 @@ export async function detectProxy() {
|
|
|
125
184
|
// `proxyOverride` lets the caller pass an already-detected proxy (the race
|
|
126
185
|
// path probes once up front) instead of re-detecting here.
|
|
127
186
|
export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride) {
|
|
128
|
-
|
|
187
|
+
if (bypassesProxy(url)) return null
|
|
188
|
+
const proxy = proxyOverride || (await detectProxy(url))
|
|
129
189
|
if (!proxy) return null
|
|
130
190
|
const maxSec = Math.max(10, Math.ceil(cfg.timeoutMs / 1000) + 5)
|
|
131
191
|
const args = [
|
|
132
192
|
'-sS',
|
|
193
|
+
'--globoff',
|
|
194
|
+
'--compressed', // request and decode supported HTTP content encodings
|
|
133
195
|
'-L', // follow redirects, like the direct fetch path
|
|
134
196
|
'--max-time', String(maxSec),
|
|
135
197
|
'--max-filesize', String(cfg.maxBytes), // unit is BYTES, not KB
|
|
@@ -154,6 +216,9 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
154
216
|
if (nl < 0) return null // no trailing metadata — abnormal response, treat as failure
|
|
155
217
|
const meta = buf.subarray(nl + 1).toString('latin1').trim()
|
|
156
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)` }
|
|
157
222
|
const parts = meta.split(' ')
|
|
158
223
|
const codeStr = parts.shift()
|
|
159
224
|
const finalUrlEff = parts.length > 1 ? parts.pop() : url
|
|
@@ -174,7 +239,9 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
174
239
|
} catch (err) {
|
|
175
240
|
// curl error 63 = exceeded --max-filesize (page too large, not a proxy
|
|
176
241
|
// problem — report it as such instead of "proxy unreachable")
|
|
177
|
-
if (err && err.code === 63
|
|
242
|
+
if (err && (err.code === 63 || (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && err.message.startsWith('stdout')))) {
|
|
243
|
+
return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
|
|
244
|
+
}
|
|
178
245
|
// curl missing, proxy down, or cancelled — let the caller report the
|
|
179
246
|
// original direct-connect error (the proxy attempt is best-effort)
|
|
180
247
|
return null
|
package/spa.js
CHANGED
|
@@ -28,7 +28,7 @@ export function looksLikeSpa(html) {
|
|
|
28
28
|
// stabilizes while the challenge sits in "Verification successful. Waiting
|
|
29
29
|
// for ... to respond" — the poll loop returned that transitional page and
|
|
30
30
|
// the caller mistook it for an improvement over static extraction.
|
|
31
|
-
const CHALLENGE_RE = /just a moment|performing security verification|verifying (you are|your) human|cf-chl|cf_chl|cdn-cgi\/challenge-platform|attention required!\s
|
|
31
|
+
const CHALLENGE_RE = /just a moment|performing security verification|verifying (you are|your) human|cf-chl|cf_chl|cdn-cgi\/challenge-platform|attention required!\s{0,1000}(?:[|·]\s{0,1000})?cloudflare|enable javascript and cookies to continue/i
|
|
32
32
|
|
|
33
33
|
export function looksLikeChallenge(html) {
|
|
34
34
|
return typeof html === 'string' && CHALLENGE_RE.test(html)
|