12 KiB
学习平台插件开发文档
一个学习平台 = src/study_tool/pugins/<平台 id>/ 一个目录:main.py 里写一个
Platform 子类,pugin.json 描述元数据,utils/ 放这个平台自己的工具。
pugin.json 是唯一权威:平台注册由加载器读 pugin.json 完成,main.py 只提供类,
不再自己 registry.register。
插件形态
插件是一个 Platform 子类:元数据用类属性,阶段用方法;没实现的阶段不要写,
保持基类的 None。私有辅助用 _ 前缀,不进契约。
class Gdgxk(Platform):
id = "gdgxk"
label = "广东公需课"
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
def session(self, cookies=None): ...
def dump_cookies(self, s): ...
def login(self, username, password): ...
def renew_session(self, s): ...
def profile(self, s): ...
def list_courses(self, s): ...
def discover(self, s, plan_id, project_id, name=""): ...
def study(self, key, s, d, **opts): ...
def _relogin(self, key): ... # 平台私有,不属于契约
platforms/base.py 的 Platform 就是普通基类:id / label / entries 与 8 个槽位
(study / session / dump_cookies / login / list_courses / discover /
renew_session / profile)在基类里默认 None / 空值,capabilities() 按「子类是否把它
覆盖成方法」推导能力名,引擎按能力决定走不走该分支。
现状(重要)
目录方案与基类已经改成类式,加载器还没写,且当前工作区里莞易学导入即失败:
platforms/base.py已是普通类,不再接受Platform(id=..., label=..., study=...)这种 函数式注册(实测TypeError: Platform() takes no arguments)。platforms/gdgxk.py已迁成class Gdgxk(Platform)并在末尾registry.register(Gdgxk()), 可正常导入。platforms/guanyixue.py还是函数式注册 →import study_tool.platforms.guanyixue直接TypeError;load_builtin()会 import 它,所以服务启动会失败。迁移完成前必须二选一: 给Platform补一个接受关键字槽位的__init__,或把莞易学也改成子类。pugins/gdgxk/main.py还是迁移前的自由函数副本(与现在的platforms/gdgxk.py不同),pugins/gdgxk/pugin.json的"id": "PLATFORM_ID"也还是常量名,二者都要按新形态重写。- 没有任何代码读
pugin.json。
目录结构
src/study_tool/
platforms/ 基类与现有实现(迁移中)
base.py Platform 基类:类属性 + 槽位默认 None
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 实现:一个 Platform 子类
utils/
__init__.py 该平台私有工具(可留空)
jspx/ 预留(迁移用占位)
__init__.py
约定:
- 目录名 =
pugin.json.name= 类属性id=Platform.id=accounts.platform=course["platform"]。 main.py被 import 时不能有副作用(加载器负责注册)。utils/只放这个平台自己的代码;跨平台共用的能力放platforms/(基类、契约、注册表、错误类型)。- 同平台不同页面代际(如莞易学旧版 / 新版)在同一个子类里用
generation分发,不要一个代际 一个插件目录。
pugin.json
类式插件不需要再逐槽位映射函数名,只需要元数据 + 类名:
{
"title": "广东省教师继续教育管理平台",
"name": "gdgxk",
"class": "Gdgxk",
"id": "gdgxk",
"label": "广东公需课",
"entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"]
}
| 键 | 含义 | 加载器行为 |
|---|---|---|
title |
平台全称,文档 / 插件列表展示 | 不参与注册 |
name |
目录名 | 与实际目录不一致则拒绝加载 |
class |
main.py 里的类名 |
必填;getattr 取类,必须是 Platform 子类 |
id |
Platform.id,写进 accounts.platform |
必填,写入实例 |
label |
界面展示名(「添加账号」下拉) | 必填,写入实例 |
entries |
登录 / 学习入口域名片段 | 必填非空列表,写入实例 |
main |
实现模块名,省略时为 main |
导入 pugins.<目录>.<main> |
| 其它键 | 预留(作者、版本、图标等) | 忽略 |
校验与优先级:缺 id / label / entries / class 拒绝加载;类不是 Platform 子类拒绝加载;
class 里的类属性与 json 不一致时以 json 为准,并把差异写进系统日志;同一 id
重复注册以先加载者为准并记系统日志。
加载器
放在 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>")。cls = getattr(模块, json["class"]),确认issubclass(cls, Platform)。inst = cls();用 json 覆盖inst.id/inst.label/inst.entries(json 权威)。registry.register(inst)。- 单个插件导入失败只影响该插件,不阻塞服务启动;按目录名排序加载,保证
GET /api/platforms输出稳定。
槽位契约
| 槽位 | 方法签名 | 调用方 | 要求 |
|---|---|---|---|
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,插件只取自己需要的。
开发流程
- 建目录:
pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}。 - 在
main.py里写class <平台类>(Platform):类属性id/label/entries, 按需实现上表方法;cookie 只保留本平台域名,会话失效抛platforms.errors.SessionExpired。 - 写
pugin.json(class指向第 2 步的类名)。 - 启动服务,看系统日志里该插件是否加载成功;
GET /api/platforms应出现该平台。 - 联调顺序:
/api/login→/api/courses→/api/discover→/api/study。 引擎、路由、前端都不需要改。
加载器就绪前,第 4 步要临时在 platforms/__init__.py 的 load_builtin() 里加一行 import
才会生效。
迁移已有平台(函数式 → 类式)
以 gdgxk 为例,步骤可复用:
- 保留模块级纯函数(
_xhr/_b64/_ocr/_parse_courses等),它们不属于契约。 - 把原来的
session/dump_cookies/login/renew_session/profile/list_courses/discover/study自由函数改成同名方法(加self,互相调用时用self.xxx),平台私有的重登逻辑改成_relogin。 - 加类属性
id/label/entries,删掉模块级PLATFORM = registry.register(Platform(...))。 - 把文件复制到
pugins/<id>/main.py,写pugin.json(class指向类名)。 - 验证:
GET /api/platforms出现该平台,登录 / 刷新课程 / 发现 / 学习行为与迁移前一致 (main.py只提供类,注册由加载器做)。 - 确认一致后删除
platforms/下的旧实现;莞易学按同样方式迁成class Guanyixue(Platform)(study内按course["generation"]分发到jspx/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"]分发;平台用自由函数 +Platform(...)注册;学习流程仍在引擎。 - 契约补全:
contract.py推导capabilities;SessionExpired移到platforms/errors.py; 各平台学习流程从引擎搬回平台模块(engine.py只剩账号级编排)。 - 类式 + 目录化(当前):
Platform变成普通基类,平台写成子类(类属性 + 方法); 一个平台一个目录pugins/<id>/,pugin.json描述元数据,加载器读 json 注册。
待办
- 先解决启动阻塞:
platforms/guanyixue.py仍是函数式注册,load_builtin()会 import 它。 要么给Platform补关键字__init__,要么把莞易学改成class Guanyixue(Platform)。 - 实现
pugins/loader.py并让load_builtin()调用它。 - 按类式重写
pugins/gdgxk/{main.py, pugin.json}(当前是自由函数副本 + 错的id)。 - 迁移顺序:gdgxk 验证「json 注册与 platforms 版行为一致」→ 莞易学 → 删除
platforms/里的 具体平台实现。 - 迁移完成后同步更新 architecture.md(仍描述函数式
Platform契约)与AGENTS.md里的「平台代码只能放在 platforms/」约定。 - 清理临时脚本
script/reference/_migrate_gdgxk_tmp.py。