| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112 |
- #!/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 "<details>" 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("<details>") < 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())
|