diff --git a/AGENTS.md b/AGENTS.md index bbbee1b..cd4d356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,24 @@ - 补充时写成明确、可执行的规则,避免只写模糊描述。 - 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。 +## 日志规范 + +- 所有日志只走 `runtime.log(key, msg, level="info")`:它统一写控制台、内存(`/api/log`)和 SQLite(`/api/log/history`),三处内容必须一致。 +- 禁止在新代码里自己 `print()` 写业务日志,也不要直接调 `store.run_log_add()`;模块只负责组织 `msg`。 +- key 约定:账号相关用账号 id(页面卡片「课程日志」直接显示);全局 / 系统事件用 `"system"`(右上角「系统日志」弹窗显示)。 +- 时间戳、级别、格式都由 `runtime.log` 统一拼(`时间 [级别] 内容`),模块里不要自己加时间前缀。 +- 前端读日志统一用 `/api/log?key=&since=`(实时)+ `/api/log/history?key=&tail=`(历史兜底),不要另起一套日志接口。 + +## 代码结构 / 平台插件 + +- 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`des*.js` 这类运行时资源。 +- 平台代码只能放在 `src/study_tool/platforms/` 下;一个平台只注册一次(`Platform` + `registry.register`),并在 `Platform.entries` 里声明登录 / 学习入口。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。 +- 新增平台的标准步骤:写 `platforms/<名字>.py`(实现 `study(key, s, d, **opts)`、声明 `entries`)→ 在 `platforms/__init__.py` 的 `load_builtin()` 里 import → 结束。不要改 `engine.py`、`routes/`、前端。 +- 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;识别平台用 `registry.detect(url, html)`。 +- 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。 +- 新代码的 import 一律显式(如 `from study_tool.config import JSPX`),不要再新增 `from xxx import *`;历史遗留的 star import 只保留在拆分前就存在的模块里。 +- `docs/architecture.md` 维护分层与插件契约,改动结构时同步更新。 + ## 文档规范 - README 保持简洁,只放项目介绍、核心功能、快速开始和文档入口。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 573e585..078f5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### 重构 + +- 前端从单文件 `static/index.html`(原生 JS)改为 `web/` 工程:React 19 + TypeScript + Vite + Tailwind CSS v4;Vite 直接输出到 `static/`,FastAPI 托管 `/` 与 `/assets`,运行 Python 不再需要 Node +- 新旧学习平台合并为「莞易学」:`jspx.dgjy.net`(登录入口)与 `study.dgjy.net`(学习入口)统一注册为 `Platform(id="guanyixue", label="莞易学")`,课程页代际(`legacy` / `spa`)由 `platforms/detect.py` 判断,平台内部按代际分发到 `jspx.py` / `study.py` +- `Platform` 新增 `entries`(登录 / 学习入口 URL 片段)与 `registry.detect()`;课程发现按入口识别平台,新增平台不再需要改引擎。新增 `GET /api/platforms` 供前端展示平台名 +- 测验交卷从 `platform`(jspx / study)改为 `generation`(legacy / spa),接口返回 `platform="guanyixue"` + `generation` +- 代码改为标准 `src/` 包布局:业务代码全部在 `src/study_tool/`,根目录 `app.py` 只做启动壳(`python app.py`、`start.bat`、Dockerfile 都不用改);新增 `pyproject.toml`,`pip install -e .` 后可用 `study-tool` 命令 +- 平台拆成插件包 `src/study_tool/platforms/`:`base.py` 定义 `Platform` 契约、`registry.py` 注册表;引擎按 `course["platform"]` 从注册表分发 +- HTTP 接口收进 `routes/`(accounts / misc / logs),`docs/architecture.md` 说明分层、平台识别与新增平台的步骤 + +### 新增 + +- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志 +- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite +- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导 + +### 修复 + +- `discover()` 引用未定义的 `detect_platform` / `has_study_marker` 导致课程发现 `NameError`:代际识别集中到 `platforms/detect.py`,两代页面共用同一套判断 +- 系统设置里的大模型配置保存后重新打开会回到默认值:`save_settings` 写的是嵌套结构 `{llm, study}`,`load_settings` 却按顶层字段读,现在改成从 `llm` 子对象读取 +- AI 答题整段同步等待导致浏览器 / 反代超时:`/api/quiz/answer` 改为流式(text/event-stream)分批返回,每批失败自动重试一次 +- AI 答题解析不出 JSON 时直接判空:补 `max_tokens`、空内容 / 非 JSON 抛错重试,并支持从自由文本、截断 JSON 里抠答案,多选连写(`AC`)也能识别 +- 控制台是 GBK 时日志里含 `⚠` 之类字符会让工作线程抛 `UnicodeEncodeError`:`runtime.log` 现在编码失败会自动降级 + ## v0.0.1 首个版本。 diff --git a/OPTIMIZATION.md b/OPTIMIZATION.md index 93cda48..bd63775 100644 --- a/OPTIMIZATION.md +++ b/OPTIMIZATION.md @@ -2,6 +2,60 @@ 已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 +## 新旧平台合并为「莞易学」 + +**状态**:已实现,待实测确认。 + +### 做了什么 + +- `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 展示。 + +### 怎么测 + +1. 分别用一个旧版课程页账号和一个新版课程页账号(或同一账号的两门课)各跑一次自动学习。 +2. 看卡片「课程日志」:应出现 `平台:莞易学 · 旧版页面` / `平台:莞易学 · 新版页面`,不再出现 `jspx` / `study` 平台名。 +3. 卡片头部徽标应为「莞易学」;打开测验,交卷后后台日志正常,说明 `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` 且禁止缓存。 + +### 怎么测 + +1. 直接 `python app.py`,打开页面:账号卡片、状态、课程列表、进度条、日志折叠都正常。 +2. 走一遍登录 → 刷新课程 → 勾选 → 开始学习 → 停止,观察按钮 loading 和日志面板。 +3. 打开测验(含单选 / 多选 / 判断 / 填空),提交后看标准答案和「再考一次」。 +4. 设置里改大模型 / 学习模式并保存,刷新后值仍在;测试连接正常。 +5. 右上角系统日志可查看、清空;断网后出现「后端未连接」提示。 + +### 如果没生效 + +- 页面空白 / 404 `/assets/...`:确认 `static/` 里有构建产物,且 `python app.py` 是在仓库根启动。 +- 改了 `web/` 但页面没变:执行 `cd web && pnpm build`,构建产物才会进 `static/`。 +- 端口不是 8848 时 `pnpm dev` 的 `/api` 代理会打错:改 `web/vite.config.ts` 的 `server.proxy`。 + ## 首次进入课程自动加入(syncSelf) **状态**:已实现,待实测确认。 @@ -39,3 +93,53 @@ GET /mod/school/wanpeng/syncSelf?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=<账号>`)和该账号卡片的「课程日志」。 + +### 怎么测 + +1. 打开一个测验,点「AI 答题」,看状态栏是否逐批更新「Ns · 已填 x/y 题」。 +2. 结束后展开该账号卡片的「课程日志」,应能看到 `AI答题 开始/第 1/2 批…/结束` 若干行;控制台同样能看到。 +3. 故意把 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 线程抛异常。 + +### 怎么测 + +1. 启动服务,控制台应出现 `[system] SQLite: …` 和 `[system] 控制台启动 -> …`。 +2. 页面右上角「系统日志」→ 看到上面两行;保存一次设置 → 多一行「设置已更新:模型 …」。 +3. 点账号卡片的「课程日志」→ 学习日志和以前一致;点「清空」后不再出现旧行。 +4. 跑一门课(旧平台 / 新平台各一次),日志里应出现该课程的流程,说明插件分发走到了对应平台。 +5. `python -c "import sys; sys.path.insert(0,'src'); from study_tool.platforms import load_builtin, registry; load_builtin(); print(registry.all())"` → 应看到 jspx / study 两个平台。 +4. 跑一次 AI 答题 → 该账号「课程日志」里是 `AI答题 …`,控制台同步同一份内容。 + +### 如果没生效 + +- 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。 +- 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。 diff --git a/README.md b/README.md index dcb38a2..951ca6b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 多账号学习控制台 +# 莞易学多账号学习控制台 -东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)的多账号挂机控制台。账号、会话、课程缓存、运行日志、系统设置全部存在服务端 SQLite,浏览器只是界面,换电脑、远程打开都在。 +莞易学(`jspx.dgjy.net` / `study.dgjy.net`,新旧版课程页自动识别)的多账号挂机控制台。账号、会话、课程缓存、运行日志、系统设置全部存在服务端 SQLite,浏览器只是界面,换电脑、远程打开都在。 ## 启动 @@ -63,6 +63,21 @@ docker compose logs -f # 看启动日志 镜像默认装 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 模式,读写不互斥)。表: @@ -127,20 +142,51 @@ docker compose logs -f # 看启动日志 ## 目录 ``` -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 浏览器油猴脚本(独立使用,与网页端无关) +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 / desenc.js) + 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) +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 库(账号/设置/日志/快照) +docs/architecture.md 分层说明 + 平台插件怎么加 +study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db) +dgjy-toolkit.user.js 浏览器油猴脚本(独立使用,与网页端无关) ``` +分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]` +从注册表取平台实现;平台用 `entries` 声明登录 / 学习入口,新增平台不用改 engine / 路由 / 前端。 + +日志统一从 `runtime.log()` 出:控制台、页面卡片「课程日志」(按账号 id)、右上角「系统日志」(key=system) +看到的是同一份;新增日志不要再自己 `print()`。 + ## 登录链路 ``` @@ -156,7 +202,9 @@ GET jspx.dgjy.net/admin/main.action table-results 课程表 `requests` 不执行 JS,中间两跳是手工解析 `scriptLogin.src` 和 `verifyURL` 走完的。 -新版平台(慕课)另走一条 OAuth 码换 token 的链,拿到 `study.dgjy.net` 的 `Middle-Token`,见 `ensure_study_session()`。 +新版页面(慕课)另走一条 OAuth 码换 token 的链,拿到 `study.dgjy.net` 的 `Middle-Token`,见 `ensure_study_session()`。 +`jspx.dgjy.net` 与 `study.dgjy.net` 在注册表里是同一个平台(`guanyixue` / 莞易学), +课程发现时自动判断页面代际(旧版 / 新版),分别走 `jspx.py` / `study.py` 的学习实现。 ## 接口 @@ -164,6 +212,7 @@ GET jspx.dgjy.net/admin/main.action table-results 课程表 | --- | --- | --- | | 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}` | 单个账号 | diff --git a/app.py b/app.py index 36b0b34..acb62e7 100644 --- a/app.py +++ b/app.py @@ -1,3414 +1,15 @@ #!/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) +真正的代码在 src/study_tool/ 包里,这里只把 src 加进 sys.path 再调 main(), +这样 `python app.py`、`start.bat`、Dockerfile 都不用改。 """ -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 sys -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:原生