dsh-read-url 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/README.zh.md +233 -0
- package/cordis.patch.yml +12 -0
- package/index.js +1059 -0
- package/package.json +40 -0
- package/proxy-fallback.js +106 -0
- package/spa.js +81 -0
package/index.js
ADDED
|
@@ -0,0 +1,1059 @@
|
|
|
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 (GBK/GB2312/UTF-8/Big5) via built-in TextDecoder
|
|
11
|
+
// - clean main-content extraction (heuristic; optional @mozilla/readability upgrade path)
|
|
12
|
+
// - HTML -> Markdown / plain text, paragraph-aligned smart truncation
|
|
13
|
+
// - session-level cache, compact text render (lowest token cost)
|
|
14
|
+
import { TextDecoder } from 'node:util'
|
|
15
|
+
import { looksLikeSpa, renderPage, closeBrowser } from './spa.js'
|
|
16
|
+
import { detectProxy, fetchViaCurlProxy } from './proxy-fallback.js'
|
|
17
|
+
// Re-export for tests / programmatic (PTC) cleanup.
|
|
18
|
+
export { closeBrowser, renderPage, looksLikeSpa } from './spa.js'
|
|
19
|
+
|
|
20
|
+
export const name = 'dsh-read-url'
|
|
21
|
+
export const inject = ['tools']
|
|
22
|
+
|
|
23
|
+
// Plugin-level configuration (overridable via cordis.patch.yml `config:` row).
|
|
24
|
+
const DEFAULTS = {
|
|
25
|
+
timeoutMs: 15000,
|
|
26
|
+
maxBytes: 3 * 1024 * 1024,
|
|
27
|
+
maxChars: 6000,
|
|
28
|
+
maxLinks: 20,
|
|
29
|
+
cacheTtlMs: 300000,
|
|
30
|
+
cacheMax: 32,
|
|
31
|
+
spaRender: true,
|
|
32
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cache = new Map()
|
|
36
|
+
const decoders = new Map()
|
|
37
|
+
|
|
38
|
+
function getDecoder(enc) {
|
|
39
|
+
let d = decoders.get(enc)
|
|
40
|
+
if (!d) {
|
|
41
|
+
try {
|
|
42
|
+
d = new TextDecoder(enc, { fatal: false })
|
|
43
|
+
} catch {
|
|
44
|
+
d = new TextDecoder('utf-8')
|
|
45
|
+
}
|
|
46
|
+
decoders.set(enc, d)
|
|
47
|
+
}
|
|
48
|
+
return d
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sniffCharset(buffer, contentType) {
|
|
52
|
+
const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
|
|
53
|
+
if (m) return m[1]
|
|
54
|
+
const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
|
|
55
|
+
const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)
|
|
56
|
+
if (meta) return meta[1]
|
|
57
|
+
if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function decodeBuffer(buffer, contentType) {
|
|
62
|
+
let enc = sniffCharset(buffer, contentType)
|
|
63
|
+
if (!enc) enc = 'utf-8'
|
|
64
|
+
enc = enc.toLowerCase().replace('gb2312', 'gbk').replace('gb_2312', 'gbk')
|
|
65
|
+
let text
|
|
66
|
+
try {
|
|
67
|
+
text = getDecoder(enc).decode(buffer)
|
|
68
|
+
} catch {
|
|
69
|
+
text = new TextDecoder('utf-8').decode(buffer)
|
|
70
|
+
}
|
|
71
|
+
if (enc !== 'utf-8' && enc !== 'utf8' && /\uFFFD/.test(text.slice(0, 2000))) {
|
|
72
|
+
const fixed = new TextDecoder('utf-8').decode(buffer)
|
|
73
|
+
if (!/\uFFFD/.test(fixed.slice(0, 2000))) return { text: fixed, charset: 'utf-8' }
|
|
74
|
+
}
|
|
75
|
+
return { text, charset: enc }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Direct-connect fetch, extracted so the race path can run it concurrently
|
|
79
|
+
// with the proxy attempt and abort it. Returns { buffer, contentType, finalUrl }
|
|
80
|
+
// on success or { error } — never throws (abort/timeout/network all mapped).
|
|
81
|
+
async function directFetch(url, signal, cfg) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
redirect: 'follow',
|
|
85
|
+
signal,
|
|
86
|
+
headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
|
|
87
|
+
})
|
|
88
|
+
if (!res.ok) return { error: `HTTP ${res.status} ${res.statusText}` }
|
|
89
|
+
const contentType = res.headers.get('content-type') || ''
|
|
90
|
+
if (contentType && !/text\/html|application\/xhtml|text\/plain/i.test(contentType)) {
|
|
91
|
+
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
92
|
+
}
|
|
93
|
+
const chunks = []
|
|
94
|
+
let size = 0
|
|
95
|
+
for await (const chunk of res.body) {
|
|
96
|
+
size += chunk.length
|
|
97
|
+
if (size > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes` }
|
|
98
|
+
chunks.push(chunk)
|
|
99
|
+
}
|
|
100
|
+
return { buffer: Buffer.concat(chunks), contentType, finalUrl: res.url }
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
103
|
+
return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
|
|
104
|
+
}
|
|
105
|
+
// undici wraps the real reason in e.cause (ENOTFOUND / ECONNREFUSED /
|
|
106
|
+
// connect-timeout / TLS) — surface it so the model can tell "bad domain"
|
|
107
|
+
// from "blocked network" and act accordingly instead of retrying blindly
|
|
108
|
+
const cause = e.cause && e.cause.message ? String(e.cause.message).slice(0, 90) : ''
|
|
109
|
+
return { error: cause || (e.message || '').slice(0, 120) }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Settle-all race that resolves with the FIRST SUCCESSFUL result (a value
|
|
114
|
+
// with a buffer and no error). If every attempt fails it resolves with
|
|
115
|
+
// { success:false, failures:[...] } once the slowest one has settled —
|
|
116
|
+
// the proxy channel (curl --max-time ~20s) is the natural ceiling, and the
|
|
117
|
+
// direct fetch aborts on the same total budget.
|
|
118
|
+
export function raceFirstSuccess(promises) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
if (promises.length === 0) {
|
|
121
|
+
resolve({ success: false, failures: [] })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const failures = []
|
|
125
|
+
let settled = 0
|
|
126
|
+
promises.forEach((p, i) => {
|
|
127
|
+
p.then((v) => {
|
|
128
|
+
if (v && !v.error && v.buffer) {
|
|
129
|
+
resolve({ success: true, value: v })
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
failures[i] = v
|
|
133
|
+
settled += 1
|
|
134
|
+
if (settled === promises.length) resolve({ success: false, failures })
|
|
135
|
+
}).catch((e) => {
|
|
136
|
+
failures[i] = { error: String((e && e.message) || e) }
|
|
137
|
+
settled += 1
|
|
138
|
+
if (settled === promises.length) resolve({ success: false, failures })
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
async function raceFetch(url, externalSignal, cfg, proxy) {
|
|
144
|
+
const directCtrl = new AbortController()
|
|
145
|
+
const proxyCtrl = new AbortController()
|
|
146
|
+
const withExt = (ctrl) =>
|
|
147
|
+
externalSignal ? AbortSignal.any([externalSignal, ctrl.signal]) : ctrl.signal
|
|
148
|
+
const directSignal = AbortSignal.any([withExt(directCtrl), AbortSignal.timeout(cfg.timeoutMs)])
|
|
149
|
+
const direct = directFetch(url, directSignal, cfg)
|
|
150
|
+
const proxied = fetchViaCurlProxy(url, cfg, withExt(proxyCtrl), proxy)
|
|
151
|
+
const winner = await raceFirstSuccess([direct, proxied])
|
|
152
|
+
directCtrl.abort()
|
|
153
|
+
proxyCtrl.abort()
|
|
154
|
+
return winner
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchPage(url, externalSignal, cfg) {
|
|
158
|
+
const proxy = await detectProxy()
|
|
159
|
+
if (proxy) {
|
|
160
|
+
const winner = await raceFetch(url, externalSignal, cfg, proxy)
|
|
161
|
+
if (winner.success) return winner.value
|
|
162
|
+
const dErr = winner.failures[0] && winner.failures[0].error
|
|
163
|
+
const pErr = winner.failures[1]
|
|
164
|
+
const px = pErr && pErr.error ? `,代理返回 ${pErr.error}` : ',代理未连通'
|
|
165
|
+
return { error: `Fetch failed: ${dErr}(直连失败;已尝试代理 ${proxy}${px})` }
|
|
166
|
+
}
|
|
167
|
+
// No proxy configured: plain direct connect (the original behaviour).
|
|
168
|
+
const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
|
|
169
|
+
const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
|
|
170
|
+
const r = await directFetch(url, signal, cfg)
|
|
171
|
+
if (r.error) return { error: `Fetch failed: ${r.error}(直连失败,未检测到代理配置)` }
|
|
172
|
+
return r
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Prefer the ctx.web capability seam (official docs/capability-seams.md):
|
|
176
|
+
// the web provider already decodes the document, so we skip our charset
|
|
177
|
+
// layer and only re-extract. Falls back to global fetch when absent.
|
|
178
|
+
// Seam calls get the same cooperative timeout as fetchPage (a hanging
|
|
179
|
+
// provider should not block the tool call forever).
|
|
180
|
+
async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
|
|
181
|
+
const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
|
|
182
|
+
if (!web || typeof web.fetch !== 'function') return null
|
|
183
|
+
try {
|
|
184
|
+
const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
|
|
185
|
+
const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
|
|
186
|
+
const res = await web.fetch(url, { signal })
|
|
187
|
+
if (!res || typeof res.content !== 'string') return null
|
|
188
|
+
const finalUrl = res.url || res.finalUrl || url
|
|
189
|
+
return { html: res.content, finalUrl }
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Decode common HTML entities in already-stripped text. Runs AFTER tag
|
|
196
|
+
// stripping so escaped tags stay visible to the stripper and only remaining
|
|
197
|
+
// text is decoded. Covers the numeric/hex forms plus the named entities that
|
|
198
|
+
// actually show up in CJK web text (spaces, dashes, quotes, symbols) —
|
|
199
|
+
// undecoded leftovers would both waste tokens and read as garbage.
|
|
200
|
+
const ENTITIES = {
|
|
201
|
+
nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '',
|
|
202
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
|
|
203
|
+
ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
|
|
204
|
+
lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201C', rdquo: '\u201D',
|
|
205
|
+
laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™',
|
|
206
|
+
deg: '°', plusmn: '±', times: '×', divide: '÷', micro: 'µ',
|
|
207
|
+
sect: '§', para: '¶', dagger: '†', prime: '′', Prime: '″',
|
|
208
|
+
permil: '‰', euro: '€', pound: '£', yen: '¥', cent: '¢',
|
|
209
|
+
sup2: '²', sup3: '³', frac12: '½', frac14: '¼', frac34: '¾',
|
|
210
|
+
}
|
|
211
|
+
export function decodeTextEntities(text) {
|
|
212
|
+
if (!text.includes('&')) return text
|
|
213
|
+
return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|([a-z][a-z0-9]{1,7}));?/gi, (m, dec, hex, name) => {
|
|
214
|
+
if (dec !== undefined) {
|
|
215
|
+
const c = Number(dec)
|
|
216
|
+
return c > 0x10ffff ? m : String.fromCodePoint(c)
|
|
217
|
+
}
|
|
218
|
+
if (hex !== undefined) {
|
|
219
|
+
const c = Number.parseInt(hex, 16)
|
|
220
|
+
return c > 0x10ffff ? m : String.fromCodePoint(c)
|
|
221
|
+
}
|
|
222
|
+
const n = (name || '').toLowerCase()
|
|
223
|
+
return Object.prototype.hasOwnProperty.call(ENTITIES, n) ? ENTITIES[n] : m
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function textOnly(html) {
|
|
228
|
+
return decodeTextEntities(html
|
|
229
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
230
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
231
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
232
|
+
.replace(/<textarea[\s\S]*?<\/textarea\s*>/gi, ' ')
|
|
233
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
|
234
|
+
.replace(/<[^>]+>/g, ' ')
|
|
235
|
+
.replace(/[ \t\r\n]+/g, ' ')
|
|
236
|
+
.trim())
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function pickMain(html) {
|
|
240
|
+
// Aggregation pages (e.g. blog homepages) have one <article> per item:
|
|
241
|
+
// collect all of them instead of only the first.
|
|
242
|
+
const articles = [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
|
|
243
|
+
if (articles.length) return articles.map((a) => a[0]).join('\n')
|
|
244
|
+
const mainTag = /<(main|div)[^>]*role=["']main["'][\s>][\s\S]*?<\/\1\s*>/i.exec(html)
|
|
245
|
+
if (mainTag) return mainTag[0]
|
|
246
|
+
const body = /<body[\s>]/i.exec(html)
|
|
247
|
+
if (body) {
|
|
248
|
+
const after = html.slice(body.index)
|
|
249
|
+
const end = after.search(/<\/body>/i)
|
|
250
|
+
return end > 0 ? after.slice(0, end) : after
|
|
251
|
+
}
|
|
252
|
+
return html
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Some sites (e.g. baidu.com) ship CSS/HTML inside hidden <textarea> with
|
|
256
|
+
// entity-escaped tags (<style>...). Reveal them so noise rules can strip
|
|
257
|
+
// them; then run noise removal again.
|
|
258
|
+
function revealEscapedTags(html) {
|
|
259
|
+
return html
|
|
260
|
+
.replace(/</gi, '<')
|
|
261
|
+
.replace(/>/gi, '>')
|
|
262
|
+
.replace(/"/gi, '"')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stripNoise(mainHtml) {
|
|
266
|
+
return mainHtml
|
|
267
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
268
|
+
.replace(/<(textarea|style|script|noscript|template)[\s>][\s\S]*?<\/\1\s*>/gi, ' ')
|
|
269
|
+
.replace(/<(nav|footer|header|aside|form|iframe|svg|canvas|dialog)[\s>][\s\S]*?<\/\1>/gi, ' ')
|
|
270
|
+
.replace(/<([a-z][a-z0-9]*)[^>]+class=["'][^"']*(ad-|ads|advert|banner|sidebar|social|share|comment|popup|modal|cookie)[^"']*["'][^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- HTML -> Markdown (lightweight, tag-state machine) ----
|
|
274
|
+
function escInline(s) {
|
|
275
|
+
return s.replace(/([\\`*_[\]])/g, '\\$1')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function inlineMd(html) {
|
|
279
|
+
let out = ''
|
|
280
|
+
const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
|
|
281
|
+
let m
|
|
282
|
+
while ((m = re.exec(html))) {
|
|
283
|
+
if (m[4] !== undefined) {
|
|
284
|
+
out += escInline(decodeTextEntities(m[4]))
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (!m[1]) continue
|
|
288
|
+
const tag = m[1].toLowerCase()
|
|
289
|
+
if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') continue
|
|
290
|
+
const inner = inlineMd(m[3])
|
|
291
|
+
if (tag === 'a') {
|
|
292
|
+
const href = /href=["']([^"']+)["']/i.exec(m[2])
|
|
293
|
+
out += href ? `[${inner}](${href[1]})` : inner
|
|
294
|
+
} else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
|
|
295
|
+
else if (tag === 'em' || tag === 'i') out += `*${inner}*`
|
|
296
|
+
else if (tag === 'code') out += `\`${inner}\``
|
|
297
|
+
else if (tag === 'br') out += ' \n'
|
|
298
|
+
else out += inner
|
|
299
|
+
}
|
|
300
|
+
return out.replace(/[ \t]+/g, ' ').trim()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function blockMd(html) {
|
|
304
|
+
let out = ''
|
|
305
|
+
const re = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1>|<[^>]+>|([^<]+)/g
|
|
306
|
+
let m
|
|
307
|
+
while ((m = re.exec(html))) {
|
|
308
|
+
if (m[4] !== undefined) {
|
|
309
|
+
out += decodeTextEntities(m[4])
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (!m[1]) continue
|
|
313
|
+
const tag = m[1].toLowerCase()
|
|
314
|
+
const attrs = m[2] || ''
|
|
315
|
+
const inner = m[3]
|
|
316
|
+
if (tag === 'style' || tag === 'script' || tag === 'textarea' || tag === 'template' || tag === 'noscript') {
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') {
|
|
320
|
+
const level = '#'.repeat(Number(tag[1]))
|
|
321
|
+
out += `\n\n${level} ${inlineMd(inner)}`
|
|
322
|
+
} else if (tag === 'p') {
|
|
323
|
+
const t = inlineMd(inner)
|
|
324
|
+
if (t) out += `\n\n${t}`
|
|
325
|
+
} else if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'main') {
|
|
326
|
+
const t = blockMd(inner)
|
|
327
|
+
if (t) out += `\n\n${t}`
|
|
328
|
+
} else if (tag === 'blockquote') {
|
|
329
|
+
const t = blockMd(inner).trim()
|
|
330
|
+
out += `\n\n> ${t.replace(/\n/g, '\n> ')}`
|
|
331
|
+
} else if (tag === 'pre') {
|
|
332
|
+
const code = inner.replace(/<[^>]+>/g, '').trim()
|
|
333
|
+
out += `\n\n\`\`\`\n${code}\n\`\`\``
|
|
334
|
+
} else if (tag === 'code') {
|
|
335
|
+
out += `\`${textOnly(inner)}\``
|
|
336
|
+
} else if (tag === 'ul' || tag === 'ol') {
|
|
337
|
+
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi
|
|
338
|
+
let lm, idx = 1, items = []
|
|
339
|
+
while ((lm = liRe.exec(inner))) {
|
|
340
|
+
const t = inlineMd(lm[1])
|
|
341
|
+
items.push(tag === 'ol' ? `${idx}. ${t}` : `- ${t}`)
|
|
342
|
+
idx++
|
|
343
|
+
}
|
|
344
|
+
if (items.length) out += `\n\n${items.join('\n')}`
|
|
345
|
+
} else if (tag === 'li') {
|
|
346
|
+
out += `\n- ${inlineMd(inner)}`
|
|
347
|
+
} else if (tag === 'br') {
|
|
348
|
+
out += ' \n'
|
|
349
|
+
} else if (tag === 'hr') {
|
|
350
|
+
out += '\n\n---'
|
|
351
|
+
} else if (tag === 'table') {
|
|
352
|
+
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
|
353
|
+
let tm, rows = []
|
|
354
|
+
while ((tm = trRe.exec(inner))) {
|
|
355
|
+
const cells = []
|
|
356
|
+
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
|
|
357
|
+
let cm
|
|
358
|
+
while ((cm = tdRe.exec(tm[1]))) cells.push(inlineMd(cm[1]).replace(/\|/g, '\\|'))
|
|
359
|
+
if (cells.length) rows.push(`| ${cells.join(' | ')} |`)
|
|
360
|
+
}
|
|
361
|
+
if (rows.length) {
|
|
362
|
+
const header = rows[0]
|
|
363
|
+
// unescape cells' escaped pipes before deriving the separator row,
|
|
364
|
+
// otherwise the --- line is one char wider per escaped pipe
|
|
365
|
+
const sep = header.replace(/\\\|/g, '|').replace(/[^|]/g, '-')
|
|
366
|
+
out += `\n\n${rows.join('\n')}\n${sep}`
|
|
367
|
+
}
|
|
368
|
+
} else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
|
|
369
|
+
const t = inlineMd(inner)
|
|
370
|
+
if (t) out += t
|
|
371
|
+
} else {
|
|
372
|
+
const t = blockMd(inner)
|
|
373
|
+
if (t) out += t
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return out
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function extract(html, mode) {
|
|
380
|
+
const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)
|
|
381
|
+
const siteName = /<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i.exec(html)
|
|
382
|
+
const langMatch = /<html[^>]+lang=["']([\w-]+)["']/i.exec(html)
|
|
383
|
+
let main = stripNoise(pickMain(html))
|
|
384
|
+
main = stripNoise(revealEscapedTags(main))
|
|
385
|
+
let bodyText
|
|
386
|
+
if (mode === 'markdown') {
|
|
387
|
+
bodyText = blockMd(main).replace(/\n{3,}/g, '\n\n').trim()
|
|
388
|
+
} else {
|
|
389
|
+
bodyText = textOnly(main).replace(/ +/g, ' ').trim()
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
title: titleMatch ? textOnly(titleMatch[1]) || titleMatch[1].trim() : '',
|
|
393
|
+
siteName: siteName ? siteName[1] : '',
|
|
394
|
+
lang: langMatch ? langMatch[1] : '',
|
|
395
|
+
text: bodyText,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Optional enhancement: when @mozilla/readability + happy-dom are installed
|
|
400
|
+
// inside the DSH profile (npm i @mozilla/readability happy-dom), extraction
|
|
401
|
+
// upgrades to the Firefox Reader Mode algorithm. Falls back to the built-in
|
|
402
|
+
// heuristic extractor when absent. MPL-2.0 / MIT libraries, used as-is.
|
|
403
|
+
let readabilityReady = null
|
|
404
|
+
async function getReadabilityExtractor() {
|
|
405
|
+
if (readabilityReady !== null) return readabilityReady
|
|
406
|
+
try {
|
|
407
|
+
const [{ Readability }, { Window }] = await Promise.all([
|
|
408
|
+
import('@mozilla/readability'),
|
|
409
|
+
import('happy-dom'),
|
|
410
|
+
])
|
|
411
|
+
readabilityReady = (html, url) => {
|
|
412
|
+
const window = new Window({ url })
|
|
413
|
+
window.document.write(html)
|
|
414
|
+
const article = new Readability(window.document).parse()
|
|
415
|
+
return article ? article.content : null
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
readabilityReady = false
|
|
419
|
+
}
|
|
420
|
+
return readabilityReady
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function smartTruncate(text, maxChars, offset = 0) {
|
|
424
|
+
const total = text.length
|
|
425
|
+
if (offset >= total) {
|
|
426
|
+
return { text: '', truncated: false, charsTotal: total, charsReturned: 0, charsStart: offset }
|
|
427
|
+
}
|
|
428
|
+
if (total <= maxChars && offset === 0) {
|
|
429
|
+
return { text, truncated: false, charsTotal: total, charsReturned: total, charsStart: 0 }
|
|
430
|
+
}
|
|
431
|
+
const paragraphs = text.split(/\n\n+/)
|
|
432
|
+
let pos = 0
|
|
433
|
+
let start = 0
|
|
434
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
435
|
+
if (pos + paragraphs[i].length > offset) {
|
|
436
|
+
start = i
|
|
437
|
+
break
|
|
438
|
+
}
|
|
439
|
+
pos += paragraphs[i].length + 2
|
|
440
|
+
}
|
|
441
|
+
let acc = ''
|
|
442
|
+
for (let i = start; i < paragraphs.length; i++) {
|
|
443
|
+
const p = paragraphs[i]
|
|
444
|
+
if (acc.length + p.length + (acc ? 2 : 0) > maxChars) break
|
|
445
|
+
acc += (acc ? '\n\n' : '') + p
|
|
446
|
+
}
|
|
447
|
+
if (!acc && maxChars > 0) acc = text.slice(offset, offset + maxChars)
|
|
448
|
+
return {
|
|
449
|
+
text: acc,
|
|
450
|
+
truncated: offset + acc.length < total,
|
|
451
|
+
charsTotal: total,
|
|
452
|
+
charsReturned: acc.length,
|
|
453
|
+
charsStart: offset,
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function extractLinks(html, limit, baseUrl) {
|
|
458
|
+
const links = []
|
|
459
|
+
const seen = new Set()
|
|
460
|
+
// Accept relative hrefs too — most real pages link internally with relative
|
|
461
|
+
// paths — and resolve them against the page URL so the model can follow them.
|
|
462
|
+
const re = /<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi
|
|
463
|
+
let m
|
|
464
|
+
// Iteration cap: a page whose nav repeats the same links thousands of times
|
|
465
|
+
// would otherwise scan the whole HTML to fill `limit` unique entries. Scan
|
|
466
|
+
// at most limit*4 anchors; dedupe keeps the result bounded regardless.
|
|
467
|
+
let scans = 0
|
|
468
|
+
const maxScans = limit * 4
|
|
469
|
+
while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
|
|
470
|
+
const href = m[1].trim()
|
|
471
|
+
if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
|
|
472
|
+
let url
|
|
473
|
+
try {
|
|
474
|
+
url = baseUrl ? new URL(href, baseUrl).href : new URL(href).href
|
|
475
|
+
} catch {
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
478
|
+
// dedupe: nav bars repeat the same links many times — one entry per URL
|
|
479
|
+
// keeps the link list compact (tokens) without losing coverage
|
|
480
|
+
if (seen.has(url)) continue
|
|
481
|
+
seen.add(url)
|
|
482
|
+
const t = textOnly(m[2])
|
|
483
|
+
if (t) links.push({ title: t.slice(0, 80), url })
|
|
484
|
+
}
|
|
485
|
+
return links
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function hostOf(url) {
|
|
489
|
+
try {
|
|
490
|
+
return new URL(url).hostname
|
|
491
|
+
} catch {
|
|
492
|
+
return ''
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function sliceFrom(full, offset, maxChars) {
|
|
497
|
+
const s = smartTruncate(full.fullText, maxChars, offset)
|
|
498
|
+
const out = {
|
|
499
|
+
url: full.url,
|
|
500
|
+
title: full.title || '',
|
|
501
|
+
siteName: full.siteName || hostOf(full.url),
|
|
502
|
+
lang: full.lang || '',
|
|
503
|
+
charset: full.charset,
|
|
504
|
+
mode: full.mode,
|
|
505
|
+
truncated: s.truncated,
|
|
506
|
+
charsTotal: s.charsTotal,
|
|
507
|
+
charsReturned: s.charsReturned,
|
|
508
|
+
charsStart: s.charsStart,
|
|
509
|
+
text: s.text,
|
|
510
|
+
}
|
|
511
|
+
if (full.rendered) out.rendered = true
|
|
512
|
+
if (full.spaHint) out.spaHint = full.spaHint
|
|
513
|
+
if (Array.isArray(full.links)) out.links = full.links
|
|
514
|
+
return out
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
518
|
+
const url = String((args && args.url) || '').trim()
|
|
519
|
+
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
520
|
+
const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
|
|
521
|
+
const mode = args.mode === 'markdown' ? 'markdown' : 'text'
|
|
522
|
+
const offset = Math.max(0, Number(args.offset) || 0)
|
|
523
|
+
|
|
524
|
+
// Cache stores the FULL extracted text keyed by url+mode+includeLinks, so
|
|
525
|
+
// continuation reads (offset) and different maxChars hit the same entry —
|
|
526
|
+
// but a links-request must not be served from a links-less cached copy.
|
|
527
|
+
// The key drops the URL fragment: #sections would otherwise split the cache.
|
|
528
|
+
let cacheUrl = url
|
|
529
|
+
try {
|
|
530
|
+
const u = new URL(url)
|
|
531
|
+
u.hash = ''
|
|
532
|
+
cacheUrl = u.href
|
|
533
|
+
} catch {
|
|
534
|
+
/* keep raw url on parse failure */
|
|
535
|
+
}
|
|
536
|
+
const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
|
|
537
|
+
const hit = cache.get(cacheKey)
|
|
538
|
+
if (hit) {
|
|
539
|
+
// Failed fetches are cached briefly so the model doesn't re-request a
|
|
540
|
+
// broken URL in a loop (token + latency saver).
|
|
541
|
+
if (hit.error) {
|
|
542
|
+
if (Date.now() - hit.time < 30000) return { error: hit.error, cached: true }
|
|
543
|
+
cache.delete(cacheKey)
|
|
544
|
+
} else if (Date.now() - hit.time < cfg.cacheTtlMs) {
|
|
545
|
+
const sliced = sliceFrom(hit.full, offset, maxChars)
|
|
546
|
+
return { ...sliced, cached: true }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let html, finalUrl, charset
|
|
551
|
+
const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
|
|
552
|
+
if (viaSeam) {
|
|
553
|
+
html = viaSeam.html
|
|
554
|
+
finalUrl = viaSeam.finalUrl
|
|
555
|
+
charset = 'provider-decoded'
|
|
556
|
+
} else {
|
|
557
|
+
const page = await fetchPage(url, externalSignal, cfg)
|
|
558
|
+
if (page.error) {
|
|
559
|
+
// Brief error cache: a failing URL usually stays failing for a while,
|
|
560
|
+
// so serve the same error from cache instead of re-fetching.
|
|
561
|
+
if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
|
|
562
|
+
cache.set(cacheKey, { time: Date.now(), error: page.error })
|
|
563
|
+
return { error: page.error }
|
|
564
|
+
}
|
|
565
|
+
const decoded = decodeBuffer(page.buffer, page.contentType)
|
|
566
|
+
html = decoded.text
|
|
567
|
+
finalUrl = page.finalUrl || url
|
|
568
|
+
charset = decoded.charset
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let extracted = extract(html, mode)
|
|
572
|
+
// Optional readability upgrade: cleaner article extraction when installed.
|
|
573
|
+
const ext = await getReadabilityExtractor()
|
|
574
|
+
const upgrade = (target, htmlText) => {
|
|
575
|
+
if (!ext) return target
|
|
576
|
+
try {
|
|
577
|
+
const clean = ext(htmlText, url)
|
|
578
|
+
if (clean && clean.length > 0) {
|
|
579
|
+
target.text = mode === 'markdown'
|
|
580
|
+
? blockMd(clean).replace(/\n{3,}/g, '\n\n').trim()
|
|
581
|
+
: textOnly(clean).replace(/ +/g, ' ').trim()
|
|
582
|
+
}
|
|
583
|
+
} catch {
|
|
584
|
+
// keep heuristic result
|
|
585
|
+
}
|
|
586
|
+
return target
|
|
587
|
+
}
|
|
588
|
+
extracted = upgrade(extracted, html)
|
|
589
|
+
|
|
590
|
+
// Optional SPA enhancement: if the page looks client-rendered and static
|
|
591
|
+
// extraction found almost nothing, try headless rendering (playwright).
|
|
592
|
+
let rendered = false
|
|
593
|
+
let spaHint = ''
|
|
594
|
+
let renderedHtml = null
|
|
595
|
+
if (cfg.spaRender !== false && looksLikeSpa(html) && (!extracted.text || extracted.text.length < 200)) {
|
|
596
|
+
const rr = await renderPage(finalUrl || url, externalSignal)
|
|
597
|
+
if (rr.html) {
|
|
598
|
+
renderedHtml = rr.html
|
|
599
|
+
const r2 = upgrade(extract(rr.html, mode), rr.html)
|
|
600
|
+
if (r2.text && r2.text.length > extracted.text.length + 50) {
|
|
601
|
+
extracted = r2
|
|
602
|
+
finalUrl = rr.finalUrl || finalUrl
|
|
603
|
+
rendered = true
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
spaHint = rr.error
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const full = {
|
|
611
|
+
url: finalUrl,
|
|
612
|
+
title: extracted.title || '',
|
|
613
|
+
siteName: extracted.siteName || '',
|
|
614
|
+
lang: extracted.lang || '',
|
|
615
|
+
charset,
|
|
616
|
+
mode,
|
|
617
|
+
fullText: extracted.text,
|
|
618
|
+
rendered,
|
|
619
|
+
spaHint,
|
|
620
|
+
// Links come from the rendered DOM when available — otherwise the SPA
|
|
621
|
+
// chain (content -> links -> next page) would break for JS-only pages.
|
|
622
|
+
links: args.includeLinks === true ? extractLinks(renderedHtml || html, cfg.maxLinks, finalUrl) : undefined,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (cache.size >= cfg.cacheMax) cache.delete(cache.keys().next().value)
|
|
626
|
+
cache.set(cacheKey, { time: Date.now(), full })
|
|
627
|
+
return sliceFrom(full, offset, maxChars)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function renderResult(value) {
|
|
631
|
+
if (typeof value === 'string') return value
|
|
632
|
+
const r = value || {}
|
|
633
|
+
if (r.error) return `Error: ${r.error}`
|
|
634
|
+
const lines = []
|
|
635
|
+
if (r.title) lines.push(`title: ${r.title}`)
|
|
636
|
+
const host = hostOf(r.url || '')
|
|
637
|
+
const meta = []
|
|
638
|
+
if (r.siteName && r.siteName !== host) meta.push(r.siteName)
|
|
639
|
+
if (r.lang) meta.push(r.lang)
|
|
640
|
+
if (r.charset) meta.push(`charset ${r.charset}`)
|
|
641
|
+
if (meta.length) lines.push(meta.join(' · '))
|
|
642
|
+
if (r.charsStart > 0) {
|
|
643
|
+
lines.push(`(chars ${r.charsStart}+${r.charsReturned}/${r.charsTotal} — offset 续读)`)
|
|
644
|
+
} else if (r.truncated) {
|
|
645
|
+
lines.push(`(chars ${r.charsReturned}/${r.charsTotal} — 截断,offset 续读)`)
|
|
646
|
+
} else if (typeof r.charsTotal === 'number') {
|
|
647
|
+
lines.push(`(chars ${r.charsTotal})`)
|
|
648
|
+
}
|
|
649
|
+
if (r.cached) lines.push('(cached)')
|
|
650
|
+
if (r.rendered) lines.push('(rendered — JS 执行后内容)')
|
|
651
|
+
if (!r.text) {
|
|
652
|
+
lines.push(r.spaHint
|
|
653
|
+
? `(无可读内容 — ${r.spaHint})`
|
|
654
|
+
: '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
|
|
655
|
+
}
|
|
656
|
+
lines.push('', r.text || '')
|
|
657
|
+
if (Array.isArray(r.links) && r.links.length) {
|
|
658
|
+
lines.push('', 'links:')
|
|
659
|
+
for (const l of r.links) lines.push(`- ${l.title} — ${l.url}`)
|
|
660
|
+
}
|
|
661
|
+
return lines.join('\n')
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function renderLinks(value) {
|
|
665
|
+
if (typeof value === 'string') return value
|
|
666
|
+
const r = value || {}
|
|
667
|
+
if (r.error) return `Error: ${r.error}`
|
|
668
|
+
if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}`
|
|
669
|
+
const lines = [`${r.count} link(s) on ${r.url}:`]
|
|
670
|
+
for (const l of r.links) lines.push(`- ${l.title || l.url} — ${l.url}`)
|
|
671
|
+
return lines.join('\n')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function readLinksTool(ctx, cfg) {
|
|
675
|
+
return {
|
|
676
|
+
name: 'read_url_links',
|
|
677
|
+
description:
|
|
678
|
+
'List links (text + URL) on a page without body text — lighter than read_url ' +
|
|
679
|
+
'for mapping what a page points to. Renders SPA pages when playwright installed.',
|
|
680
|
+
parameters: {
|
|
681
|
+
type: 'object',
|
|
682
|
+
additionalProperties: false,
|
|
683
|
+
properties: {
|
|
684
|
+
url: { type: 'string', description: 'http(s) URL to scan for links' },
|
|
685
|
+
limit: { type: 'number', description: 'Links to return (1-50; default from plugin config)' },
|
|
686
|
+
},
|
|
687
|
+
required: ['url'],
|
|
688
|
+
},
|
|
689
|
+
timeoutMs: 20000,
|
|
690
|
+
output: {
|
|
691
|
+
schema: {
|
|
692
|
+
type: 'object',
|
|
693
|
+
additionalProperties: true,
|
|
694
|
+
properties: {
|
|
695
|
+
url: { type: 'string' },
|
|
696
|
+
count: { type: 'number' },
|
|
697
|
+
links: { type: 'array', items: { type: 'object' } },
|
|
698
|
+
error: { type: 'string' },
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
render: (_args, value) => [{ type: 'text', text: renderLinks(value) }],
|
|
702
|
+
},
|
|
703
|
+
async execute(args, exec) {
|
|
704
|
+
const url = String((args && args.url) || '').trim()
|
|
705
|
+
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
706
|
+
const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
|
|
707
|
+
const viaSeam = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg)
|
|
708
|
+
let html, finalUrl
|
|
709
|
+
if (viaSeam) {
|
|
710
|
+
html = viaSeam.html
|
|
711
|
+
finalUrl = viaSeam.finalUrl
|
|
712
|
+
} else {
|
|
713
|
+
const page = await fetchPage(url, exec && exec.signal, cfg)
|
|
714
|
+
if (page.error) return { error: page.error }
|
|
715
|
+
html = decodeBuffer(page.buffer, page.contentType).text
|
|
716
|
+
finalUrl = page.finalUrl || url
|
|
717
|
+
}
|
|
718
|
+
let links = extractLinks(html, limit, finalUrl)
|
|
719
|
+
// SPA fallback: a JS-only page yields no links from its static HTML;
|
|
720
|
+
// render it and re-extract when the static result looks empty.
|
|
721
|
+
if (links.length < 3 && cfg.spaRender !== false && looksLikeSpa(html)) {
|
|
722
|
+
const rr = await renderPage(finalUrl || url, exec && exec.signal)
|
|
723
|
+
if (rr.html) {
|
|
724
|
+
const rl = extractLinks(rr.html, limit, rr.finalUrl || finalUrl)
|
|
725
|
+
if (rl.length > links.length) {
|
|
726
|
+
links = rl
|
|
727
|
+
finalUrl = rr.finalUrl || finalUrl
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { url: finalUrl, count: links.length, links }
|
|
732
|
+
},
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Run async tasks with a concurrency cap (avoids hammering a site / getting
|
|
737
|
+
// rate-limited when reading many URLs at once).
|
|
738
|
+
async function mapLimit(items, limit, fn) {
|
|
739
|
+
const out = new Array(items.length)
|
|
740
|
+
let idx = 0
|
|
741
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
742
|
+
while (idx < items.length) {
|
|
743
|
+
const i = idx++
|
|
744
|
+
out[i] = await fn(items[i], i)
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
await Promise.all(workers)
|
|
748
|
+
return out
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function renderBatch(value) {
|
|
752
|
+
if (typeof value === 'string') return value
|
|
753
|
+
const v = value || {}
|
|
754
|
+
if (v.error) return `Error: ${v.error}`
|
|
755
|
+
const lines = [`读取 ${v.succeeded}/${v.total} 页成功${v.failed ? `,${v.failed} 页失败` : ''}`]
|
|
756
|
+
for (const p of v.pages) {
|
|
757
|
+
if (p.error) {
|
|
758
|
+
lines.push('', `[失败] ${p.url} — ${p.error}`)
|
|
759
|
+
continue
|
|
760
|
+
}
|
|
761
|
+
const head = p.title || p.url
|
|
762
|
+
lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
|
|
763
|
+
if (Array.isArray(p.links) && p.links.length) {
|
|
764
|
+
const ls = p.links.map((l) => l.title + ' — ' + l.url)
|
|
765
|
+
lines.push(`links: ${ls.slice(0, 6).join(' | ')}${ls.length > 6 ? ` …共${ls.length}个` : ''}`)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return lines.join('\n')
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function readUrlBatchTool(ctx, cfg) {
|
|
772
|
+
return {
|
|
773
|
+
name: 'read_url_batch',
|
|
774
|
+
description:
|
|
775
|
+
'Read multiple URLs in parallel: each a compact clean block (same extraction + cache as read_url). ' +
|
|
776
|
+
'Failures isolated and tagged. Max 10 URLs, capped at maxChars per page.',
|
|
777
|
+
parameters: {
|
|
778
|
+
type: 'object',
|
|
779
|
+
additionalProperties: false,
|
|
780
|
+
properties: {
|
|
781
|
+
urls: { type: 'array', items: { type: 'string' }, description: 'URLs to read in parallel (1-10, http/s)' },
|
|
782
|
+
maxChars: { type: 'number', description: 'Chars per page (500-20000; default 3000)' },
|
|
783
|
+
mode: { type: 'string', enum: ['text', 'markdown'], description: 'text = plain (token-cheap, default); markdown = structured' },
|
|
784
|
+
includeLinks: { type: 'boolean', description: 'Return page links too (title+url, bounded)' },
|
|
785
|
+
},
|
|
786
|
+
required: ['urls'],
|
|
787
|
+
},
|
|
788
|
+
timeoutMs: Math.max(30000, cfg.timeoutMs + 15000),
|
|
789
|
+
output: {
|
|
790
|
+
schema: {
|
|
791
|
+
type: 'object',
|
|
792
|
+
additionalProperties: true,
|
|
793
|
+
properties: {
|
|
794
|
+
total: { type: 'number' },
|
|
795
|
+
succeeded: { type: 'number' },
|
|
796
|
+
failed: { type: 'number' },
|
|
797
|
+
pages: { type: 'array', items: { type: 'object' } },
|
|
798
|
+
error: { type: 'string' },
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
render: (_args, value) => [{ type: 'text', text: renderBatch(value) }],
|
|
802
|
+
},
|
|
803
|
+
async execute(args, exec) {
|
|
804
|
+
const a = args || {}
|
|
805
|
+
const urls = (Array.isArray(a.urls) ? a.urls : []).map((u) => String(u).trim()).filter(Boolean)
|
|
806
|
+
if (!urls.length) return { error: 'urls array is required (1-10 http(s) URLs)' }
|
|
807
|
+
const list = urls.slice(0, 10)
|
|
808
|
+
const perMax = Math.max(500, Math.min(20000, Number(a.maxChars) || 3000))
|
|
809
|
+
const mode = a.mode === 'markdown' ? 'markdown' : 'text'
|
|
810
|
+
const signal = exec && exec.signal
|
|
811
|
+
const pages = await mapLimit(list, 4, (u) =>
|
|
812
|
+
readUrl({ url: u, maxChars: perMax, mode, includeLinks: a.includeLinks === true }, ctx, signal, cfg),
|
|
813
|
+
)
|
|
814
|
+
const ok = pages.filter((p) => !p.error)
|
|
815
|
+
return {
|
|
816
|
+
total: list.length,
|
|
817
|
+
succeeded: ok.length,
|
|
818
|
+
failed: list.length - ok.length,
|
|
819
|
+
pages: pages.map((p, i) => {
|
|
820
|
+
if (p.error) return { url: list[i], error: p.error }
|
|
821
|
+
const out = {
|
|
822
|
+
url: p.url,
|
|
823
|
+
title: p.title || '',
|
|
824
|
+
chars: p.charsReturned,
|
|
825
|
+
cached: p.cached === true,
|
|
826
|
+
text: p.text,
|
|
827
|
+
}
|
|
828
|
+
if (Array.isArray(p.links) && p.links.length) out.links = p.links
|
|
829
|
+
return out
|
|
830
|
+
}),
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function normalizeUrl(url) {
|
|
837
|
+
try {
|
|
838
|
+
const u = new URL(url)
|
|
839
|
+
u.hash = ''
|
|
840
|
+
return u.href
|
|
841
|
+
} catch {
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// b is a bare hostname (hostOf() output) — must NOT be passed to new URL()
|
|
847
|
+
// as a full URL (that throws and would make every link look external).
|
|
848
|
+
function sameHost(a, b) {
|
|
849
|
+
try {
|
|
850
|
+
return new URL(a).hostname === b
|
|
851
|
+
} catch {
|
|
852
|
+
return false
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
|
|
857
|
+
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
|
|
858
|
+
const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
|
|
859
|
+
function isNoiseUrl(url) {
|
|
860
|
+
return NOISE_EXT.test(url) || NOISE_PATH.test(url)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// BFS crawl of a single site. Only same-host http(s) pages are followed;
|
|
864
|
+
// static assets / auth paths are skipped; visited URLs are deduped; a per-page
|
|
865
|
+
// failure is recorded and does not abort the crawl. No SPA rendering here —
|
|
866
|
+
// that is read_url's job; crawling favors speed and breadth.
|
|
867
|
+
async function crawlSite(entryUrl, cfg, opts, externalSignal) {
|
|
868
|
+
const { maxPages, maxDepth, includeContent, perMax } = opts
|
|
869
|
+
const host = hostOf(entryUrl)
|
|
870
|
+
const visited = new Set()
|
|
871
|
+
const pages = []
|
|
872
|
+
const failures = []
|
|
873
|
+
const queue = [{ url: entryUrl, depth: 0 }]
|
|
874
|
+
while (queue.length && pages.length < maxPages) {
|
|
875
|
+
// Never overshoot the page budget within a batch round.
|
|
876
|
+
const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
|
|
877
|
+
const results = await Promise.all(
|
|
878
|
+
batch.map(async ({ url, depth }) => {
|
|
879
|
+
const norm = normalizeUrl(url)
|
|
880
|
+
if (!norm || visited.has(norm)) return null
|
|
881
|
+
visited.add(norm)
|
|
882
|
+
const page = await fetchPage(url, externalSignal, cfg)
|
|
883
|
+
if (page.error) return { url, depth, error: page.error }
|
|
884
|
+
const html = decodeBuffer(page.buffer, page.contentType).text
|
|
885
|
+
const ex = extract(html, 'text')
|
|
886
|
+
const links = extractLinks(html, 100, page.finalUrl || url)
|
|
887
|
+
return {
|
|
888
|
+
url: page.finalUrl || url,
|
|
889
|
+
depth,
|
|
890
|
+
title: ex.title || '',
|
|
891
|
+
chars: ex.text.length,
|
|
892
|
+
text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
|
|
893
|
+
links,
|
|
894
|
+
}
|
|
895
|
+
}),
|
|
896
|
+
)
|
|
897
|
+
for (const r of results) {
|
|
898
|
+
if (!r) continue
|
|
899
|
+
if (r.error) {
|
|
900
|
+
failures.push({ url: r.url, error: r.error })
|
|
901
|
+
continue
|
|
902
|
+
}
|
|
903
|
+
pages.push(r)
|
|
904
|
+
if (r.depth + 1 <= maxDepth) {
|
|
905
|
+
// Queue cap: a huge site (millions of links) would otherwise grow the
|
|
906
|
+
// queue unboundedly while maxPages bounds only what we *process*.
|
|
907
|
+
if (queue.length >= maxPages * 20) continue
|
|
908
|
+
for (const l of r.links) {
|
|
909
|
+
if (!sameHost(l.url, host) || isNoiseUrl(l.url)) continue
|
|
910
|
+
const n = normalizeUrl(l.url)
|
|
911
|
+
if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1 })
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { host, pages, failures }
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function renderSite(value) {
|
|
920
|
+
if (typeof value === 'string') return value
|
|
921
|
+
const v = value || {}
|
|
922
|
+
if (v.error) return `Error: ${v.error}`
|
|
923
|
+
const lines = [`站点: ${v.host} · 爬取 ${v.succeeded}/${v.total} 页${v.failed ? ` · ${v.failed} 页失败` : ''}`]
|
|
924
|
+
for (const p of v.pages) {
|
|
925
|
+
const indent = ' '.repeat(p.depth)
|
|
926
|
+
const head = p.title || p.url
|
|
927
|
+
lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
|
|
928
|
+
if (p.text) lines.push(`${indent} ${p.text.slice(0, 80)}`)
|
|
929
|
+
}
|
|
930
|
+
for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
|
|
931
|
+
return lines.join('\n')
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function readUrlSiteTool(ctx, cfg) {
|
|
935
|
+
return {
|
|
936
|
+
name: 'read_url_site',
|
|
937
|
+
description:
|
|
938
|
+
'Crawl a site BFS from one URL: dedupe same-host pages, return a compact site map ' +
|
|
939
|
+
'(title + depth + size). Auth/static paths skipped; failures isolated. ' +
|
|
940
|
+
'includeContent=true attaches a short summary per page (default off, token-cheap). ' +
|
|
941
|
+
'Does not render SPA pages (use read_url for that).',
|
|
942
|
+
parameters: {
|
|
943
|
+
type: 'object',
|
|
944
|
+
additionalProperties: false,
|
|
945
|
+
properties: {
|
|
946
|
+
url: { type: 'string', description: 'Entry URL of the site to crawl (http/s)' },
|
|
947
|
+
maxPages: { type: 'number', description: 'Pages to crawl (2-50; default 15)' },
|
|
948
|
+
maxDepth: { type: 'number', description: 'Link depth from entry (1-5; default 2)' },
|
|
949
|
+
includeContent: { type: 'boolean', description: 'Short body summary per page (default off; structure-only is token-cheap)' },
|
|
950
|
+
maxCharsPerPage: { type: 'number', description: 'Summary chars per page (200-2000; default 500)' },
|
|
951
|
+
},
|
|
952
|
+
required: ['url'],
|
|
953
|
+
},
|
|
954
|
+
timeoutMs: 120000,
|
|
955
|
+
output: {
|
|
956
|
+
schema: {
|
|
957
|
+
type: 'object',
|
|
958
|
+
additionalProperties: true,
|
|
959
|
+
properties: {
|
|
960
|
+
host: { type: 'string' },
|
|
961
|
+
total: { type: 'number' },
|
|
962
|
+
succeeded: { type: 'number' },
|
|
963
|
+
failed: { type: 'number' },
|
|
964
|
+
pages: { type: 'array', items: { type: 'object' } },
|
|
965
|
+
failures: { type: 'array', items: { type: 'object' } },
|
|
966
|
+
error: { type: 'string' },
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
render: (_args, value) => [{ type: 'text', text: renderSite(value) }],
|
|
970
|
+
},
|
|
971
|
+
async execute(args, exec) {
|
|
972
|
+
const url = String((args && args.url) || '').trim()
|
|
973
|
+
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
974
|
+
const maxPages = Math.max(2, Math.min(50, Number(args.maxPages) || 15))
|
|
975
|
+
const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
|
|
976
|
+
const includeContent = args.includeContent === true
|
|
977
|
+
const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
|
|
978
|
+
const { host, pages, failures } = await crawlSite(
|
|
979
|
+
url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal,
|
|
980
|
+
)
|
|
981
|
+
return {
|
|
982
|
+
host,
|
|
983
|
+
total: pages.length + failures.length,
|
|
984
|
+
succeeded: pages.length,
|
|
985
|
+
failed: failures.length,
|
|
986
|
+
pages: pages.map((p) => {
|
|
987
|
+
const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
|
|
988
|
+
if (p.text) o.text = p.text
|
|
989
|
+
return o
|
|
990
|
+
}),
|
|
991
|
+
failures,
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export function apply(ctx, config) {
|
|
998
|
+
// Plugin-level config from cordis.patch.yml (config row), merged over defaults.
|
|
999
|
+
const cfg = { ...DEFAULTS, ...(config || {}) }
|
|
1000
|
+
|
|
1001
|
+
// Temporal composability: unload must fully revert side effects.
|
|
1002
|
+
ctx.effect(() => () => {
|
|
1003
|
+
cache.clear()
|
|
1004
|
+
closeBrowser().catch(() => {})
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
console.log('[dsh-read-url] plugin loaded; tools read_url, read_url_batch, read_url_links, read_url_site registered')
|
|
1008
|
+
|
|
1009
|
+
ctx.tools.register({
|
|
1010
|
+
name: 'read_url',
|
|
1011
|
+
description:
|
|
1012
|
+
'Fetch a page and return its clean main content (auto charset detect). ' +
|
|
1013
|
+
'text (default) = plain body capped at maxChars; markdown = headings/links/tables. ' +
|
|
1014
|
+
'Compact block: title, metadata, truncated body; 5-min session cache; ' +
|
|
1015
|
+
'SPA pages render when playwright installed; login walls are not accessible.',
|
|
1016
|
+
parameters: {
|
|
1017
|
+
type: 'object',
|
|
1018
|
+
additionalProperties: false,
|
|
1019
|
+
properties: {
|
|
1020
|
+
url: { type: 'string', description: 'http(s) URL to read' },
|
|
1021
|
+
maxChars: { type: 'number', description: 'Body text chars to return (500-20000; default from plugin config)' },
|
|
1022
|
+
offset: { type: 'number', description: 'Start at this char offset (continue long pages). Default 0; served from cache.' },
|
|
1023
|
+
mode: { type: 'string', enum: ['text', 'markdown'], description: 'text = plain (token-cheap, default); markdown = structured' },
|
|
1024
|
+
includeLinks: { type: 'boolean', description: 'Return page links too (title+url, bounded)' },
|
|
1025
|
+
},
|
|
1026
|
+
required: ['url'],
|
|
1027
|
+
},
|
|
1028
|
+
timeoutMs: Math.max(5000, cfg.timeoutMs + 5000),
|
|
1029
|
+
output: {
|
|
1030
|
+
schema: {
|
|
1031
|
+
type: 'object',
|
|
1032
|
+
additionalProperties: true,
|
|
1033
|
+
properties: {
|
|
1034
|
+
url: { type: 'string' },
|
|
1035
|
+
title: { type: 'string' },
|
|
1036
|
+
siteName: { type: 'string' },
|
|
1037
|
+
lang: { type: 'string' },
|
|
1038
|
+
charset: { type: 'string' },
|
|
1039
|
+
mode: { type: 'string' },
|
|
1040
|
+
truncated: { type: 'boolean' },
|
|
1041
|
+
charsTotal: { type: 'number' },
|
|
1042
|
+
charsReturned: { type: 'number' },
|
|
1043
|
+
text: { type: 'string' },
|
|
1044
|
+
links: { type: 'array', items: { type: 'object' } },
|
|
1045
|
+
cached: { type: 'boolean' },
|
|
1046
|
+
error: { type: 'string' },
|
|
1047
|
+
},
|
|
1048
|
+
},
|
|
1049
|
+
render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
|
|
1050
|
+
},
|
|
1051
|
+
async execute(args, exec) {
|
|
1052
|
+
return readUrl(args, ctx, exec && exec.signal, cfg)
|
|
1053
|
+
},
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
ctx.tools.register(readLinksTool(ctx, cfg))
|
|
1057
|
+
ctx.tools.register(readUrlBatchTool(ctx, cfg))
|
|
1058
|
+
ctx.tools.register(readUrlSiteTool(ctx, cfg))
|
|
1059
|
+
}
|