From f8458cff5164605da432f8fb794868d7a2bbc259 Mon Sep 17 00:00:00 2001 From: study-tool Date: Sat, 31 Oct 2026 15:50:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E6=8F=92=E4=BB=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=83=AD=E5=8A=A0=E8=BD=BD=E4=B8=8E=E5=90=AF=E7=94=A8?= =?UTF-8?q?/=E5=81=9C=E7=94=A8=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 2 +- docs/OPTIMIZATION.md | 24 +++++ docs/architecture.md | 2 +- docs/pugin.md | 48 ++++++---- src/study_tool/platforms/registry.py | 5 ++ src/study_tool/pugins/__init__.py | 127 ++++++++++++++++++++++++--- src/study_tool/routes/misc.py | 42 ++++++++- 7 files changed, 219 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4277086..bc50f56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ ## 代码结构 / 平台插件 - 业务代码都在 `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/`、前端。 - 引擎、路由、前端都不允许直接 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/` 里的产物。 diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md index 1728777..aa048bd 100644 --- a/docs/OPTIMIZATION.md +++ b/docs/OPTIMIZATION.md @@ -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`)。 + ## 平台契约改为子类实现(注册方式调整) **状态**:已实现,待实测确认。 diff --git a/docs/architecture.md b/docs/architecture.md index b5da3e6..9dfe9c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ src/study_tool/ jspx.py 莞易学旧版:会话 / 登录 / 课程抓取 / 旧版学习流程 study.py 莞易学新版 SPA:StudyClient / 接口学习 / 视频流程 pugins/ 插件式平台(加载器扫目录,新增插件不改代码) - __init__.py load_plugins():扫 pugins/*/pugin.json 并 import main.py + __init__.py load_plugins / discover / set_enabled / reload(启停 + 热重载) gdgxk/ pugin.json 注册信息:id / title / name(只放元数据) main.py 广东公需课:SSO 换票 + 视频进度心跳 diff --git a/docs/pugin.md b/docs/pugin.md index 95121cf..78a5dda 100644 --- a/docs/pugin.md +++ b/docs/pugin.md @@ -2,8 +2,10 @@ 分层与整体结构见 [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/ - base.py Platform 基类:id / label / entries + 8 个槽位默认 None - contract.py 槽位 ↔ 能力名映射,capabilities() 用它推导 - errors.py SessionExpired(会话失效,引擎据此续期 / 重登) - registry.py register / get / detect / all - detect.py 页面代际识别(legacy 旧版 / spa 新版) - guanyixue.py 平台:莞易学(jspx 登录入口 + study 学习入口,按 generation 分发) - jspx.py 同平台实现模块:旧版页面(不是插件,不注册) - study.py 同平台实现模块:新版 SPA(不是插件,不注册) - gdgxk.py 平台:广东公需课 +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 ``` 约定: @@ -96,6 +94,21 @@ src/study_tool/platforms/ - 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `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;正在跑的学习任务持有的是旧实例,跑到结束不受影响。 + ## 槽位契约 | 槽位 | 方法签名 | 调用方 | 要求 | @@ -121,11 +134,12 @@ quiz_key / cookies / plan / project / name`,插件只取自己需要的。 ## 开发流程 -1. 新建 `src/study_tool/platforms/<名字>.py`,按上面的骨架写 `class <名字>(Platform)`: - 类属性 `id` / `label` / `entries`,按需实现槽位方法。 -2. 模块末尾 `PLATFORM = registry.register(<名字>())`。 -3. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 - `from study_tool.platforms import <名字>`。 +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`。 diff --git a/src/study_tool/platforms/registry.py b/src/study_tool/platforms/registry.py index 2a8a53a..f1cdf08 100644 --- a/src/study_tool/platforms/registry.py +++ b/src/study_tool/platforms/registry.py @@ -9,6 +9,11 @@ def register(platform: Platform) -> Platform: return platform +def unregister(pid): + """停用插件 / 重载前把实现从注册表摘掉;不在表里返回 None。""" + return _PLATFORMS.pop(pid or "", None) + + def get(pid): """按 course["platform"] 取平台实现。""" return _PLATFORMS.get(pid or "") diff --git a/src/study_tool/pugins/__init__.py b/src/study_tool/pugins/__init__.py index b6991a7..bc6941c 100644 --- a/src/study_tool/pugins/__init__.py +++ b/src/study_tool/pugins/__init__.py @@ -3,24 +3,131 @@ pugin.json 注册信息:id / title / name(只写元数据,不写任何能力绑定) main.py `Platform` 子类,import 时用 `registry.register(...)` 注册自己 -加载 = 扫目录逐个 import `main.py`。插件里不用声明「哪个函数绑定哪个槽位」, -子类实现了哪些方法就具备哪些能力(映射见 `platforms/contract.py`)。 -要换加载方式,只改这里的 `load_plugins()`。 +对外四个函数(路由 `/api/plugins*` 直接用): + + load_plugins() 启动时按启用清单加载全部插件 + discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码) + set_enabled(name, bool) 启用 / 停用,立即生效并落库 + reload(name=None) 重新 import(改完插件代码不用重启服务) + +启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用, +新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。 """ import importlib import json +import sys from pathlib import Path +from study_tool import runtime, store from study_tool.platforms import registry 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(): - """导入 `pugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。""" - for meta in sorted(HERE.glob("*/pugin.json")): - info = json.loads(meta.read_text(encoding="utf-8")) - name = meta.parent.name - importlib.import_module("%s.%s.main" % (__name__, name)) - if not registry.get(info.get("id") or ""): - raise RuntimeError("插件 %s 没有注册 id=%r" % (name, info.get("id"))) + """启动时加载启用清单里的全部插件。幂等。""" + for item in discover(): + if not item["enabled"]: + continue + try: + _load(item["name"]) + 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() diff --git a/src/study_tool/routes/misc.py b/src/study_tool/routes/misc.py index 614df20..642266f 100644 --- a/src/study_tool/routes/misc.py +++ b/src/study_tool/routes/misc.py @@ -1,11 +1,11 @@ -"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。""" +"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。""" import threading import time from fastapi.responses import HTMLResponse, JSONResponse 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.config import * from study_tool.engine import study_worker @@ -25,6 +25,44 @@ def api_platforms(): 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")