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/CHANGELOG.md +27 -0
- package/README.md +17 -7
- package/README.zh.md +17 -7
- package/index.js +537 -323
- package/package.json +1 -1
- package/proxy-fallback.js +22 -1
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
523
|
-
|
|
524
|
-
|
|
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.
|
|
527
|
-
return articles.
|
|
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
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
645
|
-
const v = store
|
|
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) =>
|
|
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]
|
|
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])
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
694
|
-
else if (tag === 'br') out += ' \n'
|
|
695
|
-
else out += inner
|
|
838
|
+
else out += inner
|
|
696
839
|
}
|
|
697
|
-
return img.restore(out.
|
|
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]
|
|
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])
|
|
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 <div>
|
|
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 =
|
|
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[^>]
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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 (
|
|
750
|
-
const liRe = /<li[^>]{0,1000}>([\s\S]
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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:
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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 = /^(?:[›»>]
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
if (
|
|
1513
|
-
return {
|
|
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,
|
|
1531
|
-
}
|
|
1532
|
-
const payload = await dispatch()
|
|
1533
|
-
if (
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1665
|
-
if (
|
|
1666
|
-
|
|
1667
|
-
const
|
|
1668
|
-
if (
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
-
|
|
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
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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,
|