check-content.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. # -*- coding: utf-8 -*-
  2. """
  3. check-content.py — 知识库内容质量检查(只管文风与出处,不管结构)
  4. 为什么单独一个文件:
  5. kb.py lint 查的是「结构对不对」(死链、孤儿、缺字段)。结构对了不等于内容能用。
  6. 这个脚本查的是「内容能不能直接拿去做课」——AI 腔、长句、没出处的数字。
  7. 两者职责不同,混在一起以后没人敢改。参照 HowToLiveBetter 的 tools/check-plain.mjs。
  8. 怎么用:
  9. python 90-工具/check-content.py 列出全部问题,有问题退出码 1(CI / 提交前用)
  10. python 90-工具/check-content.py --stat 只按类型计数,不列明细
  11. python 90-工具/check-content.py --path 10-知识库/topics 只查某个目录
  12. 查三样:
  13. ① AI 腔 元叙述、空转加强词、段尾升华、万能开头。词表见 HARD / SOFT。
  14. ② 长句 超过 60 字的句子。读者是做课的同事,不是审稿人。
  15. ③ 没出处的数字 带 万/亿/%/倍/元 的数字,同一段落里必须能找到脚注、双链或「来源」字样。
  16. 做课翻车都翻在数字上,结构再完整,一个凭空来的 42% 就毁一门课。
  17. 不查什么(故意不查,免得误报多了没人看):
  18. - 「3 天 2 晚」「40 人」这类课程设计描述,它们不是统计口径,本来也不需要出处
  19. - 「第 3 节第 5 条」这类交叉引用
  20. - 代码块、表格、引用块、标题行
  21. """
  22. import sys, re, json, argparse
  23. from pathlib import Path
  24. if hasattr(sys.stdout, "reconfigure"):
  25. sys.stdout.reconfigure(encoding="utf-8", errors="replace")
  26. ROOT = Path(__file__).resolve().parent.parent
  27. WIKI = ROOT / "10-知识库"
  28. DIRS = ["sources", "topics", "entities", "modules"]
  29. MAX_SENT = 60
  30. # ① AI 腔:硬伤,见到就改。只收确实反复出现的,宁漏勿滥。
  31. HARD = [
  32. (r"本(页|文|节|章|条|条目)(主要)?(介绍|讲|说|阐述)", "元叙述"),
  33. (r"^以上(就)?是", "元叙述"),
  34. (r"(这|本)(一)?(条|页|节)(主要)?(讲|说|解决)", "元叙述"),
  35. (r"值得注意的是", "空转加强词"),
  36. (r"不难发现|不言而喻|众所周知", "空转加强词"),
  37. (r"总而言之|综上所述|总的来说|归根结底", "空转加强词"),
  38. (r"本质上(就是|是)", "空转加强词"),
  39. (r"(在当今|在当前)[^。]{0,20}(背景|形势|环境|时代)下", "万能开头"),
  40. (r"随着[^。]{0,15}的(不断|快速|飞速|持续)(发展|进步|推进)", "万能开头"),
  41. (r"让我们一起|未来可期|相信在不久的将来|共同拥抱", "段尾升华"),
  42. ]
  43. # ② 比喻与套话:建议改,不进退出码。它们在商业语境里有时是正常词,机器判断不了。
  44. SOFT = ["组合拳", "最后一公里", "护城河", "抓住抓手", "顶层设计", "颗粒度对齐",
  45. "形成闭环", "打通", "赋能", "生态位", "飞轮"]
  46. # ③ 需要出处的数字:只认统计口径的单位
  47. NUM_RE = re.compile(r"(\d[\d,]*(?:\.\d+)?)\s*(万|亿|%|%|倍|元)")
  48. SRC_MARK = re.compile(r"\[\^|\[\[|来源|出处|http|注:|待核实|推测")
  49. SENT_SPLIT = re.compile(r"[。!?;\n]+")
  50. def iter_pages(base: Path):
  51. for d in DIRS:
  52. p = base / d
  53. if not p.exists():
  54. continue
  55. for f in sorted(p.rglob("*.md")):
  56. if f.name.startswith("_"):
  57. continue
  58. yield f
  59. def strip_front_matter(text: str):
  60. if text.startswith("---"):
  61. m = re.match(r"^---\r?\n.*?\r?\n---\r?\n?", text, re.S)
  62. if m:
  63. return text[m.end():]
  64. return text
  65. def check_file(f: Path):
  66. """返回 [(kind, lineno, detail)]"""
  67. raw = f.read_text(encoding="utf-8", errors="replace")
  68. body = strip_front_matter(raw)
  69. offset = raw[:len(raw) - len(body)].count("\n")
  70. lines = body.splitlines()
  71. out = []
  72. in_code = False
  73. para_buf, para_start = [], 0 # 段落缓冲,用于数字出处判断
  74. def flush_para():
  75. if not para_buf:
  76. return
  77. text = " ".join(para_buf)
  78. has_src = bool(SRC_MARK.search(text))
  79. for m in NUM_RE.finditer(text):
  80. if not has_src:
  81. out.append(("数字无出处", para_start,
  82. f"{m.group(1)} {m.group(2)}"))
  83. for i, line in enumerate(lines):
  84. ln = offset + i + 1
  85. s = line.strip()
  86. if s.startswith("```"):
  87. in_code = not in_code
  88. flush_para(); para_buf = []
  89. continue
  90. if in_code:
  91. continue
  92. if not s:
  93. flush_para(); para_buf = []
  94. continue
  95. # 跳过不查的行
  96. if s.startswith("#") or s.startswith("|") or s.startswith(">") or s.startswith("<!--"):
  97. flush_para(); para_buf = []
  98. continue
  99. if not para_buf:
  100. para_start = ln
  101. para_buf.append(s)
  102. # ① 硬伤
  103. for pat, name in HARD:
  104. m = re.search(pat, s)
  105. if m:
  106. out.append((name, ln, f"「{m.group(0)}」"))
  107. # ② 软提示
  108. for w in SOFT:
  109. if w in s:
  110. out.append(("比喻套话", ln, f"「{w}」建议换成具体动作"))
  111. # ③ 长句
  112. for sent in SENT_SPLIT.split(s):
  113. sent = sent.strip(" -*0123456789.")
  114. n = len(re.sub(r"\s", "", sent))
  115. if n > MAX_SENT:
  116. out.append(("长句", ln, f"{n} 字(上限 {MAX_SENT}):{sent[:24]}…"))
  117. flush_para()
  118. return out
  119. def main():
  120. ap = argparse.ArgumentParser()
  121. ap.add_argument("--stat", action="store_true")
  122. ap.add_argument("--json", action="store_true")
  123. ap.add_argument("--path", default=None)
  124. a = ap.parse_args()
  125. base = Path(a.path) if a.path else WIKI
  126. if not base.is_absolute():
  127. base = ROOT / base
  128. rows, counts, nfile = [], {}, 0
  129. for f in iter_pages(base):
  130. nfile += 1
  131. for kind, ln, detail in check_file(f):
  132. rows.append({"file": f.relative_to(ROOT).as_posix(),
  133. "line": ln, "kind": kind, "detail": detail})
  134. counts[kind] = counts.get(kind, 0) + 1
  135. if a.json:
  136. print(json.dumps({"files": nfile, "issues": rows, "by_kind": counts},
  137. ensure_ascii=False, indent=2))
  138. elif a.stat:
  139. print(f"内容检查:{nfile} 个文件," +
  140. ("、".join(f"{k} {v}" for k, v in counts.items()) or "无问题"))
  141. else:
  142. for r in rows:
  143. print(f"{r['file']}:{r['line']} [{r['kind']}] {r['detail']}")
  144. print(f"\n内容检查 {nfile} 个文件,{len(rows)} 条问题:" +
  145. ("、".join(f"{k} {v}" for k, v in counts.items()) or "干净"))
  146. # 只让硬伤影响退出码;比喻套话是建议
  147. hard = sum(v for k, v in counts.items() if k != "比喻套话")
  148. return 1 if hard else 0
  149. if __name__ == "__main__":
  150. sys.exit(main())