dsh-read-url 1.9.0 → 1.9.2
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 +35 -0
- package/README.md +25 -1
- package/README.zh.md +25 -1
- package/index.js +136 -64
- package/package.json +1 -1
- package/proxy-fallback.js +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.2] - 2026-10-03
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- Windows 代理回退优先使用系统目录中的 PowerShell/curl,避免桌面进程的精简 PATH 导致代理发现或请求失败;系统工具不可用时保留原有回退与取消行为。
|
|
8
|
+
- JSON-LD 按真实 `type` 属性读取,支持等号空格、无引号值及引号内的 `>`;忽略 `data-type`、注释、普通脚本及 raw-text 元素中的伪 JSON-LD。
|
|
9
|
+
- JSON-LD 作者解析限制递归深度,正文数组仅拼接字符串项,避免深层数组触发栈溢出或非文本值污染正文;保留标签长度、JSON 大小及递归上限。
|
|
10
|
+
- 双语安装说明补充桌面版插件入口、独立 profile、内置运行时及本地链接代码重启加载方式。
|
|
11
|
+
|
|
12
|
+
### 验证
|
|
13
|
+
|
|
14
|
+
- 375 个零依赖单元断言、15 个真实 Chromium SPA 断言、64 项对抗探针通过;新增 23 条单元检查和 5 条 JSON-LD 探针。JSON-LD 原始缺陷在 v1.9.0/v1.9.1 同输入均复现;Windows 空 PATH 的真实代理请求对照 v1.9.1 确认修复。
|
|
15
|
+
- DSH 桌面版 0.2.0-rc.2 的实际 Electron 44.0.0 / Node 24.18.1 通过 17 项官方服务集成、3 项工具作用域、2 项本地代理 A/B 及 15 项 Chromium SPA 检查。极简 preset 继承全局插件工具;服务响应使用受控数据,未覆盖完整模型会话。
|
|
16
|
+
- DSH 0.1.7-rc.2 → 0.2.0-rc.2 的 8 个接触包与 4 个桌面加载链文件完成字节差异审计,并交叉核对社区 0.2.0-rc.1 升级卡;已核查接口无需适配修改。0.2.1-alpha.1 未审计。
|
|
17
|
+
- 2026-10-03 的 152 站回归:104 OK / 20 THIN / 4 EMPTY / 24 ERR / 0 THREW,OK 站零噪声。已知 NOISE 与状态波动站使用相同 HTML/DOM 和可选依赖对照 v1.9.1,全文续读与 800 字输出一致;新增订阅站 HTTP 403 在新旧版本均复现。
|
|
18
|
+
- 核心仍为零运行时依赖;四工具 description/schema 为 1,159/1,876 字符。
|
|
19
|
+
|
|
20
|
+
## [1.9.1] - 2026-09-28
|
|
21
|
+
|
|
22
|
+
### 修复
|
|
23
|
+
|
|
24
|
+
- 链接、分页、base 与 meta-refresh 按真实属性读取,支持等号空格和合法无引号属性;忽略 data-href/data-rel 等伪属性,识别 rel 中的 next 标记。
|
|
25
|
+
- meta-refresh 正确解码实体并保留 URL 中的分号及查询参数,避免跳转地址被截断。
|
|
26
|
+
- 有界标签扫描识别引号内的 `>`,空 href 不吞掉后续锚点;锚点正文单向扫描,避免损坏 HTML 触发反复搜索。
|
|
27
|
+
- 链接扫描与站点爬取在取消后返回 cancelled,避免返回旧跳转壳或部分结果作为完整成功;Retry-After 等待结束或取消时清理监听与计时器。
|
|
28
|
+
- 站点正文摘要按 maxCharsPerPage 显示,取消渲染层额外的 80 字符截断;默认仍只输出站点结构。
|
|
29
|
+
- 对抗探针等待异步检查完成,异常设置非零退出码,避免未完成的 Promise 被打印为通过。
|
|
30
|
+
|
|
31
|
+
### 验证
|
|
32
|
+
|
|
33
|
+
- 352 个零依赖单元断言、15 个真实 Chromium SPA 断言、59 项对抗探针通过;新增 30 条单元检查与 6 条未闭合锚点探针。原始维护缺陷在 v1.8.1/v1.9.0 同输入均可复现,属于既有问题。
|
|
34
|
+
- 152 站最终实网回归:103 OK / 20 THIN / 5 EMPTY / 24 ERR / 0 THREW,OK 站零噪声。Vue、React、酷狗及 Hatena 同 HTML 的全文续读与 800 字输出对照 v1.9.0 一致;微博动态波动经同 HTML/DOM 对照一致,错误站点集合未变。
|
|
35
|
+
- DSH 0.1.7-rc.2 官方包字节差异及社区升级卡(截至 rc.1)交叉核查完成;v1.9.0 和当前维护代码各通过 17 项真实官方服务集成检查,发布版 patch 另通过 4 项真实加载器检查。已核查接口无需适配修改;受控 provider 检查不等同于完整宿主 UI/模型端到端验证。
|
|
36
|
+
- 未闭合且含 href 的锚点 2.5 万/5 万/10 万输入本机约 27/57/118 ms;保留有界属性策略与零运行时依赖。四工具 description/schema 仍为 1,159/1,876 字符。
|
|
37
|
+
|
|
3
38
|
## [1.9.0] - 2026-09-19
|
|
4
39
|
|
|
5
40
|
### 新增
|
package/README.md
CHANGED
|
@@ -55,8 +55,16 @@ Implemented per official docs (`docs/capability-seams.md`, `docs/cordis-primer.m
|
|
|
55
55
|
|
|
56
56
|
## Install
|
|
57
57
|
|
|
58
|
+
### DSH Desktop
|
|
59
|
+
|
|
60
|
+
Open **Plugins → Add plugin**, enter `dsh-read-url` (npm package), the GitHub repository URL, or the local project directory, and install it. In the plugin details, check the displayed version and that the component is **running**.
|
|
61
|
+
|
|
62
|
+
Desktop uses its own `desktop` profile and bundled DSH runtime. CLI profile installations and global CLI upgrades are separate from Desktop. When configuring this plugin manually, use the Desktop profile's `cordis.patch.yml`. A local directory installation links to that source directory; restart Desktop after changing its code to load a fresh module.
|
|
63
|
+
|
|
64
|
+
### CLI / local development
|
|
65
|
+
|
|
58
66
|
```bash
|
|
59
|
-
# From GitHub
|
|
67
|
+
# From GitHub in the web profile
|
|
60
68
|
npx @deepseek-ai/dsh plugin --profile web add github:2672243194/dsh-read-url
|
|
61
69
|
|
|
62
70
|
# Local development
|
|
@@ -315,6 +323,22 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
|
|
|
315
323
|
- Vue, React and Kugou's known NOISE cases matched v1.8.1 exactly using identical HTML and optional dependencies, in both text/Markdown and full/800-character outputs. Errors were network, anti-bot, invalid-address or expected content-type boundaries.
|
|
316
324
|
- All four tools enforce the explicit origin policy, including redirect/crawl boundaries, cache isolation and visible static-reading hints. Model input remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
|
|
317
325
|
|
|
326
|
+
### v1.9.1 maintenance validation (2026-09-28)
|
|
327
|
+
|
|
328
|
+
- **352 zero-dependency unit assertions, 15 real Chromium SPA assertions and 59 adversarial probes** passed. Navigation tests cover real attributes, quoted `>` characters, empty anchors, refresh entities and semicolons; cancellation, retry cleanup and the rendered site-summary limit have regression coverage. Asynchronous probes now await completion and report failures through the exit code.
|
|
329
|
+
- The final 152-site sweep returned **103 OK / 25 THIN+EMPTY / 24 ERR / 0 THREW**, with no noise in OK results. Vue, React, Kugou and Hatena matched v1.9.0 using identical HTML, including full continuation and 800-character outputs. Weibo's variable static/SPA results also matched on identical HTML/DOM snapshots; the error-site set was unchanged.
|
|
330
|
+
- Navigation uses bounded, quote-aware attribute scanning and advances through each anchor body once. Runs with 25k/50k/100k unclosed anchors containing `href` took about **27/57/118 ms** on this machine. The existing 1,000-character attribute boundary remains in place.
|
|
331
|
+
- [DSH 0.1.7-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2) compatibility was checked against official npm package byte differences and community upgrade notes (available through rc.1). Both published v1.9.0 and this maintenance code passed **17 actual official-service integration checks each**; the published patch passed **4 loader checks** covering loading, execution, configuration remount and removal. Web-provider/runtime contracts remain compatible; no adaptation change is needed for the checked interfaces. These checks used controlled provider responses, not a complete host UI/model session.
|
|
332
|
+
- Core runtime dependencies remain zero. Model input stays at **1,159 description characters / 1,876 schema characters**; site body summaries remain opt-in and respect `maxCharsPerPage` without an extra 80-character render cutoff.
|
|
333
|
+
|
|
334
|
+
### v1.9.2 Desktop maintenance validation (2026-10-03)
|
|
335
|
+
|
|
336
|
+
- **375 zero-dependency unit assertions, 15 real Chromium SPA assertions and 64 adversarial probes** passed. Coverage includes Windows proxy fallback with a minimal PATH, real JSON-LD attributes, comment/raw-text isolation, bounded scans and deeply nested data.
|
|
337
|
+
- The installed **DSH Desktop 0.2.0-rc.2** runtime (Electron 44.0.0 / Node 24.18.1) passed **17 official-service integration checks, 3 tool-scope checks, 2 local proxy A/B checks and 15 Chromium SPA checks**. The minimal preset inherits the four globally registered plugin tools. Service responses were controlled fixtures; these checks do not cover a full model conversation.
|
|
338
|
+
- [DSH 0.2.0-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2) was audited against 0.1.7-rc.2 using byte differences across eight contact packages and four Desktop loading files, with the [community 0.2.0-rc.1 upgrade card](https://github.com/oh-my-dsh/dsh-plugin-upgrade-skill/blob/main/skills/plugin-upgrade/references/v0.2.0-rc.1.md) as a cross-check. The verified interfaces require no adaptation. The newer 0.2.1-alpha.1 was not audited.
|
|
339
|
+
- 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
|
+
- 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
|
+
|
|
318
342
|
## Roadmap
|
|
319
343
|
|
|
320
344
|
- [x] Single-page continuation (`offset` parameter)
|
package/README.zh.md
CHANGED
|
@@ -55,8 +55,16 @@ DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净
|
|
|
55
55
|
|
|
56
56
|
## 安装
|
|
57
57
|
|
|
58
|
+
### DSH 桌面版
|
|
59
|
+
|
|
60
|
+
打开 **插件 → 添加插件**,输入 `dsh-read-url`(npm 包名)、GitHub 仓库地址或本地项目目录后安装。在插件详情检查版本,以及组件是否显示 **运行中**。
|
|
61
|
+
|
|
62
|
+
桌面版使用独立的 `desktop` profile 和内置 DSH 运行时。CLI 各 profile 的安装及全局 CLI 升级与桌面版分别管理。手动配置本插件时,应使用桌面 profile 的 `cordis.patch.yml`。本地目录安装会链接源目录,修改代码后应重启桌面版,以加载新的模块。
|
|
63
|
+
|
|
64
|
+
### CLI / 本地开发
|
|
65
|
+
|
|
58
66
|
```bash
|
|
59
|
-
# 从 GitHub
|
|
67
|
+
# 从 GitHub 安装到 web profile
|
|
60
68
|
npx @deepseek-ai/dsh plugin --profile web add github:2672243194/dsh-read-url
|
|
61
69
|
|
|
62
70
|
# 本地开发
|
|
@@ -315,6 +323,22 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
|
|
|
315
323
|
- Vue、React、酷狗三个已知 NOISE 站采用同份 HTML 与相同可选依赖对照 v1.8.1,text/Markdown 全文及 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
|
|
316
324
|
- 四个工具统一遵守显式来源策略,覆盖跳转与爬取边界、缓存隔离及可见的静态读取提示。模型固定输入成本为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
|
|
317
325
|
|
|
326
|
+
### v1.9.1 维护验证(2026-09-28)
|
|
327
|
+
|
|
328
|
+
- **352 个零依赖单元断言、15 个真实 Chromium SPA 断言、59 项对抗探针**通过。导航测试覆盖真实属性、引号内的 `>`、空锚点、刷新实体和分号;取消、重试清理及站点摘要渲染上限均有回归覆盖。异步探针现在等待完成,并通过退出码报告失败。
|
|
329
|
+
- 152 站最终结果为 **103 OK / 25 THIN+EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。Vue、React、酷狗与 Hatena 使用同份 HTML 对照 v1.9.0,全文续读及 800 字输出一致;微博静态/SPA 波动也经同份 HTML/DOM 对照一致,错误站点集合未变。
|
|
330
|
+
- 导航采用有界、识别引号的属性扫描,每段锚点正文只扫描一次。本机 2.5 万/5 万/10 万个含 `href` 的未闭合锚点耗时约 **27/57/118 ms**,保留原有 1,000 字符属性边界。
|
|
331
|
+
- [DSH 0.1.7-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2) 已完成官方 npm 接触包字节差异核查,并与社区升级卡交叉验证(卡片目前到 rc.1)。发布版 v1.9.0 与当前维护代码各通过 **17 项真实官方服务集成检查**;发布版 patch 另通过 **4 项加载器检查**,覆盖加载、调用、配置重挂和卸载。Web provider/runtime 契约兼容,已核查接口无需适配修改;测试使用受控 provider 响应,未覆盖完整宿主 UI/模型会话。
|
|
332
|
+
- 核心仍为零运行时依赖,模型固定输入仍为 **description 1,159 字符 / schema 1,876 字符**;站点正文摘要仍默认关闭,启用后遵循 `maxCharsPerPage`,不再额外截成 80 字符。
|
|
333
|
+
|
|
334
|
+
### v1.9.2 桌面版维护验证(2026-10-03)
|
|
335
|
+
|
|
336
|
+
- **375 个零依赖单元断言、15 个真实 Chromium SPA 断言、64 项对抗探针**通过。覆盖 Windows 精简 PATH 下的代理回退、JSON-LD 真实属性、注释/raw-text 隔离、有界扫描与深层数据。
|
|
337
|
+
- 已安装的 **DSH 桌面版 0.2.0-rc.2** 运行时(Electron 44.0.0 / Node 24.18.1)通过 **17 项官方服务集成、3 项工具作用域、2 项本地代理 A/B 及 15 项 Chromium SPA 检查**。极简 preset 继承全局注册的四个插件工具;服务响应使用受控数据,未覆盖完整模型会话。
|
|
338
|
+
- [DSH 0.2.0-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2) 已对照 0.1.7-rc.2 完成八个接触包与四个桌面加载链文件的字节差异审计,并交叉核对[社区 0.2.0-rc.1 升级卡](https://github.com/oh-my-dsh/dsh-plugin-upgrade-skill/blob/main/skills/plugin-upgrade/references/v0.2.0-rc.1.md)。已核查接口无需适配修改;更新的 0.2.1-alpha.1 未审计。
|
|
339
|
+
- 152 站结果为 **104 OK / 20 THIN / 4 EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。已知 NOISE 与状态波动站使用相同 HTML/DOM 和可选依赖对照 v1.9.1,全文续读及 800 字输出一致;新增失败的订阅站在新旧版本均返回 HTTP 403。
|
|
340
|
+
- JSON-LD 元数据/正文支持合法属性变体并排除伪属性;作者递归有界,正文数组仅保留字符串。核心仍为零运行时依赖,模型固定输入为 **description 1,159 字符 / schema 1,876 字符**。
|
|
341
|
+
|
|
318
342
|
## Roadmap
|
|
319
343
|
|
|
320
344
|
- [x] 单页多段续读(`offset` 参数)
|
package/index.js
CHANGED
|
@@ -247,10 +247,19 @@ async function directFetchOnce(url, signal, cfg, origins) {
|
|
|
247
247
|
// filter into decodeBuffer as a bufferless "success" (bare TypeError there).
|
|
248
248
|
export async function directFetch(url, signal, cfg, origins) {
|
|
249
249
|
const first = await directFetchOnce(url, signal, cfg, origins)
|
|
250
|
-
if (first && first.retryAfterMs !== undefined
|
|
250
|
+
if (first && first.retryAfterMs !== undefined) {
|
|
251
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
251
252
|
await new Promise((resolve) => {
|
|
252
|
-
const
|
|
253
|
-
|
|
253
|
+
const done = () => {
|
|
254
|
+
clearTimeout(timer)
|
|
255
|
+
if (signal) signal.removeEventListener('abort', done)
|
|
256
|
+
resolve()
|
|
257
|
+
}
|
|
258
|
+
const timer = setTimeout(done, first.retryAfterMs)
|
|
259
|
+
if (signal) {
|
|
260
|
+
signal.addEventListener('abort', done, { once: true })
|
|
261
|
+
if (signal.aborted) done()
|
|
262
|
+
}
|
|
254
263
|
})
|
|
255
264
|
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
256
265
|
const second = await directFetchOnce(url, signal, cfg, origins)
|
|
@@ -1107,7 +1116,39 @@ function extractMeta(html) {
|
|
|
1107
1116
|
// Bounded block size keeps a hostile giant script tag from dragging the scan.
|
|
1108
1117
|
// 100k covers real article-list JSON-LD (news sites often embed dozens of
|
|
1109
1118
|
// items); metadata sits in the first node anyway.
|
|
1110
|
-
|
|
1119
|
+
function* jsonLdBlocks(html) {
|
|
1120
|
+
const open = /<!--|<([a-z][a-z0-9:-]{0,63})(?=[\s>])/gi
|
|
1121
|
+
const rawNames = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext'])
|
|
1122
|
+
let match
|
|
1123
|
+
while ((match = open.exec(html))) {
|
|
1124
|
+
if (!match[1]) {
|
|
1125
|
+
const end = html.indexOf('-->', open.lastIndex)
|
|
1126
|
+
if (end < 0) break
|
|
1127
|
+
open.lastIndex = end + 3
|
|
1128
|
+
continue
|
|
1129
|
+
}
|
|
1130
|
+
const tag = navigationTags(html.slice(match.index, match.index + 1002), /^<[a-z][a-z0-9:-]{0,63}(?=[\s>])/gi).next().value
|
|
1131
|
+
if (!tag) continue
|
|
1132
|
+
const end = match.index + tag.end
|
|
1133
|
+
open.lastIndex = end
|
|
1134
|
+
const name = match[1].toLowerCase()
|
|
1135
|
+
if (!rawNames.has(name)) continue
|
|
1136
|
+
if (name === 'plaintext') break
|
|
1137
|
+
const close = new RegExp(`</${name}\\s{0,1000}>`, 'gi')
|
|
1138
|
+
close.lastIndex = end
|
|
1139
|
+
const closing = close.exec(html)
|
|
1140
|
+
if (!closing) break
|
|
1141
|
+
// Raw-text bodies and quoted attributes cannot introduce script elements.
|
|
1142
|
+
open.lastIndex = close.lastIndex
|
|
1143
|
+
if (name !== 'script' || tag.attrs.type?.trim().toLowerCase() !== 'application/ld+json' || closing.index - end > 100000) continue
|
|
1144
|
+
try {
|
|
1145
|
+
yield JSON.parse(html.slice(end, closing.index))
|
|
1146
|
+
} catch {
|
|
1147
|
+
// Invalid data blocks do not prevent reading later JSON-LD blocks.
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1111
1152
|
function jsonLdMeta(html) {
|
|
1112
1153
|
// Recursively collect every node: nested structures are common (ItemList →
|
|
1113
1154
|
// mainEntity → Article, WebPage → mainEntityOfPage), so a flat top-level
|
|
@@ -1127,20 +1168,15 @@ function jsonLdMeta(html) {
|
|
|
1127
1168
|
if (v && typeof v === 'object') collect(v, out, depth + 1)
|
|
1128
1169
|
}
|
|
1129
1170
|
}
|
|
1130
|
-
const authorOf = (a) => {
|
|
1171
|
+
const authorOf = (a, depth = 0) => {
|
|
1172
|
+
if (depth > 20) return ''
|
|
1131
1173
|
if (typeof a === 'string') return a
|
|
1132
|
-
if (Array.isArray(a)) return authorOf(a[0])
|
|
1133
|
-
if (a && typeof a === 'object') return typeof a.name === 'string' ? a.name : authorOf(a.name)
|
|
1174
|
+
if (Array.isArray(a)) return authorOf(a[0], depth + 1)
|
|
1175
|
+
if (a && typeof a === 'object') return typeof a.name === 'string' ? a.name : authorOf(a.name, depth + 1)
|
|
1134
1176
|
return ''
|
|
1135
1177
|
}
|
|
1136
1178
|
const clean = (s) => (s ? decodeTextEntities(s).slice(0, 40) : '')
|
|
1137
|
-
for (const
|
|
1138
|
-
let json
|
|
1139
|
-
try {
|
|
1140
|
-
json = JSON.parse(m[1])
|
|
1141
|
-
} catch {
|
|
1142
|
-
continue
|
|
1143
|
-
}
|
|
1179
|
+
for (const json of jsonLdBlocks(html)) {
|
|
1144
1180
|
const nodes = []
|
|
1145
1181
|
collect(json, nodes)
|
|
1146
1182
|
for (const n of nodes) {
|
|
@@ -1156,15 +1192,9 @@ function jsonLdMeta(html) {
|
|
|
1156
1192
|
// (articleBody) while the visible HTML is an anti-bot shell or a thin
|
|
1157
1193
|
// fragment — a fallback body source the DOM extractors cannot reach. Returns
|
|
1158
1194
|
// the first string articleBody found (array values joined), stripped to
|
|
1159
|
-
// paragraphs via textLines.
|
|
1195
|
+
// paragraphs via textLines. Metadata and body share the bounded block scan.
|
|
1160
1196
|
function jsonLdArticleBody(html) {
|
|
1161
|
-
for (const
|
|
1162
|
-
let json
|
|
1163
|
-
try {
|
|
1164
|
-
json = JSON.parse(m[1])
|
|
1165
|
-
} catch {
|
|
1166
|
-
continue
|
|
1167
|
-
}
|
|
1197
|
+
for (const json of jsonLdBlocks(html)) {
|
|
1168
1198
|
const nodes = []
|
|
1169
1199
|
const collect = (node, depth = 0) => {
|
|
1170
1200
|
if (!node || typeof node !== 'object' || depth > 20) return
|
|
@@ -1180,7 +1210,7 @@ function jsonLdArticleBody(html) {
|
|
|
1180
1210
|
}
|
|
1181
1211
|
collect(json)
|
|
1182
1212
|
for (const n of nodes) {
|
|
1183
|
-
const raw = Array.isArray(n.articleBody) ? n.articleBody.join('\n\n') : n.articleBody
|
|
1213
|
+
const raw = Array.isArray(n.articleBody) ? n.articleBody.filter(part => typeof part === 'string').join('\n\n') : n.articleBody
|
|
1184
1214
|
if (typeof raw === 'string' && raw.trim()) return textLines(raw).slice(0, 200000)
|
|
1185
1215
|
}
|
|
1186
1216
|
}
|
|
@@ -1427,22 +1457,60 @@ function decodeUrlAttribute(value) {
|
|
|
1427
1457
|
}
|
|
1428
1458
|
|
|
1429
1459
|
// <base href> overrides the document base for relative URL resolution (old
|
|
1430
|
-
// forums / frame sites).
|
|
1460
|
+
// forums / frame sites). Only the FIRST <base href> in <head> counts;
|
|
1431
1461
|
// attribute order is arbitrary and the href may be protocol-relative. Empty
|
|
1432
1462
|
// href (= document URL) is a no-op; non-http(s) targets are rejected. The
|
|
1433
1463
|
// head scan is bounded — the tag cannot legally appear beyond it.
|
|
1434
1464
|
function detectBaseHref(html, finalUrl) {
|
|
1435
1465
|
const headEnd = html.search(/<\/head\s*>/i)
|
|
1436
1466
|
const head = headEnd >= 0 ? html.slice(0, headEnd) : html.slice(0, 16384)
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1467
|
+
for (const { attrs } of navigationTags(head, /<base(?=[\s/>])/gi)) {
|
|
1468
|
+
if (!Object.hasOwn(attrs, 'href')) continue
|
|
1469
|
+
const href = decodeUrlAttribute(attrs.href).trim()
|
|
1470
|
+
if (!href) return finalUrl
|
|
1471
|
+
try {
|
|
1472
|
+
const u = new URL(href, finalUrl)
|
|
1473
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : finalUrl
|
|
1474
|
+
} catch {
|
|
1475
|
+
return finalUrl
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return finalUrl
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Honor quoted '>' without nested regex alternatives. Keep the same total
|
|
1482
|
+
// tag-length bound as htmlAttributes; malformed/overlong tags stay inert.
|
|
1483
|
+
function* navigationTags(html, open) {
|
|
1484
|
+
let m
|
|
1485
|
+
while ((m = open.exec(html))) {
|
|
1486
|
+
let quote = '', end = -1
|
|
1487
|
+
const limit = Math.min(html.length, m.index + 1002)
|
|
1488
|
+
for (let i = open.lastIndex; i < limit; i++) {
|
|
1489
|
+
const ch = html[i]
|
|
1490
|
+
if (quote) {
|
|
1491
|
+
if (ch === quote) quote = ''
|
|
1492
|
+
} else if (ch === '"' || ch === "'") quote = ch
|
|
1493
|
+
else if (ch === '<') break
|
|
1494
|
+
else if (ch === '>') { end = i + 1; break }
|
|
1495
|
+
}
|
|
1496
|
+
if (end < 0) continue
|
|
1497
|
+
open.lastIndex = end
|
|
1498
|
+
yield { attrs: htmlAttributes(html.slice(m.index, end)), end }
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Scan each anchor body once; tags without a real href cannot navigate.
|
|
1503
|
+
function* navigationAnchors(html) {
|
|
1504
|
+
const open = /<a(?=[\s>])/gi
|
|
1505
|
+
const close = /<\/a\s{0,1000}>/gi
|
|
1506
|
+
for (const { attrs, end: start } of navigationTags(html, open)) {
|
|
1507
|
+
if (!attrs.href?.trim()) continue
|
|
1508
|
+
close.lastIndex = start
|
|
1509
|
+
const end = close.exec(html)
|
|
1510
|
+
if (!end) break
|
|
1511
|
+
const body = html.slice(start, end.index)
|
|
1512
|
+
open.lastIndex = close.lastIndex
|
|
1513
|
+
yield { attrs, body }
|
|
1446
1514
|
}
|
|
1447
1515
|
}
|
|
1448
1516
|
|
|
@@ -1454,18 +1522,14 @@ function extractLinks(html, limit, baseUrl) {
|
|
|
1454
1522
|
// A declared <base href> wins over the document URL (frame-site links would
|
|
1455
1523
|
// otherwise land on the wrong host).
|
|
1456
1524
|
const linkBase = detectBaseHref(html, baseUrl)
|
|
1457
|
-
// Attribute runs bounded: an unclosed <a whose '>' is far away (or missing)
|
|
1458
|
-
// must not make the regex scan the rest of the string at every anchor
|
|
1459
|
-
// position. Real attributes never approach 1000 chars.
|
|
1460
|
-
const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
|
|
1461
|
-
let m
|
|
1462
1525
|
// Iteration cap: a page whose nav repeats the same links thousands of times
|
|
1463
1526
|
// would otherwise scan the whole HTML to fill `limit` unique entries. Scan
|
|
1464
1527
|
// at most limit*4 anchors; dedupe keeps the result bounded regardless.
|
|
1465
1528
|
let scans = 0
|
|
1466
1529
|
const maxScans = limit * 4
|
|
1467
|
-
|
|
1468
|
-
|
|
1530
|
+
for (const { attrs, body } of navigationAnchors(html)) {
|
|
1531
|
+
if (links.length >= limit || scans++ >= maxScans) break
|
|
1532
|
+
const href = decodeUrlAttribute(attrs.href).trim()
|
|
1469
1533
|
if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
|
|
1470
1534
|
let url
|
|
1471
1535
|
try {
|
|
@@ -1481,7 +1545,7 @@ function extractLinks(html, limit, baseUrl) {
|
|
|
1481
1545
|
// keeps the link list compact (tokens) without losing coverage
|
|
1482
1546
|
if (seen.has(url)) continue
|
|
1483
1547
|
seen.add(url)
|
|
1484
|
-
const t = textOnly(
|
|
1548
|
+
const t = textOnly(body)
|
|
1485
1549
|
if (t) links.push({ title: t.slice(0, 80), url })
|
|
1486
1550
|
}
|
|
1487
1551
|
return links
|
|
@@ -1507,6 +1571,7 @@ export function findNextLink(html, baseUrl) {
|
|
|
1507
1571
|
// document base, and a "next page" link resolves against it.
|
|
1508
1572
|
const base = detectBaseHref(html || '', baseUrl)
|
|
1509
1573
|
const resolve = (href) => {
|
|
1574
|
+
if (!href?.trim()) return null
|
|
1510
1575
|
try {
|
|
1511
1576
|
const u = new URL(decodeUrlAttribute(href), base)
|
|
1512
1577
|
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
|
|
@@ -1514,21 +1579,20 @@ export function findNextLink(html, baseUrl) {
|
|
|
1514
1579
|
return null
|
|
1515
1580
|
}
|
|
1516
1581
|
}
|
|
1517
|
-
const
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const r =
|
|
1582
|
+
for (const { attrs } of navigationTags(html || '', /<(?:link|a)(?=[\s/>])/gi)) {
|
|
1583
|
+
const rel = decodeUrlAttribute(attrs.rel || '').toLowerCase().split(/[ \t\r\n\f]{1,1000}/)
|
|
1584
|
+
if (!rel.includes('next')) continue
|
|
1585
|
+
const r = resolve(attrs.href)
|
|
1521
1586
|
if (r) return r
|
|
1522
1587
|
}
|
|
1523
|
-
const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
|
|
1524
|
-
let m
|
|
1525
1588
|
let scans = 0
|
|
1526
1589
|
// Bounded like extractLinks: a nav bar with thousands of anchors must not
|
|
1527
1590
|
// scan the whole DOM hunting for a next-marker.
|
|
1528
|
-
|
|
1529
|
-
|
|
1591
|
+
for (const { attrs, body } of navigationAnchors(html || '')) {
|
|
1592
|
+
if (scans++ >= 400) break
|
|
1593
|
+
const t = textOnly(body)
|
|
1530
1594
|
if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
|
|
1531
|
-
const r = resolve(
|
|
1595
|
+
const r = resolve(attrs.href)
|
|
1532
1596
|
if (r) return r
|
|
1533
1597
|
}
|
|
1534
1598
|
}
|
|
@@ -1621,18 +1685,15 @@ export function metaRefreshTarget(html, baseUrl) {
|
|
|
1621
1685
|
// Locate the refresh <meta> tag first (any attribute order / case), then
|
|
1622
1686
|
// read content from inside that tag — a forward regex would demand
|
|
1623
1687
|
// http-equiv before content and miss the reversed order.
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
const
|
|
1628
|
-
const contentM = /content=("([^"]*)"|'([^']*)')/i.exec(tag)
|
|
1629
|
-
if (!contentM) continue // a refresh meta without content is inert
|
|
1630
|
-
const content = (contentM[2] || contentM[3] || '').trim()
|
|
1688
|
+
for (const { attrs } of navigationTags(html || '', /<meta(?=[\s/>])/gi)) {
|
|
1689
|
+
if (![attrs['http-equiv'], attrs.name].some(value => value?.toLowerCase() === 'refresh')) continue
|
|
1690
|
+
if (!Object.hasOwn(attrs, 'content')) continue
|
|
1691
|
+
const content = decodeUrlAttribute(attrs.content).trim()
|
|
1631
1692
|
// content="5" | content="0; url=http://…" | content=";url='…'" — the url
|
|
1632
1693
|
// part may be single/double quoted or bare; "url=" must be a real token.
|
|
1633
|
-
const urlM = /\burl\s
|
|
1694
|
+
const urlM = /\burl\s{0,1000}=\s{0,1000}(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s]{1,1000}))/i.exec(content)
|
|
1634
1695
|
if (!urlM) return null
|
|
1635
|
-
const delayM = /^\s
|
|
1696
|
+
const delayM = /^\s{0,1000}(\d{1,1000}(?:\.\d{1,1000})?)/.exec(content)
|
|
1636
1697
|
if (delayM && Number(delayM[1]) > 0) return null // timed refresh: keep the page
|
|
1637
1698
|
const target = (urlM[1] || urlM[2] || urlM[3] || '').trim()
|
|
1638
1699
|
if (!target) return null
|
|
@@ -2130,14 +2191,18 @@ function readLinksTool(ctx, cfg) {
|
|
|
2130
2191
|
const url = String((args && args.url) || '').trim()
|
|
2131
2192
|
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
2132
2193
|
const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
|
|
2194
|
+
const signal = exec && exec.signal
|
|
2195
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2133
2196
|
const route = createRoute(cfg)
|
|
2134
|
-
const page = await fetchResource(ctx, url,
|
|
2197
|
+
const page = await fetchResource(ctx, url, signal, cfg, route)
|
|
2198
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2135
2199
|
if (page.error) return { error: page.error }
|
|
2136
2200
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2137
2201
|
let finalUrl = page.finalUrl || url
|
|
2138
2202
|
// Same meta-refresh shell following as read_url: a link hop serves a
|
|
2139
2203
|
// stub whose real links live at the target. Fails open, hop-bounded.
|
|
2140
|
-
const followed = await followMetaRefresh(html, finalUrl,
|
|
2204
|
+
const followed = await followMetaRefresh(html, finalUrl, signal, cfg, ctx, route)
|
|
2205
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2141
2206
|
html = followed.html
|
|
2142
2207
|
finalUrl = followed.finalUrl
|
|
2143
2208
|
let links = extractLinks(html, limit, finalUrl)
|
|
@@ -2147,7 +2212,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
2147
2212
|
// static shell may be a JS-redirect (same rationale as read_url).
|
|
2148
2213
|
const needsRender = links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))
|
|
2149
2214
|
if (!route.restricted && needsRender) {
|
|
2150
|
-
const rr = await renderPage(finalUrl || url,
|
|
2215
|
+
const rr = await renderPage(finalUrl || url, signal)
|
|
2216
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2151
2217
|
// A challenge interstitial's links are the challenge's own scripts —
|
|
2152
2218
|
// never an improvement over whatever the static HTML offered.
|
|
2153
2219
|
if (rr.html && !looksLikeChallenge(rr.html)) {
|
|
@@ -2349,6 +2415,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2349
2415
|
attempted++
|
|
2350
2416
|
const route = { ...policy, restricted }
|
|
2351
2417
|
const page = await fetchResource(ctx, url, externalSignal, cfg, route)
|
|
2418
|
+
if (externalSignal && externalSignal.aborted) return null
|
|
2352
2419
|
if (page.error) return { url, depth, error: page.error }
|
|
2353
2420
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2354
2421
|
let pageUrl = page.finalUrl || url
|
|
@@ -2360,6 +2427,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2360
2427
|
// real content is never reached). Fails open — fetch problems keep the
|
|
2361
2428
|
// stub's own HTML.
|
|
2362
2429
|
const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx, route)
|
|
2430
|
+
if (externalSignal && externalSignal.aborted) return null
|
|
2363
2431
|
html = followed.html
|
|
2364
2432
|
pageUrl = followed.finalUrl
|
|
2365
2433
|
if (depth === 0) host = hostOf(pageUrl)
|
|
@@ -2380,6 +2448,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2380
2448
|
}
|
|
2381
2449
|
}),
|
|
2382
2450
|
)
|
|
2451
|
+
if (externalSignal && externalSignal.aborted) break
|
|
2383
2452
|
for (const r of results) {
|
|
2384
2453
|
if (!r) continue
|
|
2385
2454
|
if (r.error) {
|
|
@@ -2417,7 +2486,7 @@ function renderSite(value) {
|
|
|
2417
2486
|
const head = p.title || p.url
|
|
2418
2487
|
lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
|
|
2419
2488
|
if (p.spaHint) lines.push(`${indent}提示: ${p.spaHint}`)
|
|
2420
|
-
if (p.text) lines.push(`${indent} ${p.text
|
|
2489
|
+
if (p.text) lines.push(`${indent} ${p.text}`)
|
|
2421
2490
|
}
|
|
2422
2491
|
for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
|
|
2423
2492
|
return lines.join('\n')
|
|
@@ -2471,12 +2540,15 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
2471
2540
|
const url = String((args && args.url) || '').trim()
|
|
2472
2541
|
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
2473
2542
|
const maxPages = Math.floor(Math.max(2, Math.min(50, Number(args.maxPages) || 15)))
|
|
2543
|
+
const signal = exec && exec.signal
|
|
2544
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2474
2545
|
const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
|
|
2475
2546
|
const includeContent = args.includeContent === true
|
|
2476
2547
|
const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
|
|
2477
2548
|
const { host, pages, failures } = await crawlSite(
|
|
2478
|
-
url, cfg, { maxPages, maxDepth, includeContent, perMax },
|
|
2549
|
+
url, cfg, { maxPages, maxDepth, includeContent, perMax }, signal, ctx,
|
|
2479
2550
|
)
|
|
2551
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2480
2552
|
return {
|
|
2481
2553
|
host,
|
|
2482
2554
|
total: pages.length + failures.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
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
|
@@ -14,6 +14,23 @@
|
|
|
14
14
|
// caller reports the ORIGINAL direct-connect error with a clear note, so the
|
|
15
15
|
// model/user can act (turn the proxy on, or accept the network boundary).
|
|
16
16
|
import { execFile, execFileSync } from 'node:child_process'
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { win32 } from 'node:path'
|
|
19
|
+
|
|
20
|
+
// Desktop launches can inherit a reduced PATH. Prefer the Windows-shipped
|
|
21
|
+
// utilities when present; other platforms and custom installations use PATH.
|
|
22
|
+
function systemExecutable(name) {
|
|
23
|
+
if (process.platform !== 'win32') return name
|
|
24
|
+
const parts = name === 'powershell'
|
|
25
|
+
? ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe']
|
|
26
|
+
: ['System32', 'curl.exe']
|
|
27
|
+
for (const root of [process.env.SystemRoot, process.env.WINDIR]) {
|
|
28
|
+
if (typeof root !== 'string' || !win32.isAbsolute(root)) continue
|
|
29
|
+
const executable = win32.join(root, ...parts)
|
|
30
|
+
if (existsSync(executable)) return executable
|
|
31
|
+
}
|
|
32
|
+
return name
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
// Some servers omit the Content-Type header entirely; the content-type gate
|
|
19
36
|
// then has nothing to test and a binary body (PDF/PNG/zip) would flow into
|
|
@@ -71,7 +88,7 @@ function readWindowsSystemProxy() {
|
|
|
71
88
|
sysProxyCheckedAt = now
|
|
72
89
|
try {
|
|
73
90
|
const out = execFileSync(
|
|
74
|
-
'powershell',
|
|
91
|
+
systemExecutable('powershell'),
|
|
75
92
|
[
|
|
76
93
|
'-NoProfile', '-Command',
|
|
77
94
|
"(Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings').ProxyEnable, (Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings').ProxyServer",
|
|
@@ -127,7 +144,7 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
127
144
|
]
|
|
128
145
|
try {
|
|
129
146
|
const buf = await new Promise((resolve, reject) => {
|
|
130
|
-
execFile('curl', args, { maxBuffer: cfg.maxBytes + 8192, encoding: 'buffer', windowsHide: true, signal: externalSignal || undefined }, (err, stdout) => {
|
|
147
|
+
execFile(systemExecutable('curl'), args, { maxBuffer: cfg.maxBytes + 8192, encoding: 'buffer', windowsHide: true, signal: externalSignal || undefined }, (err, stdout) => {
|
|
131
148
|
if (err) return reject(err)
|
|
132
149
|
resolve(stdout)
|
|
133
150
|
})
|