// ==UserScript== // @name 华医网课程自动切换工具 // @namespace https://github.com/user/auto-next-lesson-tool // @version 1.1.0 // @description 自动检测华医网视频播放状态并在结束时切换到下一节课程 // @author Your Name // @match *://*.91huayi.com/* // @match *://yxpk.91huayi.com/* // @match *://xuexi.91huayi.com/* // @match *://*.huayiwang91.com/* // @icon data:image/svg+xml, // @grant none // @license MIT // @run-at document-end // ==/UserScript== /** * 华医网课程自动切换工具 * 版本: 1.1.0 * * 本脚本专为华医网平台设计,可以自动检测视频播放状态, * 在视频播放结束后自动切换到下一节课程。 * * 支持的功能: * - 自动检测华医网视频播放状态 * - 视频结束后自动点击"下一步"或"下一课"按钮 * - 自动处理视频播放过程中的弹窗提示 * - 支持自动静音播放 * - 提供详细的日志输出 * - 支持调试模式 * * 使用说明: * 1. 安装脚本后,访问华医网学习平台 * 2. 进入任意课程播放页面 * 3. 脚本会自动启动并监控视频播放状态 * 4. 视频结束后,脚本会自动切换到下一课 */ /** * 华医网课程自动切换工具类 * @class HuaYiAutoNextLesson */ class HuaYiAutoNextLesson { /** * 构造函数 * @param {Object} options - 配置选项 * @param {number} options.switchDelay - 切换延迟时间(秒),默认2秒 * @param {number} options.checkInterval - 检查间隔时间(秒),默认1秒 * @param {boolean} options.debugMode - 调试模式,默认false * @param {boolean} options.autoMute - 自动静音,默认true * @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, // 调试模式 autoMute: true, // 自动静音 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.currentCourseTitle = ''; this.currentLessonTitle = ''; // 华医网特定的选择器 this.selectors = { // 视频元素选择器 videoElements: [ 'video', '.video-js video', '.vjs-tech', '#video-player video', '.video-container video', '.player-container video', '[id*="video"] video', '[class*="video"] video' ], // 下一课按钮选择器 nextLessonButtons: [ '.next-btn', '.next-step', '.next-lesson', '.next-button', '[id*="next"]', '[class*="next"]', '[onclick*="next"]', '[href*="next"]', 'button:contains("下一步")', 'button:contains("下一课")', 'button:contains("继续学习")', 'a:contains("下一步")', 'a:contains("下一课")', 'a:contains("继续学习")', '[id*="nextLesson"]', '[class*="nextLesson"]', '.btn-next', '.step-next', '.lesson-next' ], // 弹窗关闭按钮选择器 popupCloseButtons: [ '.close-btn', '.close', '[data-dismiss="modal"]', '.modal-close', '.popup-close', '[id*="close"]', '[class*="close"]', 'button:contains("确定")', 'button:contains("知道了")', 'button:contains("关闭")', 'button:contains("继续")' ], // 进度条选择器 progressBars: [ '.progress-bar', '.progress', '.vjs-progress-bar', '.vjs-play-progress', '[class*="progress"]', '[id*="progress"]' ], // 课程标题选择器 courseTitle: [ '.course-title', '.course-name', 'h1', 'h2', '[id*="course"]', '[class*="course"]' ], // 课时标题选择器 lessonTitle: [ '.lesson-title', '.lesson-name', 'h3', 'h4', '[id*="lesson"]', '[class*="lesson"]' ] }; this.log('华医网课程自动切换工具已初始化', 'info'); this._detectCurrentPage(); } /** * 检测当前页面类型 * @private */ _detectCurrentPage() { const url = window.location.href.toLowerCase(); if (url.includes('/course/') || url.includes('/lesson/')) { this.log('检测到课程播放页面', 'info'); this._extractPageInfo(); } else if (url.includes('/course/list') || url.includes('/courses')) { this.log('检测到课程列表页面', 'info'); } else if (url.includes('/exam/') || url.includes('/test/')) { this.log('检测到考试页面', 'info'); } else { this.log('检测到其他页面', 'info'); } } /** * 提取页面信息 * @private */ _extractPageInfo() { // 提取课程标题 for (const selector of this.selectors.courseTitle) { try { const element = document.querySelector(selector); if (element && element.textContent.trim()) { this.currentCourseTitle = element.textContent.trim(); break; } } catch (error) { this.log(`提取课程标题出错: ${error.message}`, 'debug'); } } // 提取课时标题 for (const selector of this.selectors.lessonTitle) { try { const element = document.querySelector(selector); if (element && element.textContent.trim()) { this.currentLessonTitle = element.textContent.trim(); break; } } catch (error) { this.log(`提取课时标题出错: ${error.message}`, 'debug'); } } this.log(`当前课程: ${this.currentCourseTitle || '未知'}`, 'info'); this.log(`当前课时: ${this.currentLessonTitle || '未知'}`, 'info'); } /** * 启动工具 * @returns {boolean} 是否启动成功 */ start() { if (this.isRunning || this.isDestroyed) { this.log('工具已经在运行或已被销毁', 'warning'); return false; } this.isRunning = true; this.log('工具已启动', 'success'); this._notifyStateChange(); // 开始检测视频 this._startVideoCheck(); // 显示启动通知 this._showNotification('华医网课程自动切换工具已启动', '将自动检测视频并切换到下一课', 3000); 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], currentCourseTitle: this.currentCourseTitle, currentLessonTitle: this.currentLessonTitle }; } /** * 获取切换历史 * @returns {Array} 切换历史记录数组 */ getSwitchHistory() { return [...this.switchHistory]; } /** * 查找视频元素 * @returns {HTMLElement|null} 找到的视频元素或null * @private */ _findVideoElement() { // 尝试每个视频选择器 for (const selector of this.selectors.videoElements) { 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%进度视为结束 const volume = videoElement.volume; return { exists: true, currentTime: currentTime, duration: duration, progress: progress, isPaused: isPaused, isEnded: isEnded, volume: volume }; } 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 { // 检查并关闭弹窗 this._closePopups(); // 查找视频元素 const videoElement = this._findVideoElement(); if (videoElement) { this.log('检测到视频元素', 'debug'); // 如果启用了自动静音,检查并设置静音 if (this.options.autoMute && videoElement.volume > 0) { videoElement.volume = 0; this.log('已将视频设置为静音', 'info'); } // 检查视频状态 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 */ _closePopups() { for (const selector of this.selectors.popupCloseButtons) { 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)) { element.click(); this.log(`点击了弹窗关闭按钮: ${text}`, 'info'); return; } } } else { // 处理普通选择器 const elements = document.querySelectorAll(selector); for (const element of elements) { if (this._isElementVisible(element)) { element.click(); this.log(`点击了弹窗关闭按钮: ${selector}`, 'info'); return; } } } } catch (error) { this.log(`关闭弹窗出错: ${error.message}`, 'debug'); } } } /** * 安排切换到下一课 * @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() { // 尝试各种选择器查找下一课按钮 for (const selector of this.selectors.nextLessonButtons) { 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)) { this._clickNextButton(element, text); return; } } } else { // 处理普通选择器 const elements = document.querySelectorAll(selector); for (const element of elements) { if (this._isElementVisible(element)) { this._clickNextButton(element, selector); return; } } } } catch (error) { this.log(`查找下一课按钮出错: ${error.message}`, 'debug'); } } // 如果没有找到下一课按钮,尝试查找其他可能的导航元素 this.log('未找到标准的下一课按钮,尝试查找其他导航元素', 'warning'); // 尝试查找课程列表中的下一个课时 const lessonElements = document.querySelectorAll('.lesson-item, .course-item, [class*="lesson"], [class*="course"]'); if (lessonElements.length > 0) { this.log(`找到 ${lessonElements.length} 个课时元素`, 'debug'); // 查找当前激活的课时 let currentIndex = -1; for (let i = 0; i < lessonElements.length; i++) { if (lessonElements[i].classList.contains('active') || lessonElements[i].classList.contains('current') || lessonElements[i].querySelector('.active') || lessonElements[i].querySelector('.current')) { currentIndex = i; break; } } // 如果找到当前课时,尝试点击下一个课时 if (currentIndex >= 0 && currentIndex < lessonElements.length - 1) { const nextLesson = lessonElements[currentIndex + 1]; const link = nextLesson.querySelector('a') || nextLesson; if (link) { this.log(`找到下一个课时,准备点击`, 'info'); this._clickNextButton(link, '课程列表中的下一课'); return; } } } this.log('未找到下一课按钮或链接', 'error'); // 通知错误 if (this.options.onError) { this.options.onError(new Error('未找到下一课按钮或链接')); } } /** * 点击下一课按钮 * @param {HTMLElement} button - 下一课按钮元素 * @param {string} source - 按钮来源描述 * @private */ _clickNextButton(button, source) { this.log(`准备点击下一课按钮: ${source}`, 'info'); try { // 获取按钮文本作为下一课标题 let nextLessonTitle = '未知课程'; if (button.textContent.trim()) { nextLessonTitle = button.textContent.trim(); } else if (button.getAttribute('aria-label')) { nextLessonTitle = button.getAttribute('aria-label'); } else if (button.getAttribute('title')) { nextLessonTitle = button.getAttribute('title'); } // 模拟点击 const event = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); button.dispatchEvent(event); const timestamp = new Date().toLocaleTimeString(); // 记录切换历史 const switchRecord = { fromCourse: this.currentCourseTitle, fromLesson: this.currentLessonTitle, toLesson: nextLessonTitle, timestamp: timestamp, source: source }; this.switchHistory.unshift(switchRecord); if (this.switchHistory.length > 10) { this.switchHistory.pop(); // 保留最近10条记录 } this.log(`已切换到下一课: ${nextLessonTitle}`, 'success'); // 更新当前课时标题 this.currentLessonTitle = nextLessonTitle; // 显示切换通知 this._showNotification('课程已切换', `已从"${this.currentLessonTitle}"切换到"${nextLessonTitle}"`, 3000); // 通知课程已切换 if (this.options.onLessonSwitched) { this.options.onLessonSwitched(switchRecord); } } catch (error) { this.log(`点击下一课按钮出错: ${error.message}`, 'error'); // 尝试直接点击 try { button.click(); this.log('使用直接点击方法成功', 'info'); } catch (clickError) { this.log(`直接点击也失败: ${clickError.message}`, '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} title - 通知标题 * @param {string} message - 通知消息 * @param {number} duration - 显示时长(毫秒) * @private */ _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; } } `; // 检查是否已经添加了样式 if (!document.getElementById('huayi-notification-style')) { style.id = 'huayi-notification-style'; document.head.appendChild(style); } // 设置内容 notification.innerHTML = `