feat(plugins): 插件支持热加载与启用/停用清单

- pugins/__init__.py 重写:load_plugins / discover / set_enabled / reload
- 热加载:把 study_tool.pugins.<名字> 的模块从 sys.modules 摘掉后重新 import;
  重载前先 registry.unregister 旧 id,平台 id 改了也不会留残影
- 启用清单存设置项 plugins({"disabled": [...]}):未列出的默认启用,新插件自动生效;
  停用即从注册表摘掉,/api/platforms 与账号平台下拉同步消失
- platforms/registry.py 新增 unregister(pid)
- 新接口:GET /api/plugins、POST /api/plugins/{name}/enable|disable、
  POST /api/plugins/reload、POST /api/plugins/{name}/reload;失败返回 ok=false
- 同步 docs/pugin.md(新增「加载与启停」)、docs/architecture.md、docs/OPTIMIZATION.md、AGENTS.md
This commit is contained in:
study-tool
2026-10-31 15:50:06 +08:00
parent f1a57853f4
commit f8458cff51
7 changed files with 219 additions and 31 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
## 代码结构 / 平台插件 ## 代码结构 / 平台插件
- 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。散落的 JS 脚本统一收进 `script/`,不参与运行的参考脚本放 `script/reference/`。 - 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。散落的 JS 脚本统一收进 `script/`,不参与运行的参考脚本放 `script/reference/`。
- 平台代码有两个落点:插件式平台放 `src/study_tool/pugins/<名字>/`(`pugin.json` 只放 `id` / `title` / `name` 注册信息,`main.py` 放 `Platform` 子类,`load_plugins()` 扫目录 import,新增插件不用改任何 Python 代码);跟共享实现模块关系紧密的平台放 `src/study_tool/platforms/<名字>.py` 并在 `load_builtin()` 里 import。能力只能通过「在子类里实现同名方法」绑定,禁止再写 `Platform(id=..., study=..., session=...)` 这种逐项传函数的注册;模块末尾 `PLATFORM = registry.register(<子类>())`,一个平台只注册一次。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。 - 平台代码有两个落点:插件式平台放 `src/study_tool/pugins/<名字>/`(`pugin.json` 只放 `id` / `title` / `name` 注册信息,`main.py` 放 `Platform` 子类,`load_plugins()` 扫目录 import,新增插件不用改任何 Python 代码;`GET /api/plugins` 看清单,`POST /api/plugins/{name}/enable|disable` 启停,`POST /api/plugins/reload` 热重载);跟共享实现模块关系紧密的平台放 `src/study_tool/platforms/<名字>.py` 并在 `load_builtin()` 里 import。能力只能通过「在子类里实现同名方法」绑定,禁止再写 `Platform(id=..., study=..., session=...)` 这种逐项传函数的注册;模块末尾 `PLATFORM = registry.register(<子类>())`,一个平台只注册一次。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。
- 新增平台的标准步骤:建 `pugins/<名字>/`,写 `pugin.json`(`id` / `title` / `name`)和 `main.py`(`class X(Platform)`,声明 `id` / `label` / `entries`,实现 `study(self, key, s, d, **opts)`,需要登录 / 课程列表 / 发现时再实现 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile` 对应方法,没有的阶段不写,末尾 `PLATFORM = registry.register(X())`)→ 启动即可,不用改 `load_builtin()`、`engine.py`、`routes/`、前端。 - 新增平台的标准步骤:建 `pugins/<名字>/`,写 `pugin.json`(`id` / `title` / `name`)和 `main.py`(`class X(Platform)`,声明 `id` / `label` / `entries`,实现 `study(self, key, s, d, **opts)`,需要登录 / 课程列表 / 发现时再实现 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile` 对应方法,没有的阶段不写,末尾 `PLATFORM = registry.register(X())`)→ 启动即可,不用改 `load_builtin()`、`engine.py`、`routes/`、前端。
- 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;账号级操作(登录 / 刷新课程 / 发现课程 / 会话)按 `accounts.platform` 用 `registry.get()` 取 `Platform` 上的 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile`;「添加账号」的平台下拉来自 `GET /api/platforms`。 - 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;账号级操作(登录 / 刷新课程 / 发现课程 / 会话)按 `accounts.platform` 用 `registry.get()` 取 `Platform` 上的 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile`;「添加账号」的平台下拉来自 `GET /api/platforms`。
- 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。 - 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。
+24
View File
@@ -2,6 +2,30 @@
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
## 插件热加载 + 启用/停用清单
**状态**:已实现,待实测确认。
### 做了什么
- `pugins/__init__.py` 重写:`load_plugins()` / `discover()` / `set_enabled()` / `reload()`。
- 热加载:把 `study_tool.pugins.<名字>`(含子模块)从 `sys.modules` 摘掉后重新 import,
import 时 `registry.register()` 覆盖;重载前先 `registry.unregister()` 旧 id,改 id 不留残影。
- 启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,
新丢进目录的插件自动生效;停用的插件被摘出注册表,`/api/platforms` 与账号平台下拉同步消失。
- `platforms/registry.py` 新增 `unregister(pid)`。
- 新增接口:`GET /api/plugins`、`POST /api/plugins/{name}/enable|disable`、
`POST /api/plugins/reload`、`POST /api/plugins/{name}/reload`;失败返回 `{"ok": false, "error": ...}`。
- 引擎 / 路由 / 前端 / 平台实现均未改动。
### 怎么测
1. `GET /api/plugins` 列出 `gdgxk`(enabled / loaded 为 true,capabilities 七项)。
2. `POST /api/plugins/gdgxk/disable` → `GET /api/platforms` 里 gdgxk 消失;再 enable 回来。
3. 改 `pugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` →
不重启服务再跑一次学习,日志应变成新文案。
4. 重启服务:停用状态应被记住(设置项 `plugins`)。
## 平台契约改为子类实现(注册方式调整) ## 平台契约改为子类实现(注册方式调整)
**状态**:已实现,待实测确认。 **状态**:已实现,待实测确认。
+1 -1
View File
@@ -29,7 +29,7 @@ src/study_tool/
jspx.py 莞易学旧版:会话 / 登录 / 课程抓取 / 旧版学习流程 jspx.py 莞易学旧版:会话 / 登录 / 课程抓取 / 旧版学习流程
study.py 莞易学新版 SPA:StudyClient / 接口学习 / 视频流程 study.py 莞易学新版 SPA:StudyClient / 接口学习 / 视频流程
pugins/ 插件式平台(加载器扫目录,新增插件不改代码) pugins/ 插件式平台(加载器扫目录,新增插件不改代码)
__init__.py load_plugins():扫 pugins/*/pugin.json 并 import main.py __init__.py load_plugins / discover / set_enabled / reload(启停 + 热重载)
gdgxk/ gdgxk/
pugin.json 注册信息:id / title / name(只放元数据) pugin.json 注册信息:id / title / name(只放元数据)
main.py 广东公需课:SSO 换票 + 视频进度心跳 main.py 广东公需课:SSO 换票 + 视频进度心跳
+31 -17
View File
@@ -2,8 +2,10 @@
分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。 分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。
一个平台 = `src/study_tool/platforms/<平台>.py` 里的一个 `Platform` 子类:类属性放元数据, 一个平台 = 一个 `Platform` 子类:类属性放元数据,自己确实有的阶段实现成方法,模块末尾
自己确实有的阶段实现成方法,模块末尾注册一次。引擎、路由、前端都不需要改。 注册一次。插件式平台放 `src/study_tool/pugins/<名字>/`(`pugin.json` + `main.py`),由加载器
扫目录自动加载;跟共享实现模块关系紧密的放 `src/study_tool/platforms/<名字>.py`。
引擎、路由、前端都不需要改。
## 插件骨架 ## 插件骨架
@@ -75,16 +77,12 @@ PLATFORM = registry.register(MyPlatform())
## 目录结构 ## 目录结构
``` ```
src/study_tool/platforms/ src/study_tool/pugins/
base.py Platform 基类:id / label / entries + 8 个槽位默认 None __init__.py 加载器:load_plugins / discover / set_enabled / reload
contract.py 槽位 ↔ 能力名映射,capabilities() 用它推导 <名字>/
errors.py SessionExpired(会话失效,引擎据此续期 / 重登) pugin.json 注册信息:id / title / name(不写能力绑定)
registry.py register / get / detect / all main.py class <名字>(Platform)
detect.py 页面代际识别(legacy 旧版 / spa 新版) gdgxk/ 广东公需课:pugin.json + main.py
guanyixue.py 平台:莞易学(jspx 登录入口 + study 学习入口,按 generation 分发)
jspx.py 同平台实现模块:旧版页面(不是插件,不注册)
study.py 同平台实现模块:新版 SPA(不是插件,不注册)
gdgxk.py 平台:广东公需课
``` ```
约定: 约定:
@@ -96,6 +94,21 @@ src/study_tool/platforms/
- 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `base.py` / - 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `base.py` /
`contract.py` / `errors.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;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
## 槽位契约 ## 槽位契约
| 槽位 | 方法签名 | 调用方 | 要求 | | 槽位 | 方法签名 | 调用方 | 要求 |
@@ -121,11 +134,12 @@ quiz_key / cookies / plan / project / name`,插件只取自己需要的。
## 开发流程 ## 开发流程
1. 新建 `src/study_tool/platforms/<名字>.py`,按上面的骨架写 `class <名字>(Platform)`: 1. 新建 `src/study_tool/pugins/<名字>/`,放两个文件:`pugin.json`(`id` / `title` / `name`)
类属性 `id` / `label` / `entries`,按需实现槽位方法。 和 `main.py`(按上面的骨架写 `class <名字>(Platform)`)。
2. 模块末尾 `PLATFORM = registry.register(<名字>())`。 2. `main.py` 末尾 `PLATFORM = registry.register(<名字>())`。
3. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 3. 不需要改 `load_builtin()`、路由或前端 —— 加载器扫目录时自动 import 各插件。
`from study_tool.platforms import <名字>`。 跟 `jspx` / `study` 这类共享实现模块关系紧密的平台,仍可以放 `platforms/<名字>.py`,
在 `load_builtin()` 里加一行 import。
4. 启动服务,`GET /api/platforms` 应出现该平台。 4. 启动服务,`GET /api/platforms` 应出现该平台。
5. 联调顺序:`/api/login` → `/api/courses` → `/api/discover` → `/api/study`。 5. 联调顺序:`/api/login` → `/api/courses` → `/api/discover` → `/api/study`。
+5
View File
@@ -9,6 +9,11 @@ def register(platform: Platform) -> Platform:
return platform return platform
def unregister(pid):
"""停用插件 / 重载前把实现从注册表摘掉;不在表里返回 None。"""
return _PLATFORMS.pop(pid or "", None)
def get(pid): def get(pid):
"""按 course["platform"] 取平台实现。""" """按 course["platform"] 取平台实现。"""
return _PLATFORMS.get(pid or "") return _PLATFORMS.get(pid or "")
+117 -10
View File
@@ -3,24 +3,131 @@
pugin.json 注册信息:id / title / name(只写元数据,不写任何能力绑定) pugin.json 注册信息:id / title / name(只写元数据,不写任何能力绑定)
main.py `Platform` 子类,import 时用 `registry.register(...)` 注册自己 main.py `Platform` 子类,import 时用 `registry.register(...)` 注册自己
加载 = 扫目录逐个 import `main.py`。插件里不用声明「哪个函数绑定哪个槽位」, 对外四个函数(路由 `/api/plugins*` 直接用):
子类实现了哪些方法就具备哪些能力(映射见 `platforms/contract.py`)。
要换加载方式,只改这里的 `load_plugins()`。 load_plugins() 启动时按启用清单加载全部插件
discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码)
set_enabled(name, bool) 启用 / 停用,立即生效并落库
reload(name=None) 重新 import(改完插件代码不用重启服务)
启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用,
新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。
""" """
import importlib import importlib
import json import json
import sys
from pathlib import Path from pathlib import Path
from study_tool import runtime, store
from study_tool.platforms import registry from study_tool.platforms import registry
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
SETTING = "plugins"
_LOADED = {} # 插件名 -> 注册时用的平台 id,停用 / 重载时据此摘下旧实现
def _meta(name):
meta = HERE / name / "pugin.json"
if not meta.is_file():
raise RuntimeError("没有这个插件:%s" % name)
return json.loads(meta.read_text(encoding="utf-8"))
def _disabled():
return set((store.get_setting(SETTING) or {}).get("disabled") or [])
def _save_disabled(names):
store.set_setting(SETTING, {"disabled": sorted(names)})
def _forget(name):
"""把该插件的模块(含子模块)从 sys.modules 摘掉,下次 import 会重新执行。"""
pkg = "%s.%s" % (__name__, name)
for mod in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
del sys.modules[mod]
def _load(name):
"""import 一个插件并确认它注册成功;重复调用就是重新加载。"""
pid = _meta(name).get("id") or ""
_unload(name) # 旧实现先下线,id 改了也不会留下残影
importlib.import_module("%s.%s.main" % (__name__, name))
platform = registry.get(pid)
if platform is None:
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, pid))
_LOADED[name] = pid
return platform
def _unload(name):
pid = _LOADED.pop(name, None)
if pid:
registry.unregister(pid)
_forget(name)
def discover():
"""磁盘上的插件清单;不 import 插件代码。"""
disabled = _disabled()
out = []
for meta in sorted(HERE.glob("*/pugin.json")):
name = meta.parent.name
info = json.loads(meta.read_text(encoding="utf-8"))
platform = registry.get(info.get("id") or "")
out.append({
"name": name,
"id": info.get("id") or "",
"title": info.get("title") or "",
"enabled": name not in disabled,
"loaded": platform is not None,
"label": platform.label if platform else "",
"capabilities": platform.capabilities() if platform else [],
})
return out
def load_plugins(): def load_plugins():
"""导入 `pugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。""" """启动时加载启用清单里的全部插件。幂等。"""
for meta in sorted(HERE.glob("*/pugin.json")): for item in discover():
info = json.loads(meta.read_text(encoding="utf-8")) if not item["enabled"]:
name = meta.parent.name continue
importlib.import_module("%s.%s.main" % (__name__, name)) try:
if not registry.get(info.get("id") or ""): _load(item["name"])
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, info.get("id"))) except Exception as e:
runtime.log("system", "插件 %s 加载失败:%s" % (item["name"], e), level="warn")
def set_enabled(name, enabled):
"""启用 / 停用插件并立即生效;返回最新的插件清单。"""
if enabled:
platform = _load(name) # 先加载,失败了不写进启用清单
disabled = _disabled()
disabled.discard(name)
_save_disabled(disabled)
runtime.log("system", "插件已启用:%s(%s)" % (name, platform.label))
else:
disabled = _disabled()
disabled.add(name)
_save_disabled(disabled)
_unload(name)
runtime.log("system", "插件已停用:%s" % name)
return discover()
def reload(name=None):
"""重新 import 插件;name 为空 = 重载所有已启用的插件。"""
items = discover()
if name:
item = next((i for i in items if i["name"] == name), None)
if item is None:
raise RuntimeError("没有这个插件:%s" % name)
if not item["enabled"]:
raise RuntimeError("插件 %s 已停用,先启用再重载" % name)
names = [name]
else:
names = [i["name"] for i in items if i["enabled"]]
for n in names:
_load(n)
runtime.log("system", "插件已重载:%s" % n)
return discover()
+40 -2
View File
@@ -1,11 +1,11 @@
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。""" """课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。"""
import threading import threading
import time import time
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from study_tool.webapp import app from study_tool.webapp import app
from study_tool import store from study_tool import pugins, store
from study_tool.capture import * from study_tool.capture import *
from study_tool.config import * from study_tool.config import *
from study_tool.engine import study_worker from study_tool.engine import study_worker
@@ -25,6 +25,44 @@ def api_platforms():
for p in registry.all()]} for p in registry.all()]}
# ------------------------------------------------------------------ 插件管理
# 插件目录 pugins/<名字>/,实现见 study_tool.pugins。
@app.get("/api/plugins")
def api_plugins():
"""磁盘上的插件清单:是否启用、是否已加载、加载后的 label 与 capabilities。"""
return {"ok": True, "plugins": pugins.discover()}
@app.post("/api/plugins/{name}/enable")
def api_plugin_enable(name: str):
return _plugin_call(lambda: pugins.set_enabled(name, True))
@app.post("/api/plugins/{name}/disable")
def api_plugin_disable(name: str):
return _plugin_call(lambda: pugins.set_enabled(name, False))
@app.post("/api/plugins/reload")
def api_plugins_reload():
"""重载全部已启用插件:改完插件代码不用重启服务。"""
return _plugin_call(pugins.reload)
@app.post("/api/plugins/{name}/reload")
def api_plugin_reload(name: str):
return _plugin_call(lambda: pugins.reload(name))
def _plugin_call(fn):
"""插件操作失败不抛 500,返回 ok=false 给前端提示。"""
try:
return {"ok": True, "plugins": fn()}
except Exception as e:
return {"ok": False, "error": str(e), "plugins": pugins.discover()}
# ------------------------------------------------------------------ 运行日志 / 快照 # ------------------------------------------------------------------ 运行日志 / 快照
@app.get("/api/log/history") @app.get("/api/log/history")