Files
py.guanjihuan.com/website/tutorials.py
2026-11-02 17:00:28 +08:00

273 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""读取 tutorials/ 目录,为网站提供「教程」页的内容。
教程内容不手工维护:分组、标题、说明、依赖都从 tutorials/ 下的脚本
与其 README.md 里读出来。仓库里新增一个示例脚本,网站自动跟着更新。
信息来自两处,互相补充:
* 每个脚本开头的注释——标题、来源链接、一句话说明
* tutorials/README.md 里的表格——更详细的说明与依赖说明
两处都没有时,退回到按文件名与 import 推断,保证页面不为空。
"""
from __future__ import annotations
import re
from pathlib import Path
# 「# 来源 / Source: https://...」
SOURCE_RE = re.compile(r"来源\s*/\s*Source\s*[::]\s*(\S+)")
# README 表格里的一行:| `01_ai_chat.py` | 说明 | 依赖 |
TABLE_ROW_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*$")
# 脚本里的 import,用来猜依赖
IMPORT_RE = re.compile(r"^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE)
# 根目录下的脚本(不在子目录里)单独归为一组
ROOT_GROUP_TITLE = "开始使用"
ROOT_GROUP_SUBTITLE = "Getting started"
def _read_text(path: Path) -> str:
"""读文本文件,编码不对时退回 latin-1,绝不抛异常。"""
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
try:
return path.read_text(encoding="gbk")
except (OSError, UnicodeDecodeError):
return ""
def _split_heading(heading: str) -> tuple[str, str]:
"""把「一、日常场景 / Daily scenarios」拆成中文标题与英文副标题。"""
parts = re.split(r"\s*/\s*", heading.strip(), maxsplit=1)
title = parts[0].strip()
subtitle = parts[1].strip() if len(parts) > 1 else ""
return title, subtitle
def parse_readme(readme_path: Path) -> dict:
"""解析 tutorials/README.md。
返回:
files 文件名 -> {desc, req, section}
sections [(标题, 副标题, [文件名...])]
"""
files: dict[str, dict] = {}
sections: list[tuple[str, str, list[str]]] = []
if not readme_path.exists():
return {"files": files, "sections": sections}
section_title = ""
section_subtitle = ""
section_files: list[str] = []
def flush() -> None:
if section_title or section_files:
sections.append((section_title, section_subtitle, list(section_files)))
for line in _read_text(readme_path).splitlines():
if line.startswith("## "):
flush()
section_title, section_subtitle = _split_heading(line[3:])
section_files = []
continue
match = TABLE_ROW_RE.match(line.strip())
if not match:
continue
name, desc, req = match.groups()
files[name] = {
"desc": desc.strip(),
"req": req.strip(),
"section": section_title,
}
section_files.append(name)
flush()
return {"files": files, "sections": sections}
def parse_script(path: Path) -> dict:
"""读脚本开头的注释块。
约定:文件开头连续的注释里,第一行是标题,含「来源 / Source」的行是出处,
其余行是补充说明。注释块在遇到第一行代码时结束。
"""
title = ""
source = ""
notes: list[str] = []
for line in _read_text(path).splitlines():
stripped = line.strip()
if not stripped:
continue
if not stripped.startswith("#"):
break
text = stripped.lstrip("#").strip()
if not text:
continue
match = SOURCE_RE.search(text)
if match:
source = match.group(1)
continue
if not title:
title = text
continue
notes.append(text)
return {"title": title, "source": source, "notes": notes}
def detect_requirements(code: str) -> list[str]:
"""从 import 语句推断依赖(README 里没写时用)。"""
names = []
for name in IMPORT_RE.findall(code):
if name in ("guan", "os", "sys", "time", "json", "re", "math", "cmath", "random"):
continue
if name not in names:
names.append(name)
return names
def _short_title(description: str) -> str:
"""从一段说明里取出适合当标题的短句。
README 的说明常常是「try except 简写:`@guan.try_decorator`」这样,
标题取第一个短句就够了,剩下的留给说明。
"""
text = description.strip()
if not text:
return ""
head = re.split(r"[::,,。;;]", text, maxsplit=1)[0].strip()
head = head.replace("`", "").strip()
return head or text.replace("`", "").strip()
def _requirements_from_cell(cell: str) -> list[str]:
"""把 README 表格里的依赖单元格拆成列表。
只按逗号、顿号、分号拆——单元格里的斜杠常常是路径或模型名的一部分,
拆开反而看不懂。
"""
if not cell or cell.strip() in {"-", "—", "无", "N/A"}:
return []
parts = re.split(r"[,,、;;]", cell)
return [part.strip(" `·") for part in parts if part.strip(" `·")]
def collect(tutorials_dir: Path) -> dict:
"""收集 tutorials 目录下的全部示例脚本。
返回值可以直接放进 api.json,也可以直接渲染成页面。
"""
tutorials_dir = Path(tutorials_dir).resolve()
if not tutorials_dir.is_dir():
return {"dir": tutorials_dir.name, "readme": "", "groups": [], "files": [], "count": 0}
readme = tutorials_dir / "README.md"
readme_info = parse_readme(readme)
scripts = sorted(
path for path in tutorials_dir.rglob("*.py") if path.is_file()
)
groups: dict[str, dict] = {}
files: list[dict] = []
for path in scripts:
relative = path.relative_to(tutorials_dir)
group_key = relative.parts[0] if len(relative.parts) > 1 else ""
info = parse_script(path)
code = _read_text(path)
entry = readme_info["files"].get(path.name, {})
# 标题与说明都来自脚本自身:首行注释是标题,其余注释是说明。
# README 的表格只用来补标题与依赖,不再重复它写的那段说明。
title = info["title"] or _short_title(entry.get("desc", "")) or relative.name
description = ";".join(info["notes"])
requirements = _requirements_from_cell(entry.get("req", ""))
if not requirements:
requirements = detect_requirements(code)
# 链接用相对仓库根目录的路径,便于站点里直接下载脚本
repo_relative = (Path(tutorials_dir.name) / relative).as_posix()
anchor = f"{group_key or 'root'}-{relative.stem}".replace("_", "-")
item = {
"key": relative.as_posix(),
"path": repo_relative,
"group": group_key,
"title": title,
"description": description,
"requirements": requirements,
"source": info["source"],
"anchor": anchor,
"lines": len(code.splitlines()),
"code": code,
}
files.append(item)
group = groups.setdefault(
group_key,
{
"key": group_key,
"title": ROOT_GROUP_TITLE if group_key == "" else group_key.replace("_", " "),
"subtitle": ROOT_GROUP_SUBTITLE if group_key == "" else "",
"anchor": f"group-{group_key or 'root'}",
"files": [],
},
)
group["files"].append(item)
# 分组标题优先用 README 里的章节标题
for key, group in groups.items():
if not key:
continue
best: tuple[str, str, int] | None = None
for title, subtitle, names in readme_info["sections"]:
overlap = sum(
1 for item in group["files"] if Path(item["key"]).name in names
)
if overlap and (best is None or overlap > best[2]):
best = (title, subtitle, overlap)
if best:
group["title"], group["subtitle"] = best[0], best[1]
ordered = sorted(
groups.values(),
key=lambda group: (group["key"] != "", group["title"]),
)
return {
"dir": tutorials_dir.name,
"readme": (tutorials_dir / "README.md").name if readme.exists() else "",
"groups": ordered,
"files": files,
"count": len(files),
}
def without_code(data: dict) -> dict:
"""去掉源码的副本,用于放进 api.json(索引里用不着整段代码)。"""
return {
"dir": data.get("dir", ""),
"count": data.get("count", 0),
"groups": [
{
"key": group["key"],
"title": group["title"],
"subtitle": group.get("subtitle", ""),
"anchor": group["anchor"],
"files": [
{key: value for key, value in item.items() if key != "code"}
for item in group["files"]
],
}
for group in data.get("groups", [])
],
"files": [
{key: value for key, value in item.items() if key != "code"}
for item in data.get("files", [])
],
}