// ==UserScript==
// @name 巴中继续教育自动切课(跨大节修复版)
// @namespace http://tampermonkey.net/
// @version 0.5.0
// @description 修复跨大科目跳转失败,自动展开折叠章节,支持完整二级目录遍历
// @author 自用辅助
// @match *://bzys.jjyxt.cn/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ========== 配置区 ==========
const CONFIG = {
targetPlaybackRate: 2.0, // 目标倍速,建议1.2-1.5更安全
rateFluctuation: 0.2, // 倍速随机波动幅度
rateStep: 0.1,
skipSec: 10,
autoResume: true,
autoMuted: true,
autoSkipPopup: true,
// 随机暂停间隔(毫秒):6-15分钟
pauseIntervalMin: 360000,
pauseIntervalMax: 900000,
// 随机暂停时长(毫秒):1-3秒
pauseDurationMin: 1000,
pauseDurationMax: 3000,
// 切课延迟随机范围(毫秒)
nextDelayMin: 1000,
nextDelayMax: 4000,
// 下一课按钮匹配关键词
nextBtnKeywords: ['下一节', '下一课', '下一章节', '下一章', '继续学习', '下一个'],
// ========== 目录选择器(不生效可按下方教程修改) ==========
// 大节标题选择器:点击可展开/折叠的科目标题栏
chapterTitleSelector: '.chapter-title, .catalog-group-title, .section-header, .el-collapse-item__header',
// 大节容器选择器:每个大节整体包裹元素
chapterGroupSelector: '.chapter-group, .catalog-group, .el-collapse-item',
// 小节项选择器:每个具体课时的条目
sectionItemSelector: '.section-item, .course-item, .chapter-item, li.course, .list-item',
// 当前播放项标记类名
activeClass: 'active, current, playing, now, on'
};
// =================================================
let videoDom = null;
let domObserver = null;
let isJumping = false;
let toastTimer = null;
let pauseTimer = null;
// ========== 前置:劫持playbackRate隐藏真实倍速 ==========
(function hidePlaybackRate() {
const proto = HTMLMediaElement.prototype;
const originalDesc = Object.getOwnPropertyDescriptor(proto, 'playbackRate');
if (!originalDesc) return;
Object.defineProperty(proto, 'playbackRate', {
get: function() { return 1.0; },
set: function(value) { originalDesc.set.call(this, value); },
configurable: true,
enumerable: true
});
})();
// 悬浮提示
function showToast(text) {
let toast = document.getElementById('helper-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'helper-toast';
toast.style.cssText = `
position: fixed; top: 80px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.75); color: #fff; padding: 8px 16px;
border-radius: 6px; font-size: 14px; z-index: 99999;
pointer-events: none; transition: opacity 0.3s;
`;
document.body.appendChild(toast);
}
toast.textContent = text;
toast.style.opacity = '1';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.style.opacity = '0', 1200);
}
// 右下角状态面板
function createStatusPanel() {
let panel = document.getElementById('study-status-panel');
if (panel) return panel;
panel = document.createElement('div');
panel.id = 'study-status-panel';
panel.style.cssText = `
position: fixed; right: 10px; bottom: 10px; background: rgba(0,0,0,0.65);
color: #fff; padding: 10px; border-radius: 8px; font-size:12px;
z-index:99998; min-width:140px; line-height:1.6;
`;
panel.innerHTML = `
【学习辅助状态】
目标倍速:${CONFIG.targetPlaybackRate}x
播放:00:00/00:00
静音:否
H=快捷键说明
`;
document.body.appendChild(panel);
return panel;
}
function updateStatusPanel() {
if (!videoDom) return;
const panel = createStatusPanel();
panel.querySelector('#rate-line').innerText = `目标倍速:${CONFIG.targetPlaybackRate}x`;
panel.querySelector('#mute-line').innerText = `静音:${videoDom.muted ? "是" : "否"}`;
const cur = formatTime(videoDom.currentTime);
const total = formatTime(videoDom.duration || 0);
panel.querySelector('#time-line').innerText = `播放:${cur}/${total}`;
}
function formatTime(s) {
if (isNaN(s)) return "00:00";
let m = Math.floor(s / 60);
let sec = Math.floor(s % 60);
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
function showHelpInfo() {
const info = `
快捷键列表:
C:加速 +0.1 | X:减速 -0.1
Z:重置1倍速 | N:手动切下一课
←→:进退${CONFIG.skipSec}秒 | 空格:播放/暂停
M:切换静音 | Ctrl↑↓:调音量
F:全屏 | H:关闭此提示
`.trim();
alert(info);
}
// 随机数工具
function random(min, max) {
return Math.random() * (max - min) + min;
}
// ========== 核心:二级目录解析与跨大节跳转 ==========
/**
* 获取所有大节列表,每个大节包含:标题元素、小节列表
*/
function getAllChapterGroups() {
const groups = Array.from(document.querySelectorAll(CONFIG.chapterGroupSelector));
if (groups.length === 0) {
// 降级:找不到大节容器时,直接扁平处理所有小节
const items = Array.from(document.querySelectorAll(CONFIG.sectionItemSelector));
return [{ titleEl: null, sections: items }];
}
return groups.map(group => {
const titleEl = group.querySelector(CONFIG.chapterTitleSelector) || group.firstElementChild;
const sections = Array.from(group.querySelectorAll(CONFIG.sectionItemSelector));
return { groupEl: group, titleEl, sections };
});
}
/**
* 定位当前播放的小节位置
* 返回 { groupIndex, sectionIndex } 或 null
*/
function getCurrentPosition() {
const groups = getAllChapterGroups();
const activeClasses = CONFIG.activeClass.split(',').map(c => c.trim());
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
for (let s = 0; s < group.sections.length; s++) {
const section = group.sections[s];
// 匹配类名
const hasActiveClass = activeClasses.some(cls => section.classList.contains(cls.trim()));
// 匹配文字特征
const hasActiveText = section.innerText.includes('正在播放') || section.innerText.includes('学习中');
if (hasActiveClass || hasActiveText) {
return { groupIndex: g, sectionIndex: s, groups };
}
}
}
return null;
}
/**
* 等待大节展开,小节渲染完成
*/
function waitForSections(groupEl, timeout = 3000) {
return new Promise((resolve) => {
const startTime = Date.now();
const check = () => {
const sections = groupEl.querySelectorAll(CONFIG.sectionItemSelector);
if (sections.length > 0 || Date.now() - startTime > timeout) {
resolve(sections.length > 0);
} else {
requestAnimationFrame(check);
}
};
check();
});
}
/**
* 二级目录兜底切课(支持跨大节跳转)
*/
async function clickNextByCatalog() {
const pos = getCurrentPosition();
if (!pos) {
console.log('[自动切课] 无法定位当前课时');
return false;
}
const { groupIndex, sectionIndex, groups } = pos;
const currentGroup = groups[groupIndex];
const isLastInGroup = sectionIndex >= currentGroup.sections.length - 1;
const isLastGroup = groupIndex >= groups.length - 1;
// 1. 当前大节还有下一小节:直接跳转
if (!isLastInGroup) {
const nextSection = currentGroup.sections[sectionIndex + 1];
console.log(`[自动切课] 同大节跳转:第${sectionIndex+2}小节`);
nextSection.click();
return true;
}
// 2. 当前是大节最后一节,且还有下一个大节:展开下一大节,跳转其首小节
if (isLastInGroup && !isLastGroup) {
const nextGroup = groups[groupIndex + 1];
console.log('[自动切课] 到达当前大节末尾,展开下一个大科目');
showToast('正在展开下一科目...');
// 点击大节标题展开
if (nextGroup.titleEl) nextGroup.titleEl.click();
// 等待小节渲染
const success = await waitForSections(nextGroup.groupEl || nextGroup.titleEl?.parentElement);
if (success) {
// 重新获取该大节的小节列表
const newSections = Array.from((nextGroup.groupEl || nextGroup.titleEl.parentElement).querySelectorAll(CONFIG.sectionItemSelector));
if (newSections.length > 0) {
console.log('[自动切课] 跨大节跳转成功,播放下一科目第1小节');
newSections[0].click();
return true;
}
}
console.log('[自动切课] 展开大节失败');
return false;
}
// 3. 已经是最后一个大节的最后一节:学习完成
if (isLastInGroup && isLastGroup) {
showToast('🎉 全部课程学习完毕');
return true;
}
return false;
}
// 路径1:点击页面原生下一节按钮
function clickNextByButton() {
const els = document.querySelectorAll('a, button, div[role="button"], span');
for (const el of els) {
const txt = el.innerText?.trim() || '';
const match = CONFIG.nextBtnKeywords.some(k => txt.includes(k));
const vis = el.offsetParent !== null;
const dis = el.disabled || el.classList.contains('disabled');
if (match && vis && !dis) {
el.click();
return true;
}
}
return false;
}
// 统一切课入口
function goToNextChapter() {
if (isJumping) return;
isJumping = true;
const delay = random(CONFIG.nextDelayMin, CONFIG.nextDelayMax);
showToast(`${(delay/1000).toFixed(1)}秒后自动跳转下一课`);
setTimeout(async () => {
const btnOk = clickNextByButton();
if (!btnOk) {
await clickNextByCatalog();
}
setTimeout(() => { isJumping = false; }, 3000);
}, delay);
}
// ========== 倍速与播放控制 ==========
function setFluctuationRate() {
if (!videoDom) return;
const base = CONFIG.targetPlaybackRate;
const fluct = CONFIG.rateFluctuation;
const rate = base + random(-fluct, fluct);
const finalRate = Math.max(0.5, Math.min(3, rate));
const originalDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate');
originalDesc.set.call(videoDom, finalRate);
}
function adjustRate(delta) {
if (!videoDom) return;
CONFIG.targetPlaybackRate = Math.max(0.5, Math.min(3, CONFIG.targetPlaybackRate + delta));
setFluctuationRate();
showToast(`目标倍速:${CONFIG.targetPlaybackRate.toFixed(1)}x`);
}
function videoSkip(sec) {
if (!videoDom) return;
videoDom.currentTime = Math.max(0, Math.min(videoDom.duration, videoDom.currentTime + sec));
showToast(sec>0 ? `前进${sec}秒` : `后退${Math.abs(sec)}秒`);
}
function togglePlay() {
if (!videoDom) return;
if(videoDom.paused) videoDom.play();
else videoDom.pause();
showToast(videoDom.paused ? "已暂停" : "开始播放");
}
function toggleMute() {
if (!videoDom) return;
videoDom.muted = !videoDom.muted;
showToast(videoDom.muted ? "已静音" : "取消静音");
}
function volumeChange(delta) {
if (!videoDom) return;
videoDom.volume = Math.max(0, Math.min(1, videoDom.volume + delta));
showToast(`音量:${(videoDom.volume*100).toFixed(0)}%`);
}
function toggleFullscreen() {
if (!videoDom) return;
if(document.fullscreenElement) document.exitFullscreen();
else videoDom.requestFullscreen();
}
// 随机暂停模拟真人
function startRandomPause() {
clearTimeout(pauseTimer);
const nextPause = random(CONFIG.pauseIntervalMin, CONFIG.pauseIntervalMax);
pauseTimer = setTimeout(() => {
if (!videoDom || videoDom.paused || isJumping) {
startRandomPause();
return;
}
videoDom.pause();
const duration = random(CONFIG.pauseDurationMin, CONFIG.pauseDurationMax);
setTimeout(() => {
videoDom.play().catch(() => {});
startRandomPause();
}, duration);
}, nextPause);
}
// 视频绑定
function bindVideoEvent() {
const videos = document.querySelectorAll('video');
if (!videos.length) return false;
videoDom = videos[0];
videoDom.removeEventListener('ended', handleVideoEnd);
videoDom.addEventListener('ended', handleVideoEnd);
// 初始先1倍速播放5秒,再升到目标倍速
const originalDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate');
originalDesc.set.call(videoDom, 1.0);
setTimeout(() => {
setFluctuationRate();
setInterval(setFluctuationRate, 240000);
}, 5000);
videoDom.muted = CONFIG.autoMuted;
videoDom.autoplay = true;
if (CONFIG.autoResume) {
videoDom.removeEventListener('pause', handleVideoPause);
videoDom.addEventListener('pause', handleVideoPause);
}
setInterval(updateStatusPanel, 1000);
startRandomPause();
console.log(`[视频绑定完成] 目标倍速${CONFIG.targetPlaybackRate}x`);
return true;
}
function handleVideoEnd() {
goToNextChapter();
}
function handleVideoPause() {
if (isJumping) return;
setTimeout(() => videoDom?.play().catch(() => {}), 800);
}
// 全局快捷键
function initHotkeys() {
document.addEventListener('keydown', e => {
const tag = document.activeElement?.tagName;
if (['INPUT','TEXTAREA','SELECT'].includes(tag)) return;
const key = e.key.toLowerCase();
if(e.ctrlKey){
if(key === 'arrowup') {e.preventDefault();volumeChange(0.1);return;}
if(key === 'arrowdown') {e.preventDefault();volumeChange(-0.1);return;}
}
switch(key){
case 'c': e.preventDefault(); adjustRate(CONFIG.rateStep); break;
case 'x': e.preventDefault(); adjustRate(-CONFIG.rateStep); break;
case 'z': e.preventDefault(); CONFIG.targetPlaybackRate = 1; setFluctuationRate(); showToast("已重置为1倍速"); break;
case 'n': e.preventDefault(); goToNextChapter(); break;
case 'arrowleft': e.preventDefault(); videoSkip(-CONFIG.skipSec); break;
case 'arrowright': e.preventDefault(); videoSkip(CONFIG.skipSec); break;
case ' ': e.preventDefault(); togglePlay(); break;
case 'm': e.preventDefault(); toggleMute(); break;
case 'f': e.preventDefault(); toggleFullscreen(); break;
case 'h': e.preventDefault(); showHelpInfo(); break;
}
})
}
// 自动弹窗关闭
function autoSkipPopup() {
if (!CONFIG.autoSkipPopup) return;
const btns = document.querySelectorAll('button,a,.el-button,.confirm-btn');
btns.forEach(b=>{
const t = b.innerText?.trim()||'';
if(['确定','知道了','继续','确认'].includes(t) && b.offsetParent) b.click();
})
}
// DOM监听
function initObserver() {
domObserver = new MutationObserver(()=>{
if(!videoDom || !document.body.contains(videoDom)) bindVideoEvent();
autoSkipPopup();
})
domObserver.observe(document.body,{childList:true,subtree:true})
}
function init() {
bindVideoEvent() || initObserver();
initHotkeys();
autoSkipPopup();
createStatusPanel();
console.log('[跨大节修复版脚本加载完成,按H查看快捷键]');
}
window.addEventListener('load', ()=>setTimeout(init, 1200));
if(['complete','interactive'].includes(document.readyState)) setTimeout(init, 1200);
})();