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

17 KiB
Raw Blame History

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 两个平台。
  6. 跑一次 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 处理。