dsh-read-url 1.1.0 → 1.3.1

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
@@ -37,9 +37,19 @@ const DEFAULTS = {
37
37
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
38
38
  }
39
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).
40
+ // Session-level cache: url -> { time, full }. Failures live in a separate
41
+ // small map so short-lived error entries never evict hot success pages.
42
42
  const cache = new Map()
43
+ const failCache = new Map()
44
+ const FAIL_CACHE_MAX = 8
45
+ function cacheFail(key, error) {
46
+ if (failCache.size >= FAIL_CACHE_MAX) failCache.delete(failCache.keys().next().value)
47
+ failCache.set(key, { time: Date.now(), error })
48
+ }
49
+ function cacheStore(key, full, cacheMax) {
50
+ if (cache.size >= cacheMax) cache.delete(cache.keys().next().value)
51
+ cache.set(key, { time: Date.now(), full })
52
+ }
43
53
  const decoders = new Map()
44
54
 
45
55
  function getDecoder(enc) {
@@ -64,8 +74,12 @@ function sniffCharset(buffer, contentType) {
64
74
  const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
65
75
  if (m) return m[1]
66
76
  const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
67
- const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)
77
+ const meta = /<meta[^>]{0,1000}charset=["']?([\w-]+)/i.exec(head)
68
78
  if (meta) return meta[1]
79
+ // Legacy form: <meta http-equiv="Content-Type" content="text/html; charset=gb2312">
80
+ // (no charset= attribute anywhere) — common on old GBK pages with no header charset.
81
+ const metaEquiv = /<meta[^>]{0,1000}(?:http-equiv)=["']content-type["'][^>]{0,1000}content=["'][^"']*charset=([\w-]+)/i.exec(head)
82
+ if (metaEquiv) return metaEquiv[1]
69
83
  return null
70
84
  }
71
85
 
@@ -207,6 +221,15 @@ async function raceFetch(url, externalSignal, cfg, proxy) {
207
221
  const winner = await raceFirstSuccess([direct, proxied])
208
222
  directCtrl.abort()
209
223
  proxyCtrl.abort()
224
+ if (!winner.success) {
225
+ // Each failure is tagged with its channel so callers read errors by
226
+ // label ('direct' | 'proxy') rather than by array position.
227
+ const labels = ['direct', 'proxy']
228
+ winner.failures = winner.failures.map((f, i) => ({
229
+ label: labels[i],
230
+ error: f && f.error ? String(f.error) : 'unknown error',
231
+ }))
232
+ }
210
233
  return winner
211
234
  }
212
235
 
@@ -215,10 +238,10 @@ async function fetchPage(url, externalSignal, cfg) {
215
238
  if (proxy) {
216
239
  const winner = await raceFetch(url, externalSignal, cfg, proxy)
217
240
  if (winner.success) return winner.value
218
- const dErr = winner.failures[0] && winner.failures[0].error
219
- const pErr = winner.failures[1]
220
- const px = pErr && pErr.error ? `,代理返回 ${pErr.error}` : ',代理未连通'
221
- return { error: `Fetch failed: ${dErr}(直连失败;已尝试代理 ${proxy}${px})` }
241
+ const dErr = winner.failures.find((f) => f.label === 'direct')
242
+ const pErr = winner.failures.find((f) => f.label === 'proxy')
243
+ const px = pErr ? `,代理返回 ${pErr.error}` : ',代理未连通'
244
+ return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}(直连失败;已尝试代理 ${proxy}${px})` }
222
245
  }
223
246
  // No proxy configured: plain direct connect (the original behaviour).
224
247
  const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
@@ -266,30 +289,54 @@ const ENTITIES = {
266
289
  permil: '‰', euro: '€', pound: '£', yen: '¥', cent: '¢',
267
290
  sup2: '²', sup3: '³', frac12: '½', frac14: '¼', frac34: '¾',
268
291
  }
292
+ // &#0; / &#127; / &#x1F; decode to control characters that pollute the model
293
+ // context (NUL, DEL, C1 controls). Map non-whitespace controls to a space;
294
+ // keep \t\n\r — pages legitimately use &#10; for line breaks, and flattening
295
+ // those to spaces would destroy paragraph structure in markdown mode. Lone
296
+ // surrogates (U+D800–DFFF) decode to replacement chars instead of leaking a
297
+ // broken code unit into the rendered text.
298
+ function fromSafeCodePoint(c) {
299
+ if (c < 0x20) return (c === 9 || c === 10 || c === 13) ? String.fromCodePoint(c) : ' '
300
+ if (c >= 0x7f && c <= 0x9f) return ' '
301
+ if (c >= 0xd800 && c <= 0xdfff) return '\uFFFD'
302
+ return String.fromCodePoint(c)
303
+ }
304
+
269
305
  export function decodeTextEntities(text) {
270
306
  if (!text.includes('&')) return text
271
307
  return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|([a-z][a-z0-9]{1,7}));?/gi, (m, dec, hex, name) => {
272
308
  if (dec !== undefined) {
273
309
  const c = Number(dec)
274
- return c > 0x10ffff ? m : String.fromCodePoint(c)
310
+ return c > 0x10ffff ? m : fromSafeCodePoint(c)
275
311
  }
276
312
  if (hex !== undefined) {
277
313
  const c = Number.parseInt(hex, 16)
278
- return c > 0x10ffff ? m : String.fromCodePoint(c)
314
+ return c > 0x10ffff ? m : fromSafeCodePoint(c)
279
315
  }
280
316
  const n = (name || '').toLowerCase()
281
317
  return Object.prototype.hasOwnProperty.call(ENTITIES, n) ? ENTITIES[n] : m
282
318
  })
283
319
  }
284
320
 
321
+ // A '<' with no '>' after it can never start a real tag, but tag-scanning
322
+ // regexes still probe the rest of the string at every such position, degrading
323
+ // to quadratic time on pathological input. Neutralize stray '<' in the no-'>'
324
+ // tail: text content is preserved, impossible tag openings disappear, and
325
+ // scanning stays linear. Short tails ("a < b" in prose) are left untouched.
326
+ function defuseLt(html) {
327
+ const from = html.lastIndexOf('>') + 1
328
+ if (html.length - from < 512 || !html.includes('<', from)) return html
329
+ return html.slice(0, from) + html.slice(from).replace(/</g, ' ')
330
+ }
331
+
285
332
  function textOnly(html) {
286
- return decodeTextEntities(html
333
+ return decodeTextEntities(defuseLt(html)
287
334
  .replace(/<!--[\s\S]*?-->/g, ' ')
288
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
289
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
290
- .replace(/<textarea[\s\S]*?<\/textarea\s*>/gi, ' ')
291
- .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
292
- .replace(/<[^>]+>/g, ' ')
335
+ .replace(/<script[\s>][\s\S]*?<\/script>/gi, ' ')
336
+ .replace(/<style[\s>][\s\S]*?<\/style>/gi, ' ')
337
+ .replace(/<textarea[\s>][\s\S]*?<\/textarea\s*>/gi, ' ')
338
+ .replace(/<noscript[\s>][\s\S]*?<\/noscript>/gi, ' ')
339
+ .replace(/<[^>]{1,1000}>/g, ' ')
293
340
  .replace(/[ \t\r\n]+/g, ' ')
294
341
  .trim())
295
342
  }
@@ -302,11 +349,11 @@ function textOnly(html) {
302
349
  function xmlText(s) {
303
350
  let t = String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
304
351
  // 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.
352
+ // one strip-then-decode pass leaves literal tags in the rendered text.
353
+ // Iterate strip → decode until stable.
307
354
  for (let i = 0; i < 3; i++) {
308
355
  const prev = t
309
- t = decodeTextEntities(t.replace(/<[^>]+>/g, ' '))
356
+ t = decodeTextEntities(t.replace(/<[^>]{1,1000}>/g, ' '))
310
357
  if (t === prev) break
311
358
  }
312
359
  return t.replace(/\s+/g, ' ').trim()
@@ -314,11 +361,14 @@ function xmlText(s) {
314
361
 
315
362
  // RSS 2.0 <item> / Atom <entry> → compact list "title — url\n summary".
316
363
  function parseFeed(xml, limit) {
364
+ xml = defuseLt(xml) // keeps the item scan linear
317
365
  const isAtom = /<feed[\s>]/i.test(xml)
318
366
  const itemRe = isAtom ? /<entry[\s>][\s\S]*?<\/entry\s*>/gi : /<item[\s>][\s\S]*?<\/item\s*>/gi
367
+ // Without a single closing tag nothing can pair, so the scan is skipped.
368
+ const closable = isAtom ? /<\/entry\s*>/i.test(xml) : /<\/item\s*>/i.test(xml)
319
369
  const items = []
320
370
  let m
321
- while ((m = itemRe.exec(xml)) && items.length < limit) {
371
+ while (closable && (m = itemRe.exec(xml)) && items.length < limit) {
322
372
  const it = m[0]
323
373
  const title = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(it)?.[1] || '')
324
374
  const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
@@ -343,9 +393,10 @@ function parseFeed(xml, limit) {
343
393
  // sidebars, "hot articles" widgets) that carry little reading value. Split the
344
394
  // body at block-level open tags and drop segments whose text is mostly links.
345
395
  // 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
396
+ 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[^>]{0,1000}>/gi
347
397
 
348
398
  function linkTextLength(html) {
399
+ if (!/<\/a/i.test(html)) return 0 // no anchor can pair — skip the scan
349
400
  let n = 0
350
401
  const re = /<a[\s>][\s\S]*?<\/a\s*>/gi
351
402
  let m
@@ -375,9 +426,8 @@ export function densityFilter(html) {
375
426
  }
376
427
 
377
428
  // 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.
429
+ // openIdx through its BALANCED closing tag. A non-greedy regex would stop at
430
+ // the first nested </div>, truncating heavily nested container divs.
381
431
  function tagBlockAt(html, openIdx, tagName) {
382
432
  const re = new RegExp(`</?${tagName}(?=[\\s>])`, 'gi')
383
433
  re.lastIndex = openIdx
@@ -396,14 +446,16 @@ function tagBlockAt(html, openIdx, tagName) {
396
446
  export function pickMain(html) {
397
447
  // Aggregation pages (e.g. blog homepages) have one <article> per item:
398
448
  // 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.
401
- const articles = [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
449
+ // newsletter card while the real content sits in <main>) falls through to
450
+ // <main> when present.
451
+ const articles = /<\/article/i.test(html)
452
+ ? [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
453
+ : [] // without a closer in the document nothing can pair
402
454
  const hasMain = /<main[\s>]/i.test(html)
403
455
  if (articles.length && (!hasMain || textOnly(articles.map((a) => a[0]).join('')).length >= 200)) {
404
456
  return articles.map((a) => a[0]).join('\n')
405
457
  }
406
- const roleMain = /<(main|div)[^>]*role=["']main["'][\s>]/i.exec(html)
458
+ const roleMain = /<(main|div)[^>]{0,1000}role=["']main["'][\s>]/i.exec(html)
407
459
  if (roleMain) {
408
460
  const block = tagBlockAt(html, roleMain.index, roleMain[1].toLowerCase())
409
461
  if (block) return block
@@ -427,21 +479,40 @@ export function pickMain(html) {
427
479
  }
428
480
 
429
481
  // Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
430
- // entity-escaped tags (&lt;style&gt;...). Reveal them so noise rules can strip
431
- // them; then run noise removal again.
482
+ // entity-escaped tags (&lt;style&gt;...). Reveal TAG-SHAPED sequences so noise
483
+ // rules can strip them. &lt; is revealed only when a tag name follows: a bare
484
+ // &lt; from prose ("a &lt; b") would be swallowed by the tag stripper up to
485
+ // the next real '>'. Bare &gt; / &quot; never open a strip span and are
486
+ // revealed unconditionally.
432
487
  function revealEscapedTags(html) {
433
488
  return html
434
- .replace(/&lt;/gi, '<')
489
+ .replace(/&lt;(?=\/?[a-zA-Z!])/gi, '<')
435
490
  .replace(/&gt;/gi, '>')
436
491
  .replace(/&quot;/gi, '"')
437
492
  }
438
493
 
439
494
  function stripNoise(mainHtml) {
440
- return mainHtml
441
- .replace(/<!--[\s\S]*?-->/g, ' ')
442
- .replace(/<(textarea|style|script|noscript|template)[\s>][\s\S]*?<\/\1\s*>/gi, ' ')
443
- .replace(/<(nav|footer|header|aside|form|iframe|svg|canvas|dialog)[\s>][\s\S]*?<\/\1>/gi, ' ')
444
- .replace(/<([a-z][a-z0-9]*)[^>]+class=["'][^"']*(ad-|ads|advert|banner|sidebar|social|share|comment|popup|modal|cookie)[^"']*["'][^>]*>[\s\S]*?<\/\1>/gi, ' ')
495
+ // Attribute runs are bounded so a '<' whose '>' is far away costs a bounded
496
+ // scan instead of a run to that '>'; real attributes never approach 1000
497
+ // chars. Container groups only include tags whose closing tag exists in the
498
+ // input — absent closers cannot pair, so scanning for their body is wasted
499
+ // work.
500
+ const closers = new Set()
501
+ for (const c of mainHtml.matchAll(/<\/([a-z0-9]+)/gi)) closers.add(c[1].toLowerCase())
502
+ const group = (names) => names.filter((n) => closers.has(n))
503
+ const raw = group(['textarea', 'style', 'script', 'noscript', 'template'])
504
+ const box = group(['nav', 'footer', 'header', 'aside', 'form', 'iframe', 'svg', 'canvas', 'dialog'])
505
+ let out = mainHtml.replace(/<!--[\s\S]*?-->/g, ' ')
506
+ if (raw.length) {
507
+ out = out.replace(new RegExp(`<(${raw.join('|')})[\\s>][\\s\\S]*?<\\/\\1\\s*>`, 'gi'), ' ')
508
+ }
509
+ if (box.length) {
510
+ out = out.replace(new RegExp(`<(${box.join('|')})[\\s>][\\s\\S]*?<\\/\\1>`, 'gi'), ' ')
511
+ }
512
+ return out.replace(
513
+ /<([a-z][a-z0-9]*)[^>]{1,1000}class=["'][^"']{0,300}(ad-|ads|advert|banner|sidebar|social|share|comment|popup|modal|cookie)[^"']{0,300}["'][^>]{0,1000}>[\s\S]*?<\/\1>/gi,
514
+ ' ',
515
+ )
445
516
  }
446
517
 
447
518
  // ---- HTML -> Markdown (lightweight, tag-state machine) ----
@@ -456,7 +527,7 @@ function escInline(s) {
456
527
  const IMG_SENTINEL = '\u0001'
457
528
  function imgsToMarkdown(html) {
458
529
  const store = []
459
- const out = html.replace(/<img\b[^>]*>/gi, (tag) => {
530
+ const out = html.replace(/<img\b[^>]{0,1000}>/gi, (tag) => {
460
531
  const alt = /alt=["']([^"']*)["']/i.exec(tag)
461
532
  const src = /src=["']([^"']+)["']/i.exec(tag)
462
533
  store.push(src && alt && alt[1].trim() ? `![${alt[1].trim()}](${src[1]})` : '')
@@ -473,11 +544,29 @@ function imgsToMarkdown(html) {
473
544
  }
474
545
  }
475
546
 
476
- export function inlineMd(html) {
547
+ // An open tag whose closing tag appears nowhere in the input can never pair;
548
+ // removing such openers up front keeps the walker from rescanning the rest of
549
+ // the string at every position, so the walk stays linear. The walkers drop
550
+ // these tags either way, so the rendered output is unchanged. The depth cap
551
+ // bounds recursion — real pages never nest hundreds of levels; beyond the cap
552
+ // the walker degrades to plain text.
553
+ const OPEN_TAG_RE = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>/g
554
+ const MD_MAX_DEPTH = 100
555
+ function stripUnmatchedOpeners(html) {
556
+ const closers = new Set()
557
+ for (const m of html.matchAll(/<\/([a-zA-Z0-9]+)/g)) closers.add(m[1].toLowerCase())
558
+ html = defuseLt(html)
559
+ return html.replace(OPEN_TAG_RE, (m, name) => (closers.has(name.toLowerCase()) ? m : ''))
560
+ }
561
+
562
+ export function inlineMd(html, depth = 0) {
563
+ if (depth > MD_MAX_DEPTH) {
564
+ return escInline(decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim())
565
+ }
477
566
  const img = imgsToMarkdown(html)
478
- html = img.html
567
+ html = stripUnmatchedOpeners(img.html)
479
568
  let out = ''
480
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
569
+ const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
481
570
  let m
482
571
  while ((m = re.exec(html))) {
483
572
  if (m[4] !== undefined) {
@@ -487,10 +576,13 @@ export function inlineMd(html) {
487
576
  if (!m[1]) continue
488
577
  const tag = m[1].toLowerCase()
489
578
  if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
490
- const inner = inlineMd(m[3])
579
+ const inner = inlineMd(m[3], depth + 1)
491
580
  if (tag === 'a') {
492
581
  const href = /href=["']([^"']+)["']/i.exec(m[2])
493
- out += href ? `[${inner}](${href[1]})` : inner
582
+ // A raw ')' in the target would terminate the markdown link early
583
+ // (common on wiki/framework URLs) — percent-encode parens to keep the
584
+ // link parseable by the model.
585
+ out += href ? `[${inner}](${href[1].replace(/\)/g, '%29').replace(/\(/g, '%28')})` : inner
494
586
  } else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
495
587
  else if (tag === 'em' || tag === 'i') out += `*${inner}*`
496
588
  else if (tag === 'code') out += `\`${inner}\``
@@ -500,11 +592,14 @@ export function inlineMd(html) {
500
592
  return img.restore(out.replace(/[ \t]+/g, ' ').trim())
501
593
  }
502
594
 
503
- export function blockMd(html) {
504
- let out = ''
595
+ export function blockMd(html, depth = 0) {
596
+ if (depth > MD_MAX_DEPTH) {
597
+ return decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
598
+ }
505
599
  const img = imgsToMarkdown(html)
506
- html = img.html
507
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
600
+ html = stripUnmatchedOpeners(img.html)
601
+ let out = ''
602
+ const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
508
603
  let m
509
604
  while ((m = re.exec(html))) {
510
605
  if (m[4] !== undefined) {
@@ -520,18 +615,21 @@ export function blockMd(html) {
520
615
  }
521
616
  if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') {
522
617
  const level = '#'.repeat(Number(tag[1]))
523
- out += `\n\n${level} ${inlineMd(inner)}`
618
+ out += `\n\n${level} ${inlineMd(inner, depth + 1)}`
524
619
  } else if (tag === 'p') {
525
- const t = inlineMd(inner)
620
+ const t = inlineMd(inner, depth + 1)
526
621
  if (t) out += `\n\n${t}`
527
622
  } else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
528
- const t = blockMd(inner)
623
+ const t = blockMd(inner, depth + 1)
529
624
  if (t) out += `\n\n${t}`
530
625
  } else if (tag === 'blockquote') {
531
- const t = blockMd(inner).trim()
626
+ const t = blockMd(inner, depth + 1).trim()
532
627
  out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
533
628
  } else if (tag === 'pre') {
534
- const code = inner.replace(/<[^>]+>/g, '').trim()
629
+ // Strip wrapper tags FIRST, then decode entities — a literal &lt;div&gt;
630
+ // in the code must become <div> in the fenced block, but a real nested
631
+ // tag must not survive the strip to be rendered as markdown.
632
+ const code = decodeTextEntities(inner.replace(/<[^>]{1,1000}>/g, '')).trim()
535
633
  // Language hint from common highlighter conventions: the model reads
536
634
  // ```js fenced blocks far more accurately than unlabelled ones.
537
635
  const lang =
@@ -540,29 +638,38 @@ export function blockMd(html) {
540
638
  } else if (tag === 'code') {
541
639
  out += `\`${textOnly(inner)}\``
542
640
  } else if (tag === 'ul' || tag === 'ol') {
543
- const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi
544
- let lm, idx = 1, items = []
545
- while ((lm = liRe.exec(inner))) {
546
- const t = inlineMd(lm[1])
547
- items.push(tag === 'ol' ? `${idx}. ${t}` : `- ${t}`)
548
- idx++
641
+ // Without a single </li> nothing can pair, so the item scan is skipped.
642
+ const items = []
643
+ if (inner.includes('</li')) {
644
+ const liRe = /<li[^>]{0,1000}>([\s\S]*?)<\/li>/gi
645
+ let lm, idx = 1
646
+ while ((lm = liRe.exec(inner))) {
647
+ const t = inlineMd(lm[1], depth + 1)
648
+ items.push(tag === 'ol' ? `${idx}. ${t}` : `- ${t}`)
649
+ idx++
650
+ }
549
651
  }
550
652
  if (items.length) out += `\n\n${items.join('\n')}`
551
653
  } else if (tag === 'li') {
552
- out += `\n- ${inlineMd(inner)}`
654
+ out += `\n- ${inlineMd(inner, depth + 1)}`
553
655
  } else if (tag === 'br') {
554
656
  out += ' \n'
555
657
  } else if (tag === 'hr') {
556
658
  out += '\n\n---'
557
659
  } else if (tag === 'table') {
558
- const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
559
- let tm, rows = []
560
- while ((tm = trRe.exec(inner))) {
561
- const cells = []
562
- const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
563
- let cm
564
- while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1]).replace(/\|/g, '\\|'))
565
- if (cells.length) rows.push(`| ${cells.join(' | ')} |`)
660
+ const rows = []
661
+ if (inner.includes('</tr')) {
662
+ const trRe = /<tr[^>]{0,1000}>([\s\S]*?)<\/tr>/gi
663
+ let tm
664
+ while ((tm = trRe.exec(inner))) {
665
+ const cells = []
666
+ if (tm[1].includes('</t')) {
667
+ const tdRe = /<t[dh][^>]{0,1000}>([\s\S]*?)<\/t[dh]>/gi
668
+ let cm
669
+ while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1], depth + 1).replace(/\|/g, '\\|'))
670
+ }
671
+ if (cells.length) rows.push(`| ${cells.join(' | ')} |`)
672
+ }
566
673
  }
567
674
  if (rows.length) {
568
675
  const header = rows[0]
@@ -572,36 +679,110 @@ export function blockMd(html) {
572
679
  out += `\n\n${rows.join('\n')}\n${sep}`
573
680
  }
574
681
  } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
575
- const t = inlineMd(inner)
682
+ const t = inlineMd(inner, depth + 1)
576
683
  if (t) out += t
577
684
  } else {
578
- const t = blockMd(inner)
685
+ const t = blockMd(inner, depth + 1)
579
686
  if (t) out += t
580
687
  }
581
688
  }
582
689
  return img.restore(out)
583
690
  }
