// ==UserScript==
// @name 湖南开放大学刷课(旧版界面)
// @namespace Violentmonkey Scripts
// @match *://www.hnsydwpx.cn/center.html*
// @match *://www.hnsydwpx.cn/getcourseDetails.html*
// @match *://www.hnsydwpx.cn/play.html*
// @match *://www.hnsydwpx.cn/template/*
// @version 2.2
// @author n1nja88888
// @description 支持自动播放,自动换集、延长登陆时间等功能
// @run-at document-start
// @frames
// @require https://lib.baomitu.com/axios/0.27.2/axios.min.js
// @require https://lib.baomitu.com/crypto-js/3.3.0/crypto-js.min.js
// @grant GM_getValue
// @grant GM_setValue
// @license AGPL License
// ==/UserScript==
// http://www.hnsydwpx.cn/center.html 课程
// http://www.hnsydwpx.cn/play.html 课程概览
// http://www.hnsydwpx.cn/getcourseDetails.html 视频
'use strict'
console.log('n1nja88888 creates this world!')
const key = 'easyweb'
const checkedKey = 'checked'
const accountKey = 'account'
const passwordKey = 'password'
main()
async function main() {
// 对于内嵌标签页的处理
if (window.top !== window) {
await getEleAsync('script[src*="lay-config"]')
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
const error = options.error
options.error = function(...args) {
if (args[0].status != 401) {
if ($.isFunction(error))
return error.apply(this, args)
}
}
})
const temp = unsafeWindow.$
Object.defineProperty(unsafeWindow, '$', {
get() { return temp },
set(val) { }
})
Object.defineProperty(unsafeWindow.layui, 'jquery', {
get() { return temp },
set(val) { }
})
return
}
const token = JSON.parse(localStorage.getItem(key)).token
if (!token)
return
axios.defaults.baseURL = 'https://www.hnsydwpx.cn'
axios.defaults.headers.common['Authorization'] = 'Bearer ' + JSON.parse(token)
// 重写media标签的play函数
const _play = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function() {
this.muted = true // 默认静音
return _play.apply(this)
}
// 插入时机
await getEleAsync('script[src*="public"]')
loginPanel()
// 重写layui.js
unsafeWindow.layui._use = unsafeWindow.layui.use
unsafeWindow.layui.use = (...args) => {
if (!!args[0] && Array.isArray(args[0]) && args[0].length === 6
&& location.href.includes('www.hnsydwpx.cn/getcourseDetails.html'))
return
return unsafeWindow.layui._use.apply(unsafeWindow.layui, args)
}
window.addEventListener('DOMContentLoaded', () => {
if (location.href.includes('www.hnsydwpx.cn/center.html'))
centerPage()
else if (location.href.includes('www.hnsydwpx.cn/play.html'))
getEleAsync('.classItem a').then(course => course.click())
else if (location.href.includes('www.hnsydwpx.cn/getcourseDetails.html'))
videoPage()
})
}
// 获取网页元素
function getEleAsync(selector, isCollection = false, context = null) {
return new Promise(res => {
context = context ? context.document : document
const ret = get(context)
if (ret) {
res(ret)
return
}
const observer = new MutationObserver((records, observer) => {
const ret = get(context)
if (ret) {
res(ret)
observer.disconnect()
}
})
observer.observe(context, {
childList: true,
subtree: true
})
})
function get(context) {
const ret = isCollection ? context.querySelectorAll(selector) : context.querySelector(selector)
if ((!isCollection && !!ret) || (isCollection && ret.length > 0))
return ret
else
return null
}
}
// 添加登陆面板
function loginPanel() {
const isChecked = GM_getValue(checkedKey, '')
const css = `
<style>
.fixed-form {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
z-index: 9999;
}
.form-content {
margin-top: 20px;
}
.form-item {
margin-bottom: 20px;
}
.layui-input {
width: 220px;
}
.layui-form-label {
width: 90px;
}
</style>`
const panel = `
<div class="fixed-form" id="loginPanel">
<div class="form-content layui-container">
<form class="layui-form" lay-filter="form">
<div class="form-item">
<label class="layui-form-label">账号</label>
<div class="layui-input-block">
<input type="text" name="account" placeholder="请输入账号" class="layui-input" autocomplete="on">
</div>
</div>
<div class="form-item">
<label class="layui-form-label">密码</label>
<div class="layui-input-block">
<input type="password" name="password" placeholder="请输入密码" class="layui-input" autocomplete="current-password">
</div>
</div>
<div class="form-item">
<label class="layui-form-label">延长登录</label>
<div class="layui-input-block">
<input type="checkbox" name="switch" lay-skin="switch" lay-text="ON|OFF" id="switch" ${isChecked}>
</div>
</div>
</form>
</div>
</div>`
$('head').append(css)
$('body').append(panel)
layui.use('form', () => {
const form = layui.form
form.render()
if (isChecked)
isCorrect()
// 监听开关按钮的状态改变事件
form.on('switch', (data) => {
if (!data.elem.checked)
GM_setValue(checkedKey, '')
else {
GM_setValue(checkedKey, 'checked')
const account = form.val('form').account
const password = form.val('form').password
if (!isCorrect(account))
return
if (account == '' || password == '') {
print('不能填写为空,已自动关闭延长登录')
toggle(false)
}
else {
GM_setValue(accountKey, account)
GM_setValue(passwordKey, password)
GM_setValue(checkedKey, 'checked')
}
}
})
})
// 拦截所有401错误
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
const error = options.error
options.error = function(...args) {
if (args[0].status == 401) {
if (GM_getValue(checkedKey))
extendSession()
}
else {
if ($.isFunction(error))
return error.apply(this, args)
}
}
})
const temp = unsafeWindow.$
Object.defineProperty(unsafeWindow, '$', {
get() { return temp },
set(val) { }
})
Object.defineProperty(unsafeWindow.layui, 'jquery', {
get() { return temp },
set(val) { }
})
function print(msg) {
layer.open({
title: '刷课脚本提示',
content: msg
})
}
function toggle(isChecked) {
if (isChecked)
$('#switch').next().addClass('layui-form-onswitch')
else
$('#switch').next().removeClass('layui-form-onswitch')
$('#switch').next().children().eq(0).text(isChecked ? 'ON' : 'OFF')
GM_setValue(checkedKey, isChecked ? 'checked' : '')
}
function extendSession() {
if (!isCorrect())
return
const username = GM_getValue(accountKey)
const password = CryptoJS.AES.encrypt(GM_getValue(passwordKey), CryptoJS.enc.Latin1.parse(layui.webconfig.cryptoJSKey), {
iv: CryptoJS.enc.Latin1.parse(layui.webconfig.cryptoJSKey),
mode: CryptoJS.mode.CFB,
padding: CryptoJS.pad.NoPadding
}).toString()
axios.post('/auth/oauth/token?grant_type=password',
`username=${username}&password=${password}&scope=server&clientId=trainee`,
{
headers: {
'Authorization': 'Basic dGVzdDp0ZXN0'
}
}).then(res => {
const data = res.data
layui.webconfig.putToken(data.access_token)
layui.webconfig.putUser(data.user_info)
location.reload()
}).catch(err => {
print('账号或密码错误,延长登录失败,已自动关闭延长登录功能')
toggle(false)
})
}
function isCorrect(account = null) {
account = account ? account : GM_getValue(accountKey)
const username = JSON.parse(JSON.parse(localStorage.getItem('easyweb')).login_user).username
if (account == username)
return true
else {
print('检测到保存的账号与当前登录账号不符,已自动关闭延长登录功能')
toggle(false)
return false
}
}
}
async function centerPage() {
// 获取iframe的上下文
const iframe = await getEleAsync('#iframe2')
const context = iframe.contentWindow
// 判断是否全部学完
const ret = await isFinished()
if (ret)
return
// 获取课程列表
const list = $(await getEleAsync('#LearnInCompleteArr li', true, context))
// 记录要学习的点位 默认从0 即第一个视频开始
let index = 0
// 获取第一个未学习课
let curLesson = list.eq(index)
// 检查视频学习进度
let text = curLesson.find('.percent').text()
// 定位到未学状态的视频
while (text === '进度:100%') {
curLesson = list.eq(++index)
text = curLesson.find('.classItemInfo p').eq(2).text()
}
curLesson.find('button').click()
async function isFinished() {
const customerId = JSON.parse(JSON.parse(localStorage.getItem(key)).login_user).id
const res = await axios.get(`/classes/sydwpxxclassescustomercourse/selectChangeNum?customerId=${customerId}`)
return res.data.data.noNum <= 0
}
}
async function videoPage() {
// 重写发生heartbeat的时间间隔
const res = await axios.get('/js/getcourseDetails.js')
const data = res.data
.replace(/layui\.use/, 'layui._use')
.replace(/player\.on\(\'pause\'[\s\S]*?player\.on\(\'error\'/, `
let curDate = Date.now()
player.on('pause', function() {
playing = false
//======添加的代码
const temp = Date.now()
const interval = Math.ceil((temp - curDate) / 1e3)
curDate = temp
playTime += interval //本次播放时长(不是播放器的时长)
submitTime = interval //提交时长(循环清空)
//======添加的代码
var tmpSubmitTime = submitTime //暂存待提交时长
clearInterval(playtimer) //清空定时器
var restLen = parseInt(currentDuration) - parseInt(playTime) - parseInt(lastTime)
//console.log("pause", restLen, tmpSubmitTime, parseInt(playTime), parseInt(lastTime), parseInt(currentDuration));
if (errorFlag) { //错误不做提交
//console.log("error pause not submit");
errorFlag = false
} else {
submitTime = 0 //重置提交缓冲时长
//console.log("pause ok", learningToken);
if (learnStatus != 2) { //没有看完的
//没有错误
if (tmpSubmitTime > 0) { //待提交时长大于1秒
if (restLen < 3) {
//视频最后一次提交
playTime++ //最后加1秒
tmpSubmitTime++ //最后加1秒
var slIdx = submitLoading(true)
setTimeout(function() {
sendheartbeat(chapterId, tmpSubmitTime, learnStatus, function() {
$("#" + slIdx).remove() //移除遮罩
//console.log('最后提交数据ok');
//效验数据 此方法会自动判断标记是否已真实学完
handleEnded(player, learnStatus)
})
}, 2000)
} else {
sendheartbeat(chapterId, tmpSubmitTime, learnStatus, function() {
//回调
learningToken = "" //清空token
//console.log('数据提交ok');
})
}
} else {
learningToken = "" //清空token
}
} else {
//已看完的
if (parseInt(player.currentTime) >= parseInt(player.duration)) { //到进度条最后了
handleEnded(player, learnStatus)
}
if (tmpSubmitTime > 1) {
sendheartbeat(chapterId, tmpSubmitTime, learnStatus)
} else {
learningToken = "" //清空token
}
}
}
})
player.on('play', function() {
//console.log('play', learningToken);
videoMsk(false) //隐藏缓冲遮罩
learningToken = "" //开始播放清空token
sendheartbeat(chapterId, 0) //发送初次请求
playtimer = setInterval(function() {
// ========添加的代码
const temp = Date.now()
const interval = Math.ceil((temp - curDate) / 1e3)
curDate = temp
playTime += interval //本次播放时长(不是播放器的时长)
submitTime = interval //提交时长(循环清空)
// ========添加的代码
var lookedLen = parseInt(playTime) + parseInt(lastTime)
//console.log("计算时长:" + submitTime, "本次播放时长:" + playTime, "已学习时长:" + lookedLen,"当前播放器进度:" + player.currentTime, "当前视频时长:" + currentDuration);
//最新版火狐101.0.1 (64 位)出现播放完成不触发结束暂停事件,手动判断是否播放完毕
//当前播放大于等于视频时长
// 剩余时间
var restNodeId = "#shengyu" + jid
var restLen = parseInt(currentDuration) - lookedLen
restLen = restLen < 0 ? 0 : restLen
if (learnStatus == 2) {
restLen = parseInt(currentDuration) - parseInt(player.currentTime)
}
$(restNodeId).removeClass("hide")
$(restNodeId).addClass("redborder")
$(restNodeId).text("剩余" + formatSeconds2(restLen))
$(restNodeId).prev().css("width", "55%")
//没有学完且播放器计时比定时器快了,将进度条拉回来
if (learnStatus != 2 && parseInt(player.currentTime) > lookedLen) {
player.currentTime = lookedLen //拉回来
//console.log('矫正');
}
if (isFirefox && !player.ended && !player.paused && parseInt(player.currentTime) >= parseInt(player.duration)) {
if (submitTime > 1) {
//console.log("firefox 播放结束");
player.pause() //交给播放暂停去提交数据
}
} else {
if (learningToken == "") {
//网速太慢上次请求还没完成 这种情况发生在网络极端不好的情况下
errorFlag = true //标识此变量,后续暂停将不请求心跳
player.pause() //暂停
player.currentTime = parseInt(playTime) + parseInt(lastTime) //将进度条拖回上一次提交时长
playTime = playTime - submitTime //本次播放时长回退
//currentPlayTime = player.currentTime;
//开启数据提交中遮罩
var slIdx = submitLoading(true)
//console.log('网络太慢提交数据等待中...', chapterId, submitTime, learnStatus);
sendheartbeat(chapterId, submitTime, learnStatus, function() {
learningToken = "" //清空token
$("#" + slIdx).remove() //移除遮罩
//console.log('网络太慢提交数据成功..', chapterId, submitTime, learnStatus);
//成功后重新开始播放
if (!playing || player.paused) {
player.play()
}
})
submitTime = 0
} else {
//console.log("定时器提交数据:", parseInt(player.currentTime), parseInt(player.duration));
if ((parseInt(player.currentTime) + 1) >= parseInt(player.duration)) {
handleEnded(player, learnStatus)
}
sendheartbeat(chapterId, submitTime, learnStatus, function() { }) //提交数据
submitTime = 0
}
}
}, 30e3)
})
// 播放错误时,点击刷新
player.on('error'
`)
new Function(data).apply(unsafeWindow)
// 监听是否卡顿,以及是否添加video
const observer = new MutationObserver(records => {
let retry = 10, timer = null
records.forEach(record => {
const type = record.type
if ('childList' === type) {
for (const node of record.addedNodes.values()) {
if ('VIDEO' === node.tagName)
videoOps(record)
}
}
else if ('attribute' === type)
refreshOps(record)
})
function videoOps(record) {
const exit = $('.list-header').find('button')
const video = $('#studyVideo video')[0]
video.addEventListener('play', async () => {
const videoItems = await getEleAsync('.item-list', true)
if (!video.muted)
video.muted = true
// 找到第一个未看完的元素,且不是当前播放的元素
const videoItem = $(videoItems).find('.item-list-progress:not(:contains("100%"))').first().parent()
if (videoItem.length === 0)
exit.click()
if (!videoItem.hasClass('item-list-redClass'))
videoItem.click()
})
video.addEventListener('pause', async function() {
const videoItems = await getEleAsync('.item-list', true)
if ($('.item-list-redClass').index('.item-list') === videoItems.length - 1) {
isFinished().then(res => {
if (res)
exit.click()
})
}
// 停顿5s再播放,因为原视频页存在卡顿时也会停止播放,要留给原视频页对视频卡顿进行处理
setTimeout(() => video.play(), 5e3)
})
video.addEventListener('canplay', e => e.play())
}
async function refreshOps(record) {
const refresh = await getEleAsync('.xgplayer-error-refresh')
if (record.target.classList.contains('xgplayer-is-error')) {
if (!timer) {
if (retry-- <= 0)
location.reload()
timer = setTimeout(() => {
refresh.click()
timer = null
}, 1.5e3)
}
}
}
})
observer.observe($('#studyVideo')[0], {
attributes: true,
attributeFilter: ['class'],
childList: true
})
// 每1.5min检查一次是否卡住,因为heartbeat被修改为30s发送一次
// 记录上次进度
let lastProgress = $('.item-list-redClass .item-list-progress').text()
setInterval(() => {
const curProgress = $('.item-list-redClass .item-list-progress').text()
if (lastProgress === curProgress)
location.reload()
lastProgress = curProgress
}, 3 * 30e3)
async function isFinished() {
const playInfo = JSON.parse(localStorage.getItem(key)).playinfo
const res = await axios.post('/learning/coursenode/getCourseNodeProgressRedis', {
chapterId: playInfo.chapterId,
classesId: playInfo.classesId,
courseId: playInfo.courseid,
nodeId: playInfo.jid
})
return parseInt(res.data.data.demandLength) <= parseInt(res.data.data.learnLength)
}
}