Files
study-tool/docs/pugin.md
T
study-tool 53bddbabaa docs: 按 json 驱动的插件方案更新 pugin.md
确定 pugin.json 为唯一权威:加载器读 json 注册,main.py 只提供函数;补加载器流程、schema 校验规则、迁移待办。
2026-10-31 14:51:42 +08:00

10 KiB
Raw Blame History

学习平台插件开发文档

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

流程:

  1. 遍历 pugins/ 下的子目录,跳过 __pycache__、loader.py 所在层级与没有 pugin.json 的目录。
  2. 读 pugin.json,按上表校验;校验失败写 runtime.log("system", ..., level="warn") 并跳过该插件。
  3. importlib.import_module("study_tool.pugins.<目录>.<main>")。
  4. 对已知槽位键逐个 getattr(模块, 函数名),组装成 Platform(id=..., label=..., entries=..., **槽位)。
  5. registry.register(platform);重复 id 以先加载者为准并记系统日志。
  6. 单个插件导入失败只影响该插件,不阻塞服务启动。

加载顺序按目录名排序,保证 GET /api/platforms 输出稳定。

开发流程

  1. 建目录:pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}。
  2. 在 main.py 里按需实现上表函数;cookie 只保留本平台域名,会话失效抛 platforms.errors.SessionExpired。
  3. 写 pugin.json,键名对齐槽位,值为函数名;id / label / entries / study 必填。
  4. 启动服务,看系统日志里该插件是否加载成功;GET /api/platforms 应出现该平台。
  5. 联调顺序:/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」

方案演进

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