feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+665
View File
@@ -0,0 +1,665 @@
# 学习平台插件开发指南
欢迎来到学习平台插件开发指南!本章节将会引导你如何开发学习平台的插件,在这之前你需要具备以下基础知识:
- 1. 一定的 Python 开发编程经验
- 2. 一定的 Git / GitHub 使用经验
- 3. 会用浏览器开发者工具看请求、Cookie 和页面结构
> **文档定位**:本文只讲「怎么接入一个平台」——目录怎么放、类怎么写、每个槽位返回什么、怎么联调。
> 分层与整体结构见 [architecture.md](architecture.md),仓库级开发约束见 [AGENTS.md](../AGENTS.md)。
>
> **一句话原则**:插件只回答「这一步在这个站点怎么做」,流程编排只有一份,在引擎里。
> 新增平台不需要改 `engine.py`、`routes/`、前端,也不需要改 `load_builtin()`。
## 环境准备
### 1. Python 与依赖
- Python **3.10 ~ 3.14**。`ddddocr` 依赖 `onnxruntime`,后者只对这些版本的正式版发轮子;
3.15 / free-threaded / ARM 上会装不上。缺 `ddddocr` 只影响需要图形验证码识别的平台。
- 推荐用 uv(依赖版本已锁在 `uv.lock`):
```bat
uv sync
```
- 没有 uv 就用 pip:
```bat
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
```
依赖清单见 [requirements.txt](../requirements.txt),其中 `requests`、`beautifulsoup4`、`lxml`、`playwright`
是插件开发最常用的几个;标准协议、解析、编码一律优先用它们,不要手写底层实现。
### 2. 启动服务
在仓库根目录执行:
```bat
uv run app.py
```
或者:
```bat
python app.py
```
默认监听 `127.0.0.1:8848`,浏览器打开 <http://127.0.0.1:8848>。
换端口 / 对外开放用环境变量,或把 `.env.example` 复制成根目录 `.env` 再改:
```bat
set PORT=8849 && python app.py
set HOST=0.0.0.0 && python app.py
```
`AUTH_TOKEN` 非空时页面和接口都要带 `?token=<值>`(浏览器会记住 cookie);本机开发一般不用设。
启动日志里出现插件加载失败时,先看控制台 / 右上角「系统日志」,再按
[加载与启停](#加载与启停) 一节排查。
### 3. 只在改前端时才需要
前端源码在 `web/`(React + TypeScript + Vite + Tailwind),构建产物输出到 `static/` 并提交进仓库。
**插件开发通常不需要碰前端**;确实要改界面时,改完必须执行:
```bat
cd web
pnpm build
```
不要直接改 `static/` 里的产物。
### 4. 联调工具
| 工具 | 用途 |
| --- | --- |
| 浏览器 DevTools → Network | 看登录 / 列表 / 上报接口的 URL、请求体、返回体 |
| 浏览器 DevTools → Application → Cookies | 确认域名、`Path`、`HttpOnly`、会话 cookie 名 |
| 内置抓包(顶栏「抓包」) | 把真实页面 HTML 存成快照,用 `/api/snapshot/{sid}` 回看,适合分析服务端渲染页面 |
| `data/study.db`(SQLite) | 只读排查账号 / 设置 / 日志;不要手改表结构 |
| `plugins/<名字>/script/` | 该插件专属的探测 / 自测脚本,随插件一起删;通用的参考实现放根目录 `script/reference/` |
## 插件是什么
一个插件 = `../src/study_tool/plugins` 目录;一个平台 = 一个 `Platform` 子类 + 一次注册。
- **元数据**用类属性声明:`id`、`label`、`entries`。
- **能力**用「实现同名方法」绑定:实现了 `study` 就有学习能力,没实现就保持基类的 `None`。
- **注册**在模块末尾执行一次:`PLATFORM = registry.register(X())`。
### 一次学习的调用链
```
添加账号(GET /api/platforms 取平台下拉)
│
▼
POST /api/login ──────────► Platform.login(username, password)
│ └─► Platform.list_courses(s)(登录后顺手缓存课程)
▼
POST /api/courses ────────► Platform.session(cookies) → Platform.list_courses(s)
▼
POST /api/discover ───────► Platform.session(cookies) → Platform.discover(s, plan_id, project_id, name)
▼
POST /api/study ──────────► engine.study_worker
└─► Platform.session(cookies)
Platform.discover(...) # 重新拿最新进度
Platform.dump_cookies(s) # 回写会话
Platform.study(key, s, d, **opts)
▼
POST /api/stop ───────────► 置 RUNS[key]["status"] = "stopped",插件在循环里自行退出
```
### 三条设计原则
1. **流程在引擎,站点细节在插件**:课程循环、会话续期、停止、日志格式都由引擎 / `runtime` 统一处理,
插件只写「这个站点怎么登录、怎么拿列表、怎么上报进度」。
2. **缺能力就不写**:平台没有反馈表就不要写反馈逻辑,没有密码登录就不要写 `login`;
前端按 `capabilities()` 决定显示哪些按钮。
3. **不反向依赖**:插件不能 import `engine` / `routes` / 前端;插件之间也不要互相 import。
## 插件项目结构
```
src/study_tool/plugins/
__init__.py 加载器:load_plugins / discover / set_enabled / reload
<名字>/
__init__.py 包说明(必需,写一句本插件的职责即可)
plugin.json 注册信息:id / title / name(只放元数据,不写能力绑定)
main.py class <名字>(Platform):槽位实现 + 末尾 registry.register
utils/ 可选:按阶段拆的工具(http / login / course / learn / feedback / spa ...)
<实现模块>.py 可选:页面代际、复杂流程等,按需再拆
```
- **目录名就是插件名**:`GET /api/plugins` 的 `name`、启停 / 重载接口的 `{name}` 都用它。
- `main.py` 放平台类与注册;实现较复杂时按阶段拆到 `utils/` 或同目录模块
(登录、课程解析、学习上报各一块),保持单个文件可读。
- `../src/study_tool/platforms/` 只放运行时(`base` / `contract` / `errors` / `registry`),
**不要**把具体平台放回去;也不要为某个插件改 `platforms/`。
- 插件目录内互相引用用相对导入(`from .utils import course`);跨插件不要引用。
- 目录内可以按需增加 `models/` 等子目录;没有实际代码的空目录不会参与加载。
## 插件元数据:`plugin.json`
`plugin.json` **只是注册信息**,不是配置 Schema,也不参与能力绑定。加载器只读 `id`、`title` 和 `kind`:
```json
{
"id": "gdgxk",
"title": "广东省教师继续教育管理平台",
"name": "gdgxk"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `id` | 是 | 平台 id,必须与 `Platform.id` 完全一致;会写进 `accounts.platform` / `course["platform"]`,**上线后不要改** |
| `title` | 是 | 插件名称,显示在「插件管理」弹窗里;建议写平台全称 |
| `name` | 否 | 人类可读的插件名;当前加载器不读取,实际以**目录名**为准,建议与目录名保持一致 |
| `kind` | 否 | 插件类型:缺省 `platform`(学习平台,注册到 `registry`);`notify` = 推送渠道(注册到 `notify`,见「推送渠道插件」) |
补充说明:
- `id` 用稳定的英文小写标识(如 `guanyixue`、`gdgxk`),不要用中文、空格或会变动的版本号。
- 目录名、`plugin.json.name`、`Platform.id` 三者保持一致,最省心。
- 旧的 `description` / `link` 字段目前**不会被读取**:写了不报错,但插件清单、账号下拉里都不会显示。
需要描述就写在 `title` 或目录的 `README` / `__init__.py` 文档字符串里。
- 插件需要持久化自己的配置时,用 `database.get_setting(name)` / `database.set_setting(name, value)`,
键名加插件前缀(如 `"gdgxk.something"`),不要改系统设置 `llm`,也不要新建表。
- 缺少 `plugin.json` 的目录不会被扫描到;`id` 与类不一致时加载失败,日志里会出现
`插件 <目录名> 加载失败:插件 <目录名> 没有注册 id='...'`。
## 插件骨架
### `__init__.py`
```python
"""<平台名>插件:实现在 main.py,注册信息在 plugin.json。"""
```
### `main.py`
```python
"""<平台名>:登录 → 课程 → 发现 → 学习。"""
import requests
from study_tool import runtime
from study_tool.platforms import registry
from study_tool.platforms import Platform
from study_tool.platforms.errors import SessionExpired
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..."
DOMAINS = ("<平台域名>",) # cookie 域名过滤;和 entries 用同一组域名
class MyPlatform(Platform):
id = "<平台 id>" # 必须与 plugin.json 的 id 一致
label = "展示名" # 添加账号下拉、日志、插件管理里显示
entries = ("<登录入口域名>", "<学习入口域名>") # 识别页面属于本平台
# ---- 会话 ----------------------------------------------------------
def session(self, cookies=None):
s = requests.Session()
s.headers.update({"User-Agent": UA})
for c in cookies or []:
domain = c.get("domain") or ""
if not any(d in domain for d in DOMAINS):
continue
s.cookies.set(c["name"], c.get("value") or "", domain=domain.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()
# 登录失败:raise RuntimeError("账号或密码错误")
# 会话失效:raise SessionExpired("...")
return s, (self.profile(s) if self.profile else "")
def list_courses(self, s):
# -> [{"name": ..., "plan_id": ..., "project_id": ..., "in_study": True}]
...
def profile(self, s):
return "<显示名>"
def renew_session(self, s):
# 无密码续期:成功 True,失败 False(不要抛异常)
return False
# ---- 发现 / 学习 ----------------------------------------------------
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()` 按「子类是否把它覆盖成方法」
推导能力名,前端据此决定显示哪些按钮。
## 槽位契约
| 槽位 | 方法签名 | 调用方 | 缺失时 |
| --- | --- | --- | --- |
| `study` | `(self, key, s, d, **opts)` | `engine.run_one_course` | 学习时报「平台 X 没有可用的学习实现」 |
| `discover` | `(self, s, plan_id, project_id, name)` | `run_one_course`、`/api/discover` | 报「平台 X 没有可用的课程发现实现」 |
| `session` | `(self, cookies) -> requests.Session` | 引擎与各路由 | 报「平台 X 没有实现 session」 |
| `dump_cookies` | `(self, s) -> [cookie]` | 登录 / 刷新 / 发现后回写前端 | 不回写 cookie,后续请求可能丢会话 |
| `login` | `(self, username, password) -> (session, user)` | `/api/login` | 不能密码登录,`/api/login` 报 `no_platform` |
| `list_courses` | `(self, s) -> [course]` | 登录后、`/api/courses` | 登录后课程为空,`/api/courses` 报 `no_platform` |
| `renew_session` | `(self, s) -> bool` | `SessionExpired` 时无密码续期 | 直接走密码重登 |
| `profile` | `(self, s) -> 显示名` | 登录后卡片显示 | 显示名为空 |
> 对「能完整跑起来」的平台,实际必需的是 `session` + `discover` + `study`。
> 想支持「添加账号 → 登录 → 自动带出课程」,再补 `login` / `list_courses` / `dump_cookies` / `profile`。
### `session` / `dump_cookies`
- `cookies` 是前端传回的列表,元素形如 `{"name", "value", "domain", "path"}`,`domain` 可能带前导点。
- 只接收本平台的 cookie(按 `DOMAINS` 过滤),否则会把别的站点 cookie 塞进来;
域名过滤**必须**在插件里做,引擎不兜底。
- 恢复时 `domain=domain.lstrip(".")`;`path` 缺省用 `/`。
- 过滤后为空时,`/api/courses` 会返回 `no_cookie`「保存的 cookie 全部被过滤」,
这通常说明 `DOMAINS` 写错或登录域与学习域不是同一个父域。
- `dump_cookies` 把 `requests.Session` 里的 cookie 原样导出即可;域名不要改写,
下次 `session()` 还要按它恢复。
### `login`
- 返回 `(session, user)`:`session` 是**已登录**的会话,`user` 是卡片上显示的名字。
- 登录失败抛 `RuntimeError`(带可读原因),路由会记进登录记录并回显给前端。
- 会话失效抛 `SessionExpired`,不要自己弹窗、不要自己重试整轮流程。
- 登录成功后引擎 / 路由会立刻调 `list_courses` 缓存课程;`list_courses` 失败不会让登录失败,
只会把 `warn` 带回去。
### `list_courses`
返回列表,每个课程至少包含:
```python
{
"name": "课程名",
"plan_id": "...", # 引擎 / 路由都按它定位课程
"project_id": "...", # 同上
"in_study": True, # 是否在学;False 表示已结束,前端会折叠
}
```
可附加 `section` / `status_label` / `info` / `progress` 等展示字段。
`in_study` 缺失会让 `/api/courses` 的计数直接报 `KeyError`,必须给。
### `discover`
签名 `(self, s, plan_id, project_id, name="")`,返回课程描述。**以下键引擎会直接取,必须存在**:
| 键 | 说明 |
| --- | --- |
| `platform` | 本平台 id;引擎据此再次 `registry.get()` 取学习实现 |
| `course` | 课程名 |
| `progress` | 进度文本,如 `"3/10"` |
| `info` | 说明文本(可为空串) |
| `user` | 显示名 |
| `courseware` | 课件列表 |
| `feedback` | 反馈表列表 |
| `test` | 测验列表 |
可选键:`generation`(`legacy` / `spa`,用于同一平台多代页面分发)、`train_id`、`page_url`、`need_join`。
三类活动列表的元素结构由插件自己定义(前端不依赖明细,学习时由插件消费),
建议统一成 `{"title", "done", ...}`,并带上学习所需的定位字段(`href` 或 `period_id`)。
三类都为空时引擎会直接报「课程里没有任何可识别的活动」。
`discover` 里如果切换了域名 / 换了新会话,记得让后续 `study` 用同一个 `s`;
引擎会在 `discover` 之后调用 `dump_cookies(s)` 把新会话存回去。
### `study`
- 收到的是 `discover` 的返回值 `d`,**不要重新发现**;需要最新数据可在内部再取一次页面。
- `s` 是已恢复的会话,所有请求都走它;不要自己新建裸 `requests` 会话。
- 循环里必须检查停止(见下一节),否则「停止」按钮无效。
- 会话失效抛 `SessionExpired`;其它可预期错误抛 `RuntimeError`,引擎会记到该账号的运行状态里。
- 全部活动处理完后正常返回即可,不需要返回值。
### `renew_session` / `profile`
- `renew_session(s)`:无密码续期(例如 SSO 票据仍有效)。成功返回 `True`,失败返回 `False`,
**不要抛异常**——引擎会把它当成一次尝试,失败后继续走密码重登。
- `profile(s)`:只读显示名;`/api/courses` 里异常会被吞掉、显示为空,不影响主流程。
如果在 `login` 内部直接调用它,自己兜底(`self.profile(s) if self.profile else ""`)。
## 返回值与 `opts`
### `study` 的 `opts`
引擎固定传入以下参数,插件只取自己需要的:
| 参数 | 含义 |
| --- | --- |
| `workers` | 并发数(路由已夹到 1~95) |
| `cap` | 单门课程最长学习分钟数 |
| `enable_quiz` | 是否自动交测验(默认关) |
| `enable_feedback` | 是否自动交反馈表(默认开) |
| `mode` | `fast` = 只调接口;`normal` = 开浏览器真播 |
| `parallel` | `normal` 模式下的并发播放路数 |
| `quiz_key` | 答题用的大模型配置(`dict` 或 `None`) |
| `cookies` | 进入本轮学习前的 cookie 列表;网络请求请用 `s`,不要用它新建会话 |
| `plan` / `project` | `plan_id` / `project_id` |
| `name` | 课程名 |
## 日志、停止与错误
### 日志
所有日志只走 `runtime.log(key, msg, level="info")`:
- `key` 用引擎传进来的账号 id;全局 / 系统事件才用 `"system"`。
- 时间戳、级别、格式由 `runtime.log` 统一拼,插件里**不要**自己加时间前缀。
- 不要 `print()`,不要直接调 `database.run_log_add()`。
- 级别用 `info` / `warn` / `error`;正常流程用 `info`,可恢复异常用 `warn`,失败用 `error`。
```python
runtime.log(key, "开始:%s" % title)
runtime.log(key, "心跳上报失败,稍后重试:%s" % e, level="warn")
```
日志会同时进控制台、`/api/log`(实时)和 `/api/log/history`(历史),三处内容一致。
### 停止
引擎不会强杀线程,插件必须在循环里主动响应:
```python
def _stopped(self, key):
with runtime.RUNS_LOCK:
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
```
在每门课程、每个课件、每次等待前都检查一次;长等待拆成小段(如 `time.sleep(1)` 循环),
保证点「停止」后几秒内能退出。
### 错误
| 情况 | 抛什么 | 结果 |
| --- | --- | --- |
| 账号密码错误、页面结构变化、接口返回异常 | `RuntimeError("可读原因")` | 登录失败 / 该门课程失败,原因进日志与卡片 |
| 会话失效(被踢回登录页、接口返回未登录) | `SessionExpired("...")` | 引擎尝试 `renew_session`,失败再用密码重登 |
| 可恢复的瞬时错误(超时、5xx) | 插件内部重试,重试耗尽再抛 | 同上 |
不要在插件里 `sys.exit` / 杀线程 / 清空 `RUNS`;不要吞掉 `SessionExpired` 后继续请求。
## 推送渠道插件(`kind: notify`)
插件目录同样放 `plugins/<名字>/`,但 `plugin.json` 写 `"kind": "notify"`,`main.py` 实现
`notify.Channel` 子类而不是 `Platform`:
```json
{ "id": "daidai_push", "title": "呆呆面板推送", "name": "daidai_push", "kind": "notify" }
```
```python
from study_tool import notify
from study_tool.config.settings import load_settings
class DaidaiPush(notify.Channel):
id = "daidai_push"
label = "呆呆面板推送"
def send(self, title, content, context=None):
cfg = load_settings()["notify"] # base_url / app_key / app_secret / channel_name
... # 失败直接抛异常,消息会写进账号日志
NOTIFY = notify.register(DaidaiPush())
```
- 核心只调 `notify.fault(kind, key, title, content)` / `notify.retry(key, what)`;
渠道插件不参与学习流程,也不要 import `engine` / `routes`。
- 配置一律读设置项 `notify`(「系统设置 → 故障推送」里编辑),不要在插件里写死地址或密钥;
换 token 之类需要缓存的东西放模块级变量,重启后自动重取。
- 停用 / 重载由加载器统一处理:`kind=notify` 的插件从 `notify` 注册表摘除,不会残留。
- `POST /api/notify/test` 会走 `notify.send()`,等价于点「测试推送」。
## 加载与启停
加载器在 `plugins/__init__.py`,对外四个函数 + 对应 HTTP 接口:
| 函数 | 接口 | 作用 |
| --- | --- | --- |
| `load_plugins()` | 启动时自动调用 | 按启用清单 import 全部插件 |
| `discover()` | `GET /api/plugins` | 磁盘清单 + 是否启用 / 已加载 / label / sites / 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` 消失,账号平台下拉里也不会出现。
- 启动时单个插件加载失败只记一条 `system` 级别的 `warn` 日志,不会拖垮服务;
`GET /api/plugins` 里该插件 `loaded=false`,可以修好后单独 `reload`。
- 热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
- 重载已停用的插件会报错(先启用再重载);重载不存在的插件会报「没有这个插件」。
- 前端顶栏的「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`)就是这几个接口的界面。
## 开发流程(从零接入一个平台)
1. **摸清站点**:用浏览器完成一次「登录 → 课程列表 → 进入课程 → 上报进度」,
在 Network 里记下每个接口的 URL、方法、请求体、返回体、必要 header 和 cookie 域名。
服务端渲染的页面把 HTML 存下来,用 `bs4` / `lxml` 解析。
2. **建目录**:
```bat
mkdir src\study_tool\plugins\<名字>
```
放 `__init__.py`、`plugin.json`、`main.py` 三个文件;实现复杂再拆 `utils/`。
3. **先写 `session` / `dump_cookies`**:能恢复出带登录态的 `requests.Session`,
这是后面所有槽位的基础。
4. **再写 `login` / `list_courses` / `profile`**:能在「添加账号」里登录成功,
卡片显示名字,刷新课程能拿到列表。
5. **写 `discover`**:把课程页解析成统一的课程描述(三类活动列表)。
6. **最后写 `study`**:按活动类型逐个处理,循环里检查停止,日志用账号 key。
7. **注册并启动**:`PLATFORM = registry.register(<类名>())`;启动服务,
`GET /api/platforms` 应出现该平台。
8. **联调**:按 `/api/login` → `/api/courses` → `/api/discover` → `/api/study` 的顺序走一遍,
详细检查项见[联调检查表](#联调检查表)。
9. **收尾**:同步更新 [architecture.md](architecture.md) 的目录说明;按仓库规范更新
`CHANGELOG.md` / `OPTIMIZATION.md`;不要顺手改无关文件。
不需要改 `load_builtin()`、`engine.py`、`routes/` 或前端——加载器扫目录时会自动 import 各插件。
## 联调与调试
### 接口一览
| 接口 | 方法 | 主要参数 | 用到的插件槽位 |
| --- | --- | --- | --- |
| `/api/platforms` | GET | — | `capabilities()` |
| `/api/plugins` | GET | — | 插件清单(不 import 插件代码) |
| `/api/plugins/{name}/enable` `/disable` | POST | — | 加载 / 卸载 |
| `/api/plugins/reload`、`/api/plugins/{name}/reload` | POST | — | 重新 import |
| `/api/login` | POST | `account_id` 或 `username` / `password` / `platform` | `login` → `list_courses` → `dump_cookies` |
| `/api/courses` | POST | `platform`、`cookies` | `session` → `list_courses` → `profile` |
| `/api/discover` | POST | `platform`、`cookies`、`plan_id`、`project_id`、`name` | `session` → `discover` |
| `/api/study` | POST | `key`、`cookies`、`courses[]`、`workers`、`cap_minutes`、`enable_quiz`、`enable_feedback` | 后台线程:`session` → `discover` → `study` |
| `/api/stop` | POST | `key` | 置停止标记 |
| `/api/log` | GET | `key`、`since` | 实时日志 |
| `/api/log/history` | GET | `key`、`tail` | 历史日志 |
| `/api/runs` | GET | — | 各账号运行状态快照 |
| `/api/loginlog` | GET | `tail` | 登录尝试回放 |
`/api/study` 是异步的:立即返回,进度看 `/api/runs` 和 `/api/log`;同一账号正在跑时再调会返回
「该账号正在学习中」。
### curl 示例
```bash
# 平台是否注册成功
curl http://127.0.0.1:8848/api/platforms
# 插件清单(enabled / loaded / sites / capabilities)
curl http://127.0.0.1:8848/api/plugins
# 改完代码热重载单个插件
curl -X POST http://127.0.0.1:8848/api/plugins/<名字>/reload
# 用账号密码登录(platform 填插件 id)
curl -X POST http://127.0.0.1:8848/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"...","password":"...","platform":"<id>"}'
# 用已保存的 cookie 刷新课程(cookie 从上一步返回里取)
curl -X POST http://127.0.0.1:8848/api/courses \
-H 'Content-Type: application/json' \
-d '{"platform":"<id>","cookies":[...]}'
```
Windows 下把 `\` 换成一行写完,或使用 PowerShell 的 `Invoke-RestMethod`。
### 常见错误码
| code | 含义 | 排查方向 |
| --- | --- | --- |
| `no_credential` | 没有可用账号密码 | 添加账号时填密码,或传 `account_id` |
| `no_platform` | 平台未注册 / 没实现该槽位 | `/api/plugins` 看 `loaded`;确认方法名拼写 |
| `login_failed` | 登录抛了异常 | `/api/loginlog` 看平台返回;检查验证码 / 加密 / 表单字段 |
| `no_cookie` | cookie 为空或全被过滤 | `session` 的域名过滤;重新登录 |
| `session_expired` | 会话失效 | 实现 / 修好 `renew_session` 或确认密码可重登 |
| `fetch_failed` | 列表接口异常 | 页面结构变化、接口参数、header |
| `no_course` | 登录有效但没课程 | 课程页选择器 / 板块参数 |
### 排查顺序
1. `GET /api/plugins`:插件是否 `loaded=true`;失败先看系统日志里的加载错误。
2. `GET /api/platforms`:`label` 和 `capabilities` 是否符合预期(少了能力 = 方法没被识别)。
3. `/api/login`:登录是否成功;失败看 `/api/loginlog` 的 `stage` 和错误。
4. `/api/courses`:cookie 是否恢复成功、列表选择器是否正确。
5. `/api/discover`:返回的三类活动数量与页面是否一致。
6. `/api/study` + `/api/log?key=<账号 id>`:日志是否出现在该账号卡片、进度是否递增。
7. `/api/stop`:几秒内循环是否退出。
## 把已有实现改成子类
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
1. 保留模块级纯函数(`_b64` / `_ocr` / `_parse_courses` 等),它们不属于契约。
2. 把原来的槽位自由函数改成同名方法:加 `self`,模块内互相调用改成 `self.xxx`,
平台私有的重登逻辑改成 `_relogin` 这类私有方法。
3. 加类属性 `id` / `label` / `entries`,删掉模块末尾的逐项注册。
4. 模块末尾改成 `PLATFORM = registry.register(<类名>())`。
5. 实现模块一起搬进 `plugins/<名字>/`,目录内互相引用改成相对导入;
外部(如 `plugins/guanyixue/quiz.py`)如果引用了平台实现,同步改路径或改成走 `registry.get()`。
6. 逐个验证槽位行为与迁移前一致;同一平台的不同代际由 `study` 里按
`course["generation"]` 分发(参见 `plugins/guanyixue/main.py`)。
## 硬约束
- 日志只走 `runtime.log(key, msg, level=...)`,`key` 用引擎传进来的账号 id;不要自己加时间前缀。
- 循环里要响应停止:`(runtime.RUNS.get(key) or {}).get("status") == "stopped"` 时返回。
- 会话失效抛 `SessionExpired`;自动续期 / 用密码重登由引擎统一处理(`renew_session` 里除外)。
- 不 import `engine` / `routes` / 前端;插件之间只允许同平台不同代际互相引用,
模块级互相 import 成环时改用函数内延迟导入。
- cookie 域名过滤在平台自己的 `session` 里做,引擎不兜底:漏写 `session` 会直接报
「平台没有实现 session」,不会静默过滤。
- 时间、解析、编码这类通用能力用现成库(`requests` / `bs4` / `lxml` / `datetime`),不要手写。
- 新代码的 import 一律显式(如 `from study_tool.runtime import log`),不要新增 `from xxx import *`。
- 不改 `engine.py` / `routes/` / 前端来适配单个平台;平台差异只能落在插件目录里。
## 常见坑
- **`plugin.json.id` 与类 `id` 不一致**:加载时抛「没有注册 id=...」,插件不会出现在
`/api/platforms`;改完记得 `reload`。
- **插件目录缺 `__init__.py` / `plugin.json`**:`plugin.json` 缺失会直接扫描不到;
`__init__.py` 是仓库约定(现有插件都有),补上能避免打包 / 热重载时的导入歧义。
- **自由函数改方法后忘加 `self`**:模块内原来的 `_relogin(key)` 必须写成 `self._relogin(key)`,
否则运行时才报错;提交前可扫一遍模块的未定义全局名。
- **注册写两遍**:既在类里又在模块末尾逐项注册,会导致同一个 id 被覆盖,`capabilities`
也可能和实际方法不一致。只保留 `registry.register(<类名>())`。
- **cookie 域写错**:`session` 里过滤的是保存下来的 cookie,域名不匹配会得到空会话,
表现为「登录成功但刷新课程提示未登录」。
- **日志 key 用错**:平台用自己拼的字符串当 key,日志会跑进别的卡片或 `system` 弹窗。
- **把代际当平台**:旧版 / 新版注册成两个平台,账号绑定会漂移;应该在
`discover` 里写 `generation`,由 `study` 分发。
- **在 `study` 里重新 `discover`**:会重复请求、重复解析,还可能把引擎已经算好的进度覆盖掉;
需要最新数据时在插件内部按需取。
- **忽略 `dump_cookies`**:`discover` 里换了会话却不让引擎回写,下一门课又会掉登录。
- **长 `sleep` 不检查停止**:点「停止」后要等几分钟才退出;把等待拆成小段。
- **加载失败只看页面**:启动时的加载异常只写日志、不弹窗;先看控制台或系统日志。
## 联调检查表
- [ ] `GET /api/platforms` 里能看到该平台,`label` 与 `capabilities` 正确
- [ ] `GET /api/plugins` 里 `loaded=true`,`title` 正确
- [ ] 停用 → 平台从 `/api/platforms` 消失;启用 → 重新出现
- [ ] 修改代码后 `POST /api/plugins/{name}/reload` 生效,不需要重启
- [ ] 添加账号 → 选平台 → 登录成功,卡片显示 `profile` 名字
- [ ] 刷新课程返回非空列表,`in_study` 标记正确
- [ ] 发现课程返回的活动数与页面一致
- [ ] 开始学习:日志出现在该账号卡片里,进度递增
- [ ] 点停止后几秒内循环退出
- [ ] 会话失效时能走到续期 / 重登,不需要手动刷新
- [ ] 平台没实现 `session` 时,`/api/courses` 报「还没有实现 session」
- [ ] 日志里没有插件自己加的时间前缀,也没有 `print` 输出
## 提交前
- 只改插件目录(以及确有必要同步的 `docs/architecture.md`);不要顺手重构无关文件。
- 按仓库规范更新 `CHANGELOG.md` 的 `Unreleased`;需要用户测试确认的事项写进 `OPTIMIZATION.md`。
- 插件依赖的平台接口、验证码、加密方式如果是逆向得到的,把关键结论写进插件目录的
`__init__.py` 文档字符串或 `main.py` 顶部注释,方便下次维护。
- 不提交 `__pycache__`、临时抓包、`data/study.db`。
## 方案演进
1. 早期:平台实现直接写在 `engine.py` 里。
2. 拆包:`platforms/base.py` 定义 `Platform` 契约 + `registry` 注册表,引擎按
`course["platform"]` 分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。
3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`;
各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
4. 类式:`Platform` 变成普通基类,平台写成子类(类属性 + 方法),禁止逐项传函数注册。
5. 插件目录(当前):平台全部迁到 `plugins/<名字>/`(`plugin.json` + `main.py`,实现复杂的
再拆同目录模块),`load_plugins()` 扫目录加载,支持启停与热重载;
`gdgxk`、`guanyixue` 都已完成迁移。
## 附录:现有插件对照
| 插件 | 目录 | 入口域名 | 已实现能力 | 实现拆分 | 值得参考 |
| --- | --- | --- | --- | --- | --- |
| 广东公需课 | `plugins/gdgxk/` | `jsglpt.gds.edu.cn`、`jsxx.gds.edu.cn` | 登录 / 会话 / 课程 / 发现 / 学习 / 续期 / 显示名 | `utils/`:`http` / `login` / `course` / `learn` | 双域名 SSO 换票、base64 账号密码、验证码、心跳上报 |
| 莞易学 | `plugins/guanyixue/` | `jspx.dgjy.net`、`study.dgjy.net` | 登录 / 会话 / 课程 / 发现 / 学习 / 续期 / 显示名 | `utils/`:`http` / `login` / `course` / `feedback` / `learn` / `spa`;`detect.py` 识别页面代际 | 同一平台 `legacy` / `spa` 两代页面按 `generation` 分发 |
看代码时的推荐顺序:
1. `plugins/guanyixue/main.py`:最短的完整子类,能看清类属性 + 槽位 + 代际分发。
2. `plugins/guanyixue/utils/course.py`:`discover` 的返回值长什么样。
3. `plugins/gdgxk/main.py` + `utils/learn.py`:登录换票、学习循环与停止检查。
4. `platforms/base.py` / `contract.py`:契约的最终定义(有疑问以代码为准)。