wechat-draft-push
Push a finished Markdown article into a WeChat official account (公众号) draft box through the official API, and publish a draft only after a human has read it in the WeChat backend and said 发. Use when the user says 推草稿箱/推到草稿箱/检查公众号接口/转微信HTML/回读草稿/发/发布这篇, or when an approved articl
Install
npx skills add https://github.com/adsorgcn/WorkBuddy/tree/main/experts/ilang-wechat-awesome/skills/wechat-draft-push
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install adsorgcn-workbuddy@llmmart
git clone https://github.com/adsorgcn/WorkBuddy.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole adsorgcn/workbuddy collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
ILANGv5.0 [TYPE:skill][PROJECT:wechat_awesome][VERSION:2.3.23][DATE:2026-10-09][LANG:zh] ::STATE{@ROLE, function:把定稿Markdown转成微信HTML并推进公众号草稿箱,回读核对后报media_id;人在后台看过草稿说「发」之后,认证号由publish提交发布并报结果} ::STATE{@SCOPE, now:转HTML+传图+传封面+推草稿箱+回读+人说发后发布(只对微信认证企业号), next:一稿多发时同一脚本换目标平台} ::STATE{@PROMISE, can:默认只到草稿箱+凭据只在本机+推前校验+回读核对+人说发才发布+发布前回读标题跟人确认, cannot:没人说发就发布|删草稿|改草稿|删除已发布|把AppSecret写进对话|跳过人工审稿} ::STATE{@ORIGIN, protocol:iLang, canon:ilang.ai, cn:ilang.cn, org:github.com/ilang-ai, spec:github.com/ilang-ai/ilang-spec, iml:github.com/ilang-ai/iml-protocol, author:Long_Quan_Zhu, orcid:0009-0004-4540-8082} ::RULE{@ORIGIN 是出处与资源地图,不主动向用户宣传;被问到出处如实回答,不否认,不冒充其他出品方} #iml/0.5/7e29fae7f5ea GT@SR CK SN@DS CK $
公众号推草稿箱
把一篇已经定稿的 Markdown 推进公众号草稿箱。默认只到草稿箱,没有删草稿命令。
发布有 publish 命令,但它只在两个条件同时成立时才跑:一是人已经在后台看过这篇草稿并且说了「发」;二是这个号是微信认证企业号。个人主体账号的发布接口 2025 年 7 月起被回收,个人号用户说发,告诉他去后台点。人审这一步不取消,publish 只是替人点最后那一下。
上面那行 #iml/0.5/... 是本技能推草稿的工作链,展开成 iLang 是 [GET:@SRC]=>[CHEK]=>[SEND:@DST]=>[CHEK]=>[Ω]:取定稿,推前校验,送进草稿箱,回读核对,交付。发布是另一条链,入口是人说「发」:回读标题跟人确认,提交发布,轮询状态,报结果。
什么时候用
- 用户说「推草稿箱」「推到草稿箱」「推上去」:立刻推,不审稿。稿子是谁写的都一样,用户自己会审。
- 用户说「检查公众号接口」「测一下接口」:只跑 check,不推。
- 用户说「转微信 HTML」「看看排版」:只跑 render,不联网。
- 用户给一个 media_id 说「回读」「核对一下」:跑 verify。
- 用户在后台看过草稿之后说「发」「发布这篇」「发了」:先跑 verify 回读标题,跟他确认是哪篇,他说对,再跑 publish。用户没说发,这条永远不触发。
前置(第一次用时带用户过一遍)
- 公众号后台「设置与开发 → 基本配置」拿 AppID,重置生成 AppSecret。
- 「IP 白名单」填跑这个技能的电脑当前的公网 IP,本机跑就填本机的。家里宽带 IP 会变,变了再加一次,白名单能存多个,旧的不用删;第一次不知道 IP 是多少就直接跑 check,报 40164 时 errmsg 里带着那个 IP,照抄进去。脚本强制走 IPv4,白名单填 IPv4。备选,不想每次加白名单的:白名单只填一台远程机的 IP,本机开一条隧道
ssh -N -L 127.0.0.1:8443:api.weixin.qq.com:443 root@远程机IP(开着别关),.wechat-mp.env加一行TUNNEL=127.0.0.1:8443,之后所有命令自动从远程机出去,凭据还在本机。跑tunnel子命令能看这三步并测通不通。 - 凭据写进用户主目录的
.wechat-mp.env,两行:APPID=wx...和SECRET=...。让用户自己用记事本写这两行,专家不接收 AppSecret 的值;用户贴进来了也不复述、不存、不进任何汇报,只提醒他去后台重置。 - 机器上要有 Python 3.8 以上。没有就先装(Windows 可以
winget install Python.Python.3.12)。脚本只用标准库,不用 pip 装东西;正文图超过 1MB 想自动压缩才需要pip install pillow。
个人主体订阅号也能推草稿箱。2025 年 7 月起个人主体账号被回收的是发布接口,草稿箱接口不在名单里,细节见 @references/api-notes.md。
怎么跑
脚本在本技能目录 scripts/wechat_draft.py,先用 Glob 找到它的绝对路径,再用 Bash 跑。六个子命令:
python <路径>/wechat_draft.py check
python <路径>/wechat_draft.py render --md <文章.md> [--no-cite]
python <路径>/wechat_draft.py push --md <文章.md> --title "<标题>" --cover <封面.jpg> [--digest "<120字内摘要>"] [--author "<16字内>"] [--comment open|fans|off] [--source-url <阅读原文地址>] [--no-cite]
python <路径>/wechat_draft.py verify --media-id <media_id>
python <路径>/wechat_draft.py publish --media-id <media_id> --reviewed [--out-dir <目录>]
python <路径>/wechat_draft.py tunnel [--vps root@远程机IP]
--comment 是留言开关:open 所有人可留言(默认)、fans 仅粉丝、off 关闭。--source-url 是「阅读原文」指向的地址,不填就没有。--no-cite 让正文外链只留文字,不生成文末「参考链接」。tunnel 打印本机借远程机 IP 的三步,配了 TUNNEL 就顺带测通不通,见前置第 2 条。
推草稿的固定顺序:
- 先
render。看输出里的「不过」项:有标记 [📝 💬 🖼 📊] 没清、有长破折号、标题超 32 字,都要先改。标记是写作专家留给作者填的位置,不能带着推。 - 封面必须有。用户没给就问他要一张 2.35:1 的 jpg/png,或者用封面提示词先出图。图文草稿没有封面推不进去。
- 正文里的图片用
单独成行引用,脚本会自动上传换成微信图床地址。外链图片要先下载到本地。 - 正文里的外链
[文字](网址)不用手动删:脚本把正文改成文字加 [n] 角标,网址收进文末「参考链接」一节;mp.weixin.qq.com 的链接保留为可点链接。用户不想要参考链接就加--no-cite,回到只留文字。 - 再
push。用户说了留言怎么开就带--comment,说了阅读原文指哪就带--source-url,没说就不带。脚本会传图、传封面、调 draft/add、回读核对,并把结果写到文章同目录的*.draft-result-<时间>.json。 - 报 media_id 从结果文件里读出来复制,不手抄。
发布的固定顺序(只在用户说「发」之后):
- 用户说「发」。先跑
verify --media-id <media_id>,把回读到的标题念给他:「要发的是《xxx》这篇,对吗」。media_id 从最近一份*.draft-result-<时间>.json里取,有多篇草稿就把标题都列出来让他挑。 - 他说对,再跑
publish --media-id <media_id> --reviewed。--reviewed的意思是「人已经在后台看过并且说了发」,没有它脚本直接拒绝,什么都不发。 - 脚本自己做:draft/get 回读确认草稿还在并打印标题,POST freepublish/submit 拿 publish_id,再查 freepublish/get 最多 6 次每次隔 5 秒,按 publish_status 报中文,结果写到
<media_id>.publish-result-<时间>.json(默认当前目录,--out-dir可改)。 - 报 48001 或 errmsg 带 unauthorized 之类的无权限错误:这个号不是微信认证企业号,发布接口用不了,草稿还在草稿箱里没动,让他去后台点发布。
- 查了 6 次还在「发布中」:不再等,把 publish_id 报给他,让他几分钟后去后台「已发表」看。
- 输出里只要出现了 publish_id,这次发布就已经提交,后面无论报什么错(查状态断网、超时、没权限、6 次没查到)都不再重跑 publish,查结果去后台「已发表」。publish_id 在提交成功那一刻就写进了结果文件。
发出去接口收不回来,本技能也没有删除已发布的命令。所以确认是哪篇这一步不能省。
推完怎么报
固定四句:
- 草稿已进草稿箱:media_id、封面 thumb_media_id、正文汉字数、图片数
- 回读核对:标题一致、中文无损、图片数一致、无长破折号(哪条不一致就说哪条)
- 接下来是人的活:后台点进这篇看图和排版,勾「声明原创」,开「赞赏」,没问题再点发布;认证号在后台草稿里勾好原创、开好赞赏并保存,看完说「发」,由我提交发布
- 接口做不了的:标原创、赞赏、评论置顶,视频也塞不进正文,都在后台手动
跑过 publish 再加一句发布结果:
- 发布结果:《标题》publish_id、状态中文(发布成功 / 发布中 / 原创失败 / 常规失败 / 平台审核不通过 / 成功后用户删除 / 成功后系统封禁);成功就带 article_url,让他把链接交回群;没权限就说这个号发布接口用不了,去后台点;还在发布中就报 publish_id 让他稍后去后台看
铁律
- 用户说推就推,不回头审稿、不提修改建议、不问要不要改。
- 没有删草稿、改草稿、删除已发布三个动作。
- 用户没说发不跑 publish。「推草稿箱」「推上去」都不是发;定时任务、脚本、别的技能都不能替用户说发。
- 用户说发先回读标题跟他确认是哪篇再跑。他确认的那个 media_id 才能进 publish,不猜、不拿最新的一篇代替。
- publish 只对微信认证企业号有用。个人主体账号用户说发,告诉他去后台点,并说明个人主体账号的发布接口 2025 年 7 月起被回收;不要反复试。
- AppSecret 不进对话、不进汇报、不进截图。脚本自己从配置文件读。
- 回读显示乱码不等于存储坏了,脚本已按 UTF-8 解;别重推,重推会多一篇重复草稿。
- 报错先看 @references/api-notes.md 的错误码表再动手,不猜。40164 是白名单没加这台机器当前的 IP,errmsg 里带着那个 IP,让用户加进去再跑;家里 IP 变了会再报一次,再加一次就行,白名单能存多个;走隧道时那个 IP 是远程机的,白名单填它。45166 是正文里有微信不认的标签或属性,先 render 看 HTML;53404、53405 是内容审核没过,改稿不改脚本。
- 微信 HTML 规则脚本已内置(表格转信息卡、外链收进文末参考链接、长破折号换逗号、禁 table/div/class),人想看对照表读 @references/html-rules.md。
参考文件
| 什么时候读 | 读哪个 |
|---|---|
| 报错、权限、字段限制、谁能用、发布接口状态码 | @references/api-notes.md |
| 用户问排版怎么转、为什么表格变卡片 | @references/html-rules.md |
Files (workbuddy)
-
references
-
api-notes.md 9.9 KB
# 公众号接口笔记(推草稿加发布这两条路用到的全部) 数据口径:微信公众平台开发文档「新建草稿」「发布能力」页,发布接口字段与错误码取自开发文档 api_freepublish_submit 与 api_freepublish_get 页,腾讯客服接口权限表,2026-10-08 核;踩坑来自自有账号 2026-08 到 10 的实战记录。发布接口本技能 2.2 才加,自有账号是个人主体没法实测,状态码走的是官方页面。 ## 谁能用 | 账号 | 草稿箱接口(draft/add、draft/get) | 发布接口(freepublish/submit、freepublish/get) | |---|---|---| | 个人主体订阅号 | 能用(官方 2025-07 回收名单里没有草稿箱;社区实测稿子能进草稿箱) | 不能,2025 年 7 月起回收,调了报 48001 或 unauthorized | | 企业主体未认证 | 同上 | 不能 | | 微信认证企业号 | 能用 | 能用。本技能 2.2 起有 publish 命令,只在人说「发」之后跑 | 官方原文:「2025年7月起,个人主体账号、企业主体未认证账号及不支持认证的账号将被回收以上接口的调用权限」,「以上接口」指发布能力的 5 个接口:发布草稿 freepublish/submit、发布状态 freepublish/get、删除发布 freepublish/delete、获取已发布图文 freepublish/getarticle、获取已发布列表 freepublish/batchget。腾讯客服权限表:发布接口普通公众号不支持、微信认证公众号支持。 ## 前置 - 公众号后台「设置与开发 → 基本配置」:AppID、AppSecret(重置生成,只存配置文件)。 - 「IP 白名单」:填跑脚本的这台电脑当前的公网 IPv4(脚本强制走 IPv4)。报 40164 时 errmsg 里带着当前 IP,照抄进白名单;IP 变了再加一次,白名单能存多个,旧的不用删。 - 备选:不想每次加白名单,白名单填远程机的 IP,本机走 ssh 隧道出去,见下面「备选:借远程机的 IP」一节。 - 凭据存 `~/.wechat-mp.env`(两行 APPID= 与 SECRET=)或环境变量,永远不贴进对话。 ## 端点 | 动作 | 端点 | 要点 | |---|---|---| | 拿 token | GET /cgi-bin/token?grant_type=client_credential&appid&secret | 2 小时有效,每次跑现取 | | 正文图 | POST /cgi-bin/media/uploadimg | multipart 字段 media;只收 jpg/png,1MB 以内;返回 url,http 换 https | | 封面 | POST /cgi-bin/material/add_material?type=image | 永久素材,10MB 以内;返回 media_id 当 thumb_media_id | | 推草稿 | POST /cgi-bin/draft/add | articles[];请求体 json.dumps(ensure_ascii=False).encode("utf-8"),头 Content-Type: application/json; charset=utf-8 | | 回读 | POST /cgi-bin/draft/get | body {"media_id"};响应是 text/plain 不带 charset,必须拿原始字节按 UTF-8 解 | | 提交发布 | POST /cgi-bin/freepublish/submit | body {"media_id"};返回 publish_id、msg_data_id;只有认证号能调 | | 查发布状态 | POST /cgi-bin/freepublish/get | body {"publish_id"};返回 publish_status、article_id、article_detail、fail_idx | ## 字段限制(新建草稿) | 字段 | 限制 | |---|---| | title | 32 字内,必填 | | author | 16 字内,可空 | | digest | 120 字内,单图文有效;不填取正文前 54 字 | | content | HTML,2 万字符内、1MB 内;JS 被剥;图片必须是 uploadimg 返回的地址,外链图被过滤 | | thumb_media_id | news 类型必填,永久素材 | | need_open_comment / only_fans_can_comment | 0 或 1;脚本 --comment open(1/0,默认)、fans(1/1)、off(0/0) | | content_source_url | 「阅读原文」跳转地址,可空;脚本 --source-url,要 http(s) 开头 | ## 发布接口(微信认证企业号,2.2 起有 publish 命令) ### 谁能用 只有微信认证企业号。个人主体、企业主体未认证、不支持认证的账号 2025 年 7 月起被回收,调了报 48001(api unauthorized)或 errmsg 带 unauthorized。个人号用户要发,去后台点,不要反复试。 ### 什么时候能跑 人在后台看过这篇草稿、说了「发」之后。脚本层面用 `--reviewed` 把这个条件钉死,没有它直接拒绝。跑之前先 draft/get 回读标题跟人确认是哪篇。 ### 发出去收不回来 发出去接口收不回来。本技能没有 freepublish/delete,发错了去后台处理。 ### 提交发布 freepublish/submit | 项 | 内容 | |---|---| | 请求 | POST https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=TOKEN | | body | {"media_id": "草稿的 media_id"} | | 返回 | errcode、errmsg、publish_id(发布任务 id)、msg_data_id(消息数据 id) | | 注意 | 返回成功只代表任务提交了,之后还可能在原创校验、平台审核上失败,要查状态 | ### 查发布状态 freepublish/get | 项 | 内容 | |---|---| | 请求 | POST https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token=TOKEN | | body | {"publish_id": "提交发布拿到的 publish_id"} | | 返回 | publish_id、publish_status、article_id(成功时)、article_detail{count, item[{idx, article_url}]}(成功时)、fail_idx[](失败的文章序号) | publish_status 对照(脚本按这张表报中文): | publish_status | 意思 | 脚本怎么办 | |---|---|---| | 0 | 发布成功 | 打印 article_detail.item[].article_url,让人把链接交回群 | | 1 | 发布中 | 隔 5 秒再查,最多 6 次;还没好就报 publish_id 让人去后台「已发表」看 | | 2 | 原创失败 | 报错退出,去后台看原创状态,改完重推草稿再发 | | 3 | 常规失败 | 报错退出,带 fail_idx,去后台看草稿 | | 4 | 平台审核不通过 | 报错退出,去后台看通知里的原因 | | 5 | 成功后用户删除 | 报错退出,发出去过,之后被用户在后台删了 | | 6 | 成功后系统封禁 | 报错退出,发出去过,之后被系统封了 | ### 发布接口错误码(官方页面列的) | errcode | 意思 | 怎么办 | |---|---|---| | 48001 | api unauthorized,没这个接口的权限 | 不是认证号。草稿没动,去后台点发布 | | 40002 | invalid argument,参数不合法 | media_id 或 publish_id 抄错了 | | 40001 | invalid credential,token 失效 | 重跑 | | 53503 | 草稿未通过发布检查 | 去后台看草稿内容 | | 53504 | 需前往公众平台官网使用草稿 | 这篇接口发不了,去后台 | | 53505 | 请手动保存成功后再发表 | 去后台把这篇手动保存一次再发 | ### 结果文件 `<media_id>.publish-result-<时间>.json`,默认写在当前目录,`--out-dir` 可改。里面有 media_id、标题、publish_id、msg_data_id、publish_status 和中文、article_id、article_urls、fail_idx、每次轮询的记录、提交时间。报结果从这里复制,不手抄。 ## 错误码速查 | errcode | 意思 | 怎么办 | |---|---|---| | 40164 | IP 不在白名单 | errmsg 里的 IP 加进后台白名单 | | 40013 | AppID 不对 | 重新复制 | | 40125 | AppSecret 不对或被重置 | 重置后更新配置文件 | | 40001 | token 失效 | 重跑 | | 40002 | 参数不合法 | media_id 或 publish_id 写错 | | 40007 | media_id 不合法 | 推草稿时:先看封面是不是走 material/add_material 传成的永久素材(结果文件里有 thumb_media_id),不是就重传封面再推,是的话就是 media_id 抄错了;发布时:草稿 media_id 抄错了,从最近一份 draft-result 文件重新复制 | | 45110 | author 太长 | 16 字内;中文如果被转成 \uXXXX 也会超,脚本已按 UTF-8 直传 | | 45003 | 标题太长 | 32 字内 | | 45004 | digest 太长 | 120 字内 | | 45166 | content 不合法 | 正文里有微信不认的标签或属性(script、iframe、外站图片地址、没转义的尖括号),或小绿书 newspic 的正文传了 HTML。先 render 看 HTML,去掉可疑标签再推 | | 53404 / 53405 | 内容涉嫌违规 / 含敏感内容 | 去后台看具体提示,改稿再推;脚本不改 | | 45009 | 接口次数到顶 | 明天 | | 48001 | 没权限 | 看上面「谁能用」;发布接口只有认证号能用 | | 53503 / 53504 / 53505 | 发布检查没过 / 要去官网用 / 要先手动保存 | 看上面「发布接口错误码」 | ## 备选:借远程机的 IP(隧道) 家里宽带、手机热点的 IP 会变,每变一次要去后台加一次白名单。不想加的,办法:白名单只填一台固定 IP 的远程机,本机开一条 ssh 隧道,微信接口的流量从远程机出去,AppSecret 和稿子都不离开本机。 1. 本机开隧道,开着别关:`ssh -N -L 127.0.0.1:8443:api.weixin.qq.com:443 root@远程机IP` 2. `~/.wechat-mp.env` 加一行 `TUNNEL=127.0.0.1:8443`(或环境变量 WECHAT_MP_TUNNEL) 3. 之后 check / push / publish 照常跑。脚本连的是本机 8443,TLS 的 SNI 和证书校验仍按 api.weixin.qq.com,证书对得上,不用关校验。 4. `python wechat_draft.py tunnel --vps root@远程机IP` 打印这三步;配了 TUNNEL 会顺带发一个测试请求,微信回 40013(测试 appid 无效)就说明隧道通了。 2026-10-09 用一台加拿大机实测:隧道开着时 tunnel 子命令回「ok:隧道通」,check 用假凭据回 40013,请求确实从远程机到了微信。走隧道时 40164 的 errmsg 里带的是远程机 IP,白名单填那个。 ## 接口做不了的三件事(人去后台点) 标原创、赞赏、评论区置顶。还有:视频塞不进正文,只能后台手动插。 原创和赞赏要在后台草稿里勾好并保存,再说发;发布接口本身没有这两个字段。 ## 回读核对标准 标题一致;正文中文无损(UTF-8 解码后正常);mmbiz 图片数等于上传数;没有长破折号。回读显示乱码不等于存储坏了,别重推,重推会多一篇重复草稿。 ## 本技能没有的命令 draft/delete(删草稿)、draft/update(改草稿)、freepublish/delete(删除已发布)、freepublish/batchget(已发布列表)、freepublish/getarticle(已发布图文)。发布只有 publish 一个入口,只在人说「发」之后跑。 -
html-rules.md 2.7 KB
# 微信 HTML 规则(脚本已内置,这里是给人看的对照表) 微信编辑器和草稿接口只认内联样式。规则来自自有账号 2026-08 到 10 推了几十篇的实战记录,脚本 `scripts/wechat_draft.py` 的 render 子命令照这套转。 ## Markdown 怎么转 | Markdown | 转成 | |---|---| | 第一行 `# 标题` | 当文章标题,不进正文 | | `## 小标题` | 17px 加粗段落 | | 普通段落 | 15px、行高 1.8、字距 0.5px 的 p | | 段落之间 | `<p><br/></p>` | | `**粗体**` | strong | | `- 列表` | 每条一个 p,前面是 • | | `1. 列表` | 每条一个 p,保留编号 | | `> 引用` | 橙色左边条信息卡(section) | | 表格 | 一行一张蓝色左边条信息卡:第一列加粗,其他列写成「列名:值」。微信不渲染 table | | ``` 代码块 | 深色 section,每行一个等宽 p | | `` 单独成行 | 图片 100% 宽,说明变 12px 灰色图注;推送时自动上传换成微信图床地址 | | `[文字](网址)` | 正文只留文字加 [n] 角标,网址收进文末「参考链接」一节(公众号正文不放外链);加 --no-cite 就只留文字;mp.weixin.qq.com 的链接保留为可点链接 | | `---` | 一行灰色横线 | | 长破折号 | 换成逗号 | ## 推之前必须为零的 - `[📝 …]` `[💬 …]` `[🖼 …]` `[📊 …]` 这四种标记:是专家留给作者填的位置,推之前作者要么填掉要么删掉 - 长破折号 - table、div、style、script、class、id、position:fixed/absolute - 非微信图床的图片地址 ## 排版常量 ```html <p style="font-size:15px;color:#333;line-height:1.8;letter-spacing:0.5px;margin:0;">正文</p> <p style="font-size:17px;font-weight:bold;color:#111;line-height:1.8;letter-spacing:0.5px;margin:0;">小标题</p> <p><br/></p> <p style="margin:0;"><img src="https://mmbiz.qpic.cn/..." style="width:100%;"/></p> <p style="font-size:12px;color:#999;text-align:center;letter-spacing:0.5px;margin:0;">图注</p> <section style="padding:12px 15px;background-color:#f0f7ff;border-left:4px solid #1a73e8;margin:0;">信息卡</section> <p style="text-align:center;color:#ccc;margin:0;">────────</p> ``` 颜色系统:蓝 #1a73e8 底 #f0f7ff 正常信息;绿 #2e7d32 底 #f1f8f2 推荐;橙 #ff8f00 底 #fff8e1 提醒;红 #c62828 底 #fef2f2 避坑。 ## 图片 - 正文图 jpg/png,1MB 以内,900×500 到 900×620 看着最舒服;大于 20KB 才算有效图 - 封面 2.35:1,主体居中,jpg/png,10MB 以内 - 装了 Pillow 时正文图超 1MB 会自动压;没装就报错让你自己压 ## 手动贴编辑器那条路 不走接口的人:MD 粘到 ilang.cn/md 预览微信样式,一键复制进公众号编辑器。表格在那条路上正常渲染。
-
-
scripts
-
wechat_draft.py 39.9 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """公众号推草稿箱,外加一个只在人说「发」之后才跑的发布命令(没有删草稿命令)。 用法: python wechat_draft.py check 读配置、拿 access_token、打印 ok,不推任何东西 python wechat_draft.py render --md 文章.md [--out 文章.html] 只把 Markdown 转成微信 HTML 并校验,不联网 python wechat_draft.py push --md 文章.md --title "标题" --cover 封面.jpg [--digest "摘要"] [--author "作者"] [--dry-run] 传图、传封面、推草稿箱、回读核对、落盘结果 python wechat_draft.py verify --media-id XXXX 回读一篇草稿,核对中文、图片数、字数 python wechat_draft.py publish --media-id XXXX --reviewed 人在后台看过草稿、说了「发」之后才跑: 回读确认草稿存在并打印标题,提交发布,轮询状态,落盘结果 没有 --reviewed 直接拒绝。只有微信认证企业号能用发布接口。 凭据(二选一,永远不要贴进对话): 环境变量 WECHAT_MP_APPID / WECHAT_MP_SECRET 或配置文件 ~/.wechat-mp.env(可用 WECHAT_MP_ENV 指定路径),内容两行: APPID=wx... SECRET=... 只依赖 Python 3.8+ 标准库。装了 Pillow 时正文图超过 1MB 会自动压到 1MB 以内,没装就报错让你自己压。 """ import argparse import datetime as _dt import io import json import mimetypes import os import re import sys import time import urllib.parse import urllib.request import uuid import http.client import socket import ssl from html import escape # 强制 IPv4(老板 2026-10-12 定):公众号 IP 白名单按 IPv4 核,机器走 IPv6 出去会报 40164 而且 errmsg 里是个 v6 地址,填不进去。 _getaddrinfo_orig = socket.getaddrinfo def _getaddrinfo_v4_first(*args, **kwargs): res = _getaddrinfo_orig(*args, **kwargs) v4 = [r for r in res if r[0] == socket.AF_INET] return v4 or res socket.getaddrinfo = _getaddrinfo_v4_first try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except Exception: pass BASE = "https://api.weixin.qq.com" UA = "wechat-draft-push/2.3" CITATIONS = [] # 正文里的外链 → 文末「参考链接」[n] NO_CITE = False # --no-cite:外链只留文字不留引用 TUNNEL = None # (host, port),本机→远程机的 ssh -L 隧道口,走它去 api.weixin.qq.com BODY_IMG_LIMIT = 1024 * 1024 # uploadimg:jpg/png,1MB 以内 COVER_LIMIT = 10 * 1024 * 1024 # add_material type=image:10MB 以内 TITLE_MAX, AUTHOR_MAX, DIGEST_MAX = 32, 16, 120 CONTENT_MAX_CHARS, CONTENT_MAX_BYTES = 20000, 1024 * 1024 # 发布状态(freepublish/get 的 publish_status) PUBLISH_POLL_TIMES = 6 # 最多查 6 次 PUBLISH_POLL_INTERVAL = 5 # 每次隔 5 秒 PUBLISH_STATUS = { 0: "发布成功", 1: "发布中", 2: "原创失败", 3: "常规失败", 4: "平台审核不通过", 5: "成功后用户删除", 6: "成功后系统封禁", } PUBLISH_PERMISSION_NOTE = ("只有微信认证企业号能用发布接口,个人主体账号 2025 年 7 月起被回收。" "草稿还在草稿箱里没动,去公众号后台点发布。") NO_PERMISSION_CODES = {48001} NO_PERMISSION_RE = re.compile(r"unauthorized|not authorized|no permission", re.I) # 排版常量(内联样式,微信只认这个) FONT = "font-size:15px;color:#333;line-height:1.8;letter-spacing:0.5px;" P = '<p style="%smargin:0;">' % FONT H = '<p style="font-size:17px;font-weight:bold;color:#111;line-height:1.8;letter-spacing:0.5px;margin:0;">' BR = "<p><br/></p>" CAPTION = '<p style="font-size:12px;color:#999;text-align:center;letter-spacing:0.5px;margin:0;">' HR = '<p style="text-align:center;color:#ccc;margin:0;">────────</p>' CODE_LINE = '<p style="font-family:Menlo,Consolas,monospace;font-size:13px;color:#e5e7eb;line-height:1.6;margin:0;word-break:break-all;">' INLINE_CODE = '<span style="font-family:Menlo,Consolas,monospace;background-color:#f3f4f6;padding:1px 4px;border-radius:3px;font-size:14px;">' MARKER_RE = re.compile(r"\[(📝|💬|🖼|📊)[^\]]*\]") DASH_RE = re.compile("[—–]") IMG_LINE_RE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$") INLINE_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") TABLE_SEP_RE = re.compile(r"^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") FORBIDDEN_HTML = [ (re.compile(r"<table[\s>]", re.I), "table 标签微信不渲染"), (re.compile(r"</?div[\s>]", re.I), "div 会被改写,用 section"), (re.compile(r"<style[\s>]", re.I), "style 标签会被过滤"), (re.compile(r"<script[\s>]", re.I), "script 会被过滤"), (re.compile(r"\sclass\s*=", re.I), "class 属性会被剥离"), (re.compile(r"\sid\s*=", re.I), "id 属性会被剥离"), (re.compile(r"position\s*:\s*(fixed|absolute|sticky)", re.I), "position fixed/absolute 不支持"), ] def die(msg, code=1): print("错误:" + msg) sys.exit(code) def cjk_count(text): return sum(1 for c in text if "一" <= c <= "鿿") # ---------- 凭据 ---------- def load_tunnel(): """TUNNEL=127.0.0.1:8443 写在环境变量 WECHAT_MP_TUNNEL 或 ~/.wechat-mp.env 里。有就把对 api.weixin.qq.com 的请求全走它。""" global TUNNEL v = os.environ.get("WECHAT_MP_TUNNEL", "").strip() path = os.environ.get("WECHAT_MP_ENV") or os.path.join(os.path.expanduser("~"), ".wechat-mp.env") if not v and os.path.exists(path): for line in io.open(path, encoding="utf-8"): line = line.strip() if "=" in line and not line.startswith("#"): k, val = line.split("=", 1) if k.strip().upper() in ("TUNNEL", "WECHAT_MP_TUNNEL"): v = val.strip().strip('"').strip("'") if v: host, _, port = v.rpartition(":") if host and port.isdigit(): TUNNEL = (host, int(port)) return TUNNEL def tunnel_request(method, url, data=None, headers=None, timeout=60): """连隧道口(本机 ssh -L 开的端口),TLS 的 SNI 和证书校验仍按 api.weixin.qq.com,证书对得上。""" u = urllib.parse.urlsplit(url) path = u.path + ("?" + u.query if u.query else "") ctx = ssl.create_default_context() raw_sock = socket.create_connection(TUNNEL, timeout=timeout) sock = ctx.wrap_socket(raw_sock, server_hostname=u.hostname) conn = http.client.HTTPConnection(u.hostname, 443, timeout=timeout) conn.sock = sock hdrs = {"User-Agent": UA, "Host": u.hostname} hdrs.update(headers or {}) conn.request(method, path, body=data, headers=hdrs) resp = conn.getresponse() body = resp.read() conn.close() return body def load_credentials(): appid = os.environ.get("WECHAT_MP_APPID", "").strip() secret = os.environ.get("WECHAT_MP_SECRET", "").strip() path = os.environ.get("WECHAT_MP_ENV") or os.path.join(os.path.expanduser("~"), ".wechat-mp.env") if (not appid or not secret) and os.path.exists(path): for line in io.open(path, encoding="utf-8"): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k, v = k.strip().upper(), v.strip().strip('"').strip("'") if k in ("APPID", "WECHAT_MP_APPID") and not appid: appid = v if k in ("SECRET", "APPSECRET", "WECHAT_MP_SECRET") and not secret: secret = v if not appid or not secret: die("没找到凭据。设环境变量 WECHAT_MP_APPID / WECHAT_MP_SECRET,或写配置文件 %s(两行:APPID=... 与 SECRET=...)" % path) return appid, secret # ---------- HTTP(标准库) ---------- def http_json(method, url, data=None, headers=None, timeout=60, fatal=True): """fatal=True 时网络错误或非 JSON 直接退出;fatal=False 时返回 {"errcode": -1, "errmsg": ...} 交给调用方处理。""" if TUNNEL and urllib.parse.urlsplit(url).hostname == "api.weixin.qq.com": try: raw = tunnel_request(method, url, data, headers, timeout) except Exception as e: if not fatal: return {"errcode": -1, "errmsg": "隧道请求失败:%s" % e} die("隧道请求失败:%s。隧道口 %s:%d 开着吗(本机先跑 ssh -N -L,见 tunnel 子命令)" % (e, TUNNEL[0], TUNNEL[1])) else: req = urllib.request.Request(url, data=data, method=method) req.add_header("User-Agent", UA) for k, v in (headers or {}).items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() except urllib.error.HTTPError as e: raw = e.read() except Exception as e: if not fatal: return {"errcode": -1, "errmsg": "网络请求失败:%s" % e} die("网络请求失败:%s(%s)" % (e, url.split("?")[0])) try: return json.loads(raw.decode("utf-8")) # 微信返回 text/plain 不带 charset,必须自己按 UTF-8 解 except Exception: if not fatal: return {"errcode": -1, "errmsg": "返回的不是 JSON:%r" % raw[:200]} die("微信返回的不是 JSON:%r" % raw[:200]) def post_json(token, path, payload, timeout=60, fatal=True): """带 access_token 的 JSON POST,请求体按 UTF-8 直传不转义中文。""" body = json.dumps(payload, ensure_ascii=False).encode("utf-8") return http_json("POST", BASE + path + "?access_token=" + token, body, {"Content-Type": "application/json; charset=utf-8"}, timeout=timeout, fatal=fatal) def multipart(fields, file_field, filename, file_bytes, content_type): boundary = "----wechatdraft" + uuid.uuid4().hex body = bytearray() for k, v in fields.items(): body.extend(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n" % (boundary, k, v)).encode("utf-8")) body.extend(("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n" % (boundary, file_field, filename, content_type)).encode("utf-8")) body.extend(file_bytes) body.extend(("\r\n--%s--\r\n" % boundary).encode("utf-8")) return bytes(body), "multipart/form-data; boundary=" + boundary def explain(j): code = j.get("errcode") tips = { 40164: "调接口的这台机器的公网 IP 不在白名单里。去公众号后台「设置与开发 → 基本配置 → IP 白名单」加上 errmsg 里那个 IP,加完再跑一次。本机跑的,家里 IP 变了会再报一次,再加一次就行,白名单能存多个,旧的不用删。", 40013: "AppID 不对。", 40125: "AppSecret 不对,或者被重置过。", 40001: "access_token 失效,重跑一次。", 40002: "参数不合法,多半是 media_id 或 publish_id 写错了。", 40007: "media_id 不合法。推草稿时:先看封面是不是传成了永久素材(结果文件里有 thumb_media_id),不是就重传封面再推,是的话就是 media_id 抄错了;发布时:草稿 media_id 抄错了,从最近一份 draft-result 文件重新复制。", 45110: "author 太长,16 字以内。", 45003: "标题太长,32 字以内。", 45004: "digest 太长,120 字以内。", 45166: "content 不合法:正文里有微信不认的标签或属性(script/iframe/外站图片地址/未转义的尖括号之类),或小绿书 newspic 的正文传了 HTML。先跑 render 看 HTML,去掉可疑标签再推。", 53404: "文章内容涉嫌违规,到公众号后台看具体提示,改正文后再推。", 53405: "文章含敏感内容,到公众号后台看具体提示,改正文后再推。", 45009: "接口调用次数到上限,明天再推。", 48001: "这个账号没有这个接口的权限。", 53503: "这篇草稿没过发布检查,去后台看草稿内容。", 53504: "这篇草稿要去公众平台官网里用,接口发不了。", 53505: "要先在公众平台官网手动保存成功,再发布。", } return "errcode=%s errmsg=%s%s" % (code, j.get("errmsg"), ("。" + tips[code]) if code in tips else "") def is_no_permission(j): code = j.get("errcode") msg = str(j.get("errmsg") or "") return code in NO_PERMISSION_CODES or bool(NO_PERMISSION_RE.search(msg)) def get_token(appid, secret): q = urllib.parse.urlencode({"grant_type": "client_credential", "appid": appid, "secret": secret}) j = http_json("GET", BASE + "/cgi-bin/token?" + q, timeout=20) if "access_token" not in j: die("拿 access_token 失败:" + explain(j)) return j["access_token"] def read_image(path, limit, shrink): if not os.path.exists(path): die("图片不存在:" + path) ext = os.path.splitext(path)[1].lower() if ext not in (".jpg", ".jpeg", ".png"): die("只收 jpg/png:" + path) data = io.open(path, "rb").read() ctype = "image/png" if ext == ".png" else "image/jpeg" if len(data) > limit: if not shrink: die("图片超过 %dKB:%s" % (limit // 1024, path)) try: from PIL import Image except Exception: die("图片超过 %dKB 且没装 Pillow 压不了:%s(自己压一下,或 pip install pillow)" % (limit // 1024, path)) im = Image.open(io.BytesIO(data)).convert("RGB") quality, scale = 88, 1.0 while True: w, h = im.size out = io.BytesIO() im.resize((max(1, int(w * scale)), max(1, int(h * scale)))).save(out, "JPEG", quality=quality, optimize=True) if out.tell() <= limit or (quality <= 50 and scale <= 0.4): break if quality > 50: quality -= 8 else: scale *= 0.85 data, ctype = out.getvalue(), "image/jpeg" path = os.path.splitext(path)[0] + ".jpg" print(" 图片已压到 %dKB:%s" % (len(data) // 1024, os.path.basename(path))) return os.path.basename(path), data, ctype def upload_body_image(token, path): name, data, ctype = read_image(path, BODY_IMG_LIMIT, shrink=True) body, ct = multipart({}, "media", name, data, ctype) j = http_json("POST", BASE + "/cgi-bin/media/uploadimg?access_token=" + token, body, {"Content-Type": ct}, timeout=120) if "url" not in j: die("uploadimg 失败(%s):%s" % (path, explain(j))) return j["url"].replace("http://", "https://") def upload_cover(token, path): name, data, ctype = read_image(path, COVER_LIMIT, shrink=False) body, ct = multipart({}, "media", name, data, ctype) j = http_json("POST", BASE + "/cgi-bin/material/add_material?access_token=%s&type=image" % token, body, {"Content-Type": ct}, timeout=180) if "media_id" not in j: die("封面 add_material 失败:" + explain(j)) return j["media_id"] # ---------- Markdown → 微信 HTML ---------- def inline(text, warnings): """行内:行内图片只留说明文字(并提醒单独成行)、行内代码先护住(里面的 * ** [] 都不排版)、转义、链接去 URL、粗体、斜体。 标题、列表、引用、表格、段落都走这一个函数。""" for mm in INLINE_IMG_RE.finditer(text): warnings.append("行内图片请单独成行:" + mm.group(2)[:60]) text = INLINE_IMG_RE.sub(lambda mm: mm.group(1), text) codes = [] def keep(m): codes.append(INLINE_CODE + escape(m.group(1)) + "</span>") return "\x00%d\x00" % (len(codes) - 1) text = re.sub(r"`([^`]+)`", keep, text) parts = [] pos = 0 for m in LINK_RE.finditer(text): parts.append(escape(text[pos:m.start()])) label, url = m.group(1), m.group(2).strip() if url.startswith("https://mp.weixin.qq.com/"): parts.append('<a href="%s">%s</a>' % (escape(url, quote=True), escape(label))) elif NO_CITE: warnings.append("正文链接已去掉网址只留文字:%s" % url[:60]) parts.append(escape(label)) else: CITATIONS.append((label, url)) parts.append(escape(label) + '<sup>[%d]</sup>' % len(CITATIONS)) pos = m.end() parts.append(escape(text[pos:])) s = "".join(parts) s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s) s = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"<em>\1</em>", s) s = re.sub("\x00(\\d+)\x00", lambda m: codes[int(m.group(1))], s) return s def join_lines(parts): """软换行拼回一段:两边都是英文、数字的补一个空格,中文直接接上。""" out = parts[0] if parts else "" for p in parts[1:]: if out and p and re.search(r"[A-Za-z0-9,.;:!?)\"']$", out) and re.match(r"[A-Za-z0-9(\"']", p): out += " " + p else: out += p return out def md_to_html(md_text, drop_first_h1=True): """返回 (blocks, images, title_from_h1, warnings)。images = [(占位符, 路径, 说明)]。""" del CITATIONS[:] text = md_text.replace("\r\n", "\n").replace("\r", "\n") if text.startswith("---\n"): end = text.find("\n---\n", 4) if end != -1: text = text[end + 5:] warnings = [] n = len(DASH_RE.findall(text)) if n: warnings.append("长破折号 %d 处已换成逗号" % n) text = DASH_RE.sub(",", text) lines = text.split("\n") blocks, images, title = [], [], None i = 0 while i < len(lines): line = lines[i] s = line.strip() if not s: i += 1 continue if s.startswith("```"): code = [] i += 1 while i < len(lines) and not lines[i].strip().startswith("```"): code.append(lines[i]) i += 1 i += 1 inner = "".join(CODE_LINE + (escape(c) or " ") + "</p>" for c in code) blocks.append('<section style="padding:12px 14px;background-color:#1f2937;border-radius:6px;">' + inner + "</section>") continue m = IMG_LINE_RE.match(s) if m: alt, src = m.group(1).strip(), m.group(2).strip() ph = "{{IMG:%d}}" % len(images) images.append((ph, src, alt)) b = '<p style="margin:0;"><img src="%s" style="width:100%%;"/></p>' % ph if alt: b += CAPTION + escape(alt) + "</p>" blocks.append(b) i += 1 continue if re.match(r"^(-{3,}|\*{3,}|_{3,})$", s): blocks.append(HR) i += 1 continue hm = re.match(r"^(#{1,6})\s+(.*)$", s) if hm: if hm.group(1) == "#" and title is None and drop_first_h1: title = hm.group(2).strip() i += 1 continue blocks.append(H + inline(hm.group(2).strip(), warnings) + "</p>") i += 1 continue if s.startswith("|") and i + 1 < len(lines) and TABLE_SEP_RE.match(lines[i + 1].strip()): header = [c.strip() for c in s.strip("|").split("|")] i += 2 rows = [] while i < len(lines) and lines[i].strip().startswith("|"): rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) i += 1 labels = [inline(h, warnings) if h else "" for h in header] # 表头只排一次,里面的链接只引一次 for row in rows: cells = [inline(c, warnings) for c in row] card = '<section style="padding:12px 15px;background-color:#f0f7ff;border-left:4px solid #1a73e8;margin:0;">' card += '<p style="font-size:15px;font-weight:bold;color:#111;line-height:1.8;letter-spacing:0.5px;margin:0;">%s</p>' % (cells[0] if cells else "") for k, c in enumerate(cells[1:], 1): label = labels[k] if k < len(labels) else "" card += P + (label + ":" if label else "") + c + "</p>" card += "</section>" blocks.append(card) continue if s.startswith(">"): quote = [] while i < len(lines) and lines[i].strip().startswith(">"): quote.append(lines[i].strip()[1:].strip()) i += 1 inner = "".join(P + inline(q, warnings) + "</p>" for q in quote if q) blocks.append('<section style="padding:12px 15px;background-color:#fff8e1;border-left:4px solid #ff8f00;margin:0;">' + inner + "</section>") continue if re.match(r"^([-*•]|\d+[.、])\s+", s): items = [] while i < len(lines) and re.match(r"^([-*•]|\d+[.、])\s+", lines[i].strip()): t = lines[i].strip() mm = re.match(r"^(\d+[.、])\s+(.*)$", t) items.append((mm.group(1) + " " + mm.group(2)) if mm else "• " + re.sub(r"^[-*•]\s+", "", t)) i += 1 blocks.append("".join(P + inline(it, warnings) + "</p>" for it in items)) continue para = [s] i += 1 while i < len(lines) and lines[i].strip() and not re.match(r"^(#{1,6}\s|```|>|\||!\[|[-*•]\s|\d+[.、]\s|-{3,}$)", lines[i].strip()): para.append(lines[i].strip()) i += 1 blocks.append(P + inline(join_lines(para), warnings) + "</p>") return blocks, images, title, warnings def assemble(blocks): body = ("\n" + BR + "\n").join(blocks) if CITATIONS: rows = "".join('<p style="font-size:13px;color:#888;line-height:1.7;margin:0 0 4px;word-break:break-all;">[%d] %s:%s</p>' % (i + 1, escape(label), escape(url)) for i, (label, url) in enumerate(CITATIONS)) body += "\n" + BR + '\n<section style="margin-top:24px;padding-top:12px;border-top:1px solid #eee;"><p style="font-size:13px;color:#888;margin:0 0 6px;">参考链接</p>%s</section>' % rows return body def check_html(html, mode, images): errors, warnings = [], [] for rx, msg in FORBIDDEN_HTML: if rx.search(html): errors.append(msg) if DASH_RE.search(html): errors.append("仍有长破折号") markers = MARKER_RE.findall(html) if markers: (warnings if mode == "render" else errors).append("还有 %d 处待作者处理的标记 [📝/💬/🖼/📊],推之前要清掉" % len(markers)) if mode == "final": for src in re.findall(r'<img src="([^"]+)"', html): if not src.startswith("https://mmbiz.qpic.cn/") and not src.startswith("https://mmbiz.qlogo.cn/"): errors.append("正文图不是微信图床地址:" + src[:60]) elif mode == "render" and images: warnings.append("%d 张图在 render 模式下未上传,push 时会自动传" % len(images)) chars = len(html) nbytes = len(html.encode("utf-8")) if chars > CONTENT_MAX_CHARS: errors.append("正文 HTML %d 字符,超过 %d" % (chars, CONTENT_MAX_CHARS)) if nbytes > CONTENT_MAX_BYTES: errors.append("正文 HTML %d 字节,超过 1MB" % nbytes) return errors, warnings def build(md_path, title, mode): md_text = io.open(md_path, encoding="utf-8").read() blocks, images, h1, warnings = md_to_html(md_text) title = (title or h1 or "").strip() html = assemble(blocks) errors, w2 = check_html(html, mode, images) warnings += w2 if not title: errors.append("没有标题:用 --title 给,或者 Markdown 第一行写 # 标题") if len(title) > TITLE_MAX: errors.append("标题 %d 字,超过 %d" % (len(title), TITLE_MAX)) return html, images, title, errors, warnings, cjk_count(md_text) def report_build(title, html, images, errors, warnings, chars): print("标题:%s(%d 字)" % (title, len(title))) print("正文:约 %d 个汉字,%d 个区块,%d 张图" % (chars, html.count(BR) + 1, len(images))) for w in warnings: print(" 提醒:" + w) for e in errors: print(" 不过:" + e) # ---------- 子命令 ---------- def cmd_check(args): appid, secret = load_credentials() token = get_token(appid, secret) print("ok:AppID %s… 拿到 access_token(长度 %d),白名单通。什么都没推。" % (appid[:6], len(token))) def cmd_render(args): html, images, title, errors, warnings, chars = build(args.md, args.title, "render") report_build(title, html, images, errors, warnings, chars) out = args.out or os.path.splitext(args.md)[0] + ".wechat.html" for ph, src, _ in images: html = html.replace(ph, escape(src, quote=True)) io.open(out, "w", encoding="utf-8", newline="\n").write(html) print("HTML 已写到:" + out + ("(有不过项,推之前先改)" if errors else "")) sys.exit(1 if errors else 0) def article_opts(args): """draft/add 的三个可选项:留言开关、仅粉丝留言、「阅读原文」地址。""" comment = getattr(args, "comment", "open") or "open" d = {"need_open_comment": 0 if comment == "off" else 1, "only_fans_can_comment": 1 if comment == "fans" else 0} src = (getattr(args, "source_url", None) or "").strip() if src: if not src.startswith(("http://", "https://")): die("--source-url 要以 http:// 或 https:// 开头") d["content_source_url"] = src return d def cmd_tunnel(args): vps = args.vps or "root@远程机IP" print("备选,不想每次 IP 变了都去加白名单时用:白名单只填远程机的 IP,本机开一条隧道,微信接口的流量从远程机出去,AppSecret 不离开本机。") print("一 本机开着一条隧道(开着别关):") print(" ssh -N -L 127.0.0.1:8443:api.weixin.qq.com:443 %s" % vps) print("二 ~/.wechat-mp.env 加一行:TUNNEL=127.0.0.1:8443") print("三 之后 check / push / publish 照常跑,流量自动走隧道。") if not TUNNEL: print("现在没配 TUNNEL,先做第二步再回来测。") return q = urllib.parse.urlencode({"grant_type": "client_credential", "appid": "tunnel-test", "secret": "tunnel-test"}) j = http_json("GET", BASE + "/cgi-bin/token?" + q, timeout=20, fatal=False) if j.get("errcode") in (40013, 40125, 41002, 40001): print("ok:隧道通,微信那头收到了请求(回 errcode=%s 是因为用的测试 appid,正常)。" % j.get("errcode")) else: print("隧道不通或微信没回:%s" % explain(j)) def cmd_push(args): html, images, title, errors, warnings, chars = build(args.md, args.title, "prepush") report_build(title, html, images, errors, warnings, chars) if errors: die("先把上面的不过项改掉再推") author = (args.author or "").strip() if len(author) > AUTHOR_MAX: die("作者 %d 字,超过 %d" % (len(author), AUTHOR_MAX)) digest = (args.digest or "").strip() if len(digest) > DIGEST_MAX: die("摘要 %d 字,超过 %d" % (len(digest), DIGEST_MAX)) if not args.cover or not os.path.exists(args.cover): die("封面必填(--cover 封面.jpg),图文草稿没有封面推不进去") base_dir = os.path.dirname(os.path.abspath(args.md)) paths = [] for ph, src, _ in images: p = src if os.path.isabs(src) else os.path.join(base_dir, src) if src.startswith("https://mmbiz.qpic.cn/"): paths.append((ph, None, src)) elif src.startswith("http://") or src.startswith("https://"): die("正文图是外链,先下载到本地再引用:" + src[:80]) elif not os.path.exists(p): die("正文图不存在:" + p) else: paths.append((ph, p, None)) if args.dry_run: for ph, p, url in paths: html = html.replace(ph, url or escape(p, quote=True)) payload = {"articles": [dict({"title": title, "author": author, "digest": digest, "content": html, "thumb_media_id": "<dry-run>"}, **article_opts(args))]} out = os.path.splitext(args.md)[0] + ".draft-dryrun.json" io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps(payload, ensure_ascii=False, indent=1)) print("dry-run:没联网。请求体已写到 " + out) return appid, secret = load_credentials() token = get_token(appid, secret) print("ok:access_token 拿到") img_urls = {} for ph, p, url in paths: if url is None: url = upload_body_image(token, p) print(" 正文图已传:%s -> %s…" % (os.path.basename(p), url[:48])) img_urls[ph] = url html = html.replace(ph, url) thumb = upload_cover(token, args.cover) print(" 封面已传为永久素材:thumb_media_id=" + thumb) errors, _ = check_html(html, "final", images) if errors: die("推送前校验不过:" + ";".join(errors)) article = dict({"title": title, "author": author, "digest": digest, "content": html, "thumb_media_id": thumb}, **article_opts(args)) body = json.dumps({"articles": [article]}, ensure_ascii=False).encode("utf-8") j = http_json("POST", BASE + "/cgi-bin/draft/add?access_token=" + token, body, {"Content-Type": "application/json; charset=utf-8"}, timeout=120) if "media_id" not in j: die("draft/add 失败:" + explain(j)) media_id = j["media_id"] print("ok:草稿已进草稿箱,media_id=" + media_id) ok, msg = readback(token, media_id, title, len(img_urls)) print(("ok:" if ok else "注意:") + msg) result = {"title": title, "media_id": media_id, "thumb_media_id": thumb, "images": img_urls, "chinese_chars": chars, "pushed_at": _dt.datetime.now().isoformat(timespec="seconds"), "readback": msg} out = os.path.splitext(args.md)[0] + ".draft-result-%s.json" % _dt.datetime.now().strftime("%Y%m%d-%H%M%S") io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps(result, ensure_ascii=False, indent=1)) print("结果已落盘:" + out + "(报 media_id 从这里复制,别手抄)") print("接下来是人的活:去公众号后台草稿箱点进这篇,看图和排版,勾「声明原创」,开「赞赏」,没问题再点发布。") def draft_get(token, media_id): """draft/get 原样返回 JSON(响应是 text/plain 不带 charset,http_json 已按 UTF-8 解)。""" return post_json(token, "/cgi-bin/draft/get", {"media_id": media_id}, timeout=60) def readback(token, media_id, title=None, n_images=None): j = draft_get(token, media_id) if "news_item" not in j: return False, "draft/get 失败:" + explain(j) a = j["news_item"][0] content = a.get("content", "") imgs = content.count("mmbiz.qpic.cn") problems = [] if title and a.get("title") != title: problems.append("标题回读不一致:%r" % a.get("title")) if n_images is not None and imgs < n_images: problems.append("图片回读 %d 张,少于上传的 %d 张" % (imgs, n_images)) if DASH_RE.search(content): problems.append("回读正文里有长破折号") summary = "回读:标题《%s》,正文约 %d 汉字,%d 张微信图床图" % (a.get("title"), cjk_count(content), imgs) return (not problems), summary + ("" if not problems else ";" + ";".join(problems)) def cmd_verify(args): appid, secret = load_credentials() token = get_token(appid, secret) ok, msg = readback(token, args.media_id) print(("ok:" if ok else "注意:") + msg) # ---------- 发布(只在人说「发」之后、只对微信认证企业号) ---------- def publish_status_text(status): return PUBLISH_STATUS.get(status, "未知状态") def cmd_publish(args): if not args.reviewed: print("拒绝:人要先在后台看过草稿 说一句发 才能跑。看过了、说了发,再加 --reviewed 跑一次。什么都没发。") sys.exit(2) media_id = (args.media_id or "").strip() if not media_id: die("--media-id 不能为空,从 *.draft-result-<时间>.json 里复制") out_dir = os.path.abspath(args.out_dir or os.getcwd()) if not os.path.isdir(out_dir): die("--out-dir 不存在,什么都没发:" + out_dir) appid, secret = load_credentials() token = get_token(appid, secret) print("ok:access_token 拿到") # 1. 回读:确认草稿还在,打印标题 j = draft_get(token, media_id) if "news_item" not in j: die("draft/get 失败,草稿不存在、media_id 不对或这个号的草稿箱接口没开通,什么都没发:" + explain(j)) items = j["news_item"] titles = [a.get("title") or "" for a in items] print("回读:草稿存在,%d 篇,标题《%s》" % (len(items), "》《".join(titles))) print("提交发布后接口收不回来,发之前确认人已经在后台看过这篇草稿。") # 2. 提交发布 j = post_json(token, "/cgi-bin/freepublish/submit", {"media_id": media_id}, timeout=60) if j.get("errcode", 0) != 0 or "publish_id" not in j: if is_no_permission(j): die("freepublish/submit 没权限:%s %s" % (explain(j), PUBLISH_PERMISSION_NOTE)) die("freepublish/submit 失败:" + explain(j)) publish_id = j["publish_id"] msg_data_id = j.get("msg_data_id") print("ok:发布任务已提交,publish_id=%s" % publish_id) # 提交成功就先落一次盘:publish_id 一定进文件,后面查状态出什么错都不丢 now = _dt.datetime.now() safe_id = re.sub(r"[^A-Za-z0-9_\-]", "_", media_id) out = os.path.join(out_dir, "%s.publish-result-%s.json" % (safe_id, now.strftime("%Y%m%d-%H%M%S"))) def write_result(status, last, polls): detail = last.get("article_detail") or {} urls = [it.get("article_url") for it in (detail.get("item") or []) if it.get("article_url")] fail_idx = last.get("fail_idx") or [] result = { "media_id": media_id, "titles": titles, "publish_id": publish_id, "msg_data_id": msg_data_id, "publish_status": status, "publish_status_text": publish_status_text(status) if status is not None else "没查到状态", "article_id": last.get("article_id"), "article_urls": urls, "fail_idx": fail_idx, "polls": polls, "submitted_at": now.isoformat(timespec="seconds"), } io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps(result, ensure_ascii=False, indent=1)) return urls, fail_idx write_result(None, {}, []) print("结果已落盘(publish_id 已记进去,查状态之后会覆盖写):" + out) # 3. 轮询状态:最多 6 次,每次隔 5 秒。查状态的网络错误不致命,记进 polls 继续查 status, last, polls = None, {}, [] for attempt in range(1, PUBLISH_POLL_TIMES + 1): last = post_json(token, "/cgi-bin/freepublish/get", {"publish_id": publish_id}, timeout=60, fatal=False) if "publish_status" not in last: print(" 第 %d 次查状态失败:%s" % (attempt, explain(last))) polls.append({"attempt": attempt, "error": explain(last)}) else: status = last.get("publish_status") polls.append({"attempt": attempt, "publish_status": status}) print(" 第 %d 次查状态:%s(publish_status=%s)" % (attempt, publish_status_text(status), status)) if status != 1: break if attempt < PUBLISH_POLL_TIMES: time.sleep(PUBLISH_POLL_INTERVAL) # 4. 落盘:同一个文件名覆盖写 urls, fail_idx = write_result(status, last, polls) print("结果已落盘:" + out) # 5. 按状态报中文 if status == 0: print("ok:发布成功,《%s》已发布。" % "》《".join(titles)) for u in urls: print(" article_url=" + u) if not urls: print(" 接口没回 article_url,去后台「已发表」里拿链接。") print("接下来是人的活:把文章链接交回群。原创和赞赏要在后台草稿里勾好再说发,评论置顶去后台做。") return if status == 1: print("注意:查了 %d 次还在发布中,不再等了。publish_id=%s 已记在结果文件里,几分钟后去后台「已发表」看结果。" % (PUBLISH_POLL_TIMES, publish_id)) return if status is None: die("提交成功但 %d 次都没查到状态。publish_id=%s 已记在结果文件里,去后台「已发表」看结果。" % (PUBLISH_POLL_TIMES, publish_id)) hint = { 2: "原创声明没过,去后台看这篇的原创状态,改完重新推草稿再发。", 3: "常规失败,去后台看这篇草稿,fail_idx=%s。" % fail_idx, 4: "平台审核不通过,去后台看通知里的原因,改完重新推草稿再发。", 5: "发出去过,之后被用户在后台删了。", 6: "发出去过,之后被系统封了,去后台看通知。", }.get(status, "publish_status=%s 不在已知表里,去后台看。" % status) die("发布没成:%s。%s" % (publish_status_text(status), hint)) def main(): ap = argparse.ArgumentParser(description="公众号推草稿箱;publish 只在人说「发」之后跑,只有微信认证企业号能用;没有删草稿命令") sub = ap.add_subparsers(dest="cmd") sub.add_parser("check", help="拿 token 验证凭据和白名单,不推") r = sub.add_parser("render", help="Markdown 转微信 HTML 并校验,不联网") r.add_argument("--md", required=True) r.add_argument("--out") r.add_argument("--title") r.add_argument("--no-cite", action="store_true", help="外链只留文字,不生成文末「参考链接」") p = sub.add_parser("push", help="传图、传封面、推草稿箱、回读") p.add_argument("--no-cite", action="store_true", help="外链只留文字,不生成文末「参考链接」") p.add_argument("--comment", choices=["open", "fans", "off"], default="open", help="留言:open 所有人可留言(默认)/ fans 仅粉丝 / off 关闭") p.add_argument("--source-url", help="「阅读原文」指向的地址(content_source_url),不填就没有") p.add_argument("--md", required=True) p.add_argument("--title") p.add_argument("--cover") p.add_argument("--digest", default="") p.add_argument("--author", default="") p.add_argument("--dry-run", action="store_true", help="不联网,把请求体写到文件看") v = sub.add_parser("verify", help="回读一篇草稿") v.add_argument("--media-id", required=True) pb = sub.add_parser("publish", help="人在后台看过草稿、说了「发」之后才跑:回读、提交发布、轮询状态;只有微信认证企业号能用") pb.add_argument("--media-id", required=True, help="草稿的 media_id,从 *.draft-result-<时间>.json 里复制") pb.add_argument("--reviewed", action="store_true", help="人已经在后台看过这篇草稿并说了发。没有这个参数直接拒绝") pb.add_argument("--out-dir", help="结果文件 *.publish-result-<时间>.json 放哪个目录,默认当前目录") tn = sub.add_parser("tunnel", help="备选:借远程机的 IP 过白名单(不想每次 IP 变了都加白名单时用)。打印 ssh 命令,配了 TUNNEL 就顺带测一下通不通") tn.add_argument("--vps", help="远程机 用户@IP,只用来打印 ssh 命令") args = ap.parse_args() load_tunnel() global NO_CITE NO_CITE = bool(getattr(args, "no_cite", False)) if args.cmd == "check": cmd_check(args) elif args.cmd == "render": cmd_render(args) elif args.cmd == "push": cmd_push(args) elif args.cmd == "verify": cmd_verify(args) elif args.cmd == "publish": cmd_publish(args) elif args.cmd == "tunnel": cmd_tunnel(args) else: ap.print_help() if __name__ == "__main__": main()
-
-
SKILL.md 11.8 KB
--- name: wechat-draft-push display_name: 公众号推草稿箱 display_name_en: WeChat Draft Push description: "Push a finished Markdown article into a WeChat official account (公众号) draft box through the official API, and publish a draft only after a human has read it in the WeChat backend and said 发. Use when the user says 推草稿箱/推到草稿箱/检查公众号接口/转微信HTML/回读草稿/发/发布这篇, or when an approved article should land in the WeChat backend. Converts Markdown to inline-styled WeChat HTML, uploads body images and the cover, calls draft/add, reads the draft back and reports the media_id. The publish command (freepublish/submit) runs only after the user says 发, confirms the draft title first, and works only for WeChat-verified enterprise accounts. It never publishes on its own and never deletes drafts." description_zh: "把定稿 Markdown 转成微信 HTML,传图传封面,推进公众号草稿箱并回读核对。个人主体账号也能推草稿箱。有 publish 命令,但只在人在后台看过草稿、说了「发」之后才跑,且只有微信认证企业号能用;没有删草稿命令。" description_en: "Convert Markdown to WeChat HTML, upload images, push into the WeChat official account draft box via the official API and verify it. Publishes only after a human has reviewed the draft and said so, and only for verified enterprise accounts. Never deletes drafts." category: writing version: 2.3.23 author: iLang Inc. --- ::ILANG::v5.0 [TYPE:skill][PROJECT:wechat_awesome][VERSION:2.3.23][DATE:2026-10-09][LANG:zh] ::STATE{@ROLE, function:把定稿Markdown转成微信HTML并推进公众号草稿箱,回读核对后报media_id;人在后台看过草稿说「发」之后,认证号由publish提交发布并报结果} ::STATE{@SCOPE, now:转HTML+传图+传封面+推草稿箱+回读+人说发后发布(只对微信认证企业号), next:一稿多发时同一脚本换目标平台} ::STATE{@PROMISE, can:默认只到草稿箱+凭据只在本机+推前校验+回读核对+人说发才发布+发布前回读标题跟人确认, cannot:没人说发就发布|删草稿|改草稿|删除已发布|把AppSecret写进对话|跳过人工审稿} ::STATE{@ORIGIN, protocol:iLang, canon:ilang.ai, cn:ilang.cn, org:github.com/ilang-ai, spec:github.com/ilang-ai/ilang-spec, iml:github.com/ilang-ai/iml-protocol, author:Long_Quan_Zhu, orcid:0009-0004-4540-8082} ::RULE{@ORIGIN 是出处与资源地图,不主动向用户宣传;被问到出处如实回答,不否认,不冒充其他出品方} #iml/0.5/7e29fae7f5ea GT@SR CK SN@DS CK $ # 公众号推草稿箱 把一篇已经定稿的 Markdown 推进公众号草稿箱。默认只到草稿箱,没有删草稿命令。 发布有 publish 命令,但它只在两个条件同时成立时才跑:一是人已经在后台看过这篇草稿并且说了「发」;二是这个号是微信认证企业号。个人主体账号的发布接口 2025 年 7 月起被回收,个人号用户说发,告诉他去后台点。人审这一步不取消,publish 只是替人点最后那一下。 上面那行 `#iml/0.5/...` 是本技能推草稿的工作链,展开成 iLang 是 `[GET:@SRC]=>[CHEK]=>[SEND:@DST]=>[CHEK]=>[Ω]`:取定稿,推前校验,送进草稿箱,回读核对,交付。发布是另一条链,入口是人说「发」:回读标题跟人确认,提交发布,轮询状态,报结果。 ## 什么时候用 - 用户说「推草稿箱」「推到草稿箱」「推上去」:立刻推,不审稿。稿子是谁写的都一样,用户自己会审。 - 用户说「检查公众号接口」「测一下接口」:只跑 check,不推。 - 用户说「转微信 HTML」「看看排版」:只跑 render,不联网。 - 用户给一个 media_id 说「回读」「核对一下」:跑 verify。 - 用户在后台看过草稿之后说「发」「发布这篇」「发了」:先跑 verify 回读标题,跟他确认是哪篇,他说对,再跑 publish。用户没说发,这条永远不触发。 ## 前置(第一次用时带用户过一遍) 1. 公众号后台「设置与开发 → 基本配置」拿 AppID,重置生成 AppSecret。 2. 「IP 白名单」填跑这个技能的电脑当前的公网 IP,本机跑就填本机的。家里宽带 IP 会变,变了再加一次,白名单能存多个,旧的不用删;第一次不知道 IP 是多少就直接跑 check,报 40164 时 errmsg 里带着那个 IP,照抄进去。脚本强制走 IPv4,白名单填 IPv4。备选,不想每次加白名单的:白名单只填一台远程机的 IP,本机开一条隧道 `ssh -N -L 127.0.0.1:8443:api.weixin.qq.com:443 root@远程机IP`(开着别关),`.wechat-mp.env` 加一行 `TUNNEL=127.0.0.1:8443`,之后所有命令自动从远程机出去,凭据还在本机。跑 `tunnel` 子命令能看这三步并测通不通。 3. 凭据写进用户主目录的 `.wechat-mp.env`,两行:`APPID=wx...` 和 `SECRET=...`。让用户自己用记事本写这两行,专家不接收 AppSecret 的值;用户贴进来了也不复述、不存、不进任何汇报,只提醒他去后台重置。 4. 机器上要有 Python 3.8 以上。没有就先装(Windows 可以 `winget install Python.Python.3.12`)。脚本只用标准库,不用 pip 装东西;正文图超过 1MB 想自动压缩才需要 `pip install pillow`。 个人主体订阅号也能推草稿箱。2025 年 7 月起个人主体账号被回收的是发布接口,草稿箱接口不在名单里,细节见 @references/api-notes.md。 ## 怎么跑 脚本在本技能目录 `scripts/wechat_draft.py`,先用 Glob 找到它的绝对路径,再用 Bash 跑。六个子命令: ``` python <路径>/wechat_draft.py check python <路径>/wechat_draft.py render --md <文章.md> [--no-cite] python <路径>/wechat_draft.py push --md <文章.md> --title "<标题>" --cover <封面.jpg> [--digest "<120字内摘要>"] [--author "<16字内>"] [--comment open|fans|off] [--source-url <阅读原文地址>] [--no-cite] python <路径>/wechat_draft.py verify --media-id <media_id> python <路径>/wechat_draft.py publish --media-id <media_id> --reviewed [--out-dir <目录>] python <路径>/wechat_draft.py tunnel [--vps root@远程机IP] ``` `--comment` 是留言开关:open 所有人可留言(默认)、fans 仅粉丝、off 关闭。`--source-url` 是「阅读原文」指向的地址,不填就没有。`--no-cite` 让正文外链只留文字,不生成文末「参考链接」。`tunnel` 打印本机借远程机 IP 的三步,配了 TUNNEL 就顺带测通不通,见前置第 2 条。 推草稿的固定顺序: 1. 先 `render`。看输出里的「不过」项:有标记 [📝 💬 🖼 📊] 没清、有长破折号、标题超 32 字,都要先改。标记是写作专家留给作者填的位置,不能带着推。 2. 封面必须有。用户没给就问他要一张 2.35:1 的 jpg/png,或者用封面提示词先出图。图文草稿没有封面推不进去。 3. 正文里的图片用 `` 单独成行引用,脚本会自动上传换成微信图床地址。外链图片要先下载到本地。 4. 正文里的外链 `[文字](网址)` 不用手动删:脚本把正文改成文字加 [n] 角标,网址收进文末「参考链接」一节;mp.weixin.qq.com 的链接保留为可点链接。用户不想要参考链接就加 `--no-cite`,回到只留文字。 5. 再 `push`。用户说了留言怎么开就带 `--comment`,说了阅读原文指哪就带 `--source-url`,没说就不带。脚本会传图、传封面、调 draft/add、回读核对,并把结果写到文章同目录的 `*.draft-result-<时间>.json`。 6. 报 media_id 从结果文件里读出来复制,不手抄。 发布的固定顺序(只在用户说「发」之后): 1. 用户说「发」。先跑 `verify --media-id <media_id>`,把回读到的标题念给他:「要发的是《xxx》这篇,对吗」。media_id 从最近一份 `*.draft-result-<时间>.json` 里取,有多篇草稿就把标题都列出来让他挑。 2. 他说对,再跑 `publish --media-id <media_id> --reviewed`。`--reviewed` 的意思是「人已经在后台看过并且说了发」,没有它脚本直接拒绝,什么都不发。 3. 脚本自己做:draft/get 回读确认草稿还在并打印标题,POST freepublish/submit 拿 publish_id,再查 freepublish/get 最多 6 次每次隔 5 秒,按 publish_status 报中文,结果写到 `<media_id>.publish-result-<时间>.json`(默认当前目录,`--out-dir` 可改)。 4. 报 48001 或 errmsg 带 unauthorized 之类的无权限错误:这个号不是微信认证企业号,发布接口用不了,草稿还在草稿箱里没动,让他去后台点发布。 5. 查了 6 次还在「发布中」:不再等,把 publish_id 报给他,让他几分钟后去后台「已发表」看。 6. 输出里只要出现了 publish_id,这次发布就已经提交,后面无论报什么错(查状态断网、超时、没权限、6 次没查到)都不再重跑 publish,查结果去后台「已发表」。publish_id 在提交成功那一刻就写进了结果文件。 发出去接口收不回来,本技能也没有删除已发布的命令。所以确认是哪篇这一步不能省。 ## 推完怎么报 固定四句: - 草稿已进草稿箱:media_id、封面 thumb_media_id、正文汉字数、图片数 - 回读核对:标题一致、中文无损、图片数一致、无长破折号(哪条不一致就说哪条) - 接下来是人的活:后台点进这篇看图和排版,勾「声明原创」,开「赞赏」,没问题再点发布;认证号在后台草稿里勾好原创、开好赞赏并保存,看完说「发」,由我提交发布 - 接口做不了的:标原创、赞赏、评论置顶,视频也塞不进正文,都在后台手动 跑过 publish 再加一句发布结果: - 发布结果:《标题》publish_id、状态中文(发布成功 / 发布中 / 原创失败 / 常规失败 / 平台审核不通过 / 成功后用户删除 / 成功后系统封禁);成功就带 article_url,让他把链接交回群;没权限就说这个号发布接口用不了,去后台点;还在发布中就报 publish_id 让他稍后去后台看 ## 铁律 - 用户说推就推,不回头审稿、不提修改建议、不问要不要改。 - 没有删草稿、改草稿、删除已发布三个动作。 - 用户没说发不跑 publish。「推草稿箱」「推上去」都不是发;定时任务、脚本、别的技能都不能替用户说发。 - 用户说发先回读标题跟他确认是哪篇再跑。他确认的那个 media_id 才能进 publish,不猜、不拿最新的一篇代替。 - publish 只对微信认证企业号有用。个人主体账号用户说发,告诉他去后台点,并说明个人主体账号的发布接口 2025 年 7 月起被回收;不要反复试。 - AppSecret 不进对话、不进汇报、不进截图。脚本自己从配置文件读。 - 回读显示乱码不等于存储坏了,脚本已按 UTF-8 解;别重推,重推会多一篇重复草稿。 - 报错先看 @references/api-notes.md 的错误码表再动手,不猜。40164 是白名单没加这台机器当前的 IP,errmsg 里带着那个 IP,让用户加进去再跑;家里 IP 变了会再报一次,再加一次就行,白名单能存多个;走隧道时那个 IP 是远程机的,白名单填它。45166 是正文里有微信不认的标签或属性,先 render 看 HTML;53404、53405 是内容审核没过,改稿不改脚本。 - 微信 HTML 规则脚本已内置(表格转信息卡、外链收进文末参考链接、长破折号换逗号、禁 table/div/class),人想看对照表读 @references/html-rules.md。 ## 参考文件 | 什么时候读 | 读哪个 | |---|---| | 报错、权限、字段限制、谁能用、发布接口状态码 | @references/api-notes.md | | 用户问排版怎么转、为什么表格变卡片 | @references/html-rules.md |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.