Files
study-tool/docs/architecture.md
T
study-tool 491fd08c1e refactor(plugins): 莞易学迁到 pugins/guanyixue
- platforms/guanyixue.py → pugins/guanyixue/main.py
- jspx.py / study.py / detect.py 一并搬进插件目录,插件自包含:
  热重载会把实现模块一起重新 import,不再是半截插件
- 导入路径改成 study_tool.pugins.guanyixue.*;quiz.py 两处引用同步更新
- platforms/__init__.py 只剩运行时,load_builtin() 只调 load_plugins()
- 新增 pugins/guanyixue/pugin.json(id / title / name)
- 同步 AGENTS.md、docs/architecture.md、docs/pugin.md、docs/OPTIMIZATION.md
2026-10-31 16:18:45 +08:00

11 KiB
Raw Blame History

架构说明

目录

app.py                     启动壳:把 src 加进 sys.path,调用 study_tool.app.main()
pyproject.toml             打包配置(src 布局,console script: study-tool)
src/study_tool/
  __init__.py              包信息(版本)
  __main__.py              python -m study_tool 入口
  app.py                   组装:FastAPI、鉴权中间件、页面 / health、启动 main()
  webapp.py                FastAPI 实例(路由模块从这里注册,避免循环导入)
  config.py                常量 / 环境变量 / 根目录定位(data、static、script)
  runtime.py               运行状态 RUNS + 统一日志 log()
  store.py                 SQLite:账号 / 设置 / 日志 / 状态 / 缓存 / 快照
  models.py                HTTP 请求模型(Pydantic)
  settings_store.py        系统设置读写
  llm.py                   大模型调用(OpenAI 兼容)
  quiz.py                  测验解析 / 交卷 / AI 答题(含 /api/quiz/*)
  engine.py                账号级编排:课程循环、会话续期、按 platform 分发
  capture.py               抓包
  platforms/               平台运行时(见下)
    base.py                Platform 契约(id / label / 槽位 / capabilities)
    contract.py            生命周期 ↔ 槽位 ↔ 能力名映射
    errors.py              SessionExpired(平台层错误类型)
    registry.py            注册表:register / get / detect / all
  pugins/                  插件式平台(加载器扫目录,新增插件不改代码)
    __init__.py            load_plugins / discover / set_enabled / reload(启停 + 热重载)
    gdgxk/
      pugin.json           注册信息:id / title / name(只放元数据)
      main.py              广东公需课:Platform 子类(登录 / 课程 / 学习 / 续期)
      utils/http.py        域名 / UA / XHR 上报封装与 SessionExpired
      utils/login.py       base64 账号密码 + 图形验证码 + ITLMS 换票
      utils/course.py      课程列表卡片 / 学习页上报参数 / 进度页活动清单
      utils/learn.py       活动页打开 → 注册播放 → 1x 心跳上报
    guanyixue/
      pugin.json           注册信息:id / title / name
      main.py              莞易学:Platform 子类,按 generation 分发
      jspx.py              旧版页面:会话 / 登录 / 课程抓取 / 旧版学习流程
      study.py             新版 SPA:StudyClient / 接口学习 / 视频流程
      detect.py            页面代际识别(legacy 旧版 / spa 新版)
  routes/                  HTTP 接口
    accounts.py            账号 / 登录 / 登录记录
    misc.py                课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
    logs.py                /api/log、/api/runs
web/                      前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
static/                   前端构建产物(index.html + assets/ + favicon.svg,提交进仓库)
script/                   服务端 JS 脚本:des.js、desenc.js(Node 调用)
script/reference/         不参与运行的参考脚本:dgjy-toolkit.user.js、study-axios.mjs

分层

  • 入口层:app.py(根)+ study_tool/app.py。前者只负责 sys.path 与启动,后者组装应用。
  • 接口层:routes/* 与 quiz.py 里的 /api/quiz/*,只做参数校验、调用引擎、返回结果。
  • 领域层:engine.py(账号级编排:课程循环 / 会话续期 / 按平台分发)、platforms/*(平台实现,含各自的学习流程)、llm.py / quiz.py 的解析逻辑。
  • 基础设施:store.py(SQLite)、runtime.py(状态 + 日志)、config.py。

依赖方向单向:接口 → 领域 → 基础设施。基础设施不反向 import 接口。

engine.py 与 routes/* 只用 registry.get(...) 取平台实现,不 import 具体平台模块; 平台实现之间只有同平台不同代际会互相 import(study.py → jspx.py),且用函数内延迟导入避免成环。

平台插件与识别

平台实现在 platforms/ 或插件目录 pugins/<名字>/,都通过注册表注入,engine 只按 course["platform"] 分发:

# engine.run_one_course 末尾
pf = registry.get(d.get("platform"))
pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...)

Platform 契约(见 platforms/base.py):一个平台 = 一个 Platform 子类,类属性放元数据, 自己确实有的阶段实现成方法;注册时只实例化子类,不再逐项绑定函数。

class Platform:
    id = ""                  # course["platform"] 的取值
    label = ""               # 日志 / 界面展示名,如「莞易学」
    entries = ()             # 登录入口 / 学习入口的 URL 片段
    # 槽位:子类实现哪个阶段就覆盖成方法(第一个参数是 self);保持 None = 该平台没有这项能力
    study = None             # study(self, key, s, d, **opts) -> None
    session = None           # session(self, cookies) -> requests.Session
    dump_cookies = None      # dump_cookies(self, session) -> [cookie]
    login = None             # login(self, username, password) -> (session, user)
    list_courses = None      # list_courses(self, session) -> [course]
    discover = None          # discover(self, session, plan_id, project_id, name) -> 课程描述
    renew_session = None     # renew_session(self, session) -> bool,无密码续期
    profile = None           # profile(self, session) -> 显示名
    # capabilities() 按非空槽位推导能力名

registry.detect(url, html) 按 entries 命中平台;「添加账号」时选择的平台写进 accounts.platform,之后登录 / 刷新课程 / 发现课程都用 registry.get(account["platform"]) 取实现。课程发现时把平台 id 写进 course["platform"],把页面代际写进 course["generation"](legacy / spa);引擎按平台分发,平台内部再按代际选择学习实现。

opts 固定包含:workers / cap / enable_quiz / enable_feedback / mode / parallel / quiz_key / cookies / plan / project / name。插件可以只取自己需要的。

生命周期与能力

阶段与槽位对应,缺的阶段不实现即可(不要抛 NotImplementedError):

  • 登录 / 会话:login、session、renew_session、profile
  • 查询课程:list_courses
  • 课程详情:discover
  • 学习:study
  • 测验:暂未进契约(quiz.py 仍是莞易学专用),下沉为平台能力后再加 quiz

Platform.capabilities() 按非空槽位推导能力名(映射见 platforms/contract.py): login / session / renew / profile / courses / discover / study。 GET /api/platforms 一并返回 capabilities,前端据此决定显示哪些按钮。 流程顺序只有一个实现(引擎),插件只负责单步。

内置:莞易学

jspx.dgjy.net(登录入口)和 study.dgjy.net(学习入口)是同一个平台,统一注册为 id=guanyixue、label=莞易学,实现在 pugins/guanyixue/。两代课程页:

  • jspx.py:旧版页面(tier-tree-one / tier3),负责会话、登录、课程抓取, 以及旧版学习流程(_study_builtin:反馈表 + 测验 + 刷课件)。
  • study.py:新版 Vue SPA,StudyClient + /train/course/front/* 接口, 以及新版学习流程(run_study_course:接口上报 / 真播视频)。
  • main.py:注册平台并按 course["generation"] 分发;detect.py 只判断页面代际, 不判断平台归属。

内置:广东公需课

id=gdgxk,pugins/gdgxk/ 实现(插件目录,pugin.json 只放 id / title / name,main.py 只放 Platform 子类,登录 / 解析 / 心跳工具在 utils/): 在 jsglpt.gds.edu.cn 登录(账号、密码 都是 base64),再用 ssoItlmsGxkTicket.action 换票进 jsxx.gds.edu.cn;视频按真实 流逝时间上报 updateViewTime1。没有测验 / 反馈表,所以不实现对应能力。

新增一个平台

  1. 新建 src/study_tool/pugins/<名字>/,写 pugin.json(id / title / name)和 main.py:

    from study_tool.platforms import registry
    from study_tool.platforms.base import Platform
    
    class MyPlatform(Platform):
        id = "<平台 id>"
        label = "展示名"
        entries = ("<登录入口域名>", "<学习入口域名>")
    
        def session(self, cookies=None): ...
        def dump_cookies(self, s): ...
        def login(self, username, password): ...
        def list_courses(self, s): ...
        def discover(self, s, plan_id, project_id, name=""): ...
        def profile(self, s): ...
    
        def study(self, key, s, d, **opts):
            # 用 s(requests.Session,带该账号 cookie)把课程活动跑完
            # 日志一律 runtime.log(key, ...)
            ...
    
    PLATFORM = registry.register(MyPlatform())
    

    平台确实没有的阶段不要写,保持基类的 None,引擎按能力判断走不走那个分支。

  2. 不用改任何代码:load_plugins() 扫目录时会自动 import 这个插件。实现复杂时可以像 pugins/guanyixue/ 那样在同一目录再拆 jspx.py / study.py 等实现模块。

  3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 / 发现课程 / 学习都走它的实现,不需要改路由或前端。

已内置莞易学(guanyixue)和广东公需课(gdgxk),都在 pugins/ 下;accounts.platform 是 平台分发依据,接入新平台只需建一个插件目录,引擎与路由不用改。

前端

web/ 是独立的前端工程:React 19 + TypeScript + Vite + Tailwind CSS v4。

  • web/src/App.tsx:账号轮询、操作编排、弹窗状态;components/ 放卡片 / 弹窗 / 基础 UI。
  • web/src/lib/api.ts:统一 fetch 封装(401 / 离线判定)与 SSE 读取。
  • web/src/types.ts:账号、课程、运行状态、测验等接口类型。
  • 构建产物输出到仓库根的 static/(index.html + assets/* + favicon.svg), FastAPI 直接托管:/ 返回 static/index.html(禁止缓存),/assets 挂 StaticFiles。
  • 开发:cd web && pnpm install && pnpm dev(5173,/api 代理到 127.0.0.1:8848)。
  • 构建 / 类型检查:pnpm build / pnpm typecheck。

日志

统一入口 runtime.log(key, msg, level="info"),一次写三处:控制台、内存 (/api/log 给前端轮询)、SQLite(/api/log/history)。key 用账号 id 或 system。 规范见 AGENTS.md 的「日志规范」,不要自己 print()。