Skill: liboss504/wechat-official-tool (version 1.0.0)

--- name: wechat-official-account version: 1.1.0 description: | 微信公众号运营全流程技能。覆盖选题研究、内容创作、图片生成、内容优化、API直写草稿箱/排版校对、定时发布、数据分析等完整环节。与 humanizer、agent-browser、automation-workflows、 baidu-search、多模态内容生成 等技能协同工作。 触发词：公众号运营、微信推文、公众号排版、公众号发布、写推文 allowed-tools: - Read - Write - Edit - PowerShell - AskUserQuestion - WebFetch - WebSearch - Skill - automation_update --- # 微信公众号运营全流程技能 ## 概述 本技能提供微信公众号运营的完整工作流，从选题到发布再到数据分析，覆盖全链路。 --- ## 流程图 ``` 选题研究 → 内容创作 → 图片生成 → 内容优化 → 排版校对 → 发布 → 数据分析 ↓ ↓ 百度搜索/多引擎搜索 自动化定时发布 ``` --- ## 环节一：选题研究 > **账号定位**：情感心理、财政金融、文学艺术、随想感悟四类。选题在热门话题基础上侧重这四类方向。 ### 目标 找到有流量、有共鸣、适合账号定位的话题。 ### 操作步骤 1. **热点捕捉**：用 `baidu-search` 或 `multi-search-engine` 搜索： - "今日热点 微信公众号" - "微信公众号 热门话题 [你的领域]" - "微信指数 [关键词]" 2. **竞品分析**： - 搜索同领域头部公众号近期推文 - 记录：标题风格、选题角度、阅读量（如有显示） 3. **关键词规划**： - 主关键词：账号核心领域词 - 长尾词：具体问题类词（"如何..."、"为什么..."） ### 输出模板 ``` 【选题单】 标题方向： 核心关键词： 目标读者痛点： 竞品参考： 预计发布时间： ``` --- ## 环节二：内容创作 ### AI 写作提示词模板 ``` 你是一位经验丰富的微信公众号编辑，请帮我写一篇推文： 【选题】[填写话题] 【目标读者】[填写受众，如：25-35岁职场人] 【文章风格】[干货型/故事型/观点型] 【字数要求】[800-1500字] 【核心观点】[1-3个要传达的要点] 要求： 1. 标题吸引人但不标题党 2. 开头用痛点/故事引入 3. 正文分3-5个小标题，逻辑清晰 4. 结尾有互动引导（评论/在看/转发） 5. 语言自然，不堆砌辞藻 ``` ### 文章结构模板 ``` 【标题】20字以内，含关键词 【导语】100字 - 痛点/现象引入 - 点出本文价值 【正文】3-5个小节，每节200-300字 ## 小标题1：现象描述 ## 小标题2：原因分析 ## 小标题3：解决方案 ## 小标题4：案例/数据支撑 【结尾】100-150字 - 总结核心观点 - 引导互动（评论/在看） - 预告下期内容（可选） ``` --- ## 环节三：图片生成 ### 封面图 使用 `多模态内容生成` 技能生成封面图： ``` 提示词模板： 微信公众号封面图，[文章主题]，[风格描述]， 简洁大气，适合手机端显示，16:9比例， 建议尺寸 900×500px，PNG格式，主色调：[颜色]，留文字排版空间 ``` > ⚠️ 封面图文件大小须 < 2MB（永久素材），如用草稿 API 直写则须 < 64KB。 > 推荐使用 `wechat-oa-draft` 技能处理封面图压缩与上传。 ### 文中配图 ``` 文中插图，[具体场景描述]，[风格：扁平插画/写实照片/信息图]， 尺寸适配手机屏幕，清晰美观 ``` ### 图片注意事项 - 封面图建议 900×500px（2.35:1 或 16:9） - 文中图片宽度建议 1080px - 避免文字过多（手机端看不清） --- ## 环节四：内容优化（去除AI痕迹） 使用 `humanizer` 技能对 AI 生成的内容进行优化： 1. 去除 AI 特征词汇（此外、值得注意的是、值得注意的是...） 2. 增加个人观点和语气 3. 长短句交替，避免机械化结构 4. 加入具体细节和案例 ### 自检清单 - [ ] 没有"此外"、"值得注意的是"等AI高频词 - [ ] 标题不超过20字 - [ ] 有小标题，且小标题有吸引力 - [ ] 段落不超过5行（手机阅读友好） - [ ] 有引导互动的结尾 - [ ] 关键词自然出现3-5次 --- ## 环节五：排版校对 ### 微信公众号排版规范 | 元素 | 规范 | |------|------| | 正文字号 | 15-16px（微信默认16px） | | 行间距 | 1.8-2.0 | | 段间距 | 15-20px | | 页边距 | 10-15px | | 小标题 | 加粗，可加底色 | | 重点文字 | 加粗 或 标色（公众号允许的颜色） | ### 常用排版工具 - **秀米**（xiumi.us）：模板丰富，适合新手 - **135编辑器**（135editor.com）：功能强大 - **Markdown Here**：Markdown 转公众号格式 ### 排版检查清单 - [ ] 封面图已上传 - [ ] 摘要已填写（50字内，吸引点击） - [ ] 正文无错别字 - [ ] 图片已压缩（单张<500KB） - [ ] 有"阅读原文"链接（如需要） - [ ] 作者信息已填写 --- ## 环节六：发布 ### 方式A：手动发布（精确控制） 1. 登录微信公众平台（mp.weixin.qq.com） 2. 新建图文消息 3. 粘贴内容，上传图片 4. 设置封面、摘要 5. 预览 → 发布 ### 方式B：浏览器自动化发布（使用 agent-browser 技能） 适用于定时批量发布场景： ```bash # 1. 打开公众平台 agent-browser open https://mp.weixin.qq.com # 2. 扫码登录（首次需要手动扫码，之后可保存状态） agent-browser snapshot -i # 3. 保存登录状态 agent-browser state save wechat-mp-auth.json # 之后使用时加载状态 agent-browser state load wechat-mp-auth.json ``` > ⚠️ 注意：微信公众平台有安全验证，自动化发布可能需要处理验证码。 ### 方式C：定时发布（使用 automation-workflows 思路） 在微信公众平台内配置： 1. 编辑好图文后，点击"定时群发" 2. 设置发布时间（可设未来7天内） 3. 支持每日定时（需公众号权限） ### 方式D：API 直写草稿箱（无需第三方排版工具） ⭐ 推荐 使用 `wechat-oa-draft` 技能，直接将文章写入公众号草稿箱，跳过秀米/135编辑器。 **前置条件**： - 拥有公众号 AppID + AppSecret（管理员后台查看） - 调用服务器 IP `112.8.202.216` 已加入 IP 白名单 - 凭证保存在 `{workspace}/wechat_credentials.json` **完整流程**（由 `wechat-oa-draft` 技能自动执行）： ``` 1. 获取 Access Token（有效期2小时） GET /cgi-bin/token 2. 上传封面图（永久素材接口 ⚠️ 非临时素材） POST /cgi-bin/material/add_material?type=image 图片须 < 2MB，技能自动压缩 3. 构造草稿 JSON 使用 scripts/create_draft.py 将 .md 转微信 HTML 4. 提交草稿 POST /cgi-bin/draft/add ``` **常见错误速查**： | errcode | 含义 | 解决方案 | |---------|------|---------| | 40164 | IP不在白名单 | mp后台添加 `112.8.202.216` | | 40006 | media 尺寸超限 | 压缩图片至 < 2MB | | 40007 | 无效 media_id | 改用 `add_material` 永久素材 | | 40001 | token 无效/过期 | 重新获取（7200秒有效期） | > ⚠️ **关键坑**：草稿接口只接受**永久素材** media_id， > 用 `media/upload?type=thumb`（临时素材）上传的图会报 40007！ --- ## 环节七：数据分析 ### 核心指标 | 指标 | 意义 | 优化方向 | |------|------|---------| | 阅读量 | 内容触达规模 | 优化标题、封面 | | 分享率 | 内容传播力 | 增加共鸣点、金句 | | 在看数 | 内容认可度 | 提升观点鲜明度 | | 完读率 | 内容质量 | 优化开头、精简篇幅 | | 涨粉数 | 内容转化力 | 强化账号价值主张 | ### 数据获取 在微信公众平台 → 统计 → 图文分析 中查看。 ### 内容优化循环 ``` 发布 → 看数据 → 分析问题 → 调整内容策略 → 下一期发布 ``` --- ## 完整工作流示例 ### 场景：每周发布1篇干货推文 **周一**：选题研究（30分钟） - 用 baidu-search 搜索本周热点 - 确定选题，填写选题单 **周二**：内容创作（1小时） - 用 AI 写作提示词生成初稿 - 用 humanizer 优化文字 **周三**：图片生成（30分钟） - 用多模态内容生成 做封面图 - 生成文中配图 **周四**：排版校对（30分钟） - 用秀米/135编辑器排版 - 检查排版清单 **周五**：发布 - 手动发布 或 设定时发布 - 分享到朋友圈/社群 **下周一**：数据分析（15分钟） - 记录核心指标 - 总结本次优劣 - 反馈到下一期选题 --- ## 自动化配置建议 使用 `automation-workflows` 思路，可以设置： 1. **每周一早上9点**：提醒选题（用 automation_update 设置定时任务） 2. **发布后2小时**：提醒查看早期数据 3. **每月1号**：提醒做上月数据总结 ### 设置定时提醒示例 可以用 automation_update 工具设置周期性提醒，例如： - 每周一提醒选题 - 每周五提醒发布 --- ## 常见问题 **Q：没有原创保护怎么办？** A：坚持原创，在"原创"按钮处声明。持续原创7天可申请开通"赞赏"。 **Q：阅读量低怎么提升？** A：优化标题（含关键词）、提升封面图质量、在合适时间发布（早8点、午12点、晚8-10点）。 **Q：如何快速涨粉？** A：内容质量第一；适当在相关社群分享；与其他号互推；设置"关注后自动回复"引导。 --- ## 技能依赖 | 依赖技能 | 用途 | |---------|------| | `wechat-oa-draft` | API 直写草稿箱（封面压缩+上传+提交） | | `humanizer` | 去除AI写作痕迹 | | `agent-browser` | 浏览器自动化发布 | | `automation-workflows` | 定时任务配置 | | `baidu-search` | 中文热点搜索 | | `multi-search-engine` | 多引擎搜索 | | `多模态内容生成` | 封面图/配图生成 | | `tencent-docs` | 草稿存储与管理 | --- ## 版本记录 - v1.1.0 (2026-05-11)：新增「方式D：API直写草稿箱」；修正封面图尺寸要求；新增常见错误速查表 - v1.0.0 (2026-05-11)：初始版本，完整工作流

