# Wechat Official Account

> 微信公众号运营管理 — 素材管理、文章导出、草稿创建、发布尝试、token维护。覆盖开放平台API的素材操作与发布链路。

- Skill: `huang1125677925/wechat-official-account` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add huang1125677925/wechat-official-account`
- Raw SKILL.md: https://api.skillmd.com/api/skills/huang1125677925/wechat-official-account/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: huang1125677925 (https://skillmd.com/u/huang1125677925)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/huang1125677925/wechat-official-account

---


# 微信公众号运营管理

通过微信开放平台官方API操作自己的公众号。适用于文章导出、素材管理、数据分析等场景。

⚠️ **仅限操作自己的公众号** — API绑定了公众号的appid/appsecret，无法抓取别人的号。

## API基础

### 获取access_token

**⚠️ 优先使用 stable_token 接口（POST JSON），旧接口偶发 40001 无效凭证错误。**

```bash
# 推荐：stable_token（POST + JSON body）
TOKEN=$(curl -s -X POST "https://api.weixin.qq.com/cgi-bin/stable_token" \
  -H "Content-Type: application/json" \
  -d "{\"grant_type\":\"client_credential\",\"appid\":\"$APPID\",\"secret\":\"$APPSECRET\"}" \
  | python3 -c "import json,sys;print(json.load(sys.stdin).get('access_token',''))")

# 备用：旧 token 接口（偶发40001）
# curl -s "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=$APPID&secret=$APPSECRET"
```

- 有效期 **7200秒（2小时）**
- 过期需重新获取，没有refresh_token机制
- 每天调用有限额，普通号2000次/天
- **在 execute_code 中每次调用必须重新获取 token**，跨进程不共享，之前获取的 token 在新进程中无效

### 获取素材列表

```bash
curl -s -X POST "https://api.weixin.qq.com/cgi-bin/material/batchget_material?access_token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"news","offset":0,"count":20}'
```

**回包结构：**
```
{
  "total_count": 21,
  "item_count": 20,
  "item": [
    {
      "media_id": "xxx",
      "update_time": 1629034241,  ＜-- ⚠️ 这是正确的发布时间
      "content": {
        "news_item": [
          {
            "title": "北漂回忆2",
            "update_time": 0,       ＜-- ⚠️ 这里是0！不要用这个！
            "create_time": 0,       ＜-- ⚠️ 这里也是0！
            "url": "https://..."
          }
        ]
      }
    }
  ]
}
```

### 获取单篇素材内容

```bash
curl -s -X POST "https://api.weixin.qq.com/cgi-bin/material/get_material?access_token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"media_id":"xxx"}'
```

返回 `news_item[0].content` 字段包含文章全文HTML。

## 文章发布API

微信开放平台提供草稿箱API用于创建和发布文章。

### 上传图片

```bash
# 封面图（永久素材，返回media_id用于thumb_media_id）
curl -s -F "media=@cover.png" \
  "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=$TOKEN&type=image"
# → {"media_id":"xxx", "url":"http://..."}

# 文中插图（返回url嵌入HTML）
curl -s -F "media=@chart.png" \
  "https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=$TOKEN"
# → {"url":"http://mmbiz.qpic.cn/..."}
```

### 创建草稿

```bash
curl -s -X POST "https://api.weixin.qq.com/cgi-bin/draft/add?access_token=$TOKEN" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{
    "articles": [{
      "title": "标题",
      "thumb_media_id": "封面media_id",
      "author": "作者",
      "digest": "摘要",
      "show_cover_pic": 1,
      "content": "<section>HTML内容</section>",
      "content_source_url": "",
      "need_open_comment": 1,
      "only_fans_can_comment": 0
    }]
  }'
# → {"media_id":"draft_xxx", "item":[{"index":0, "ad_count":1}]}
```

**⚠️ 45003 / 45004 长度限制陷阱：**
- `title` 和 `digest` 限制按 **UTF-8 字节** 计算，不是字符数
- 一个中文字 = 3 字节，标题上限约 20 个中文字（64 字节），摘要上限约 40 个中文字（120 字节）
- 报 `45003 title size out of limit` → 标题字节超了，缩减即可
- 报 `45004 description size out of limit` → 摘要字节超了

### 提交发布（⚠️ 需权限）

```bash
curl -s -X POST "https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"media_id":"draft_xxx"}'
# 成功: {"errcode":0, "publish_id":"xxx"}
# 失败: {"errcode":48001, "errmsg":"api unauthorized"}
```

**48001 权限错误**：表示公众号未获得发布API权限。需要：
- 完成微信认证（加V的认证服务号）
- 设置IP白名单（开发→基本配置→IP白名单）
- 若未认证，草稿创建成功后通知用户**手动群发**

## 封面图生成

无设计工具时可用 headless Chrome 截图 HTML 生成封面：

```python
# 1. 写一个 styled HTML（深蓝渐变背景 + 标题文字）
# 2. 截图
google-chrome --headless --disable-gpu --no-sandbox \
  --screenshot=cover.png --window-size=900,383 cover.html
