- 单一 Key:API 与 MCP 共用一个 wmh_ Key,凭据迁移至 settings 表(0004 删除 api_credentials) - Markdown:入库时 HTML→Markdown(mailhook 同思路),webhook payload 新增 markdown 字段 - /emails 路由改为 /inboxs 并支持域名筛选;设置按钮移入头像下拉 - 主题切换按钮(头像左侧,亮/暗持久化);设置模态仅 全局 Webhook/账号密码 两 tab 且高度限屏 - 列表操作列按钮加 SVG 图标;mailboxes 去 Webhook 列;最近邮件加收件人列
334 lines
8.8 KiB
TypeScript
334 lines
8.8 KiB
TypeScript
/**
|
|
* 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");
|
|
}
|