Files
MediaCrawler/docs/gen_api_doc.py
T
butubb 556680c613
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
docs: API 接口清单(从 OpenAPI schema 生成)
58 个接口,按 auth/crawler/creator/data/monitor/settings 分组,逐条列出方法、路径、
是否需要登录、说明、参数(含类型/必填/默认)、请求体 schema;附录展开请求体的字段。

**生成的,不是手写的** —— 手写的接口文档从改代码那天起就开始腐烂,而且烂得无声无息。
`docs/gen_api_doc.py` 只读 app 对象,不连库不起服务。

两处值得说的:

* 鉴权**不能从 schema 里读** —— require_auth 是普通依赖不是 security scheme,不会进
  OpenAPI 的 security 字段。所以那份「哪些路径免登录」的名单是照着 include_router
  核的,而且 auth 那一组不能一刀切(/me 和 /password 各自另有保护)。
* 生成完**顺手验一遍表格**:参数类型里的 `integer | null` 到处是,一个没转义的竖线
  就会把整张表切歪,而 markdown 不报错、只是安静地多一格。验过这个检查确实会拦。

接口的响应没有结构可列 —— 它们返回普通 dict,没声明 response_model。
2026-10-12 08:50:53 +08:00

276 lines
11 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.
# -*- coding: utf-8 -*-
"""从 FastAPI 自己的 OpenAPI schema 生成 docs/api.md。
**为什么是生成的而不是手写的**:手写的接口文档,从改代码的那天起就开始腐烂,而且烂得
无声无息 —— 读者还会以为它是对的。这份每次跑一遍就是最新的。
./.venv/Scripts/python.exe docs/gen_api_doc.py # Windows
python docs/gen_api_doc.py # 服务器/容器里
注意这个脚本**只读** app 对象,不连数据库、不起服务。
"""
import pathlib
import re
import sys
# 让脚本能从仓库根目录跑起来(它自己不在根目录)。
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from api.main import app # noqa: E402
OUTPUT = pathlib.Path(__file__).resolve().parent / "api.md"
# **不能从 schema 里读出鉴权** —— `require_auth` 是个普通依赖,不是 security scheme,
# 所以它不会出现在 OpenAPI 的 security 字段里。这份名单是照 api/routers/ 里实际写的
# `dependencies=[Depends(require_auth)]` 核出来的:
#
# * auth 那一组整体**不加** require_auth(登录本身就不能要求先登录),但组里
# /me 和 /password 各自另有保护 —— 所以不能按「组」一刀切。
# * 其它五个 router 都在 include_router 上统一挂了 require_auth。
PUBLIC_PATHS = {
"/api/auth/login", # 登录本身
"/api/auth/logout", # 清了 cookie 就完事,没有会话也能调
}
TAG_TITLES = {
"auth": "认证",
"crawler": "采集",
"creator": "运营(博主账号)",
"data": "数据",
"monitor": "监控",
"settings": "设置",
}
TAG_ORDER = ["auth", "crawler", "creator", "data", "monitor", "settings"]
JSON_METHODS = {"post", "put", "patch"}
BACKSLASH = chr(92)
PIPE = "|"
def first_paragraph(text: str) -> str:
"""文档字符串的第一段 —— 整篇塞进去会把接口清单淹掉。"""
text = (text or "").strip()
if not text:
return ""
return text.split("\n\n")[0].strip()
def describe_schema(schema: dict, spec: dict) -> str:
"""把一个 schema 说成人话:引用就展开名字,数组说成「数组」。"""
if not schema:
return ""
if "$ref" in schema:
return schema["$ref"].rsplit("/", 1)[-1]
kind = schema.get("type", "")
if kind == "array":
inner = describe_schema(schema.get("items", {}), spec)
return f"{inner}[]"
if kind == "object":
props = schema.get("properties") or {}
return "{" + ", ".join(props.keys()) + "}" if props else "object"
if schema.get("anyOf") or schema.get("allOf"):
variants = schema.get("anyOf") or schema.get("allOf")
return " | ".join(filter(None, (describe_schema(v, spec) for v in variants)))
return kind or "?"
def param_cell(params: list, spec: dict) -> str:
if not params:
return ""
parts = []
for param in params:
name = param["name"]
where = param["in"]
kind = describe_schema(param.get("schema", {}), spec)
required = "必填" if param.get("required") else "可选"
default = param.get("schema", {}).get("default")
suffix = f"={default!r}" if default is not None else ""
# 查询参数是前端最常搞错的地方,标出来。
parts.append(f"`{name}`({where} {kind} {required}{suffix})")
return "<br>".join(parts).replace("|", "\\|")
def body_cell(op: dict, spec: dict) -> str:
body = op.get("requestBody")
if not body:
return ""
content = body.get("content", {})
schema = content.get("application/json", {}).get("schema", {})
# anyOf 会拼出 `integer | null` 这种,竖线不转义就会把这行切成两格。
return describe_schema(schema, spec).replace("|", "\\|")
def render() -> str:
spec = app.openapi()
paths = spec["paths"]
by_tag: dict[str, list] = {}
for path, operations in paths.items():
for method, op in operations.items():
if method not in ("get", "post", "put", "patch", "delete"):
continue
tag = (op.get("tags") or ["其他"])[0]
by_tag.setdefault(tag, []).append((path, method, op))
lines: list[str] = []
lines.append("# API 接口清单")
lines.append("")
lines.append("从 FastAPI 自己的 OpenAPI schema 生成的,**不是手写的** ——")
lines.append("手写的接口文档从改代码那天起就开始腐烂,而且烂得无声无息。")
lines.append("")
lines.append("重新生成:")
lines.append("")
lines.append("```bash")
lines.append("./.venv/Scripts/python.exe docs/gen_api_doc.py")
lines.append("```")
lines.append("")
lines.append("## 通用约定")
lines.append("")
lines.append("| | |")
lines.append("|---|---|")
lines.append("| 前缀 | 所有接口都在 `/api` 下(WebSocket 是 `/api/ws/...`)|")
lines.append(
"| 鉴权 | **Cookie 优先,Bearer 次之**:`token_from_request()` 先读会话 Cookie,"
"读不到再读 `Authorization: Bearer <token>`。浏览器用 Cookie,脚本用 Bearer |"
)
lines.append(
"| 会话怎么来 | `POST /api/auth/login`(密码),响应种下会话 Cookie;"
"有效期由服务端 `resolve_session()` 判定 |"
)
lines.append("| 错误形状 | FastAPI 默认的 `{\"detail\": \"...\"}`;401 是登录失效 |")
lines.append(
"| **没有 `/docs`** | 特意关掉的:`/docs`、`/redoc`、`/openapi.json` 默认"
"**不需要登录**,等于把整份接口地图连同「Try it out」一起送出去。要看交互式的,"
"得挂在鉴权后面 |"
)
lines.append(
"| WebSocket | `GET /api/ws/logs`(升级为 ws)—— 实时日志流。**不在这里的清单里**:"
"OpenAPI 描述不了 WebSocket。它自己用 `require_ws_auth` 校验会话 Cookie |"
)
lines.append("")
lines.append("## 目录")
lines.append("")
for tag in TAG_ORDER:
if tag in by_tag:
title = TAG_TITLES.get(tag, tag)
lines.append(f"- [{title}](#{title})({len(by_tag[tag])} 个)")
lines.append("")
for tag in TAG_ORDER:
if tag not in by_tag:
continue
title = TAG_TITLES.get(tag, tag)
lines.append(f"## {title}")
lines.append("")
if tag == "auth":
lines.append(
"这一组**没有**统一挂 `require_auth`(登录不能要求先登录),所以逐个标。"
)
lines.append("")
lines.append("| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 |")
lines.append("|---|---|:--:|---|---|---|")
for path, method, op in sorted(by_tag[tag], key=lambda item: (item[0], item[1])):
summary = first_paragraph(op.get("description") or op.get("summary") or "")
# 表格单元格里的换行/竖线会把表格撑破。
summary = summary.replace("\n", " ").replace("|", "\\|")
params = [p for p in op.get("parameters", []) if p["in"] != "header"]
lines.append(
"| `{}` | `{}` | {} | {} | {} | {} |".format(
method.upper(),
path,
"—" if path in PUBLIC_PATHS else "✓",
summary,
param_cell(params, spec),
body_cell(op, spec),
)
)
lines.append("")
# ---- 附录:请求体长什么样 ------------------------------------------------
#
# 上面那些接口的「请求体」列只写了 schema 的名字,不写字段的话等于没说。
used = set()
for operations in paths.values():
for method, op in operations.items():
if method in JSON_METHODS:
body = op.get("requestBody")
if body:
name = describe_schema(
body.get("content", {}).get("application/json", {}).get("schema", {}),
spec,
)
if name:
used.add(name)
schemas = spec.get("components", {}).get("schemas", {})
if used:
lines.append("## 请求体结构")
lines.append("")
for name in sorted(used):
schema = schemas.get(name)
if not schema:
continue
lines.append(f"### {name}")
lines.append("")
lines.append("| 字段 | 类型 | 必填 | 默认 | 说明 |")
lines.append("|---|---|:--:|---|---|")
required = set(schema.get("required") or [])
for field, prop in (schema.get("properties") or {}).items():
kind = describe_schema(prop, spec).replace("|", "\\|")
default = prop.get("default")
desc = (prop.get("description") or "").replace("\n", " ").replace("|", "\\|")
lines.append(
"| `{}` | {} | {} | {} | {} |".format(
field,
kind,
"✓" if field in required else "",
"" if default is None else repr(default),
desc,
)
)
lines.append("")
lines.append(
"> **响应没有结构可列。** 这些接口返回的是普通 dict,没有声明 "
"`response_model`,所以 OpenAPI 里没有响应的 shape。要看响应就调一次,"
"或者读对应的 `service.py`。"
)
lines.append("")
return "\n".join(lines) + "\n"
def check_tables(text: str) -> None:
"""生成完顺手验一遍表格。
一个没转义的竖线就能把整张表切歪 —— 而 markdown 不会报错,它只是安静地多出一格。
参数类型里的 `integer | null` 到处都是,所以这不是假想的风险。
"""
escaped = BACKSLASH + PIPE
sentinel = "\x00"
expected = {2, 5, 6} # 约定表 2、请求体表 5、接口表 6
bad = []
for number, line in enumerate(text.splitlines(), 1):
if not line.lstrip().startswith(PIPE):
continue
cells = line.strip().replace(escaped, sentinel).split(PIPE)[1:-1]
if len(cells) not in expected:
bad.append((number, len(cells), line[:70]))
if bad:
for number, count, snippet in bad[:5]:
print(f" 第 {number} 行有 {count} 格(预期 {sorted(expected)}): {snippet}")
raise SystemExit("生成的表格结构不对,先修转义")
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
text = render()
check_tables(text)
OUTPUT.write_text(text, encoding="utf-8")
print(f"写出 {OUTPUT}({len(text.splitlines())} 行)")