# 3. 上传
curl -F "media=@cover.png" \
  "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=$TOKEN&type=image"
```

**封面素材建议**：900×383（2.35:1），深蓝/暗色系保持与排版风格一致。

直接调用 `scripts/cover_generate.py` 一键生成：
```bash
python3 scripts/cover_generate.py "主标题" "副标题" [可选小标] [/tmp/cover.png]
```

模板文件：`templates/cover.html`（HTML/CSS 模板） + `templates/draft_payload.json`（完整 draft/add payload 示例）。

### HTML body 提取

生成的文章 HTML 通常是完整文档（含 `<html><body>...</body></html>`），创建草稿时需要只取 body 内容：

```python
import re
m = re.search(r'<body>(.*?)</body>', html, re.DOTALL)
body = m.group(1).strip()  # 去掉外层标签，保留所有 section/p/table
```

### 文章内容HTML注意事项

微信图文编辑器只支持有限的HTML标签：

- 用 `<section>` 包裹整体结构
- 标题用 `<h2>/<h3>` + 样式（微信不支持h4）
- 表格用 `<table>` + 内联样式
- 图片用 `<img>` + `<section style="text-align:center">` 居中
- 提示框用 `background` + `border-left` 模拟
- 颜色用十六进制内联样式

**不要用的标签：** `<div>` 在微信中可能渲染异常，统一用 `<section>`。

**⚠️ API 推送 vs 手动粘贴的重大差异：**

| 问题 | 手动粘贴到编辑器 | API 创建草稿 |
|:---|:---|:---|
| 深色主题（#1a1a2e底+浅灰字） | 正常显示 | **变成乱码/看不清** |
| `<tr style="background:...">` 表格表头底色 | 正常 | **被剥离，显示为白底** |
| `<section>` 嵌套背景色 | 正常 | 可能被重置 |

**对策——API 推送文章必须用浅色/白底配色方案：**
- 主背景：白色 `#fff`，不设外层深色底
- 表头背景必须写在 `<th style="background-color:#1a1a1a;">` 上，**不能写在 `<tr>` 上**
- 正文颜色用 `#333`/`#555`/`#34495e` 等深色，确保白底上可读
- 深色块仅用于结尾引用框等局部区域（`<blockquote>` + 白字），不影响全局可读性
- `linear-gradient` 等复杂 CSS 在 API 推送时不可靠，用纯色

### 完整端到端流程（推一篇公众号文章的最小代码）

```python
import os, re, json, subprocess, requests

APPID = "your_appid_here"        # 黄妙然的号
APPSECRET = "your_appsecret_here"
AUTHOR = "王晨"

# Step 1: 拿 token（必须 stable_token，旧接口偶发40001）
r = requests.post(
    "https://api.weixin.qq.com/cgi-bin/stable_token",
    json={"grant_type": "client_credential", "appid": APPID, "secret": APPSECRET},
    timeout=15,
)
token = r.json()["access_token"]

# Step 2: 抽 body（如果是完整 HTML 文档）
with open("/tmp/article.html", "r", encoding="utf-8") as f:
    html = f.read()
m = re.search(r"<body[^>]*>(.*?)</body>", html, re.DOTALL)
body = m.group(1).strip() if m else html

# Step 3: 标题/摘要字节检查（**必须** 在调用前 print 出来确认）
title = "标题"
digest = "摘要"
print(len(title.encode("utf-8")), "/ 64   ", len(digest.encode("utf-8")), "/ 120")
# 标题 64 字节 ≈ 21 汉字；摘要 120 字节 ≈ 40 汉字

# Step 4: 上传封面（永久素材）
r = requests.post(
    f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image",
    files={"media": ("cover.png", open("/tmp/cover.png", "rb"), "image/png")},
    timeout=30,
)
thumb_media_id = r.json()["media_id"]

# Step 5: 创建草稿
article = {"articles": [{
    "title": title,
    "thumb_media_id": thumb_media_id,
    "author": AUTHOR,
    "digest": digest,
    "show_cover_pic": 1,
    "content": body,
    "content_source_url": "",
    "need_open_comment": 1,
    "only_fans_can_comment": 0,
}]}
r = requests.post(
    f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}",
    data=json.dumps(article, ensure_ascii=False).encode("utf-8"),
    headers={"Content-Type": "application/json; charset=utf-8"},
    timeout=30,
)
print(r.json())  # {"media_id":"draft_xxx", "item":[{"index":0, "ad_count":N}]}
# ad_count > 0 是平台自动注入文中广告，**不算错误**，直接忽略
```

