feat: study modes (fast/parallel) and course-container completion fix

- settings: study.mode fast|normal and study.parallel 1-5

- normal mode: one headless browser per video, visibility keep-alive, auto-degrade when the platform enforces order

- report study/log/detail/save before isComplete; drop lastProgressTime on completion

- complete periodType 10/13 container nodes and recompute counters via PUT calFinalScore

- capture mode: POST /api/capture records all XHR/fetch including new tabs

- UI: study mode in settings, capture / stop-capture menu items
This commit is contained in:
study-tool
2026-10-27 22:30:57 +08:00
commit 585a7a28dd
18 changed files with 8285 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
study.dgjy.net(莞易学 / 东莞教师培训)视频课时 —— 自动完成 + 完成度核验
机制(逆向结论)
* 播放器是网易云课堂 EduPlayer,进度上报接口:
POST /train/course/front/period/study/log
body {"periodId":4133,"isComplete":0|1,"lastProgressTime":"<秒>"}
* onTimeupdate 时若 currentTime > maxTime 才上报(isComplete=0)
* 视频播到结尾触发 onPlayEnd -> POST {"periodId":x,"isComplete":1} ← 这才是"完成"
* onSeek 守卫:仅当 n.newData !== duration 且 allowVideoDrag === "0" 且 newData > maxTime
时才会重新拉签名把进度拉回去。所以「拖到正好结尾」不会被拦。
* 上报后 10 秒自动跳到下一课时。
完成度核验(唯一可信来源)
GET /train/course/front/period/list/tree/studyLog?courseId=<MOOC课程ID>&isFront=1
每个课时节点里的 studyLog.completeTime 非空 = 平台真的记为完成。
用法
python study_video.py verify <trainCourseId> # 只核验,不动数据
python study_video.py run <trainCourseId> # 完成所有未完成的视频课时
"""
import os
import re
import sys
import time
import requests
import store
HERE = os.path.dirname(os.path.abspath(__file__))
BASE = "https://study.dgjy.net"
KV_SESSION = "study_session" # 会话存在 SQLite 的 kv 表(和网页端同一份库)
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0")
CDP = "http://127.0.0.1:9222"
def load_session():
"""从 SQLite 读上次抓到的会话;没有返回 None。"""
sess = store.kv_get(KV_SESSION)
return sess if isinstance(sess, dict) and sess.get("token") else None
# ------------------------------------------------------------------ 会话 / 认证
def capture_session():
"""从已登录的浏览器里取 Middle-Token 与 cookie。"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.connect_over_cdp(CDP)
cookies = b.contexts[0].cookies()
token = ""
for c in cookies:
if c["name"] == "Middle-Token" and "study.dgjy.net" in (c.get("domain") or ""):
token = c["value"]
if not token:
raise SystemExit("没找到 Middle-Token —— 请先在浏览器里登录 study.dgjy.net")
sess = {"token": token,
"cookies": [{"name": c["name"], "value": c["value"],
"domain": c["domain"], "path": c["path"]} for c in cookies]}
store.kv_set(KV_SESSION, sess)
return sess
def api(sess=None):
if sess is None:
sess = load_session() or capture_session()
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Authorization": "Bearer " + sess["token"],
"Referer": BASE + "/training/courseVideo"})
for c in sess["cookies"]:
if "dgjy.net" in (c.get("domain") or ""):
s.cookies.set(c["name"], c["value"] or "",
domain=(c.get("domain") or "").lstrip("."), path=c.get("path") or "/")
return s
def check_auth(s):
r = s.get(BASE + "/train/getInfo", timeout=30)
try:
return r.json().get("code") == 200
except Exception:
return False
# ------------------------------------------------------------------ 课程结构
def course_info(s, train_id):
return s.get(BASE + "/train/course/front/trainCourse/getById?id=%s" % train_id,
timeout=30).json().get("data") or {}
def period_tree(s, course_id, is_front="1"):
r = s.get(BASE + "/train/course/front/period/list/tree/studyLog?courseId=%s&isFront=%s"
% (course_id, is_front), timeout=30)
return r.json().get("data") or []
def flatten(nodes, out=None, parent=""):
out = [] if out is None else out
for n in nodes or []:
sl = n.get("studyLog") or {}
out.append({
"periodId": n.get("id"),
"name": n.get("name") or "",
"parent": parent,
"periodType": str(n.get("periodType") or ""),
"duration": n.get("duration"),
"periodScore": n.get("periodScore"),
"allowVideoDrag": n.get("allowVideoDrag"),
"completeLimit": n.get("completeLimit"),
"kcdm": n.get("kcdm"),
"completeTime": sl.get("completeTime"),
"logPeriodScore": sl.get("logPeriodScore"),
"lastProgressTime": sl.get("lastProgressTime"),
})
flatten(n.get("children"), out, n.get("name") or parent)
return out
def video_periods(s, train_id):
"""返回 (培训课程信息, MOOC课程ID, 视频课时列表)"""
info = course_info(s, train_id)
top = flatten(period_tree(s, train_id, ""))
mooc = ""
for t in top:
if t["kcdm"]:
mooc = t["kcdm"]
break
vids = []
if mooc:
vids = [p for p in flatten(period_tree(s, mooc, "1")) if p["periodType"] == "2"]
return info, mooc, vids
# ------------------------------------------------------------------ 核验
def report(info, vids):
print()
print("课程:%s" % info.get("courseName", "?"))
st = info.get("study") or {}
print("进度:%s/%s 课时 规则 completeRule=%s" %
(st.get("periodStudy"), st.get("periodTotal"), info.get("completeRule")))
print()
print("%-8s %-40s %-8s %-6s %-20s %s" %
("periodId", "课时名", "时长(s)", "拖拽", "completeTime", "判定"))
done = 0
for v in vids:
ok = bool(v["completeTime"])
done += ok
print("%-8s %-40s %-8s %-6s %-20s %s" %
(v["periodId"], v["name"][:40], v["duration"], v["allowVideoDrag"],
v["completeTime"] or "-", "✅ 已完成" if ok else "❌ 未完成"))
print()
print("视频课时:%d/%d 已完成" % (done, len(vids)))
return done, len(vids)
# ------------------------------------------------------------------ 自动完成
def complete_one(pg, train_id, train_course, period_id, timeout=180):
"""打开视频页 -> 拖到结尾 -> 播放 -> 等 isComplete=1 上报。返回 True/False。"""
url = ("%s/training/courseVideo?classhour=%d&isJoin=1&needJoin=1&courseId=%s&isFront=1"
"&trainCourseId=%s" % (BASE, period_id, train_course, train_id))
posted = {"done": False}
def on_req(r):
try:
if "study/log" in r.url and "detail" not in r.url:
body = r.post_data or ""
if '"isComplete":1' in body.replace(" ", ""):
posted["done"] = True
except Exception:
pass
pg.on("request", on_req)
pg.goto(url, wait_until="domcontentloaded", timeout=60000)
dur = None
for _ in range(40):
time.sleep(1.5)
st = pg.evaluate("""() => {
const v = Array.from(document.querySelectorAll('video')).filter(x=>x.duration>100)[0];
return v ? {dur: v.duration, ct: v.currentTime} : null;
}""")
if st and st["dur"] > 100:
dur = st["dur"]
break
if not dur:
print(" 播放器未就绪")
return False
# 拖到结尾前 4 秒(绕开 onSeek 守卫,等价于"拖到正好结尾")
pg.evaluate("""() => {
const v = Array.from(document.querySelectorAll('video')).filter(x=>x.duration>100)[0];
v.muted = true;
v.currentTime = Math.max(0, v.duration - 4);
}""")
time.sleep(1)
btn = pg.query_selector(".j-bigplaybtn") or pg.query_selector(".j-playbtn")
if btn and btn.bounding_box():
bx = btn.bounding_box()
pg.mouse.click(bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2)
t0 = time.time()
while time.time() - t0 < timeout:
time.sleep(2)
if posted["done"]:
return True
st = pg.evaluate("""() => {
const v = Array.from(document.querySelectorAll('video')).filter(x=>x.duration>100)[0];
return v ? {ct: Math.round(v.currentTime), ended: v.ended, paused: v.paused} : null;
}""")
# 防挂机弹窗「是否继续观看视频?」
try:
ok = pg.query_selector(".el-message-box__btns .el-button--primary")
if ok:
ok.click()
except Exception:
pass
# 播放结束但还没上报 -> 再补一次播放
if st and st["ended"] and not posted["done"]:
btn = pg.query_selector(".j-bigplaybtn") or pg.query_selector(".j-playbtn")
if btn and btn.bounding_box():
bx = btn.bounding_box()
pg.mouse.click(bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2)
return posted["done"]
def run(train_id, only_missing=True):
sess = load_session() or capture_session()
s = api(sess)
if not check_auth(s):
print("会话失效,重新抓取…")
sess = capture_session()
s = api(sess)
if not check_auth(s):
raise SystemExit("认证失败:请先在浏览器登录 study.dgjy.net")
info, mooc, vids = video_periods(s, train_id)
print("=== 处理前核验 ===")
report(info, vids)
todo = [v for v in vids if not v["completeTime"]]
if not todo:
print("\n所有视频课时均已完成,无需处理。")
return
print("\n待完成 %d 个课时:" % len(todo))
for v in todo:
print(" %s %s (%ss)" % (v["periodId"], v["name"][:40], v["duration"]))
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.connect_over_cdp(CDP)
ctx = b.contexts[0]
pg = ctx.new_page()
for i, v in enumerate(todo, 1):
print("\n[%d/%d] %s %s" % (i, len(todo), v["periodId"], v["name"][:40]))
try:
ok = complete_one(pg, train_id, train_id, v["periodId"])
except Exception as e:
print(" 异常:", e)
ok = False
print(" 上报 isComplete=1 :", "✅" if ok else "❌")
time.sleep(4)
try:
pg.close()
except Exception:
pass
print("\n=== 处理后核验(以平台数据为准)===")
time.sleep(3)
info2, _, vids2 = video_periods(s, train_id)
report(info2, vids2)
if __name__ == "__main__":
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
mode, tid = sys.argv[1], sys.argv[2]
if mode == "verify":
sess = load_session() or capture_session()
s = api(sess)
info, mooc, vids = video_periods(s, tid)
print("MOOC 课程ID:", mooc)
report(info, vids)
elif mode == "run":
run(tid)
else:
print(__doc__)