fix(gdgxk): 打通登录全链路(图形验证码 OCR + 正确换票入口)

- 登录要过 4 位图形验证码:readCaptcha 返回 data:image/png;base64,用 ddddocr 识别,
  失败按登录页 #errorText 判断原因并最多重试 5 次
- 换票入口纠正:票按 ITLMS-xxx||ticketId 两段 GET /nts/loginToStoreByAccount,
  之前直接带 ?ticket= 访问 courseRegister 是无效的
- requirements / pyproject 增加 ddddocr;文档同步实测结论
This commit is contained in:
study-tool
2026-10-30 22:30:32 +08:00
parent 16346159c2
commit 31f7020190
7 changed files with 60 additions and 22 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
### 新增
- 新增平台「广东公需课」(`platforms/gdgxk.py`):`jsglpt.gds.edu.cn` 账号密码(base64)登录后换 ITLMS 票进 `jsxx.gds.edu.cn`,课程列表读 `/uc/store/courseRegister`;学习只跑 `updateViewTime1` 进度心跳(服务端按 `lastViewTime` 直接记账,不需要真播)。配套 `script/capture-jsglpt.py`(抓包取证)与 `script/test-gdgxk.py`(接口自测)
- 新增平台「广东公需课」(`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`(接口自测)
- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导
+4 -3
View File
@@ -174,12 +174,12 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
## 广东公需课平台接入(jsglpt → jsxx)
**状态**:接口链路已实测跑通(课程列表、播放注册、进度心跳都拿到 `responseCode=00`),待用真实密码登录 + 未学完课程确认端到端。
**状态**:登录(账号密码 + 图形验证码 OCR)、课程列表、课程发现、播放注册、进度心跳全部实测跑通;只剩「未学完课程」的进度增长待确认。
### 已确认的接口
- 登录:`POST https://jsglpt.gds.edu.cn/loginIn.do`,`loginUser.userName` / `loginUser.password` 都是 **base64**(账号是手机号),`loginUser.loginType=normal`;表单还有 `captcha` 字段,`GET /login/readCaptcha.action` 返回 `"data:image/png;base64,..."` 的 4 位图形验证码(是否强制尚未验证)。
- 换票:`POST /teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=` → `ITLMS-<hex>||<sig>`;带 `?ticket=` 访问 `https://jsxx.gds.edu.cn/uc/store/courseRegister` 即拿到学习平台会话,两个域名的 cookie 要一起保存。
- 登录:`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>','<课程名>')`。
- 学习页:`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}`。
@@ -207,3 +207,4 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。
-1
View File
@@ -5,6 +5,5 @@
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
并校验 `static/` 与源码同步。
- 广东公需课:确认 `loginIn.do` 的图形验证码是否强制;若强制,接 OCR 或大模型识图后再自动登录。
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
- 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。
+1 -1
View File
@@ -1,3 +1,3 @@
name: study-tool
version: 0.0.2
version: 0.0.3
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"beautifulsoup4>=4.12",
"lxml>=5.0",
"playwright>=1.40",
"ddddocr",
]
[project.scripts]
+1
View File
@@ -4,3 +4,4 @@ requests>=2.31
beautifulsoup4>=4.12
lxml>=5.0
playwright>=1.40
ddddocr
+44 -8
View File
@@ -62,31 +62,67 @@ def dump_cookies(s):
def _sso(s):
"""jsglpt 会话换 ITLMS 票,再把票交给 jsxx 换学习会话。"""
"""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-"):
if not ticket.startswith("ITLMS-") or "||" not in ticket:
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
q = s.get(X + "/uc/store/courseRegister?ticket=" + requests.utils.quote(ticket), timeout=30)
if "showCourse" not in q.text and "我的课程" not in q.text:
raise _expired("带票进学习平台失败,可能票已失效")
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": "",
"captcha": _ocr().classification(_captcha(s)),
}
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code != 302 or "/default.jsp" not in (r.headers.get("location") or ""):
raise RuntimeError("登录失败:账号或密码不对,或平台要求图形验证码(HTTP %s)" % r.status_code)
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):