path: scripts/compress_img.py
content: """
compress_img.py - 压缩封面图以满足微信永久素材上传限制

用法:
  python compress_img.py <输入图片路径> <输出图片路径> [quality]

默认 quality=85，输出 JPEG 格式
微信永久素材限制 2MB，目标压到 500KB 以内以保证上传速度
"""
from PIL import Image
import os
import sys


def compress_cover(input_path, output_path, quality=85, max_size=500 * 1024):
    """
    压缩封面图：保持原分辨率，用 JPEG quality 控制文件大小。
    微信永久素材限制 2MB，公众号封面图固定横版 1280×720，目标 < 500KB。
    """
    img = Image.open(input_path).convert("RGB")
    orig_w, orig_h = img.width, img.height

    # 先用指定 quality 保存
    img.save(output_path, "JPEG", quality=quality, optimize=True)
    size = os.path.getsize(output_path)
    print(f"输入:  {os.path.getsize(input_path):,} bytes ({orig_w}x{orig_h})")
    print(f"JPEG q={quality}: {size:,} bytes ({img.width}x{img.height})")

    # 如果还是太大，逐步降 quality
    q = quality
    while size > max_size and q > 30:
        q -= 10
        img.save(output_path, "JPEG", quality=q, optimize=True)
        size = os.path.getsize(output_path)
        print(f"  重试 q={q}: {size:,} bytes")

    # 如果 quality 降到太低还太大，才缩分辨率
    if size > max_size:
        scale = 0.8
        while size > max_size and scale > 0.3:
            new_w = int(orig_w * scale)
            new_h = int(orig_h * scale)
            img_small = img.resize((new_w, new_h), Image.LANCZOS)
            img_small.save(output_path, "JPEG", quality=70, optimize=True)
            size = os.path.getsize(output_path)
            print(f"  缩放 {new_w}x{new_h}: {size:,} bytes")
            scale -= 0.1

    final_size = os.path.getsize(output_path)
    final_img = Image.open(output_path)
    print(f"最终: {final_size:,} bytes, {final_img.width}x{final_img.height}, quality={q}")

    # 检查分辨率
    if final_img.width != orig_w or final_img.height != orig_h:
        print(f"⚠️  注意：图片被缩放了（{orig_w}x{orig_h} → {final_img.width}x{final_img.height}）")

    return final_size


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print(__doc__)
        sys.exit(1)

    input_path = sys.argv[1]
    output_path = sys.argv[2]
    quality = int(sys.argv[3]) if len(sys.argv) > 3 else 85

    compress_cover(input_path, output_path, quality)


