| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- # -*- coding: utf-8 -*-
- """
- check-content.py — 知识库内容质量检查(只管文风与出处,不管结构)
- 为什么单独一个文件:
- kb.py lint 查的是「结构对不对」(死链、孤儿、缺字段)。结构对了不等于内容能用。
- 这个脚本查的是「内容能不能直接拿去做课」——AI 腔、长句、没出处的数字。
- 两者职责不同,混在一起以后没人敢改。参照 HowToLiveBetter 的 tools/check-plain.mjs。
- 怎么用:
- python 90-工具/check-content.py 列出全部问题,有问题退出码 1(CI / 提交前用)
- python 90-工具/check-content.py --stat 只按类型计数,不列明细
- python 90-工具/check-content.py --path 10-知识库/topics 只查某个目录
- 查三样:
- ① AI 腔 元叙述、空转加强词、段尾升华、万能开头。词表见 HARD / SOFT。
- ② 长句 超过 60 字的句子。读者是做课的同事,不是审稿人。
- ③ 没出处的数字 带 万/亿/%/倍/元 的数字,同一段落里必须能找到脚注、双链或「来源」字样。
- 做课翻车都翻在数字上,结构再完整,一个凭空来的 42% 就毁一门课。
- 不查什么(故意不查,免得误报多了没人看):
- - 「3 天 2 晚」「40 人」这类课程设计描述,它们不是统计口径,本来也不需要出处
- - 「第 3 节第 5 条」这类交叉引用
- - 代码块、表格、引用块、标题行
- """
- import sys, re, json, argparse
- from pathlib import Path
- if hasattr(sys.stdout, "reconfigure"):
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
- ROOT = Path(__file__).resolve().parent.parent
- WIKI = ROOT / "10-知识库"
- DIRS = ["sources", "topics", "entities", "modules"]
- MAX_SENT = 60
- # ① AI 腔:硬伤,见到就改。只收确实反复出现的,宁漏勿滥。
- HARD = [
- (r"本(页|文|节|章|条|条目)(主要)?(介绍|讲|说|阐述)", "元叙述"),
- (r"^以上(就)?是", "元叙述"),
- (r"(这|本)(一)?(条|页|节)(主要)?(讲|说|解决)", "元叙述"),
- (r"值得注意的是", "空转加强词"),
- (r"不难发现|不言而喻|众所周知", "空转加强词"),
- (r"总而言之|综上所述|总的来说|归根结底", "空转加强词"),
- (r"本质上(就是|是)", "空转加强词"),
- (r"(在当今|在当前)[^。]{0,20}(背景|形势|环境|时代)下", "万能开头"),
- (r"随着[^。]{0,15}的(不断|快速|飞速|持续)(发展|进步|推进)", "万能开头"),
- (r"让我们一起|未来可期|相信在不久的将来|共同拥抱", "段尾升华"),
- ]
- # ② 比喻与套话:建议改,不进退出码。它们在商业语境里有时是正常词,机器判断不了。
- SOFT = ["组合拳", "最后一公里", "护城河", "抓住抓手", "顶层设计", "颗粒度对齐",
- "形成闭环", "打通", "赋能", "生态位", "飞轮"]
- # ③ 需要出处的数字:只认统计口径的单位
- NUM_RE = re.compile(r"(\d[\d,]*(?:\.\d+)?)\s*(万|亿|%|%|倍|元)")
- SRC_MARK = re.compile(r"\[\^|\[\[|来源|出处|http|注:|待核实|推测")
- SENT_SPLIT = re.compile(r"[。!?;\n]+")
- def iter_pages(base: Path):
- for d in DIRS:
- p = base / d
- if not p.exists():
- continue
- for f in sorted(p.rglob("*.md")):
- if f.name.startswith("_"):
- continue
- yield f
- def strip_front_matter(text: str):
- if text.startswith("---"):
- m = re.match(r"^---\r?\n.*?\r?\n---\r?\n?", text, re.S)
- if m:
- return text[m.end():]
- return text
- def check_file(f: Path):
- """返回 [(kind, lineno, detail)]"""
- raw = f.read_text(encoding="utf-8", errors="replace")
- body = strip_front_matter(raw)
- offset = raw[:len(raw) - len(body)].count("\n")
- lines = body.splitlines()
- out = []
- in_code = False
- para_buf, para_start = [], 0 # 段落缓冲,用于数字出处判断
- def flush_para():
- if not para_buf:
- return
- text = " ".join(para_buf)
- has_src = bool(SRC_MARK.search(text))
- for m in NUM_RE.finditer(text):
- if not has_src:
- out.append(("数字无出处", para_start,
- f"{m.group(1)} {m.group(2)}"))
- for i, line in enumerate(lines):
- ln = offset + i + 1
- s = line.strip()
- if s.startswith("```"):
- in_code = not in_code
- flush_para(); para_buf = []
- continue
- if in_code:
- continue
- if not s:
- flush_para(); para_buf = []
- continue
- # 跳过不查的行
- if s.startswith("#") or s.startswith("|") or s.startswith(">") or s.startswith("<!--"):
- flush_para(); para_buf = []
- continue
- if not para_buf:
- para_start = ln
- para_buf.append(s)
- # ① 硬伤
- for pat, name in HARD:
- m = re.search(pat, s)
- if m:
- out.append((name, ln, f"「{m.group(0)}」"))
- # ② 软提示
- for w in SOFT:
- if w in s:
- out.append(("比喻套话", ln, f"「{w}」建议换成具体动作"))
- # ③ 长句
- for sent in SENT_SPLIT.split(s):
- sent = sent.strip(" -*0123456789.")
- n = len(re.sub(r"\s", "", sent))
- if n > MAX_SENT:
- out.append(("长句", ln, f"{n} 字(上限 {MAX_SENT}):{sent[:24]}…"))
- flush_para()
- return out
- def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--stat", action="store_true")
- ap.add_argument("--json", action="store_true")
- ap.add_argument("--path", default=None)
- a = ap.parse_args()
- base = Path(a.path) if a.path else WIKI
- if not base.is_absolute():
- base = ROOT / base
- rows, counts, nfile = [], {}, 0
- for f in iter_pages(base):
- nfile += 1
- for kind, ln, detail in check_file(f):
- rows.append({"file": f.relative_to(ROOT).as_posix(),
- "line": ln, "kind": kind, "detail": detail})
- counts[kind] = counts.get(kind, 0) + 1
- if a.json:
- print(json.dumps({"files": nfile, "issues": rows, "by_kind": counts},
- ensure_ascii=False, indent=2))
- elif a.stat:
- print(f"内容检查:{nfile} 个文件," +
- ("、".join(f"{k} {v}" for k, v in counts.items()) or "无问题"))
- else:
- for r in rows:
- print(f"{r['file']}:{r['line']} [{r['kind']}] {r['detail']}")
- print(f"\n内容检查 {nfile} 个文件,{len(rows)} 条问题:" +
- ("、".join(f"{k} {v}" for k, v in counts.items()) or "干净"))
- # 只让硬伤影响退出码;比喻套话是建议
- hard = sum(v for k, v in counts.items() if k != "比喻套话")
- return 1 if hard else 0
- if __name__ == "__main__":
- sys.exit(main())
|