dsh-read-url 1.9.0 → 1.9.1
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 +18 -0
- package/README.md +8 -0
- package/README.zh.md +8 -0
- package/index.js +95 -44
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.1] - 2026-09-28
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- 链接、分页、base 与 meta-refresh 按真实属性读取,支持等号空格和合法无引号属性;忽略 data-href/data-rel 等伪属性,识别 rel 中的 next 标记。
|
|
8
|
+
- meta-refresh 正确解码实体并保留 URL 中的分号及查询参数,避免跳转地址被截断。
|
|
9
|
+
- 有界标签扫描识别引号内的 `>`,空 href 不吞掉后续锚点;锚点正文单向扫描,避免损坏 HTML 触发反复搜索。
|
|
10
|
+
- 链接扫描与站点爬取在取消后返回 cancelled,避免返回旧跳转壳或部分结果作为完整成功;Retry-After 等待结束或取消时清理监听与计时器。
|
|
11
|
+
- 站点正文摘要按 maxCharsPerPage 显示,取消渲染层额外的 80 字符截断;默认仍只输出站点结构。
|
|
12
|
+
- 对抗探针等待异步检查完成,异常设置非零退出码,避免未完成的 Promise 被打印为通过。
|
|
13
|
+
|
|
14
|
+
### 验证
|
|
15
|
+
|
|
16
|
+
- 352 个零依赖单元断言、15 个真实 Chromium SPA 断言、59 项对抗探针通过;新增 30 条单元检查与 6 条未闭合锚点探针。原始维护缺陷在 v1.8.1/v1.9.0 同输入均可复现,属于既有问题。
|
|
17
|
+
- 152 站最终实网回归:103 OK / 20 THIN / 5 EMPTY / 24 ERR / 0 THREW,OK 站零噪声。Vue、React、酷狗及 Hatena 同 HTML 的全文续读与 800 字输出对照 v1.9.0 一致;微博动态波动经同 HTML/DOM 对照一致,错误站点集合未变。
|
|
18
|
+
- DSH 0.1.7-rc.2 官方包字节差异及社区升级卡(截至 rc.1)交叉核查完成;v1.9.0 和当前维护代码各通过 17 项真实官方服务集成检查,发布版 patch 另通过 4 项真实加载器检查。已核查接口无需适配修改;受控 provider 检查不等同于完整宿主 UI/模型端到端验证。
|
|
19
|
+
- 未闭合且含 href 的锚点 2.5 万/5 万/10 万输入本机约 27/57/118 ms;保留有界属性策略与零运行时依赖。四工具 description/schema 仍为 1,159/1,876 字符。
|
|
20
|
+
|
|
3
21
|
## [1.9.0] - 2026-09-19
|
|
4
22
|
|
|
5
23
|
### 新增
|
package/README.md
CHANGED
|
@@ -315,6 +315,14 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
|
|
|
315
315
|
- 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
316
|
- 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
317
|
|
|
318
|
+
### v1.9.1 maintenance validation (2026-09-28)
|
|
319
|
+
|
|
320
|
+
- **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.
|
|
321
|
+
- 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.
|
|
322
|
+
- 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.
|
|
323
|
+
- [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.
|
|
324
|
+
- 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.
|
|
325
|
+
|
|
318
326
|
## Roadmap
|
|
319
327
|
|
|
320
328
|
- [x] Single-page continuation (`offset` parameter)
|
package/README.zh.md
CHANGED
|
@@ -315,6 +315,14 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
|
|
|
315
315
|
- Vue、React、酷狗三个已知 NOISE 站采用同份 HTML 与相同可选依赖对照 v1.8.1,text/Markdown 全文及 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
|
|
316
316
|
- 四个工具统一遵守显式来源策略,覆盖跳转与爬取边界、缓存隔离及可见的静态读取提示。模型固定输入成本为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
|
|
317
317
|
|
|
318
|
+
### v1.9.1 维护验证(2026-09-28)
|
|
319
|
+
|
|
320
|
+
- **352 个零依赖单元断言、15 个真实 Chromium SPA 断言、59 项对抗探针**通过。导航测试覆盖真实属性、引号内的 `>`、空锚点、刷新实体和分号;取消、重试清理及站点摘要渲染上限均有回归覆盖。异步探针现在等待完成,并通过退出码报告失败。
|
|
321
|
+
- 152 站最终结果为 **103 OK / 25 THIN+EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。Vue、React、酷狗与 Hatena 使用同份 HTML 对照 v1.9.0,全文续读及 800 字输出一致;微博静态/SPA 波动也经同份 HTML/DOM 对照一致,错误站点集合未变。
|
|
322
|
+
- 导航采用有界、识别引号的属性扫描,每段锚点正文只扫描一次。本机 2.5 万/5 万/10 万个含 `href` 的未闭合锚点耗时约 **27/57/118 ms**,保留原有 1,000 字符属性边界。
|
|
323
|
+
- [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/模型会话。
|
|
324
|
+
- 核心仍为零运行时依赖,模型固定输入仍为 **description 1,159 字符 / schema 1,876 字符**;站点正文摘要仍默认关闭,启用后遵循 `maxCharsPerPage`,不再额外截成 80 字符。
|
|
325
|
+
|
|
318
326
|
## Roadmap
|
|
319
327
|
|
|
320
328
|
- [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)
|
|
@@ -1427,22 +1436,60 @@ function decodeUrlAttribute(value) {
|
|
|
1427
1436
|
}
|
|
1428
1437
|
|
|
1429
1438
|
// <base href> overrides the document base for relative URL resolution (old
|
|
1430
|
-
// forums / frame sites).
|
|
1439
|
+
// forums / frame sites). Only the FIRST <base href> in <head> counts;
|
|
1431
1440
|
// attribute order is arbitrary and the href may be protocol-relative. Empty
|
|
1432
1441
|
// href (= document URL) is a no-op; non-http(s) targets are rejected. The
|
|
1433
1442
|
// head scan is bounded — the tag cannot legally appear beyond it.
|
|
1434
1443
|
function detectBaseHref(html, finalUrl) {
|
|
1435
1444
|
const headEnd = html.search(/<\/head\s*>/i)
|
|
1436
1445
|
const head = headEnd >= 0 ? html.slice(0, headEnd) : html.slice(0, 16384)
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
+
for (const { attrs } of navigationTags(head, /<base(?=[\s/>])/gi)) {
|
|
1447
|
+
if (!Object.hasOwn(attrs, 'href')) continue
|
|
1448
|
+
const href = decodeUrlAttribute(attrs.href).trim()
|
|
1449
|
+
if (!href) return finalUrl
|
|
1450
|
+
try {
|
|
1451
|
+
const u = new URL(href, finalUrl)
|
|
1452
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : finalUrl
|
|
1453
|
+
} catch {
|
|
1454
|
+
return finalUrl
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return finalUrl
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Honor quoted '>' without nested regex alternatives. Keep the same total
|
|
1461
|
+
// tag-length bound as htmlAttributes; malformed/overlong tags stay inert.
|
|
1462
|
+
function* navigationTags(html, open) {
|
|
1463
|
+
let m
|
|
1464
|
+
while ((m = open.exec(html))) {
|
|
1465
|
+
let quote = '', end = -1
|
|
1466
|
+
const limit = Math.min(html.length, m.index + 1002)
|
|
1467
|
+
for (let i = open.lastIndex; i < limit; i++) {
|
|
1468
|
+
const ch = html[i]
|
|
1469
|
+
if (quote) {
|
|
1470
|
+
if (ch === quote) quote = ''
|
|
1471
|
+
} else if (ch === '"' || ch === "'") quote = ch
|
|
1472
|
+
else if (ch === '<') break
|
|
1473
|
+
else if (ch === '>') { end = i + 1; break }
|
|
1474
|
+
}
|
|
1475
|
+
if (end < 0) continue
|
|
1476
|
+
open.lastIndex = end
|
|
1477
|
+
yield { attrs: htmlAttributes(html.slice(m.index, end)), end }
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Scan each anchor body once; tags without a real href cannot navigate.
|
|
1482
|
+
function* navigationAnchors(html) {
|
|
1483
|
+
const open = /<a(?=[\s>])/gi
|
|
1484
|
+
const close = /<\/a\s{0,1000}>/gi
|
|
1485
|
+
for (const { attrs, end: start } of navigationTags(html, open)) {
|
|
1486
|
+
if (!attrs.href?.trim()) continue
|
|
1487
|
+
close.lastIndex = start
|
|
1488
|
+
const end = close.exec(html)
|
|
1489
|
+
if (!end) break
|
|
1490
|
+
const body = html.slice(start, end.index)
|
|
1491
|
+
open.lastIndex = close.lastIndex
|
|
1492
|
+
yield { attrs, body }
|
|
1446
1493
|
}
|
|
1447
1494
|
}
|
|
1448
1495
|
|
|
@@ -1454,18 +1501,14 @@ function extractLinks(html, limit, baseUrl) {
|
|
|
1454
1501
|
// A declared <base href> wins over the document URL (frame-site links would
|
|
1455
1502
|
// otherwise land on the wrong host).
|
|
1456
1503
|
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
1504
|
// Iteration cap: a page whose nav repeats the same links thousands of times
|
|
1463
1505
|
// would otherwise scan the whole HTML to fill `limit` unique entries. Scan
|
|
1464
1506
|
// at most limit*4 anchors; dedupe keeps the result bounded regardless.
|
|
1465
1507
|
let scans = 0
|
|
1466
1508
|
const maxScans = limit * 4
|
|
1467
|
-
|
|
1468
|
-
|
|
1509
|
+
for (const { attrs, body } of navigationAnchors(html)) {
|
|
1510
|
+
if (links.length >= limit || scans++ >= maxScans) break
|
|
1511
|
+
const href = decodeUrlAttribute(attrs.href).trim()
|
|
1469
1512
|
if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
|
|
1470
1513
|
let url
|
|
1471
1514
|
try {
|
|
@@ -1481,7 +1524,7 @@ function extractLinks(html, limit, baseUrl) {
|
|
|
1481
1524
|
// keeps the link list compact (tokens) without losing coverage
|
|
1482
1525
|
if (seen.has(url)) continue
|
|
1483
1526
|
seen.add(url)
|
|
1484
|
-
const t = textOnly(
|
|
1527
|
+
const t = textOnly(body)
|
|
1485
1528
|
if (t) links.push({ title: t.slice(0, 80), url })
|
|
1486
1529
|
}
|
|
1487
1530
|
return links
|
|
@@ -1507,6 +1550,7 @@ export function findNextLink(html, baseUrl) {
|
|
|
1507
1550
|
// document base, and a "next page" link resolves against it.
|
|
1508
1551
|
const base = detectBaseHref(html || '', baseUrl)
|
|
1509
1552
|
const resolve = (href) => {
|
|
1553
|
+
if (!href?.trim()) return null
|
|
1510
1554
|
try {
|
|
1511
1555
|
const u = new URL(decodeUrlAttribute(href), base)
|
|
1512
1556
|
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
|
|
@@ -1514,21 +1558,20 @@ export function findNextLink(html, baseUrl) {
|
|
|
1514
1558
|
return null
|
|
1515
1559
|
}
|
|
1516
1560
|
}
|
|
1517
|
-
const
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const r =
|
|
1561
|
+
for (const { attrs } of navigationTags(html || '', /<(?:link|a)(?=[\s/>])/gi)) {
|
|
1562
|
+
const rel = decodeUrlAttribute(attrs.rel || '').toLowerCase().split(/[ \t\r\n\f]{1,1000}/)
|
|
1563
|
+
if (!rel.includes('next')) continue
|
|
1564
|
+
const r = resolve(attrs.href)
|
|
1521
1565
|
if (r) return r
|
|
1522
1566
|
}
|
|
1523
|
-
const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
|
|
1524
|
-
let m
|
|
1525
1567
|
let scans = 0
|
|
1526
1568
|
// Bounded like extractLinks: a nav bar with thousands of anchors must not
|
|
1527
1569
|
// scan the whole DOM hunting for a next-marker.
|
|
1528
|
-
|
|
1529
|
-
|
|
1570
|
+
for (const { attrs, body } of navigationAnchors(html || '')) {
|
|
1571
|
+
if (scans++ >= 400) break
|
|
1572
|
+
const t = textOnly(body)
|
|
1530
1573
|
if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
|
|
1531
|
-
const r = resolve(
|
|
1574
|
+
const r = resolve(attrs.href)
|
|
1532
1575
|
if (r) return r
|
|
1533
1576
|
}
|
|
1534
1577
|
}
|
|
@@ -1621,18 +1664,15 @@ export function metaRefreshTarget(html, baseUrl) {
|
|
|
1621
1664
|
// Locate the refresh <meta> tag first (any attribute order / case), then
|
|
1622
1665
|
// read content from inside that tag — a forward regex would demand
|
|
1623
1666
|
// 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()
|
|
1667
|
+
for (const { attrs } of navigationTags(html || '', /<meta(?=[\s/>])/gi)) {
|
|
1668
|
+
if (![attrs['http-equiv'], attrs.name].some(value => value?.toLowerCase() === 'refresh')) continue
|
|
1669
|
+
if (!Object.hasOwn(attrs, 'content')) continue
|
|
1670
|
+
const content = decodeUrlAttribute(attrs.content).trim()
|
|
1631
1671
|
// content="5" | content="0; url=http://…" | content=";url='…'" — the url
|
|
1632
1672
|
// part may be single/double quoted or bare; "url=" must be a real token.
|
|
1633
|
-
const urlM = /\burl\s
|
|
1673
|
+
const urlM = /\burl\s{0,1000}=\s{0,1000}(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s]{1,1000}))/i.exec(content)
|
|
1634
1674
|
if (!urlM) return null
|
|
1635
|
-
const delayM = /^\s
|
|
1675
|
+
const delayM = /^\s{0,1000}(\d{1,1000}(?:\.\d{1,1000})?)/.exec(content)
|
|
1636
1676
|
if (delayM && Number(delayM[1]) > 0) return null // timed refresh: keep the page
|
|
1637
1677
|
const target = (urlM[1] || urlM[2] || urlM[3] || '').trim()
|
|
1638
1678
|
if (!target) return null
|
|
@@ -2130,14 +2170,18 @@ function readLinksTool(ctx, cfg) {
|
|
|
2130
2170
|
const url = String((args && args.url) || '').trim()
|
|
2131
2171
|
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
2132
2172
|
const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
|
|
2173
|
+
const signal = exec && exec.signal
|
|
2174
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2133
2175
|
const route = createRoute(cfg)
|
|
2134
|
-
const page = await fetchResource(ctx, url,
|
|
2176
|
+
const page = await fetchResource(ctx, url, signal, cfg, route)
|
|
2177
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2135
2178
|
if (page.error) return { error: page.error }
|
|
2136
2179
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2137
2180
|
let finalUrl = page.finalUrl || url
|
|
2138
2181
|
// Same meta-refresh shell following as read_url: a link hop serves a
|
|
2139
2182
|
// stub whose real links live at the target. Fails open, hop-bounded.
|
|
2140
|
-
const followed = await followMetaRefresh(html, finalUrl,
|
|
2183
|
+
const followed = await followMetaRefresh(html, finalUrl, signal, cfg, ctx, route)
|
|
2184
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2141
2185
|
html = followed.html
|
|
2142
2186
|
finalUrl = followed.finalUrl
|
|
2143
2187
|
let links = extractLinks(html, limit, finalUrl)
|
|
@@ -2147,7 +2191,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
2147
2191
|
// static shell may be a JS-redirect (same rationale as read_url).
|
|
2148
2192
|
const needsRender = links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))
|
|
2149
2193
|
if (!route.restricted && needsRender) {
|
|
2150
|
-
const rr = await renderPage(finalUrl || url,
|
|
2194
|
+
const rr = await renderPage(finalUrl || url, signal)
|
|
2195
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2151
2196
|
// A challenge interstitial's links are the challenge's own scripts —
|
|
2152
2197
|
// never an improvement over whatever the static HTML offered.
|
|
2153
2198
|
if (rr.html && !looksLikeChallenge(rr.html)) {
|
|
@@ -2349,6 +2394,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2349
2394
|
attempted++
|
|
2350
2395
|
const route = { ...policy, restricted }
|
|
2351
2396
|
const page = await fetchResource(ctx, url, externalSignal, cfg, route)
|
|
2397
|
+
if (externalSignal && externalSignal.aborted) return null
|
|
2352
2398
|
if (page.error) return { url, depth, error: page.error }
|
|
2353
2399
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2354
2400
|
let pageUrl = page.finalUrl || url
|
|
@@ -2360,6 +2406,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2360
2406
|
// real content is never reached). Fails open — fetch problems keep the
|
|
2361
2407
|
// stub's own HTML.
|
|
2362
2408
|
const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx, route)
|
|
2409
|
+
if (externalSignal && externalSignal.aborted) return null
|
|
2363
2410
|
html = followed.html
|
|
2364
2411
|
pageUrl = followed.finalUrl
|
|
2365
2412
|
if (depth === 0) host = hostOf(pageUrl)
|
|
@@ -2380,6 +2427,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2380
2427
|
}
|
|
2381
2428
|
}),
|
|
2382
2429
|
)
|
|
2430
|
+
if (externalSignal && externalSignal.aborted) break
|
|
2383
2431
|
for (const r of results) {
|
|
2384
2432
|
if (!r) continue
|
|
2385
2433
|
if (r.error) {
|
|
@@ -2417,7 +2465,7 @@ function renderSite(value) {
|
|
|
2417
2465
|
const head = p.title || p.url
|
|
2418
2466
|
lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
|
|
2419
2467
|
if (p.spaHint) lines.push(`${indent}提示: ${p.spaHint}`)
|
|
2420
|
-
if (p.text) lines.push(`${indent} ${p.text
|
|
2468
|
+
if (p.text) lines.push(`${indent} ${p.text}`)
|
|
2421
2469
|
}
|
|
2422
2470
|
for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
|
|
2423
2471
|
return lines.join('\n')
|
|
@@ -2471,12 +2519,15 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
2471
2519
|
const url = String((args && args.url) || '').trim()
|
|
2472
2520
|
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
2473
2521
|
const maxPages = Math.floor(Math.max(2, Math.min(50, Number(args.maxPages) || 15)))
|
|
2522
|
+
const signal = exec && exec.signal
|
|
2523
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2474
2524
|
const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
|
|
2475
2525
|
const includeContent = args.includeContent === true
|
|
2476
2526
|
const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
|
|
2477
2527
|
const { host, pages, failures } = await crawlSite(
|
|
2478
|
-
url, cfg, { maxPages, maxDepth, includeContent, perMax },
|
|
2528
|
+
url, cfg, { maxPages, maxDepth, includeContent, perMax }, signal, ctx,
|
|
2479
2529
|
)
|
|
2530
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
2480
2531
|
return {
|
|
2481
2532
|
host,
|
|
2482
2533
|
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.1",
|
|
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",
|