# 架构说明 ## 目录 ``` 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 学习引擎:登录→发现→按平台分发→worker / 视频 / 提交 capture.py 抓包 platforms/ 平台插件(见下) base.py Platform 契约(id / label / study / entries) registry.py 注册表:register / get / detect / all detect.py 页面代际识别(legacy 旧版 / spa 新版) guanyixue.py 莞易学平台:合并 jspx 登录入口与 study 学习入口 jspx.py 莞易学旧版页面实现(会话 / 登录 / 课程抓取 / 旧版学习) study.py 莞易学新版 SPA 实现(StudyClient / 接口学习) 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 接口。 ## 平台插件与识别 平台实现在 `platforms/`,通过注册表注入,`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`): ```python @dataclass class Platform: id: str # course["platform"] 的取值 label: str # 日志 / 界面展示名,如「莞易学」 study: Callable # (key, s, d, **opts) -> None entries: tuple[str, ...] # 登录入口 / 学习入口的 URL 片段 # 账号级能力:添加账号时选中的平台决定登录 / 刷新课程 / 发现课程走哪套实现 session: Callable # (cookies) -> requests.Session dump_cookies: Callable # (session) -> [cookie] login: Callable # (username, password) -> (session, user) list_courses: Callable # (session) -> [course] discover: Callable # (session, plan_id, project_id, name) -> 课程描述 renew_session: Callable # (session) -> bool,无密码续期 profile: Callable # (session) -> 显示名 # 槽位为 None = 该平台没有这项能力;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=莞易学`。两代课程页: - `platforms/jspx.py`:旧版页面(`tier-tree-one` / `tier3`),负责会话、登录、课程抓取, 学习流程在 `engine._study_builtin`。 - `platforms/study.py`:新版 Vue SPA,`StudyClient` + `/train/course/front/*` 接口, 学习流程在 `engine.run_study_course`。 - `platforms/guanyixue.py`:注册平台并按 `course["generation"]` 分发; `platforms/detect.py` 只判断页面代际,不判断平台归属。 ### 内置:广东公需课 `id=gdgxk`,`platforms/gdgxk.py` 单文件实现:在 `jsglpt.gds.edu.cn` 登录(账号、密码 都是 base64),再用 `ssoItlmsGxkTicket.action` 换票进 `jsxx.gds.edu.cn`;视频按真实 流逝时间上报 `updateViewTime1`。没有测验 / 反馈表,所以不实现对应能力。 ### 新增一个平台 1. 新建 `src/study_tool/platforms/<名字>.py`: ```python from study_tool.platforms import registry from study_tool.platforms.base import Platform def _study(key, s, d, **opts): # 用 s(requests.Session,带该账号 cookie)把课程活动跑完 # 日志一律 runtime.log(key, ...) ... PLATFORM = registry.register(Platform( id="<平台 id>", label="展示名", study=_study, entries=("<登录入口域名>", "<学习入口域名>"), session=, dump_cookies=, login=, list_courses=, discover=, )) ``` 2. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 `from study_tool.platforms import <名字>`。 3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 / 发现课程 / 学习都走它的实现,不需要改路由或前端。 已内置莞易学(`guanyixue`)和广东公需课(`gdgxk`);`accounts.platform` 是平台分发依据, 接入新平台只需在 `platforms/<平台>.py` 实现对应阶段的槽位,引擎与路由不用改。 ## 前端 `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()`。