docs: 插件改为 Platform 子类形态,更新 pugin.md 契约与迁移步骤
This commit is contained in:
+110
-90
@@ -1,30 +1,59 @@
|
||||
# 学习平台插件开发文档
|
||||
|
||||
一个学习平台 = `src/study_tool/pugins/<平台 id>/` 一个目录:`main.py` 放实现,
|
||||
`pugin.json` 放元数据与槽位映射,`utils/` 放这个平台自己的工具。
|
||||
一个学习平台 = `src/study_tool/pugins/<平台 id>/` 一个目录:`main.py` 里写一个
|
||||
`Platform` 子类,`pugin.json` 描述元数据,`utils/` 放这个平台自己的工具。
|
||||
|
||||
**`pugin.json` 是唯一权威**:平台注册由加载器读 `pugin.json` 完成,`main.py` 只提供函数,
|
||||
**`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()` 按「子类是否把它
|
||||
覆盖成方法」推导能力名,引擎按能力决定走不走该分支。
|
||||
|
||||
## 现状(重要)
|
||||
|
||||
目录方案已定,加载器还没写,`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`。
|
||||
- `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 契约
|
||||
platforms/ 基类与现有实现(迁移中)
|
||||
base.py Platform 基类:类属性 + 槽位默认 None
|
||||
contract.py 生命周期 ↔ 槽位 ↔ 能力名映射
|
||||
errors.py SessionExpired
|
||||
registry.py register / get / detect / all
|
||||
@@ -34,8 +63,8 @@ src/study_tool/
|
||||
loader.py (待实现)扫描 pugin.json 并注册平台
|
||||
gdgxk/
|
||||
__init__.py 留空,仅让目录成为包
|
||||
pugin.json 元数据 + 槽位映射(唯一权威)
|
||||
main.py 实现函数,不自注册
|
||||
pugin.json 元数据(唯一权威)
|
||||
main.py 实现:一个 Platform 子类
|
||||
utils/
|
||||
__init__.py 该平台私有工具(可留空)
|
||||
jspx/ 预留(迁移用占位)
|
||||
@@ -44,67 +73,60 @@ src/study_tool/
|
||||
|
||||
约定:
|
||||
|
||||
- 目录名 = `pugin.json.name` = `Platform.id` = `accounts.platform` = `course["platform"]`。
|
||||
- 目录名 = `pugin.json.name` = 类属性 `id` = `Platform.id` = `accounts.platform` =
|
||||
`course["platform"]`。
|
||||
- `main.py` 被 import 时不能有副作用(加载器负责注册)。
|
||||
- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(契约、注册表、错误类型)。
|
||||
- 同平台不同页面代际(如莞易学的旧版 / 新版)拆成同目录内多个实现模块,由
|
||||
`Platform.study` 按 `course["generation"]` 分发,不要一个代际一个插件目录。
|
||||
- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(基类、契约、注册表、错误类型)。
|
||||
- 同平台不同页面代际(如莞易学旧版 / 新版)在同一个子类里用 `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",
|
||||
"class": "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"
|
||||
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
|
||||
}
|
||||
```
|
||||
|
||||
| 键 | 含义 | 加载器行为 |
|
||||
| --- | --- | --- |
|
||||
| `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` 取函数,必须是可调用对象 |
|
||||
| `name` | 目录名 | 与实际目录不一致则拒绝加载 |
|
||||
| `class` | `main.py` 里的类名 | 必填;`getattr` 取类,必须是 `Platform` 子类 |
|
||||
| `id` | `Platform.id`,写进 `accounts.platform` | 必填,写入实例 |
|
||||
| `label` | 界面展示名(「添加账号」下拉) | 必填,写入实例 |
|
||||
| `entries` | 登录 / 学习入口域名片段 | 必填非空列表,写入实例 |
|
||||
| `main` | 实现模块名,省略时为 `main` | 导入 `pugins.<目录>.<main>` |
|
||||
| 其它键 | 预留(作者、版本、图标等) | 忽略 |
|
||||
|
||||
校验规则:缺 `id` / `label` / `entries` / `study` 拒绝加载;`entries` 非空列表;
|
||||
槽位键在 `main.py` 里找不到或不是 callable 拒绝加载;同一 `id` 重复注册以先加载的为准并记系统日志。
|
||||
校验与优先级:缺 `id` / `label` / `entries` / `class` 拒绝加载;类不是 `Platform` 子类拒绝加载;
|
||||
`class` 里的类属性与 json 不一致时**以 json 为准**,并把差异写进系统日志;同一 `id`
|
||||
重复注册以先加载者为准并记系统日志。
|
||||
|
||||
## main.py 契约
|
||||
## 加载器
|
||||
|
||||
只写函数,不写 `registry.register`。缺的阶段不实现(不要抛 `NotImplementedError`),
|
||||
流程顺序由引擎负责,插件只解决「这一步在这个站点怎么做」。
|
||||
放在 `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` | 学习前必填 |
|
||||
@@ -125,32 +147,12 @@ src/study_tool/
|
||||
`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` 必填。
|
||||
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`。
|
||||
引擎、路由、前端都不需要改。
|
||||
@@ -158,6 +160,22 @@ quiz_key / cookies / plan / project / name`,插件只取自己需要的。
|
||||
加载器就绪前,第 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;不要自己加时间前缀。
|
||||
@@ -166,7 +184,7 @@ quiz_key / cookies / plan / project / name`,插件只取自己需要的。
|
||||
- 不 import `engine` / `routes` / 前端;平台之间只允许同平台不同代际互相 import,
|
||||
模块级互相 import 成环时改用函数内延迟导入。
|
||||
- 时间、解析、编码这类通用能力用现成库,不要手写。
|
||||
- 通用能力(契约、注册表、错误类型、日志)放 `platforms/`,平台私有代码放自己的目录。
|
||||
- 通用能力(基类、契约、注册表、错误类型、日志)放 `platforms/`,平台私有代码放自己的目录。
|
||||
|
||||
## 联调检查表
|
||||
|
||||
@@ -184,18 +202,20 @@ quiz_key / cookies / plan / project / name`,插件只取自己需要的。
|
||||
|
||||
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/` 里的实现逐步迁入。
|
||||
`course["platform"]` 分发;平台用自由函数 + `Platform(...)` 注册;学习流程仍在引擎。
|
||||
3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`;
|
||||
各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
|
||||
4. 类式 + 目录化(当前):`Platform` 变成普通基类,平台写成子类(类属性 + 方法);
|
||||
一个平台一个目录 `pugins/<id>/`,`pugin.json` 描述元数据,加载器读 json 注册。
|
||||
|
||||
## 待办
|
||||
|
||||
- 实现 `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/` 方案)与
|
||||
- 先解决启动阻塞:`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`。
|
||||
|
||||
Reference in New Issue
Block a user