Files
study-tool/docs/OPTIMIZATION.md
T
study-tool 7259ab8d96 refactor(platforms): 学习流程下沉到平台层,engine 只保留编排
SessionExpired 移到 platforms/errors.py;旧版流程(_study_builtin)搬到 jspx.py,新版 SPA 流程(run_study_course + 视频)搬到 study.py;engine.py 只剩账号级编排,不再 import 具体平台;routes/quiz 去掉平台 star import,session/dump_cookies 缺失时明确报错。顺带修复学习链路三处未定义全局名(jspx/study/quiz)。
2026-10-31 14:21:36 +08:00

250 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.
# OPTIMIZATION
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
## 平台流程下沉(契约第二步)
**状态**:已实现,待实测确认。
### 做了什么
- `SessionExpired` 移到 `platforms/errors.py`,`config.py` 继续导出;平台实现不再反向 import 引擎。
- 旧版学习流程(反馈表 / 测验 / 刷课件)从 `engine.py` 搬到 `platforms/jspx.py`;新版 SPA 流程(含真播视频)搬到 `platforms/study.py`。
- `engine.py` 只剩账号级编排(课程循环 / 会话续期 / 按平台分发,115 行),不再 import 任何具体平台模块。
- `routes/*`、`quiz.py` 去掉对 `platforms.jspx` / `platforms.study` 的 star import;平台没实现 `session` / `dump_cookies` 时明确报错,不再退回莞易学的默认实现(原来会静默按 dgjy 过滤 cookie)。
### 怎么测
1. 莞易学:刷新课程 → 发现课程 → 开始学习(快速、常规各跑一次),反馈表 / 测验照常提交。
2. 广东公需课:开始学习,确认进度照常上报。
3. 错误分支:平台缺少 `session` 时,`/api/courses`、`/api/discover` 应返回「还没有实现 session」。
## 添加账号选择平台
**状态**:已实现,待实测确认。
### 做了什么
- 「添加账号」弹窗最上面新增「学习平台」下拉,选项来自 `GET /api/platforms`(当前有「莞易学」「广东公需课」)。
- 账号记录新增 `platform` 字段(SQLite 老库启动时自动 `ALTER TABLE` 补列)。
- 登录(`/api/login`)、刷新课程(`/api/courses`)、发现课程(`/api/discover`、`/api/quiz`)、
开始学习(引擎 `run_one_course`)都按账号所选平台取 `Platform` 上的实现;选「莞易学」时
仍然自动识别旧版 / 新版课程页。
- 以后新增平台:在 `platforms/<名字>.py` 实现 `session` / `login` / `list_courses` / `discover` /
`study` 并注册,下拉里就会多一个选项。
### 怎么测
1. 打开「添加账号」,确认最上面能看到「学习平台 / 莞易学」。
2. 正常添加账号并登录,卡片右上角显示所选平台的 label(当前为「莞易学」)。
3. 用一个已有账号点「刷新课程」「开始学习」,行为应与改动前一致(旧版 / 新版都能跑)。
### 如果没生效
- 下拉是空的:`GET /api/platforms` 应返回 `[{id, label}]`;检查 `platforms/__init__.py` 的
`load_builtin()` 是否导入了平台模块。
- 登录报「平台 xxx 还没有实现登录」:该平台的 `Platform.login` 没注册或 id 对不上。
- 老库启动报 `no such column: platform`:删掉 `data/study.db` 重启(当前账号数据为空),
或确认 `store._ensure_columns()` 已执行。
## 新旧平台合并为「莞易学」
**状态**:已实现,待实测确认。
### 做了什么
- `jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)在注册表里合并成一个平台:
`id=guanyixue`、`label=莞易学`。卡片右上角只显示「莞易学」。
- 课程发现时按 URL 和页面标记判断代际:旧版 `legacy`(`tier-tree-one` / `tier3`)、
新版 `spa`(Vue SPA / `/training/*`),写进 `course["generation"]`。
- 学习实现按代际分发:`legacy` 走 `engine._study_builtin`,`spa` 走 `engine.run_study_course`。
- 课程日志里的「平台:」一行会显示 `莞易学 · 旧版页面` 或 `莞易学 · 新版页面`。
- `GET /api/platforms` 返回 `[{id:"guanyixue",label:"莞易学"}]`,前端用 label 展示。
### 怎么测
1. 分别用一个旧版课程页账号和一个新版课程页账号(或同一账号的两门课)各跑一次自动学习。
2. 看卡片「课程日志」:应出现 `平台:莞易学 · 旧版页面` / `平台:莞易学 · 新版页面`,不再出现 `jspx` / `study` 平台名。
3. 卡片头部徽标应为「莞易学」;打开测验,交卷后后台日志正常,说明 `generation` 分支没走错。
### 如果没生效
- 日志报「无法识别课程页结构」:把页面快照发出来,可能站点又改版了,
需要补 `platforms/detect.py` 的代际标记。
- 日志报 `平台 xxx 没有可用的学习实现`:`course["platform"]` 不是 `guanyixue`,
检查 `platforms/jspx.py` 的 `_discover_once()` 是否被其他分支覆盖了。
## 前端 React 重构
**状态**:已实现,待实测确认。
### 做了什么
- 删除单文件 `static/index.html`,前端源码迁到 `web/`:React 19 + TypeScript + Vite +
Tailwind CSS v4,构建产物输出到 `static/`(`index.html` + `assets/*` + `favicon.svg`)。
- 页面功能保持不变:账号轮询(5s)、卡片内日志(1.5s)、课程勾选、登录 / 刷新 / 开始 / 停止 /
抓包 / 删除、自动交测验 / 反馈表开关、测验弹窗(手动作答 / AI 流式答题 / 再考一次)、
系统设置、系统日志、离线提示、Toast。
- FastAPI 新增 `/assets` 静态挂载和 `/favicon.svg`,`/` 仍返回 `static/index.html` 且禁止缓存。
### 怎么测
1. 直接 `python app.py`,打开页面:账号卡片、状态、课程列表、进度条、日志折叠都正常。
2. 走一遍登录 → 刷新课程 → 勾选 → 开始学习 → 停止,观察按钮 loading 和日志面板。
3. 打开测验(含单选 / 多选 / 判断 / 填空),提交后看标准答案和「再考一次」。
4. 设置里改大模型 / 学习模式并保存,刷新后值仍在;测试连接正常。
5. 右上角系统日志可查看、清空;断网后出现「后端未连接」提示。
### 如果没生效
- 页面空白 / 404 `/assets/...`:确认 `static/` 里有构建产物,且 `python app.py` 是在仓库根启动。
- 改了 `web/` 但页面没变:执行 `cd web && pnpm build`,构建产物才会进 `static/`。
- 端口不是 8848 时 `pnpm dev` 的 `/api` 代理会打错:改 `web/vite.config.ts` 的 `server.proxy`。
## 首次进入课程自动加入(syncSelf)
**状态**:已实现,待实测确认。
### 做了什么
首次进入没加入过的课程时,平台课时树会回 `code=500`「你还没有加入学习…」,前端把这句话
渲染成挡路的弹层,不点就进不去。真正把账号同步进课程的是页面自己发的这一发:
```
GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
```
(抓包实录:`isJoin=0 needJoin=1` → syncSelf 约 2s → 再拉 `getById` 就是 `isJoin=1`。)
`POST /course/front/student/join` 对 `isFreeJoin=0` 的慕课课会被拒「该课程无法直接加入,请联系管理员」,
所以现在 `ensure_study_join` 的顺序是:已加入 → `student/join` → `syncSelf` + 轮询 `getById`(约 11s)
→ 刷新 jspx 课程页复查。`course_code` 取自 `trainCourse/getById` 的 `courseCode`。
### 怎么测
1. 用一个没加入过该课程的账号,或把某课时的 `studyLog` 清掉(制造「首次进入」)。
2. 跑一次自动学习,看日志里加入那一段。
预期出现其中一条:
- `加入培训:同步课程页后已加入(student/join 被拒:…;syncSelf=请求成功)`
- `加入培训:刷新课程页后已加入(…)`
确认点是:之后的 `period/study/log` 真记账(课时 `completeTime` 有值),且全程不需要人工点弹层。
### 如果没生效
- 日志是 `加入培训被拒:…;课程没有 courseCode,无法走同步接口`
→ 该账号/该课程的 `getById` 没返回 `courseCode`,需要再抓一次包定位它的来源
(抓包时重点看 `syncSelf?courseCode=` 参数是从哪个响应/本地状态来的)。
- 日志显示 syncSelf 成功但 `getById` 一直 `isJoin=0`
→ 同步在服务端没落库,先看 `syncSelf` 响应的 `result.code`,再考虑补 `/mod/gjt/roleList` 之外的接口。
## AI 答题(流式 + 重试 + 日志)
**状态**:已实现,待实测确认。
### 做了什么
- `/api/quiz/answer` 从「等全部批次算完再返回」改成流式(`text/event-stream`):每批 5 题算完就推一行,连接一直有数据,反代不会空闲断连。
- 每批失败按错误类型决定是否重试一次:超时 / 5xx / 429 / 空内容 / 非 JSON 会重试(第二次关掉 `response_format`、口头要求 JSON),鉴权类错误直接放弃这一批。
- 一批里只答出部分题时,第二轮只补问没答出来的题。
- 解析加了兜底:标准 JSON、markdown 包裹、截断 JSON、`1.A / 第3题:B / 3. 错误` 这类自由文本都能识别;多选连写 `AC` 会拆成 `A#C`。
- 日志:开始(题数/批数/模型/超时)、每批耗时与题数、失败原因、模型原文摘要、结束统计;同时打到控制台、SQLite(`/api/log/history?key=<账号>`)和该账号卡片的「课程日志」。
### 怎么测
1. 打开一个测验,点「AI 答题」,看状态栏是否逐批更新「Ns · 已填 x/y 题」。
2. 结束后展开该账号卡片的「课程日志」,应能看到 `AI答题 开始/第 1/2 批…/结束` 若干行;控制台同样能看到。
3. 故意把 base_url 改错或断开网络再点一次,日志里应出现 `失败 x.xs:LLM 404/超时…`,界面提示「详见该账号课程日志」。
### 如果没生效
- 状态栏一直停在 0/0、日志里没有任何 `AI答题` 行 → 说明请求没到后端(看浏览器 Network 里 `/api/quiz/answer` 的状态码)。
- 日志里每批都 `失败 … LLM 401` → api_key 不对;`LLM 404` → base_url 少了 `/v1`。
- 每批都是 `本批只答出 0 题` 且模型原文很长 → 模型没按要求给题号,可把批次调小(`quiz.py` 里 `QUIZ_BATCH`)或换个指令遵循更好的模型。
## 模块拆分 + 统一日志源 + 平台插件
**状态**:已实现,待实测确认。
### 做了什么
- 代码改成 `src/` 包布局:`src/study_tool/` 下按层分 `config`(常量)、`runtime`(状态 + 日志)、`store`(SQLite)、`engine`(学习引擎)、`capture`、`models`、`settings_store`、`llm`、`quiz`、`platforms/`(平台插件)、`routes/`(HTTP 接口)。根目录 `app.py` 只做启动壳,`python app.py` / `start.bat` / Dockerfile 的启动方式不变;新增 `pyproject.toml`(`pip install -e .` 后可用 `study-tool` 命令)。
- 平台插件化:`platforms/base.py` 定义 `Platform(id, label, study)`,`platforms/registry.py` 提供 `register/get/all`,`platforms/jspx.py` 与 `platforms/study.py` 各自注册;`engine.run_one_course` 发现课程后改成 `registry.get(course["platform"]).study(...)`,不再写死 `if platform == "study"`。新增平台 = 加一个模块 + 一行 import,`docs/architecture.md` 有完整步骤。
- 日志统一走 `runtime.log(key, msg, level)`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份;设置保存、服务启动都会写。
- 新增右上角「系统日志」弹窗,看 `key=system` 的全局日志;卡片「课程日志」看该账号的日志。
- GBK 控制台编不了的字符(⚠ 等)自动降级,不再让 worker 线程抛异常。
### 怎么测
1. 启动服务,控制台应出现 `[system] SQLite: …` 和 `[system] 控制台启动 -> …`。
2. 页面右上角「系统日志」→ 看到上面两行;保存一次设置 → 多一行「设置已更新:模型 …」。
3. 点账号卡片的「课程日志」→ 学习日志和以前一致;点「清空」后不再出现旧行。
4. 跑一门课(旧平台 / 新平台各一次),日志里应出现该课程的流程,说明插件分发走到了对应平台。
5. `python -c "import sys; sys.path.insert(0,'src'); from study_tool.platforms import load_builtin, registry; load_builtin(); print(registry.all())"` → 应看到 jspx / study 两个平台。
4. 跑一次 AI 答题 → 该账号「课程日志」里是 `AI答题 …`,控制台同步同一份内容。
### 如果没生效
- 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。
- 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。
## 广东公需课平台接入(jsglpt → jsxx)
**状态**:登录(账号密码 + 图形验证码 OCR)、课程列表、课程发现、播放注册、进度心跳全部实测跑通;只剩「未学完课程」的进度增长待确认。
### 已确认的接口
- 登录:`POST https://jsglpt.gds.edu.cn/loginIn.do`,`loginUser.userName` / `loginUser.password` 都是 **base64**(账号是手机号),`loginUser.loginType=normal`;`captcha` 是**强制**的 4 位图形验证码,`GET /login/readCaptcha.action` 返回 `"data:image/png;base64,..."`,用 `ddddocr` 离线识别(识别错就换一张重试,最多 5 次)。登录失败时服务端把原因写回登录页 `#errorText`(如「请输入正确的验证码!」),据此区分是验证码还是密码问题。
- 换票:`POST /teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=` → `ITLMS-<hex>||<ticketId>`;把票按 `||` 切成两段,`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=<前段>&ticketId=<后段>` 才拿到学习平台会话(入口来自 jsglpt 首页的隐藏表单 `SSO_ITLMS_PUBLIC_STUDY_FORM`;直接带 `?ticket=` 访问 `courseRegister` 是无效的)。两个域名的 cookie 要在同一个 session 里。
- 课程列表:`GET /uc/store/courseRegister`,页面里是 `showCourse('<课程id>','<课程名>')`;同一张卡片里还有 `课程学时:N`、`培训年度:YYYY`、`未完成/已完成` 徽标,已解析成 `section` / `info` / `status_label` 显示在卡片上。
- 学习页:`GET /study/course/<课程id>?v=1`(服务端渲染、UTF-8),含 `url1`(CDN 直链 MP4)、`t`(总秒数)、`md5Uid`、`/a_<aid>/...` 上报前缀。
- 播放注册:`POST /a_<aid>/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=<uid>&playbackSessionId=<sid>&playbackTakeover=true`)→ `{time, md5}`。
- 进度心跳:`POST /a_<aid>/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。
- 已看秒数:`GET /a_<aid>/study/video/user/getViewTime?id=&md5=&expires=&md5Uid=`,`responseData` 直接就是秒数。
### 踩到的坑
- 三个上报接口必须带 **Referer(指向课程页)**,否则 500 且响应体为空。
- `isLimit` 只能传 `false` / `true`,传 `"N"` 会 400。
- 课程列表页、学习页都是 UTF-8(别按 GBK 解,会花屏)。
### 关键结论
进度不靠播放器,`lastViewTime` 直接写服务端。实测把 2640 秒的课报到 2700 秒也被接受,说明可以跳着报、不必真播;默认 `speed=60`、`beat=2`,44 分钟的课约 45 秒报完。课程是否学完看 `getViewTime >= t`。
### 怎么测
1. 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。
2. 选一门**没学完**的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。
3. 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 cookie,再补 OCR。
### 如果没生效
- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。
## 运行状态与日志修复(重启标记 / 历史日志 / 删账号清日志)
**状态**:已实现,待实测确认。
### 做了什么
- **重启标记不再污染新一轮运行**:`app.py` 的 `hydrate_runs()` 把上次没跑完的状态标成「已中断」后立刻落库(库里不再一直挂着 `studying`);`runtime.set_state()` 在状态从非运行态切到 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,所以点「开始学习」后不会还挂着「服务重启,上次运行已中断」。
- **重启后历史日志能读出来了**:前端拉日志的兜底条件原是「内存为空 **且** status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读。现在只看内存是否为空(`web/src/App.tsx`)。
- **删账号连带清日志**:`store.account_delete()` 除 run_state / run_log(账号 id)外,还会按**用户名**清 run_log 与 login_log(登录日志的 key 和 `login_log.username` 记的是手机号,不是账号 id);`DELETE /api/accounts/{id}` 同时把内存里这两个 key 的日志一起丢掉。
### 怎么测
1. 重启服务后打开页面:卡片应是新状态;点「开始学习」,红条与上一轮的旧进度立即被覆盖。
2. 重启服务(内存日志清空)后展开某账号「课程日志」:应显示 SQLite 里的历史行,而不是「暂无日志」。
3. 删掉一个账号再刷新:该账号的课程日志、登录记录(`/api/loginlog`)都不应再有它的行;`system` 与其它账号的日志必须保留。
### 如果没生效
- 红条还在 → 看 `run_state` 里那条是否仍带旧 `error`,删掉该行即可复位。
- 历史日志还是空 → 确认 `static/` 已重新构建(`cd web && pnpm build`)并强刷页面。
- 删完还有日志 → 平台的登录用户名与账号 `username` 字段不一致时清不掉,需按账号实际写入的 key 处理。