公众号直发草稿箱(微信官方 API 通道)
把 wechat-graphics 产出的那一份直发版 HTML,原样推进公众号草稿箱。不群发——
群发由使用者在后台手动点。
> 为什么不用第三方(言序 / 秀米 / 135):它们的绑定走扫码授权,控制权在别人手里;
> 且 WorkBuddy 连接器市场里唯一能做公众号发布的「言序」不接受 AppSecret。
> 因此本技能选择了官方 API 直连方案。
一键流程
S="$HOME/.workbuddy/skills/wechat-draft-publish/scripts/mp_publish.py"
P="python" # 任何 Python 3 都行(本脚本零第三方依赖)
"$P" "$S" doctor # 体检接口权限(换号 / 换机器先跑)
"$P" "$S" whoami # 验凭证 + 看公众号资料
"$P" "$S" draft 文章_直发版.html --title "标题" --thumb 封面.jpg
# ↑ 记下返回的 media_id,紧接着做反向核验(每期必做,不可跳)
"$P" "$(dirname "$S")/draft_verify.py" <media_id> 文章_直发版.html
顺序固定:先 doctor → 再 draft → 最后必须跑 draft_verify.py。draft 内部会先调
wechat-graphics/scripts/build_wechat.py --check-only 做格式门禁,退出码非 0 直接拒绝发布。
标题与摘要:优先写在源头,别敲命令行
draft / update 都支持从文章 HTML 顶部读这两个注释(命令行参数优先级更高):
<!-- MP-TITLE: 文章标题(≤64 字) -->
<!-- MP-DIGEST: 摘要(≤120 字) -->
- 好处:改稿只改一处,不用每次拼命令行;项目文件里也留了痕。
- 这两个注释不会进正文 —— 发布脚本提交前会把
MP-*注释剥掉。 - 不写摘要 = 微信自己抓正文开头,抓到的常是「先说一件…」这类句子,转发卡片上很难看,
等于白扔一次曝光。摘要要自己写:给判断、给钩子、埋关键词,别复述标题。
改稿重推:用 update,不要用 draft
"$P" "$S" update 文章_直发版.html --media-id <media_id> --thumb 封面.jpg
draft 每次都新建一篇,后台会堆一串同名草稿;update 用 media_id(可加 --index,默认 0)
原地更新,草稿不变。改错字、换配图、补摘要,都走 update。
反向核验 draft_verify.py
把草稿箱内容拉回来,与本地直发版比四项:标签序列 / 内联 style / 纯文本 / 色值序列,
再加两个发布字段:标题 / 摘要(基准取自直发版顶部的 MP-TITLE / MP-DIGEST 注释)。
六项全一致、退出码 0,才算「本地所见即草稿箱所得」。任一项不等会打印第一处差异。
> 摘要那一项是 2026-10-03 补的。 此前只比正文,结果「摘要到底有没有落进草稿箱」
> 没人验——后台摘要框仍显示 0/120,让人误以为没填。补上后实测:摘要 81 = 81 一致。
> 教训:比对项要覆盖提交的每一个字段,只比正文等于漏了一半。
两处归一化(不归一化会每期误报,已内置,别再手工核对):
- 图片地址:微信把正文图
src换成data-src="mmbiz.qpic.cn",属正常懒加载,不参与比对。 - 引号实体:微信把 style 里的
'转成&#39;(HTML 实体),渲染等价。
> 直发版若没写 MP-TITLE,标题一栏会显示「无本地基准」——不是错误,但建议补上
> (否则每次发布都要手敲 --title,容易漏)。
草稿箱是不是推成功了,怎么确认
doctor 里的 draft/count 只给总数,不足以定案。要确认得列列表:
# 拉草稿列表(title + media_id),核对刚推的这篇在不在、是不是第一条
python -c "..." # 见 SKILL 或直接复用 mp_publish 的 _http 调 cgi-bin/draft/batchget
cgi-bin/draft/batchget({"offset":0,"count":20,"no_content":1})列出标题与 media_id。
no_content=1 时 update_time 返回 0,属正常。
> assets/selftest/ 里存了一套联调素材(article.html + 已构建好的 直发版.html)。
> 通道疑似不通时,直接拿它发一篇测试草稿定位问题,不用现场造素材:
> bash
> "$P" "$S" draft "$(dirname "$S")/assets/selftest/直发版.html" \
> --title "通道自测(可删)" --thumb 你的封面.jpg
>
> 注:上架版本不含配图(平台不接受二进制文件),联调时请自备一张封面图。
运行环境(跨机通用)
| 项 | 值 |
|---|---|
| 脚本 | <skills>/wechat-draft-publish/scripts/mp_publish.py |
| 凭证 | ~/.workbuddy/mp-config.json —— 故意不放 skill 目录,skill 会被云同步,密钥不能跟着走 |
| token 缓存 | ~/.workbuddy/.mp-token-cache.json |
| 解释器 | 脚本零第三方依赖(只用标准库),任何 Python 3 都行 |
| 自检脚本定位 | 自动找同级的 ../wechat-graphics/scripts/build_wechat.py;可用 WECHAT_BUILD_SCRIPT=<完整路径> 覆盖 |
| 公众号 | 个人主体 · 未认证订阅号,AppID 见配置文件 |
| 出口 IP | 动态分配,见下方白名单章节 |
> 换电脑必做(2026-10-03 补):① 两个技能目录必须是兄弟关系(同放 <skills>/ 下),
> 否则 mp_publish.py 找不到自检脚本 —— 此时它直接拒发,不会静默跳过;
> ② 新机器要自建 mp-config.json(密钥不入包);
> ③ 新机器的公网 IP 必须加进微信后台 IP 白名单,否则 40164。
> ④ 本脚本本身不需要 Pillow,但它调起的自检需要,所以机器上仍要装好 Pillow。
接口与坑(都踩过)
| 环节 | 接口 | 要点 |
|---|---|---|
| 取凭证 | cgi-bin/stable_token |
用 stable 版,不用老的 /cgi-bin/token;缓存 7200s,提前 300s 续 |
| 正文图 | cgi-bin/media/uploadimg |
返回 mmbiz.qpic.cn 链接,不占素材库配额;单张 ≤1MB |
| 封面 | cgi-bin/material/add_material?type=thumb |
返回 media_id;图 ≤2MB |
| 建草稿 | cgi-bin/draft/add |
写入草稿箱 |
| 体检 | cgi-bin/draft/count、cgi-bin/material/get_materialcount |
只读,用来探权限 |
四个必踩的坑
draft/add的 JSON 必须ensure_ascii=False再 encode("utf-8"),否则中文出问题。- 正文图片必须是
mmbiz.qpic.cn域名,外链图会被微信静默过滤(不报错,图就是不显示)。
脚本会自动把 base64 内嵌图和本地图片上传后替换src—— 不需要人工处理。 - 图片超限:正文图 >1MB / 封面 >2MB 直接失败;正文整体 payload 逼近 1MB 也危险。
图片换成 URL 后正文只剩十几 KB,正常。 - 字数上限:
title≤64 字、digest≤120 字、author≤8 字。
IP 白名单(最常见的拦路虎)
微信要求调用方 IP 挂在「设置与开发 → 基本配置 → IP 白名单」里。
- 报
40164时,errmsg 形如invalid ip 1.2.3.4, not in whitelist,
脚本已经用正则把这个 IP 抠出来单独打印,直接复制去后台加即可。 - 后台加白名单需要管理员扫码确认。
- 本机出口是动态 IP,长期跑必须挪到公司固定 IP 的服务器上;
本机只适合做连通性验证。
已验证事实(2026-10-03)
- 个人主体未认证订阅号也能用草稿箱接口 ——
draft/count返回{"total_count":2}、
material/get_materialcount返回image_count:60。之前担心的「未认证没权限」不成立。 - 2026-10-03 21:10 创刊号实测(第 01 期《AI 落地总是烂尾…》,220.6 KB / 4 图):
推送后跑draft_verify.py,标签序列 224 = 224、内联 style 114 = 114、纯文本 2309 = 2309、
色值 107 = 107,四项全一致。唯一差异是 style 里单引号被转成&#39;(渲染等价,已归一化)。
→ 「本地所见 = 草稿箱所得」在纯内联直发版上再次成立。 - 动态 IP 仍是常态拦路虎:本次发布前后探到的出口是一个临时分配地址(连探三次稳定),
与早先记录的地址不是同一个 —— 说明确实会漂移。
加白名单后立刻通。每次发布前先whoami,不要沿用上次记下的 IP。 cgi-bin/account/getaccountbasicinfo对这类号不返回nick_name,但会返回
account_type/principal_name/wx_verify_info,用这几个字段判断主体与认证状态。- 端到端实测成功:
--thumb封面 + 2 张 base64 正文图 → 草稿箱,media_id正常返回。
已实测:API 通道对 HTML 的清洗(2026-10-03,结论已成立)
API 通道不比编辑器粘贴宽松,剥得一样干净。 拿一份带 <style> 的精排版原文件直接推上去,
再拉回草稿箱逐项对照:
| 项目 | 本地 | 草稿箱 |
|---|---|---|
<style> 标签 |
1 | 0(整段剥光) |
var(--x) 变量 |
37 | 0 |
display:flex |
3 | 0 |
linear-gradient |
4 | 0 |
box-shadow |
4 | 0 |
class= 属性 |
36 | 36(属性留着,规则已死) |
内联 style= |
0 | 0 |
⚠️ 最阴的一处:微信剥掉 <style>,却保留 class 属性。拉回来一看标签都在,
极易误判"没事"——实际那些 class 早已是死标签,一个样式都不生效,
全篇按浏览器默认样式渲染(正文掉到 14px,就是你看到的那篇白文)。
结论:样式只能在源头就写成行内属性。这正是 wechat-graphics 定「单版制」的实证依据。
⚠️ 别把精排版原文件直接往这个接口推(哪怕加 --skip-check)——结果就是上面那张表,
推上去是白文,白费一次调用。要抢救它的视觉,只有一条路:先用 wechat-graphics 的
本地转换器(Glob 找 **/公众号排版转换器.html,单文件离线可开)把 <style> 转成行内,
再走本 skill 发布。新文章不需要转换器——源头就写对,直接出直发版。
反向验证(同一根通道,另一个结果)
一篇全内联直发版推上去后拉回比对:内联样式 147 处零差异、纯文本 2238 字完全一致、
标签序列 290 个完全一致,10 个暖色值逐个核对全部原样。
微信只动图片 src → data-src="mmbiz.qpic.cn"(懒加载,属正常)。
同一个接口,一份被判白文、一份零差异,差别只在源头的写法。
安全
- 密钥只写在
~/.workbuddy/mp-config.json。永远不要把 AppSecret 写进 skill 目录、
写进文章、贴到群聊或提交到仓库。 - 只写草稿箱。群发必须在公众号后台手动操作。
- 若 AppSecret 曾出现在聊天/文档等不安全位置,应去后台「重置密钥」。