From c4bc97dd70b0cea5f40d06dccf455dd512e7eafe Mon Sep 17 00:00:00 2001 From: study-tool Date: Fri, 30 Oct 2026 22:33:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(gdgxk):=20=E8=AF=BE=E7=A8=8B=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=98=BE=E7=A4=BA=E5=9F=B9=E8=AE=AD=E5=B9=B4=E5=BA=A6?= =?UTF-8?q?=20/=20=E5=AD=A6=E6=97=B6=20/=20=E5=AE=8C=E6=88=90=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_courses 解析 showCourse 卡片里的「培训年度」「课程学时」「未完成/已完成」, 返回 section / info / status_label,前端拼成「2026年公需课 · 未完成 · 30 学时」 - 修正完成判定:以平台状态为准(需所有教学活动 100% + 试卷考核 ≥60), 不再用视频秒数够不够来推断,避免把未完成的课当已学完跳过 --- CHANGELOG.md | 2 +- OPTIMIZATION.md | 2 +- docs/progress/todo.md | 3 +- metadata.yaml | 2 +- src/study_tool/platforms/gdgxk.py | 47 ++++++++++++++++++++++++------- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7fea6..55cd24b 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` 直接记账,不需要真播)。票按 `ITLMS-xxx||ticketId` 两段交给 `jsxx.gds.edu.cn/nts/loginToStoreByAccount`;登录要过 4 位图形验证码,用 ddddocr 离线识别并自动重试。配套 `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`(接口自测);课程列表解析出培训年度 / 学时 / 完成状态,卡片显示成「2026年公需课 · 未完成 · 30 学时」 - 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志 - 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite - 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导 diff --git a/OPTIMIZATION.md b/OPTIMIZATION.md index 0bf8c49..9a7e02d 100644 --- a/OPTIMIZATION.md +++ b/OPTIMIZATION.md @@ -180,7 +180,7 @@ GET /mod/school/wanpeng/syncSelf?courseCode= - 登录:`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 /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_/...` 上报前缀。 - 播放注册:`POST /a_/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=&playbackSessionId=&playbackTakeover=true`)→ `{time, md5}`。 - 进度心跳:`POST /a_/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。 diff --git a/docs/progress/todo.md b/docs/progress/todo.md index 650bf32..137fa17 100644 --- a/docs/progress/todo.md +++ b/docs/progress/todo.md @@ -6,4 +6,5 @@ - 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`, 并校验 `static/` 与源码同步。 - 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。 -- 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。 +- 广东公需课:**试卷考核**未接入 —— 平台判定通过要「所有教学活动 100% + 试卷考核 ≥60 分」,所以视频报满后课程仍显示「未完成」。 +- 广东公需课:视频弹题未接入。 diff --git a/metadata.yaml b/metadata.yaml index ab996e7..8959566 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,3 +1,3 @@ name: study-tool -version: 0.0.3 +version: 0.0.4 description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台 diff --git a/src/study_tool/platforms/gdgxk.py b/src/study_tool/platforms/gdgxk.py index ec6f2af..acbf5df 100644 --- a/src/study_tool/platforms/gdgxk.py +++ b/src/study_tool/platforms/gdgxk.py @@ -142,17 +142,32 @@ def profile(s): 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, seen = [], set() - for cid, name in re.findall(r"showCourse\('([^']+)','([^']*)'\)", html): - if cid in seen: - continue - seen.add(cid) - out.append({"plan_id": cid, "project_id": "", "name": name.strip(), - "in_study": True, "platform": PLATFORM_ID}) + out = _parse_courses(html) if not out: - if "loginBtn" in html or "showLogin" in html: + if "logout" not in html: raise _expired("学习平台会话已失效,请重新登录") return [] return out @@ -217,14 +232,21 @@ def discover(s, plan_id, project_id, name=""): cw["sid"] = sid sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"]) cur = _view_time(s, cw, cw["uid"], sign) - done = cw["total"] > 0 and cur >= cw["total"] + # 完成与否以平台状态为准:要「所有教学活动 100% + 试卷考核 ≥60」,视频秒数够不代表完成 + status = "" + for c in _parse_courses(s.get(X + "/uc/store/courseRegister", timeout=30).text): + if c["plan_id"] == cid: + status = c["status_label"] + break + done = status == "已完成" return { "user": profile(s), "course": name or cw["title"], - "progress": "%d/%d 秒" % (cur, cw["total"]), + "progress": "%d/%d 秒%s" % (cur, cw["total"], "(课程已完成)" if done else ""), "info": cw["video"], "courseware": [{"id": cw["id"], "title": cw["title"], "href": page, "total": cw["total"], "viewTime": cur, "done": done, + "status": status, "aid": cw["aid"], "uid": cw["uid"]}], "feedback": [], "test": [], "platform": PLATFORM_ID, "generation": "gxk", @@ -253,6 +275,11 @@ def study(key, s, d, **opts): continue runtime.log(key, "开始学习:%s(总长 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cw["viewTime"])) + if cw["total"] and cw["viewTime"] >= cw["total"]: + runtime.log(key, "视频已播完,但课程仍显示「%s」:还差试卷考核或其他教学活动" + % (cw.get("status") or "未完成"), level="warn") + runtime.set_state(key, done=i) + continue _learn(key, s, cw, speed, beat) runtime.set_state(key, done=i) runtime.set_state(key, status="done", done=total)