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

33 KiB
Raw Permalink Blame History

学习平台插件开发指南

欢迎来到学习平台插件开发指南!本章节将会引导你如何开发学习平台的插件,在这之前你需要具备以下基础知识:

    1. 一定的 Python 开发编程经验
    1. 一定的 Git / GitHub 使用经验
    1. 会用浏览器开发者工具看请求、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",插件在循环里自行退出

三条设计原则

  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:

{
  "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); 渠道插件不参与学习流程,也不要 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. 建目录:

    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 的目录说明;按仓库规范更新 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 登录有效但没课程 课程页选择器 / 板块参数

排查顺序

  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:契约的最终定义(有疑问以代码为准)。