/** * HTML -> Markdown 转换(参考 mailhook(Rust) mail.rs 的实现思路): * 遍历 DOM,按块级/行内语义转换,表格/列表/引用/代码块均有映射; * 输出折叠连续空行。用于邮件正文提取(docs/07 webhook payload 的 markdown 字段)。 */ import { parse, HTMLElement, TextNode, type Node as HtmlNode } from "node-html-parser"; /** # 成员变量: 不参与转换的标签(内容整体丢弃) **/ const SKIP_TAGS = new Set([ "script", "style", "head", "noscript", "template", "meta", "link", "title", ]); /** # 函数 * 将 HTML 片段转换为 Markdown * 参数: html - HTML 片段(可为完整文档) * 返回值: Markdown 文本;空内容返回空串 */ export function htmlToMarkdown(html: string): string { let root: HTMLElement; try { root = parse(html); } catch { return ""; } const blocks: string[] = []; renderChildren(root, blocks, 0); return collapseBlankLines( blocks .filter((b) => b.trim().length > 0) .join("\n\n"), ); } /** # 函数 * 压缩连续空行为单个空行 */ function collapseBlankLines(s: string): string { const lines = s.split("\n"); const out: string[] = []; let blank = 0; for (const line of lines) { if (line.trim().length === 0) { blank += 1; if (blank > 1) continue; } else { blank = 0; } out.push(line); } return out.join("\n").trim(); } /** # 函数 * 折叠空白(换行/多空格折叠为单空格) */ function normalizeWs(s: string): string { return s.split(/\s+/).filter(Boolean).join(" "); } /** # 函数 * 将   归一为空格 */ function collapseNbsp(s: string): string { return s.replace(/\u00a0/g, " "); } /** # 函数 * 取元素行内文本(递归处理 strong/em/code/a 等行内语义) */ function inlineText(el: HTMLElement): string { const buf: string[] = []; renderInlineChildren(el, buf); return normalizeWs(buf.join("")); } /** # 函数 * 渲染元素子节点为块级 Markdown 片段列表 */ function renderChildren(el: HTMLElement, blocks: string[], indent: number): void { for (const child of el.childNodes) { renderNode(child, blocks, indent); } } /** # 函数 * 渲染单个节点(元素或文本) */ function renderNode(node: HtmlNode, blocks: string[], indent: number): void { if (node instanceof HTMLElement) { const tag = node.rawTagName?.toLowerCase() ?? ""; if (SKIP_TAGS.has(tag)) return; renderElement(node, tag, blocks, indent); return; } if (node.nodeType === 3) { // 文本节点 const txt = normalizeWs(collapseNbsp(node.text ?? "")); if (txt) blocks.push(" ".repeat(indent) + txt); } } /** # 函数 * 块级元素映射:h1-h6/p/hr/pre/blockquote/ul/ol/table/img 等 */ function renderElement(el: HTMLElement, tag: string, blocks: string[], indent: number): void { const pad = " ".repeat(indent); switch (tag) { case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": { const level = Number(tag.slice(1)) || 1; blocks.push(`${pad}${"#".repeat(level)} ${inlineText(el)}`); return; } case "p": { const buf: string[] = []; renderInlineChildren(el, buf); const txt = normalizeWs(buf.join("")); if (txt) blocks.push(pad + txt); return; } case "br": blocks.push(""); return; case "hr": blocks.push(`${pad}---`); return; case "pre": { const code = collectPreText(el); blocks.push(`${pad}\`\`\`\n${code}\n${pad}\`\`\``); return; } case "blockquote": { const inner: string[] = []; renderChildren(el, inner, 0); const quoted = inner .join("\n\n") .split("\n") .map((l) => `${pad}> ${l}`) .join("\n"); blocks.push(quoted); return; } case "ul": case "ol": { let idx = 0; for (const li of el.querySelectorAll(":scope > li")) { idx += 1; const own: string[] = []; // 行内内容(跳过嵌套列表) for (const c of li.childNodes) { const isList = c instanceof HTMLElement && ["ul", "ol"].includes(c.rawTagName?.toLowerCase() ?? ""); if (isList) continue; renderInlineNode(c, own); } const marker = tag === "ol" ? `${idx}.` : "-"; blocks.push(`${pad}${marker} ${normalizeWs(own.join(""))}`); // 嵌套列表 for (const sub of li.querySelectorAll(":scope > ul, :scope > ol")) { renderElement(sub, sub.rawTagName.toLowerCase(), blocks, indent + 1); } } return; } case "table": { const md = tableMarkdown(el, indent); if (md) blocks.push(md); return; } case "img": { const src = el.getAttribute("src") ?? ""; const alt = el.getAttribute("alt") ?? ""; if (src) blocks.push(`${pad}![${alt}](${src})`); return; } case "a": { const href = el.getAttribute("href") ?? ""; const txt = inlineText(el); if (txt) blocks.push(`${pad}[${txt}](${href})`); return; } default: // 通用块级容器:递归处理子节点 renderChildren(el, blocks, indent); } } /** # 函数 * 收集 pre 元素保留原始换行的文本 */ function collectPreText(el: HTMLElement): string { const out: string[] = []; const walk = (node: HtmlNode) => { if (node instanceof HTMLElement) { if ((node.rawTagName ?? "").toLowerCase() === "br") out.push("\n"); for (const c of node.childNodes) walk(c); return; } if (node.nodeType === 3) out.push(collapseNbsp(node.text ?? "")); }; for (const c of el.childNodes) walk(c); return out.join("").replace(/^\n+|\n+$/g, ""); } /** # 函数 * 渲染元素子节点为行内 Markdown */ function renderInlineChildren(el: HTMLElement, out: string[]): void { for (const child of el.childNodes) { renderInlineNode(child, out); } } /** # 函数 * 行内节点映射:strong/em/del/code/br/a/img;行内上下文的块级标签降级为空格分隔 */ function renderInlineNode(node: HtmlNode, out: string[]): void { if (!(node instanceof HTMLElement)) { if (node.nodeType === 3) out.push(collapseNbsp(node.text ?? "")); return; } const tag = node.rawTagName?.toLowerCase() ?? ""; if (SKIP_TAGS.has(tag)) return; const inner = (): string => { const buf: string[] = []; renderInlineChildren(node, buf); return buf.join("").trim(); }; switch (tag) { case "strong": case "b": { const buf = inner(); if (buf) out.push(`**${buf}**`); return; } case "em": case "i": { const buf = inner(); if (buf) out.push(`*${buf}*`); return; } case "del": case "s": case "strike": { const buf = inner(); if (buf) out.push(`~~${buf}~~`); return; } case "code": { const buf = inner(); if (buf) out.push(`\`${buf}\``); return; } case "br": out.push("\n"); return; case "a": { const href = node.getAttribute("href") ?? ""; const buf = normalizeWs(node.textContent ?? ""); out.push(buf ? `[${buf}](${href})` : `<${href}>`); return; } case "img": { const src = node.getAttribute("src") ?? ""; const alt = node.getAttribute("alt") ?? ""; out.push(`![${alt}](${src})`); return; } case "p": case "div": case "tr": case "li": case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": case "table": case "blockquote": case "pre": { // 行内上下文里出现的块级标签:换行后继续,避免内容粘连 out.push(" "); renderInlineChildren(node, out); out.push(" "); return; } default: renderInlineChildren(node, out); } } /** # 函数 * 表格 -> Markdown 管道表(首行作表头,列数对齐,| 转义) */ function tableMarkdown(table: HTMLElement, indent: number): string | null { const pad = " ".repeat(indent); const rows: string[][] = []; for (const tr of table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")) { const cells: string[] = []; for (const cell of tr.querySelectorAll(":scope > th, :scope > td")) { cells.push(inlineText(cell)); } if (cells.length > 0) rows.push(cells); } if (rows.length === 0) return null; const width = Math.max(...rows.map((r) => r.length)); const padRow = (r: string[]) => { const c = [...r]; while (c.length < width) c.push(""); return c; }; const esc = (s: string) => s.replace(/\|/g, "\\|"); const lines: string[] = []; lines.push(`${pad}| ${padRow(rows[0]).map(esc).join(" | ")} |`); lines.push(`${pad}|${new Array(width).fill(" --- ").join("|")}|`); for (const r of rows.slice(1)) { lines.push(`${pad}| ${padRow(r).map(esc).join(" | ")} |`); } return lines.join("\n"); }