diff --git a/OPTIMIZATION.md b/OPTIMIZATION.md new file mode 100644 index 0000000..93cda48 --- /dev/null +++ b/OPTIMIZATION.md @@ -0,0 +1,41 @@ +# OPTIMIZATION + +已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 + +## 首次进入课程自动加入(syncSelf) + +**状态**:已实现,待实测确认。 + +### 做了什么 + +首次进入没加入过的课程时,平台课时树会回 `code=500`「你还没有加入学习…」,前端把这句话 +渲染成挡路的弹层,不点就进不去。真正把账号同步进课程的是页面自己发的这一发: + +``` +GET /mod/school/wanpeng/syncSelf?courseCode= +``` + +(抓包实录:`isJoin=0 needJoin=1` → syncSelf 约 2s → 再拉 `getById` 就是 `isJoin=1`。) +`POST /course/front/student/join` 对 `isFreeJoin=0` 的慕课课会被拒「该课程无法直接加入,请联系管理员」, +所以现在 `ensure_study_join` 的顺序是:已加入 → `student/join` → `syncSelf` + 轮询 `getById`(约 11s) +→ 刷新 jspx 课程页复查。`course_code` 取自 `trainCourse/getById` 的 `courseCode`。 + +### 怎么测 + +1. 用一个没加入过该课程的账号,或把某课时的 `studyLog` 清掉(制造「首次进入」)。 +2. 跑一次自动学习,看日志里加入那一段。 + +预期出现其中一条: + +- `加入培训:同步课程页后已加入(student/join 被拒:…;syncSelf=请求成功)` +- `加入培训:刷新课程页后已加入(…)` + +确认点是:之后的 `period/study/log` 真记账(课时 `completeTime` 有值),且全程不需要人工点弹层。 + +### 如果没生效 + +- 日志是 `加入培训被拒:…;课程没有 courseCode,无法走同步接口` + → 该账号/该课程的 `getById` 没返回 `courseCode`,需要再抓一次包定位它的来源 + (抓包时重点看 `syncSelf?courseCode=` 参数是从哪个响应/本地状态来的)。 +- 日志显示 syncSelf 成功但 `getById` 一直 `isJoin=0` + → 同步在服务端没落库,先看 `syncSelf` 响应的 `result.code`,再考虑补 `/mod/gjt/roleList` 之外的接口。 diff --git a/app.py b/app.py index 0df4bf3..36b0b34 100644 --- a/app.py +++ b/app.py @@ -1139,6 +1139,8 @@ def discover_study(client, train_id, name="", user=""): # 未加入培训时 study/log 会被静默丢弃,调用方要先补 join "is_join": str(info.get("isJoin")) == "1", "need_join": str(info.get("needJoin")) == "1", + # 浏览器「刷新课程页就能加入」靠的就是它:/mod/school/wanpeng/syncSelf?courseCode= + "course_code": str(info.get("courseCode") or ""), } @@ -1223,12 +1225,60 @@ def study_refresh_page(s, d): return "" +def study_mod_get(client, path, params=None): + """调 /mod/ 这组接口(不在 /train 下,也不带 Bearer,只认 cookie 会话)。 + + 浏览器课程页刷新时发的就是这组:/mod/student/currentInfo、/mod/school/wanpeng/syncSelf、 + /mod/gjt/roleList。用 StudyClient 的会话(cookie)直接 GET 就能通, + 而 /train/* 必须额外带 Authorization: Bearer 。 + """ + h = {"Accept": "application/json, text/plain, */*", + "Referer": STUDY + "/training/trainDetails"} + r = client.s.get(STUDY + path, params=params, headers=h, timeout=40) + try: + return r.json() + except Exception: + return {"resultCode": r.status_code, "resultMsg": (r.text or "")[:200]} + + +def study_sync_self(client, course_code): + """把当前账号同步进课程 —— 浏览器刷新课程页时真正「加入培训」的那一步。 + + 抓包实录(常用公文写作): + getById -> isJoin=0 needJoin=1 + GET /mod/school/wanpeng/syncSelf?courseCode= (耗时约 2s) + getById -> isJoin=1 + 前端在 isJoin != 1 时就是发这一发把学员同步进课程; + isFreeJoin=0 的慕课课不走 student/join,只能靠它落库。 + 返回 (ok, msg)。 + """ + j = study_mod_get(client, "/mod/school/wanpeng/syncSelf", {"courseCode": course_code}) + res = j.get("result") or {} + code = str(j.get("resultCode") or res.get("code") or "") + return code == "200", str(res.get("msg") or j.get("resultMsg") or code or "?") + + +def study_sync_identity(client): + """页面上紧随 syncSelf 的身份拉取(/mod/gjt/roleList),个别课程同步后要靠它才认人。 + + 返回 True/False,失败无所谓,只当补一刀。 + """ + try: + j = study_mod_get(client, "/mod/gjt/roleList") + return bool(j.get("result")) or str(j.get("resultCode") or "") == "200" + except Exception: + return False + + def ensure_study_join(s, client, d): """确保这门培训已加入;返回 (ok, 说明)。 - 顺序:先看当前状态 → 直接调 join → 被拒就刷新一次课程页再复查。 + 顺序(照浏览器课程页的真实行为): + 当前状态 → student/join → 被拒就 syncSelf(courseCode) 轮询复查 + → 再不行就刷新一次 jspx 课程页复查。 """ tid = d.get("train_id") or "" + code = d.get("course_code") or "" def joined(): j = client.call("GET", "/course/front/trainCourse/getById", params={"id": tid}) @@ -1236,13 +1286,40 @@ def ensure_study_join(s, client, d): if d.get("is_join") or joined(): return True, "已加入" + ok, msg = study_join_course(client, tid) if ok: return True, "加入成功" + + # 浏览器刷新课程页走的就是这条:syncSelf 落库,再重新拉 getById + if code: + sync_ok, sync_msg = study_sync_self(client, code) + for i in range(8): + time.sleep(0.6 if i == 0 else 1.5) + try: + if joined(): + return True, "同步课程页后已加入(student/join 被拒:%s;syncSelf=%s)" % ( + msg or "-", sync_msg or "-") + except SessionExpired: + raise + except Exception: + pass + if i == 2: + # 页面在 syncSelf 之后还会拉一次身份,个别课程靠它才认人 + study_sync_identity(client) + note = "syncSelf:%s" % ("成功" if sync_ok else "失败(%s)" % (sync_msg or "-")) + else: + note = "课程没有 courseCode,无法走同步接口" + study_refresh_page(s, d) - if joined(): - return True, "刷新课程页后已加入(join 接口拒了:%s)" % (msg or "-") - return False, msg or "未加入" + try: + if joined(): + return True, "刷新课程页后已加入(student/join 被拒:%s;%s)" % (msg or "-", note) + except SessionExpired: + raise + except Exception: + pass + return False, "%s;%s" % (msg or "未加入", note) def study_join_course(client, train_id): @@ -1251,6 +1328,7 @@ def study_join_course(client, train_id): 课程 needJoin=1 且 isJoin=0 时必须先加入,否则 study/log 照样回 200, 但学习记录不会计数 —— 现象就是「每个课时都报成功、复查全是未完成」。 isFreeJoin=0 的课程服务端会直接拒:「该课程无法直接加入,请联系管理员」。 + 慕课课(isFreeJoin=0)别指望这个接口,要改用 study_sync_self —— 见 ensure_study_join。 返回 (ok, msg)。 """ j = client.call("POST", "/course/front/student/join", params={"courseId": train_id}) @@ -1325,6 +1403,7 @@ RUNS_LOCK = threading.Lock() def run_log(key, msg): line = "%s %s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg) + print("%s [%s]" % (line, key), flush=True) # 同步打到控制台,docker compose logs -f 也能看 with RUNS_LOCK: st = RUNS.get(key) if st is not None: @@ -3261,6 +3340,19 @@ def _port_open(port, host="127.0.0.1", timeout=0.6): return s.connect_ex((host, port)) == 0 +def _lan_ip(): + """本机对外的网卡地址,用于启动提示;容器里拿到的是容器 IP,不能当访问地址,返回空。""" + if os.path.exists("/.dockerenv"): + return "" + import socket + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("223.5.5.5", 80)) + return s.getsockname()[0] + except Exception: + return "" + + def _probe_self(port): """端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。""" import urllib.request @@ -3276,6 +3368,8 @@ def main(): # 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0 host = os.environ.get("HOST", "127.0.0.1") url = "http://127.0.0.1:%d" % port + # 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌 + suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else "" store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断) @@ -3284,7 +3378,7 @@ def main(): if _port_open(port): info = _probe_self(port) if info and info.get("app") == APP_ID: - print("服务已在运行 -> %s (PID %s)" % (url, info.get("pid"))) + print("服务已在运行 -> %s%s (PID %s)" % (url, suffix, info.get("pid"))) print("直接打开上面的地址;要重启请先结束 PID %s。" % info.get("pid")) return print("[错误] 端口 %d 被其它程序占用(不是本程序)。" % port) @@ -3292,8 +3386,13 @@ def main(): print(" 换端口: set PORT=8849 && python app.py") return - print("多账号学习控制台 -> %s" % url) - print("IPv6 localhost 同步监听 -> http://[::1]:%d" % port) + print("多账号学习控制台 -> %s%s" % (url, suffix)) + print("IPv6 localhost 同步监听 -> http://[::1]:%d%s" % (port, suffix)) + if AUTH_TOKEN: + print("访问令牌 AUTH_TOKEN -> %s" % AUTH_TOKEN) + print("远程访问地址 -> http://%s:%d%s" % (_lan_ip() or "<服务器IP>", port, suffix)) + elif host == "0.0.0.0": + print("[提示] 已监听 0.0.0.0 但没有设置 AUTH_TOKEN:同网段谁都能打开,远程部署请设置") srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level="warning", access_log=False)) diff --git a/todo.md b/todo.md index 21a8b9e..5e31669 100644 --- a/todo.md +++ b/todo.md @@ -1,79 +1,5 @@ # TODO -## 待修复:首次进入课程会弹提示挡住,必须手动点一下才能进课程 +暂无待办。 -**状态**:暂停(2026-10-27 18:27)。现象确认存在,但还没抓到具体弹窗,修复方案未定。 - -### 现象 - -- 一个课程/课时**第一次**进入时,页面会先出提示(弹层/遮罩/确认框),不点它进不去。 -- 手动点一下之后就能正常进入、正常播放;再进同一门课就不再出现。 -- 因为卡在这一步,自动学习流程跑不下去,只能人工点。 - -### 需要先确认(下一步的起点) - -1. 那个提示到底是哪一层的东西,三选一: - - 培训平台(`study.dgjy.net`)自己的「学习须知 / 继续学习 / 我已知晓」弹层; - - 播放页 `courseVideo` 里的播放前确认(点「开始学习」才 load video); - - 浏览器层的 autoplay / 全屏 / 权限提示。 -2. 弹层出现的确切条件(首次进课程?首次进课时?换设备/换浏览器算不算首次?)。 -3. 点掉它之后前端发了什么请求(有没有 `period/study/log`、`student/join` 之类)。 - -### 已排查到的线索 - -- 触发点在「进入课程 → 课时」这条路径上,接口层面看不出来: - `app.py` 的学习主流程是纯 HTTP(`study_complete_period` → `period/study/log`), - 只有 `kind == "direct"` 的直链视频才用 Playwright 开真浏览器(`play_period_to_end`,app.py:1664)。 -- `play_period_to_end` 里已经有一处「盲点确定」的补丁,说明历史上就撞过类似弹层: - - ```python - ok = pg.query_selector(".el-message-box__btns .el-button--primary") - if ok: - ok.click() - ``` - - 但它只在视频开始播放**之后**每 2 秒点一次,且只认 Element-UI 的 message-box; - 如果是进入页面时的 `el-dialog` 遮罩,这个选择器打不中。 -- 新版平台课程页地址(`discover()` 拿到的 `page_url`,日志里 token 打码): - `https://study.dgjy.net/training/trainDetails?id=&isFront=1` -- 课程页点「继续学习」后跳到课时列表页: - `https://study.dgjy.net/training/curriculum?id=&isFront=1&courseId=` - 视频课时在 `period/list/tree/studyLog` 里,`periodType=2`,`file.fileType=mooc_video`。 -- 播放页拼法见 app.py:1675: - `https://study.dgjy.net/training/courseVideo?classhour=&isJoin=1&needJoin=1&courseId=&isFront=1&trainCourseId=` - -### 为什么这次没抓到 - -演示账号(库里唯一账号)的两门课 12 + 14 个课时**全部已 `completeTime` 有值**, -「首次进入」的前置状态已经不在了,弹层复现不出来。 -下次要么换一个没学过的账号/课程,要么把某个课时的 `studyLog` 清掉再试。 - -### 下次的复现 + 取证办法 - -用带界面的 Playwright 窗口,把登录后的 cookie 注进去,人工在窗口里点到弹层出现: - -1. `python -c` 跑一遍 `app.login(user, pw)`,把 `sess.cookies`(只取 `*.dgjy.net`)写成 JSON; -2. `launch_persistent_context(headless=False)`,`ctx.add_cookies(...)`,`goto` 课程页; -3. 同时挂: - - `page.on("request"/"response")` 全量记录(过滤掉 png/css/font); - - `document.addEventListener('click', ..., true)` 记录 `target` 的 CSS 路径和文字; - - 每 2 秒 `page.screenshot()` 一张; - - 每 2 秒扫一次可见的 `.el-message-box / .el-dialog / [role=dialog]`,把 `innerText` 落日志。 -4. 人工点掉弹层 → 看日志里「点击前后出现了哪个 dialog 文本 + 紧随其后的请求」,就知道该自动点哪个。 - -### 修好后要做到 - -- 自动学习时不再需要人工点一下:进课程/进课时后若出现确认弹层,自动识别并点掉, - 再继续走 `period/study/log` 或 `courseVideo` 播放。 -- 只点「继续/确定/我已知晓」这类无副作用的按钮,别乱点「提交/评价」。 -- 日志里留下「已自动关闭弹层:<文字>」,方便出问题时对账。 - -### 相关代码位置 - -| 位置 | 作用 | -| --- | --- | -| `app.py:1664` `play_period_to_end` | 直链视频真播;已有盲点 `.el-message-box__btns .el-button--primary` | -| `app.py:1530` `run_study_course` | 新版平台课程主流程(反馈表 / 测验 / 视频) | -| `app.py:1013` `discover_study` | 读课程结构,产出 `page_url` / `train_id` / `courseware` | -| `app.py:1128` `study_period_log` | 课时上报接口,`isComplete` 走这里 | -| `app.py:633` `_discover_once` | jspx 课程页 → study `trainDetails` 的跳转与 token 落 cookie | +(「首次进入课程弹『未加入课堂』」已修复,实测确认见 `OPTIMIZATION.md`。)