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()