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/CHANGELOG.md +44 -0
- package/README.md +27 -8
- package/README.zh.md +27 -8
- package/index.js +619 -379
- package/package.json +1 -1
- package/proxy-fallback.js +22 -1
- package/spa.js +45 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "1.
|
|
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 &&
|
|
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
|
-
|
|
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
|
}
|