8.8 KiB
学习平台插件开发文档
一个学习平台 = src/study_tool/pugins/<平台 id>/ 一个目录:main.py 放实现,pugin.json
放元数据,utils/ 放这个平台自己的工具。平台 id 同时是 accounts.platform 与
course["platform"] 的取值,必须与目录名、registry 里注册的 Platform.id 三者一致。
现状(重要)
新目录方案还没有接加载器,pugins/ 下的代码目前不会被执行:
- 启动时只调
platforms/__init__.py的load_builtin(),它 import 的是platforms.gdgxk与platforms.guanyixue。 pugins/gdgxk/main.py与platforms/gdgxk.py内容完全相同(副本)。- 没有任何代码读取
pugin.json;注册仍由main.py末尾的registry.register(Platform(...))完成。
所以现在改 pugins/gdgxk/main.py 不生效;要改行为先改 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/ 插件目录(一个平台一个子目录)
gdgxk/
__init__.py 留空,仅让目录成为包
pugin.json 插件元数据
main.py 实现 + registry.register(Platform(...))
utils/
__init__.py 该平台私有工具(可留空)
jspx/ 预留(迁移用占位)
__init__.py
约定:
- 目录名 =
Platform.id=accounts.platform=course["platform"]。 - 一个平台只注册一次;注册发生在 import 时,因此插件模块被导入不能有副作用(除注册)。
utils/只放这个平台自己的代码;跨平台共用的能力放platforms/(契约、注册表、错误类型)。- 同平台不同页面代际(如莞易学的旧版 / 新版)拆成同目录内的多个实现模块,由
Platform.study按course["generation"]分发,不要一个代际一个插件目录。
pugin.json
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"id": "PLATFORM_ID",
"label": "广东公需课",
"study": "study",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
}
字段含义与现状:
| 字段 | 含义 | 现状 |
|---|---|---|
title |
平台全称,给文档 / 未来的插件列表用 | 无代码读取 |
name |
插件名,与目录名一致 | 无代码读取 |
id |
Platform.id,写进 accounts.platform |
当前值是常量名 PLATFORM_ID,应该是 "gdgxk" |
label |
界面展示名(「添加账号」下拉) | 无代码读取,实际值在 main.py 里 |
study |
study 实现所在的函数名 |
无代码读取 |
entries |
登录 / 学习入口域名片段,用于识别平台归属 | 无代码读取,实际值在 main.py 里 |
注意:pugin.json 与 main.py 里的 Platform(...) 字段重复。在加载器定下来之前,
以 main.py 为准;pugin.json 只当元数据,改了它不会影响运行。
main.py 契约
实现 Platform 上的槽位,缺的阶段不实现(不要抛 NotImplementedError)。流程顺序由引擎负责,
插件只解决「这一步在这个站点怎么做」。
| 槽位 | 签名 | 调用方 | 要求 |
|---|---|---|---|
id / label |
字符串 | 注册表、GET /api/platforms、前端下拉 |
必填 |
entries |
tuple[str, ...] |
registry.detect() |
必填 |
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/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}。 -
在
main.py里按需实现上表槽位;cookie 只保留本平台域名,会话失效抛platforms.errors.SessionExpired。 -
文件末尾注册:
PLATFORM = registry.register(Platform( id="<平台 id>", label="<展示名>", entries=("<登录域名>", "<学习域名>"), session=new_session, dump_cookies=dump_cookies, login=login, list_courses=list_courses, discover=discover, study=study, )) -
填
pugin.json(当前只作文档用)。 -
接入加载:加载器就绪前,还需要在
platforms/__init__.py的load_builtin()里加一行 import,插件才会被注册。 -
联调顺序:
GET /api/platforms(下拉里出现该平台)→/api/login→/api/courses→/api/discover→/api/study。引擎、路由、前端都不需要改。
硬约束
- 日志只走
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描述元数据,platforms/里的实现逐步迁入。
待定项
- 加载器:扫描
pugins/*/自动 import,还是继续在load_builtin()手写 import? pugin.json与main.py的字段重复,谁是唯一来源(若以 json 为准,注册就要由加载器做)?- 命名:
pugins/pugin.json/pugin.md是否是最终拼写(英文通常是 plugins / plugin.json)。 platforms/是否最终删除;莞易学(jspx/study)是否迁入pugins/。platforms/gdgxk.py与pugins/gdgxk/main.py目前是两份副本,迁移完成前以哪份为准。pugin.json的"id": "PLATFORM_ID"应为具体值"gdgxk"。
相关文档:architecture.md(分层与平台契约)、OPTIMIZATION.md(待实测项)、 progress/todo.md(后续待办)。