boxmoe_header_banner_img

Hello! 欢迎来到LLsdogのblog!

加载中

文章导读

Hermes Agent如何代理微信公众号发文章


avatar
llsdog 2026年6月7日 373

我把 AI Agent 接进了微信公众号后台:Token 管理、素材上传与管线设计全记录

从「AI 写稿 → 人工贴排版」到「一句话触发 → 草稿箱验收」,一条 80 行 Python 脚本的搭建过程与踩坑复盘。


一、痛点:最后一公里的断裂

我用 Hermes(一个能执行 shell、搜索网页、读写文件的 AI agent)辅助写作。流程顺滑:在终端里给它选题 → 搜索资料 → 出稿 → 配图。

然后呢?

打开浏览器,登录 mp.weixin.qq.com,复制粘贴、排版、上传封面、填摘要。每次 20 分钟,纯机械劳动。

不是不能做——是觉得蠢。前面全自动化了,最后一公里居然是最原始的手工。而且有三个具体痛点:

  1. 排版耗时。 Markdown 到微信公众号 HTML 的转换、标签适配、图片嵌入,一篇 1500 字的文章 20 分钟起步
  2. 图片链路断裂。 AI 生成了 SVG/配图,但微信要先上传到素材库拿到 mmbiz.qpic.cn 域名 URL 才能嵌入正文。手动「传图→获取 URL→替换占位符」是纯机械循环
  3. 不能端到端。 理想状态是选题 → 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,
        }]
    })

四个必填字段:titlecontent(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 后会自动按步骤执行——不需要我手动告诉它每一步怎么走。

完整工作流:

  1. 用户给选题(「写一篇关于 X 的文章」)
  2. Agent 用 web search 收集资料
  3. Agent 写成 HTML 格式文章(半教程风格,正文直接是微信兼容的 HTML)
  4. Agent 用 Python Pillow 生成封面图(900×500+ PNG,深色主题,中文标题)
  5. Agent 生成配图(架构图、前后对比图等,2-3 张)
  6. Agent 调 wechat_mp.py upload --image ... 上传所有图片
  7. Agent 拿到图片 URL 后替换 HTML 中的占位符
  8. Agent 调 wechat_mp.py draft --title "..." --content-file ... --thumb-media-id ... 创建草稿
  9. 人去 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 路径:LOCALAPPDATAAPPDATA → 脚本自身目录的相对路径。

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)

查看评论列表
评论头像
老梦站长 2026年07月25日
广告位招租————下北泽19号19810位,联系电话:114514,价格十分甚至九分的便宜,购买者有机会返赠迎宾酒和意大利面
评论头像
Jessica Lee 2026年08月17日
Rất cảm ơn những chia sẻ này.

发表评论

表情 颜文字
插入代码