我相信很多人在兰州理工大学继续教育平台(域名如gs.chinamde.cn
)刷继续教育课程时,也会因为要手动点击每个视频,等待播放完毕,再切换到下一个视频,而感到枯燥与耗时。本文记录一次我从零开始、一步步开发并完善“自动刷课” Tampermonkey 脚本的过程,这个过程,包括自动跳转大节/小节、智能判断展开状态,也能掌握如何在页面上注入日志面板、调试脚本运行状态。
免责声明:所有内容仅供学习和研究,切勿用于任何违规或作弊场景。请尊重平台规则和知识版权。
一、最初想法:切换逻辑的思考
- 场景:打开某门网课播放页后,通常需要点击列表中的小节(子视频),页面载入后播放,等待结束,再点击下一个;如果章节列表是折叠的,还需先展开大章节,再点入子视频。每次都要动鼠标,繁琐。
- 初步目标:让脚本在视频结束时,自动点击下一个子视频;若当前大章节已播完,自动展开下一个大节并点击其首个子视频;静默运行,减少人为干预。
- 第一步思考:怎样识别当前播放位置?如何找到下一个子视频?页面如何表示已展开或收起?播放结束如何监听?
二、探索页面结构:找“大节”和“小节”选择器
打开浏览器开发者工具(F12),在“Elements”面板中查看章节列表区域的 DOM。不难发现:
- 视频大节容器
- 包含一组子视频条目的节点,其外层 DOM 元素上有一个自动生成的类名:
.Play_video_item__sAMwi
。 - 我们在脚本中称它为“大章节”。
- 该节点通常包含一个标题区域(章节名称)和若干“小节”列表。
- 包含一组子视频条目的节点,其外层 DOM 元素上有一个自动生成的类名:
- 视频小节条目
- 每个具体的视频条目,其 DOM 元素带有类名:
.Play_child_item__4L1N4
。 - 点击该元素即可播放对应视频。脚本中称之为“小节”。
- 每个具体的视频条目,其 DOM 元素带有类名:
- 当前播放状态标记
- 当某个小节正在播放时,页面会给对应标题元素添加一个激活类:
.Play_active__Mb2AQ
。 - 这正是定位“当前正在播放的小节”最可靠的方法。脚本通过
document.querySelector('.Play_active__Mb2AQ')
,再closest('.Play_child_item__4L1N4')
定位父容器,找到当前所在位置。
- 当某个小节正在播放时,页面会给对应标题元素添加一个激活类:
- 大章节标题文本
- 章节名称文本所在元素类名:
.Play_video_title_text__3_Y_U
。 - 主要用于日志输出,打印当前/目标章节名称,方便调试和观察脚本行为。
- 章节名称文本所在元素类名:
- 展开/收起图标
- 大章节区域有一个可点击的展开/收起按钮,DOM 节点上的类名:
.Play_video_title_edit__BKCak
,并带有alt
属性:通常alt="收起"
表示当前已展开,alt
为其他值(或“展开”)表示当前为折叠状态。 - 为了在自动切换到下一个大章节时,确保该章节列表展开,脚本需要判断这个
alt
属性:if (alt !== '收起')
则点击展开;否则跳过展开操作。
- 大章节区域有一个可点击的展开/收起按钮,DOM 节点上的类名:
三、进阶改进:判断大章节展开状态
3.1 为什么要判断展开?
- 若平台大章节列表默认折叠,每次切换到新大章节时,需要先点击展开按钮,再查找子视频条目;否则脚本找不到
.Play_child_item__4L1N4
,导致无法跳转。 - 观察 DOM:大章节标题区域有一个展开/收起图标,类名
.Play_video_title_edit__BKCak
,带alt
属性,alt="收起"
表示已展开,否则为“折叠”状态。
3.2 修改逻辑:只在折叠时点击展开
在 playNextVideo()
中,对“找到下一个大章节”后,添加判断并展开的步骤:
SELECTORS.toggleIcon
对应.Play_video_title_edit__BKCak
;- 通过
getAttribute('alt')
判断状态,只有在alt !== '收起'
时才执行toggle.click()
;若已展开,跳过,节省无用点击。
关键代码示例:
javascriptCopyEdit// 省略前面部分...
if (nextRoot) {
const titleEl = nextRoot.querySelector(SELECTORS.rootTitle);
const title = titleEl ? titleEl.innerText.trim() : '未知章节';
appendLog(`准备切换到新大章节:${title}`);
// 判断并展开
const toggle = nextRoot.querySelector(SELECTORS.toggleIcon);
if (toggle) {
const alt = toggle.getAttribute('alt');
if (alt !== '收起') {
appendLog(`展开章节:${title}`);
toggle.click();
await sleep(500);
} else {
appendLog(`章节已展开:${title}`);
}
} else {
appendLog('⚠️ 未找到章节展开/收起图标');
}
await sleep(1000);
const firstChild = nextRoot.querySelector(SELECTORS.childChapterItem);
if (firstChild) {
appendLog('点击新大章节第一个视频');
firstChild.click();
} else {
appendLog('新大章节内未找到视频');
}
}
这样,可以保证在切换大章节时,不会因为折叠状态而失效。
四、最终完善:注入日志面板 UI,实时可视化脚本行为
虽然 console.log
在调试时很有帮助,但有时打开控制台不方便,或者想让其他同事更直观看到脚本运行状态。于是我决定将日志同步到页面右下角,形成一个“日志面板”。
4.1 UI 日志面板思路
- 注入一个固定定位的
<div>
,位于页面右下角,半透明背景、绿色等色调,模仿控制台输出风格; - 每次脚本要记录信息时,调用
appendLog(text)
:既console.log
,也把文本追加到面板内部,并自动滚动到底; - 仅在首次记录时创建面板,避免重复创建;使用 Tampermonkey 的
GM_addStyle
注入 CSS 样式,控制面板外观; - 使脚本更“傻瓜”:不需打开控制台也能看到日志提示,比如“未找到 video 元素,5秒后重试”、“展开章节:第X章”、“切换到下一个视频:XXX”等。
4.2 关键代码示例
样式注入(addUIStyle
): javascriptCopyEditfunction addUIStyle() { GM_addStyle(` #gm-log-panel { position: fixed; bottom: 10px; right: 10px; width: 300px; max-height: 400px; background: rgba(0,0,0,0.7); color: #0f0; font-family: monospace; font-size: 12px; overflow: auto; padding: 8px; border-radius: 4px; z-index: 999999; } #gm-log-panel h4 { margin: 0 0 4px; font-size: 14px; color: #fff; } #gm-log-content { white-space: pre-wrap; } `); }
创建面板(createLogPanel
): javascriptCopyEditlet logPanel; function createLogPanel() { if (logPanel) return; addUIStyle(); logPanel = document.createElement('div'); logPanel.id = 'gm-log-panel'; logPanel.innerHTML = '<h4>脚本日志</h4><div id="gm-log-content"></div>'; document.body.appendChild(logPanel); }
追加日志(appendLog
): javascriptCopyEditfunction appendLog(text) { const now = new Date().toLocaleTimeString(); const line = `[自动刷课] ${now}: ${text}`; console.log(line); if (!logPanel) createLogPanel(); const content = document.getElementById('gm-log-content'); if (content) { content.textContent += line + '\n'; logPanel.scrollTop = logPanel.scrollHeight; } }
在初始化与跳转逻辑中使用:
initialize()中:appendLog('video 找到,开始监听');
appendLog('未找到 video 元素,5秒后重试');等;
onEnded() 中:appendLog('视频播放结束,3秒后切换');
playNextVideo() 中:所有定位失败、找到下一个、展开章节、点击首个视频、全部完成等信息都用 appendLog(...) 输出。
4.3 完整脚本
javascriptCopyEdit// ==UserScript==
// @name chinamde.cn 自动刷课(带 UI)
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 自动播放并跳到下一个视频,带简单界面展示日志输出
// @author boyi
// @match *://*.chinamde.cn/*
// @grant GM_addStyle
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// ============================= 配置区 =============================
const SELECTORS = {
videoPlayer: '#mse video',
rootChapter: '.Play_video_item__sAMwi', // 视频大节容器
childChapterItem: '.Play_child_item__4L1N4', // 视频小节条目
activeChildTitle: '.Play_active__Mb2AQ', // 当前激活(播放中)小节标题
rootTitle: '.Play_video_title_text__3_Y_U', // 大节标题文本(用于日志)
toggleIcon: '.Play_video_title_edit__BKCak' // 大节展开/收起图标,检查 alt 属性
};
// ================================================================
const SCRIPT_NAME = 'chinamde 自动刷课';
let logPanel;
// --- UI 模块:日志面板 ---
function addUIStyle() {
GM_addStyle(`
#gm-log-panel {
position: fixed;
bottom: 10px;
right: 10px;
width: 300px;
max-height: 400px;
background: rgba(0,0,0,0.7);
color: #0f0;
font-family: monospace;
font-size: 12px;
overflow: auto;
padding: 8px;
border-radius: 4px;
z-index: 999999;
}
#gm-log-panel h4 {
margin: 0 0 4px;
font-size: 14px;
color: #fff;
}
#gm-log-content {
white-space: pre-wrap;
}
`);
}
function createLogPanel() {
if (logPanel) return;
addUIStyle();
logPanel = document.createElement('div');
logPanel.id = 'gm-log-panel';
logPanel.innerHTML = '<h4>脚本日志</h4><div id="gm-log-content"></div>';
document.body.appendChild(logPanel);
}
function appendLog(text) {
const now = new Date().toLocaleTimeString();
const line = `[${SCRIPT_NAME}] ${now}: ${text}`;
console.log(line);
if (!logPanel) createLogPanel();
const content = document.getElementById('gm-log-content');
if (content) {
content.textContent += line + '\n';
logPanel.scrollTop = logPanel.scrollHeight;
}
}
// --- 核心逻辑 ---
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function playNextVideo() {
const activeTitle = document.querySelector(SELECTORS.activeChildTitle);
if (!activeTitle) {
appendLog('找不到当前播放标记,跳过下一步');
return;
}
const activeItem = activeTitle.closest(SELECTORS.childChapterItem);
if (!activeItem) {
appendLog('找不到当前子章节元素,跳过下一步');
return;
}
// 尝试下一个子章节
let nextItem = activeItem.nextElementSibling;
if (nextItem && nextItem.matches(SELECTORS.childChapterItem)) {
const name = nextItem.textContent.split('\n')[0].trim();
appendLog(`切换到下一个视频:${name}`);
nextItem.click();
return;
}
// 大章节切换
appendLog('当前小节已播完,查找下一个大章节...');
const currentRoot = activeItem.closest(SELECTORS.rootChapter);
if (!currentRoot) {
appendLog('找不到当前大章节容器');
return;
}
let nextRoot = currentRoot.nextElementSibling;
while (nextRoot && !nextRoot.matches(SELECTORS.rootChapter)) {
nextRoot = nextRoot.nextElementSibling;
}
if (nextRoot) {
const titleEl = nextRoot.querySelector(SELECTORS.rootTitle);
const title = titleEl ? titleEl.innerText.trim() : '未知章节';
appendLog(`准备切换到新大章节:${title}`);
// 确保大章节已展开:alt="收起" 表示已展开
const toggle = nextRoot.querySelector(SELECTORS.toggleIcon);
if (toggle) {
const alt = toggle.getAttribute('alt');
if (alt !== '收起') {
appendLog(`展开章节:${title}`);
toggle.click();
await sleep(500);
} else {
appendLog(`章节已展开:${title}`);
}
} else {
appendLog('⚠️ 未找到章节展开/收起图标');
}
await sleep(1000);
const firstChild = nextRoot.querySelector(SELECTORS.childChapterItem);
if (firstChild) {
appendLog('点击新大章节第一个视频');
firstChild.click();
} else {
appendLog('新大章节内未找到视频');
}
} else {
appendLog('所有课程已播放完毕');
}
}
function initialize() {
createLogPanel();
const video = document.querySelector(SELECTORS.videoPlayer);
if (!video) {
appendLog('未找到 video 元素,5秒后重试');
setTimeout(initialize, 5000);
return;
}
appendLog('video 找到,开始监听');
video.muted = true;
video.play().catch(() => appendLog('自动播放被浏览器拦截,请手动点击播放'));
video.removeEventListener('ended', onEnded);
video.addEventListener('ended', onEnded);
}
async function onEnded() {
appendLog('视频播放结束,3秒后切换');
await sleep(3000);
playNextVideo();
}
// SPA 路由支持:监听 DOM 变动后重新初始化
const observer = new MutationObserver(() => {
clearTimeout(window._gm_debounce);
window._gm_debounce = setTimeout(initialize, 500);
});
observer.observe(document.body, { childList: true, subtree: true });
// 启动脚本
setTimeout(initialize, 2000);
})();

结语
该脚本需要油猴管理器,TamperMonkey
或者暴力猴
作为运行环境,推荐Edge浏览器插件中心装一个TamperMonkey
,然后导入完整代码,即可刷新网课平台使用。
脚本难免会有部分BUG,如不影响,请忽略显示错误等。