Files
study-tool/study_video.py
T
study-tool 585a7a28dd 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
2026-10-27 22:30:57 +08:00

299 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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__)