dsh-read-url 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -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
 
@@ -104,7 +104,6 @@ export function decodeBuffer(buffer, contentType) {
104
104
  // XML (RSS/Atom feeds) and plain-text families (txt/md/csv — logs, docs,
105
105
  // datasets) are read natively — see readUrl's type dispatch.
106
106
  // 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
107
 
109
108
  // Retry-After header (seconds form) → ms, capped; undefined when absent/invalid.
110
109
  function retryAfterMs(res) {
@@ -138,7 +137,7 @@ async function directFetchOnce(url, signal, cfg) {
138
137
  return { error: `HTTP ${res.status} ${res.statusText}` }
139
138
  }
140
139
  const contentType = res.headers.get('content-type') || ''
141
- if (contentType && !FETCHABLE_CT.test(contentType)) {
140
+ if (contentType && !isFetchableContentType(contentType)) {
142
141
  discardBody()
143
142
  return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
144
143
  }
@@ -244,8 +243,10 @@ async function raceFetch(url, externalSignal, cfg, proxy) {
244
243
  return winner
245
244
  }
246
245
 
247
- async function fetchPage(url, externalSignal, cfg) {
248
- const proxy = await detectProxy()
246
+ async function fetchPage(url, externalSignal, cfg) {
247
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
248
+ const proxy = await detectProxy()
249
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
249
250
  if (proxy) {
250
251
  const winner = await raceFetch(url, externalSignal, cfg, proxy)
251
252
  if (winner.success) return winner.value
@@ -267,22 +268,38 @@ async function fetchPage(url, externalSignal, cfg) {
267
268
  // layer and only re-extract. Falls back to global fetch when absent.
268
269
  // Seam calls get the same cooperative timeout as fetchPage (a hanging
269
270
  // provider should not block the tool call forever).
270
- async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
271
- try {
271
+ async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
272
+ let web
273
+ try {
272
274
  // ctx.get for a non-injected service throws on strict cordis hosts (seen
273
275
  // 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
- }
276
+ web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
277
+ } catch {
278
+ return null
279
+ }
280
+ if (!web || typeof web.fetch !== 'function') return null
281
+ try {
282
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
283
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
284
+ if (signal.aborted) return { error: 'cancelled' }
285
+ const res = await new Promise((resolve, reject) => {
286
+ const onAbort = () => reject(signal.reason || new Error('cancelled'))
287
+ signal.addEventListener('abort', onAbort, { once: true })
288
+ Promise.resolve().then(() => web.fetch({ url }, signal)).then(resolve, reject)
289
+ .finally(() => signal.removeEventListener('abort', onAbort))
290
+ })
291
+ if (!res || !Number.isInteger(res.statusCode) || !res.body || typeof res.body.content !== 'string' || !['html', 'text'].includes(res.body.kind)) {
292
+ return { error: 'Web provider returned an unsupported response' }
293
+ }
294
+ if (res.statusCode < 200 || res.statusCode >= 300) return { error: `HTTP ${res.statusCode}` }
295
+ if (Buffer.byteLength(res.body.content, 'utf8') > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes` }
296
+ return { html: res.body.content, kind: res.body.kind, finalUrl: res.url || url, sourceTruncated: res.truncated === true }
297
+ } catch (e) {
298
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
299
+ if (e && e.code === 'WEB_PROVIDER_UNAVAILABLE') return null
300
+ return { error: `Web provider failed: ${String((e && e.message) || e).slice(0, 160)}` }
301
+ }
302
+ }
286
303
 
287
304
  // Decode common HTML entities in already-stripped text. Runs AFTER tag
288
305
  // stripping so escaped tags stay visible to the stripper and only remaining
@@ -402,28 +419,64 @@ function xmlText(s) {
402
419
  }
403
420
 
404
421
  // 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]) || ''
422
+ function parseFeed(xml, limit, baseUrl = '') {
423
+ xml = defuseLt(xml) // keeps the item scan linear
424
+ const root = /<(?:[a-z][\w.-]{0,31}:)?(?:feed|rss|RDF)(?=[\s>])[^>]{0,1000}>/i.exec(xml)
425
+ const isAtom = !!root && /:?(?:feed)\b/i.test(root[0].split(/[\s>]/, 1)[0])
426
+ const resolve = (value, base) => {
427
+ try {
428
+ const u = new URL(decodeUrlAttribute(value), base || undefined)
429
+ return /^https?:$/.test(u.protocol) ? u.href : ''
430
+ } catch { return '' }
431
+ }
432
+ const feedBase = resolve(root ? htmlAttributes(root[0])['xml:base'] || '' : '', baseUrl) || baseUrl
433
+ const field = (body, names) => {
434
+ const re = new RegExp(`<((?:[a-z][\\w.-]{0,31}:)?(?:${names}))(?=[\\s>])[^>]{0,1000}>([\\s\\S]{0,200000}?)</\\1\\s{0,1000}>`, 'i')
435
+ return re.exec(body)
436
+ }
437
+ const textOf = (m) => {
438
+ if (!m) return ''
439
+ const attrs = htmlAttributes(m[0].slice(0, m[0].indexOf('>') + 1))
440
+ if (isAtom && (!attrs.type || attrs.type === 'text')) {
441
+ return decodeTextEntities(m[2].replace(/<!\[CDATA\[([\s\S]{0,200000}?)\]\]>/g, '$1')).trim()
442
+ }
443
+ return xmlText(m[2])
444
+ }
445
+ const itemRe = /<(\/?)([a-z][\w.-]{0,31}:)?(entry|item)(?=[\s>])[^>]{0,1000}>/gi
446
+ const items = []
447
+ let start = null
448
+ let m
449
+ while ((m = itemRe.exec(xml)) && items.length < limit) {
450
+ if (m[3].toLowerCase() !== (isAtom ? 'entry' : 'item')) continue
451
+ if (!m[1]) { if (!start) start = m; continue }
452
+ if (!start || (start[2] || '') !== (m[2] || '')) continue
453
+ const opening = start[0]
454
+ const it = xml.slice(start.index + opening.length, m.index)
455
+ start = null
456
+ const itemBase = resolve(htmlAttributes(opening)['xml:base'] || '', feedBase) || feedBase
457
+ const title = textOf(field(it, 'title'))
458
+ let link = ''
459
+ if (isAtom) {
460
+ for (const lm of it.matchAll(/<(?:[a-z][\w.-]{0,31}:)?link(?=[\s/>])[^>]{0,1000}>/gi)) {
461
+ const attrs = htmlAttributes(lm[0])
462
+ if (attrs.rel && attrs.rel !== 'alternate') continue
463
+ const target = resolve(attrs.href || '', resolve(attrs['xml:base'] || '', itemBase) || itemBase)
464
+ if (!target || !attrs.href) continue
465
+ link = target
466
+ if (!attrs.type || /^(?:text\/html|application\/xhtml\+xml)$/i.test(attrs.type)) break
467
+ }
468
+ } else {
469
+ const rawLink = field(it, 'link')?.[2]?.trim()
470
+ if (rawLink) link = resolve(rawLink.replace(/<!\[CDATA\[([\s\S]{0,10000}?)\]\]>/g, '$1'), itemBase)
471
+ }
419
472
  // Namespaced fields (WordPress <content:encoded>) pair via a captured
420
473
  // name + backreference: the closer must match the SAME name, so a
421
474
  // <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] || '')
475
+ const desc = textOf(field(it, 'description|summary|content(?::encoded)?'))
423
476
  if (!title && !link && !desc) continue
424
477
  items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
425
478
  }
426
- const feedTitle = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(xml)?.[1] || '')
479
+ const feedTitle = textOf(field(xml, 'title'))
427
480
  const lines = []
428
481
  for (const it of items) {
429
482
  lines.push(`- ${it.title}${it.url ? ` — ${it.url}` : ''}`)
@@ -498,13 +551,14 @@ function tagBlockAt(html, openIdx, tagName, maxScan = Infinity) {
498
551
  // continues from the removed block's end, so removed interiors are never
499
552
  // rescanned and total cost stays near-linear in the surviving input.
500
553
  const BALANCED_SCAN_WINDOW = 100000
501
- function stripBalanced(html, openTagRe) {
554
+ function stripBalanced(html, openTagRe, accept = () => true) {
502
555
  openTagRe.lastIndex = 0
503
556
  let out = ''
504
557
  let last = 0
505
558
  let m
506
- while ((m = openTagRe.exec(html))) {
507
- const tag = m[1].toLowerCase()
559
+ while ((m = openTagRe.exec(html))) {
560
+ const tag = m[1].toLowerCase()
561
+ if (!accept(m[0], tag)) continue
508
562
  const block = tagBlockAt(html, m.index, tag, BALANCED_SCAN_WINDOW)
509
563
  if (!block) continue // unclosed container — keep the content
510
564
  out += html.slice(last, m.index) + ' '
@@ -514,17 +568,24 @@ function stripBalanced(html, openTagRe) {
514
568
  return out + html.slice(last)
515
569
  }
516
570
 
517
- export function pickMain(html) {
571
+ export function pickMain(html) {
518
572
  // Aggregation pages (e.g. blog homepages) have one <article> per item:
519
573
  // collect all of them instead of only the first. A tiny <article> (e.g. a
520
574
  // newsletter card while the real content sits in <main>) falls through to
521
575
  // <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
576
+ const articles = []
577
+ let articleDepth = 0
578
+ let articleStart = 0
579
+ for (const m of html.matchAll(/<\/?article(?=[\s>])[^>]{0,1000}>/gi)) {
580
+ if (m[0][1] !== '/') {
581
+ if (articleDepth++ === 0) articleStart = m.index
582
+ } else if (articleDepth > 0 && --articleDepth === 0) {
583
+ articles.push(html.slice(articleStart, m.index + m[0].length))
584
+ }
585
+ }
525
586
  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')
587
+ if (articles.length && (!hasMain || textOnly(articles.join('')).length >= 200)) {
588
+ return articles.join('\n')
528
589
  }
529
590
  const roleMain = /<(main|div)[^>]{0,1000}role=["']main["'][\s>]/i.exec(html)
530
591
  if (roleMain) {
@@ -549,56 +610,102 @@ export function pickMain(html) {
549
610
  return densityFilter(html)
550
611
  }
551
612
 
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
- }
613
+ // Parse complete, bounded opening tags; quoted values remain one attribute.
614
+ function htmlAttributes(tag) {
615
+ const attrs = Object.create(null)
616
+ if (tag.length > 1002) return attrs
617
+ const start = /^<[a-z][a-z0-9:-]{0,63}/i.exec(tag)
618
+ if (!start) return attrs
619
+ const re = /\s{1,1000}([^\s"'<>/=]{1,64})(?:\s{0,1000}=\s{0,1000}(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s"'=<>`]{1,1000})))?/gy
620
+ re.lastIndex = start[0].length
621
+ let m
622
+ while ((m = re.exec(tag))) {
623
+ const key = m[1].toLowerCase()
624
+ if (!Object.hasOwn(attrs, key)) attrs[key] = m[2] ?? m[3] ?? m[4] ?? ''
625
+ }
626
+ return attrs
627
+ }
628
+
629
+ // Raw-text elements and comments cannot supply visible document containers.
630
+ function stripRawElements(html) {
631
+ const open = /<!--|<(script|style|textarea|noscript|template)(?=[\s>])[^>]{0,1000}>/gi
632
+ const parts = []
633
+ let last = 0
634
+ let m
635
+ while ((m = open.exec(html))) {
636
+ let end
637
+ if (!m[1]) {
638
+ const close = html.indexOf('-->', open.lastIndex)
639
+ end = close < 0 ? html.length : close + 3
640
+ } else {
641
+ const close = new RegExp(`</${m[1]}\\s{0,1000}>`, 'gi')
642
+ close.lastIndex = open.lastIndex
643
+ const match = close.exec(html)
644
+ end = match ? close.lastIndex : html.length
645
+ }
646
+ parts.push(html.slice(last, m.index), ' ')
647
+ last = end
648
+ open.lastIndex = end
649
+ }
650
+ parts.push(html.slice(last))
651
+ return parts.join('')
652
+ }
653
+
654
+ // Track native noise containers in one pass, including large embedded assets.
655
+ function stripStructuralNoise(html) {
656
+ const re = /<(\/?)(nav|footer|header|aside|form|iframe|svg|canvas|dialog|object|embed)(?=[\s/>])/gi
657
+ const active = new Map()
658
+ const ranges = []
659
+ let m
660
+ while ((m = re.exec(html))) {
661
+ const end = html.indexOf('>', re.lastIndex)
662
+ if (end < 0) break
663
+ const name = m[2].toLowerCase()
664
+ if (!m[1] && name === 'svg' && html[end - 1] === '/') {
665
+ ranges.push({ start: m.index, end: end + 1 })
666
+ } else if (!m[1]) {
667
+ const state = active.get(name)
668
+ if (state) state.depth++
669
+ else {
670
+ const range = { start: m.index, end: 0, depth: 1 }
671
+ ranges.push(range)
672
+ active.set(name, range)
673
+ }
674
+ } else if (end - re.lastIndex <= 1000 && !html.slice(re.lastIndex, end).trim()) {
675
+ const state = active.get(name)
676
+ if (state && --state.depth === 0) {
677
+ state.end = end + 1
678
+ active.delete(name)
679
+ }
680
+ }
681
+ re.lastIndex = end + 1
682
+ }
683
+ const parts = []
684
+ let last = 0
685
+ for (const range of ranges) {
686
+ if (!range.end || range.end <= last) continue
687
+ if (range.start > last) parts.push(html.slice(last, range.start))
688
+ parts.push(' ')
689
+ last = range.end
690
+ }
691
+ parts.push(html.slice(last))
692
+ return parts.join('')
693
+ }
694
+
695
+ function stripNoise(mainHtml) {
696
+ // Bounded attribute parsing and balanced removal preserve nested content.
697
+ const out = stripStructuralNoise(stripRawElements(mainHtml))
698
+ // Attribute names determine visibility; class heuristics apply to containers
699
+ // so source-code tokens remain visible. Noise names match whole word segments.
700
+ return stripBalanced(out, /<([a-z][a-z0-9-]{0,63})\b[^>]{0,1000}>/gi, (tag, name) => {
701
+ const attrs = htmlAttributes(tag)
702
+ if (Object.hasOwn(attrs, 'hidden') || attrs['aria-hidden']?.toLowerCase() === 'true') return true
703
+ 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
704
+ if (/(?:onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-)/i.test(attrs.id || '')) return true
705
+ if (!/^(?:div|section|article|ul|ol|li|p)$/.test(name)) return false
706
+ return /(?:^|[\s_-])(?:ads?|adsbygoogle|advert(?:isement|ising)?|banner|sidebar|social|share|comments?|popup|modal|cookies?)(?=$|[\s_-])/i.test(attrs.class || '')
707
+ })
708
+ }
602
709
 
603
710
  // ---- HTML -> Markdown (lightweight, tag-state machine) ----
604
711
  function escInline(s) {
@@ -609,9 +716,15 @@ function escInline(s) {
609
716
  // walker): convert descriptive images to markdown BEFORE the tag walk, via a
610
717
  // sentinel so escInline cannot escape the generated brackets. Decorative
611
718
  // images (empty alt) are dropped — the model cannot see pixels anyway.
612
- const IMG_SENTINEL = '\u0001'
613
- function imgsToMarkdown(html) {
614
- const store = []
719
+ const IMG_SENTINEL = '\u0001'
720
+ let markdownSentinelId = 0
721
+ function imgsToMarkdown(html) {
722
+ const store = new Map()
723
+ const protect = (value) => {
724
+ const id = markdownSentinelId++
725
+ store.set(id, value)
726
+ return `${IMG_SENTINEL}${id}${IMG_SENTINEL}`
727
+ }
615
728
  // AMP pages use <amp-img> instead of <img>; responsive layouts wrap images
616
729
  // in <picture><source srcset>. When the <img> fallback carries no usable
617
730
  // src (lazy placeholder), the first source URL is injected into it so the
@@ -634,15 +747,15 @@ function imgsToMarkdown(html) {
634
747
  const lazy = /data-(?:src|original|lazy-src)=["']([^"']+)["']/i.exec(tag)
635
748
  if (lazy) srcVal = lazy[1]
636
749
  }
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,
750
+ return protect(srcVal && alt && alt[1].trim() ? `![${alt[1].trim()}](${srcVal})` : '')
751
+ })
752
+ return {
753
+ html: out,
754
+ protect,
642
755
  // Unknown sentinel indexes are PRESERVED (a nested walker's restore must
643
756
  // 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)]
757
+ restore: (s) => s.replace(new RegExp(`${IMG_SENTINEL}(\\d{1,16})${IMG_SENTINEL}`, 'g'), (m, i) => {
758
+ const v = store.get(Number(i))
646
759
  return v === undefined ? m : v
647
760
  }),
648
761
  }
@@ -657,12 +770,36 @@ function imgsToMarkdown(html) {
657
770
  const OPEN_TAG_RE = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>/g
658
771
  const MD_MAX_DEPTH = 100
659
772
  const MD_TABLE_MAX_ROWS = 25
660
- function stripUnmatchedOpeners(html) {
773
+ function stripUnmatchedOpeners(html) {
661
774
  const closers = new Set()
662
775
  for (const m of html.matchAll(/<\/([a-zA-Z0-9]+)/g)) closers.add(m[1].toLowerCase())
663
776
  html = defuseLt(html)
664
- return html.replace(OPEN_TAG_RE, (m, name) => (closers.has(name.toLowerCase()) ? m : ''))
665
- }
777
+ return html.replace(OPEN_TAG_RE, (m, name) => {
778
+ const tag = name.toLowerCase()
779
+ return tag === 'br' || tag === 'hr' || closers.has(tag) ? m : ''
780
+ })
781
+ }
782
+
783
+ function codeText(html) {
784
+ return decodeTextEntities(html.replace(/<br\b[^>]{0,1000}>/gi, '\n').replace(/<[^>]{1,1000}>/g, ''))
785
+ }
786
+
787
+ function backtickFence(code, minLength) {
788
+ let longest = 0, run = 0
789
+ for (const ch of code) {
790
+ run = ch === '`' ? run + 1 : 0
791
+ if (run > longest) longest = run
792
+ }
793
+ return '`'.repeat(Math.max(minLength, longest + 1))
794
+ }
795
+
796
+ function inlineCode(html) {
797
+ const code = codeText(html).replace(/\r\n?|\n/g, ' ')
798
+ if (!code) return ''
799
+ const fence = backtickFence(code, 1)
800
+ const pad = /^[ `]|[ `]$/.test(code) && /[^ ]/.test(code) ? ' ' : ''
801
+ return `${fence}${pad}${code}${pad}${fence}`
802
+ }
666
803
 
667
804
  export function inlineMd(html, depth = 0) {
668
805
  if (depth > MD_MAX_DEPTH) {
@@ -671,16 +808,24 @@ export function inlineMd(html, depth = 0) {
671
808
  const img = imgsToMarkdown(html)
672
809
  html = stripUnmatchedOpeners(img.html)
673
810
  let out = ''
674
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
811
+ 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
812
  let m
676
813
  while ((m = re.exec(html))) {
677
814
  if (m[4] !== undefined) {
678
- out += escInline(decodeTextEntities(m[4]))
815
+ out += escInline(decodeTextEntities(m[4]).replace(/[ \t]{1,3145728}/g, ' '))
679
816
  continue
680
817
  }
681
- if (!m[1]) continue
682
- const tag = m[1].toLowerCase()
683
- if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
818
+ if (!m[1]) {
819
+ if (/^<br\b/i.test(m[0])) out += img.protect(' \n')
820
+ else if (/^<hr\b/i.test(m[0])) out += img.protect('\n\n---\n\n')
821
+ continue
822
+ }
823
+ const tag = m[1].toLowerCase()
824
+ if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
825
+ if (tag === 'code') {
826
+ out += img.protect(inlineCode(m[3]))
827
+ continue
828
+ }
684
829
  const inner = inlineMd(m[3], depth + 1)
685
830
  if (tag === 'a') {
686
831
  const href = /href=["']([^"']+)["']/i.exec(m[2])
@@ -690,11 +835,9 @@ export function inlineMd(html, depth = 0) {
690
835
  out += href ? `[${inner}](${href[1].replace(/\)/g, '%29').replace(/\(/g, '%28')})` : inner
691
836
  } else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
692
837
  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
838
+ else out += inner
696
839
  }
697
- return img.restore(out.replace(/[ \t]+/g, ' ').trim())
840
+ return img.restore(out.trim())
698
841
  }
699
842
 
700
843
  export function blockMd(html, depth = 0) {
@@ -704,14 +847,18 @@ export function blockMd(html, depth = 0) {
704
847
  const img = imgsToMarkdown(html)
705
848
  html = stripUnmatchedOpeners(img.html)
706
849
  let out = ''
707
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
850
+ 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
851
  let m
709
852
  while ((m = re.exec(html))) {
710
853
  if (m[4] !== undefined) {
711
854
  out += decodeTextEntities(m[4])
712
855
  continue
713
856
  }
714
- if (!m[1]) continue
857
+ if (!m[1]) {
858
+ if (/^<br\b/i.test(m[0])) out += ' \n'
859
+ else if (/^<hr\b/i.test(m[0])) out += '\n\n---\n\n'
860
+ continue
861
+ }
715
862
  const tag = m[1].toLowerCase()
716
863
  const attrs = m[2] || ''
717
864
  const inner = m[3]
@@ -735,19 +882,20 @@ export function blockMd(html, depth = 0) {
735
882
  // Strip wrapper tags FIRST, then decode entities — a literal &lt;div&gt;
736
883
  // in the code must become <div> in the fenced block, but a real nested
737
884
  // tag must not survive the strip to be rendered as markdown.
738
- const code = decodeTextEntities(inner.replace(/<[^>]{1,1000}>/g, '')).trim()
885
+ const code = codeText(inner).replace(/^\r?\n|\r?\n$/g, '')
739
886
  // Language hint from common highlighter conventions: the model reads
740
887
  // ```js fenced blocks far more accurately than unlabelled ones.
741
888
  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)}\``
889
+ /<code\b[^>]{0,1000}class=["'][^"']{0,1000}(?:language|lang)-([\w#+.-]{1,80})(?:[\s"'])/i.exec(inner)?.[1] || ''
890
+ const fence = backtickFence(code, 3)
891
+ out += `\n\n${fence}${lang}\n${code}\n${fence}`
892
+ } else if (tag === 'code') {
893
+ out += img.protect(inlineCode(inner))
746
894
  } else if (tag === 'ul' || tag === 'ol') {
747
895
  // Without a single </li> nothing can pair, so the item scan is skipped.
748
896
  const items = []
749
- if (inner.includes('</li')) {
750
- const liRe = /<li[^>]{0,1000}>([\s\S]*?)<\/li>/gi
897
+ if (/<\/li\b/i.test(inner)) {
898
+ const liRe = /<li\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/li>/gi
751
899
  let lm, idx = 1
752
900
  while ((lm = liRe.exec(inner))) {
753
901
  const t = inlineMd(lm[1], depth + 1)
@@ -762,35 +910,40 @@ export function blockMd(html, depth = 0) {
762
910
  out += ' \n'
763
911
  } else if (tag === 'hr') {
764
912
  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, '-')
913
+ } else if (tag === 'table') {
914
+ const rows = []
915
+ let rowCount = 0, columnCount = 0
916
+ if (/<\/tr\b/i.test(inner)) {
917
+ const trRe = /<tr\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/tr>/gi
918
+ let tm
919
+ while ((tm = trRe.exec(inner))) {
920
+ const cells = []
921
+ if (/<\/t[dh]\b/i.test(tm[1])) {
922
+ const tdRe = /<t[dh]\b[^>]{0,1000}>([\s\S]{0,3145728}?)<\/t[dh]>/gi
923
+ if (rows.length >= MD_TABLE_MAX_ROWS) {
924
+ if (tdRe.test(tm[1])) rowCount++
925
+ continue
926
+ }
927
+ let cm
928
+ while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1], depth + 1).replace(/\|/g, '\\|').split(/\r?\n/).map(line => line.trimEnd()).join('<br>'))
929
+ }
930
+ if (cells.length) {
931
+ if (!rows.length) columnCount = cells.length
932
+ rows.push(`| ${cells.join(' | ')} |`)
933
+ rowCount++
934
+ }
935
+ }
936
+ }
937
+ if (rows.length) {
938
+ const sep = `| ${Array(columnCount).fill('---').join(' | ')} |`
785
939
  // Compatibility tables (40+ rows × many columns) are the single
786
940
  // largest token sink in markdown mode; the hint keeps the model
787
941
  // 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)
942
+ const more = rowCount - rows.length
943
+ out += `\n\n${[rows[0], sep, ...rows.slice(1)].join('\n')}${more > 0 ? `\n…+${more} rows` : ''}`
944
+ }
945
+ } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
946
+ const t = inlineMd(m[0], depth + 1)
794
947
  if (t) out += t
795
948
  } else {
796
949
  const t = blockMd(inner, depth + 1)
@@ -804,11 +957,12 @@ export function blockMd(html, depth = 0) {
804
957
  // `key`. Attribute order is arbitrary (content may precede property/name), and
805
958
  // the content value may itself contain quotes — locate the tag first, then
806
959
  // 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] || '') : ''
960
+ function metaContent(html, key) {
961
+ for (const m of html.matchAll(/<meta(?=[\s/>])[^>]{0,1000}>/gi)) {
962
+ const attrs = htmlAttributes(m[0])
963
+ if ((attrs.property || attrs.name || '').toLowerCase() === key) return attrs.content || ''
964
+ }
965
+ return ''
812
966
  }
813
967
 
814
968
  // Page-level metadata the model actually asks about (who wrote it, when) —
@@ -945,12 +1099,17 @@ function noscriptBody(html) {
945
1099
  // win; otherwise the FIRST time tag in the scoped html is used. Callers pass
946
1100
  // the noise-stripped main content so footer/comment timestamps (which carry
947
1101
  // 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) : ''
1102
+ function timeTagMeta(html) {
1103
+ if (!html) return ''
1104
+ let first = ''
1105
+ for (const m of html.matchAll(/<time(?=[\s>])[^>]{0,1000}>/gi)) {
1106
+ const attrs = htmlAttributes(m[0])
1107
+ const value = (attrs.datetime || '').trim().slice(0, 40)
1108
+ if (!value) continue
1109
+ if (/(?:^|\s)datePublished(?:\s|$)/i.test(attrs.itemprop || '') || Object.hasOwn(attrs, 'pubdate')) return value
1110
+ if (!first) first = value
1111
+ }
1112
+ return first
954
1113
  }
955
1114
 
956
1115
  // CJK / loose-variant dates normalize to ISO so the metadata line stays
@@ -1023,8 +1182,7 @@ export function extract(html, mode, anchor = '') {
1023
1182
  const ogTitle = metaContent(html, 'og:title')
1024
1183
  const siteName = metaContent(html, 'og:site_name')
1025
1184
  const langMatch = /<html[^>]{1,1000}lang=["']([\w-]+)["']/i.exec(html)
1026
- let main = stripNoise(pickMain(html))
1027
- main = stripNoise(revealEscapedTags(main))
1185
+ const main = stripNoise(pickMain(stripRawElements(html)))
1028
1186
  // A URL fragment scopes the read to one section of a long reference page —
1029
1187
  // slice at the anchor so only that section is extracted. Unmatched anchors
1030
1188
  // degrade to the full document instead of failing.
@@ -1111,63 +1269,68 @@ async function getReadabilityExtractor() {
1111
1269
  return readabilityReady
1112
1270
  }
1113
1271
 
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
- }
1272
+ export function smartTruncate(text, maxChars, offset = 0) {
1273
+ const total = text.length
1274
+ offset = Math.max(0, Math.floor(Number(offset) || 0))
1275
+ maxChars = Math.max(0, Math.floor(Number(maxChars) || 0))
1276
+ if (offset >= total) {
1277
+ return { text: '', truncated: false, charsTotal: total, charsReturned: 0, charsStart: offset }
1278
+ }
1119
1279
  if (total <= maxChars && offset === 0) {
1120
1280
  return { text, truncated: false, charsTotal: total, charsReturned: total, charsStart: 0 }
1121
1281
  }
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,
1282
+ let start = offset
1283
+ // Continuations omit paragraph separators and report the exact source span.
1284
+ if (text[start] === '\n' && (text[start - 1] === '\n' || text[start + 1] === '\n')) {
1285
+ while (start < total && text[start] === '\n') start++
1286
+ }
1287
+ let end = Math.min(total, start + maxChars)
1288
+ if (end < total && maxChars > 0) {
1289
+ const window = text.slice(start, end + 2)
1290
+ let boundary = window.lastIndexOf('\n\n', end - start)
1291
+ if (boundary >= 0) boundary += start
1292
+ while (boundary > start && text[boundary - 1] === '\n') boundary--
1293
+ if (boundary > start) end = boundary
1294
+ else {
1295
+ // Prefer the last complete sentence inside the current window.
1296
+ for (let i = end - 1; i >= start; i--) {
1297
+ if ('.!?\n。!?'.includes(text[i])) {
1298
+ end = i + 1
1299
+ break
1300
+ }
1301
+ }
1302
+ }
1303
+ // Keep UTF-16 surrogate pairs together when the hard limit splits one.
1304
+ const previous = text.charCodeAt(end - 1), next = text.charCodeAt(end)
1305
+ if (end > start + 1 && previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--
1306
+ }
1307
+ const acc = text.slice(start, end)
1308
+ return {
1309
+ text: acc,
1310
+ truncated: end < total,
1154
1311
  charsTotal: total,
1155
1312
  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) {
1313
+ charsStart: start,
1314
+ }
1315
+ }
1316
+
1317
+ function decodeUrlAttribute(value) {
1318
+ // Attribute entities preserve bare query keys followed by an equals sign.
1319
+ return value.replace(/&(?:#(?:\d{1,10}|x[0-9a-f]{1,8});?|[a-z][a-z0-9]{1,7}(?:;|(?![a-z0-9=])))/gi,
1320
+ (entity) => decodeTextEntities(entity))
1321
+ }
1322
+
1323
+ // <base href> overrides the document base for relative URL resolution (old
1324
+ // forums / frame sites). Per HTML spec only the FIRST <base> in <head> counts;
1325
+ // attribute order is arbitrary and the href may be protocol-relative. Empty
1326
+ // href (= document URL) is a no-op; non-http(s) targets are rejected. The
1327
+ // head scan is bounded — the tag cannot legally appear beyond it.
1328
+ function detectBaseHref(html, finalUrl) {
1166
1329
  const headEnd = html.search(/<\/head\s*>/i)
1167
1330
  const head = headEnd >= 0 ? html.slice(0, headEnd) : html.slice(0, 16384)
1168
1331
  const base = /<base\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>/i.exec(head)
1169
1332
  if (!base) return finalUrl
1170
- const href = base[1].trim()
1333
+ const href = decodeUrlAttribute(base[1]).trim()
1171
1334
  if (!href) return finalUrl
1172
1335
  try {
1173
1336
  const u = new URL(href, finalUrl)
@@ -1196,7 +1359,7 @@ function extractLinks(html, limit, baseUrl) {
1196
1359
  let scans = 0
1197
1360
  const maxScans = limit * 4
1198
1361
  while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
1199
- const href = m[1].trim()
1362
+ const href = decodeUrlAttribute(m[1]).trim()
1200
1363
  if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
1201
1364
  let url
1202
1365
  try {
@@ -1231,7 +1394,7 @@ function hostOf(url) {
1231
1394
  // the model had to notice the truncation and re-call the tool per page. The
1232
1395
  // recognition is deliberately conservative: rel=next (standard) or a short
1233
1396
  // 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
1397
+ const NEXT_TEXT_RE = /^(?:[›»>]{1,3}\s{0,4})?(?:下一页|下页|下一頁|下頁|后一页|後一頁|next page|next|older entries|[›»>]{1,3})(?:\s{0,4}[›»>]{1,3})?$/i
1235
1398
 
1236
1399
  export function findNextLink(html, baseUrl) {
1237
1400
  // Same <base href> rule as extractLinks: frame/forum sites pin their
@@ -1239,7 +1402,7 @@ export function findNextLink(html, baseUrl) {
1239
1402
  const base = detectBaseHref(html || '', baseUrl)
1240
1403
  const resolve = (href) => {
1241
1404
  try {
1242
- const u = new URL(href, base)
1405
+ const u = new URL(decodeUrlAttribute(href), base)
1243
1406
  return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
1244
1407
  } catch {
1245
1408
  return null
@@ -1320,7 +1483,7 @@ export function compactJson(value) {
1320
1483
  if (typeof v === 'string') return clip(v)
1321
1484
  if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1))
1322
1485
  if (v && typeof v === 'object' && depth < 12) {
1323
- const o = {}
1486
+ const o = Object.create(null)
1324
1487
  for (const k of Object.keys(v)) o[k] = walk(v[k], depth + 1)
1325
1488
  return o
1326
1489
  }
@@ -1384,36 +1547,42 @@ export function metaRefreshTarget(html, baseUrl) {
1384
1547
  // cache key is the ORIGINAL url, and a shell re-request should re-follow
1385
1548
  // rather than serve a foreign page.
1386
1549
  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
1550
+ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
1551
+ let cur = html
1552
+ let curUrl = finalUrl
1553
+ let curCharset = '' // set only when a hop actually happened
1554
+ let sourceTruncated
1391
1555
  const seen = new Set([normalizeUrl(finalUrl)])
1392
1556
  // Chain budget: each hop carries its own fetch timeout, so 3 hops could
1393
1557
  // 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++) {
1558
+ const t0 = Date.now()
1559
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
1560
+ const hopSignal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
1561
+ for (let i = 0; i < MAX_META_REFRESH_HOPS; i++) {
1562
+ if (hopSignal.aborted) break
1396
1563
  const target = metaRefreshTarget(cur, curUrl)
1397
1564
  if (!target) break
1398
1565
  if (Date.now() - t0 > cfg.timeoutMs) break
1399
1566
  const norm = normalizeUrl(target)
1400
1567
  if (!norm || seen.has(norm)) break
1401
1568
  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)
1569
+ const page = await fetchViaWebSeam(ctx, target, hopSignal, cfg) || await fetchPage(target, hopSignal, cfg)
1570
+ if (page.error) break
1571
+ const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
1572
+ if (!/html|xhtml/.test(ct)) break
1573
+ const decoded = typeof page.html === 'string' ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
1407
1574
  cur = decoded.text
1408
- curCharset = decoded.charset
1409
- curUrl = page.finalUrl || target
1410
- }
1411
- return { html: cur, finalUrl: curUrl, charset: curCharset }
1575
+ curCharset = decoded.charset
1576
+ curUrl = page.finalUrl || target
1577
+ sourceTruncated = page.sourceTruncated === true
1578
+ }
1579
+ return { html: cur, finalUrl: curUrl, charset: curCharset, sourceTruncated }
1412
1580
  }
1413
1581
 
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' }
1582
+ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1583
+ const url = String((args && args.url) || '').trim()
1584
+ if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1585
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1417
1586
  const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
1418
1587
  const mode = args.mode === 'markdown' ? 'markdown' : 'text'
1419
1588
  const offset = Math.max(0, Number(args.offset) || 0)
@@ -1467,8 +1636,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1467
1636
  return { ...sliced, cached: true }
1468
1637
  }
1469
1638
 
1470
- const failWith = (error) => {
1471
- cacheFail(cacheKey, error)
1639
+ const failWith = (error) => {
1640
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1641
+ cacheFail(cacheKey, error)
1472
1642
  return { error }
1473
1643
  }
1474
1644
 
@@ -1478,75 +1648,105 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1478
1648
  // JSON with JS-only literals (NaN/Infinity — hand-rolled serializers) is
1479
1649
  // not valid JSON: retry once with the literals nulled rather than dropping
1480
1650
  // 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'))
1651
+ const parseJsonLoose = (t) => {
1652
+ try {
1653
+ return JSON.parse(t)
1654
+ } catch {
1655
+ const parts = []
1656
+ let quoted = false
1657
+ let start = 0
1658
+ for (let i = 0; i < t.length; i++) {
1659
+ const ch = t[i]
1660
+ if (quoted) {
1661
+ if (ch === '\\') i++
1662
+ else if (ch === '"') quoted = false
1663
+ continue
1664
+ }
1665
+ if (ch === '"') { quoted = true; continue }
1666
+ if (ch !== 'N' && ch !== 'I' && ch !== '-') continue
1667
+ const begin = i
1668
+ const literal = ch === '-' ? i + 1 : i
1669
+ const length = t.startsWith('Infinity', literal) ? 8 : t.startsWith('NaN', literal) ? 3 : 0
1670
+ if (!length) continue
1671
+ const end = literal + length
1672
+ if ((begin > 0 && !':,[ \t\r\n'.includes(t[begin - 1])) || (end < t.length && !',]} \t\r\n'.includes(t[end]))) continue
1673
+ parts.push(t.slice(start, begin), 'null')
1674
+ start = end
1675
+ i = end - 1
1676
+ }
1677
+ parts.push(t.slice(start))
1678
+ return JSON.parse(parts.join(''))
1486
1679
  }
1487
1680
  }
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) }
1681
+ const dispatch = async () => {
1682
+ const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1683
+ const page = viaSeam || await fetchPage(url, externalSignal, cfg)
1684
+ if (page.error) return { error: page.error }
1685
+ const ct = viaSeam ? (page.kind === 'text' ? 'text/plain' : 'text/html') : (page.contentType || '').split(';')[0].toLowerCase().trim()
1686
+ const decoded = viaSeam ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
1687
+ const meta = { finalUrl: page.finalUrl || url, sourceTruncated: page.sourceTruncated === true }
1688
+ const trimmed = decoded.text.trim()
1689
+ if (/json$/.test(ct) || ((viaSeam || !ct || ct === 'text/plain') && (trimmed.startsWith('{') || trimmed.startsWith('[')))) {
1690
+ try {
1691
+ return { ...meta, kind: 'json', text: compactJson(parseJsonLoose(trimmed)) }
1692
+ } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
1693
+ }
1694
+ if (/[+/]xml$/.test(ct) || viaSeam || !ct || ct === 'text/plain') {
1695
+ let xmlHead = trimmed.slice(0, 16384)
1696
+ for (let i = 0; i < 8; i++) {
1697
+ const close = xmlHead.startsWith('<?') ? '?>' : xmlHead.startsWith('<!--') ? '-->' : ''
1698
+ if (!close) break
1699
+ const end = xmlHead.indexOf(close)
1700
+ if (end < 0) break
1701
+ xmlHead = xmlHead.slice(end + close.length).trimStart()
1702
+ }
1703
+ const root = /^<(?:[A-Za-z_][\w.-]{0,63}:)?(rss|feed|urlset|sitemapindex|RDF)(?=[\s>])/i.exec(xmlHead)
1704
+ const xmlKind = root ? root[1].toLowerCase() : ''
1705
+ if (xmlKind === 'urlset' || xmlKind === 'sitemapindex') {
1706
+ return { error: 'Unsupported content (XML sitemap)' }
1707
+ }
1708
+ if (xmlKind === 'rss' || xmlKind === 'feed' || (xmlKind === 'rdf' && /<(?:[A-Za-z_][\w.-]{0,63}:)?item(?=[\s>])/i.test(decoded.text))) {
1709
+ return { ...meta, kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks, meta.finalUrl) }
1514
1710
  }
1515
1711
  }
1516
1712
  // Plain-text families (txt/md/csv): line structure IS the content — the
1517
1713
  // HTML pipeline would flatten every newline into spaces. Pages that are
1518
1714
  // actually HTML but mislabelled text/plain keep the HTML pipeline.
1519
- if (/^text\/(?:plain|markdown|csv)$/.test(ct)) {
1715
+ if (!ct || /^text\/(?:plain|markdown|csv)$/.test(ct)) {
1520
1716
  const head = decoded.text.slice(0, 5000)
1521
- if (!/<(?:html|body|div|p|table)\b[\s>]/i.test(head)) {
1717
+ if ((viaSeam && page.kind === 'text') || ct === 'text/markdown' || ct === 'text/csv' || !/<(?:html|body|div|p|table)\b[\s>]/i.test(head)) {
1522
1718
  const text = decoded.text
1523
1719
  .replace(/\r\n?/g, '\n')
1524
1720
  .replace(/[ \t]+\n/g, '\n')
1525
1721
  .replace(/\n{4,}/g, '\n\n\n')
1526
1722
  .trim()
1527
- return { kind: 'text', text, charset: decoded.charset }
1723
+ return { ...meta, kind: 'text', text, charset: decoded.charset }
1528
1724
  }
1529
1725
  }
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 }
1726
+ return { ...meta, kind: 'html', html: decoded.text, charset: decoded.charset }
1727
+ }
1728
+ const payload = await dispatch()
1729
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1730
+ if (payload.error) return failWith(payload.error)
1731
+ const resultUrl = payload.finalUrl || url
1732
+ const truncatedHint = '提供方已截断原文,当前内容不是完整页面'
1733
+ let sourceHint = payload.sourceTruncated ? truncatedHint : ''
1734
+
1735
+ if (payload.kind === 'text') {
1736
+ const full = { url: resultUrl, title: '', siteName: hostOf(resultUrl), lang: '', charset: payload.charset, mode: 'text', fullText: payload.text, paginated: 1, spaHint: sourceHint }
1537
1737
  cacheStore(cacheKey, full, cfg.cacheMax)
1538
1738
  return sliceFrom(full, offset, maxChars)
1539
1739
  }
1540
- if (payload.kind === 'json') {
1541
- const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
1740
+ if (payload.kind === 'json') {
1741
+ const full = { url: resultUrl, title: '', siteName: hostOf(resultUrl), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1, spaHint: sourceHint }
1542
1742
  cacheStore(cacheKey, full, cfg.cacheMax)
1543
1743
  return sliceFrom(full, offset, maxChars)
1544
1744
  }
1545
1745
  if (payload.kind === 'feed') {
1546
1746
  const f = payload.feed
1547
1747
  const full = {
1548
- url, title: f.title || '', siteName: hostOf(url), lang: '', charset: 'feed',
1549
- mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count,
1748
+ url: resultUrl, title: f.title || '', siteName: hostOf(resultUrl), lang: '', charset: 'feed',
1749
+ mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count, spaHint: sourceHint,
1550
1750
  links: args.includeLinks === true ? f.items : undefined,
1551
1751
  }
1552
1752
  cacheStore(cacheKey, full, cfg.cacheMax)
@@ -1561,11 +1761,13 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1561
1761
  // follow immediately, BEFORE extraction and SPA rendering — a shell that
1562
1762
  // redirects needs no headless round-trip, and extracting the stub is wasted
1563
1763
  // work. Fails open on any fetch problem; the follow is hop-bounded.
1564
- const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg)
1764
+ const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx)
1765
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1565
1766
  html = followed.html
1566
1767
  finalUrl = followed.finalUrl
1567
- // The target may use a different encoding than the shell page — reflect it.
1568
- if (followed.charset) charset = followed.charset
1768
+ // The target may use a different encoding than the shell page — reflect it.
1769
+ if (followed.charset) charset = followed.charset
1770
+ if (followed.sourceTruncated !== undefined) sourceHint = followed.sourceTruncated ? truncatedHint : ''
1569
1771
 
1570
1772
  let extracted = extract(html, mode, anchor)
1571
1773
  // Optional readability upgrade: cleaner article extraction when installed.
@@ -1599,7 +1801,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1599
1801
  // lose, rendering is always worth one bounded attempt. Pages with no
1600
1802
  // scripts at all cannot change under rendering and are skipped.
1601
1803
  let rendered = false
1602
- let spaHint = ''
1804
+ let spaHint = sourceHint
1603
1805
  let renderedHtml = null
1604
1806
  const needsRender = !extracted.text || extracted.text.length < 200
1605
1807
  const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
@@ -1659,15 +1861,19 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1659
1861
  const nu = normalizeUrl(nextUrl)
1660
1862
  if (!nu || seen.has(nu)) break
1661
1863
  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++
1864
+ const page = await fetchViaWebSeam(ctx, nextUrl, externalSignal, cfg) || await fetchPage(nextUrl, externalSignal, cfg)
1865
+ if (page.error) break
1866
+ const pageFinal = normalizeUrl(page.finalUrl || nextUrl)
1867
+ if (!pageFinal || !sameHost(pageFinal, startHost) || (pageFinal !== nu && seen.has(pageFinal))) break
1868
+ seen.add(pageFinal)
1869
+ const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
1870
+ if (!/html|xhtml/.test(ct)) break
1871
+ const nextHtml = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
1872
+ const nextEx = extract(nextHtml, mode)
1873
+ if (!nextEx.text || nextEx.text.length < 20) break
1874
+ extracted.text = joinPageText(extracted.text, nextEx.text)
1875
+ if (page.sourceTruncated && !spaHint.includes(truncatedHint)) spaHint = [spaHint, truncatedHint].filter(Boolean).join(';')
1876
+ paginated++
1671
1877
  nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1672
1878
  }
1673
1879
 
@@ -1678,7 +1884,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1678
1884
  spaHint = '正文极短(可能登录墙或反爬拦截)'
1679
1885
  }
1680
1886
 
1681
- const full = {
1887
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1888
+ const full = {
1682
1889
  url: finalUrl,
1683
1890
  title: extracted.title || '',
1684
1891
  siteName: extracted.siteName || '',
@@ -1806,20 +2013,13 @@ function readLinksTool(ctx, cfg) {
1806
2013
  const url = String((args && args.url) || '').trim()
1807
2014
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1808
2015
  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
- }
2016
+ const page = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg) || await fetchPage(url, exec && exec.signal, cfg)
2017
+ if (page.error) return { error: page.error }
2018
+ let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2019
+ let finalUrl = page.finalUrl || url
1820
2020
  // Same meta-refresh shell following as read_url: a link hop serves a
1821
2021
  // stub whose real links live at the target. Fails open, hop-bounded.
1822
- const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg)
2022
+ const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx)
1823
2023
  html = followed.html
1824
2024
  finalUrl = followed.finalUrl
1825
2025
  let links = extractLinks(html, limit, finalUrl)
@@ -1999,34 +2199,44 @@ function isNoiseUrl(url) {
1999
2199
  // static assets / auth paths are skipped; visited URLs are deduped; a per-page
2000
2200
  // failure is recorded and does not abort the crawl. No SPA rendering here —
2001
2201
  // that is read_url's job; crawling favors speed and breadth.
2002
- async function crawlSite(entryUrl, cfg, opts, externalSignal) {
2202
+ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2003
2203
  const { maxPages, maxDepth, includeContent, perMax } = opts
2004
- const host = hostOf(entryUrl)
2005
- const visited = new Set()
2204
+ let host = hostOf(entryUrl)
2205
+ const visited = new Set()
2206
+ const resolved = new Set()
2207
+ let attempted = 0
2006
2208
  const pages = []
2007
2209
  const failures = []
2008
2210
  const queue = [{ url: entryUrl, depth: 0 }]
2009
2211
  // Stop once the host's cooperative signal fires (tool budget hit): without
2010
2212
  // this the loop keeps draining the queue on instantly-aborted fetches.
2011
- while (queue.length && pages.length < maxPages && !(externalSignal && externalSignal.aborted)) {
2213
+ while (queue.length && attempted < maxPages && !(externalSignal && externalSignal.aborted)) {
2012
2214
  // Never overshoot the page budget within a batch round.
2013
- const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
2215
+ const batch = queue.splice(0, Math.min(2, maxPages - attempted, queue.length))
2014
2216
  const results = await Promise.all(
2015
2217
  batch.map(async ({ url, depth }) => {
2016
2218
  const norm = normalizeUrl(url)
2017
- if (!norm || visited.has(norm)) return null
2018
- visited.add(norm)
2019
- const page = await fetchPage(url, externalSignal, cfg)
2219
+ if (!norm || visited.has(norm)) return null
2220
+ visited.add(norm)
2221
+ attempted++
2222
+ const page = await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
2020
2223
  if (page.error) return { url, depth, error: page.error }
2021
- let html = decodeBuffer(page.buffer, page.contentType).text
2022
- let pageUrl = page.finalUrl || url
2224
+ let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2225
+ let pageUrl = page.finalUrl || url
2226
+ if (depth > 0 && !sameHost(pageUrl, host)) {
2227
+ return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
2228
+ }
2023
2229
  // Same shell-following as read_url: a meta-refresh stub would otherwise
2024
2230
  // be crawled AS the page (title-less hop text pollutes results and the
2025
2231
  // real content is never reached). Fails open — fetch problems keep the
2026
2232
  // stub's own HTML.
2027
- const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg)
2028
- html = followed.html
2029
- pageUrl = followed.finalUrl
2233
+ const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx)
2234
+ html = followed.html
2235
+ pageUrl = followed.finalUrl
2236
+ if (depth === 0) host = hostOf(pageUrl)
2237
+ else if (!sameHost(pageUrl, host)) {
2238
+ return { url, depth, error: `Redirected outside the site: ${pageUrl}` }
2239
+ }
2030
2240
  const ex = extract(html, 'text')
2031
2241
  const links = extractLinks(html, 100, pageUrl)
2032
2242
  return {
@@ -2045,7 +2255,11 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
2045
2255
  failures.push({ url: r.url, error: r.error })
2046
2256
  continue
2047
2257
  }
2048
- pages.push(r)
2258
+ const finalNorm = normalizeUrl(r.url)
2259
+ if (!finalNorm || resolved.has(finalNorm)) continue
2260
+ resolved.add(finalNorm)
2261
+ visited.add(finalNorm)
2262
+ pages.push(r)
2049
2263
  if (r.depth + 1 <= maxDepth) {
2050
2264
  // Queue cap: a huge site (millions of links) would otherwise grow the
2051
2265
  // queue unboundedly while maxPages bounds only what we *process*.
@@ -2123,12 +2337,12 @@ function readUrlSiteTool(ctx, cfg) {
2123
2337
  async execute(args, exec) {
2124
2338
  const url = String((args && args.url) || '').trim()
2125
2339
  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))
2340
+ const maxPages = Math.floor(Math.max(2, Math.min(50, Number(args.maxPages) || 15)))
2127
2341
  const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
2128
2342
  const includeContent = args.includeContent === true
2129
2343
  const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
2130
2344
  const { host, pages, failures } = await crawlSite(
2131
- url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal,
2345
+ url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal, ctx,
2132
2346
  )
2133
2347
  return {
2134
2348
  host,