# 学习控制台 莞易学(`jspx.dgjy.net` / `study.dgjy.net`,新旧版课程页自动识别)的多账号挂机控制台。账号、会话、课程缓存、运行日志、系统设置全部存在服务端 SQLite,浏览器只是界面,换电脑、远程打开都在。 ## 启动 ### 方式一:本机(Windows) ```bat start.bat ``` 或手动(uv,依赖版本已锁在 `uv.lock`): ```bat uv sync uv run app.py ``` 没有 uv 就用 pip(Docker 镜像里也是走的这条): ```bat pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple python app.py ``` 依赖默认走阿里云镜像:uv 的源配在 `pyproject.toml`,`uv.lock` 里记录的也是它;Dockerfile 用 `PIP_INDEX_URL` 构建参数(默认阿里云,`--build-arg PIP_INDEX_URL=https://pypi.org/simple` 可回官方源)。临时换源:`UV_DEFAULT_INDEX=https://mirrors.cloud.tencent.com/pypi/simple uv sync`。清华源 `pypi.tuna.tsinghua.edu.cn` 会 403 uv 的 JSON simple 请求,不要设。 `ddddocr` 依赖 `onnxruntime`,后者只对 Python 3.10–3.14 的正式版发轮子;3.15 / free-threaded / ARM 上会装不上(`ResolutionImpossible`)。缺它只影响广东公需课的验证码识别,其余平台不受影响。 浏览器打开 (`localhost` 也可以,服务同时监听 `::1`)。 换端口 / 对外开放: ```bat set PORT=8849 && python app.py set HOST=0.0.0.0 && python app.py ``` 也可以不 set:把 `.env.example` 复制成根目录 `.env` 再改(`cp .env.example .env`),本机启动同样读它。 同名系统环境变量优先于 `.env`;注意 `.env` 里 `AUTH_TOKEN` 非空时,本机访问也要 `http://127.0.0.1:8848/?token=<值>`。 ### 方式二:Docker(推荐用于远程管理) ```bash cp .env.example .env # 编辑 .env:至少设一个 AUTH_TOKEN(远程管理必须有,否则谁都能读到里面的账号密码) # AUTH_TOKEN=$(python -c "import secrets;print(secrets.token_urlsafe(24))") docker compose up -d --build docker compose logs -f # 看启动日志 ``` 打开 `http://<服务器IP>:8848/?token=`,令牌会写进 cookie,之后正常用。 | 操作 | 命令 | | --- | --- | | 起 / 重建 | `docker compose up -d --build` | | 停 | `docker compose down` | | 看日志 | `docker compose logs -f --tail=200` | | 备份数据 | `cp data/study.db data/study.db.bak`(服务运行中也可拷,WAL 模式) | | 进容器 | `docker compose exec study-tool bash` | | 换端口 | `.env` 里改 `PORT`,再 `docker compose up -d` | 环境变量(写在根目录 `.env` 里,本机启动和 Docker 都读;同名的系统环境变量优先): | 变量 | 默认 | 说明 | | --- | --- | --- | | `HOST` | `127.0.0.1` | 监听地址;Docker 里是 `0.0.0.0` | | `BIND_ADDR` | 同 `HOST` | `HOST` 的别名(docker compose 用它绑宿主机端口),本机启动也认 | | `PORT` | `8848` | 端口 | | `DB_PATH` | `<项目>/data/study.db` | SQLite 库文件 | | `AUTH_TOKEN` | 空 | 非空 = 页面/接口需要 `?token=` 或 cookie;`/api/health` 永远放行 | | `RUN_LOG_KEEP` | `2000` | 每个账号保留的日志行数 | | `SNAPSHOT_KEEP` | `200` | 课程页快照保留条数 | | `NODE_BIN` | 自动找 | node.exe 路径(算登录表单的 DES) | | `TZ` | `Asia/Shanghai` | 只对 Docker(Linux 容器)有效,Windows 本机忽略 | 镜像默认装 Chromium(直链视频要真播到结尾)。不需要就用 `docker compose build --build-arg WITH_PLAYWRIGHT=0`,镜像能小一大截。 ## 前端开发 页面在 `web/`,技术栈 React 19 + TypeScript + Vite + Tailwind CSS v4。构建产物已提交到 `static/`,只跑 Python 时不需要装 Node: ```bash cd web pnpm install pnpm dev # http://127.0.0.1:5173,/api 代理到 8848 pnpm build # 输出到 ../static/,Python 端直接托管 pnpm typecheck ``` 改完前端执行一次 `pnpm build`,刷新页面即可;`static/index.html` 禁止缓存。 ## 数据存储(SQLite3) 单文件 `data/study.db`(WAL 模式,读写不互斥)。表: | 表 | 内容 | | --- | --- | | `accounts` | 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 自动交测验·反馈表开关 | | `settings` | 大模型配置(`base_url` / `api_key` / `model` / 温度 / 超时 / 提示词) | | `kv` | 通用缓存(例如 study 平台试出来的接口前缀、`study_video.py` 的会话) | | `login_log` | 每次登录尝试(平台原话,`/api/loginlog` 读它) | | `run_log` | 每个账号的运行日志(`/api/log/history?key=&tail=200`) | | `run_state` | 运行状态快照,进程重启后 `/api/runs` 仍能看到,未跑完的标 `interrupted` | | `snapshots` | 站点改版时存下的课程页原始 HTML(`/api/snapshots`、`/api/snapshot/`) | 旧的 `data/settings.json`、`data/logs/*.log`、`data/study_session.json` 在首次启动时自动导入, 原文件改名为 `*.imported`(回滚旧版本时改名回去即可)。 密码是明文存在库里的——会话失效时后台要能自己重登。所以这个库等价于账号密码本: 不要提交进 git,Docker 部署时务必设 `AUTH_TOKEN`,公网前面再加一层 HTTPS 反代。 ## 用法 1. 右上角「添加账号」→ 选「学习平台」(当前只有莞易学)→ 填账号密码(存进服务端 SQLite)。 2. 点「登录」→ 自动读 `/admin/main.action` 的「在学课程」。 3. 勾选课程 → 「开始学习」→ 卡片底部「课程日志」展开看实时进度。 4. 「自动交反馈表」默认开;「自动交测验」默认关(答题正确率无法保证)。 ## 测验 卡片上「测验」→ 弹窗按表格列出题号 / 题型 / 题干 / 选项 →「AI 答题」按系统设置里的模型填答案 → 核对后「提交」。 - **老平台(jspx)**:`showTestQums.action` 取题,`endTest.action` 交卷,回分从 `doTest.action` 抓 - **新平台(study.dgjy.net)**: - 取题 `GET /train/questions/front/testpaper/getById?testpaperResultId=…` - 还没生成答卷时先自动创建(不用再手工去页面点一次「开始考试」): `PUT /train/questions/front/testpaper/start/do` `{"periodId": "<课时 id>", "trainCourseId": ""}` → `data.testpaperResultId` - 交卷 `PUT /train/questions/front/result/submitExam` `{"resultId": "", "resultItemList": [{"fileIds": [], "itemId": "", "replay": [0,2] | null}]}` - `replay` 是**0 基选项下标**数组:历史答案会自动回填,提交时字母答案按选项顺序换成下标 - 交卷后看答案 `GET /train/questions/front/testpaper/teacher/review?testpaperResultId=…` 返回 `data.questions[].data[]` 里的 `answer`(标准答案)、`replay`(学生答案)、 `answerScore`、`analysis`。只有测验配置 `examAfterWatchAnswer=1` 时才在页面展示 - 重考:课程树里的 `allowExamCount` / `surplusExamCount` 是总次数 / 剩余次数 (平台一般 3 次)。`surplusExamCount > 0` 时再调一次 `start/do` 会生成新答卷, 不需要老师重置;次数用完平台会拒绝 - 判断题(`JUDGMENT`)接口里没有选项(`metasData` 只有一个 null 占位),页面会补出 `A 正确 / B 错误`,交卷时映射成 `[0]` / `[1]`;模型写「正确/对/√」也会收敛成 A、B - 题组结构 `data.questions[].data[]`,鉴权用 `Authorization: Bearer ` 已交卷的测验会显示「已完成 · 得分 X · 及格 Y · 已通过」并禁用「AI 答题」「提交」, 按钮文字变成「已交卷」、旁边写「已交卷 · 不可重复提交」——不会出现点了没反应的情况。 如果后端发现平台已经收卷,会直接提示「已经交过卷了,不能重复提交」,不会偷偷覆盖原成绩。 ## 系统设置 右上角「设置」→ 大模型:`base_url`(OpenAI 兼容,写到 `/v1` 为止)、`api_key`、`model`、 温度、超时、答题提示词。配置存 SQLite 的 `settings` 表,`api_key` 不回显,留空表示不修改。 「测试连接」发一条 8 token 的请求验证连通性。 ## 目录 ``` app.py 启动壳:把 src 加进 sys.path,调 study_tool.app.main() pyproject.toml 打包(src 布局;pip install -e . 后可用 study-tool 命令) src/study_tool/ app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main() webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入) runtime.py 运行状态 + 统一日志源(控制台 / 内存 / SQLite) config.py 常量、环境变量、根目录定位(data / static / script) store.py SQLite3:账号 / 设置 / 登录记录 / 运行日志 / 状态 / 缓存 / 快照 models.py HTTP 请求模型 settings_store.py 系统设置读写 llm.py 大模型调用 quiz.py 测验解析 / 交卷 / AI 答题 engine.py 学习引擎:发现 → 按平台分发 → worker / 视频 / 提交 capture.py 抓包 platforms/ 平台插件 base.py Platform 契约(id / label / study / entries) registry.py 注册表:register / get / detect / all detect.py 页面代际识别(旧版 / 新版 SPA) guanyixue.py 莞易学:合并 jspx 登录入口与 study 学习入口 jspx.py 莞易学旧版页面实现 study.py 莞易学新版 SPA 实现 routes/ HTTP 接口 accounts.py 账号 / 登录 / 登录记录 misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表 logs.py /api/log、/api/runs web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4) static/ 前端构建产物(index.html + assets/ + favicon.svg) script/des.js 服务端 DES 加密(登录表单 rsa 字段,站点原版算法) script/desenc.js DES 命令行封装(Node 调用,config.py 里定位) script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit.user.js、study-axios.mjs start.bat 一键启动(Windows) Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium) docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查 .env.example 环境变量样例(复制成 .env;本机启动与 Docker 都读) requirements.txt 依赖 data/study.db SQLite 库(账号/设置/日志/快照) docs/architecture.md 分层说明 + 平台插件怎么加 study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db) ``` 分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]` 从注册表取平台实现;平台用 `entries` 声明登录 / 学习入口,并可按需实现 `login` / `list_courses` / `discover` 等账号级入口;「添加账号」选的平台存进账号记录,登录与课程发现按它分发,新增平台不用改 engine / 路由 / 前端。 日志统一从 `runtime.log()` 出:控制台、页面卡片「课程日志」(按账号 id)、右上角「系统日志」(key=system) 看到的是同一份;新增日志不要再自己 `print()`。 ## 登录链路 ``` GET tyyh.dgjy.net/tpass/login 取 lt / execution POST tyyh.dgjy.net/tpass/login un pd ul pl rsa(des) lt → CASTGC GET dj.dgjy.net/.../authorize 302 + JS 中转页 GET passport.dgjy.net/scriptLogin JS 返回 verifyURL GET dj.dgjy.net/fpf/login/verify.action 302 GET jspx.dgjy.net/tstudy/passport/login.action 下发 jspx JSESSIONID GET jspx.dgjy.net/admin/main.action table-results 课程表 ``` `requests` 不执行 JS,中间两跳是手工解析 `scriptLogin.src` 和 `verifyURL` 走完的。 新版页面(慕课)另走一条 OAuth 码换 token 的链,拿到 `study.dgjy.net` 的 `Middle-Token`,见 `ensure_study_session()`。 `jspx.dgjy.net` 与 `study.dgjy.net` 在注册表里是同一个平台(`guanyixue` / 莞易学), 课程发现时自动判断页面代际(旧版 / 新版),分别走 `jspx.py` / `study.py` 的学习实现。 ## 接口 | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/` | 控制台页面 | | GET | `/api/health` | 存活探测:pid / port / runs / SQLite 路径与行数(不需要 token) | | GET | `/api/platforms` | 已注册平台(id / label),用于界面展示平台名 | | GET | `/api/accounts` | 账号列表(会话、课程缓存、勾选,**不含密码**) | | POST | `/api/accounts` | 新增/更新账号;`password` 不传 = 保留库里原密码 | | GET | `/api/accounts/{id}` | 单个账号 | | DELETE | `/api/accounts/{id}` | 删除账号(连带清掉它的运行状态与日志) | | POST | `/api/login` | `{account_id}`(密码从库里取)或 `{username,password}` → cookies + 课程列表 | | POST | `/api/courses` | 用已有 cookies 刷新课程 | | POST | `/api/discover` | 按 planId/projectId 查课程详情 | | POST | `/api/study` | 开始挂机 | | POST | `/api/stop` | 停止 | | POST | `/api/quiz` | 取一次测验的题目与选项 | | POST | `/api/quiz/answer` | 把题目交给大模型,返回 `{题号: 答案}` | | POST | `/api/quiz/submit` | 按题号合并答案后交卷 | | GET/POST | `/api/settings` | 读取 / 保存大模型配置(key 脱敏) | | POST | `/api/llm/test` | 测试模型连通性 | | GET | `/api/log` | 增量日志 `?key=&since=`(内存里的实时日志) | | GET | `/api/log/history` | SQLite 里的历史日志 `?key=&tail=`;不带 key 列出各账号行数 | | DELETE | `/api/log/history` | 清掉某个账号的历史日志 `?key=` | | GET | `/api/runs` | 所有账号运行状态(重启后从 SQLite 恢复) | | GET | `/api/loginlog` | 登录尝试记录 `?tail=` | | GET | `/api/snapshots` | 课程页快照列表 | | GET | `/api/snapshot/{id}` | 取回某份快照的原始 HTML | 远程调用时(设了 `AUTH_TOKEN`)带上 `-H "X-Auth-Token: $TOKEN"`: ```bash curl -H "X-Auth-Token: $TOKEN" http://host:8848/api/health curl -X POST -H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" \ -d '{"account_id":"a1"}' http://host:8848/api/login ``` ## 排查 登录失败先看 `data/study.db` 的 `login_log` 表(或 `GET /api/loginlog`)最后一行: - `您还剩N次机会…` — 平台判密码错,跟链路无关 - `SSO 校验未通过` / `jspx 没有建立会话` — 授权链断了,站点改版 - `Incoming markup is of an invalid type` — 代码把布尔当 HTML 用了 - 页面能开、接口全 `ERR_CONNECTION_REFUSED` — 服务没起或端口被占,`netstat -ano | findstr :8848` - 容器里起来了但连不上 — `docker compose logs` 看是否 `Address already in use`,`.env` 换 `PORT` - 页面 401 / 空白提示需要访问令牌 — 用 `/?token=` 打开一次