把博客内容自动/半自动地发布到「growdu」微信公众号。本文先讲清楚微信公众号生态对程序化发布的硬约束,再给两套技术路线对比,最后给推荐方案与具体实施步骤。
整体架构与构建链路见 架构.md,日常维护见 博客搭建指南.md,本文只讲同步。
目标
- 存量迁移:把博客现有 ~200 篇文档批量迁移到公众号(精选或全量,根据工作量决定)。
- 新增自动:往
docs/推一篇新文章后,CI 自动把转换好的版本推到公众号草稿箱(已认证账号)或发邮件通知(未认证账号)。 - 不破坏博客本身:同步链路必须完全旁路,对
docs/、source/_posts/、构建流程零侵入。
一、先认清公众号生态的硬约束
这是整个方案设计的前提。微信的开放程度比 GitHub Pages / Notion 之类低得多,不是所有账号都能走 API:
| 维度 | 约束 | 影响 |
|---|---|---|
群发 API(/cgi-bin/message/mass/send) |
2018 年起对未认证的订阅号关闭;服务号必须微信认证后才能调用 | 个人未认证订阅号只能走”草稿箱 + 后台手动点群发” |
草稿箱 API(/cgi-bin/draft/add /cgi-bin/draft/send) |
微信认证后可用 | 已认证账号可程序化推草稿、可程序化群发 |
| 群发频次 | 个人订阅号 1 条/天;服务号 4–100 条/天(看认证时长) | 自动发布必须内置频次控制 |
| access_token | 2 小时过期,每天 2000 次上限 | CI 跑的话必须做缓存,否则每次都拿新 token 会很快撞限 |
| IP 白名单 | /cgi-bin/token 调用方 IP 必须加白名单 |
GitHub Actions runner 的 IP 段不固定,不能用共享白名单 |
| 图片 | 外部 URL 会被替换成”该图片已过期”占位图 | 所有图片必须先调用 /cgi-bin/material/add_material 上传到微信素材库,再替换正文里的 src |
| 排版 | 只支持内联样式,class= / <style> / 外部 CSS 全部失效 |
转换器必须输出全部内联样式的 HTML |
| 代码块 | 无语法高亮;<pre> 里的换行/缩进会被压平 |
需要用 pre 截图或 inline style 强行保住样式 |
| 表格 | 支持,但样式极简(边框颜色有限) | 复杂表格要降级 |
| 脚注 / TOC / 自定义 HTML | 不支持 | Markdown 的部分特性要手动转换或丢弃 |
| 文章长度 | 单篇 2 万字 | 长文章要拆 |
| 频次检测 | 群发太频繁会被风控 | 自动发布必须串行 + 加随机延迟 |
结论:方案要分两条岔路——账号已认证走 API 自动化最省事;未认证只能走”转换好 + 推草稿 + 人工群发”半自动。
二、技术路线对比
下面四条都能跑,差异在可维护性、对账号类型的要求、合规风险。
路线 A:浏览器自动化(Puppeteer / Playwright)
登录微信公众平台后台,模拟点击”新建图文 → 粘贴 → 上传图片 → 保存为草稿”。
- 优点:通用,能绕开所有 API 限制
- 缺点:登录态易过期、微信反爬严格、违反《微信公众平台服务协议》、GitHub Actions 跑容易被风控
- 适用:临时玩票,不建议生产
不推荐。
路线 B:官方草稿箱 API
走 /cgi-bin/draft/add 把图文推到草稿箱,已认证账号还可以 /cgi-bin/message/mass/sendall 直接群发。
- 优点:稳定,官方支持,不会被风控
- 缺点:账号必须微信认证;要解决 access_token 缓存、IP 白名单(用自建代理中转)、图片素材上传
- 适用:已认证账号的推荐方案
路线 C:Markdown → 微信 HTML 转换 + 人工粘贴
用开源工具(mdnice、wechat-format、md2wechat 等)把 markdown 转成带主题样式的内联 HTML 片段,复制到公众号编辑器里,再人工点群发。
- 优点:零账号门槛,零合规风险
- 缺点:每次都要手动操作
- 适用:未认证账号、低频发布、人工审校必要场景
路线 D:混合(自动转换 + 自动推草稿 + 人工群发)
CI 自动把新文章转成微信 HTML,自动推草稿(已认证)或发邮件(未认证),人只需点”群发”按钮。
- 优点:兼顾自动化和合规,可分阶段升级
- 缺点:仍需要登录后台点一次
- 适用:最推荐的演进路径
三、推荐方案
路线 D,分两阶段实施,对应公众号账号的当前状态和未来升级:
阶段一(无论账号类型,立即可做)
docs/*.md ──► 转换器 ──► 微信风格 HTML(带主题)
│
├──► 浏览器复制粘贴 ──► 微信后台
└──► (可选) CI 发送邮件通知
↑ 用于"存量迁移"和"低频发布"
阶段二(账号完成微信认证后启用)
docs/*.md ──► 转换器 ──► 微信风格 HTML
│
├──► 图片上传素材库
├──► /cgi-bin/draft/add ──► 草稿箱
└──► /cgi-bin/draft/send ──► 群发(可选)
↑ 用于"自动发布",全无人值守
阶段一现在就能跑、零风险;阶段二等你账号完成微信认证后接入 API,CI 里加几个 step 即可。
四、阶段一详细设计
4.1 工具选型
| 工具 | 用途 | 选型理由 |
|---|---|---|
| mdnice | Markdown → 微信风格 HTML | 主题多,输出全内联样式,开源 |
| wechat-format | 同上,备选 | 纯函数式,适合 CI 集成 |
自写 Node.js 脚本(tools/md-to-wechat.js) |
桥接:读 front matter、调转换器、写产物 | 与现有 tools/ 风格统一 |
| Puppeteer | 截图代码块(如果转换器保不住样式) | 兜底方案 |
| GitHub Actions + 邮件 | 推送新文章通知 | 已有 SMTP 的话直接复用 |
选 mdnice,它的输出是”完整带主题的内联 HTML”,可以直接复制到微信编辑器。也可以命令行调用其 markdown-nice 包的 parse() 方法。
4.2 转换器脚本设计
tools/md-to-wechat.js,单文件、零外部依赖(除 mdnice):
// 伪代码
const fs = require('fs');
const path = require('path');
const { parse } = require('@mdnice/markdown-nice');
const SRC = 'docs';
const OUT = 'build/wechat';
function walk(dir) {
// 递归找所有 *.md(不进 _posts 和 .git)
}
for (const f of walk(SRC)) {
const raw = fs.readFileSync(f, 'utf8');
const { content, theme, title, author, digest } = parse(raw); // mdnice
// 输出三件套:标题、摘要、HTML
fs.writeFileSync(`${OUT}/${slug}.html`, render({ title, content, author }));
fs.writeFileSync(`${OUT}/${slug}.digest.txt`, digest);
}
输出文件落在 build/wechat/,不进 git(已在 .gitignore 类似位置)。
4.3 存量迁移流程
- 在本地跑
node tools/md-to-wechat.js,得到build/wechat/*.html - 打开 mdnice 官方编辑器(mdnice.com)批量导入 HTML
- 复制到公众号后台”图文素材”里
- 人工检查:
- 标题、配图、摘要
- 代码块是否压平
- 链接是否丢失
- 图片是否需要重新上传到素材库
- 保存为草稿,不直接群发(避免一次性打扰粉丝)
工作量预估:200 篇 × 5 分钟人工 = 1000 分钟 ≈ 17 小时。建议分批做,一次 10–20 篇。
4.4 新增自动通知流程
CI 监听 docs/ 变更,跑转换器,通过邮件把格式化后的 HTML 片段发到指定邮箱:
# .github/workflows/wechat-sync.yml
name: sync-to-wechat
on:
push:
paths:
- 'docs/**'
- 'tools/md-to-wechat.js'
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Build WeChat HTML
run: node tools/md-to-wechat.js
- name: Send email notification
if: hashFiles('build/wechat/*.html') != ''
uses: dawidd6/action-send-mail@v3
with:
server_address: ${{ secrets.SMTP_HOST }}
server_port: ${{ secrets.SMTP_PORT }}
username: ${{ secrets.SMTP_USER }}
password: ${{ secrets.SMTP_PASS }}
subject: '[博客新文章] ${{ github.event.head_commit.message }}'
body: |
新文章已转换完成:
https://github.com/growdu/blog/blob/master/build/wechat/<slug>.html
复制 HTML 到公众号后台即可发布。
to: ${{ secrets.WECHAT_NOTIFY_EMAIL }}
人工收到邮件后,打开 HTML,复制到公众号后台保存为草稿(不需要群发的话直接留在草稿箱)。
五、阶段二详细设计(待账号认证)
5.1 前置条件
- 公众号完成微信认证(个人订阅号 30 元/年,企业另算)
- 登录 微信公众平台 → 开发 → 基本配置 → 拿到 AppID + AppSecret
- 把自建代理服务器的公网 IP 加到”IP 白名单”(GitHub Actions 的 IP 不固定,不能直接加)
- AppSecret 存到 GitHub Secrets:
WECHAT_APPID/WECHAT_SECRET
5.2 代理服务器
GitHub Actions 跑不了白名单(IP 段不固定且会变),需要在自有服务器(已经是博客部署服务器 growdu.cn)上跑一个轻量代理,把 token 请求转发到微信。
GitHub Actions
│ (POST /wechat/token)
▼
nginx (growdu.cn) ─► 127.0.0.1:3000 ─► Node/Python 脚本 ─► api.weixin.qq.com
▲
│ IP 固定 = 博客服务器公网 IP
│ 加白名单只用加这一个
实现细节在 tools/wechat-proxy/ 下,单独一个 Express/FastAPI 服务,路由三个:
GET /token—— 拿 access_token 并缓存(2 小时不过期则复用,<2h 提前 5min 刷新)POST /upload-image—— 接收图片二进制 →/cgi-bin/material/add_material→ 返回media_id和urlPOST /draft—— 接收{ articles: [...] }→/cgi-bin/draft/add→ 返回media_id
5.3 图片处理流程
markdown 里 
│
▼
1. 抓图(CI 跑 hexo generate 时产物里有,或自己 fetch)
2. POST /upload-image 拿到微信 url: https://mmbiz.qpic.cn/...
3. 把 HTML 里所有 <img src="..."> 替换成微信 url
4. 把替换后的 HTML 提交到 /draft
如果文章图片特别多(>10 张/篇),可以加个并发控制,限速 5 张/秒。
5.4 群发频次控制
- 维护一个
build/wechat/sent.log(git ignore) - CI 跑前先查今天有没有发过:发了就跳过
- 每天 0 点重置(GitHub Actions 用 cron 触发清理)
- 群发走
/cgi-bin/message/mass/sendall,参数is_to_all: true表示全量群发
// tools/wechat-daily.js
const sent = JSON.parse(fs.readFileSync('build/wechat/sent.log', 'utf8'));
const today = new Date().toISOString().slice(0, 10);
if (sent.last_sent_date === today) {
console.log('今日已发,跳过');
process.exit(0);
}
// ... 调 /draft/send 群发
sent.last_sent_date = today;
fs.writeFileSync('build/wechat/sent.log', JSON.stringify(sent, null, 2));
5.5 完整 CI 流程(阶段二)
name: sync-to-wechat
on:
push:
paths:
- 'docs/**'
- 'tools/md-to-wechat.js'
- 'tools/wechat-draft.js'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
# 1. 转成微信风格 HTML
- run: node tools/md-to-wechat.js
# 2. 上传图片素材
- run: node tools/wechat-upload-images.js
env:
WECHAT_PROXY: ${{ secrets.WECHAT_PROXY }}
# 3. 推草稿
- run: node tools/wechat-draft.js
env:
WECHAT_APPID: ${{ secrets.WECHAT_APPID }}
WECHAT_PROXY: ${{ secrets.WECHAT_PROXY }}
# 4. (可选) 群发
- run: node tools/wechat-send.js
env:
WECHAT_APPID: ${{ secrets.WECHAT_APPID }}
WECHAT_PROXY: ${{ secrets.WECHAT_PROXY }}
if: github.event_name == 'push' && contains(github.event.head_commit.message, '[wechat-send]')
最后一行的小技巧:commit message 带 [wechat-send] tag 才真正群发,否则只推草稿。避免每次 push 都打扰粉丝。
六、代码块特殊处理
微信不支持语法高亮,且 <pre> 里多个空格会被合并。两种处理方式:
方案 1:用 mdnice 自带的代码块主题
mdnice 输出的代码块用 <section> 包裹 + 全内联样式,勉强能保住颜色。优先试这个。
方案 2:代码块截图(兜底)
长代码用 Shiki 或 Puppeteer + highlight.js 渲染成图片,3000px 高度以内一张图搞定。
写在 tools/md-to-wechat.js 里:
function renderCodeBlock(code, lang) {
if (code.length > 1500) {
// 截长代码:渲染成图片
return `<img src="data:image/png;base64,${shikiScreenshot(code, lang)}" />`;
}
return `<pre style="..."><code>${escapeHtml(code)}</code></pre>`;
}
七、风险与缓解
| 风险 | 概率 | 影响 | 缓解 |
|---|---|---|---|
| 微信封禁 access_token | 中 | 中 | 不在 CI 共享 runner 上跑,走自有代理 |
| 群发被风控 | 低 | 高 | 频次控制 + 随机延迟(10–60min) |
| 图片上传失败 | 中 | 中 | 重试 3 次 + 失败时人工告警邮件 |
| 转换器输出与微信不兼容 | 中 | 低 | 阶段一人工审校兜底,阶段二先推草稿不发 |
| 个人未认证账号 | 当前 | 阻塞阶段二 | 先做阶段一,等认证完成升级 |
| 微信生态规则变更 | 中 | 中 | 所有脚本集中在 tools/wechat-*.js,单点维护 |
八、待办与里程碑
| # | 里程碑 | 状态 |
|---|---|---|
| 1 | 在 tools/md-to-wechat.js 实现本地转换脚本 |
待办 |
| 2 | 跑存量 200 篇,得到 build/wechat/*.html |
待办 |
| 3 | 人工挑 10 篇试发到公众号,踩一遍坑 | 待办 |
| 4 | 加 CI workflow:新文章自动转换 + 邮件通知 | 待办 |
| 5 | 公众号完成微信认证 | 取决于你 |
| 6 | 部署 tools/wechat-proxy/ 代理 |
阶段二 |
| 7 | 接入 /draft/add 推草稿 |
阶段二 |
| 8 | 接入 /mass/sendall 群发(带 commit tag 触发) |
阶段二 |
| 9 | 代码块截图兜底 | 阶段二 |
九、相关文档
- 架构.md — 整体构建链路,
tools/在 CI 里的位置 - 部署到服务器.md —
growdu.cn服务器现状,阶段二的代理会跑在同台机器 - 同名文章-html-与-md-版本管理.md —
docs/下index.htmlvsindex.md的处理逻辑,转换器要从同一份 front matter 读