dsh-read-url 1.8.0 → 1.8.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 +17 -0
- package/README.md +10 -1
- package/README.zh.md +10 -1
- package/index.js +83 -57
- package/package.json +1 -1
- package/spa.js +45 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.1] - 2026-09-19
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- SPA 导航取消及时关闭当前页面;启动期间取消不再创建页面,卸载等待浏览器关闭,并发清理共享等待,断开连接后可重新启动;旧启动失败不会清空新浏览器引用。
|
|
8
|
+
- 浏览器启动异常保留简短真实原因,避免把执行权限错误误判为缺少 Playwright;DOM 轮询按连续失败次数判断停止。
|
|
9
|
+
- 有效成功缓存优先于并发失败,成功写入或命中清理同键失败记录。
|
|
10
|
+
- 站点爬取按 URL 路径边界过滤,保留 feedback、admin-guide 及查询参数中包含登录地址的正常页面。
|
|
11
|
+
- 编码声明支持等号空格和无引号属性,排除 data-charset 伪属性;Markdown 链接与图片按真实属性读取,正确处理实体、括号和图片说明文本。
|
|
12
|
+
|
|
13
|
+
### 验证
|
|
14
|
+
|
|
15
|
+
- 297 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针通过;新增 21 条单元断言和 3 条 SPA 断言。
|
|
16
|
+
- 152 站实网回归:104 OK / 23 THIN+EMPTY / 25 ERR / 0 THREW,OK 站零噪声;Vue、React、酷狗三站已知 NOISE 在同输入 v1.8.0 A/B 中逐字一致。
|
|
17
|
+
- 修复前 v1.7.0 / v1.8.0 同输入均复现相关缺陷,属于既有问题;核心零运行时依赖,新增或修改的正则保持有界。
|
|
18
|
+
- 四工具 description 共 1,159 字符,参数 schema 共 1,876 字符;预算仍为 1,250 / 2,000。
|
|
19
|
+
|
|
3
20
|
## [1.8.0] - 2026-09-07
|
|
4
21
|
|
|
5
22
|
### 内容与准确性
|
package/README.md
CHANGED
|
@@ -274,10 +274,19 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
|
|
|
274
274
|
| Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
|
|
275
275
|
| Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
|
|
276
276
|
|
|
277
|
+
### Released v1.8.0 validation snapshot
|
|
278
|
+
|
|
277
279
|
- **276 zero-dependency assertions + 12 SPA assertions** pass. `node test.mjs` covers provider contracts, cancellation/retry, extraction/metadata, RSS/Atom, Markdown, continuation, batching and crawl boundaries. `node probe.mjs` checks pathological tags, nesting, long whitespace and bounded scanning.
|
|
278
280
|
- Local maintenance validation (2026-09-07): **102 OK / 27 THIN+EMPTY / 23 ERR / 0 THREW** across 152 sites, with no noise among OK results; 53 adversarial probes pass. ERR results are network/anti-bot failures, invalid addresses or intentionally unsupported binary types. Known NOISE flags were checked against the previous code using identical inputs.
|
|
279
281
|
- Real case: on a Xiaoheihe post, comment like-counts (`up` field) could not be attributed from flattened text — **precise fields should come from the page's underlying data API** (e.g. `/bbs/app/link/tree` JSON). This is a shared boundary of text extractors, not a defect.
|
|
280
282
|
|
|
283
|
+
### v1.8.1 maintenance validation (2026-09-19)
|
|
284
|
+
|
|
285
|
+
- **297 zero-dependency assertions + 15 real Chromium SPA assertions + 53 adversarial probes** pass. `test-lifecycle.mjs`, included in `test.mjs`, uses a local browser fixture for cancellation, launch failures, concurrent cleanup and restart checks.
|
|
286
|
+
- The 152-site run returned **104 OK / 23 THIN+EMPTY / 25 ERR / 0 THREW**, with no noise among OK results. The three known NOISE sites have identical decoded content and extracted output against v1.8.0 using the same input. Network errors and intentionally unsupported types remain separate from extraction results.
|
|
287
|
+
- SPA cancellation closes the active page; plugin disposal awaits browser shutdown. Valid success-cache entries take precedence over concurrent failures. Crawl filtering uses path boundaries. Charset and Markdown attributes use bounded parsing.
|
|
288
|
+
- Fixed model input cost remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
|
|
289
|
+
|
|
281
290
|
## Roadmap
|
|
282
291
|
|
|
283
292
|
- [x] Single-page continuation (`offset` parameter)
|
|
@@ -306,7 +315,7 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
|
|
|
306
315
|
|
|
307
316
|
```bash
|
|
308
317
|
node test.mjs # zero-dependency self-tests (charset/extract/markdown/truncate)
|
|
309
|
-
node test-spa.mjs # SPA rendering tests (
|
|
318
|
+
node test-spa.mjs # SPA rendering tests (15 assertions; SKIPs if playwright absent)
|
|
310
319
|
node multi-site.mjs # 152-site real-world sweep (needs network, CONC=8 tunable): portals/SPA/login-walls/static/feeds/JSON/anti-bot/net-boundaries
|
|
311
320
|
|
|
312
321
|
# End-to-end (requires DSH CLI)
|
package/README.zh.md
CHANGED
|
@@ -274,10 +274,19 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
|
|
|
274
274
|
| **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
|
|
275
275
|
| **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
|
|
276
276
|
|
|
277
|
+
### 已发布 v1.8.0 验证快照
|
|
278
|
+
|
|
277
279
|
- **276 个零依赖单元断言 + 12 个 SPA 断言**通过;`node test.mjs` 包含网络契约、取消重试、正文/元数据、RSS/Atom、Markdown、续读、批量与站点边界测试。`node probe.mjs` 验证极端标签、嵌套、长空白和有界扫描性能。
|
|
278
280
|
- 本机维护验证(2026-09-07):152 站为 **102 OK / 27 THIN+EMPTY / 23 ERR / 0 THREW**,OK 站零噪声;53 项对抗探针通过。ERR 为网络、反爬、无效地址或预期不支持的二进制类型;已知 NOISE 标记经同输入新旧 A/B 核查。
|
|
279
281
|
- 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
|
|
280
282
|
|
|
283
|
+
### v1.8.1 维护验证(2026-09-19)
|
|
284
|
+
|
|
285
|
+
- **297 个零依赖单元断言 + 15 个真实 Chromium SPA 断言 + 53 项对抗探针**通过。`test.mjs` 已包含 `test-lifecycle.mjs`,通过本地浏览器桩验证取消、启动失败、并发清理和重启。
|
|
286
|
+
- 152 站实网结果为 **104 OK / 23 THIN+EMPTY / 25 ERR / 0 THREW**,OK 站零噪声。三个已知 NOISE 站在同输入 v1.8.0 对照中,解码内容与提取结果逐字一致;网络错误与预期不支持的类型单独归类。
|
|
287
|
+
- SPA 取消关闭当前页面,插件卸载等待浏览器关闭;有效成功缓存优先于并发失败;爬取过滤遵循路径边界;编码与 Markdown 属性使用有界解析。
|
|
288
|
+
- 模型固定输入成本仍为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
|
|
289
|
+
|
|
281
290
|
## Roadmap
|
|
282
291
|
|
|
283
292
|
- [x] 单页多段续读(`offset` 参数)
|
|
@@ -308,7 +317,7 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
|
|
|
308
317
|
node test.mjs # 单元自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离/配置钳制/控制字符/JSON-LD/base href/meta-refresh)
|
|
309
318
|
|
|
310
319
|
# SPA 渲染真实测试(需 playwright 已安装,未装自动 SKIP)
|
|
311
|
-
node test-spa.mjs #
|
|
320
|
+
node test-spa.mjs # 15 断言:JS 正文/渲染后链接/缓存隔离/导航取消/浏览器重启
|
|
312
321
|
|
|
313
322
|
# 152 站真实世界验证(需联网,CONC=8 可调并发)
|
|
314
323
|
node multi-site.mjs # 门户/SPA/登录墙/静态/订阅源/JSON/反爬/网络边界,输出分级结果
|
package/index.js
CHANGED
|
@@ -46,9 +46,10 @@ function cacheFail(key, error) {
|
|
|
46
46
|
if (failCache.size >= FAIL_CACHE_MAX) failCache.delete(failCache.keys().next().value)
|
|
47
47
|
failCache.set(key, { time: Date.now(), error })
|
|
48
48
|
}
|
|
49
|
-
function cacheStore(key, full, cacheMax) {
|
|
50
|
-
if (cache.size >= cacheMax) cache.delete(cache.keys().next().value)
|
|
51
|
-
cache.set(key, { time: Date.now(), full })
|
|
49
|
+
function cacheStore(key, full, cacheMax) {
|
|
50
|
+
if (cache.size >= cacheMax) cache.delete(cache.keys().next().value)
|
|
51
|
+
cache.set(key, { time: Date.now(), full })
|
|
52
|
+
failCache.delete(key)
|
|
52
53
|
}
|
|
53
54
|
const decoders = new Map()
|
|
54
55
|
|
|
@@ -65,21 +66,29 @@ function getDecoder(enc) {
|
|
|
65
66
|
return d
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
function
|
|
69
|
+
function declaredCharset(contentType) {
|
|
70
|
+
const m = /(?:^|;)[ \t]{0,1000}charset[ \t]{0,1000}=[ \t]{0,1000}(?:"([\w.:-]{1,80})"|'([\w.:-]{1,80})'|([\w.:-]{1,80}))(?=[ \t;]|$)/i.exec(contentType || '')
|
|
71
|
+
return m ? m[1] || m[2] || m[3] : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sniffCharset(buffer, contentType) {
|
|
69
75
|
// BOM first — byte-level evidence beats any declared charset (a UTF-16 body
|
|
70
76
|
// with a latin1-read <meta> probe garbles the meta match anyway).
|
|
71
77
|
if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
|
|
72
78
|
if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le'
|
|
73
79
|
if (buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be'
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
const head = buffer.subarray(0, 2048).toString('latin1')
|
|
77
|
-
const meta
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const header = declaredCharset(contentType)
|
|
81
|
+
if (header) return header
|
|
82
|
+
const head = buffer.subarray(0, 2048).toString('latin1')
|
|
83
|
+
for (const meta of head.matchAll(/<meta(?=[\s/>])[^>]{0,1000}>/gi)) {
|
|
84
|
+
const attrs = htmlAttributes(meta[0])
|
|
85
|
+
const charset = attrs.charset?.trim()
|
|
86
|
+
if (charset && /^[\w.:-]{1,80}$/.test(charset)) return charset
|
|
87
|
+
if (attrs['http-equiv']?.toLowerCase() === 'content-type') {
|
|
88
|
+
const legacy = declaredCharset(attrs.content)
|
|
89
|
+
if (legacy) return legacy
|
|
90
|
+
}
|
|
91
|
+
}
|
|
83
92
|
return null
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -708,9 +717,15 @@ function stripNoise(mainHtml) {
|
|
|
708
717
|
}
|
|
709
718
|
|
|
710
719
|
// ---- HTML -> Markdown (lightweight, tag-state machine) ----
|
|
711
|
-
function escInline(s) {
|
|
712
|
-
return s.replace(/([\\`*_[\]])/g, '\\$1')
|
|
713
|
-
}
|
|
720
|
+
function escInline(s) {
|
|
721
|
+
return s.replace(/([\\`*_[\]])/g, '\\$1')
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function markdownTarget(value) {
|
|
725
|
+
return decodeUrlAttribute(value).trim()
|
|
726
|
+
.replace(/\(/g, '%28').replace(/\)/g, '%29')
|
|
727
|
+
.replace(/[<>\s\\]/g, encodeURIComponent)
|
|
728
|
+
}
|
|
714
729
|
|
|
715
730
|
// <img> is a void element (never matches the paired-tag regex in either md
|
|
716
731
|
// walker): convert descriptive images to markdown BEFORE the tag walk, via a
|
|
@@ -729,25 +744,30 @@ function imgsToMarkdown(html) {
|
|
|
729
744
|
// in <picture><source srcset>. When the <img> fallback carries no usable
|
|
730
745
|
// src (lazy placeholder), the first source URL is injected into it so the
|
|
731
746
|
// normal img pass below still emits the image.
|
|
732
|
-
html = html.replace(/<picture\b[^>]
|
|
733
|
-
const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
|
|
734
|
-
const imgSrc = imgTag &&
|
|
735
|
-
if (imgTag
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
747
|
+
html = html.replace(/<picture\b[^>]{0,1000}>([\s\S]{0,50000}?)<\/picture\s{0,1000}>/gi, (block) => {
|
|
748
|
+
const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
|
|
749
|
+
const imgSrc = imgTag && htmlAttributes(imgTag[0]).src
|
|
750
|
+
if (!imgTag || (imgSrc && !/^data:/i.test(imgSrc))) return block
|
|
751
|
+
let source = ''
|
|
752
|
+
for (const tag of block.matchAll(/<source\b[^>]{0,1000}>/gi)) {
|
|
753
|
+
source = htmlAttributes(tag[0]).srcset?.trim().split(/[\s,]{1,1000}/, 1)[0] || ''
|
|
754
|
+
if (source) break
|
|
755
|
+
}
|
|
756
|
+
if (!source) return block
|
|
757
|
+
const escaped = decodeUrlAttribute(source).replace(/&/g, '&').replace(/"/g, '"')
|
|
758
|
+
return block.replace(imgTag[0], () => `<img src="${escaped}"${imgTag[0].slice(4)}`)
|
|
759
|
+
})
|
|
760
|
+
const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
|
|
761
|
+
const attrs = htmlAttributes(tag)
|
|
762
|
+
const alt = decodeTextEntities(attrs.alt || '').replace(/\s{1,1000}/g, ' ').trim()
|
|
763
|
+
let srcVal = attrs.src?.trim()
|
|
744
764
|
// Lazy-load wrappers keep the real URL in data-* attributes; a data: URI
|
|
745
765
|
// src is an inline placeholder spacer, not a usable image reference.
|
|
746
766
|
if (!srcVal || /^data:/i.test(srcVal)) {
|
|
747
|
-
const lazy =
|
|
748
|
-
if (lazy) srcVal = lazy
|
|
749
|
-
}
|
|
750
|
-
return protect(srcVal && alt
|
|
767
|
+
const lazy = attrs['data-src'] || attrs['data-original'] || attrs['data-lazy-src']
|
|
768
|
+
if (lazy) srcVal = lazy.trim()
|
|
769
|
+
}
|
|
770
|
+
return protect(srcVal && alt ? `})` : '')
|
|
751
771
|
})
|
|
752
772
|
return {
|
|
753
773
|
html: out,
|
|
@@ -826,13 +846,13 @@ export function inlineMd(html, depth = 0) {
|
|
|
826
846
|
out += img.protect(inlineCode(m[3]))
|
|
827
847
|
continue
|
|
828
848
|
}
|
|
829
|
-
const inner = inlineMd(m[3], depth + 1)
|
|
830
|
-
if (tag === 'a') {
|
|
831
|
-
const href =
|
|
849
|
+
const inner = inlineMd(m[3], depth + 1)
|
|
850
|
+
if (tag === 'a') {
|
|
851
|
+
const href = htmlAttributes(`<a${m[2]}>`).href
|
|
832
852
|
// A raw ')' in the target would terminate the markdown link early
|
|
833
853
|
// (common on wiki/framework URLs) — percent-encode parens to keep the
|
|
834
854
|
// link parseable by the model.
|
|
835
|
-
out += href ? `[${inner}](${href
|
|
855
|
+
out += href?.trim() ? `[${inner}](${markdownTarget(href)})` : inner
|
|
836
856
|
} else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
|
|
837
857
|
else if (tag === 'em' || tag === 'i') out += `*${inner}*`
|
|
838
858
|
else out += inner
|
|
@@ -1612,8 +1632,18 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1612
1632
|
} catch {
|
|
1613
1633
|
/* keep bare url */
|
|
1614
1634
|
}
|
|
1615
|
-
const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
|
|
1616
|
-
|
|
1635
|
+
const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
|
|
1636
|
+
const hit = cache.get(cacheKey)
|
|
1637
|
+
if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
|
|
1638
|
+
// Valid content takes precedence over a concurrent fetch failure.
|
|
1639
|
+
failCache.delete(cacheKey)
|
|
1640
|
+
// LRU touch keeps hot pages available throughout their TTL.
|
|
1641
|
+
cache.delete(cacheKey)
|
|
1642
|
+
cache.set(cacheKey, hit)
|
|
1643
|
+
const sliced = sliceFrom(hit.full, offset, maxChars)
|
|
1644
|
+
return { ...sliced, cached: true }
|
|
1645
|
+
}
|
|
1646
|
+
// Failed fetches are cached briefly so the model doesn't re-request a
|
|
1617
1647
|
// broken URL in a loop (token + latency saver).
|
|
1618
1648
|
const fh = failCache.get(cacheKey)
|
|
1619
1649
|
if (fh) {
|
|
@@ -1626,15 +1656,6 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1626
1656
|
}
|
|
1627
1657
|
failCache.delete(cacheKey)
|
|
1628
1658
|
}
|
|
1629
|
-
const hit = cache.get(cacheKey)
|
|
1630
|
-
if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
|
|
1631
|
-
// LRU touch: hot pages survive eviction even when cached long ago — the
|
|
1632
|
-
// Map re-insertion moves the entry to the newest slot.
|
|
1633
|
-
cache.delete(cacheKey)
|
|
1634
|
-
cache.set(cacheKey, hit)
|
|
1635
|
-
const sliced = sliceFrom(hit.full, offset, maxChars)
|
|
1636
|
-
return { ...sliced, cached: true }
|
|
1637
|
-
}
|
|
1638
1659
|
|
|
1639
1660
|
const failWith = (error) => {
|
|
1640
1661
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
@@ -2189,11 +2210,16 @@ function sameHost(a, b) {
|
|
|
2189
2210
|
}
|
|
2190
2211
|
|
|
2191
2212
|
// URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
|
|
2192
|
-
const NOISE_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|css|js|json|xml|pdf|zip|gz|tar|7z|mp3|m3u8?|mpd|flv|ts|mp4|avi|mov|webm|woff2?|ttf|eot|map)
|
|
2193
|
-
const NOISE_PATH =
|
|
2194
|
-
function isNoiseUrl(url) {
|
|
2195
|
-
|
|
2196
|
-
|
|
2213
|
+
const NOISE_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|css|js|json|xml|pdf|zip|gz|tar|7z|mp3|m3u8?|mpd|flv|ts|mp4|avi|mov|webm|woff2?|ttf|eot|map)$/i
|
|
2214
|
+
const NOISE_PATH = /\/(?:login|signin|register|logout|signup|api|admin|wp-admin|wp-login|feed|rss|sitemap|robots\.txt|cdn-cgi)(?:\/|[.;]|$)/i
|
|
2215
|
+
function isNoiseUrl(url) {
|
|
2216
|
+
try {
|
|
2217
|
+
const path = new URL(url).pathname
|
|
2218
|
+
return NOISE_EXT.test(path) || NOISE_PATH.test(path)
|
|
2219
|
+
} catch {
|
|
2220
|
+
return true
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2197
2223
|
|
|
2198
2224
|
// BFS crawl of a single site. Only same-host http(s) pages are followed;
|
|
2199
2225
|
// static assets / auth paths are skipped; visited URLs are deduped; a per-page
|
|
@@ -2382,11 +2408,11 @@ export function apply(ctx, config) {
|
|
|
2382
2408
|
if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
|
|
2383
2409
|
|
|
2384
2410
|
// Temporal composability: unload must fully revert side effects.
|
|
2385
|
-
ctx.effect(() => () => {
|
|
2386
|
-
cache.clear()
|
|
2387
|
-
failCache.clear()
|
|
2388
|
-
closeBrowser()
|
|
2389
|
-
})
|
|
2411
|
+
ctx.effect(() => () => {
|
|
2412
|
+
cache.clear()
|
|
2413
|
+
failCache.clear()
|
|
2414
|
+
return closeBrowser()
|
|
2415
|
+
})
|
|
2390
2416
|
|
|
2391
2417
|
console.log('[dsh-read-url] plugin loaded; tools read_url, read_url_batch, read_url_links, read_url_site registered')
|
|
2392
2418
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.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",
|
package/spa.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// When absent, reads fall back to static extraction with a clear hint.
|
|
5
5
|
|
|
6
6
|
let browserPromise = null
|
|
7
|
+
const closingBrowsers = new Set()
|
|
8
|
+
|
|
9
|
+
function renderError(error) {
|
|
10
|
+
return `Render failed: ${String(error?.message || error).split('\n', 1)[0].slice(0, 500)}`
|
|
11
|
+
}
|
|
7
12
|
|
|
8
13
|
// Heuristic: a page whose HTML carries many <script> tags is likely a
|
|
9
14
|
// client-rendered SPA (Vue/React) whose body lives only after JS execution.
|
|
@@ -33,12 +38,19 @@ async function getBrowser() {
|
|
|
33
38
|
if (!browserPromise) {
|
|
34
39
|
// A failed launch (playwright later installed / chromium download finished)
|
|
35
40
|
// must not stay cached as a rejected promise — reset so the next call retries.
|
|
36
|
-
|
|
41
|
+
const pending = import('playwright')
|
|
37
42
|
.then(({ chromium }) => chromium.launch({ headless: true }))
|
|
43
|
+
.then((browser) => {
|
|
44
|
+
browser.once('disconnected', () => {
|
|
45
|
+
if (browserPromise === pending) browserPromise = null
|
|
46
|
+
})
|
|
47
|
+
return browser
|
|
48
|
+
})
|
|
38
49
|
.catch((e) => {
|
|
39
|
-
browserPromise = null
|
|
50
|
+
if (browserPromise === pending) browserPromise = null
|
|
40
51
|
throw e
|
|
41
52
|
})
|
|
53
|
+
browserPromise = pending
|
|
42
54
|
}
|
|
43
55
|
return browserPromise
|
|
44
56
|
}
|
|
@@ -49,12 +61,23 @@ export async function renderPage(url, externalSignal) {
|
|
|
49
61
|
let browser
|
|
50
62
|
try {
|
|
51
63
|
browser = await getBrowser()
|
|
52
|
-
} catch {
|
|
53
|
-
return { error: '
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
66
|
+
return { error: error?.code === 'ERR_MODULE_NOT_FOUND'
|
|
67
|
+
? 'SPA 渲染需 playwright(npm i playwright && npx playwright install chromium)'
|
|
68
|
+
: renderError(error) }
|
|
54
69
|
}
|
|
70
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
55
71
|
let page
|
|
72
|
+
let pageClosing
|
|
73
|
+
const closePage = () => {
|
|
74
|
+
if (page && !pageClosing) pageClosing = page.close().catch(() => {})
|
|
75
|
+
return pageClosing
|
|
76
|
+
}
|
|
56
77
|
try {
|
|
57
78
|
page = await browser.newPage()
|
|
79
|
+
externalSignal?.addEventListener('abort', closePage, { once: true })
|
|
80
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
58
81
|
// 'domcontentloaded' instead of 'networkidle': heartbeat-polling sites
|
|
59
82
|
// (qq-news, juejin) never go idle and would time out at 30s. Instead wait
|
|
60
83
|
// for the DOM to stabilize (content stops growing) up to 10s — SPA paint
|
|
@@ -72,7 +95,8 @@ export async function renderPage(url, externalSignal) {
|
|
|
72
95
|
.evaluate(() => (document.body ? document.body.innerHTML.length : 0))
|
|
73
96
|
.catch(() => -1)
|
|
74
97
|
if (Date.now() - t0 > 10000) break
|
|
75
|
-
|
|
98
|
+
evalFails = len === -1 ? evalFails + 1 : 0
|
|
99
|
+
if (evalFails >= 2) break
|
|
76
100
|
// stop once two consecutive reads agree — including empty bodies (a
|
|
77
101
|
// blank page should not burn the full 10s poll)
|
|
78
102
|
if (len === prevLen && prevLen >= 0) break
|
|
@@ -93,25 +117,33 @@ export async function renderPage(url, externalSignal) {
|
|
|
93
117
|
if (!looksLikeChallenge(html)) break
|
|
94
118
|
}
|
|
95
119
|
}
|
|
120
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
96
121
|
const finalUrl = page.url()
|
|
97
122
|
return { html, finalUrl }
|
|
98
123
|
} catch (e) {
|
|
99
124
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
100
|
-
return { error:
|
|
125
|
+
return { error: renderError(e) }
|
|
101
126
|
} finally {
|
|
102
|
-
|
|
127
|
+
externalSignal?.removeEventListener('abort', closePage)
|
|
128
|
+
await closePage()
|
|
103
129
|
}
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
// Called on plugin unload (temporal composability): release the browser.
|
|
107
133
|
export async function closeBrowser() {
|
|
108
|
-
if (!browserPromise) return
|
|
109
134
|
const p = browserPromise
|
|
110
135
|
browserPromise = null
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
if (p) {
|
|
137
|
+
const closing = (async () => {
|
|
138
|
+
try {
|
|
139
|
+
const b = await p
|
|
140
|
+
await b.close()
|
|
141
|
+
} catch {
|
|
142
|
+
// A failed launch or an already closed browser needs no further cleanup.
|
|
143
|
+
}
|
|
144
|
+
})()
|
|
145
|
+
closingBrowsers.add(closing)
|
|
146
|
+
closing.finally(() => closingBrowsers.delete(closing))
|
|
116
147
|
}
|
|
148
|
+
await Promise.all(closingBrowsers)
|
|
117
149
|
}
|