**标题/摘要字节限制的实测值**（2026-06-06 推送"尾盘买卖逻辑与技巧"实测通过）：

| 字段 | 上限 | 实测推送 | 备注 |
|------|------|----------|------|
| title | 64 字节 | 63 字节（21 汉字）| 45003 超限 |
| digest | 120 字节 | 120 字节（40 汉字）| 45004 超限 |
| author | 20 字节 | "王晨"（6 字节）| — |

⚠️ **早期记忆曾记错 digest 为 36 字节**（实际是 120）。如果你看到 36 一定是错记，**以本 skill 为准**。

### ⚠️ 推封面时的命名边界

**绝不要在封面上擅自加系列名/期数**，比如"交易笔记 · 第 12 期"——这不是用户告诉你的具体系列。封面顶部小标只放用户明确指定的字样，没有就**只留主副标题**。

```html
<!-- ❌ 错误：擅自加 "交易笔记 · 第 12 期" 这种猜测 -->
<div class="tag">交易笔记 · 第 12 期</div>

<!-- ✅ 安全：只放主副标题 -->
<div class="title">尾盘买卖逻辑与技巧</div>
<div class="sub">从理论到实战的系统整理</div>
```

如果用户给的是多期文章系列（用户自己说过"这是第 X 期"），才写小标；否则一律不写。

### 草稿创建编码要求

**必须显式指定 UTF-8 编码和 Content-Type charset：**

```python
# ✅ 正确：显式 encode + Content-Type charset
payload = json.dumps(article, ensure_ascii=False).encode("utf-8")
r = requests.post(url, data=payload,
    headers={"Content-Type": "application/json; charset=utf-8"})

# ❌ 错误：用 requests 的 json= 参数（charset 可能不完整）
r = requests.post(url, json=article)
```

**原因：** 使用 `requests.post(json=...)` 时 Content-Type 可能不带 `charset=utf-8`，导致中文在微信端解析为乱码。

### execute_code 中文引号陷阱

在 `execute_code` 的 Python 字符串中使用中文引号 `""` 会导致 SyntaxError，因为 Python 解析器把 `"` 当成字符串边界：

```python
# ❌ SyntaxError
title = "别再9:30才盯盘了！真正的高手，早在9:15就已看穿一切"
#       ^--- Python sees this as end-of-string

# ✅ 用 unicode 转义
title = '别再9:30才盯盘了！真正的高手，早在9:15就已看穿一切'
# 或
title = '\u201c过年加油站\u201d：情绪周期五阶段'
```

**规则：`execute_code` 中的字符串如果包含中文引号 `""`，要么用单引号包裹，要么用 `\u201c` / `\u201d` 转义。**

### 中文引号 SyntaxError 陷阱

当标题含中文弯引号 `""` 时，在 Python 字符串中会触发 `SyntaxError`——因为 `"` 看起来像普通双引号 `"`，导致 Python 误解析字符串边界：

```python
# ❌ 错误：中文弯引号 "瓶颈理论" 中的 " 被 Python 当成字符串结束符
title = "一年45倍！Serenity的"瓶颈理论"，A股怎么用？"
# SyntaxError: invalid syntax

# ✅ 方案1：用 Unicode 转义 \u201c \u201d
title = '一年45倍！Serenity的\u201c瓶颈理论\u201d，A股怎么用？'

# ✅ 方案2：用单引号包裹字符串（如果内部不含单引号）
title = '一年45倍！Serenity的"瓶颈理论"，A股怎么用？'
```

> 这个坑也适用于 `digest`、`author` 等任何含中文标点的字符串参数。在 `execute_code` 沙箱中写 JSON payload 时尤其要留意，因为沙箱会将代码作为普通 Python 解析。

### 草稿文章更新 vs 替换策略

微信 API 没有"更新草稿"的接口。需要替换一篇文章时只能：

1. 用 `draft/delete` 删除旧草稿
2. 重新 `draft/add` 创建新草稿

```python
# 删除旧草稿
requests.post(f"https://api.weixin.qq.com/cgi-bin/draft/delete?access_token={token}",
    json={"media_id": "draft_xxxxx"})

# 重新创建
requests.post(f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}", ...)
```

