#!/usr/bin/env python3 # -*- coding: utf-8 -*- """公众号文章自检:机器能量化的硬规则与产出配额,不靠眼睛。 用法:python3 check_article.py [--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() fm_pre = re.match(r"^---\n.*?\n---\n", md, re.S) blocks = split_blocks(md[fm_pre.end():] if fm_pre else 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()