dsh-read-url 0.4.8 → 1.0.0
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 +73 -0
- package/README.md +59 -25
- package/README.zh.md +61 -28
- package/cordis.patch.yml +2 -0
- package/index.js +507 -65
- package/package.json +2 -2
- package/proxy-fallback.js +25 -1
- package/spa.js +41 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSeek Harness URL reader: fetch any webpage, auto-detect encoding (GBK/
|
|
3
|
+
"version": "1.0.0",
|
|
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
package/proxy-fallback.js
CHANGED
|
@@ -15,6 +15,23 @@
|
|
|
15
15
|
// model/user can act (turn the proxy on, or accept the network boundary).
|
|
16
16
|
import { execFile, execFileSync } from 'node:child_process'
|
|
17
17
|
|
|
18
|
+
// Some servers omit the Content-Type header entirely; the content-type gate
|
|
19
|
+
// then has nothing to test and a binary body (PDF/PNG/zip) would flow into
|
|
20
|
+
// the HTML pipeline as mojibake. Sniff the leading bytes instead: text
|
|
21
|
+
// documents never contain NUL bytes or a run of control characters.
|
|
22
|
+
// Shared by the direct path (index.js) and the curl proxy path below.
|
|
23
|
+
export function looksBinary(buffer) {
|
|
24
|
+
const n = Math.min(buffer.length, 512)
|
|
25
|
+
if (!n) return false
|
|
26
|
+
let ctrl = 0
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
const b = buffer[i]
|
|
29
|
+
if (b === 0) return true
|
|
30
|
+
if (b < 9 || (b > 13 && b < 32)) ctrl++
|
|
31
|
+
}
|
|
32
|
+
return ctrl / n > 0.3
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
let sysProxyCache = null // '' = checked & absent; string = proxy URL
|
|
19
36
|
let sysProxyCheckedAt = 0 // cache timestamp; a failed read is re-tried after 60s
|
|
20
37
|
const SYS_PROXY_RETRY_MS = 60 * 1000
|
|
@@ -98,8 +115,15 @@ export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride)
|
|
|
98
115
|
const [codeStr, ...ctParts] = meta.split(' ')
|
|
99
116
|
const code = Number(codeStr)
|
|
100
117
|
const contentType = ctParts.join(' ')
|
|
118
|
+
if (code >= 200 && code < 300) {
|
|
119
|
+
// Headerless responses: gate binary bodies out (same rule as the direct
|
|
120
|
+
// path — curl writes an empty %{content_type} then).
|
|
121
|
+
if (!contentType && looksBinary(body)) {
|
|
122
|
+
return { error: 'Unsupported content-type (no header, binary body)' }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
101
125
|
if (!(code >= 200 && code < 300)) return { error: `HTTP ${code}` }
|
|
102
|
-
if (contentType && !/text\/html|application\/xhtml|text\/plain/i.test(contentType)) {
|
|
126
|
+
if (contentType && !/text\/html|application\/xhtml|text\/plain|\/json|[+/]xml/i.test(contentType)) {
|
|
103
127
|
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
104
128
|
}
|
|
105
129
|
return { buffer: body, contentType, finalUrl: url, viaProxy: proxy }
|
package/spa.js
CHANGED
|
@@ -18,10 +18,27 @@ export function looksLikeSpa(html) {
|
|
|
18
18
|
return n >= 5
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// High-specificity markers of a bot-challenge interstitial (Cloudflare and
|
|
22
|
+
// friends). Measured: ruanyifeng.com served "Just a moment..." whose DOM
|
|
23
|
+
// stabilizes while the challenge sits in "Verification successful. Waiting
|
|
24
|
+
// for ... to respond" — the poll loop returned that transitional page and
|
|
25
|
+
// the caller mistook it for an improvement over static extraction.
|
|
26
|
+
const CHALLENGE_RE = /just a moment|performing security verification|verifying (you are|your) human|cf-chl|cf_chl|cdn-cgi\/challenge-platform|attention required!\s*[|·]?\s*cloudflare|enable javascript and cookies to continue/i
|
|
27
|
+
|
|
28
|
+
export function looksLikeChallenge(html) {
|
|
29
|
+
return typeof html === 'string' && CHALLENGE_RE.test(html)
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
async function getBrowser() {
|
|
22
33
|
if (!browserPromise) {
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
// A failed launch (playwright later installed / chromium download finished)
|
|
35
|
+
// must not stay cached as a rejected promise — reset so the next call retries.
|
|
36
|
+
browserPromise = import('playwright')
|
|
37
|
+
.then(({ chromium }) => chromium.launch({ headless: true }))
|
|
38
|
+
.catch((e) => {
|
|
39
|
+
browserPromise = null
|
|
40
|
+
throw e
|
|
41
|
+
})
|
|
25
42
|
}
|
|
26
43
|
return browserPromise
|
|
27
44
|
}
|
|
@@ -60,7 +77,21 @@ export async function renderPage(url, externalSignal) {
|
|
|
60
77
|
if (len === prevLen && prevLen >= 0) break
|
|
61
78
|
prevLen = len
|
|
62
79
|
}
|
|
63
|
-
|
|
80
|
+
// A bot-challenge interstitial "stabilizes" the same way real content
|
|
81
|
+
// does while it waits to redirect. When the DOM settled on a challenge
|
|
82
|
+
// page, keep polling briefly: the challenge often clears and navigates
|
|
83
|
+
// on its own within a few seconds (measured: Cloudflare "Verification
|
|
84
|
+
// successful. Waiting for ... to respond").
|
|
85
|
+
let html = await page.content()
|
|
86
|
+
if (looksLikeChallenge(html)) {
|
|
87
|
+
const t1 = Date.now()
|
|
88
|
+
while (Date.now() - t1 < 8000) {
|
|
89
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
90
|
+
await page.waitForTimeout(700)
|
|
91
|
+
html = await page.content()
|
|
92
|
+
if (!looksLikeChallenge(html)) break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
64
95
|
const finalUrl = page.url()
|
|
65
96
|
return { html, finalUrl }
|
|
66
97
|
} catch (e) {
|
|
@@ -73,9 +104,13 @@ export async function renderPage(url, externalSignal) {
|
|
|
73
104
|
|
|
74
105
|
// Called on plugin unload (temporal composability): release the browser.
|
|
75
106
|
export async function closeBrowser() {
|
|
76
|
-
if (browserPromise)
|
|
77
|
-
|
|
107
|
+
if (!browserPromise) return
|
|
108
|
+
const p = browserPromise
|
|
109
|
+
browserPromise = null
|
|
110
|
+
try {
|
|
111
|
+
const b = await p
|
|
78
112
|
await b.close().catch(() => {})
|
|
79
|
-
|
|
113
|
+
} catch {
|
|
114
|
+
// launch never completed — nothing to close
|
|
80
115
|
}
|
|
81
116
|
}
|