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 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 (12 assertions; SKIPs if playwright absent)
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 # 12 断言:JS 正文/渲染后链接/工具不崩溃/缓存隔离/JS 跳转壳
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 sniffCharset(buffer, contentType) {
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 m = /charset=["']?([\w-]+)/i.exec(contentType || '')
75
- if (m) return m[1]
76
- const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
77
- const meta = /<meta[^>]{0,1000}charset=["']?([\w-]+)/i.exec(head)
78
- if (meta) return meta[1]
79
- // Legacy form: <meta http-equiv="Content-Type" content="text/html; charset=gb2312">
80
- // (no charset= attribute anywhere) — common on old GBK pages with no header charset.
81
- const metaEquiv = /<meta[^>]{0,1000}(?:http-equiv)=["']content-type["'][^>]{0,1000}content=["'][^"']*charset=([\w-]+)/i.exec(head)
82
- if (metaEquiv) return metaEquiv[1]
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[^>]*>([\s\S]{0,50000}?)<\/picture\s*>/gi, (block) => {
733
- const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
734
- const imgSrc = imgTag && /(?:^|\s)src=["']([^"']+)["']/i.exec(imgTag[0])
735
- if (imgTag && imgSrc && !/^data:/i.test(imgSrc[1])) return block
736
- const source = /<source\b[^>]{0,1000}\bsrcset=["']([^"'\s>]+)/i.exec(block)
737
- if (!source || !imgTag) return block
738
- return block.replace(/<img\b[^>]{0,1000}>/i, (img0) => img0.replace(/<img/i, `<img src="${source[1]}"`))
739
- })
740
- const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
741
- const alt = /alt=["']([^"']*)["']/i.exec(tag)
742
- let src = /src=["']([^"']+)["']/i.exec(tag)
743
- let srcVal = src && src[1]
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, '&amp;').replace(/"/g, '&quot;')
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 = /data-(?:src|original|lazy-src)=["']([^"']+)["']/i.exec(tag)
748
- if (lazy) srcVal = lazy[1]
749
- }
750
- return protect(srcVal && alt && alt[1].trim() ? `![${alt[1].trim()}](${srcVal})` : '')
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 ? `![${escInline(alt)}](${markdownTarget(srcVal)})` : '')
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 = /href=["']([^"']+)["']/i.exec(m[2])
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[1].replace(/\)/g, '%29').replace(/\(/g, '%28')})` : inner
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
- // Failed fetches are cached briefly so the model doesn't re-request a
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)(\?|#|$)/i
2193
- const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
2194
- function isNoiseUrl(url) {
2195
- return NOISE_EXT.test(url) || NOISE_PATH.test(url)
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().catch(() => {})
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.0",
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
- browserPromise = import('playwright')
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: 'SPA 渲染需 playwright(npm i playwright && npx playwright install chromium)' }
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
- if (len === -1 && ++evalFails >= 2) break
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: `Render failed: ${e.message}` }
125
+ return { error: renderError(e) }
101
126
  } finally {
102
- if (page) await page.close().catch(() => {})
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
- try {
112
- const b = await p
113
- await b.close().catch(() => {})
114
- } catch {
115
- // launch never completed — nothing to close
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
  }