Files
study-tool/docs/architecture.md
T
study-tool 48e72ab1e7 docs: 发版流程拆成通用与项目两层
现象:AGENTS.md「发版本流程」里写死了本项目的文件名和命令(metadata.yaml、uv tool run
bump-my-version、uv.lock),换项目没法直接复用;手写那一行还出现了 `runm` 和 `etadata.yaml`
两处笔误。

根因:通用规则和项目实现细节混在同一节,规则文件承担了本该属于架构文档的内容。

边界:
- AGENTS.md「发版本流程」改成通用表述:确认后触发、CHANGELOG 整理、锁文件自检、发版工具一次
  完成改版本 + 提交 + 打 tag、tag 查重、发版不构建;不再出现具体文件名和命令
- docs/architecture.md 新增「发版」一节,放本项目的三处版本文件、bump-my-version 命令、
  CRLF + regex 的配置坑、改配置后先 dry-run
- docs/agent_workflow.md「项目契约和 Agent 行为规则分开」补上这次的证据,Seen 升到 2×
- 不改代码、不改版本号、不打 tag

运行了什么:git status 自查(只提交这 3 个文档文件);提交时 pre-commit 跑 ruff / web typecheck / commitizen
没跑什么:没跑 bump-my-version 真实发版;没跑测试、没构建(本次无代码改动)
2026-11-01 21:22:56 +08:00

