Files
study-tool/docs/pugin.md
T

222 lines
12 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` 里写一个
`Platform` 子类,`pugin.json` 描述元数据,`utils/` 放这个平台自己的工具。
**`pugin.json` 是唯一权威**:平台注册由加载器读 `pugin.json` 完成,`main.py` 只提供类,
不再自己 `registry.register`。
## 插件形态
插件是一个 `Platform` 子类:元数据用类属性,阶段用方法;没实现的阶段不要写,
保持基类的 `None`。私有辅助用 `_` 前缀,不进契约。
```python
class Gdgxk(Platform):
id = "gdgxk"
label = "广东公需课"
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
def session(self, cookies=None): ...
def dump_cookies(self, s): ...
def login(self, username, password): ...
def renew_session(self, s): ...
def profile(self, s): ...
def list_courses(self, s): ...
def discover(self, s, plan_id, project_id, name=""): ...
def study(self, key, s, d, **opts): ...
def _relogin(self, key): ... # 平台私有,不属于契约
```
`platforms/base.py` 的 `Platform` 就是普通基类:`id` / `label` / `entries` 与 8 个槽位
(`study` / `session` / `dump_cookies` / `login` / `list_courses` / `discover` /
`renew_session` / `profile`)在基类里默认 `None` / 空值,`capabilities()` 按「子类是否把它
覆盖成方法」推导能力名,引擎按能力决定走不走该分支。
## 现状(重要)
目录方案与基类已经改成类式,加载器还没写,且**当前工作区里莞易学导入即失败**:
- `platforms/base.py` 已是普通类,不再接受 `Platform(id=..., label=..., study=...)` 这种
函数式注册(实测 `TypeError: Platform() takes no arguments`)。
- `platforms/gdgxk.py` 已迁成 `class Gdgxk(Platform)` 并在末尾 `registry.register(Gdgxk())`,
可正常导入。
- `platforms/guanyixue.py` 还是函数式注册 → `import study_tool.platforms.guanyixue` 直接
`TypeError`;`load_builtin()` 会 import 它,所以服务启动会失败。迁移完成前必须二选一:
给 `Platform` 补一个接受关键字槽位的 `__init__`,或把莞易学也改成子类。
- `pugins/gdgxk/main.py` 还是迁移前的自由函数副本(与现在的 `platforms/gdgxk.py` 不同),
`pugins/gdgxk/pugin.json` 的 `"id": "PLATFORM_ID"` 也还是常量名,二者都要按新形态重写。
- 没有任何代码读 `pugin.json`。
## 目录结构
```
src/study_tool/
platforms/ 基类与现有实现(迁移中)
base.py Platform 基类:类属性 + 槽位默认 None
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 实现:一个 Platform 子类
utils/
__init__.py 该平台私有工具(可留空)
jspx/ 预留(迁移用占位)
__init__.py
```
约定:
- 目录名 = `pugin.json.name` = 类属性 `id` = `Platform.id` = `accounts.platform` =
`course["platform"]`。
- `main.py` 被 import 时不能有副作用(加载器负责注册)。
- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(基类、契约、注册表、错误类型)。
- 同平台不同页面代际(如莞易学旧版 / 新版)在同一个子类里用 `generation` 分发,不要一个代际
一个插件目录。
## pugin.json
类式插件不需要再逐槽位映射函数名,只需要元数据 + 类名:
```json
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"class": "Gdgxk",
"id": "gdgxk",
"label": "广东公需课",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
}
```
| 键 | 含义 | 加载器行为 |
| --- | --- | --- |
| `title` | 平台全称,文档 / 插件列表展示 | 不参与注册 |
| `name` | 目录名 | 与实际目录不一致则拒绝加载 |
| `class` | `main.py` 里的类名 | 必填;`getattr` 取类,必须是 `Platform` 子类 |
| `id` | `Platform.id`,写进 `accounts.platform` | 必填,写入实例 |
| `label` | 界面展示名(「添加账号」下拉) | 必填,写入实例 |
| `entries` | 登录 / 学习入口域名片段 | 必填非空列表,写入实例 |
| `main` | 实现模块名,省略时为 `main` | 导入 `pugins.<目录>.<main>` |
| 其它键 | 预留(作者、版本、图标等) | 忽略 |
校验与优先级:缺 `id` / `label` / `entries` / `class` 拒绝加载;类不是 `Platform` 子类拒绝加载;
`class` 里的类属性与 json 不一致时**以 json 为准**,并把差异写进系统日志;同一 `id`
重复注册以先加载者为准并记系统日志。
## 加载器
放在 `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. `cls = getattr(模块, json["class"])`,确认 `issubclass(cls, Platform)`。
5. `inst = cls()`;用 json 覆盖 `inst.id` / `inst.label` / `inst.entries`(json 权威)。
6. `registry.register(inst)`。
7. 单个插件导入失败只影响该插件,不阻塞服务启动;按目录名排序加载,保证
`GET /api/platforms` 输出稳定。
## 槽位契约
| 槽位 | 方法签名 | 调用方 | 要求 |
| --- | --- | --- | --- |
| `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`,插件只取自己需要的。
## 开发流程
1. 建目录:`pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}`。
2. 在 `main.py` 里写 `class <平台类>(Platform)`:类属性 `id` / `label` / `entries`,
按需实现上表方法;cookie 只保留本平台域名,会话失效抛 `platforms.errors.SessionExpired`。
3. 写 `pugin.json`(`class` 指向第 2 步的类名)。
4. 启动服务,看系统日志里该插件是否加载成功;`GET /api/platforms` 应出现该平台。
5. 联调顺序:`/api/login` → `/api/courses` → `/api/discover` → `/api/study`。
引擎、路由、前端都不需要改。
加载器就绪前,第 4 步要临时在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 import
才会生效。
## 迁移已有平台(函数式 → 类式)
以 gdgxk 为例,步骤可复用:
1. 保留模块级纯函数(`_xhr` / `_b64` / `_ocr` / `_parse_courses` 等),它们不属于契约。
2. 把原来的 `session` / `dump_cookies` / `login` / `renew_session` / `profile` /
`list_courses` / `discover` / `study` 自由函数改成同名方法(加 `self`,互相调用时用
`self.xxx`),平台私有的重登逻辑改成 `_relogin`。
3. 加类属性 `id` / `label` / `entries`,删掉模块级 `PLATFORM = registry.register(Platform(...))`。
4. 把文件复制到 `pugins/<id>/main.py`,写 `pugin.json`(`class` 指向类名)。
5. 验证:`GET /api/platforms` 出现该平台,登录 / 刷新课程 / 发现 / 学习行为与迁移前一致
(`main.py` 只提供类,注册由加载器做)。
6. 确认一致后删除 `platforms/` 下的旧实现;莞易学按同样方式迁成
`class Guanyixue(Platform)`(`study` 内按 `course["generation"]` 分发到 `jspx` / `study` 的
模块级函数)。
## 硬约束
- 日志只走 `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"]` 分发;平台用自由函数 + `Platform(...)` 注册;学习流程仍在引擎。
3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`;
各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
4. 类式 + 目录化(当前):`Platform` 变成普通基类,平台写成子类(类属性 + 方法);
一个平台一个目录 `pugins/<id>/`,`pugin.json` 描述元数据,加载器读 json 注册。
## 待办
- 先解决启动阻塞:`platforms/guanyixue.py` 仍是函数式注册,`load_builtin()` 会 import 它。
要么给 `Platform` 补关键字 `__init__`,要么把莞易学改成 `class Guanyixue(Platform)`。
- 实现 `pugins/loader.py` 并让 `load_builtin()` 调用它。
- 按类式重写 `pugins/gdgxk/{main.py, pugin.json}`(当前是自由函数副本 + 错的 `id`)。
- 迁移顺序:gdgxk 验证「json 注册与 platforms 版行为一致」→ 莞易学 → 删除 `platforms/` 里的
具体平台实现。
- 迁移完成后同步更新 [architecture.md](architecture.md)(仍描述函数式 `Platform` 契约)与
`AGENTS.md` 里的「平台代码只能放在 platforms/」约定。
- 清理临时脚本 `script/reference/_migrate_gdgxk_tmp.py`。