Files
study-tool/docs/pugin.md
T

172 lines
8.8 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/` 放这个平台自己的工具。平台 id 同时是 `accounts.platform` 与
`course["platform"]` 的取值,必须与目录名、`registry` 里注册的 `Platform.id` 三者一致。
## 现状(重要)
新目录方案还没有接加载器,**`pugins/` 下的代码目前不会被执行**:
- 启动时只调 `platforms/__init__.py` 的 `load_builtin()`,它 import 的是
`platforms.gdgxk` 与 `platforms.guanyixue`。
- `pugins/gdgxk/main.py` 与 `platforms/gdgxk.py` 内容完全相同(副本)。
- 没有任何代码读取 `pugin.json`;注册仍由 `main.py` 末尾的
`registry.register(Platform(...))` 完成。
所以现在改 `pugins/gdgxk/main.py` 不生效;要改行为先改 `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/ 插件目录(一个平台一个子目录)
gdgxk/
__init__.py 留空,仅让目录成为包
pugin.json 插件元数据
main.py 实现 + registry.register(Platform(...))
utils/
__init__.py 该平台私有工具(可留空)
jspx/ 预留(迁移用占位)
__init__.py
```
约定:
- 目录名 = `Platform.id` = `accounts.platform` = `course["platform"]`。
- 一个平台只注册一次;注册发生在 import 时,因此插件模块被导入不能有副作用(除注册)。
- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(契约、注册表、错误类型)。
- 同平台不同页面代际(如莞易学的旧版 / 新版)拆成同目录内的多个实现模块,由 `Platform.study`
按 `course["generation"]` 分发,不要一个代际一个插件目录。
## pugin.json
```json
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"id": "PLATFORM_ID",
"label": "广东公需课",
"study": "study",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
}
```
字段含义与现状:
| 字段 | 含义 | 现状 |
| --- | --- | --- |
| `title` | 平台全称,给文档 / 未来的插件列表用 | 无代码读取 |
| `name` | 插件名,与目录名一致 | 无代码读取 |
| `id` | `Platform.id`,写进 `accounts.platform` | 当前值是常量名 `PLATFORM_ID`,应该是 `"gdgxk"` |
| `label` | 界面展示名(「添加账号」下拉) | 无代码读取,实际值在 `main.py` 里 |
| `study` | `study` 实现所在的函数名 | 无代码读取 |
| `entries` | 登录 / 学习入口域名片段,用于识别平台归属 | 无代码读取,实际值在 `main.py` 里 |
注意:`pugin.json` 与 `main.py` 里的 `Platform(...)` 字段重复。在加载器定下来之前,
**以 `main.py` 为准**;`pugin.json` 只当元数据,改了它不会影响运行。
## main.py 契约
实现 `Platform` 上的槽位,缺的阶段不实现(不要抛 `NotImplementedError`)。流程顺序由引擎负责,
插件只解决「这一步在这个站点怎么做」。
| 槽位 | 签名 | 调用方 | 要求 |
| --- | --- | --- | --- |
| `id` / `label` | 字符串 | 注册表、`GET /api/platforms`、前端下拉 | 必填 |
| `entries` | `tuple[str, ...]` | `registry.detect()` | 必填 |
| `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` 会把它返回给前端。
## 开发流程
1. 建目录:`pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}`。
2. 在 `main.py` 里按需实现上表槽位;cookie 只保留本平台域名,会话失效抛
`platforms.errors.SessionExpired`。
3. 文件末尾注册:
```python
PLATFORM = registry.register(Platform(
id="<平台 id>", label="<展示名>", entries=("<登录域名>", "<学习域名>"),
session=new_session, dump_cookies=dump_cookies, login=login,
list_courses=list_courses, discover=discover, study=study,
))
```
4. 填 `pugin.json`(当前只作文档用)。
5. 接入加载:加载器就绪前,还需要在 `platforms/__init__.py` 的 `load_builtin()` 里加一行
import,插件才会被注册。
6. 联调顺序:`GET /api/platforms`(下拉里出现该平台)→ `/api/login` → `/api/courses` →
`/api/discover` → `/api/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"]` 分发;学习流程仍在引擎。
3. 契约补全:`contract.py` 用「已实现槽位 → 能力名」推导 `capabilities`;`SessionExpired`
移到 `platforms/errors.py`;各平台的学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
4. 目录化(当前):一个平台一个目录 `pugins/<id>/`,用 `pugin.json` 描述元数据,
`platforms/` 里的实现逐步迁入。
## 待定项
- 加载器:扫描 `pugins/*/` 自动 import,还是继续在 `load_builtin()` 手写 import?
- `pugin.json` 与 `main.py` 的字段重复,谁是唯一来源(若以 json 为准,注册就要由加载器做)?
- 命名:`pugins` / `pugin.json` / `pugin.md` 是否是最终拼写(英文通常是 plugins / plugin.json)。
- `platforms/` 是否最终删除;莞易学(`jspx` / `study`)是否迁入 `pugins/`。
- `platforms/gdgxk.py` 与 `pugins/gdgxk/main.py` 目前是两份副本,迁移完成前以哪份为准。
- `pugin.json` 的 `"id": "PLATFORM_ID"` 应为具体值 `"gdgxk"`。
相关文档:[architecture.md](architecture.md)(分层与平台契约)、[OPTIMIZATION.md](OPTIMIZATION.md)(待实测项)、
[progress/todo.md](progress/todo.md)(后续待办)。