跳到正文
跟我做
自媒体自动化9 分钟Node.js

Markdown 写完,转成公众号排版

用一段不依赖第三方库的 Node 脚本,把 Markdown 转成带行内样式的 HTML。浏览器打开后全选复制,粘贴进公众号编辑器,标题、列表、代码块都能保留。

约 9 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 准备一篇示例
  3. 写脚本
  4. 运行
  5. 常见坑
  6. 1. 图片不会自动上传
  7. 2. 表格不支持
  8. 3. 代码块里的反引号
  9. 4. 样式被平台改写
  10. 下一步读什么

30 秒速查

  • 文章用 Markdown 写,标题、列表、代码都能保留
  • 跑 node resources/md2wechat/md2wechat.mjs 示例.md
  • 生成 示例.html,浏览器打开后全选复制
  • 粘贴到公众号编辑器,发布前预览一遍

这篇教程解决什么问题

在编辑器里写 Markdown 很顺,粘到公众号就乱了:标题没了层级,列表挤成一段,代码块变成纯文本。

用第三方排版工具当然可以,但通常要登录、绑定账号,样式也在别人手里。这篇换一种做法:本地把 Markdown 转成带行内样式的 HTML,复制粘贴进公众号。

不调用接口,不绑定账号,样式表在你自己手里。

准备一篇示例

新建 示例.md:

# 为什么表格总乱码

很多人以为是 **Excel 坏了**,其实多半是编码没对上。

## 先做三件事

1. 备份原文件
2. 看文件开头
3. 用 `utf8` 重新打开

> 不要在原文件上直接保存。

```bash
node fix-csv.mjs data/
```

参考:[跟我做](https://www.genwozuo.com/)

外层用四个反引号,避免和里面的代码块冲突。

写脚本

保存为 resources/md2wechat/md2wechat.mjs:

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

// 把 Markdown 转成公众号能直接粘贴的 HTML。
// 公众号会过滤 <style>,所以样式全部写成行内 style;不依赖任何第三方库。

const STYLE = {
  section:
    "font-size:16px;line-height:1.75;color:#333;letter-spacing:.5px;max-width:677px;margin:0 auto;padding:0 16px;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;",
  h2: "font-size:22px;font-weight:700;line-height:1.4;margin:28px 0 12px;color:#1a1a1a;",
  h3: "font-size:18px;font-weight:700;line-height:1.4;margin:22px 0 10px;color:#1a1a1a;",
  h4: "font-size:16px;font-weight:700;line-height:1.4;margin:18px 0 8px;color:#1a1a1a;",
  p: "margin:12px 0;font-size:16px;line-height:1.75;color:#333;",
  blockquote:
    "margin:16px 0;padding:10px 14px;border-left:3px solid #2f6f5e;background:#f7f7f5;color:#555;",
  code: "padding:2px 5px;border-radius:4px;background:#f2f2f0;color:#c7254e;font-size:14px;",
  pre: "margin:16px 0;padding:12px 14px;border-radius:8px;background:#10201c;color:#e6efe9;overflow-x:auto;font-size:13px;line-height:1.6;",
  li: "margin:6px 0;font-size:16px;line-height:1.75;color:#333;",
  a: "color:#2f6f5e;text-decoration:none;border-bottom:1px solid #b9d8cd;",
  hr: "border:0;border-top:1px solid #e5e5e5;margin:24px 0;",
};

export function escapeHtml(text) {
  return String(text)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

export function inline(text) {
  const codes = [];
  let value = text.replace(/`([^`]+)`/g, (_match, code) => {
    codes.push(code);
    return `\u0000${codes.length - 1}\u0000`;
  });

  value = escapeHtml(value);
  value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  value = value.replace(
    /\[([^\]]+)\]\(([^)]+)\)/g,
    `<a href="${escapeHtml("$2")}" style="${STYLE.a}">$1</a>`,
  );

  return value.replace(/\u0000(\d+)\u0000/g, (_match, index) => {
    return `<code style="${STYLE.code}">${escapeHtml(codes[Number(index)])}</code>`;
  });
}

function isBlockStart(line) {
  return (
    /^(#{1,4})\s+/.test(line) ||
    /^```/.test(line) ||
    /^> /.test(line) ||
    /^- /.test(line) ||
    /^\d+\. /.test(line) ||
    /^---+$/.test(line.trim())
  );
}

