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