diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md index b020e95..ce5c3b4 100644 --- a/docs/OPTIMIZATION.md +++ b/docs/OPTIMIZATION.md @@ -16,6 +16,7 @@ - 删掉 `src/study_tool/pugins/` 草稿目录:它和 `platforms/gdgxk.py` 内容重复, `pugin.json` 里「按名字声明 `study` / `entries`」的绑定方式作废,元数据改为类属性。 - 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。 +- 新增 [docs/pugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。 ### 怎么测 diff --git a/docs/pugin.md b/docs/pugin.md index 5922ba2..95121cf 100644 --- a/docs/pugin.md +++ b/docs/pugin.md @@ -1,141 +1,113 @@ # 学习平台插件开发文档 -一个学习平台 = `src/study_tool/pugins/<平台 id>/` 一个目录:`main.py` 里写一个 -`Platform` 子类,`pugin.json` 描述元数据,`utils/` 放这个平台自己的工具。 +分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。 -**`pugin.json` 是唯一权威**:平台注册由加载器读 `pugin.json` 完成,`main.py` 只提供类, -不再自己 `registry.register`。 +一个平台 = `src/study_tool/platforms/<平台>.py` 里的一个 `Platform` 子类:类属性放元数据, +自己确实有的阶段实现成方法,模块末尾注册一次。引擎、路由、前端都不需要改。 -## 插件形态 - -插件是一个 `Platform` 子类:元数据用类属性,阶段用方法;没实现的阶段不要写, -保持基类的 `None`。私有辅助用 `_` 前缀,不进契约。 +## 插件骨架 ```python -class Gdgxk(Platform): - id = "gdgxk" - label = "广东公需课" - entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn") +"""<平台名>:登录 → 发现 → 学习。""" +import requests - 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): ... # 平台私有,不属于契约 +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()) ``` -`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`。 +平台确实没有的阶段**不要写**,保持基类的 `None`;`capabilities()` 按「子类是否把它覆盖成 +方法」推导能力名,前端据此决定显示哪些按钮。 ## 目录结构 ``` -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 +src/study_tool/platforms/ + base.py Platform 基类:id / label / entries + 8 个槽位默认 None + contract.py 槽位 ↔ 能力名映射,capabilities() 用它推导 + errors.py SessionExpired(会话失效,引擎据此续期 / 重登) + registry.py register / get / detect / all + detect.py 页面代际识别(legacy 旧版 / spa 新版) + guanyixue.py 平台:莞易学(jspx 登录入口 + study 学习入口,按 generation 分发) + jspx.py 同平台实现模块:旧版页面(不是插件,不注册) + study.py 同平台实现模块:新版 SPA(不是插件,不注册) + gdgxk.py 平台:广东公需课 ``` 约定: -- 目录名 = `pugin.json.name` = 类属性 `id` = `Platform.id` = `accounts.platform` = - `course["platform"]`。 -- `main.py` 被 import 时不能有副作用(加载器负责注册)。 -- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(基类、契约、注册表、错误类型)。 -- 同平台不同页面代际(如莞易学旧版 / 新版)在同一个子类里用 `generation` 分发,不要一个代际 - 一个插件目录。 - -## pugin.json - -类式插件不需要再逐槽位映射函数名,只需要元数据 + 类名: - -```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.<目录>.
` | -| 其它键 | 预留(作者、版本、图标等) | 忽略 | - -校验与优先级:缺 `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.<目录>.
")`。 -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` 输出稳定。 +- 一个平台只注册一次;注册发生在模块被 import 时,模块里除注册外不要有副作用。 +- 平台 id = 模块里 `id` = `accounts.platform` = `course["platform"]`,三处必须一致。 +- 同一平台不同页面代际拆成同目录下的实现模块(`jspx.py` / `study.py`),由平台类按 + `course["generation"]` 分发,不要一个代际注册一个平台。 +- 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `base.py` / + `contract.py` / `errors.py`。 ## 槽位契约 | 槽位 | 方法签名 | 调用方 | 要求 | | --- | --- | --- | --- | -| `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` | `(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`),必须包含: @@ -149,32 +121,25 @@ 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` 应出现该平台。 +1. 新建 `src/study_tool/platforms/<名字>.py`,按上面的骨架写 `class <名字>(Platform)`: + 类属性 `id` / `label` / `entries`,按需实现槽位方法。 +2. 模块末尾 `PLATFORM = registry.register(<名字>())`。 +3. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 + `from study_tool.platforms import <名字>`。 +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//main.py`,写 `pugin.json`(`class` 指向类名)。 -5. 验证:`GET /api/platforms` 出现该平台,登录 / 刷新课程 / 发现 / 学习行为与迁移前一致 - (`main.py` 只提供类,注册由加载器做)。 -6. 确认一致后删除 `platforms/` 下的旧实现;莞易学按同样方式迁成 - `class Guanyixue(Platform)`(`study` 内按 `course["generation"]` 分发到 `jspx` / `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`)。 ## 硬约束 @@ -182,40 +147,40 @@ quiz_key / cookies / plan / project / name`,插件只取自己需要的。 - 循环里要响应停止:`(runtime.RUNS.get(key) or {}).get("status") == "stopped"` 时返回。 - 会话失效抛 `SessionExpired`;自动续期 / 用密码重登由引擎统一处理(`renew_session` 里除外)。 - 不 import `engine` / `routes` / 前端;平台之间只允许同平台不同代际互相 import, - 模块级互相 import 成环时改用函数内延迟导入。 + 模块级互相 import 成环时改用函数内延迟导入(参见 `study.py` ↔ `jspx.py`)。 +- cookie 域名过滤在平台自己的 `session` 里做,引擎不再兜底:漏写 `session` 会直接报 + 「平台没有实现 session」,不会静默过滤。 - 时间、解析、编码这类通用能力用现成库,不要手写。 -- 通用能力(基类、契约、注册表、错误类型、日志)放 `platforms/`,平台私有代码放自己的目录。 ## 联调检查表 -- [ ] 启动后系统日志里没有该插件的加载告警 -- [ ] `GET /api/platforms` 里能看到该平台,`label` 正确 +- [ ] `GET /api/platforms` 里能看到该平台,`label` 与 `capabilities` 正确 - [ ] 添加账号 → 选平台 → 登录成功,卡片显示 `profile` 名字 - [ ] 刷新课程返回非空列表,`in_study` 标记正确 - [ ] 发现课程返回的活动数与页面一致 - [ ] 开始学习:日志出现在该账号卡片里,进度递增 - [ ] 点停止后几秒内循环退出 - [ ] 会话失效时能走到续期 / 重登,不需要手动刷新 -- [ ] 平台没实现 `session` 时,`/api/courses` 明确报「还没有实现 session」 +- [ ] 平台没实现 `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"]` 分发;平台用自由函数 + `Platform(...)` 注册;学习流程仍在引擎。 + `course["platform"]` 分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。 3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`; 各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。 -4. 类式 + 目录化(当前):`Platform` 变成普通基类,平台写成子类(类属性 + 方法); - 一个平台一个目录 `pugins//`,`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](architecture.md)(仍描述函数式 `Platform` 契约)与 - `AGENTS.md` 里的「平台代码只能放在 platforms/」约定。 -- 清理临时脚本 `script/reference/_migrate_gdgxk_tmp.py`。 +4. 类式(当前):`Platform` 变成普通基类,平台写成子类(类属性 + 方法), + 禁止逐项传函数注册;`gdgxk` 与 `guanyixue` 已完成迁移。