我相信很多人在兰州理工大学继续教育平台(域名如gs.chinamde.cn)刷继续教育课程时,也会因为要手动点击每个视频,等待播放完毕,再切换到下一个视频,而感到枯燥与耗时。本文记录一次我从零开始、一步步开发并完善“自动刷课” Tampermonkey 脚本的过程,这个过程,包括自动跳转大节/小节、智能判断展开状态,也能掌握如何在页面上注入日志面板、调试脚本运行状态。

免责声明:所有内容仅供学习和研究,切勿用于任何违规或作弊场景。请尊重平台规则和知识版权。

一、最初想法:切换逻辑的思考

  • 场景:打开某门网课播放页后,通常需要点击列表中的小节(子视频),页面载入后播放,等待结束,再点击下一个;如果章节列表是折叠的,还需先展开大章节,再点入子视频。每次都要动鼠标,繁琐。
  • 初步目标:让脚本在视频结束时,自动点击下一个子视频;若当前大章节已播完,自动展开下一个大节并点击其首个子视频;静默运行,减少人为干预。
  • 第一步思考:怎样识别当前播放位置?如何找到下一个子视频?页面如何表示已展开或收起?播放结束如何监听?

二、探索页面结构:找“大节”和“小节”选择器

打开浏览器开发者工具(F12),在“Elements”面板中查看章节列表区域的 DOM。不难发现:

  1. 视频大节容器
    • 包含一组子视频条目的节点,其外层 DOM 元素上有一个自动生成的类名:.Play_video_item__sAMwi
    • 我们在脚本中称它为“大章节”。
    • 该节点通常包含一个标题区域(章节名称)和若干“小节”列表。
  2. 视频小节条目
    • 每个具体的视频条目,其 DOM 元素带有类名:.Play_child_item__4L1N4
    • 点击该元素即可播放对应视频。脚本中称之为“小节”。
  3. 当前播放状态标记
    • 当某个小节正在播放时,页面会给对应标题元素添加一个激活类:.Play_active__Mb2AQ
    • 这正是定位“当前正在播放的小节”最可靠的方法。脚本通过 document.querySelector('.Play_active__Mb2AQ'),再 closest('.Play_child_item__4L1N4') 定位父容器,找到当前所在位置。
  4. 大章节标题文本
    • 章节名称文本所在元素类名:.Play_video_title_text__3_Y_U
    • 主要用于日志输出,打印当前/目标章节名称,方便调试和观察脚本行为。
  5. 展开/收起图标
    • 大章节区域有一个可点击的展开/收起按钮,DOM 节点上的类名:.Play_video_title_edit__BKCak,并带有 alt 属性:通常 alt="收起" 表示当前已展开,alt 为其他值(或“展开”)表示当前为折叠状态。
    • 为了在自动切换到下一个大章节时,确保该章节列表展开,脚本需要判断这个 alt 属性:if (alt !== '收起') 则点击展开;否则跳过展开操作。

三、进阶改进:判断大章节展开状态

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,如不影响,请忽略显示错误等。