体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
48 KiB
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/。
怎么测
- 重启服务,看控制台和右上角「系统日志」:启动那行应是
控制台启动 -> http://127.0.0.1:8848(需要访问令牌:见 .env 的 AUTH_TOKEN),不应出现令牌本身。 - 已保存 API Key 时打开「系统设置 → 大模型」:API Key 右侧有「清空」,点了保存后
has_key应变成 false;「故障推送」里的 app_secret 同理。 - 把推送
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+ 细边框),长行自动换行不再横向滚动。
怎么测
- 刷新页面后点一次「刷新课程」(或「重新登录」):卡片标题应从手机号变成平台姓名(广东公需课学员是「*栋」这种打码名),第二行仍显示登录账号。
- 下次学习时看日志:应出现
进度上报:平台已记 x/y 秒(z%),本次上报到 n 秒,百分比与秒数对得上;课件跑完是平台已记 y/y 秒。 - 日志面板应是浅色底: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变体。
怎么测
- 刷新页面,点账号卡片右下「⋯」→「修改账号信息」:弹窗应回填当前平台 / 账号 / 备注,密码框为空并提示「已保存密码,需要更换时再填」。
- 只改备注后保存:应提示「账号信息已保存,正在用新凭据重新登录…」,随后登录成功并刷新课程;密码留空不应把库里密码清空。
- 故意填错密码保存:卡片应变成「登录失败」+ 红条,红条下出现「修改账号信息」按钮,点它能直接再改。
- 点「⋯」→「删除账号」:弹自绘确认框(红色图标 + 红色「删除账号」按钮),取消不删除、确定才删;测验缺题提交、再考一次也应弹同样的自绘弹窗,不再出现浏览器默认框。
故障推送:呆呆面板推送渠道(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。 - 触发点:学完仍未完成 / 进度没推进 / 没有可识别活动、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与「未完成任务点自动重学」、莞易学单课时重报都计入重试次数。
- 前端「系统设置」新增「故障推送」区块(含「测试推送」按钮),插件管理里该插件显示能力「推送」。
怎么测
- 重启服务,打开「系统设置」:左侧是「大模型 / 学习模式 / 故障推送」三栏,右侧只显示当前分类;切到「故障推送」应能看到已填好的 app_key / app_secret(secret 显示为掩码),点面板右上「测试推送」应收到一条「【测试】学习控制台推送」。
- 「插件管理」里应有「呆呆面板推送」卡片,能力显示「推送」;停用后点「测试推送」应提示没有可用渠道,重新启用后恢复。
- 跑一门学不完的课(或故意让会话失效):课程结束应收到「学习进度异常」或「登录/会话异常」推送,且同一账号 10 分钟内不会重复推。
- 公需课连续 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。
怎么测
- 重启服务后打开右上角「插件管理」:两张卡片都显示中文能力,且各有一行「适配网站」(公需课
jsglpt.gds.edu.cn · jsxx.gds.edu.cn,莞易学jspx.dgjy.net · study.dgjy.net)。 - 点停用 / 启用 / 重载:清单刷新后网站与能力仍正确。
公需课交互式测验(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之前也读课程主页(拿不到表单),现在同样改为读考核活动页。
怎么测
- 找一门考核还没交的公需课,点「打开测验」:应列出 30 道题的题干 / 题型 / 选项。
- 点「AI 答题」:应分批把答案填进选项(走
/api/quiz/answer,纯大模型)。 - 改几道答案后点交卷:应返回得分;再点一次「打开测验」应显示已交卷且题目可见。
- 若提示「考核页里没有找到题目」,把该考核页 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=…)同步运行状态。
怎么测
- 让账号会话失效(隔一段时间或换设备登录一次),点「刷新课程」:应看到「会话已失效,正在自动重新登录…」后直接读到课程,不再弹「平台「」还没有实现刷新课程」。
- 自动重登后等 10 秒以上,再点一次「刷新课程」:不应再次触发登录(说明旧 cookie 没有被写回去)。
- 学习中故意让会话掉线,日志出现「重新登录成功,继续学习」后结束运行,再点「刷新课程」:应一次成功、不再重登。
公需课自动重学(plugins/gdgxk/main.py 的 study)
状态:已实现,待实测确认。
做了什么
- 每刷完一轮活动,都重新请求进度页
/study/course/progess取任务点状态;全部已完成(或一开始就全部已完成)时也会重取一次,以平台状态为准。 - 进度页里还有未完成任务点,就等 60 秒(
ROUND_WAIT)后自动重新开始学习,只重刷未完成的活动。 - 连续 3 轮(
MAX_STALL)完成数没增加,或达到 5 轮(MAX_ROUNDS)就停止,日志列出剩余任务点,避免考核等非视频任务点死循环。 - 重试等待期间点「停止」会立即退出。
怎么测
- 选一个有未完成任务点的账号点「开始学习」,观察日志有没有「第 N 轮结束……稍后自动重新学习」,并且自动开始下一轮。
- 选一个全部任务点已完成的账号,日志应显示「平台进度:N/N 个活动已完成」后结束。
- 若某个任务点平台迟迟不记完成,应在 3 轮后停下并列出该任务点;如果 60 秒不够平台刷新状态,调大
ROUND_WAIT。
公需课选课兜底(plugins/gdgxk/utils/register.py)
状态:已实现,未实测(写的时候没有账号,选课超市的页面结构和选课请求是按截图推断的)。
做了什么
list_courses发现课程列表里没有目标年度的课时,调用register.ensure:读/uc/store/course/里所有课程链接,逐个打开详情页判断状态。- 状态判断:任一页按钮是「进入学习」= 已经选中,直接重读课程列表;选中的课位置对应页写「已有选课」= 已选了别的课,不再选;都没选就选第
pick门。 - 配置在
plugins/gdgxk/config.json:year(空 = 当前年份)、pick(第几门,从 1 开始)。暂未做弹窗让用户挑。
怎么测
- 用一个当年还没选课的账号点「刷新课程」,看系统日志有没有「公需课选课:…」。
- 若提示「没有解析到课程链接」或「没有找到选课按钮的请求地址」,把
/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(上方输入框 + 查询按钮,下方课程列表与进度),其余路径仍是控制台。
怎么测
- 设置
AUTH_TOKEN后启动,不带 token 打开/query,应能正常显示;打开/仍要求 token。 - 输入已添加账号的用户名点「查询」,应显示课程列表和进度;输入不存在的用户名显示「没有找到该账号」。
- 注意:该接口任何人知道用户名就能查到课程与进度,公网部署请确认可接受。
题库匹配(核心 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(考核按序答案)没动:考核改走题干匹配,按序答案留着备用。
怎么测
- 在项目根目录用 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"}}。 - 换一道题库里没有的题:没配大模型时日志有「还没配置大模型」警告;配了大模型会返回
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可关掉自动交卷。- 页面上已显示「您已完成测验」时直接跳过,不会重复交卷。
怎么测
- 找一门视频已刷完、只差考核的课跑一次,日志应出现「考核已交卷,得分 N」。
- 故意让题库缺题(临时改
bank/2026.json):应出现「考核共 N 题,M 题没答案,本次不交卷」,且不提交。 - 重跑已完成的课:日志应是「考核已完成」,不再请求
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.pyimport 它来注册/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同步更新目录与模块引用。
怎么测
uv sync后启动,/api/health正常,data/study.db能正常建表 / 读写。- 账号列表 / 登录 / 登录记录、课程日志、运行状态(
/api/runs)、快照、设置保存、LLM 测试接口,行为与改造前一致。 - 抓包开始 / 停止 / 查看记录正常;插件停用 / 启用 / 重载正常。
- 莞易学插件启用时,
/api/quiz、/api/quiz/submit、/api/quiz/answer仍可用。 - 莞易学账号登录(旧版 jspx 与新版 study 两条链路都走一遍)、刷新课程、学习正常;新版课程学习开头仍有「新版课程 ID」日志。
- 首次启动不再创建
data/logs目录(只要data/debug与数据库)。 - 启动后不会再出现
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 / 为空。
怎么测
cd web && pnpm build后启动,界面能打开;平台下拉里有「莞易学」「广东公需课」。- 莞易学账号卡片「更多」菜单有「打开测验」「抓包」;广东公需课账号的菜单里没有这两项。
- 「插件」弹窗里停用莞易学:
/api/quiz返回 404;再启用恢复;反复点「重载」不报错。 - 把
plugins/guanyixue整个目录挪走再启动:服务正常,添加账号只能选公需课;之前的莞易学账号登录时提示平台未实现,不影响其它账号。 - 在仓库根目录执行
python src/study_tool/plugins/guanyixue/script/study_video.py verify <trainCourseId>能跑(脚本自己把src加进sys.path)。 - 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 都读」。
怎么测
- 根目录有
.env(AUTH_TOKEN非空)时直接python app.py,启动日志里的地址应带?token=。 - 不带 token 开
http://127.0.0.1:8848/应显示「需要访问令牌」页;带?token=<值>正常进控制台。 .env改PORT=8849重启 → 实际监听 8849;命令行set PORT=8850再启动应压过.env。.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.*。
怎么测
GET /api/plugins里guanyixue的 loaded 为 true。- 用莞易学账号走一遍登录、刷新课程、发现课程(旧版 / 新版各一门更好)。
- 启动一次学习并停止,确认日志里仍有进度与完成状态输出。
莞易学迁到插件目录
状态:已实现,待实测确认。
做了什么
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)。
怎么测
GET /api/plugins列出gdgxk与guanyixue,两者 enabled / loaded 都是 true。- 莞易学:刷新课程 → 发现课程 → 开始学习(旧版 / 新版各一次);测验弹窗照常。
- 插件弹窗里对 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,「添加账号」的平台下拉同步变化。 - 引擎 / 路由 / 平台实现均未改动。
怎么测
GET /api/plugins列出gdgxk(enabled / loaded 为 true,capabilities 七项)。POST /api/plugins/gdgxk/disable→GET /api/platforms里 gdgxk 消失;再 enable 回来。- 改
plugins/gdgxk/main.py里一句日志文案 →POST /api/plugins/gdgxk/reload→ 不重启服务再跑一次学习,日志应变成新文案。 - 重启服务:停用状态应被记住(设置项
plugins)。 - 前端顶栏「插件管理」:停用 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:插件骨架、槽位契约、新增与迁移流程、常见坑。
怎么测
GET /api/platforms仍返回gdgxk与guanyixue,capabilities与改动前一致 (广东公需课:login/session/renew/profile/courses/discover/study)。- 莞易学:刷新课程 → 发现课程 → 开始学习照常(旧版 / 新版都跑一次)。
- 广东公需课:登录 → 刷新课程 → 开始学习照常。
平台流程下沉(契约第二步)
状态:已实现,待实测确认。
做了什么
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)。
怎么测
- 莞易学:刷新课程 → 发现课程 → 开始学习(快速、常规各跑一次),反馈表 / 测验照常提交。
- 广东公需课:开始学习,确认进度照常上报。
- 错误分支:平台缺少
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并注册,下拉里就会多一个选项。
怎么测
- 打开「添加账号」,确认最上面能看到「学习平台 / 莞易学」。
- 正常添加账号并登录,卡片右上角显示所选平台的 label(当前为「莞易学」)。
- 用一个已有账号点「刷新课程」「开始学习」,行为应与改动前一致(旧版 / 新版都能跑)。
如果没生效
- 下拉是空的:
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 展示。
怎么测
- 分别用一个旧版课程页账号和一个新版课程页账号(或同一账号的两门课)各跑一次自动学习。
- 看卡片「课程日志」:应出现
平台:莞易学 · 旧版页面/平台:莞易学 · 新版页面,不再出现jspx/study平台名。 - 卡片头部徽标应为「莞易学」;打开测验,交卷后后台日志正常,说明
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且禁止缓存。
怎么测
- 直接
python app.py,打开页面:账号卡片、状态、课程列表、进度条、日志折叠都正常。 - 走一遍登录 → 刷新课程 → 勾选 → 开始学习 → 停止,观察按钮 loading 和日志面板。
- 打开测验(含单选 / 多选 / 判断 / 填空),提交后看标准答案和「再考一次」。
- 设置里改大模型 / 学习模式并保存,刷新后值仍在;测试连接正常。
- 右上角系统日志可查看、清空;断网后出现「后端未连接」提示。
如果没生效
- 页面空白 / 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。
怎么测
- 用一个没加入过该课程的账号,或把某课时的
studyLog清掉(制造「首次进入」)。 - 跑一次自动学习,看日志里加入那一段。
预期出现其中一条:
加入培训:同步课程页后已加入(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=<账号>)和该账号卡片的「课程日志」。
怎么测
- 打开一个测验,点「AI 答题」,看状态栏是否逐批更新「Ns · 已填 x/y 题」。
- 结束后展开该账号卡片的「课程日志」,应能看到
AI答题 开始/第 1/2 批…/结束若干行;控制台同样能看到。 - 故意把 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 线程抛异常。
怎么测
- 启动服务,控制台应出现
[system] SQLite: …和[system] 控制台启动 -> …。 - 页面右上角「系统日志」→ 看到上面两行;保存一次设置 → 多一行「设置已更新:模型 …」。
- 点账号卡片的「课程日志」→ 学习日志和以前一致;点「清空」后不再出现旧行。
- 跑一门课(旧平台 / 新平台各一次),日志里应出现该课程的流程,说明插件分发走到了对应平台。
python -c "import sys; sys.path.insert(0,'src'); from study_tool.platforms import load_builtin, registry; load_builtin(); print(registry.all())"→ 应看到 jspx / study 两个平台。- 跑一次 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。
怎么测
- 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。
- 选一门没学完的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。
- 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 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 的日志一起丢掉。
怎么测
- 重启服务后打开页面:卡片应是新状态;点「开始学习」,红条与上一轮的旧进度立即被覆盖。
- 重启服务(内存日志清空)后展开某账号「课程日志」:应显示 SQLite 里的历史行,而不是「暂无日志」。
- 删掉一个账号再刷新:该账号的课程日志、登录记录(
/api/loginlog)都不应再有它的行;system与其它账号的日志必须保留。
如果没生效
- 红条还在 → 看
run_state里那条是否仍带旧error,删掉该行即可复位。 - 历史日志还是空 → 确认
static/已重新构建(cd web && pnpm build)并强刷页面。 - 删完还有日志 → 平台的登录用户名与账号
username字段不一致时清不掉,需按账号实际写入的 key 处理。