302 lines
11 KiB
Python
302 lines
11 KiB
Python
#!/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
|
||
|
||
# 代码在 src/study_tool 包里,命令行脚本自己把 src 加进 sys.path
|
||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
|
||
|
||
from study_tool import store # noqa: E402
|
||
|
||
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__)
|