Files
study-tool/docs/pugin.md
T

12 KiB
Raw Blame History

学习平台插件开发文档

一个学习平台 = 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/ 实现)。

  1. 遍历 pugins/ 下的子目录,跳过 __pycache__、loader.py 同层文件与没有 pugin.json 的目录。
  2. 读 pugin.json 并按上表校验;失败写 runtime.log("system", ..., level="warn") 后跳过。
  3. importlib.import_module("study_tool.pugins.<目录>.<main>")。
  4. cls = getattr(模块, json["class"]),确认 issubclass(cls, Platform)。
  5. inst = cls();用 json 覆盖 inst.id / inst.label / inst.entries(json 权威)。
  6. registry.register(inst)。
  7. 单个插件导入失败只影响该插件,不阻塞服务启动;按目录名排序加载,保证 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,插件只取自己需要的。

开发流程

  1. 建目录:pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}。
  2. 在 main.py 里写 class <平台类>(Platform):类属性 id / label / entries, 按需实现上表方法;cookie 只保留本平台域名,会话失效抛 platforms.errors.SessionExpired。
  3. 写 pugin.json(class 指向第 2 步的类名)。
  4. 启动服务,看系统日志里该插件是否加载成功;GET /api/platforms 应出现该平台。
  5. 联调顺序:/api/login → /api/courses → /api/discover → /api/study。 引擎、路由、前端都不需要改。

加载器就绪前,第 4 步要临时在 platforms/__init__.py 的 load_builtin() 里加一行 import 才会生效。

迁移已有平台(函数式 → 类式)

以 gdgxk 为例,步骤可复用:

  1. 保留模块级纯函数(_xhr / _b64 / _ocr / _parse_courses 等),它们不属于契约。
  2. 把原来的 session / dump_cookies / login / renew_session / profile / list_courses / discover / study 自由函数改成同名方法(加 self,互相调用时用 self.xxx),平台私有的重登逻辑改成 _relogin。
  3. 加类属性 id / label / entries,删掉模块级 PLATFORM = registry.register(Platform(...))。
  4. 把文件复制到 pugins/<id>/main.py,写 pugin.json(class 指向类名)。
  5. 验证:GET /api/platforms 出现该平台,登录 / 刷新课程 / 发现 / 学习行为与迁移前一致 (main.py 只提供类,注册由加载器做)。
  6. 确认一致后删除 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」

方案演进

  1. 早期:平台实现直接写在 engine.py 里。
  2. 拆包:platforms/base.py 定义 Platform 契约 + registry 注册表,引擎按 course["platform"] 分发;平台用自由函数 + Platform(...) 注册;学习流程仍在引擎。
  3. 契约补全:contract.py 推导 capabilities;SessionExpired 移到 platforms/errors.py; 各平台学习流程从引擎搬回平台模块(engine.py 只剩账号级编排)。
  4. 类式 + 目录化(当前):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。