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

270 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 学习控制台
莞易学(`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`)。缺它只影响广东公需课的验证码识别,其余平台不受影响。
浏览器打开 <http://127.0.0.1:8848>(`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=<AUTH_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 平台试出来的接口前缀、莞易学 `script/study_video.py` 的会话) |
| `login_log` | 每次登录尝试(平台原话,`/api/loginlog` 读它) |
| `run_log` | 每个账号的运行日志(`/api/log/history?key=<id>&tail=200`) |
| `run_state` | 运行状态快照,进程重启后 `/api/runs` 仍能看到,未跑完的标 `interrupted` |
| `snapshots` | 站点改版时存下的课程页原始 HTML(`/api/snapshots`、`/api/snapshot/<id>`) |
旧的 `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": "<trainCourseId>"}`
→ `data.testpaperResultId`
- 交卷 `PUT /train/questions/front/result/submitExam`
`{"resultId": "<resultId>", "resultItemList": [{"fileIds": [], "itemId": "<itemResultId>", "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 <Middle-Token>`
已交卷的测验会显示「已完成 · 得分 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/ 全局路径常量(ROOT / DATA / STATIC_DIR);settings.py 系统设置读写
database/ SQLite 存储(SQLAlchemy ORM):models.py + 读写函数
models.py 通用 HTTP 请求模型
llm/ 大模型调用
engine.py 学习引擎:发现 → 按平台分发 → worker / 视频 / 提交
platforms/ 平台运行时
base.py Platform 契约(id / label / study / entries)
registry.py 注册表:register / get / detect / all
plugins/ 平台插件
guanyixue/ 莞易学:main.py + config.py(站点常量)+ utils/ + capture.py(抓包)+ quiz.py(测验)
routes/ HTTP 接口
accounts.py 账号 / 登录 / 登录记录
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
logs.py /api/log、/api/runs
settings.py /api/settings
llm.py /api/llm/test
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
static/ 前端构建产物(index.html + assets/ + favicon.svg)
script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit.user.js、study-axios.mjs
(插件专属脚本在 plugins/<名字>/script/:莞易学的 des.js、desenc.js、study_video.py,公需课的抓包 / 自测脚本)
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 分层说明 + 平台插件怎么加
```
分层与插件契约见 [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` / 莞易学),
课程发现时自动判断页面代际(旧版 / 新版),分别走 `utils/learn.py` / `utils/spa.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=<AUTH_TOKEN>` 打开一次