f8458cff51
- 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
10 KiB
10 KiB
学习平台插件开发文档
分层与整体结构见 architecture.md,本文只讲「怎么写一个平台插件」。
一个平台 = 一个 Platform 子类:类属性放元数据,自己确实有的阶段实现成方法,模块末尾
注册一次。插件式平台放 src/study_tool/pugins/<名字>/(pugin.json + main.py),由加载器
扫目录自动加载;跟共享实现模块关系紧密的放 src/study_tool/platforms/<名字>.py。
引擎、路由、前端都不需要改。
插件骨架
"""<平台名>:登录 → 发现 → 学习。"""
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;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
槽位契约
| 槽位 | 方法签名 | 调用方 | 要求 |
|---|---|---|---|
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,插件只取自己需要的。
开发流程
- 新建
src/study_tool/pugins/<名字>/,放两个文件:pugin.json(id/title/name) 和main.py(按上面的骨架写class <名字>(Platform))。 main.py末尾PLATFORM = registry.register(<名字>())。- 不需要改
load_builtin()、路由或前端 —— 加载器扫目录时自动 import 各插件。 跟jspx/study这类共享实现模块关系紧密的平台,仍可以放platforms/<名字>.py, 在load_builtin()里加一行 import。 - 启动服务,
GET /api/platforms应出现该平台。 - 联调顺序:
/api/login→/api/courses→/api/discover→/api/study。
把已有实现改成子类
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
- 保留模块级纯函数(
_b64/_ocr/_parse_courses等),它们不属于契约。 - 把原来的槽位自由函数改成同名方法:加
self,模块内互相调用改成self.xxx, 平台私有的重登逻辑改成_relogin这类私有方法。 - 加类属性
id/label/entries,删掉模块末尾的逐项注册。 - 模块末尾改成
PLATFORM = registry.register(<类名>())。 - 逐个验证槽位行为与迁移前一致;同一平台的两个代际由
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分发。
方案演进
- 早期:平台实现直接写在
engine.py里。 - 拆包:
platforms/base.py定义Platform契约 +registry注册表,引擎按course["platform"]分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。 - 契约补全:
contract.py推导capabilities;SessionExpired移到platforms/errors.py; 各平台学习流程从引擎搬回平台模块(engine.py只剩账号级编排)。 - 类式(当前):
Platform变成普通基类,平台写成子类(类属性 + 方法), 禁止逐项传函数注册;gdgxk与guanyixue已完成迁移。