Files
study-tool/docs/pugin.md
T
study-tool f8458cff51 feat(plugins): 插件支持热加载与启用/停用清单
- pugins/__init__.py 重写:load_plugins / discover / set_enabled / reload
- 热加载:把 study_tool.pugins.<名字> 的模块从 sys.modules 摘掉后重新 import;
  重载前先 registry.unregister 旧 id,平台 id 改了也不会留残影
- 启用清单存设置项 plugins({"disabled": [...]}):未列出的默认启用,新插件自动生效;
  停用即从注册表摘掉,/api/platforms 与账号平台下拉同步消失
- platforms/registry.py 新增 unregister(pid)
- 新接口:GET /api/plugins、POST /api/plugins/{name}/enable|disable、
  POST /api/plugins/reload、POST /api/plugins/{name}/reload;失败返回 ok=false
- 同步 docs/pugin.md(新增「加载与启停」)、docs/architecture.md、docs/OPTIMIZATION.md、AGENTS.md
2026-10-31 15:50:06 +08:00

10 KiB
Raw Blame History

学习平台插件开发文档

分层与整体结构见 architecture.md,本文只讲「怎么写一个平台插件」。

一个平台 = 一个 Platform 子类:类属性放元数据,自己确实有的阶段实现成方法,模块末尾 注册一次。插件式平台放 src/study_tool/pugins/<名字>/(pugin.json + main.py),由加载器 扫目录自动加载;跟共享实现模块关系紧密的放 src/study_tool/platforms/<名字>.py。 引擎、路由、前端都不需要改。

插件骨架

"""<平台名>:登录 → 发现 → 学习。"""
import requests

from study_tool import runtime
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
from study_tool.platforms.errors import SessionExpired

UA = "Mozilla/5.0 ..."
DOMAINS = ("<平台域名>",)


class MyPlatform(Platform):
    id = "<平台 id>"                       # 写进 accounts.platform / course["platform"]
    label = "展示名"                        # 日志与界面展示
    entries = ("<登录入口域名>", "<学习入口域名>")   # 识别平台归属

    def session(self, cookies=None):
        s = requests.Session()
        s.headers.update({"User-Agent": UA})
        for c in cookies or []:
            if not any(d in (c.get("domain") or "") for d in DOMAINS):
                continue
            s.cookies.set(c["name"], c.get("value") or "",
                          domain=(c.get("domain") or "").lstrip("."),
                          path=c.get("path") or "/")
        return s

    def dump_cookies(self, s):
        return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
                for c in s.cookies]

    def login(self, username, password):
        s = self.session()
        # ... 登录失败抛 RuntimeError;会话失效抛 SessionExpired
        return s, self.profile(s)

    def list_courses(self, s):
        # -> [{"name", "plan_id", "project_id", "in_study", ...}]
        ...

    def discover(self, s, plan_id, project_id, name=""):
        return {"platform": self.id, "course": name, "progress": "0/0", "info": "",
                "user": "", "courseware": [], "feedback": [], "test": []}

    def study(self, key, s, d, **opts):
        for cw in d["courseware"]:
            if self._stopped(key):
                return
            runtime.log(key, "开始:%s" % cw["title"])
            # ... 上报进度;被踢回登录页时 raise SessionExpired("...")
            runtime.log(key, "完成:%s" % cw["title"])

    def _stopped(self, key):               # 私有辅助:_ 开头,不属于契约
        with runtime.RUNS_LOCK:
            return (runtime.RUNS.get(key) or {}).get("status") == "stopped"


PLATFORM = registry.register(MyPlatform())

平台确实没有的阶段不要写,保持基类的 None;capabilities() 按「子类是否把它覆盖成 方法」推导能力名,前端据此决定显示哪些按钮。

目录结构

src/study_tool/pugins/
  __init__.py     加载器:load_plugins / discover / set_enabled / reload
  <名字>/
    pugin.json    注册信息:id / title / name(不写能力绑定)
    main.py       class <名字>(Platform)
  gdgxk/          广东公需课:pugin.json + main.py

约定:

  • 一个平台只注册一次;注册发生在模块被 import 时,模块里除注册外不要有副作用。
  • 平台 id = 模块里 id = accounts.platform = course["platform"],三处必须一致。
  • 同一平台不同页面代际拆成同目录下的实现模块(jspx.py / study.py),由平台类按 course["generation"] 分发,不要一个代际注册一个平台。
  • 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 base.py / contract.py / errors.py。

加载与启停

加载器在 pugins/__init__.py,对外四个函数 + 对应 HTTP 接口:

函数 接口 作用
load_plugins() 启动时自动调用 按启用清单 import 全部插件
discover() GET /api/plugins 磁盘清单 + 是否启用 / 已加载 / label / capabilities
set_enabled(name, bool) POST /api/plugins/{name}/enable、/disable 立即加载或从注册表摘掉,并落库
reload(name=None) POST /api/plugins/reload、/api/plugins/{name}/reload 重新 import,改完插件代码不用重启

