fix: 首次进入课程自动同步加入(syncSelf)
首次进入课程时课时树回 code=500「你还没有加入学习…」,前端把它渲染成挡路弹层, 人工点掉后 SPA 自己补发 syncSelf 才落库 —— 所以第二次进同一门课就正常。 - discover_study 返回值补 course_code(trainCourse/getById.courseCode) - 新增 study_mod_get:/mod/* 只带 cookie + Referer,不带 Bearer - 新增 study_sync_self:GET /mod/school/wanpeng/syncSelf?courseCode=… - 新增 study_sync_identity:GET /mod/gjt/roleList 补一刀身份 - ensure_study_join 改为:已加入 → student/join → syncSelf + 轮询 getById → 刷新 jspx 页 - todo.md 收尾,新增 OPTIMIZATION.md 记录待实测确认项
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# OPTIMIZATION
|
||||||
|
|
||||||
|
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
|
||||||
|
|
||||||
|
## 首次进入课程自动加入(syncSelf)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
首次进入没加入过的课程时,平台课时树会回 `code=500`「你还没有加入学习…」,前端把这句话
|
||||||
|
渲染成挡路的弹层,不点就进不去。真正把账号同步进课程的是页面自己发的这一发:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /mod/school/wanpeng/syncSelf?courseCode=<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` 之外的接口。
|
||||||
@@ -1139,6 +1139,8 @@ def discover_study(client, train_id, name="", user=""):
|
|||||||
# 未加入培训时 study/log 会被静默丢弃,调用方要先补 join
|
# 未加入培训时 study/log 会被静默丢弃,调用方要先补 join
|
||||||
"is_join": str(info.get("isJoin")) == "1",
|
"is_join": str(info.get("isJoin")) == "1",
|
||||||
"need_join": str(info.get("needJoin")) == "1",
|
"need_join": str(info.get("needJoin")) == "1",
|
||||||
|
# 浏览器「刷新课程页就能加入」靠的就是它:/mod/school/wanpeng/syncSelf?courseCode=<courseCode>
|
||||||
|
"course_code": str(info.get("courseCode") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1223,12 +1225,60 @@ def study_refresh_page(s, d):
|
|||||||
return ""
|
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 <Middle-Token>。
|
||||||
|
"""
|
||||||
|
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=<code> (耗时约 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):
|
def ensure_study_join(s, client, d):
|
||||||
"""确保这门培训已加入;返回 (ok, 说明)。
|
"""确保这门培训已加入;返回 (ok, 说明)。
|
||||||
|
|
||||||
顺序:先看当前状态 → 直接调 join → 被拒就刷新一次课程页再复查。
|
顺序(照浏览器课程页的真实行为):
|
||||||
|
当前状态 → student/join → 被拒就 syncSelf(courseCode) 轮询复查
|
||||||
|
→ 再不行就刷新一次 jspx 课程页复查。
|
||||||
"""
|
"""
|
||||||
tid = d.get("train_id") or ""
|
tid = d.get("train_id") or ""
|
||||||
|
code = d.get("course_code") or ""
|
||||||
|
|
||||||
def joined():
|
def joined():
|
||||||
j = client.call("GET", "/course/front/trainCourse/getById", params={"id": tid})
|
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():
|
if d.get("is_join") or joined():
|
||||||
return True, "已加入"
|
return True, "已加入"
|
||||||
|
|
||||||
ok, msg = study_join_course(client, tid)
|
ok, msg = study_join_course(client, tid)
|
||||||
if ok:
|
if ok:
|
||||||
return True, "加入成功"
|
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)
|
study_refresh_page(s, d)
|
||||||
if joined():
|
try:
|
||||||
return True, "刷新课程页后已加入(join 接口拒了:%s)" % (msg or "-")
|
if joined():
|
||||||
return False, msg or "未加入"
|
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):
|
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,
|
课程 needJoin=1 且 isJoin=0 时必须先加入,否则 study/log 照样回 200,
|
||||||
但学习记录不会计数 —— 现象就是「每个课时都报成功、复查全是未完成」。
|
但学习记录不会计数 —— 现象就是「每个课时都报成功、复查全是未完成」。
|
||||||
isFreeJoin=0 的课程服务端会直接拒:「该课程无法直接加入,请联系管理员」。
|
isFreeJoin=0 的课程服务端会直接拒:「该课程无法直接加入,请联系管理员」。
|
||||||
|
慕课课(isFreeJoin=0)别指望这个接口,要改用 study_sync_self —— 见 ensure_study_join。
|
||||||
返回 (ok, msg)。
|
返回 (ok, msg)。
|
||||||
"""
|
"""
|
||||||
j = client.call("POST", "/course/front/student/join", params={"courseId": train_id})
|
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):
|
def run_log(key, msg):
|
||||||
line = "%s %s" % (time.strftime("%Y-%m-%d %H:%M:%S"), 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:
|
with RUNS_LOCK:
|
||||||
st = RUNS.get(key)
|
st = RUNS.get(key)
|
||||||
if st is not None:
|
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
|
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):
|
def _probe_self(port):
|
||||||
"""端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。"""
|
"""端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。"""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -3276,6 +3368,8 @@ def main():
|
|||||||
# 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0
|
# 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0
|
||||||
host = os.environ.get("HOST", "127.0.0.1")
|
host = os.environ.get("HOST", "127.0.0.1")
|
||||||
url = "http://127.0.0.1:%d" % port
|
url = "http://127.0.0.1:%d" % port
|
||||||
|
# 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌
|
||||||
|
suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else ""
|
||||||
|
|
||||||
store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
|
store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
|
||||||
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
|
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
|
||||||
@@ -3284,7 +3378,7 @@ def main():
|
|||||||
if _port_open(port):
|
if _port_open(port):
|
||||||
info = _probe_self(port)
|
info = _probe_self(port)
|
||||||
if info and info.get("app") == APP_ID:
|
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"))
|
print("直接打开上面的地址;要重启请先结束 PID %s。" % info.get("pid"))
|
||||||
return
|
return
|
||||||
print("[错误] 端口 %d 被其它程序占用(不是本程序)。" % port)
|
print("[错误] 端口 %d 被其它程序占用(不是本程序)。" % port)
|
||||||
@@ -3292,8 +3386,13 @@ def main():
|
|||||||
print(" 换端口: set PORT=8849 && python app.py")
|
print(" 换端口: set PORT=8849 && python app.py")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("多账号学习控制台 -> %s" % url)
|
print("多账号学习控制台 -> %s%s" % (url, suffix))
|
||||||
print("IPv6 localhost 同步监听 -> http://[::1]:%d" % port)
|
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,
|
srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port,
|
||||||
log_level="warning", access_log=False))
|
log_level="warning", access_log=False))
|
||||||
|
|||||||
@@ -1,79 +1,5 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## 待修复:首次进入课程会弹提示挡住,必须手动点一下才能进课程
|
暂无待办。
|
||||||
|
|
||||||
**状态**:暂停(2026-10-27 18:27)。现象确认存在,但还没抓到具体弹窗,修复方案未定。
|
(「首次进入课程弹『未加入课堂』」已修复,实测确认见 `OPTIMIZATION.md`。)
|
||||||
|
|
||||||
### 现象
|
|
||||||
|
|
||||||
- 一个课程/课时**第一次**进入时,页面会先出提示(弹层/遮罩/确认框),不点它进不去。
|
|
||||||
- 手动点一下之后就能正常进入、正常播放;再进同一门课就不再出现。
|
|
||||||
- 因为卡在这一步,自动学习流程跑不下去,只能人工点。
|
|
||||||
|
|
||||||
### 需要先确认(下一步的起点)
|
|
||||||
|
|
||||||
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=<trainCourseId>&isFront=1`
|
|
||||||
- 课程页点「继续学习」后跳到课时列表页:
|
|
||||||
`https://study.dgjy.net/training/curriculum?id=<moocCourseId>&isFront=1&courseId=<trainCourseId>`
|
|
||||||
视频课时在 `period/list/tree/studyLog` 里,`periodType=2`,`file.fileType=mooc_video`。
|
|
||||||
- 播放页拼法见 app.py:1675:
|
|
||||||
`https://study.dgjy.net/training/courseVideo?classhour=<periodId>&isJoin=1&needJoin=1&courseId=<trainCourseId>&isFront=1&trainCourseId=<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 |
|
|
||||||
|
|||||||
Reference in New Issue
Block a user