Files
study-tool/docs/OPTIMIZATION.md
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

48 KiB
Raw Permalink Blame History

OPTIMIZATION

已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。

发版体检修复(src/study_tool/app.py、routes/settings.py、notify.py、web/src/components/SettingsDialog.tsx)

状态:已实现,已用独立端口 + 独立库实例复测(首页 / /query / 系统设置 / 插件管理均正常渲染,无 console 报错);「清空密钥」「推送失败重试」需你实测。

做了什么

  • 启动日志不再输出 AUTH_TOKEN 明文(日志同时进控制台、内存和 SQLite,前端「系统日志」里能看到),改成「(需要访问令牌:见 .env 的 AUTH_TOKEN)」。
  • 系统设置:温度留空 / 填 0 = 不改,不再把已保存的温度清成 0;API Key、app_secret 右侧加「清空」按钮(clear_api_key、notify.clear_app_secret),不用再拿一个假值覆盖。
  • 故障推送:发送失败只冷却 1 分钟,原来一次网络抖动会把整段 cooldown_min 的冷却占掉。
  • 文案与文档:platforms/、pugins/ 统一成 plugins/;docs/pugin.md 更名为 docs/plugin.md。
  • 工程:pyproject.toml 版本与 metadata.yaml 对齐,bumpversion 同时更新两处;.gitignore 补 .idea/、.ruff_cache/。

怎么测

  1. 重启服务,看控制台和右上角「系统日志」:启动那行应是 控制台启动 -> http://127.0.0.1:8848(需要访问令牌:见 .env 的 AUTH_TOKEN),不应出现令牌本身。
  2. 已保存 API Key 时打开「系统设置 → 大模型」:API Key 右侧有「清空」,点了保存后 has_key 应变成 false;「故障推送」里的 app_secret 同理。
  3. 把推送 base_url 改成打不通的地址,触发一次故障推送:日志应出现「故障推送失败」,约 1 分钟后再次触发会重新尝试,而不是等满 10 分钟。

卡片标题显示学员姓名 + 日志面板明亮模式(plugins/gdgxk/main.py、utils/learn.py、web/src/components/ui/LogLines.tsx、AccountCard.tsx、SystemLogDialog.tsx)

状态:已实现,已用隔离实例截图核对(标题显示平台姓名、日志面板明亮 + 分级配色);「登录后回填姓名」和「进度上报新文案」需你下次学习时确认。

做了什么

  • 广东公需课 profile() 改成先解析个人中心头像区的 <a class="name">:学员账号平台显示的是打码姓名(如「*栋」),老师账号是完整姓名;解析不到才退回原来的「你好,X 老师」。卡片标题仍是 user → username 的优先级,所以登录 / 刷新课程后标题就变成姓名,第二行继续显示登录账号。
  • 进度上报日志改成能对得上的说法:进度上报:平台已记 1200/2580 秒(46%),本次上报到 1201 秒;课件结束改 课件完成:xxx(平台已记 2580/2580 秒),不再出现「还差一半却说 100%」。
  • 新增 ui/LogLines.tsx:按 时间 [级别] 内容 拆行渲染,info 灰蓝、warn 琥珀、error / fail 红色、success 绿色,时间戳淡灰。
  • 账号卡片的「课程日志」和右上角「系统日志」都从黑底改成浅色底(bg-slate-50 + 细边框),长行自动换行不再横向滚动。

怎么测

  1. 刷新页面后点一次「刷新课程」(或「重新登录」):卡片标题应从手机号变成平台姓名(广东公需课学员是「*栋」这种打码名),第二行仍显示登录账号。
  2. 下次学习时看日志:应出现 进度上报:平台已记 x/y 秒(z%),本次上报到 n 秒,百分比与秒数对得上;课件跑完是 平台已记 y/y 秒。
  3. 日志面板应是浅色底:info 灰蓝、warn 琥珀色、error 红色;点右上角「系统日志」也是同样的浅色配色,长行自动换行。

