Files
study-tool/docs/pugin.md
T
study-tool bdd0bdf073 refactor(platforms): 平台插件改为 Platform 子类形态
Platform 由 dataclass 改为普通基类(类属性 + 方法槽位),禁止逐项传函数注册;gdgxk 与 guanyixue 完成迁移;AGENTS.md、architecture.md 同步新契约;新增 docs/pugin.md(骨架 / 槽位契约 / 开发与迁移流程 / 常见坑),OPTIMIZATION.md 补待测条目。
2026-10-31 15:07:25 +08:00

187 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 学习平台插件开发文档
分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。
一个平台 = `src/study_tool/platforms/<平台>.py` 里的一个 `Platform` 子类:类属性放元数据,
自己确实有的阶段实现成方法,模块末尾注册一次。引擎、路由、前端都不需要改。
## 插件骨架
```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/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 平台:广东公需课
```
约定:
- 一个平台只注册一次;注册发生在模块被 import 时,模块里除注册外不要有副作用。
- 平台 id = 模块里 `id` = `accounts.platform` = `course["platform"]`,三处必须一致。
- 同一平台不同页面代际拆成同目录下的实现模块(`jspx.py` / `study.py`),由平台类按
`course["generation"]` 分发,不要一个代际注册一个平台。
- 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `base.py` /
`contract.py` / `errors.py`。
## 槽位契约
| 槽位 | 方法签名 | 调用方 | 要求 |
| --- | --- | --- | --- |
| `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/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`。
## 把已有实现改成子类
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
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` 已完成迁移。