From 585a7a28ddbaf9ddf60b0812d37bea929a4c9017 Mon Sep 17 00:00:00 2001 From: study-tool Date: Tue, 27 Oct 2026 22:30:57 +0800 Subject: [PATCH] feat: study modes (fast/parallel) and course-container completion fix - settings: study.mode fast|normal and study.parallel 1-5 - normal mode: one headless browser per video, visibility keep-alive, auto-degrade when the platform enforces order - report study/log/detail/save before isComplete; drop lastProgressTime on completion - complete periodType 10/13 container nodes and recompute counters via PUT calFinalScore - capture mode: POST /api/capture records all XHR/fetch including new tabs - UI: study mode in settings, capture / stop-capture menu items --- .dockerignore | 24 + .env.example | 18 + .gitignore | 15 + AGENTS.md | 38 + Dockerfile | 43 + README.md | 207 +++ app.py | 3315 ++++++++++++++++++++++++++++++++++++++++++ des.js | 867 +++++++++++ desenc.js | 19 + dgjy-toolkit.user.js | 1376 ++++++++++++++++++ docker-compose.yml | 39 + js/study-axios.mjs | 215 +++ requirements.txt | 6 + start.bat | 39 + static/index.html | 1201 +++++++++++++++ store.py | 486 +++++++ study_video.py | 298 ++++ todo.md | 79 + 18 files changed, 8285 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 des.js create mode 100644 desenc.js create mode 100644 dgjy-toolkit.user.js create mode 100644 docker-compose.yml create mode 100644 js/study-axios.mjs create mode 100644 requirements.txt create mode 100644 start.bat create mode 100644 static/index.html create mode 100644 store.py create mode 100644 study_video.py create mode 100644 todo.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..94449da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# 构建上下文里不需要的东西。 +# data/ 必须排除:里面是本地账号库和 settings.json(含 api_key), +# 运行期由 volume 挂进来,不能打进镜像层。 + +.git +.gitignore +__pycache__/ +**/__pycache__/ +*.py[cod] +*.pyo +.venv +venv/ +env/ +.idea/ +.vscode/ +data/ +*.db +*.db-wal +*.db-shm +*.log +*.imported +.env +docker-compose.override.yml +node_modules/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ab0237 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# 复制成 .env 后按需修改:cp .env.example .env +# docker compose 会自动读取同目录的 .env + +# 远程访问令牌:非空时,页面和所有接口都要带 ?token=<值>(首次带参数访问会记住 cookie)。 +# 生成一个:python -c "import secrets;print(secrets.token_urlsafe(24))" +AUTH_TOKEN= + +# 监听端口(宿主机) +PORT=8848 + +# 绑定地址:0.0.0.0 = 所有网卡(远程管理);127.0.0.1 = 只允许本机 + 反代转发 +BIND_ADDR=0.0.0.0 + +# 每个账号保留的运行日志行数 / 课程页快照条数 +RUN_LOG_KEEP=2000 +SNAPSHOT_KEEP=200 + +TZ=Asia/Shanghai diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34b7772 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# 运行期数据:SQLite 库(含明文账号密码)、导入备份、调试快照 +data/ +*.db +*.db-wal +*.db-shm +*.imported + +# 本地环境 +.env +.venv/ +venv/ +__pycache__/ +*.py[cod] +.playwright-mcp/ +node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bbbee1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +本文档用于约束本项目中的 AI / 自动化开发行为。开发时优先遵循本文件,其次遵循用户当前消息。 + +## 基本原则 + +- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。 +- 写代码保持最少行数,能简单实现就不要引入复杂抽象。 +- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。 +- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。 +- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。 +- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。 +- 不要改无关文件,不要顺手重构。 +- 任何文件编辑、改动、删除或格式化前,必须先询问用户是否允许;只有得到明确同意后才能修改。 +- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。 + +## 反复提醒沉淀 + +- 如果开发过程中总是遇到某个问题,或者用户反复提醒同一个注意事项,需要把该注意事项补充到本文件。 +- 补充时写成明确、可执行的规则,避免只写模糊描述。 +- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。 + +## 文档规范 + +- README 保持简洁,只放项目介绍、核心功能、快速开始和文档入口。 +- 后续待办写到 `docs/progress/todo.md`。 +- 已实现但还需要用户测试确认的事项写到 `OPTIMIZATION.md`。 +- 每次任务完成前,都要根据实际变更检查并更新 `OPTIMIZATION.md` 和 `OPTIMIZATION.md`;如果功能或待办没有变化,也要确认无需修改。 +- 文档不要写过期日期;除非用户明确要求记录具体时间。 + +## 发版本流程 + +- 发版本时,先把 `CHANGELOG.md` 的 `Unreleased` 变更整理成新的版本记录,并保留空的 `Unreleased` 标题。 +- 每次提交必须将当前版本号进行提升,更新 `metadata.yaml`里面的`version`中的版本号。 +- 将当前未提交的代码全部提交到 Git。 +- 提交完成后,给当前提交打最新版本号对应的 tag,例如 `v0.0.5`。 +- 发版本流程中不要执行编译、测试或构建,除非用户明确要求。 +- 如果触发重要的决策,你需要将代码提交到`git`中,并且将本次已经修改的内容进行总结 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c6879c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# 多账号学习控制台 —— 运行镜像 +# 构建: docker build -t study-tool:latest . +# 瘦身: docker build --build-arg WITH_PLAYWRIGHT=0 -t study-tool:latest . (不要直链视频播放) +FROM python:3.12-slim-bookworm + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + DEBIAN_FRONTEND=noninteractive \ + TZ=Asia/Shanghai \ + HOST=0.0.0.0 \ + PORT=8848 \ + DATA_DIR=/app/data \ + DB_PATH=/app/data/study.db \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + +WORKDIR /app + +# nodejs: desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法) +# curl: 健康检查用 +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +# 直链视频(fileType=other)必须用真浏览器播到结尾;不需要就 WITH_PLAYWRIGHT=0 +ARG WITH_PLAYWRIGHT=1 +RUN if [ "$WITH_PLAYWRIGHT" = "1" ]; then \ + playwright install --with-deps chromium && rm -rf /var/lib/apt/lists/* ; \ + fi + +COPY . . +RUN mkdir -p /app/data + +EXPOSE 8848 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${PORT}/api/health" || exit 1 + +CMD ["python", "app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcb38a2 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# 多账号学习控制台 + +东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)的多账号挂机控制台。账号、会话、课程缓存、运行日志、系统设置全部存在服务端 SQLite,浏览器只是界面,换电脑、远程打开都在。 + +## 启动 + +### 方式一:本机(Windows) + +```bat +start.bat +``` + +或手动: + +```bat +pip install -r requirements.txt +python app.py +``` + +浏览器打开 (`localhost` 也可以,服务同时监听 `::1`)。 + +换端口 / 对外开放: + +```bat +set PORT=8849 && python app.py +set HOST=0.0.0.0 && python app.py +``` + +### 方式二: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` | + +环境变量: + +| 变量 | 默认 | 说明 | +| --- | --- | --- | +| `HOST` | `127.0.0.1` | 监听地址;Docker 里是 `0.0.0.0` | +| `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) | + +镜像默认装 Chromium(直链视频要真播到结尾)。不需要就用 +`docker compose build --build-arg WITH_PLAYWRIGHT=0`,镜像能小一大截。 + +## 数据存储(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 服务端:登录链路 / 课程抓取 / 课时上报 / 测验 / 反馈表 / HTTP 接口 +store.py SQLite3 存储层:账号 / 设置 / 登录记录 / 运行日志 / 状态 / 缓存 / 快照 +static/index.html 前端:账号卡片 + 卡片内「课程日志」折叠面板(无框架,原生 JS) +des.js desenc.js DES 加密(登录表单 rsa 字段,站点原版算法) +start.bat 一键启动(Windows) +Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium) +docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查 +.env.example 远程管理用的环境变量样例(复制成 .env) +requirements.txt 依赖 +data/study.db SQLite 库(账号/设置/日志/快照) +study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db) +dgjy-toolkit.user.js 浏览器油猴脚本(独立使用,与网页端无关) +``` + +## 登录链路 + +``` +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()`。 + +## 接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/` | 控制台页面 | +| GET | `/api/health` | 存活探测:pid / port / runs / SQLite 路径与行数(不需要 token) | +| 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=` 打开一次 + diff --git a/app.py b/app.py new file mode 100644 index 0000000..0df4bf3 --- /dev/null +++ b/app.py @@ -0,0 +1,3315 @@ +#!/usr/bin/env python3 +""" +多账号学习控制台 —— 东莞市教师培训平台 (jspx.dgjy.net) + +设计 + * 账号、密码、会话 cookie、课程缓存、运行日志、系统设置全部存 **SQLite3**(store.py) + 默认库文件 data/study.db,可用 DB_PATH 指定;重启/换机器只搬这一个文件 + * 登录后自动抓取 /admin/main.action 的「在学课程」列表 + * 勾选课程 -> 并发挂机 -> 自动交反馈表与测验 + * 多个账号可同时运行 + +启动: python app.py 浏览器打开 http://127.0.0.1:8848 (Docker 见 README) +""" +import json +import os +import re +import shutil +import subprocess +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from html import unescape as html_unescape +from urllib.parse import parse_qs, unquote, urljoin, urlsplit + +import requests +import uvicorn +from bs4 import BeautifulSoup +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse +from pydantic import BaseModel + +import store + +HERE = os.path.dirname(os.path.abspath(__file__)) +DATA = os.path.join(HERE, "data") +LOGDIR = os.path.join(DATA, "logs") # 旧版日志目录;只有首次迁移会读它 +os.makedirs(LOGDIR, exist_ok=True) + +def _find_node(): + """Locate a node.exe: $NODE_BIN > PATH > common install dirs.""" + for c in [os.environ.get("NODE_BIN"), shutil.which("node"), shutil.which("node.exe"), + r"D:\Software\devlopSoftware\Node\24.16.0\node.exe", + r"C:\Program Files\nodejs\node.exe", + r"C:\Program Files (x86)\nodejs\node.exe"]: + if c and os.path.exists(c): + return c + return "node" + + +NODE = _find_node() +DESENC = os.path.join(HERE, "desenc.js") + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0") + +JSPX = "https://jspx.dgjy.net" +TPASS = "https://tyyh.dgjy.net/tpass" +# 表单提交带上来源头,和浏览器一致(该站有 WAF,字段异常会直接 403) +LOGIN_HEADERS = {"Referer": TPASS + "/login", "Origin": "https://tyyh.dgjy.net"} +AUTHORIZE = ("https://tyyh.dgjy.net/tpass/oauth2.0/authorize?client_id=wpxtgl&scope=all" + "&response_type=code&state=random&redirect_uri=https%3A%2F%2Fdj.dgjy.net%2Fhomepage" + "%2Fremote%2Fopenapi%2Fdgwjt%2Flogin%3Furl%3Dhttps%3A%2F%2Fjspx.dgjy.net%2Ftstudy" + "%2Fpassport%2Flogin.action") +EP_STUDY = JSPX + "/train/trainee/courseware-studyTimeUpdate.action" + +# 新版培训平台(莞易学/study.dgjy.net)的课程接口。2026 年起的「慕课」课程不再 +# 用 jspx 的 tier-tree 页面,而是 Vue SPA + /train/course/front/* 这组 JSON 接口。 +TRAIN_AUTHORIZE_PATH = ("/oauth2.0/authorize?client_id=gyx&scope=all&response_type=code" + "&state=random&redirect_uri=https%3A%2F%2Fstudy.dgjy.net%2F%23%2Flogin%2Fback") +STUDY = os.environ.get("STUDY_BASE", "https://study.dgjy.net").rstrip("/") +DEBUG_DIR = os.path.join(DATA, "debug") # 页面快照的兜底目录(正常存 SQLite 的 snapshots 表) +os.makedirs(DEBUG_DIR, exist_ok=True) + +HEX32 = re.compile(r"^[0-9A-Fa-f]{32}$") +# OAuth 授权码:tpass 发的是 ST-369516-xxxx-tpass,带连字符,不能只认 [0-9A-Za-z] +CODE_RE = re.compile(r"(?:[?#&]|\b)code=([0-9A-Za-z._\-]{6,128})") + +# 「我的课程」的三个板块 -> 各自的 AJAX 列表接口与 courseType +COURSE_SECTIONS = [ + ("在学课程", "/train/trainee/studyingCourse.action", "studying"), + ("助学课程", "/train/assist/assistanceCourse.action", "assistance"), + ("督学课程", "/train/inspect/inspectingCourse.action", "inspecting"), +] +# 每个板块的三种状态(页面上是「进行中 / 未开始 / 已结束」三个页签) +PLAN_STATUS = [ + ("进行中", "TAKING_PLAN"), + ("未开始", "NO_BEGAIN_PLAN"), + ("已结束", "FINISHED_PLAN"), +] +# 只有这两种状态还能进去学;已结束的只能查看 +STUDYABLE_STATUS = {"TAKING_PLAN", "NO_BEGAIN_PLAN"} + + +class SessionExpired(RuntimeError): + """站点要求重新登录(cookie 已失效)。""" + + +# ------------------------------------------------------------------------ crypto + +def str_enc(data: str) -> str: + r = subprocess.run([NODE, DESENC, data, "1", "2", "3"], + capture_output=True, text=True, timeout=30) + if r.returncode != 0: + raise RuntimeError("strEnc failed: %s" % (r.stderr or "")[:200]) + return r.stdout.strip() + + +# ------------------------------------------------------------------- http client + +def new_session(cookies=None): + s = requests.Session() + s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"}) + for c in (cookies or []): + if "dgjy" not in (c.get("domain") or ""): + continue + s.cookies.set(c["name"], c.get("value") or "", + domain=(c.get("domain") or "").lstrip("."), + path=c.get("path") or "/") + return s + + +def dump_cookies(s): + return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path} + for c in s.cookies] + + +def whoami(html): + soup = BeautifulSoup(html, "lxml") + hdr = soup.select_one("#header") + if hdr: + m = re.search(r"你好[,,]?\s*(\S+)\s*老师", re.sub(r"\s+", " ", hdr.get_text(" ", strip=True))) + if m: + return m.group(1) + return "" + + +def parse_ids(text): + plan = project = "" + m = re.search(r"planId=([0-9A-Fa-f]{16,})", text or "") + if m: + plan = m.group(1) + m = re.search(r"projectId=([0-9A-Fa-f]{16,})", text or "") + if m: + project = m.group(1) + return plan, project + + +def _follow_js_login(s, page): + """补上浏览器才会执行的两跳,否则只拿得到 CASTGC、拿不到 jspx 的会话。 `oauth2.0/authorize` 链的最后一跳**不是 302**,而是 dj.dgjy.net 上一个 + 1486 字节的 JS 中转页,里面动态插 + 旧代码直接把它当正常页面解析 -> 一个表格都没有 -> 静默返回空列表, + 于是「会话失效」「站点改版」「账号确实没有课」被压成同一条提示。 + """ + r = s.get(JSPX + "/admin/main.action", timeout=40) + html = r.text or "" + if "/admin/login.action" in html and "table-results" not in html: + # CASTGC 还有效的话,这里能无密码把 jspx 会话补回来 + if ensure_jspx_session(s): + r = s.get(JSPX + "/admin/main.action", timeout=40) + html = r.text or "" + if "/admin/login.action" in html and "table-results" not in html: + raise SessionExpired("登录已失效:站点把请求跳回了 /admin/login.action(请重新登录)") + if "table-results" not in html: + raise RuntimeError("课程页结构异常:未找到 table-results 表格(站点可能已改版,页面长度 %d)" + % len(html)) + return html + + +def _parse_course_rows(html, section=""): + """从「我的课程」列表片段里抽出课程行。 + + 行结构(各板块一致): + + +
在学课程

