cfa960221e
- 顶栏新增「插件管理」图标按钮,打开 PluginsDialog - 弹窗列出 pugins/ 下的插件:标题 / 目录名 / 是否启用与已加载 / 平台 label / 能力清单 - 每个插件可启用、停用、重载,底部有「全部重载」 - 启停成功后重新拉 /api/platforms,「添加账号」的平台下拉同步刷新 - types.ts 新增 PluginInfo;App.tsx 把平台列表加载提成 loadPlatforms 复用 - static/ 重新构建(pnpm typecheck + pnpm build 均通过) - 顺带把 .vscode/ 纳入跟踪
202 lines
10 KiB
Markdown
202 lines
10 KiB
Markdown
# 学习平台插件开发文档
|
||
|
||
分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。
|
||
|
||
一个平台 = 一个 `Platform` 子类:类属性放元数据,自己确实有的阶段实现成方法,模块末尾
|
||
注册一次。插件式平台放 `src/study_tool/pugins/<名字>/`(`pugin.json` + `main.py`),由加载器
|
||
扫目录自动加载;跟共享实现模块关系紧密的放 `src/study_tool/platforms/<名字>.py`。
|
||
引擎、路由、前端都不需要改。
|
||
|
||
## 插件骨架
|
||
|
||
```python
|
||
"""<平台名>:登录 → 发现 → 学习。"""
|
||
import requests
|
||
|
||
from study_tool import runtime
|
||
from study_tool.platforms import registry
|
||
from study_tool.platforms.base import Platform
|
||
from study_tool.platforms.errors import SessionExpired
|
||
|
||
UA = "Mozilla/5.0 ..."
|
||
DOMAINS = ("<平台域名>",)
|
||
|
||
|
||
class MyPlatform(Platform):
|
||
id = "<平台 id>" # 写进 accounts.platform / course["platform"]
|
||
label = "展示名" # 日志与界面展示
|
||
entries = ("<登录入口域名>", "<学习入口域名>") # 识别平台归属
|
||
|
||
def session(self, cookies=None):
|
||
s = requests.Session()
|
||
s.headers.update({"User-Agent": UA})
|
||
for c in cookies or []:
|
||
if not any(d in (c.get("domain") or "") for d in DOMAINS):
|
||
continue
|
||
s.cookies.set(c["name"], c.get("value") or "",
|
||
domain=(c.get("domain") or "").lstrip("."),
|
||
path=c.get("path") or "/")
|
||
return s
|
||
|
||
def dump_cookies(self, s):
|
||
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
|
||
for c in s.cookies]
|
||
|
||
def login(self, username, password):
|
||
s = self.session()
|
||
# ... 登录失败抛 RuntimeError;会话失效抛 SessionExpired
|
||
return s, self.profile(s)
|
||
|
||
def list_courses(self, s):
|
||
# -> [{"name", "plan_id", "project_id", "in_study", ...}]
|
||
...
|
||
|
||
def discover(self, s, plan_id, project_id, name=""):
|
||
return {"platform": self.id, "course": name, "progress": "0/0", "info": "",
|
||
"user": "", "courseware": [], "feedback": [], "test": []}
|
||
|
||
def study(self, key, s, d, **opts):
|
||
for cw in d["courseware"]:
|
||
if self._stopped(key):
|
||
return
|
||
runtime.log(key, "开始:%s" % cw["title"])
|
||
# ... 上报进度;被踢回登录页时 raise SessionExpired("...")
|
||
runtime.log(key, "完成:%s" % cw["title"])
|
||
|
||
def _stopped(self, key): # 私有辅助:_ 开头,不属于契约
|
||
with runtime.RUNS_LOCK:
|
||
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
||
|
||
|
||
PLATFORM = registry.register(MyPlatform())
|
||
```
|
||
|
||
平台确实没有的阶段**不要写**,保持基类的 `None`;`capabilities()` 按「子类是否把它覆盖成
|
||
方法」推导能力名,前端据此决定显示哪些按钮。
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
src/study_tool/pugins/
|
||
__init__.py 加载器:load_plugins / discover / set_enabled / reload
|
||
<名字>/
|
||
pugin.json 注册信息:id / title / name(不写能力绑定)
|
||
main.py class <名字>(Platform)
|
||
gdgxk/ 广东公需课:pugin.json + main.py
|
||
```
|
||
|
||
约定:
|
||
|
||
- 一个平台只注册一次;注册发生在模块被 import 时,模块里除注册外不要有副作用。
|
||
- 平台 id = 模块里 `id` = `accounts.platform` = `course["platform"]`,三处必须一致。
|
||
- 同一平台不同页面代际拆成同目录下的实现模块(`jspx.py` / `study.py`),由平台类按
|
||
`course["generation"]` 分发,不要一个代际注册一个平台。
|
||
- 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `base.py` /
|
||
`contract.py` / `errors.py`。
|
||
|
||
## 加载与启停
|
||
|
||
加载器在 `pugins/__init__.py`,对外四个函数 + 对应 HTTP 接口:
|
||
|
||
| 函数 | 接口 | 作用 |
|
||
| --- | --- | --- |
|
||
| `load_plugins()` | 启动时自动调用 | 按启用清单 import 全部插件 |
|
||
| `discover()` | `GET /api/plugins` | 磁盘清单 + 是否启用 / 已加载 / label / capabilities |
|
||
| `set_enabled(name, bool)` | `POST /api/plugins/{name}/enable`、`/disable` | 立即加载或从注册表摘掉,并落库 |
|
||
| `reload(name=None)` | `POST /api/plugins/reload`、`/api/plugins/{name}/reload` | 重新 import,改完插件代码不用重启 |
|
||
|
||
启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,新丢进目录的插件自动生效。
|
||
停用的插件会 `registry.unregister()`,从 `/api/platforms` 消失,账号的平台下拉里也不会出现。
|
||
热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
|
||
前端顶栏的「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`)就是这几个接口的界面。
|
||
|
||
## 槽位契约
|
||
|
||
| 槽位 | 方法签名 | 调用方 | 要求 |
|
||
| --- | --- | --- | --- |
|
||
| `study` | `(self, key, s, d, **opts)` | `engine.run_one_course` | 必填 |
|
||
| `discover` | `(self, s, plan_id, project_id, name)` | `run_one_course`、`/api/discover` | 学习前必填 |
|
||
| `session` | `(self, cookies) -> requests.Session` | 引擎与各路由 | 强烈建议(缺失直接报错) |
|
||
| `dump_cookies` | `(self, s) -> [cookie]` | 登录 / 刷新 / 发现后回写前端 | 强烈建议 |
|
||
| `login` | `(self, username, password) -> (session, user)` | `/api/login` | 要密码登录则必填 |
|
||
| `list_courses` | `(self, s) -> [course]` | 登录后、`/api/courses` | 要刷新课程则必填 |
|
||
| `renew_session` | `(self, s) -> bool` | `SessionExpired` 时无密码续期 | 选填 |
|
||
| `profile` | `(self, 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. 新建 `src/study_tool/pugins/<名字>/`,放两个文件:`pugin.json`(`id` / `title` / `name`)
|
||
和 `main.py`(按上面的骨架写 `class <名字>(Platform)`)。
|
||
2. `main.py` 末尾 `PLATFORM = registry.register(<名字>())`。
|
||
3. 不需要改 `load_builtin()`、路由或前端 —— 加载器扫目录时自动 import 各插件。
|
||
跟 `jspx` / `study` 这类共享实现模块关系紧密的平台,仍可以放 `platforms/<名字>.py`,
|
||
在 `load_builtin()` 里加一行 import。
|
||
4. 启动服务,`GET /api/platforms` 应出现该平台。
|
||
5. 联调顺序:`/api/login` → `/api/courses` → `/api/discover` → `/api/study`。
|
||
|
||
## 把已有实现改成子类
|
||
|
||
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
|
||
|
||
1. 保留模块级纯函数(`_b64` / `_ocr` / `_parse_courses` 等),它们不属于契约。
|
||
2. 把原来的槽位自由函数改成同名方法:加 `self`,模块内互相调用改成 `self.xxx`,
|
||
平台私有的重登逻辑改成 `_relogin` 这类私有方法。
|
||
3. 加类属性 `id` / `label` / `entries`,删掉模块末尾的逐项注册。
|
||
4. 模块末尾改成 `PLATFORM = registry.register(<类名>())`。
|
||
5. 逐个验证槽位行为与迁移前一致;同一平台的两个代际由 `study` 里按
|
||
`course["generation"]` 分发(参见 `guanyixue.py`)。
|
||
|
||
## 硬约束
|
||
|
||
- 日志只走 `runtime.log(key, msg, level=...)`,`key` 用引擎传进来的账号 id;不要自己加时间前缀。
|
||
- 循环里要响应停止:`(runtime.RUNS.get(key) or {}).get("status") == "stopped"` 时返回。
|
||
- 会话失效抛 `SessionExpired`;自动续期 / 用密码重登由引擎统一处理(`renew_session` 里除外)。
|
||
- 不 import `engine` / `routes` / 前端;平台之间只允许同平台不同代际互相 import,
|
||
模块级互相 import 成环时改用函数内延迟导入(参见 `study.py` ↔ `jspx.py`)。
|
||
- cookie 域名过滤在平台自己的 `session` 里做,引擎不再兜底:漏写 `session` 会直接报
|
||
「平台没有实现 session」,不会静默过滤。
|
||
- 时间、解析、编码这类通用能力用现成库,不要手写。
|
||
|
||
## 联调检查表
|
||
|
||
- [ ] `GET /api/platforms` 里能看到该平台,`label` 与 `capabilities` 正确
|
||
- [ ] 添加账号 → 选平台 → 登录成功,卡片显示 `profile` 名字
|
||
- [ ] 刷新课程返回非空列表,`in_study` 标记正确
|
||
- [ ] 发现课程返回的活动数与页面一致
|
||
- [ ] 开始学习:日志出现在该账号卡片里,进度递增
|
||
- [ ] 点停止后几秒内循环退出
|
||
- [ ] 会话失效时能走到续期 / 重登,不需要手动刷新
|
||
- [ ] 平台没实现 `session` 时,`/api/courses` 报「还没有实现 session」
|
||
|
||
## 常见坑
|
||
|
||
- **自由函数改方法后忘加 `self`**:模块内原来的 `_relogin(key)` 必须写成 `self._relogin(key)`,
|
||
否则运行时才报错;提交前可用 `symtable` 扫一遍模块的未定义全局名。
|
||
- **注册写两遍**:既在类里又在模块末尾逐项注册,会导致同一个 id 被覆盖,`capabilities`
|
||
也可能和实际方法不一致。只保留 `registry.register(<类名>())`。
|
||
- **cookie 域写错**:`session` 里过滤的是保存下来的 cookie,域名不匹配会得到空会话,
|
||
表现为「登录成功但刷新课程提示未登录」。
|
||
- **日志 key 用错**:平台用自己拼的字符串当 key,日志会跑进别的卡片或 `system` 弹窗。
|
||
- **把代际当平台**:旧版 / 新版注册成两个平台,账号绑定会漂移;应该在
|
||
`discover` 里写 `generation`,由 `study` 分发。
|
||
|
||
## 方案演进
|
||
|
||
1. 早期:平台实现直接写在 `engine.py` 里。
|
||
2. 拆包:`platforms/base.py` 定义 `Platform` 契约 + `registry` 注册表,引擎按
|
||
`course["platform"]` 分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。
|
||
3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`;
|
||
各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
|
||
4. 类式(当前):`Platform` 变成普通基类,平台写成子类(类属性 + 方法),
|
||
禁止逐项传函数注册;`gdgxk` 与 `guanyixue` 已完成迁移。
|