bdd0bdf073
Platform 由 dataclass 改为普通基类(类属性 + 方法槽位),禁止逐项传函数注册;gdgxk 与 guanyixue 完成迁移;AGENTS.md、architecture.md 同步新契约;新增 docs/pugin.md(骨架 / 槽位契约 / 开发与迁移流程 / 常见坑),OPTIMIZATION.md 补待测条目。
187 lines
9.4 KiB
Markdown
187 lines
9.4 KiB
Markdown
# 学习平台插件开发文档
|
||
|
||
分层与整体结构见 [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` 已完成迁移。
|