Files
study-tool/docs/pugin.md
T
study-tool cfa960221e feat(web): 插件管理弹窗(启停 / 重载)
- 顶栏新增「插件管理」图标按钮,打开 PluginsDialog
- 弹窗列出 pugins/ 下的插件:标题 / 目录名 / 是否启用与已加载 / 平台 label / 能力清单
- 每个插件可启用、停用、重载,底部有「全部重载」
- 启停成功后重新拉 /api/platforms,「添加账号」的平台下拉同步刷新
- types.ts 新增 PluginInfo;App.tsx 把平台列表加载提成 loadPlatforms 复用
- static/ 重新构建(pnpm typecheck + pnpm build 均通过)
- 顺带把 .vscode/ 纳入跟踪
2026-10-31 16:01:44 +08:00

202 lines
10 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.
# 学习平台插件开发文档
分层与整体结构见 [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` 已完成迁移。