我把 AI Agent 接进了微信公众号后台:Token 管理、素材上传与管线设计全记录
从「AI 写稿 → 人工贴排版」到「一句话触发 → 草稿箱验收」,一条 80 行 Python 脚本的搭建过程与踩坑复盘。
一、痛点:最后一公里的断裂
我用 Hermes(一个能执行 shell、搜索网页、读写文件的 AI agent)辅助写作。流程顺滑:在终端里给它选题 → 搜索资料 → 出稿 → 配图。
然后呢?
打开浏览器,登录 mp.weixin.qq.com,复制粘贴、排版、上传封面、填摘要。每次 20 分钟,纯机械劳动。
不是不能做——是觉得蠢。前面全自动化了,最后一公里居然是最原始的手工。而且有三个具体痛点:
- 排版耗时。 Markdown 到微信公众号 HTML 的转换、标签适配、图片嵌入,一篇 1500 字的文章 20 分钟起步
- 图片链路断裂。 AI 生成了 SVG/配图,但微信要先上传到素材库拿到
mmbiz.qpic.cn域名 URL 才能嵌入正文。手动「传图→获取 URL→替换占位符」是纯机械循环 - 不能端到端。 理想状态是选题 → agent 自动走完 → 去草稿箱审核 → 发布。不是写到一半切回去手工排版
所以花了半个下午,把微信公众平台 API 整个接进了 Hermes。
二、为什么不用现成工具
做之前先想了一圈替代方案。
直接用 ChatGPT/Claude 写稿 + 手动贴?
可以,但没有解决排版和图片的机械劳动。而且每次写文章需要手动 copy-paste 好几轮——把稿子从 AI 窗口复制出来、格式转换、传图、填摘要。机械操作的次数没有减少,只是换了个 AI 前端。
用 Zapier / n8n 这类低代码平台?
它们的微信公众平台集成通常只有基础能力——发模板消息、获取用户列表。草稿创建 + 素材上传的完整链路很多平台没有封装。就算有,也不如自己写的脚本灵活——比如我想要 Token 提前 5 分钟刷新而非刚好过期再刷新,低代码平台不会给你这种控制粒度。
用 Python requests 库写适配器?
最自然的方案。但我不想为 80 行的脚本引入外部依赖——部署、迁移、给别人复用时都多一步 pip install。标准库够用就只用标准库。
所以最终方案是: Python 标准库(urllib + json + tempfile)+ Hermes skill 机制,零外部依赖,跨平台。
三、架构
三层,结构不复杂:
Hermes Agent(决策层)
↓ 选题 + 大纲
Python 适配器 wechat_mp.py(执行层)
↓ HTTP API
微信公众平台(草稿箱 → 审核 → 发布)
两个关键架构决策:
决策一:独立脚本,不直接调 HTTP。 微信的图片上传接口是 multipart/form-data 格式,不是 JSON。所有接口共用一个 access_token,需要缓存和刷新。把这些状态管理拆到每次 agent tool call 里是自找麻烦——独立脚本封装干净得多。
决策二:Agent 只到草稿这一步。 Agent 可以写稿、配图、排版,但不能点「发布」。人机边界画在审核这一步——机器省掉所有机械劳动,但「要不要发」必须人来决定。这不是技术限制,是有意为之的安全策略。
四、核心技术实现
4.1 Token 管理:缓存 + 提前刷新
微信所有 API 都要带 access_token,有效期 7200 秒(两小时)。获取 token 的接口是:
GET https://api.weixin.qq.com/cgi-bin/token
?grant_type=client_credential
&appid={APPID}
&secret={APPSECRET}
返回格式:
{"access_token": "xxx", "expires_in": 7200}
最简单的策略是每次调业务接口前重新获取 token。但一篇文章可能触发 3-4 次接口(上传封面、上传配图、创建草稿),多出的请求不仅浪费,还可能触发频率限制。
方案:本地文件缓存 + 提前 5 分钟刷新。
TOKEN_FILE = Path(tempfile.gettempdir()) / ".wechat_token_cache.json"
def get_token():
if TOKEN_FILE.exists():
c = json.loads(TOKEN_FILE.read_text())
# 距离过期超过 5 分钟 → 直接用缓存
if time.time() - c["_t"] < c["expires_in"] - 300:
return c["access_token"]
# 缓存不存在或快过期 → 重新获取
d = _fetch_token() # 调 token 接口
TOKEN_FILE.write_text(json.dumps(d))
return d["access_token"]
_t 是获取 token 的时间戳,expires_in 是微信返回的有效期。expires_in - 300 就是提前 5 分钟刷新——这 5 分钟的缓冲窗口是防止「两次连续 API 调用之间刚好过期」这种竞态。
为什么存文件而不是内存?因为 Hermes 的每次 tool call 可能是独立进程,内存变量不共享。文件是唯一跨进程共享状态的途径。
另外注意用了 tempfile.gettempdir() 而不是硬编码 /tmp/——Windows 上是 %TEMP%,Linux 上是 /tmp,确保跨平台兼容。
4.2 素材上传:手动构造 multipart/form-data
这是适配器里最折腾的一段。微信的素材上传接口:
POST https://api.weixin.qq.com/cgi-bin/material/add_material
?access_token={TOKEN}
&type=image
Content-Type: multipart/form-data
请求体是标准的 multipart 格式——包含边界分隔符、Content-Disposition 头、文件二进制数据。
Python 的 requests 库有内置的 multipart 编码,但我不想引入外部依赖。标准库没有直接支持,需要手动拼:
def upload_image(path):
token = get_token()
url = f"{MATERIAL_URL}?access_token={token}&type=image"
boundary = "----HermesWXBoundary"
filename = os.path.basename(path)
with open(path, "rb") as f:
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="media"; '
f'filename="{filename}"\r\n'
f"Content-Type: image/png\r\n\r\n"
).encode() + f.read() + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(url, data=body)
req.add_header("Content-Type",
f"multipart/form-data; boundary={boundary}")
with urllib.request.urlopen(req) as r:
d = json.loads(r.read().decode())
return d["media_id"], d.get("url", "")
返回值有两个关键字段:
media_id:用于封面引用和永久素材管理url:mmbiz.qpic.cn 域名,用于在正文<img>标签中嵌入图片
一个容易踩的坑: 正文 <img src> 必须是 mmbiz.qpic.cn 域名。任何其他域名的图片都会被微信吞掉。正确流程是先调 upload 拿到 url,再把 url 嵌入 HTML。
4.3 草稿创建
相比素材上传,草稿创建就干净了——标准 JSON POST:
def create_draft(title, html, thumb="", digest=""):
token = get_token()
return _post_json(f"{DRAFT_URL}?access_token={token}", {
"articles": [{
"title": title,
"author": "AngieXun",
"digest": digest,
"content": html,
"thumb_media_id": thumb,
}]
})
四个必填字段:title、content(HTML 字符串)、thumb_media_id(封面素材 ID)、digest(摘要)。
正文 HTML 支持微信白名单标签:<h2>、<h3>、<p>、<strong>、<img>、<pre>、<code>、<ul>、<ol>、<li>、<blockquote>。<div> 大概率不生效,不要写 inline style,不要引入外部 CSS/JS。
为什么必须两步走? 创建草稿需要 thumb_media_id,这个 ID 先要上传封面才能拿到。两步之间的间隔如果刚好跨过 token 过期点,第二步直接失败。这个问题靠「提前 5 分钟刷新 token」的策略解决。
五、Hermes 集成
光有脚本不够——需要让 Hermes「知道」怎么用这套工具。我用 Hermes 的 skill 机制写了一份工作流文件。
skill 本质是一个 markdown 文档,告诉 agent:你有哪些工具可用、什么步骤做什么事、常见坑怎么绕。agent 加载 skill 后会自动按步骤执行——不需要我手动告诉它每一步怎么走。
完整工作流:
- 用户给选题(「写一篇关于 X 的文章」)
- Agent 用 web search 收集资料
- Agent 写成 HTML 格式文章(半教程风格,正文直接是微信兼容的 HTML)
- Agent 用 Python Pillow 生成封面图(900×500+ PNG,深色主题,中文标题)
- Agent 生成配图(架构图、前后对比图等,2-3 张)
- Agent 调
wechat_mp.py upload --image ...上传所有图片 - Agent 拿到图片 URL 后替换 HTML 中的占位符
- Agent 调
wechat_mp.py draft --title "..." --content-file ... --thumb-media-id ...创建草稿 - 人去 mp.weixin.qq.com 草稿箱审核 → 点「群发」
第 9 步之所以还是手动,是因为微信的 freepublish/submit 接口需要更高权限(48001 错误),个人订阅号打不通。但这一步本身也在人机边界的合理位置——审核和发布权留在人手里。
六、踩坑实录
每个坑都花了至少十分钟。如果你是复刻这套系统,这节能省你不少时间。
6.1 IP 白名单 → errcode 40164
微信公众平台 API 要求调用方 IP 在白名单里。去 mp.weixin.qq.com → 开发 → 基本配置 → IP 白名单,加上跑脚本的服务器的公网 IP。很多人测试时在本地能跑通,部署到服务器就挂了——就是这个问题。
6.2 封面尺寸 → errcode 53402
封面图必须 ≥ 900×500 像素,PNG 格式。尺寸不够或格式不对直接报错。
6.3 正文图片不显示
图片 <img src> 必须是 mmbiz.qpic.cn 域名。其他域名的图片微信全吞。先把所有图片上传拿到 url,再替换 HTML 里的占位符——这个顺序不能反。
6.4 Token 竞态过期 → errcode 40001
草稿创建分两步(先上传封面 → 再创建草稿),两步之间如果 token 刚好过期,第二步直接失败。加了 5 分钟缓冲后没再出现过。
6.5 HTML 标签被过滤
微信只支持白名单标签。<div> 大概率不生效,inline style 会被吃掉。正文 HTML 只用 <h2>、<h3>、<p>、<strong>、<img>、<pre>、<code> 这些就够。
6.6 Token 缓存路径跨平台不兼容
一开始把缓存文件写死在了 /tmp/,但 Windows 上没有这个路径。改成 tempfile.gettempdir() 后自动适配——Windows 走 %TEMP%,Linux 走 /tmp。
6.7 .env 凭证文件路径不一致
WSL、Windows 原生、Hermes sandbox 三种执行环境下,用户目录布局不同。适配器需要按优先级搜索多个可能的 .env 路径:LOCALAPPDATA → APPDATA → 脚本自身目录的相对路径。
6.8 WSL 终端网络隔离
部分 WSL 配置下无法直接访问外网 API。Hermes 的 terminal 工具跑在 WSL 里,api.weixin.qq.com 可能超时。切到 execute_code(跑在 Windows 原生 Python)后网络不受限。
6.9 发布接口权限不足 → errcode 48001
个人订阅号的免费发布接口权限未开放。需要用认证服务号或手动发布。
七、效果与后续方向
当前管线状态:
| 环节 | 状态 |
|---|---|
| 选题 | 一句话 |
| 搜索 + 写作 + 配图 | 全自动 |
| 封面生成 | Python Pillow 自动 |
| 素材上传 + 草稿创建 | 全自动 |
| 发布 | 需手动点击(API 权限限制) |
后续几个扩展方向:
- 定时发布。 在 Hermes skill 里加上 cron(或 crontab),让 agent 按固定频率生成草稿,人工审核后定时推送
- 系列文章模板。 把技术教程、工具评测等固定格式做成可复用模板,agent 往里填空
- 数据反馈闭环。 接上阅读量/分享数的获取接口,让 agent 根据数据表现调整选题。目前微信统计接口对个人号也有限制,待研究
- 多图排版自动化。 当前适配器只支持单张上传,批量上传 + 自动排版(左图右文、多图并列)的脚本正在写
八、附录:完整脚本
整个适配器不到 80 行(去掉注释和空行),零外部依赖。放在 Hermes 的 profile 目录下作为 skill 配套脚本:
#!/usr/bin/env python3
import os, json, time, sys, argparse
import urllib.request, urllib.error, tempfile
from pathlib import Path
def _load_env():
candidates = [
Path("/mnt/c/Users/14382/AppData/Local/hermes/profiles/llsdog/.env"),
Path(os.environ.get("LOCALAPPDATA", "")) / "hermes/profiles/llsdog/.env",
Path(os.environ.get("APPDATA", "")) / "hermes/profiles/llsdog/.env",
Path(__file__).resolve().parent.parent / ".env",
]
for env_path in candidates:
if env_path.exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
return
_load_env()
APPID = os.environ.get("WECHAT_MP_APPID", "")
SECRET = os.environ.get("WECHAT_MP_SECRET", "")
AUTHOR = os.environ.get("WECHAT_MP_AUTHOR", "AngieXun")
TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"
MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"
TOKEN_FILE = Path(tempfile.gettempdir()) / ".wechat_token_cache.json"
def _fetch_token():
url = f"{TOKEN_URL}?grant_type=client_credential&appid={APPID}&secret={SECRET}"
with urllib.request.urlopen(url) as r:
d = json.loads(r.read().decode())
d["_t"] = time.time()
return d
def get_token():
if TOKEN_FILE.exists():
try:
c = json.loads(TOKEN_FILE.read_text())
if time.time() - c.get("_t", 0) < c.get("expires_in", 7200) - 300:
return c["access_token"]
except: pass
d = _fetch_token()
TOKEN_FILE.write_text(json.dumps(d))
return d["access_token"]
def _post_json(url, payload):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
def upload_image(path):
token = get_token()
url = f"{MATERIAL_URL}?access_token={token}&type=image"
boundary = "----HermesWXBoundary"
filename = os.path.basename(path)
with open(path, "rb") as f:
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="media"; '
f'filename="{filename}"\r\n'
f"Content-Type: image/png\r\n\r\n"
).encode() + f.read() + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(url, data=body)
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
with urllib.request.urlopen(req) as r:
d = json.loads(r.read().decode())
return d["media_id"], d.get("url", "")
def create_draft(title, html, thumb="", digest=""):
token = get_token()
return _post_json(f"{DRAFT_URL}?access_token={token}", {"articles": [{
"title": title, "author": AUTHOR, "digest": digest,
"content": html, "thumb_media_id": thumb,
}]})
# CLI omitted for brevity — see full script in repo




评论(2)