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