le-wechat-article v1.0:Leroy 专属公众号写作工坊(类型路由 / 分层审读 / 排版转换 / 草稿箱同步)
This commit is contained in:
@@ -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="草稿模式:图位  不算错误(交付版必须去掉此开关)")
|
||||
args = ap.parse_args()
|
||||
sys.exit(check(args.article, args.type, args.strict, args.allow_placeholder))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user