启用清单存在设置项 plugins({"disabled": [...]}):没列进去的默认启用,新丢进目录的插件自动生效。 停用的插件会 registry.unregister(),从 /api/platforms 消失,账号的平台下拉里也不会出现。 热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。

槽位契约

槽位 方法签名 调用方 要求
study (self, key, s, d, **opts) engine.run_one_course 必填
discover (self, s, plan_id, project_id, name) run_one_course、/api/discover 学习前必填
session (self, cookies) -> requests.Session 引擎与各路由 强烈建议(缺失直接报错)
dump_cookies (self, s) -> [cookie] 登录 / 刷新 / 发现后回写前端 强烈建议
login (self, username, password) -> (session, user) /api/login 要密码登录则必填
list_courses (self, s) -> [course] 登录后、/api/courses 要刷新课程则必填
renew_session (self, s) -> bool SessionExpired 时无密码续期 选填
profile (self, 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. 新建 src/study_tool/pugins/<名字>/,放两个文件:pugin.json(id / title / name) 和 main.py(按上面的骨架写 class <名字>(Platform))。
  2. main.py 末尾 PLATFORM = registry.register(<名字>())。
  3. 不需要改 load_builtin()、路由或前端 —— 加载器扫目录时自动 import 各插件。 跟 jspx / study 这类共享实现模块关系紧密的平台,仍可以放 platforms/<名字>.py, 在 load_builtin() 里加一行 import。
  4. 启动服务,GET /api/platforms 应出现该平台。
  5. 联调顺序:/api/login → /api/courses → /api/discover → /api/study。

把已有实现改成子类

老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:

  1. 保留模块级纯函数(_b64 / _ocr / _parse_courses 等),它们不属于契约。
  2. 把原来的槽位自由函数改成同名方法:加 self,模块内互相调用改成 self.xxx, 平台私有的重登逻辑改成 _relogin 这类私有方法。
  3. 加类属性 id / label / entries,删掉模块末尾的逐项注册。
  4. 模块末尾改成 PLATFORM = registry.register(<类名>())。
  5. 逐个验证槽位行为与迁移前一致;同一平台的两个代际由 study 里按 course["generation"] 分发(参见 guanyixue.py)。

硬约束

  • 日志只走 runtime.log(key, msg, level=...),key 用引擎传进来的账号 id;不要自己加时间前缀。
  • 循环里要响应停止:(runtime.RUNS.get(key) or {}).get("status") == "stopped" 时返回。
  • 会话失效抛 SessionExpired;自动续期 / 用密码重登由引擎统一处理(renew_session 里除外)。
  • 不 import engine / routes / 前端;平台之间只允许同平台不同代际互相 import, 模块级互相 import 成环时改用函数内延迟导入(参见 study.py ↔ jspx.py)。
  • cookie 域名过滤在平台自己的 session 里做,引擎不再兜底:漏写 session 会直接报 「平台没有实现 session」,不会静默过滤。
  • 时间、解析、编码这类通用能力用现成库,不要手写。

联调检查表

  • GET /api/platforms 里能看到该平台,label 与 capabilities 正确
  • 添加账号 → 选平台 → 登录成功,卡片显示 profile 名字
  • 刷新课程返回非空列表,in_study 标记正确
  • 发现课程返回的活动数与页面一致
  • 开始学习:日志出现在该账号卡片里,进度递增
  • 点停止后几秒内循环退出
  • 会话失效时能走到续期 / 重登,不需要手动刷新
  • 平台没实现 session 时,/api/courses 报「还没有实现 session」

常见坑

  • 自由函数改方法后忘加 self:模块内原来的 _relogin(key) 必须写成 self._relogin(key), 否则运行时才报错;提交前可用 symtable 扫一遍模块的未定义全局名。
  • 注册写两遍:既在类里又在模块末尾逐项注册,会导致同一个 id 被覆盖,capabilities 也可能和实际方法不一致。只保留 registry.register(<类名>())。
  • cookie 域写错:session 里过滤的是保存下来的 cookie,域名不匹配会得到空会话, 表现为「登录成功但刷新课程提示未登录」。
  • 日志 key 用错:平台用自己拼的字符串当 key,日志会跑进别的卡片或 system 弹窗。
  • 把代际当平台:旧版 / 新版注册成两个平台,账号绑定会漂移;应该在 discover 里写 generation,由 study 分发。

方案演进

  1. 早期:平台实现直接写在 engine.py 里。
  2. 拆包:platforms/base.py 定义 Platform 契约 + registry 注册表,引擎按 course["platform"] 分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。
  3. 契约补全:contract.py 推导 capabilities;SessionExpired 移到 platforms/errors.py; 各平台学习流程从引擎搬回平台模块(engine.py 只剩账号级编排)。
  4. 类式(当前):Platform 变成普通基类,平台写成子类(类属性 + 方法), 禁止逐项传函数注册;gdgxk 与 guanyixue 已完成迁移。