225 lines
9.4 KiB
Python
225 lines
9.4 KiB
Python
#!/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()
|