dsh-read-url 0.4.7
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 +260 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/README.zh.md +233 -0
- package/cordis.patch.yml +12 -0
- package/index.js +1059 -0
- package/package.json +40 -0
- package/proxy-fallback.js +106 -0
- package/spa.js +81 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-read-url",
|
|
3
|
+
"version": "0.4.7",
|
|
4
|
+
"description": "DeepSeek Harness URL reader: fetch any webpage, auto-detect encoding (GBK/GB2312/UTF-8/Big5), extract clean main content, output compact text/Markdown to save tokens. Zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"spa.js",
|
|
10
|
+
"proxy-fallback.js",
|
|
11
|
+
"cordis.patch.yml",
|
|
12
|
+
"README.md",
|
|
13
|
+
"README.zh.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/2672243194/dsh-read-url.git"
|
|
20
|
+
},
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "cordis.patch.yml"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"deepseek-harness",
|
|
28
|
+
"dsh",
|
|
29
|
+
"dsh-plugin",
|
|
30
|
+
"readability",
|
|
31
|
+
"web",
|
|
32
|
+
"markdown",
|
|
33
|
+
"reader",
|
|
34
|
+
"url-reader"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// proxy-fallback.js — retry a failed direct fetch through the user's proxy
|
|
2
|
+
//
|
|
3
|
+
// Design: direct connect first, proxy only as fallback. When the direct
|
|
4
|
+
// fetch fails with a CONNECTION-class error (blocked network, refused,
|
|
5
|
+
// DNS miss, connect timeout), we look up the user's own proxy — first the
|
|
6
|
+
// standard HTTP_PROXY/HTTPS_PROXY env vars, then the Windows system proxy
|
|
7
|
+
// (registry) where proxy apps like Clash actually persist it — and retry
|
|
8
|
+
// once through it via the system `curl` (ships with Windows 10+/macOS/Linux;
|
|
9
|
+
// zero npm deps). The proxy URL is passed explicitly with `-x`, so we don't
|
|
10
|
+
// depend on curl's env-var reading.
|
|
11
|
+
//
|
|
12
|
+
// Transparency: a successful proxied read returns exactly like a direct one
|
|
13
|
+
// (the caller never knows); a failed proxied attempt returns null and the
|
|
14
|
+
// caller reports the ORIGINAL direct-connect error with a clear note, so the
|
|
15
|
+
// model/user can act (turn the proxy on, or accept the network boundary).
|
|
16
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
17
|
+
|
|
18
|
+
let sysProxyCache = null // '' = checked & absent; string = proxy URL
|
|
19
|
+
|
|
20
|
+
// Windows system proxy from the registry (HKCU Internet Settings). PowerShell
|
|
21
|
+
// is used because `reg.exe` is frequently blocked by security policy; the
|
|
22
|
+
// read is synchronous and cached after the first call (it only runs after a
|
|
23
|
+
// direct-connect failure, so the ~1s cost is invisible on the happy path).
|
|
24
|
+
function readWindowsSystemProxy() {
|
|
25
|
+
if (sysProxyCache !== null) return sysProxyCache
|
|
26
|
+
sysProxyCache = ''
|
|
27
|
+
try {
|
|
28
|
+
const out = execFileSync(
|
|
29
|
+
'powershell',
|
|
30
|
+
[
|
|
31
|
+
'-NoProfile', '-Command',
|
|
32
|
+
"(Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings').ProxyEnable, (Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings').ProxyServer",
|
|
33
|
+
],
|
|
34
|
+
{ encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
35
|
+
)
|
|
36
|
+
const [enable, server] = out.trim().split(/\r?\n/).map((s) => s.trim())
|
|
37
|
+
if (enable !== '1' || !server) return sysProxyCache
|
|
38
|
+
// ProxyServer may be "host:port" or "http=...;https=host:port"
|
|
39
|
+
const httpsPart = server.split(';').map((s) => s.trim()).find((s) => /^https=/i.test(s))
|
|
40
|
+
sysProxyCache = (httpsPart ? httpsPart.replace(/^https=/i, '') : server) || ''
|
|
41
|
+
} catch {
|
|
42
|
+
sysProxyCache = ''
|
|
43
|
+
}
|
|
44
|
+
return sysProxyCache
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The user's proxy, in priority order: env vars, then Windows system proxy.
|
|
48
|
+
export async function detectProxy() {
|
|
49
|
+
const env =
|
|
50
|
+
process.env.HTTPS_PROXY ||
|
|
51
|
+
process.env.https_proxy ||
|
|
52
|
+
process.env.HTTP_PROXY ||
|
|
53
|
+
process.env.http_proxy
|
|
54
|
+
if (env) return env
|
|
55
|
+
if (process.platform === 'win32') return readWindowsSystemProxy()
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Returns { buffer, contentType, finalUrl } on success, { error } when the
|
|
60
|
+
// proxy answered but the target failed, or null when there is no proxy /
|
|
61
|
+
// curl is unavailable / the proxy itself is unreachable.
|
|
62
|
+
// `proxyOverride` lets the caller pass an already-detected proxy (the race
|
|
63
|
+
// path probes once up front) instead of re-detecting here.
|
|
64
|
+
export async function fetchViaCurlProxy(url, cfg, externalSignal, proxyOverride) {
|
|
65
|
+
const proxy = proxyOverride || (await detectProxy())
|
|
66
|
+
if (!proxy) return null
|
|
67
|
+
const maxSec = Math.max(10, Math.ceil(cfg.timeoutMs / 1000) + 5)
|
|
68
|
+
const args = [
|
|
69
|
+
'-sS',
|
|
70
|
+
'-L', // follow redirects, like the direct fetch path
|
|
71
|
+
'--max-time', String(maxSec),
|
|
72
|
+
'--max-filesize', String(cfg.maxBytes), // unit is BYTES, not KB
|
|
73
|
+
'-x', proxy,
|
|
74
|
+
'-A', cfg.userAgent,
|
|
75
|
+
'-w', '\n%{http_code} %{content_type}',
|
|
76
|
+
url,
|
|
77
|
+
]
|
|
78
|
+
try {
|
|
79
|
+
const buf = await new Promise((resolve, reject) => {
|
|
80
|
+
execFile('curl', args, { maxBuffer: cfg.maxBytes + 8192, encoding: 'buffer', windowsHide: true, signal: externalSignal || undefined }, (err, stdout) => {
|
|
81
|
+
if (err) return reject(err)
|
|
82
|
+
resolve(stdout)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
// tail line carries the metadata: "<http_code> <content_type>"
|
|
86
|
+
const nl = buf.lastIndexOf(0x0a)
|
|
87
|
+
if (nl < 0) return null // no trailing metadata — abnormal response, treat as failure
|
|
88
|
+
const meta = buf.subarray(nl + 1).toString('latin1').trim()
|
|
89
|
+
const body = buf.subarray(0, nl)
|
|
90
|
+
const [codeStr, ...ctParts] = meta.split(' ')
|
|
91
|
+
const code = Number(codeStr)
|
|
92
|
+
const contentType = ctParts.join(' ')
|
|
93
|
+
if (!(code >= 200 && code < 300)) return { error: `HTTP ${code}` }
|
|
94
|
+
if (contentType && !/text\/html|application\/xhtml|text\/plain/i.test(contentType)) {
|
|
95
|
+
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
96
|
+
}
|
|
97
|
+
return { buffer: body, contentType, finalUrl: url, viaProxy: proxy }
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// curl error 63 = exceeded --max-filesize (page too large, not a proxy
|
|
100
|
+
// problem — report it as such instead of "proxy unreachable")
|
|
101
|
+
if (err && err.code === 63) return { error: `Page exceeds ${cfg.maxBytes} bytes (via proxy)` }
|
|
102
|
+
// curl missing, proxy down, or cancelled — let the caller report the
|
|
103
|
+
// original direct-connect error (the proxy attempt is best-effort)
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
package/spa.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// spa.js — optional SPA rendering enhancement for dsh-read-url
|
|
2
|
+
// Zero-dependency by default; activates only when `playwright` is installed
|
|
3
|
+
// in the DSH profile directory (npm i playwright && npx playwright install chromium).
|
|
4
|
+
// When absent, reads fall back to static extraction with a clear hint.
|
|
5
|
+
|
|
6
|
+
let browserPromise = null
|
|
7
|
+
|
|
8
|
+
// Heuristic: a page whose HTML carries many <script> tags is likely a
|
|
9
|
+
// client-rendered SPA (Vue/React) whose body lives only after JS execution.
|
|
10
|
+
// Counts with exec() instead of match() to avoid materializing a large array
|
|
11
|
+
// for multi-MB HTML (match() builds an array entry per script tag).
|
|
12
|
+
export function looksLikeSpa(html) {
|
|
13
|
+
if (!html) return false
|
|
14
|
+
const re = /<script[\s>]/gi
|
|
15
|
+
let n = 0
|
|
16
|
+
let m
|
|
17
|
+
while ((m = re.exec(html)) && n < 5) n++
|
|
18
|
+
return n >= 5
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getBrowser() {
|
|
22
|
+
if (!browserPromise) {
|
|
23
|
+
const { chromium } = await import('playwright')
|
|
24
|
+
browserPromise = chromium.launch({ headless: true })
|
|
25
|
+
}
|
|
26
|
+
return browserPromise
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Render a URL with headless Chromium and return the post-JS DOM HTML.
|
|
30
|
+
export async function renderPage(url, externalSignal) {
|
|
31
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
32
|
+
let browser
|
|
33
|
+
try {
|
|
34
|
+
browser = await getBrowser()
|
|
35
|
+
} catch {
|
|
36
|
+
return { error: 'SPA 渲染需 playwright(npm i playwright && npx playwright install chromium)' }
|
|
37
|
+
}
|
|
38
|
+
let page
|
|
39
|
+
try {
|
|
40
|
+
page = await browser.newPage()
|
|
41
|
+
// 'domcontentloaded' instead of 'networkidle': heartbeat-polling sites
|
|
42
|
+
// (qq-news, juejin) never go idle and would time out at 30s. Instead wait
|
|
43
|
+
// for the DOM to stabilize (content stops growing) up to 10s — SPA paint
|
|
44
|
+
// usually lands within a couple of seconds of DOM-ready.
|
|
45
|
+
// The cooperative signal aborts both the navigation and the poll loop, so
|
|
46
|
+
// a cancelled/timed-out call releases the browser immediately instead of
|
|
47
|
+
// burning the full poll window.
|
|
48
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000, signal: externalSignal || undefined })
|
|
49
|
+
const t0 = Date.now()
|
|
50
|
+
let prevLen = -1
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
53
|
+
await page.waitForTimeout(500)
|
|
54
|
+
const len = await page
|
|
55
|
+
.evaluate(() => (document.body ? document.body.innerHTML.length : 0))
|
|
56
|
+
.catch(() => -1)
|
|
57
|
+
if (Date.now() - t0 > 10000) break
|
|
58
|
+
// stop once two consecutive reads agree — including empty bodies (a
|
|
59
|
+
// blank page should not burn the full 10s poll)
|
|
60
|
+
if (len === prevLen && prevLen >= 0) break
|
|
61
|
+
prevLen = len
|
|
62
|
+
}
|
|
63
|
+
const html = await page.content()
|
|
64
|
+
const finalUrl = page.url()
|
|
65
|
+
return { html, finalUrl }
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
68
|
+
return { error: `Render failed: ${e.message}` }
|
|
69
|
+
} finally {
|
|
70
|
+
if (page) await page.close().catch(() => {})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Called on plugin unload (temporal composability): release the browser.
|
|
75
|
+
export async function closeBrowser() {
|
|
76
|
+
if (browserPromise) {
|
|
77
|
+
const b = await browserPromise
|
|
78
|
+
await b.close().catch(() => {})
|
|
79
|
+
browserPromise = null
|
|
80
|
+
}
|
|
81
|
+
}
|