# 学习平台插件开发文档 分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。 一个平台 = 一个 `Platform` 子类:类属性放元数据,自己确实有的阶段实现成方法,模块末尾 注册一次。插件式平台放 `src/study_tool/pugins/<名字>/`(`pugin.json` + `main.py`),由加载器 扫目录自动加载;跟共享实现模块关系紧密的放 `src/study_tool/platforms/<名字>.py`。 引擎、路由、前端都不需要改。 ## 插件骨架 ```python """<平台名>:登录 → 发现 → 学习。""" 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;正在跑的学习任务持有的是旧实例,跑到结束不受影响。 前端顶栏的「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`)就是这几个接口的界面。 ## 槽位契约 | 槽位 | 方法签名 | 调用方 | 要求 | | --- | --- | --- | --- | | `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` 已完成迁移。