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/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
+ }