path: scripts/create_draft.py
content: """
create_draft.py - 将 Markdown 文章转换为微信草稿 JSON

用法:
  python create_draft.py <文章.md路径> <封面media_id> <输出.json路径> [摘要]

依赖: 标准库（re, json）
无需第三方库
"""
import json
import re
import sys


def md_to_wechat_html(md_text):
    html_parts = []
    lines = md_text.strip().split("\n")
    in_code_block = False

    for line in lines:
        line = line.strip()

        if line.startswith("```"):
            in_code_block = not in_code_block
            continue

        if in_code_block:
            escaped = line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            html_parts.append(
                f'<p style="background:#f6f8fa;padding:10px;font-family:monospace;">{escaped}</p>'
            )
            continue

        # 分割线
        if line == "---":
            html_parts.append('<hr style="border:none;border-top:1px solid #eee;margin:20px 0;"/>')
            continue

        # h2 标题
        if line.startswith("## "):
            title = escape_html(line[3:])
            html_parts.append(
                f'<h2 style="font-size:20px;font-weight:bold;margin:24px 0 10px 0;border-left:4px solid #5766d9;padding-left:10px;">{title}</h2>'
            )
            continue

        # 主标题（# 开头的第一行，跳过）
        if line.startswith("# "):
            continue

        # 修改记录等辅助段落（以 ## 修改记录 开头则截断）
        if line.startswith("## 修改记录"):
            break

        # 空行
        if not line:
            continue

        # 列表项
        if line.startswith("- "):
            item = process_inline(line[2:])
            html_parts.append(
                f'<p style="margin:6px 0;padding-left:16px;color:#555;">&#8226; {item}</p>'
            )
            continue

        line = process_inline(line)
        html_parts.append(
            f'<p style="margin:12px 0;line-height:1.9;font-size:15px;">{line}</p>'
        )

    return "\n".join(html_parts)


