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