48e72ab1e7
现象: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 真实发版;没跑测试、没构建(本次无代码改动)
270 lines
17 KiB
Markdown
270 lines
17 KiB
Markdown
# 架构说明
|
||
|
||
## 目录
|
||
|
||
```
|
||
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` 兼容换行)。
|