### 文中插图完整流程

封面图和文中插图走不同的 API：

| 用途 | API | 返回 | 用途 |
|:---|:---|:---|:---|
| 封面 | `material/add_material?type=image` | `media_id`（用于 `thumb_media_id`） | 文章封面图 |
| 正文插图 | `media/uploadimg` | `url`（mmbiz.qpic.cn CDN 链接） | 嵌入 HTML 的 `<img>` |

**完整流程：**

```python
# 1. 上传图片 → 获得 CDN URL
with open("image.png", "rb") as f:
    r = requests.post(
        f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={token}",
        files={"media": ("image.png", f, "image/png")})
img_url = r.json()["url"]  # "http://mmbiz.qpic.cn/..."

# 2. 将 URL 嵌入文章 HTML
article_html = f'''
<section style="text-align:center;margin:20px 0;">
<img src="{img_url}" style="max-width:100%;height:auto;display:block;margin:0 auto;">
</section>
'''

# 3. 创建草稿（含图的 body）
payload = json.dumps({"articles": [{
    "title": title, "content": body  # body 中已含 img_url
}]}, ensure_ascii=False).encode("utf-8")
```

### ⚠️ 图文草稿不显示图的排查流程（实战经验 2026-06-06）

**症状：** API推送草稿后，文章里 `<img>` 标签在 source 里都有，URL访问也是200，但草稿箱预览看不到图。

**根因（按发生概率从高到低）：**

| # | 原因 | 验证方法 | 修复 |
|:--|:-----|:---------|:----|
| 1 | **草稿箱页面缓存** — 微信草稿箱编辑页有强缓存 | F5/Ctrl+Shift+R 强制刷新草稿箱页面 | 刷新即可 |
| 2 | **图片CDN URL已过期** — `media/uploadimg` 返回的URL生命周期较短 | curl测试URL状态码，200说明有效 | 重新 `media/uploadimg` 上传，用新URL重新 `draft/add` |
| 3 | **图被推到 `sz_mmbiz_png/` 子目录**（不一定有，但有时URL会被改写） | 在草稿source里检查实际URL | 重新上传并使用新URL |
| 4 | **图片太大未自动展开** — 高图（如>2000px的K线图）默认折叠 | 在草稿编辑器里点开"显示更多图片" | 不需要修复，群发后会自动展开 |
| 5 | **图片URL被微信重写** — 偶尔URL会被改成 `__biz` 参数形式 | 在草稿编辑器里右键看图实际URL | 重新上传并推送 |

**通用排错流程：**

```python
# 1. 验证草稿source里是否有 <img> 标签
import re
draft = get_draft(media_id)
imgs = re.findall(r'<img[^>]+src="([^"]+)"', draft["content"])
print(f"找到 {len(imgs)} 张图")
for i, src in enumerate(imgs, 1):
    # 2. 验证每张图URL是否可访问
    r = requests.get(src)
    print(f"图{i}: {r.status_code} | {len(r.content)} bytes")
    # 200 = OK；403/404 = URL失效
```

**最稳的做法：重传+重建**

```python
# 删除旧草稿
requests.post(f"https://api.weixin.qq.com/cgi-bin/draft/delete?access_token={token}",
    json={"media_id": old_media_id})

# 重新上传所有图片（每次都拿新URL）
for img in images:
    r = requests.post(
        f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={token}",
        files={"media": img})
    img_urls.append(r.json()["url"])

# 用新URL重建HTML并推送
html = rebuild_html(content, img_urls)
requests.post(f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}",
    data=json.dumps({"articles":[{"content": html, ...}]}, ensure_ascii=False).encode("utf-8"),
    headers={"Content-Type": "application/json; charset=utf-8"})
```

