Compare commits

..

5 Commits

Author SHA1 Message Date
study-tool 0e2923e675 fix: 重新登录后清掉上一轮已结束的运行状态
- 服务重启留下的「已中断」状态会在登录后继续压着卡片徽标、挂着旧进度和红条
- 登录成功后清掉该账号已结束的运行状态(内存 + run_state 表),正在跑的不动,内存日志保留
- 新增 store.run_state_delete(),/api/run 轮询随即把 run 置空,徽标回到「已登录」
2026-10-31 01:00:57 +08:00
study-tool e6515fe278 fix: 运行状态与日志三处修复;依赖改用 uv 锁 + 国内镜像
- 重启标记不再污染新一轮运行:hydrate_runs() 标记「已中断」后立即落库,runtime.set_state() 在切入 discovering/studying 时清掉上一轮的 error/progress/finished_at
- 服务重启后卡片「课程日志」能读到 SQLite 历史:前端兜底条件不再拿 status 是否为空做判断(重启后 status 恒为 interrupted)
- 删除账号连带清日志:account_delete() 按账号 id + 用户名清 run_state/run_log/login_log,删除接口同时清内存里两个 key
- 新增 uv.lock 依赖锁定;pyproject 走阿里云 PyPI 镜像,Dockerfile 支持 PIP_INDEX_URL 构建参数
- 文档移入 docs/,.gitignore 忽略 *.egg-info/(uv run 可编辑安装产物)
2026-10-31 00:53:40 +08:00
study-tool c4bc97dd70 feat(gdgxk): 课程卡片显示培训年度 / 学时 / 完成状态
- list_courses 解析 showCourse 卡片里的「培训年度」「课程学时」「未完成/已完成」,
  返回 section / info / status_label,前端拼成「2026年公需课 · 未完成 · 30 学时」
- 修正完成判定:以平台状态为准(需所有教学活动 100% + 试卷考核 ≥60),
  不再用视频秒数够不够来推断,避免把未完成的课当已学完跳过
2026-10-30 22:33:46 +08:00
study-tool 31f7020190 fix(gdgxk): 打通登录全链路(图形验证码 OCR + 正确换票入口)
- 登录要过 4 位图形验证码:readCaptcha 返回 data:image/png;base64,用 ddddocr 识别,
  失败按登录页 #errorText 判断原因并最多重试 5 次
- 换票入口纠正:票按 ITLMS-xxx||ticketId 两段 GET /nts/loginToStoreByAccount,
  之前直接带 ?ticket= 访问 courseRegister 是无效的
- requirements / pyproject 增加 ddddocr;文档同步实测结论
2026-10-30 22:30:32 +08:00
study-tool 16346159c2 feat: 接入广东公需课平台(jsglpt 登录 + jsxx 进度心跳)
- 新增 platforms/gdgxk.py:base64 登录 → ssoItlmsGxkTicket 换票进 jsxx →
  /uc/store/courseRegister 取课程 → updateViewTime1 心跳上报进度(无需真播)
