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/LICENSE +21 -0
- package/README.md +157 -9
- package/cordis.patch.yml +22 -0
- package/dsh-plugin.naming.json +28 -0
- package/lib/cli.js +251 -0
- package/lib/entities.mjs +65 -0
- package/lib/errors.mjs +137 -0
- package/lib/extract.mjs +192 -0
- package/lib/format.mjs +137 -0
- package/lib/html.mjs +822 -0
- package/lib/index.mjs +15 -0
- package/lib/limit.mjs +120 -0
- package/lib/markdown.mjs +161 -0
- package/lib/net.mjs +283 -0
- package/lib/robots.mjs +171 -0
- package/lib/scrape.mjs +560 -0
- package/package.json +45 -9
- package/plugin.mjs +197 -0
- package/scripts/check.mjs +170 -0
- package/scripts/verify-install.mjs +143 -0
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 ? `` : `` })
|
|
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
|
+
}
|