f1a57853f4
- platforms/gdgxk.py → pugins/gdgxk/main.py(实现不变,仍是 class Gdgxk(Platform)) - pugins/gdgxk/pugin.json 只放注册信息 id / title / name,不声明任何能力绑定 - pugins/__init__.py:load_plugins() 扫 pugins/*/pugin.json、import 各自的 main.py, 再用 pugin.json 的 id 校验注册结果;要换加载方式只改这一个函数 - platforms/__init__.py::load_builtin() 调 load_plugins();guanyixue 依赖 jspx/study, 暂留 platforms/ - engine / routes / quiz / contract / 前端未改动 - 同步 AGENTS.md、docs/architecture.md、docs/OPTIMIZATION.md
275 lines
19 KiB
Markdown
275 lines
19 KiB
Markdown
# OPTIMIZATION
|
||
|
||
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
|
||
|
||
## 平台契约改为子类实现(注册方式调整)
|
||
|
||
**状态**:已实现,待实测确认。
|
||
|
||
### 做了什么
|
||
|
||
- `Platform` 从 dataclass「注册时逐项传函数」改成基类:`id` / `label` / `entries` 是类属性,
|
||
能力是子类方法;没实现的阶段保持基类的 `None`,`capabilities()` 与 `/api/platforms` 不变。
|
||
- `pugins/gdgxk/main.py`(原 `platforms/gdgxk.py`):`session`(原 `new_session`)/ `login` /
|
||
`list_courses` / `discover` / `study` 等收进 `class Gdgxk(Platform)`;解析 / 上报类 helper
|
||
仍是模块级函数。
|
||
- `platforms/guanyixue.py`:改成 `class Guanyixue(Platform)`,方法转调 `jspx` / `study`。
|
||
- `pugins/` 改成插件目录:`pugins/<名字>/pugin.json`(只放 `id` / `title` / `name`)+ `main.py`
|
||
(`Platform` 子类);`pugins/__init__.py::load_plugins()` 扫目录 import,`load_builtin()` 调它。
|
||
插件里不再声明任何能力绑定。
|
||
- 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。
|
||
- 新增 [docs/pugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。
|
||
|
||
### 怎么测
|
||
|
||
1. `GET /api/platforms` 仍返回 `gdgxk` 与 `guanyixue`,`capabilities` 与改动前一致
|
||
(广东公需课:`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`)。
|
||
2. 莞易学:刷新课程 → 发现课程 → 开始学习照常(旧版 / 新版都跑一次)。
|
||
3. 广东公需课:登录 → 刷新课程 → 开始学习照常。
|
||
|
||
## 平台流程下沉(契约第二步)
|
||
|
||
**状态**:已实现,待实测确认。
|
||
|
||
### 做了什么
|
||
|
||
- `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 处理。
|