体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
33 KiB
学习平台插件开发指南
欢迎来到学习平台插件开发指南!本章节将会引导你如何开发学习平台的插件,在这之前你需要具备以下基础知识:
-
- 一定的 Python 开发编程经验
-
- 一定的 Git / GitHub 使用经验
-
- 会用浏览器开发者工具看请求、Cookie 和页面结构
文档定位:本文只讲「怎么接入一个平台」——目录怎么放、类怎么写、每个槽位返回什么、怎么联调。 分层与整体结构见 architecture.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):uv sync -
没有 uv 就用 pip:
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
依赖清单见 requirements.txt,其中 requests、beautifulsoup4、lxml、playwright
是插件开发最常用的几个;标准协议、解析、编码一律优先用它们,不要手写底层实现。
2. 启动服务
在仓库根目录执行:
uv run app.py
或者:
python app.py
默认监听 127.0.0.1:8848,浏览器打开 http://127.0.0.1:8848。
换端口 / 对外开放用环境变量,或把 .env.example 复制成根目录 .env 再改:
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/ 并提交进仓库。
插件开发通常不需要碰前端;确实要改界面时,改完必须执行:
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",插件在循环里自行退出
三条设计原则
- 流程在引擎,站点细节在插件:课程循环、会话续期、停止、日志格式都由引擎 /
runtime统一处理, 插件只写「这个站点怎么登录、怎么拿列表、怎么上报进度」。 - 缺能力就不写:平台没有反馈表就不要写反馈逻辑,没有密码登录就不要写
login; 前端按capabilities()决定显示哪些按钮。 - 不反向依赖:插件不能 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:
{
"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
"""<平台名>插件:实现在 main.py,注册信息在 plugin.json。"""
main.py
"""<平台名>:登录 → 课程 → 发现 → 学习。"""
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
返回列表,每个课程至少包含:
{
"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。
runtime.log(key, "开始:%s" % title)
runtime.log(key, "心跳上报失败,稍后重试:%s" % e, level="warn")
日志会同时进控制台、/api/log(实时)和 /api/log/history(历史),三处内容一致。
停止
引擎不会强杀线程,插件必须在循环里主动响应:
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:
{ "id": "daidai_push", "title": "呆呆面板推送", "name": "daidai_push", "kind": "notify" }
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); 渠道插件不参与学习流程,也不要 importengine/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)就是这几个接口的界面。
开发流程(从零接入一个平台)
-
摸清站点:用浏览器完成一次「登录 → 课程列表 → 进入课程 → 上报进度」, 在 Network 里记下每个接口的 URL、方法、请求体、返回体、必要 header 和 cookie 域名。 服务端渲染的页面把 HTML 存下来,用
bs4/lxml解析。 -
建目录:
mkdir src\study_tool\plugins\<名字>放
__init__.py、plugin.json、main.py三个文件;实现复杂再拆utils/。 -
先写
session/dump_cookies:能恢复出带登录态的requests.Session, 这是后面所有槽位的基础。 -
再写
login/list_courses/profile:能在「添加账号」里登录成功, 卡片显示名字,刷新课程能拿到列表。 -
写
discover:把课程页解析成统一的课程描述(三类活动列表)。 -
最后写
study:按活动类型逐个处理,循环里检查停止,日志用账号 key。 -
注册并启动:
PLATFORM = registry.register(<类名>());启动服务,GET /api/platforms应出现该平台。 -
联调:按
/api/login→/api/courses→/api/discover→/api/study的顺序走一遍, 详细检查项见联调检查表。 -
收尾:同步更新 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 示例
# 平台是否注册成功
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 |
登录有效但没课程 | 课程页选择器 / 板块参数 |
排查顺序
GET /api/plugins:插件是否loaded=true;失败先看系统日志里的加载错误。GET /api/platforms:label和capabilities是否符合预期(少了能力 = 方法没被识别)。/api/login:登录是否成功;失败看/api/loginlog的stage和错误。/api/courses:cookie 是否恢复成功、列表选择器是否正确。/api/discover:返回的三类活动数量与页面是否一致。/api/study+/api/log?key=<账号 id>:日志是否出现在该账号卡片、进度是否递增。/api/stop:几秒内循环是否退出。
把已有实现改成子类
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
- 保留模块级纯函数(
_b64/_ocr/_parse_courses等),它们不属于契约。 - 把原来的槽位自由函数改成同名方法:加
self,模块内互相调用改成self.xxx, 平台私有的重登逻辑改成_relogin这类私有方法。 - 加类属性
id/label/entries,删掉模块末尾的逐项注册。 - 模块末尾改成
PLATFORM = registry.register(<类名>())。 - 实现模块一起搬进
plugins/<名字>/,目录内互相引用改成相对导入; 外部(如plugins/guanyixue/quiz.py)如果引用了平台实现,同步改路径或改成走registry.get()。 - 逐个验证槽位行为与迁移前一致;同一平台的不同代际由
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。
方案演进
- 早期:平台实现直接写在
engine.py里。 - 拆包:
platforms/base.py定义Platform契约 +registry注册表,引擎按course["platform"]分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。 - 契约补全:
contract.py推导capabilities;SessionExpired移到platforms/errors.py; 各平台学习流程从引擎搬回平台模块(engine.py只剩账号级编排)。 - 类式:
Platform变成普通基类,平台写成子类(类属性 + 方法),禁止逐项传函数注册。 - 插件目录(当前):平台全部迁到
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 分发 |
看代码时的推荐顺序:
plugins/guanyixue/main.py:最短的完整子类,能看清类属性 + 槽位 + 代际分发。plugins/guanyixue/utils/course.py:discover的返回值长什么样。plugins/gdgxk/main.py+utils/learn.py:登录换票、学习循环与停止检查。platforms/base.py/contract.py:契约的最终定义(有疑问以代码为准)。