feat: 接入广东公需课平台(jsglpt 登录 + jsxx 进度心跳)
- 新增 platforms/gdgxk.py:base64 登录 → ssoItlmsGxkTicket 换票进 jsxx → /uc/store/courseRegister 取课程 → updateViewTime1 心跳上报进度(无需真播) - platforms/__init__.py 注册;配套抓包脚本与自测脚本 - CHANGELOG / OPTIMIZATION / todo 同步,版本升到 0.0.2
This commit is contained in:
@@ -12,4 +12,5 @@ from study_tool.platforms.base import Platform # noqa: F401
|
||||
|
||||
def load_builtin():
|
||||
"""导入内置平台,触发注册。幂等,app 启动时调用一次。"""
|
||||
from study_tool.platforms import gdgxk # noqa: F401
|
||||
from study_tool.platforms import guanyixue # noqa: F401
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""广东公需课:jsglpt 登录 → jsxx 学习。
|
||||
|
||||
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
|
||||
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
|
||||
两个域名的 cookie 都留在同一个 requests.Session 里。
|
||||
|
||||
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度不靠播放器,靠
|
||||
`updateViewTime1` 心跳上报(60 秒一次,`lastViewTime` 是已看秒数),
|
||||
`viewTime` 追平视频总长就算学完,所以这里只跑心跳、不真播。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
|
||||
from study_tool import runtime
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.base import Platform
|
||||
|
||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
|
||||
G = "https://jsglpt.gds.edu.cn"
|
||||
X = "https://jsxx.gds.edu.cn"
|
||||
PLATFORM_ID = "gdgxk"
|
||||
|
||||
|
||||
|
||||
def _xhr(s, method, url, **kw):
|
||||
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
|
||||
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
|
||||
return s.request(method, url, timeout=30, **kw)
|
||||
|
||||
|
||||
def _expired(msg):
|
||||
"""引擎认的是同一个 SessionExpired,这里延迟取避免循环 import。"""
|
||||
from study_tool.engine import SessionExpired
|
||||
return SessionExpired(msg)
|
||||
|
||||
|
||||
def _b64(text):
|
||||
return base64.b64encode(str(text).encode("utf-8")).decode()
|
||||
|
||||
|
||||
def new_session(cookies=None):
|
||||
s = requests.Session()
|
||||
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
|
||||
for c in (cookies or []):
|
||||
if "gds.edu.cn" not in (c.get("domain") or ""):
|
||||
continue
|
||||
s.cookies.set(c["name"], c.get("value") or "",
|
||||
domain=(c.get("domain") or "").lstrip("."),
|
||||
path=c.get("path") or "/")
|
||||
return s
|
||||
|
||||
|
||||
def dump_cookies(s):
|
||||
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
|
||||
for c in s.cookies]
|
||||
|
||||
|
||||
def _sso(s):
|
||||
"""jsglpt 会话换 ITLMS 票,再把票交给 jsxx 换学习会话。"""
|
||||
r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
|
||||
ticket = (r.text or "").strip()
|
||||
if not ticket.startswith("ITLMS-"):
|
||||
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
|
||||
q = s.get(X + "/uc/store/courseRegister?ticket=" + requests.utils.quote(ticket), timeout=30)
|
||||
if "showCourse" not in q.text and "我的课程" not in q.text:
|
||||
raise _expired("带票进学习平台失败,可能票已失效")
|
||||
return s
|
||||
|
||||
|
||||
def login(username, password):
|
||||
s = new_session()
|
||||
s.get(G + "/login.jsp", timeout=30)
|
||||
data = {
|
||||
"loginUser.userName": _b64(username),
|
||||
"loginUser.loginType": "normal",
|
||||
"loginUser.password": _b64(password),
|
||||
"captcha": "",
|
||||
}
|
||||
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
|
||||
if r.status_code != 302 or "/default.jsp" not in (r.headers.get("location") or ""):
|
||||
raise RuntimeError("登录失败:账号或密码不对,或平台要求图形验证码(HTTP %s)" % r.status_code)
|
||||
_sso(s)
|
||||
return s, profile(s) or username
|
||||
|
||||
|
||||
def renew_session(s):
|
||||
try:
|
||||
_sso(s)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def profile(s):
|
||||
html = s.get(G + "/teacher/index.do", timeout=30).text
|
||||
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def list_courses(s):
|
||||
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
|
||||
out, seen = [], set()
|
||||
for cid, name in re.findall(r"showCourse\('([^']+)','([^']*)'\)", html):
|
||||
if cid in seen:
|
||||
continue
|
||||
seen.add(cid)
|
||||
out.append({"plan_id": cid, "project_id": "", "name": name.strip(),
|
||||
"in_study": True, "platform": PLATFORM_ID})
|
||||
if not out:
|
||||
if "loginBtn" in html or "showLogin" in html:
|
||||
raise _expired("学习平台会话已失效,请重新登录")
|
||||
return []
|
||||
return out
|
||||
|
||||
|
||||
def parse_course(html):
|
||||
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、总长、已看秒数。"""
|
||||
d = {}
|
||||
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
|
||||
if not m:
|
||||
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
|
||||
d["aid"] = "a_" + m.group(1)
|
||||
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
|
||||
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
|
||||
if not m:
|
||||
raise RuntimeError("学习页里没有找到课件 id")
|
||||
d["id"] = m.group(1)
|
||||
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
|
||||
if not m:
|
||||
raise RuntimeError("学习页里没有找到 md5Uid")
|
||||
d["uid"] = m.group(1)
|
||||
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
|
||||
d["total"] = int(m.group(1)) if m else 0
|
||||
m = re.search(r"url1\s*=\s*'([^']+)'", html)
|
||||
d["video"] = m.group(1) if m else ""
|
||||
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
|
||||
if "<title>" in html else "")
|
||||
return d
|
||||
|
||||
|
||||
def _sign(s, cw, uid):
|
||||
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
|
||||
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
|
||||
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
|
||||
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
|
||||
j = r.json() if r.text.strip().startswith("{") else {}
|
||||
if j.get("responseCode") != "00":
|
||||
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
|
||||
return j["responseData"]
|
||||
|
||||
|
||||
def _view_time(s, cw, uid, sign):
|
||||
r = _xhr(s, "GET", X + "/" + cw["aid"] + "/study/video/user/getViewTime",
|
||||
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
|
||||
"md5Uid": uid})
|
||||
j = r.json() if r.text.strip().startswith("{") else {}
|
||||
rd = j.get("responseData")
|
||||
if isinstance(rd, dict):
|
||||
return int(rd.get("viewTime") or 0)
|
||||
return int(rd or 0)
|
||||
|
||||
|
||||
def discover(s, plan_id, project_id, name=""):
|
||||
cid = plan_id
|
||||
page = X + "/study/course/" + cid
|
||||
s.headers["Referer"] = page
|
||||
r = s.get(page + "?v=1", timeout=30)
|
||||
if "loginBtn" in r.text and "md5Uid" not in r.text:
|
||||
raise _expired("学习平台会话已失效,请重新登录")
|
||||
cw = parse_course(r.text)
|
||||
sid = uuid.uuid4().hex
|
||||
cw["sid"] = sid
|
||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
|
||||
cur = _view_time(s, cw, cw["uid"], sign)
|
||||
done = cw["total"] > 0 and cur >= cw["total"]
|
||||
return {
|
||||
"user": profile(s),
|
||||
"course": name or cw["title"],
|
||||
"progress": "%d/%d 秒" % (cur, cw["total"]),
|
||||
"info": cw["video"],
|
||||
"courseware": [{"id": cw["id"], "title": cw["title"], "href": page,
|
||||
"total": cw["total"], "viewTime": cur, "done": done,
|
||||
"aid": cw["aid"], "uid": cw["uid"]}],
|
||||
"feedback": [], "test": [],
|
||||
"platform": PLATFORM_ID, "generation": "gxk",
|
||||
"plan_id": cid, "project_id": "",
|
||||
}
|
||||
|
||||
|
||||
def _stopped(key):
|
||||
with runtime.RUNS_LOCK:
|
||||
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
||||
|
||||
|
||||
def study(key, s, d, **opts):
|
||||
"""只跑心跳上报:每 beat 秒把 lastViewTime 往前推 beat*speed 秒。"""
|
||||
speed = float(opts.get("speed") or 60)
|
||||
beat = int(opts.get("beat") or 2)
|
||||
total = len(d["courseware"])
|
||||
runtime.set_state(key, status="studying", total=total, done=0)
|
||||
for i, cw in enumerate(d["courseware"], 1):
|
||||
if _stopped(key):
|
||||
runtime.log(key, "已按要求停止")
|
||||
return
|
||||
if cw.get("done"):
|
||||
runtime.log(key, "课件 %s 已学完,跳过" % cw["title"])
|
||||
runtime.set_state(key, done=i)
|
||||
continue
|
||||
runtime.log(key, "开始学习:%s(总长 %d 秒,已看 %d 秒)"
|
||||
% (cw["title"], cw["total"], cw["viewTime"]))
|
||||
_learn(key, s, cw, speed, beat)
|
||||
runtime.set_state(key, done=i)
|
||||
runtime.set_state(key, status="done", done=total)
|
||||
runtime.log(key, "全部课件心跳上报完成")
|
||||
|
||||
|
||||
def _learn(key, s, cw, speed, beat):
|
||||
uid, total = cw["uid"], cw["total"]
|
||||
s.headers["Referer"] = cw.get("href") or X
|
||||
sid = uuid.uuid4().hex
|
||||
base = {"_method": "PUT", "aid": cw["aid"]}
|
||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
|
||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||
while cur < total:
|
||||
if _stopped(key):
|
||||
runtime.log(key, "已按要求停止")
|
||||
return
|
||||
time.sleep(beat)
|
||||
cur = min(total, cur + int(beat * speed))
|
||||
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
|
||||
md5Uid=uid, lastViewTime=str(cur), playbackSessionId=sid)
|
||||
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
|
||||
data=data)
|
||||
j = r.json() if r.text.strip().startswith("{") else {}
|
||||
rd = j.get("responseData") or {}
|
||||
if j.get("responseCode") == "00" and rd.get("md5"):
|
||||
sign = {"md5": rd["md5"], "time": rd.get("time")}
|
||||
server = int(rd.get("viewTime") or cur)
|
||||
else:
|
||||
server = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||
runtime.log(key, "进度上报:本地 %d 秒 / 服务端 %d 秒 / 总长 %d 秒" % (cur, server, total))
|
||||
if server < cur:
|
||||
# 服务端没认这个进度,按它的值继续,别把差距越拉越大
|
||||
cur = server
|
||||
if cur >= total:
|
||||
break
|
||||
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
|
||||
|
||||
|
||||
PLATFORM = registry.register(Platform(
|
||||
id=PLATFORM_ID,
|
||||
label="广东公需课",
|
||||
study=study,
|
||||
entries=("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"),
|
||||
session=new_session,
|
||||
dump_cookies=dump_cookies,
|
||||
login=login,
|
||||
list_courses=list_courses,
|
||||
discover=discover,
|
||||
renew_session=renew_session,
|
||||
profile=profile,
|
||||
))
|
||||
Reference in New Issue
Block a user