From a6aa6a88f9e31dac1e7f3936865cd44f441ad39a Mon Sep 17 00:00:00 2001 From: study-tool Date: Sat, 31 Oct 2026 14:43:15 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B2=89=E6=B7=80=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=96=B9=E6=A1=88=E4=B8=8E=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=EF=BC=88docs/pugin.md=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/pugin.md | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/pugin.md diff --git a/docs/pugin.md b/docs/pugin.md new file mode 100644 index 0000000..ac64668 --- /dev/null +++ b/docs/pugin.md @@ -0,0 +1,171 @@ +# 学习平台插件开发文档 + +一个学习平台 = `src/study_tool/pugins/<平台 id>/` 一个目录:`main.py` 放实现,`pugin.json` +放元数据,`utils/` 放这个平台自己的工具。平台 id 同时是 `accounts.platform` 与 +`course["platform"]` 的取值,必须与目录名、`registry` 里注册的 `Platform.id` 三者一致。 + +## 现状(重要) + +新目录方案还没有接加载器,**`pugins/` 下的代码目前不会被执行**: + +- 启动时只调 `platforms/__init__.py` 的 `load_builtin()`,它 import 的是 + `platforms.gdgxk` 与 `platforms.guanyixue`。 +- `pugins/gdgxk/main.py` 与 `platforms/gdgxk.py` 内容完全相同(副本)。 +- 没有任何代码读取 `pugin.json`;注册仍由 `main.py` 末尾的 + `registry.register(Platform(...))` 完成。 + +所以现在改 `pugins/gdgxk/main.py` 不生效;要改行为先改 `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/ 插件目录(一个平台一个子目录) + gdgxk/ + __init__.py 留空,仅让目录成为包 + pugin.json 插件元数据 + main.py 实现 + registry.register(Platform(...)) + utils/ + __init__.py 该平台私有工具(可留空) + jspx/ 预留(迁移用占位) + __init__.py +``` + +约定: + +- 目录名 = `Platform.id` = `accounts.platform` = `course["platform"]`。 +- 一个平台只注册一次;注册发生在 import 时,因此插件模块被导入不能有副作用(除注册)。 +- `utils/` 只放这个平台自己的代码;跨平台共用的能力放 `platforms/`(契约、注册表、错误类型)。 +- 同平台不同页面代际(如莞易学的旧版 / 新版)拆成同目录内的多个实现模块,由 `Platform.study` + 按 `course["generation"]` 分发,不要一个代际一个插件目录。 + +## pugin.json + +```json +{ + "title": "广东省教师继续教育管理平台", + "name": "gdgxk", + "id": "PLATFORM_ID", + "label": "广东公需课", + "study": "study", + "entries": ["jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"] +} +``` + +字段含义与现状: + +| 字段 | 含义 | 现状 | +| --- | --- | --- | +| `title` | 平台全称,给文档 / 未来的插件列表用 | 无代码读取 | +| `name` | 插件名,与目录名一致 | 无代码读取 | +| `id` | `Platform.id`,写进 `accounts.platform` | 当前值是常量名 `PLATFORM_ID`,应该是 `"gdgxk"` | +| `label` | 界面展示名(「添加账号」下拉) | 无代码读取,实际值在 `main.py` 里 | +| `study` | `study` 实现所在的函数名 | 无代码读取 | +| `entries` | 登录 / 学习入口域名片段,用于识别平台归属 | 无代码读取,实际值在 `main.py` 里 | + +注意:`pugin.json` 与 `main.py` 里的 `Platform(...)` 字段重复。在加载器定下来之前, +**以 `main.py` 为准**;`pugin.json` 只当元数据,改了它不会影响运行。 + +## main.py 契约 + +实现 `Platform` 上的槽位,缺的阶段不实现(不要抛 `NotImplementedError`)。流程顺序由引擎负责, +插件只解决「这一步在这个站点怎么做」。 + +| 槽位 | 签名 | 调用方 | 要求 | +| --- | --- | --- | --- | +| `id` / `label` | 字符串 | 注册表、`GET /api/platforms`、前端下拉 | 必填 | +| `entries` | `tuple[str, ...]` | `registry.detect()` | 必填 | +| `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` 会把它返回给前端。 + +## 开发流程 + +1. 建目录:`pugins/<平台 id>/{__init__.py, main.py, pugin.json, utils/__init__.py}`。 +2. 在 `main.py` 里按需实现上表槽位;cookie 只保留本平台域名,会话失效抛 + `platforms.errors.SessionExpired`。 +3. 文件末尾注册: + + ```python + PLATFORM = registry.register(Platform( + id="<平台 id>", label="<展示名>", entries=("<登录域名>", "<学习域名>"), + session=new_session, dump_cookies=dump_cookies, login=login, + list_courses=list_courses, discover=discover, study=study, + )) + ``` + +4. 填 `pugin.json`(当前只作文档用)。 +5. 接入加载:加载器就绪前,还需要在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 + import,插件才会被注册。 +6. 联调顺序:`GET /api/platforms`(下拉里出现该平台)→ `/api/login` → `/api/courses` → + `/api/discover` → `/api/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"]` 分发;学习流程仍在引擎。 +3. 契约补全:`contract.py` 用「已实现槽位 → 能力名」推导 `capabilities`;`SessionExpired` + 移到 `platforms/errors.py`;各平台的学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。 +4. 目录化(当前):一个平台一个目录 `pugins//`,用 `pugin.json` 描述元数据, + `platforms/` 里的实现逐步迁入。 + +## 待定项 + +- 加载器:扫描 `pugins/*/` 自动 import,还是继续在 `load_builtin()` 手写 import? +- `pugin.json` 与 `main.py` 的字段重复,谁是唯一来源(若以 json 为准,注册就要由加载器做)? +- 命名:`pugins` / `pugin.json` / `pugin.md` 是否是最终拼写(英文通常是 plugins / plugin.json)。 +- `platforms/` 是否最终删除;莞易学(`jspx` / `study`)是否迁入 `pugins/`。 +- `platforms/gdgxk.py` 与 `pugins/gdgxk/main.py` 目前是两份副本,迁移完成前以哪份为准。 +- `pugin.json` 的 `"id": "PLATFORM_ID"` 应为具体值 `"gdgxk"`。 + +相关文档:[architecture.md](architecture.md)(分层与平台契约)、[OPTIMIZATION.md](OPTIMIZATION.md)(待实测项)、 +[progress/todo.md](progress/todo.md)(后续待办)。