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/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,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
- // Direct-connect fetch, extracted so the race path can run it concurrently
79
- // with the proxy attempt and abort it. Returns { buffer, contentType, finalUrl }
80
- // on success or { error } — never throws (abort/timeout/network all mapped).
81
- 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) {
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
- if (!res.ok) return { error: `HTTP ${res.status} ${res.statusText}` }
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 && !/text\/html|application\/xhtml|text\/plain/i.test(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
- 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 }
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 (&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
+
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
- if (articles.length) return articles.map((a) => a[0]).join('\n')
244
- const mainTag =
245
- /<(main|div)[^>]*role=["']main["'][\s>][\s\S]*?<\/\1\s*>/i.exec(html) ||
246
- // Bare <main> without role="main": doc generators (VitePress/VuePress,
247
- // MDN, MDX sites) mark the body with a plain <main> tag. Falling back to
248
- // the whole <body> would drag in the top/local nav (menu, outline, skip
249
- // link) — the extraction quality loss we saw on vuejs.org.
250
- /<main[\s>][\s\S]*?<\/main\s*>/i.exec(html)
251
- 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
+ }
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
- 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\`\`\``
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
- text: bodyText,
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
- let html, finalUrl, charset
557
- const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
558
- if (viaSeam) {
559
- html = viaSeam.html
560
- finalUrl = viaSeam.finalUrl
561
- charset = 'provider-decoded'
562
- } else {
563
- const page = await fetchPage(url, externalSignal, cfg)
564
- if (page.error) {
565
- // Brief error cache: a failing URL usually stays failing for a while,
566
- // so serve the same error from cache instead of re-fetching.
567
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
568
- cache.set(cacheKey, { time: Date.now(), error: page.error })
569
- 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' }
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
- html = decoded.text
573
- finalUrl = page.finalUrl || url
574
- 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 }
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
- 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)) {
602
954
  const rr = await renderPage(finalUrl || url, externalSignal)
603
955
  if (rr.html) {
604
956
  renderedHtml = rr.html
605
- const r2 = upgrade(extract(rr.html, mode), rr.html)
606
- if (r2.text && r2.text.length > extracted.text.length + 50) {
607
- extracted = r2
608
- finalUrl = rr.finalUrl || finalUrl
609
- 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
+ }
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
- lines.push(r.spaHint
659
- ? `(无可读内容 — ${r.spaHint})`
660
- : '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
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
- timeoutMs: 20000,
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
- if (links.length < 3 && cfg.spaRender !== false && looksLikeSpa(html)) {
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
- if (rr.html) {
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
- timeoutMs: Math.max(30000, cfg.timeoutMs + 15000),
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
- while (queue.length && pages.length < maxPages) {
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
- timeoutMs: 120000,
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
- timeoutMs: Math.max(5000, cfg.timeoutMs + 5000),
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',