def process_inline(text):
    # 粗体 **text**
    text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
    return text


def escape_html(text):
    return (text
            .replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace('"', "&quot;"))


def create_draft(md_path, thumb_media_id, output_path, digest=None):
    with open(md_path, "r", encoding="utf-8") as f:
        content = f.read()

    # 提取主标题（第一个 # 后面那行）
    title_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
    title = title_match.group(1).strip() if title_match else "无标题"

    # 提取第一段作为摘要（如果未提供）
    if digest is None:
        paragraphs = [l.strip() for l in content.split("\n")
                      if l.strip() and not l.strip().startswith("#")
                      and not l.strip().startswith("##") and l.strip() != "---"]
        digest = paragraphs[0][:54] + "..." if paragraphs else ""

    html_content = md_to_wechat_html(content)

    draft = {
        "articles": [
            {
                "title": title,
                "author": "",
                "digest": digest,
                "content": html_content,
                "content_source_url": "",
                "thumb_media_id": thumb_media_id,
                "need_open_comment": 1,
                "only_fans_can_comment": 0
            }
        ]
    }

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(draft, f, ensure_ascii=False, indent=2)

    print(f"标题: {title}")
    print(f"内容长度: {len(html_content)} 字符")
    print(f"草稿已保存到: {output_path}")
    return draft


