le-wechat-article v1.0:Leroy 专属公众号写作工坊(类型路由 / 分层审读 / 排版转换 / 草稿箱同步)

This commit is contained in:
Leroy
2026-10-06 13:57:30 +08:00
parent d3e9bd45bd
commit cbb405e80a
14 changed files with 1689 additions and 0 deletions
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""公众号文章自检:机器能量化的硬规则与产出配额,不靠眼睛。
用法:python3 check_article.py <article.md> [--type <类型>] [--strict]
退出码:有 ERROR → 1;仅 WARN/INFO → 0(--strict 时 WARN 也算 1)。
脚本量不出的部分(加粗挑得对不对、套话是否承担功能)仍需人工判断。
"""
import argparse
import re
import sys
TYPE_RANGES = {
"简报": (1500, 3000), "测评": (2500, 5000), "复盘": (2500, 6000),
"技术": (2500, 6000), "叙事": (2000, 5000),
}
FILLER_PATTERNS = ["在当今", "随着{}的发展", "综上所述", "总而言之", "值得注意的是",
"不难发现", "让我们来看看", "下面让我们", "众所周知"]
HAN = r"\u4e00-\u9fff"
def strip_inline_code(text):
return re.sub(r"`[^`]*`", "", text)
def split_blocks(md):
"""按行分类,标记代码围栏内外的行。"""
lines = md.split("\n")
fenced, blocks = False, []
for ln in lines:
if ln.lstrip().startswith("```"):
fenced = not fenced
continue
blocks.append((ln, fenced))
return blocks
def check(path, art_type, strict, allow_placeholder=False):
errors, warns, infos = [], [], []
md = open(path, encoding="utf-8").read()
blocks = split_blocks(md)
# ---- 标题层级 ----
h1 = [ln for ln, f in blocks if not f and re.match(r"^# ", ln)]
bad_h = [ln for ln, f in blocks if not f and re.match(r"^(##|####+) ", ln)]
if len(h1) != 1:
errors.append(f"标题层级:全文应有且仅有一个 `# ` 主标题,当前 {len(h1)} 个")
if bad_h:
errors.append(f"标题层级:正文只允许 `###`,发现 {len(bad_h)} 处其他层级(如 {bad_h[0][:30]}…)")
# ---- 占位残留 ----
for ln, f in blocks:
if f:
continue
if re.search(r"placeholder|TODO[::]?", ln):
if allow_placeholder and re.match(r"^\s*!\[.*\]\(placeholder", ln):
continue # 草稿期图位占位合法:alt 里是信息位文字规划
errors.append(f"占位残留:{ln.strip()[:50]}")
# ---- 表格(按连续行分组,每张表报一次) ----
groups, cur = [], []
for ln, f in blocks:
if not f and ln.lstrip().startswith("|"):
cur.append(ln)
elif cur:
groups.append(cur)
cur = []
if cur:
groups.append(cur)
for g in groups:
rows = [r for r in g if not re.match(r"^\s*\|[\s:|-]+\|\s*$", r)]
if not rows:
continue
cols = max(r.count("|") - 1 for r in rows)
if cols > 3:
errors.append(f"表格超过 3 列({cols} 列),移动端不可读,请改写为列表")
elif max(len(c) for c in rows[0].split("|")[1:-1]) > 12:
warns.append(f"表格单元格文字过长:{rows[0].strip()[:40]}…")
# ---- front matter digest 与导语重复 ----
fm = re.match(r"^---\n(.*?)\n---\n", md, re.S)
digest = ""
if fm:
m = re.search(r"^digest:\s*(.+)$", fm.group(1), re.M)
if m:
digest = m.group(1).strip().strip("\"'「」")
body = md[fm.end():] if fm else md
first_quote = ""
for ln, f in split_blocks(body):
if f or not ln.strip() or ln.startswith("# "):
continue # 跳过空行与主标题,找正文首个引用
if ln.startswith(">"):
first_quote = ln.lstrip("> ").strip()
break
if digest and first_quote and digest[:20] == first_quote[:20]:
warns.append(f"摘要 ≠ 导语:digest 与正文首个引用开头 20 字相同,读者会同一句读两遍")
# ---- 加粗配额 ----
plain_texts, bolds = [], [] # plain_texts: (去加粗去代码文本, 本行是否有加粗)
section_has_bold, cur_section_bold, section_names, cur_section = [], False, [], None
for ln, f in blocks:
if f or ln.lstrip().startswith("|"):
continue
if re.match(r"^### ", ln):
if cur_section is not None:
section_names.append(cur_section)
section_has_bold.append(cur_section_bold)
cur_section, cur_section_bold = ln, False
plain_texts.append(("\n", True)) # 小节标题本身是落点,重置累计
continue
line_bolds = re.findall(r"\*\*([^*]+)\*\*", ln)
bolds.extend(line_bolds)
if line_bolds:
cur_section_bold = True
plain = strip_inline_code(re.sub(r"\*\*[^*]+\*\*", "", ln))
plain_texts.append((plain + "\n", bool(line_bolds)))
if cur_section is not None:
section_names.append(cur_section)
section_has_bold.append(cur_section_bold)
for name, has in zip(section_names, section_has_bold):
if not has:
warns.append(f"加粗配额:小节「{name.lstrip('# ')}」全节无加粗落点")
# 连续无加粗字符数(跨段累计;含加粗/小节标题/金句卡的行重置计数)
gap_runs, run = [], 0
for text, has_bold in plain_texts:
if has_bold or re.match(r"^>\s*.{4,}\s*——", text.strip()):
run = 0
continue
run += len(re.sub(r"\s+", "", text))
if run > 200:
gap_runs.append(run)
run = 0
if gap_runs:
warns.append(f"加粗配额:存在 {len(gap_runs)} 处连续 >200 字无加粗(最长 {max(gap_runs)} 字),读者划过整屏找不到重点")
bold_chain = " / ".join(bolds)
if not bolds:
errors.append("加粗配额:全文无加粗——读者只看标题和加粗应能理解全篇,当前做不到")
# ---- 金句卡 ----
golden = [ln for ln, f in blocks if not f and re.match(r"^> .{4,} ——", ln)]
if len(golden) > 1:
warns.append(f"金句卡:`> 金句 —— 出处` 出现 {len(golden)} 处,至多 1 处,多了退化成装饰条")
# ---- 中英文间距 / 引号 / 省略号(排除代码、链接、图片) ----
scan_lines = []
for ln, f in blocks:
if f or ln.lstrip().startswith("|"):
continue
clean = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", ln) # 图片
clean = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", clean) # 链接留文字
clean = strip_inline_code(clean)
scan_lines.append(clean)
scan = "\n".join(scan_lines)
spacing_hits = re.findall(f"[{HAN}][A-Za-z0-9]|[A-Za-z0-9][{HAN}]", scan)
if spacing_hits:
warns.append(f"中英文间距:{len(spacing_hits)} 处汉字与英数字之间缺一个半角空格,如:「{spacing_hits[0]}」")
curly = re.findall(r"[\u201c\u201d\u2018\u2019]", scan)
if curly:
warns.append(f"引号:发现 {len(curly)} 个中文弯引号,请统一改用直角引号「」『』")
ascii_q = re.findall(f'"[^"]*[{HAN}][^"]*"', scan)
if ascii_q:
warns.append(f"引号:{len(ascii_q)} 处用 ASCII 直引号包裹中文(如「{ascii_q[0]}」),请改用直角引号「」")
if re.search(r"\.\.\.|。{3,}", scan):
infos.append("省略号:请使用……(两个 U+2026),不要用三个句点")
# ---- 高频套话 ----
for pat in FILLER_PATTERNS:
p = pat.format("AI|科技|技术") if "{}" in pat else pat
hits = re.findall(p, scan)
if hits:
warns.append(f"套话检查:「{pat}」出现 {len(hits)} 次——保留与否取决于是否承担真实功能,请人工判断")
# ---- 字数 ----
text = re.sub(r"[#>*`|\-\[\]()!\s]", "", scan)
n = len(text)
if art_type in TYPE_RANGES:
lo, hi = TYPE_RANGES[art_type]
if n < lo:
infos.append(f"字数:约 {n} 字,低于「{art_type}」参考下限 {lo}——素材不足时允许,勿注水")
elif n > hi * 1.3:
warns.append(f"字数:约 {n} 字,明显超出「{art_type}」参考上限 {hi},检查是否有注水")
else:
infos.append(f"字数:约 {n} 字({art_type} 参考 {lo}–{hi})")
else:
infos.append(f"字数:约 {n} 字(未指定类型,无参考区间)")
# ---- 输出 ----
print("=" * 56)
print(f"公众号自检 · {path}")
print("=" * 56)
for tag, items in (("❌ ERROR", errors), ("⚠️ WARN", warns), ("ℹ️ INFO", infos)):
for it in items:
print(f"{tag} {it}")
if bold_chain:
print("-" * 56)
print("加粗串读提要(连读应是一篇能独立看懂的缩写版):")
print(" " + (bold_chain[:600] + ("…" if len(bold_chain) > 600 else "")))
print("-" * 56)
total = len(errors) + len(warns)
print(f"结果:{len(errors)} 错误 / {len(warns)} 提醒 / {len(infos)} 信息")
if errors:
return 1
if strict and warns:
print("--strict 模式:WARN 视为不通过")
return 1
return 0
def main():
ap = argparse.ArgumentParser(description="公众号文章硬规则与产出配额自检")
ap.add_argument("article")
ap.add_argument("--type", choices=list(TYPE_RANGES), help="文章类型(提供字数参考区间)")
ap.add_argument("--strict", action="store_true", help="WARN 也视为不通过")
ap.add_argument("--allow-placeholder", action="store_true",
help="草稿模式:图位 ![…](placeholder) 不算错误(交付版必须去掉此开关)")
args = ap.parse_args()
sys.exit(check(args.article, args.type, args.strict, args.allow_placeholder))
if __name__ == "__main__":
main()
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Markdown → 微信公众号可粘贴 HTML(全内联样式,零依赖)。
排版 = 骨架(结构装饰)× 色板(主题色)两层:
骨架 工程 色条标题 + 主题色加粗 + 灰底金句卡——教程、技术、工程实践(默认)
杂志 深色大标题 + 深灰粗体 + 装饰线金句卡——叙事、访谈、生活方式
简报 紧凑节奏 + 主题色小标题——更新简报、快讯、清单
色板 靛蓝 / 松绿 / 黛紫 / 绛红(--color 可自定义 #RRGGBB)
用法:
python3 md2wechat.py article.md -o article.html # 工程骨架 + 靛蓝
python3 md2wechat.py article.md --skeleton 杂志 --palette 松绿 # 指定骨架与色板
python3 md2wechat.py article.md --list-themes # 看全部组合与适用场景
python3 md2wechat.py article.md --preview # 三骨架对照页(375px 栏宽)
输出用浏览器打开,全选复制 → 粘贴进公众号编辑器。
支持的语法子集:#(跳过)/ ### / 段落 / **加粗** / `行内代码` / [链接] / ![图片](src "题注") /
> 引用(含「—— 出处」识别为金句卡)/ - 与 1. 列表 / --- 分隔 / ```代码块``` / ≤3 列表格。
"""
import argparse
import html
import re
import sys
PALETTES = {"靛蓝": "#1A5CFF", "松绿": "#0F7B6C", "黛紫": "#6B4FBB", "绛红": "#B03A2E"}
SKELETONS = {
"工程": {"alias": "eng", "fit": "教程、技术解析、工程实践"},
"杂志": {"alias": "mag", "fit": "叙事、访谈、生活方式、品牌"},
"简报": {"alias": "brief", "fit": "更新简报、快讯、清单文"},
}
def esc(t):
return html.escape(t, quote=False)
def darken(hexc, f=0.72):
hexc = hexc.lstrip("#")
r, g, b = (int(hexc[i:i + 2], 16) for i in (0, 2, 4))
m = lambda v: max(0, int(v * f))
return f"#{m(r):02x}{m(g):02x}{m(b):02x}"
def styles(sk, color, px):
"""骨架差异全部参数化在这里:返回各元素的内联样式串。"""
n = int(px[:-2])
dark = darken(color)
if sk == "杂志":
r, g, b = color.lstrip("#")[0:2], color.lstrip("#")[2:4], color.lstrip("#")[4:6]
hl = f"rgba({int(r,16)},{int(g,16)},{int(b,16)},0.12)"
return {
"p": f"font-size:{px};line-height:1.8;color:#333;letter-spacing:.5px;margin:0 0 22px;",
"strong": f"color:#111;font-weight:700;background:{hl};",
"link": f"color:{dark};text-decoration:none;border-bottom:1px solid {hl};",
"h3": (f"margin:34px 0 16px;font-size:{n + 1}px;font-weight:700;color:#111;"
f"border-bottom:2px solid #e8e8e8;padding-bottom:10px;line-height:1.5;"),
"quote": (f"margin:26px 0;padding:4px 16px;border-left:2px solid #ccc;"
f"font-size:{n - 1}px;line-height:1.8;color:#888;"),
"golden": (f"margin:30px 0;padding:20px 10px;text-align:center;"
f"border-top:1px solid {color};border-bottom:1px solid {color};"
f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"),
"li": "margin:0 0 12px;line-height:1.8;", "li_ul": f"padding-left:1.4em;margin:0 0 22px;font-size:{px};line-height:1.8;color:#333;",
}
if sk == "简报":
return {
"p": f"font-size:{px};line-height:1.7;color:#3a3a3a;margin:0 0 16px;",
"strong": f"color:{color};font-weight:600;",
"link": f"color:{color};text-decoration:none;",
"h3": (f"margin:26px 0 12px;font-size:{n + 1}px;font-weight:600;color:{dark};"
f"line-height:1.5;"),
"quote": (f"margin:20px 0;padding:2px 14px;border-left:2px solid {color};"
f"font-size:{n - 1}px;line-height:1.7;color:#777;"),
"golden": (f"margin:24px 0;padding:12px 8px;text-align:center;"
f"font-size:{n + 1}px;line-height:1.8;color:{dark};font-weight:500;"
f"letter-spacing:1px;"),
"li": "margin:0 0 8px;line-height:1.7;", "li_ul": f"padding-left:1.4em;margin:0 0 16px;font-size:{px};line-height:1.7;color:#3a3a3a;",
}
# 工程(默认)
return {
"p": f"font-size:{px};line-height:1.75;color:#3f3f3f;letter-spacing:.4px;margin:0 0 20px;",
"strong": f"color:{color};font-weight:600;",
"link": f"color:{color};text-decoration:none;",
"h3": (f"margin:30px 0 16px;font-size:{n + 1}px;font-weight:600;color:{dark};"
f"border-left:4px solid {color};padding-left:10px;line-height:1.5;"),
"quote": (f"margin:24px 0;padding:12px 16px;border-left:3px solid #ddd;background:#f7f8fa;"
f"font-size:{n - 1}px;line-height:1.8;color:#888;"),
"golden": (f"margin:28px 0;padding:20px 24px;background:#f7f8fa;text-align:center;"
f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"),
"li": "margin:0 0 10px;line-height:1.75;", "li_ul": f"padding-left:1.4em;margin:0 0 20px;font-size:{px};line-height:1.75;color:#3f3f3f;",
}
def inline(text, st):
"""行内元素转换:先保护行内代码,再处理加粗/链接/图片。"""
codes = []
def stash(m):
codes.append(m.group(1))
return f"\x00{len(codes) - 1}\x00"
t = re.sub(r"`([^`]+)`", stash, text)
t = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)",
lambda m: (f'<img src="{esc(m.group(2))}" style="max-width:100%;display:block;margin:16px auto;'
f'border-radius:6px;"'
+ (f'<figcaption style="text-align:center;font-size:13px;color:#999;'
f'margin:8px 0 20px;">{esc(m.group(3))}</figcaption>' if m.group(3) else "")),
t)
t = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
rf'<a href="\2" style="{st["link"]}">\1</a>', t)
t = re.sub(r"\*\*([^*]+)\*\*", rf'<strong style="{st["strong"]}">\1</strong>', t)
t = re.sub(r"\x00(\d+)\x00",
lambda m: f'<code style="background:#f2f3f5;color:#c7254e;border-radius:3px;'
f'padding:2px 5px;font-size:14px;font-family:Menlo,Consolas,monospace;">'
f'{esc(codes[int(m.group(1))])}</code>', t)
return t
def convert(md, sk, color, px):
st = styles(sk, color, px)
dark = darken(color)
lines = md.split("\n")
out, i, n = [], 0, len(lines)
in_front = lines and lines[0].strip() == "---"
front_done = not in_front
def flush_list(items, ordered):
tag = "ol" if ordered else "ul"
bullet = re.compile(r"^\s*(?:\d+\.|-)\s+")
lis = "".join(
f'<li style="{st["li"]}">' + inline(bullet.sub("", it), st) + "</li>"
for it in items)
out.append(f'<{tag} style="{st["li_ul"]}">{lis}</{tag}>')
while i < n:
ln = lines[i]
if not front_done:
if i > 0 and ln.strip() == "---":
front_done = True
i += 1
continue
s = ln.strip()
if not s:
i += 1
continue
if s.startswith("```"):
i += 1
buf = []
while i < n and not lines[i].strip().startswith("```"):
buf.append(lines[i])
i += 1
i += 1
code = esc("\n".join(buf))
out.append(f'<pre style="background:#f6f8fa;border-radius:8px;padding:14px 16px;'
f'overflow-x:auto;font-size:13.5px;line-height:1.6;margin:0 0 20px;'
f'font-family:Menlo,Consolas,monospace;color:#24292e;">{code}</pre>')
continue
if s.startswith("# ") and not s.startswith("##"):
i += 1 # 主标题不进正文(填在公众号后台)
continue
if s.startswith("###") and not s.startswith("####"):
out.append(f'<h3 style="{st["h3"]}">{inline(s.lstrip("#").strip(), st)}</h3>')
i += 1
continue
if re.match(r"^####+\s", s):
out.append(f'<p style="margin:24px 0 12px;font-weight:600;color:{dark};'
f'font-size:{px};">{inline(s.lstrip("#").strip(), st)}</p>')
i += 1
continue
if re.match(r"^-{3,}$", s):
out.append('<hr style="border:none;border-top:1px solid #eaeaea;margin:28px 0;"/>')
i += 1
continue
if s.startswith(">"):
buf = []
while i < n and lines[i].strip().startswith(">"):
buf.append(lines[i].strip().lstrip(">").strip())
i += 1
text = "<br/>".join(inline(b, st) for b in buf if b)
key = "golden" if "——" in text else "quote"
out.append(f'<blockquote style="{st[key]}">{text}</blockquote>')
continue
if s.startswith("|"):
tbl = []
while i < n and lines[i].strip().startswith("|"):
tbl.append(lines[i].strip())
i += 1
rows = [r for r in tbl if not re.match(r"^\|[\s:|-]+\|$", r)]
if rows:
cols = max(r.count("|") - 1 for r in rows)
if cols > 3:
print(f"⚠️ 表格超过 3 列({cols} 列),已跳过渲染——请先改写为列表", file=sys.stderr)
else:
trs = []
for ri, r in enumerate(rows):
cells = [c.strip() for c in r.strip("|").split("|")]
head = (f"background:#f7f8fa;font-weight:600;color:{dark};" if ri == 0 else "")
tds = "".join(
f'<td style="border:1px solid #eaeaea;padding:8px 10px;'
f'font-size:{int(px[:-2]) - 1}px;{head}">{inline(c, st)}</td>'
for c in cells)
trs.append(f"<tr>{tds}</tr>")
out.append('<table style="border-collapse:collapse;margin:0 auto 20px;">'
+ "".join(trs) + "</table>")
continue
if re.match(r"^\s*-\s+", ln):
items = []
while i < n and re.match(r"^\s*-\s+", lines[i]):
items.append(lines[i])
i += 1
flush_list(items, ordered=False)
continue
if re.match(r"^\s*\d+\.\s+", ln):
items = []
while i < n and re.match(r"^\s*\d+\.\s+", lines[i]):
items.append(lines[i])
i += 1
flush_list(items, ordered=True)
continue
out.append(f'<p style="{st["p"]}">{inline(s, st)}</p>')
i += 1
return (f'<section style="max-width:578px;margin:0 auto;font-family:'
f'-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Hiragino Sans GB\','
f'\'Microsoft YaHei\',sans-serif;">' + "".join(out) + "</section>")
SAMPLE = """### 小节标题示例
正文段落:这里是**主题加粗落点**,后面是[链接示例](https://example.com)与行内代码 `npm install`。
- **标签一**:说明文字
- **标签二**:说明文字
> 值得截图转发的一句金句。 —— 出处
> 普通引用:补充说明的引文。
![图片示例](data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='120'%3E%3Crect width='100%25' height='100%25' fill='%23eee'/%3E%3C/svg%3E "题注")
---
结尾段落,含**收束落点**。
"""
def list_themes():
print("骨架 × 色板(--skeleton 骨架 --palette 色板,或 --color '#RRGGBB' 自定义)\n")
for sk, meta in SKELETONS.items():
print(f" {sk}(--skeleton {sk} 或 {meta['alias']})—— {meta['fit']}")
print(f" 色板:{' / '.join(f'{k} {v}' for k, v in PALETTES.items())}\n")
def resolve_skeleton(name):
if not name:
return "工程"
for sk, meta in SKELETONS.items():
if name in (sk, meta["alias"]):
return sk
sys.exit(f"未知骨架 {name},可选:{' / '.join(SKELETONS)}(或其英文别名)")
def main():
ap = argparse.ArgumentParser(description="Markdown → 微信公众号 HTML(骨架×色板,全内联样式)")
ap.add_argument("article", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("--skeleton", help="骨架:工程 / 杂志 / 简报(默认 工程)")
ap.add_argument("--palette", choices=list(PALETTES))
ap.add_argument("--theme", choices=list(PALETTES), help="兼容旧参数,等同 --palette")
ap.add_argument("--color", help="自定义主题色 #RRGGBB,覆盖色板")
ap.add_argument("--font-size", default="16px")
ap.add_argument("--list-themes", action="store_true", help="列出全部骨架与色板")
ap.add_argument("--preview", action="store_true", help="生成三骨架对照页(用内置样文)")
args = ap.parse_args()
if args.list_themes:
list_themes()
return
sk = resolve_skeleton(args.skeleton)
color = args.color or PALETTES.get(args.palette or args.theme or "靛蓝")
if not re.match(r"^#[0-9A-Fa-f]{6}$", color):
sys.exit(f"颜色格式应为 #RRGGBB,收到 {color}")
if args.preview:
blocks = []
for sk_name in SKELETONS:
doc = convert(SAMPLE, sk_name, color, args.font_size)
blocks.append(f'<h2 style="text-align:center;font-size:15px;color:#999;'
f'margin:40px 0 12px;">骨架:{sk_name} · 色板 {color}</h2>'
f'<div style="max-width:375px;margin:0 auto;border:1px solid #ddd;'
f'padding:16px 14px;">{doc}</div>')
page = ("<!DOCTYPE html><html><head><meta charset='utf-8'><title>主题预览</title></head>"
f"<body>{''.join(blocks)}</body></html>")
out = args.output or "theme-preview.html"
open(out, "w", encoding="utf-8").write(page)
print(f"✔ {out}(骨架对照页,375px 栏宽 ≈ 手机宽度)——浏览器打开选一个顺眼的")
return
if not args.article:
sys.exit("请提供 markdown 文件路径(或用 --list-themes / --preview)")
md = open(args.article, encoding="utf-8").read()
doc = convert(md, sk, color, args.font_size)
full = ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<title>公众号文章</title></head>"
f"<body style='background:#fff;'>{doc}</body></html>")
out = args.output or re.sub(r"\.md$", ".html", args.article)
open(out, "w", encoding="utf-8").write(full)
print(f"✔ {out}(骨架 {sk} · 主题色 {color})——浏览器打开后全选复制,粘贴进公众号编辑器")
if __name__ == "__main__":
main()
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把成稿同步到微信公众号草稿箱(只写草稿,绝不群发)。
前置条件:
1. 已认证的公众号(未认证账号调用草稿接口会返回 48001)。
2. 环境变量 WECHAT_APP_ID、WECHAT_APP_SECRET(在 mp.weixin.qq.com 设置与开发→基本配置获取)。
3. 调用方公网 IP 已加入公众号后台的 IP 白名单(家用宽带 IP 变化后需回后台更新)。
用法:
python3 publish_draft.py <文章目录> [--title 标题] [--digest 摘要] [--cover 封面图] \
[--check] [--dry-run] [--author 作者]
--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 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("--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 = os.environ.get("WECHAT_APP_ID", "")
secret = os.environ.get("WECHAT_APP_SECRET", "")
# --check 只验凭证与 IP,不需要文章产物
if args.check:
if not (app_id and secret):
print("❌ 缺少环境变量 WECHAT_APP_ID / WECHAT_APP_SECRET\n"
" 获取路径:mp.weixin.qq.com → 设置与开发 → 基本配置\n"
" 设置方式:export WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy(建议写进 ~/.zshrc)\n"
" 别把密钥写进任何 git 仓库。", file=sys.stderr)
sys.exit(2)
get_token(app_id, secret) # 失败时内部已 die 并翻译错误码
print("✔ 凭证与 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):
print("❌ 缺少环境变量 WECHAT_APP_ID / WECHAT_APP_SECRET\n"
" 获取路径:mp.weixin.qq.com → 设置与开发 → 基本配置\n"
" 设置方式:export WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy(建议写进 ~/.zshrc)\n"
" 别把密钥写进任何 git 仓库。", file=sys.stderr)
sys.exit(2)
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()