dsh-read-url 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,1507 +1,1663 @@
1
- // dsh-read-url — DeepSeek Harness URL reader plugin
2
- // Zero external dependencies (Node 20+ built-ins only).
3
- // Aligned with DSH architecture:
4
- // - all network access goes through the ctx.web capability seam (official
5
- // docs/capability-seams.md), falling back to global fetch when absent
6
- // - cache is registered under ctx.effect, so unload fully reverts it
7
- // (temporal composability, docs/cordis-primer.md)
8
- // - cooperative tool-call timeout via ToolDefinition.timeoutMs + exec.signal
9
- // Focus: token economy + smarter extraction for DSH agents.
10
- // - charset auto-detect (BOM UTF-16 / GBK/GB2312/UTF-8/Big5/Shift-JIS)
11
- // - content dispatch: HTML / JSON APIs / RSS-Atom feeds natively
12
- // - clean main-content extraction (heuristic; optional @mozilla/readability upgrade path)
13
- // + text-density fallback on the body path
14
- // - auto-joined pagination, page metadata (published/author)
15
- // - HTML -> Markdown / plain text, paragraph-aligned smart truncation
16
- // - session-level cache, compact text render (lowest token cost)
17
- import { TextDecoder } from 'node:util'
18
- import { looksLikeSpa, renderPage, closeBrowser, looksLikeChallenge } from './spa.js'
19
- import { detectProxy, fetchViaCurlProxy, looksBinary } from './proxy-fallback.js'
20
- // Re-export for tests / programmatic (PTC) cleanup.
21
- export { closeBrowser, renderPage, looksLikeSpa, looksLikeChallenge } from './spa.js'
22
-
23
- export const name = 'dsh-read-url'
24
- export const inject = ['tools']
25
-
26
- // Plugin-level configuration (overridable via cordis.patch.yml `config:` row).
27
- const DEFAULTS = {
28
- timeoutMs: 15000,
29
- maxBytes: 3 * 1024 * 1024,
30
- maxChars: 6000,
31
- maxLinks: 20,
32
- cacheTtlMs: 300000,
33
- cacheMax: 32,
34
- spaRender: true,
35
- paginate: true,
36
- paginateMax: 3,
37
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
38
- }
39
-
40
- // Session-level cache: url -> { time, full } on success, { time, error } on
41
- // failure (short TTL so a broken URL is not re-fetched in a loop).
42
- const cache = new Map()
43
- const decoders = new Map()
44
-
45
- function getDecoder(enc) {
46
- let d = decoders.get(enc)
47
- if (!d) {
48
- try {
49
- d = new TextDecoder(enc, { fatal: false })
50
- } catch {
51
- d = new TextDecoder('utf-8')
52
- }
53
- decoders.set(enc, d)
54
- }
55
- return d
56
- }
57
-
58
- function sniffCharset(buffer, contentType) {
59
- // BOM first — byte-level evidence beats any declared charset (a UTF-16 body
60
- // with a latin1-read <meta> probe garbles the meta match anyway).
61
- if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
62
- if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le'
63
- if (buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be'
64
- const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
65
- if (m) return m[1]
66
- const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
67
- const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)
68
- if (meta) return meta[1]
69
- return null
70
- }
71
-
72
- export function decodeBuffer(buffer, contentType) {
73
- let enc = sniffCharset(buffer, contentType)
74
- if (!enc) enc = 'utf-8'
75
- enc = enc.toLowerCase().replace('gb2312', 'gbk').replace('gb_2312', 'gbk')
76
- let text
77
- try {
78
- text = getDecoder(enc).decode(buffer)
79
- } catch {
80
- text = new TextDecoder('utf-8').decode(buffer)
81
- }
82
- if (enc !== 'utf-8' && enc !== 'utf8' && /\uFFFD/.test(text.slice(0, 2000))) {
83
- const fixed = new TextDecoder('utf-8').decode(buffer)
84
- if (!/\uFFFD/.test(fixed.slice(0, 2000))) return { text: fixed, charset: 'utf-8' }
85
- }
86
- return { text, charset: enc }
87
- }
88
-
89
- // Content types this plugin can consume. Beyond HTML: JSON (data APIs) and
90
- // XML (RSS/Atom feeds) are read natively — see readUrl's type dispatch.
91
- // Note "application/rss+xml": the separator before xml is '+', not '/'.
92
- const FETCHABLE_CT = /text\/html|application\/xhtml|text\/plain|\/json|[+/]xml/i
93
-
94
- // Retry-After header (seconds form) → ms, capped; undefined when absent/invalid.
95
- function retryAfterMs(res) {
96
- const v = res.headers.get('retry-after')
97
- if (!v) return undefined
98
- const sec = Number(v)
99
- if (!Number.isFinite(sec) || sec < 0) return undefined
100
- return Math.min(sec * 1000, 5000)
101
- }
102
-
103
- // Single fetch attempt. Never throws; maps abort/timeout/network to { error }.
104
- async function directFetchOnce(url, signal, cfg) {
105
- try {
106
- const res = await fetch(url, {
107
- redirect: 'follow',
108
- signal,
109
- headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
110
- })
111
- // Early-return paths must cancel the body stream, or the connection is
112
- // held open undrained and cannot return to the keep-alive pool.
113
- const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
114
- if (!res.ok) {
115
- // Rate-limit / temporary-overload: honor Retry-After with one retry
116
- // (capped at 5s so the cooperative timeout still bounds the call).
117
- if (res.status === 429 || res.status === 503) {
118
- const ra = retryAfterMs(res)
119
- discardBody()
120
- if (ra !== undefined) return { retryAfterMs: ra }
121
- }
122
- discardBody()
123
- return { error: `HTTP ${res.status} ${res.statusText}` }
124
- }
125
- const contentType = res.headers.get('content-type') || ''
126
- if (contentType && !FETCHABLE_CT.test(contentType)) {
127
- discardBody()
128
- return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
129
- }
130
- if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url }
131
- const chunks = []
132
- let size = 0
133
- for await (const chunk of res.body) {
134
- size += chunk.length
135
- if (size > cfg.maxBytes) { discardBody(); return { error: `Page exceeds ${cfg.maxBytes} bytes` } }
136
- chunks.push(chunk)
137
- }
138
- const buffer = Buffer.concat(chunks)
139
- // Headerless responses: gate binary bodies out of the HTML pipeline.
140
- if (!contentType && looksBinary(buffer)) {
141
- return { error: 'Unsupported content-type (no header, binary body)' }
142
- }
143
- return { buffer, contentType, finalUrl: res.url }
144
- } catch (e) {
145
- if (e.name === 'AbortError' || e.name === 'TimeoutError') {
146
- return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
147
- }
148
- // undici wraps the real reason in e.cause (ENOTFOUND / ECONNREFUSED /
149
- // connect-timeout / TLS) — surface it so the model can tell "bad domain"
150
- // from "blocked network" and act accordingly instead of retrying blindly
151
- const cause = e.cause && e.cause.message ? String(e.cause.message).slice(0, 90) : ''
152
- return { error: cause || (e.message || '').slice(0, 120) }
153
- }
154
- }
155
-
156
- // Direct-connect fetch with a single Retry-After-aware retry on 429/503.
157
- async function directFetch(url, signal, cfg) {
158
- const first = await directFetchOnce(url, signal, cfg)
159
- if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
160
- await new Promise((resolve) => {
161
- const t = setTimeout(resolve, first.retryAfterMs)
162
- if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
163
- })
164
- return directFetchOnce(url, signal, cfg)
165
- }
166
- return first
167
- }
168
-
169
- // Settle-all race that resolves with the FIRST SUCCESSFUL result (a value
170
- // with a buffer and no error). If every attempt fails it resolves with
171
- // { success:false, failures:[...] } once the slowest one has settled —
172
- // the proxy channel (curl --max-time ~20s) is the natural ceiling, and the
173
- // direct fetch aborts on the same total budget.
174
- export function raceFirstSuccess(promises) {
175
- return new Promise((resolve) => {
176
- if (promises.length === 0) {
177
- resolve({ success: false, failures: [] })
178
- return
179
- }
180
- const failures = []
181
- let settled = 0
182
- promises.forEach((p, i) => {
183
- p.then((v) => {
184
- if (v && !v.error && v.buffer) {
185
- resolve({ success: true, value: v })
186
- return
187
- }
188
- failures[i] = v
189
- settled += 1
190
- if (settled === promises.length) resolve({ success: false, failures })
191
- }).catch((e) => {
192
- failures[i] = { error: String((e && e.message) || e) }
193
- settled += 1
194
- if (settled === promises.length) resolve({ success: false, failures })
195
- })
196
- })
197
- })
198
- }
199
- async function raceFetch(url, externalSignal, cfg, proxy) {
200
- const directCtrl = new AbortController()
201
- const proxyCtrl = new AbortController()
202
- const withExt = (ctrl) =>
203
- externalSignal ? AbortSignal.any([externalSignal, ctrl.signal]) : ctrl.signal
204
- const directSignal = AbortSignal.any([withExt(directCtrl), AbortSignal.timeout(cfg.timeoutMs)])
205
- const direct = directFetch(url, directSignal, cfg)
206
- const proxied = fetchViaCurlProxy(url, cfg, withExt(proxyCtrl), proxy)
207
- const winner = await raceFirstSuccess([direct, proxied])
208
- directCtrl.abort()
209
- proxyCtrl.abort()
210
- return winner
211
- }
212
-
213
- async function fetchPage(url, externalSignal, cfg) {
214
- const proxy = await detectProxy()
215
- if (proxy) {
216
- const winner = await raceFetch(url, externalSignal, cfg, proxy)
217
- if (winner.success) return winner.value
218
- const dErr = winner.failures[0] && winner.failures[0].error
219
- const pErr = winner.failures[1]
220
- const px = pErr && pErr.error ? `,代理返回 ${pErr.error}` : ',代理未连通'
221
- return { error: `Fetch failed: ${dErr}(直连失败;已尝试代理 ${proxy}${px})` }
222
- }
223
- // No proxy configured: plain direct connect (the original behaviour).
224
- const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
225
- const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
226
- const r = await directFetch(url, signal, cfg)
227
- if (r.error) return { error: `Fetch failed: ${r.error}(直连失败,未检测到代理配置)` }
228
- return r
229
- }
230
-
231
- // Prefer the ctx.web capability seam (official docs/capability-seams.md):
232
- // the web provider already decodes the document, so we skip our charset
233
- // layer and only re-extract. Falls back to global fetch when absent.
234
- // Seam calls get the same cooperative timeout as fetchPage (a hanging
235
- // provider should not block the tool call forever).
236
- async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
237
- try {
238
- // ctx.get for a non-injected service throws on strict cordis hosts (seen
239
- // with 'settings') — treat that as "seam absent" and fall through.
240
- const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
241
- if (!web || typeof web.fetch !== 'function') return null
242
- const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
243
- const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
244
- const res = await web.fetch(url, { signal })
245
- if (!res || typeof res.content !== 'string') return null
246
- const finalUrl = res.url || res.finalUrl || url
247
- return { html: res.content, finalUrl }
248
- } catch {
249
- return null
250
- }
251
- }
252
-
253
- // Decode common HTML entities in already-stripped text. Runs AFTER tag
254
- // stripping so escaped tags stay visible to the stripper and only remaining
255
- // text is decoded. Covers the numeric/hex forms plus the named entities that
256
- // actually show up in CJK web text (spaces, dashes, quotes, symbols) —
257
- // undecoded leftovers would both waste tokens and read as garbage.
258
- const ENTITIES = {
259
- nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '',
260
- amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
261
- ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
262
- lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201C', rdquo: '\u201D',
263
- laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™',
264
- deg: '°', plusmn: '±', times: '×', divide: '÷', micro: 'µ',
265
- sect: '§', para: '¶', dagger: '†', prime: '′', Prime: '″',
266
- permil: '‰', euro: '€', pound: '£', yen: '¥', cent: '¢',
267
- sup2: '²', sup3: '³', frac12: '½', frac14: '¼', frac34: '¾',
268
- }
269
- export function decodeTextEntities(text) {
270
- if (!text.includes('&')) return text
271
- return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|([a-z][a-z0-9]{1,7}));?/gi, (m, dec, hex, name) => {
272
- if (dec !== undefined) {
273
- const c = Number(dec)
274
- return c > 0x10ffff ? m : String.fromCodePoint(c)
275
- }
276
- if (hex !== undefined) {
277
- const c = Number.parseInt(hex, 16)
278
- return c > 0x10ffff ? m : String.fromCodePoint(c)
279
- }
280
- const n = (name || '').toLowerCase()
281
- return Object.prototype.hasOwnProperty.call(ENTITIES, n) ? ENTITIES[n] : m
282
- })
283
- }
284
-
285
- function textOnly(html) {
286
- return decodeTextEntities(html
287
- .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(/<[^>]+>/g, ' ')
293
- .replace(/[ \t\r\n]+/g, ' ')
294
- .trim())
295
- }
296
-
297
- // ---- JSON / RSS / Atom content dispatch ----
298
- // A URL is not always an HTML page: data APIs answer JSON and content sources
299
- // answer feeds. Both are model-readable when compacted, so read_url handles
300
- // them natively instead of erroring with "Unsupported content-type".
301
-
302
- function xmlText(s) {
303
- let t = String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
304
- // Feeds double-escape HTML in descriptions (&lt;a href=…&gt;查看全文&lt;/a&gt;):
305
- // one strip-then-decode pass leaves literal tags in the rendered text
306
- // (measured on sspai.com/feed). Iterate strip → decode until stable.
307
- for (let i = 0; i < 3; i++) {
308
- const prev = t
309
- t = decodeTextEntities(t.replace(/<[^>]+>/g, ' '))
310
- if (t === prev) break
311
- }
312
- return t.replace(/\s+/g, ' ').trim()
313
- }
314
-
315
- // RSS 2.0 <item> / Atom <entry> → compact list "title — url\n summary".
316
- function parseFeed(xml, limit) {
317
- const isAtom = /<feed[\s>]/i.test(xml)
318
- const itemRe = isAtom ? /<entry[\s>][\s\S]*?<\/entry\s*>/gi : /<item[\s>][\s\S]*?<\/item\s*>/gi
319
- const items = []
320
- let m
321
- while ((m = itemRe.exec(xml)) && items.length < limit) {
322
- const it = m[0]
323
- const title = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(it)?.[1] || '')
324
- const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
325
- const linkHref = /<link[^>]+href=["']([^"']+)["']/i.exec(it)
326
- const link = (linkTag && xmlText(linkTag[1])) || (linkHref && linkHref[1]) || ''
327
- const desc = xmlText(/<(?:description|summary|content)[^>]*>([\s\S]*?)<\/(?:description|summary|content)>/i.exec(it)?.[1] || '')
328
- if (!title && !link && !desc) continue
329
- items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
330
- }
331
- const feedTitle = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(xml)?.[1] || '')
332
- const lines = []
333
- for (const it of items) {
334
- lines.push(`- ${it.title}${it.url ? ` — ${it.url}` : ''}`)
335
- if (it.summary) lines.push(` ${it.summary}`)
336
- }
337
- return { title: feedTitle, count: items.length, items, text: lines.join('\n') }
338
- }
339
-
340
- // ---- Text-density fallback (body path only) ----
341
- // Pages without <article>/<main> degrade to the whole <body>: semantic noise
342
- // stripping then still leaves link-dominated blocks (related posts, category
343
- // sidebars, "hot articles" widgets) that carry little reading value. Split the
344
- // body at block-level open tags and drop segments whose text is mostly links.
345
- // 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[^>]*>/gi
347
-
348
- function linkTextLength(html) {
349
- let n = 0
350
- const re = /<a[\s>][\s\S]*?<\/a\s*>/gi
351
- let m
352
- while ((m = re.exec(html))) n += textOnly(m[0]).length
353
- return n
354
- }
355
-
356
- export function densityFilter(html) {
357
- const marks = []
358
- BLOCK_OPEN_RE.lastIndex = 0
359
- let m
360
- while ((m = BLOCK_OPEN_RE.exec(html))) marks.push(m.index)
361
- if (marks.length < 2) return html
362
- const segs = []
363
- if (marks[0] > 0) segs.push(html.slice(0, marks[0]))
364
- for (let k = 0; k < marks.length; k++) {
365
- segs.push(html.slice(marks[k], k + 1 < marks.length ? marks[k + 1] : html.length))
366
- }
367
- const kept = segs.filter((seg) => {
368
- const text = textOnly(seg)
369
- if (text.length < 2) return false
370
- // short link-dominated segment = nav item / recommendation widget entry
371
- if (text.length < 300 && linkTextLength(seg) > text.length * 0.65) return false
372
- return true
373
- })
374
- return kept.length ? kept.join('') : html
375
- }
376
-
377
- // Depth-counted block extraction: returns html from the opening tag at
378
- // openIdx through its BALANCED closing tag. A non-greedy regex stops at the
379
- // first nested </div> — measured: gnu.org "content" div cut to 2700 chars,
380
- // naver.com "container" to 115 (zero text). Container divs nest heavily.
381
- function tagBlockAt(html, openIdx, tagName) {
382
- const re = new RegExp(`</?${tagName}(?=[\\s>])`, 'gi')
383
- re.lastIndex = openIdx
384
- let depth = 0
385
- let m
386
- while ((m = re.exec(html))) {
387
- depth += m[0][1] === '/' ? -1 : 1
388
- if (depth === 0) {
389
- const close = html.indexOf('>', m.index)
390
- return close > 0 ? html.slice(openIdx, close + 1) : null
391
- }
392
- }
393
- return null
394
- }
395
-
396
- export function pickMain(html) {
397
- // Aggregation pages (e.g. blog homepages) have one <article> per item:
398
- // collect all of them instead of only the first. A tiny <article> (e.g. a
399
- // newsletter card — measured on about.gitlab.com: 71 chars of text while
400
- // the real content sits in <main>) falls through to <main> when present.
401
- const articles = [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
402
- const hasMain = /<main[\s>]/i.test(html)
403
- if (articles.length && (!hasMain || textOnly(articles.map((a) => a[0]).join('')).length >= 200)) {
404
- return articles.map((a) => a[0]).join('\n')
405
- }
406
- const roleMain = /<(main|div)[^>]*role=["']main["'][\s>]/i.exec(html)
407
- if (roleMain) {
408
- const block = tagBlockAt(html, roleMain.index, roleMain[1].toLowerCase())
409
- if (block) return block
410
- }
411
- // Bare <main> without role="main": doc generators (VitePress/VuePress,
412
- // MDN, MDX sites) mark the body with a plain <main> tag. Falling back to
413
- // the whole <body> would drag in the top/local nav (menu, outline, skip
414
- // link) — the extraction quality loss we saw on vuejs.org.
415
- const bareMain = /<main[\s>]/i.exec(html)
416
- if (bareMain) {
417
- const block = tagBlockAt(html, bareMain.index, 'main')
418
- if (block) return block
419
- }
420
- const body = /<body[\s>]/i.exec(html)
421
- if (body) {
422
- const after = html.slice(body.index)
423
- const end = after.search(/<\/body>/i)
424
- return densityFilter(end > 0 ? after.slice(0, end) : after)
425
- }
426
- return densityFilter(html)
427
- }
428
-
429
- // Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
430
- // entity-escaped tags (&lt;style&gt;...). Reveal them so noise rules can strip
431
- // them; then run noise removal again.
432
- function revealEscapedTags(html) {
433
- return html
434
- .replace(/&lt;/gi, '<')
435
- .replace(/&gt;/gi, '>')
436
- .replace(/&quot;/gi, '"')
437
- }
438
-
439
- function stripNoise(mainHtml) {
440
- return mainHtml
441
- .replace(/<!--[\s\S]*?-->/g, ' ')
442
- .replace(/<(textarea|style|script|noscript|template)[\s>][\s\S]*?<\/\1\s*>/gi, ' ')
443
- .replace(/<(nav|footer|header|aside|form|iframe|svg|canvas|dialog)[\s>][\s\S]*?<\/\1>/gi, ' ')
444
- .replace(/<([a-z][a-z0-9]*)[^>]+class=["'][^"']*(ad-|ads|advert|banner|sidebar|social|share|comment|popup|modal|cookie)[^"']*["'][^>]*>[\s\S]*?<\/\1>/gi, ' ')
445
- }
446
-
447
- // ---- HTML -> Markdown (lightweight, tag-state machine) ----
448
- function escInline(s) {
449
- return s.replace(/([\\`*_[\]])/g, '\\$1')
450
- }
451
-
452
- // <img> is a void element (never matches the paired-tag regex in either md
453
- // walker): convert descriptive images to markdown BEFORE the tag walk, via a
454
- // sentinel so escInline cannot escape the generated brackets. Decorative
455
- // images (empty alt) are dropped — the model cannot see pixels anyway.
456
- const IMG_SENTINEL = '\u0001'
457
- function imgsToMarkdown(html) {
458
- const store = []
459
- const out = html.replace(/<img\b[^>]*>/gi, (tag) => {
460
- const alt = /alt=["']([^"']*)["']/i.exec(tag)
461
- const src = /src=["']([^"']+)["']/i.exec(tag)
462
- store.push(src && alt && alt[1].trim() ? `![${alt[1].trim()}](${src[1]})` : '')
463
- return `${IMG_SENTINEL}${store.length - 1}${IMG_SENTINEL}`
464
- })
465
- return {
466
- html: out,
467
- // Unknown sentinel indexes are PRESERVED (a nested walker's restore must
468
- // not eat sentinels owned by an outer frame — blockMd recurses into itself).
469
- restore: (s) => s.replace(new RegExp(`${IMG_SENTINEL}(\\d+)${IMG_SENTINEL}`, 'g'), (m, i) => {
470
- const v = store[Number(i)]
471
- return v === undefined ? m : v
472
- }),
473
- }
474
- }
475
-
476
- export function inlineMd(html) {
477
- const img = imgsToMarkdown(html)
478
- html = img.html
479
- let out = ''
480
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
481
- let m
482
- while ((m = re.exec(html))) {
483
- if (m[4] !== undefined) {
484
- out += escInline(decodeTextEntities(m[4]))
485
- continue
486
- }
487
- if (!m[1]) continue
488
- const tag = m[1].toLowerCase()
489
- if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
490
- const inner = inlineMd(m[3])
491
- if (tag === 'a') {
492
- const href = /href=["']([^"']+)["']/i.exec(m[2])
493
- out += href ? `[${inner}](${href[1]})` : inner
494
- } else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
495
- else if (tag === 'em' || tag === 'i') out += `*${inner}*`
496
- else if (tag === 'code') out += `\`${inner}\``
497
- else if (tag === 'br') out += ' \n'
498
- else out += inner
499
- }
500
- return img.restore(out.replace(/[ \t]+/g, ' ').trim())
501
- }
502
-
503
- export function blockMd(html) {
504
- let out = ''
505
- const img = imgsToMarkdown(html)
506
- html = img.html
507
- const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
508
- let m
509
- while ((m = re.exec(html))) {
510
- if (m[4] !== undefined) {
511
- out += decodeTextEntities(m[4])
512
- continue
513
- }
514
- if (!m[1]) continue
515
- const tag = m[1].toLowerCase()
516
- const attrs = m[2] || ''
517
- const inner = m[3]
518
- if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') {
519
- continue
520
- }
521
- if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') {
522
- const level = '#'.repeat(Number(tag[1]))
523
- out += `\n\n${level} ${inlineMd(inner)}`
524
- } else if (tag === 'p') {
525
- const t = inlineMd(inner)
526
- if (t) out += `\n\n${t}`
527
- } else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
528
- const t = blockMd(inner)
529
- if (t) out += `\n\n${t}`
530
- } else if (tag === 'blockquote') {
531
- const t = blockMd(inner).trim()
532
- out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
533
- } else if (tag === 'pre') {
534
- const code = inner.replace(/<[^>]+>/g, '').trim()
535
- // Language hint from common highlighter conventions: the model reads
536
- // ```js fenced blocks far more accurately than unlabelled ones.
537
- const lang =
538
- /<code[^>]+class=["'][^"']*(?:language|lang)-([\w#+.-]+)["']/i.exec(inner)?.[1] || ''
539
- out += `\n\n\`\`\`${lang}\n${code}\n\`\`\``
540
- } else if (tag === 'code') {
541
- out += `\`${textOnly(inner)}\``
542
- } else if (tag === 'ul' || tag === 'ol') {
543
- const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi
544
- let lm, idx = 1, items = []
545
- while ((lm = liRe.exec(inner))) {
546
- const t = inlineMd(lm[1])
547
- items.push(tag === 'ol' ? `${idx}. ${t}` : `- ${t}`)
548
- idx++
549
- }
550
- if (items.length) out += `\n\n${items.join('\n')}`
551
- } else if (tag === 'li') {
552
- out += `\n- ${inlineMd(inner)}`
553
- } else if (tag === 'br') {
554
- out += ' \n'
555
- } else if (tag === 'hr') {
556
- out += '\n\n---'
557
- } else if (tag === 'table') {
558
- const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
559
- let tm, rows = []
560
- while ((tm = trRe.exec(inner))) {
561
- const cells = []
562
- const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
563
- let cm
564
- while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1]).replace(/\|/g, '\\|'))
565
- if (cells.length) rows.push(`| ${cells.join(' | ')} |`)
566
- }
567
- if (rows.length) {
568
- const header = rows[0]
569
- // unescape cells' escaped pipes before deriving the separator row,
570
- // otherwise the --- line is one char wider per escaped pipe
571
- const sep = header.replace(/\\\|/g, '|').replace(/[^|]/g, '-')
572
- out += `\n\n${rows.join('\n')}\n${sep}`
573
- }
574
- } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
575
- const t = inlineMd(inner)
576
- if (t) out += t
577
- } else {
578
- const t = blockMd(inner)
579
- if (t) out += t
580
- }
581
- }
582
- return img.restore(out)
583
- }
584
-
585
- // Page-level metadata the model actually asks about (who wrote it, when) —
586
- // cheap to harvest from <meta>, previously thrown away.
587
- function extractMeta(html) {
588
- const get = (names) => {
589
- for (const n of names) {
590
- const m = new RegExp(`<meta[^>]+(?:property|name)=["']${n}["'][^>]+content=["']([^"']+)["']`, 'i').exec(html)
591
- if (m) return decodeTextEntities(m[1]).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
592
- }
593
- return ''
594
- }
595
- return {
596
- published: get(['article:published_time', 'og:published_time', 'pubdate', 'publishdate', 'date', 'dc.date']),
597
- author: get(['author', 'article:author', 'og:article:author']),
598
- description: get(['og:description', 'description']),
599
- }
600
- }
601
-
602
- // Meta-less pages (measured: ruanyifeng.com carries ZERO author/date meta
603
- // tags) still print the byline inside the body head: "作者:阮一峰 日期:
604
- // 2026年8月21日". Fill ONLY the missing fields, and only from the first
605
- // 600 chars of body text — deeper matches are content, not bylines.
606
- const BYLINE_AUTHOR_RE = /(?:作者|責任編輯|責任主编|editor)[::\s]{1,3}([^\s,,。/|()()[\]]{2,24})/
607
- 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
608
-
609
- function bylineFallback(text, meta) {
610
- const head = typeof text === 'string' ? text.slice(0, 600) : ''
611
- const out = { published: meta.published, author: meta.author }
612
- if (!out.author) {
613
- const m = BYLINE_AUTHOR_RE.exec(head)
614
- if (m) out.author = m[1].trim().slice(0, 40)
615
- }
616
- if (!out.published) {
617
- const m = BYLINE_DATE_RE.exec(head)
618
- if (m) out.published = m[1].replace(/\s+/g, ' ').trim().slice(0, 40)
619
- }
620
- return out
621
- }
622
-
623
- export function extract(html, mode) {
624
- const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)
625
- const siteName = /<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i.exec(html)
626
- const langMatch = /<html[^>]+lang=["']([\w-]+)["']/i.exec(html)
627
- let main = stripNoise(pickMain(html))
628
- main = stripNoise(revealEscapedTags(main))
629
- let bodyText
630
- if (mode === 'markdown') {
631
- bodyText = blockMd(main).replace(/\n{3,}/g, '\n\n').trim()
632
- } else {
633
- bodyText = textOnly(main).replace(/ +/g, ' ').trim()
634
- }
635
- const meta = extractMeta(html)
636
- // Empty-body pages (login walls, JS-only shells) still carry og:description
637
- // — surface it as a fallback hint instead of nothing.
638
- let text = bodyText
639
- let usedDescription = false
640
- if (!text && meta.description) {
641
- text = meta.description
642
- usedDescription = true
643
- }
644
- const byline = bylineFallback(text, meta)
645
- return {
646
- title: titleMatch ? textOnly(titleMatch[1]) || titleMatch[1].trim() : '',
647
- siteName: siteName ? siteName[1] : '',
648
- lang: langMatch ? langMatch[1] : '',
649
- published: byline.published,
650
- author: byline.author,
651
- text,
652
- usedDescription,
653
- }
654
- }
655
-
656
- // Optional enhancement: when @mozilla/readability + happy-dom are installed
657
- // inside the DSH profile (npm i @mozilla/readability happy-dom), extraction
658
- // upgrades to the Firefox Reader Mode algorithm. Falls back to the built-in
659
- // heuristic extractor when absent. MPL-2.0 / MIT libraries, used as-is.
660
- let readabilityReady = null
661
- async function getReadabilityExtractor() {
662
- if (readabilityReady !== null) return readabilityReady
663
- try {
664
- const [{ Readability }, { Window }] = await Promise.all([
665
- import('@mozilla/readability'),
666
- import('happy-dom'),
667
- ])
668
- readabilityReady = (html, url) => {
669
- const window = new Window({ url })
670
- window.document.write(html)
671
- const article = new Readability(window.document).parse()
672
- return article ? article.content : null
673
- }
674
- } catch {
675
- readabilityReady = false
676
- }
677
- return readabilityReady
678
- }
679
-
680
- export function smartTruncate(text, maxChars, offset = 0) {
681
- const total = text.length
682
- if (offset >= total) {
683
- return { text: '', truncated: false, charsTotal: total, charsReturned: 0, charsStart: offset }
684
- }
685
- if (total <= maxChars && offset === 0) {
686
- return { text, truncated: false, charsTotal: total, charsReturned: total, charsStart: 0 }
687
- }
688
- const paragraphs = text.split(/\n\n+/)
689
- let pos = 0
690
- let start = 0
691
- for (let i = 0; i < paragraphs.length; i++) {
692
- if (pos + paragraphs[i].length > offset) {
693
- start = i
694
- break
695
- }
696
- pos += paragraphs[i].length + 2
697
- }
698
- let acc = ''
699
- for (let i = start; i < paragraphs.length; i++) {
700
- const p = paragraphs[i]
701
- if (acc.length + p.length + (acc ? 2 : 0) > maxChars) break
702
- acc += (acc ? '\n\n' : '') + p
703
- }
704
- if (!acc && maxChars > 0) acc = text.slice(offset, offset + maxChars)
705
- return {
706
- text: acc,
707
- truncated: offset + acc.length < total,
708
- charsTotal: total,
709
- charsReturned: acc.length,
710
- charsStart: offset,
711
- }
712
- }
713
-
714
- function extractLinks(html, limit, baseUrl) {
715
- const links = []
716
- const seen = new Set()
717
- // Accept relative hrefs too — most real pages link internally with relative
718
- // paths — and resolve them against the page URL so the model can follow them.
719
- const re = /<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi
720
- let m
721
- // Iteration cap: a page whose nav repeats the same links thousands of times
722
- // would otherwise scan the whole HTML to fill `limit` unique entries. Scan
723
- // at most limit*4 anchors; dedupe keeps the result bounded regardless.
724
- let scans = 0
725
- const maxScans = limit * 4
726
- while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
727
- const href = m[1].trim()
728
- if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
729
- let url
730
- try {
731
- url = baseUrl ? new URL(href, baseUrl).href : new URL(href).href
732
- } catch {
733
- continue
734
- }
735
- // dedupe: nav bars repeat the same links many times — one entry per URL
736
- // keeps the link list compact (tokens) without losing coverage
737
- if (seen.has(url)) continue
738
- seen.add(url)
739
- const t = textOnly(m[2])
740
- if (t) links.push({ title: t.slice(0, 80), url })
741
- }
742
- return links
743
- }
744
-
745
- function hostOf(url) {
746
- try {
747
- return new URL(url).hostname
748
- } catch {
749
- return ''
750
- }
751
- }
752
-
753
- // ---- Pagination: auto-follow "next page" ----
754
- // Long articles on novel/news/forum sites are split into pages; without this
755
- // the model had to notice the truncation and re-call the tool per page. The
756
- // recognition is deliberately conservative: rel=next (standard) or a short
757
- // anchor whose whole text is a next-page marker — no fuzzy guessing.
758
- const NEXT_TEXT_RE = /^(?:下一页|下页|后一页|下一頁|下頁|next page|next|›{1,3}|»{1,3}|>|older entries)$/i
759
-
760
- export function findNextLink(html, baseUrl) {
761
- const resolve = (href) => {
762
- try {
763
- const u = new URL(href, baseUrl)
764
- return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
765
- } catch {
766
- return null
767
- }
768
- }
769
- const rel = /<(?:link|a)\b[^>]+rel=["']next["'][^>]*>/i.exec(html || '')
770
- if (rel) {
771
- const href = /href=["']([^"']+)["']/i.exec(rel[0])
772
- const r = href && resolve(href[1])
773
- if (r) return r
774
- }
775
- const re = /<a\b[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi
776
- let m
777
- while ((m = re.exec(html || ''))) {
778
- const t = textOnly(m[2])
779
- if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
780
- const r = resolve(m[1])
781
- if (r) return r
782
- }
783
- }
784
- return null
785
- }
786
-
787
- // Join two page bodies, dropping a repeated suffix/prefix overlap (many
788
- // paginated sites repeat the last paragraph or a heading on the next page).
789
- function joinPageText(a, b) {
790
- const max = Math.min(300, a.length, b.length)
791
- for (let n = max; n >= 30; n--) {
792
- if (a.slice(a.length - n) === b.slice(0, n)) {
793
- const rest = b.slice(n).trim()
794
- return rest ? `${a}\n\n${rest}` : a
795
- }
796
- }
797
- return `${a}\n\n${b}`
798
- }
799
-
800
- function sliceFrom(full, offset, maxChars) {
801
- const s = smartTruncate(full.fullText, maxChars, offset)
802
- const out = {
803
- url: full.url,
804
- title: full.title || '',
805
- siteName: full.siteName || hostOf(full.url),
806
- lang: full.lang || '',
807
- charset: full.charset,
808
- mode: full.mode,
809
- truncated: s.truncated,
810
- charsTotal: s.charsTotal,
811
- charsReturned: s.charsReturned,
812
- charsStart: s.charsStart,
813
- text: s.text,
814
- }
815
- if (full.rendered) out.rendered = true
816
- if (full.spaHint) out.spaHint = full.spaHint
817
- if (full.paginated > 1) out.paginated = full.paginated
818
- if (full.feedCount) out.feedCount = full.feedCount
819
- if (full.published) out.published = full.published
820
- if (full.author) out.author = full.author
821
- if (Array.isArray(full.links)) out.links = full.links
822
- return out
823
- }
824
-
825
- export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
826
- const url = String((args && args.url) || '').trim()
827
- if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
828
- const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
829
- const mode = args.mode === 'markdown' ? 'markdown' : 'text'
830
- const offset = Math.max(0, Number(args.offset) || 0)
831
-
832
- // Cache stores the FULL extracted text keyed by url+mode+includeLinks, so
833
- // continuation reads (offset) and different maxChars hit the same entry —
834
- // but a links-request must not be served from a links-less cached copy.
835
- // The key drops the URL fragment: #sections would otherwise split the cache.
836
- let cacheUrl = url
837
- try {
838
- const u = new URL(url)
839
- u.hash = ''
840
- cacheUrl = u.href
841
- } catch {
842
- /* keep raw url on parse failure */
843
- }
844
- const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
845
- const hit = cache.get(cacheKey)
846
- if (hit) {
847
- // Failed fetches are cached briefly so the model doesn't re-request a
848
- // broken URL in a loop (token + latency saver).
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
- }
856
- }
857
-
858
- const failWith = (error) => {
859
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
860
- cache.set(cacheKey, { time: Date.now(), error })
861
- return { error }
862
- }
863
-
864
- // Non-HTML payloads (JSON APIs, RSS/Atom feeds) are read natively: fetch,
865
- // dispatch by content-type / payload shape, render compact. Falls back to
866
- // the HTML pipeline for everything else.
867
- const dispatch = async () => {
868
- const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
869
- if (viaSeam) {
870
- const t = viaSeam.html.trim()
871
- if (t.startsWith('{') || t.startsWith('[')) {
872
- try {
873
- return { kind: 'json', text: JSON.stringify(JSON.parse(t), null, 1) }
874
- } catch { /* not JSON — keep HTML pipeline */ }
875
- }
876
- return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
877
- }
878
- const page = await fetchPage(url, externalSignal, cfg)
879
- if (page.error) return { error: page.error }
880
- const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
881
- const decoded = decodeBuffer(page.buffer, page.contentType)
882
- if (/json$/.test(ct)) {
883
- try {
884
- return { kind: 'json', text: JSON.stringify(JSON.parse(decoded.text), null, 1) }
885
- } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
886
- }
887
- if (/[+/]xml$/.test(ct)) {
888
- if (/<urlset[\s>]/i.test(decoded.text) || /<sitemapindex[\s>]/i.test(decoded.text)) {
889
- return { error: 'Unsupported content (XML sitemap)' }
890
- }
891
- if (/<rss[\s>]/i.test(decoded.text) || /<feed[\s>]/i.test(decoded.text)) {
892
- return { kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks) }
893
- }
894
- }
895
- return { kind: 'html', html: decoded.text, finalUrl: page.finalUrl || url, charset: decoded.charset }
896
- }
897
- const payload = await dispatch()
898
- if (payload.error) return failWith(payload.error)
899
-
900
- if (payload.kind === 'json') {
901
- const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
902
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
903
- cache.set(cacheKey, { time: Date.now(), full })
904
- return sliceFrom(full, offset, maxChars)
905
- }
906
- if (payload.kind === 'feed') {
907
- const f = payload.feed
908
- const full = {
909
- url, title: f.title || '', siteName: hostOf(url), lang: '', charset: 'feed',
910
- mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count,
911
- links: args.includeLinks === true ? f.items : undefined,
912
- }
913
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
914
- cache.set(cacheKey, { time: Date.now(), full })
915
- return sliceFrom(full, offset, maxChars)
916
- }
917
-
918
- let html = payload.html
919
- let finalUrl = payload.finalUrl || url
920
- const charset = payload.charset
921
-
922
- let extracted = extract(html, mode)
923
- // Optional readability upgrade: cleaner article extraction when installed.
924
- const ext = await getReadabilityExtractor()
925
- const upgrade = (target, htmlText) => {
926
- if (!ext) return target
927
- try {
928
- const clean = ext(htmlText, url)
929
- if (clean && clean.length > 0) {
930
- target.text = mode === 'markdown'
931
- ? blockMd(clean).replace(/\n{3,}/g, '\n\n').trim()
932
- : textOnly(clean).replace(/ +/g, ' ').trim()
933
- }
934
- } catch {
935
- // keep heuristic result
936
- }
937
- return target
938
- }
939
- extracted = upgrade(extracted, html)
940
-
941
- // Optional SPA enhancement: if the page looks client-rendered and static
942
- // extraction found almost nothing, try headless rendering (playwright).
943
- // Beyond script-heavy shells (looksLikeSpa, ≥5 <script> tags), a page whose
944
- // extraction is EMPTY but carries any <script> may be a JS-redirect shell
945
- // (measured: taptap.cn — 2 scripts, empty <body>, redirects via JS) — with
946
- // no text to lose, rendering is always worth one bounded attempt. Pages
947
- // with no scripts at all cannot change under rendering and are skipped.
948
- let rendered = false
949
- let spaHint = ''
950
- let renderedHtml = null
951
- const needsRender = !extracted.text || extracted.text.length < 200
952
- const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
953
- if (cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
954
- const rr = await renderPage(finalUrl || url, externalSignal)
955
- if (rr.html) {
956
- renderedHtml = rr.html
957
- // A challenge interstitial (Cloudflare "Just a moment...") must never
958
- // be accepted as an improvement — it can carry MORE text than the real
959
- // static body (measured: 259 chars of verification prose vs 135 chars
960
- // of real content on ruanyifeng.com) and would evict the latter.
961
- if (looksLikeChallenge(rr.html)) {
962
- renderedHtml = null // pagination/links must not read the interstitial DOM
963
- spaHint = extracted.text
964
- ? '已渲染但被人机验证页拦截,保留静态提取结果'
965
- : '已渲染但被人机验证页拦截(无静态内容可用)'
966
- } else {
967
- const r2 = upgrade(extract(rr.html, mode), rr.html)
968
- // Accept the rendered text when it meaningfully beats the static one.
969
- // From an EMPTY static extraction even a short body is a real gain
970
- // (measured: taptap-style JS shells render 30-60 chars of real content
971
- // that the fixed +50 margin would reject).
972
- const gain = r2.text.length - extracted.text.length
973
- const accept = r2.text && (gain > 50 || (!extracted.text && r2.text.length >= 20))
974
- if (accept) {
975
- extracted = r2
976
- finalUrl = rr.finalUrl || finalUrl
977
- rendered = true
978
- } else if (!extracted.text) {
979
- spaHint = '已渲染仍无可读内容(可能登录墙或反爬拦截)'
980
- }
981
- }
982
- } else {
983
- spaHint = rr.error
984
- }
985
- }
986
-
987
- // Auto-pagination: follow the "next page" chain (bounded, same-host, no
988
- // loops). Continuation pages go through the fast static path — a paginated
989
- // SPA chain would cost one full render per page, out of proportion.
990
- let paginated = 1
991
- const paginateMax = cfg.paginate === false ? 1 : Math.max(1, Math.min(10, Number(cfg.paginateMax) || 3))
992
- const startHost = hostOf(finalUrl || url)
993
- const seen = new Set([normalizeUrl(finalUrl || url)])
994
- let nextUrl = paginateMax > 1 ? findNextLink(renderedHtml || html, finalUrl || url) : null
995
- while (
996
- nextUrl && paginated < paginateMax &&
997
- sameHost(nextUrl, startHost) &&
998
- !(externalSignal && externalSignal.aborted)
999
- ) {
1000
- const nu = normalizeUrl(nextUrl)
1001
- if (!nu || seen.has(nu)) break
1002
- seen.add(nu)
1003
- const page = await fetchPage(nextUrl, externalSignal, cfg)
1004
- if (page.error) break
1005
- const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1006
- if (!/html|xhtml/.test(ct)) break
1007
- const nextHtml = decodeBuffer(page.buffer, page.contentType).text
1008
- const nextEx = extract(nextHtml, mode)
1009
- if (!nextEx.text || nextEx.text.length < 20) break
1010
- extracted.text = joinPageText(extracted.text, nextEx.text)
1011
- paginated++
1012
- nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1013
- }
1014
-
1015
- const full = {
1016
- url: finalUrl,
1017
- title: extracted.title || '',
1018
- siteName: extracted.siteName || '',
1019
- lang: extracted.lang || '',
1020
- charset,
1021
- mode,
1022
- fullText: extracted.text,
1023
- rendered,
1024
- spaHint,
1025
- paginated,
1026
- published: extracted.published || '',
1027
- author: extracted.author || '',
1028
- // Links come from the rendered DOM when available — otherwise the SPA
1029
- // chain (content -> links -> next page) would break for JS-only pages.
1030
- links: args.includeLinks === true ? extractLinks(renderedHtml || html, cfg.maxLinks, finalUrl) : undefined,
1031
- }
1032
-
1033
- if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
1034
- cache.set(cacheKey, { time: Date.now(), full })
1035
- return sliceFrom(full, offset, maxChars)
1036
- }
1037
-
1038
- function renderResult(value) {
1039
- if (typeof value === 'string') return value
1040
- const r = value || {}
1041
- if (r.error) return `Error: ${r.error}`
1042
- const lines = []
1043
- if (r.title) lines.push(`title: ${r.title}`)
1044
- const host = hostOf(r.url || '')
1045
- const meta = []
1046
- if (r.siteName && r.siteName !== host) meta.push(r.siteName)
1047
- if (r.lang) meta.push(r.lang)
1048
- if (r.charset && r.charset !== 'json' && r.charset !== 'feed') meta.push(`charset ${r.charset}`)
1049
- if (r.published) meta.push(r.published)
1050
- if (r.author) meta.push(`by ${r.author}`)
1051
- if (r.feedCount) meta.push(`feed · ${r.feedCount} 条`)
1052
- if (r.mode === 'json') meta.push('json')
1053
- if (meta.length) lines.push(meta.join(' · '))
1054
- if (r.charsStart > 0) {
1055
- lines.push(`(chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal} — offset 续读)`)
1056
- } else if (r.truncated) {
1057
- lines.push(`(chars ${r.charsReturned}/${r.charsTotal} — 截断,offset 续读)`)
1058
- } else if (typeof r.charsTotal === 'number') {
1059
- lines.push(`(chars ${r.charsTotal})`)
1060
- }
1061
- if (r.cached) lines.push('(cached)')
1062
- if (r.paginated > 1) lines.push(`(已自动拼接 ${r.paginated} 页)`)
1063
- if (r.rendered) lines.push('(rendered — JS 执行后内容)')
1064
- if (!r.text) {
1065
- if (r.charsStart > 0 && !r.truncated) {
1066
- lines.push('(offset 已到文末,无更多内容)')
1067
- } else {
1068
- lines.push(r.spaHint
1069
- ? `(无可读内容 — ${r.spaHint})`
1070
- : '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
1071
- }
1072
- }
1073
- lines.push('', r.text || '')
1074
- if (Array.isArray(r.links) && r.links.length) {
1075
- lines.push('', 'links:')
1076
- for (const l of r.links) lines.push(`- ${l.title} — ${l.url}`)
1077
- }
1078
- return lines.join('\n')
1079
- }
1080
-
1081
- function renderLinks(value) {
1082
- if (typeof value === 'string') return value
1083
- const r = value || {}
1084
- if (r.error) return `Error: ${r.error}`
1085
- if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}`
1086
- const lines = [`${r.count} link(s) on ${r.url}:`]
1087
- for (const l of r.links) lines.push(`- ${l.title || l.url} — ${l.url}`)
1088
- return lines.join('\n')
1089
- }
1090
-
1091
- function readLinksTool(ctx, cfg) {
1092
- return {
1093
- name: 'read_url_links',
1094
- description:
1095
- 'List links (text + URL) on a page without body text — lighter than read_url ' +
1096
- 'for mapping what a page points to. Renders SPA pages when playwright installed.',
1097
- parameters: {
1098
- type: 'object',
1099
- additionalProperties: false,
1100
- properties: {
1101
- url: { type: 'string', description: 'http(s) URL to scan for links' },
1102
- limit: { type: 'number', description: 'Links to return (1-50; default from plugin config)' },
1103
- },
1104
- required: ['url'],
1105
- },
1106
- // Covers SPA-render fallback: full fetch timeout + playwright render cap.
1107
- timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
1108
- output: {
1109
- schema: {
1110
- type: 'object',
1111
- additionalProperties: true,
1112
- properties: {
1113
- url: { type: 'string' },
1114
- count: { type: 'number' },
1115
- links: { type: 'array', items: { type: 'object' } },
1116
- error: { type: 'string' },
1117
- },
1118
- },
1119
- render: (_args, value) => [{ type: 'text', text: renderLinks(value) }],
1120
- },
1121
- async execute(args, exec) {
1122
- const url = String((args && args.url) || '').trim()
1123
- if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1124
- const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
1125
- const viaSeam = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg)
1126
- let html, finalUrl
1127
- if (viaSeam) {
1128
- html = viaSeam.html
1129
- finalUrl = viaSeam.finalUrl
1130
- } else {
1131
- const page = await fetchPage(url, exec && exec.signal, cfg)
1132
- if (page.error) return { error: page.error }
1133
- html = decodeBuffer(page.buffer, page.contentType).text
1134
- finalUrl = page.finalUrl || url
1135
- }
1136
- let links = extractLinks(html, limit, finalUrl)
1137
- // SPA fallback: a JS-only page yields no links from its static HTML;
1138
- // render it and re-extract when the static result looks empty. Any
1139
- // <script> counts as renderable when there are no links at all — the
1140
- // static shell may be a JS-redirect (same rationale as read_url).
1141
- if (links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))) {
1142
- const rr = await renderPage(finalUrl || url, exec && exec.signal)
1143
- // A challenge interstitial's links are the challenge's own scripts —
1144
- // never an improvement over whatever the static HTML offered.
1145
- if (rr.html && !looksLikeChallenge(rr.html)) {
1146
- const rl = extractLinks(rr.html, limit, rr.finalUrl || finalUrl)
1147
- if (rl.length > links.length) {
1148
- links = rl
1149
- finalUrl = rr.finalUrl || finalUrl
1150
- }
1151
- }
1152
- }
1153
- return { url: finalUrl, count: links.length, links }
1154
- },
1155
- }
1156
- }
1157
-
1158
- // Run async tasks with a concurrency cap (avoids hammering a site / getting
1159
- // rate-limited when reading many URLs at once).
1160
- async function mapLimit(items, limit, fn) {
1161
- const out = new Array(items.length)
1162
- let idx = 0
1163
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1164
- while (idx < items.length) {
1165
- const i = idx++
1166
- out[i] = await fn(items[i], i)
1167
- }
1168
- })
1169
- await Promise.all(workers)
1170
- return out
1171
- }
1172
-
1173
- function renderBatch(value) {
1174
- if (typeof value === 'string') return value
1175
- const v = value || {}
1176
- if (v.error) return `Error: ${v.error}`
1177
- const lines = [`读取 ${v.succeeded}/${v.total} 页成功${v.failed ? `,${v.failed} 页失败` : ''}`]
1178
- for (const p of v.pages) {
1179
- if (p.error) {
1180
- lines.push('', `[失败] ${p.url} — ${p.error}`)
1181
- continue
1182
- }
1183
- const head = p.title || p.url
1184
- lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
1185
- if (Array.isArray(p.links) && p.links.length) {
1186
- const ls = p.links.map((l) => l.title + ' — ' + l.url)
1187
- lines.push(`links: ${ls.slice(0, 6).join(' | ')}${ls.length > 6 ? ` …共${ls.length}个` : ''}`)
1188
- }
1189
- }
1190
- return lines.join('\n')
1191
- }
1192
-
1193
- function readUrlBatchTool(ctx, cfg) {
1194
- return {
1195
- name: 'read_url_batch',
1196
- description:
1197
- 'Read multiple URLs in parallel: each a compact clean block (same extraction + cache as read_url). ' +
1198
- 'Failures isolated and tagged. Max 10 URLs, capped at maxChars per page.',
1199
- parameters: {
1200
- type: 'object',
1201
- additionalProperties: false,
1202
- properties: {
1203
- urls: { type: 'array', items: { type: 'string' }, description: 'URLs to read in parallel (1-10, http/s)' },
1204
- maxChars: { type: 'number', description: 'Chars per page (500-20000; default 3000)' },
1205
- mode: { type: 'string', enum: ['text', 'markdown'], description: 'text = plain (token-cheap, default); markdown = structured' },
1206
- includeLinks: { type: 'boolean', description: 'Return page links too (title+url, bounded)' },
1207
- },
1208
- required: ['urls'],
1209
- },
1210
- // Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
1211
- timeoutMs: Math.max(60000, cfg.timeoutMs * 3 + 135000),
1212
- output: {
1213
- schema: {
1214
- type: 'object',
1215
- additionalProperties: true,
1216
- properties: {
1217
- total: { type: 'number' },
1218
- succeeded: { type: 'number' },
1219
- failed: { type: 'number' },
1220
- pages: { type: 'array', items: { type: 'object' } },
1221
- error: { type: 'string' },
1222
- },
1223
- },
1224
- render: (_args, value) => [{ type: 'text', text: renderBatch(value) }],
1225
- },
1226
- async execute(args, exec) {
1227
- const a = args || {}
1228
- const urls = (Array.isArray(a.urls) ? a.urls : []).map((u) => String(u).trim()).filter(Boolean)
1229
- if (!urls.length) return { error: 'urls array is required (1-10 http(s) URLs)' }
1230
- const list = urls.slice(0, 10)
1231
- const perMax = Math.max(500, Math.min(20000, Number(a.maxChars) || 3000))
1232
- const mode = a.mode === 'markdown' ? 'markdown' : 'text'
1233
- const signal = exec && exec.signal
1234
- const pages = await mapLimit(list, 4, (u) =>
1235
- readUrl({ url: u, maxChars: perMax, mode, includeLinks: a.includeLinks === true }, ctx, signal, cfg),
1236
- )
1237
- const ok = pages.filter((p) => !p.error)
1238
- return {
1239
- total: list.length,
1240
- succeeded: ok.length,
1241
- failed: list.length - ok.length,
1242
- pages: pages.map((p, i) => {
1243
- if (p.error) return { url: list[i], error: p.error }
1244
- const out = {
1245
- url: p.url,
1246
- title: p.title || '',
1247
- chars: p.charsReturned,
1248
- cached: p.cached === true,
1249
- text: p.text,
1250
- }
1251
- if (Array.isArray(p.links) && p.links.length) out.links = p.links
1252
- return out
1253
- }),
1254
- }
1255
- },
1256
- }
1257
- }
1258
-
1259
- function normalizeUrl(url) {
1260
- try {
1261
- const u = new URL(url)
1262
- u.hash = ''
1263
- return u.href
1264
- } catch {
1265
- return null
1266
- }
1267
- }
1268
-
1269
- // b is a bare hostname (hostOf() output) — must NOT be passed to new URL()
1270
- // as a full URL (that throws and would make every link look external).
1271
- function sameHost(a, b) {
1272
- try {
1273
- return new URL(a).hostname === b
1274
- } catch {
1275
- return false
1276
- }
1277
- }
1278
-
1279
- // URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
1280
- 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
1281
- const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
1282
- function isNoiseUrl(url) {
1283
- return NOISE_EXT.test(url) || NOISE_PATH.test(url)
1284
- }
1285
-
1286
- // BFS crawl of a single site. Only same-host http(s) pages are followed;
1287
- // static assets / auth paths are skipped; visited URLs are deduped; a per-page
1288
- // failure is recorded and does not abort the crawl. No SPA rendering here —
1289
- // that is read_url's job; crawling favors speed and breadth.
1290
- async function crawlSite(entryUrl, cfg, opts, externalSignal) {
1291
- const { maxPages, maxDepth, includeContent, perMax } = opts
1292
- const host = hostOf(entryUrl)
1293
- const visited = new Set()
1294
- const pages = []
1295
- const failures = []
1296
- const queue = [{ url: entryUrl, depth: 0 }]
1297
- // Stop once the host's cooperative signal fires (tool budget hit): without
1298
- // this the loop keeps draining the queue on instantly-aborted fetches.
1299
- while (queue.length && pages.length < maxPages && !(externalSignal && externalSignal.aborted)) {
1300
- // Never overshoot the page budget within a batch round.
1301
- const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
1302
- const results = await Promise.all(
1303
- batch.map(async ({ url, depth }) => {
1304
- const norm = normalizeUrl(url)
1305
- if (!norm || visited.has(norm)) return null
1306
- visited.add(norm)
1307
- const page = await fetchPage(url, externalSignal, cfg)
1308
- if (page.error) return { url, depth, error: page.error }
1309
- const html = decodeBuffer(page.buffer, page.contentType).text
1310
- const ex = extract(html, 'text')
1311
- const links = extractLinks(html, 100, page.finalUrl || url)
1312
- return {
1313
- url: page.finalUrl || url,
1314
- depth,
1315
- title: ex.title || '',
1316
- chars: ex.text.length,
1317
- text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
1318
- links,
1319
- }
1320
- }),
1321
- )
1322
- for (const r of results) {
1323
- if (!r) continue
1324
- if (r.error) {
1325
- failures.push({ url: r.url, error: r.error })
1326
- continue
1327
- }
1328
- pages.push(r)
1329
- if (r.depth + 1 <= maxDepth) {
1330
- // Queue cap: a huge site (millions of links) would otherwise grow the
1331
- // queue unboundedly while maxPages bounds only what we *process*.
1332
- if (queue.length >= maxPages * 20) continue
1333
- for (const l of r.links) {
1334
- if (!sameHost(l.url, host) || isNoiseUrl(l.url)) continue
1335
- const n = normalizeUrl(l.url)
1336
- if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1 })
1337
- }
1338
- }
1339
- }
1340
- }
1341
- return { host, pages, failures }
1342
- }
1343
-
1344
- function renderSite(value) {
1345
- if (typeof value === 'string') return value
1346
- const v = value || {}
1347
- if (v.error) return `Error: ${v.error}`
1348
- const lines = [`站点: ${v.host} · 爬取 ${v.succeeded}/${v.total} 页${v.failed ? ` · ${v.failed} 页失败` : ''}`]
1349
- for (const p of v.pages) {
1350
- const indent = ' '.repeat(p.depth)
1351
- const head = p.title || p.url
1352
- lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
1353
- if (p.text) lines.push(`${indent} ${p.text.slice(0, 80)}`)
1354
- }
1355
- for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
1356
- return lines.join('\n')
1357
- }
1358
-
1359
- function readUrlSiteTool(ctx, cfg) {
1360
- return {
1361
- name: 'read_url_site',
1362
- description:
1363
- 'Crawl a site BFS from one URL: dedupe same-host pages, return a compact site map ' +
1364
- '(title + depth + size). Auth/static paths skipped; failures isolated. ' +
1365
- 'includeContent=true attaches a short summary per page (default off, token-cheap). ' +
1366
- 'Does not render SPA pages (use read_url for that).',
1367
- parameters: {
1368
- type: 'object',
1369
- additionalProperties: false,
1370
- properties: {
1371
- url: { type: 'string', description: 'Entry URL of the site to crawl (http/s)' },
1372
- maxPages: { type: 'number', description: 'Pages to crawl (2-50; default 15)' },
1373
- maxDepth: { type: 'number', description: 'Link depth from entry (1-5; default 2)' },
1374
- includeContent: { type: 'boolean', description: 'Short body summary per page (default off; structure-only is token-cheap)' },
1375
- maxCharsPerPage: { type: 'number', description: 'Summary chars per page (200-2000; default 500)' },
1376
- },
1377
- required: ['url'],
1378
- },
1379
- // Worst case: ceil(maxPages/2)=25 waves of paired fetches (no SPA render
1380
- // here); the cooperative signal stops the loop early once the budget is hit.
1381
- timeoutMs: Math.max(120000, cfg.timeoutMs * 25),
1382
- output: {
1383
- schema: {
1384
- type: 'object',
1385
- additionalProperties: true,
1386
- properties: {
1387
- host: { type: 'string' },
1388
- total: { type: 'number' },
1389
- succeeded: { type: 'number' },
1390
- failed: { type: 'number' },
1391
- pages: { type: 'array', items: { type: 'object' } },
1392
- failures: { type: 'array', items: { type: 'object' } },
1393
- error: { type: 'string' },
1394
- },
1395
- },
1396
- render: (_args, value) => [{ type: 'text', text: renderSite(value) }],
1397
- },
1398
- async execute(args, exec) {
1399
- const url = String((args && args.url) || '').trim()
1400
- if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1401
- const maxPages = Math.max(2, Math.min(50, Number(args.maxPages) || 15))
1402
- const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
1403
- const includeContent = args.includeContent === true
1404
- const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
1405
- const { host, pages, failures } = await crawlSite(
1406
- url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal,
1407
- )
1408
- return {
1409
- host,
1410
- total: pages.length + failures.length,
1411
- succeeded: pages.length,
1412
- failed: failures.length,
1413
- pages: pages.map((p) => {
1414
- const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
1415
- if (p.text) o.text = p.text
1416
- return o
1417
- }),
1418
- failures,
1419
- }
1420
- },
1421
- }
1422
- }
1423
-
1424
- export function apply(ctx, config) {
1425
- // Plugin-level config from cordis.patch.yml (config row), merged over defaults.
1426
- // YAML is free-form: numbers can arrive quoted as strings, which would poison
1427
- // arithmetic and AbortSignal timeouts downstream — coerce to numbers once
1428
- // here, clamp to sane ranges, and fall back to defaults on garbage.
1429
- const cfg = { ...DEFAULTS, ...(config || {}) }
1430
- const clamp = (v, d, min, max) => {
1431
- const n = Number(v)
1432
- return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
1433
- }
1434
- cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
1435
- cfg.maxBytes = clamp(cfg.maxBytes, DEFAULTS.maxBytes, 65536, 16 * 1024 * 1024)
1436
- cfg.maxChars = clamp(cfg.maxChars, DEFAULTS.maxChars, 500, 20000)
1437
- cfg.maxLinks = clamp(cfg.maxLinks, DEFAULTS.maxLinks, 1, 50)
1438
- cfg.cacheTtlMs = clamp(cfg.cacheTtlMs, DEFAULTS.cacheTtlMs, 0, 3600000)
1439
- cfg.cacheMax = clamp(cfg.cacheMax, DEFAULTS.cacheMax, 1, 256)
1440
- cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
1441
- if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
1442
- if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
1443
- if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
1444
-
1445
- // Temporal composability: unload must fully revert side effects.
1446
- ctx.effect(() => () => {
1447
- cache.clear()
1448
- closeBrowser().catch(() => {})
1449
- })
1450
-
1451
- console.log('[dsh-read-url] plugin loaded; tools read_url, read_url_batch, read_url_links, read_url_site registered')
1452
-
1453
- ctx.tools.register({
1454
- name: 'read_url',
1455
- description:
1456
- 'Fetch a page and return its clean main content (auto charset detect). ' +
1457
- 'text (default) = plain body capped at maxChars; markdown = headings/links/tables. ' +
1458
- 'Compact block: title, metadata, truncated body; 5-min session cache; ' +
1459
- 'SPA pages render when playwright installed; login walls are not accessible.',
1460
- parameters: {
1461
- type: 'object',
1462
- additionalProperties: false,
1463
- properties: {
1464
- url: { type: 'string', description: 'http(s) URL to read' },
1465
- maxChars: { type: 'number', description: 'Body text chars to return (500-20000; default from plugin config)' },
1466
- offset: { type: 'number', description: 'Start at this char offset (continue long pages). Default 0; served from cache.' },
1467
- mode: { type: 'string', enum: ['text', 'markdown'], description: 'text = plain (token-cheap, default); markdown = structured' },
1468
- includeLinks: { type: 'boolean', description: 'Return page links too (title+url, bounded)' },
1469
- },
1470
- required: ['url'],
1471
- },
1472
- // Pipeline budget must cover the real worst case: first page fetch +
1473
- // SPA render (goto 30s cap + DOM-stability poll 10s) + up to paginateMax
1474
- // continuation fetches (static, no render). Derived from load-time cfg —
1475
- // scale with it, never clamp it.
1476
- timeoutMs: Math.max(45000, cfg.timeoutMs * (cfg.paginate === false ? 1 : cfg.paginateMax + 1) + 45000),
1477
- output: {
1478
- schema: {
1479
- type: 'object',
1480
- additionalProperties: true,
1481
- properties: {
1482
- url: { type: 'string' },
1483
- title: { type: 'string' },
1484
- siteName: { type: 'string' },
1485
- lang: { type: 'string' },
1486
- charset: { type: 'string' },
1487
- mode: { type: 'string' },
1488
- truncated: { type: 'boolean' },
1489
- charsTotal: { type: 'number' },
1490
- charsReturned: { type: 'number' },
1491
- text: { type: 'string' },
1492
- links: { type: 'array', items: { type: 'object' } },
1493
- cached: { type: 'boolean' },
1494
- error: { type: 'string' },
1495
- },
1496
- },
1497
- render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
1498
- },
1499
- async execute(args, exec) {
1500
- return readUrl(args, ctx, exec && exec.signal, cfg)
1501
- },
1502
- })
1503
-
1504
- ctx.tools.register(readLinksTool(ctx, cfg))
1505
- ctx.tools.register(readUrlBatchTool(ctx, cfg))
1506
- ctx.tools.register(readUrlSiteTool(ctx, cfg))
1507
- }
1
+ // dsh-read-url — DeepSeek Harness URL reader plugin
2
+ // Zero external dependencies (Node 20+ built-ins only).
3
+ // Aligned with DSH architecture:
4
+ // - all network access goes through the ctx.web capability seam (official
5
+ // docs/capability-seams.md), falling back to global fetch when absent
6
+ // - cache is registered under ctx.effect, so unload fully reverts it
7
+ // (temporal composability, docs/cordis-primer.md)
8
+ // - cooperative tool-call timeout via ToolDefinition.timeoutMs + exec.signal
9
+ // Focus: token economy + smarter extraction for DSH agents.
10
+ // - charset auto-detect (BOM UTF-16 / GBK/GB2312/UTF-8/Big5/Shift-JIS)
11
+ // - content dispatch: HTML / JSON APIs / RSS-Atom feeds natively
12
+ // - clean main-content extraction (heuristic; optional @mozilla/readability upgrade path)
13
+ // + text-density fallback on the body path
14
+ // - auto-joined pagination, page metadata (published/author)
15
+ // - HTML -> Markdown / plain text, paragraph-aligned smart truncation
16
+ // - session-level cache, compact text render (lowest token cost)
17
+ import { TextDecoder } from 'node:util'
18
+ import { looksLikeSpa, renderPage, closeBrowser, looksLikeChallenge } from './spa.js'
19
+ import { detectProxy, fetchViaCurlProxy, looksBinary } from './proxy-fallback.js'
20
+ // Re-export for tests / programmatic (PTC) cleanup.
21
+ export { closeBrowser, renderPage, looksLikeSpa, looksLikeChallenge } from './spa.js'
22
+
23
+ export const name = 'dsh-read-url'
24
+ export const inject = ['tools']
25
+
26
+ // Plugin-level configuration (overridable via cordis.patch.yml `config:` row).
27
+ const DEFAULTS = {
28
+ timeoutMs: 15000,
29
+ maxBytes: 3 * 1024 * 1024,
30
+ maxChars: 6000,
31
+ maxLinks: 20,
32
+ cacheTtlMs: 300000,
33
+ cacheMax: 32,
34
+ spaRender: true,
35
+ paginate: true,
36
+ paginateMax: 3,
37
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
38
+ }
39
+
40
+ // Session-level cache: url -> { time, full }. Failures live in a separate
41
+ // small map so 30 s error entries never evict hot success pages from the
42
+ // main cache (they used to share the FIFO budget).
43
+ const cache = new Map()
44
+ const failCache = new Map()
45
+ const FAIL_CACHE_MAX = 8
46
+ function cacheFail(key, error) {
47
+ if (failCache.size >= FAIL_CACHE_MAX) failCache.delete(failCache.keys().next().value)
48
+ failCache.set(key, { time: Date.now(), error })
49
+ }
50
+ function cacheStore(key, full, cacheMax) {
51
+ if (cache.size >= cacheMax) cache.delete(cache.keys().next().value)
52
+ cache.set(key, { time: Date.now(), full })
53
+ }
54
+ const decoders = new Map()
55
+
56
+ function getDecoder(enc) {
57
+ let d = decoders.get(enc)
58
+ if (!d) {
59
+ try {
60
+ d = new TextDecoder(enc, { fatal: false })
61
+ } catch {
62
+ d = new TextDecoder('utf-8')
63
+ }
64
+ decoders.set(enc, d)
65
+ }
66
+ return d
67
+ }
68
+
69
+ function sniffCharset(buffer, contentType) {
70
+ // BOM first — byte-level evidence beats any declared charset (a UTF-16 body
71
+ // with a latin1-read <meta> probe garbles the meta match anyway).
72
+ if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
73
+ if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le'
74
+ if (buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be'
75
+ const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
76
+ if (m) return m[1]
77
+ const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
78
+ const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)
79
+ if (meta) return meta[1]
80
+ return null
81
+ }
82
+
83
+ export function decodeBuffer(buffer, contentType) {
84
+ let enc = sniffCharset(buffer, contentType)
85
+ if (!enc) enc = 'utf-8'
86
+ enc = enc.toLowerCase().replace('gb2312', 'gbk').replace('gb_2312', 'gbk')
87
+ let text
88
+ try {
89
+ text = getDecoder(enc).decode(buffer)
90
+ } catch {
91
+ text = new TextDecoder('utf-8').decode(buffer)
92
+ }
93
+ if (enc !== 'utf-8' && enc !== 'utf8' && /\uFFFD/.test(text.slice(0, 2000))) {
94
+ const fixed = new TextDecoder('utf-8').decode(buffer)
95
+ if (!/\uFFFD/.test(fixed.slice(0, 2000))) return { text: fixed, charset: 'utf-8' }
96
+ }
97
+ return { text, charset: enc }
98
+ }
99
+
100
+ // Content types this plugin can consume. Beyond HTML: JSON (data APIs) and
101
+ // XML (RSS/Atom feeds) are read natively — see readUrl's type dispatch.
102
+ // Note "application/rss+xml": the separator before xml is '+', not '/'.
103
+ const FETCHABLE_CT = /text\/html|application\/xhtml|text\/plain|\/json|[+/]xml/i
104
+
105
+ // Retry-After header (seconds form) → ms, capped; undefined when absent/invalid.
106
+ function retryAfterMs(res) {
107
+ const v = res.headers.get('retry-after')
108
+ if (!v) return undefined
109
+ const sec = Number(v)
110
+ if (!Number.isFinite(sec) || sec < 0) return undefined
111
+ return Math.min(sec * 1000, 5000)
112
+ }
113
+
114
+ // Single fetch attempt. Never throws; maps abort/timeout/network to { error }.
115
+ async function directFetchOnce(url, signal, cfg) {
116
+ try {
117
+ const res = await fetch(url, {
118
+ redirect: 'follow',
119
+ signal,
120
+ headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
121
+ })
122
+ // Early-return paths must cancel the body stream, or the connection is
123
+ // held open undrained and cannot return to the keep-alive pool.
124
+ const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
125
+ if (!res.ok) {
126
+ // Rate-limit / temporary-overload: honor Retry-After with one retry
127
+ // (capped at 5s so the cooperative timeout still bounds the call).
128
+ if (res.status === 429 || res.status === 503) {
129
+ const ra = retryAfterMs(res)
130
+ discardBody()
131
+ if (ra !== undefined) return { retryAfterMs: ra }
132
+ }
133
+ discardBody()
134
+ return { error: `HTTP ${res.status} ${res.statusText}` }
135
+ }
136
+ const contentType = res.headers.get('content-type') || ''
137
+ if (contentType && !FETCHABLE_CT.test(contentType)) {
138
+ discardBody()
139
+ return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
140
+ }
141
+ if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url }
142
+ const chunks = []
143
+ let size = 0
144
+ for await (const chunk of res.body) {
145
+ size += chunk.length
146
+ if (size > cfg.maxBytes) { discardBody(); return { error: `Page exceeds ${cfg.maxBytes} bytes` } }
147
+ chunks.push(chunk)
148
+ }
149
+ const buffer = Buffer.concat(chunks)
150
+ // Headerless responses: gate binary bodies out of the HTML pipeline.
151
+ if (!contentType && looksBinary(buffer)) {
152
+ return { error: 'Unsupported content-type (no header, binary body)' }
153
+ }
154
+ return { buffer, contentType, finalUrl: res.url }
155
+ } catch (e) {
156
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
157
+ return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
158
+ }
159
+ // undici wraps the real reason in e.cause (ENOTFOUND / ECONNREFUSED /
160
+ // connect-timeout / TLS) — surface it so the model can tell "bad domain"
161
+ // from "blocked network" and act accordingly instead of retrying blindly
162
+ const cause = e.cause && e.cause.message ? String(e.cause.message).slice(0, 90) : ''
163
+ return { error: cause || (e.message || '').slice(0, 120) }
164
+ }
165
+ }
166
+
167
+ // Direct-connect fetch with a single Retry-After-aware retry on 429/503.
168
+ async function directFetch(url, signal, cfg) {
169
+ const first = await directFetchOnce(url, signal, cfg)
170
+ if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
171
+ await new Promise((resolve) => {
172
+ const t = setTimeout(resolve, first.retryAfterMs)
173
+ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
174
+ })
175
+ return directFetchOnce(url, signal, cfg)
176
+ }
177
+ return first
178
+ }
179
+
180
+ // Settle-all race that resolves with the FIRST SUCCESSFUL result (a value
181
+ // with a buffer and no error). If every attempt fails it resolves with
182
+ // { success:false, failures:[...] } once the slowest one has settled —
183
+ // the proxy channel (curl --max-time ~20s) is the natural ceiling, and the
184
+ // direct fetch aborts on the same total budget.
185
+ export function raceFirstSuccess(promises) {
186
+ return new Promise((resolve) => {
187
+ if (promises.length === 0) {
188
+ resolve({ success: false, failures: [] })
189
+ return
190
+ }
191
+ const failures = []
192
+ let settled = 0
193
+ promises.forEach((p, i) => {
194
+ p.then((v) => {
195
+ if (v && !v.error && v.buffer) {
196
+ resolve({ success: true, value: v })
197
+ return
198
+ }
199
+ failures[i] = v
200
+ settled += 1
201
+ if (settled === promises.length) resolve({ success: false, failures })
202
+ }).catch((e) => {
203
+ failures[i] = { error: String((e && e.message) || e) }
204
+ settled += 1
205
+ if (settled === promises.length) resolve({ success: false, failures })
206
+ })
207
+ })
208
+ })
209
+ }
210
+ async function raceFetch(url, externalSignal, cfg, proxy) {
211
+ const directCtrl = new AbortController()
212
+ const proxyCtrl = new AbortController()
213
+ const withExt = (ctrl) =>
214
+ externalSignal ? AbortSignal.any([externalSignal, ctrl.signal]) : ctrl.signal
215
+ const directSignal = AbortSignal.any([withExt(directCtrl), AbortSignal.timeout(cfg.timeoutMs)])
216
+ const direct = directFetch(url, directSignal, cfg)
217
+ const proxied = fetchViaCurlProxy(url, cfg, withExt(proxyCtrl), proxy)
218
+ const winner = await raceFirstSuccess([direct, proxied])
219
+ directCtrl.abort()
220
+ proxyCtrl.abort()
221
+ if (!winner.success) {
222
+ // Attach channel labels where the attempt order is defined, so callers
223
+ // never have to guess which slot is the proxy error.
224
+ const labels = ['direct', 'proxy']
225
+ winner.failures = winner.failures.map((f, i) => ({
226
+ label: labels[i],
227
+ error: f && f.error ? String(f.error) : 'unknown error',
228
+ }))
229
+ }
230
+ return winner
231
+ }
232
+
233
+ async function fetchPage(url, externalSignal, cfg) {
234
+ const proxy = await detectProxy()
235
+ if (proxy) {
236
+ const winner = await raceFetch(url, externalSignal, cfg, proxy)
237
+ if (winner.success) return winner.value
238
+ const dErr = winner.failures.find((f) => f.label === 'direct')
239
+ const pErr = winner.failures.find((f) => f.label === 'proxy')
240
+ const px = pErr ? `,代理返回 ${pErr.error}` : ',代理未连通'
241
+ return { error: `Fetch failed: ${dErr ? dErr.error : 'unknown error'}(直连失败;已尝试代理 ${proxy}${px})` }
242
+ }
243
+ // No proxy configured: plain direct connect (the original behaviour).
244
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
245
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
246
+ const r = await directFetch(url, signal, cfg)
247
+ if (r.error) return { error: `Fetch failed: ${r.error}(直连失败,未检测到代理配置)` }
248
+ return r
249
+ }
250
+
251
+ // Prefer the ctx.web capability seam (official docs/capability-seams.md):
252
+ // the web provider already decodes the document, so we skip our charset
253
+ // layer and only re-extract. Falls back to global fetch when absent.
254
+ // Seam calls get the same cooperative timeout as fetchPage (a hanging
255
+ // provider should not block the tool call forever).
256
+ async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
257
+ try {
258
+ // ctx.get for a non-injected service throws on strict cordis hosts (seen
259
+ // with 'settings') — treat that as "seam absent" and fall through.
260
+ const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
261
+ if (!web || typeof web.fetch !== 'function') return null
262
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
263
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
264
+ const res = await web.fetch(url, { signal })
265
+ if (!res || typeof res.content !== 'string') return null
266
+ const finalUrl = res.url || res.finalUrl || url
267
+ return { html: res.content, finalUrl }
268
+ } catch {
269
+ return null
270
+ }
271
+ }
272
+
273
+ // Decode common HTML entities in already-stripped text. Runs AFTER tag
274
+ // stripping so escaped tags stay visible to the stripper and only remaining
275
+ // text is decoded. Covers the numeric/hex forms plus the named entities that
276
+ // actually show up in CJK web text (spaces, dashes, quotes, symbols) —
277
+ // undecoded leftovers would both waste tokens and read as garbage.
278
+ const ENTITIES = {
279
+ nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '',
280
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
281
+ ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
282
+ lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201C', rdquo: '\u201D',
283
+ laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™',
284
+ deg: '°', plusmn: '±', times: '×', divide: '÷', micro: 'µ',
285
+ sect: '§', para: '¶', dagger: '†', prime: '′', Prime: '″',
286
+ permil: '‰', euro: '€', pound: '£', yen: '¥', cent: '¢',
287
+ sup2: '²', sup3: '³', frac12: '½', frac14: '¼', frac34: '¾',
288
+ }
289
+ export function decodeTextEntities(text) {
290
+ if (!text.includes('&')) return text
291
+ return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|([a-z][a-z0-9]{1,7}));?/gi, (m, dec, hex, name) => {
292
+ if (dec !== undefined) {
293
+ const c = Number(dec)
294
+ return c > 0x10ffff ? m : String.fromCodePoint(c)
295
+ }
296
+ if (hex !== undefined) {
297
+ const c = Number.parseInt(hex, 16)
298
+ return c > 0x10ffff ? m : String.fromCodePoint(c)
299
+ }
300
+ const n = (name || '').toLowerCase()
301
+ return Object.prototype.hasOwnProperty.call(ENTITIES, n) ? ENTITIES[n] : m
302
+ })
303
+ }
304
+
305
+ // Quadratic-cost guard: a '<' that has NO '>' after it can never start a real
306
+ // tag, yet every "<...>"-style regex rescans to the end of the string at each
307
+ // such position (measured: 10k stray '<'s in 60KB → seconds; a 3MB page would
308
+ // stall the tool call). Neutralize stray '<' in the no-'>' tail — text content
309
+ // is preserved, only the impossible tag-openings go away. Short tails
310
+ // ("a < b" in prose) are left untouched.
311
+ function defuseLt(html) {
312
+ const from = html.lastIndexOf('>') + 1
313
+ if (html.length - from < 512 || !html.includes('<', from)) return html
314
+ return html.slice(0, from) + html.slice(from).replace(/</g, ' ')
315
+ }
316
+
317
+ function textOnly(html) {
318
+ return decodeTextEntities(defuseLt(html)
319
+ .replace(/<!--[\s\S]*?-->/g, ' ')
320
+ .replace(/<script[\s>][\s\S]*?<\/script>/gi, ' ')
321
+ .replace(/<style[\s>][\s\S]*?<\/style>/gi, ' ')
322
+ .replace(/<textarea[\s>][\s\S]*?<\/textarea\s*>/gi, ' ')
323
+ .replace(/<noscript[\s>][\s\S]*?<\/noscript>/gi, ' ')
324
+ .replace(/<[^>]{1,1000}>/g, ' ')
325
+ .replace(/[ \t\r\n]+/g, ' ')
326
+ .trim())
327
+ }
328
+
329
+ // ---- JSON / RSS / Atom content dispatch ----
330
+ // A URL is not always an HTML page: data APIs answer JSON and content sources
331
+ // answer feeds. Both are model-readable when compacted, so read_url handles
332
+ // them natively instead of erroring with "Unsupported content-type".
333
+
334
+ function xmlText(s) {
335
+ let t = String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
336
+ // Feeds double-escape HTML in descriptions (&lt;a href=…&gt;查看全文&lt;/a&gt;):
337
+ // one strip-then-decode pass leaves literal tags in the rendered text
338
+ // (measured on sspai.com/feed). Iterate strip → decode until stable.
339
+ for (let i = 0; i < 3; i++) {
340
+ const prev = t
341
+ t = decodeTextEntities(t.replace(/<[^>]{1,1000}>/g, ' '))
342
+ if (t === prev) break
343
+ }
344
+ return t.replace(/\s+/g, ' ').trim()
345
+ }
346
+
347
+ // RSS 2.0 <item> / Atom <entry> → compact list "title — url\n summary".
348
+ function parseFeed(xml, limit) {
349
+ xml = defuseLt(xml) // no-'>' tail would make the lazy item scan quadratic
350
+ const isAtom = /<feed[\s>]/i.test(xml)
351
+ const itemRe = isAtom ? /<entry[\s>][\s\S]*?<\/entry\s*>/gi : /<item[\s>][\s\S]*?<\/item\s*>/gi
352
+ // No-closer guard: unclosed items would rescan the rest of the feed from
353
+ // every <item> position.
354
+ const closable = isAtom ? /<\/entry\s*>/i.test(xml) : /<\/item\s*>/i.test(xml)
355
+ const items = []
356
+ let m
357
+ while (closable && (m = itemRe.exec(xml)) && items.length < limit) {
358
+ const it = m[0]
359
+ const title = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(it)?.[1] || '')
360
+ const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
361
+ const linkHref = /<link[^>]+href=["']([^"']+)["']/i.exec(it)
362
+ const link = (linkTag && xmlText(linkTag[1])) || (linkHref && linkHref[1]) || ''
363
+ const desc = xmlText(/<(?:description|summary|content)[^>]*>([\s\S]*?)<\/(?:description|summary|content)>/i.exec(it)?.[1] || '')
364
+ if (!title && !link && !desc) continue
365
+ items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
366
+ }
367
+ const feedTitle = xmlText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(xml)?.[1] || '')
368
+ const lines = []
369
+ for (const it of items) {
370
+ lines.push(`- ${it.title}${it.url ? ` — ${it.url}` : ''}`)
371
+ if (it.summary) lines.push(` ${it.summary}`)
372
+ }
373
+ return { title: feedTitle, count: items.length, items, text: lines.join('\n') }
374
+ }
375
+
376
+ // ---- Text-density fallback (body path only) ----
377
+ // Pages without <article>/<main> degrade to the whole <body>: semantic noise
378
+ // stripping then still leaves link-dominated blocks (related posts, category
379
+ // sidebars, "hot articles" widgets) that carry little reading value. Split the
380
+ // body at block-level open tags and drop segments whose text is mostly links.
381
+ // Standard-container pages (article/main) never reach this code path.
382
+ 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
383
+
384
+ function linkTextLength(html) {
385
+ if (!/<\/a/i.test(html)) return 0 // no anchor can pair — skip the scan
386
+ let n = 0
387
+ const re = /<a[\s>][\s\S]*?<\/a\s*>/gi
388
+ let m
389
+ while ((m = re.exec(html))) n += textOnly(m[0]).length
390
+ return n
391
+ }
392
+
393
+ export function densityFilter(html) {
394
+ const marks = []
395
+ BLOCK_OPEN_RE.lastIndex = 0
396
+ let m
397
+ while ((m = BLOCK_OPEN_RE.exec(html))) marks.push(m.index)
398
+ if (marks.length < 2) return html
399
+ const segs = []
400
+ if (marks[0] > 0) segs.push(html.slice(0, marks[0]))
401
+ for (let k = 0; k < marks.length; k++) {
402
+ segs.push(html.slice(marks[k], k + 1 < marks.length ? marks[k + 1] : html.length))
403
+ }
404
+ const kept = segs.filter((seg) => {
405
+ const text = textOnly(seg)
406
+ if (text.length < 2) return false
407
+ // short link-dominated segment = nav item / recommendation widget entry
408
+ if (text.length < 300 && linkTextLength(seg) > text.length * 0.65) return false
409
+ return true
410
+ })
411
+ return kept.length ? kept.join('') : html
412
+ }
413
+
414
+ // Depth-counted block extraction: returns html from the opening tag at
415
+ // openIdx through its BALANCED closing tag. A non-greedy regex stops at the
416
+ // first nested </div> — measured: gnu.org "content" div cut to 2700 chars,
417
+ // naver.com "container" to 115 (zero text). Container divs nest heavily.
418
+ function tagBlockAt(html, openIdx, tagName) {
419
+ const re = new RegExp(`</?${tagName}(?=[\\s>])`, 'gi')
420
+ re.lastIndex = openIdx
421
+ let depth = 0
422
+ let m
423
+ while ((m = re.exec(html))) {
424
+ depth += m[0][1] === '/' ? -1 : 1
425
+ if (depth === 0) {
426
+ const close = html.indexOf('>', m.index)
427
+ return close > 0 ? html.slice(openIdx, close + 1) : null
428
+ }
429
+ }
430
+ return null
431
+ }
432
+
433
+ export function pickMain(html) {
434
+ // Aggregation pages (e.g. blog homepages) have one <article> per item:
435
+ // collect all of them instead of only the first. A tiny <article> (e.g. a
436
+ // newsletter card — measured on about.gitlab.com: 71 chars of text while
437
+ // the real content sits in <main>) falls through to <main> when present.
438
+ const articles = /<\/article/i.test(html)
439
+ ? [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
440
+ : [] // no closer anywhere: matchAll would rescan per open (quadratic)
441
+ const hasMain = /<main[\s>]/i.test(html)
442
+ if (articles.length && (!hasMain || textOnly(articles.map((a) => a[0]).join('')).length >= 200)) {
443
+ return articles.map((a) => a[0]).join('\n')
444
+ }
445
+ const roleMain = /<(main|div)[^>]{0,1000}role=["']main["'][\s>]/i.exec(html)
446
+ if (roleMain) {
447
+ const block = tagBlockAt(html, roleMain.index, roleMain[1].toLowerCase())
448
+ if (block) return block
449
+ }
450
+ // Bare <main> without role="main": doc generators (VitePress/VuePress,
451
+ // MDN, MDX sites) mark the body with a plain <main> tag. Falling back to
452
+ // the whole <body> would drag in the top/local nav (menu, outline, skip
453
+ // link) — the extraction quality loss we saw on vuejs.org.
454
+ const bareMain = /<main[\s>]/i.exec(html)
455
+ if (bareMain) {
456
+ const block = tagBlockAt(html, bareMain.index, 'main')
457
+ if (block) return block
458
+ }
459
+ const body = /<body[\s>]/i.exec(html)
460
+ if (body) {
461
+ const after = html.slice(body.index)
462
+ const end = after.search(/<\/body>/i)
463
+ return densityFilter(end > 0 ? after.slice(0, end) : after)
464
+ }
465
+ return densityFilter(html)
466
+ }
467
+
468
+ // Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
469
+ // entity-escaped tags (&lt;style&gt;...). Reveal TAG-SHAPED sequences so noise
470
+ // rules can strip them. A blanket &lt; → < decode would turn prose "a &lt; b"
471
+ // into a raw "<", and the tag stripper would then swallow everything from it
472
+ // to the next real ">" — measured content loss on pages with escaped
473
+ // comparison operators. So: &lt; is revealed only when a tag name follows;
474
+ // bare &gt; / &quot; are safe to reveal (they never open a strip span).
475
+ function revealEscapedTags(html) {
476
+ return html
477
+ .replace(/&lt;(?=\/?[a-zA-Z!])/gi, '<')
478
+ .replace(/&gt;/gi, '>')
479
+ .replace(/&quot;/gi, '"')
480
+ }
481
+
482
+ function stripNoise(mainHtml) {
483
+ // Attribute runs are bounded: an unbounded [^>]+ rescans toward the next
484
+ // '>' at every '<' position — adversarial markup with one distant '>' made
485
+ // this quadratic (measured: 40k '<style' + one '>' → 82s). Real attributes
486
+ // never approach 1000 chars.
487
+ // Container groups are built from closers that actually exist: absent
488
+ // closing tags made the lazy body scan walk the rest of the string per
489
+ // opening position for nothing.
490
+ const closers = new Set()
491
+ for (const c of mainHtml.matchAll(/<\/([a-z0-9]+)/gi)) closers.add(c[1].toLowerCase())
492
+ const group = (names) => names.filter((n) => closers.has(n))
493
+ const raw = group(['textarea', 'style', 'script', 'noscript', 'template'])
494
+ const box = group(['nav', 'footer', 'header', 'aside', 'form', 'iframe', 'svg', 'canvas', 'dialog'])
495
+ let out = mainHtml.replace(/<!--[\s\S]*?-->/g, ' ')
496
+ if (raw.length) {
497
+ out = out.replace(new RegExp(`<(${raw.join('|')})[\\s>][\\s\\S]*?<\\/\\1\\s*>`, 'gi'), ' ')
498
+ }
499
+ if (box.length) {
500
+ out = out.replace(new RegExp(`<(${box.join('|')})[\\s>][\\s\\S]*?<\\/\\1>`, 'gi'), ' ')
501
+ }
502
+ return out.replace(
503
+ /<([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,
504
+ ' ',
505
+ )
506
+ }
507
+
508
+ // ---- HTML -> Markdown (lightweight, tag-state machine) ----
509
+ function escInline(s) {
510
+ return s.replace(/([\\`*_[\]])/g, '\\$1')
511
+ }
512
+
513
+ // <img> is a void element (never matches the paired-tag regex in either md
514
+ // walker): convert descriptive images to markdown BEFORE the tag walk, via a
515
+ // sentinel so escInline cannot escape the generated brackets. Decorative
516
+ // images (empty alt) are dropped — the model cannot see pixels anyway.
517
+ const IMG_SENTINEL = '\u0001'
518
+ function imgsToMarkdown(html) {
519
+ const store = []
520
+ const out = html.replace(/<img\b[^>]{0,1000}>/gi, (tag) => {
521
+ const alt = /alt=["']([^"']*)["']/i.exec(tag)
522
+ const src = /src=["']([^"']+)["']/i.exec(tag)
523
+ store.push(src && alt && alt[1].trim() ? `![${alt[1].trim()}](${src[1]})` : '')
524
+ return `${IMG_SENTINEL}${store.length - 1}${IMG_SENTINEL}`
525
+ })
526
+ return {
527
+ html: out,
528
+ // Unknown sentinel indexes are PRESERVED (a nested walker's restore must
529
+ // not eat sentinels owned by an outer frame — blockMd recurses into itself).
530
+ restore: (s) => s.replace(new RegExp(`${IMG_SENTINEL}(\\d+)${IMG_SENTINEL}`, 'g'), (m, i) => {
531
+ const v = store[Number(i)]
532
+ return v === undefined ? m : v
533
+ }),
534
+ }
535
+ }
536
+
537
+ // An open tag whose closing tag appears NOWHERE in the input can never pair:
538
+ // the paired alternative of the walker regex would rescan the rest of the
539
+ // string at every such position (measured: 10k unclosed <div>s in 60KB →
540
+ // 2.2s quadratic; a 3MB page would stall the tool call for minutes). Pre-
541
+ // stripping unmatched openers is behaviour-identical — they previously fell
542
+ // through to the single-tag branch, which drops them — and makes the walk
543
+ // linear. Depth cap: recursion on 100+-deep nesting would overflow the stack
544
+ // long before real pages nest that far; degrade to plain text instead.
545
+ const OPEN_TAG_RE = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>/g
546
+ const MD_MAX_DEPTH = 100
547
+ function stripUnmatchedOpeners(html) {
548
+ const closers = new Set()
549
+ for (const m of html.matchAll(/<\/([a-zA-Z0-9]+)/g)) closers.add(m[1].toLowerCase())
550
+ html = defuseLt(html)
551
+ return html.replace(OPEN_TAG_RE, (m, name) => (closers.has(name.toLowerCase()) ? m : ''))
552
+ }
553
+
554
+ export function inlineMd(html, depth = 0) {
555
+ if (depth > MD_MAX_DEPTH) {
556
+ return escInline(decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim())
557
+ }
558
+ const img = imgsToMarkdown(html)
559
+ html = stripUnmatchedOpeners(img.html)
560
+ let out = ''
561
+ const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
562
+ let m
563
+ while ((m = re.exec(html))) {
564
+ if (m[4] !== undefined) {
565
+ out += escInline(decodeTextEntities(m[4]))
566
+ continue
567
+ }
568
+ if (!m[1]) continue
569
+ const tag = m[1].toLowerCase()
570
+ if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
571
+ const inner = inlineMd(m[3], depth + 1)
572
+ if (tag === 'a') {
573
+ const href = /href=["']([^"']+)["']/i.exec(m[2])
574
+ out += href ? `[${inner}](${href[1]})` : inner
575
+ } else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
576
+ else if (tag === 'em' || tag === 'i') out += `*${inner}*`
577
+ else if (tag === 'code') out += `\`${inner}\``
578
+ else if (tag === 'br') out += ' \n'
579
+ else out += inner
580
+ }
581
+ return img.restore(out.replace(/[ \t]+/g, ' ').trim())
582
+ }
583
+
584
+ export function blockMd(html, depth = 0) {
585
+ if (depth > MD_MAX_DEPTH) {
586
+ return decodeTextEntities(html.replace(/<[^>]{1,1000}>/g, ' ')).replace(/\s+/g, ' ').trim()
587
+ }
588
+ const img = imgsToMarkdown(html)
589
+ html = stripUnmatchedOpeners(img.html)
590
+ let out = ''
591
+ const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>([\s\S]*?)<\/\1>|<[^>]{1,1000}>|([^<]+)/g
592
+ let m
593
+ while ((m = re.exec(html))) {
594
+ if (m[4] !== undefined) {
595
+ out += decodeTextEntities(m[4])
596
+ continue
597
+ }
598
+ if (!m[1]) continue
599
+ const tag = m[1].toLowerCase()
600
+ const attrs = m[2] || ''
601
+ const inner = m[3]
602
+ if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') {
603
+ continue
604
+ }
605
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') {
606
+ const level = '#'.repeat(Number(tag[1]))
607
+ out += `\n\n${level} ${inlineMd(inner, depth + 1)}`
608
+ } else if (tag === 'p') {
609
+ const t = inlineMd(inner, depth + 1)
610
+ if (t) out += `\n\n${t}`
611
+ } else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
612
+ const t = blockMd(inner, depth + 1)
613
+ if (t) out += `\n\n${t}`
614
+ } else if (tag === 'blockquote') {
615
+ const t = blockMd(inner, depth + 1).trim()
616
+ out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
617
+ } else if (tag === 'pre') {
618
+ const code = inner.replace(/<[^>]{1,1000}>/g, '').trim()
619
+ // Language hint from common highlighter conventions: the model reads
620
+ // ```js fenced blocks far more accurately than unlabelled ones.
621
+ const lang =
622
+ /<code[^>]+class=["'][^"']*(?:language|lang)-([\w#+.-]+)["']/i.exec(inner)?.[1] || ''
623
+ out += `\n\n\`\`\`${lang}\n${code}\n\`\`\``
624
+ } else if (tag === 'code') {
625
+ out += `\`${textOnly(inner)}\``
626
+ } else if (tag === 'ul' || tag === 'ol') {
627
+ // No-closer guard: <li>s without a single </li> would make the lazy
628
+ // scan walk the rest of inner from every open position (quadratic).
629
+ const items = []
630
+ if (inner.includes('</li')) {
631
+ const liRe = /<li[^>]{0,1000}>([\s\S]*?)<\/li>/gi
632
+ let lm, idx = 1
633
+ while ((lm = liRe.exec(inner))) {
634
+ const t = inlineMd(lm[1], depth + 1)
635
+ items.push(tag === 'ol' ? `${idx}. ${t}` : `- ${t}`)
636
+ idx++
637
+ }
638
+ }
639
+ if (items.length) out += `\n\n${items.join('\n')}`
640
+ } else if (tag === 'li') {
641
+ out += `\n- ${inlineMd(inner, depth + 1)}`
642
+ } else if (tag === 'br') {
643
+ out += ' \n'
644
+ } else if (tag === 'hr') {
645
+ out += '\n\n---'
646
+ } else if (tag === 'table') {
647
+ const rows = []
648
+ if (inner.includes('</tr')) {
649
+ const trRe = /<tr[^>]{0,1000}>([\s\S]*?)<\/tr>/gi
650
+ let tm
651
+ while ((tm = trRe.exec(inner))) {
652
+ const cells = []
653
+ if (tm[1].includes('</t')) {
654
+ const tdRe = /<t[dh][^>]{0,1000}>([\s\S]*?)<\/t[dh]>/gi
655
+ let cm
656
+ while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1], depth + 1).replace(/\|/g, '\\|'))
657
+ }
658
+ if (cells.length) rows.push(`| ${cells.join(' | ')} |`)
659
+ }
660
+ }
661
+ if (rows.length) {
662
+ const header = rows[0]
663
+ // unescape cells' escaped pipes before deriving the separator row,
664
+ // otherwise the --- line is one char wider per escaped pipe
665
+ const sep = header.replace(/\\\|/g, '|').replace(/[^|]/g, '-')
666
+ out += `\n\n${rows.join('\n')}\n${sep}`
667
+ }
668
+ } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
669
+ const t = inlineMd(inner, depth + 1)
670
+ if (t) out += t
671
+ } else {
672
+ const t = blockMd(inner, depth + 1)
673
+ if (t) out += t
674
+ }
675
+ }
676
+ return img.restore(out)
677
+ }
678
+
679
+ // Page-level metadata the model actually asks about (who wrote it, when) —
680
+ // cheap to harvest from <meta>, previously thrown away.
681
+ function extractMeta(html) {
682
+ const get = (names) => {
683
+ for (const n of names) {
684
+ const m = new RegExp(`<meta[^>]{1,1000}(?:property|name)=["']${n}["'][^>]{1,1000}content=["']([^"']+)["']`, 'i').exec(html)
685
+ if (m) return decodeTextEntities(m[1]).replace(/<[^>]{1,1000}>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
686
+ }
687
+ return ''
688
+ }
689
+ return {
690
+ published: get(['article:published_time', 'og:published_time', 'pubdate', 'publishdate', 'date', 'dc.date']),
691
+ author: get(['author', 'article:author', 'og:article:author']),
692
+ description: get(['og:description', 'description']),
693
+ }
694
+ }
695
+
696
+ // Meta-less pages (measured: ruanyifeng.com carries ZERO author/date meta
697
+ // tags) still print the byline inside the body head: "作者:阮一峰 日期:
698
+ // 2026年8月21日". Fill ONLY the missing fields, and only from the first
699
+ // 600 chars of body text — deeper matches are content, not bylines.
700
+ const BYLINE_AUTHOR_RE = /(?:作者|責任編輯|責任主编|editor)[::\s]{1,3}([^\s,,。/|()()[\]]{2,24})/
701
+ 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
702
+
703
+ function bylineFallback(text, meta) {
704
+ const head = typeof text === 'string' ? text.slice(0, 600) : ''
705
+ const out = { published: meta.published, author: meta.author }
706
+ if (!out.author) {
707
+ const m = BYLINE_AUTHOR_RE.exec(head)
708
+ if (m) out.author = m[1].trim().slice(0, 40)
709
+ }
710
+ if (!out.published) {
711
+ const m = BYLINE_DATE_RE.exec(head)
712
+ if (m) out.published = m[1].replace(/\s+/g, ' ').trim().slice(0, 40)
713
+ }
714
+ return out
715
+ }
716
+
717
+ export function extract(html, mode) {
718
+ html = defuseLt(html) // kills the no-'>'-tail quadratic scans before any picker runs
719
+ const titleMatch = /<title[^>]{0,1000}>([\s\S]*?)<\/title>/i.exec(html) || /<meta[^>]{1,1000}property=["']og:title["'][^>]{1,1000}content=["']([^"']+)["']/i.exec(html)
720
+ const siteName = /<meta[^>]{1,1000}property=["']og:site_name["'][^>]{1,1000}content=["']([^"']+)["']/i.exec(html)
721
+ const langMatch = /<html[^>]{1,1000}lang=["']([\w-]+)["']/i.exec(html)
722
+ let main = stripNoise(pickMain(html))
723
+ main = stripNoise(revealEscapedTags(main))
724
+ let bodyText
725
+ if (mode === 'markdown') {
726
+ bodyText = blockMd(main).replace(/\n{3,}/g, '\n\n').trim()
727
+ } else {
728
+ bodyText = textOnly(main).replace(/ +/g, ' ').trim()
729
+ }
730
+ const meta = extractMeta(html)
731
+ // Empty-body pages (login walls, JS-only shells) still carry og:description
732
+ // — surface it as a fallback hint instead of nothing.
733
+ let text = bodyText
734
+ let usedDescription = false
735
+ if (!text && meta.description) {
736
+ text = meta.description
737
+ usedDescription = true
738
+ }
739
+ const byline = bylineFallback(text, meta)
740
+ return {
741
+ title: titleMatch ? textOnly(titleMatch[1]) || titleMatch[1].trim() : '',
742
+ siteName: siteName ? siteName[1] : '',
743
+ lang: langMatch ? langMatch[1] : '',
744
+ published: byline.published,
745
+ author: byline.author,
746
+ text,
747
+ usedDescription,
748
+ }
749
+ }
750
+
751
+ // Optional enhancement: when @mozilla/readability + happy-dom are installed
752
+ // inside the DSH profile (npm i @mozilla/readability happy-dom), extraction
753
+ // upgrades to the Firefox Reader Mode algorithm. Falls back to the built-in
754
+ // heuristic extractor when absent. MPL-2.0 / MIT libraries, used as-is.
755
+ let readabilityReady = null
756
+ async function getReadabilityExtractor() {
757
+ if (readabilityReady !== null) return readabilityReady
758
+ try {
759
+ const [{ Readability }, { Window }] = await Promise.all([
760
+ import('@mozilla/readability'),
761
+ import('happy-dom'),
762
+ ])
763
+ readabilityReady = (html, url) => {
764
+ const window = new Window({ url })
765
+ window.document.write(html)
766
+ const article = new Readability(window.document).parse()
767
+ return article ? article.content : null
768
+ }
769
+ } catch {
770
+ readabilityReady = false
771
+ }
772
+ return readabilityReady
773
+ }
774
+
775
+ export function smartTruncate(text, maxChars, offset = 0) {
776
+ const total = text.length
777
+ if (offset >= total) {
778
+ return { text: '', truncated: false, charsTotal: total, charsReturned: 0, charsStart: offset }
779
+ }
780
+ if (total <= maxChars && offset === 0) {
781
+ return { text, truncated: false, charsTotal: total, charsReturned: total, charsStart: 0 }
782
+ }
783
+ const paragraphs = text.split(/\n\n+/)
784
+ let pos = 0
785
+ let start = 0
786
+ for (let i = 0; i < paragraphs.length; i++) {
787
+ if (pos + paragraphs[i].length > offset) {
788
+ start = i
789
+ break
790
+ }
791
+ pos += paragraphs[i].length + 2
792
+ }
793
+ let acc = ''
794
+ for (let i = start; i < paragraphs.length; i++) {
795
+ const p = paragraphs[i]
796
+ if (acc.length + p.length + (acc ? 2 : 0) > maxChars) break
797
+ acc += (acc ? '\n\n' : '') + p
798
+ }
799
+ if (!acc && maxChars > 0) {
800
+ // The first paragraph after the offset is itself longer than maxChars.
801
+ // Keep the alignment promise at sentence level before hard-slicing.
802
+ const p = paragraphs[start] || ''
803
+ const rel = Math.max(0, offset - pos)
804
+ const rest = p.length > rel ? p.slice(rel) : text.slice(offset)
805
+ const sentences = rest.match(/[^.!?\n。!?]+[.!?\n。!?]*/g) || [rest]
806
+ for (const s of sentences) {
807
+ if (acc.length + s.length > maxChars) break
808
+ acc += s
809
+ }
810
+ if (!acc) acc = rest.slice(0, maxChars)
811
+ }
812
+ return {
813
+ text: acc,
814
+ truncated: offset + acc.length < total,
815
+ charsTotal: total,
816
+ charsReturned: acc.length,
817
+ charsStart: offset,
818
+ }
819
+ }
820
+
821
+ function extractLinks(html, limit, baseUrl) {
822
+ const links = []
823
+ const seen = new Set()
824
+ // Accept relative hrefs too — most real pages link internally with relative
825
+ // paths — and resolve them against the page URL so the model can follow them.
826
+ const re = /<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi
827
+ let m
828
+ // Iteration cap: a page whose nav repeats the same links thousands of times
829
+ // would otherwise scan the whole HTML to fill `limit` unique entries. Scan
830
+ // at most limit*4 anchors; dedupe keeps the result bounded regardless.
831
+ let scans = 0
832
+ const maxScans = limit * 4
833
+ while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
834
+ const href = m[1].trim()
835
+ if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
836
+ let url
837
+ try {
838
+ url = baseUrl ? new URL(href, baseUrl).href : new URL(href).href
839
+ } catch {
840
+ continue
841
+ }
842
+ // Only web links are followable by the model / crawler — ftp: etc. would
843
+ // pass new URL() resolution and pollute link lists and crawl queues.
844
+ if (!/^https?:/i.test(url)) continue
845
+ // dedupe: nav bars repeat the same links many times — one entry per URL
846
+ // keeps the link list compact (tokens) without losing coverage
847
+ if (seen.has(url)) continue
848
+ seen.add(url)
849
+ const t = textOnly(m[2])
850
+ if (t) links.push({ title: t.slice(0, 80), url })
851
+ }
852
+ return links
853
+ }
854
+
855
+ function hostOf(url) {
856
+ try {
857
+ return new URL(url).hostname
858
+ } catch {
859
+ return ''
860
+ }
861
+ }
862
+
863
+ // ---- Pagination: auto-follow "next page" ----
864
+ // Long articles on novel/news/forum sites are split into pages; without this
865
+ // the model had to notice the truncation and re-call the tool per page. The
866
+ // recognition is deliberately conservative: rel=next (standard) or a short
867
+ // anchor whose whole text is a next-page marker — no fuzzy guessing.
868
+ const NEXT_TEXT_RE = /^(?:[›»>]+\s*)?(?:下一页|下页|下一篇|下一頁|下頁|后一页|後一頁|next page|next|older entries|[›»>]{1,3})(?:\s*[›»>]+)?$/i
869
+
870
+ export function findNextLink(html, baseUrl) {
871
+ const resolve = (href) => {
872
+ try {
873
+ const u = new URL(href, baseUrl)
874
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
875
+ } catch {
876
+ return null
877
+ }
878
+ }
879
+ const rel = /<(?:link|a)\b[^>]+rel=["']next["'][^>]*>/i.exec(html || '')
880
+ if (rel) {
881
+ const href = /href=["']([^"']+)["']/i.exec(rel[0])
882
+ const r = href && resolve(href[1])
883
+ if (r) return r
884
+ }
885
+ const re = /<a\b[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi
886
+ let m
887
+ let scans = 0
888
+ // Bounded like extractLinks: a nav bar with thousands of anchors must not
889
+ // scan the whole DOM hunting for a next-marker.
890
+ while ((m = re.exec(html || '')) && scans++ < 400) {
891
+ const t = textOnly(m[2])
892
+ if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
893
+ const r = resolve(m[1])
894
+ if (r) return r
895
+ }
896
+ }
897
+ return null
898
+ }
899
+
900
+ // Join two page bodies, dropping a repeated suffix/prefix overlap (many
901
+ // paginated sites repeat the last paragraph or a heading on the next page).
902
+ function joinPageText(a, b) {
903
+ const max = Math.min(300, a.length, b.length)
904
+ for (let n = max; n >= 30; n--) {
905
+ if (a.slice(a.length - n) === b.slice(0, n)) {
906
+ const rest = b.slice(n).trim()
907
+ return rest ? `${a}\n\n${rest}` : a
908
+ }
909
+ }
910
+ return `${a}\n\n${b}`
911
+ }
912
+
913
+ function sliceFrom(full, offset, maxChars) {
914
+ const s = smartTruncate(full.fullText, maxChars, offset)
915
+ const out = {
916
+ url: full.url,
917
+ title: full.title || '',
918
+ siteName: full.siteName || hostOf(full.url),
919
+ lang: full.lang || '',
920
+ charset: full.charset,
921
+ mode: full.mode,
922
+ truncated: s.truncated,
923
+ charsTotal: s.charsTotal,
924
+ charsReturned: s.charsReturned,
925
+ charsStart: s.charsStart,
926
+ text: s.text,
927
+ }
928
+ if (full.rendered) out.rendered = true
929
+ if (full.spaHint) out.spaHint = full.spaHint
930
+ if (full.paginated > 1) out.paginated = full.paginated
931
+ if (full.feedCount) out.feedCount = full.feedCount
932
+ if (full.published) out.published = full.published
933
+ if (full.author) out.author = full.author
934
+ if (Array.isArray(full.links)) out.links = full.links
935
+ return out
936
+ }
937
+
938
+ // ---- JSON payloads: compact render ----
939
+ // Indented JSON spends a newline + indent per key — pure token overhead for
940
+ // the model. Render compact, and clip monster string values (embedded HTML,
941
+ // base64 blobs) with a visible marker so the model knows there is more.
942
+ const JSON_STR_MAX = 1500
943
+ export function compactJson(value) {
944
+ const clip = (s) =>
945
+ s.length > JSON_STR_MAX ? s.slice(0, JSON_STR_MAX) + `…[+${s.length - JSON_STR_MAX} chars]` : s
946
+ const walk = (v, depth) => {
947
+ if (typeof v === 'string') return clip(v)
948
+ if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1))
949
+ if (v && typeof v === 'object' && depth < 12) {
950
+ const o = {}
951
+ for (const k of Object.keys(v)) o[k] = walk(v[k], depth + 1)
952
+ return o
953
+ }
954
+ return v
955
+ }
956
+ try {
957
+ return JSON.stringify(walk(value, 0))
958
+ } catch {
959
+ // Extreme nesting/size where even native stringify exhausts the stack —
960
+ // degrade to a clipped raw render instead of crashing the tool call.
961
+ try {
962
+ return clip(String(value))
963
+ } catch {
964
+ return '[unserializable json]'
965
+ }
966
+ }
967
+ }
968
+
969
+ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
970
+ const url = String((args && args.url) || '').trim()
971
+ if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
972
+ const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
973
+ const mode = args.mode === 'markdown' ? 'markdown' : 'text'
974
+ const offset = Math.max(0, Number(args.offset) || 0)
975
+
976
+ // Cache stores the FULL extracted text keyed by url+mode+includeLinks, so
977
+ // continuation reads (offset) and different maxChars hit the same entry —
978
+ // but a links-request must not be served from a links-less cached copy.
979
+ // The key drops the URL fragment: #sections would otherwise split the cache.
980
+ let cacheUrl = url
981
+ try {
982
+ const u = new URL(url)
983
+ u.hash = ''
984
+ cacheUrl = u.href
985
+ } catch {
986
+ /* keep raw url on parse failure */
987
+ }
988
+ const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
989
+ // Failed fetches are cached briefly so the model doesn't re-request a
990
+ // broken URL in a loop (token + latency saver).
991
+ const fh = failCache.get(cacheKey)
992
+ if (fh) {
993
+ if (Date.now() - fh.time < 30000) return { error: fh.error, cached: true }
994
+ failCache.delete(cacheKey)
995
+ }
996
+ const hit = cache.get(cacheKey)
997
+ if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
998
+ const sliced = sliceFrom(hit.full, offset, maxChars)
999
+ return { ...sliced, cached: true }
1000
+ }
1001
+
1002
+ const failWith = (error) => {
1003
+ cacheFail(cacheKey, error)
1004
+ return { error }
1005
+ }
1006
+
1007
+ // Non-HTML payloads (JSON APIs, RSS/Atom feeds) are read natively: fetch,
1008
+ // dispatch by content-type / payload shape, render compact. Falls back to
1009
+ // the HTML pipeline for everything else.
1010
+ const dispatch = async () => {
1011
+ const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1012
+ if (viaSeam) {
1013
+ const t = viaSeam.html.trim()
1014
+ if (t.startsWith('{') || t.startsWith('[')) {
1015
+ try {
1016
+ return { kind: 'json', text: compactJson(JSON.parse(t)) }
1017
+ } catch { /* not JSON — keep HTML pipeline */ }
1018
+ }
1019
+ return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
1020
+ }
1021
+ const page = await fetchPage(url, externalSignal, cfg)
1022
+ if (page.error) return { error: page.error }
1023
+ const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1024
+ const decoded = decodeBuffer(page.buffer, page.contentType)
1025
+ if (/json$/.test(ct)) {
1026
+ try {
1027
+ return { kind: 'json', text: compactJson(JSON.parse(decoded.text)) }
1028
+ } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
1029
+ }
1030
+ if (/[+/]xml$/.test(ct)) {
1031
+ if (/<urlset[\s>]/i.test(decoded.text) || /<sitemapindex[\s>]/i.test(decoded.text)) {
1032
+ return { error: 'Unsupported content (XML sitemap)' }
1033
+ }
1034
+ if (/<rss[\s>]/i.test(decoded.text) || /<feed[\s>]/i.test(decoded.text)) {
1035
+ return { kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks) }
1036
+ }
1037
+ }
1038
+ return { kind: 'html', html: decoded.text, finalUrl: page.finalUrl || url, charset: decoded.charset }
1039
+ }
1040
+ const payload = await dispatch()
1041
+ if (payload.error) return failWith(payload.error)
1042
+
1043
+ if (payload.kind === 'json') {
1044
+ const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
1045
+ cacheStore(cacheKey, full, cfg.cacheMax)
1046
+ return sliceFrom(full, offset, maxChars)
1047
+ }
1048
+ if (payload.kind === 'feed') {
1049
+ const f = payload.feed
1050
+ const full = {
1051
+ url, title: f.title || '', siteName: hostOf(url), lang: '', charset: 'feed',
1052
+ mode: 'feed', fullText: f.text, paginated: 1, feedCount: f.count,
1053
+ links: args.includeLinks === true ? f.items : undefined,
1054
+ }
1055
+ cacheStore(cacheKey, full, cfg.cacheMax)
1056
+ return sliceFrom(full, offset, maxChars)
1057
+ }
1058
+
1059
+ let html = payload.html
1060
+ let finalUrl = payload.finalUrl || url
1061
+ const charset = payload.charset
1062
+
1063
+ let extracted = extract(html, mode)
1064
+ // Optional readability upgrade: cleaner article extraction when installed.
1065
+ const ext = await getReadabilityExtractor()
1066
+ const upgrade = (target, htmlText) => {
1067
+ if (!ext) return target
1068
+ try {
1069
+ const clean = ext(htmlText, url)
1070
+ if (clean && clean.length > 0) {
1071
+ target.text = mode === 'markdown'
1072
+ ? blockMd(clean).replace(/\n{3,}/g, '\n\n').trim()
1073
+ : textOnly(clean).replace(/ +/g, ' ').trim()
1074
+ }
1075
+ } catch {
1076
+ // keep heuristic result
1077
+ }
1078
+ return target
1079
+ }
1080
+ extracted = upgrade(extracted, html)
1081
+
1082
+ // Optional SPA enhancement: if the page looks client-rendered and static
1083
+ // extraction found almost nothing, try headless rendering (playwright).
1084
+ // Beyond script-heavy shells (looksLikeSpa, ≥5 <script> tags), a page whose
1085
+ // extraction is EMPTY but carries any <script> may be a JS-redirect shell
1086
+ // (measured: taptap.cn — 2 scripts, empty <body>, redirects via JS) — with
1087
+ // no text to lose, rendering is always worth one bounded attempt. Pages
1088
+ // with no scripts at all cannot change under rendering and are skipped.
1089
+ let rendered = false
1090
+ let spaHint = ''
1091
+ let renderedHtml = null
1092
+ const needsRender = !extracted.text || extracted.text.length < 200
1093
+ const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
1094
+ if (cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1095
+ const rr = await renderPage(finalUrl || url, externalSignal)
1096
+ if (rr.html) {
1097
+ renderedHtml = rr.html
1098
+ // A challenge interstitial (Cloudflare "Just a moment...") must never
1099
+ // be accepted as an improvement — it can carry MORE text than the real
1100
+ // static body (measured: 259 chars of verification prose vs 135 chars
1101
+ // of real content on ruanyifeng.com) and would evict the latter.
1102
+ if (looksLikeChallenge(rr.html)) {
1103
+ renderedHtml = null // pagination/links must not read the interstitial DOM
1104
+ spaHint = extracted.text
1105
+ ? '已渲染但被人机验证页拦截,保留静态提取结果'
1106
+ : '已渲染但被人机验证页拦截(无静态内容可用)'
1107
+ } else {
1108
+ const r2 = upgrade(extract(rr.html, mode), rr.html)
1109
+ // Accept the rendered text when it meaningfully beats the static one.
1110
+ // From an EMPTY static extraction even a short body is a real gain
1111
+ // (measured: taptap-style JS shells render 30-60 chars of real content
1112
+ // that the fixed +50 margin would reject).
1113
+ const gain = r2.text.length - extracted.text.length
1114
+ const accept = r2.text && (gain > 50 || (!extracted.text && r2.text.length >= 20))
1115
+ if (accept) {
1116
+ extracted = r2
1117
+ finalUrl = rr.finalUrl || finalUrl
1118
+ rendered = true
1119
+ } else if (!extracted.text) {
1120
+ spaHint = '已渲染仍无可读内容(可能登录墙或反爬拦截)'
1121
+ }
1122
+ }
1123
+ } else {
1124
+ spaHint = rr.error
1125
+ }
1126
+ }
1127
+
1128
+ // Auto-pagination: follow the "next page" chain (bounded, same-host, no
1129
+ // loops). Continuation pages go through the fast static path — a paginated
1130
+ // SPA chain would cost one full render per page, out of proportion.
1131
+ let paginated = 1
1132
+ const paginateMax = cfg.paginate === false ? 1 : Math.max(1, Math.min(10, Number(cfg.paginateMax) || 3))
1133
+ const startHost = hostOf(finalUrl || url)
1134
+ const seen = new Set([normalizeUrl(finalUrl || url)])
1135
+ let nextUrl = paginateMax > 1 ? findNextLink(renderedHtml || html, finalUrl || url) : null
1136
+ while (
1137
+ nextUrl && paginated < paginateMax &&
1138
+ sameHost(nextUrl, startHost) &&
1139
+ !(externalSignal && externalSignal.aborted)
1140
+ ) {
1141
+ const nu = normalizeUrl(nextUrl)
1142
+ if (!nu || seen.has(nu)) break
1143
+ seen.add(nu)
1144
+ const page = await fetchPage(nextUrl, externalSignal, cfg)
1145
+ if (page.error) break
1146
+ const ct = (page.contentType || '').split(';')[0].toLowerCase().trim()
1147
+ if (!/html|xhtml/.test(ct)) break
1148
+ const nextHtml = decodeBuffer(page.buffer, page.contentType).text
1149
+ const nextEx = extract(nextHtml, mode)
1150
+ if (!nextEx.text || nextEx.text.length < 20) break
1151
+ extracted.text = joinPageText(extracted.text, nextEx.text)
1152
+ paginated++
1153
+ nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1154
+ }
1155
+
1156
+ const full = {
1157
+ url: finalUrl,
1158
+ title: extracted.title || '',
1159
+ siteName: extracted.siteName || '',
1160
+ lang: extracted.lang || '',
1161
+ charset,
1162
+ mode,
1163
+ fullText: extracted.text,
1164
+ rendered,
1165
+ spaHint,
1166
+ paginated,
1167
+ published: extracted.published || '',
1168
+ author: extracted.author || '',
1169
+ // Links come from the rendered DOM when available — otherwise the SPA
1170
+ // chain (content -> links -> next page) would break for JS-only pages.
1171
+ links: args.includeLinks === true ? extractLinks(renderedHtml || html, cfg.maxLinks, finalUrl) : undefined,
1172
+ }
1173
+
1174
+ cacheStore(cacheKey, full, cfg.cacheMax)
1175
+ return sliceFrom(full, offset, maxChars)
1176
+ }
1177
+
1178
+ function renderResult(value) {
1179
+ if (typeof value === 'string') return value
1180
+ const r = value || {}
1181
+ if (r.error) return `Error: ${r.error}`
1182
+ const lines = []
1183
+ if (r.title) lines.push(`title: ${r.title}`)
1184
+ const host = hostOf(r.url || '')
1185
+ const meta = []
1186
+ if (r.siteName && r.siteName !== host) meta.push(r.siteName)
1187
+ if (r.lang) meta.push(r.lang)
1188
+ if (r.charset && r.charset !== 'utf-8' && r.charset !== 'json' && r.charset !== 'feed') {
1189
+ meta.push(`charset ${r.charset}`)
1190
+ }
1191
+ if (r.published) meta.push(r.published)
1192
+ if (r.author) meta.push(`by ${r.author}`)
1193
+ if (r.feedCount) meta.push(`feed · ${r.feedCount} 条`)
1194
+ if (r.mode === 'json') meta.push('json')
1195
+ if (meta.length) lines.push(meta.join(' · '))
1196
+ // One compact status line instead of several parenthetical lines — the
1197
+ // flags are hints, not content, so they share a single line of tokens.
1198
+ const flags = []
1199
+ if (r.charsStart > 0) flags.push(`chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal}`, 'offset 续读')
1200
+ else if (r.truncated) flags.push(`chars ${r.charsReturned}/${r.charsTotal}`, '截断', 'offset 续读')
1201
+ else if (typeof r.charsTotal === 'number') flags.push(`chars ${r.charsTotal}`)
1202
+ if (r.cached) flags.push('cached')
1203
+ if (r.paginated > 1) flags.push(`已拼接${r.paginated}页`)
1204
+ if (r.rendered) flags.push('rendered')
1205
+ if (flags.length) lines.push(`(${flags.join(' · ')})`)
1206
+ if (!r.text) {
1207
+ if (r.charsStart > 0 && !r.truncated) {
1208
+ lines.push('(offset 已到文末,无更多内容)')
1209
+ } else {
1210
+ lines.push(r.spaHint
1211
+ ? `(无可读内容 — ${r.spaHint})`
1212
+ : '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
1213
+ }
1214
+ }
1215
+ lines.push('', r.text || '')
1216
+ if (Array.isArray(r.links) && r.links.length) {
1217
+ lines.push('', 'links:')
1218
+ for (const l of r.links) lines.push(`- ${l.title} — ${l.url}`)
1219
+ }
1220
+ return lines.join('\n')
1221
+ }
1222
+
1223
+ function renderLinks(value) {
1224
+ if (typeof value === 'string') return value
1225
+ const r = value || {}
1226
+ if (r.error) return `Error: ${r.error}`
1227
+ if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}`
1228
+ const lines = [`${r.count} link(s) on ${r.url}:`]
1229
+ for (const l of r.links) lines.push(`- ${l.title || l.url} — ${l.url}`)
1230
+ return lines.join('\n')
1231
+ }
1232
+
1233
+ function readLinksTool(ctx, cfg) {
1234
+ return {
1235
+ name: 'read_url_links',
1236
+ description:
1237
+ 'List links (text + URL) on a page without body text — lighter than read_url ' +
1238
+ 'for mapping what a page points to. Renders SPA pages when playwright installed.',
1239
+ parameters: {
1240
+ type: 'object',
1241
+ additionalProperties: false,
1242
+ properties: {
1243
+ url: { type: 'string', description: 'http(s) URL to scan for links' },
1244
+ limit: { type: 'number', description: 'Links to return (1-50; default from plugin config)' },
1245
+ },
1246
+ required: ['url'],
1247
+ },
1248
+ // Covers SPA-render fallback: full fetch timeout + playwright render cap.
1249
+ timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
1250
+ isConcurrencySafe: () => true,
1251
+ output: {
1252
+ schema: {
1253
+ type: 'object',
1254
+ additionalProperties: true,
1255
+ properties: {
1256
+ url: { type: 'string' },
1257
+ count: { type: 'number' },
1258
+ links: { type: 'array', items: { type: 'object' } },
1259
+ error: { type: 'string' },
1260
+ },
1261
+ },
1262
+ render: (_args, value) => [{ type: 'text', text: renderLinks(value) }],
1263
+ },
1264
+ async execute(args, exec) {
1265
+ const url = String((args && args.url) || '').trim()
1266
+ if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1267
+ const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
1268
+ const viaSeam = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg)
1269
+ let html, finalUrl
1270
+ if (viaSeam) {
1271
+ html = viaSeam.html
1272
+ finalUrl = viaSeam.finalUrl
1273
+ } else {
1274
+ const page = await fetchPage(url, exec && exec.signal, cfg)
1275
+ if (page.error) return { error: page.error }
1276
+ html = decodeBuffer(page.buffer, page.contentType).text
1277
+ finalUrl = page.finalUrl || url
1278
+ }
1279
+ let links = extractLinks(html, limit, finalUrl)
1280
+ // SPA fallback: a JS-only page yields no links from its static HTML;
1281
+ // render it and re-extract when the static result looks empty. Any
1282
+ // <script> counts as renderable when there are no links at all — the
1283
+ // static shell may be a JS-redirect (same rationale as read_url).
1284
+ if (links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))) {
1285
+ const rr = await renderPage(finalUrl || url, exec && exec.signal)
1286
+ // A challenge interstitial's links are the challenge's own scripts —
1287
+ // never an improvement over whatever the static HTML offered.
1288
+ if (rr.html && !looksLikeChallenge(rr.html)) {
1289
+ const rl = extractLinks(rr.html, limit, rr.finalUrl || finalUrl)
1290
+ if (rl.length > links.length) {
1291
+ links = rl
1292
+ finalUrl = rr.finalUrl || finalUrl
1293
+ }
1294
+ }
1295
+ }
1296
+ return { url: finalUrl, count: links.length, links }
1297
+ },
1298
+ }
1299
+ }
1300
+
1301
+ // Run async tasks with a concurrency cap (avoids hammering a site / getting
1302
+ // rate-limited when reading many URLs at once).
1303
+ async function mapLimit(items, limit, fn) {
1304
+ const out = new Array(items.length)
1305
+ let idx = 0
1306
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1307
+ while (idx < items.length) {
1308
+ const i = idx++
1309
+ out[i] = await fn(items[i], i)
1310
+ }
1311
+ })
1312
+ await Promise.all(workers)
1313
+ return out
1314
+ }
1315
+
1316
+ function renderBatch(value) {
1317
+ if (typeof value === 'string') return value
1318
+ const v = value || {}
1319
+ if (v.error) return `Error: ${v.error}`
1320
+ const lines = [`读取 ${v.succeeded}/${v.total} 页成功${v.failed ? `,${v.failed} 页失败` : ''}`]
1321
+ for (const p of v.pages) {
1322
+ if (p.error) {
1323
+ lines.push('', `[失败] ${p.url} — ${p.error}`)
1324
+ continue
1325
+ }
1326
+ const head = p.title || p.url
1327
+ lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
1328
+ if (Array.isArray(p.links) && p.links.length) {
1329
+ const ls = p.links.map((l) => l.title + ' — ' + l.url)
1330
+ lines.push(`links: ${ls.slice(0, 6).join(' | ')}${ls.length > 6 ? ` …共${ls.length}个` : ''}`)
1331
+ }
1332
+ }
1333
+ return lines.join('\n')
1334
+ }
1335
+
1336
+ function readUrlBatchTool(ctx, cfg) {
1337
+ return {
1338
+ name: 'read_url_batch',
1339
+ description:
1340
+ 'Read multiple URLs in parallel: each a compact clean block (same extraction + cache as read_url). ' +
1341
+ 'Failures isolated and tagged. Max 10 URLs, capped at maxChars per page.',
1342
+ parameters: {
1343
+ type: 'object',
1344
+ additionalProperties: false,
1345
+ properties: {
1346
+ urls: { type: 'array', items: { type: 'string' }, description: 'URLs to read in parallel (1-10, http/s)' },
1347
+ maxChars: { type: 'number', description: 'Chars per page (500-20000; default 3000)' },
1348
+ mode: { type: 'string', enum: ['text', 'markdown'], description: 'text = plain (token-cheap, default); markdown = structured' },
1349
+ includeLinks: { type: 'boolean', description: 'Return page links too (title+url, bounded)' },
1350
+ },
1351
+ required: ['urls'],
1352
+ },
1353
+ // Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
1354
+ timeoutMs: Math.max(60000, cfg.timeoutMs * 3 + 135000),
1355
+ // Concurrent batch calls multiply fetch pressure but stay isolated (each
1356
+ // call owns its result array; the session cache is commutative).
1357
+ isConcurrencySafe: () => true,
1358
+ output: {
1359
+ schema: {
1360
+ type: 'object',
1361
+ additionalProperties: true,
1362
+ properties: {
1363
+ total: { type: 'number' },
1364
+ succeeded: { type: 'number' },
1365
+ failed: { type: 'number' },
1366
+ pages: { type: 'array', items: { type: 'object' } },
1367
+ error: { type: 'string' },
1368
+ },
1369
+ },
1370
+ render: (_args, value) => [{ type: 'text', text: renderBatch(value) }],
1371
+ },
1372
+ async execute(args, exec) {
1373
+ const a = args || {}
1374
+ const urls = (Array.isArray(a.urls) ? a.urls : []).map((u) => String(u).trim()).filter(Boolean)
1375
+ if (!urls.length) return { error: 'urls array is required (1-10 http(s) URLs)' }
1376
+ const list = urls.slice(0, 10)
1377
+ const perMax = Math.max(500, Math.min(20000, Number(a.maxChars) || 3000))
1378
+ const mode = a.mode === 'markdown' ? 'markdown' : 'text'
1379
+ const signal = exec && exec.signal
1380
+ const pages = await mapLimit(list, 4, (u) =>
1381
+ readUrl({ url: u, maxChars: perMax, mode, includeLinks: a.includeLinks === true }, ctx, signal, cfg),
1382
+ )
1383
+ const ok = pages.filter((p) => !p.error)
1384
+ return {
1385
+ total: list.length,
1386
+ succeeded: ok.length,
1387
+ failed: list.length - ok.length,
1388
+ pages: pages.map((p, i) => {
1389
+ if (p.error) return { url: list[i], error: p.error }
1390
+ const out = {
1391
+ url: p.url,
1392
+ title: p.title || '',
1393
+ chars: p.charsReturned,
1394
+ cached: p.cached === true,
1395
+ text: p.text,
1396
+ }
1397
+ if (Array.isArray(p.links) && p.links.length) out.links = p.links
1398
+ return out
1399
+ }),
1400
+ }
1401
+ },
1402
+ }
1403
+ }
1404
+
1405
+ function normalizeUrl(url) {
1406
+ try {
1407
+ const u = new URL(url)
1408
+ u.hash = ''
1409
+ return u.href
1410
+ } catch {
1411
+ return null
1412
+ }
1413
+ }
1414
+
1415
+ // b is a bare hostname (hostOf() output) — must NOT be passed to new URL()
1416
+ // as a full URL (that throws and would make every link look external).
1417
+ function sameHost(a, b) {
1418
+ try {
1419
+ return new URL(a).hostname === b
1420
+ } catch {
1421
+ return false
1422
+ }
1423
+ }
1424
+
1425
+ // URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
1426
+ 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
1427
+ const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
1428
+ function isNoiseUrl(url) {
1429
+ return NOISE_EXT.test(url) || NOISE_PATH.test(url)
1430
+ }
1431
+
1432
+ // BFS crawl of a single site. Only same-host http(s) pages are followed;
1433
+ // static assets / auth paths are skipped; visited URLs are deduped; a per-page
1434
+ // failure is recorded and does not abort the crawl. No SPA rendering here —
1435
+ // that is read_url's job; crawling favors speed and breadth.
1436
+ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
1437
+ const { maxPages, maxDepth, includeContent, perMax } = opts
1438
+ const host = hostOf(entryUrl)
1439
+ const visited = new Set()
1440
+ const pages = []
1441
+ const failures = []
1442
+ const queue = [{ url: entryUrl, depth: 0 }]
1443
+ // Stop once the host's cooperative signal fires (tool budget hit): without
1444
+ // this the loop keeps draining the queue on instantly-aborted fetches.
1445
+ while (queue.length && pages.length < maxPages && !(externalSignal && externalSignal.aborted)) {
1446
+ // Never overshoot the page budget within a batch round.
1447
+ const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
1448
+ const results = await Promise.all(
1449
+ batch.map(async ({ url, depth }) => {
1450
+ const norm = normalizeUrl(url)
1451
+ if (!norm || visited.has(norm)) return null
1452
+ visited.add(norm)
1453
+ const page = await fetchPage(url, externalSignal, cfg)
1454
+ if (page.error) return { url, depth, error: page.error }
1455
+ const html = decodeBuffer(page.buffer, page.contentType).text
1456
+ const ex = extract(html, 'text')
1457
+ const links = extractLinks(html, 100, page.finalUrl || url)
1458
+ return {
1459
+ url: page.finalUrl || url,
1460
+ depth,
1461
+ title: ex.title || '',
1462
+ chars: ex.text.length,
1463
+ text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
1464
+ links,
1465
+ }
1466
+ }),
1467
+ )
1468
+ for (const r of results) {
1469
+ if (!r) continue
1470
+ if (r.error) {
1471
+ failures.push({ url: r.url, error: r.error })
1472
+ continue
1473
+ }
1474
+ pages.push(r)
1475
+ if (r.depth + 1 <= maxDepth) {
1476
+ // Queue cap: a huge site (millions of links) would otherwise grow the
1477
+ // queue unboundedly while maxPages bounds only what we *process*.
1478
+ if (queue.length >= maxPages * 20) continue
1479
+ for (const l of r.links) {
1480
+ if (!sameHost(l.url, host) || isNoiseUrl(l.url)) continue
1481
+ const n = normalizeUrl(l.url)
1482
+ if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1 })
1483
+ }
1484
+ }
1485
+ }
1486
+ }
1487
+ return { host, pages, failures }
1488
+ }
1489
+
1490
+ function renderSite(value) {
1491
+ if (typeof value === 'string') return value
1492
+ const v = value || {}
1493
+ if (v.error) return `Error: ${v.error}`
1494
+ const lines = [`站点: ${v.host} · 爬取 ${v.succeeded}/${v.total} 页${v.failed ? ` · ${v.failed} 页失败` : ''}`]
1495
+ for (const p of v.pages) {
1496
+ const indent = ' '.repeat(p.depth)
1497
+ const head = p.title || p.url
1498
+ lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
1499
+ if (p.text) lines.push(`${indent} ${p.text.slice(0, 80)}`)
1500
+ }
1501
+ for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
1502
+ return lines.join('\n')
1503
+ }
1504
+
1505
+ function readUrlSiteTool(ctx, cfg) {
1506
+ return {
1507
+ name: 'read_url_site',
1508
+ description:
1509
+ 'Crawl a site BFS from one URL: dedupe same-host pages, return a compact site map ' +
1510
+ '(title + depth + size). Auth/static paths skipped; failures isolated. ' +
1511
+ 'includeContent=true attaches a short summary per page (default off, token-cheap). ' +
1512
+ 'Does not render SPA pages (use read_url for that).',
1513
+ parameters: {
1514
+ type: 'object',
1515
+ additionalProperties: false,
1516
+ properties: {
1517
+ url: { type: 'string', description: 'Entry URL of the site to crawl (http/s)' },
1518
+ maxPages: { type: 'number', description: 'Pages to crawl (2-50; default 15)' },
1519
+ maxDepth: { type: 'number', description: 'Link depth from entry (1-5; default 2)' },
1520
+ includeContent: { type: 'boolean', description: 'Short body summary per page (default off; structure-only is token-cheap)' },
1521
+ maxCharsPerPage: { type: 'number', description: 'Summary chars per page (200-2000; default 500)' },
1522
+ },
1523
+ required: ['url'],
1524
+ },
1525
+ // Worst case: ceil(maxPages/2)=25 waves of paired fetches (no SPA render
1526
+ // here); the cooperative signal stops the loop early once the budget is hit.
1527
+ timeoutMs: Math.max(120000, cfg.timeoutMs * 25),
1528
+ // Crawl state (visited set, queue) is call-local; only the shared session
1529
+ // cache is global and commutative — parallel crawls of different hosts
1530
+ // are safe and useful (one task mapping several sites).
1531
+ isConcurrencySafe: () => true,
1532
+ output: {
1533
+ schema: {
1534
+ type: 'object',
1535
+ additionalProperties: true,
1536
+ properties: {
1537
+ host: { type: 'string' },
1538
+ total: { type: 'number' },
1539
+ succeeded: { type: 'number' },
1540
+ failed: { type: 'number' },
1541
+ pages: { type: 'array', items: { type: 'object' } },
1542
+ failures: { type: 'array', items: { type: 'object' } },
1543
+ error: { type: 'string' },
1544
+ },
1545
+ },
1546
+ render: (_args, value) => [{ type: 'text', text: renderSite(value) }],
1547
+ },
1548
+ async execute(args, exec) {
1549
+ const url = String((args && args.url) || '').trim()
1550
+ if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1551
+ const maxPages = Math.max(2, Math.min(50, Number(args.maxPages) || 15))
1552
+ const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
1553
+ const includeContent = args.includeContent === true
1554
+ const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
1555
+ const { host, pages, failures } = await crawlSite(
1556
+ url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal,
1557
+ )
1558
+ return {
1559
+ host,
1560
+ total: pages.length + failures.length,
1561
+ succeeded: pages.length,
1562
+ failed: failures.length,
1563
+ pages: pages.map((p) => {
1564
+ const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
1565
+ if (p.text) o.text = p.text
1566
+ return o
1567
+ }),
1568
+ failures,
1569
+ }
1570
+ },
1571
+ }
1572
+ }
1573
+
1574
+ export function apply(ctx, config) {
1575
+ // Plugin-level config from cordis.patch.yml (config row), merged over defaults.
1576
+ // YAML is free-form: numbers can arrive quoted as strings, which would poison
1577
+ // arithmetic and AbortSignal timeouts downstream — coerce to numbers once
1578
+ // here, clamp to sane ranges, and fall back to defaults on garbage.
1579
+ const cfg = { ...DEFAULTS, ...(config || {}) }
1580
+ const clamp = (v, d, min, max) => {
1581
+ const n = Number(v)
1582
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
1583
+ }
1584
+ cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
1585
+ cfg.maxBytes = clamp(cfg.maxBytes, DEFAULTS.maxBytes, 65536, 16 * 1024 * 1024)
1586
+ cfg.maxChars = clamp(cfg.maxChars, DEFAULTS.maxChars, 500, 20000)
1587
+ cfg.maxLinks = clamp(cfg.maxLinks, DEFAULTS.maxLinks, 1, 50)
1588
+ cfg.cacheTtlMs = clamp(cfg.cacheTtlMs, DEFAULTS.cacheTtlMs, 0, 3600000)
1589
+ cfg.cacheMax = clamp(cfg.cacheMax, DEFAULTS.cacheMax, 1, 256)
1590
+ cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
1591
+ if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
1592
+ if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
1593
+ if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
1594
+
1595
+ // Temporal composability: unload must fully revert side effects.
1596
+ ctx.effect(() => () => {
1597
+ cache.clear()
1598
+ failCache.clear()
1599
+ closeBrowser().catch(() => {})
1600
+ })
1601
+
1602
+ console.log('[dsh-read-url] plugin loaded; tools read_url, read_url_batch, read_url_links, read_url_site registered')
1603
+
1604
+ ctx.tools.register({
1605
+ name: 'read_url',
1606
+ description:
1607
+ 'Fetch a page and return its clean main content (auto charset detect). ' +
1608
+ 'text (default) = plain body capped at maxChars; markdown = headings/links/tables. ' +
1609
+ 'Compact block: title, metadata, truncated body; 5-min session cache; ' +
1610
+ 'SPA pages render when playwright installed; login walls are not accessible.',
1611
+ parameters: {
1612
+ type: 'object',
1613
+ additionalProperties: false,
1614
+ properties: {
1615
+ url: { type: 'string', description: 'http(s) URL to read' },
1616
+ maxChars: { type: 'number', description: 'Body text chars to return (500-20000; default from plugin config)' },
1617
+ offset: { type: 'number', description: 'Start at this char offset (continue long pages). Default 0; served from cache.' },
1618
+ mode: { type: 'string', enum: ['text', 'markdown'], description: 'text = plain (token-cheap, default); markdown = structured' },
1619
+ includeLinks: { type: 'boolean', description: 'Return page links too (title+url, bounded)' },
1620
+ },
1621
+ required: ['url'],
1622
+ },
1623
+ // Pipeline budget must cover the real worst case: first page fetch +
1624
+ // SPA render (goto 30s cap + DOM-stability poll 10s) + up to paginateMax
1625
+ // continuation fetches (static, no render). Derived from load-time cfg —
1626
+ // scale with it, never clamp it.
1627
+ timeoutMs: Math.max(45000, cfg.timeoutMs * (cfg.paginate === false ? 1 : cfg.paginateMax + 1) + 45000),
1628
+ // All shared state is commutative-safe under concurrent calls (session
1629
+ // cache Map get/set, decoder cache, one shared browser with isolated
1630
+ // pages per render), so the agent may fan out several read_url calls in
1631
+ // one parallel group — a real win for multi-source reading tasks.
1632
+ isConcurrencySafe: () => true,
1633
+ output: {
1634
+ schema: {
1635
+ type: 'object',
1636
+ additionalProperties: true,
1637
+ properties: {
1638
+ url: { type: 'string' },
1639
+ title: { type: 'string' },
1640
+ siteName: { type: 'string' },
1641
+ lang: { type: 'string' },
1642
+ charset: { type: 'string' },
1643
+ mode: { type: 'string' },
1644
+ truncated: { type: 'boolean' },
1645
+ charsTotal: { type: 'number' },
1646
+ charsReturned: { type: 'number' },
1647
+ text: { type: 'string' },
1648
+ links: { type: 'array', items: { type: 'object' } },
1649
+ cached: { type: 'boolean' },
1650
+ error: { type: 'string' },
1651
+ },
1652
+ },
1653
+ render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
1654
+ },
1655
+ async execute(args, exec) {
1656
+ return readUrl(args, ctx, exec && exec.signal, cfg)
1657
+ },
1658
+ })
1659
+
1660
+ ctx.tools.register(readLinksTool(ctx, cfg))
1661
+ ctx.tools.register(readUrlBatchTool(ctx, cfg))
1662
+ ctx.tools.register(readUrlSiteTool(ctx, cfg))
1663
+ }