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/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) via built-in TextDecoder
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
- // Direct-connect fetch, extracted so the race path can run it concurrently
81
- // with the proxy attempt and abort it. Returns { buffer, contentType, finalUrl }
82
- // on success or { error } — never throws (abort/timeout/network all mapped).
83
- async function directFetch(url, signal, cfg) {
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) { discardBody(); return { error: `HTTP ${res.status} ${res.statusText}` } }
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 && !/text\/html|application\/xhtml|text\/plain/i.test(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
- return { buffer: Buffer.concat(chunks), contentType, finalUrl: res.url }
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 (&lt;a href=…&gt;查看全文&lt;/a&gt;):
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
- if (articles.length) return articles.map((a) => a[0]).join('\n')
253
- const mainTag =
254
- /<(main|div)[^>]*role=["']main["'][\s>][\s\S]*?<\/\1\s*>/i.exec(html) ||
255
- // Bare <main> without role="main": doc generators (VitePress/VuePress,
256
- // MDN, MDX sites) mark the body with a plain <main> tag. Falling back to
257
- // the whole <body> would drag in the top/local nav (menu, outline, skip
258
- // link) — the extraction quality loss we saw on vuejs.org.
259
- /<main[\s>][\s\S]*?<\/main\s*>/i.exec(html)
260
- if (mainTag) return mainTag[0]
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
- out += `\n\n\`\`\`\n${code}\n\`\`\``
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
- text: bodyText,
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
- let html, finalUrl, charset
566
- const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
567
- if (viaSeam) {
568
- html = viaSeam.html
569
- finalUrl = viaSeam.finalUrl
570
- charset = 'provider-decoded'
571
- } else {
572
- const page = await fetchPage(url, externalSignal, cfg)
573
- if (page.error) {
574
- // Brief error cache: a failing URL usually stays failing for a while,
575
- // so serve the same error from cache instead of re-fetching.
576
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
577
- cache.set(cacheKey, { time: Date.now(), error: page.error })
578
- return { error: page.error }
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
- html = decoded.text
582
- finalUrl = page.finalUrl || url
583
- charset = decoded.charset
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
- if (cfg.spaRender !== false && looksLikeSpa(html) && (!extracted.text || extracted.text.length < 200)) {
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
- const r2 = upgrade(extract(rr.html, mode), rr.html)
615
- if (r2.text && r2.text.length > extracted.text.length + 50) {
616
- extracted = r2
617
- finalUrl = rr.finalUrl || finalUrl
618
- rendered = true
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
- if (links.length < 3 && cfg.spaRender !== false && looksLikeSpa(html)) {
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
- if (rr.html) {
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: full fetch timeout
1069
- // (cfg.timeoutMs) + SPA render (playwright goto 30s cap + DOM-stability
1070
- // poll 10s) + margin. Derived from load-time cfg — scale with it, never clamp it.
1071
- timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
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',