diff --git a/CHANGELOG.md b/CHANGELOG.md index 324e4fa..3a7fea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导 diff --git a/OPTIMIZATION.md b/OPTIMIZATION.md index 285e751..0bf8c49 100644 --- a/OPTIMIZATION.md +++ b/OPTIMIZATION.md @@ -174,12 +174,12 @@ GET /mod/school/wanpeng/syncSelf?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-||`;带 `?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-||`;把票按 `||` 切成两段,`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_/...` 上报前缀。 - 播放注册:`POST /a_/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=&playbackSessionId=&playbackTakeover=true`)→ `{time, md5}`。 @@ -207,3 +207,4 @@ GET /mod/school/wanpeng/syncSelf?courseCode= - 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。 - 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。 - 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。 +- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。 diff --git a/docs/progress/todo.md b/docs/progress/todo.md index 7dd7a69..650bf32 100644 --- a/docs/progress/todo.md +++ b/docs/progress/todo.md @@ -5,6 +5,5 @@ - 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。 - 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`, 并校验 `static/` 与源码同步。 -- 广东公需课:确认 `loginIn.do` 的图形验证码是否强制;若强制,接 OCR 或大模型识图后再自动登录。 - 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。 - 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。 diff --git a/metadata.yaml b/metadata.yaml index 943bc0a..ab996e7 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,3 +1,3 @@ name: study-tool -version: 0.0.2 +version: 0.0.3 description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台 diff --git a/pyproject.toml b/pyproject.toml index 718fab4..1c1dd14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "beautifulsoup4>=4.12", "lxml>=5.0", "playwright>=1.40", + "ddddocr", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index d0f2591..81ce594 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ requests>=2.31 beautifulsoup4>=4.12 lxml>=5.0 playwright>=1.40 +ddddocr diff --git a/src/study_tool/platforms/gdgxk.py b/src/study_tool/platforms/gdgxk.py index 6c7e82f..ec6f2af 100644 --- a/src/study_tool/platforms/gdgxk.py +++ b/src/study_tool/platforms/gdgxk.py @@ -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):