Files
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

666 lines
33 KiB
Markdown
Raw Permalink 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.
# 学习平台插件开发指南
欢迎来到学习平台插件开发指南!本章节将会引导你如何开发学习平台的插件,在这之前你需要具备以下基础知识:
- 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`:契约的最终定义(有疑问以代码为准)。