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

270 lines
17 KiB
Markdown
Raw 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.
# 架构说明
## 目录
```
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"]` 分发:
```python
# engine.run_one_course 末尾
pf = registry.get(d.get("platform"))
pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...)
```
`Platform` 契约(见 `platforms/base.py`):一个平台 = 一个 `Platform` 子类,类属性放元数据,
自己确实有的阶段实现成方法;注册时只实例化子类,不再逐项绑定函数。
```python
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`:
```python
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` 兼容换行)。