@@ -4,9 +4,9 @@
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
两个域名的 cookie 都留在同一个 requests.Session 里。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度不靠播放器,靠
`updateViewTime1` 心跳上报(60 秒一次,`lastViewTime` 是已看秒数),
`viewTime` 追平视频总长就算学完,所以这里只跑心跳、不真播 。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷 。
"""
import base64
import json
@@ -16,7 +16,7 @@ import uuid
import requests
from study_tool import runtime
from study_tool import runtime , store
from study_tool . platforms import registry
from study_tool . platforms . base import Platform
@@ -174,7 +174,7 @@ def list_courses(s):
def parse_course ( html ) :
""" 从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、总 长、已看秒数。 """
""" 从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时 长、已看秒数。 """
d = { }
m = re . search ( r " /a_([0-9a-f] {32} )/study/video/user/ " , html )
if not m :
@@ -189,8 +189,11 @@ def parse_course(html):
if not m :
raise RuntimeError ( " 学习页里没有找到 md5Uid " )
d [ " uid " ] = m . group ( 1 )
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
m = re . search ( r " \ bt \ s*= \ s* ' ( \ d+) ' " , html )
d [ " total " ] = int ( m . group ( 1 ) ) if m else 0
d [ " viewTime " ] = int ( m . group ( 1 ) ) if m else 0
m = re . search ( r " 要求观看视频<span>( \ d+)</span>分钟 " , html )
d [ " total " ] = int ( m . group ( 1 ) ) * 60 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 ( )
@@ -220,6 +223,24 @@ def _view_time(s, cw, uid, sign):
return int ( rd or 0 )
def _progress ( s , cid ) :
""" 学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。 """
html = s . get ( X + " / " + cid + " /study/course/progess " , timeout = 30 ) . text
if " studyCourse( " not in html :
raise _expired ( " 学习平台会话已失效,请重新登录 " )
m = re . search ( r " \ $ \ ( ' #actNum ' \ ) \ .text \ ( ' ( \ d+) ' \ ) " , html )
total = int ( m . group ( 1 ) ) if m else 0
m = re . search ( r " \ $ \ ( ' #completeActNum ' \ ) \ .text \ ( ' ( \ d+) ' \ ) " , html )
done = int ( m . group ( 1 ) ) if m else 0
acts = [ ]
for row in re . findall ( r " <tr[^>]*>([ \ s \ S]*?)</tr> " , html ) :
m = re . search ( r " studyCourse \ ( ' [^ ' ]* ' , ' [^ ' ]* ' , ' (a_[0-9a-f] {32} ) ' \ )[^>]*>([^<]+)< " , row )
if m :
acts . append ( { " aid " : m . group ( 1 ) , " title " : m . group ( 2 ) . strip ( ) ,
" done " : " u-ico-finish " in row } )
return { " total " : total or len ( acts ) , " done " : done , " acts " : acts }
def discover ( s , plan_id , project_id , name = " " ) :
cid = plan_id
page = X + " /study/course/ " + cid
@@ -227,27 +248,21 @@ def discover(s, plan_id, project_id, name=""):
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 )
# 完成与否以平台状态为准:要「所有教学活动 100% + 试卷考核 ≥60」,视频秒数够不代表完成
status = " "
for c in _parse_courses ( s . get ( X + " /uc/store/courseRegister " , timeout = 30 ) . text ) :
if c [ " plan_id " ] == cid :
status = c [ " status_label " ]
break
done = status == " 已完成 "
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = _progress ( s , cid )
if not pr [ " acts " ] :
raise RuntimeError ( " 进度页里没有找到活动列表,页面结构可能变了 " )
courseware = [ {
" aid " : a [ " aid " ] , " title " : a [ " title " ] , " done " : a [ " done " ] ,
" href " : X + " / " + a [ " aid " ] + " /study/course/ " + cid ,
" total " : 0 , " viewTime " : 0 ,
} for a in pr [ " acts " ] ]
return {
" user " : profile ( s ) ,
" course " : name or cw [ " title " ] ,
" progress " : " %d / %d 秒 %s " % ( cur , cw [ " total " ] , " (课程已完成) " if done else " " ) ,
" info " : cw [ " video " ] ,
" courseware " : [ { " id " : cw [ " id " ] , " title " : cw [ " title " ] , " href " : page ,
" total " : cw [ " total " ] , " viewTime " : cur , " done " : done ,
" status " : status ,
" aid " : cw [ " aid " ] , " uid " : cw [ " uid " ] } ] ,
" course " : name or " 公需课程 " ,
" progress " : " 已完成 %d / %d 个活动 " % ( pr [ " done " ] , pr [ " total " ] ) ,
" info " : page ,
" courseware " : courseware ,
" feedback " : [ ] , " test " : [ ] ,
" platform " : PLATFORM_ID , " generation " : " gxk " ,
" plan_id " : cid , " project_id " : " " ,
@@ -259,63 +274,126 @@ def _stopped(key):
return ( runtime . RUNS . get ( key ) or { } ) . get ( " status " ) == " stopped "
def _relogin ( key ) :
""" 掉线后按库里存的账号密码重登,返回新会话。 """
acc = store . account_get ( key ) or { }
pw = store . account_password ( key )
if not pw :
raise _expired ( " 会话掉线,且没有保存密码,无法自动重连 " )
runtime . log ( key , " 会话掉线,正在用账号密码自动重新登录… " , level = " warn " )
s , _ = login ( acc . get ( " username " ) or " " , pw )
runtime . log ( key , " 重新登录成功,继续学习 " )
return s
def _study_one ( key , s , cw , speed , beat ) :
""" 打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。 """
try :
_sso ( s ) # 一门课要跑几十分钟到几小时,每个活动前续一次票
except Exception :
pass # 续票失败但老会话还能用就继续
html = s . get ( cw [ " href " ] , timeout = 30 ) . text
if " loginBtn " in html and " md5Uid " not in html :
raise _expired ( " 学习页要求重新登录 " )
try :
info = parse_course ( html )
except Exception as e :
raise RuntimeError ( " 不是视频活动( %s ) " % e )
if not info [ " total " ] :
raise RuntimeError ( " 页面上没有「要求观看视频 N 分钟」 " )
cw . update ( id = info [ " id " ] , uid = info [ " uid " ] , total = info [ " total " ] , viewTime = info [ " viewTime " ] )
s . headers [ " Referer " ] = cw [ " href " ]
sign = _sign ( s , { " aid " : cw [ " aid " ] , " id " : cw [ " id " ] , " sid " : uuid . uuid4 ( ) . hex } , cw [ " uid " ] )
cur = _view_time ( s , { " aid " : cw [ " aid " ] , " id " : cw [ " id " ] } , cw [ " uid " ] , sign )
runtime . log ( key , " 开始学习: %s (要求 %d 秒,已看 %d 秒) " % ( cw [ " title " ] , cw [ " total " ] , cur ) )
if cur < cw [ " total " ] :
_learn ( key , s , cw , speed , beat )
else :
runtime . log ( key , " 服务端已记 %d 秒,达到要求,等平台刷新完成状态 " % cur , level = " warn " )
def study ( key , s , d , * * opts ) :
""" 只跑心跳上报:每 beat 秒把 lastViewTime 往前推 beat*speed 秒 。"""
speed = float ( opts . get ( " speed " ) or 60 )
beat = int ( opts . get ( " beat " ) or 2 )
""" 按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷 。"""
speed = float ( opts . get ( " speed " ) or 1 )
beat = int ( opts . get ( " beat " ) or 60 )
cid = d [ " plan_id " ]
total = len ( d [ " courseware " ] )
runtime . set_state ( key , status = " studying " , total = total , done = 0 )
skipped = [ ]
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 . log ( key , " 活动 %d / %d 已完成 ,跳过: %s " % ( i , total , cw [ " title " ] ) )
runtime . set_state ( key , done = i )
continue
runtime . log ( key , " 开始学习: %s (总长 %d 秒,已看 %d 秒) "
% ( cw [ " title " ] , cw [ " total " ] , cw [ " viewTime " ] ) )
if cw [ " total " ] and cw [ " viewTime " ] > = cw [ " total " ] :
runtime . log ( key , " 视频已播完,但课程仍显示「 %s 」:还差试卷考核或其他教学活动 "
% ( cw . get ( " status " ) or " 未完成 " ) , level = " warn " )
runtime . set_state ( key , done = i )
continue
_learn ( key , s , cw , speed , beat )
for attempt in ( 1 , 2 ) :
try :
_study_one ( key , s , cw , speed , beat )
break
except Exception as e :
expired = e . __class__ . __name__ == " SessionExpired "
if expired and attempt == 1 :
s = _relogin ( key ) # 掉线:重登后把当前活动重新走一遍
continue
if expired :
raise
runtime . log ( key , " 活动「 %s 」跳过: %s " % ( cw [ " title " ] , e ) , level = " warn " )
skipped . append ( cw [ " title " ] )
break
runtime . set_state ( key , done = i )
runtime . set_state ( key , status = " done " , done = total )
runtime . log ( key , " 全部课件心跳上报完成 " )
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
try :
pr = _progress ( s , cid )
except Exception as e :
if e . __class__ . __name__ != " SessionExpired " :
raise
s = _relogin ( key )
pr = _progress ( s , cid )
act_total = pr [ " total " ] or total
runtime . set_state ( key , status = " done " , done = pr [ " done " ] , total = act_total )
if pr [ " done " ] > = act_total :
runtime . log ( key , " 平台进度: %d / %d 个活动已完成 " % ( pr [ " done " ] , act_total ) )
else :
runtime . log ( key , " 平台进度: %d / %d 个活动已完成,剩余需人工处理 %s "
% ( pr [ " done " ] , act_total ,
( " ( " + " 、 " . join ( skipped ) + " ) " ) if skipped else " " ) ,
level = " warn " )
def _learn ( key , s , cw , speed , beat ) :
""" 心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。 """
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 )
start , t0 , stall = cur , time . time ( ) , 0
while cur < total :
if _stopped ( key ) :
runtime . log ( key , " 已按要求停止 " )
return
time . sleep ( beat )
cur = min ( total , cur + int ( beat * speed ) )
want = min ( total , start + int ( ( time . time ( ) - t0 ) * speed ) )
data = dict ( base , isLimit = " false " , md5 = sign . get ( " md5 " ) , expires = sign . get ( " time " ) ,
md5Uid = uid , lastViewTime = str ( cur ) , playbackSessionId = sid )
md5Uid = uid , lastViewTime = str ( max ( want , 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 { }
if " loginBtn " in ( r . text or " " ) or not ( r . text or " " ) . strip ( ) . startswith ( " { " ) :
raise _expired ( " 上报被踢回登录页(HTTP %s ) " % r . status_code )
j = r . json ( )
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
before = cur
cur = _view_time ( s , { " aid " : cw [ " aid " ] , " id " : cw [ " id " ] } , uid , sign )
runtime . log ( key , " 进度上报:目标 %d 秒 / 服务端 %d 秒 / 要求 %d 秒 " % ( want , cur , total ) )
stall = stall + 1 if cur < = before else 0
if stall > = 5 :
runtime . log ( key , " 连续 5 次上报都没被平台接受,先停这个活动 " , level = " warn " )
return
runtime . log ( key , " 课件完成: %s ( %d 秒) " % ( cw [ " title " ] , total ) )