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:
+1
-1
@@ -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` 的启动/设置类日志
|
- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志
|
||||||
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
|
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
|
||||||
- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导
|
- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导
|
||||||
|
|||||||
+4
-3
@@ -174,12 +174,12 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
|
|||||||
|
|
||||||
## 广东公需课平台接入(jsglpt → jsxx)
|
## 广东公需课平台接入(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 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>||<sig>`;带 `?ticket=` 访问 `https://jsxx.gds.edu.cn/uc/store/courseRegister` 即拿到学习平台会话,两个域名的 cookie 要一起保存。
|
- 换票:`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 /uc/store/courseRegister`,页面里是 `showCourse('<课程id>','<课程名>')`。
|
||||||
- 学习页:`GET /study/course/<课程id>?v=1`(服务端渲染、UTF-8),含 `url1`(CDN 直链 MP4)、`t`(总秒数)、`md5Uid`、`/a_<aid>/...` 上报前缀。
|
- 学习页:`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/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`。
|
- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。
|
||||||
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
|
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
|
||||||
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
|
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
|
||||||
|
- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。
|
||||||
|
|||||||
@@ -5,6 +5,5 @@
|
|||||||
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
|
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
|
||||||
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
|
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
|
||||||
并校验 `static/` 与源码同步。
|
并校验 `static/` 与源码同步。
|
||||||
- 广东公需课:确认 `loginIn.do` 的图形验证码是否强制;若强制,接 OCR 或大模型识图后再自动登录。
|
|
||||||
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
|
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
|
||||||
- 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。
|
- 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
name: study-tool
|
name: study-tool
|
||||||
version: 0.0.2
|
version: 0.0.3
|
||||||
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
|
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"beautifulsoup4>=4.12",
|
"beautifulsoup4>=4.12",
|
||||||
"lxml>=5.0",
|
"lxml>=5.0",
|
||||||
"playwright>=1.40",
|
"playwright>=1.40",
|
||||||
|
"ddddocr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ requests>=2.31
|
|||||||
beautifulsoup4>=4.12
|
beautifulsoup4>=4.12
|
||||||
lxml>=5.0
|
lxml>=5.0
|
||||||
playwright>=1.40
|
playwright>=1.40
|
||||||
|
ddddocr
|
||||||
|
|||||||
@@ -62,31 +62,67 @@ def dump_cookies(s):
|
|||||||
|
|
||||||
|
|
||||||
def _sso(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)
|
r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
|
||||||
ticket = (r.text or "").strip()
|
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 "空响应"))
|
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
|
||||||
q = s.get(X + "/uc/store/courseRegister?ticket=" + requests.utils.quote(ticket), timeout=30)
|
t, tid = ticket.split("||", 1)
|
||||||
if "showCourse" not in q.text and "我的课程" not in q.text:
|
q = s.get(X + "/nts/loginToStoreByAccount", params={"ticket": t, "ticketId": tid}, timeout=30)
|
||||||
raise _expired("带票进学习平台失败,可能票已失效")
|
if "logout" not in q.text and "我的课程" not in q.text:
|
||||||
|
raise _expired("带票进学习平台失败,票可能已失效")
|
||||||
return s
|
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):
|
def login(username, password):
|
||||||
s = new_session()
|
s = new_session()
|
||||||
s.get(G + "/login.jsp", timeout=30)
|
s.get(G + "/login.jsp", timeout=30)
|
||||||
data = {
|
err = ""
|
||||||
"loginUser.userName": _b64(username),
|
for _ in range(5):
|
||||||
"loginUser.loginType": "normal",
|
data = {
|
||||||
"loginUser.password": _b64(password),
|
"loginUser.userName": _b64(username),
|
||||||
"captcha": "",
|
"loginUser.loginType": "normal",
|
||||||
}
|
"loginUser.password": _b64(password),
|
||||||
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
|
"captcha": _ocr().classification(_captcha(s)),
|
||||||
if r.status_code != 302 or "/default.jsp" not in (r.headers.get("location") or ""):
|
}
|
||||||
raise RuntimeError("登录失败:账号或密码不对,或平台要求图形验证码(HTTP %s)" % r.status_code)
|
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
|
||||||
_sso(s)
|
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
|
||||||
return s, profile(s) or username
|
_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):
|
def renew_session(s):
|
||||||
|
|||||||
Reference in New Issue
Block a user