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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "0.4.8",
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.",
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
- const { chromium } = await import('playwright')
24
- browserPromise = chromium.launch({ headless: true })
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
- const html = await page.content()
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
- const b = await browserPromise
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
- browserPromise = null
113
+ } catch {
114
+ // launch never completed — nothing to close
80
115
  }
81
116
  }