名字

+

班期:..分数:82成绩:合格

+
查看
+ + 注意 href 是**不带前导斜杠的相对路径**(`courseStudyMain.action?...`), + 相对 `/train/trainee/` 解析,不能直接 `JSPX + href`。 + """ + soup = BeautifulSoup(html, "lxml") + out = [] + for table in soup.select("table.table-results"): + th = table.select_one("th") + sec = section or (re.sub(r"\s+", " ", th.get_text(" ", strip=True)) if th else "") + # 只按「有没有课程行」判断,不能用 "暂无" in table.get_text(): + # 表格里任何一个单元格出现「暂无」都会把整张表(含全部课程)丢掉。 + rows = [tr for tr in table.select("tr") if tr.select_one("p.course-tt a")] + for tr in rows: + a = tr.select_one("p.course-tt a") + href = unquote(a.get("href") or "") + plan, project = parse_ids(href) + if not plan or not project: + continue + dd = tr.select_one("p.course-dd") + out.append({ + "section": sec, + "name": a.get_text(" ", strip=True), + "info": re.sub(r"\s+", " ", dd.get_text(" ", strip=True)) if dd else "", + "plan_id": plan, + "project_id": project, + "url": urljoin(JSPX + "/train/trainee/", href), + "progress": "", + }) + return out + + +def scrape_courses(s, with_progress=True): + """把「我的课程」下三个板块 × 三种状态**全部**拉出来,含已学完的。 + + 站点把列表拆成了 AJAX 接口,每个组合一次请求: + + /train/trainee/studyingCourse.action?courseType=studying&planStatus=TAKING_PLAN + /train/assist/assistanceCourse.action?courseType=assistance&planStatus=FINISHED_PLAN + /train/inspect/inspectingCourse.action?courseType=inspecting&planStatus=NO_BEGAIN_PLAN + ... + + 只看 /admin/main.action 是看不到「已结束」课程的——那个首页只渲染在学/助学/督学 + 三张「进行中」的概览表,课一学完就从首页消失了。 + + 返回空列表只代表「三个板块九种状态都没课」; + 会话失效或页面改版一律抛异常,让调用方能给出准确提示。 + """ + # 先用首页校验会话,避免九个接口各自失败还看不出来 + _jspx_call(s, lambda: fetch_admin(s)) + + out, seen = [], set() + for section, path, ctype in COURSE_SECTIONS: + for label, status in PLAN_STATUS: + url = "%s%s?courseType=%s&planStatus=%s" % (JSPX, path, ctype, status) + try: + html = s.get(url, timeout=40).text + except Exception: + continue + for c in _parse_course_rows(html, section): + key = (c["plan_id"], c["project_id"]) + if key in seen: + continue + seen.add(key) + c["course_type"] = ctype + c["status"] = status + c["status_label"] = label + c["in_study"] = status in STUDYABLE_STATUS + out.append(c) + + # 进度只在「进行中/未开始」的课上查,已结束的动辄几十门,逐个请求太慢 + if with_progress: + for c in out: + if not c["in_study"]: + continue + try: + rr = s.get(c["url"], timeout=30) + p = BeautifulSoup(rr.text, "lxml").select_one("p.loading font") + c["progress"] = p.get_text(strip=True) if p else "" + except Exception: + pass + return out + + +def parse_jspx_course(html): + """老版 jspx 课程页(tier-tree)解析。""" + soup = BeautifulSoup(html, "lxml") + course = soup.select_one("h1") + prog = soup.select_one("p.loading font") + info = soup.select_one("div.results-info p.info") + + cw, fb, ts = [], [], [] + for one in soup.select("div.tier-tree-one"): + ns = one.select_one("p.tier1.has-children span.name") + chapter = re.sub(r"\s+", " ", ns.get_text(" ", strip=True)) if ns else "?" + for p in one.select("p.tier3"): + a = p.select_one("a") + if not a: + continue + href = a.get("href") or "" + txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) + name = p.select_one("span.name") + title = re.sub(r"\s+", " ", name.get_text(" ", strip=True)) if name else txt[:40] + m = re.search(r"(\d+)分钟", txt) + done = "已完成" in txt + if "courseware.action" in href: + cw.append({"chapter": chapter, "title": title, "href": href, "done": done, + "duration": int(m.group(1)) if m else None}) + elif "doFeedback.action" in href: + fb.append({"title": title, "href": href, "done": done}) + elif "doTest.action" in href: + ts.append({"title": title, "href": href, "done": done}) + return { + "user": whoami(html), + "course": course.get_text(" ", strip=True) if course else "?", + "progress": prog.get_text(strip=True) if prog else "?", + "info": re.sub(r"\s+", " ", info.get_text(" ", strip=True)) if info else "?", + "courseware": cw, "feedback": fb, "test": ts, + } + + +def discover(s, plan, project, name=""): + """打开课程页并解析;会话失效时先用 CASTGC 补一次 jspx 授权再重试。""" + return _jspx_call(s, lambda: _discover_once(s, plan, project, name)) + + +def _discover_once(s, plan, project, name=""): + url = "%s/train/trainee/courseStudyMain.action?planId=%s&projectId=%s&trainRole=0" % (JSPX, plan, project) + r = s.get(url, timeout=40) + html = r.text or "" + if "/admin/login.action" in html and len(html) < 2000: + # 课程页也走同一个判断:CASTGC 有效就无密码续期 + if ensure_jspx_session(s): + r = s.get(url, timeout=40) + html = r.text or "" + if "没有权限" in html: + raise RuntimeError("该账号对此课程没有权限") + if "/admin/login.action" in html and len(html) < 2000: + raise SessionExpired("登录已失效:课程页把请求跳回了 /admin/login.action(请重新登录)") + + # 课程页 302 到 study 时,token 在跳转 URL 里;哪怕 SPA 外壳返回空也要当成学过的新平台 + study_url = "study.dgjy.net" in (r.url or "") + tok = extract_study_token(r.url, r.history) + if tok: + host = urlsplit(r.url).hostname or urlsplit(STUDY).hostname + try: + s.cookies.set("Middle-Token", tok, domain=host, path="/") + except Exception: + pass + + if len(html) < 3000 and not (study_url or tok or has_study_marker(html, r.url)): + raise RuntimeError("课程页返回异常(页面长度仅 %d,登录可能已失效)" % len(html)) + + platform = detect_platform(html, r.url) + if platform == "jspx": + d = parse_jspx_course(html) + if d["courseware"] or d["feedback"] or d["test"]: + d["platform"] = "jspx" + return d + # 老结构但一个活动都没有:多半是「慕课」外链页(页面还在 jspx,课时在 study 平台) + if not has_study_marker(html, r.url): + snap = save_snapshot(plan, project, r.url, html) + raise RuntimeError("课程页能打开,但没有任何课件/反馈表/测验(页面已存 %s)。" + "若是慕课课程,请确认新平台登录态是否有效" % snap) + elif platform != "study": + snap = save_snapshot(plan, project, r.url, html) + raise RuntimeError("无法识别课程页结构(站点可能又改版):页面里既没有 jspx 的 " + "tier-tree,也没有新版 SPA 标记。页面已存 %s,请把它发我" % snap) + + # ---- 新版平台(Vue SPA + /train/course/front/* JSON 接口)---- + tid = extract_train_id(r.url, html) + user = whoami(html) + client = StudyClient(s, r.url) + + def _load(): + nonlocal tid + if not tid: + tid = study_resolve_train_id(client, name) + if not tid: + snap = save_snapshot(plan, project, r.url, html) + raise RuntimeError("新版课程页未带 trainCourseId,且按课程名也匹配不到(课程名:%s)。" + "页面已存 %s" % (name or "?", snap)) + return discover_study(client, tid, name, user) + + try: + d = _load() + except SessionExpired: + client.bootstrap() + tid = extract_train_id(r.url, html) + d = _load() + d["platform"] = "study" + # URL 里可能带 token(凭据),日志/状态里一律打码;鉴权已经写进 cookie + d["page_url"] = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", r.url or "") + d["plan_id"] = plan + d["project_id"] = project + return d + + +# ------------------------------------------------------- 新版平台:页面识别/提取 + +def detect_platform(html, page_url=""): + """判断课程页是哪一代前端。 + + jspx 老页:div.tier-tree-one / p.tier3 + 新版:Vue SPA(
+ static/js/app.*.js)或直接带 /training/ 链接 + """ + if "tier-tree-one" in html or "tier3" in html: + return "jspx" + if has_study_marker(html, page_url): + return "study" + return "unknown" + + +def has_study_marker(html, page_url=""): + if page_url and "study.dgjy.net" in page_url: + return True + for k in ("/training/curriculum", "/training/courseVideo", "/training/comment", + "static/js/app.", "study.dgjy.net/training", "study.dgjy.net/#/"): + if k in html: + return True + flat = html.replace('"', "").replace("'", "") + return "
" in flat + + +def extract_train_id(page_url, html): + """从跳转后的 URL / 页面内链接 / 隐藏域里找新版培训课程 ID(32 位 hex)。""" + q = parse_qs(urlsplit(page_url or "").query) + for k in ("trainCourseId", "courseId", "id"): + for v in q.get(k, []): + if HEX32.match(v or ""): + return v + + # 页面里指向 study 的链接 / iframe / JS 跳转 + for m in re.finditer(r"(?:https?:)?//[^\s\"'<>]*study\.dgjy\.net/[^\s\"'<>]+", html or ""): + u = m.group(0) + tail = u.split("#", 1)[1] if "#" in u else u + qq = parse_qs(tail.split("?", 1)[1]) if "?" in tail else {} + for k in ("trainCourseId", "courseId", "id"): + for v in qq.get(k, []): + if HEX32.match(v or ""): + return v + # 相对路径 /training/curriculum?id=... + for m in re.finditer(r"/training/(?:curriculum|courseVideo|comment)\?([^\s\"'<>]+)", html or ""): + qq = parse_qs(m.group(1).replace("&", "&")) + for k in ("trainCourseId", "courseId", "id"): + for v in qq.get(k, []): + if HEX32.match(v or ""): + return v + # 隐藏域 + try: + soup = BeautifulSoup(html or "", "lxml") + for i in soup.select("input[type=hidden]"): + nm = (i.get("name") or "").lower() + val = (i.get("value") or "").strip() + if HEX32.match(val) and ("course" in nm or nm in ("id", "trainid")): + return val + except Exception: + pass + return "" + + +def extract_study_token(page_url, history=None): + """课程页跳转到 study 时,token 直接挂在 URL 上(前端读它写 Middle-Token cookie)。 + + courseStudyMain.action -> 302 study/openapi/train/front/getById?... + -> 302 study/training/trainDetails?id=..&token= + """ + urls = [page_url or ""] + for h in history or []: + try: + urls.append(h.headers.get("Location") or "") + except Exception: + pass + for u in urls: + if not u: + continue + tail = u.split("#", 1)[1] if "#" in u else u + qq = parse_qs(tail.split("?", 1)[1]) if "?" in tail else {} + for k in ("token", "access_token", "Middle-Token"): + v = qq.get(k) + if v and v[0]: + return v[0] + return "" + + +def save_snapshot(plan, project, url, html): + """存下原始页面(进 SQLite),方便站点再次改版时定位选择器。 + + 返回给用户的是取回地址 /api/snapshot/;写库失败才退回文件。 + """ + safe_url = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", url or "") + try: + sid = store.snapshot_add(plan, project, safe_url, html or "") + return "/api/snapshot/%d" % sid + except Exception: + pass + name = "course_%s_%s_%s.html" % (re.sub(r"[^\w.-]", "_", str(plan))[:20], + re.sub(r"[^\w.-]", "_", str(project))[:20], + time.strftime("%H%M%S")) + path = os.path.join(DEBUG_DIR, name) + try: + with open(path, "w", encoding="utf-8") as f: + f.write("\n" % safe_url) + f.write(html or "") + return path + except Exception: + return "(保存失败)" + + +# ------------------------------------------------------- 新版平台:HTTP 客户端 + +def _study_token(s): + for c in s.cookies: + if c.name == "Middle-Token" and "dgjy" in (c.domain or ""): + return c.value + return "" + + +class StudyClient: + """新版平台接口客户端。 + + API 前缀不写死:课程页可能由 jspx 代理、也可能跳到 study.dgjy.net, + 所以按「页面 origin/train」→「study.dgjy.net/train」→「gjt/tp_edu_biz/train」 + 依次试,哪个能返回 200 就用哪个(结果缓存)。 + """ + + def __init__(self, session, page_url=""): + self.s = session + self.token = _study_token(session) + self.bases = [] + origin = "" + if page_url: + u = urlsplit(page_url) + if u.scheme and u.netloc: + origin = "%s://%s" % (u.scheme, u.netloc) + for b in ([origin + "/train"] if origin else []) + [STUDY + "/train"]: + if b not in self.bases: + self.bases.append(b) + if origin and "gjt.dgjy.net" in origin: + for b in (origin + "/tp_edu_biz/train",): + if b not in self.bases: + self.bases.append(b) + self.base = None + self.last_errors = [] + + def _raw(self, method, base, path, params=None, json_body=None): + h = {"Accept": "application/json, text/plain, */*", + "Referer": STUDY + "/training/curriculum"} + if self.token: + h["Authorization"] = "Bearer " + self.token + r = self.s.request(method, base + path, params=params, json=json_body, + headers=h, timeout=40) + try: + return r.json() + except Exception: + # 不是 JSON:多半是打到了错误前缀(返回登录页/HTML),标 -1 让 call() 换下一个 base + return {"code": -1 if r.status_code < 400 else r.status_code, + "msg": "非 JSON 响应:%s" % (r.text or "")[:120]} + + def call(self, method, path, params=None, json_body=None): + bases = ([self.base] if self.base else []) + [b for b in self.bases if b != self.base] + self.last_errors = [] + saw_auth = False + for b in bases: + try: + j = self._raw(method, b, path, params, json_body) + except Exception as e: + self.last_errors.append("%s -> %s" % (b, e)) + continue + code = j.get("code") + if code in (401, 403) or code == 404 or code == -1: + saw_auth = saw_auth or code in (401, 403) + self.last_errors.append("%s -> %s" % (b, j.get("msg") or ("业务码 %s" % code))) + if self.base == b: + self.base = None + continue + self.base = b + return j + if saw_auth and not self.base: + raise SessionExpired("新版平台没有登录态(Middle-Token 缺失或已过期):请点「重新登录」。%s" + % "; ".join(self.last_errors)[:180]) + raise RuntimeError("新版平台接口不可用:%s" % ("; ".join(self.last_errors)[:300] or "无可用地址")) + + def bootstrap(self): + """用现有 SSO 票据强制换一张新的 study.dgjy.net token(旧的可能已过期)。""" + try: + ensure_study_session(self.s, force=True) + except Exception: + pass + self.token = _study_token(self.s) + + +# ------------------------------------------------------- 新版平台:课程结构 + +def _flatten_periods(nodes, chapter="", out=None): + out = [] if out is None else out + for n in nodes or []: + if not isinstance(n, dict): + continue + nm = re.sub(r"\s+", " ", n.get("name") or "").strip() + row = dict(n) + row["_chapter"] = chapter + out.append(row) + if isinstance(n.get("children"), list) and n["children"]: + _flatten_periods(n["children"], nm or chapter, out) + return out + + +def _video_duration(n): + f = n.get("file") or {} + for v in (f.get("videoDuration"), f.get("duration"), f.get("fileDuration"), + n.get("duration"), n.get("videoDuration")): + try: + iv = int(float(v)) + if iv > 0: + return iv + except Exception: + continue + return 0 + + +def _period_done(n): + sl = n.get("studyLog") or {} + if sl.get("completeTime"): + return True + try: + return float(sl.get("progress") or 0) >= 1 + except Exception: + return False + + +def _int_or_none(v): + """平台把次数偶尔给成字符串;取不到就返回 None(页面按「未知」处理)。""" + try: + return int(v) + except (TypeError, ValueError): + return None + + +# 视频文件类型 -> 播放器通道(来自课程播放页 chunk-3d0ebfea 的 judgeVideoType) +CLOUD_TYPES = {"cloud_video", "middle"} # polyv 云视频:/period/middle/play +MOOC_TYPES = {"mooc_video"} # 网易云课堂:走签名/moc 播放 +OTHER_TYPES = {"other"} # 普通 mp4:原生