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/html.mjs ADDED
@@ -0,0 +1,822 @@
1
+ /**
2
+ * HTML 引擎:把一段 HTML 解析成「带源行号的结构化块」。
3
+ *
4
+ * 三条硬约束(决定了下面所有取舍):
5
+ * 1) **零依赖**——不引 cheerio/parse5,自己写一个最小分词器(标签 + 文本 + 注释);
6
+ * 2) **行号永不漂移**——每个块都记住它在原始 HTML 里的起止行,于是每条内容都能回指
7
+ * 「URL 第 N 行」;同时保留一份与源文件等长的 `textLines`(剥标签后的可见文本);
8
+ * 3) **不执行 JS**——只读服务端返回的 HTML。客户端渲染的页面会被明确标成「疑似 SPA」,
9
+ * 而不是假装抓到了内容。
10
+ *
11
+ * 去噪在这一层做(按元素/属性判定,确定性、可解释),正文密度挑选取在 extract.mjs。
12
+ */
13
+
14
+ import { decodeEntities } from './entities.mjs'
15
+
16
+ /** 空元素(不产生子节点,也不进栈)。 */
17
+ export const VOID_ELEMENTS = new Set([
18
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
19
+ 'param', 'source', 'track', 'wbr',
20
+ ])
21
+
22
+ /** 结构性噪音元素 → 记到 removed 的哪个分类下(值 = 分类名)。 */
23
+ export const NOISE_ELEMENTS = {
24
+ script: 'script',
25
+ style: 'style',
26
+ noscript: 'noscript',
27
+ template: 'template',
28
+ svg: 'svg',
29
+ canvas: 'canvas',
30
+ iframe: 'iframe',
31
+ object: 'iframe',
32
+ embed: 'iframe',
33
+ nav: 'nav',
34
+ footer: 'footer',
35
+ aside: 'aside',
36
+ form: 'form',
37
+ menu: 'nav',
38
+ dialog: 'dialog',
39
+ button: 'button',
40
+ select: 'form',
41
+ option: 'form',
42
+ textarea: 'form',
43
+ input: 'form',
44
+ }
45
+
46
+ /**
47
+ * 属性/类名命中即整块丢弃的「界面噪音」。顺序即优先级。
48
+ * 这是 cookie 条、分享按钮、广告位、面包屑的主要识别方式——它们几乎都有可辨认的 class/id。
49
+ */
50
+ export const NOISE_ATTR_RULES = [
51
+ { kind: 'consent', re: /(cookie[-_]?(consent|banner|notice|bar|policy|law)|consent[-_](banner|manager|sdk)|gdpr|onetrust|trustarc|cookiebot|osano|didomi|privacy[-_]?(banner|notice)|cmp[-_](banner|container)|cnil|接受.{0,6}cookie)/i },
52
+ { kind: 'share', re: /(share[-_]?(this|bar|buttons?|box|toolbar|icons?|links?)?|social[-_]?(share|links?|bar|icons?|buttons?)|addthis|sharethis|addtoany|weixin|weibo|twitter[-_]share|follow[-_]?us)/i },
53
+ { kind: 'comment', re: /(comment[-_]?(list|section|form|thread|area|box)?$|^comments?$|disqus|respond[-_]?form)/i },
54
+ { kind: 'ad', re: /(advert|adsense|ad[-_]?(slot|unit|banner|container|box)|sponsor|dfp[-_]|promo[-_]?(banner|box)|sponsored)/i },
55
+ { kind: 'nav', re: /(breadcrumb|pagination|^pager$|pager[-_]|navbar|nav[-_]?(bar|menu|list)?$|^nav$|site[-_]?nav|top[-_]?bar|sidebar|skip[-_]?link|site[-_]?header|masthead)/i },
56
+ ]
57
+
58
+ /** 块级容器元素(可以承载正文,关闭时产出一个块)。 */
59
+ const BLOCK_CONTAINERS = new Set([
60
+ 'p', 'div', 'section', 'article', 'main', 'header', 'hgroup', 'address',
61
+ 'dd', 'dt', 'figcaption', 'caption', 'details', 'summary', 'body', 'html', 'center',
62
+ ])
63
+
64
+ /** 行内标记元素 → 输出时包什么。 */
65
+ const INLINE_MARKS = {
66
+ a: null, strong: '**', b: '**', em: '*', i: '*', cite: '*', del: '~~', s: '~~',
67
+ strike: '~~', mark: null, sub: null, sup: null, small: null, u: null, abbr: null,
68
+ time: null, q: null, span: null, font: null, ins: null, bdi: null, bdo: null, var: null, dfn: null,
69
+ }
70
+
71
+ /** 标题元素 → 级别。 */
72
+ const HEADINGS = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 }
73
+
74
+ /**
75
+ * 把 HTML 切成 token(标签 / 文本 / 注释),并给每个 token 记住所在行。
76
+ * @param {string} html 已归一换行的 HTML
77
+ * @returns {Array<object>} token 列表
78
+ */
79
+ export function tokenizeHtml(html) {
80
+ const source = String(html ?? '')
81
+ // 注释、声明(<!doctype …> / <![CDATA[…]]>)、处理指令(<?xml …?>)都要跳过:
82
+ // 否则 `<!doctype html>` 会被当成正文文字,混进第一段
83
+ const re = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/?[a-zA-Z][a-zA-Z0-9:_-]*(?:"[^"]*"|'[^']*'|[^>"'])*>/g
84
+ const tokens = []
85
+ let line = 1
86
+ let last = 0
87
+
88
+ const countNewlines = (text) => {
89
+ let count = 0
90
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count += 1
91
+ return count
92
+ }
93
+
94
+ const pushText = (from, to) => {
95
+ if (to <= from) return
96
+ // 按行切分文本 token:行号精确到行,`<pre>` 里的换行也不会被吃掉(后续行带前导 \n)
97
+ const segments = source.slice(from, to).split('\n')
98
+ for (let i = 0; i < segments.length; i++) {
99
+ const raw = i === 0 ? segments[i] : `\n${segments[i]}`
100
+ tokens.push({ type: 'text', raw, line: line + i, endLine: line + i })
101
+ }
102
+ line += segments.length - 1
103
+ }
104
+
105
+ for (const match of source.matchAll(re)) {
106
+ pushText(last, match.index)
107
+ const raw = match[0]
108
+ const newlines = countNewlines(raw)
109
+ if (raw.startsWith('<!--')) {
110
+ tokens.push({ type: 'comment', raw, line, endLine: line + newlines })
111
+ } else if (raw.startsWith('<!') || raw.startsWith('<?')) {
112
+ tokens.push({ type: 'decl', raw, line, endLine: line + newlines })
113
+ } else {
114
+ const closing = raw[1] === '/'
115
+ const nameMatch = /^<\/?\s*([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(raw)
116
+ const name = nameMatch ? nameMatch[1].toLowerCase() : ''
117
+ tokens.push({
118
+ type: closing ? 'close' : 'open',
119
+ name,
120
+ raw,
121
+ attrs: closing ? {} : parseAttrs(raw),
122
+ line,
123
+ endLine: line + newlines,
124
+ selfClosing: /\/\s*>$/.test(raw) || VOID_ELEMENTS.has(name),
125
+ })
126
+ }
127
+ line += newlines
128
+ last = match.index + raw.length
129
+ }
130
+ pushText(last, source.length)
131
+ return tokens
132
+ }
133
+
134
+ /**
135
+ * 解析一个开标签的属性(支持双引号 / 单引号 / 无引号)。
136
+ * @param {string} raw 原始标签文本
137
+ * @returns {Record<string,string>} 属性表(键小写,值已解实体)
138
+ */
139
+ export function parseAttrs(raw) {
140
+ const attrs = {}
141
+ let body = String(raw).replace(/^<\s*\/?\s*[a-zA-Z][a-zA-Z0-9:_-]*/, '')
142
+ body = body.replace(/\/?\s*>$/, '')
143
+ const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g
144
+ for (const match of body.matchAll(re)) {
145
+ const key = match[1].toLowerCase()
146
+ if (!key || key === '/') continue
147
+ if (key in attrs) continue
148
+ attrs[key] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? '')
149
+ }
150
+ return attrs
151
+ }
152
+
153
+ /**
154
+ * 元素的「界面噪音分类」:先看标签,再看 class/id/role/aria-label。
155
+ * @param {string} name 元素名(小写)
156
+ * @param {Record<string,string>} attrs 属性
157
+ * @returns {string|null} 噪音分类,干净元素返回 null
158
+ */
159
+ export function noiseKindOf(name, attrs) {
160
+ if (NOISE_ELEMENTS[name]) return NOISE_ELEMENTS[name]
161
+ const label = [attrs.class ?? '', attrs.id ?? '', attrs.role ?? '', attrs['aria-label'] ?? '', attrs['data-testid'] ?? ''].join(' ')
162
+ if (!label.trim()) return null
163
+ if (name === 'a' || name === 'span' || name === 'p') {
164
+ // 行内元素的 class 经常是 "share-link" 之类,但整段正文也可能叫 "share";只认强特征
165
+ if (/(^|[\s_-])(share|social)([\s_-]|$)/i.test(label) && /(share|social)/i.test(label)) return 'share'
166
+ return null
167
+ }
168
+ for (const rule of NOISE_ATTR_RULES) {
169
+ if (rule.re.test(label)) return rule.kind
170
+ }
171
+ if (/^(navigation|menu|search)$/i.test(attrs.role ?? '')) return 'nav'
172
+ if (/^complementary$/i.test(attrs.role ?? '')) return 'aside'
173
+ return null
174
+ }
175
+
176
+ /**
177
+ * 把相对 URL 补成绝对 URL(补不了就原样返回)。
178
+ * @param {string} href 原始地址
179
+ * @param {string} baseUrl 页面最终地址
180
+ * @returns {string} 绝对地址
181
+ */
182
+ export function resolveUrl(href, baseUrl) {
183
+ const value = String(href ?? '').trim()
184
+ if (!value || value.startsWith('#') || /^(?:mailto|tel|javascript|data):/i.test(value)) return value
185
+ if (!baseUrl) return value
186
+ try {
187
+ return new URL(value, baseUrl).toString()
188
+ } catch {
189
+ return value
190
+ }
191
+ }
192
+
193
+ /**
194
+ * SPA / 客户端渲染的信号(只读原始 HTML,不动 JS)。
195
+ * @param {string} html 原始 HTML
196
+ * @returns {object} 信号集合
197
+ */
198
+ export function detectSpaSignals(html) {
199
+ const source = String(html ?? '')
200
+ return {
201
+ nextData: /__NEXT_DATA__/.test(source),
202
+ nuxt: /__NUXT__|window\.__NUXT__/.test(source),
203
+ initialState: /__INITIAL_STATE__|__APOLLO_STATE__|__PRELOADED_STATE__|__REDUX_STATE__/.test(source),
204
+ rootEmpty: /<(?:div|main)[^>]+id=["'](?:root|app|__next|svelte)["'][^>]*>\s*<\/(?:div|main)>/i.test(source),
205
+ noscriptJs: /<noscript>[\s\S]{0,400}?(?:enable|requires?|需要|请开启|请启用|disabled)[\s\S]{0,100}?javascript/i.test(source),
206
+ scriptCount: (source.match(/<script\b/gi) ?? []).length,
207
+ hasMain: /<(?:main|article)\b/i.test(source),
208
+ hasJsonLd: /application\/ld\+json/i.test(source),
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 收拢空白(HTML 里连续空白等价于一个空格)。
214
+ * @param {string} text 文本
215
+ * @returns {string} 收拢后的文本
216
+ */
217
+ export function collapseWhitespace(text) {
218
+ return String(text ?? '').replace(/[\s\u00a0\u200b]+/g, ' ')
219
+ }
220
+
221
+ /**
222
+ * 把行内片段渲染成 Markdown(链接 / 粗体 / 斜体 / 行内代码 / 删除线)。
223
+ * 只处理「配对的 open/close」,未闭合的按最内层收尾,绝不丢文字。
224
+ * @param {Array<object>} parts 片段序列
225
+ * @returns {string} Markdown 行内文本
226
+ */
227
+ export function renderInline(parts) {
228
+ const root = { tag: null, out: [] }
229
+ const frames = [root]
230
+ const wrap = (frame, inner) => {
231
+ const text = inner
232
+ if (!text) return ''
233
+ if (frame.tag === 'a') {
234
+ const href = frame.href && !frame.href.startsWith('#') ? frame.href : ''
235
+ return href ? `[${text}](${href})` : text
236
+ }
237
+ if (frame.tag === 'code') return text.includes('`') ? `\`\` ${text} \`\`` : `\`${text}\``
238
+ if (frame.tag === 'mark' || frame.tag === 'strong' || frame.tag === 'b') {
239
+ return frame.tag === 'mark' ? text : `**${text}**`
240
+ }
241
+ if (frame.tag === 'em' || frame.tag === 'i' || frame.tag === 'cite' || frame.tag === 'q') return `*${text}*`
242
+ if (frame.tag === 'del' || frame.tag === 's' || frame.tag === 'strike') return `~~${text}~~`
243
+ return text
244
+ }
245
+
246
+ for (const part of parts) {
247
+ const frame = frames[frames.length - 1]
248
+ if (part.type === 'text') frame.out.push(part.value)
249
+ else if (part.type === 'open') frames.push({ tag: part.tag, href: part.href, out: [] })
250
+ else if (part.type === 'close') {
251
+ if (frames.length === 1) continue
252
+ const closed = frames.pop()
253
+ const inner = closed.out.join('').trim()
254
+ frames[frames.length - 1].out.push(wrap(closed, inner))
255
+ }
256
+ }
257
+ while (frames.length > 1) {
258
+ const closed = frames.pop()
259
+ const inner = closed.out.join('').trim()
260
+ frames[frames.length - 1].out.push(wrap(closed, inner))
261
+ }
262
+ return root.out.join('')
263
+ }
264
+
265
+ /**
266
+ * 解析 HTML 为「带行号的结构化块」。
267
+ * @param {string} html 原始 HTML
268
+ * @param {{baseUrl?:string,maxLinks?:number}} [options] 选项
269
+ * @returns {{title:string,blocks:Array<object>,textLines:string[],removed:object,links:Array<object>,signals:object,stats:object}} 解析结果
270
+ */
271
+ export function parseHtml(html, options = {}) {
272
+ const baseUrl = options.baseUrl ?? ''
273
+ const maxLinks = options.maxLinks ?? 200
274
+ const normalized = String(html ?? '').replace(/\r\n?/g, '\n')
275
+ const rawLines = normalized.split('\n')
276
+ const tokens = tokenizeHtml(normalized)
277
+ const signals = detectSpaSignals(normalized)
278
+
279
+ const blocks = []
280
+ const textLines = new Array(rawLines.length).fill('')
281
+ const removed = { chars: {}, elements: {} }
282
+ const links = []
283
+ const stack = []
284
+ let titleFromTag = ''
285
+ let titleBuffer = ''
286
+ let inTitle = false
287
+ let blockSeq = 0
288
+ let loose = null
289
+ let headingFallback = ''
290
+ const stats = { tokens: tokens.length, elements: 0, noiseElements: 0, links: 0, headings: 0, tables: 0, lists: 0, codeBlocks: 0 }
291
+
292
+ /**
293
+ * 追加可见文本到行号对齐的 textLines。
294
+ * @param {string} raw 文本
295
+ * @param {number} line 起始行(1 基)
296
+ * @returns {void}
297
+ */
298
+ const appendVisible = (raw, line) => {
299
+ // 每个文本 token 的行号已经是「内容所在行」,前导 \n 只是行分隔符,不能算成多一行
300
+ const segments = String(raw).replace(/^\n/, '').split('\n')
301
+ for (let i = 0; i < segments.length; i++) {
302
+ const index = line - 1 + i
303
+ if (index >= textLines.length) textLines.push('')
304
+ textLines[index] += ` ${segments[i]}`
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 取当前生效的噪音分类(继承祖先)。
310
+ * @returns {string|null} 噪音分类
311
+ */
312
+ const activeNoise = () => {
313
+ // 噪音标记会向下继承(拒噪分支里 push 的每个子元素都带 noise),所以看栈顶方向第一个带标记的即可
314
+ for (let i = stack.length - 1; i >= 0; i--) {
315
+ if (stack[i].noise) return stack[i].noise
316
+ }
317
+ return null
318
+ }
319
+
320
+ /**
321
+ * 取最内层可承载文字的容器。
322
+ * @returns {object} 容器
323
+ */
324
+ const innerContainer = () => {
325
+ for (let i = stack.length - 1; i >= 0; i--) {
326
+ if (stack[i].container) return stack[i].container
327
+ }
328
+ if (!loose) loose = { kind: 'paragraph', parts: [], firstSeq: blockSeq, line: 1, endLine: 1, depth: 0 }
329
+ return loose
330
+ }
331
+
332
+ /**
333
+ * 往容器里塞行内片段。
334
+ * @param {object} part 片段
335
+ * @returns {void}
336
+ */
337
+ const pushPart = (part) => {
338
+ const container = innerContainer()
339
+ if (container.firstSeq === null || container.firstSeq === undefined) container.firstSeq = blockSeq
340
+ container.parts.push(part)
341
+ }
342
+
343
+ /**
344
+ * 取最内层的行(表格)。
345
+ * @returns {object|null} 行上下文
346
+ */
347
+ const innermostRow = () => {
348
+ for (let i = stack.length - 1; i >= 0; i--) if (stack[i].row) return stack[i].row
349
+ return null
350
+ }
351
+
352
+ /**
353
+ * 取最内层的列表。
354
+ * @returns {object|null} 列表上下文
355
+ */
356
+ const innermostList = () => {
357
+ for (let i = stack.length - 1; i >= 0; i--) if (stack[i].list) return stack[i].list
358
+ return null
359
+ }
360
+
361
+ /**
362
+ * 造一条块记录。
363
+ * @param {object} block 块
364
+ * @returns {void}
365
+ */
366
+ const emit = (block) => {
367
+ const seq = block.seq ?? blockSeq
368
+ blocks.push({ seq, ...block })
369
+ blockSeq += 1
370
+ }
371
+
372
+ /**
373
+ * 收尾一个容器 → 产出块。
374
+ * @param {object} entry 栈条目
375
+ * @param {number} endLine 关闭行
376
+ * @returns {void}
377
+ */
378
+ const finalizeContainer = (entry, endLine) => {
379
+ const container = entry.container
380
+ if (!container) return
381
+ const text = container.kind === 'code' ? container.parts.map((p) => p.value ?? '').join('') : renderInline(container.parts)
382
+ const trimmed = container.kind === 'code' ? text.replace(/^\n+|\n+$/g, '') : text.trim()
383
+ if (!trimmed) return
384
+
385
+ // 单元格 / 列表项 / 引用里的内层块(<td><p>…</p></td>、<li><div>…</div></li>)不单独成块,
386
+ // 而是把文字吸收进外层容器——否则表格里会冒出游离段落,列表标记也会丢
387
+ // 只有「段落 / 标题 / 代码」这类内容块会被吸收;嵌套列表项与嵌套表格必须保持独立块
388
+ const absorbTarget = findAbsorbingContainer(stack)
389
+ const absorbable = container.kind === 'paragraph' || container.kind === 'heading' || container.kind === 'code'
390
+ if (absorbable && absorbTarget && absorbTarget !== container) {
391
+ if (absorbTarget.parts.length > 0) {
392
+ absorbTarget.parts.push({ type: 'text', value: absorbTarget.kind === 'quote' ? '\n' : ' ' })
393
+ }
394
+ absorbTarget.parts.push({ type: 'text', value: trimmed })
395
+ if (absorbTarget.firstSeq === null || absorbTarget.firstSeq === undefined) absorbTarget.firstSeq = container.firstSeq
396
+ return
397
+ }
398
+
399
+ if (container.kind === 'cell') {
400
+ const row = innermostRow()
401
+ if (row) {
402
+ row.cells.push(trimmed)
403
+ if (container.cellType === 'th') row.hasTh = true
404
+ }
405
+ return
406
+ }
407
+ if (container.kind === 'code') {
408
+ stats.codeBlocks += 1
409
+ emit({
410
+ kind: 'code', line: container.line, endLine, seq: container.firstSeq ?? blockSeq,
411
+ text: trimmed, lang: container.lang ?? '', inMain: entry.inMain,
412
+ })
413
+ return
414
+ }
415
+ if (container.kind === 'heading') {
416
+ stats.headings += 1
417
+ if (container.level === 1 && !headingFallback) headingFallback = stripInline(trimmed)
418
+ emit({
419
+ kind: 'heading', line: container.line, endLine, seq: container.firstSeq ?? blockSeq,
420
+ text: trimmed, level: container.level, inMain: entry.inMain, id: container.id ?? '',
421
+ })
422
+ return
423
+ }
424
+ if (container.kind === 'listItem') {
425
+ emit({
426
+ kind: 'listItem', line: container.line, endLine, seq: container.firstSeq ?? blockSeq,
427
+ text: trimmed, ordered: container.ordered, index: container.index, depth: container.depth,
428
+ inMain: entry.inMain,
429
+ })
430
+ return
431
+ }
432
+ if (container.kind === 'quote') {
433
+ emit({
434
+ kind: 'quote', line: container.line, endLine, seq: container.firstSeq ?? blockSeq,
435
+ text: trimmed, inMain: entry.inMain,
436
+ })
437
+ return
438
+ }
439
+ emit({
440
+ kind: 'paragraph', line: container.line, endLine, seq: container.firstSeq ?? blockSeq,
441
+ text: trimmed, inMain: entry.inMain,
442
+ })
443
+ }
444
+
445
+ /**
446
+ * 收尾一个开标签对应的所有副作用。
447
+ * @param {object} entry 栈条目
448
+ * @param {number} endLine 关闭行(1 基)
449
+ * @returns {void}
450
+ */
451
+ const finalizeEntry = (entry, endLine) => {
452
+ if (entry.name === 'title') {
453
+ inTitle = false
454
+ const text = collapseWhitespace(titleBuffer).trim()
455
+ if (text && !titleFromTag) titleFromTag = text.slice(0, 300)
456
+ return
457
+ }
458
+ if (entry.container) {
459
+ entry.container.line = entry.container.line ?? entry.line
460
+ finalizeContainer(entry, endLine)
461
+ entry.container = null
462
+ }
463
+ if (entry.table) {
464
+ const rows = entry.table.rows.filter((row) => row.cells.length > 0)
465
+ if (rows.length > 0) {
466
+ // Markdown 表格必须有表头行:有 <th> 就用首行当表头;没有就把首行提上来(并标注 headerIsTh:false)
467
+ const headerIsTh = rows.length > 1 && rows[0].hasTh
468
+ stats.tables += 1
469
+ emit({
470
+ kind: 'table', line: entry.line, endLine, seq: entry.seq,
471
+ header: rows[0].cells, rows: rows.slice(1).map((row) => row.cells), headerIsTh, inMain: entry.inMain,
472
+ })
473
+ }
474
+ entry.table = null
475
+ }
476
+ if (entry.list) entry.list = null
477
+ }
478
+
479
+ for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) {
480
+ const token = tokens[tokenIndex]
481
+ blockSeq = tokenIndex
482
+
483
+ if (token.type === 'text') {
484
+ const noise = activeNoise()
485
+ if (noise) {
486
+ const chars = collapseWhitespace(token.raw).trim().length
487
+ if (chars > 0) {
488
+ removed.chars[noise] = (removed.chars[noise] ?? 0) + chars
489
+ }
490
+ continue
491
+ }
492
+ if (inTitle) { titleBuffer += decodeEntities(token.raw); continue }
493
+ const decoded = decodeEntities(token.raw)
494
+ appendVisible(decoded, token.line)
495
+ const container = innerContainer()
496
+ const isCode = container.kind === 'code'
497
+ const value = isCode ? decoded : collapseWhitespace(decoded)
498
+ if (!value.trim()) {
499
+ // 空白 token 不能一律丢:行内元素之间靠它隔开,`<pre>` 里的空行靠它保留
500
+ if (isCode) {
501
+ if (value) container.parts.push({ type: 'text', value })
502
+ } else if (value.length > 0 && container.parts.length > 0) {
503
+ container.parts.push({ type: 'text', value: ' ' })
504
+ }
505
+ continue
506
+ }
507
+ if (container.firstSeq === null || container.firstSeq === undefined) container.firstSeq = tokenIndex
508
+ container.parts.push({ type: 'text', value })
509
+ continue
510
+ }
511
+
512
+ if (token.type !== 'open' && token.type !== 'close') continue
513
+
514
+ if (token.type === 'close') {
515
+ const index = findLastIndex(stack, (entry) => entry.name === token.name)
516
+ if (index === -1) continue
517
+ while (stack.length > index) {
518
+ const entry = stack.pop()
519
+ // 行内标记的闭合必须同步写进片段序列,否则 `</a>` 之后的正文会被误并进链接文字
520
+ if (entry.inline) pushPart({ type: 'close', tag: entry.inline.tag })
521
+ finalizeEntry(entry, token.line)
522
+ }
523
+ continue
524
+ }
525
+
526
+ const name = token.name
527
+ const attrs = token.attrs ?? {}
528
+ stats.elements += 1
529
+ const noise = noiseKindOf(name, attrs)
530
+ const inheritedNoise = activeNoise()
531
+ const inMain = hasAncestorMain(stack) || name === 'main' || name === 'article' ||
532
+ /^main$/i.test(attrs.role ?? '') || /articleBody/i.test(attrs.itemprop ?? '')
533
+
534
+ if (name === 'title') {
535
+ inTitle = true
536
+ titleBuffer = ''
537
+ stack.push({ name, kind: 'title', noise: inheritedNoise, line: token.line, inMain })
538
+ continue
539
+ }
540
+
541
+ if (noise || inheritedNoise) {
542
+ const kind = noise ?? inheritedNoise
543
+ if (noise) {
544
+ stats.noiseElements += 1
545
+ removed.elements[kind] = (removed.elements[kind] ?? 0) + 1
546
+ }
547
+ if (token.selfClosing) continue
548
+ stack.push({ name, kind: 'noise', noise: kind, line: token.line, inMain })
549
+ continue
550
+ }
551
+
552
+ if (HEADINGS[name]) {
553
+ stack.push({
554
+ name, kind: 'heading', line: token.line, inMain, seq: tokenIndex,
555
+ container: { kind: 'heading', level: HEADINGS[name], parts: [], firstSeq: null, line: token.line, id: attrs.id ?? '' },
556
+ })
557
+ continue
558
+ }
559
+
560
+ if (name === 'ul' || name === 'ol') {
561
+ stats.lists += 1
562
+ stack.push({ name, kind: 'list', line: token.line, inMain, list: { ordered: name === 'ol', counter: 0, depth: countAncestors(stack, (entry) => entry.kind === 'list') + 1 } })
563
+ continue
564
+ }
565
+
566
+ if (name === 'li') {
567
+ const list = innermostList()
568
+ let index = null
569
+ let ordered = false
570
+ let depth = 1
571
+ if (list) {
572
+ list.counter += 1
573
+ index = list.ordered ? list.counter : null
574
+ ordered = list.ordered
575
+ depth = list.depth
576
+ }
577
+ stack.push({
578
+ name, kind: 'li', line: token.line, inMain, seq: tokenIndex,
579
+ container: { kind: 'listItem', parts: [], firstSeq: null, line: token.line, ordered, index, depth },
580
+ listOwner: list,
581
+ })
582
+ continue
583
+ }
584
+
585
+ if (name === 'table') {
586
+ stack.push({ name, kind: 'table', line: token.line, inMain, seq: tokenIndex, table: { rows: [], current: null, line: token.line } })
587
+ continue
588
+ }
589
+
590
+ if (name === 'tr') {
591
+ const tableEntry = findLast(stack, (entry) => entry.table)
592
+ if (tableEntry) {
593
+ const row = { cells: [], hasTh: false, line: token.line }
594
+ tableEntry.table.rows.push(row)
595
+ stack.push({ name, kind: 'row', line: token.line, inMain, row })
596
+ } else {
597
+ stack.push({ name, kind: 'row', line: token.line, inMain, row: { cells: [], hasTh: false, line: token.line } })
598
+ }
599
+ continue
600
+ }
601
+
602
+ if (name === 'td' || name === 'th') {
603
+ // 没有 <tr> 的畸形表格:补一行,别把单元格文字丢掉
604
+ if (!innermostRow()) {
605
+ const tableEntry = findLast(stack, (entry) => entry.table)
606
+ if (tableEntry) {
607
+ const row = { cells: [], hasTh: false, line: token.line }
608
+ tableEntry.table.rows.push(row)
609
+ stack.push({ name: 'tr', kind: 'row', line: token.line, inMain, row })
610
+ }
611
+ }
612
+ stack.push({
613
+ name, kind: 'cell', line: token.line, inMain, seq: tokenIndex,
614
+ container: { kind: 'cell', parts: [], firstSeq: null, line: token.line, cellType: name === 'th' ? 'th' : 'td' },
615
+ })
616
+ continue
617
+ }
618
+
619
+ if (name === 'pre') {
620
+ const lang = languageOf(attrs, '')
621
+ stack.push({
622
+ name, kind: 'pre', line: token.line, inMain, seq: tokenIndex,
623
+ container: { kind: 'code', parts: [], firstSeq: null, line: token.line, lang },
624
+ })
625
+ continue
626
+ }
627
+
628
+ if (name === 'code' && hasAncestor(stack, (entry) => entry.kind === 'pre')) {
629
+ // `<pre><code class="language-js">`:语言在 code 上
630
+ const preEntry = [...stack].reverse().find((entry) => entry.kind === 'pre')
631
+ if (preEntry?.container) preEntry.container.lang = languageOf(attrs, preEntry.container.lang)
632
+ stack.push({ name, kind: 'inline', line: token.line, inMain })
633
+ continue
634
+ }
635
+
636
+ if (name === 'blockquote') {
637
+ stack.push({
638
+ name, kind: 'quote', line: token.line, inMain, seq: tokenIndex,
639
+ container: { kind: 'quote', parts: [], firstSeq: null, line: token.line },
640
+ })
641
+ continue
642
+ }
643
+
644
+ if (name === 'br') {
645
+ pushPart({ type: 'text', value: '\n' })
646
+ continue
647
+ }
648
+
649
+ if (name === 'hr') {
650
+ emit({ kind: 'rule', line: token.line, endLine: token.line, seq: tokenIndex, inMain })
651
+ continue
652
+ }
653
+
654
+ if (name === 'img') {
655
+ const alt = collapseWhitespace(attrs.alt ?? '').trim()
656
+ const src = resolveUrl(attrs.src ?? '', baseUrl)
657
+ if (alt || src) pushPart({ type: 'text', value: alt ? `![${alt}](${src})` : `![](${src})` })
658
+ continue
659
+ }
660
+
661
+ if (name in INLINE_MARKS) {
662
+ if (token.selfClosing) continue
663
+ if (name === 'a') {
664
+ const href = resolveUrl(attrs.href ?? '', baseUrl)
665
+ if (href && !href.startsWith('#') && links.length < maxLinks) {
666
+ links.push({ url: href, line: token.line })
667
+ stats.links += 1
668
+ }
669
+ stack.push({ name, kind: 'inline', line: token.line, inMain, inline: { tag: 'a', href } })
670
+ pushPart({ type: 'open', tag: 'a', href })
671
+ continue
672
+ }
673
+ const tag = name === 'b' ? 'strong' : name === 'i' ? 'em' : name
674
+ stack.push({ name, kind: 'inline', line: token.line, inMain, inline: { tag } })
675
+ pushPart({ type: 'open', tag })
676
+ continue
677
+ }
678
+
679
+ if (name === 'code' || name === 'kbd' || name === 'samp' || name === 'tt') {
680
+ if (token.selfClosing) continue
681
+ stack.push({ name, kind: 'inline', line: token.line, inMain, inline: { tag: 'code' } })
682
+ pushPart({ type: 'open', tag: 'code' })
683
+ continue
684
+ }
685
+
686
+ if (BLOCK_CONTAINERS.has(name)) {
687
+ stack.push({
688
+ name, kind: 'block', line: token.line, inMain, seq: tokenIndex,
689
+ container: { kind: 'paragraph', parts: [], firstSeq: null, line: token.line },
690
+ })
691
+ continue
692
+ }
693
+
694
+ // 其余标签(head/meta/link/script 已在噪音里、col/colgroup/tbody/thead/tfoot/…)不进栈也不产块
695
+ if (!token.selfClosing) stack.push({ name, kind: 'plain', line: token.line, inMain })
696
+ }
697
+
698
+ // EOF:把还开着的元素按最内层优先收尾(浏览器容错的简化版)
699
+ while (stack.length > 0) {
700
+ const entry = stack.pop()
701
+ finalizeEntry(entry, rawLines.length)
702
+ }
703
+ if (loose) {
704
+ const text = renderInline(loose.parts).trim()
705
+ if (text) emit({ kind: 'paragraph', line: loose.line, endLine: rawLines.length, seq: loose.firstSeq ?? 0, text, inMain: false })
706
+ }
707
+
708
+ blocks.sort((a, b) => a.seq - b.seq)
709
+
710
+ // 行号对齐的可见文本:收拢空白,保持与源文件同样的行数
711
+ for (let i = 0; i < textLines.length; i++) textLines[i] = collapseWhitespace(textLines[i]).trim()
712
+
713
+ const title = titleFromTag || headingFallback || firstNonEmpty(textLines).slice(0, 200)
714
+
715
+ return { title, blocks, textLines, removed, links, signals, stats: { ...stats, blocks: blocks.length } }
716
+ }
717
+
718
+ /**
719
+ * 从 class 里取代码语言(`language-js` / `lang-js`)。
720
+ * @param {Record<string,string>} attrs 属性
721
+ * @param {string} fallback 兜底值
722
+ * @returns {string} 语言名
723
+ */
724
+ function languageOf(attrs, fallback = '') {
725
+ const match = /(?:language|lang)-([\w+#.-]+)/i.exec(attrs.class ?? '')
726
+ return match ? match[1].toLowerCase() : fallback
727
+ }
728
+
729
+ /**
730
+ * 找最内层的「吸收型」容器(单元格 / 列表项 / 引用):它们的子块不单独成块。
731
+ * @param {Array<object>} stack 元素栈
732
+ * @returns {object|null} 吸收目标容器
733
+ */
734
+ function findAbsorbingContainer(stack) {
735
+ for (let i = stack.length - 1; i >= 0; i--) {
736
+ const container = stack[i].container
737
+ if (!container) continue
738
+ if (container.kind === 'cell' || container.kind === 'listItem' || container.kind === 'quote') return container
739
+ }
740
+ return null
741
+ }
742
+
743
+ /**
744
+ * 判断祖先里有没有某个元素。
745
+ * @param {Array<object>} stack 栈
746
+ * @param {(entry:object)=>boolean} predicate 判定
747
+ * @returns {boolean} 是否存在
748
+ */
749
+ function hasAncestor(stack, predicate) {
750
+ for (let i = stack.length - 1; i >= 0; i--) if (predicate(stack[i])) return true
751
+ return false
752
+ }
753
+
754
+ /**
755
+ * 数祖先里满足条件的个数。
756
+ * @param {Array<object>} stack 栈
757
+ * @param {(entry:object)=>boolean} predicate 判定
758
+ * @returns {number} 个数
759
+ */
760
+ function countAncestors(stack, predicate) {
761
+ let count = 0
762
+ for (const entry of stack) if (predicate(entry)) count += 1
763
+ return count
764
+ }
765
+
766
+ /**
767
+ * 判断当前是否在 `<main>` / `<article>` 里(正文信号的强证据)。
768
+ * @param {Array<object>} stack 栈
769
+ * @returns {boolean} 是否在正文容器内
770
+ */
771
+ function hasAncestorMain(stack) {
772
+ for (const entry of stack) {
773
+ if (entry.inMain) return true
774
+ if (entry.name === 'main' || entry.name === 'article') return true
775
+ }
776
+ return false
777
+ }
778
+
779
+ /**
780
+ * 从后往前找第一个满足条件的元素下标。
781
+ * @param {Array<object>} list 列表
782
+ * @param {(item:object)=>boolean} predicate 判定
783
+ * @returns {number} 下标,找不到返回 -1
784
+ */
785
+ function findLastIndex(list, predicate) {
786
+ for (let i = list.length - 1; i >= 0; i--) if (predicate(list[i])) return i
787
+ return -1
788
+ }
789
+
790
+ /**
791
+ * 从后往前找第一个满足条件的元素。
792
+ * @param {Array<object>} list 列表
793
+ * @param {(item:object)=>boolean} predicate 判定
794
+ * @returns {object|undefined} 元素
795
+ */
796
+ function findLast(list, predicate) {
797
+ for (let i = list.length - 1; i >= 0; i--) if (predicate(list[i])) return list[i]
798
+ return undefined
799
+ }
800
+
801
+ /**
802
+ * 去掉行内 Markdown 标记(标题/摘要用纯文本)。
803
+ * @param {string} text 含标记的文本
804
+ * @returns {string} 纯文本
805
+ */
806
+ export function stripInline(text) {
807
+ return String(text ?? '')
808
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
809
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
810
+ .replace(/(\*\*|~~|\*|`)/g, '')
811
+ .trim()
812
+ }
813
+
814
+ /**
815
+ * 取第一个非空行。
816
+ * @param {string[]} lines 行
817
+ * @returns {string} 第一个非空行
818
+ */
819
+ function firstNonEmpty(lines) {
820
+ for (const line of lines) if (line && line.trim()) return line.trim()
821
+ return ''
822
+ }