584
691
 
692
+ // Read the content attribute of the FIRST <meta> whose property/name equals
693
+ // `key`. Attribute order is arbitrary (content may precede property/name), and
694
+ // the content value may itself contain quotes — locate the tag first, then
695
+ // read content from inside it with a backreference pair.
696
+ function metaContent(html, key) {
697
+ const tagM = new RegExp(`<meta\\b[^>]{0,1000}(?:property|name)=["']${key}["'][^>]{0,1000}>`, 'i').exec(html)
698
+ if (!tagM) return ''
699
+ const m = /content=("([^"]*)"|'([^']*)')/i.exec(tagM[0])
700
+ return m ? (m[2] || m[3] || '') : ''
701
+ }
702
+
585
703
  // Page-level metadata the model actually asks about (who wrote it, when) —
586
- // cheap to harvest from <meta>, previously thrown away.
704
+ // cheap to harvest from <meta>. publishedExplicit records whether the date
705
+ // came from a semantic article/og key (authoritative) or a generic date/dc.date
706
+ // stamp (often last-modified, weaker than a JSON-LD datePublished).
587
707
  function extractMeta(html) {
588
- const get = (names) => {
708
+ const getKey = (names) => {
589
709
  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)
710
+ const v = metaContent(html, n)
711
+ if (v) {
712
+ return {
713
+ v: decodeTextEntities(v).replace(/<[^>]{1,1000}>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80),
714
+ key: n,
715
+ }
716
+ }
592
717
  }
593
- return ''
718
+ return { v: '', key: '' }
594
719
  }