17 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/                  全局路径常量(ROOT / DATA / STATIC_DIR),不放任何平台常量
    settings.py            系统设置读写(大模型 + 学习方式)
  runtime.py               运行状态 RUNS + 统一日志 log()
  database/                SQLite 存储(SQLAlchemy ORM)
    models.py              ORM 模型:settings / kv / accounts / login_log / run_log / run_state / snapshots
    __init__.py            engine / Session + 读写函数(账号、设置、日志、状态、缓存、快照)
  models.py                通用 HTTP 请求模型(Pydantic);测验请求模型在 plugins/guanyixue/quiz.py
  llm/                     大模型调用(OpenAI 兼容),后续大模型代码都放这里
  bank.py                  题库匹配 + 大模型兜底(与平台无关):只决定「这题选什么」,不碰平台页面 / 接口
  engine.py                账号级编排:课程循环、会话续期、按 platform 分发
  platforms/               平台运行时(见下)
    base.py                Platform 契约(id / label / 槽位 / capabilities)
    contract.py            生命周期 ↔ 槽位 ↔ 能力名映射
    errors.py              SessionExpired(平台层错误类型)
    registry.py            注册表:register / get / detect / all
  plugins/                  插件式平台(加载器扫目录,新增插件不改代码)
    __init__.py            load_plugins / discover / set_enabled / reload(启停 + 热重载)
    gdgxk/
      plugin.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 心跳上报
      utils/exam.py        课程考核:解析内嵌题面 → 题库/AI 答题 → POST finishTest2 交卷
      utils/bank.py        题库入口:读 bank/*.json,调核心 bank.answer(题库优先、大模型兜底)
      bank/                本年题库数据(*.json,格式 [{"q": 题干, "a": "B"}]),换年份只加文件
      script/              插件专属脚本:capture-jsglpt.py(抓包取证)、test-gdgxk.py(接口自测)
    guanyixue/
      plugin.json           注册信息:id / title / name
      main.py              莞易学:Platform 子类,按 generation 分发
      config.py            站点地址 / 登录授权入口 / 课程板块 / Node 与 desenc.js 定位 / 快照兜底目录
      utils/http.py        会话 / cookie / 身份 / 新版平台 token
      utils/login.py       账号密码登录 / CASTGC 续期 / token 换取
      utils/course.py      旧版课程列表 / 结构解析 / 页面代际分发
      utils/feedback.py    旧版反馈表与测验提交
      utils/learn.py       旧版学习流程:反馈表 + 测验 + 课件心跳
      utils/spa.py         新版 SPA:StudyClient / 接口学习 / 视频流程
      detect.py            页面代际识别(legacy 旧版 / spa 新版)
      script/              插件专属脚本:des.js、desenc.js(Node 调用)、study_video.py(命令行刷视频)
      capture.py           抓包调试:把页面 XHR/fetch 落成 data/capture-*.json
      quiz.py              测验解析 / 交卷 / AI 答题(含 /api/quiz/*、测验请求模型、答题提示词、题型名)
  routes/                  HTTP 接口(load_routes() 启动时扫描本目录,新增文件自动注册)
    accounts.py            账号 / 登录 / 登录记录
    misc.py                课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
    logs.py                /api/log、/api/runs
    settings.py            /api/settings
    llm.py                 /api/llm/test
web/                      前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
static/                   前端构建产物(index.html + assets/ + favicon.svg,提交进仓库)
script/reference/         不参与运行的参考脚本:dgjy-toolkit.user.js、study-axios.mjs
(插件专属脚本跟着插件走:plugins/<名字>/script/,如莞易学的 des.js、desenc.js、study_video.py)

分层

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

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

新代码的 import 一律显式(如 from study_tool.config import JSPX),不要再新增 from xxx import *;历史遗留的 star import 只保留在拆分前就存在的模块里。

engine.py 与 routes/* 只用 registry.get(...) 取平台实现,不 import 具体平台模块; 平台实现之间只有同平台不同代际会互相 import(course.py → spa.py),依赖保持单向,避免成环。

平台插件与识别

平台实现在 platforms/ 或插件目录 plugins/<名字>/,都通过注册表注入,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(self, s, d, b) 取题、quiz_submit(self, s, b) 按用户答案交卷;能力名 quiz(两者都实现才显示)。/api/quiz* 接口由莞易学插件注册、内部按 registry.get(platform) 分发,前端只在 capabilities 含 quiz 时显示「打开测验」
  • 抓包调试:capture(self, key, cookies, minutes) + captures(key) / capture_reset(key);能力名 capture,前端据此显示「抓包」

插件可删除

整个 plugins/<名字>/ 目录删掉后,系统照常启动(目录全空也行),只是少一个平台:

  • 核心(engine / routes / database / 前端)不 import 任何具体插件,不写死平台 id;账号的 platform 默认空串,前端平台下拉、「打开测验」「抓包」都按 GET /api/platforms 返回的 capabilities 动态显示。
  • 插件在 import 时用 @app.post(...) 注册的接口,由加载器(plugins/__init__.py)记账:停用 / 重载 / 加载失败时一并摘除,不会残留也不会重复注册。
  • 插件专属的常量、脚本、请求模型、依赖都放在插件目录里(config.py、script/、quiz.py),不放核心目录。
  • 已有账号选的平台被删后,该账号登录 / 刷新课程会返回 no_platform,不影响其它账号。

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=莞易学,实现在 plugins/guanyixue/。两代课程页:

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

内置:广东公需课

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

新增一个平台

  1. 新建 ../src/study_tool/plugins,写 plugin.json(id / title / name)和 main.py:

    
    

from study_tool.platforms import registry from study_tool.platforms 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 这个插件。实现复杂时可以像
`plugins/guanyixue/` 那样把工具按阶段拆到 `utils/`(http / login / course / learn / spa)。

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

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

## 故障推送

`notify.py` 是通知中心:核心代码只调 `notify.fault(kind, key, title, content)` /
`notify.retry(key, what)`,不 import 任何具体推送服务;真正发请求的渠道在插件里。

```python
class Channel:  # notify.Channel
 id = ""
 label = ""

 def send(self, title, content, context=None): ...  # 失败抛异常


NOTIFY = notify.register(DaidaiPush())  # 插件 main.py 末尾注册
  • 插件 plugin.json 写 "kind": "notify",加载器就从 notify 注册表校验(默认 kind=platform 走 registry);/api/plugins 会多返回 kind,能力显示为「推送」。
  • 故障类型:run(整体运行异常)、course(单门课程异常)、login(登录失败 / 会话无法续期)、progress(学完仍未完成 / 进度没推进 / 没有可识别活动)、 retry(同一原因反复重试)。每种类型在设置项 notify 里有独立开关。
  • 触发点在引擎与路由:课程循环前后对比进度快照(engine._snap + notify.check_progress)、 课程异常 / 运行异常、SessionExpired 续期与重登、/api/login 失败;插件内部的重试 (公需课重登 / 自动重学、莞易学单课时重报)调 notify.retry 计数。
  • 节流:同一 (类型, 账号) 在 cooldown_min 分钟内只推一次;retry 只在计数刚达到 retry_threshold 时推一次;每轮学习开始 notify.reset(key) 清零计数。
  • 配置在系统设置 notify:base_url / app_key / app_secret / channel_name / 各类型开关 / retry_threshold / cooldown_min;POST /api/notify/test 走同一个 notify.send() 发测试推送。内置渠道插件 plugins/daidai_push/(呆呆面板)。

前端

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),三处内容必须一致。

  • 禁止在新代码里自己 print() 写业务日志,也不要直接调 store.run_log_add();模块只负责组织 msg。
  • key 约定:账号相关用账号 id(页面卡片「课程日志」直接显示);全局 / 系统事件用 "system"(右上角「系统日志」弹窗显示)。
  • 时间戳、级别、格式都由 runtime.log 统一拼(时间 [级别] 内容),模块里不要自己加时间前缀。
  • 前端读日志统一用 /api/log?key=&since=(实时)+ /api/log/history?key=&tail=(历史兜底),不要另起一套日志接口。

发版

通用流程见 AGENTS.md「发版本流程」;本项目的具体做法:

  • 版本号三处:metadata.yaml、pyproject.toml、uv.lock(其中 study-tool 那一段),由 pyproject.toml 的 [tool.bumpversion] 统一改,不要手改。
  • 发版前先 uv lock 自检;它若改动了 uv.lock,说明某个任务漏提交了锁文件,补进那次任务。
  • 命令:uv tool run bump-my-version bump patch。工作区需干净;它一次完成改三处版本、提交 chore(release): vX.Y.Z、打 tag vX.Y.Z,不要再手动 commit 或 git tag。
  • 发完用 git log -1 和 git tag -l 确认 tag 存在、HEAD 正确;同名 tag 已存在时工具会失败,不会覆盖。
  • 前端有改动时,pnpm build 并提交 static/ 要在发版前的普通提交里完成,发版步骤本身不构建。
  • 改了 [tool.bumpversion] 配置后,先跑 uv tool run bump-my-version bump patch --dry-run --allow-dirty -vv,确认三个文件都会被改。
  • 仓库文件是 CRLF,且 bump-my-version 默认按字面量搜索:用正则的条目必须写 regex = true,并且不要用 $ 锚定行尾(uv.lock 那条用 \r?\n 兼容换行)。