Files
MetaSkill/skills/le-wechat-article/scripts/publish_draft.py
T

272 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把成稿同步到微信公众号草稿箱(只写草稿,绝不群发)。
前置条件:
1. 已认证的公众号(未认证账号调用草稿接口会返回 48001)。
2. 环境变量凭证(在 mp.weixin.qq.com 设置与开发→基本配置获取,只放 shell 环境变量,不进 git):
默认账号 WECHAT_APP_ID / WECHAT_APP_SECRET;
多账号按 --account <key> 读 WECHAT_<KEY>_APP_ID / WECHAT_<KEY>_APP_SECRET(key 转大写),
每个账号在各自后台配置,互不相通。
3. 调用方公网 IP 已加入**该账号**后台的 IP 白名单(家用宽带 IP 变化后需回后台更新)。
用法:
python3 publish_draft.py <文章目录> [--title 标题] [--digest 摘要] [--cover 封面图] \
[--account 账号key] [--check] [--dry-run] [--author 作者]
--account 多账号选择,读 WECHAT_<KEY>_APP_ID / WECHAT_<KEY>_APP_SECRET;缺省用默认凭证
--check 只验证凭证与 IP 白名单(调 token 接口),不写任何内容
--dry-run 不联网,打印将上传的图片与将提交的草稿参数
退出码:0 成功;1 失败(错误码已翻译成人话);2 凭证缺失或参数不合法。
"""
import argparse
import json
import os
import re
import struct
import sys
import urllib.parse
import urllib.request
API = "https://api.weixin.qq.com"
ERR = {
40001: "AppSecret 错误或 access_token 已过期——核对 WECHAT_APP_SECRET,或稍后重试",
40013: "AppID 无效——核对 WECHAT_APP_ID",
40164: "本机公网 IP 不在白名单——把提示里的 IP 加到公众号后台「基本配置→IP白名单」后重试",
40125: "AppSecret 无效或已重置——到后台重置 Secret 后更新环境变量",
45009: "接口当日调用额度用完——明天再试",
48001: "该账号无此 API 权限——草稿箱接口需要已认证公众号;未认证个人订阅号用不了",
40113: "图片格式被素材接口拒绝(部分 PNG 编码会被挑剔)——转成 jpg 再试:"
"sips -s format jpeg cover.png --out cover.jpg(macOS 自带),"
"然后用 --cover cover.jpg 重跑",
53401: "封面图校验失败——换一张(建议 900×383 以上的 2.35:1 图)",
53402: "封面 media_id 无效或不是图片素材",
}
def die(msg, code=1):
print(f"❌ {msg}", file=sys.stderr)
sys.exit(code)
def wx_err(r):
"""把微信返回体翻译成人话;成功返回 None。"""
code = r.get("errcode", 0)
if not code:
return None
hint = ERR.get(code, r.get("errmsg", "未知错误"))
return f"errcode {code}:{hint}({r.get('errmsg', '')})"
def http_json(url, data=None, timeout=30):
"""GET(data=None)或 JSON POST,返回解析后的 dict。"""
req = urllib.request.Request(url, method="POST" if data is not None else "GET")
body = None
if data is not None:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
req.add_header("Content-Type", "application/json; charset=utf-8")
with urllib.request.urlopen(req, body, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def multipart(field, filename, content):
b = "----LeWeChatBoundary7MA4YWxk"
parts = [
f"--{b}\r\nContent-Disposition: form-data; name=\"{field}\"; "
f"filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
]
body = parts[0].encode("utf-8") + content + f"\r\n--{b}--\r\n".encode("utf-8")
return body, b
def post_file(url, path):
with open(path, "rb") as f:
content = f.read()
body, boundary = multipart("media", os.path.basename(path), content)
req = urllib.request.Request(url, method="POST", data=body)
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
def img_size(path):
"""读图片像素尺寸:PNG(IHDR)/ JPEG(SOF 帧),标准库解析。"""
with open(path, "rb") as f:
head = f.read(32)
f.seek(0)
data = f.read()
if head.startswith(b"\x89PNG"):
w, h = struct.unpack(">II", data[16:24])
return w, h
if head.startswith(b"\xff\xd8"): # JPEG:找第一帧 SOF0/2
i = 2
while i < len(data) - 9:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in (0xC0, 0xC2):
h, w = struct.unpack(">HH", data[i + 5:i + 9])
return w, h
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
i += 2
continue
seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
i += 2 + seg_len
raise ValueError(f"无法识别图片尺寸(支持 PNG/JPEG):{path}")
def crop_fractions(w, h, tw, th):
"""最大居中裁剪框 → 微信 pic_crop 格式 x1_y1_x2_y2(0–1 百分比)。"""
if w / h >= tw / th:
ch, cw = h, h * tw / th
else:
cw, ch = w, w * th / tw
x1, y1 = (w - cw) / 2 / w, (h - ch) / 2 / h
x2, y2 = x1 + cw / w, y1 + ch / h
return f"{x1:.4f}_{y1:.4f}_{x2:.4f}_{y2:.4f}"
def get_token(app_id, secret):
q = urllib.parse.urlencode({"grant_type": "client_credential",
"appid": app_id, "secret": secret})
try:
r = http_json(f"{API}/cgi-bin/token?{q}")
except urllib.error.URLError as e:
die(f"网络请求失败:{e}")
err = wx_err(r)
if err:
die(f"获取 access_token 失败——{err}")
return r["access_token"]
def load_creds(account):
"""按账号取凭证:--account key 读 WECHAT_<KEY>_APP_ID/_SECRET,缺省读默认对。"""
if account:
key = re.sub(r"[^A-Z0-9]+", "_", account.upper()).strip("_")
env_id, env_secret = f"WECHAT_{key}_APP_ID", f"WECHAT_{key}_APP_SECRET"
else:
env_id, env_secret = "WECHAT_APP_ID", "WECHAT_APP_SECRET"
return os.environ.get(env_id, ""), os.environ.get(env_secret, ""), env_id, env_secret
def missing_creds(env_id, env_secret):
print(f"❌ 缺少环境变量 {env_id} / {env_secret}\n"
" 获取路径:mp.weixin.qq.com(该账号)→ 设置与开发 → 基本配置\n"
f" 设置方式:export {env_id}=xxx {env_secret}=yyy(建议写进 ~/.zshrc)\n"
" 多账号:每个号各配一对,同步时用 --account <key> 选择。别把密钥写进任何 git 仓库。",
file=sys.stderr)
sys.exit(2)
def main():
ap = argparse.ArgumentParser(description="同步成稿到公众号草稿箱(不群发)")
ap.add_argument("dir", help="文章目录(含 article.html)")
ap.add_argument("--title", required=not any(a in sys.argv for a in ("--check", "--dry-run")))
ap.add_argument("--digest", default="")
ap.add_argument("--author", default="")
ap.add_argument("--cover", help="封面图路径(默认在文章目录找 cover.png/jpg/jpeg/webp)")
ap.add_argument("--account", default="", help="多账号选择:读 WECHAT_<KEY>_APP_ID/_SECRET;缺省用默认凭证")
ap.add_argument("--check", action="store_true", help="只验证凭证与 IP 白名单")
ap.add_argument("--no-crop", action="store_true", help="不提交封面裁剪框参数")
ap.add_argument("--dry-run", action="store_true", help="不联网,打印执行计划")
args = ap.parse_args()
app_id, secret, env_id, env_secret = load_creds(args.account)
acct_note = f"账号 {args.account}" if args.account else "默认账号"
# --check 只验凭证与 IP,不需要文章产物
if args.check:
if not (app_id and secret):
missing_creds(env_id, env_secret)
get_token(app_id, secret) # 失败时内部已 die 并翻译错误码
print(f"✔ {acct_note} 凭证与 IP 白名单验证通过,可以同步草稿箱")
return
# ---- 收集产物(dry-run 也走这段,但不联网) ----
html_path = os.path.join(args.dir, "article.html")
if not os.path.isfile(html_path):
die(f"找不到 {html_path}——先跑 md2wechat.py 生成 HTML", 2)
html_text = open(html_path, encoding="utf-8").read()
if args.title and len(args.title) > 64:
die(f"标题 {len(args.title)} 字,微信上限 64 字", 2)
if len(args.digest) > 120:
die(f"摘要 {len(args.digest)} 字,微信上限 120 字", 2)
cover = args.cover or next(
(os.path.join(args.dir, f"cover{ext}")
for ext in (".jpg", ".jpeg", ".png", ".webp")
if os.path.isfile(os.path.join(args.dir, f"cover{ext}"))), None)
# 正文里的本地图片 → 逐个经 uploadimg 换成微信域名 URL
local_imgs = re.findall(r'src="([^"]+)"', html_text)
local_imgs = [s for s in local_imgs if not s.startswith(("http://", "https://", "data:"))]
if args.dry_run:
print("—— DRY RUN(不联网,不需要凭证)——")
print(f"标题:{args.title or '(未提供)'}")
print(f"摘要:{args.digest or '(空)'}({len(args.digest)}/120 字)")
print(f"封面:{cover or '❌ 未找到——用 --cover 指定或放 cover.png 进文章目录'}")
print(f"正文本地图片 {len(local_imgs)} 张:")
for s in local_imgs:
print(f" - {s}")
crop_note = "跳过(--no-crop)" if args.no_crop else f"2.35:1={crop_fractions(*img_size(cover), 2.35, 1)} · 1:1={crop_fractions(*img_size(cover), 1, 1)}"
print(f"裁剪框:{crop_note}")
print("执行动作:上传封面拿 thumb_media_id → 逐张上传正文图并替换 src → 提交 draft/add")
return
if not (app_id and secret):
missing_creds(env_id, env_secret)
if not args.title:
die("正式同步必须提供 --title", 2)
if not cover:
die("找不到封面图——先按封面 brief 生成 cover.png 放进文章目录,或用 --cover 指定", 2)
token = get_token(app_id, secret)
print("上传封面…")
r = post_file(f"{API}/cgi-bin/material/add_material?type=image&access_token={token}", cover)
err = wx_err(r)
if err:
die(f"封面上传失败——{err}")
thumb_media_id = r["media_id"]
print(f" ✔ thumb_media_id={thumb_media_id}")
for s in local_imgs:
p = os.path.join(args.dir, s)
if not os.path.isfile(p):
die(f"正文图片不存在:{p}")
print(f"上传正文图 {s}…")
r = post_file(f"{API}/cgi-bin/media/uploadimg?access_token={token}", p)
err = wx_err(r)
if err:
die(f"正文图上传失败({s})——{err}")
html_text = html_text.replace(f'src="{s}"', f'src="{r["url"]}"')
print(f" ✔ {s} → {r['url']}")
print("提交草稿…")
article = {"title": args.title, "content": html_text,
"thumb_media_id": thumb_media_id,
"need_open_comment": 0, "only_fans_can_comment": 0}
if not args.no_crop:
w, h = img_size(cover)
article["pic_crop_235_1"] = crop_fractions(w, h, 2.35, 1)
article["pic_crop_1_1"] = crop_fractions(w, h, 1, 1)
print(f" 裁剪框:2.35:1={article['pic_crop_235_1']} · 1:1={article['pic_crop_1_1']}")
if args.digest:
article["digest"] = args.digest
if args.author:
article["author"] = args.author
r = http_json(f"{API}/cgi-bin/draft/add?access_token={token}", {"articles": [article]})
err = wx_err(r)
if err:
die(f"草稿提交失败——{err}")
print(f"✔ 已进入草稿箱(media_id={r['media_id']})\n"
f" 下一步:mp.weixin.qq.com → 内容与互动 → 发表 → 草稿,人工预览确认后再发表。\n"
f" 本 skill 到草稿箱为止,不执行群发。")
if __name__ == "__main__":
main()