diff --git a/CHANGELOG.md b/CHANGELOG.md index c6adbb6..324e4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +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`(接口自测) - 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志 - 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite - 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导 diff --git a/OPTIMIZATION.md b/OPTIMIZATION.md index 01bff25..285e751 100644 --- a/OPTIMIZATION.md +++ b/OPTIMIZATION.md @@ -171,3 +171,39 @@ GET /mod/school/wanpeng/syncSelf?courseCode= - 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。 - 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。 + +## 广东公需课平台接入(jsglpt → jsxx) + +**状态**:接口链路已实测跑通(课程列表、播放注册、进度心跳都拿到 `responseCode=00`),待用真实密码登录 + 未学完课程确认端到端。 + +### 已确认的接口 + +- 登录:`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 要一起保存。 +- 课程列表:`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}`。 +- 进度心跳:`POST /a_/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。 +- 已看秒数:`GET /a_/study/video/user/getViewTime?id=&md5=&expires=&md5Uid=`,`responseData` 直接就是秒数。 + +### 踩到的坑 + +- 三个上报接口必须带 **Referer(指向课程页)**,否则 500 且响应体为空。 +- `isLimit` 只能传 `false` / `true`,传 `"N"` 会 400。 +- 课程列表页、学习页都是 UTF-8(别按 GBK 解,会花屏)。 + +### 关键结论 + +进度不靠播放器,`lastViewTime` 直接写服务端。实测把 2640 秒的课报到 2700 秒也被接受,说明可以跳着报、不必真播;默认 `speed=60`、`beat=2`,44 分钟的课约 45 秒报完。课程是否学完看 `getViewTime >= t`。 + +### 怎么测 + +1. 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。 +2. 选一门**没学完**的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。 +3. 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 cookie,再补 OCR。 + +### 如果没生效 + +- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。 +- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。 +- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。 diff --git a/docs/progress/todo.md b/docs/progress/todo.md index 2dc7be6..7dd7a69 100644 --- a/docs/progress/todo.md +++ b/docs/progress/todo.md @@ -5,3 +5,6 @@ - 前端补一条自动化回归(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 c50c86f..943bc0a 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,3 +1,3 @@ name: study-tool -version: 0.0.1 +version: 0.0.2 description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台 diff --git a/script/capture-jsglpt.py b/script/capture-jsglpt.py new file mode 100644 index 0000000..7b991a7 --- /dev/null +++ b/script/capture-jsglpt.py @@ -0,0 +1,127 @@ +"""广东省教师继续教育平台抓包工具(一次性取证用,不参与运行)。 + +用法: + python script/capture-jsglpt.py [分钟数] + +行为:开一个有头 Chromium(持久 profile,登录态会保留),打开 jsglpt 登录页, +把你在窗口里操作期间产生的 XHR/fetch 全量落到 data/capture-jsglpt-<时间>.jsonl, +同时每 3 秒把 cookie 快照写到 data/capture-jsglpt-cookies.json。 +""" +import json +import os +import sys +import time + +from playwright.sync_api import sync_playwright + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA = os.path.join(ROOT, "data") +LOGIN = "https://jsglpt.gds.edu.cn/login.jsp" +MINUTES = float(sys.argv[1]) if len(sys.argv) > 1 else 40 + +os.makedirs(DATA, exist_ok=True) +STAMP = time.strftime("%Y%m%d-%H%M%S") +JSONL = os.path.join(DATA, "capture-jsglpt-%s.jsonl" % STAMP) +COOKIES = os.path.join(DATA, "capture-jsglpt-cookies.json") +PROFILE = os.path.join(DATA, ".gd-profile") + +out = open(JSONL, "a", encoding="utf-8") +seen = 0 + + +def emit(rec): + global seen + seen += 1 + rec["n"] = seen + out.write(json.dumps(rec, ensure_ascii=False) + "\n") + out.flush() + print(" %-4s %s %s -> %s" % (rec.get("rt", "doc"), rec.get("method", ""), + rec.get("url", "")[:150], rec.get("status", "")), flush=True) + + +def on_response(r): + try: + req = r.request + rt = req.resource_type + if rt not in ("xhr", "fetch", "document"): + return + h = {} + try: + h = dict(r.headers or {}) + except Exception: + pass + body = "" + # 只留文本类响应,视频/图片跳过 + if not (r.url or "").lower().split("?")[0].endswith((".mp4", ".m3u8", ".ts", ".png", ".jpg", ".gif", ".woff", ".woff2", ".css", ".js")): + try: + body = r.text()[:8000] + except Exception: + body = "" + emit({ + "t": time.strftime("%H:%M:%S"), + "rt": rt, + "method": req.method, + "url": r.url, + "req_headers": dict(req.headers or {}), + "req": (req.post_data or "")[:4000], + "status": r.status, + "resp_headers": h, + "resp": body, + }) + except Exception: + pass + + +def main(): + print("抓包输出:%s" % JSONL, flush=True) + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + PROFILE, headless=False, + args=["--disable-blink-features=AutomationControlled"], + viewport={"width": 1440, "height": 900}, + ) + hooked = set() + + def hook(page): + if id(page) in hooked: + return + hooked.add(id(page)) + page.on("response", on_response) + print("[页面] %s" % (page.url or "about:blank"), flush=True) + + ctx.on("page", hook) + pg = ctx.pages[0] if ctx.pages else ctx.new_page() + hook(pg) + try: + pg.goto(LOGIN, wait_until="domcontentloaded", timeout=60000) + except Exception as e: + print("打开登录页失败:%s" % e, flush=True) + + last = 0.0 + t0 = time.time() + while time.time() - t0 < MINUTES * 60: + try: + now = time.time() + if now - last > 3: + last = now + with open(COOKIES, "w", encoding="utf-8") as f: + json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1) + ctx.pages[0].wait_for_timeout(1000) + except Exception: + # 用户把所有页面关掉了,收工 + break + try: + with open(COOKIES, "w", encoding="utf-8") as f: + json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1) + except Exception: + pass + try: + ctx.close() + except Exception: + pass + out.close() + print("抓包结束:%d 条 → %s" % (seen, JSONL), flush=True) + + +if __name__ == "__main__": + main() diff --git a/script/test-gdgxk.py b/script/test-gdgxk.py new file mode 100644 index 0000000..5e3356f --- /dev/null +++ b/script/test-gdgxk.py @@ -0,0 +1,41 @@ +"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。 + +用法:PYTHONPATH=src python script/test-gdgxk.py [课件id] +""" +import json +import sys +import uuid + +from study_tool.platforms import gdgxk + +cid = sys.argv[1] if len(sys.argv) > 1 else "" +cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8")) +s = gdgxk.new_session(cookies) + +courses = gdgxk.list_courses(s) +print("课程数:", len(courses)) +for c in courses: + print(" ", c["plan_id"], c["name"]) + +if not cid and courses: + cid = courses[0]["plan_id"] + +d = gdgxk.discover(s, cid, "") +print("\n课程:", d["course"]) +print("进度:", d["progress"]) +print("视频:", d["info"]) +cw = d["courseware"][0] +print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"]) + +sid = uuid.uuid4().hex +sign = gdgxk._sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"]) +print("\n播放注册签名:", sign) +print("服务端已看秒数:", gdgxk._view_time(s, cw, cw["uid"], sign)) + +r = gdgxk._xhr(s, "POST", + gdgxk.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1", + data={"_method": "PUT", "aid": cw["aid"], "isLimit": "false", "md5": sign["md5"], + "expires": sign["time"], "md5Uid": cw["uid"], + "lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])), + "playbackSessionId": sid}) +print("\n心跳响应:", r.status_code, r.text[:300]) diff --git a/src/study_tool/platforms/__init__.py b/src/study_tool/platforms/__init__.py index 751ed3e..5454420 100644 --- a/src/study_tool/platforms/__init__.py +++ b/src/study_tool/platforms/__init__.py @@ -12,4 +12,5 @@ from study_tool.platforms.base import Platform # noqa: F401 def load_builtin(): """导入内置平台,触发注册。幂等,app 启动时调用一次。""" + from study_tool.platforms import gdgxk # noqa: F401 from study_tool.platforms import guanyixue # noqa: F401 diff --git a/src/study_tool/platforms/gdgxk.py b/src/study_tool/platforms/gdgxk.py new file mode 100644 index 0000000..6c7e82f --- /dev/null +++ b/src/study_tool/platforms/gdgxk.py @@ -0,0 +1,271 @@ +"""广东公需课:jsglpt 登录 → jsxx 学习。 + +登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用 +`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话; +两个域名的 cookie 都留在同一个 requests.Session 里。 + +学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度不靠播放器,靠 +`updateViewTime1` 心跳上报(60 秒一次,`lastViewTime` 是已看秒数), +`viewTime` 追平视频总长就算学完,所以这里只跑心跳、不真播。 +""" +import base64 +import json +import re +import time +import uuid + +import requests + +from study_tool import runtime +from study_tool.platforms import registry +from study_tool.platforms.base import Platform + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36") +G = "https://jsglpt.gds.edu.cn" +X = "https://jsxx.gds.edu.cn" +PLATFORM_ID = "gdgxk" + + + +def _xhr(s, method, url, **kw): + """这些上报接口必须带 Referer(指向课程页),否则直接 500。""" + kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest" + return s.request(method, url, timeout=30, **kw) + + +def _expired(msg): + """引擎认的是同一个 SessionExpired,这里延迟取避免循环 import。""" + from study_tool.engine import SessionExpired + return SessionExpired(msg) + + +def _b64(text): + return base64.b64encode(str(text).encode("utf-8")).decode() + + +def new_session(cookies=None): + s = requests.Session() + s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"}) + for c in (cookies or []): + if "gds.edu.cn" not in (c.get("domain") or ""): + continue + s.cookies.set(c["name"], c.get("value") or "", + domain=(c.get("domain") or "").lstrip("."), + path=c.get("path") or "/") + return s + + +def dump_cookies(s): + return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path} + for c in s.cookies] + + +def _sso(s): + """jsglpt 会话换 ITLMS 票,再把票交给 jsxx 换学习会话。""" + r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30) + ticket = (r.text or "").strip() + if not ticket.startswith("ITLMS-"): + 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("带票进学习平台失败,可能票已失效") + return s + + +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 + + +def renew_session(s): + try: + _sso(s) + return True + except Exception: + return False + + +def profile(s): + html = s.get(G + "/teacher/index.do", timeout=30).text + m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html)) + if m: + return m.group(1) + m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html) + return m.group(1) if m else "" + + +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}) + if not out: + if "loginBtn" in html or "showLogin" in html: + raise _expired("学习平台会话已失效,请重新登录") + return [] + return out + + +def parse_course(html): + """从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、总长、已看秒数。""" + d = {} + m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html) + if not m: + raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了") + d["aid"] = "a_" + m.group(1) + m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \ + or re.search(r"id=([0-9a-f]{32})&md5Uid=", html) + if not m: + raise RuntimeError("学习页里没有找到课件 id") + d["id"] = m.group(1) + m = re.search(r"md5Uid=([0-9a-f]{64,128})", html) + if not m: + raise RuntimeError("学习页里没有找到 md5Uid") + d["uid"] = m.group(1) + m = re.search(r"\bt\s*=\s*'(\d+)'", html) + d["total"] = int(m.group(1)) if m else 0 + m = re.search(r"url1\s*=\s*'([^']+)'", html) + d["video"] = m.group(1) if m else "" + d["title"] = (re.search(r"([^<]*)", html).group(1).strip() + if "" in html else "") + return d + + +def _sign(s, cw, uid): + """updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。""" + r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1", + data={"_method": "PUT", "id": cw["id"], "md5Uid": uid, + "playbackSessionId": cw["sid"], "playbackTakeover": "true"}) + j = r.json() if r.text.strip().startswith("{") else {} + if j.get("responseCode") != "00": + raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应")) + return j["responseData"] + + +def _view_time(s, cw, uid, sign): + r = _xhr(s, "GET", X + "/" + cw["aid"] + "/study/video/user/getViewTime", + params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"), + "md5Uid": uid}) + j = r.json() if r.text.strip().startswith("{") else {} + rd = j.get("responseData") + if isinstance(rd, dict): + return int(rd.get("viewTime") or 0) + return int(rd or 0) + + +def discover(s, plan_id, project_id, name=""): + cid = plan_id + page = X + "/study/course/" + cid + s.headers["Referer"] = page + r = s.get(page + "?v=1", timeout=30) + if "loginBtn" in r.text and "md5Uid" not in r.text: + raise _expired("学习平台会话已失效,请重新登录") + cw = parse_course(r.text) + sid = uuid.uuid4().hex + 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"] + return { + "user": profile(s), + "course": name or cw["title"], + "progress": "%d/%d 秒" % (cur, cw["total"]), + "info": cw["video"], + "courseware": [{"id": cw["id"], "title": cw["title"], "href": page, + "total": cw["total"], "viewTime": cur, "done": done, + "aid": cw["aid"], "uid": cw["uid"]}], + "feedback": [], "test": [], + "platform": PLATFORM_ID, "generation": "gxk", + "plan_id": cid, "project_id": "", + } + + +def _stopped(key): + with runtime.RUNS_LOCK: + return (runtime.RUNS.get(key) or {}).get("status") == "stopped" + + +def study(key, s, d, **opts): + """只跑心跳上报:每 beat 秒把 lastViewTime 往前推 beat*speed 秒。""" + speed = float(opts.get("speed") or 60) + beat = int(opts.get("beat") or 2) + total = len(d["courseware"]) + runtime.set_state(key, status="studying", total=total, done=0) + for i, cw in enumerate(d["courseware"], 1): + if _stopped(key): + runtime.log(key, "已按要求停止") + return + if cw.get("done"): + runtime.log(key, "课件 %s 已学完,跳过" % cw["title"]) + runtime.set_state(key, done=i) + continue + runtime.log(key, "开始学习:%s(总长 %d 秒,已看 %d 秒)" + % (cw["title"], cw["total"], cw["viewTime"])) + _learn(key, s, cw, speed, beat) + runtime.set_state(key, done=i) + runtime.set_state(key, status="done", done=total) + runtime.log(key, "全部课件心跳上报完成") + + +def _learn(key, s, cw, speed, beat): + uid, total = cw["uid"], cw["total"] + s.headers["Referer"] = cw.get("href") or X + sid = uuid.uuid4().hex + base = {"_method": "PUT", "aid": cw["aid"]} + sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid) + cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign) + while cur < total: + if _stopped(key): + runtime.log(key, "已按要求停止") + return + time.sleep(beat) + cur = min(total, cur + int(beat * speed)) + data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"), + md5Uid=uid, lastViewTime=str(cur), playbackSessionId=sid) + r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1", + data=data) + j = r.json() if r.text.strip().startswith("{") else {} + rd = j.get("responseData") or {} + if j.get("responseCode") == "00" and rd.get("md5"): + sign = {"md5": rd["md5"], "time": rd.get("time")} + server = int(rd.get("viewTime") or cur) + else: + server = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign) + runtime.log(key, "进度上报:本地 %d 秒 / 服务端 %d 秒 / 总长 %d 秒" % (cur, server, total)) + if server < cur: + # 服务端没认这个进度,按它的值继续,别把差距越拉越大 + cur = server + if cur >= total: + break + runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total)) + + +PLATFORM = registry.register(Platform( + id=PLATFORM_ID, + label="广东公需课", + study=study, + entries=("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"), + session=new_session, + dump_cookies=dump_cookies, + login=login, + list_courses=list_courses, + discover=discover, + renew_session=renew_session, + profile=profile, +)) diff --git a/todo.md b/todo.md index 6826bb5..07c1eae 100644 --- a/todo.md +++ b/todo.md @@ -2,7 +2,7 @@ ## 待评估:接入广东公需课平台(jsxx.gds.edu.cn) -**状态**:只沉淀思路,不实现。手上没有公需课账号,接口抓不到,代码不动。 +**状态**:已接入,见 `src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。 ### 背景