if __name__ == "__main__":
    if len(sys.argv) < 4:
        print("用法: python create_draft.py <文章.md> <thumb_media_id> <输出.json> [摘要]")
        sys.exit(1)

    md_path = sys.argv[1]
    thumb_media_id = sys.argv[2]
    output_path = sys.argv[3]
    digest = sys.argv[4] if len(sys.argv) > 4 else None

    create_draft(md_path, thumb_media_id, output_path, digest)


path: scripts/publish.py
content: """
publish.py - 微信公众号一键发布脚本

用法:
  python publish.py <文章.md路径> <封面图路径> [摘要]

功能:
  1. 读取 wechat_credentials.json 获取 AppID 和 AppSecret
  2. 获取 Access Token
  3. 上传封面图到永久素材
  4. 将 Markdown 转换为微信草稿 JSON
  5. 提交草稿到公众号

依赖: requests, Pillow
"""
import json
import os
import re
import sys
from pathlib import Path

# 尝试导入，缺少时给出友好提示
try:
    import requests
except ImportError:
    print("❌ 缺少 requests 库，请安装：pip install requests")
    sys.exit(1)

try:
    from PIL import Image
except ImportError:
    print("❌ 缺少 Pillow 库，请安装：pip install Pillow")
    sys.exit(1)


# ============== 配置 ==============
API_BASE = "https://api.weixin.qq.com"
CREDENTIALS_FILE = "wechat_credentials.json"


# ============== 凭证读取 ==============
def load_credentials():
    """读取微信公众号凭证"""
    if not os.path.exists(CREDENTIALS_FILE):
        raise FileNotFoundError(
            f"❌ 找不到凭证文件：{CREDENTIALS_FILE}\n"
            f"请在工作目录创建包含 appid 和 appsecret 的 JSON 文件"
        )

    with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
        creds = json.load(f)

    if "appid" not in creds or "appsecret" not in creds:
        raise ValueError("❌ 凭证文件必须包含 appid 和 appsecret 字段")

    return creds["appid"], creds["appsecret"]


# ============== API 请求 ==============
def get_access_token(appid, appsecret):
    """获取 Access Token"""
    url = f"{API_BASE}/cgi-bin/token"
    params = {
        "grant_type": "client_credential",
        "appid": appid,
        "secret": appsecret
    }

    print("📡 获取 Access Token...")
    resp = requests.get(url, params=params, timeout=30)
    data = resp.json()

    if "errcode" in data and data["errcode"] != 0:
        error_msg = data.get("errmsg", "未知错误")
        if data["errcode"] == 40164:
            raise Exception(f"❌ 错误 40164：IP不在白名单\n请在 mp.weixin.qq.com 后台添加 IP：112.8.202.216")
        raise Exception(f"❌ 获取 Token 失败：{error_msg}")

    token = data["access_token"]
    print(f"✅ Token 获取成功")
    return token


def upload_permanent_material(token, file_path):
    """上传永久素材（封面图）"""
    url = f"{API_BASE}/cgi-bin/material/add_material"
    params = {"access_token": token, "type": "image"}

    # 检查文件大小
    file_size = os.path.getsize(file_path)
    max_size = 2 * 1024 * 1024  # 2MB

    if file_size > max_size:
        raise Exception(
            f"❌ 图片太大（{file_size / 1024 / 1024:.1f}MB），"
            f"永久素材限制 2MB\n"
            f"请使用 compress_img.py 压缩图片"
        )

    print(f"📤 上传封面图：{os.path.basename(file_path)} ({file_size / 1024:.0f}KB)...")

    with open(file_path, "rb") as f:
        files = {"media": (os.path.basename(file_path), f, "image/png")}
        resp = requests.post(url, params=params, files=files, timeout=60)

    data = resp.json()

    if "errcode" in data and data["errcode"] != 0:
        error_msg = data.get("errmsg", "未知错误")
        if data["errcode"] == 40006:
            raise Exception(f"❌ 图片太大，请压缩后再试")
        if data["errcode"] == 40007:
            raise Exception(f"❌ 无效的 media_id，请确认使用永久素材接口")
        raise Exception(f"❌ 上传失败：{error_msg}")

    media_id = data["media_id"]
    print(f"✅ 封面上传成功：media_id = {media_id}")
    return media_id


