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 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 (recommended, easy updates)
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 && !(signal && signal.aborted)) {
250
+ if (first && first.retryAfterMs !== undefined) {
251
+ if (signal && signal.aborted) return { error: 'cancelled' }
251
252
  await new Promise((resolve) => {
252
- const t = setTimeout(resolve, first.retryAfterMs)
253
- if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
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
- const LDJSON_RE = /<script[^>]{0,1000}type=["']application\/ld\+json["'][^>]{0,1000}>([\s\S]{0,100000}?)<\/script\s*>/gi
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 m of html.matchAll(LDJSON_RE)) {
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. Block scan is the same bounded LDJSON_RE.
1195
+ // paragraphs via textLines. Metadata and body share the bounded block scan.
1160
1196
  function jsonLdArticleBody(html) {
1161
- for (const m of html.matchAll(LDJSON_RE)) {
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). Per HTML spec only the FIRST <base> in <head> counts;
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 base = /<base\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>/i.exec(head)
1438
- if (!base) return finalUrl
1439
- const href = decodeUrlAttribute(base[1]).trim()
1440
- if (!href) return finalUrl
1441
- try {
1442
- const u = new URL(href, finalUrl)
1443
- return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : finalUrl
1444
- } catch {
1445
- return finalUrl
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
- while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
1468
- const href = decodeUrlAttribute(m[1]).trim()
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(m[2])
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 rel = /<(?:link|a)\b[^>]{0,1000}rel=["']next["'][^>]{0,1000}>/i.exec(html || '')
1518
- if (rel) {
1519
- const href = /href=["']([^"']+)["']/i.exec(rel[0])
1520
- const r = href && resolve(href[1])
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
- while ((m = re.exec(html || '')) && scans++ < 400) {
1529
- const t = textOnly(m[2])
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(m[1])
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 re = /<meta\b[^>]{0,1000}(?:http-equiv|name)=["']refresh["'][^>]{0,1000}>/gi
1625
- let m
1626
- while ((m = re.exec(html || ''))) {
1627
- const tag = m[0]
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*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))/i.exec(content)
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*(\d+(?:\.\d+)?)/.exec(content)
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, exec && exec.signal, cfg, route)
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, exec && exec.signal, cfg, ctx, route)
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, exec && exec.signal)
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.slice(0, 80)}`)
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 }, exec && exec.signal, ctx,
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.0",
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
  })