- platforms/__init__.py 注册;配套抓包脚本与自测脚本
- CHANGELOG / OPTIMIZATION / todo 同步,版本升到 0.0.2
2026-10-30 22:17:47 +08:00
24 changed files with 2292 additions and 49 deletions
+1
View File
@@ -9,6 +9,7 @@ data/
.env
.venv/
venv/
*.egg-info/
__pycache__/
*.py[cod]
.playwright-mcp/
+11
View File
@@ -17,6 +17,7 @@
### 新增
- 新增平台「广东公需课」(`platforms/gdgxk.py`):`jsglpt.gds.edu.cn` 账号密码(base64)登录后换 ITLMS 票进 `jsxx.gds.edu.cn`,课程列表读 `/uc/store/courseRegister`;学习只跑 `updateViewTime1` 进度心跳(服务端按 `lastViewTime` 直接记账,不需要真播)。票按 `ITLMS-xxx||ticketId` 两段交给 `jsxx.gds.edu.cn/nts/loginToStoreByAccount`;登录要过 4 位图形验证码,用 ddddocr 离线识别并自动重试。配套 `script/capture-jsglpt.py`(抓包取证)与 `script/test-gdgxk.py`(接口自测);课程列表解析出培训年度 / 学时 / 完成状态,卡片显示成「2026年公需课 · 未完成 · 30 学时」
- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导
@@ -29,6 +30,16 @@
- AI 答题整段同步等待导致浏览器 / 反代超时:`/api/quiz/answer` 改为流式(text/event-stream)分批返回,每批失败自动重试一次
- AI 答题解析不出 JSON 时直接判空:补 `max_tokens`、空内容 / 非 JSON 抛错重试,并支持从自由文本、截断 JSON 里抠答案,多选连写(`AC`)也能识别
- 控制台是 GBK 时日志里含 `⚠` 之类字符会让工作线程抛 `UnicodeEncodeError`:`runtime.log` 现在编码失败会自动降级
- 服务重启把上次没跑完的运行标成「已中断」后,那句话会被后续 `set_state` 一起写回库,新一轮运行一起步就顶着「服务重启,上次运行已中断」,库里还留着 `status=studying` 的幽灵记录:`runtime.set_state()` 在状态切入 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,`hydrate_runs()` 标记后立即落库
- 服务重启后卡片「课程日志」显示「暂无日志」,SQLite 里的历史读不出来:前端兜底条件原为「内存为空且 status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读;现在只看内存是否为空
- 删除账号只清了按账号 id 记的 `run_state` / `run_log`,登录日志(key 与 `login_log.username` 记的是用户名 / 手机号)残留:`account_delete()` 改为按账号 id + 用户名一起清,`DELETE /api/accounts/{id}` 同时清掉内存里两个 key 的日志
- 服务重启留下的「已中断」状态在重新登录后仍然压在卡片上,徽标显示「已中断」并挂着旧进度和红条:登录成功后清掉该账号上一轮已结束的运行状态(内存 + SQLite),正在跑(`discovering` / `studying`)的不动,内存日志保留
### 其他
- 新增 `uv.lock` 依赖锁定与 uv 安装方式;`pyproject.toml` 增加阿里云 PyPI 镜像(`[[tool.uv.index]]`),`Dockerfile` 增加 `PIP_INDEX_URL` 构建参数(默认阿里云,可回官方源),README 补充 uv / pip / 换源说明
- `.gitignore` 忽略 `*.egg-info/`(`uv run` 可编辑安装的产物)
- 文档整理:`OPTIMIZATION.md`、`todo.md` 移入 `docs/`;移除不再使用的 `start.bat`
## v0.0.1
+4 -1
View File
@@ -23,8 +23,11 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \
&& rm -rf /var/lib/apt/lists/*
# 国内镜像:默认走阿里云;要回官方源加 --build-arg PIP_INDEX_URL=https://pypi.org/simple
ARG PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple
COPY requirements.txt ./
RUN pip install -r requirements.txt
RUN pip install -r requirements.txt -i "$PIP_INDEX_URL"
# 直链视频(fileType=other)必须用真浏览器播到结尾;不需要就 WITH_PLAYWRIGHT=0
ARG WITH_PLAYWRIGHT=1
+13 -2
View File
@@ -10,13 +10,24 @@
start.bat
```
或手动:
或手动(uv,依赖版本已锁在 `uv.lock`):
```bat
pip install -r requirements.txt
uv sync
uv run app.py
```
没有 uv 就用 pip(Docker 镜像里也是走的这条):
```bat
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
python app.py
```
依赖默认走阿里云镜像:uv 的源配在 `pyproject.toml`,`uv.lock` 里记录的也是它;Dockerfile 用 `PIP_INDEX_URL` 构建参数(默认阿里云,`--build-arg PIP_INDEX_URL=https://pypi.org/simple` 可回官方源)。临时换源:`UV_DEFAULT_INDEX=https://mirrors.cloud.tencent.com/pypi/simple uv sync`。清华源 `pypi.tuna.tsinghua.edu.cn` 会 403 uv 的 JSON simple 请求,不要设。
`ddddocr` 依赖 `onnxruntime`,后者只对 Python 3.10–3.14 的正式版发轮子;3.15 / free-threaded / ARM 上会装不上(`ResolutionImpossible`)。缺它只影响广东公需课的验证码识别,其余平台不受影响。
浏览器打开 <http://127.0.0.1:8848>(`localhost` 也可以,服务同时监听 `::1`)。
换端口 / 对外开放:
+59
View File
@@ -171,3 +171,62 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
- 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。
- 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。
## 广东公需课平台接入(jsglpt → jsxx)
**状态**:登录(账号密码 + 图形验证码 OCR)、课程列表、课程发现、播放注册、进度心跳全部实测跑通;只剩「未学完课程」的进度增长待确认。
### 已确认的接口
- 登录:`POST https://jsglpt.gds.edu.cn/loginIn.do`,`loginUser.userName` / `loginUser.password` 都是 **base64**(账号是手机号),`loginUser.loginType=normal`;`captcha` 是**强制**的 4 位图形验证码,`GET /login/readCaptcha.action` 返回 `"data:image/png;base64,..."`,用 `ddddocr` 离线识别(识别错就换一张重试,最多 5 次)。登录失败时服务端把原因写回登录页 `#errorText`(如「请输入正确的验证码!」),据此区分是验证码还是密码问题。
- 换票:`POST /teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=` → `ITLMS-<hex>||<ticketId>`;把票按 `||` 切成两段,`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=<前段>&ticketId=<后段>` 才拿到学习平台会话(入口来自 jsglpt 首页的隐藏表单 `SSO_ITLMS_PUBLIC_STUDY_FORM`;直接带 `?ticket=` 访问 `courseRegister` 是无效的)。两个域名的 cookie 要在同一个 session 里。
- 课程列表:`GET /uc/store/courseRegister`,页面里是 `showCourse('<课程id>','<课程名>')`;同一张卡片里还有 `课程学时:N`、`培训年度:YYYY`、`未完成/已完成` 徽标,已解析成 `section` / `info` / `status_label` 显示在卡片上。
- 学习页:`GET /study/course/<课程id>?v=1`(服务端渲染、UTF-8),含 `url1`(CDN 直链 MP4)、`t`(总秒数)、`md5Uid`、`/a_<aid>/...` 上报前缀。
- 播放注册:`POST /a_<aid>/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=<uid>&playbackSessionId=<sid>&playbackTakeover=true`)→ `{time, md5}`。
- 进度心跳:`POST /a_<aid>/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。
- 已看秒数:`GET /a_<aid>/study/video/user/getViewTime?id=&md5=&expires=&md5Uid=`,`responseData` 直接就是秒数。
### 踩到的坑
- 三个上报接口必须带 **Referer(指向课程页)**,否则 500 且响应体为空。
- `isLimit` 只能传 `false` / `true`,传 `"N"` 会 400。
- 课程列表页、学习页都是 UTF-8(别按 GBK 解,会花屏)。
### 关键结论
进度不靠播放器,`lastViewTime` 直接写服务端。实测把 2640 秒的课报到 2700 秒也被接受,说明可以跳着报、不必真播;默认 `speed=60`、`beat=2`,44 分钟的课约 45 秒报完。课程是否学完看 `getViewTime >= t`。
### 怎么测
1. 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。
2. 选一门**没学完**的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。
3. 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 cookie,再补 OCR。
### 如果没生效
- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。
## 运行状态与日志修复(重启标记 / 历史日志 / 删账号清日志)
**状态**:已实现,待实测确认。
### 做了什么
- **重启标记不再污染新一轮运行**:`app.py` 的 `hydrate_runs()` 把上次没跑完的状态标成「已中断」后立刻落库(库里不再一直挂着 `studying`);`runtime.set_state()` 在状态从非运行态切到 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,所以点「开始学习」后不会还挂着「服务重启,上次运行已中断」。
- **重启后历史日志能读出来了**:前端拉日志的兜底条件原是「内存为空 **且** status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读。现在只看内存是否为空(`web/src/App.tsx`)。
- **删账号连带清日志**:`store.account_delete()` 除 run_state / run_log(账号 id)外,还会按**用户名**清 run_log 与 login_log(登录日志的 key 和 `login_log.username` 记的是手机号,不是账号 id);`DELETE /api/accounts/{id}` 同时把内存里这两个 key 的日志一起丢掉。
### 怎么测
1. 重启服务后打开页面:卡片应是新状态;点「开始学习」,红条与上一轮的旧进度立即被覆盖。
2. 重启服务(内存日志清空)后展开某账号「课程日志」:应显示 SQLite 里的历史行,而不是「暂无日志」。
3. 删掉一个账号再刷新:该账号的课程日志、登录记录(`/api/loginlog`)都不应再有它的行;`system` 与其它账号的日志必须保留。
### 如果没生效
- 红条还在 → 看 `run_state` 里那条是否仍带旧 `error`,删掉该行即可复位。
- 历史日志还是空 → 确认 `static/` 已重新构建(`cd web && pnpm build`)并强刷页面。
- 删完还有日志 → 平台的登录用户名与账号 `username` 字段不一致时清不掉,需按账号实际写入的 key 处理。
+3
View File
@@ -5,3 +5,6 @@
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
并校验 `static/` 与源码同步。
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
- 广东公需课:**试卷考核**未接入 —— 平台判定通过要「所有教学活动 100% + 试卷考核 ≥60 分」,所以视频报满后课程仍显示「未完成」。
- 广东公需课:视频弹题未接入。
+6 -1
View File
@@ -2,7 +2,7 @@
## 待评估:接入广东公需课平台(jsxx.gds.edu.cn)
**状态**:只沉淀思路,不实现。手上没有公需课账号,接口抓不到,代码不动。
**状态**:已接入,见 `src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。
### 背景
@@ -51,6 +51,11 @@
- 题库命中优先、LLM 兜底(省 token、命中时更准)
- 强制播完才放开「下一节」—— 项目已有等价实现(播到 `ended` + `completeTime` 校验)
## 待办:日志 key 与 ddddocr 报错提示
- **登录日志的 key 用错维度**:`routes/accounts.py` 的 `_log_login()` 拿**用户名(手机号)**当 key 写 run_log,而卡片「课程日志」读的是**账号 id**,两边对不上——登录行只有登录记录页(`login_log` 表)能看到。要么统一成账号 id,要么卡片同时聚合两个 key。
- **ddddocr 的报错提示会误导**:`platforms/gdgxk.py` 把任何导入失败都写成「需要先装 ddddocr(pip install ddddocr)」,`uv sync` 在应用运行期间改写包造成的 `DLL load failed` 也会被说成「没装」。应区分 ImportError 与 OSError(DLL),并把真实异常写进 `runtime.log`。
## 已完成
- 「首次进入课程弹『未加入课堂』」已修复,实测确认见 `OPTIMIZATION.md`。
+1 -1
View File
@@ -1,3 +1,3 @@
name: study-tool
version: 0.0.1
version: 0.0.6
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
+10
View File
@@ -14,6 +14,7 @@ dependencies = [
"beautifulsoup4>=4.12",
"lxml>=5.0",
"playwright>=1.40",
"ddddocr",
]
[project.scripts]
@@ -24,3 +25,12 @@ package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
# 国内镜像:uv 默认走阿里云源,不再直连 pypi.org。
# 换源:UV_DEFAULT_INDEX=https://mirrors.cloud.tencent.com/pypi/simple uv sync
# 想回官方:UV_DEFAULT_INDEX=https://pypi.org/simple uv sync
# (清华源 pypi.tuna.tsinghua.edu.cn 会 403 uv 的 JSON simple 请求,别用)
[[tool.uv.index]]
name = "aliyun"
url = "https://mirrors.aliyun.com/pypi/simple"
default = true
+1
View File
@@ -4,3 +4,4 @@ requests>=2.31
beautifulsoup4>=4.12
lxml>=5.0
playwright>=1.40
ddddocr
+127
View File
@@ -0,0 +1,127 @@
"""广东省教师继续教育平台抓包工具(一次性取证用,不参与运行)。
用法:
python script/capture-jsglpt.py [分钟数]
行为:开一个有头 Chromium(持久 profile,登录态会保留),打开 jsglpt 登录页,
把你在窗口里操作期间产生的 XHR/fetch 全量落到 data/capture-jsglpt-<时间>.jsonl,
同时每 3 秒把 cookie 快照写到 data/capture-jsglpt-cookies.json。
"""
import json
import os
import sys
import time
from playwright.sync_api import sync_playwright
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA = os.path.join(ROOT, "data")
LOGIN = "https://jsglpt.gds.edu.cn/login.jsp"
MINUTES = float(sys.argv[1]) if len(sys.argv) > 1 else 40
os.makedirs(DATA, exist_ok=True)
STAMP = time.strftime("%Y%m%d-%H%M%S")
JSONL = os.path.join(DATA, "capture-jsglpt-%s.jsonl" % STAMP)
COOKIES = os.path.join(DATA, "capture-jsglpt-cookies.json")
PROFILE = os.path.join(DATA, ".gd-profile")
out = open(JSONL, "a", encoding="utf-8")
seen = 0
def emit(rec):
global seen
seen += 1
rec["n"] = seen
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
out.flush()
print(" %-4s %s %s -> %s" % (rec.get("rt", "doc"), rec.get("method", ""),
rec.get("url", "")[:150], rec.get("status", "")), flush=True)
def on_response(r):
try:
req = r.request
rt = req.resource_type
if rt not in ("xhr", "fetch", "document"):
return
h = {}
try:
h = dict(r.headers or {})
except Exception:
pass
body = ""
# 只留文本类响应,视频/图片跳过
if not (r.url or "").lower().split("?")[0].endswith((".mp4", ".m3u8", ".ts", ".png", ".jpg", ".gif", ".woff", ".woff2", ".css", ".js")):
try:
body = r.text()[:8000]
except Exception:
body = "<binary>"
emit({
"t": time.strftime("%H:%M:%S"),
"rt": rt,
"method": req.method,
"url": r.url,
"req_headers": dict(req.headers or {}),
"req": (req.post_data or "")[:4000],
"status": r.status,
"resp_headers": h,
"resp": body,
})
except Exception:
pass
def main():
print("抓包输出:%s" % JSONL, flush=True)
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
PROFILE, headless=False,
args=["--disable-blink-features=AutomationControlled"],
viewport={"width": 1440, "height": 900},
)
hooked = set()
def hook(page):
if id(page) in hooked:
return
hooked.add(id(page))
page.on("response", on_response)
print("[页面] %s" % (page.url or "about:blank"), flush=True)
ctx.on("page", hook)
pg = ctx.pages[0] if ctx.pages else ctx.new_page()
hook(pg)
try:
pg.goto(LOGIN, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
print("打开登录页失败:%s" % e, flush=True)
last = 0.0
t0 = time.time()
while time.time() - t0 < MINUTES * 60:
try:
now = time.time()
if now - last > 3:
last = now
with open(COOKIES, "w", encoding="utf-8") as f:
json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1)
ctx.pages[0].wait_for_timeout(1000)
except Exception:
# 用户把所有页面关掉了,收工
break
try:
with open(COOKIES, "w", encoding="utf-8") as f:
json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
out.close()
print("抓包结束:%d 条 → %s" % (seen, JSONL), flush=True)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。
用法:PYTHONPATH=src python script/test-gdgxk.py [课件id]
"""
import json
import sys
import uuid
from study_tool.platforms import gdgxk
cid = sys.argv[1] if len(sys.argv) > 1 else ""
cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8"))
s = gdgxk.new_session(cookies)
courses = gdgxk.list_courses(s)
print("课程数:", len(courses))
for c in courses:
print(" ", c["plan_id"], c["name"])
if not cid and courses:
cid = courses[0]["plan_id"]
d = gdgxk.discover(s, cid, "")
print("\n课程:", d["course"])
print("进度:", d["progress"])
print("视频:", d["info"])
cw = d["courseware"][0]
print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"])
sid = uuid.uuid4().hex
sign = gdgxk._sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
print("\n播放注册签名:", sign)
print("服务端已看秒数:", gdgxk._view_time(s, cw, cw["uid"], sign))
r = gdgxk._xhr(s, "POST",
gdgxk.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
data={"_method": "PUT", "aid": cw["aid"], "isLimit": "false", "md5": sign["md5"],
"expires": sign["time"], "md5Uid": cw["uid"],
"lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])),
"playbackSessionId": sid})
print("\n心跳响应:", r.status_code, r.text[:300])
+4
View File
@@ -100,6 +100,10 @@ def hydrate_runs():
if st.get("status") in ("discovering", "studying"):
st["status"] = "interrupted"
st["error"] = "服务重启,上次运行已中断"
try:
store.run_state_set(k, st) # 落库,别让库里一直挂着 studying
except Exception:
pass
RUNS[k] = st
+1
View File
@@ -12,4 +12,5 @@ from study_tool.platforms.base import Platform # noqa: F401
def load_builtin():
"""导入内置平台,触发注册。幂等,app 启动时调用一次。"""
from study_tool.platforms import gdgxk # noqa: F401
from study_tool.platforms import guanyixue # noqa: F401
+412
View File
@@ -0,0 +1,412 @@
"""广东公需课:jsglpt 登录 → jsxx 学习。
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
两个域名的 cookie 都留在同一个 requests.Session 里。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
"""
import base64
import json
import re
import time
import uuid
import requests
from study_tool import runtime, store
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
G = "https://jsglpt.gds.edu.cn"
X = "https://jsxx.gds.edu.cn"
PLATFORM_ID = "gdgxk"
def _xhr(s, method, url, **kw):
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
return s.request(method, url, timeout=30, **kw)
def _expired(msg):
"""引擎认的是同一个 SessionExpired,这里延迟取避免循环 import。"""
from study_tool.engine import SessionExpired
return SessionExpired(msg)
def _b64(text):
return base64.b64encode(str(text).encode("utf-8")).decode()
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 "gds.edu.cn" 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 _sso(s):
"""jsglpt 会话换 ITLMS 票,票按 `ITLMS-xxx||ticketId` 两段交给 jsxx。
交接入口来自 jsglpt 首页那个隐藏表单:
`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=&ticketId=`
"""
r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
ticket = (r.text or "").strip()
if not ticket.startswith("ITLMS-") or "||" not in ticket:
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
t, tid = ticket.split("||", 1)
q = s.get(X + "/nts/loginToStoreByAccount", params={"ticket": t, "ticketId": tid}, timeout=30)
if "logout" not in q.text and "我的课程" not in q.text:
raise _expired("带票进学习平台失败,票可能已失效")
return s
_OCR = None
def _ocr():
"""4 位图形验证码:ddddocr 离线识别。"""
global _OCR
if _OCR is None:
try:
import ddddocr
except Exception as e:
raise RuntimeError("登录要过图形验证码,需要先装 ddddocr(pip install ddddocr):%s" % e)
_OCR = ddddocr.DdddOcr(show_ad=False)
return _OCR
def _captcha(s):
raw = s.get(G + "/login/readCaptcha.action?d=%.6f" % time.time(), timeout=30).text
return base64.b64decode(raw.strip().strip('"').split(",", 1)[-1])
def _login_error(s):
html = s.get(G + "/login.jsp", timeout=30).text
m = re.search(r'id="errorText"[^>]*>([^<]*)<', html)
return (m.group(1).strip() if m else "")
def login(username, password):
s = new_session()
s.get(G + "/login.jsp", timeout=30)
err = ""
for _ in range(5):
data = {
"loginUser.userName": _b64(username),
"loginUser.loginType": "normal",
"loginUser.password": _b64(password),
"captcha": _ocr().classification(_captcha(s)),
}
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
_sso(s)
return s, profile(s) or username
err = _login_error(s)
if "验证码" not in err: # 不是验证码的问题,重试没意义
break
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
def renew_session(s):
try:
_sso(s)
return True
except Exception:
return False
def profile(s):
html = s.get(G + "/teacher/index.do", timeout=30).text
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
if m:
return m.group(1)
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
return m.group(1) if m else ""
def _parse_courses(html):
"""课程列表页:每个 `showCourse('id','名称')` 前面就是这张卡片的信息。"""
out = []
for m in re.finditer(r"showCourse\('([^']+)','([^']*)'\)", html):
block = html[max(0, m.start() - 2500):m.start()]
year = re.search(r"培训年度:\s*<[^>]*>(\d{4})<", block)
hours = re.search(r"课程学时:\s*<[^>]*>(\d+)<", block)
st = re.search(r">\s*(未完成|已完成)\s*<", block)
out.append({
"plan_id": m.group(1),
"project_id": "",
"name": m.group(2).strip(),
"section": ("%s年公需课" % year.group(1)) if year else "",
"status_label": st.group(1) if st else "",
"info": ("%s 学时" % hours.group(1)) if hours else "",
"in_study": True,
"platform": PLATFORM_ID,
})
return out
def list_courses(s):
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
out = _parse_courses(html)
if not out:
if "logout" not in html:
raise _expired("学习平台会话已失效,请重新登录")
return []
return out
def parse_course(html):
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
d = {}
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
if not m:
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
d["aid"] = "a_" + m.group(1)
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
if not m:
raise RuntimeError("学习页里没有找到课件 id")
d["id"] = m.group(1)
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
if not m:
raise RuntimeError("学习页里没有找到 md5Uid")
d["uid"] = m.group(1)
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
d["viewTime"] = int(m.group(1)) if m else 0
m = re.search(r"要求观看视频<span>(\d+)</span>分钟", html)
d["total"] = int(m.group(1)) * 60 if m else 0
m = re.search(r"url1\s*=\s*'([^']+)'", html)
d["video"] = m.group(1) if m else ""
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
if "<title>" in html else "")
return d
def _sign(s, cw, uid):
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
j = r.json() if r.text.strip().startswith("{") else {}
if j.get("responseCode") != "00":
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
return j["responseData"]
def _view_time(s, cw, uid, sign):
r = _xhr(s, "GET", X + "/" + cw["aid"] + "/study/video/user/getViewTime",
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
"md5Uid": uid})
j = r.json() if r.text.strip().startswith("{") else {}
rd = j.get("responseData")
if isinstance(rd, dict):
return int(rd.get("viewTime") or 0)
return int(rd or 0)
def _progress(s, cid):
"""学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。"""
html = s.get(X + "/" + cid + "/study/course/progess", timeout=30).text
if "studyCourse(" not in html:
raise _expired("学习平台会话已失效,请重新登录")
m = re.search(r"\$\('#actNum'\)\.text\('(\d+)'\)", html)
total = int(m.group(1)) if m else 0
m = re.search(r"\$\('#completeActNum'\)\.text\('(\d+)'\)", html)
done = int(m.group(1)) if m else 0
acts = []
for row in re.findall(r"<tr[^>]*>([\s\S]*?)</tr>", html):
m = re.search(r"studyCourse\('[^']*','[^']*','(a_[0-9a-f]{32})'\)[^>]*>([^<]+)<", row)
if m:
acts.append({"aid": m.group(1), "title": m.group(2).strip(),
"done": "u-ico-finish" in row})
return {"total": total or len(acts), "done": done, "acts": acts}
def discover(s, plan_id, project_id, name=""):
cid = plan_id
page = X + "/study/course/" + cid
s.headers["Referer"] = page
r = s.get(page + "?v=1", timeout=30)
if "loginBtn" in r.text and "md5Uid" not in r.text:
raise _expired("学习平台会话已失效,请重新登录")
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = _progress(s, cid)
if not pr["acts"]:
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
courseware = [{
"aid": a["aid"], "title": a["title"], "done": a["done"],
"href": X + "/" + a["aid"] + "/study/course/" + cid,
"total": 0, "viewTime": 0,
} for a in pr["acts"]]
return {
"user": profile(s),
"course": name or "公需课程",
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
"info": page,
"courseware": courseware,
"feedback": [], "test": [],
"platform": PLATFORM_ID, "generation": "gxk",
"plan_id": cid, "project_id": "",
}
def _stopped(key):
with runtime.RUNS_LOCK:
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
def _relogin(key):
"""掉线后按库里存的账号密码重登,返回新会话。"""
acc = store.account_get(key) or {}
pw = store.account_password(key)
if not pw:
raise _expired("会话掉线,且没有保存密码,无法自动重连")
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
s, _ = login(acc.get("username") or "", pw)
runtime.log(key, "重新登录成功,继续学习")
return s
def _study_one(key, s, cw, speed, beat):
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
try:
_sso(s) # 一门课要跑几十分钟到几小时,每个活动前续一次票
except Exception:
pass # 续票失败但老会话还能用就继续
html = s.get(cw["href"], timeout=30).text
if "loginBtn" in html and "md5Uid" not in html:
raise _expired("学习页要求重新登录")
try:
info = parse_course(html)
except Exception as e:
raise RuntimeError("不是视频活动(%s)" % e)
if not info["total"]:
raise RuntimeError("页面上没有「要求观看视频 N 分钟」")
cw.update(id=info["id"], uid=info["uid"], total=info["total"], viewTime=info["viewTime"])
s.headers["Referer"] = cw["href"]
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": uuid.uuid4().hex}, cw["uid"])
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, cw["uid"], sign)
runtime.log(key, "开始学习:%s(要求 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cur))
if cur < cw["total"]:
_learn(key, s, cw, speed, beat)
else:
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
def study(key, s, d, **opts):
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
speed = float(opts.get("speed") or 1)
beat = int(opts.get("beat") or 60)
cid = d["plan_id"]
total = len(d["courseware"])
runtime.set_state(key, status="studying", total=total, done=0)
skipped = []
for i, cw in enumerate(d["courseware"], 1):
if _stopped(key):
runtime.log(key, "已按要求停止")
return
if cw.get("done"):
runtime.log(key, "活动 %d/%d 已完成,跳过:%s" % (i, total, cw["title"]))
runtime.set_state(key, done=i)
continue
for attempt in (1, 2):
try:
_study_one(key, s, cw, speed, beat)
break
except Exception as e:
expired = e.__class__.__name__ == "SessionExpired"
if expired and attempt == 1:
s = _relogin(key) # 掉线:重登后把当前活动重新走一遍
continue
if expired:
raise
runtime.log(key, "活动「%s」跳过:%s" % (cw["title"], e), level="warn")
skipped.append(cw["title"])
break
runtime.set_state(key, done=i)
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
try:
pr = _progress(s, cid)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
raise
s = _relogin(key)
pr = _progress(s, cid)
act_total = pr["total"] or total
runtime.set_state(key, status="done", done=pr["done"], total=act_total)
if pr["done"] >= act_total:
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], act_total))
else:
runtime.log(key, "平台进度:%d/%d 个活动已完成,剩余需人工处理%s"
% (pr["done"], act_total,
("(" + "、".join(skipped) + ")") if skipped else ""),
level="warn")
def _learn(key, s, cw, speed, beat):
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
uid, total = cw["uid"], cw["total"]
s.headers["Referer"] = cw.get("href") or X
sid = uuid.uuid4().hex
base = {"_method": "PUT", "aid": cw["aid"]}
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
start, t0, stall = cur, time.time(), 0
while cur < total:
if _stopped(key):
runtime.log(key, "已按要求停止")
return
time.sleep(beat)
want = min(total, start + int((time.time() - t0) * speed))
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
md5Uid=uid, lastViewTime=str(max(want, cur)), playbackSessionId=sid)
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
data=data)
if "loginBtn" in (r.text or "") or not (r.text or "").strip().startswith("{"):
raise _expired("上报被踢回登录页(HTTP %s)" % r.status_code)
j = r.json()
rd = j.get("responseData") or {}
if j.get("responseCode") == "00" and rd.get("md5"):
sign = {"md5": rd["md5"], "time": rd.get("time")}
before = cur
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
runtime.log(key, "进度上报:目标 %d 秒 / 服务端 %d 秒 / 要求 %d 秒" % (want, cur, total))
stall = stall + 1 if cur <= before else 0
if stall >= 5:
runtime.log(key, "连续 5 次上报都没被平台接受,先停这个活动", level="warn")
return
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
PLATFORM = registry.register(Platform(
id=PLATFORM_ID,
label="广东公需课",
study=study,
entries=("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"),
session=new_session,
dump_cookies=dump_cookies,
login=login,
list_courses=list_courses,
discover=discover,
renew_session=renew_session,
profile=profile,
))
+30
View File
@@ -23,6 +23,32 @@ def _log_login(username, ok, code, error, elapsed, stage=""):
pass
_RUN_FIELDS = ("status", "error", "progress", "done", "total", "course",
"started", "finished_at")
def _clear_done_run(account_id):
"""重新登录后清掉上一轮已经结束的运行状态。
服务重启会把上次没跑完的运行标成「已中断」,那条状态会一直压着卡片的
「已登录」徽标、还挂着旧进度和红条;登录成功说明用户开始新一轮,
这时就该把它清掉。正在跑的(discovering / studying)绝不能动,
否则会把实时进度清没。内存里保留 log,卡片日志不受影响。
"""
key = str(account_id or "")
if not key:
return
with RUNS_LOCK:
st = RUNS.get(key)
if st and st.get("status") in ("discovering", "studying"):
return
if st:
for field in _RUN_FIELDS:
st.pop(field, None)
st["updated"] = time.time()
store.run_state_delete(key)
def _login_account(b: LoginIn):
"""解析出一组可登录的凭据:显式传入优先,否则按账号 id 从库里取。"""
acc = store.account_get(b.account_id) if b.account_id else None
@@ -65,6 +91,7 @@ def api_login(b: LoginIn):
_log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user)
if acc:
store.account_save_login(acc["id"], user, cookies, courses)
_clear_done_run(acc["id"]) # 上一轮已结束的运行状态不再挂在新会话上
return {"ok": True, "user": user, "cookies": cookies, "courses": courses, "warn": warn}
@@ -108,7 +135,10 @@ def api_account_get(account_id: str):
@app.delete("/api/accounts/{account_id}")
def api_account_del(account_id: str):
acc = store.account_get(account_id) or {}
store.account_delete(account_id)
with RUNS_LOCK:
RUNS.pop(account_id, None)
# 登录日志挂在用户名下,一起清掉,不然删完账号日志还在
RUNS.pop(acc.get("username") or "", None)
return {"ok": True}
+2
View File
@@ -1,4 +1,5 @@
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
import threading
import time
from fastapi.responses import HTMLResponse, JSONResponse
@@ -13,6 +14,7 @@ from study_tool.platforms import registry
from study_tool.platforms.jspx import *
from study_tool.platforms.study import *
from study_tool.runtime import RUNS, RUNS_LOCK, log
from study_tool.settings_store import load_settings
# ------------------------------------------------------------------ 平台
+8
View File
@@ -40,9 +40,17 @@ def log(key, msg, level="info", persist=True):
return line
ACTIVE = ("discovering", "studying")
def set_state(key, **kw):
with RUNS_LOCK:
st = RUNS.setdefault(key, {})
# 新一轮开始:上一轮留下的 error / progress / finished_at 不能跟着存进库,
# 否则重启标记「服务重启,上次运行已中断」会粘在新运行上。
if kw.get("status") in ACTIVE and st.get("status") not in ACTIVE:
for stale in ("error", "progress", "finished_at"):
st.pop(stale, None)
st.update(kw)
st["updated"] = time.time()
store.run_state_set(key, RUNS.get(key) or {})
+15 -1
View File
@@ -402,10 +402,20 @@ def account_save_login(account_id, user, cookies, courses):
def account_delete(account_id):
"""删账号,连同它的运行状态、课程日志、登录记录一起删。
登录日志的 key 和 login_log.username 记的是用户名(手机号),不是账号 id,
两边都要清,否则删完账号旧日志还留在页面上。
"""
aid = str(account_id or "")
acc = account_get(aid) or {}
uname = str(acc.get("username") or "")
write("DELETE FROM accounts WHERE id=?", (aid,))
write("DELETE FROM run_state WHERE key=?", (aid,))
write("DELETE FROM run_log WHERE key=?", (aid,))
for key in (aid, uname):
if key:
write("DELETE FROM run_log WHERE key=?", (key,))
write("DELETE FROM login_log WHERE username=?", (key,))
# ------------------------------------------------------------------ 登录记录
@@ -477,6 +487,10 @@ def run_state_all():
return out
def run_state_delete(key):
write("DELETE FROM run_state WHERE key=?", (str(key or ""),))
# ------------------------------------------------------------------ 页面快照
def snapshot_add(plan_id, project_id, url, html):
-39
View File
@@ -1,39 +0,0 @@
@echo off
chcp 65001 >nul
title 多账号学习控制台 · 东莞市教师培训平台
cd /d "%~dp0"
echo ============================================
echo 多账号学习控制台 jspx.dgjy.net
echo ============================================
echo.
where python >nul 2>nul
if errorlevel 1 (
echo [错误] 未找到 python,请先安装 Python 3.10+ 并加入 PATH
pause
exit /b 1
)
where node >nul 2>nul
if errorlevel 1 (
if not exist "%NODE_BIN%" (
echo [警告] 未在 PATH 中找到 node。若登录报错,请设置环境变量 NODE_BIN 指向 node.exe
)
)
python -c "import fastapi, uvicorn, requests, bs4" >nul 2>nul
if errorlevel 1 (
echo [安装依赖] fastapi uvicorn requests beautifulsoup4 lxml
python -m pip install -r requirements.txt
)
echo.
echo 启动中... 浏览器请打开: http://127.0.0.1:8848
echo 关闭本窗口即停止服务。
echo.
start "" http://127.0.0.1:8848
python app.py
pause
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#f6f8fc" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>莞易学 · 多账号学习控制台</title>
<script type="module" crossorigin src="/assets/index-Dfv5RNeD.js"></script>
<script type="module" crossorigin src="/assets/index-DsTLd63E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BubgUKHl.css">
</head>
<body>
Generated
+1537
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -234,8 +234,10 @@ export default function App() {
response = await api<LogResponse>(`/api/log?key=${encodeURIComponent(id)}&since=0`)
}
let lines = response.lines || []
// 服务重启后内存日志为空,退回读一次 SQLite 历史
if (!lines.length && !response.status && !state.historyTried) {
// 服务重启后内存日志为空,退回读一次 SQLite 历史。
// 不能拿 status 当条件:重启会把上次的运行状态标成 interrupted,
// status 非空但内存日志是空的,历史就永远读不出来。
if (!lines.length && !state.historyTried) {
state.historyTried = true
try {
const history = await api<{ lines?: string[] }>(