export function markdownToHtml(markdown) {
  const lines = markdown.replaceAll("\r\n", "\n").split("\n");
  const blocks = [];
  let index = 0;

  while (index < lines.length) {
    const line = lines[index];
    if (!line.trim()) {
      index += 1;
      continue;
    }

    if (line.startsWith("```")) {
      index += 1;
      const code = [];
      while (index < lines.length && !lines[index].startsWith("```")) {
        code.push(lines[index]);
        index += 1;
      }
      index += 1;
      blocks.push(`<pre style="${STYLE.pre}"><code>${escapeHtml(code.join("\n"))}</code></pre>`);
      continue;
    }

    const heading = /^(#{1,4})\s+(.*)$/.exec(line);
    if (heading) {
      const level = Math.min(heading[1].length + 1, 4);
      blocks.push(`<h${level} style="${STYLE[`h${level}`]}">${inline(heading[2])}</h${level}>`);
      index += 1;
      continue;
    }

    if (line.startsWith("> ")) {
      const quote = [];
      while (index < lines.length && lines[index].startsWith("> ")) {
        quote.push(lines[index].slice(2));
        index += 1;
      }
      blocks.push(
        `<blockquote style="${STYLE.blockquote}"><p style="${STYLE.p}">${inline(quote.join("<br/>"))}</p></blockquote>`,
      );
      continue;
    }

    if (/^- /.test(line)) {
      const items = [];
      while (index < lines.length && /^- /.test(lines[index])) {
        items.push(lines[index].slice(2));
        index += 1;
      }
      blocks.push(
        `<ul style="margin:12px 0;padding-left:22px;">${items
          .map((item) => `<li style="${STYLE.li}">${inline(item)}</li>`)
          .join("")}</ul>`,
      );
      continue;
    }

    if (/^\d+\. /.test(line)) {
      const items = [];
      while (index < lines.length && /^\d+\. /.test(lines[index])) {
        items.push(lines[index].replace(/^\d+\.\s+/, ""));
        index += 1;
      }
      blocks.push(
        `<ol style="margin:12px 0;padding-left:22px;">${items
          .map((item) => `<li style="${STYLE.li}">${inline(item)}</li>`)
          .join("")}</ol>`,
      );
      continue;
    }

    if (/^---+$/.test(line.trim())) {
      blocks.push(`<hr style="${STYLE.hr}"/>`);
      index += 1;
      continue;
    }

    const paragraph = [];
    while (index < lines.length && lines[index].trim() && !isBlockStart(lines[index])) {
      paragraph.push(lines[index]);
      index += 1;
    }
    blocks.push(`<p style="${STYLE.p}">${inline(paragraph.join("<br/>"))}</p>`);
  }

  return `<section style="${STYLE.section}">\n${blocks.join("\n")}\n</section>\n`;
}

const isDirectRun =
  process.argv[1] &&
  pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;

if (isDirectRun) {
  const input = process.argv.slice(2).find((arg) => !arg.startsWith("--"));
  if (!input) {
    console.error("用法:node md2wechat.mjs <文章.md>");
    process.exitCode = 1;
  } else {
    const source = await readFile(input, "utf8");
    const html = markdownToHtml(source);
    const output = input.replace(/\.md$/i, "") + ".html";
    await writeFile(output, html, "utf8");
    console.log(`已生成:${output}(${html.length} 字符)`);
  }
}

脚本做三件事:

  • 按行解析标题、列表、引用、代码块和段落;
  • 行内处理加粗、行内代码和链接;
  • 所有样式写成 style="...",因为公众号会丢掉 <style>。

运行

node resources/md2wechat/md2wechat.mjs 示例.md

真实输出:

已生成:示例.html(1547 字符)

打开 示例.html,页面是一段窄栏排版。全选复制,粘贴到公众号编辑器:

  • 标题会变成加粗的大字;
  • 列表保留项目符号;
  • 代码块是深色底、等宽字体;
  • 链接带下划线。

发布前在公众号里预览一次,确认平台没有改写代码块背景。

  1. 运行脚本生成 HTML

    node resources/md2wechat/md2wechat.mjs 示例.md → 示例.html(1547 字符)。

  2. 全选复制,粘进公众号编辑器

    标题变粗、列表保留项目符号、代码块深色、链接带下划线。

  3. 发布前预览一次

    确认平台没有改写代码块背景或链接样式。

图 1:从 Markdown 到公众号,三步走完。

常见坑

1. 图片不会自动上传

脚本只转文字。图片要在公众号编辑器里手动插入;Markdown 里的 ![]() 会被当普通文字。想批量处理图片,先看批量压缩图片。

2. 表格不支持

公众号对表格支持有限,复杂表格建议转成图片,或者拆成列表。脚本不解析 | 表格。

3. 代码块里的反引号

Markdown 里嵌代码块时,外层用四个反引号;否则解析会提前结束。

4. 样式被平台改写

公众号可能改写链接、代码块背景和字体。发布前预览,必要时在编辑器里手动补一下。

下一步读什么

排版解决的是「发出去好不好看」,发什么仍然靠选题:选题库和内容日历 会帮你把未来两周排好。配图可以用小红书批量封面一次出 20 张,再回来排版。

这篇也属于从选题到发布的发布环节。

常见问题

样式会被公众号过滤吗?

公众号会过滤 <style> 标签,所以脚本把样式全写成行内 style。大部分能保留,但代码块背景、链接下划线可能被平台改写;发布前一定要在编辑器里预览。

支持图片和表格吗?

不支持。图片要在公众号编辑器里手动上传,表格建议转成图片或简化成列表。这个脚本只解决文字排版,不处理素材上传。

需要绑定公众号或登录吗?

不需要。脚本只把 Markdown 转成 HTML,不调用任何接口。复制粘贴是手动的,账号和密码不经过脚本。

能改颜色和字号吗?

能。样式集中在脚本顶部的 STYLE 对象里,改 h2、p、code 的色值或字号即可;改完重新生成一次 HTML。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程