Files
MetaSkill/skills/le-wechat-article/scripts/check_article.py
T

225 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()