// ==UserScript== // @name 课程自动切换工具 // @namespace https://github.com/user/auto-next-lesson-tool // @version 1.0.0 // @description 自动检测视频播放状态并在结束时切换到下一节课程 // @author Your Name // @match *://*.example.com/* // @match *://*.learning-platform.com/* // @match *://*.online-course.com/* // @icon data:image/svg+xml, // @grant none // @license MIT // @run-at document-end // ==/UserScript== /** * 课程自动切换工具 - 脚本猫版本 * 版本: 1.0.0 * * 本脚本可以在支持的在线学习平台上自动检测视频播放状态, * 并在视频播放结束后自动切换到下一节课程。 * * 支持的平台: * - 通用视频网站(包含标准video元素的页面) * - 常见在线学习平台 * * 使用说明: * 1. 安装脚本后,访问支持的学习平台 * 2. 脚本会自动检测页面中的视频元素 * 3. 当视频播放结束时,脚本会自动查找并点击"下一课"按钮 * 4. 可以通过控制台查看详细日志(控制台输入: AutoNextLesson.debugMode = true) */ /** * 课程自动切换工具类 * @class AutoNextLesson */ class AutoNextLesson { /** * 构造函数 * @param {Object} options - 配置选项 * @param {number} options.switchDelay - 切换延迟时间(秒),默认2秒 * @param {number} options.checkInterval - 检查间隔时间(秒),默认1秒 * @param {boolean} options.debugMode - 调试模式,默认false * @param {Function} options.onStateChange - 状态变化回调 * @param {Function} options.onVideoDetected - 视频检测回调 * @param {Function} options.onVideoEnded - 视频结束回调 * @param {Function} options.onLessonSwitched - 课程切换回调 * @param {Function} options.onError - 错误回调 */ constructor(options = {}) { this.options = { switchDelay: 2, // 切换延迟时间(秒) checkInterval: 1, // 检查间隔时间(秒) debugMode: false, // 调试模式 onStateChange: null, // 状态变化回调 onVideoDetected: null, // 视频检测回调 onVideoEnded: null, // 视频结束回调 onLessonSwitched: null, // 课程切换回调 onError: null // 错误回调 }; // 合并用户配置 Object.assign(this.options, options); this.isRunning = false; this.isDestroyed = false; this.checkTimer = null; this.switchTimer = null; this.lastVideoState = null; this.switchHistory = []; this.platformName = this._detectPlatform(); this.log(`工具已初始化,检测到平台: ${this.platformName}`, 'info'); } /** * 启动工具 * @returns {boolean} 是否启动成功 */ start() { if (this.isRunning || this.isDestroyed) { this.log('工具已经在运行或已被销毁', 'warning'); return false; } this.isRunning = true; this.log('工具已启动', 'success'); this._notifyStateChange(); // 开始检测视频 this._startVideoCheck(); return true; } /** * 停止工具 * @returns {boolean} 是否停止成功 */ stop() { if (!this.isRunning || this.isDestroyed) { this.log('工具未运行或已被销毁', 'warning'); return false; } this.isRunning = false; this._clearTimers(); this.log('工具已停止', 'info'); this._notifyStateChange(); return true; } /** * 销毁工具 * @returns {boolean} 是否销毁成功 */ destroy() { if (this.isDestroyed) { this.log('工具已被销毁', 'warning'); return false; } this.stop(); this.isDestroyed = true; this.log('工具已销毁', 'info'); this._notifyStateChange(); return true; } /** * 更新配置 * @param {Object} newOptions - 新的配置选项 * @returns {boolean} 是否更新成功 */ updateOptions(newOptions) { if (this.isDestroyed) { this.log('工具已被销毁,无法更新配置', 'error'); return false; } Object.assign(this.options, newOptions); this.log('配置已更新: ' + JSON.stringify(newOptions), 'info'); // 如果工具正在运行,重新启动检测 if (this.isRunning) { this._clearTimers(); this._startVideoCheck(); } return true; } /** * 获取当前状态 * @returns {Object} 当前状态对象 */ getState() { return { isRunning: this.isRunning, isDestroyed: this.isDestroyed, options: { ...this.options }, switchHistory: [...this.switchHistory], platformName: this.platformName }; } /** * 获取切换历史 * @returns {Array} 切换历史记录数组 */ getSwitchHistory() { return [...this.switchHistory]; } /** * 检测当前平台 * @returns {string} 平台名称 * @private */ _detectPlatform() { const url = window.location.href.toLowerCase(); // 检测常见的在线学习平台 if (url.includes('coursera.org')) return 'Coursera'; if (url.includes('edx.org')) return 'edX'; if (url.includes('udemy.com')) return 'Udemy'; if (url.includes('imooc.com')) return '慕课网'; if (url.includes('icourse163.org')) return '中国大学MOOC'; if (url.includes('study.163.com')) return '网易云课堂'; if (url.includes('ke.qq.com')) return '腾讯课堂'; if (url.includes('zhihuishu.com')) return '智慧树'; if (url.includes('chaoxing.com')) return '超星学习通'; if (url.includes('xuetangx.com')) return '学堂在线'; // 检测视频网站 if (url.includes('youtube.com') || url.includes('youtu.be')) return 'YouTube'; if (url.includes('bilibili.com')) return '哔哩哔哩'; if (url.includes('iqiyi.com')) return '爱奇艺'; if (url.includes('youku.com')) return '优酷'; if (url.includes('tudou.com')) return '土豆'; // 默认返回通用平台 return '通用平台'; } /** * 根据平台获取特定的视频选择器 * @returns {Array} 视频选择器数组 * @private */ _getVideoSelectors() { // 通用视频选择器 const commonSelectors = [ 'video', '.video-container video', '.player-container video', '.video-player video', 'video[src]', 'video[poster]' ]; // 平台特定的视频选择器 const platformSelectors = { 'Coursera': [ '.rc-VideoPlayer video', '.video-player video' ], 'edX': [ '.video-player video', '.video-container video' ], 'Udemy': [ '.video-player video', '.udemy-video-player video' ], '慕课网': [ '.video-container video', '.mooc-video video' ], '中国大学MOOC': [ '.video-player video', '.mooc-player video' ], '网易云课堂': [ '.video-container video', '.study-video video' ], '腾讯课堂': [ '.video-player video', '.tencent-player video' ], '智慧树': [ '.video-container video', '.zhihuishu-player video' ], '超星学习通': [ '.video-player video', '.chaoxing-player video' ], '学堂在线': [ '.video-container video', '.xuetang-player video' ], 'YouTube': [ 'video.html5-main-video', '.html5-video-player video' ], '哔哩哔哩': [ '.bilibili-player video', 'video.bilibili-player-video' ], '爱奇艺': [ '.iqp-player video', '.iqiyi-player video' ], '优酷': [ '.youku-player video', '.yk-player video' ], '土豆': [ '.tudou-player video', '.td-player video' ] }; // 返回平台特定选择器和通用选择器的组合 return [...(platformSelectors[this.platformName] || []), ...commonSelectors]; } /** * 根据平台获取特定的下一课按钮选择器 * @returns {Array} 下一课按钮选择器数组 * @private */ _getNextLessonSelectors() { // 通用下一课按钮选择器 const commonSelectors = [ '.next-lesson', '.next-button', '.next-btn', '.next-step', '.next-chapter', '.next-unit', '[data-action="next"]', '[aria-label*="下一课"]', '[aria-label*="下一个"]', '[aria-label*="Next"]', '[aria-label*="Next lesson"]', '[title*="下一课"]', '[title*="下一个"]', '[title*="Next"]', '[title*="Next lesson"]', 'button:contains("下一课")', 'button:contains("下一个")', 'button:contains("Next")', 'button:contains("Next lesson")', 'a:contains("下一课")', 'a:contains("下一个")', 'a:contains("Next")', 'a:contains("Next lesson")' ]; // 平台特定的下一课按钮选择器 const platformSelectors = { 'Coursera': [ '.rc-WeekNavigation-next', '.next-lecture-button', '[data-e2e="next-lecture-button"]' ], 'edX': [ '.sequence-nav-next', '.next-sequence-button', '[data-position="next"]' ], 'Udemy': [ '.next-unit-button', '.udemy-next-button', '[data-purpose="next-unit"]' ], '慕课网': [ '.next-lesson-btn', '.mooc-next-button' ], '中国大学MOOC': [ '.next-chapter-btn', '.mooc-next-button' ], '网易云课堂': [ '.next-course-btn', '.study-next-button' ], '腾讯课堂': [ '.next-lesson-btn', '.tencent-next-button' ], '智慧树': [ '.next-chapter-btn', '.zhihuishu-next-button' ], '超星学习通': [ '.next-lesson-btn', '.chaoxing-next-button' ], '学堂在线': [ '.next-course-btn', '.xuetang-next-button' ] }; // 返回平台特定选择器和通用选择器的组合 return [...(platformSelectors[this.platformName] || []), ...commonSelectors]; } /** * 查找视频元素 * @returns {HTMLElement|null} 找到的视频元素或null * @private */ _findVideoElement() { const selectors = this._getVideoSelectors(); // 尝试每个选择器 for (const selector of selectors) { try { const elements = document.querySelectorAll(selector); if (elements.length > 0) { this.log(`使用选择器 "${selector}" 找到 ${elements.length} 个视频元素`, 'debug'); // 返回第一个可见的视频元素 for (const element of elements) { if (this._isElementVisible(element)) { return element; } } } } catch (error) { this.log(`选择器 "${selector}" 执行出错: ${error.message}`, 'debug'); } } // 如果没有找到视频元素,尝试查找其他可能的视频播放元素 const videoContainers = document.querySelectorAll('.video-container, .player-container, .video-player'); if (videoContainers.length > 0) { this.log(`找到 ${videoContainers.length} 个视频容器`, 'debug'); // 尝试在容器内查找视频元素 for (const container of videoContainers) { const containerVideo = container.querySelector('video'); if (containerVideo && this._isElementVisible(containerVideo)) { this.log('在视频容器内找到视频元素', 'debug'); return containerVideo; } } } return null; } /** * 检查元素是否可见 * @param {HTMLElement} element - 要检查的元素 * @returns {boolean} 元素是否可见 * @private */ _isElementVisible(element) { // 检查元素是否在DOM中 if (!document.body.contains(element)) { return false; } // 检查元素的尺寸 const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return false; } // 检查元素是否被隐藏 const computedStyle = window.getComputedStyle(element); if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden' || computedStyle.opacity === '0') { return false; } // 检查元素是否在视口中 const isInViewport = ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); return isInViewport; } /** * 检查视频状态 * @param {HTMLElement} videoElement - 视频元素 * @returns {Object} 视频状态对象 * @private */ _checkVideoState(videoElement) { try { // 检查视频元素的基本属性 const currentTime = videoElement.currentTime || 0; const duration = videoElement.duration || 0; const progress = duration > 0 ? currentTime / duration : 0; const isPaused = videoElement.paused; const isEnded = videoElement.ended || progress >= 0.98; // 98%进度视为结束 return { exists: true, currentTime: currentTime, duration: duration, progress: progress, isPaused: isPaused, isEnded: isEnded }; } catch (error) { this.log(`检查视频状态出错: ${error.message}`, 'error'); return { exists: false, error: error.message }; } } /** * 开始视频检测 * @private */ _startVideoCheck() { if (!this.isRunning) return; this.log(`开始检测视频,检查间隔: ${this.options.checkInterval}秒`, 'debug'); const checkVideo = () => { if (!this.isRunning) return; try { // 查找视频元素 const videoElement = this._findVideoElement(); if (videoElement) { this.log('检测到视频元素', 'debug'); // 检查视频状态 const videoState = this._checkVideoState(videoElement); // 通知视频检测状态 if (this.options.onVideoDetected) { this.options.onVideoDetected(videoState); } // 检查视频是否结束 if (videoState.isEnded && this.lastVideoState && !this.lastVideoState.isEnded) { this.log('检测到视频播放结束', 'info'); // 通知视频结束 if (this.options.onVideoEnded) { this.options.onVideoEnded(videoState); } // 延迟切换到下一课 this._scheduleSwitch(); } this.lastVideoState = videoState; } else { this.log('未检测到视频元素', 'debug'); // 通知未检测到视频 if (this.options.onVideoDetected) { this.options.onVideoDetected({ exists: false }); } this.lastVideoState = null; } } catch (error) { this.log(`视频检测出错: ${error.message}`, 'error'); // 通知错误 if (this.options.onError) { this.options.onError(error); } } // 继续检测 this.checkTimer = setTimeout(checkVideo, this.options.checkInterval * 1000); }; // 立即开始第一次检测 checkVideo(); } /** * 安排切换到下一课 * @private */ _scheduleSwitch() { if (!this.isRunning) return; this.log(`将在 ${this.options.switchDelay} 秒后切换到下一课`, 'info'); this.switchTimer = setTimeout(() => { if (!this.isRunning) return; try { // 切换到下一课 this._switchToNextLesson(); } catch (error) { this.log(`切换课程出错: ${error.message}`, 'error'); // 通知错误 if (this.options.onError) { this.options.onError(error); } } }, this.options.switchDelay * 1000); } /** * 切换到下一课 * @private */ _switchToNextLesson() { const selectors = this._getNextLessonSelectors(); let nextButton = null; // 尝试各种选择器查找下一课按钮 for (const selector of selectors) { try { // 处理:contains选择器 if (selector.includes(':contains(')) { const text = selector.match(/:contains\("([^"]+)"\)/)[1]; const baseSelector = selector.replace(/:contains\("([^"]+)"\)/, ''); const elements = document.querySelectorAll(baseSelector); for (const element of elements) { if (element.textContent.includes(text) && this._isElementVisible(element)) { nextButton = element; this.log(`使用文本匹配 "${text}" 找到下一课按钮`, 'debug'); break; } } } else { // 处理普通选择器 const elements = document.querySelectorAll(selector); if (elements.length > 0) { // 找到第一个可见的按钮 for (const element of elements) { if (this._isElementVisible(element)) { nextButton = element; this.log(`使用选择器 "${selector}" 找到下一课按钮`, 'debug'); break; } } } } if (nextButton) break; } catch (error) { this.log(`选择器 "${selector}" 执行出错: ${error.message}`, 'debug'); } } // 如果找到了下一课按钮,点击它 if (nextButton) { // 获取按钮文本或属性作为课程标题 let lessonTitle = '未知课程'; if (nextButton.textContent.trim()) { lessonTitle = nextButton.textContent.trim(); } else if (nextButton.getAttribute('aria-label')) { lessonTitle = nextButton.getAttribute('aria-label'); } else if (nextButton.getAttribute('title')) { lessonTitle = nextButton.getAttribute('title'); } this.log(`准备点击下一课按钮: ${lessonTitle}`, 'info'); try { // 模拟点击 const event = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); nextButton.dispatchEvent(event); const timestamp = new Date().toLocaleTimeString(); // 记录切换历史 const switchRecord = { lesson: lessonTitle, timestamp: timestamp, platform: this.platformName }; this.switchHistory.unshift(switchRecord); if (this.switchHistory.length > 10) { this.switchHistory.pop(); // 保留最近10条记录 } this.log(`已切换到下一课: ${lessonTitle}`, 'success'); // 通知课程已切换 if (this.options.onLessonSwitched) { this.options.onLessonSwitched(switchRecord); } } catch (error) { this.log(`点击下一课按钮出错: ${error.message}`, 'error'); // 尝试直接点击 try { nextButton.click(); this.log('使用直接点击方法成功', 'info'); } catch (clickError) { this.log(`直接点击也失败: ${clickError.message}`, 'error'); } } } else { this.log('未找到下一课按钮', 'warning'); // 尝试查找下一页链接 const nextPageLinks = [ '.pagination .next', '.pager .next', '[rel="next"]', 'a:contains("下一页")', 'a:contains("Next page")' ]; for (const selector of nextPageLinks) { try { const elements = document.querySelectorAll(selector); if (elements.length > 0) { const nextPageLink = elements[0]; if (this._isElementVisible(nextPageLink)) { this.log(`找到下一页链接,尝试导航`, 'info'); try { nextPageLink.click(); const timestamp = new Date().toLocaleTimeString(); // 记录切换历史 const switchRecord = { lesson: '下一页', timestamp: timestamp, platform: this.platformName }; this.switchHistory.unshift(switchRecord); if (this.switchHistory.length > 10) { this.switchHistory.pop(); } // 通知课程已切换 if (this.options.onLessonSwitched) { this.options.onLessonSwitched(switchRecord); } return; } catch (error) { this.log(`点击下一页链接出错: ${error.message}`, 'error'); } } } } catch (error) { this.log(`下一页选择器 "${selector}" 执行出错: ${error.message}`, 'debug'); } } this.log('未找到下一页链接', 'error'); // 通知错误 if (this.options.onError) { this.options.onError(new Error('未找到下一课按钮或链接')); } } } /** * 清除所有定时器 * @private */ _clearTimers() { if (this.checkTimer) { clearTimeout(this.checkTimer); this.checkTimer = null; } if (this.switchTimer) { clearTimeout(this.switchTimer); this.switchTimer = null; } } /** * 通知状态变化 * @private */ _notifyStateChange() { if (this.options.onStateChange) { this.options.onStateChange(this.getState()); } } /** * 记录日志 * @param {string} message - 日志消息 * @param {string} level - 日志级别 (info, success, warning, error, debug) * @private */ log(message, level = 'info') { // 调试模式下显示所有日志,否则只显示非调试日志 if (level === 'debug' && !this.options.debugMode) { return; } const timestamp = new Date().toLocaleTimeString(); const logEntry = { message: message, level: level, timestamp: timestamp, platform: this.platformName }; // 输出到控制台 const consoleMethod = level === 'error' ? 'error' : level === 'warning' ? 'warn' : level === 'success' ? 'log' : level === 'debug' ? 'debug' : 'info'; console[consoleMethod](`[AutoNextLesson v1.0.0] [${this.platformName}] [${timestamp}] ${message}`); // 触发自定义事件,以便UI可以监听并显示日志 if (typeof document !== 'undefined') { try { const event = new CustomEvent('autonextlesson:log', { detail: logEntry }); document.dispatchEvent(event); } catch (error) { console.error(`[AutoNextLesson] 触发日志事件失败: ${error.message}`); } } } } // 创建全局实例 let autoNextLessonInstance = null; // 初始化函数 function initAutoNextLesson() { // 检查是否已经初始化 if (autoNextLessonInstance) { console.log('[AutoNextLesson v1.0.0] 工具已经初始化'); return; } // 创建实例 autoNextLessonInstance = new AutoNextLesson({ switchDelay: 2, checkInterval: 1, debugMode: false, onStateChange: function(state) { console.log(`[AutoNextLesson v1.0.0] 状态变化: ${state.isRunning ? '运行中' : '已停止'}`); }, onVideoDetected: function(videoState) { if (videoState.exists) { const progressPercent = Math.round(videoState.progress * 100); console.log(`[AutoNextLesson v1.0.0] 检测到视频,进度: ${progressPercent}%`); } else { console.log('[AutoNextLesson v1.0.0] 未检测到视频'); } }, onVideoEnded: function(videoState) { console.log('[AutoNextLesson v1.0.0] 视频已结束,准备切换到下一课'); }, onLessonSwitched: function(switchRecord) { console.log(`[AutoNextLesson v1.0.0] 已切换到: ${switchRecord.lesson}`); }, onError: function(error) { console.error(`[AutoNextLesson v1.0.0] 错误: ${error.message}`); } }); // 启动工具 autoNextLessonInstance.start(); // 在页面上显示提示信息 showNotification('课程自动切换工具已启动', '将自动检测视频并切换到下一课', 3000); console.log('[AutoNextLesson v1.0.0] 工具已初始化并启动'); console.log('[AutoNextLesson v1.0.0] 提示: 在控制台输入 AutoNextLesson.debugMode = true 开启调试模式'); } // 显示通知函数 function showNotification(title, message, duration = 3000) { // 创建通知元素 const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #fff; border-left: 4px solid #3b82f6; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); padding: 12px 16px; border-radius: 4px; max-width: 300px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; animation: slideIn 0.3s ease-out; `; // 添加样式 const style = document.createElement('style'); style.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(style); // 设置内容 notification.innerHTML = `