dsh-read-url 0.4.9 → 1.1.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 +69 -0
- package/README.md +55 -23
- package/README.zh.md +55 -24
- package/cordis.patch.yml +2 -0
- package/index.js +479 -61
- package/package.json +2 -2
- package/proxy-fallback.js +25 -1
- package/spa.js +26 -1
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,6 +32,8 @@ 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
|
|
|
@@ -51,12 +56,16 @@ function getDecoder(enc) {
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
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'
|
|
54
64
|
const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
|
|
55
65
|
if (m) return m[1]
|
|
56
66
|
const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
|
|
57
67
|
const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)
|
|
58
68
|
if (meta) return meta[1]
|
|
59
|
-
if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
|
|
60
69
|
return null
|
|
61
70
|
}
|
|
62
71
|
|
|
@@ -77,10 +86,22 @@ export function decodeBuffer(buffer, contentType) {
|
|
|
77
86
|
return { text, charset: enc }
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
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) {
|
|
84
105
|
try {
|
|
85
106
|
const res = await fetch(url, {
|
|
86
107
|
redirect: 'follow',
|
|
@@ -90,9 +111,19 @@ async function directFetch(url, signal, cfg) {
|
|
|
90
111
|
// Early-return paths must cancel the body stream, or the connection is
|
|
91
112
|
// held open undrained and cannot return to the keep-alive pool.
|
|
92
113
|
const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
|
|
93
|
-
if (!res.ok) {
|
|
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
|
+
}
|
|
94
125
|
const contentType = res.headers.get('content-type') || ''
|
|
95
|
-
if (contentType &&
|
|
126
|
+
if (contentType && !FETCHABLE_CT.test(contentType)) {
|
|
96
127
|
discardBody()
|
|
97
128
|
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
98
129
|
}
|
|
@@ -104,7 +135,12 @@ async function directFetch(url, signal, cfg) {
|
|
|
104
135
|
if (size > cfg.maxBytes) { discardBody(); return { error: `Page exceeds ${cfg.maxBytes} bytes` } }
|
|
105
136
|
chunks.push(chunk)
|
|
106
137
|
}
|
|
107
|
-
|
|
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 }
|
|
108
144
|
} catch (e) {
|
|
109
145
|
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
110
146
|
return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
|
|
@@ -117,6 +153,19 @@ async function directFetch(url, signal, cfg) {
|
|
|
117
153
|
}
|
|
118
154
|
}
|
|
119
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
|
+
|
|
120
169
|
// Settle-all race that resolves with the FIRST SUCCESSFUL result (a value
|
|
121
170
|
// with a buffer and no error). If every attempt fails it resolves with
|
|
122
171
|
// { success:false, failures:[...] } once the slowest one has settled —
|
|
@@ -245,26 +294,136 @@ function textOnly(html) {
|
|
|
245
294
|
.trim())
|
|
246
295
|
}
|
|
247
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
|
+
|
|
248
396
|
export function pickMain(html) {
|
|
249
397
|
// Aggregation pages (e.g. blog homepages) have one <article> per item:
|
|
250
|
-
// 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.
|
|
251
401
|
const articles = [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
}
|
|
261
420
|
const body = /<body[\s>]/i.exec(html)
|
|
262
421
|
if (body) {
|
|
263
422
|
const after = html.slice(body.index)
|
|
264
423
|
const end = after.search(/<\/body>/i)
|
|
265
|
-
return end > 0 ? after.slice(0, end) : after
|
|
424
|
+
return densityFilter(end > 0 ? after.slice(0, end) : after)
|
|
266
425
|
}
|
|
267
|
-
return html
|
|
426
|
+
return densityFilter(html)
|
|
268
427
|
}
|
|
269
428
|
|
|
270
429
|
// Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
|
|
@@ -290,7 +449,33 @@ function escInline(s) {
|
|
|
290
449
|
return s.replace(/([\\`*_[\]])/g, '\\$1')
|
|
291
450
|
}
|
|
292
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
|
+
|
|
293
476
|
export function inlineMd(html) {
|
|
477
|
+
const img = imgsToMarkdown(html)
|
|
478
|
+
html = img.html
|
|
294
479
|
let out = ''
|
|
295
480
|
const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
|
|
296
481
|
let m
|
|
@@ -312,11 +497,13 @@ export function inlineMd(html) {
|
|
|
312
497
|
else if (tag === 'br') out += ' \n'
|
|
313
498
|
else out += inner
|
|
314
499
|
}
|
|
315
|
-
return out.replace(/[ \t]+/g, ' ').trim()
|
|
500
|
+
return img.restore(out.replace(/[ \t]+/g, ' ').trim())
|
|
316
501
|
}
|
|
317
502
|
|
|
318
503
|
export function blockMd(html) {
|
|
319
504
|
let out = ''
|
|
505
|
+
const img = imgsToMarkdown(html)
|
|
506
|
+
html = img.html
|
|
320
507
|
const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
|
|
321
508
|
let m
|
|
322
509
|
while ((m = re.exec(html))) {
|
|
@@ -345,7 +532,11 @@ export function blockMd(html) {
|
|
|
345
532
|
out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
|
|
346
533
|
} else if (tag === 'pre') {
|
|
347
534
|
const code = inner.replace(/<[^>]+>/g, '').trim()
|
|
348
|
-
|
|
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\`\`\``
|
|
349
540
|
} else if (tag === 'code') {
|
|
350
541
|
out += `\`${textOnly(inner)}\``
|
|
351
542
|
} else if (tag === 'ul' || tag === 'ol') {
|
|
@@ -388,6 +579,44 @@ export function blockMd(html) {
|
|
|
388
579
|
if (t) out += t
|
|
389
580
|
}
|
|
390
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
|
+
}
|
|
391
620
|
return out
|
|
392
621
|
}
|
|
393
622
|
|
|
@@ -403,11 +632,24 @@ export function extract(html, mode) {
|
|
|
403
632
|
} else {
|
|
404
633
|
bodyText = textOnly(main).replace(/ +/g, ' ').trim()
|
|
405
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)
|
|
406
645
|
return {
|
|
407
646
|
title: titleMatch ? textOnly(titleMatch[1]) || titleMatch[1].trim() : '',
|
|
408
647
|
siteName: siteName ? siteName[1] : '',
|
|
409
648
|
lang: langMatch ? langMatch[1] : '',
|
|
410
|
-
|
|
649
|
+
published: byline.published,
|
|
650
|
+
author: byline.author,
|
|
651
|
+
text,
|
|
652
|
+
usedDescription,
|
|
411
653
|
}
|
|
412
654
|
}
|
|
413
655
|
|
|
@@ -508,6 +750,53 @@ function hostOf(url) {
|
|
|
508
750
|
}
|
|
509
751
|
}
|
|
510
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
|
+
|
|
511
800
|
function sliceFrom(full, offset, maxChars) {
|
|
512
801
|
const s = smartTruncate(full.fullText, maxChars, offset)
|
|
513
802
|
const out = {
|
|
@@ -525,6 +814,10 @@ function sliceFrom(full, offset, maxChars) {
|
|
|
525
814
|
}
|
|
526
815
|
if (full.rendered) out.rendered = true
|
|
527
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
|
|
528
821
|
if (Array.isArray(full.links)) out.links = full.links
|
|
529
822
|
return out
|
|
530
823
|
}
|
|
@@ -562,27 +855,70 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
562
855
|
}
|
|
563
856
|
}
|
|
564
857
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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' }
|
|
579
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()
|
|
580
881
|
const decoded = decodeBuffer(page.buffer, page.contentType)
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 }
|
|
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)
|
|
584
916
|
}
|
|
585
917
|
|
|
918
|
+
let html = payload.html
|
|
919
|
+
let finalUrl = payload.finalUrl || url
|
|
920
|
+
const charset = payload.charset
|
|
921
|
+
|
|
586
922
|
let extracted = extract(html, mode)
|
|
587
923
|
// Optional readability upgrade: cleaner article extraction when installed.
|
|
588
924
|
const ext = await getReadabilityExtractor()
|
|
@@ -604,24 +940,78 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
604
940
|
|
|
605
941
|
// Optional SPA enhancement: if the page looks client-rendered and static
|
|
606
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.
|
|
607
948
|
let rendered = false
|
|
608
949
|
let spaHint = ''
|
|
609
950
|
let renderedHtml = null
|
|
610
|
-
|
|
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)) {
|
|
611
954
|
const rr = await renderPage(finalUrl || url, externalSignal)
|
|
612
955
|
if (rr.html) {
|
|
613
956
|
renderedHtml = rr.html
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
}
|
|
619
981
|
}
|
|
620
982
|
} else {
|
|
621
983
|
spaHint = rr.error
|
|
622
984
|
}
|
|
623
985
|
}
|
|
624
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
|
+
|
|
625
1015
|
const full = {
|
|
626
1016
|
url: finalUrl,
|
|
627
1017
|
title: extracted.title || '',
|
|
@@ -632,6 +1022,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
632
1022
|
fullText: extracted.text,
|
|
633
1023
|
rendered,
|
|
634
1024
|
spaHint,
|
|
1025
|
+
paginated,
|
|
1026
|
+
published: extracted.published || '',
|
|
1027
|
+
author: extracted.author || '',
|
|
635
1028
|
// Links come from the rendered DOM when available — otherwise the SPA
|
|
636
1029
|
// chain (content -> links -> next page) would break for JS-only pages.
|
|
637
1030
|
links: args.includeLinks === true ? extractLinks(renderedHtml || html, cfg.maxLinks, finalUrl) : undefined,
|
|
@@ -652,7 +1045,11 @@ function renderResult(value) {
|
|
|
652
1045
|
const meta = []
|
|
653
1046
|
if (r.siteName && r.siteName !== host) meta.push(r.siteName)
|
|
654
1047
|
if (r.lang) meta.push(r.lang)
|
|
655
|
-
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')
|
|
656
1053
|
if (meta.length) lines.push(meta.join(' · '))
|
|
657
1054
|
if (r.charsStart > 0) {
|
|
658
1055
|
lines.push(`(chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal} — offset 续读)`)
|
|
@@ -662,6 +1059,7 @@ function renderResult(value) {
|
|
|
662
1059
|
lines.push(`(chars ${r.charsTotal})`)
|
|
663
1060
|
}
|
|
664
1061
|
if (r.cached) lines.push('(cached)')
|
|
1062
|
+
if (r.paginated > 1) lines.push(`(已自动拼接 ${r.paginated} 页)`)
|
|
665
1063
|
if (r.rendered) lines.push('(rendered — JS 执行后内容)')
|
|
666
1064
|
if (!r.text) {
|
|
667
1065
|
if (r.charsStart > 0 && !r.truncated) {
|
|
@@ -707,6 +1105,7 @@ function readLinksTool(ctx, cfg) {
|
|
|
707
1105
|
},
|
|
708
1106
|
// Covers SPA-render fallback: full fetch timeout + playwright render cap.
|
|
709
1107
|
timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
|
|
1108
|
+
isConcurrencySafe: () => true,
|
|
710
1109
|
output: {
|
|
711
1110
|
schema: {
|
|
712
1111
|
type: 'object',
|
|
@@ -737,10 +1136,14 @@ function readLinksTool(ctx, cfg) {
|
|
|
737
1136
|
}
|
|
738
1137
|
let links = extractLinks(html, limit, finalUrl)
|
|
739
1138
|
// SPA fallback: a JS-only page yields no links from its static HTML;
|
|
740
|
-
// render it and re-extract when the static result looks empty.
|
|
741
|
-
|
|
1139
|
+
// render it and re-extract when the static result looks empty. Any
|
|
1140
|
+
// <script> counts as renderable when there are no links at all — the
|
|
1141
|
+
// static shell may be a JS-redirect (same rationale as read_url).
|
|
1142
|
+
if (links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))) {
|
|
742
1143
|
const rr = await renderPage(finalUrl || url, exec && exec.signal)
|
|
743
|
-
|
|
1144
|
+
// A challenge interstitial's links are the challenge's own scripts —
|
|
1145
|
+
// never an improvement over whatever the static HTML offered.
|
|
1146
|
+
if (rr.html && !looksLikeChallenge(rr.html)) {
|
|
744
1147
|
const rl = extractLinks(rr.html, limit, rr.finalUrl || finalUrl)
|
|
745
1148
|
if (rl.length > links.length) {
|
|
746
1149
|
links = rl
|
|
@@ -805,8 +1208,11 @@ function readUrlBatchTool(ctx, cfg) {
|
|
|
805
1208
|
},
|
|
806
1209
|
required: ['urls'],
|
|
807
1210
|
},
|
|
808
|
-
// Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
|
|
1211
|
+
// Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
|
|
809
1212
|
timeoutMs: Math.max(60000, cfg.timeoutMs * 3 + 135000),
|
|
1213
|
+
// Concurrent batch calls multiply fetch pressure but stay isolated (each
|
|
1214
|
+
// call owns its result array; the session cache is commutative).
|
|
1215
|
+
isConcurrencySafe: () => true,
|
|
810
1216
|
output: {
|
|
811
1217
|
schema: {
|
|
812
1218
|
type: 'object',
|
|
@@ -974,9 +1380,13 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
974
1380
|
},
|
|
975
1381
|
required: ['url'],
|
|
976
1382
|
},
|
|
977
|
-
// Worst case: ceil(maxPages/2)=25 waves of paired fetches (no SPA render
|
|
978
|
-
// here); the cooperative signal stops the loop early once the budget is hit.
|
|
1383
|
+
// Worst case: ceil(maxPages/2)=25 waves of paired fetches (no SPA render
|
|
1384
|
+
// here); the cooperative signal stops the loop early once the budget is hit.
|
|
979
1385
|
timeoutMs: Math.max(120000, cfg.timeoutMs * 25),
|
|
1386
|
+
// Crawl state (visited set, queue) is call-local; only the shared session
|
|
1387
|
+
// cache is global and commutative — parallel crawls of different hosts
|
|
1388
|
+
// are safe and useful (one task mapping several sites).
|
|
1389
|
+
isConcurrencySafe: () => true,
|
|
980
1390
|
output: {
|
|
981
1391
|
schema: {
|
|
982
1392
|
type: 'object',
|
|
@@ -1035,8 +1445,10 @@ export function apply(ctx, config) {
|
|
|
1035
1445
|
cfg.maxLinks = clamp(cfg.maxLinks, DEFAULTS.maxLinks, 1, 50)
|
|
1036
1446
|
cfg.cacheTtlMs = clamp(cfg.cacheTtlMs, DEFAULTS.cacheTtlMs, 0, 3600000)
|
|
1037
1447
|
cfg.cacheMax = clamp(cfg.cacheMax, DEFAULTS.cacheMax, 1, 256)
|
|
1448
|
+
cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
|
|
1038
1449
|
if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
|
|
1039
1450
|
if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
|
|
1451
|
+
if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
|
|
1040
1452
|
|
|
1041
1453
|
// Temporal composability: unload must fully revert side effects.
|
|
1042
1454
|
ctx.effect(() => () => {
|
|
@@ -1065,10 +1477,16 @@ export function apply(ctx, config) {
|
|
|
1065
1477
|
},
|
|
1066
1478
|
required: ['url'],
|
|
1067
1479
|
},
|
|
1068
|
-
// Pipeline budget must cover the real worst case:
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1071
|
-
|
|
1480
|
+
// Pipeline budget must cover the real worst case: first page fetch +
|
|
1481
|
+
// SPA render (goto 30s cap + DOM-stability poll 10s) + up to paginateMax
|
|
1482
|
+
// continuation fetches (static, no render). Derived from load-time cfg —
|
|
1483
|
+
// scale with it, never clamp it.
|
|
1484
|
+
timeoutMs: Math.max(45000, cfg.timeoutMs * (cfg.paginate === false ? 1 : cfg.paginateMax + 1) + 45000),
|
|
1485
|
+
// All shared state is commutative-safe under concurrent calls (session
|
|
1486
|
+
// cache Map get/set, decoder cache, one shared browser with isolated
|
|
1487
|
+
// pages per render), so the agent may fan out several read_url calls in
|
|
1488
|
+
// one parallel group — a real win for multi-source reading tasks.
|
|
1489
|
+
isConcurrencySafe: () => true,
|
|
1072
1490
|
output: {
|
|
1073
1491
|
schema: {
|
|
1074
1492
|
type: 'object',
|