账号卡片:修改账号信息弹窗 + 自绘确认弹窗(web/src/components/AccountEditDialog.tsx、ui/ConfirmDialog.tsx、ui/Modal.tsx、ui/Button.tsx、AccountCard.tsx、QuizDialog.tsx、App.tsx)

状态:已实现,已用独立端口 + 复制库实例截图核对「修改账号信息」弹窗与「删除账号」确认框;「保存后自动重新登录」这一条还需你实测。

做了什么

  • 账号卡片「更多操作」菜单最上面新增「修改账号信息」;账号带 lastError 时,红条下方也直接给一个「修改账号信息」按钮,出错账号不用再翻菜单。
  • 新增 AccountEditDialog:可改学习平台 / 登录账号 / 密码 / 备注。密码留空表示不修改(后端 account_upsert 保留原密码),带显示/隐藏切换。保存后写库并立刻用新凭据重新登录一次:弹窗先关,登录在后台跑,失败走 toast + 卡片红条。
  • 新增 ui/ConfirmDialog.tsx 的 useConfirm():项目内统一风格的确认弹窗(图标 + 标题 + 说明 + 主次按钮,危险操作用红色实心按钮)。替换了三处 window.confirm:删除账号(App.tsx)、提交测验缺题(QuizDialog.tsx)、再考一次(QuizDialog.tsx)。
  • Modal 增加 sm 尺寸;Button 增加 danger-solid 变体。

怎么测

  1. 刷新页面,点账号卡片右下「⋯」→「修改账号信息」:弹窗应回填当前平台 / 账号 / 备注,密码框为空并提示「已保存密码,需要更换时再填」。
  2. 只改备注后保存:应提示「账号信息已保存,正在用新凭据重新登录…」,随后登录成功并刷新课程;密码留空不应把库里密码清空。
  3. 故意填错密码保存:卡片应变成「登录失败」+ 红条,红条下出现「修改账号信息」按钮,点它能直接再改。
  4. 点「⋯」→「删除账号」:弹自绘确认框(红色图标 + 红色「删除账号」按钮),取消不删除、确定才删;测验缺题提交、再考一次也应弹同样的自绘弹窗,不再出现浏览器默认框。

故障推送:呆呆面板推送渠道(study_tool/notify.py、plugins/daidai_push/、routes/settings.py、engine.py、web/src/components/SettingsDialog.tsx)

状态:已实现并实测通过(本机已用给定 app_key / app_secret 换到 token 并成功发出测试推送;进度异常 / 多次重试的自动触发还需一次真实学习验证)。

