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
+52 -16
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)
data = {
"loginUser.userName": _b64(username),
"loginUser.loginType": "normal",
"loginUser.password": _b64(password),
"captcha": "",
}
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)
_sso(s)
return s, profile(s) or username
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):