720
+ const p = getKey(['article:published_time', 'og:published_time', 'pubdate', 'publishdate', 'date', 'dc.date'])
721
+ const a = getKey(['author', 'article:author', 'og:article:author'])
722
+ const d = getKey(['og:description', 'description'])
595
723
  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']),
724
+ published: p.v,
725
+ publishedExplicit: /^(article|og):/i.test(p.key),
726
+ author: a.v,
727
+ description: d.v,
728
+ }
729
+ }
730
+
731
+ // schema.org JSON-LD blocks carry publication metadata (datePublished/author)
732
+ // many news sites put nowhere else — <meta> covers only a subset and the
733
+ // byline regex cannot see ISO-8601 timestamps. Parse the first ld+json block
734
+ // that yields either field; tolerate broken JSON and every structural shape
735
+ // (plain object, @type arrays, @graph graphs, author as string/object/array).
736
+ // Bounded block size keeps a hostile giant script tag from dragging the scan.
737
+ // 100k covers real article-list JSON-LD (news sites often embed dozens of
738
+ // items); metadata sits in the first node anyway.
739
+ const LDJSON_RE = /<script[^>]{0,1000}type=["']application\/ld\+json["'][^>]{0,1000}>([\s\S]{0,100000}?)<\/script\s*>/gi
740
+ function jsonLdMeta(html) {
741
+ // Recursively collect every node: nested structures are common (ItemList →
742
+ // mainEntity → Article, WebPage → mainEntityOfPage), so a flat top-level
743
+ // scan alone misses most real payloads. Depth-capped against hostile
744
+ // nesting; arrays, @graph and plain object properties are all expanded.
745
+ const collect = (node, out, depth = 0) => {
746
+ if (!node || typeof node !== 'object' || depth > 20) return
747
+ if (Array.isArray(node)) {
748
+ for (const it of node) collect(it, out, depth + 1)
749
+ return
750
+ }
751
+ out.push(node)
752
+ if (Array.isArray(node['@graph'])) for (const it of node['@graph']) collect(it, out, depth + 1)
753
+ for (const k of Object.keys(node)) {
754
+ if (k === '@graph') continue
755
+ const v = node[k]
756
+ if (v && typeof v === 'object') collect(v, out, depth + 1)
757
+ }
758
+ }
759
+ const authorOf = (a) => {
760
+ if (typeof a === 'string') return a
761
+ if (Array.isArray(a)) return authorOf(a[0])
762
+ if (a && typeof a === 'object') return typeof a.name === 'string' ? a.name : authorOf(a.name)
763
+ return ''
764
+ }
765
+ const clean = (s) => (s ? decodeTextEntities(s).slice(0, 40) : '')
766
+ for (const m of html.matchAll(LDJSON_RE)) {
767
+ let json
768
+ try {
769
+ json = JSON.parse(m[1])
770
+ } catch {
771
+ continue
772
+ }
773
+ const nodes = []
774
+ collect(json, nodes)
775
+ for (const n of nodes) {
776
+ const published = clean(typeof n.datePublished === 'string' ? n.datePublished : '')
777
+ const author = clean(authorOf(n.author))
778
+ if (published || author) return { published, author }
779
+ }
599
780
  }
781
+ return { published: '', author: '' }
600
782
  }
601
783
 
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
784
+ // Meta-less pages still print the byline inside the body head: "作者:阮一峰
785
+ // 日期:2026年8月21日". Fill ONLY the missing fields, and only from the first
605
786
  // 600 chars of body text — deeper matches are content, not bylines.
606
787
  const BYLINE_AUTHOR_RE = /(?:作者|責任編輯|責任主编|editor)[::\s]{1,3}([^\s,,。/|()()[\]]{2,24})/
607
788
  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
@@ -621,9 +802,11 @@ function bylineFallback(text, meta) {
621
802
  }
622
803
 
623
804
  export function extract(html, mode) {
624
- const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)
625
- const siteName = /<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i.exec(html)
626
- const langMatch = /<html[^>]+lang=["']([\w-]+)["']/i.exec(html)
805
+ html = defuseLt(html) // kills the no-'>'-tail quadratic scans before any picker runs
806
+ const titleTag = /<title[^>]{0,1000}>([\s\S]*?)<\/title>/i.exec(html)
807
+ const ogTitle = metaContent(html, 'og:title')
808
+ const siteName = metaContent(html, 'og:site_name')
809
+ const langMatch = /<html[^>]{1,1000}lang=["']([\w-]+)["']/i.exec(html)
627
810
  let main = stripNoise(pickMain(html))
628
811
  main = stripNoise(revealEscapedTags(main))
629
812
  let bodyText
@@ -633,6 +816,14 @@ export function extract(html, mode) {
633
816
  bodyText = textOnly(main).replace(/ +/g, ' ').trim()
634
817
  }
635
818
  const meta = extractMeta(html)
819
+ const ld = jsonLdMeta(html)
820
+ // Merge chain: <meta> → JSON-LD → byline. An explicit article:/og: meta date
821
+ // stays authoritative; otherwise a JSON-LD datePublished beats the generic
822
+ // date meta key (last-modified stamps are not publication times).
823
+ const published = ld.published
824
+ ? (meta.publishedExplicit ? meta.published : ld.published)
825
+ : meta.published
826
+ const author = ld.author || meta.author
636
827
  // Empty-body pages (login walls, JS-only shells) still carry og:description
637
828
  // — surface it as a fallback hint instead of nothing.
638
829
  let text = bodyText
@@ -641,10 +832,10 @@ export function extract(html, mode) {
641
832
  text = meta.description
642
833
  usedDescription = true
643
834
  }
644
- const byline = bylineFallback(text, meta)
835
+ const byline = bylineFallback(text, { published, author })
645
836
  return {
646
- title: titleMatch ? textOnly(titleMatch[1]) || titleMatch[1].trim() : '',
647
- siteName: siteName ? siteName[1] : '',
837
+ title: titleTag ? textOnly(titleTag[1]) || titleTag[1].trim() : ogTitle,
838
+ siteName,
648
839
  lang: langMatch ? langMatch[1] : '',
649
840
  published: byline.published,
650
841
  author: byline.author,
@@ -701,7 +892,19 @@ export function smartTruncate(text, maxChars, offset = 0) {
701
892
  if (acc.length + p.length + (acc ? 2 : 0) > maxChars) break
702
893
  acc += (acc ? '\n\n' : '') + p
703
894
  }
704
- if (!acc && maxChars > 0) acc = text.slice(offset, offset + maxChars)
895
+ if (!acc && maxChars > 0) {
896
+ // The first paragraph after the offset is itself longer than maxChars.
897
+ // Keep the alignment promise at sentence level before hard-slicing.
898
+ const p = paragraphs[start] || ''
899
+ const rel = Math.max(0, offset - pos)
900
+ const rest = p.length > rel ? p.slice(rel) : text.slice(offset)
901
+ const sentences = rest.match(/[^.!?\n。!?]+[.!?\n。!?]*/g) || [rest]
902
+ for (const s of sentences) {
903
+ if (acc.length + s.length > maxChars) break
904
+ acc += s
905
+ }
906
+ if (!acc) acc = rest.slice(0, maxChars)
907
+ }
705
908
  return {
706
909
  text: acc,
707
910
  truncated: offset + acc.length < total,
@@ -711,12 +914,38 @@ export function smartTruncate(text, maxChars, offset = 0) {
711
914
  }
712
915
  }
713
916
 
917
+ // <base href> overrides the document base for relative URL resolution (old
918
+ // forums / frame sites). Per HTML spec only the FIRST <base> in <head> counts;
919
+ // attribute order is arbitrary and the href may be protocol-relative. Empty
920
+ // href (= document URL) is a no-op; non-http(s) targets are rejected. The
921
+ // head scan is bounded — the tag cannot legally appear beyond it.
922
+ function detectBaseHref(html, finalUrl) {
923
+ const headEnd = html.search(/<\/head\s*>/i)
924
+ const head = headEnd >= 0 ? html.slice(0, headEnd) : html.slice(0, 16384)
925
+ const base = /<base\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>/i.exec(head)
926
+ if (!base) return finalUrl
927
+ const href = base[1].trim()
928
+ if (!href) return finalUrl
929
+ try {
930
+ const u = new URL(href, finalUrl)
931
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : finalUrl
932
+ } catch {
933
+ return finalUrl
934
+ }
935
+ }
936
+
714
937
  function extractLinks(html, limit, baseUrl) {
715
938
  const links = []
716
939
  const seen = new Set()
717
940
  // Accept relative hrefs too — most real pages link internally with relative
718
941
  // paths — and resolve them against the page URL so the model can follow them.
719
- const re = /<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi
942
+ // A declared <base href> wins over the document URL (frame-site links would
943
+ // otherwise land on the wrong host).
944
+ const linkBase = detectBaseHref(html, baseUrl)
945
+ // Attribute runs bounded: an unclosed <a whose '>' is far away (or missing)
946
+ // must not make the regex scan the rest of the string at every anchor
947
+ // position. Real attributes never approach 1000 chars.
948
+ const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
720
949
  let m
721
950
  // Iteration cap: a page whose nav repeats the same links thousands of times
722
951
  // would otherwise scan the whole HTML to fill `limit` unique entries. Scan
@@ -728,10 +957,14 @@ function extractLinks(html, limit, baseUrl) {
728
957
  if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
729
958
  let url
730
959
  try {
731
- url = baseUrl ? new URL(href, baseUrl).href : new URL(href).href
960
+ url = linkBase ? new URL(href, linkBase).href : new URL(href).href
732
961
  } catch {
733
962
  continue
734
963
  }
964
+ // Only http(s) links are followable by the model / crawler; other schemes
965
+ // (ftp:, data:) survive URL resolution and would pollute link lists and
966
+ // crawl queues.
967
+ if (!/^https?:/i.test(url)) continue
735
968
  // dedupe: nav bars repeat the same links many times — one entry per URL
736
969
  // keeps the link list compact (tokens) without losing coverage
737
970
  if (seen.has(url)) continue
@@ -755,26 +988,32 @@ function hostOf(url) {
755
988
  // the model had to notice the truncation and re-call the tool per page. The
756
989
  // recognition is deliberately conservative: rel=next (standard) or a short
757
990
  // 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
991
+ const NEXT_TEXT_RE = /^(?:[›»>]+\s*)?(?:下一页|下页|下一篇|下一頁|下頁|后一页|後一頁|next page|next|older entries|[›»>]{1,3})(?:\s*[›»>]+)?$/i
759
992
 
760
993
  export function findNextLink(html, baseUrl) {
994
+ // Same <base href> rule as extractLinks: frame/forum sites pin their
995
+ // document base, and a "next page" link resolves against it.
996
+ const base = detectBaseHref(html || '', baseUrl)
761
997
  const resolve = (href) => {
762
998
  try {
763
- const u = new URL(href, baseUrl)
999
+ const u = new URL(href, base)
764
1000
  return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
765
1001
  } catch {
766
1002
  return null
767
1003
  }
768
1004
  }
769
- const rel = /<(?:link|a)\b[^>]+rel=["']next["'][^>]*>/i.exec(html || '')
1005
+ const rel = /<(?:link|a)\b[^>]{0,1000}rel=["']next["'][^>]{0,1000}>/i.exec(html || '')
770
1006
  if (rel) {
771
1007
  const href = /href=["']([^"']+)["']/i.exec(rel[0])
772
1008
  const r = href && resolve(href[1])
773
1009
  if (r) return r
774
1010
  }
775
- const re = /<a\b[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi
1011
+ const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
776
1012
  let m
777
- while ((m = re.exec(html || ''))) {
1013
+ let scans = 0
1014
+ // Bounded like extractLinks: a nav bar with thousands of anchors must not
1015
+ // scan the whole DOM hunting for a next-marker.
1016
+ while ((m = re.exec(html || '')) && scans++ < 400) {
778
1017
  const t = textOnly(m[2])
779
1018
  if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
780
1019
  const r = resolve(m[1])
@@ -822,6 +1061,105 @@ function sliceFrom(full, offset, maxChars) {
822
1061
  return out
823
1062
  }
824
1063
 
1064
+ // ---- JSON payloads: compact render ----
1065
+ // Indented JSON spends a newline + indent per key — pure token overhead for
1066
+ // the model. Render compact, and clip monster string values (embedded HTML,
1067
+ // base64 blobs) with a visible marker so the model knows there is more.
1068
+ const JSON_STR_MAX = 1500
1069
+ export function compactJson(value) {
1070
+ const clip = (s) =>
1071
+ s.length > JSON_STR_MAX ? s.slice(0, JSON_STR_MAX) + `…[+${s.length - JSON_STR_MAX} chars]` : s
1072
+ const walk = (v, depth) => {
1073
+ if (typeof v === 'string') return clip(v)
1074
+ if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1))
1075
+ if (v && typeof v === 'object' && depth < 12) {
1076
+ const o = {}
1077
+ for (const k of Object.keys(v)) o[k] = walk(v[k], depth + 1)
1078
+ return o
1079
+ }
1080
+ return v
1081
+ }
1082
+ try {
1083
+ return JSON.stringify(walk(value, 0))
1084
+ } catch {
1085
+ // Extreme nesting/size where even native stringify exhausts the stack —
1086
+ // degrade to a clipped raw render instead of crashing the tool call.
1087
+ try {
1088
+ return clip(String(value))
1089
+ } catch {
1090
+ return '[unserializable json]'
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ // ---- meta-refresh shells: follow to the real page ----
1096
+ // <meta http-equiv="refresh" content="0; url=..."> stubs (link hops,
1097
+ // anti-hotlink relays, no-JS SPA entries) serve only a jump page — the body
1098
+ // lives at the target. Parse the FIRST matching <meta>; attribute order and
1099
+ // case are arbitrary, content quotes are stripped, a protocol-relative target
1100
+ // resolves against the document (or <base href>). Only IMMEDIATE redirects
1101
+ // are followed: a timed auto-refresh (content="30; url=…") is a normal page
1102
+ // with content, not a shell. A bare timed refresh with no url part reloads
1103
+ // the same page — null. Non-http(s) targets are refused.
1104
+ export function metaRefreshTarget(html, baseUrl) {
1105
+ // Locate the refresh <meta> tag first (any attribute order / case), then
1106
+ // read content from inside that tag — a forward regex would demand
1107
+ // http-equiv before content and miss the reversed order.
1108
+ const re = /<meta\b[^>]{0,1000}(?:http-equiv|name)=["']refresh["'][^>]{0,1000}>/gi
1109
+ let m
1110
+ while ((m = re.exec(html || ''))) {
1111
+ const tag = m[0]
1112
+ const contentM = /content=("([^"]*)"|'([^']*)')/i.exec(tag)
1113
+ if (!contentM) continue // a refresh meta without content is inert
1114
+ const content = (contentM[2] || contentM[3] || '').trim()
1115
+ // content="5" | content="0; url=http://…" | content=";url='…'" — the url
1116
+ // part may be single/double quoted or bare; "url=" must be a real token.
1117
+ const urlM = /\burl\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))/i.exec(content)
1118
+ if (!urlM) return null
1119
+ const delayM = /^\s*(\d+(?:\.\d+)?)/.exec(content)
1120
+ if (delayM && Number(delayM[1]) > 0) return null // timed refresh: keep the page
1121
+ const target = (urlM[1] || urlM[2] || urlM[3] || '').trim()
1122
+ if (!target) return null
1123
+ try {
1124
+ const u = new URL(target, detectBaseHref(html || '', baseUrl))
1125
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
1126
+ } catch {
1127
+ return null
1128
+ }
1129
+ }
1130
+ return null
1131
+ }
1132
+
1133
+ // Bounded following: at most 3 hops, deduped by normalized URL. Any fetch
1134
+ // problem (network error, non-HTML body) FAILS OPEN — the shell's own HTML
1135
+ // is kept for static extraction instead of erroring the call. Followed pages
1136
+ // are never written to the session cache under their own URL: the caller's
1137
+ // cache key is the ORIGINAL url, and a shell re-request should re-follow
1138
+ // rather than serve a foreign page.
1139
+ const MAX_META_REFRESH_HOPS = 3
1140
+ async function followMetaRefresh(html, finalUrl, externalSignal, cfg) {
1141
+ let cur = html
1142
+ let curUrl = finalUrl
1143
+ let curCharset = '' // set only when a hop actually happened
1144
+ const seen = new Set([normalizeUrl(finalUrl)])
1145
+ for (let i = 0; i < MAX_META_REFRESH_HOPS; i++) {
1146
+ const target = metaRefreshTarget(cur, curUrl)
1147
+ if (!target) break
1148
+ const norm = normalizeUrl(target)
1149
+ if (!norm || seen.has(norm)) break
1150
+ seen.add(norm)
1151
+ const page = await fetchPage(target, externalSignal, cfg)
1152
+ if (page.error) break
1153
+ const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1154
+ if (!/html|xhtml/.test(ct)) break
1155
+ const decoded = decodeBuffer(page.buffer, page.contentType)
1156
+ cur = decoded.text
1157
+ curCharset = decoded.charset
1158
+ curUrl = page.finalUrl || target
1159
+ }
1160
+ return { html: cur, finalUrl: curUrl, charset: curCharset }
1161
+ }
1162
+
825
1163
  export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
826
1164
  const url = String((args && args.url) || '').trim()
827
1165
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
@@ -842,22 +1180,21 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
842
1180
  /* keep raw url on parse failure */
843
1181
  }
844
1182
  const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1183
+ // Failed fetches are cached briefly so the model doesn't re-request a
1184
+ // broken URL in a loop (token + latency saver).
1185
+ const fh = failCache.get(cacheKey)
1186
+ if (fh) {
1187
+ if (Date.now() - fh.time < 30000) return { error: fh.error, cached: true }
1188
+ failCache.delete(cacheKey)
1189
+ }
845
1190
  const hit = cache.get(cacheKey)
846
- if (hit) {
847
- // Failed fetches are cached briefly so the model doesn't re-request a
848
- // broken URL in a loop (token + latency saver).
849
- if (hit.error) {
850
- if (Date.now() - hit.time < 30000) return { error: hit.error, cached: true }
851
- cache.delete(cacheKey)
852
- } else if (Date.now() - hit.time < cfg.cacheTtlMs) {
853
- const sliced = sliceFrom(hit.full, offset, maxChars)
854
- return { ...sliced, cached: true }
855
- }
1191
+ if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1192
+ const sliced = sliceFrom(hit.full, offset, maxChars)
1193
+ return { ...sliced, cached: true }
856
1194
  }
857
1195
 
858
1196
  const failWith = (error) => {
859
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
860
- cache.set(cacheKey, { time: Date.now(), error })
1197
+ cacheFail(cacheKey, error)
861
1198
  return { error }
862
1199
  }
863
1200
 
@@ -870,7 +1207,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
870
1207
  const t = viaSeam.html.trim()
871
1208
  if (t.startsWith('{') || t.startsWith('[')) {
872
1209
  try {
873
- return { kind: 'json', text: JSON.stringify(JSON.parse(t), null, 1) }
1210
+ return { kind: 'json', text: compactJson(JSON.parse(t)) }
874
1211
  } catch { /* not JSON — keep HTML pipeline */ }
875
1212
  }
876
1213
  return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
@@ -881,7 +1218,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
881
1218
  const decoded = decodeBuffer(page.buffer, page.contentType)
882
1219
  if (/json$/.test(ct)) {
883
1220
  try {
884
- return { kind: 'json', text: JSON.stringify(JSON.parse(decoded.text), null, 1) }
1221
+ return { kind: 'json', text: compactJson(JSON.parse(decoded.text)) }
885
1222
  } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
886
1223
  }
887
1224
  if (/[+/]xml$/.test(ct)) {
@@ -899,8 +1236,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
899
1236
 
900
1237
  if (payload.kind === 'json') {
901
1238
  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 })
1239
+ cacheStore(cacheKey, full, cfg.cacheMax)
904
1240
  return sliceFrom(full, offset, maxChars)
905
1241
  }
906
1242
  if (payload.kind === 'feed') {
@@ -910,22 +1246,34 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
910
1246
  mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count,
911
1247
  links: args.includeLinks === true ? f.items : undefined,
912
1248
  }
913
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
914
- cache.set(cacheKey, { time: Date.now(), full })
1249
+ cacheStore(cacheKey, full, cfg.cacheMax)
915
1250
  return sliceFrom(full, offset, maxChars)
916
1251
  }
917
1252
 
918
1253
  let html = payload.html
919
1254
  let finalUrl = payload.finalUrl || url
920
- const charset = payload.charset
1255
+ let charset = payload.charset
1256
+
1257
+ // meta-refresh shells (link hops, anti-hotlink relays, no-JS SPA entries):
1258
+ // follow immediately, BEFORE extraction and SPA rendering — a shell that
1259
+ // redirects needs no headless round-trip, and extracting the stub is wasted
1260
+ // work. Fails open on any fetch problem; the follow is hop-bounded.
1261
+ const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg)
1262
+ html = followed.html
1263
+ finalUrl = followed.finalUrl
1264
+ // The target may use a different encoding than the shell page — reflect it.
1265
+ if (followed.charset) charset = followed.charset
921
1266
 
922
1267
  let extracted = extract(html, mode)
923
1268
  // Optional readability upgrade: cleaner article extraction when installed.
924
1269
  const ext = await getReadabilityExtractor()
925
- const upgrade = (target, htmlText) => {
1270
+ // The base passed to readability must be the CURRENT final URL — after a
1271
+ // meta-refresh follow or SPA render the document may live on another host,
1272
+ // and relative links/images would resolve against the original request URL.
1273
+ const upgrade = (target, htmlText, base) => {
926
1274
  if (!ext) return target
927
1275
  try {
928
- const clean = ext(htmlText, url)
1276
+ const clean = ext(htmlText, base || finalUrl)
929
1277
  if (clean && clean.length > 0) {
930
1278
  target.text = mode === 'markdown'
931
1279
  ? blockMd(clean).replace(/\n{3,}/g, '\n\n').trim()
@@ -942,9 +1290,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
942
1290
  // extraction found almost nothing, try headless rendering (playwright).
943
1291
  // Beyond script-heavy shells (looksLikeSpa, ≥5 <script> tags), a page whose
944
1292
  // 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.
1293
+ // (2 scripts, empty <body>, redirect issued from JS) — with no text to
1294
+ // lose, rendering is always worth one bounded attempt. Pages with no
1295
+ // scripts at all cannot change under rendering and are skipped.
948
1296
  let rendered = false
949
1297
  let spaHint = ''
950
1298
  let renderedHtml = null
@@ -956,19 +1304,18 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
956
1304
  renderedHtml = rr.html
957
1305
  // A challenge interstitial (Cloudflare "Just a moment...") must never
958
1306
  // 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.
1307
+ // static body and would evict the latter.
961
1308
  if (looksLikeChallenge(rr.html)) {
962
1309
  renderedHtml = null // pagination/links must not read the interstitial DOM
963
1310
  spaHint = extracted.text
964
1311
  ? '已渲染但被人机验证页拦截,保留静态提取结果'
965
1312
  : '已渲染但被人机验证页拦截(无静态内容可用)'
966
1313
  } else {
967
- const r2 = upgrade(extract(rr.html, mode), rr.html)
1314
+ const r2 = upgrade(extract(rr.html, mode), rr.html, rr.finalUrl || finalUrl)
968
1315
  // Accept the rendered text when it meaningfully beats the static one.
969
1316
  // 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).
1317
+ // (JS shells can render just 30-60 chars of real content, below the
1318
+ // fixed +50 margin).
972
1319
  const gain = r2.text.length - extracted.text.length
973
1320
  const accept = r2.text && (gain > 50 || (!extracted.text && r2.text.length >= 20))
974
1321
  if (accept) {
@@ -1030,8 +1377,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1030
1377
  links: args.includeLinks === true ? extractLinks(renderedHtml || html, cfg.maxLinks, finalUrl) : undefined,
1031
1378
  }
1032
1379
 
1033
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
1034
- cache.set(cacheKey, { time: Date.now(), full })
1380
+ cacheStore(cacheKey, full, cfg.cacheMax)
1035
1381
  return sliceFrom(full, offset, maxChars)
1036
1382
  }
1037
1383
 
@@ -1045,22 +1391,24 @@ function renderResult(value) {
1045
1391
  const meta = []
1046
1392
  if (r.siteName && r.siteName !== host) meta.push(r.siteName)
1047
1393
  if (r.lang) meta.push(r.lang)
1048
- if (r.charset && r.charset !== 'json' && r.charset !== 'feed') meta.push(`charset ${r.charset}`)
1394
+ if (r.charset && r.charset !== 'utf-8' && r.charset !== 'json' && r.charset !== 'feed') {
1395
+ meta.push(`charset ${r.charset}`)
1396
+ }
1049
1397
  if (r.published) meta.push(r.published)
1050
1398
  if (r.author) meta.push(`by ${r.author}`)
1051
1399
  if (r.feedCount) meta.push(`feed · ${r.feedCount} 条`)
1052
1400
  if (r.mode === 'json') meta.push('json')
1053
1401
  if (meta.length) lines.push(meta.join(' · '))
1054
- if (r.charsStart > 0) {
1055
- lines.push(`(chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal} — offset 续读)`)
1056
- } else if (r.truncated) {
1057
- lines.push(`(chars ${r.charsReturned}/${r.charsTotal} — 截断,offset 续读)`)
1058
- } else if (typeof r.charsTotal === 'number') {
1059
- lines.push(`(chars ${r.charsTotal})`)
1060
- }
1061
- if (r.cached) lines.push('(cached)')
1062
- if (r.paginated > 1) lines.push(`(已自动拼接 ${r.paginated} 页)`)
1063
- if (r.rendered) lines.push('(rendered — JS 执行后内容)')
1402
+ // One compact status line instead of several parenthetical lines — the
1403
+ // flags are hints, not content, so they share a single line of tokens.
1404
+ const flags = []
1405
+ if (r.charsStart > 0) flags.push(`chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal}`, 'offset 续读')
1406
+ else if (r.truncated) flags.push(`chars ${r.charsReturned}/${r.charsTotal}`, '截断', 'offset 续读')
1407
+ else if (typeof r.charsTotal === 'number') flags.push(`chars ${r.charsTotal}`)
1408
+ if (r.cached) flags.push('cached')
1409
+ if (r.paginated > 1) flags.push(`已拼接${r.paginated}页`)
1410
+ if (r.rendered) flags.push('rendered')
1411
+ if (flags.length) lines.push(`(${flags.join(' · ')})`)
1064
1412
  if (!r.text) {
1065
1413
  if (r.charsStart > 0 && !r.truncated) {
1066
1414
  lines.push('(offset 已到文末,无更多内容)')
@@ -1134,6 +1482,11 @@ function readLinksTool(ctx, cfg) {
1134
1482
  html = decodeBuffer(page.buffer, page.contentType).text
1135
1483
  finalUrl = page.finalUrl || url
1136
1484
  }
1485
+ // Same meta-refresh shell following as read_url: a link hop serves a
1486
+ // stub whose real links live at the target. Fails open, hop-bounded.
1487
+ const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg)
1488
+ html = followed.html
1489
+ finalUrl = followed.finalUrl
1137
1490
  let links = extractLinks(html, limit, finalUrl)
1138
1491
  // SPA fallback: a JS-only page yields no links from its static HTML;
1139
1492
  // render it and re-extract when the static result looks empty. Any
@@ -1281,7 +1634,7 @@ function sameHost(a, b) {
1281
1634
  }
1282
1635
 
1283
1636
  // URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
1284
- const NOISE_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|css|js|json|xml|pdf|zip|gz|tar|7z|mp3|mp4|avi|mov|webm|woff2?|ttf|eot|map)(\?|#|$)/i
1637
+ const NOISE_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|css|js|json|xml|pdf|zip|gz|tar|7z|mp3|m3u8?|mpd|flv|ts|mp4|avi|mov|webm|woff2?|ttf|eot|map)(\?|#|$)/i
1285
1638
  const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
1286
1639
  function isNoiseUrl(url) {
1287
1640
  return NOISE_EXT.test(url) || NOISE_PATH.test(url)
@@ -1453,6 +1806,7 @@ export function apply(ctx, config) {
1453
1806
  // Temporal composability: unload must fully revert side effects.
1454
1807
  ctx.effect(() => () => {
1455
1808
  cache.clear()
1809
+ failCache.clear()
1456
1810
  closeBrowser().catch(() => {})
1457
1811
  })
1458
1812