# ============== Markdown 转换 ==============
def md_to_wechat_html(md_text):
    """将 Markdown 转换为微信公众号 HTML"""
    html_parts = []
    lines = md_text.strip().split("\n")
    in_code_block = False

    for line in lines:
        line = line.strip()

        if line.startswith("```"):
            in_code_block = not in_code_block
            continue

        if in_code_block:
            escaped = line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            html_parts.append(
                f'<p style="background:#f6f8fa;padding:10px;font-family:monospace;font-size:14px;">{escaped}</p>'
            )
            continue

        # 分割线
        if line == "---":
            html_parts.append('<hr style="border:none;border-top:1px solid #eee;margin:20px 0;"/>')
            continue

        # h2 标题
        if line.startswith("## "):
            title = escape_html(line[3:])
            html_parts.append(
                f'<h2 style="font-size:20px;font-weight:bold;margin:24px 0 10px 0;'
                f'border-left:4px solid #5766d9;padding-left:10px;">{title}</h2>'
            )
            continue

        # 主标题（# 开头的第一行，跳过）
        if line.startswith("# "):
            continue

        # 修改记录等辅助段落（以 ## 修改记录 开头则截断）
        if line.startswith("## 修改记录"):
            break

        # 空行
        if not line:
            continue

        # 列表项
        if line.startswith("- "):
            item = process_inline(line[2:])
            html_parts.append(
                f'<p style="margin:6px 0;padding-left:16px;color:#555;">&#8226; {item}</p>'
            )
            continue

        line = process_inline(line)
        html_parts.append(
            f'<p style="margin:12px 0;line-height:1.9;font-size:15px;">{line}</p>'
        )

    return "\n".join(html_parts)


def process_inline(text):
    """处理行内格式：粗体等"""
    text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
    return text


def escape_html(text):
    """转义 HTML 特殊字符"""
    return (text
            .replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace('"', "&quot;"))


# ============== 草稿提交 ==============
def create_draft_json(md_path, thumb_media_id, digest=None):
    """从 Markdown 文件创建草稿 JSON"""
    with open(md_path, "r", encoding="utf-8") as f:
        content = f.read()

    # 提取主标题
    title_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
    title = title_match.group(1).strip() if title_match else "无标题"

    # 提取第一段作为摘要
    if digest is None:
        paragraphs = [l.strip() for l in content.split("\n")
                      if l.strip() and not l.strip().startswith("#")
                      and not l.strip().startswith("##") and l.strip() != "---"]
        digest = paragraphs[0][:54] + "..." if paragraphs else ""

    html_content = md_to_wechat_html(content)

    draft = {
        "articles": [
            {
                "title": title,
                "author": "",
                "digest": digest,
                "content": html_content,
                "content_source_url": "",
                "thumb_media_id": thumb_media_id,
                "need_open_comment": 1,
                "only_fans_can_comment": 0
            }
        ]
    }

    print(f"📝 标题：{title}")
    print(f"📝 摘要：{digest[:50]}...")

    return draft


def submit_draft(token, draft):
    """提交草稿到公众号"""
    url = f"{API_BASE}/cgi-bin/draft/add"
    params = {"access_token": token}

    print("📤 提交草稿...")
    resp = requests.post(url, params=params, json=draft, timeout=30)
    data = resp.json()

    if "errcode" in data and data["errcode"] != 0:
        error_msg = data.get("errmsg", "未知错误")
        raise Exception(f"❌ 提交草稿失败：{error_msg}")

    media_id = data.get("media_id", "未知")
    print(f"✅ 草稿提交成功！")
    print(f"   media_id = {media_id}")
    return media_id


