feat(gdgxk): 课程卡片显示培训年度 / 学时 / 完成状态

- list_courses 解析 showCourse 卡片里的「培训年度」「课程学时」「未完成/已完成」,
  返回 section / info / status_label,前端拼成「2026年公需课 · 未完成 · 30 学时」
- 修正完成判定:以平台状态为准(需所有教学活动 100% + 试卷考核 ≥60),
  不再用视频秒数够不够来推断,避免把未完成的课当已学完跳过
This commit is contained in:
study-tool
2026-10-30 22:33:46 +08:00
parent 31f7020190
commit c4bc97dd70
5 changed files with 42 additions and 14 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` 直接记账,不需要真播)。票按 `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
- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导
+1 -1
View File
@@ -180,7 +180,7 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<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-<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>','<课程名>')`;同一张卡片里还有 `课程学时:N`、`培训年度:YYYY`、`未完成/已完成` 徽标,已解析成 `section` / `info` / `status_label` 显示在卡片上。
- 学习页:`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/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。
+2 -1
View File
@@ -6,4 +6,5 @@
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
并校验 `static/` 与源码同步。
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
- 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。
- 广东公需课:**试卷考核**未接入 —— 平台判定通过要「所有教学活动 100% + 试卷考核 ≥60 分」,所以视频报满后课程仍显示「未完成」。
- 广东公需课:视频弹题未接入。
+1 -1
View File
@@ -1,3 +1,3 @@
name: study-tool
version: 0.0.3
version: 0.0.4
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
+37 -10
View File
@@ -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)