dsh-scrape 0.0.1 → 0.1.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/lib/scrape.mjs ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * 编排层:URL / HTML → `{markdown, title, provenance, removed, warnings}`。
3
+ *
4
+ * 一次抓取按固定顺序做八件事,任何一步都能单独关掉或说清:
5
+ * 1) 校验 URL;
6
+ * 2) 读 robots.txt 并判定(robots:false 才跳过,且必须警告);
7
+ * 3) 同域限速排队,抓页面(手动跟随重定向、记 provenance);
8
+ * 4) 认字符集解码(GBK/Big5 不再乱码);
9
+ * 5) 按行/元素去噪(script/style/nav/footer/cookie 条/分享按钮);
10
+ * 6) 密度法挑正文并给 confidence;
11
+ * 7) 渲染成带 `<!-- L42 -->` 行号锚点的 Markdown;
12
+ * 8) **不执行 JS**:内容不足且看到 SPA 骨架时显式警告,而不是假装成功。
13
+ */
14
+
15
+ import { ScrapeError, describeError } from './errors.mjs'
16
+ import { DEFAULT_USER_AGENT, fetchOptional, fetchPage } from './net.mjs'
17
+ import { createThrottle, hostKey, DEFAULT_MIN_INTERVAL_MS } from './limit.mjs'
18
+ import { parseRobots, checkAllowance, originOf, pathOf, describeAllowance } from './robots.mjs'
19
+ import { parseHtml } from './html.mjs'
20
+ import { selectMain, blockLength, EXTRACT_VERSION, MIN_MAIN_CHARS } from './extract.mjs'
21
+ import { renderMarkdown, textLinesToMarkdown } from './markdown.mjs'
22
+
23
+ /** 包版本(包名/工具名/版本号只在 package.json 与本常量各出现一次)。 */
24
+ export const VERSION = '0.1.0'
25
+
26
+ /** 工具名(DSH 里模型看到的名字)。 */
27
+ export const TOOL_NAME = 'dsh_scrape'
28
+
29
+ /** 默认参数(可被工具参数或 CLI 覆盖)。 */
30
+ export const DEFAULTS = {
31
+ timeoutMs: 15000,
32
+ maxBytes: 2_000_000,
33
+ maxRedirects: 5,
34
+ maxChars: 200_000,
35
+ maxUrls: 20,
36
+ minIntervalMs: DEFAULT_MIN_INTERVAL_MS,
37
+ minChars: MIN_MAIN_CHARS,
38
+ robots: true,
39
+ lineRefs: true,
40
+ }
41
+
42
+ /** 固定声明的能力边界(每次都随结果返回,别让调用方以为它什么都能干)。 */
43
+ export const LIMITATIONS = [
44
+ '不执行 JavaScript:客户端渲染的页面只能拿到服务端 HTML,内容可能不全(会显式警告)。',
45
+ '正文抽取是「结构化密度」启发式,不认 CSS 隐藏、不认付费墙,误判时可以用 lineRefs:false 看原文。',
46
+ '只处理 HTML / 纯文本 / JSON / XML;PDF、图片、Office 文档里的文字不解析。',
47
+ '不携带登录态与 Cookie,需要登录的页面会拿到登录页。',
48
+ 'robots.txt 只按 `dsh-scrape` 与 `*` 两组判定(长匹配优先、Allow 同长胜出),不实现非标准扩展。',
49
+ ]
50
+
51
+ /** removed 里固定出现的分类(缺省 0,调用方不必判空)。 */
52
+ const REMOVED_KEYS = [
53
+ 'script', 'style', 'noscript', 'template', 'svg', 'canvas', 'iframe', 'nav', 'footer',
54
+ 'aside', 'form', 'dialog', 'button', 'consent', 'share', 'comment', 'ad',
55
+ ]
56
+
57
+ /**
58
+ * 造一条 warning。
59
+ * @param {string} code 稳定错误码
60
+ * @param {string} message 中文说明
61
+ * @param {'info'|'warn'|'error'} [severity] 严重程度
62
+ * @returns {{code:string,message:string,severity:string}} warning
63
+ */
64
+ export function warning(code, message, severity = 'warn') {
65
+ return { code, message, severity }
66
+ }
67
+
68
+ /**
69
+ * 把字符统计补齐成固定键(缺省 0)。
70
+ * @param {object} chars 各分类字数
71
+ * @param {object} elements 各分类元素数
72
+ * @returns {object} 补齐后的 removed
73
+ */
74
+ function normalizeRemoved(chars = {}, elements = {}) {
75
+ const out = {}
76
+ let total = 0
77
+ for (const key of REMOVED_KEYS) {
78
+ const value = Math.round(chars[key] ?? 0)
79
+ out[key] = value
80
+ total += value
81
+ }
82
+ out.boilerplate = 0
83
+ out.total = total
84
+ out.elements = { ...elements }
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * robots 信息的对外形状(blocked 结果与正常结果必须一致,否则调用方要写两套判断)。
90
+ * @param {object|null} info resolveRobots 结果
91
+ * @returns {object|null} 规范化 robots 信息
92
+ */
93
+ function normalizeRobotsInfo(info) {
94
+ if (!info) return null
95
+ return {
96
+ url: info.url,
97
+ available: info.available,
98
+ status: info.status,
99
+ allowed: info.allowance ? info.allowance.allowed : null,
100
+ usedGroup: info.allowance?.usedGroup ?? null,
101
+ matchedRule: info.allowance?.matchedRule ?? null,
102
+ crawlDelayMs: info.crawlDelayMs ?? null,
103
+ error: info.error ?? null,
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 判断页面是否疑似客户端渲染(SPA),并给出理由。
109
+ * @param {object} signals parseHtml 的 SPA 信号
110
+ * @param {number} textChars 可见正文字数
111
+ * @returns {{suspected:boolean,reasons:string[]}} 判定
112
+ */
113
+ export function detectSpa(signals, textChars) {
114
+ const reasons = []
115
+ if (signals.nextData) reasons.push('页面含 __NEXT_DATA__(Next.js 数据脚本)')
116
+ if (signals.nuxt) reasons.push('页面含 __NUXT__(Nuxt 数据脚本)')
117
+ if (signals.initialState) reasons.push('页面含 __INITIAL_STATE__ / __APOLLO_STATE__(前端状态脚本)')
118
+ if (signals.rootEmpty) reasons.push('#root / #app 等挂载点为空壳')
119
+ if (signals.noscriptJs) reasons.push('<noscript> 里写着「需要启用 JavaScript」')
120
+ const shell = reasons.length > 0
121
+ const suspected = (textChars < 200 && shell) || (textChars < 120 && signals.scriptCount >= 5 && !signals.hasMain)
122
+ if (!shell && textChars < 120 && signals.scriptCount >= 5 && !signals.hasMain) {
123
+ reasons.push(`正文只有 ${textChars} 字,但页面有 ${signals.scriptCount} 个脚本且没有 <main>/<article>`)
124
+ }
125
+ return { suspected, reasons }
126
+ }
127
+
128
+ /**
129
+ * 没抓到正文时的兜底 Markdown:宁可给一段说明,也不返回空字符串。
130
+ * @param {{title:string,finalUrl:string,spa:object}} info 上下文
131
+ * @returns {string} Markdown
132
+ */
133
+ function emptyBodyMarkdown(info) {
134
+ const lines = ['<!-- L1 -->', '> **未提取到可见正文。**']
135
+ if (info.spa?.suspected) {
136
+ lines.push(`> 该页疑似客户端渲染(SPA):${info.spa.reasons.join(';')}。`)
137
+ lines.push('> 本工具不执行 JavaScript,所以拿到的是页面骨架。可以改用预渲染版本、站点的静态副本,或 `robots:false` 后再试一次。')
138
+ } else {
139
+ lines.push('> 去掉脚本、导航、页脚与弹层后没有剩下文字:可能是空页面、纯图片页、或被反爬拦下的占位页。')
140
+ }
141
+ if (info.finalUrl) lines.push(`> 页面地址:${info.finalUrl}`)
142
+ return lines.join('\n')
143
+ }
144
+
145
+ /**
146
+ * 解析并判定 robots.txt(失败不抛错,交给调用方决定口径)。
147
+ * @param {string} url 页面 URL
148
+ * @param {object} options 选项(throttle/timeoutMs/signal/userAgent)
149
+ * @returns {Promise<object>} robots 信息
150
+ */
151
+ export async function resolveRobots(url, options = {}) {
152
+ const robotsUrl = `${originOf(url)}/robots.txt`
153
+ const fetched = await fetchOptional(robotsUrl, {
154
+ timeoutMs: options.timeoutMs ?? DEFAULTS.timeoutMs,
155
+ signal: options.signal,
156
+ throttle: options.throttle,
157
+ userAgent: options.userAgent ?? DEFAULT_USER_AGENT,
158
+ })
159
+
160
+ if (!fetched) {
161
+ return { url: robotsUrl, available: false, status: null, allowance: null, error: '抓不到 robots.txt(网络错误)' }
162
+ }
163
+ if (fetched.status !== 200) {
164
+ return {
165
+ url: robotsUrl, available: false, status: fetched.status, allowance: null,
166
+ error: `robots.txt 返回 HTTP ${fetched.status}`,
167
+ }
168
+ }
169
+ const parsed = parseRobots(fetched.text)
170
+ const allowance = checkAllowance(parsed, pathOf(url), { userAgent: options.userAgent ?? DEFAULT_USER_AGENT })
171
+ return {
172
+ url: robotsUrl,
173
+ available: true,
174
+ status: fetched.status,
175
+ allowance,
176
+ crawlDelayMs: allowance.crawlDelayMs,
177
+ sitemaps: parsed.sitemaps.slice(0, 10),
178
+ warnings: parsed.warnings.slice(0, 5),
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 把一段 HTML 变成结果(URL 路径与本地 HTML 路径共用)。
184
+ * @param {string} html HTML 原文
185
+ * @param {object} options 选项
186
+ * @returns {object} 正文与统计
187
+ */
188
+ function extractFromHtml(html, options = {}) {
189
+ const doc = parseHtml(html, { baseUrl: options.baseUrl ?? '' })
190
+ const totalVisible = doc.blocks.reduce((sum, block) => sum + blockLength(block), 0)
191
+ const spa = detectSpa(doc.signals, totalVisible)
192
+ const main = selectMain(doc.blocks, { spa: spa.suspected, minChars: options.minChars ?? DEFAULTS.minChars })
193
+
194
+ let rendered = main.blocks.length > 0
195
+ ? renderMarkdown(main.blocks, { lineRefs: options.lineRefs !== false, maxChars: options.maxChars ?? DEFAULTS.maxChars })
196
+ : textLinesToMarkdown(doc.textLines, { lineRefs: options.lineRefs !== false })
197
+
198
+ const warnings = []
199
+ if (spa.suspected) {
200
+ warnings.push(warning(
201
+ 'spa_suspected',
202
+ `疑似 SPA(客户端渲染):正文仅 ${totalVisible} 字,${spa.reasons.join(';')}。本工具不执行 JS,内容可能不全——请改用静态版本或预渲染地址。`,
203
+ 'warn',
204
+ ))
205
+ }
206
+ if (totalVisible === 0) {
207
+ warnings.push(warning('no_visible_text', '去掉脚本/导航/页脚后没有任何可见文字。', 'error'))
208
+ } else if (main.chars.kept < (options.minChars ?? DEFAULTS.minChars)) {
209
+ warnings.push(warning('thin_content', `可信正文只有 ${main.chars.kept} 字(低于 ${options.minChars ?? DEFAULTS.minChars} 字),可能是列表页/首页。`, 'info'))
210
+ }
211
+ if (rendered.markdown.trim().length === 0) {
212
+ rendered = { ...rendered, markdown: emptyBodyMarkdown({ title: doc.title, finalUrl: options.baseUrl ?? '', spa }) }
213
+ }
214
+
215
+ return { doc, main, rendered, spa, totalVisible, warnings }
216
+ }
217
+
218
+ /**
219
+ * 抓一个 URL 并转成可引用的 Markdown。
220
+ * @param {{url?:string,urls?:string[],html?:string}} input 输入(url / urls / html 三选一)
221
+ * @param {object} [options] 选项
222
+ * @returns {Promise<object>} 结果
223
+ */
224
+ export async function scrape(input = {}, options = {}) {
225
+ const urls = Array.isArray(input.urls) ? input.urls.filter(Boolean) : []
226
+ if (urls.length > 0 && !input.url) return scrapeMany(urls, options)
227
+ if (!input.url && typeof input.html === 'string') {
228
+ // 本地 HTML 也可以带一个 baseUrl(CLI --file 会给 file:// 地址),相对链接才能补绝对
229
+ return scrapeHtml(input.html, { ...options, baseUrl: options.baseUrl ?? input.baseUrl })
230
+ }
231
+ if (!input.url) {
232
+ throw new ScrapeError('invalid_input', '缺少输入:需要 url、urls 或 html 之一', {
233
+ hint: '例:{ "url": "https://example.com" };本地 HTML 用 { "html": "<html>…" },CLI 用 --file',
234
+ })
235
+ }
236
+
237
+ const settings = {
238
+ ...DEFAULTS,
239
+ ...options,
240
+ maxUrls: options.maxUrls ?? DEFAULTS.maxUrls,
241
+ }
242
+ const throttle = options.throttle ?? createThrottle({ minIntervalMs: settings.minIntervalMs })
243
+ const fetchedAt = new Date().toISOString()
244
+ const warnings = []
245
+ const notes = []
246
+ const target = String(input.url)
247
+
248
+ let robotsInfo = null
249
+ if (settings.robots !== false) {
250
+ robotsInfo = await resolveRobots(target, { ...settings, throttle })
251
+ if (robotsInfo.crawlDelayMs && robotsInfo.crawlDelayMs > 0) {
252
+ throttle.setHostInterval(hostKey(target), robotsInfo.crawlDelayMs)
253
+ notes.push(`robots.txt 声明 Crawl-delay=${robotsInfo.crawlDelayMs / 1000}s,已按此间隔限速`)
254
+ }
255
+ if (robotsInfo.allowance && !robotsInfo.allowance.allowed) {
256
+ // 遵守 robots:不发页面请求,但要给出可读结论与证据(命中了第几行)
257
+ return {
258
+ tool: TOOL_NAME,
259
+ version: VERSION,
260
+ kind: 'url',
261
+ blocked: 'robots',
262
+ title: '',
263
+ markdown: '',
264
+ provenance: {
265
+ requestedUrl: target,
266
+ finalUrl: null,
267
+ redirects: [],
268
+ fetchedAt,
269
+ httpStatus: null,
270
+ statusText: '',
271
+ bytes: 0,
272
+ contentType: '',
273
+ charset: '',
274
+ truncated: false,
275
+ elapsedMs: 0,
276
+ robots: normalizeRobotsInfo(robotsInfo),
277
+ },
278
+ removed: normalizeRemoved({}, {}),
279
+ content: {
280
+ chars: 0, blocks: 0, headings: 0, tables: 0, codeBlocks: 0, links: 0,
281
+ confidence: 0, method: 'blocked', reasons: ['robotstxt 禁止抓取'], extractor: EXTRACT_VERSION,
282
+ spaSuspected: false, rawTextChars: 0,
283
+ },
284
+ warnings: [warning(
285
+ 'robots_disallowed',
286
+ `${describeAllowance(robotsInfo.allowance, pathOf(target))}。已按 robots.txt 跳过抓取;如确有权抓,传 robots:false(会记一条警告)。`,
287
+ 'error',
288
+ )],
289
+ notes: robotsInfo.warnings ?? [],
290
+ limitations: LIMITATIONS,
291
+ }
292
+ }
293
+ if (!robotsInfo.available) {
294
+ warnings.push(warning('robots_unavailable', `${robotsInfo.error},按「允许」处理(这不算站点的禁止声明)。`, 'info'))
295
+ } else if (robotsInfo.allowance) {
296
+ notes.push(describeAllowance(robotsInfo.allowance, pathOf(target)))
297
+ }
298
+ } else {
299
+ warnings.push(warning(
300
+ 'robots_disabled',
301
+ 'robots:false 已关闭 robots.txt 检查:本次抓取没有遵守目标站点的 robots 规则,请确认你有权抓这个页面。',
302
+ 'warn',
303
+ ))
304
+ }
305
+
306
+ const page = await fetchPage(target, {
307
+ timeoutMs: settings.timeoutMs,
308
+ maxBytes: settings.maxBytes,
309
+ maxRedirects: settings.maxRedirects,
310
+ signal: settings.signal,
311
+ throttle,
312
+ userAgent: settings.userAgent ?? DEFAULT_USER_AGENT,
313
+ })
314
+
315
+ if (page.truncated) {
316
+ warnings.push(warning('truncated', `响应超过 maxBytes=${settings.maxBytes} 字节,已截断;后半部分内容没有解析。`, 'warn'))
317
+ }
318
+ if (page.redirects.length > 0) {
319
+ const crossOrigin = page.redirects.some((hop) => originOf(hop.from) !== originOf(hop.to))
320
+ if (crossOrigin) {
321
+ warnings.push(warning('cross_origin_redirect', `抓取过程中发生了跨站重定向(${page.redirects.length} 跳),引用时请以最终地址为准。`, 'info'))
322
+ }
323
+ notes.push(`跟随 ${page.redirects.length} 次重定向,最终地址 ${page.finalUrl}`)
324
+ }
325
+ if (page.charsetFallback) {
326
+ warnings.push(warning('charset_fallback', `声明的字符集「${page.charsetDeclared}」无法解码,已退回 UTF-8;中文可能乱码。`, 'warn'))
327
+ }
328
+
329
+ const contentType = page.contentType.toLowerCase()
330
+ const isHtml = /html|xhtml\+xml|<unknown>/.test(contentType) || contentType === '' || /<\s*(?:!doctype|html|head|body)/i.test(page.text.slice(0, 512))
331
+
332
+ // 二进制 / 非文本:不假装解析
333
+ if (!page.isText && !isHtml) {
334
+ const markdown = [
335
+ '<!-- L1 -->',
336
+ `> **这不是 HTML 文本**:content-type = \`${page.contentType || '(缺失)'}\`,${page.bytes} 字节。`,
337
+ '> 本工具只把 HTML / 纯文本 / JSON / XML 变成 Markdown;PDF、图片、Office 文档请走文档解析工具。',
338
+ ].join('\n')
339
+ return buildResult({
340
+ kind: 'url', title: '', markdown, page, robotsInfo, fetchedAt,
341
+ removed: normalizeRemoved({}, {}),
342
+ content: { chars: 0, blocks: 0, headings: 0, tables: 0, codeBlocks: 0, links: 0, confidence: 0, method: 'binary', reasons: ['二进制内容'], extractor: EXTRACT_VERSION, spaSuspected: false, rawTextChars: 0 },
343
+ warnings: [...warnings, warning('binary_content', `content-type 是 ${page.contentType || '(缺失)'},不是可解析的文本。`, 'error')],
344
+ notes, limitations: LIMITATIONS, target,
345
+ })
346
+ }
347
+
348
+ // 非 HTML 的文本(纯文本 / JSON / XML / Markdown):整段包成代码块,行号照旧从原文件数
349
+ if (!isHtml) {
350
+ const rendered = textLinesToMarkdown(page.text.replace(/\r\n?/g, '\n').split('\n'), { lineRefs: settings.lineRefs !== false })
351
+ const markdown = ['```' + fenceLanguage(contentType), page.text.replace(/\r\n?/g, '\n').trimEnd(), '```'].join('\n')
352
+ return buildResult({
353
+ kind: 'url', title: page.finalUrl, markdown, rendered: { ...rendered, markdown }, page, robotsInfo, fetchedAt,
354
+ removed: normalizeRemoved({}, {}),
355
+ content: {
356
+ chars: page.text.length, blocks: 1, headings: 0, tables: 0, codeBlocks: 1, links: 0,
357
+ confidence: 0.9, method: 'plain-text', reasons: ['content-type 不是 HTML,按纯文本整段保留'], extractor: EXTRACT_VERSION,
358
+ spaSuspected: false, rawTextChars: page.text.length,
359
+ },
360
+ warnings: [...warnings, warning('not_html', `content-type 是 ${page.contentType || '(缺失)'},按纯文本处理(未做正文抽取)。`, 'info')],
361
+ notes, limitations: LIMITATIONS, target,
362
+ })
363
+ }
364
+
365
+ const parsed = extractFromHtml(page.text, {
366
+ baseUrl: page.finalUrl,
367
+ lineRefs: settings.lineRefs,
368
+ maxChars: settings.maxChars,
369
+ minChars: settings.minChars,
370
+ })
371
+
372
+ const removed = normalizeRemoved(parsed.doc.removed.chars, parsed.doc.removed.elements)
373
+ removed.boilerplate = Math.round(parsed.main.chars.droppedBoilerplate)
374
+ removed.total += removed.boilerplate
375
+
376
+ const content = {
377
+ chars: parsed.main.chars.kept,
378
+ markdownChars: parsed.rendered.chars,
379
+ blocks: parsed.rendered.blockCount,
380
+ headings: parsed.doc.stats.headings,
381
+ tables: parsed.doc.stats.tables,
382
+ codeBlocks: parsed.doc.stats.codeBlocks,
383
+ links: parsed.doc.stats.links,
384
+ confidence: parsed.main.confidence,
385
+ method: parsed.main.method,
386
+ reasons: parsed.main.reasons,
387
+ extractor: parsed.main.version,
388
+ spaSuspected: parsed.spa.suspected,
389
+ spaReasons: parsed.spa.reasons,
390
+ rawTextChars: parsed.totalVisible,
391
+ }
392
+
393
+ return buildResult({
394
+ kind: 'url',
395
+ title: parsed.doc.title,
396
+ markdown: parsed.rendered.markdown,
397
+ rendered: parsed.rendered,
398
+ page, robotsInfo, fetchedAt,
399
+ removed,
400
+ content,
401
+ warnings: [...warnings, ...parsed.warnings],
402
+ notes: [...notes, ...(parsed.doc.signals.nextData ? ['页面带 __NEXT_DATA__:这是 Next.js 的数据脚本,不执行它拿不到组件渲染后的文字'] : [])],
403
+ limitations: LIMITATIONS,
404
+ target,
405
+ })
406
+ }
407
+
408
+ /**
409
+ * 只给一段 HTML(离线/测试/本地文件)。不做网络、不判 robots。
410
+ * @param {string} html HTML 原文
411
+ * @param {object} [options] 选项
412
+ * @returns {object} 结果
413
+ */
414
+ export function scrapeHtml(html, options = {}) {
415
+ const settings = { ...DEFAULTS, ...options }
416
+ const parsed = extractFromHtml(html, {
417
+ baseUrl: options.baseUrl ?? '',
418
+ lineRefs: settings.lineRefs,
419
+ maxChars: settings.maxChars,
420
+ minChars: settings.minChars,
421
+ })
422
+ const removed = normalizeRemoved(parsed.doc.removed.chars, parsed.doc.removed.elements)
423
+ removed.boilerplate = Math.round(parsed.main.chars.droppedBoilerplate)
424
+ removed.total += removed.boilerplate
425
+
426
+ return {
427
+ tool: TOOL_NAME,
428
+ version: VERSION,
429
+ kind: 'html',
430
+ title: parsed.doc.title,
431
+ markdown: parsed.rendered.markdown,
432
+ provenance: {
433
+ requestedUrl: null,
434
+ finalUrl: options.baseUrl ? String(options.baseUrl) : null,
435
+ redirects: [],
436
+ fetchedAt: new Date().toISOString(),
437
+ httpStatus: null,
438
+ statusText: '',
439
+ bytes: Buffer.byteLength(String(html), 'utf8'),
440
+ contentType: 'text/html',
441
+ charset: 'utf-8',
442
+ truncated: false,
443
+ elapsedMs: 0,
444
+ robots: null,
445
+ },
446
+ removed,
447
+ content: {
448
+ chars: parsed.main.chars.kept,
449
+ markdownChars: parsed.rendered.chars,
450
+ blocks: parsed.rendered.blockCount,
451
+ headings: parsed.doc.stats.headings,
452
+ tables: parsed.doc.stats.tables,
453
+ codeBlocks: parsed.doc.stats.codeBlocks,
454
+ links: parsed.doc.stats.links,
455
+ confidence: parsed.main.confidence,
456
+ method: parsed.main.method,
457
+ reasons: parsed.main.reasons,
458
+ extractor: parsed.main.version,
459
+ spaSuspected: parsed.spa.suspected,
460
+ spaReasons: parsed.spa.reasons,
461
+ rawTextChars: parsed.totalVisible,
462
+ },
463
+ warnings: parsed.warnings,
464
+ notes: [],
465
+ lineMap: parsed.rendered.lines ?? [],
466
+ limitations: LIMITATIONS,
467
+ }
468
+ }
469
+
470
+ /**
471
+ * 批量抓(同域自动串行限速;单个失败不影响其它)。
472
+ * @param {string[]} urls URL 列表
473
+ * @param {object} [options] 选项
474
+ * @returns {Promise<object>} 多页结果
475
+ */
476
+ export async function scrapeMany(urls, options = {}) {
477
+ const settings = { ...DEFAULTS, ...options }
478
+ const list = urls.slice(0, settings.maxUrls)
479
+ const throttle = options.throttle ?? createThrottle({ minIntervalMs: settings.minIntervalMs })
480
+ const pages = []
481
+ const failures = []
482
+
483
+ for (const url of list) {
484
+ try {
485
+ pages.push(await scrape({ url }, { ...settings, throttle, maxUrls: settings.maxUrls }))
486
+ } catch (error) {
487
+ failures.push({ url: String(url), error: describeError(error) })
488
+ }
489
+ }
490
+
491
+ return {
492
+ tool: TOOL_NAME,
493
+ version: VERSION,
494
+ kind: 'multi',
495
+ ok: failures.length === 0 && pages.every((page) => page.blocked === undefined),
496
+ count: pages.length,
497
+ requested: list.length,
498
+ truncatedList: urls.length > list.length,
499
+ pages,
500
+ failures,
501
+ warnings: urls.length > list.length
502
+ ? [warning('too_many_urls', `一次最多抓 ${settings.maxUrls} 个 URL,后面的 ${urls.length - list.length} 个没抓。`, 'warn')]
503
+ : [],
504
+ limitations: LIMITATIONS,
505
+ }
506
+ }
507
+
508
+ /**
509
+ * 组装单页结果(URL 路径统一出口)。
510
+ * @param {object} parts 部件
511
+ * @returns {object} 结果
512
+ */
513
+ function buildResult(parts) {
514
+ const { page, robotsInfo, fetchedAt, target } = parts
515
+ const warnings = parts.warnings ?? []
516
+ if (page.elapsedMs > 8000) {
517
+ warnings.push(warning('slow_response', `服务器响应用了 ${page.elapsedMs}ms,接近默认超时。`, 'info'))
518
+ }
519
+ return {
520
+ tool: TOOL_NAME,
521
+ version: VERSION,
522
+ kind: parts.kind,
523
+ title: parts.title ?? '',
524
+ markdown: parts.markdown ?? '',
525
+ provenance: {
526
+ requestedUrl: target,
527
+ finalUrl: page.finalUrl,
528
+ redirects: page.redirects,
529
+ fetchedAt,
530
+ httpStatus: page.status,
531
+ statusText: page.statusText,
532
+ bytes: page.bytes,
533
+ contentType: page.contentType,
534
+ charset: page.charset,
535
+ charsetDeclared: page.charsetDeclared,
536
+ truncated: page.truncated,
537
+ elapsedMs: page.elapsedMs,
538
+ robots: normalizeRobotsInfo(robotsInfo),
539
+ },
540
+ removed: parts.removed,
541
+ content: parts.content,
542
+ warnings,
543
+ notes: parts.notes ?? [],
544
+ lineMap: parts.rendered?.lines ?? [],
545
+ limitations: parts.limitations ?? LIMITATIONS,
546
+ }
547
+ }
548
+
549
+ /**
550
+ * 按 content-type 猜代码围栏语言。
551
+ * @param {string} contentType 响应类型
552
+ * @returns {string} 语言名
553
+ */
554
+ function fenceLanguage(contentType) {
555
+ if (/json/.test(contentType)) return 'json'
556
+ if (/xml|rss|atom/.test(contentType)) return 'xml'
557
+ if (/markdown/.test(contentType)) return 'markdown'
558
+ if (/javascript/.test(contentType)) return 'js'
559
+ return 'text'
560
+ }
package/package.json CHANGED
@@ -1,26 +1,62 @@
1
1
  {
2
2
  "name": "dsh-scrape",
3
- "version": "0.0.1",
4
- "description": "Turn any page, feed or repo into clean Markdown/text with per-site rules, retries and JS-page fallbacks, so an agent can cite what it actually fetched. | 把网页、RSS、仓库抓成干净的 Markdown/文本,带站点规则、重试与 JS 页面兜底,让 agent 引用的内容确实来自它抓到的东西。",
3
+ "version": "0.1.0",
4
+ "description": "把网页变成干净、可引用、带行号的 Markdown:遵守 robots.txt、同域限速、跟随重定向、自动认字符集、按行去噪、密度法挑正文并给 confidence,每条内容带 `<!-- L42 -->` 源行号锚点,不执行 JS 且对疑似 SPA 显式警告。零运行时依赖,含 CLI。 | Turns any web page into clean, citable Markdown with per-line provenance: robots-aware, host-throttled, redirect-following, charset-aware (GBK/Big5), boilerplate-stripped, density-based main-content extraction with a confidence score, and `<!-- L42 -->` line anchors. Never executes JavaScript; warns explicitly when a page looks like a client-rendered SPA.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
8
8
  "deepseek-harness",
9
9
  "scrape",
10
- "crawl",
11
- "fetch",
10
+ "scraper",
11
+ "web-scraping",
12
12
  "markdown",
13
- "browser",
14
- "geo",
13
+ "html-to-markdown",
14
+ "readability",
15
+ "robots-txt",
16
+ "citation",
17
+ "provenance",
18
+ "crawl",
19
+ "web-reader",
15
20
  "抓取",
16
- "爬虫"
21
+ "网页转markdown"
17
22
  ],
18
23
  "license": "MIT",
24
+ "author": "he fangsheng (https://github.com/dongsheng123132)",
19
25
  "type": "module",
26
+ "main": "./plugin.mjs",
27
+ "bin": {
28
+ "dsh-scrape": "./lib/cli.js"
29
+ },
30
+ "exports": {
31
+ ".": "./plugin.mjs",
32
+ "./cli": "./lib/cli.js",
33
+ "./lib/*": "./lib/*",
34
+ "./package.json": "./package.json"
35
+ },
20
36
  "files": [
21
- "README.md"
37
+ "plugin.mjs",
38
+ "lib/",
39
+ "scripts/",
40
+ "cordis.patch.yml",
41
+ "dsh-plugin.naming.json",
42
+ "README.md",
43
+ "LICENSE"
22
44
  ],
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ }
49
+ },
23
50
  "engines": {
24
- "node": ">=22"
51
+ "node": ">=22",
52
+ "dsh": ">=0.2.2-rc.1 <0.3.0"
53
+ },
54
+ "scripts": {
55
+ "test": "node --test test/*.test.mjs",
56
+ "test:dir": "node --test test/",
57
+ "check": "node --check plugin.mjs && node scripts/check.mjs",
58
+ "verify:install": "node scripts/verify-install.mjs",
59
+ "scrape": "node lib/cli.js",
60
+ "audit:readme": "node ../dsh-geo-audit/lib/cli.js --file README.md"
25
61
  }
26
62
  }