From 556680c6135b9753b1be73cb93a700a9ddf023b6 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Mon, 12 Oct 2026 08:50:53 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20API=20=E6=8E=A5=E5=8F=A3=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=88=E4=BB=8E=20OpenAPI=20schema=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- docs/api.md | 257 +++++++++++++++++++++++++++++++++++++++++ docs/gen_api_doc.py | 275 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 docs/api.md create mode 100644 docs/gen_api_doc.py diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..37cabfe --- /dev/null +++ b/docs/api.md @@ -0,0 +1,257 @@ +# API 接口清单 + +从 FastAPI 自己的 OpenAPI schema 生成的,**不是手写的** —— +手写的接口文档从改代码那天起就开始腐烂,而且烂得无声无息。 + +重新生成: + +```bash +./.venv/Scripts/python.exe docs/gen_api_doc.py +``` + +## 通用约定 + +| | | +|---|---| +| 前缀 | 所有接口都在 `/api` 下(WebSocket 是 `/api/ws/...`)| +| 鉴权 | **Cookie 优先,Bearer 次之**:`token_from_request()` 先读会话 Cookie,读不到再读 `Authorization: Bearer `。浏览器用 Cookie,脚本用 Bearer | +| 会话怎么来 | `POST /api/auth/login`(密码),响应种下会话 Cookie;有效期由服务端 `resolve_session()` 判定 | +| 错误形状 | FastAPI 默认的 `{"detail": "..."}`;401 是登录失效 | +| **没有 `/docs`** | 特意关掉的:`/docs`、`/redoc`、`/openapi.json` 默认**不需要登录**,等于把整份接口地图连同「Try it out」一起送出去。要看交互式的,得挂在鉴权后面 | +| WebSocket | `GET /api/ws/logs`(升级为 ws)—— 实时日志流。**不在这里的清单里**:OpenAPI 描述不了 WebSocket。它自己用 `require_ws_auth` 校验会话 Cookie | + +## 目录 + +- [认证](#认证)(4 个) +- [采集](#采集)(4 个) +- [运营(博主账号)](#运营(博主账号))(8 个) +- [数据](#数据)(4 个) +- [监控](#监控)(36 个) +- [设置](#设置)(2 个) + +## 认证 + +这一组**没有**统一挂 `require_auth`(登录不能要求先登录),所以逐个标。 + +| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 | +|---|---|:--:|---|---|---| +| `POST` | `/api/auth/login` | — | Login | | LoginPayload | +| `POST` | `/api/auth/logout` | — | Logout | | | +| `GET` | `/api/auth/me` | ✓ | Identity probe. The SPA treats a 401 here as "show the login page". | | | +| `POST` | `/api/auth/password` | ✓ | Change the password and log every device out. | | ChangePasswordPayload | + +## 采集 + +| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 | +|---|---|:--:|---|---|---| +| `GET` | `/api/crawler/logs` | ✓ | Get recent logs | `limit`(query integer 可选=100) | | +| `POST` | `/api/crawler/start` | ✓ | Start crawler task. | | CrawlerStartRequest | +| `GET` | `/api/crawler/status` | ✓ | Get crawler status | | | +| `POST` | `/api/crawler/stop` | ✓ | Stop crawler task | | | + +## 运营(博主账号) + +| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 | +|---|---|:--:|---|---|---| +| `GET` | `/api/creator/accounts` | ✓ | 账号列表。**不含 cookie**,只给 ``has_cookie``。 | | | +| `DELETE` | `/api/creator/accounts/{account_id}` | ✓ | Delete Account | `account_id`(path integer 必填) | | +| `GET` | `/api/creator/accounts/{account_id}` | ✓ | Get Account Detail | `account_id`(path integer 必填) | | +| `POST` | `/api/creator/accounts/{account_id}/check` | ✓ | 重测登录态与数据权限。 | `account_id`(path integer 必填) | | +| `POST` | `/api/creator/accounts/{account_id}/sync` | ✓ | 拉取作品数据。 | `account_id`(path integer 必填)
`days`(query integer 可选=90) | | +| `DELETE` | `/api/creator/login` | ✓ | Cancel Login | | | +| `GET` | `/api/creator/login` | ✓ | 轮询扫码结果;一旦成功就把账号落库并返回它。 | | | +| `POST` | `/api/creator/login` | ✓ | Start Login | | | + +## 数据 + +| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 | +|---|---|:--:|---|---|---| +| `GET` | `/api/data/download/{file_path}` | ✓ | Download file | `file_path`(path string 必填) | | +| `GET` | `/api/data/files` | ✓ | Get data file list | `platform`(query string \| null 可选)
`file_type`(query string \| null 可选) | | +| `GET` | `/api/data/files/{file_path}` | ✓ | Get file content or preview | `file_path`(path string 必填)
`preview`(query boolean 可选=True)
`limit`(query integer 可选=100) | | +| `GET` | `/api/data/stats` | ✓ | Get data statistics | | | + +## 监控 + +| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 | +|---|---|:--:|---|---|---| +| `GET` | `/api/monitor/comment-notes` | ✓ | Works that have comments, newest first, with counts. | `task_id`(query integer \| null 可选)
`platform`(query string \| null 可选) | | +| `GET` | `/api/monitor/comments` | ✓ | Comments, each carrying the work it belongs to. | `task_id`(query integer \| null 可选)
`note_id`(query string \| null 可选)
`group_by`(query string \| null 可选)
`limit`(query integer 可选=200)
`platform`(query string \| null 可选) | | +| `DELETE` | `/api/monitor/cookie` | ✓ | Clear Cookie Endpoint | `platform`(query string 可选='xhs') | | +| `GET` | `/api/monitor/cookie` | ✓ | Cookie health only -- deliberately never returns the cookie value. | `platform`(query string 可选='xhs') | | +| `POST` | `/api/monitor/cookie` | ✓ | Set Cookie Endpoint | `platform`(query string 可选='xhs') | CookiePayload | +| `GET` | `/api/monitor/covers/{note_id}` | ✓ | 作品封面,从本地缓存读。 | `note_id`(path string 必填) | | +| `PUT` | `/api/monitor/creators/{creator_hash}` | ✓ | 给博主起个备注(界面上的「备注」)。 | `creator_hash`(path string 必填)
`platform`(query string 可选='xhs') | CreatorAliasPayload | +| `GET` | `/api/monitor/events` | ✓ | List Events | `task_id`(query integer \| null 可选)
`type`(query string \| null 可选)
`since_id`(query integer \| null 可选)
`limit`(query integer 可选=200)
`platform`(query string \| null 可选) | | +| `POST` | `/api/monitor/events/read` | ✓ | Mark Events Read | `task_id`(query integer \| null 可选) | | +| `GET` | `/api/monitor/export` | ✓ | Download collected data as CSV or Excel. | `kind`(query string 必填)
`task_id`(query integer[] \| null 可选)
`note_id`(query string \| null 可选)
`start_date`(query string \| null 可选)
`end_date`(query string \| null 可选)
`days`(query integer 可选=7)
`format`(query string 可选='csv')
`platform`(query string \| null 可选) | | +| `DELETE` | `/api/monitor/login/qr` | ✓ | Drop our tab and stop polling. | | | +| `GET` | `/api/monitor/login/qr` | ✓ | Poll the live session: waiting -> success / expired / error. | | | +| `POST` | `/api/monitor/login/qr` | ✓ | Open the login page in the CDP browser and return its QR code. | `platform`(query string 可选='xhs') | | +| `GET` | `/api/monitor/login/state` | ✓ | Ask the browser itself whether it is signed in. | `force`(query boolean 可选=False) | | +| `GET` | `/api/monitor/notes` | ✓ | List Notes | `task_id`(query integer \| null 可选)
`only_new`(query boolean 可选=False)
`limit`(query integer 可选=200)
`platform`(query string \| null 可选)
`tag_id`(query integer[] \| null 可选) | | +| `PUT` | `/api/monitor/notes/{note_id}` | ✓ | 给**作品**起个备注。 | `note_id`(path string 必填)
`platform`(query string 可选='xhs') | NoteAliasPayload | +| `GET` | `/api/monitor/notes/{note_id}/series` | ✓ | Metric time series for a single note. | `note_id`(path string 必填)
`task_id`(query integer \| null 可选) | | +| `PUT` | `/api/monitor/notes/{note_id}/tags` | ✓ | 把一条作品的标签**整体替换**成这一组(空列表 = 全摘掉)。 | `note_id`(path string 必填)
`platform`(query string 可选='xhs') | NoteTagsPayload | +| `GET` | `/api/monitor/overview` | ✓ | Headline numbers for the dashboard tiles, scoped to one platform. | `platform`(query string \| null 可选) | | +| `GET` | `/api/monitor/report` | ✓ | Daily new-content counts and interaction deltas for the selected tasks. | `task_id`(query integer[] \| null 可选)
`start_date`(query string \| null 可选)
`end_date`(query string \| null 可选)
`days`(query integer 可选=7)
`platform`(query string \| null 可选) | | +| `GET` | `/api/monitor/tags` | ✓ | 标签词表 + 各自被用在多少条作品上(删之前能看清代价)。 | | | +| `POST` | `/api/monitor/tags` | ✓ | Create Tag | | TagPayload | +| `DELETE` | `/api/monitor/tags/{tag_id}` | ✓ | 删标签,连同它在所有作品上的贴纸。 | `tag_id`(path integer 必填) | | +| `PUT` | `/api/monitor/tags/{tag_id}` | ✓ | Update Tag | `tag_id`(path integer 必填) | TagPayload | +| `GET` | `/api/monitor/tasks` | ✓ | List Tasks | `platform`(query string \| null 可选) | | +| `POST` | `/api/monitor/tasks` | ✓ | Create Task | | MonitorTaskCreate | +| `DELETE` | `/api/monitor/tasks/{task_id}` | ✓ | Delete Task | `task_id`(path integer 必填) | | +| `PATCH` | `/api/monitor/tasks/{task_id}` | ✓ | Update Task | `task_id`(path integer 必填) | MonitorTaskUpdate | +| `POST` | `/api/monitor/tasks/{task_id}/run` | ✓ | Queue a run immediately and return; the crawl itself takes minutes. | `task_id`(path integer 必填) | | +| `GET` | `/api/monitor/tasks/{task_id}/runs` | ✓ | List Runs | `task_id`(path integer 必填)
`limit`(query integer 可选=50) | | +| `GET` | `/api/monitor/upstream` | ✓ | 最近一次上游检查的结果。 | | | +| `POST` | `/api/monitor/upstream/check` | ✓ | 立刻检查一次上游仓库,并把结果写回缓存。 | | | +| `DELETE` | `/api/monitor/webhook` | ✓ | Clear Webhook | | | +| `GET` | `/api/monitor/webhook` | ✓ | Get Webhook | | | +| `POST` | `/api/monitor/webhook` | ✓ | Set Webhook | | WebhookPayload | +| `POST` | `/api/monitor/webhook/test` | ✓ | Send a test message so the user can verify the robot works before relying on it. | | WebhookTestPayload | + +## 设置 + +| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 | +|---|---|:--:|---|---|---| +| `GET` | `/api/settings` | ✓ | Settings for one platform, plus the system-wide ones. | `platform`(query string 可选='xhs') | | +| `PUT` | `/api/settings` | ✓ | Partial update: only the keys present in the body are written. | `platform`(query string 可选='xhs') | SettingsUpdatePayload | + +## 请求体结构 + +### ChangePasswordPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `current` | string | ✓ | | | +| `new` | string | ✓ | | | + +### CookiePayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `cookie` | string | ✓ | | | + +### CrawlerStartRequest + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `platform` | PlatformEnum | ✓ | | | +| `login_type` | LoginTypeEnum | | 'qrcode' | | +| `crawler_type` | CrawlerTypeEnum | | 'search' | | +| `keywords` | string | | '' | | +| `specified_ids` | string | | '' | | +| `creator_ids` | string | | '' | | +| `start_page` | integer | | 1 | | +| `enable_comments` | boolean | | True | | +| `enable_sub_comments` | boolean | | False | | +| `enable_media` | boolean | | False | | +| `save_option` | SaveDataOptionEnum | | 'jsonl' | | +| `cookies` | string | | '' | | +| `headless` | boolean | | False | | +| `max_notes_count` | integer \| null | | | | +| `max_comments_count` | integer \| null | | | | +| `save_data_path` | string \| null | | | | +| `enable_cdp_mode` | boolean \| null | | | | +| `inject_all_cookies` | boolean \| null | | | | +| `save_login_state` | boolean \| null | | | | +| `cookies_file` | string \| null | | | | +| `max_concurrency_num` | integer \| null | | | | +| `crawler_max_sleep_sec` | integer \| null | | | | +| `enable_ip_proxy` | boolean \| null | | | | +| `ip_proxy_pool_count` | integer \| null | | | | +| `ip_proxy_provider_name` | string \| null | | | | +| `static_proxy_url` | string \| null | | | | + +### CreatorAliasPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `alias` | string | | '' | | + +### LoginPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `password` | string | ✓ | | | + +### MonitorTaskCreate + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `name` | string | ✓ | | | +| `platform` | string | | 'xhs' | | +| `mode` | string | ✓ | | | +| `interval_minutes` | integer \| null | | | | +| `schedule_mode` | string | | 'interval' | | +| `schedule_hours` | integer[] | | | | +| `schedule_days` | integer[] | | | | +| `schedule_minute` | integer | | 0 | | +| `max_notes_count` | integer \| null | | | | +| `enable_comments` | boolean | | True | | +| `max_comments_count` | integer \| null | | | | +| `run_timeout_seconds` | integer | | 3600 | | +| `enabled` | boolean | | True | | +| `notify_enabled` | boolean | | False | | +| `notify_failures` | boolean | | True | | +| `targets` | string[] | ✓ | | | + +### MonitorTaskUpdate + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `name` | string \| null | | | | +| `enabled` | boolean \| null | | | | +| `interval_minutes` | integer \| null | | | | +| `schedule_mode` | string \| null | | | | +| `schedule_hours` | integer[] \| null | | | | +| `schedule_days` | integer[] \| null | | | | +| `schedule_minute` | integer \| null | | | | +| `max_notes_count` | integer \| null | | | | +| `enable_comments` | boolean \| null | | | | +| `max_comments_count` | integer \| null | | | | +| `run_timeout_seconds` | integer \| null | | | | +| `notify_enabled` | boolean \| null | | | | +| `notify_failures` | boolean \| null | | | | +| `targets` | string[] \| null | | | | + +### NoteAliasPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `alias` | string | | '' | | + +### NoteTagsPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `tag_ids` | integer[] | | | | + +### SettingsUpdatePayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| + +### TagPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `name` | string | ✓ | | | +| `color` | string | | '' | | + +### WebhookPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `url` | string | | '' | 企业微信机器人 Webhook 地址,留空表示停用 | + +### WebhookTestPayload + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|:--:|---|---| +| `url` | string \| null | | | 不传则使用已保存的地址 | + +> **响应没有结构可列。** 这些接口返回的是普通 dict,没有声明 `response_model`,所以 OpenAPI 里没有响应的 shape。要看响应就调一次,或者读对应的 `service.py`。 + diff --git a/docs/gen_api_doc.py b/docs/gen_api_doc.py new file mode 100644 index 0000000..76d49db --- /dev/null +++ b/docs/gen_api_doc.py @@ -0,0 +1,275 @@ +# -*- 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 "
".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 `。浏览器用 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())} 行)")