dsh-read-url 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "1.7.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/proxy-fallback.js CHANGED
@@ -23,6 +23,19 @@ import { execFile, execFileSync } from 'node:child_process'
23
23
  export function looksBinary(buffer) {
24
24
  const n = Math.min(buffer.length, 512)
25
25
  if (!n) return false
26
+ const utf16le = buffer[0] === 0xff && buffer[1] === 0xfe
27
+ const utf16be = buffer[0] === 0xfe && buffer[1] === 0xff
28
+ if ((utf16le || utf16be) && buffer.length % 2 === 0) {
29
+ let controls = 0
30
+ let units = 0
31
+ for (let i = 2; i + 1 < n; i += 2) {
32
+ const code = utf16le ? buffer[i] | (buffer[i + 1] << 8) : (buffer[i] << 8) | buffer[i + 1]
33
+ if (code === 0) return true
34
+ if (code < 9 || (code > 13 && code < 32)) controls++
35
+ units++
36
+ }
37
+ return units > 0 && controls / units > 0.3
38
+ }
26
39
  let ctrl = 0
27
40
  for (let i = 0; i < n; i++) {
28
41
  const b = buffer[i]
@@ -32,6 +45,14 @@ export function looksBinary(buffer) {
32
45
  return ctrl / n > 0.3
33
46
  }
34
47
 
48
+ // Shared by direct and proxy retrieval so a document's route cannot change
49
+ // which text formats are accepted. Structured suffixes include vendor APIs.
50
+ export function isFetchableContentType(contentType) {
51
+ const mime = (contentType || '').split(';', 1)[0].trim().toLowerCase()
52
+ return ['text/html', 'application/xhtml+xml', 'text/plain', 'text/markdown', 'text/csv', 'application/json', 'text/json', 'application/xml', 'text/xml'].includes(mime) ||
53
+ /^[a-z][a-z0-9!#$&^_.+-]{0,63}\/(?:[a-z0-9!#$&^_.+-]{1,127}\+)?(?:json|xml)$/.test(mime)
54
+ }
55
+
35
56
  let sysProxyCache = null // '' = checked & absent; string = proxy URL
36
57
  let sysProxyCheckedAt = 0 // cache timestamp; a failed read is re-tried after 60s
37
58
  const SYS_PROXY_RETRY_MS = 60 * 1000
@@ -129,7 +150,7 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
129
150
  }
130
151
  }
131
152
  if (!(code >= 200 && code < 300)) return { error: `HTTP ${code}` }
132
- if (contentType && !/text\/html|application\/xhtml|text\/plain|\/json|[+/]xml/i.test(contentType)) {
153
+ if (contentType && !isFetchableContentType(contentType)) {
133
154
  return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
134
155
  }
135
156
  return { buffer: body, contentType, finalUrl: finalUrlEff, viaProxy: proxy }
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
  }