#!/usr/bin/env python3 """内容校验 + 目录生成(只用标准库,Python 3.10+)。 用法: python3 tools/build_catalog.py # 校验并写出 catalog.json python3 tools/build_catalog.py --check # 只校验,不写文件(CI / 提交前用) python3 tools/build_catalog.py --strict # 额外要求:每个技术栈至少 1 课、1 个场景、1 份面试题 校验内容:frontmatter 字段和取值、id 唯一、prerequisites / related 指向存在的 id、 相对链接指向存在的文件、代码块围栏闭合、各类型必需的小节标题。规范见 CONTRIBUTING.md。 """ from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CONTENT_DIRS = ["plan", "topics", "scenarios"] SCHEMA_VERSION = 1 TYPES = {"index", "lesson", "scenario", "interview", "project", "week"} DOMAINS = {"backend", "devops", "python", "ai", "frontend", "cross", "plan"} LEVELS = {"basic", "intermediate", "advanced"} QUESTION_LEVELS = ["基础", "进阶", "场景题"] ID_RE = re.compile(r"^[a-z0-9]+(?:\.[a-z0-9-]+)+$") REQUIRED = {"id", "title", "type", "domain"} LIST_FIELDS = {"stack", "tags", "prerequisites", "related", "week"} REQUIRED_SECTIONS = { "lesson": ["解释说明", "案例", "代码示例", "易错点"], "scenario": ["业务背景", "约束", "方案设计", "关键代码", "取舍", "易错点", "面试怎么讲"], } FENCE_RE = re.compile(r"^\s*(```|~~~)") LINK_RE = re.compile(r"(?= 2: return v[1:-1] if re.fullmatch(r"-?\d+", v): return int(v) if re.fullmatch(r"-?\d+\.\d+", v): return float(v) if v in ("true", "false"): return v == "true" return v def parse_frontmatter(text: str) -> tuple[dict | None, str, list[str]]: """只支持规范里约定的子集:`键: 标量` 和 `键: [a, b]`(单行流式列表)。""" errs: list[str] = [] if not text.startswith("---\n"): return None, text, ["缺少 frontmatter(文件第一行必须是 ---)"] end = text.find("\n---\n", 4) if end < 0: return None, text, ["frontmatter 没有结束的 ---"] meta: dict = {} for n, line in enumerate(text[4:end].splitlines(), 2): if not line.strip() or line.lstrip().startswith("#"): continue m = re.match(r"^([a-z_]+):\s*(.*)$", line) if not m: errs.append(f"frontmatter 第 {n} 行无法解析:{line!r}") continue k, v = m.group(1), m.group(2).strip() if v.startswith("["): if not v.endswith("]"): errs.append(f"frontmatter 字段 {k} 的列表必须写在一行内") continue inner = v[1:-1].strip() meta[k] = [parse_scalar(x) for x in inner.split(",")] if inner else [] else: meta[k] = parse_scalar(v) return meta, text[end + 5:], errs def scan_body(body: str) -> tuple[bool, list[str], list[str], dict[str, int]]: """返回:围栏是否闭合、二级标题、链接目标、按级别统计的面试题数。""" in_fence, fence_mark = False, "" headings, links = [], [] counts = {k: 0 for k in QUESTION_LEVELS} for line in body.splitlines(): m = FENCE_RE.match(line) if m: if not in_fence: in_fence, fence_mark = True, m.group(1) elif m.group(1) == fence_mark and line.strip() == fence_mark: in_fence = False continue if in_fence: continue if line.startswith("## "): headings.append(line[3:].strip()) q = QUESTION_RE.match(line) if q: counts[q.group(1)] += 1 links += LINK_RE.findall(line) return not in_fence, headings, links, counts def main() -> int: check_only = "--check" in sys.argv strict = "--strict" in sys.argv errors: list[str] = [] items: list[dict] = [] files = sorted(p for d in CONTENT_DIRS for p in (ROOT / d).rglob("*.md") if "examples" not in p.relative_to(ROOT).parts and "node_modules" not in p.parts) for path in files: rel = path.relative_to(ROOT).as_posix() text = path.read_text(encoding="utf-8") meta, body, errs = parse_frontmatter(text) errors += [f"{rel}: {e}" for e in errs] if meta is None: continue for f in REQUIRED - meta.keys(): errors.append(f"{rel}: 缺少必填字段 {f}") for f in LIST_FIELDS & meta.keys(): if not isinstance(meta[f], list): errors.append(f"{rel}: 字段 {f} 必须是列表,例如 {f}: [a, b]") t = meta.get("type") if t not in TYPES: errors.append(f"{rel}: type={t!r} 不在 {sorted(TYPES)} 中") if meta.get("domain") not in DOMAINS: errors.append(f"{rel}: domain={meta.get('domain')!r} 不在 {sorted(DOMAINS)} 中") if not ID_RE.match(str(meta.get("id", ""))): errors.append(f"{rel}: id={meta.get('id')!r} 格式不对(小写字母数字,用 . 分段)") if t in ("lesson", "scenario", "interview") and meta.get("level") not in LEVELS: errors.append(f"{rel}: {t} 必须有 level({sorted(LEVELS)})") if "est_hours" in meta and not isinstance(meta["est_hours"], (int, float)): errors.append(f"{rel}: est_hours 必须是数字") for w in meta.get("week", []) if isinstance(meta.get("week"), list) else []: if not isinstance(w, int) or not 1 <= w <= 16: errors.append(f"{rel}: week 取值 {w!r} 必须是 1~16 的整数") closed, headings, links, counts = scan_body(body) if not closed: errors.append(f"{rel}: 代码块围栏没有闭合") for sec in REQUIRED_SECTIONS.get(t, []): if not any(sec in h for h in headings): errors.append(f"{rel}: {t} 缺少小节“## {sec}”") if t == "interview" and sum(counts.values()) == 0: errors.append(f"{rel}: 面试题文件没有题目(标题格式:### 【基础】问题)") for target in links: if re.match(r"^[a-z]+:", target) or target.startswith("#"): continue dest = (path.parent / target.split("#", 1)[0]).resolve() if not dest.exists(): errors.append(f"{rel}: 链接指向的文件不存在:{target}") item = { "id": meta.get("id"), "title": meta.get("title"), "type": t, "domain": meta.get("domain"), "stack": meta.get("stack", []), "tags": meta.get("tags", []), "level": meta.get("level"), "prerequisites": meta.get("prerequisites", []), "related": meta.get("related", []), "est_hours": meta.get("est_hours"), "week": meta.get("week", []), "order": meta.get("order"), "path": rel, "sections": headings, } if t == "interview": item["questions"] = counts items.append(item) ids = {} for it in items: if it["id"] in ids: errors.append(f"{it['path']}: id {it['id']} 与 {ids[it['id']]} 重复") ids[it["id"]] = it["path"] for it in items: for f in ("prerequisites", "related"): for ref in it[f] if isinstance(it[f], list) else []: if ref not in ids: errors.append(f"{it['path']}: {f} 引用了不存在的 id:{ref}") stacks = [] for it in items: if it["type"] == "index" and it["path"].startswith("topics/") and it["path"].count("/") == 3: sid = it["stack"][0] if it["stack"] else None mine = [x for x in items if x["path"].startswith(it["path"].rsplit("/", 1)[0] + "/")] q = {k: 0 for k in QUESTION_LEVELS} for x in mine: for k, v in x.get("questions", {}).items(): q[k] += v stack_dir = ROOT / it["path"].rsplit("/", 1)[0] examples = sorted(p.relative_to(ROOT).as_posix() for p in (stack_dir / "examples").glob("*")) \ if (stack_dir / "examples").exists() else [] entry = {"stack": sid, "domain": it["domain"], "title": it["title"], "index": it["path"], "lessons": sum(x["type"] == "lesson" for x in mine), "scenarios": sum(x["type"] == "scenario" for x in mine), "interview_questions": q, "examples": examples} stacks.append(entry) if strict and (entry["lessons"] == 0 or entry["scenarios"] == 0 or sum(q.values()) == 0): errors.append(f"{it['path']}: 严格模式要求技术栈 {sid} 至少有 1 课、1 个场景和面试题") stacks.sort(key=lambda s: (s["domain"], s["stack"] or "")) if errors: print(f"校验失败:{len(errors)} 个问题") for e in errors: print(" -", e) return 1 q_total = {k: sum(s["interview_questions"][k] for s in stacks) for k in QUESTION_LEVELS} print(f"校验通过:{len(items)} 个文件,{len(stacks)} 个技术栈," f"{sum(i['type'] == 'lesson' for i in items)} 课,{sum(i['type'] == 'scenario' for i in items)} 个场景," f"面试题 {sum(q_total.values())} 道({'、'.join(f'{k} {v}' for k, v in q_total.items())})") if not check_only: catalog = {"schema_version": SCHEMA_VERSION, "generated_by": "tools/build_catalog.py", "stacks": stacks, "items": sorted(items, key=lambda x: x["id"])} (ROOT / "catalog.json").write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print("已写出 catalog.json") return 0 if __name__ == "__main__": sys.exit(main())