dsh-read-url 1.7.0 → 1.8.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
@@ -16,7 +16,7 @@
16
16
  // - session-level cache, compact text render (lowest token cost)
17
17
  import { TextDecoder } from 'node:util'
18
18
  import { looksLikeSpa, renderPage, closeBrowser, looksLikeChallenge } from './spa.js'
19
- import { detectProxy, fetchViaCurlProxy, looksBinary } from './proxy-fallback.js'
19
+ import { detectProxy, fetchViaCurlProxy, looksBinary, isFetchableContentType } from './proxy-fallback.js'
20
20
  // Re-export for tests / programmatic (PTC) cleanup.
21
21
  export { closeBrowser, renderPage, looksLikeSpa, looksLikeChallenge } from './spa.js'
22
22
 
@@ -46,9 +46,10 @@ function cacheFail(key, error) {
46
46
  if (failCache.size >= FAIL_CACHE_MAX) failCache.delete(failCache.keys().next().value)
47
47
  failCache.set(key, { time: Date.now(), error })
48
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 })
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
+ failCache.delete(key)
52
53
  }
53
54
  const decoders = new Map()
54
55
 
@@ -65,21 +66,29 @@ function getDecoder(enc) {
65
66
  return d
66
67
  }
67
68
 
68
- function sniffCharset(buffer, contentType) {
69
+ function declaredCharset(contentType) {
70
+ const m = /(?:^|;)[ \t]{0,1000}charset[ \t]{0,1000}=[ \t]{0,1000}(?:"([\w.:-]{1,80})"|'([\w.:-]{1,80})'|([\w.:-]{1,80}))(?=[ \t;]|$)/i.exec(contentType || '')
71
+ return m ? m[1] || m[2] || m[3] : null
72
+ }
73
+
74
+ function sniffCharset(buffer, contentType) {
69
75
  // BOM first — byte-level evidence beats any declared charset (a UTF-16 body
70
76
  // with a latin1-read <meta> probe garbles the meta match anyway).
71
77
  if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
72
78
  if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le'
73
79
  if (buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be'
74
- const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
75
- if (m) return m[1]
76
- const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
77
- const meta = /<meta[^>]{0,1000}charset=["']?([\w-]+)/i.exec(head)
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]
80
+ const header = declaredCharset(contentType)
81
+ if (header) return header
82
+ const head = buffer.subarray(0, 2048).toString('latin1')
83
+ for (const meta of head.matchAll(/<meta(?=[\s/>])[^>]{0,1000}>/gi)) {
84
+ const attrs = htmlAttributes(meta[0])
85
+ const charset = attrs.charset?.trim()
86
+ if (charset && /^[\w.:-]{1,80}$/.test(charset)) return charset
87
+ if (attrs['http-equiv']?.toLowerCase() === 'content-type') {
88
+ const legacy = declaredCharset(attrs.content)
89
+ if (legacy) return legacy
90
+ }
91
+ }
83
92
  return null
84
93
  }
85
94
 
@@ -104,7 +113,6 @@ export function decodeBuffer(buffer, contentType) {
104
113
  // XML (RSS/Atom feeds) and plain-text families (txt/md/csv — logs, docs,
105
114
  // datasets) are read natively — see readUrl's type dispatch.
106
115
  // Note "application/rss+xml": the separator before xml is '+', not '/'.
107
- const FETCHABLE_CT = /text\/html|application\/xhtml|text\/(?:plain|markdown|csv)|\/json|[+/]xml/i
108
116
 
109
117
  // Retry-After header (seconds form) → ms, capped; undefined when absent/invalid.
110
118
  function retryAfterMs(res) {
@@ -138,7 +146,7 @@ async function directFetchOnce(url, signal, cfg) {
138
146
  return { error: `HTTP ${res.status} ${res.statusText}` }
139
147
  }
140
148
  const contentType = res.headers.get('content-type') || ''
141
- if (contentType && !FETCHABLE_CT.test(contentType)) {
149
+ if (contentType && !isFetchableContentType(contentType)) {
142
150
  discardBody()
143
151
  return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
144
152
  }
@@ -244,8 +252,10 @@ async function raceFetch(url, externalSignal, cfg, proxy) {
244
252
  return winner
245
253
  }
246
254
 
247
- async function fetchPage(url, externalSignal, cfg) {
248
- const proxy = await detectProxy()
255
+ async function fetchPage(url, externalSignal, cfg) {
256
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
257
+ const proxy = await detectProxy()
258
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
249
259
  if (proxy) {
250
260
  const winner = await raceFetch(url, externalSignal, cfg, proxy)
251
261
  if (winner.success) return winner.value
@@ -267,22 +277,38 @@ async function fetchPage(url, externalSignal, cfg) {
267
277
  // layer and only re-extract. Falls back to global fetch when absent.
268
278
  // Seam calls get the same cooperative timeout as fetchPage (a hanging
269
279
  // provider should not block the tool call forever).
270
- async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
271
- try {
280
+ async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
281
+ let web
282
+ try {
272
283
  // ctx.get for a non-injected service throws on strict cordis hosts (seen
273
284
  // with 'settings') — treat that as "seam absent" and fall through.
274
- const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
275
- if (!web || typeof web.fetch !== 'function') return null
276
- const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
277
- const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
278
- const res = await web.fetch(url, { signal })
279
- if (!res || typeof res.content !== 'string') return null
280
- const finalUrl = res.url || res.finalUrl || url
281
- return { html: res.content, finalUrl }
282
- } catch {
283
- return null
284
- }
285
- }
285
+ web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
286
+ } catch {
287
+ return null
288
+ }
289
+ if (!web || typeof web.fetch !== 'function') return null
290
+ try {
291
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
292
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
293
+ if (signal.aborted) return { error: 'cancelled' }
294
+ const res = await new Promise((resolve, reject) => {
295
+ const onAbort = () => reject(signal.reason || new Error('cancelled'))
296
+ signal.addEventListener('abort', onAbort, { once: true })
297
+ Promise.resolve().then(() => web.fetch({ url }, signal)).then(resolve, reject)
298
+ .finally(() => signal.removeEventListener('abort', onAbort))
299
+ })
300
+ if (!res || !Number.isInteger(res.statusCode) || !res.body || typeof res.body.content !== 'string' || !['html', 'text'].includes(res.body.kind)) {
301
+ return { error: 'Web provider returned an unsupported response' }
302
+ }
303
+ if (res.statusCode < 200 || res.statusCode >= 300) return { error: `HTTP ${res.statusCode}` }
304
+ if (Buffer.byteLength(res.body.content, 'utf8') > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes` }
305
+ return { html: res.body.content, kind: res.body.kind, finalUrl: res.url || url, sourceTruncated: res.truncated === true }
306
+ } catch (e) {
307
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
308
+ if (e && e.code === 'WEB_PROVIDER_UNAVAILABLE') return null
309
+ return { error: `Web provider failed: ${String((e && e.message) || e).slice(0, 160)}` }
310
+ }
311
+ }
286
312
 
287
313
  // Decode common HTML entities in already-stripped text. Runs AFTER tag
288
314
  // stripping so escaped tags stay visible to the stripper and only remaining
@@ -402,28 +428,64 @@ function xmlText(s) {
402
428
  }
403
429
 
404
430
  // RSS 2.0 <item> / Atom <entry> → compact list "title — url\n summary".
405
- function parseFeed(xml, limit) {
406
- xml = defuseLt(xml) // keeps the item scan linear
407
- const isAtom = /<feed[\s>]/i.test(xml)
408
- const itemRe = isAtom ? /<entry[\s>][\s\S]*?<\/entry\s*>/gi : /<item[\s>][\s\S]*?<\/item\s*>/gi
409
- // Without a single closing tag nothing can pair, so the scan is skipped.
410
- const closable = isAtom ? /<\/entry\s*>/i.test(xml) : /<\/item\s*>/i.test(xml)
411
- const items = []
412
- let m
413
- while (closable && (m = itemRe.exec(xml)) && items.length < limit) {
414
- const it = m[0]
415
- const title = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(it)?.[1] || '')
416
- const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
417
- const linkHref = /<link[^>]+href=["']([^"']+)["']/i.exec(it)
418
- const link = (linkTag && xmlText(linkTag[1])) || (linkHref && linkHref[1]) || ''
431
+ function parseFeed(xml, limit, baseUrl = '') {
432
+ xml = defuseLt(xml) // keeps the item scan linear
433
+ const root = /<(?:[a-z][\w.-]{0,31}:)?(?:feed|rss|RDF)(?=[\s>])[^>]{0,1000}>/i.exec(xml)
434
+ const isAtom = !!root && /:?(?:feed)\b/i.test(root[0].split(/[\s>]/, 1)[0])
435
+ const resolve = (value, base) => {
436
+ try {
437
+ const u = new URL(decodeUrlAttribute(value), base || undefined)
438
+ return /^https?:$/.test(u.protocol) ? u.href : ''
439
+ } catch { return '' }
440
+ }
441
+ const feedBase = resolve(root ? htmlAttributes(root[0])['xml:base'] || '' : '', baseUrl) || baseUrl
442
+ const field = (body, names) => {
443
+ const re = new RegExp(`<((?:[a-z][\\w.-]{0,31}:)?(?:${names}))(?=[\\s>])[^>]{0,1000}>([\\s\\S]{0,200000}?)</\\1\\s{0,1000}>`, 'i')
444
+ return re.exec(body)
445
+ }
446
+ const textOf = (m) => {
447
+ if (!m) return ''
448
+ const attrs = htmlAttributes(m[0].slice(0, m[0].indexOf('>') + 1))
449
+ if (isAtom && (!attrs.type || attrs.type === 'text')) {
450
+ return decodeTextEntities(m[2].replace(/<!\[CDATA\[([\s\S]{0,200000}?)\]\]>/g, '$1')).trim()
451
+ }
452
+ return xmlText(m[2])
453
+ }
454
+ const itemRe = /<(\/?)([a-z][\w.-]{0,31}:)?(entry|item)(?=[\s>])[^>]{0,1000}>/gi
455
+ const items = []
456
+ let start = null
457
+ let m
458
+ while ((m = itemRe.exec(xml)) && items.length < limit) {
459
+ if (m[3].toLowerCase() !== (isAtom ? 'entry' : 'item')) continue
460
+ if (!m[1]) { if (!start) start = m; continue }
461
+ if (!start || (start[2] || '') !== (m[2] || '')) continue
462
+ const opening = start[0]
463
+ const it = xml.slice(start.index + opening.length, m.index)
464
+ start = null
465
+ const itemBase = resolve(htmlAttributes(opening)['xml:base'] || '', feedBase) || feedBase
466
+ const title = textOf(field(it, 'title'))
467
+ let link = ''
468
+ if (isAtom) {
469
+ for (const lm of it.matchAll(/<(?:[a-z][\w.-]{0,31}:)?link(?=[\s/>])[^>]{0,1000}>/gi)) {
470
+ const attrs = htmlAttributes(lm[0])
471
+ if (attrs.rel && attrs.rel !== 'alternate') continue
472
+ const target = resolve(attrs.href || '', resolve(attrs['xml:base'] || '', itemBase) || itemBase)
473
+ if (!target || !attrs.href) continue
474
+ link = target
475
+ if (!attrs.type || /^(?:text\/html|application\/xhtml\+xml)$/i.test(attrs.type)) break
476
+ }
477
+ } else {
478
+ const rawLink = field(it, 'link')?.[2]?.trim()
479
+ if (rawLink) link = resolve(rawLink.replace(/<!\[CDATA\[([\s\S]{0,10000}?)\]\]>/g, '$1'), itemBase)
480
+ }
419
481
  // Namespaced fields (WordPress <content:encoded>) pair via a captured
420
482
  // name + backreference: the closer must match the SAME name, so a
421
483
  // <content:encoded> opener can never pair with a distant </description>.
422
- const desc = xmlText(/<((?:description|summary|content)(?::[a-zA-Z0-9]+)?)(?:\s[^>]*)?>([\s\S]*?)<\/\1\s*>/i.exec(it)?.[2] || '')
484
+ const desc = textOf(field(it, 'description|summary|content(?::encoded)?'))
423
485
  if (!title && !link && !desc) continue
424
486
  items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
425
487
  }
426
- const feedTitle = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(xml)?.[1] || '')
488
+ const feedTitle = textOf(field(xml, 'title'))
427
489
  const lines = []
428
490
  for (const it of items) {
429
491
  lines.push(`- ${it.title}${it.url ? ` — ${it.url}` : ''}`)
@@ -498,13 +560,14 @@ function tagBlockAt(html, openIdx, tagName, maxScan = Infinity) {
498
560
  // continues from the removed block's end, so removed interiors are never
499
561
  // rescanned and total cost stays near-linear in the surviving input.
500
562
  const BALANCED_SCAN_WINDOW = 100000
501
- function stripBalanced(html, openTagRe) {
563
+ function stripBalanced(html, openTagRe, accept = () => true) {
502
564
  openTagRe.lastIndex = 0
503
565
  let out = ''
504
566
  let last = 0
505
567
  let m
506
- while ((m = openTagRe.exec(html))) {
507
- const tag = m[1].toLowerCase()
568
+ while ((m = openTagRe.exec(html))) {
569
+ const tag = m[1].toLowerCase()
570
+ if (!accept(m[0], tag)) continue
508
571
  const block = tagBlockAt(html, m.index, tag, BALANCED_SCAN_WINDOW)
509
572
  if (!block) continue // unclosed container — keep the content
510
573
  out += html.slice(last, m.index) + ' '
@@ -514,17 +577,24 @@ function stripBalanced(html, openTagRe) {
514
577
  return out + html.slice(last)
515
578
  }
516
579
 
517
- export function pickMain(html) {
580
+ export function pickMain(html) {
518
581
  // Aggregation pages (e.g. blog homepages) have one <article> per item:
519
582
  // collect all of them instead of only the first. A tiny <article> (e.g. a
520
583
  // newsletter card while the real content sits in <main>) falls through to
521
584
  // <main> when present.
522
- const articles = /<\/article/i.test(html)
523
- ? [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
524
- : [] // without a closer in the document nothing can pair
585
+ const articles = []
586
+ let articleDepth = 0
587
+ let articleStart = 0
588
+ for (const m of html.matchAll(/<\/?article(?=[\s>])[^>]{0,1000}>/gi)) {
589
+ if (m[0][1] !== '/') {
590
+ if (articleDepth++ === 0) articleStart = m.index
591
+ } else if (articleDepth > 0 && --articleDepth === 0) {
592
+ articles.push(html.slice(articleStart, m.index + m[0].length))
593
+ }
594
+ }
525
595
  const hasMain = /<main[\s>]/i.test(html)
526
- if (articles.length && (!hasMain || textOnly(articles.map((a) => a[0]).join('')).length >= 200)) {
527
- return articles.map((a) => a[0]).join('\n')
596
+ if (articles.length && (!hasMain || textOnly(articles.join('')).length >= 200)) {
597
+ return articles.join('\n')
528
598
  }
529
599
  const roleMain = /<(main|div)[^>]{0,1000}role=["']main["'][\s>]/i.exec(html)
530
600
  if (roleMain) {
@@ -549,100 +619,163 @@ export function pickMain(html) {
549
619
  return densityFilter(html)
550
620
  }
551
621
 
552
- // Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
553
- // entity-escaped tags (&lt;style&gt;...). Reveal TAG-SHAPED sequences so noise
554
- // rules can strip them. &lt; is revealed only when a tag name follows: a bare
555
- // &lt; from prose ("a &lt; b") would be swallowed by the tag stripper up to
556
- // the next real '>'. Bare &gt; / &quot; never open a strip span and are
557
- // revealed unconditionally.
558
- function revealEscapedTags(html) {
559
- return html
560
- .replace(/&lt;(?=\/?[a-zA-Z!])/gi, '<')
561
- .replace(/&gt;/gi, '>')
562
- .replace(/&quot;/gi, '"')
563
- }
564
-
565
- function stripNoise(mainHtml) {
566
- // Attribute runs are bounded so a '<' whose '>' is far away costs a bounded
567
- // scan instead of a run to that '>'; real attributes never approach 1000
568
- // chars. Container groups only include tags whose closing tag exists in the
569
- // input — absent closers cannot pair, so scanning for their body is wasted
570
- // work.
571
- const closers = new Set()
572
- for (const c of mainHtml.matchAll(/<\/([a-z0-9]+)/gi)) closers.add(c[1].toLowerCase())
573
- const group = (names) => names.filter((n) => closers.has(n))
574
- const raw = group(['textarea', 'style', 'script', 'noscript', 'template'])
575
- const box = group(['nav', 'footer', 'header', 'aside', 'form', 'iframe', 'svg', 'canvas', 'dialog', 'object', 'embed'])
576
- let out = mainHtml.replace(/<!--[\s\S]*?-->/g, ' ')
577
- if (raw.length) {
578
- out = out.replace(new RegExp(`<(${raw.join('|')})[\\s>][\\s\\S]*?<\\/\\1\\s*>`, 'gi'), ' ')
579
- }
580
- if (box.length) {
581
- out = out.replace(new RegExp(`<(${box.join('|')})[\\s>][\\s\\S]*?<\\/\\1>`, 'gi'), ' ')
582
- }
583
- // Elements explicitly hidden from users are invisible decoration or stateful
584
- // UI (collapsed panels, modal templates) — their text must not leak into the
585
- // body. The tag list covers every container that real pages use for hidden
586
- // blocks (nav/footer/header/aside already fell to the box group above).
587
- // `visibility:collapse` hides table rows and flex items the same way
588
- // `display:none` hides boxes, and whitespace around the style colon is legal
589
- // CSS. The [\s"'] guard keeps `hidden`/`style` from matching inside compound
590
- // attr names (data-hidden, aria-hidden). Consent/GDPR banners mount by id
591
- // (onetrust, cookiebot, cybot, gdpr) rather than class, so a second pass
592
- // keys on the id attribute. Removal is a depth-counted balanced scan —
593
- // banners are deeply nested and a lazy `[\s\S]*?` would stop at the first
594
- // inner closer, leaking the rest of the banner text.
595
- out = stripBalanced(out, /<(div|span|section|p|ul|ol|dl|li|article|main|details|figure|pre|table|h[1-6])\b[^>]{0,1000}[\s"'](?:hidden\b|aria-hidden\s*=\s*["']?true|style\s*=\s*["'][^"']{0,200}(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse)))[^>]{0,1000}>/gi)
596
- out = stripBalanced(out, /<([a-z][a-z0-9]*)\s+id=["'][^"']{0,120}(?:onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-)[^"']{0,120}["'][^>]{0,1000}>/gi)
597
- return out.replace(
598
- /<([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,
599
- ' ',
600
- )
601
- }
622
+ // Parse complete, bounded opening tags; quoted values remain one attribute.
623
+ function htmlAttributes(tag) {
624
+ const attrs = Object.create(null)
625
+ if (tag.length > 1002) return attrs
626
+ const start = /^<[a-z][a-z0-9:-]{0,63}/i.exec(tag)
627
+ if (!start) return attrs
628
+ const re = /\s{1,1000}([^\s"'<>/=]{1,64})(?:\s{0,1000}=\s{0,1000}(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s"'=<>`]{1,1000})))?/gy
629
+ re.lastIndex = start[0].length
630
+ let m
631
+ while ((m = re.exec(tag))) {
632
+ const key = m[1].toLowerCase()
633
+ if (!Object.hasOwn(attrs, key)) attrs[key] = m[2] ?? m[3] ?? m[4] ?? ''
634
+ }
635
+ return attrs
636
+ }
637
+
638
+ // Raw-text elements and comments cannot supply visible document containers.
639
+ function stripRawElements(html) {
640
+ const open = /<!--|<(script|style|textarea|noscript|template)(?=[\s>])[^>]{0,1000}>/gi
641
+ const parts = []
642
+ let last = 0
643
+ let m
644
+ while ((m = open.exec(html))) {
645
+ let end
646
+ if (!m[1]) {
647
+ const close = html.indexOf('-->', open.lastIndex)
648
+ end = close < 0 ? html.length : close + 3
649
+ } else {
650
+ const close = new RegExp(`</${m[1]}\\s{0,1000}>`, 'gi')
651
+ close.lastIndex = open.lastIndex
652
+ const match = close.exec(html)
653
+ end = match ? close.lastIndex : html.length
654
+ }
655
+ parts.push(html.slice(last, m.index), ' ')
656
+ last = end
657
+ open.lastIndex = end
658
+ }
659
+ parts.push(html.slice(last))
660
+ return parts.join('')
661
+ }
662
+
663
+ // Track native noise containers in one pass, including large embedded assets.
664
+ function stripStructuralNoise(html) {
665
+ const re = /<(\/?)(nav|footer|header|aside|form|iframe|svg|canvas|dialog|object|embed)(?=[\s/>])/gi
666
+ const active = new Map()
667
+ const ranges = []
668
+ let m
669
+ while ((m = re.exec(html))) {
670
+ const end = html.indexOf('>', re.lastIndex)
671
+ if (end < 0) break
672
+ const name = m[2].toLowerCase()
673
+ if (!m[1] && name === 'svg' && html[end - 1] === '/') {
674
+ ranges.push({ start: m.index, end: end + 1 })
675
+ } else if (!m[1]) {
676
+ const state = active.get(name)
677
+ if (state) state.depth++
678
+ else {
679
+ const range = { start: m.index, end: 0, depth: 1 }
680
+ ranges.push(range)
681
+ active.set(name, range)
682
+ }
683
+ } else if (end - re.lastIndex <= 1000 && !html.slice(re.lastIndex, end).trim()) {
684
+ const state = active.get(name)
685
+ if (state && --state.depth === 0) {
686
+ state.end = end + 1
687
+ active.delete(name)
688
+ }
689
+ }
690
+ re.lastIndex = end + 1
691
+ }
692
+ const parts = []
693
+ let last = 0
694
+ for (const range of ranges) {
695
+ if (!range.end || range.end <= last) continue
696
+ if (range.start > last) parts.push(html.slice(last, range.start))
697
+ parts.push(' ')
698
+ last = range.end
699
+ }
700
+ parts.push(html.slice(last))
701
+ return parts.join('')
702
+ }
703
+
704
+ function stripNoise(mainHtml) {
705
+ // Bounded attribute parsing and balanced removal preserve nested content.
706
+ const out = stripStructuralNoise(stripRawElements(mainHtml))
707
+ // Attribute names determine visibility; class heuristics apply to containers
708
+ // so source-code tokens remain visible. Noise names match whole word segments.
709
+ return stripBalanced(out, /<([a-z][a-z0-9-]{0,63})\b[^>]{0,1000}>/gi, (tag, name) => {
710
+ const attrs = htmlAttributes(tag)
711
+ if (Object.hasOwn(attrs, 'hidden') || attrs['aria-hidden']?.toLowerCase() === 'true') return true
712
+ if (/(?:^|;)\s{0,200}(?:display\s{0,20}:\s{0,20}none|visibility\s{0,20}:\s{0,20}(?:hidden|collapse))\s{0,20}(?:!important\s{0,20})?(?:;|$)/i.test(attrs.style || '')) return true
713
+ if (/(?:onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-)/i.test(attrs.id || '')) return true
714
+ if (!/^(?:div|section|article|ul|ol|li|p)$/.test(name)) return false
715
+ return /(?:^|[\s_-])(?:ads?|adsbygoogle|advert(?:isement|ising)?|banner|sidebar|social|share|comments?|popup|modal|cookies?)(?=$|[\s_-])/i.test(attrs.class || '')
716
+ })
717
+ }
602
718
 
603
719
  // ---- HTML -> Markdown (lightweight, tag-state machine) ----
604
- function escInline(s) {
605
- return s.replace(/([\\`*_[\]])/g, '\\$1')
606
- }
720
+ function escInline(s) {
721
+ return s.replace(/([\\`*_[\]])/g, '\\$1')
722
+ }
723
+
724
+ function markdownTarget(value) {
725
+ return decodeUrlAttribute(value).trim()
726
+ .replace(/\(/g, '%28').replace(/\)/g, '%29')
727
+ .replace(/[<>\s\\]/g, encodeURIComponent)
728
+ }
607
729
 
608
730
  // <img> is a void element (never matches the paired-tag regex in either md
609
731
  // walker): convert descriptive images to markdown BEFORE the tag walk, via a
610
732
  // sentinel so escInline cannot escape the generated brackets. Decorative
611
733
  // images (empty alt) are dropped — the model cannot see pixels anyway.
612
- const IMG_SENTINEL = '\u0001'
613
- function imgsToMarkdown(html) {
614
- const store = []
734
+ const IMG_SENTINEL = '\u0001'
735
+ let markdownSentinelId = 0
736
+ function imgsToMarkdown(html) {
737
+ const store = new Map()
738
+ const protect = (value) => {
739
+ const id = markdownSentinelId++
740
+ store.set(id, value)
741
+ return `${IMG_SENTINEL}${id}${IMG_SENTINEL}`
742
+ }
615
743
  // AMP pages use <amp-img> instead of <img>; responsive layouts wrap images
616
744
  // in <picture><source srcset>. When the <img> fallback carries no usable
617
745
  // src (lazy placeholder), the first source URL is injected into it so the
618
746
  // normal img pass below still emits the image.
619
- html = html.replace(/<picture\b[^>]*>([\s\S]{0,50000}?)<\/picture\s*>/gi, (block) => {
620
- const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
621
- const imgSrc = imgTag && /(?:^|\s)src=["']([^"']+)["']/i.exec(imgTag[0])
622
- if (imgTag && imgSrc && !/^data:/i.test(imgSrc[1])) return block
623
- const source = /<source\b[^>]{0,1000}\bsrcset=["']([^"'\s>]+)/i.exec(block)
624
- if (!source || !imgTag) return block
625
- return block.replace(/<img\b[^>]{0,1000}>/i, (img0) => img0.replace(/<img/i, `<img src="${source[1]}"`))
626
- })
627
- const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
628
- const alt = /alt=["']([^"']*)["']/i.exec(tag)
629
- let src = /src=["']([^"']+)["']/i.exec(tag)
630
- let srcVal = src && src[1]
747
+ html = html.replace(/<picture\b[^>]{0,1000}>([\s\S]{0,50000}?)<\/picture\s{0,1000}>/gi, (block) => {
748
+ const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
749
+ const imgSrc = imgTag && htmlAttributes(imgTag[0]).src
750
+ if (!imgTag || (imgSrc && !/^data:/i.test(imgSrc))) return block
751
+ let source = ''
752
+ for (const tag of block.matchAll(/<source\b[^>]{0,1000}>/gi)) {
753
+ source = htmlAttributes(tag[0]).srcset?.trim().split(/[\s,]{1,1000}/, 1)[0] || ''
754
+ if (source) break
755
+ }
756
+ if (!source) return block
757
+ const escaped = decodeUrlAttribute(source).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
758
+ return block.replace(imgTag[0], () => `<img src="${escaped}"${imgTag[0].slice(4)}`)
759
+ })
760
+ const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
761
+ const attrs = htmlAttributes(tag)
762
+ const alt = decodeTextEntities(attrs.alt || '').replace(/\s{1,1000}/g, ' ').trim()
763
+ let srcVal = attrs.src?.trim()
631
764
  // Lazy-load wrappers keep the real URL in data-* attributes; a data: URI
632
765
  // src is an inline placeholder spacer, not a usable image reference.
633
766
  if (!srcVal || /^data:/i.test(srcVal)) {
634
- const lazy = /data-(?:src|original|lazy-src)=["']([^"']+)["']/i.exec(tag)
635
- if (lazy) srcVal = lazy[1]
636
- }
637
- store.push(srcVal && alt && alt[1].trim() ? `![${alt[1].trim()}](${srcVal})` : '')
638
- return `${IMG_SENTINEL}${store.length - 1}${IMG_SENTINEL}`
639
- })
640
- return {
641
- html: out,
767
+ const lazy = attrs['data-src'] || attrs['data-original'] || attrs['data-lazy-src']
768
+ if (lazy) srcVal = lazy.trim()
769
+ }
770
+ return protect(srcVal && alt ? `![${escInline(alt)}](${markdownTarget(srcVal)})` : '')
771
+ })
772
+ return {
773
+ html: out,
774
+ protect,
642
775
  // Unknown sentinel indexes are PRESERVED (a nested walker's restore must
643
776
  // not eat sentinels owned by an outer frame — blockMd recurses into itself).
644
- restore: (s) => s.replace(new RegExp(`${IMG_SENTINEL}(\\d+)${IMG_SENTINEL}`, 'g'), (m, i) => {
645
- const v = store[Number(i)]
777
+ restore: (s) => s.replace(new RegExp(`${IMG_SENTINEL}(\\d{1,16})${IMG_SENTINEL}`, 'g'), (m, i) => {
778
+ const v = store.get(Number(i))
646
779
  return v === undefined ? m : v
647
780
  }),
648
781
  }
@@ -657,12 +790,36 @@ function imgsToMarkdown(html) {
657
790
  const OPEN_TAG_RE = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>/g
658
791
  const MD_MAX_DEPTH = 100
659
792
  const MD_TABLE_MAX_ROWS = 25
660
- function stripUnmatchedOpeners(html) {
793
+ function stripUnmatchedOpeners(html) {
661
794
  const closers = new Set()
662
795
  for (const m of html.matchAll(/<\/([a-zA-Z0-9]+)/g)) closers.add(m[1].toLowerCase())
663
796
  html = defuseLt(html)
664
- return html.replace(OPEN_TAG_RE, (m, name) => (closers.has(name.toLowerCase()) ? m : ''))
665
- }
797
+ return html.replace(OPEN_TAG_RE, (m, name) => {
798
+ const tag = name.toLowerCase()
799
+ return tag === 'br' || tag === 'hr' || closers.has(tag) ? m : ''
800
+ })
801
+ }
802
+
803
+ function codeText(html) {
804
+ return decodeTextEntities(html.replace(/<br\b[^>]{0,1000}>/gi, '\n').replace(/<[^>]{1,1000}>/g, ''))
805
+ }
806
+
807
+ function backtickFence(code, minLength) {
808
+ let longest = 0, run = 0
809
+ for (const ch of code) {
810
+ run = ch === '`' ? run + 1 : 0
811
+ if (run > longest) longest = run
812
+ }
813
+ return '`'.repeat(Math.max(minLength, longest + 1))
814
+ }
815
+
816
+ function inlineCode(html) {
817
+ const code = codeText(html).replace(/\r\n?|\n/g, ' ')
818
+ if (!code) return ''
819
+ const fence = backtickFence(code, 1)
820
+ const pad = /^[ `]|[ `]$/.test(code) && /[^ ]/.test(code) ? ' ' : ''
821
+ return `${fence}${pad}${code}${pad}${fence}`
822
+ }
666
823
 
667
824
  export function inlineMd(html, depth = 0) {
668
825
  if (depth > MD_MAX_DEPTH) {
@@ -671,30 +828,36 @@ export function inlineMd(html, depth = 0) {
671
828
  const img = imgsToMarkdown(html)
672
829
  html = stripUnmatchedOpeners(img.html)
673
830
  let out = ''
674
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
831
+ const re = /<(?:br|hr)\b[^>]{0,1000}>|<([a-zA-Z0-9]{1,100})((?:"[^"]{0,1000}"|'[^']{0,1000}'|[^'">]){0,1000})>([\s\S]{0,3145728}?)<\/\1>|<[^>]{1,1000}>|([^<]{1,3145728})/gi
675
832
  let m
676
833
  while ((m = re.exec(html))) {
677
834
  if (m[4] !== undefined) {
678
- out += escInline(decodeTextEntities(m[4]))
835
+ out += escInline(decodeTextEntities(m[4]).replace(/[ \t]{1,3145728}/g, ' '))
679
836
  continue
680
837
  }
681
- if (!m[1]) continue
682
- const tag = m[1].toLowerCase()
683
- if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
684
- const inner = inlineMd(m[3], depth + 1)
685
- if (tag === 'a') {
686
- const href = /href=["']([^"']+)["']/i.exec(m[2])
838
+ if (!m[1]) {
839
+ if (/^<br\b/i.test(m[0])) out += img.protect(' \n')
840
+ else if (/^<hr\b/i.test(m[0])) out += img.protect('\n\n---\n\n')
841
+ continue
842
+ }
843
+ const tag = m[1].toLowerCase()
844
+ if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
845
+ if (tag === 'code') {
846
+ out += img.protect(inlineCode(m[3]))
847
+ continue
848
+ }
849
+ const inner = inlineMd(m[3], depth + 1)
850
+ if (tag === 'a') {
851
+ const href = htmlAttributes(`<a${m[2]}>`).href
687
852
  // A raw ')' in the target would terminate the markdown link early
688
853
  // (common on wiki/framework URLs) — percent-encode parens to keep the
689
854
  // link parseable by the model.
690
- out += href ? `[${inner}](${href[1].replace(/\)/g, '%29').replace(/\(/g, '%28')})` : inner
855
+ out += href?.trim() ? `[${inner}](${markdownTarget(href)})` : inner
691
856
  } else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
692
857
  else if (tag === 'em' || tag === 'i') out += `*${inner}*`
693
- else if (tag === 'code') out += `\`${inner}\``
694
- else if (tag === 'br') out += ' \n'
695
- else out += inner
858
+ else out += inner
696
859
  }
697
- return img.restore(out.replace(/[ \t]+/g, ' ').trim())
860
+ return img.restore(out.trim())
698
861
  }
699
862
 
700
863
  export function blockMd(html, depth = 0) {
@@ -704,14 +867,18 @@ export function blockMd(html, depth = 0) {
704
867
  const img = imgsToMarkdown(html)
705
868
  html = stripUnmatchedOpeners(img.html)
706
869
  let out = ''
707
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
870
+ const re = /<(?:br|hr)\b[^>]{0,1000}>|<([a-zA-Z0-9]{1,100})((?:"[^"]{0,1000}"|'[^']{0,1000}'|[^'">]){0,1000})>([\s\S]{0,3145728}?)<\/\1>|<[^>]{1,1000}>|([^<]{1,3145728})/gi
708
871
  let m
709
872
  while ((m = re.exec(html))) {
710
873
  if (m[4] !== undefined) {
711
874
  out += decodeTextEntities(m[4])
712
875
  continue
713
876
  }
714
- if (!m[1]) continue
877
+ if (!m[1]) {
878
+ if (/^<br\b/i.test(m[0])) out += ' \n'
879
+ else if (/^<hr\b/i.test(m[0])) out += '\n\n---\n\n'
880
+ continue
881
+ }
715
882
  const tag = m[1].toLowerCase()
716
883
  const attrs = m[2] || ''
717
884
  const inner = m[3]
@@ -735,19 +902,20 @@ export function blockMd(html, depth = 0) {
735
902
  // Strip wrapper tags FIRST, then decode entities — a literal &lt;div&gt;
736
903
  // in the code must become <div> in the fenced block, but a real nested
737
904
  // tag must not survive the strip to be rendered as markdown.
738
- const code = decodeTextEntities(inner.replace(/<[^>]{1,1000}>/g, '')).trim()
905
+ const code = codeText(inner).replace(/^\r?\n|\r?\n$/g, '')
739
906
  // Language hint from common highlighter conventions: the model reads
740
907
  // ```js fenced blocks far more accurately than unlabelled ones.
741
908
  const lang =
742
- /<code[^>]+class=["'][^"']*(?:language|lang)-([\w#+.-]+)["']/i.exec(inner)?.[1] || ''
743
- out += `\n\n\`\`\`${lang}\n${code}\n\`\`\``
744
- } else if (tag === 'code') {
745
- out += `\`${textOnly(inner)}\``
909
+ /<code\b[^>]{0,1000}class=["'][^"']{0,1000}(?:language|lang)-([\w#+.-]{1,80})(?:[\s"'])/i.exec(inner)?.[1] || ''
910
+ const fence = backtickFence(code, 3)
911
+ out += `\n\n${fence}${lang}\n${code}\n${fence}`
912
+ } else if (tag === 'code') {
913
+ out += img.protect(inlineCode(inner))
746
914
  } else if (tag === 'ul' || tag === 'ol') {
747
915
  // Without a single </li> nothing can pair, so the item scan is skipped.
748
916
  const items = []
749
- if (inner.includes('</li')) {
750
- const liRe = /<li[^>]{0,1000}>([\s\S]*?)<\/li>/gi
917
+ if (/<\/li\b/i.test(inner)) {
918
+ const liRe = /<li\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/li>/gi
751
919
  let lm, idx = 1
752
920
  while ((lm = liRe.exec(inner))) {
753
921
  const t = inlineMd(lm[1], depth + 1)
@@ -762,35 +930,40 @@ export function blockMd(html, depth = 0) {
762
930
  out += ' \n'
763
931
  } else if (tag === 'hr') {
764
932
  out += '\n\n---'
765
- } else if (tag === 'table') {
766
- const rows = []
767
- if (inner.includes('</tr')) {
768
- const trRe = /<tr[^>]{0,1000}>([\s\S]*?)<\/tr>/gi
769
- let tm
770
- while ((tm = trRe.exec(inner))) {
771
- const cells = []
772
- if (tm[1].includes('</t')) {
773
- const tdRe = /<t[dh][^>]{0,1000}>([\s\S]*?)<\/t[dh]>/gi
774
- let cm
775
- while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1], depth + 1).replace(/\|/g, '\\|'))
776
- }
777
- if (cells.length) rows.push(`| ${cells.join(' | ')} |`)
778
- }
779
- }
780
- if (rows.length) {
781
- const header = rows[0]
782
- // unescape cells' escaped pipes before deriving the separator row,
783
- // otherwise the --- line is one char wider per escaped pipe
784
- const sep = header.replace(/\\\|/g, '|').replace(/[^|]/g, '-')
933
+ } else if (tag === 'table') {
934
+ const rows = []
935
+ let rowCount = 0, columnCount = 0
936
+ if (/<\/tr\b/i.test(inner)) {
937
+ const trRe = /<tr\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/tr>/gi
938
+ let tm
939
+ while ((tm = trRe.exec(inner))) {
940
+ const cells = []
941
+ if (/<\/t[dh]\b/i.test(tm[1])) {
942
+ const tdRe = /<t[dh]\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/t[dh]>/gi
943
+ if (rows.length >= MD_TABLE_MAX_ROWS) {
944
+ if (tdRe.test(tm[1])) rowCount++
945
+ continue
946
+ }
947
+ let cm
948
+ while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1], depth + 1).replace(/\|/g, '\\|').split(/\r?\n/).map(line => line.trimEnd()).join('<br>'))
949
+ }
950
+ if (cells.length) {
951
+ if (!rows.length) columnCount = cells.length
952
+ rows.push(`| ${cells.join(' | ')} |`)
953
+ rowCount++
954
+ }
955
+ }
956
+ }
957
+ if (rows.length) {
958
+ const sep = `| ${Array(columnCount).fill('---').join(' | ')} |`
785
959
  // Compatibility tables (40+ rows × many columns) are the single
786
960
  // largest token sink in markdown mode; the hint keeps the model
787
961
  // aware the table continues without repeating the cell noise.
788
- const kept = rows.length > MD_TABLE_MAX_ROWS ? rows.slice(0, MD_TABLE_MAX_ROWS) : rows
789
- const more = rows.length - kept.length
790
- out += `\n\n${kept.join('\n')}\n${sep}${more > 0 ? `\n…+${more} rows` : ''}`
791
- }
792
- } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
793
- const t = inlineMd(inner, depth + 1)
962
+ const more = rowCount - rows.length
963
+ out += `\n\n${[rows[0], sep, ...rows.slice(1)].join('\n')}${more > 0 ? `\n…+${more} rows` : ''}`
964
+ }
965
+ } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
966
+ const t = inlineMd(m[0], depth + 1)
794
967
  if (t) out += t
795
968
  } else {
796
969
  const t = blockMd(inner, depth + 1)
@@ -804,11 +977,12 @@ export function blockMd(html, depth = 0) {
804
977
  // `key`. Attribute order is arbitrary (content may precede property/name), and
805
978
  // the content value may itself contain quotes — locate the tag first, then
806
979
  // read content from inside it with a backreference pair.
807
- function metaContent(html, key) {
808
- const tagM = new RegExp(`<meta\\b[^>]{0,1000}(?:property|name)=["']${key}["'][^>]{0,1000}>`, 'i').exec(html)
809
- if (!tagM) return ''
810
- const m = /content=("([^"]*)"|'([^']*)')/i.exec(tagM[0])
811
- return m ? (m[2] || m[3] || '') : ''
980
+ function metaContent(html, key) {
981
+ for (const m of html.matchAll(/<meta(?=[\s/>])[^>]{0,1000}>/gi)) {
982
+ const attrs = htmlAttributes(m[0])
983
+ if ((attrs.property || attrs.name || '').toLowerCase() === key) return attrs.content || ''
984
+ }
985
+ return ''
812
986
  }
813
987
 
814
988
  // Page-level metadata the model actually asks about (who wrote it, when) —
@@ -945,12 +1119,17 @@ function noscriptBody(html) {
945
1119
  // win; otherwise the FIRST time tag in the scoped html is used. Callers pass
946
1120
  // the noise-stripped main content so footer/comment timestamps (which carry
947
1121
  // their own <time> tags) are out of scope. Empty attribute is a no-op.
948
- function timeTagMeta(html) {
949
- if (!html) return ''
950
- const marked = /<time\b[^>]{0,1000}(?:itemprop=["']datePublished["']|pubdate\b)[^>]{0,1000}datetime=["']([^"']{1,60})["']/i.exec(html)
951
- if (marked) return marked[1].trim().slice(0, 40)
952
- const m = /<time\b[^>]{0,1000}datetime=["']([^"']{1,60})["']/i.exec(html)
953
- return m ? m[1].trim().slice(0, 40) : ''
1122
+ function timeTagMeta(html) {
1123
+ if (!html) return ''
1124
+ let first = ''
1125
+ for (const m of html.matchAll(/<time(?=[\s>])[^>]{0,1000}>/gi)) {
1126
+ const attrs = htmlAttributes(m[0])
1127
+ const value = (attrs.datetime || '').trim().slice(0, 40)
1128
+ if (!value) continue
1129
+ if (/(?:^|\s)datePublished(?:\s|$)/i.test(attrs.itemprop || '') || Object.hasOwn(attrs, 'pubdate')) return value
1130
+ if (!first) first = value
1131
+ }
1132
+ return first
954
1133
  }
955
1134
 
956
1135
  // CJK / loose-variant dates normalize to ISO so the metadata line stays
@@ -1023,8 +1202,7 @@ export function extract(html, mode, anchor = '') {
1023
1202
  const ogTitle = metaContent(html, 'og:title')
1024
1203
  const siteName = metaContent(html, 'og:site_name')
1025
1204
  const langMatch = /<html[^>]{1,1000}lang=["']([\w-]+)["']/i.exec(html)
1026
- let main = stripNoise(pickMain(html))
1027
- main = stripNoise(revealEscapedTags(main))
1205
+ const main = stripNoise(pickMain(stripRawElements(html)))
1028
1206
  // A URL fragment scopes the read to one section of a long reference page —
1029
1207
  // slice at the anchor so only that section is extracted. Unmatched anchors
1030
1208
  // degrade to the full document instead of failing.
@@ -1111,63 +1289,68 @@ async function getReadabilityExtractor() {
1111
1289
  return readabilityReady
1112
1290
  }
1113
1291
 
1114
- export function smartTruncate(text, maxChars, offset = 0) {
1115
- const total = text.length
1116
- if (offset >= total) {
1117
- return { text: '', truncated: false, charsTotal: total, charsReturned: 0, charsStart: offset }
1118
- }
1292
+ export function smartTruncate(text, maxChars, offset = 0) {
1293
+ const total = text.length
1294
+ offset = Math.max(0, Math.floor(Number(offset) || 0))
1295
+ maxChars = Math.max(0, Math.floor(Number(maxChars) || 0))
1296
+ if (offset >= total) {
1297
+ return { text: '', truncated: false, charsTotal: total, charsReturned: 0, charsStart: offset }
1298
+ }
1119
1299
  if (total <= maxChars && offset === 0) {
1120
1300
  return { text, truncated: false, charsTotal: total, charsReturned: total, charsStart: 0 }
1121
1301
  }
1122
- const paragraphs = text.split(/\n\n+/)
1123
- let pos = 0
1124
- let start = 0
1125
- for (let i = 0; i < paragraphs.length; i++) {
1126
- if (pos + paragraphs[i].length > offset) {
1127
- start = i
1128
- break
1129
- }
1130
- pos += paragraphs[i].length + 2
1131
- }
1132
- let acc = ''
1133
- for (let i = start; i < paragraphs.length; i++) {
1134
- const p = paragraphs[i]
1135
- if (acc.length + p.length + (acc ? 2 : 0) > maxChars) break
1136
- acc += (acc ? '\n\n' : '') + p
1137
- }
1138
- if (!acc && maxChars > 0) {
1139
- // The first paragraph after the offset is itself longer than maxChars.
1140
- // Keep the alignment promise at sentence level before hard-slicing.
1141
- const p = paragraphs[start] || ''
1142
- const rel = Math.max(0, offset - pos)
1143
- const rest = p.length > rel ? p.slice(rel) : text.slice(offset)
1144
- const sentences = rest.match(/[^.!?\n。!?]+[.!?\n。!?]*/g) || [rest]
1145
- for (const s of sentences) {
1146
- if (acc.length + s.length > maxChars) break
1147
- acc += s
1148
- }
1149
- if (!acc) acc = rest.slice(0, maxChars)
1150
- }
1151
- return {
1152
- text: acc,
1153
- truncated: offset + acc.length < total,
1302
+ let start = offset
1303
+ // Continuations omit paragraph separators and report the exact source span.
1304
+ if (text[start] === '\n' && (text[start - 1] === '\n' || text[start + 1] === '\n')) {
1305
+ while (start < total && text[start] === '\n') start++
1306
+ }
1307
+ let end = Math.min(total, start + maxChars)
1308
+ if (end < total && maxChars > 0) {
1309
+ const window = text.slice(start, end + 2)
1310
+ let boundary = window.lastIndexOf('\n\n', end - start)
1311
+ if (boundary >= 0) boundary += start
1312
+ while (boundary > start && text[boundary - 1] === '\n') boundary--
1313
+ if (boundary > start) end = boundary
1314
+ else {
1315
+ // Prefer the last complete sentence inside the current window.
1316
+ for (let i = end - 1; i >= start; i--) {
1317
+ if ('.!?\n。!?'.includes(text[i])) {
1318
+ end = i + 1
1319
+ break
1320
+ }
1321
+ }
1322
+ }
1323
+ // Keep UTF-16 surrogate pairs together when the hard limit splits one.
1324
+ const previous = text.charCodeAt(end - 1), next = text.charCodeAt(end)
1325
+ if (end > start + 1 && previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--
1326
+ }
1327
+ const acc = text.slice(start, end)
1328
+ return {
1329
+ text: acc,
1330
+ truncated: end < total,
1154
1331
  charsTotal: total,
1155
1332
  charsReturned: acc.length,
1156
- charsStart: offset,
1157
- }
1158
- }
1159
-
1160
- // <base href> overrides the document base for relative URL resolution (old
1161
- // forums / frame sites). Per HTML spec only the FIRST <base> in <head> counts;
1162
- // attribute order is arbitrary and the href may be protocol-relative. Empty
1163
- // href (= document URL) is a no-op; non-http(s) targets are rejected. The
1164
- // head scan is bounded — the tag cannot legally appear beyond it.
1165
- function detectBaseHref(html, finalUrl) {
1333
+ charsStart: start,
1334
+ }
1335
+ }
1336
+
1337
+ function decodeUrlAttribute(value) {
1338
+ // Attribute entities preserve bare query keys followed by an equals sign.
1339
+ return value.replace(/&(?:#(?:\d{1,10}|x[0-9a-f]{1,8});?|[a-z][a-z0-9]{1,7}(?:;|(?![a-z0-9=])))/gi,
1340
+ (entity) => decodeTextEntities(entity))
1341
+ }
1342
+
1343
+ // <base href> overrides the document base for relative URL resolution (old
1344
+ // forums / frame sites). Per HTML spec only the FIRST <base> in <head> counts;
1345
+ // attribute order is arbitrary and the href may be protocol-relative. Empty
1346
+ // href (= document URL) is a no-op; non-http(s) targets are rejected. The
1347
+ // head scan is bounded — the tag cannot legally appear beyond it.
1348
+ function detectBaseHref(html, finalUrl) {
1166
1349
  const headEnd = html.search(/<\/head\s*>/i)
1167
1350
  const head = headEnd >= 0 ? html.slice(0, headEnd) : html.slice(0, 16384)
1168
1351
  const base = /<base\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>/i.exec(head)
1169
1352
  if (!base) return finalUrl
1170
- const href = base[1].trim()
1353
+ const href = decodeUrlAttribute(base[1]).trim()
1171
1354
  if (!href) return finalUrl
1172
1355
  try {
1173
1356
  const u = new URL(href, finalUrl)
@@ -1196,7 +1379,7 @@ function extractLinks(html, limit, baseUrl) {
1196
1379
  let scans = 0
1197
1380
  const maxScans = limit * 4
1198
1381
  while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
1199
- const href = m[1].trim()
1382
+ const href = decodeUrlAttribute(m[1]).trim()
1200
1383
  if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
1201
1384
  let url
1202
1385
  try {
@@ -1231,7 +1414,7 @@ function hostOf(url) {
1231
1414
  // the model had to notice the truncation and re-call the tool per page. The
1232
1415
  // recognition is deliberately conservative: rel=next (standard) or a short
1233
1416
  // anchor whose whole text is a next-page marker — no fuzzy guessing.
1234
- const NEXT_TEXT_RE = /^(?:[›»>]+\s*)?(?:下一页|下页|下一篇|下一頁|下頁|后一页|後一頁|next page|next|older entries|[›»>]{1,3})(?:\s*[›»>]+)?$/i
1417
+ const NEXT_TEXT_RE = /^(?:[›»>]{1,3}\s{0,4})?(?:下一页|下页|下一頁|下頁|后一页|後一頁|next page|next|older entries|[›»>]{1,3})(?:\s{0,4}[›»>]{1,3})?$/i
1235
1418
 
1236
1419
  export function findNextLink(html, baseUrl) {
1237
1420
  // Same <base href> rule as extractLinks: frame/forum sites pin their
@@ -1239,7 +1422,7 @@ export function findNextLink(html, baseUrl) {
1239
1422
  const base = detectBaseHref(html || '', baseUrl)
1240
1423
  const resolve = (href) => {
1241
1424
  try {
1242
- const u = new URL(href, base)
1425
+ const u = new URL(decodeUrlAttribute(href), base)
1243
1426
  return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
1244
1427
  } catch {
1245
1428
  return null
@@ -1320,7 +1503,7 @@ export function compactJson(value) {
1320
1503
  if (typeof v === 'string') return clip(v)
1321
1504
  if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1))
1322
1505
  if (v && typeof v === 'object' && depth < 12) {
1323
- const o = {}
1506
+ const o = Object.create(null)
1324
1507
  for (const k of Object.keys(v)) o[k] = walk(v[k], depth + 1)
1325
1508
  return o
1326
1509
  }
@@ -1384,36 +1567,42 @@ export function metaRefreshTarget(html, baseUrl) {
1384
1567
  // cache key is the ORIGINAL url, and a shell re-request should re-follow
1385
1568
  // rather than serve a foreign page.
1386
1569
  const MAX_META_REFRESH_HOPS = 3
1387
- async function followMetaRefresh(html, finalUrl, externalSignal, cfg) {
1388
- let cur = html
1389
- let curUrl = finalUrl
1390
- let curCharset = '' // set only when a hop actually happened
1570
+ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
1571
+ let cur = html
1572
+ let curUrl = finalUrl
1573
+ let curCharset = '' // set only when a hop actually happened
1574
+ let sourceTruncated
1391
1575
  const seen = new Set([normalizeUrl(finalUrl)])
1392
1576
  // Chain budget: each hop carries its own fetch timeout, so 3 hops could
1393
1577
  // triple the call's cost — the whole follow chain shares ONE timeout.
1394
- const t0 = Date.now()
1395
- for (let i = 0; i < MAX_META_REFRESH_HOPS; i++) {
1578
+ const t0 = Date.now()
1579
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
1580
+ const hopSignal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
1581
+ for (let i = 0; i < MAX_META_REFRESH_HOPS; i++) {
1582
+ if (hopSignal.aborted) break
1396
1583
  const target = metaRefreshTarget(cur, curUrl)
1397
1584
  if (!target) break
1398
1585
  if (Date.now() - t0 > cfg.timeoutMs) break
1399
1586
  const norm = normalizeUrl(target)
1400
1587
  if (!norm || seen.has(norm)) break
1401
1588
  seen.add(norm)
1402
- const page = await fetchPage(target, externalSignal, cfg)
1403
- if (page.error) break
1404
- const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1405
- if (!/html|xhtml/.test(ct)) break
1406
- const decoded = decodeBuffer(page.buffer, page.contentType)
1589
+ const page = await fetchViaWebSeam(ctx, target, hopSignal, cfg) || await fetchPage(target, hopSignal, cfg)
1590
+ if (page.error) break
1591
+ const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
1592
+ if (!/html|xhtml/.test(ct)) break
1593
+ const decoded = typeof page.html === 'string' ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
1407
1594
  cur = decoded.text
1408
- curCharset = decoded.charset
1409
- curUrl = page.finalUrl || target
1410
- }
1411
- return { html: cur, finalUrl: curUrl, charset: curCharset }
1595
+ curCharset = decoded.charset
1596
+ curUrl = page.finalUrl || target
1597
+ sourceTruncated = page.sourceTruncated === true
1598
+ }
1599
+ return { html: cur, finalUrl: curUrl, charset: curCharset, sourceTruncated }
1412
1600
  }
1413
1601
 
1414
- export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1415
- const url = String((args && args.url) || '').trim()
1416
- if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1602
+ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1603
+ const url = String((args && args.url) || '').trim()
1604
+ if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1605
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1417
1606
  const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
1418
1607
  const mode = args.mode === 'markdown' ? 'markdown' : 'text'
1419
1608
  const offset = Math.max(0, Number(args.offset) || 0)
@@ -1443,8 +1632,18 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1443
1632
  } catch {
1444
1633
  /* keep bare url */
1445
1634
  }
1446
- const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1447
- // Failed fetches are cached briefly so the model doesn't re-request a
1635
+ const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1636
+ const hit = cache.get(cacheKey)
1637
+ if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1638
+ // Valid content takes precedence over a concurrent fetch failure.
1639
+ failCache.delete(cacheKey)
1640
+ // LRU touch keeps hot pages available throughout their TTL.
1641
+ cache.delete(cacheKey)
1642
+ cache.set(cacheKey, hit)
1643
+ const sliced = sliceFrom(hit.full, offset, maxChars)
1644
+ return { ...sliced, cached: true }
1645
+ }
1646
+ // Failed fetches are cached briefly so the model doesn't re-request a
1448
1647
  // broken URL in a loop (token + latency saver).
1449
1648
  const fh = failCache.get(cacheKey)
1450
1649
  if (fh) {
@@ -1457,18 +1656,10 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1457
1656
  }
1458
1657
  failCache.delete(cacheKey)
1459
1658
  }
1460
- const hit = cache.get(cacheKey)
1461
- if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1462
- // LRU touch: hot pages survive eviction even when cached long ago — the
1463
- // Map re-insertion moves the entry to the newest slot.
1464
- cache.delete(cacheKey)
1465
- cache.set(cacheKey, hit)
1466
- const sliced = sliceFrom(hit.full, offset, maxChars)
1467
- return { ...sliced, cached: true }
1468
- }
1469
1659
 
1470
- const failWith = (error) => {
1471
- cacheFail(cacheKey, error)
1660
+ const failWith = (error) => {
1661
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1662
+ cacheFail(cacheKey, error)
1472
1663
  return { error }
1473
1664
  }
1474
1665
 
@@ -1478,75 +1669,105 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1478
1669
  // JSON with JS-only literals (NaN/Infinity — hand-rolled serializers) is
1479
1670
  // not valid JSON: retry once with the literals nulled rather than dropping
1480
1671
  // the whole page into the HTML pipeline as a raw-text dump.
1481
- const parseJsonLoose = (t) => {
1482
- try {
1483
- return JSON.parse(t)
1484
- } catch {
1485
- return JSON.parse(t.replace(/-?(?:NaN|Infinity)\b/g, 'null'))
1672
+ const parseJsonLoose = (t) => {
1673
+ try {
1674
+ return JSON.parse(t)
1675
+ } catch {
1676
+ const parts = []
1677
+ let quoted = false
1678
+ let start = 0
1679
+ for (let i = 0; i < t.length; i++) {
1680
+ const ch = t[i]
1681
+ if (quoted) {
1682
+ if (ch === '\\') i++
1683
+ else if (ch === '"') quoted = false
1684
+ continue
1685
+ }
1686
+ if (ch === '"') { quoted = true; continue }
1687
+ if (ch !== 'N' && ch !== 'I' && ch !== '-') continue
1688
+ const begin = i
1689
+ const literal = ch === '-' ? i + 1 : i
1690
+ const length = t.startsWith('Infinity', literal) ? 8 : t.startsWith('NaN', literal) ? 3 : 0
1691
+ if (!length) continue
1692
+ const end = literal + length
1693
+ if ((begin > 0 && !':,[ \t\r\n'.includes(t[begin - 1])) || (end < t.length && !',]} \t\r\n'.includes(t[end]))) continue
1694
+ parts.push(t.slice(start, begin), 'null')
1695
+ start = end
1696
+ i = end - 1
1697
+ }
1698
+ parts.push(t.slice(start))
1699
+ return JSON.parse(parts.join(''))
1486
1700
  }
1487
1701
  }
1488
- const dispatch = async () => {
1489
- const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1490
- if (viaSeam) {
1491
- const t = viaSeam.html.trim()
1492
- if (t.startsWith('{') || t.startsWith('[')) {
1493
- try {
1494
- return { kind: 'json', text: compactJson(parseJsonLoose(t)) }
1495
- } catch { /* not JSON — keep HTML pipeline */ }
1496
- }
1497
- return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
1498
- }
1499
- const page = await fetchPage(url, externalSignal, cfg)
1500
- if (page.error) return { error: page.error }
1501
- const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1502
- const decoded = decodeBuffer(page.buffer, page.contentType)
1503
- if (/json$/.test(ct)) {
1504
- try {
1505
- return { kind: 'json', text: compactJson(parseJsonLoose(decoded.text)) }
1506
- } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
1507
- }
1508
- if (/[+/]xml$/.test(ct)) {
1509
- if (/<urlset[\s>]/i.test(decoded.text) || /<sitemapindex[\s>]/i.test(decoded.text)) {
1510
- return { error: 'Unsupported content (XML sitemap)' }
1511
- }
1512
- if (/<rss[\s>]/i.test(decoded.text) || /<feed[\s>]/i.test(decoded.text)) {
1513
- return { kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks) }
1702
+ const dispatch = async () => {
1703
+ const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1704
+ const page = viaSeam || await fetchPage(url, externalSignal, cfg)
1705
+ if (page.error) return { error: page.error }
1706
+ const ct = viaSeam ? (page.kind === 'text' ? 'text/plain' : 'text/html') : (page.contentType || '').split(';')[0].toLowerCase().trim()
1707
+ const decoded = viaSeam ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
1708
+ const meta = { finalUrl: page.finalUrl || url, sourceTruncated: page.sourceTruncated === true }
1709
+ const trimmed = decoded.text.trim()
1710
+ if (/json$/.test(ct) || ((viaSeam || !ct || ct === 'text/plain') && (trimmed.startsWith('{') || trimmed.startsWith('[')))) {
1711
+ try {
1712
+ return { ...meta, kind: 'json', text: compactJson(parseJsonLoose(trimmed)) }
1713
+ } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
1714
+ }
1715
+ if (/[+/]xml$/.test(ct) || viaSeam || !ct || ct === 'text/plain') {
1716
+ let xmlHead = trimmed.slice(0, 16384)
1717
+ for (let i = 0; i < 8; i++) {
1718
+ const close = xmlHead.startsWith('<?') ? '?>' : xmlHead.startsWith('<!--') ? '-->' : ''
1719
+ if (!close) break
1720
+ const end = xmlHead.indexOf(close)
1721
+ if (end < 0) break
1722
+ xmlHead = xmlHead.slice(end + close.length).trimStart()
1723
+ }
1724
+ const root = /^<(?:[A-Za-z_][\w.-]{0,63}:)?(rss|feed|urlset|sitemapindex|RDF)(?=[\s>])/i.exec(xmlHead)
1725
+ const xmlKind = root ? root[1].toLowerCase() : ''
1726
+ if (xmlKind === 'urlset' || xmlKind === 'sitemapindex') {
1727
+ return { error: 'Unsupported content (XML sitemap)' }
1728
+ }
1729
+ if (xmlKind === 'rss' || xmlKind === 'feed' || (xmlKind === 'rdf' && /<(?:[A-Za-z_][\w.-]{0,63}:)?item(?=[\s>])/i.test(decoded.text))) {
1730
+ return { ...meta, kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks, meta.finalUrl) }
1514
1731
  }
1515
1732
  }
1516
1733
  // Plain-text families (txt/md/csv): line structure IS the content — the
1517
1734
  // HTML pipeline would flatten every newline into spaces. Pages that are
1518
1735
  // actually HTML but mislabelled text/plain keep the HTML pipeline.
1519
- if (/^text\/(?:plain|markdown|csv)$/.test(ct)) {
1736
+ if (!ct || /^text\/(?:plain|markdown|csv)$/.test(ct)) {
1520
1737
  const head = decoded.text.slice(0, 5000)
1521
- if (!/<(?:html|body|div|p|table)\b[\s>]/i.test(head)) {
1738
+ if ((viaSeam && page.kind === 'text') || ct === 'text/markdown' || ct === 'text/csv' || !/<(?:html|body|div|p|table)\b[\s>]/i.test(head)) {
1522
1739
  const text = decoded.text
1523
1740
  .replace(/\r\n?/g, '\n')
1524
1741
  .replace(/[ \t]+\n/g, '\n')
1525
1742
  .replace(/\n{4,}/g, '\n\n\n')
1526
1743
  .trim()
1527
- return { kind: 'text', text, charset: decoded.charset }
1744
+ return { ...meta, kind: 'text', text, charset: decoded.charset }
1528
1745
  }
1529
1746
  }
1530
- return { kind: 'html', html: decoded.text, finalUrl: page.finalUrl || url, charset: decoded.charset }
1531
- }
1532
- const payload = await dispatch()
1533
- if (payload.error) return failWith(payload.error)
1534
-
1535
- if (payload.kind === 'text') {
1536
- const full = { url, title: '', siteName: hostOf(url), lang: '', charset: payload.charset, mode: 'text', fullText: payload.text, paginated: 1 }
1747
+ return { ...meta, kind: 'html', html: decoded.text, charset: decoded.charset }
1748
+ }
1749
+ const payload = await dispatch()
1750
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1751
+ if (payload.error) return failWith(payload.error)
1752
+ const resultUrl = payload.finalUrl || url
1753
+ const truncatedHint = '提供方已截断原文,当前内容不是完整页面'
1754
+ let sourceHint = payload.sourceTruncated ? truncatedHint : ''
1755
+
1756
+ if (payload.kind === 'text') {
1757
+ const full = { url: resultUrl, title: '', siteName: hostOf(resultUrl), lang: '', charset: payload.charset, mode: 'text', fullText: payload.text, paginated: 1, spaHint: sourceHint }
1537
1758
  cacheStore(cacheKey, full, cfg.cacheMax)
1538
1759
  return sliceFrom(full, offset, maxChars)
1539
1760
  }
1540
- if (payload.kind === 'json') {
1541
- const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
1761
+ if (payload.kind === 'json') {
1762
+ const full = { url: resultUrl, title: '', siteName: hostOf(resultUrl), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1, spaHint: sourceHint }
1542
1763
  cacheStore(cacheKey, full, cfg.cacheMax)
1543
1764
  return sliceFrom(full, offset, maxChars)
1544
1765
  }
1545
1766
  if (payload.kind === 'feed') {
1546
1767
  const f = payload.feed
1547
1768
  const full = {
1548
- url, title: f.title || '', siteName: hostOf(url), lang: '', charset: 'feed',
1549
- mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count,
1769
+ url: resultUrl, title: f.title || '', siteName: hostOf(resultUrl), lang: '', charset: 'feed',
1770
+ mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count, spaHint: sourceHint,
1550
1771
  links: args.includeLinks === true ? f.items : undefined,
1551
1772
  }
1552
1773
  cacheStore(cacheKey, full, cfg.cacheMax)
@@ -1561,11 +1782,13 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1561
1782
  // follow immediately, BEFORE extraction and SPA rendering — a shell that
1562
1783
  // redirects needs no headless round-trip, and extracting the stub is wasted
1563
1784
  // work. Fails open on any fetch problem; the follow is hop-bounded.
1564
- const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg)
1785
+ const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx)
1786
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1565
1787
  html = followed.html
1566
1788
  finalUrl = followed.finalUrl
1567
- // The target may use a different encoding than the shell page — reflect it.
1568
- if (followed.charset) charset = followed.charset
1789
+ // The target may use a different encoding than the shell page — reflect it.
1790
+ if (followed.charset) charset = followed.charset
1791
+ if (followed.sourceTruncated !== undefined) sourceHint = followed.sourceTruncated ? truncatedHint : ''
1569
1792
 
1570
1793
  let extracted = extract(html, mode, anchor)
1571
1794
  // Optional readability upgrade: cleaner article extraction when installed.
@@ -1599,7 +1822,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1599
1822
  // lose, rendering is always worth one bounded attempt. Pages with no
1600
1823
  // scripts at all cannot change under rendering and are skipped.
1601
1824
  let rendered = false
1602
- let spaHint = ''
1825
+ let spaHint = sourceHint
1603
1826
  let renderedHtml = null
1604
1827
  const needsRender = !extracted.text || extracted.text.length < 200
1605
1828
  const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
@@ -1659,15 +1882,19 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1659
1882
  const nu = normalizeUrl(nextUrl)
1660
1883
  if (!nu || seen.has(nu)) break
1661
1884
  seen.add(nu)
1662
- const page = await fetchPage(nextUrl, externalSignal, cfg)
1663
- if (page.error) break
1664
- const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1665
- if (!/html|xhtml/.test(ct)) break
1666
- const nextHtml = decodeBuffer(page.buffer, page.contentType).text
1667
- const nextEx = extract(nextHtml, mode)
1668
- if (!nextEx.text || nextEx.text.length < 20) break
1669
- extracted.text = joinPageText(extracted.text, nextEx.text)
1670
- paginated++
1885
+ const page = await fetchViaWebSeam(ctx, nextUrl, externalSignal, cfg) || await fetchPage(nextUrl, externalSignal, cfg)
1886
+ if (page.error) break
1887
+ const pageFinal = normalizeUrl(page.finalUrl || nextUrl)
1888
+ if (!pageFinal || !sameHost(pageFinal, startHost) || (pageFinal !== nu && seen.has(pageFinal))) break
1889
+ seen.add(pageFinal)
1890
+ const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
1891
+ if (!/html|xhtml/.test(ct)) break
1892
+ const nextHtml = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
1893
+ const nextEx = extract(nextHtml, mode)
1894
+ if (!nextEx.text || nextEx.text.length < 20) break
1895
+ extracted.text = joinPageText(extracted.text, nextEx.text)
1896
+ if (page.sourceTruncated && !spaHint.includes(truncatedHint)) spaHint = [spaHint, truncatedHint].filter(Boolean).join(';')
1897
+ paginated++
1671
1898
  nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1672
1899
  }
1673
1900
 
@@ -1678,7 +1905,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1678
1905
  spaHint = '正文极短(可能登录墙或反爬拦截)'
1679
1906
  }
1680
1907
 
1681
- const full = {
1908
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1909
+ const full = {
1682
1910
  url: finalUrl,
1683
1911
  title: extracted.title || '',
1684
1912
  siteName: extracted.siteName || '',
@@ -1806,20 +2034,13 @@ function readLinksTool(ctx, cfg) {
1806
2034
  const url = String((args && args.url) || '').trim()
1807
2035
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1808
2036
  const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
1809
- const viaSeam = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg)
1810
- let html, finalUrl
1811
- if (viaSeam) {
1812
- html = viaSeam.html
1813
- finalUrl = viaSeam.finalUrl
1814
- } else {
1815
- const page = await fetchPage(url, exec && exec.signal, cfg)
1816
- if (page.error) return { error: page.error }
1817
- html = decodeBuffer(page.buffer, page.contentType).text
1818
- finalUrl = page.finalUrl || url
1819
- }
2037
+ const page = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg) || await fetchPage(url, exec && exec.signal, cfg)
2038
+ if (page.error) return { error: page.error }
2039
+ let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2040
+ let finalUrl = page.finalUrl || url
1820
2041
  // Same meta-refresh shell following as read_url: a link hop serves a
1821
2042
  // stub whose real links live at the target. Fails open, hop-bounded.
1822
- const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg)
2043
+ const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx)
1823
2044
  html = followed.html
1824
2045
  finalUrl = followed.finalUrl
1825
2046
  let links = extractLinks(html, limit, finalUrl)
@@ -1989,44 +2210,59 @@ function sameHost(a, b) {
1989
2210
  }
1990
2211
 
1991
2212
  // URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
1992
- 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
1993
- const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
1994
- function isNoiseUrl(url) {
1995
- return NOISE_EXT.test(url) || NOISE_PATH.test(url)
1996
- }
2213
+ 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
2214
+ const NOISE_PATH = /\/(?:login|signin|register|logout|signup|api|admin|wp-admin|wp-login|feed|rss|sitemap|robots\.txt|cdn-cgi)(?:\/|[.;]|$)/i
2215
+ function isNoiseUrl(url) {
2216
+ try {
2217
+ const path = new URL(url).pathname
2218
+ return NOISE_EXT.test(path) || NOISE_PATH.test(path)
2219
+ } catch {
2220
+ return true
2221
+ }
2222
+ }
1997
2223
 
1998
2224
  // BFS crawl of a single site. Only same-host http(s) pages are followed;
1999
2225
  // static assets / auth paths are skipped; visited URLs are deduped; a per-page
2000
2226
  // failure is recorded and does not abort the crawl. No SPA rendering here —
2001
2227
  // that is read_url's job; crawling favors speed and breadth.
2002
- async function crawlSite(entryUrl, cfg, opts, externalSignal) {
2228
+ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2003
2229
  const { maxPages, maxDepth, includeContent, perMax } = opts
2004
- const host = hostOf(entryUrl)
2005
- const visited = new Set()
2230
+ let host = hostOf(entryUrl)
2231
+ const visited = new Set()
2232
+ const resolved = new Set()
2233
+ let attempted = 0
2006
2234
  const pages = []
2007
2235
  const failures = []
2008
2236
  const queue = [{ url: entryUrl, depth: 0 }]
2009
2237
  // Stop once the host's cooperative signal fires (tool budget hit): without
2010
2238
  // this the loop keeps draining the queue on instantly-aborted fetches.
2011
- while (queue.length && pages.length < maxPages && !(externalSignal && externalSignal.aborted)) {
2239
+ while (queue.length && attempted < maxPages && !(externalSignal && externalSignal.aborted)) {
2012
2240
  // Never overshoot the page budget within a batch round.
2013
- const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
2241
+ const batch = queue.splice(0, Math.min(2, maxPages - attempted, queue.length))
2014
2242
  const results = await Promise.all(
2015
2243
  batch.map(async ({ url, depth }) => {
2016
2244
  const norm = normalizeUrl(url)
2017
- if (!norm || visited.has(norm)) return null
2018
- visited.add(norm)
2019
- const page = await fetchPage(url, externalSignal, cfg)
2245
+ if (!norm || visited.has(norm)) return null
2246
+ visited.add(norm)
2247
+ attempted++
2248
+ const page = await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
2020
2249
  if (page.error) return { url, depth, error: page.error }
2021
- let html = decodeBuffer(page.buffer, page.contentType).text
2022
- let pageUrl = page.finalUrl || url
2250
+ let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2251
+ let pageUrl = page.finalUrl || url
2252
+ if (depth > 0 && !sameHost(pageUrl, host)) {
2253
+ return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
2254
+ }
2023
2255
  // Same shell-following as read_url: a meta-refresh stub would otherwise
2024
2256
  // be crawled AS the page (title-less hop text pollutes results and the
2025
2257
  // real content is never reached). Fails open — fetch problems keep the
2026
2258
  // stub's own HTML.
2027
- const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg)
2028
- html = followed.html
2029
- pageUrl = followed.finalUrl
2259
+ const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx)
2260
+ html = followed.html
2261
+ pageUrl = followed.finalUrl
2262
+ if (depth === 0) host = hostOf(pageUrl)
2263
+ else if (!sameHost(pageUrl, host)) {
2264
+ return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
2265
+ }
2030
2266
  const ex = extract(html, 'text')
2031
2267
  const links = extractLinks(html, 100, pageUrl)
2032
2268
  return {
@@ -2045,7 +2281,11 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
2045
2281
  failures.push({ url: r.url, error: r.error })
2046
2282
  continue
2047
2283
  }
2048
- pages.push(r)
2284
+ const finalNorm = normalizeUrl(r.url)
2285
+ if (!finalNorm || resolved.has(finalNorm)) continue
2286
+ resolved.add(finalNorm)
2287
+ visited.add(finalNorm)
2288
+ pages.push(r)
2049
2289
  if (r.depth + 1 <= maxDepth) {
2050
2290
  // Queue cap: a huge site (millions of links) would otherwise grow the
2051
2291
  // queue unboundedly while maxPages bounds only what we *process*.
@@ -2123,12 +2363,12 @@ function readUrlSiteTool(ctx, cfg) {
2123
2363
  async execute(args, exec) {
2124
2364
  const url = String((args && args.url) || '').trim()
2125
2365
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
2126
- const maxPages = Math.max(2, Math.min(50, Number(args.maxPages) || 15))
2366
+ const maxPages = Math.floor(Math.max(2, Math.min(50, Number(args.maxPages) || 15)))
2127
2367
  const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
2128
2368
  const includeContent = args.includeContent === true
2129
2369
  const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
2130
2370
  const { host, pages, failures } = await crawlSite(
2131
- url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal,
2371
+ url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal, ctx,
2132
2372
  )
2133
2373
  return {
2134
2374
  host,
@@ -2168,11 +2408,11 @@ export function apply(ctx, config) {
2168
2408
  if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
2169
2409
 
2170
2410
  // Temporal composability: unload must fully revert side effects.
2171
- ctx.effect(() => () => {
2172
- cache.clear()
2173
- failCache.clear()
2174
- closeBrowser().catch(() => {})
2175
- })
2411
+ ctx.effect(() => () => {
2412
+ cache.clear()
2413
+ failCache.clear()
2414
+ return closeBrowser()
2415
+ })
2176
2416
 
2177
2417
  console.log('[dsh-read-url] plugin loaded; tools read_url, read_url_batch, read_url_links, read_url_site registered')
2178
2418