公众号的正文编辑器既看不懂 Markdown,也吃不下网页 HTML——它只认一小片【内联样式的 HTML】。本节要做的事,就是把 3.4.2 拿到的 Markdown 稿,转换到这一小片范围里,并且用一段审计代码保证转换结果一定落在范围内。
这是本章唯一一节纯技术、且不依赖任何平台的内容。3.4.2 拿到了 body_md,本节把它变成 body_html(3.2 字段表里的两列)。这一节也是本章相对第二章最大的增量——第二章的 RPA 只搬字段,本章的 RPA 还要搬格式。
一、先搞清楚【公众号到底吃什么】
三样东西放一起对比,一眼就明白问题在哪:
| 你手上的东西 | 公众号编辑器怎么看它 |
|---|---|
Markdown(## 标题、**加粗**) |
完全不认识,星号和井号会原样显示 |
网页 HTML(带 <style>、class="lead") |
认识一部分,但会洗掉另一部分——样式表被丢掉,class 被丢掉 |
内联样式 HTML(<p style="font-size:15px">) |
认识,且能保留——这是唯一能稳定存活的形式 |
所以本节的目标非常明确:把 Markdown 转换成【内联样式 HTML】。
二、公众号的清洗规则:六条铁律
下面六条是排版自动化的全部约束。每一条都对应一类【粘贴过去就完了】的事故:
| # | 铁律 | 违反的后果 |
|---|---|---|
| 1 | 不能有 <style> 标签 |
整段样式表被丢弃,全文变成无格式裸文本 |
| 2 | 不能有 class / id 属性 |
属性被剥掉 → 依赖它的样式全部失效 |
| 3 | 样式必须写在元素的 style 里 |
否则不生效(写在父容器上也常常不生效,因为父容器可能被重写) |
| 4 | 不能用 <h1> 到 <h6> |
会被转成 <p> 或直接删掉 → 样式白写。标题要用 <p> + 内联样式模拟 |
| 5 | 不支持伪元素、动画、定位、外部资源、脚本 | :before/:after/animation/position/transform/外链 CSS 字体,全部无效 |
| 6 | 正文里的图片必须来自微信素材库 | 外链图片发布后会变成裂图(不是不显示,是发布后才坏) |
能稳定使用的 CSS 属性其实不多,这一小片就够了:
font-size line-height letter-spacing color background-color
font-weight font-family text-align margin padding
border border-left border-bottom border-radius box-shadow
【能用单色、边框和留白表达的时候,就不要用渐变、阴影和复杂布局。】 因为那些复杂效果恰恰是最容易被清洗掉的,而且清洗是【悄悄发生】的——你在这边看着好好的,粘过去就没了。
三、一个关键机制:为什么【内联样式】能活下来
这一条很多人不理解,但它决定了 RPA 该怎么做:
公众号后台接受的不是【HTML 源码】,而是【剪贴板里的富文本】。
| 粘贴方式 | 结果 |
|---|---|
直接 Ctrl + V(剪贴板里带 HTML 格式) |
内联样式保留,排版完整 |
| 把 HTML 源码当纯文本粘进去 | 标签原样显示,或者干脆被清掉 |
这就是为什么那些 Markdown 排版工具能用——它们做的事不是【给你一段 HTML 代码让你贴】,而是把渲染好的富文本放进剪贴板,你按一下粘贴,样式就带过来了。
RPA 要做的不是【往输入框里打字】,而是:
1. 把转换好的 HTML 放进剪贴板的【HTML 格式】槽位(不是纯文本槽位);
2. 在编辑器里执行一次粘贴(Ctrl + V);
3. 回读一次,确认样式真的进来了。
如果只是把 HTML 字符串当文本输进去,你会看到满屏的尖括号。 这是本节最实操、也最容易踩的一个坑。
四、转换规则表
把 Markdown 的每种元素,映射到一个带内联样式的 <p>:
| Markdown | 转换后 | 说明 |
|---|---|---|
## 大标题 |
<p style="字号19px;居中;加粗"> |
只用于章内大标题(见下节的双标题坑) |
### 小标题 |
<p style="字号16px;加粗;左边框4px"> |
用左边框做视觉层级,比字号差更醒目 |
| 普通段落 | <p style="15px;行高1.8;色#3f3f3f"> |
主体 |
**加粗** |
<strong style="加粗;色#000"> |
<strong> 也必须带内联样式,否则被当默认样式 |
> 引用 |
<p style="浅底+左边框3px"> |
用背景色和边框模拟引用块 |
- 列表项 |
<p>· 内容</p> |
不用 <ul><li>——列表样式在微信里最容易错乱 |
1. 列表项 |
<p>1、内容</p> |
数字自己拼进文本,可控 |
--- |
<p style="居中;浅灰;字间距4px">✦ ✦ ✦</p> |
用符号做分隔,比 <hr> 稳 |
<table> |
不要转,改成截图 | 表格样式在微信里最不稳定 |
排版参数(一套可以直接用的推荐值):
| 参数 | 推荐值 | 为什么 |
|---|---|---|
| 正文字号 | 15px | 移动端阅读的舒适区(15~16px) |
| 行高 | 1.8 | 公众号以手机阅读为主,行高比屏幕宽裕 |
| 字间距 | 0.5px | 一点点空隙,读起来不挤 |
| 段间距 | 16px | 段与段之间留白,替代首行缩进 |
| 段首缩进 | 不用 | 公众号的阅读习惯是不缩进 |
| 正文颜色 | #3f3f3f | 深灰,比纯黑柔和 |
| 小标题 | 16px 加粗 + 左边框 | 层级靠边框,不靠花哨字体 |
五、动手 Demo:写一个转换器,再用审计器验收
下面这段代码只用 Python 标准库(不需要装任何东西)。它做两件事:转,以及验。
# -*- coding: utf-8 -*-
"""3.4.3 Demo:Markdown → 公众号可用的内联样式 HTML(纯标准库)"""
import re
import html
# ---- 所有样式都内联写死,不用 class / <style> ----
S = {
"p": "font-size:15px;line-height:1.8;letter-spacing:0.5px;color:#3f3f3f;margin:0 0 16px 0;padding:0;",
"h2": "font-size:19px;line-height:1.6;font-weight:bold;color:#222222;margin:28px 0 16px 0;text-align:center;",
"h3": "font-size:16px;line-height:1.6;font-weight:bold;color:#222222;margin:24px 0 12px 0;border-left:4px solid #d0a848;padding-left:10px;",
"strong": "font-weight:bold;color:#000000;",
"quote": "font-size:14px;line-height:1.8;color:#888888;margin:0 0 16px 0;border-left:3px solid #dddddd;padding:6px 0 6px 12px;background-color:#f7f7f7;",
"li": "font-size:15px;line-height:1.8;letter-spacing:0.5px;color:#3f3f3f;margin:0 0 8px 0;padding-left:8px;",
"hr": "text-align:center;color:#c9c9c9;font-size:13px;margin:24px 0;letter-spacing:4px;",
}
def inline(t):
t = html.escape(t, quote=False)
return re.sub(r"\*\*(.+?)\*\*",
lambda m: '<strong style="%s">%s</strong>' % (S["strong"], m.group(1)), t)
def convert(md):
out = []
for raw in md.split("\n"):
ln = raw.rstrip()
if not ln.strip():
continue
if re.match(r"^#{2,}\s+", ln): # ## / ### 一律转成 p
level = len(ln) - len(ln.lstrip("#"))
key = "h2" if level <= 2 else "h3"
out.append('<p style="%s">%s</p>' % (S[key], inline(ln.lstrip("#").strip())))
elif ln.startswith("> "):
out.append('<p style="%s">%s</p>' % (S["quote"], inline(ln[2:].strip())))
elif ln.strip() in ("---", "***", "___"):
out.append('<p style="%s">✦ ✦ ✦</p>' % S["hr"])
elif re.match(r"^[-*]\s+", ln):
out.append('<p style="%s">· %s</p>' % (S["li"], inline(re.sub(r"^[-*]\s+", "", ln))))
elif re.match(r"^\d+\.\s+", ln):
n = re.match(r"^(\d+)\.", ln).group(1)
out.append('<p style="%s">%s、%s</p>' % (S["li"], n, inline(re.sub(r"^\d+\.\s+", "", ln))))
else:
out.append('<p style="%s">%s</p>' % (S["p"], inline(ln.strip())))
return "\n".join(out)
def audit(h):
"""交付前自检:微信会剥掉/改坏的东西,一个都不许出现"""
bad, low = [], h.lower()
if "<style" in low:
bad.append("出现 <style> 标签")
if re.search(r"\sclass\s*=", low):
bad.append("出现 class 属性")
if re.search(r"\sid\s*=", low):
bad.append("出现 id 属性")
for t in ("h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "table"):
if "<" + t in low:
bad.append("出现 <%s> 标签" % t)
for prop in ("position:", "transform:", "animation:", "float:"):
if prop in low:
bad.append("出现微信不支持的 CSS:%s" % prop)
for m in re.finditer(r"<p(\s[^>]*)?>", h):
if "style=" not in (m.group(1) or ""):
bad.append("有一个 <p> 没有内联样式")
return bad
实际运行输出(真跑,不是示意):
================================================================
转换结果(前 6 个元素)
================================================================
<p style="font-size:19px;line-height:1.6;font-weight:bold;color:#222222;margin:28px 0 16px 0;text-align:center;">玄关收纳:我家最后只留下三件东西</p>
<p style="font-size:15px;line-height:1.8;letter-spacing:0.5px;color:#3f3f3f;margin:0 0 16px 0;padding:0;">玄关永远堆着鞋和快递盒,出门前翻三分钟找不到钥匙。</p>
<p style="font-size:16px;line-height:1.6;font-weight:bold;color:#222222;margin:24px 0 12px 0;border-left:4px solid #d0a848;padding-left:10px;">一、先做减法,再谈收纳</p>
<p style="font-size:15px;line-height:1.8;letter-spacing:0.5px;color:#3f3f3f;margin:0 0 16px 0;padding:0;"><strong style="font-weight:bold;color:#000000;">第一步不是买收纳盒,是扔东西。</strong> 我把不穿的鞋和攒了没用的袋子清掉,剩下的空间比想象的大。</p>
<p style="font-size:14px;line-height:1.8;color:#888888;margin:0 0 16px 0;border-left:3px solid #dddddd;padding:6px 0 6px 12px;background-color:#f7f7f7;">收纳的第一原则:先减后加,不是先加后减。</p>
<p style="font-size:16px;line-height:1.6;font-weight:bold;color:#222222;margin:24px 0 12px 0;border-left:4px solid #d0a848;padding-left:10px;">二、三件东西够用了</p>
...
总元素数 = 12,总字符数 = 1818
================================================================
交付前审计
================================================================
[OK] 无 <style> / 无 class / 无 id / 无 h1-h6 / 无 div / 无 table
[OK] 无 position/transform/animation/float
[OK] 每个 <p> 都带内联样式
================================================================
反面教材对照(同一个审计器跑一段【错的】HTML)
================================================================
[X] 出现 <style> 标签
[X] 出现 class 属性
[X] 出现 <h2> 标签
[X] 出现 <div> 标签
[X] 有一个 <p> 没有内联样式
读结果,四个值得注意的地方:
- 转换器输出的每一行都是【一个
<p>带一长串内联样式】——这就是 3.2 里说的【给公众号读的稿】。它不好看、不好读,但它不会坏; <strong>也带了内联样式——因为默认的<strong>样式也可能被平台的样式覆盖,写上更稳;- 审计器的三段输出分别对应三种状态:全绿(可以交付)、抓到问题(必须改)、以及反面教材里五条全中——注意那五条恰好就是第二节铁律 1~4 的违反。审计器不是装饰,它把【六条铁律】变成了可执行的检查;
<h2>也在被禁的名单里——这一点容易被忽略。公众号里用<p>模拟标题,不要用真正的标题标签(铁律 4)。
六、四个必须知道的坑
坑一:双标题
现象:发布后标题出现两次(后台标题栏一个、正文开头一个)。
原因:Markdown 稿里的 ## 标题 被一起转进了正文。公众号的标题是独立字段,正文里不该再有它。
解法:3.4.2 的解析器已经把第一个标题行【从正文里拿走了】——这就是那一节为什么要专门删掉标题行。本节 Demo 里保留 ## 行 只是为了演示【它会被转成 <p> 而不是 <h2>】,实际流程里它不会走到这里。
坑二:表格
现象:表格粘过去后所有单元格挤成一坨,或者干脆消失。
原因:微信对 <table> 的支持很不稳定。
解法:表格改成图片(截图或生成图片再上传素材库)。这是唯一可靠的做法,也是很多公众号实际在用的做法。
坑三:正文图片的【延迟爆炸】
现象:在编辑器里预览一切正常,点发布后图片全变裂图。
原因:外链图片在发布时才被替换/校验,微信只认自己图床的地址(mmbiz.qpic.cn 域名)。
解法:正文里每张图都要先上传到微信素材库,把地址换成素材库返回的地址。注意【预览正常】不能作为通过标准——这是本节最阴险的一个坑。
坑四:代码块
现象:技术类文章里的代码粘过去后字体错乱、缩进丢失、甚至被当成普通段落。
原因:微信支持代码块,但语法高亮依赖内联样式,写不好就是一团乱。
解法:代码块的每一行都要单独包一个带内联样式的 <p>(用等宽字体 + 浅底),不要指望 <pre> 自动处理好。如果是长代码,截图反而更省事。
七、⚙️ 2026 现状:为什么【自己写转换器】而不是用现成工具
2026 年做 Markdown 转公众号 HTML 的工具已经很多:
| 类型 | 代表 | 适合谁 |
|---|---|---|
| 在线编辑器 | Markdown Nice(mdnice)、各类公众号编辑器 | 手工写作的人——打开网页、写完复制、粘贴 |
| 命令行/脚本工具 | 各类开源转换器 | 开发者 |
| 本地技能/插件 | 各类编辑器插件 | 写在自己的笔记软件里的人 |
本章为什么还要自己写一个:因为这里的转换是【流水线里的一个环节】,不是【一个人坐在电脑前排版】。
| 需求 | 现成工具 | 自己写 |
|---|---|---|
| 能被 RPA 调用 | 多数不行(要人手点) | 可以 |
| 输出能被程序接住 | 多数只给剪贴板 | 返回字符串,可直接入库 |
| 样式按品牌定制 | 受主题限制 | 完全可控 |
| 加审计、加字段级校验 | 没有 | 可以(本节的审查函数) |
| 出问题时能改 | 只能等作者更新 | 自己就能改 |
工具服务【人】,代码服务【流程】。 本章的排版是流程里的一个工序,它需要一个能被程序调用的、输出确定的东西——所以自己写。而如果你只是偶尔手工发一篇文章,用 mdnice 就够了,不必写代码。 选工具还是选代码,看的是【谁来调用它】,不是【哪个更高级】。
30 秒实验
把本节 Demo 里 S["p"] 的那串样式,只改一个数字:把 font-size:15px 改成 font-size:15(去掉单位)。
会发生什么:
- 转换器和审计器都不会报错——因为审计器只检查【有没有 style】,不检查样式写得对不对;
- 粘到公众号里,这一段的字号会变成默认值(不是 15px,因为缺单位,浏览器直接忽略这条声明)。
结论:审计器能挡住【结构性错误】(少了 style、多了 class),挡不住【语义性错误】(单位写错、颜色写错)。 后者只能靠人眼看一次预览来兜底。
这也是本节和 3.4.2 的一条共同纪律:机器负责【可判定的部分】,人负责【要判断的部分】。
常见误区
- 【把 HTML 源码当文本打进编辑器就行】——错。 公众号要的是剪贴板里的富文本,不是 HTML 源码。必须把 HTML 放进剪贴板的【HTML 格式】槽位再粘贴,否则你会看到满屏尖括号。
- 【用
<h2>写小标题最标准】——错。<h1>到<h6>会被微信转成<p>或删掉,样式白写。标题一律用<p>+ 内联样式模拟。 - 【编辑器里预览正常就说明没问题】——错。 正文外链图片是在发布那一刻才爆的。预览正常不能作为通过标准——图片必须先上传到素材库。
- 【表格直接转过去就行】——错。 微信对
<table>支持很不稳定。表格改成图片是唯一可靠的做法。 - 【有审计器就不用看预览了】——错。 审计器挡【结构性错误】,挡不住【语义性错误】(单位写错、颜色写错、字号不舒服)。机器管可判定的,人管要判断的。