/**
* 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}`);
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(``);
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");
}