确定 pugin.json 为唯一权威:加载器读 json 注册,main.py 只提供函数;补加载器流程、schema 校验规则、迁移待办。
10 KiB
学习平台插件开发文档
一个学习平台 = src/study_tool/pugins/<平台 id>/ 一个目录:main.py 放实现,
pugin.json 放元数据与槽位映射,utils/ 放这个平台自己的工具。
pugin.json 是唯一权威:平台注册由加载器读 pugin.json 完成,main.py 只提供函数,
不再自己 registry.register。
现状(重要)
目录方案已定,加载器还没写,pugins/ 下的代码目前不会被执行:
- 启动只调
platforms/__init__.py的load_builtin(),它 import 的是platforms.gdgxk与platforms.guanyixue,没有任何代码读pugin.json。 pugins/gdgxk/main.py与platforms/gdgxk.py内容完全相同(副本),且main.py末尾仍有一段registry.register(Platform(...)),与「json 权威」冲突。pugins/gdgxk/pugin.json的"id": "PLATFORM_ID"写的是常量名,加载器按 json 注册会把 id 注册成字面量PLATFORM_ID,接入前必须改成"gdgxk"。
加载器落地前的行为以 platforms/ 为准;要改 gdgxk 现有行为,先改 platforms/gdgxk.py。
目录结构
src/study_tool/
platforms/ 现有实现,加载器切换前仍然生效
base.py Platform 契约
contract.py 生命周期 ↔ 槽位 ↔ 能力名映射
errors.py SessionExpired
registry.py register / get / detect / all
detect.py 页面代际识别(legacy / spa)
guanyixue.py jspx.py study.py gdgxk.py
pugins/ 插件目录(一个平台一个子目录)
loader.py (待实现)扫描 pugin.json 并注册平台
gdgxk/
__init__.py 留空,仅让目录成为包
pugin.json 元数据 + 槽位映射(唯一权威)
main.py 实现函数,不自注册
utils/
__init__.py 该平台私有工具(可留空)
jspx/ 预留(迁移用占位)
__init__.py
约定:
- 目录名 =
pugin.json.name=Platform.id=accounts.platform=course["platform"]。 main.py被 import 时不能有副作用(加载器负责注册)。utils/只放这个平台自己的代码;跨平台共用的能力放platforms/(契约、注册表、错误类型)。- 同平台不同页面代际(如莞易学的旧版 / 新版)拆成同目录内多个实现模块,由
Platform.study按course["generation"]分发,不要一个代际一个插件目录。
pugin.json
当前内容(gdgxk):
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"id": "PLATFORM_ID",
"label": "广东公需课",
"study": "study",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
}
目标 schema:键名即槽位名,值为 main.py 里的函数名;只写实现了的槽位,没写的视为不支持。
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"id": "gdgxk",
"label": "广东公需课",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"],
"login": "login",
"session": "new_session",
"dump_cookies": "dump_cookies",
"list_courses": "list_courses",
"discover": "discover",
"study": "study",
"renew_session": "renew_session",
"profile": "profile"
}
| 键 | 含义 | 加载器行为 |
|---|---|---|
title |
平台全称,文档 / 插件列表展示 | 不参与注册 |
name |
目录名,必须与所在目录一致 | 校验不一致则拒绝加载 |
id |
Platform.id,写进 accounts.platform |
必填,直接作为 Platform.id |
label |
界面展示名(「添加账号」下拉) | 必填,作为 Platform.label |
entries |
登录 / 学习入口域名片段 | 必填,作为 Platform.entries |
main |
实现模块名,省略时为 main |
importlib 导入 pugins.<目录>.<main> |
login session dump_cookies list_courses discover study renew_session profile |
槽位 → main.py 里的函数名 |
getattr 取函数,必须是可调用对象 |
| 其它键 | 预留(作者、版本、图标等) | 忽略 |
校验规则:缺 id / label / entries / study 拒绝加载;entries 非空列表;
槽位键在 main.py 里找不到或不是 callable 拒绝加载;同一 id 重复注册以先加载的为准并记系统日志。
main.py 契约
只写函数,不写 registry.register。缺的阶段不实现(不要抛 NotImplementedError),
流程顺序由引擎负责,插件只解决「这一步在这个站点怎么做」。
| 槽位 | 签名 | 调用方 | 要求 |
|---|---|---|---|
study |
(key, s, d, **opts) |
engine.run_one_course |
必填 |
discover |
(s, plan_id, project_id, name) |
run_one_course、/api/discover |
学习前必填 |
session |
(cookies) -> requests.Session |
引擎与各路由 | 强烈建议(缺失会直接报错) |
dump_cookies |
(s) -> [cookie] |
登录 / 刷新 / 发现后回写前端 | 强烈建议 |
login |
(username, password) -> (session, user) |
/api/login |
要密码登录则必填 |
list_courses |
(s) -> [course] |
登录后、/api/courses |
要刷新课程则必填 |
renew_session |
(s) -> bool |
SessionExpired 时无密码续期 |
选填 |
profile |
(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,插件只取自己需要的。
Platform.capabilities() 按加载后非空的槽位推导能力名(映射见 platforms/contract.py),
GET /api/platforms 会把它返回给前端。
加载器
放在 pugins/loader.py,由 platforms/__init__.py 的 load_builtin() 调用(加载器就绪前
load_builtin() 继续手写 import 现有 platforms/ 实现)。
流程:
- 遍历
pugins/下的子目录,跳过__pycache__、loader.py所在层级与没有pugin.json的目录。 - 读
pugin.json,按上表校验;校验失败写runtime.log("system", ..., level="warn")并跳过该插件。 importlib.import_module("study_tool.pugins.<目录>.<main>")。- 对已知槽位键逐个
getattr(模块, 函数名),组装成Platform(id=..., label=..., entries=..., **槽位)。 registry.register(platform);重复id以先加载者为准并记系统日志。- 单个插件导入失败只影响该插件,不阻塞服务启动。
加载顺序按目录名排序,保证 GET /api/platforms 输出稳定。
开发流程
- 建目录:
pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}。 - 在
main.py里按需实现上表函数;cookie 只保留本平台域名,会话失效抛platforms.errors.SessionExpired。 - 写
pugin.json,键名对齐槽位,值为函数名;id/label/entries/study必填。 - 启动服务,看系统日志里该插件是否加载成功;
GET /api/platforms应出现该平台。 - 联调顺序:
/api/login→/api/courses→/api/discover→/api/study。 引擎、路由、前端都不需要改。
加载器就绪前,第 4 步要临时在 platforms/__init__.py 的 load_builtin() 里加一行 import
才会生效。
硬约束
- 日志只走
runtime.log(key, msg, level=...),key用引擎传进来的账号 id;不要自己加时间前缀。 - 循环里要响应停止:
(runtime.RUNS.get(key) or {}).get("status") == "stopped"时返回。 - 会话失效抛
SessionExpired;自动续期 / 用密码重登由引擎统一处理(renew_session里除外)。 - 不 import
engine/routes/ 前端;平台之间只允许同平台不同代际互相 import, 模块级互相 import 成环时改用函数内延迟导入。 - 时间、解析、编码这类通用能力用现成库,不要手写。
- 通用能力(契约、注册表、错误类型、日志)放
platforms/,平台私有代码放自己的目录。
联调检查表
- 启动后系统日志里没有该插件的加载告警
GET /api/platforms里能看到该平台,label正确- 添加账号 → 选平台 → 登录成功,卡片显示
profile名字 - 刷新课程返回非空列表,
in_study标记正确 - 发现课程返回的活动数与页面一致
- 开始学习:日志出现在该账号卡片里,进度递增
- 点停止后几秒内循环退出
- 会话失效时能走到续期 / 重登,不需要手动刷新
- 平台没实现
session时,/api/courses明确报「还没有实现 session」
方案演进
- 早期:平台实现直接写在
engine.py里。 - 拆包:
platforms/base.py定义Platform契约 +registry注册表,引擎按course["platform"]分发;学习流程仍在引擎。 - 契约补全:
contract.py用「已实现槽位 → 能力名」推导capabilities;SessionExpired移到platforms/errors.py;各平台的学习流程从引擎搬回平台模块(engine.py只剩账号级编排)。 - 目录化(当前):一个平台一个目录
pugins/<id>/,pugin.json描述元数据与槽位映射, 加载器读 json 注册;platforms/里的实现逐步迁入。
待办
- 实现
pugins/loader.py并让load_builtin()调用它;现有platforms/实现先保持原样。 - 修正
pugins/gdgxk/pugin.json的id(PLATFORM_ID→gdgxk),补齐其余槽位键。 - 删除
pugins/gdgxk/main.py末尾的registry.register(...)(注册改由加载器做)。 - 迁移顺序:先用 gdgxk 验证「json 注册与 platforms 版行为一致」,再迁莞易学(
jspx/study), 最后删除platforms/里的具体平台实现。 - 迁移完成后同步更新 architecture.md(当前仍描述
platforms/方案)与AGENTS.md里的「平台代码只能放在 platforms/」约定。