# ============== 主流程 ==============
def main():
    # 检查参数
    if len(sys.argv) < 3:
        print("""
📤 微信公众号一键发布

用法:
  python publish.py <文章.md路径> <封面图路径> [摘要]

示例:
  python publish.py my_article.md cover.png
  python publish.py my_article.md cover.png "这是一篇关于xxx的文章"

注意:
  1. 工作目录需包含 wechat_credentials.json
  2. 封面图需为 1280×720 分辨率，< 2MB
  3. 服务器 IP 112.8.202.216 需加入白名单
""")
        sys.exit(1)

    md_path = sys.argv[1]
    cover_path = sys.argv[2]
    digest = sys.argv[3] if len(sys.argv) > 3 else None

    # 验证文件
    if not os.path.exists(md_path):
        print(f"❌ 找不到文章文件：{md_path}")
        sys.exit(1)

    if not os.path.exists(cover_path):
        print(f"❌ 找不到封面图：{cover_path}")
        sys.exit(1)

    try:
        # Step 1: 读取凭证
        print("\n" + "=" * 40)
        print("📋 Step 1: 读取凭证")
        print("=" * 40)
        appid, appsecret = load_credentials()

        # Step 2: 获取 Token
        print("\n" + "=" * 40)
        print("📋 Step 2: 获取 Access Token")
        print("=" * 40)
        [REDACTED_SECRET], appsecret)

        # Step 3: 上传封面图
        print("\n" + "=" * 40)
        print("📋 Step 3: 上传封面图")
        print("=" * 40)
        media_id = upload_permanent_material(token, cover_path)

        # Step 4: 创建草稿
        print("\n" + "=" * 40)
        print("📋 Step 4: 创建草稿")
        print("=" * 40)
        draft = create_draft_json(md_path, media_id, digest)

        # Step 5: 提交草稿
        print("\n" + "=" * 40)
        print("📋 Step 5: 提交草稿")
        print("=" * 40)
        submit_draft(token, draft)

        # 完成
        print("\n" + "🎉" * 20)
        print("✅ 发布成功！")
        print("🎉" * 20)
        print("\n请前往微信公众号后台 - 内容与互动 - 草稿箱 查看和编辑")

    except Exception as e:
        print(f"\n{'❌' * 20}")
        print(f"发布失败：{e}")
        print(f"{'❌' * 20}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/upload_cover.py
content: """
upload_cover.py - 仅上传封面图到永久素材

用法:
  python upload_cover.py <封面图路径>

输出:
  media_id - 用于后续创建草稿
"""
import json
import os
import sys

try:
    import requests
except ImportError:
    print("❌ 缺少 requests 库，请安装：pip install requests")
    sys.exit(1)


API_BASE = "https://api.weixin.qq.com"
CREDENTIALS_FILE = "wechat_credentials.json"


def load_credentials():
    if not os.path.exists(CREDENTIALS_FILE):
        raise FileNotFoundError(f"❌ 找不到凭证文件：{CREDENTIALS_FILE}")

    with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
        creds = json.load(f)

    return creds["appid"], creds["appsecret"]


def get_access_token(appid, appsecret):
    url = f"{API_BASE}/cgi-bin/token"
    params = {
        "grant_type": "client_credential",
        "appid": appid,
        "secret": appsecret
    }
    resp = requests.get(url, params=params, timeout=30)
    data = resp.json()

    if "errcode" in data and data["errcode"] != 0:
        raise Exception(f"获取 Token 失败：{data.get('errmsg', '未知错误')}")

    return data["access_token"]


def upload_cover(token, file_path):
    url = f"{API_BASE}/cgi-bin/material/add_material"
    params = {"access_token": token, "type": "image"}

    print(f"上传封面图：{os.path.basename(file_path)}...")

    with open(file_path, "rb") as f:
        files = {"media": (os.path.basename(file_path), f, "image/png")}
        resp = requests.post(url, params=params, files=files, timeout=60)

    data = resp.json()

    if "errcode" in data and data["errcode"] != 0:
        raise Exception(f"上传失败：{data.get('errmsg', '未知错误')}")

    return data["media_id"]


def main():
    if len(sys.argv) < 2:
        print("用法: python upload_cover.py <封面图路径>")
        print("输出: media_id")
        sys.exit(1)

    cover_path = sys.argv[1]

    if not os.path.exists(cover_path):
        print(f"❌ 找不到封面图：{cover_path}")
        sys.exit(1)

    # 检查文件大小
    file_size = os.path.getsize(cover_path)
    max_size = 2 * 1024 * 1024
    if file_size > max_size:
        print(f"❌ 图片太大（{file_size / 1024 / 1024:.1f}MB），限制 2MB")
        sys.exit(1)

    appid, appsecret = load_credentials()
    [REDACTED_SECRET], appsecret)
    media_id = upload_cover(token, cover_path)

    print(f"\n✅ 上传成功！")
    print(f"media_id = {media_id}")
    print(f"\n下一步：使用此 media_id 创建草稿")


