#!/usr/bin/env python3 """Check required lesson sections and local Markdown links.""" from __future__ import annotations import ast import re import sys from pathlib import Path REQUIRED = ( "本节目标", "前置知识", "问题动机", "核心", "实现指南", "扩展与联系", "精选例题", "总结与自检", "课后训练建议", ) # 「课前零食」不是缩短版的正式课:它没有 OJ 例题或 C++ 附件, # 但必须有从直觉到算法的引导、可运行的小实验和可读的图示。 SNACK_REQUIRED = ( "本节目标", "前置知识", "互动规则", "从零搭框架", "问题动机", "步步式引导", "核心知识", "实现指南", "纸上模拟", "互动小实验室", "扩展与联系", "总结与自检", "一周预热安排", ) def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else "课程") errors: list[str] = [] all_documents = sorted(root.rglob("*.md")) lessons = [ path for path in all_documents if path.name != "README.md" and not path.name.startswith("00-") ] snacks = [ path for path in all_documents if path.name.startswith("00-课前零食-") ] for path in lessons + snacks: text = path.read_text(encoding="utf-8") headings = "\n".join(re.findall(r"^#{1,6}\s+(.+)$", text, flags=re.MULTILINE)) required_sections = SNACK_REQUIRED if path in snacks else REQUIRED for required in required_sections: if required not in headings: errors.append(f"{path}: missing section containing {required!r}") if "
" not in text: errors.append(f"{path}: no folded hints/self-check content") if re.search(r"```(?:cpp|c\+\+)\n.*?\bint\s+main\s*\(", text, flags=re.DOTALL): errors.append(f"{path}: standalone C++ implementation must be an attachment") if re.search(r"^\\\[\s*$|^\\\]\s*$", text, flags=re.MULTILINE): errors.append(f"{path}: display LaTeX must use $$ delimiters, not \\[ or \\]") if path in snacks: if len(text) < 4500: errors.append(f"{path}: snack is too short for an interactive scaffold") pauses = len(re.findall(r"^>\s*停顿", text, flags=re.MULTILINE)) if pauses < 3: errors.append(f"{path}: snack needs at least three prediction pauses") if text.count("
") < 5: errors.append(f"{path}: snack needs several staged feedback blocks") svg_links = re.findall(r"!\[[^]]*\]\(([^)]+\.svg)\)", text) if len(svg_links) < 2: errors.append(f"{path}: snack needs at least two SVG illustrations") python_links = re.findall(r"\]\(([^)]*互动程序/[^)]*\.py)\)", text) if not python_links: errors.append(f"{path}: snack needs a linked Python mini-lab") for target in python_links: lab = (path.parent / target).resolve() if not lab.exists(): continue # The generic local-link pass below reports this too. try: ast.parse(lab.read_text(encoding="utf-8"), filename=str(lab)) except SyntaxError as error: errors.append(f"{path}: Python mini-lab has syntax error: {error}") if "精选例题" in headings: errors.append(f"{path}: snack must not contain formal OJ examples") for path in sorted(root.rglob("*.md")): text = path.read_text(encoding="utf-8") prose = re.sub(r"```.*?```", "", text, flags=re.DOTALL) for target in re.findall(r"\[[^]]*\]\(([^)]+)\)", prose): target = target.split("#", 1)[0] if not target or re.match(r"(?:https?|mailto):", target): continue resolved = (path.parent / target).resolve() if not resolved.exists(): errors.append(f"{path}: broken local link {target}") for error in errors: print(error, file=sys.stderr) print(f"lessons={len(lessons)} snacks={len(snacks)} errors={len(errors)}") return 1 if errors else 0 if __name__ == "__main__": raise SystemExit(main())