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/index.js
CHANGED
|
@@ -7,15 +7,18 @@
|
|
|
7
7
|
// (temporal composability, docs/cordis-primer.md)
|
|
8
8
|
// - cooperative tool-call timeout via ToolDefinition.timeoutMs + exec.signal
|
|
9
9
|
// Focus: token economy + smarter extraction for DSH agents.
|
|
10
|
-
// - charset auto-detect (GBK/GB2312/UTF-8/Big5)
|
|
10
|
+
// - charset auto-detect (BOM UTF-16 / GBK/GB2312/UTF-8/Big5/Shift-JIS)
|
|
11
|
+
// - content dispatch: HTML / JSON APIs / RSS-Atom feeds natively
|
|
11
12
|
// - clean main-content extraction (heuristic; optional @mozilla/readability upgrade path)
|
|
13
|
+
// + text-density fallback on the body path
|
|
14
|
+
// - auto-joined pagination, page metadata (published/author)
|
|
12
15
|
// - HTML -> Markdown / plain text, paragraph-aligned smart truncation
|
|
13
16
|
// - session-level cache, compact text render (lowest token cost)
|
|
14
17
|
import { TextDecoder } from 'node:util'
|
|
15
|
-
import { looksLikeSpa, renderPage, closeBrowser } from './spa.js'
|
|
16
|
-
import { detectProxy, fetchViaCurlProxy } from './proxy-fallback.js'
|
|
18
|
+
import { looksLikeSpa, renderPage, closeBrowser, looksLikeChallenge } from './spa.js'
|
|
19
|
+
import { detectProxy, fetchViaCurlProxy, looksBinary } from './proxy-fallback.js'
|
|
17
20
|
// Re-export for tests / programmatic (PTC) cleanup.
|
|
18
|
-
export { closeBrowser, renderPage, looksLikeSpa } from './spa.js'
|
|
21
|
+
export { closeBrowser, renderPage, looksLikeSpa, looksLikeChallenge } from './spa.js'
|
|
19
22
|
|
|
20
23
|
export const name = 'dsh-read-url'
|
|
21
24
|
export const inject = ['tools']
|
|
@@ -29,9 +32,13 @@ const DEFAULTS = {
|
|
|
29
32
|
cacheTtlMs: 300000,
|
|
30
33
|
cacheMax: 32,
|
|
31
34
|
spaRender: true,
|
|
35
|
+
paginate: true,
|
|
36
|
+
paginateMax: 3,
|
|
32
37
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// Session-level cache: url -> { time, full } on success, { time, error } on
|
|
41
|
+
// failure (short TTL so a broken URL is not re-fetched in a loop).
|
|
35
42
|
const cache = new Map()
|
|
36
43
|
const decoders = new Map()
|
|
37
44
|
|
|
@@ -49,12 +56,16 @@ function getDecoder(enc) {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
function sniffCharset(buffer, contentType) {
|
|
59
|
+
// BOM first — byte-level evidence beats any declared charset (a UTF-16 body
|
|
60
|
+
// with a latin1-read <meta> probe garbles the meta match anyway).
|
|
61
|
+
if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
|
|
62
|
+
if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le'
|
|
63
|
+
if (buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be'
|
|
52
64
|
const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
|
|
53
65
|
if (m) return m[1]
|
|
54
66
|
const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
|
|
55
67
|
const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)
|
|
56
68
|
if (meta) return meta[1]
|
|
57
|
-
if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
|
|
58
69
|
return null
|
|
59
70
|
}
|
|
60
71
|
|
|
@@ -75,29 +86,61 @@ export function decodeBuffer(buffer, contentType) {
|
|
|
75
86
|
return { text, charset: enc }
|
|
76
87
|
}
|
|
77
88
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
89
|
+
// Content types this plugin can consume. Beyond HTML: JSON (data APIs) and
|
|
90
|
+
// XML (RSS/Atom feeds) are read natively — see readUrl's type dispatch.
|
|
91
|
+
// Note "application/rss+xml": the separator before xml is '+', not '/'.
|
|
92
|
+
const FETCHABLE_CT = /text\/html|application\/xhtml|text\/plain|\/json|[+/]xml/i
|
|
93
|
+
|
|
94
|
+
// Retry-After header (seconds form) → ms, capped; undefined when absent/invalid.
|
|
95
|
+
function retryAfterMs(res) {
|
|
96
|
+
const v = res.headers.get('retry-after')
|
|
97
|
+
if (!v) return undefined
|
|
98
|
+
const sec = Number(v)
|
|
99
|
+
if (!Number.isFinite(sec) || sec < 0) return undefined
|
|
100
|
+
return Math.min(sec * 1000, 5000)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Single fetch attempt. Never throws; maps abort/timeout/network to { error }.
|
|
104
|
+
async function directFetchOnce(url, signal, cfg) {
|
|
82
105
|
try {
|
|
83
106
|
const res = await fetch(url, {
|
|
84
107
|
redirect: 'follow',
|
|
85
108
|
signal,
|
|
86
109
|
headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
|
|
87
110
|
})
|
|
88
|
-
|
|
111
|
+
// Early-return paths must cancel the body stream, or the connection is
|
|
112
|
+
// held open undrained and cannot return to the keep-alive pool.
|
|
113
|
+
const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
// Rate-limit / temporary-overload: honor Retry-After with one retry
|
|
116
|
+
// (capped at 5s so the cooperative timeout still bounds the call).
|
|
117
|
+
if (res.status === 429 || res.status === 503) {
|
|
118
|
+
const ra = retryAfterMs(res)
|
|
119
|
+
discardBody()
|
|
120
|
+
if (ra !== undefined) return { retryAfterMs: ra }
|
|
121
|
+
}
|
|
122
|
+
discardBody()
|
|
123
|
+
return { error: `HTTP ${res.status} ${res.statusText}` }
|
|
124
|
+
}
|
|
89
125
|
const contentType = res.headers.get('content-type') || ''
|
|
90
|
-
if (contentType &&
|
|
126
|
+
if (contentType && !FETCHABLE_CT.test(contentType)) {
|
|
127
|
+
discardBody()
|
|
91
128
|
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
92
129
|
}
|
|
130
|
+
if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url }
|
|
93
131
|
const chunks = []
|
|
94
132
|
let size = 0
|
|
95
133
|
for await (const chunk of res.body) {
|
|
96
134
|
size += chunk.length
|
|
97
|
-
if (size > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes` }
|
|
135
|
+
if (size > cfg.maxBytes) { discardBody(); return { error: `Page exceeds ${cfg.maxBytes} bytes` } }
|
|
98
136
|
chunks.push(chunk)
|
|
99
137
|
}
|
|
100
|
-
|
|
138
|
+
const buffer = Buffer.concat(chunks)
|
|
139
|
+
// Headerless responses: gate binary bodies out of the HTML pipeline.
|
|
140
|
+
if (!contentType && looksBinary(buffer)) {
|
|
141
|
+
return { error: 'Unsupported content-type (no header, binary body)' }
|
|
142
|
+
}
|
|
143
|
+
return { buffer, contentType, finalUrl: res.url }
|
|
101
144
|
} catch (e) {
|
|
102
145
|
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
103
146
|
return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
|
|
@@ -110,6 +153,19 @@ async function directFetch(url, signal, cfg) {
|
|
|
110
153
|
}
|
|
111
154
|
}
|
|
112
155
|
|
|
156
|
+
// Direct-connect fetch with a single Retry-After-aware retry on 429/503.
|
|
157
|
+
async function directFetch(url, signal, cfg) {
|
|
158
|
+
const first = await directFetchOnce(url, signal, cfg)
|
|
159
|
+
if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
|
|
160
|
+
await new Promise((resolve) => {
|
|
161
|
+
const t = setTimeout(resolve, first.retryAfterMs)
|
|
162
|
+
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
|
|
163
|
+
})
|
|
164
|
+
return directFetchOnce(url, signal, cfg)
|
|
165
|
+
}
|
|
166
|
+
return first
|
|
167
|
+
}
|
|
168
|
+
|
|
113
169
|
// Settle-all race that resolves with the FIRST SUCCESSFUL result (a value
|
|
114
170
|
// with a buffer and no error). If every attempt fails it resolves with
|
|
115
171
|
// { success:false, failures:[...] } once the slowest one has settled —
|
|
@@ -178,9 +234,11 @@ async function fetchPage(url, externalSignal, cfg) {
|
|
|
178
234
|
// Seam calls get the same cooperative timeout as fetchPage (a hanging
|
|
179
235
|
// provider should not block the tool call forever).
|
|
180
236
|
async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
|
|
181
|
-
const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
|
|
182
|
-
if (!web || typeof web.fetch !== 'function') return null
|
|
183
237
|
try {
|
|
238
|
+
// ctx.get for a non-injected service throws on strict cordis hosts (seen
|
|
239
|
+
// with 'settings') — treat that as "seam absent" and fall through.
|
|
240
|
+
const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
|
|
241
|
+
if (!web || typeof web.fetch !== 'function') return null
|
|
184
242
|
const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
|
|
185
243
|
const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
|
|
186
244
|
const res = await web.fetch(url, { signal })
|
|
@@ -236,26 +294,136 @@ function textOnly(html) {
|
|
|
236
294
|
.trim())
|
|
237
295
|
}
|
|
238
296
|
|
|
297
|
+
// ---- JSON / RSS / Atom content dispatch ----
|
|
298
|
+
// A URL is not always an HTML page: data APIs answer JSON and content sources
|
|
299
|
+
// answer feeds. Both are model-readable when compacted, so read_url handles
|
|
300
|
+
// them natively instead of erroring with "Unsupported content-type".
|
|
301
|
+
|
|
302
|
+
function xmlText(s) {
|
|
303
|
+
let t = String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
304
|
+
// Feeds double-escape HTML in descriptions (<a href=…>查看全文</a>):
|
|
305
|
+
// one strip-then-decode pass leaves literal tags in the rendered text
|
|
306
|
+
// (measured on sspai.com/feed). Iterate strip → decode until stable.
|
|
307
|
+
for (let i = 0; i < 3; i++) {
|
|
308
|
+
const prev = t
|
|
309
|
+
t = decodeTextEntities(t.replace(/<[^>]+>/g, ' '))
|
|
310
|
+
if (t === prev) break
|
|
311
|
+
}
|
|
312
|
+
return t.replace(/\s+/g, ' ').trim()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// RSS 2.0 <item> / Atom <entry> → compact list "title — url\n summary".
|
|
316
|
+
function parseFeed(xml, limit) {
|
|
317
|
+
const isAtom = /<feed[\s>]/i.test(xml)
|
|
318
|
+
const itemRe = isAtom ? /<entry[\s>][\s\S]*?<\/entry\s*>/gi : /<item[\s>][\s\S]*?<\/item\s*>/gi
|
|
319
|
+
const items = []
|
|
320
|
+
let m
|
|
321
|
+
while ((m = itemRe.exec(xml)) && items.length < limit) {
|
|
322
|
+
const it = m[0]
|
|
323
|
+
const title = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(it)?.[1] || '')
|
|
324
|
+
const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
|
|
325
|
+
const linkHref = /<link[^>]+href=["']([^"']+)["']/i.exec(it)
|
|
326
|
+
const link = (linkTag && xmlText(linkTag[1])) || (linkHref && linkHref[1]) || ''
|
|
327
|
+
const desc = xmlText(/<(?:description|summary|content)[^>]*>([\s\S]*?)<\/(?:description|summary|content)>/i.exec(it)?.[1] || '')
|
|
328
|
+
if (!title && !link && !desc) continue
|
|
329
|
+
items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
|
|
330
|
+
}
|
|
331
|
+
const feedTitle = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(xml)?.[1] || '')
|
|
332
|
+
const lines = []
|
|
333
|
+
for (const it of items) {
|
|
334
|
+
lines.push(`- ${it.title}${it.url ? ` — ${it.url}` : ''}`)
|
|
335
|
+
if (it.summary) lines.push(` ${it.summary}`)
|
|
336
|
+
}
|
|
337
|
+
return { title: feedTitle, count: items.length, items, text: lines.join('\n') }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- Text-density fallback (body path only) ----
|
|
341
|
+
// Pages without <article>/<main> degrade to the whole <body>: semantic noise
|
|
342
|
+
// stripping then still leaves link-dominated blocks (related posts, category
|
|
343
|
+
// sidebars, "hot articles" widgets) that carry little reading value. Split the
|
|
344
|
+
// body at block-level open tags and drop segments whose text is mostly links.
|
|
345
|
+
// Standard-container pages (article/main) never reach this code path.
|
|
346
|
+
const BLOCK_OPEN_RE = /<(?:p|div|section|article|main|h[1-6]|table|tbody|tr|ul|ol|li|blockquote|pre|dl|figure|figcaption|fieldset)\b[^>]*>/gi
|
|
347
|
+
|
|
348
|
+
function linkTextLength(html) {
|
|
349
|
+
let n = 0
|
|
350
|
+
const re = /<a[\s>][\s\S]*?<\/a\s*>/gi
|
|
351
|
+
let m
|
|
352
|
+
while ((m = re.exec(html))) n += textOnly(m[0]).length
|
|
353
|
+
return n
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function densityFilter(html) {
|
|
357
|
+
const marks = []
|
|
358
|
+
BLOCK_OPEN_RE.lastIndex = 0
|
|
359
|
+
let m
|
|
360
|
+
while ((m = BLOCK_OPEN_RE.exec(html))) marks.push(m.index)
|
|
361
|
+
if (marks.length < 2) return html
|
|
362
|
+
const segs = []
|
|
363
|
+
if (marks[0] > 0) segs.push(html.slice(0, marks[0]))
|
|
364
|
+
for (let k = 0; k < marks.length; k++) {
|
|
365
|
+
segs.push(html.slice(marks[k], k + 1 < marks.length ? marks[k + 1] : html.length))
|
|
366
|
+
}
|
|
367
|
+
const kept = segs.filter((seg) => {
|
|
368
|
+
const text = textOnly(seg)
|
|
369
|
+
if (text.length < 2) return false
|
|
370
|
+
// short link-dominated segment = nav item / recommendation widget entry
|
|
371
|
+
if (text.length < 300 && linkTextLength(seg) > text.length * 0.65) return false
|
|
372
|
+
return true
|
|
373
|
+
})
|
|
374
|
+
return kept.length ? kept.join('') : html
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Depth-counted block extraction: returns html from the opening tag at
|
|
378
|
+
// openIdx through its BALANCED closing tag. A non-greedy regex stops at the
|
|
379
|
+
// first nested </div> — measured: gnu.org "content" div cut to 2700 chars,
|
|
380
|
+
// naver.com "container" to 115 (zero text). Container divs nest heavily.
|
|
381
|
+
function tagBlockAt(html, openIdx, tagName) {
|
|
382
|
+
const re = new RegExp(`</?${tagName}(?=[\\s>])`, 'gi')
|
|
383
|
+
re.lastIndex = openIdx
|
|
384
|
+
let depth = 0
|
|
385
|
+
let m
|
|
386
|
+
while ((m = re.exec(html))) {
|
|
387
|
+
depth += m[0][1] === '/' ? -1 : 1
|
|
388
|
+
if (depth === 0) {
|
|
389
|
+
const close = html.indexOf('>', m.index)
|
|
390
|
+
return close > 0 ? html.slice(openIdx, close + 1) : null
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
|
|
239
396
|
export function pickMain(html) {
|
|
240
397
|
// Aggregation pages (e.g. blog homepages) have one <article> per item:
|
|
241
|
-
// collect all of them instead of only the first.
|
|
398
|
+
// collect all of them instead of only the first. A tiny <article> (e.g. a
|
|
399
|
+
// newsletter card — measured on about.gitlab.com: 71 chars of text while
|
|
400
|
+
// the real content sits in <main>) falls through to <main> when present.
|
|
242
401
|
const articles = [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
402
|
+
const hasMain = /<main[\s>]/i.test(html)
|
|
403
|
+
if (articles.length && (!hasMain || textOnly(articles.map((a) => a[0]).join('')).length >= 200)) {
|
|
404
|
+
return articles.map((a) => a[0]).join('\n')
|
|
405
|
+
}
|
|
406
|
+
const roleMain = /<(main|div)[^>]*role=["']main["'][\s>]/i.exec(html)
|
|
407
|
+
if (roleMain) {
|
|
408
|
+
const block = tagBlockAt(html, roleMain.index, roleMain[1].toLowerCase())
|
|
409
|
+
if (block) return block
|
|
410
|
+
}
|
|
411
|
+
// Bare <main> without role="main": doc generators (VitePress/VuePress,
|
|
412
|
+
// MDN, MDX sites) mark the body with a plain <main> tag. Falling back to
|
|
413
|
+
// the whole <body> would drag in the top/local nav (menu, outline, skip
|
|
414
|
+
// link) — the extraction quality loss we saw on vuejs.org.
|
|
415
|
+
const bareMain = /<main[\s>]/i.exec(html)
|
|
416
|
+
if (bareMain) {
|
|
417
|
+
const block = tagBlockAt(html, bareMain.index, 'main')
|
|
418
|
+
if (block) return block
|
|
419
|
+
}
|
|
252
420
|
const body = /<body[\s>]/i.exec(html)
|
|
253
421
|
if (body) {
|
|
254
422
|
const after = html.slice(body.index)
|
|
255
423
|
const end = after.search(/<\/body>/i)
|
|
256
|
-
return end > 0 ? after.slice(0, end) : after
|
|
424
|
+
return densityFilter(end > 0 ? after.slice(0, end) : after)
|
|
257
425
|
}
|
|
258
|
-
return html
|
|
426
|
+
return densityFilter(html)
|
|
259
427
|
}
|
|
260
428
|
|
|
261
429
|
// Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
|
|
@@ -281,7 +449,33 @@ function escInline(s) {
|
|
|
281
449
|
return s.replace(/([\\`*_[\]])/g, '\\$1')
|
|
282
450
|
}
|
|
283
451
|
|
|
452
|
+
// <img> is a void element (never matches the paired-tag regex in either md
|
|
453
|
+
// walker): convert descriptive images to markdown BEFORE the tag walk, via a
|
|
454
|
+
// sentinel so escInline cannot escape the generated brackets. Decorative
|
|
455
|
+
// images (empty alt) are dropped — the model cannot see pixels anyway.
|
|
456
|
+
const IMG_SENTINEL = '\u0001'
|
|
457
|
+
function imgsToMarkdown(html) {
|
|
458
|
+
const store = []
|
|
459
|
+
const out = html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
460
|
+
const alt = /alt=["']([^"']*)["']/i.exec(tag)
|
|
461
|
+
const src = /src=["']([^"']+)["']/i.exec(tag)
|
|
462
|
+
store.push(src && alt && alt[1].trim() ? `![${alt[1].trim()}](${src[1]})` : '')
|
|
463
|
+
return `${IMG_SENTINEL}${store.length - 1}${IMG_SENTINEL}`
|
|
464
|
+
})
|
|
465
|
+
return {
|
|
466
|
+
html: out,
|
|
467
|
+
// Unknown sentinel indexes are PRESERVED (a nested walker's restore must
|
|
468
|
+
// not eat sentinels owned by an outer frame — blockMd recurses into itself).
|
|
469
|
+
restore: (s) => s.replace(new RegExp(`${IMG_SENTINEL}(\\d+)${IMG_SENTINEL}`, 'g'), (m, i) => {
|
|
470
|
+
const v = store[Number(i)]
|
|
471
|
+
return v === undefined ? m : v
|
|
472
|
+
}),
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
284
476
|
export function inlineMd(html) {
|
|
477
|
+
const img = imgsToMarkdown(html)
|
|
478
|
+
html = img.html
|
|
285
479
|
let out = ''
|
|
286
480
|
const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
|
|
287
481
|
let m
|
|
@@ -303,11 +497,13 @@ export function inlineMd(html) {
|
|
|
303
497
|
else if (tag === 'br') out += ' \n'
|
|
304
498
|
else out += inner
|
|
305
499
|
}
|
|
306
|
-
return out.replace(/[ \t]+/g, ' ').trim()
|
|
500
|
+
return img.restore(out.replace(/[ \t]+/g, ' ').trim())
|
|
307
501
|
}
|
|
308
502
|
|
|
309
503
|
export function blockMd(html) {
|
|
310
504
|
let out = ''
|
|
505
|
+
const img = imgsToMarkdown(html)
|
|
506
|
+
html = img.html
|
|
311
507
|
const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
|
|
312
508
|
let m
|
|
313
509
|
while ((m = re.exec(html))) {
|
|
@@ -336,7 +532,11 @@ export function blockMd(html) {
|
|
|
336
532
|
out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
|
|
337
533
|
} else if (tag === 'pre') {
|
|
338
534
|
const code = inner.replace(/<[^>]+>/g, '').trim()
|
|
339
|
-
|
|
535
|
+
// Language hint from common highlighter conventions: the model reads
|
|
536
|
+
// ```js fenced blocks far more accurately than unlabelled ones.
|
|
537
|
+
const lang =
|
|
538
|
+
/<code[^>]+class=["'][^"']*(?:language|lang)-([\w#+.-]+)["']/i.exec(inner)?.[1] || ''
|
|
539
|
+
out += `\n\n\`\`\`${lang}\n${code}\n\`\`\``
|
|
340
540
|
} else if (tag === 'code') {
|
|
341
541
|
out += `\`${textOnly(inner)}\``
|
|
342
542
|
} else if (tag === 'ul' || tag === 'ol') {
|
|
@@ -379,6 +579,44 @@ export function blockMd(html) {
|
|
|
379
579
|
if (t) out += t
|
|
380
580
|
}
|
|
381
581
|
}
|
|
582
|
+
return img.restore(out)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Page-level metadata the model actually asks about (who wrote it, when) —
|
|
586
|
+
// cheap to harvest from <meta>, previously thrown away.
|
|
587
|
+
function extractMeta(html) {
|
|
588
|
+
const get = (names) => {
|
|
589
|
+
for (const n of names) {
|
|
590
|
+
const m = new RegExp(`<meta[^>]+(?:property|name)=["']${n}["'][^>]+content=["']([^"']+)["']`, 'i').exec(html)
|
|
591
|
+
if (m) return decodeTextEntities(m[1]).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
592
|
+
}
|
|
593
|
+
return ''
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
published: get(['article:published_time', 'og:published_time', 'pubdate', 'publishdate', 'date', 'dc.date']),
|
|
597
|
+
author: get(['author', 'article:author', 'og:article:author']),
|
|
598
|
+
description: get(['og:description', 'description']),
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Meta-less pages (measured: ruanyifeng.com carries ZERO author/date meta
|
|
603
|
+
// tags) still print the byline inside the body head: "作者:阮一峰 日期:
|
|
604
|
+
// 2026年8月21日". Fill ONLY the missing fields, and only from the first
|
|
605
|
+
// 600 chars of body text — deeper matches are content, not bylines.
|
|
606
|
+
const BYLINE_AUTHOR_RE = /(?:作者|責任編輯|責任主编|editor)[::\s]{1,3}([^\s,,。/|()()[\]]{2,24})/
|
|
607
|
+
const BYLINE_DATE_RE = /(?:发表日期|发布日期|发表时间|发布时间|日期|发表于|发布于|published(?:\s+on)?)[::\s]{0,3}(\d{4}[-/.年]\s?\d{1,2}[-/.月]\s?\d{1,2}日?|\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2})?)/i
|
|
608
|
+
|
|
609
|
+
function bylineFallback(text, meta) {
|
|
610
|
+
const head = typeof text === 'string' ? text.slice(0, 600) : ''
|
|
611
|
+
const out = { published: meta.published, author: meta.author }
|
|
612
|
+
if (!out.author) {
|
|
613
|
+
const m = BYLINE_AUTHOR_RE.exec(head)
|
|
614
|
+
if (m) out.author = m[1].trim().slice(0, 40)
|
|
615
|
+
}
|
|
616
|
+
if (!out.published) {
|
|
617
|
+
const m = BYLINE_DATE_RE.exec(head)
|
|
618
|
+
if (m) out.published = m[1].replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
619
|
+
}
|
|
382
620
|
return out
|
|
383
621
|
}
|
|
384
622
|
|
|
@@ -394,11 +632,24 @@ export function extract(html, mode) {
|
|
|
394
632
|
} else {
|
|
395
633
|
bodyText = textOnly(main).replace(/ +/g, ' ').trim()
|
|
396
634
|
}
|
|
635
|
+
const meta = extractMeta(html)
|
|
636
|
+
// Empty-body pages (login walls, JS-only shells) still carry og:description
|
|
637
|
+
// — surface it as a fallback hint instead of nothing.
|
|
638
|
+
let text = bodyText
|
|
639
|
+
let usedDescription = false
|
|
640
|
+
if (!text && meta.description) {
|
|
641
|
+
text = meta.description
|
|
642
|
+
usedDescription = true
|
|
643
|
+
}
|
|
644
|
+
const byline = bylineFallback(text, meta)
|
|
397
645
|
return {
|
|
398
646
|
title: titleMatch ? textOnly(titleMatch[1]) || titleMatch[1].trim() : '',
|
|
399
647
|
siteName: siteName ? siteName[1] : '',
|
|
400
648
|
lang: langMatch ? langMatch[1] : '',
|
|
401
|
-
|
|
649
|
+
published: byline.published,
|
|
650
|
+
author: byline.author,
|
|
651
|
+
text,
|
|
652
|
+
usedDescription,
|
|
402
653
|
}
|
|
403
654
|
}
|
|
404
655
|
|
|
@@ -499,6 +750,53 @@ function hostOf(url) {
|
|
|
499
750
|
}
|
|
500
751
|
}
|
|
501
752
|
|
|
753
|
+
// ---- Pagination: auto-follow "next page" ----
|
|
754
|
+
// Long articles on novel/news/forum sites are split into pages; without this
|
|
755
|
+
// the model had to notice the truncation and re-call the tool per page. The
|
|
756
|
+
// recognition is deliberately conservative: rel=next (standard) or a short
|
|
757
|
+
// anchor whose whole text is a next-page marker — no fuzzy guessing.
|
|
758
|
+
const NEXT_TEXT_RE = /^(?:下一页|下页|后一页|下一頁|下頁|next page|next|›{1,3}|»{1,3}|>|older entries)$/i
|
|
759
|
+
|
|
760
|
+
export function findNextLink(html, baseUrl) {
|
|
761
|
+
const resolve = (href) => {
|
|
762
|
+
try {
|
|
763
|
+
const u = new URL(href, baseUrl)
|
|
764
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
|
|
765
|
+
} catch {
|
|
766
|
+
return null
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const rel = /<(?:link|a)\b[^>]+rel=["']next["'][^>]*>/i.exec(html || '')
|
|
770
|
+
if (rel) {
|
|
771
|
+
const href = /href=["']([^"']+)["']/i.exec(rel[0])
|
|
772
|
+
const r = href && resolve(href[1])
|
|
773
|
+
if (r) return r
|
|
774
|
+
}
|
|
775
|
+
const re = /<a\b[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi
|
|
776
|
+
let m
|
|
777
|
+
while ((m = re.exec(html || ''))) {
|
|
778
|
+
const t = textOnly(m[2])
|
|
779
|
+
if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
|
|
780
|
+
const r = resolve(m[1])
|
|
781
|
+
if (r) return r
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return null
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Join two page bodies, dropping a repeated suffix/prefix overlap (many
|
|
788
|
+
// paginated sites repeat the last paragraph or a heading on the next page).
|
|
789
|
+
function joinPageText(a, b) {
|
|
790
|
+
const max = Math.min(300, a.length, b.length)
|
|
791
|
+
for (let n = max; n >= 30; n--) {
|
|
792
|
+
if (a.slice(a.length - n) === b.slice(0, n)) {
|
|
793
|
+
const rest = b.slice(n).trim()
|
|
794
|
+
return rest ? `${a}\n\n${rest}` : a
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return `${a}\n\n${b}`
|
|
798
|
+
}
|
|
799
|
+
|
|
502
800
|
function sliceFrom(full, offset, maxChars) {
|
|
503
801
|
const s = smartTruncate(full.fullText, maxChars, offset)
|
|
504
802
|
const out = {
|
|
@@ -516,6 +814,10 @@ function sliceFrom(full, offset, maxChars) {
|
|
|
516
814
|
}
|
|
517
815
|
if (full.rendered) out.rendered = true
|
|
518
816
|
if (full.spaHint) out.spaHint = full.spaHint
|
|
817
|
+
if (full.paginated > 1) out.paginated = full.paginated
|
|
818
|
+
if (full.feedCount) out.feedCount = full.feedCount
|
|
819
|
+
if (full.published) out.published = full.published
|
|
820
|
+
if (full.author) out.author = full.author
|
|
519
821
|
if (Array.isArray(full.links)) out.links = full.links
|
|
520
822
|
return out
|
|
521
823
|
}
|
|
@@ -553,26 +855,69 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
553
855
|
}
|
|
554
856
|
}
|
|
555
857
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
858
|
+
const failWith = (error) => {
|
|
859
|
+
if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
|
|
860
|
+
cache.set(cacheKey, { time: Date.now(), error })
|
|
861
|
+
return { error }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Non-HTML payloads (JSON APIs, RSS/Atom feeds) are read natively: fetch,
|
|
865
|
+
// dispatch by content-type / payload shape, render compact. Falls back to
|
|
866
|
+
// the HTML pipeline for everything else.
|
|
867
|
+
const dispatch = async () => {
|
|
868
|
+
const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
|
|
869
|
+
if (viaSeam) {
|
|
870
|
+
const t = viaSeam.html.trim()
|
|
871
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
872
|
+
try {
|
|
873
|
+
return { kind: 'json', text: JSON.stringify(JSON.parse(t), null, 1) }
|
|
874
|
+
} catch { /* not JSON — keep HTML pipeline */ }
|
|
875
|
+
}
|
|
876
|
+
return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
|
|
570
877
|
}
|
|
878
|
+
const page = await fetchPage(url, externalSignal, cfg)
|
|
879
|
+
if (page.error) return { error: page.error }
|
|
880
|
+
const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
|
|
571
881
|
const decoded = decodeBuffer(page.buffer, page.contentType)
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
882
|
+
if (/json$/.test(ct)) {
|
|
883
|
+
try {
|
|
884
|
+
return { kind: 'json', text: JSON.stringify(JSON.parse(decoded.text), null, 1) }
|
|
885
|
+
} catch { /* invalid JSON — serve through the HTML pipeline as text */ }
|
|
886
|
+
}
|
|
887
|
+
if (/[+/]xml$/.test(ct)) {
|
|
888
|
+
if (/<urlset[\s>]/i.test(decoded.text) || /<sitemapindex[\s>]/i.test(decoded.text)) {
|
|
889
|
+
return { error: 'Unsupported content (XML sitemap)' }
|
|
890
|
+
}
|
|
891
|
+
if (/<rss[\s>]/i.test(decoded.text) || /<feed[\s>]/i.test(decoded.text)) {
|
|
892
|
+
return { kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks) }
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return { kind: 'html', html: decoded.text, finalUrl: page.finalUrl || url, charset: decoded.charset }
|
|
575
896
|
}
|
|
897
|
+
const payload = await dispatch()
|
|
898
|
+
if (payload.error) return failWith(payload.error)
|
|
899
|
+
|
|
900
|
+
if (payload.kind === 'json') {
|
|
901
|
+
const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
|
|
902
|
+
if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
|
|
903
|
+
cache.set(cacheKey, { time: Date.now(), full })
|
|
904
|
+
return sliceFrom(full, offset, maxChars)
|
|
905
|
+
}
|
|
906
|
+
if (payload.kind === 'feed') {
|
|
907
|
+
const f = payload.feed
|
|
908
|
+
const full = {
|
|
909
|
+
url, title: f.title || '', siteName: hostOf(url), lang: '', charset: 'feed',
|
|
910
|
+
mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count,
|
|
911
|
+
links: args.includeLinks === true ? f.items : undefined,
|
|
912
|
+
}
|
|
913
|
+
if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
|
|
914
|
+
cache.set(cacheKey, { time: Date.now(), full })
|
|
915
|
+
return sliceFrom(full, offset, maxChars)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
let html = payload.html
|
|
919
|
+
let finalUrl = payload.finalUrl || url
|
|
920
|
+
const charset = payload.charset
|
|
576
921
|
|
|
577
922
|
let extracted = extract(html, mode)
|
|
578
923
|
// Optional readability upgrade: cleaner article extraction when installed.
|
|
@@ -595,24 +940,78 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
595
940
|
|
|
596
941
|
// Optional SPA enhancement: if the page looks client-rendered and static
|
|
597
942
|
// extraction found almost nothing, try headless rendering (playwright).
|
|
943
|
+
// Beyond script-heavy shells (looksLikeSpa, ≥5 <script> tags), a page whose
|
|
944
|
+
// extraction is EMPTY but carries any <script> may be a JS-redirect shell
|
|
945
|
+
// (measured: taptap.cn — 2 scripts, empty <body>, redirects via JS) — with
|
|
946
|
+
// no text to lose, rendering is always worth one bounded attempt. Pages
|
|
947
|
+
// with no scripts at all cannot change under rendering and are skipped.
|
|
598
948
|
let rendered = false
|
|
599
949
|
let spaHint = ''
|
|
600
950
|
let renderedHtml = null
|
|
601
|
-
|
|
951
|
+
const needsRender = !extracted.text || extracted.text.length < 200
|
|
952
|
+
const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
|
|
953
|
+
if (cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
|
|
602
954
|
const rr = await renderPage(finalUrl || url, externalSignal)
|
|
603
955
|
if (rr.html) {
|
|
604
956
|
renderedHtml = rr.html
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
957
|
+
// A challenge interstitial (Cloudflare "Just a moment...") must never
|
|
958
|
+
// be accepted as an improvement — it can carry MORE text than the real
|
|
959
|
+
// static body (measured: 259 chars of verification prose vs 135 chars
|
|
960
|
+
// of real content on ruanyifeng.com) and would evict the latter.
|
|
961
|
+
if (looksLikeChallenge(rr.html)) {
|
|
962
|
+
renderedHtml = null // pagination/links must not read the interstitial DOM
|
|
963
|
+
spaHint = extracted.text
|
|
964
|
+
? '已渲染但被人机验证页拦截,保留静态提取结果'
|
|
965
|
+
: '已渲染但被人机验证页拦截(无静态内容可用)'
|
|
966
|
+
} else {
|
|
967
|
+
const r2 = upgrade(extract(rr.html, mode), rr.html)
|
|
968
|
+
// Accept the rendered text when it meaningfully beats the static one.
|
|
969
|
+
// From an EMPTY static extraction even a short body is a real gain
|
|
970
|
+
// (measured: taptap-style JS shells render 30-60 chars of real content
|
|
971
|
+
// that the fixed +50 margin would reject).
|
|
972
|
+
const gain = r2.text.length - extracted.text.length
|
|
973
|
+
const accept = r2.text && (gain > 50 || (!extracted.text && r2.text.length >= 20))
|
|
974
|
+
if (accept) {
|
|
975
|
+
extracted = r2
|
|
976
|
+
finalUrl = rr.finalUrl || finalUrl
|
|
977
|
+
rendered = true
|
|
978
|
+
} else if (!extracted.text) {
|
|
979
|
+
spaHint = '已渲染仍无可读内容(可能登录墙或反爬拦截)'
|
|
980
|
+
}
|
|
610
981
|
}
|
|
611
982
|
} else {
|
|
612
983
|
spaHint = rr.error
|
|
613
984
|
}
|
|
614
985
|
}
|
|
615
986
|
|
|
987
|
+
// Auto-pagination: follow the "next page" chain (bounded, same-host, no
|
|
988
|
+
// loops). Continuation pages go through the fast static path — a paginated
|
|
989
|
+
// SPA chain would cost one full render per page, out of proportion.
|
|
990
|
+
let paginated = 1
|
|
991
|
+
const paginateMax = cfg.paginate === false ? 1 : Math.max(1, Math.min(10, Number(cfg.paginateMax) || 3))
|
|
992
|
+
const startHost = hostOf(finalUrl || url)
|
|
993
|
+
const seen = new Set([normalizeUrl(finalUrl || url)])
|
|
994
|
+
let nextUrl = paginateMax > 1 ? findNextLink(renderedHtml || html, finalUrl || url) : null
|
|
995
|
+
while (
|
|
996
|
+
nextUrl && paginated < paginateMax &&
|
|
997
|
+
sameHost(nextUrl, startHost) &&
|
|
998
|
+
!(externalSignal && externalSignal.aborted)
|
|
999
|
+
) {
|
|
1000
|
+
const nu = normalizeUrl(nextUrl)
|
|
1001
|
+
if (!nu || seen.has(nu)) break
|
|
1002
|
+
seen.add(nu)
|
|
1003
|
+
const page = await fetchPage(nextUrl, externalSignal, cfg)
|
|
1004
|
+
if (page.error) break
|
|
1005
|
+
const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
|
|
1006
|
+
if (!/html|xhtml/.test(ct)) break
|
|
1007
|
+
const nextHtml = decodeBuffer(page.buffer, page.contentType).text
|
|
1008
|
+
const nextEx = extract(nextHtml, mode)
|
|
1009
|
+
if (!nextEx.text || nextEx.text.length < 20) break
|
|
1010
|
+
extracted.text = joinPageText(extracted.text, nextEx.text)
|
|
1011
|
+
paginated++
|
|
1012
|
+
nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
616
1015
|
const full = {
|
|
617
1016
|
url: finalUrl,
|
|
618
1017
|
title: extracted.title || '',
|
|
@@ -623,6 +1022,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
623
1022
|
fullText: extracted.text,
|
|
624
1023
|
rendered,
|
|
625
1024
|
spaHint,
|
|
1025
|
+
paginated,
|
|
1026
|
+
published: extracted.published || '',
|
|
1027
|
+
author: extracted.author || '',
|
|
626
1028
|
// Links come from the rendered DOM when available — otherwise the SPA
|
|
627
1029
|
// chain (content -> links -> next page) would break for JS-only pages.
|
|
628
1030
|
links: args.includeLinks === true ? extractLinks(renderedHtml || html, cfg.maxLinks, finalUrl) : undefined,
|
|
@@ -643,7 +1045,11 @@ function renderResult(value) {
|
|
|
643
1045
|
const meta = []
|
|
644
1046
|
if (r.siteName && r.siteName !== host) meta.push(r.siteName)
|
|
645
1047
|
if (r.lang) meta.push(r.lang)
|
|
646
|
-
if (r.charset) meta.push(`charset ${r.charset}`)
|
|
1048
|
+
if (r.charset && r.charset !== 'json' && r.charset !== 'feed') meta.push(`charset ${r.charset}`)
|
|
1049
|
+
if (r.published) meta.push(r.published)
|
|
1050
|
+
if (r.author) meta.push(`by ${r.author}`)
|
|
1051
|
+
if (r.feedCount) meta.push(`feed · ${r.feedCount} 条`)
|
|
1052
|
+
if (r.mode === 'json') meta.push('json')
|
|
647
1053
|
if (meta.length) lines.push(meta.join(' · '))
|
|
648
1054
|
if (r.charsStart > 0) {
|
|
649
1055
|
lines.push(`(chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal} — offset 续读)`)
|
|
@@ -653,11 +1059,16 @@ function renderResult(value) {
|
|
|
653
1059
|
lines.push(`(chars ${r.charsTotal})`)
|
|
654
1060
|
}
|
|
655
1061
|
if (r.cached) lines.push('(cached)')
|
|
1062
|
+
if (r.paginated > 1) lines.push(`(已自动拼接 ${r.paginated} 页)`)
|
|
656
1063
|
if (r.rendered) lines.push('(rendered — JS 执行后内容)')
|
|
657
1064
|
if (!r.text) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1065
|
+
if (r.charsStart > 0 && !r.truncated) {
|
|
1066
|
+
lines.push('(offset 已到文末,无更多内容)')
|
|
1067
|
+
} else {
|
|
1068
|
+
lines.push(r.spaHint
|
|
1069
|
+
? `(无可读内容 — ${r.spaHint})`
|
|
1070
|
+
: '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
|
|
1071
|
+
}
|
|
661
1072
|
}
|
|
662
1073
|
lines.push('', r.text || '')
|
|
663
1074
|
if (Array.isArray(r.links) && r.links.length) {
|
|
@@ -692,7 +1103,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
692
1103
|
},
|
|
693
1104
|
required: ['url'],
|
|
694
1105
|
},
|
|
695
|
-
|
|
1106
|
+
// Covers SPA-render fallback: full fetch timeout + playwright render cap.
|
|
1107
|
+
timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
|
|
696
1108
|
output: {
|
|
697
1109
|
schema: {
|
|
698
1110
|
type: 'object',
|
|
@@ -723,10 +1135,14 @@ function readLinksTool(ctx, cfg) {
|
|
|
723
1135
|
}
|
|
724
1136
|
let links = extractLinks(html, limit, finalUrl)
|
|
725
1137
|
// SPA fallback: a JS-only page yields no links from its static HTML;
|
|
726
|
-
// render it and re-extract when the static result looks empty.
|
|
727
|
-
|
|
1138
|
+
// render it and re-extract when the static result looks empty. Any
|
|
1139
|
+
// <script> counts as renderable when there are no links at all — the
|
|
1140
|
+
// static shell may be a JS-redirect (same rationale as read_url).
|
|
1141
|
+
if (links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))) {
|
|
728
1142
|
const rr = await renderPage(finalUrl || url, exec && exec.signal)
|
|
729
|
-
|
|
1143
|
+
// A challenge interstitial's links are the challenge's own scripts —
|
|
1144
|
+
// never an improvement over whatever the static HTML offered.
|
|
1145
|
+
if (rr.html && !looksLikeChallenge(rr.html)) {
|
|
730
1146
|
const rl = extractLinks(rr.html, limit, rr.finalUrl || finalUrl)
|
|
731
1147
|
if (rl.length > links.length) {
|
|
732
1148
|
links = rl
|
|
@@ -791,7 +1207,8 @@ function readUrlBatchTool(ctx, cfg) {
|
|
|
791
1207
|
},
|
|
792
1208
|
required: ['urls'],
|
|
793
1209
|
},
|
|
794
|
-
|
|
1210
|
+
// Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
|
|
1211
|
+
timeoutMs: Math.max(60000, cfg.timeoutMs * 3 + 135000),
|
|
795
1212
|
output: {
|
|
796
1213
|
schema: {
|
|
797
1214
|
type: 'object',
|
|
@@ -877,7 +1294,9 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
|
|
|
877
1294
|
const pages = []
|
|
878
1295
|
const failures = []
|
|
879
1296
|
const queue = [{ url: entryUrl, depth: 0 }]
|
|
880
|
-
|
|
1297
|
+
// Stop once the host's cooperative signal fires (tool budget hit): without
|
|
1298
|
+
// this the loop keeps draining the queue on instantly-aborted fetches.
|
|
1299
|
+
while (queue.length && pages.length < maxPages && !(externalSignal && externalSignal.aborted)) {
|
|
881
1300
|
// Never overshoot the page budget within a batch round.
|
|
882
1301
|
const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
|
|
883
1302
|
const results = await Promise.all(
|
|
@@ -957,7 +1376,9 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
957
1376
|
},
|
|
958
1377
|
required: ['url'],
|
|
959
1378
|
},
|
|
960
|
-
|
|
1379
|
+
// Worst case: ceil(maxPages/2)=25 waves of paired fetches (no SPA render
|
|
1380
|
+
// here); the cooperative signal stops the loop early once the budget is hit.
|
|
1381
|
+
timeoutMs: Math.max(120000, cfg.timeoutMs * 25),
|
|
961
1382
|
output: {
|
|
962
1383
|
schema: {
|
|
963
1384
|
type: 'object',
|
|
@@ -1002,7 +1423,24 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
1002
1423
|
|
|
1003
1424
|
export function apply(ctx, config) {
|
|
1004
1425
|
// Plugin-level config from cordis.patch.yml (config row), merged over defaults.
|
|
1426
|
+
// YAML is free-form: numbers can arrive quoted as strings, which would poison
|
|
1427
|
+
// arithmetic and AbortSignal timeouts downstream — coerce to numbers once
|
|
1428
|
+
// here, clamp to sane ranges, and fall back to defaults on garbage.
|
|
1005
1429
|
const cfg = { ...DEFAULTS, ...(config || {}) }
|
|
1430
|
+
const clamp = (v, d, min, max) => {
|
|
1431
|
+
const n = Number(v)
|
|
1432
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
|
|
1433
|
+
}
|
|
1434
|
+
cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
|
|
1435
|
+
cfg.maxBytes = clamp(cfg.maxBytes, DEFAULTS.maxBytes, 65536, 16 * 1024 * 1024)
|
|
1436
|
+
cfg.maxChars = clamp(cfg.maxChars, DEFAULTS.maxChars, 500, 20000)
|
|
1437
|
+
cfg.maxLinks = clamp(cfg.maxLinks, DEFAULTS.maxLinks, 1, 50)
|
|
1438
|
+
cfg.cacheTtlMs = clamp(cfg.cacheTtlMs, DEFAULTS.cacheTtlMs, 0, 3600000)
|
|
1439
|
+
cfg.cacheMax = clamp(cfg.cacheMax, DEFAULTS.cacheMax, 1, 256)
|
|
1440
|
+
cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
|
|
1441
|
+
if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
|
|
1442
|
+
if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
|
|
1443
|
+
if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
|
|
1006
1444
|
|
|
1007
1445
|
// Temporal composability: unload must fully revert side effects.
|
|
1008
1446
|
ctx.effect(() => () => {
|
|
@@ -1031,7 +1469,11 @@ export function apply(ctx, config) {
|
|
|
1031
1469
|
},
|
|
1032
1470
|
required: ['url'],
|
|
1033
1471
|
},
|
|
1034
|
-
|
|
1472
|
+
// Pipeline budget must cover the real worst case: first page fetch +
|
|
1473
|
+
// SPA render (goto 30s cap + DOM-stability poll 10s) + up to paginateMax
|
|
1474
|
+
// continuation fetches (static, no render). Derived from load-time cfg —
|
|
1475
|
+
// scale with it, never clamp it.
|
|
1476
|
+
timeoutMs: Math.max(45000, cfg.timeoutMs * (cfg.paginate === false ? 1 : cfg.paginateMax + 1) + 45000),
|
|
1035
1477
|
output: {
|
|
1036
1478
|
schema: {
|
|
1037
1479
|
type: 'object',
|