commit 585a7a28ddbaf9ddf60b0812d37bea929a4c9017 Author: study-tool Date: Tue Oct 27 22:30:57 2026 +0800 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 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:原生