Files
study-tool/docs/pugin.md
T
study-tool 53bddbabaa docs: 按 json 驱动的插件方案更新 pugin.md
确定 pugin.json 为唯一权威:加载器读 json 注册,main.py 只提供函数;补加载器流程、schema 校验规则、迁移待办。
2026-10-31 14:51:42 +08:00

202 lines
10 KiB
Markdown
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.
# 学习平台插件开发文档
一个学习平台 = `src/study_tool/pugins/<平台 id>/` 一个目录:`main.py` 放实现,
`pugin.json` 放元数据与槽位映射,`utils/` 放这个平台自己的工具。
**`pugin.json` 是唯一权威**:平台注册由加载器读 `pugin.json` 完成,`main.py` 只提供函数,
不再自己 `registry.register`。
## 现状(重要)
目录方案已定,加载器还没写,`pugins/` 下的代码目前不会被执行:
- 启动只调 `platforms/__init__.py` 的 `load_builtin()`,它 import 的是
`platforms.gdgxk` 与 `platforms.guanyixue`,没有任何代码读 `pugin.json`。
- `pugins/gdgxk/main.py` 与 `platforms/gdgxk.py` 内容完全相同(副本),且 `main.py`
末尾仍有一段 `registry.register(Platform(...))`,与「json 权威」冲突。
- `pugins/gdgxk/pugin.json` 的 `"id": "PLATFORM_ID"` 写的是常量名,加载器按 json 注册会把
id 注册成字面量 `PLATFORM_ID`,接入前必须改成 `"gdgxk"`。
加载器落地前的行为以 `platforms/` 为准;要改 gdgxk 现有行为,先改 `platforms/gdgxk.py`。
## 目录结构
```
src/study_tool/
platforms/ 现有实现,加载器切换前仍然生效
base.py Platform 契约
contract.py 生命周期 ↔ 槽位 ↔ 能力名映射
errors.py SessionExpired
registry.py register / get / detect / all
detect.py 页面代际识别(legacy / spa)
guanyixue.py jspx.py study.py gdgxk.py
pugins/ 插件目录(一个平台一个子目录)
loader.py (待实现)扫描 pugin.json 并注册平台
gdgxk/
__init__.py 留空,仅让目录成为包
pugin.json 元数据 + 槽位映射(唯一权威)
main.py 实现函数,不自注册
utils/
__init__.py 该平台私有工具(可留空)
jspx/ 预留(迁移用占位)
__init__.py
```
约定:
- 目录名 = `pugin.json.name` = `Platform.id` = `accounts.platform` = `course["platform"]`。
- `main.py` 被 import 时不能有副作用(加载器负责注册)。
- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(契约、注册表、错误类型)。
- 同平台不同页面代际(如莞易学的旧版 / 新版)拆成同目录内多个实现模块,由
`Platform.study` 按 `course["generation"]` 分发,不要一个代际一个插件目录。
## pugin.json
当前内容(gdgxk):
```json
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"id": "PLATFORM_ID",
"label": "广东公需课",
"study": "study",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
}
```
目标 schema:键名即槽位名,值为 `main.py` 里的函数名;只写实现了的槽位,没写的视为不支持。
```json
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"id": "gdgxk",
"label": "广东公需课",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"],
"login": "login",
"session": "new_session",
"dump_cookies": "dump_cookies",
"list_courses": "list_courses",
"discover": "discover",
"study": "study",
"renew_session": "renew_session",
"profile": "profile"
}
```
| 键 | 含义 | 加载器行为 |
| --- | --- | --- |
| `title` | 平台全称,文档 / 插件列表展示 | 不参与注册 |
| `name` | 目录名,必须与所在目录一致 | 校验不一致则拒绝加载 |
| `id` | `Platform.id`,写进 `accounts.platform` | 必填,直接作为 `Platform.id` |
| `label` | 界面展示名(「添加账号」下拉) | 必填,作为 `Platform.label` |
| `entries` | 登录 / 学习入口域名片段 | 必填,作为 `Platform.entries` |
| `main` | 实现模块名,省略时为 `main` | `importlib` 导入 `pugins.<目录>.<main>` |
| `login` `session` `dump_cookies` `list_courses` `discover` `study` `renew_session` `profile` | 槽位 → `main.py` 里的函数名 | `getattr` 取函数,必须是可调用对象 |
| 其它键 | 预留(作者、版本、图标等) | 忽略 |
校验规则:缺 `id` / `label` / `entries` / `study` 拒绝加载;`entries` 非空列表;
槽位键在 `main.py` 里找不到或不是 callable 拒绝加载;同一 `id` 重复注册以先加载的为准并记系统日志。
## main.py 契约
只写函数,不写 `registry.register`。缺的阶段不实现(不要抛 `NotImplementedError`),
流程顺序由引擎负责,插件只解决「这一步在这个站点怎么做」。
| 槽位 | 签名 | 调用方 | 要求 |
| --- | --- | --- | --- |
| `study` | `(key, s, d, **opts)` | `engine.run_one_course` | 必填 |
| `discover` | `(s, plan_id, project_id, name)` | `run_one_course`、`/api/discover` | 学习前必填 |
| `session` | `(cookies) -> requests.Session` | 引擎与各路由 | 强烈建议(缺失会直接报错) |
| `dump_cookies` | `(s) -> [cookie]` | 登录 / 刷新 / 发现后回写前端 | 强烈建议 |
| `login` | `(username, password) -> (session, user)` | `/api/login` | 要密码登录则必填 |
| `list_courses` | `(s) -> [course]` | 登录后、`/api/courses` | 要刷新课程则必填 |
| `renew_session` | `(s) -> bool` | `SessionExpired` 时无密码续期 | 选填 |
| `profile` | `(s) -> 显示名` | 登录后卡片显示 | 选填 |
`study` 收到的是 `discover` 的返回值(`d`),必须包含:
- `platform`:本平台 id(引擎据此再次取实现)
- `course` / `progress` / `info` / `user`:课程名、进度、说明、显示名
- `courseware` / `feedback` / `test`:三类活动列表,元素至少含 `title`、`href` / `period_id`、`done`
- 可选:`generation`(`legacy` / `spa`)、`train_id`、`page_url`、`need_join`
`opts` 固定包含 `workers / cap / enable_quiz / enable_feedback / mode / parallel /
quiz_key / cookies / plan / project / name`,插件只取自己需要的。
`Platform.capabilities()` 按加载后非空的槽位推导能力名(映射见 `platforms/contract.py`),
`GET /api/platforms` 会把它返回给前端。
## 加载器
放在 `pugins/loader.py`,由 `platforms/__init__.py` 的 `load_builtin()` 调用(加载器就绪前
`load_builtin()` 继续手写 import 现有 `platforms/` 实现)。
流程:
1. 遍历 `pugins/` 下的子目录,跳过 `__pycache__`、`loader.py` 所在层级与没有 `pugin.json` 的目录。
2. 读 `pugin.json`,按上表校验;校验失败写 `runtime.log("system", ..., level="warn")` 并跳过该插件。
3. `importlib.import_module("study_tool.pugins.<目录>.<main>")`。
4. 对已知槽位键逐个 `getattr(模块, 函数名)`,组装成 `Platform(id=..., label=...,
entries=..., **槽位)`。
5. `registry.register(platform)`;重复 `id` 以先加载者为准并记系统日志。
6. 单个插件导入失败只影响该插件,不阻塞服务启动。
加载顺序按目录名排序,保证 `GET /api/platforms` 输出稳定。
## 开发流程
1. 建目录:`pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}`。
2. 在 `main.py` 里按需实现上表函数;cookie 只保留本平台域名,会话失效抛
`platforms.errors.SessionExpired`。
3. 写 `pugin.json`,键名对齐槽位,值为函数名;`id` / `label` / `entries` / `study` 必填。
4. 启动服务,看系统日志里该插件是否加载成功;`GET /api/platforms` 应出现该平台。
5. 联调顺序:`/api/login` → `/api/courses` → `/api/discover` → `/api/study`。
引擎、路由、前端都不需要改。
加载器就绪前,第 4 步要临时在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 import
才会生效。
## 硬约束
- 日志只走 `runtime.log(key, msg, level=...)`,`key` 用引擎传进来的账号 id;不要自己加时间前缀。
- 循环里要响应停止:`(runtime.RUNS.get(key) or {}).get("status") == "stopped"` 时返回。
- 会话失效抛 `SessionExpired`;自动续期 / 用密码重登由引擎统一处理(`renew_session` 里除外)。
- 不 import `engine` / `routes` / 前端;平台之间只允许同平台不同代际互相 import,
模块级互相 import 成环时改用函数内延迟导入。
- 时间、解析、编码这类通用能力用现成库,不要手写。
- 通用能力(契约、注册表、错误类型、日志)放 `platforms/`,平台私有代码放自己的目录。
## 联调检查表
- [ ] 启动后系统日志里没有该插件的加载告警
- [ ] `GET /api/platforms` 里能看到该平台,`label` 正确
- [ ] 添加账号 → 选平台 → 登录成功,卡片显示 `profile` 名字
- [ ] 刷新课程返回非空列表,`in_study` 标记正确
- [ ] 发现课程返回的活动数与页面一致
- [ ] 开始学习:日志出现在该账号卡片里,进度递增
- [ ] 点停止后几秒内循环退出
- [ ] 会话失效时能走到续期 / 重登,不需要手动刷新
- [ ] 平台没实现 `session` 时,`/api/courses` 明确报「还没有实现 session」
## 方案演进
1. 早期:平台实现直接写在 `engine.py` 里。
2. 拆包:`platforms/base.py` 定义 `Platform` 契约 + `registry` 注册表,引擎按
`course["platform"]` 分发;学习流程仍在引擎。
3. 契约补全:`contract.py` 用「已实现槽位 → 能力名」推导 `capabilities`;`SessionExpired`
移到 `platforms/errors.py`;各平台的学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
4. 目录化(当前):一个平台一个目录 `pugins/<id>/`,`pugin.json` 描述元数据与槽位映射,
加载器读 json 注册;`platforms/` 里的实现逐步迁入。
## 待办
- 实现 `pugins/loader.py` 并让 `load_builtin()` 调用它;现有 `platforms/` 实现先保持原样。
- 修正 `pugins/gdgxk/pugin.json` 的 `id`(`PLATFORM_ID` → `gdgxk`),补齐其余槽位键。
- 删除 `pugins/gdgxk/main.py` 末尾的 `registry.register(...)`(注册改由加载器做)。
- 迁移顺序:先用 gdgxk 验证「json 注册与 platforms 版行为一致」,再迁莞易学(`jspx` / `study`),
最后删除 `platforms/` 里的具体平台实现。
- 迁移完成后同步更新 [architecture.md](architecture.md)(当前仍描述 `platforms/` 方案)与
`AGENTS.md` 里的「平台代码只能放在 platforms/」约定。