check_course_docs.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. #!/usr/bin/env python3
  2. """Check required lesson sections and local Markdown links."""
  3. from __future__ import annotations
  4. import ast
  5. import re
  6. import sys
  7. from pathlib import Path
  8. REQUIRED = (
  9. "本节目标",
  10. "前置知识",
  11. "问题动机",
  12. "核心",
  13. "实现指南",
  14. "扩展与联系",
  15. "精选例题",
  16. "总结与自检",
  17. "课后训练建议",
  18. )
  19. # 「课前零食」不是缩短版的正式课:它没有 OJ 例题或 C++ 附件,
  20. # 但必须有从直觉到算法的引导、可运行的小实验和可读的图示。
  21. SNACK_REQUIRED = (
  22. "本节目标",
  23. "前置知识",
  24. "互动规则",
  25. "从零搭框架",
  26. "问题动机",
  27. "步步式引导",
  28. "核心知识",
  29. "实现指南",
  30. "纸上模拟",
  31. "互动小实验室",
  32. "扩展与联系",
  33. "总结与自检",
  34. "一周预热安排",
  35. )
  36. def main() -> int:
  37. root = Path(sys.argv[1] if len(sys.argv) > 1 else "课程")
  38. errors: list[str] = []
  39. all_documents = sorted(root.rglob("*.md"))
  40. lessons = [
  41. path
  42. for path in all_documents
  43. if path.name != "README.md" and not path.name.startswith("00-")
  44. ]
  45. snacks = [
  46. path for path in all_documents if path.name.startswith("00-课前零食-")
  47. ]
  48. for path in lessons + snacks:
  49. text = path.read_text(encoding="utf-8")
  50. headings = "\n".join(re.findall(r"^#{1,6}\s+(.+)$", text, flags=re.MULTILINE))
  51. required_sections = SNACK_REQUIRED if path in snacks else REQUIRED
  52. for required in required_sections:
  53. if required not in headings:
  54. errors.append(f"{path}: missing section containing {required!r}")
  55. if "<details>" not in text:
  56. errors.append(f"{path}: no folded hints/self-check content")
  57. if re.search(r"```(?:cpp|c\+\+)\n.*?\bint\s+main\s*\(", text, flags=re.DOTALL):
  58. errors.append(f"{path}: standalone C++ implementation must be an attachment")
  59. if re.search(r"^\\\[\s*$|^\\\]\s*$", text, flags=re.MULTILINE):
  60. errors.append(f"{path}: display LaTeX must use $$ delimiters, not \\[ or \\]")
  61. if path in snacks:
  62. if len(text) < 4500:
  63. errors.append(f"{path}: snack is too short for an interactive scaffold")
  64. pauses = len(re.findall(r"^>\s*停顿", text, flags=re.MULTILINE))
  65. if pauses < 3:
  66. errors.append(f"{path}: snack needs at least three prediction pauses")
  67. if text.count("<details>") < 5:
  68. errors.append(f"{path}: snack needs several staged feedback blocks")
  69. svg_links = re.findall(r"!\[[^]]*\]\(([^)]+\.svg)\)", text)
  70. if len(svg_links) < 2:
  71. errors.append(f"{path}: snack needs at least two SVG illustrations")
  72. python_links = re.findall(r"\]\(([^)]*互动程序/[^)]*\.py)\)", text)
  73. if not python_links:
  74. errors.append(f"{path}: snack needs a linked Python mini-lab")
  75. for target in python_links:
  76. lab = (path.parent / target).resolve()
  77. if not lab.exists():
  78. continue # The generic local-link pass below reports this too.
  79. try:
  80. ast.parse(lab.read_text(encoding="utf-8"), filename=str(lab))
  81. except SyntaxError as error:
  82. errors.append(f"{path}: Python mini-lab has syntax error: {error}")
  83. if "精选例题" in headings:
  84. errors.append(f"{path}: snack must not contain formal OJ examples")
  85. for path in sorted(root.rglob("*.md")):
  86. text = path.read_text(encoding="utf-8")
  87. prose = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
  88. for target in re.findall(r"\[[^]]*\]\(([^)]+)\)", prose):
  89. target = target.split("#", 1)[0]
  90. if not target or re.match(r"(?:https?|mailto):", target):
  91. continue
  92. resolved = (path.parent / target).resolve()
  93. if not resolved.exists():
  94. errors.append(f"{path}: broken local link {target}")
  95. for error in errors:
  96. print(error, file=sys.stderr)
  97. print(f"lessons={len(lessons)} snacks={len(snacks)} errors={len(errors)}")
  98. return 1 if errors else 0
  99. if __name__ == "__main__":
  100. raise SystemExit(main())