做了什么

  • 新增 study_tool/notify.py:渠道注册表 + fault() 故障上报 + retry() 重试计数 + 冷却(同账号同类型 cooldown_min 分钟内只推一次,重试只在刚达到阈值时推)。
  • 新增插件 plugins/daidai_push/(plugin.json 里 "kind": "notify"):POST {base_url}/api/open-api/token 换 token,内存缓存并按 expires_in 过期,发送遇 401/403 自动换新 token 重发一次;POST {base_url}/api/notifications/send 发通知。
  • 设置项 notify:base_url(默认 https://corn.hanhan.ltd)、app_key、app_secret、channel_name(留空走默认渠道)、enabled、四个分类开关、retry_threshold(默认 3)、cooldown_min(默认 10)。GET/POST /api/settings 读写,app_secret 只回显前后几位;新增 POST /api/notify/test。
  • 触发点:学完仍未完成 / 进度没推进 / 没有可识别活动、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与「未完成任务点自动重学」、莞易学单课时重报都计入重试次数。
  • 前端「系统设置」新增「故障推送」区块(含「测试推送」按钮),插件管理里该插件显示能力「推送」。

怎么测

  1. 重启服务,打开「系统设置」:左侧是「大模型 / 学习模式 / 故障推送」三栏,右侧只显示当前分类;切到「故障推送」应能看到已填好的 app_key / app_secret(secret 显示为掩码),点面板右上「测试推送」应收到一条「【测试】学习控制台推送」。
  2. 「插件管理」里应有「呆呆面板推送」卡片,能力显示「推送」;停用后点「测试推送」应提示没有可用渠道,重新启用后恢复。
  3. 跑一门学不完的课(或故意让会话失效):课程结束应收到「学习进度异常」或「登录/会话异常」推送,且同一账号 10 分钟内不会重复推。
  4. 公需课连续 3 轮进度不增加、或莞易学单课时连续 3 次上报未生效时,应收到「多次重试」推送(阈值可在设置里改)。

插件管理卡片:能力中文 + 适配网站(web/src/components/PluginsDialog.tsx、plugins/__init__.py、platforms/contract.py)

状态:已实现,待实测确认(前端已 cd web && pnpm build;/api/plugins 多了 sites,需重启服务才生效)。

做了什么

  • 卡片上的能力名由 login / session / … 改成中文:登录 / 会话 / 续期 / 账号信息 / 课程列表 / 课程详情 / 学习 / 测验 / 抓包(映射在 PluginsDialog.tsx,接口仍返回能力键)。
  • /api/plugins 每条插件多返回 sites:取平台 entries 并去掉被更具体域名覆盖的父域(莞易学的 dgjy.net 不会单独列)。
  • 卡片内的信息改成白底分组面板:适配网站 一行用等宽字体标签列出域名,能力 一行用蓝色标签(chip)逐个列出,平台 / id 放在最下面一行弱化展示。
  • 顺手把卡片里过期的 plugins/、plugin.json 文案改成 plugins/、plugin.json。

怎么测

  1. 重启服务后打开右上角「插件管理」:两张卡片都显示中文能力,且各有一行「适配网站」(公需课 jsglpt.gds.edu.cn · jsxx.gds.edu.cn,莞易学 jspx.dgjy.net · study.dgjy.net)。
  2. 点停用 / 启用 / 重载:清单刷新后网站与能力仍正确。

公需课交互式测验(plugins/gdgxk/main.py 的 quiz / quiz_submit)

状态:取题已验证(本机账号那门课解析出 30 道题、题型 01、选项 A–D);交卷未实测(该课考核早已提交,done=true,所以弹窗只会显示题目、按钮禁用)。

做了什么

  • 公需课补上 quiz / quiz_submit 两个槽位,前端卡片的「打开测验」因此出现(能力由方法推导)。
  • 取题:考核是独立活动页 jsxx.gds.edu.cn/<aid>/study/course/<plan_id>(课程主页上没有 #testForm),从课程目录里找标题含「考核 / 考试」的活动,打开它再 exam.parse;题型映射成弹窗认的 01/02/03。已交卷的课也返回题目,方便回看。
  • 交卷:exam.submit_letters 重取一次考核页拿隐藏字段,把用户选的字母按选项 value(Choice0..4)换成平台要的值,再走原来的 finishTest2 提交并回读得分。
  • /api/quiz/submit 改为按平台分发;莞易学的交卷逻辑挪进 quiz.submit_quiz,行为不变。
  • 顺带修好学习流程里的自动考核:exam.run 之前也读课程主页(拿不到表单),现在同样改为读考核活动页。

怎么测

  1. 找一门考核还没交的公需课,点「打开测验」:应列出 30 道题的题干 / 题型 / 选项。
  2. 点「AI 答题」:应分批把答案填进选项(走 /api/quiz/answer,纯大模型)。
  3. 改几道答案后点交卷:应返回得分;再点一次「打开测验」应显示已交卷且题目可见。
  4. 若提示「考核页里没有找到题目」,把该考核页 HTML 发我,exam.parse 的选择器要跟着改。

刷新课程反复重登 / 空平台报错(web/src/App.tsx、routes/accounts.py、plugins/gdgxk/main.py)

状态:已实现,待实测确认(前端需先 cd web && pnpm build)。

做了什么

  • refreshCourses 在 session_expired 自动重登后的重试补上 platform,不再出现「平台「」还没有实现刷新课程」,登录成功的课程也会正常写回卡片。
  • 前端只在运行中(discovering / studying)才把 /api/runs 的 cookies 回写账号;运行结束后不再用开跑时的旧会话覆盖刚登录的新会话。
  • _clear_done_run 一并清掉运行状态里的 cookies,重新登录后旧会话不会继续挂在 /api/runs 里。
  • 公需课学习中掉线重登(_relogin)成功后,用 database.account_save_cookies 把新会话写回账号,并 set_state(cookies=…) 同步运行状态。

怎么测

  1. 让账号会话失效(隔一段时间或换设备登录一次),点「刷新课程」:应看到「会话已失效,正在自动重新登录…」后直接读到课程,不再弹「平台「」还没有实现刷新课程」。
  2. 自动重登后等 10 秒以上,再点一次「刷新课程」:不应再次触发登录(说明旧 cookie 没有被写回去)。
  3. 学习中故意让会话掉线,日志出现「重新登录成功,继续学习」后结束运行,再点「刷新课程」:应一次成功、不再重登。

公需课自动重学(plugins/gdgxk/main.py 的 study)

状态:已实现,待实测确认。

做了什么

  • 每刷完一轮活动,都重新请求进度页 /study/course/progess 取任务点状态;全部已完成(或一开始就全部已完成)时也会重取一次,以平台状态为准。
  • 进度页里还有未完成任务点,就等 60 秒(ROUND_WAIT)后自动重新开始学习,只重刷未完成的活动。
  • 连续 3 轮(MAX_STALL)完成数没增加,或达到 5 轮(MAX_ROUNDS)就停止,日志列出剩余任务点,避免考核等非视频任务点死循环。
  • 重试等待期间点「停止」会立即退出。

怎么测

  1. 选一个有未完成任务点的账号点「开始学习」,观察日志有没有「第 N 轮结束……稍后自动重新学习」,并且自动开始下一轮。
  2. 选一个全部任务点已完成的账号,日志应显示「平台进度:N/N 个活动已完成」后结束。
  3. 若某个任务点平台迟迟不记完成,应在 3 轮后停下并列出该任务点;如果 60 秒不够平台刷新状态,调大 ROUND_WAIT。

公需课选课兜底(plugins/gdgxk/utils/register.py)

状态:已实现,未实测(写的时候没有账号,选课超市的页面结构和选课请求是按截图推断的)。

做了什么

  • list_courses 发现课程列表里没有目标年度的课时,调用 register.ensure:读 /uc/store/course/ 里所有课程链接,逐个打开详情页判断状态。
  • 状态判断:任一页按钮是「进入学习」= 已经选中,直接重读课程列表;选中的课位置对应页写「已有选课」= 已选了别的课,不再选;都没选就选第 pick 门。
  • 配置在 plugins/gdgxk/config.json:year(空 = 当前年份)、pick(第几门,从 1 开始)。暂未做弹窗让用户挑。

怎么测

  1. 用一个当年还没选课的账号点「刷新课程」,看系统日志有没有「公需课选课:…」。
  2. 若提示「没有解析到课程链接」或「没有找到选课按钮的请求地址」,把 /uc/store/course/ 页面 HTML、未选状态的详情页 HTML、点「选课」时的请求(cURL)发我,据此改 register.py 里的正则和请求方式(现在假定选课是 GET)。

公开查询页 /query

状态:已实现,待实测确认(前端需先 cd web && pnpm build)。

做了什么

  • 新增 routes/query.py:GET /api/query?username= 按用户名返回账号的课程列表(名称 / 章节 / 状态 / 进度)和运行进度,不含密码 / cookie / 账号 id。
  • app.py 的 token 中间件放行 /query、/api/query、/favicon.svg 和 /assets/*;/query 与 / 共用同一个前端入口。
  • 前端 main.tsx 按路径渲染:/query 显示新页面 pages/QueryPage.tsx(上方输入框 + 查询按钮,下方课程列表与进度),其余路径仍是控制台。

怎么测

  1. 设置 AUTH_TOKEN 后启动,不带 token 打开 /query,应能正常显示;打开 / 仍要求 token。
  2. 输入已添加账号的用户名点「查询」,应显示课程列表和进度;输入不存在的用户名显示「没有找到该账号」。
  3. 注意:该接口任何人知道用户名就能查到课程与进度,公网部署请确认可接受。

题库匹配(核心 bank.py + 公需课题库数据)

状态:已实现,待实测确认。匹配部分已完成;公需课考核已接到它上面(见下一节)。

做了什么

  • 新增 src/study_tool/bank.py(与平台无关):题干归一化后按「精确 → 互相包含(两边至少 8 字)→ 相似度 ≥ 0.9」查题库;没命中的题按 5 题一批问设置里的大模型;没配大模型就留空并打 warn 日志。入口 bank.answer(key, questions, bank)。
  • 新增 plugins/gdgxk/utils/bank.py 与 plugins/gdgxk/bank/2026.json:题库数据由脚本的 121 条 videoBank 转成 [{"q": 题干, "a": "B"}]。明年换题库只需往 bank/ 加 JSON,同目录所有 *.json 会合并。
  • answer.py(考核按序答案)没动:考核改走题干匹配,按序答案留着备用。

怎么测

  1. 在项目根目录用 Python 执行:from study_tool.plugins.gdgxk.utils import bank; bank.answer("t", [{"no":1,"type":"判断","title":"生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。","options":[{"key":"A","text":"正确"},{"key":"B","text":"错误"}]}]),应返回 {"1": {"answer": ["B"], "source": "bank"}}。
  2. 换一道题库里没有的题:没配大模型时日志有「还没配置大模型」警告;配了大模型会返回 source: "llm"。

公需课课程考核(plugins/gdgxk/utils/exam.py)

状态:已实现,待实测确认(本轮只验证了解析与选项映射,交卷响应体还没在真实环境跑过)。

做了什么

  • 考核不是 JSON 接口:题目内嵌在课程学习页 HTML(#topicList .m-topic-item),交卷是 POST .../ncts/test/delivery/<id>/finishTest2,body 为 _method=put + 表单序列化(Spring method override)。
  • 题型与字段:单选 radio、多选 checkbox(最多 5 个选项)、是非 radio,字段名 qti_response_Q{n}:{n+1}:1_RESPONSE,值 Choice0..4;隐藏字段 qti_item_* 原样回传。
  • exam.parse 解析题面 / 选项字母 / 提交值;exam.answer 把题库或 AI 给的字母换成 ChoiceN;exam.submit 按页面 ajaxSubmit 的做法交卷。
  • 题库没覆盖全部题目时不交卷,只打 warn 并列出缺答案的题号(避免瞎蒙把分数拉低);交完卷解析「已得 N 分」,低于 60 分打 warn 提示人工重考。
  • main.py 的 study() 在视频轮次跑完、进度仍有缺口时调用一次考核,交完再按进度页复核。opts["exam_auto"]=False 可关掉自动交卷。
  • 页面上已显示「您已完成测验」时直接跳过,不会重复交卷。

怎么测

  1. 找一门视频已刷完、只差考核的课跑一次,日志应出现「考核已交卷,得分 N」。
  2. 故意让题库缺题(临时改 bank/2026.json):应出现「考核共 N 题,M 题没答案,本次不交卷」,且不提交。
  3. 重跑已完成的课:日志应是「考核已完成」,不再请求 finishTest2。

目录整理:config / database / llm 包,平台代码收进插件

状态:已实现,待实测确认。

做了什么

  • 二次清理:莞易学专属常量(站点地址、登录授权入口、课程板块、NODE / DESENC、DEBUG_DIR 等)迁到 plugins/guanyixue/config.py;测验请求模型 / 答题提示词 / 题型名迁到 plugins/guanyixue/quiz.py;config.LOGDIR、SessionExpired 再导出、AccountsIn 无人使用,已删除;核心模块的 star import 改为显式。
  • config.py → config/ 包:config/__init__.py 放常量 / 环境变量 / 根目录定位,config/settings.py 放系统设置默认值与读写。
  • settings_store.py 拆开:/api/settings 移到 routes/settings.py;设置读写留在 config/settings.py(插件不再反向 import 路由)。
  • llm.py → llm/ 包:llm_chat() 在 llm/__init__.py,/api/llm/test 移到 routes/llm.py;后续大模型相关代码都放这个目录。
  • store.py → database/ 包,改用 SQLAlchemy 2.0 ORM:database/models.py 定义 settings / kv / accounts / login_log / run_log / run_state / snapshots 七张表,database/__init__.py 提供 engine、线程级 scoped Session 和原来的读写函数(函数名与返回结构不变)。
  • capture.py → plugins/guanyixue/capture.py;抓包接口按账号所属平台走 registry.get(...) 的 capture / captures / capture_reset。
  • quiz.py → plugins/guanyixue/quiz.py(莞易学专用);插件 main.py import 它来注册 /api/quiz/*。
  • app.py 调 routes.load_routes() 自动扫描并导入 routes/*(新增路由文件不用改 app.py,之前漏写 import 会导致全部 /api/* 404);新增依赖 sqlalchemy>=2.0(pyproject.toml,需 uv sync)。
  • docs/architecture.md、README.md、docs/plugin.md 同步更新目录与模块引用。

怎么测

  1. uv sync 后启动,/api/health 正常,data/study.db 能正常建表 / 读写。
  2. 账号列表 / 登录 / 登录记录、课程日志、运行状态(/api/runs)、快照、设置保存、LLM 测试接口,行为与改造前一致。
  3. 抓包开始 / 停止 / 查看记录正常;插件停用 / 启用 / 重载正常。
  4. 莞易学插件启用时,/api/quiz、/api/quiz/submit、/api/quiz/answer 仍可用。
  5. 莞易学账号登录(旧版 jspx 与新版 study 两条链路都走一遍)、刷新课程、学习正常;新版课程学习开头仍有「新版课程 ID」日志。
  6. 首次启动不再创建 data/logs 目录(只要 data/debug 与数据库)。
  7. 启动后不会再出现 src/data/:database 的 DATA_DIR 复用 config.DATA(仓库根 data/)。之前 database/ 比旧 store.py 深一层,路径多上了一级才落到 src/data;原 src/data/study.db 里的账号 / 登录记录 / 日志已合并进根目录 data/study.db,页面应能看到两个 gdgxk 账号。

已知取舍

  • /api/quiz/submit、/api/quiz/answer 仍由莞易学插件自己注册;加载器负责在停用 / 重载时摘掉,所以不再重复注册。要做成通用路由需等第二个平台有测验。

插件可删除:路由随插件进出、前端按能力显示、残件归位

状态:已实现,待实测确认(我只做了静态检查、前端 typecheck / build,以及在临时副本里实际启动验证)。

做了什么

  • plugins/__init__.py:加载器记录每个插件 import 期间注册到 app 的路由,停用 / 重载 / 加载失败时一并摘除;重载不再重复注册 /api/quiz*。
  • platforms/contract.py / base.py:能力新增 quiz(quiz(self, s, d, b))与 capture;/api/platforms 的 capabilities 会带上它们。莞易学 quiz.py 的取题拆成 fetch_quiz,/api/quiz 改走 pf.quiz。
  • 去掉写死的 guanyixue 默认值:accounts.platform 默认空串、请求模型默认空串、_capture_platform 不再兜底;前端平台列表初值为空,「打开测验」「抓包」按 capabilities 显示,页脚和添加账号提示不再写「莞易学」。
  • 残件归位:script/des.js、desenc.js、study_video.py → plugins/guanyixue/script/;script/capture-jsglpt.py、test-gdgxk.py → plugins/gdgxk/script/;DESENC 路径随插件目录走;test-gdgxk.py 改成走新的插件 API。
  • 已验证(临时副本):正常启动;停用 → 启用 → 重载两次后 /api/quiz* 恰好各一条;删掉莞易学目录 / 删光所有插件目录后服务都能启动,/api/platforms 分别只剩 gdgxk / 为空。

怎么测

  1. cd web && pnpm build 后启动,界面能打开;平台下拉里有「莞易学」「广东公需课」。
  2. 莞易学账号卡片「更多」菜单有「打开测验」「抓包」;广东公需课账号的菜单里没有这两项。
  3. 「插件」弹窗里停用莞易学:/api/quiz 返回 404;再启用恢复;反复点「重载」不报错。
  4. 把 plugins/guanyixue 整个目录挪走再启动:服务正常,添加账号只能选公需课;之前的莞易学账号登录时提示平台未实现,不影响其它账号。
  5. 在仓库根目录执行 python src/study_tool/plugins/guanyixue/script/study_video.py verify <trainCourseId> 能跑(脚本自己把 src 加进 sys.path)。
  6. Docker 构建后 nodejs 仍能被登录调用(DESENC 现在指向插件目录)。

本机启动读取根目录 .env

状态:已实现,待实测确认。

做了什么

  • 修问题:以前只有 docker compose 会读 .env,本机 python app.py / uv run app.py / start.bat 下 AUTH_TOKEN / PORT / RUN_LOG_KEEP / SNAPSHOT_KEEP 全部不生效。
  • study_tool/__init__.py 用 python-dotenv 加载仓库根 .env;放在包初始化处,早于 store / config 读环境变量(database/__init__.py 是 import 时就读 DB_PATH / RUN_LOG_KEEP)。已存在的环境变量优先,Docker 由 compose 注入的不受影响。
  • 新依赖 python-dotenv>=1.0(pyproject.toml / requirements.txt,需 uv sync)。
  • app.py 监听地址改成 HOST → BIND_ADDR → 127.0.0.1,.env 里的 BIND_ADDR 本机也生效。
  • README 环境变量表补 .env 说明与 BIND_ADDR / TZ 两行;.env.example 说明改为「本机与 Docker 都读」。

怎么测

  1. 根目录有 .env(AUTH_TOKEN 非空)时直接 python app.py,启动日志里的地址应带 ?token=。
  2. 不带 token 开 http://127.0.0.1:8848/ 应显示「需要访问令牌」页;带 ?token=<值> 正常进控制台。
  3. .env 改 PORT=8849 重启 → 实际监听 8849;命令行 set PORT=8850 再启动应压过 .env。
  4. .env 里 BIND_ADDR=127.0.0.1 → 只绑本机;改 0.0.0.0 → 同网段可访问。

莞易学工具按阶段拆到 utils

状态:已实现,待实测确认。

做了什么

  • plugins/guanyixue/ 下的 jspx.py / study.py 按阶段拆到 utils/:http.py(会话 / cookie / token)、login.py(登录 / SSO / 续期)、course.py(旧版课程抓取与代际分发)、 feedback.py(反馈表 / 测验)、learn.py(旧版学习流程)、spa.py(新版 SPA)。
  • main.py 只留 Platform 子类,按 course["generation"] 分发到 learn / spa。
  • 新模块全部改成显式 import,不再用 from study_tool.config import * / jspx import *; quiz.py 的引用同步改成 utils.*。

怎么测

  1. GET /api/plugins 里 guanyixue 的 loaded 为 true。
  2. 用莞易学账号走一遍登录、刷新课程、发现课程(旧版 / 新版各一门更好)。
  3. 启动一次学习并停止,确认日志里仍有进度与完成状态输出。

莞易学迁到插件目录

状态:已实现,待实测确认。

做了什么

  • platforms/guanyixue.py → plugins/guanyixue/main.py;jspx.py / study.py / detect.py 一并搬进 plugins/guanyixue/,插件自包含,热重载能覆盖全部实现模块。
  • 导入路径改成 study_tool.plugins.guanyixue.*;quiz.py 里对 jspx / study 的引用同步更新。
  • platforms/__init__.py 只剩运行时,load_builtin() 只调 load_plugins()。
  • 新增 plugins/guanyixue/plugin.json(id / title / name)。

怎么测

  1. GET /api/plugins 列出 gdgxk 与 guanyixue,两者 enabled / loaded 都是 true。
  2. 莞易学:刷新课程 → 发现课程 → 开始学习(旧版 / 新版各一次);测验弹窗照常。
  3. 插件弹窗里对 guanyixue 点「重载」后再跑一次学习,行为不变。

插件热加载 + 启用/停用清单

状态:已实现,待实测确认。

做了什么

  • plugins/__init__.py 重写:load_plugins() / discover() / set_enabled() / reload()。
  • 热加载:把 study_tool.plugins.<名字>(含子模块)从 sys.modules 摘掉后重新 import, import 时 registry.register() 覆盖;重载前先 registry.unregister() 旧 id,改 id 不留残影。
  • 启用清单存在设置项 plugins({"disabled": [...]}):没列进去的默认启用, 新丢进目录的插件自动生效;停用的插件被摘出注册表,/api/platforms 与账号平台下拉同步消失。
  • platforms/registry.py 新增 unregister(pid)。
  • 新增接口:GET /api/plugins、POST /api/plugins/{name}/enable|disable、 POST /api/plugins/reload、POST /api/plugins/{name}/reload;失败返回 {"ok": false, "error": ...}。
  • 前端顶栏新增「插件管理」弹窗(web/src/components/PluginsDialog.tsx):列出插件与状态, 支持启停 / 单个重载 / 全部重载;启停后会重新拉 /api/platforms,「添加账号」的平台下拉同步变化。
  • 引擎 / 路由 / 平台实现均未改动。

怎么测

  1. GET /api/plugins 列出 gdgxk(enabled / loaded 为 true,capabilities 七项)。
  2. POST /api/plugins/gdgxk/disable → GET /api/platforms 里 gdgxk 消失;再 enable 回来。
  3. 改 plugins/gdgxk/main.py 里一句日志文案 → POST /api/plugins/gdgxk/reload → 不重启服务再跑一次学习,日志应变成新文案。
  4. 重启服务:停用状态应被记住(设置项 plugins)。
  5. 前端顶栏「插件管理」:停用 gdgxk → 「添加账号」的平台下拉立刻少一项;重新启用后恢复。

平台契约改为子类实现(注册方式调整)

状态:已实现,待实测确认。

做了什么

  • Platform 从 dataclass「注册时逐项传函数」改成基类:id / label / entries 是类属性, 能力是子类方法;没实现的阶段保持基类的 None,capabilities() 与 /api/platforms 不变。
  • plugins/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。
  • plugins/ 改成插件目录:plugins/<名字>/plugin.json(只放 id / title / name)+ main.py (Platform 子类);plugins/__init__.py::load_plugins() 扫目录 import,load_builtin() 调它。 插件里不再声明任何能力绑定。
  • 引擎 / 路由 / quiz.py / 前端 / platforms/contract.py 均未改动。
  • 新增 docs/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 两个平台。
  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 处理。