if __name__ == "__main__":
    main()


path: references/api_ref.md
content: # 微信公众号 API 参考

## 基础信息

- **API 域名**: `https://api.weixin.qq.com`
- **Access Token**: 每次调用前重新获取，有效期 7200 秒
- **必需前提**: 调用 IP 已在 mp.weixin.qq.com 后台加入白名单

## 核心 API 端点

| 功能 | 方法 | URL | 说明 |
|------|------|-----|------|
| 获取 Access Token | GET | `/cgi-bin/token?grant_type=client_credential&appid={appid}&[REDACTED_SECRET]` | 返回 `{access_token, expires_in}` |
| 上传永久图片素材 | POST | `/cgi-bin/material/add_material?access_token={token}&type=image` | multipart/form-data，< 2MB |
| 上传临时图片素材 | POST | `/cgi-bin/media/upload?access_token={token}&type=image` | multipart/form-data，< 2MB，3天有效期 |
| 上传临时 thumb 素材 | POST | `/cgi-bin/media/upload?access_token={token}&type=thumb` | **< 64KB**，3天有效期 |
| 新增草稿 | POST | `/cgi-bin/draft/add?access_token={token}` | JSON body，草稿接口**只接受永久素材 media_id** |

## 草稿接口 media_id 规则（关键）

```
草稿 thumb_media_id 必须是永久素材！
不能用 /media/upload?type=thumb 返回的临时 media_id
```

草稿支持的封面图类型：`image` 类型永久素材（add_material）。

## PowerShell multipart 上传模板

```powershell
$token = "..."
$filePath = "C:\path\to\cover.png"
$boundary = [guid]::NewGuid().ToString("N")
$fileBytes = [System.IO.File]::ReadAllBytes($filePath)
$fileName = [System.IO.Path]::GetFileName($filePath)

$header = "--$boundary`r`nContent-Disposition: form-data; name=`"media`"; filename=`"$fileName`"`r`nContent-Type: image/png`r`n`r`n"
$footer = "`r`n--$boundary--`r`n"
$binHeader = [System.Text.Encoding]::UTF8.GetBytes($header)
$binFooter = [System.Text.Encoding]::UTF8.GetBytes($footer)

$memStream = New-Object System.IO.MemoryStream
$memStream.Write($binHeader, 0, $binHeader.Length)
$memStream.Write($fileBytes, 0, $fileBytes.Length)
$memStream.Write($binFooter, 0, $binFooter.Length)
$body = $memStream.ToArray()
$memStream.Close()

$url = "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=$token&type=image"
$resp = Invoke-WebRequest -Uri $url -Method POST -Body $body -ContentType "multipart/form-data; boundary=$boundary"
$resp.Content
```

## Access Token 刷新时机

- Token 有效期 2 小时
- 建议每次写草稿前重新获取，不要复用超过 30 分钟的 token
- 返回 errcode 40001 时立即重新获取

## 草稿 JSON 结构

```json
{
  "articles": [{
    "title": "文章标题",
    "author": "作者名（可选）",
    "digest": "摘要（可选，不超过54字）",
    "content": "<p>HTML正文...</p>",
    "content_source_url": "",
    "thumb_media_id": "永久素材 media_id",
    "need_open_comment": 1,
    "only_fans_can_comment": 0
  }]
}
```

## 错误码速查

| errcode | errmsg | 含义 | 解决方案 |
|---------|--------|------|---------|
| 40164 | invalid ip hint | IP不在白名单 | mp后台添加 112.8.202.216 |
| 40006 | size limit | media 尺寸超限 | 压缩图片 |
| 40007 | invalid media_id | 无效 media_id | 用 add_material 永久素材接口 |
| 40013 | invalid appid | appid 不合法 | 检查 wechat_credentials.json |
| 40001 | invalid credential | access_token 无效/过期 | 重新获取 token |
| 41006 | media_id missing | 缺少 thumb_media_id | 确保封面图已上传 |
| 44001 | empty content | 空内容 | 检查 Markdown 内容 |


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)