**关键教训：**
- 图文草稿显示问题，**重传+重建** 是最快的解决方式（< 10秒）
- 草稿source里有图 ≠ 草稿预览显示图 — 两件事要分开验证
- 群发后图片正常显示的概率 > 99%，草稿预览是"参考性"的
```

### ⚠️ 列表项之间不能有换行/空白（2026-06-06 实测）

**症状：** 推送的 ul/ol 块在草稿箱显示为"每个 li 后面跟一个空 li"。实测：22 个列表块，原始 74 个 li 在草稿里变成 172 个，其中 97 个是空的。

**根因：** 微信 API 存储时会把 `</li>` 和 `<li>` 之间的换行/空白字符（`\n`、空格、制表符）**自动转成空 `<li>`**。

**验证（实测通过）：**
- 原始 HTML（li 间有 `\n`）→ 推送后 172 li，97 空
- 修复 HTML（li 间无空白，全部 `</li><li>` 紧挨着）→ 推送后 74 li，0 空

**修复方案：**
```python
import re
new_body = re.sub(
    r'<(ul|ol)([^>]*?)>(.*?)</\1>',
    lambda m: f'<{m.group(1)}{m.group(2)}>' + re.sub(r'</li>\s+<li', '</li><li', m.group(3)) + f'</{m.group(1)}>',
    body,
    flags=re.DOTALL
)
```

**规则：以后写带 ul/ol 的文章 HTML 时，li 之间绝对不能留任何空白/换行。**

**⚠️ 补充：li 内部也不能有纯空白内容（2026-06-06 第二波修复）**

**症状补充：** 即使 li 之间没有空白，有些 `<li>` 自身内部只含 `\n`（如 `<li>\n</li>`），会被微信 API 解析为可见的空列表项。

**修复：** 推草稿前统一清洗：
```python
new_body = re.sub(r'<li[^>]*>\s*</li>', '', body)
```

**完整清洗流程（推荐）：**
```python
# Step 1: 去掉 li 间空白（防止被解析成新的空 li）
body = re.sub(r'</li>\\s+<li', '</li><li', body)

# Step 2: 去掉只有空白的 li（防止显式空项）
body = re.sub(r'<li[^>]*>\\s*</li>', '', body)
```

### 草稿乱码修复

从 `draft/batchget` 拉取的内容如果编码异常（中文字符显示为 `æä¹æ` 等乱码），是因为内容被以 latin-1 编码存储但实际是 UTF-8 字节。修复方法：

```python
# 从 batchget 拿到原始内容后
with open("draft_content.html", "r", encoding="utf-8") as f:
    raw = f.read()

# 用 re-encode 修复
fixed = raw.encode('latin-1').decode('utf-8')
```

**常见原因：** 通过 ima 知识库导出或 AI 助手直接粘贴的含中文 HTML 内容，在通过微信 API 存储时发生了编码 mis-match。修复后重新 `draft/add` 即可。

### ⚠️ 从文档生成文章的核心规则

**来源文档→公众号文章的成文规则（2026-06-06 用户明确）**

1. **文章内容中不要出现文章标题。** 标题只设置在微信草稿的 `title` 字段中，HTML 内容 body 里不写任何 `<h1>` 或等效的标题文本。正文直接从一个引导块或引言开始。

2. **尽量不删减原文内容。** 从腾讯文档、知识库或任何来源转换文章时，保持原文的完整结构、段落、数据表格和结论。可以调整排版格式（段落划分、标题层级、表格美化），但不能删除、合并、改写核心内容点。

3. **清理AI对话前缀。** 腾讯文档的内容通常以 `用户:...ima:...` 开头，创建文章前要去掉这些对话头尾和免责声明，只保留纯内容部分。

4. **标题/摘要字节校验。** 推送前用 `len(title.encode("utf-8"))` 和 `len(digest.encode("utf-8"))` 检查字节数。超限时主动缩短，不要依赖API静默截断。

**示例：首板战法文章的生成**
```
来源文档: 和ima的对话 → 首板战法内容
处理步骤:
  1. 读文档原始内容
  2. 去掉"用户:...ima:..."的对话前缀
  3. 去掉结尾"（内容由AI生成，仅供参考）"的免责
  4. 分成核心逻辑、四大战法、操作细节等章节
  5. 套白底HTML模板排版
  6. 生成封面（交易系统·标题）
  7. 推送草稿（title字段写标题，body不含标题）
```

## 文章导出（Python脚本）

详见 `scripts/wechat_export.py`。

### 用法

```bash
# 列出所有文章
python3 wechat_export.py list

# 下载所有文章为Markdown（文件名 YYYYMMDD_标题.md）
python3 wechat_export.py save

# 下载单篇
python3 wechat_export.py get <media_id>
```

### 关键陷阱：时间戳来源

`batchget_material` 接口返回的 `news_item[].update_time` **永远是0**。正确发布时间在 `item.update_time`（素材级别）：

```python
# ✅ 正确
item.get("update_time")  # Unix时间戳

# ❌ 错误
n.get("update_time")     # 永远是0！
```

而 `get_material`（获取单篇）接口的 `news_item[].create_time/update_time` **有正常值**，与batchget不同。

## References

- [微信官方文档-素材管理](https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_materials_list.html)
- `references/api-notes.md` — 接口字段说明

