您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动播放福建网络干部学院课程视频,支持自动切换章节和科目,自动保存进度,自动处理token
// ==UserScript== // @name 福建网络干部学院课程自动播放 // @name:en Fujian Cadre Network College Course Auto Player // @namespace https://greasyforks.org/users/1410751-liuxing7954 // @version 2.2.0 // @description 自动播放福建网络干部学院课程视频,支持自动切换章节和科目,自动保存进度,自动处理token // @description:en Auto play Fujian Cadre Network College course videos, auto switch chapters and subjects, auto save progress, auto handle token // @author liuxing7954 // @match *://www.fsa.gov.cn/* // @match https://www.fsa.gov.cn/video* // @match *://www.fsa.gov.cn/videoChoose* // @match https://www.fsa.gov.cn/videoChoose* // @license MIT // @icon https://www.fsa.gov.cn/favicon.ico // @grant none // @run-at document-end // ==/UserScript== (function () { 'use strict'; /** * 应用程序常量配置 * 集中管理所有配置项,便于维护和修改 */ const CONSTANTS = { /** * localStorage存储键值配置 * 用于持久化保存用户设置和应用状态 */ STORAGE_KEYS: { DEBUG_MODE: 'debugMode', // 调试模式开关状态 COURSE_MODE: 'courseMode', // 课程模式:elective(选修) / required(必修) STUDENT_ID: 'studentId', // 学生ID,用于API请求 TOKEN: 'wlxytk', // 网络学院认证token REFRESH_TOKEN: 'rt', // 刷新token,用于续期认证 CONSOLE_POSITION: 'console_position', // 控制台窗口位置 PANEL_POSITION: 'config-panel_position' // 配置面板窗口位置 }, /** * 时间间隔配置(毫秒) * 控制各种定时任务的执行频率 */ INTERVALS: { DEBUG: 5000, // 调试模式下的主循环间隔(5秒,便于调试) NORMAL: 20000, // 正常模式下的主循环间隔(20秒,减少服务器负载) SAVE_PROGRESS: 2000, // 保存进度的延迟时间(2秒) INITIALIZATION: 5000, // 页面初始化等待时间(5秒) VIDEO_CHECK: 1000, // 检查视频元素的间隔(1秒) PAGE_RELOAD: 5000 // 页面重新加载延迟(5秒) }, /** * 进度相关配置 * 控制课程完成度的判断标准 */ PROGRESS: { TARGET: 100, // 章节完成的目标进度百分比 MAX_VIDEO_ATTEMPTS: 10 // 等待视频加载的最大尝试次数 }, /** * 用户界面配置 * 控制UI组件的显示效果 */ UI: { MAX_LOG_LINES_DEBUG: 20, // 调试模式下控制台最大日志行数 MAX_LOG_LINES_NORMAL: 10, // 正常模式下控制台最大日志行数 NOTIFICATION_DURATION: 2000 // 通知消息显示时长(2秒) }, /** * API接口地址配置 * 福建网络干部学院的课程数据接口 */ API: { // 选修课程列表接口 ELECTIVE: 'https://www.fsa.gov.cn/api/study/my/elective/myElectives', // 必修课程列表接口 REQUIRED: 'https://www.fsa.gov.cn/api/study/years/yearsCourseware/annualPortalCourseList' }, /** * DOM选择器配置 * 用于定位页面中的关键元素 */ SELECTORS: { VIDEO: 'video', // 视频播放元素 CURRENT_COURSE: '.kc-list li h5[style*="color: rgb(166, 0, 0)"]', // 当前选中的课程(红色标题) CHAPTERS: '.kc-list li', // 所有章节列表 PROGRESS_SPAN: '.kc-info span:last-child', // 章节进度显示文本 SAVE_BUTTON: 'button.el-button.btn span:first-child', // 保存进度按钮 COURSE_TITLE: 'h5', // 课程标题元素 // 课程选择页面的第一个课程选项 FIRST_COURSE: '#app > div.video-page-main > div.page-content > div > div.choose-body > div:nth-child(2) > span.choose-content', // 文章阅读页面的状态显示 READING_STATUS: '#app > div.index-content > div.page-content > div.time-d-box > div > span', START_READING_BUTTON: 'button:nth-child(2)' // 开始阅读按钮 } }; /** * 拖拽管理器 - 处理元素拖拽功能 * 提供通用的元素拖拽能力,支持位置记忆和边界检测 */ class DragManager { /** * 构造函数 - 初始化拖拽状态 */ constructor() { this.isDragging = false; // 是否正在拖拽 this.currentElement = null; // 当前被拖拽的元素 this.startX = 0; // 拖拽开始时的鼠标X坐标 this.startY = 0; // 拖拽开始时的鼠标Y坐标 this.elementStartX = 0; // 拖拽开始时元素的X坐标 this.elementStartY = 0; // 拖拽开始时元素的Y坐标 this.originalStyles = {}; // 保存元素原始样式,用于拖拽结束后恢复 } /** * 使元素可拖拽 * @param {HTMLElement} element - 要拖拽的目标元素 * @param {HTMLElement} handle - 拖拽手柄元素(通常是标题栏) * @param {string|null} storageKey - localStorage存储键,用于记忆位置 */ makeDraggable(element, handle, storageKey = null) { if (!element || !handle) return; // 恢复上次保存的位置 if (storageKey) { this.restorePosition(element, storageKey); } // 设置拖拽手柄样式 handle.style.cursor = 'move'; // 显示移动光标 handle.style.userSelect = 'none'; // 禁止文本选择 // 绑定鼠标按下事件 - 开始拖拽 handle.addEventListener('mousedown', (e) => { e.preventDefault(); // 阻止默认行为(如文本选择) this.startDrag(e, element, storageKey); }); // 全局鼠标移动事件 - 拖拽过程 document.addEventListener('mousemove', (e) => { if (this.isDragging && this.currentElement === element) { this.drag(e); } }); // 全局鼠标释放事件 - 结束拖拽 document.addEventListener('mouseup', () => { if (this.isDragging && this.currentElement === element) { this.stopDrag(storageKey); } }); } /** * 开始拖拽操作 * @param {MouseEvent} e - 鼠标按下事件对象 * @param {HTMLElement} element - 被拖拽的目标元素 * @param {string} storageKey - localStorage存储键,用于保存位置 */ startDrag(e, element, storageKey) { this.isDragging = true; // 标记为正在拖拽状态 this.currentElement = element; // 记录当前拖拽的元素 // 记录拖拽开始时的鼠标坐标(相对于视口) this.startX = e.clientX; this.startY = e.clientY; // 获取元素当前在屏幕上的绝对位置 const rect = element.getBoundingClientRect(); this.elementStartX = rect.left; // 元素左边距离视口左边的距离 this.elementStartY = rect.top; // 元素上边距离视口上边的距离 // 保存元素的原始样式,确保拖拽结束后能完全恢复 this.originalStyles = { transition: element.style.transition, // 过渡动画 zIndex: element.style.zIndex, // 层级 opacity: element.style.opacity, // 透明度 transform: element.style.transform, // 变换效果 filter: element.style.filter // 滤镜效果(包含backdrop-filter) }; // 应用拖拽时的视觉反馈样式 element.style.transition = 'none'; // 暂时禁用过渡动画,确保拖拽流畅 element.style.zIndex = '10002'; // 提升层级,确保在最上层显示 element.style.opacity = '0.9'; // 轻微透明,提示正在拖拽 element.style.transform = 'scale(1.02)'; // 轻微放大,增强视觉反馈 // 在原有滤镜基础上添加投影效果,保持现有的毛玻璃效果 const currentFilter = element.style.filter || ''; element.style.filter = currentFilter + ' drop-shadow(0 10px 30px rgba(0,0,0,0.3))'; // 改变全局光标样式,提示用户正在拖拽操作 document.body.style.cursor = 'move'; // 显示移动光标 document.body.style.userSelect = 'none'; // 禁止文本选择,避免拖拽时误选 } /** * 拖拽过程中的位置更新 * @param {MouseEvent} e - 鼠标移动事件对象 */ drag(e) { if (!this.isDragging || !this.currentElement) return; // 计算鼠标相对于拖拽开始位置的移动距离 const deltaX = e.clientX - this.startX; // X轴移动距离 const deltaY = e.clientY - this.startY; // Y轴移动距离 // 根据移动距离计算元素的新位置 let newX = this.elementStartX + deltaX; let newY = this.elementStartY + deltaY; // 边界检测 - 防止元素被拖拽到屏幕可视区域外 const element = this.currentElement; const rect = element.getBoundingClientRect(); const maxX = window.innerWidth - rect.width; // 最大X坐标(右边界) const maxY = window.innerHeight - rect.height; // 最大Y坐标(下边界) // 限制坐标范围:左上角不能超出(0,0),右下角不能超出屏幕边界 newX = Math.max(0, Math.min(newX, maxX)); newY = Math.max(0, Math.min(newY, maxY)); // 将计算后的新位置应用到元素上 element.style.left = newX + 'px'; // 设置左边距 element.style.top = newY + 'px'; // 设置上边距 element.style.right = 'auto'; // 清除右边距定位 element.style.bottom = 'auto'; // 清除下边距定位 } /** * 结束拖拽操作,恢复元素状态 * @param {string} storageKey - localStorage存储键,用于保存最终位置 */ stopDrag(storageKey) { if (!this.isDragging || !this.currentElement) return; const element = this.currentElement; // 恢复所有原始样式,确保不破坏元素的原有外观 element.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; // 添加平滑过渡动画 element.style.zIndex = this.originalStyles.zIndex || ''; // 恢复层级 element.style.opacity = this.originalStyles.opacity || ''; // 恢复透明度 element.style.transform = this.originalStyles.transform || '';// 恢复变换效果 element.style.filter = this.originalStyles.filter || ''; // 恢复滤镜效果(重要!保持毛玻璃效果) // 恢复全局光标和选择状态 document.body.style.cursor = ''; // 恢复默认光标 document.body.style.userSelect = ''; // 恢复文本选择功能 // 将当前位置保存到localStorage,下次页面加载时自动恢复 if (storageKey) { this.savePosition(element, storageKey); } // 重置拖拽管理器的内部状态 this.isDragging = false; // 标记拖拽结束 this.currentElement = null; // 清空当前元素引用 this.originalStyles = {}; // 清空样式缓存 } /** * 保存元素当前位置到localStorage * @param {HTMLElement} element - 要保存位置的元素 * @param {string} storageKey - localStorage存储键前缀 */ savePosition(element, storageKey) { const rect = element.getBoundingClientRect(); const position = { left: rect.left, // 元素距离视口左边的距离 top: rect.top // 元素距离视口上边的距离 }; // 将位置信息序列化并保存到localStorage StorageManager.set(`${storageKey}_position`, JSON.stringify(position)); } /** * 从localStorage恢复元素位置 * @param {HTMLElement} element - 要恢复位置的元素 * @param {string} storageKey - localStorage存储键前缀 */ restorePosition(element, storageKey) { const savedPosition = StorageManager.get(`${storageKey}_position`); if (savedPosition) { try { // 解析保存的位置信息 const position = JSON.parse(savedPosition); // 应用保存的位置,使用fixed定位 element.style.left = position.left + 'px'; // 设置左边距 element.style.top = position.top + 'px'; // 设置上边距 element.style.right = 'auto'; // 清除右边距定位 element.style.bottom = 'auto'; // 清除下边距定位 } catch (error) { console.warn('Failed to restore position:', error); } } } } /** * 工具类 - 提供通用工具方法 * 包含异步操作、时间格式化、DOM操作等常用功能 */ class Utils { /** * 异步延时函数 * @param {number} ms - 等待时间(毫秒) * @returns {Promise} - 延时Promise对象 */ static async sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 格式化当前时间为本地时间字符串 * @returns {string} - 格式化的时间字符串(如:14:30:25) */ static formatTime() { return new Date().toLocaleTimeString(); } /** * 解析进度文本,提取百分比数值 * @param {string} progressText - 进度文本(如:"进度:85.5%") * @returns {number} - 解析出的进度数值(如:85.5),解析失败返回0 */ static parseProgress(progressText) { return parseFloat(progressText.replace('进度:', '')) || 0; } /** * 验证元素是否为有效的DOM元素 * @param {*} element - 要检验的对象 * @returns {boolean} - 是否为有效的DOM元素 */ static isValidElement(element) { return element && element instanceof Element; } /** * 安全的单元素选择器,避免无效选择器导致的异常 * @param {string} selector - CSS选择器字符串 * @param {Element|Document} parent - 父元素,默认为document * @returns {Element|null} - 找到的元素或null */ static safeQuerySelector(selector, parent = document) { try { return parent.querySelector(selector); } catch (error) { console.warn(`Invalid selector: ${selector}`, error); return null; } } /** * 安全的多元素选择器,避免无效选择器导致的异常 * @param {string} selector - CSS选择器字符串 * @param {Element|Document} parent - 父元素,默认为document * @returns {NodeList|Array} - 找到的元素列表或空数组 */ static safeQuerySelectorAll(selector, parent = document) { try { return parent.querySelectorAll(selector); } catch (error) { console.warn(`Invalid selector: ${selector}`, error); return []; } } } /** * 存储管理器 - 处理localStorage操作 * 提供安全的本地存储读写功能,包含错误处理和类型转换 */ class StorageManager { /** * 从localStorage获取字符串值 * @param {string} key - 存储键名 * @param {*} defaultValue - 默认值,当key不存在时返回 * @returns {string|*} - 存储的值或默认值 */ static get(key, defaultValue = null) { try { const value = localStorage.getItem(key); return value !== null ? value : defaultValue; } catch (error) { console.warn(`Failed to get storage item: ${key}`, error); return defaultValue; } } /** * 向localStorage设置字符串值 * @param {string} key - 存储键名 * @param {string} value - 要存储的值 * @returns {boolean} - 是否设置成功 */ static set(key, value) { try { localStorage.setItem(key, value); return true; } catch (error) { console.warn(`Failed to set storage item: ${key}`, error); return false; } } /** * 从localStorage获取布尔值 * @param {string} key - 存储键名 * @param {boolean} defaultValue - 默认布尔值 * @returns {boolean} - 解析后的布尔值 */ static getBool(key, defaultValue = false) { return this.get(key) === 'true' || defaultValue; } /** * 向localStorage设置布尔值 * @param {string} key - 存储键名 * @param {boolean} value - 要存储的布尔值 * @returns {boolean} - 是否设置成功 */ static setBool(key, value) { return this.set(key, String(Boolean(value))); } } /** * 配置管理器 - 集中管理所有配置 * 负责应用程序的配置状态管理,包括调试模式、运行间隔、课程模式等 */ class ConfigManager { /** * 构造函数 - 初始化配置项 * 从localStorage读取保存的配置,提供默认值 */ constructor() { // 调试模式相关配置 this.debug = { enabled: StorageManager.getBool(CONSTANTS.STORAGE_KEYS.DEBUG_MODE, false), // 是否开启调试模式 interval: CONSTANTS.INTERVALS.DEBUG, // 调试模式运行间隔(5秒) maxLogLines: CONSTANTS.UI.MAX_LOG_LINES_DEBUG // 调试模式最大日志行数 }; // 正常模式相关配置 this.normal = { interval: CONSTANTS.INTERVALS.NORMAL, // 正常模式运行间隔(20秒) maxLogLines: CONSTANTS.UI.MAX_LOG_LINES_NORMAL // 正常模式最大日志行数 }; // 课程相关配置 this.course = { targetProgress: CONSTANTS.PROGRESS.TARGET, // 章节完成的目标进度(100%) mode: StorageManager.get(CONSTANTS.STORAGE_KEYS.COURSE_MODE, 'elective') // 课程模式:elective(选修) / required(必修) }; } /** * 获取当前是否处于调试模式 * @returns {boolean} - 是否启用调试模式 */ get isDebugEnabled() { return this.debug.enabled; } /** * 获取当前运行间隔 * @returns {number} - 根据调试模式返回对应的运行间隔(毫秒) */ get currentInterval() { return this.isDebugEnabled ? this.debug.interval : this.normal.interval; } /** * 获取最大日志行数 * @returns {number} - 根据调试模式返回对应的最大日志行数 */ get maxLogLines() { return this.isDebugEnabled ? this.debug.maxLogLines : this.normal.maxLogLines; } /** * 获取当前课程模式对应的API地址 * @returns {string} - 课程列表API地址 */ get courseApiUrl() { return this.course.mode === 'required' ? CONSTANTS.API.REQUIRED : CONSTANTS.API.ELECTIVE; } /** * 切换调试模式开关 * 同时更新localStorage中的设置 */ toggleDebug() { this.debug.enabled = !this.debug.enabled; StorageManager.setBool(CONSTANTS.STORAGE_KEYS.DEBUG_MODE, this.debug.enabled); } /** * 设置课程模式 * @param {string} mode - 课程模式:'elective'(选修) 或 'required'(必修) */ setCourseMode(mode) { this.course.mode = mode; StorageManager.set(CONSTANTS.STORAGE_KEYS.COURSE_MODE, mode); } } /** * 日志管理器 - 处理日志输出和显示 */ class LogManager { constructor(config) { this.config = config; this.consoleDiv = null; this.headerDiv = null; this.bodyDiv = null; this.isMinimized = false; this.dragManager = new DragManager(); this.init(); } init() { this.createConsole(); this.bindEvents(); this.enableDragging(); } createConsole() { // 创建主容器 this.consoleDiv = document.createElement('div'); this.consoleDiv.id = 'custom-console'; this.consoleDiv.style.cssText = ` position: fixed; bottom: 20px; right: 20px; width: 500px; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; z-index: 9999; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05); backdrop-filter: blur(20px); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; `; // 创建头部 this.headerDiv = document.createElement('div'); this.headerDiv.style.cssText = ` background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 13px; cursor: move; user-select: none; `; this.headerDiv.innerHTML = ` <div style="display: flex; align-items: center; flex: 1; cursor: move;" id="console-drag-handle"> <div style="width: 8px; height: 8px; background: #4ade80; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 8px rgba(74, 222, 128, 0.6);"></div> <span>脚本控制台</span> <div style="margin-left: 8px; opacity: 0.7; font-size: 10px;">拖拽移动</div> </div> <div style="display: flex; align-items: center; gap: 8px;"> <button id="clearLog" style=" background: rgba(255, 255, 255, 0.2); border: none; color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; transition: all 0.2s ease; " onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">清空</button> <div id="minimizeBtn" style=" width: 16px; height: 16px; background: rgba(255, 255, 255, 0.2); border-radius: 3px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: all 0.2s ease; " onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">−</div> </div> `; // 创建主体 this.bodyDiv = document.createElement('div'); this.bodyDiv.style.cssText = ` height: 300px; overflow-y: auto; padding: 16px; font-size: 12px; line-height: 1.5; color: #e2e8f0; background: rgba(0, 0, 0, 0.2); `; // 自定义滚动条样式 const style = document.createElement('style'); style.textContent = ` #custom-console div::-webkit-scrollbar { width: 6px; } #custom-console div::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.1); border-radius: 3px; } #custom-console div::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.3); border-radius: 3px; } #custom-console div::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.5); } `; document.head.appendChild(style); this.consoleDiv.appendChild(this.headerDiv); this.consoleDiv.appendChild(this.bodyDiv); document.body.appendChild(this.consoleDiv); } bindEvents() { // 最小化/最大化功能 const minimizeBtn = document.getElementById('minimizeBtn'); minimizeBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.toggleMinimize(); }); // 清空日志功能 const clearBtn = document.getElementById('clearLog'); clearBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.clearLogs(); }); } enableDragging() { const dragHandle = document.getElementById('console-drag-handle'); if (dragHandle) { this.dragManager.makeDraggable(this.consoleDiv, dragHandle, 'console'); } } toggleMinimize() { this.isMinimized = !this.isMinimized; const minimizeBtn = document.getElementById('minimizeBtn'); if (this.isMinimized) { this.bodyDiv.style.display = 'none'; this.consoleDiv.style.height = 'auto'; minimizeBtn.textContent = '+'; } else { this.bodyDiv.style.display = 'block'; this.consoleDiv.style.height = 'auto'; minimizeBtn.textContent = '−'; } } clearLogs() { this.bodyDiv.innerHTML = ''; this.log('日志已清空', true); } log(message, isDebug = false) { if (isDebug && !this.config.isDebugEnabled) return; const logLine = this.createLogLine(message, isDebug); this.bodyDiv.appendChild(logLine); this.bodyDiv.scrollTop = this.bodyDiv.scrollHeight; this.limitLogLines(); // 控制台输出 const consoleMessage = `${Utils.formatTime()} - ${message}`; if (isDebug) { console.debug(`[DEBUG] ${consoleMessage}`); } else { console.log(consoleMessage); } } createLogLine(message, isDebug) { const logLine = document.createElement('div'); const time = Utils.formatTime(); // 根据消息类型设置不同的样式 let messageColor = '#e2e8f0'; let icon = '•'; if (isDebug) { messageColor = '#10b981'; icon = '🔍'; } else if (message.includes('错误') || message.includes('失败')) { messageColor = '#ef4444'; icon = '❌'; } else if (message.includes('成功') || message.includes('完成')) { messageColor = '#22c55e'; icon = '✅'; } else if (message.includes('警告')) { messageColor = '#f59e0b'; icon = '⚠️'; } logLine.style.cssText = ` display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); animation: fadeIn 0.3s ease-in-out; word-wrap: break-word; color: ${messageColor}; `; logLine.innerHTML = ` <span style=" color: #64748b; font-size: 10px; min-width: 60px; margin-top: 1px; ">${time}</span> <span style=" margin-top: 1px; min-width: 16px; ">${icon}</span> <span style=" flex: 1; word-break: break-word; ">${isDebug ? '[DEBUG] ' : ''}${message}</span> `; // 添加淡入动画 const style = document.createElement('style'); style.textContent = ` @keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } `; if (!document.head.querySelector('style[data-log-animation]')) { style.setAttribute('data-log-animation', 'true'); document.head.appendChild(style); } return logLine; } limitLogLines() { while (this.bodyDiv.children.length > this.config.maxLogLines) { this.bodyDiv.removeChild(this.bodyDiv.firstChild); } } error(message, error = null) { const errorMessage = error ? `${message}: ${error.message}` : message; this.log(`❌ ${errorMessage}`); if (this.config.isDebugEnabled && error?.stack) { this.log(`Stack: ${error.stack}`, true); } } } /** * 视频控制器 - 处理视频播放操作 * 负责课程视频的播放控制,这是自动挂课的核心业务逻辑 */ class VideoController { /** * 构造函数 * @param {LogManager} logger - 日志管理器实例 */ constructor(logger) { this.logger = logger; } /** * 获取页面中的视频播放器元素 * @returns {HTMLVideoElement|null} - 视频元素或null */ getPlayer() { const video = Utils.safeQuerySelector(CONSTANTS.SELECTORS.VIDEO); if (!video && this.logger.config.isDebugEnabled) { this.logger.log('未找到视频元素', true); } return video; } /** * 等待视频元素加载完成 * 由于页面动态加载,需要轮询等待视频元素出现 * @param {number} maxAttempts - 最大尝试次数,避免无限等待 * @returns {Promise<HTMLVideoElement|null>} - 返回视频元素或null */ async waitForVideo(maxAttempts = CONSTANTS.PROGRESS.MAX_VIDEO_ATTEMPTS) { for (let attempts = 0; attempts < maxAttempts; attempts++) { const video = this.getPlayer(); if (video) { this.logVideoInfo(video); return video; } this.logger.log(`等待视频加载,第 ${attempts + 1} 次尝试...`, true); await Utils.sleep(CONSTANTS.INTERVALS.VIDEO_CHECK); } this.logger.log(`视频加载超时,已尝试 ${maxAttempts} 次`, true); return null; } /** * 记录视频基本信息,用于调试 * @param {HTMLVideoElement} video - 视频元素 */ logVideoInfo(video) { if (!this.logger.config.isDebugEnabled) return; this.logger.log('视频元素已就绪', true); this.logger.log(`视频状态: ${video.paused ? '已暂停' : '播放中'}`, true); this.logger.log(`视频时长: ${video.duration || 0}秒`, true); this.logger.log(`当前进度: ${video.currentTime || 0}秒`, true); } /** * 播放视频 - 自动挂课的核心功能 * 实现视频的自动播放,确保课程能够正常进行 * @returns {Promise<boolean>} - 播放是否成功 */ async play() { const video = await this.waitForVideo(); if (!video) return false; try { // 检查视频是否已在播放 if (video.paused) { // 设置静音模式,避免浏览器自动播放策略限制 video.muted = true; // 调用播放方法,可能返回Promise await video.play(); this.logger.log('视频已开始播放(静音模式)', true); this.logPlaybackInfo(video); return true; } else { this.logger.log('视频已经在播放中', true); } } catch (error) { // 播放失败可能由于浏览器安全策略,需要用户交互 this.logger.error('播放视频出错', error); } return false; } /** * 记录视频播放相关信息 * @param {HTMLVideoElement} video - 视频元素 */ logPlaybackInfo(video) { if (!this.logger.config.isDebugEnabled) return; this.logger.log(`视频播放速率: ${video.playbackRate}`, true); this.logger.log(`视频音量: ${video.volume}`, true); } /** * 暂停视频播放 * @returns {boolean} - 暂停是否成功 */ pause() { const video = this.getPlayer(); if (!video) return false; if (!video.paused) { video.pause(); this.logger.log('暂停播放视频', true); this.logger.log(`暂停时视频进度: ${video.currentTime}秒`, true); return true; } else { this.logger.log('视频已经处于暂停状态', true); } return false; } /** * 获取视频当前状态 - 用于监控播放进度 * @returns {Object|null} - 视频状态对象,包含播放进度等信息 */ getStatus() { const video = this.getPlayer(); if (!video) return null; const status = { currentTime: video.currentTime || 0, // 当前播放时间(秒) duration: video.duration || 0, // 视频总时长(秒) paused: video.paused, // 是否暂停 playbackRate: video.playbackRate || 1, // 播放倍速 volume: video.volume || 0 // 音量 (0-1) }; this.logVideoStatus(status); return status; } /** * 记录详细的视频状态信息,便于监控和调试 * @param {Object} status - 视频状态对象 */ logVideoStatus(status) { if (!this.logger.config.isDebugEnabled) return; this.logger.log('当前视频状态:', true); this.logger.log(`- 当前时间: ${status.currentTime}秒`, true); this.logger.log(`- 总时长: ${status.duration}秒`, true); this.logger.log(`- 播放状态: ${status.paused ? '已暂停' : '播放中'}`, true); this.logger.log(`- 播放速率: ${status.playbackRate}`, true); this.logger.log(`- 音量: ${status.volume}`, true); } } /** * API客户端 - 处理网络请求 * 负责与福建网络干部学院API的通信,包括认证、课程列表获取等 */ class ApiClient { /** * 构造函数 * @param {LogManager} logger - 日志管理器实例 */ constructor(logger) { this.logger = logger; } /** * 获取API请求的认证头信息 * 从localStorage读取token和refreshToken,用于API认证 * @returns {Object} - 包含认证信息的HTTP头 * @throws {Error} - 当缺少认证信息时抛出错误 */ getAuthHeaders() { const token = StorageManager.get(CONSTANTS.STORAGE_KEYS.TOKEN); const refreshToken = StorageManager.get(CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN); if (!token || !refreshToken) { throw new Error('缺少认证信息'); } return { 'Content-Type': 'application/json', 'Authorization': token, // 主要认证token 'RefreshAuthorization': refreshToken // 刷新token,用于续期 }; } /** * 获取课程列表 - 核心API调用方法 * 向干部学院服务器请求学生的课程列表数据 * @param {string} url - API接口地址(选修课或必修课) * @returns {Promise<Object>} - 返回课程列表数据 * @throws {Error} - 当请求失败时抛出错误 */ async fetchCourseList(url) { const studentId = StorageManager.get(CONSTANTS.STORAGE_KEYS.STUDENT_ID); if (!studentId) { throw new Error('未找到学生ID'); } // 构建API请求体 - 符合干部学院API规范 const requestBody = { studentId: studentId, // 学生唯一标识 size: 30, // 每页课程数量 current: 1 // 当前页码 }; this.logRequestInfo(url, requestBody); // 发送POST请求获取课程数据 const response = await fetch(url, { method: 'POST', headers: this.getAuthHeaders(), // 包含认证信息 body: JSON.stringify(requestBody) }); // 处理可能的token刷新 await this.handleTokenRefresh(response); if (!response.ok) { throw new Error(`请求失败: ${response.status} ${response.statusText}`); } const data = await response.json(); this.logResponseInfo(data); return data; } /** * 记录API请求信息,用于调试 * @param {string} url - 请求地址 * @param {Object} requestBody - 请求体 */ logRequestInfo(url, requestBody) { if (!this.logger.config.isDebugEnabled) return; this.logger.log('准备获取课程列表...', true); this.logger.log(`请求URL: ${url}`, true); this.logger.log(`请求体: ${JSON.stringify(requestBody)}`, true); } /** * 处理token刷新逻辑 * 服务器可能在响应头中返回新的token,需要及时更新本地存储 * @param {Response} response - HTTP响应对象 */ async handleTokenRefresh(response) { if (response.status !== 200) return; // 从响应头获取新的token(可能是小写或大写Authorization) const newToken = response.headers.get('authorization') || response.headers.get('Authorization'); const newRefreshToken = response.headers.get('refre'); if (this.logger.config.isDebugEnabled) { this.logger.log('Token更新信息:', true); this.logger.log(`- 新token: ${newToken ? '已获取' : '未获取'}`, true); this.logger.log(`- 新refreshToken: ${newRefreshToken ? '已获取' : '未获取'}`, true); } // 更新本地存储中的认证信息 if (newToken) { StorageManager.set(CONSTANTS.STORAGE_KEYS.TOKEN, newToken); } if (newRefreshToken) { StorageManager.set(CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN, newRefreshToken); } } /** * 记录API响应信息,用于调试和监控 * @param {Object} data - API返回的数据 */ logResponseInfo(data) { if (!this.logger.config.isDebugEnabled) return; this.logger.log('课程列表响应:', true); this.logger.log(`- 总课程数: ${data.data?.total || 0}`, true); this.logger.log(`- 当前页: ${data.data?.current || 0}`, true); this.logger.log(`- 每页大小: ${data.data?.size || 0}`, true); this.logger.log(`- 课程记录数: ${data.data?.records?.length || 0}`, true); } } /** * 课程管理器 - 处理课程相关操作 * 负责课程进度检测、保存、切换等核心业务逻辑,是自动挂课的关键组件 */ class CourseManager { /** * 构造函数 * @param {ConfigManager} config - 配置管理器实例 * @param {LogManager} logger - 日志管理器实例 */ constructor(config, logger) { this.config = config; this.logger = logger; this.apiClient = new ApiClient(logger); // API客户端,用于获取课程列表 } /** * 获取当前课程信息 - 核心方法 * 解析页面中当前选中的课程及其进度信息 * @returns {Object|null} - 课程信息对象或null */ getCurrentCourseInfo() { // 查找页面中标红的当前课程(通过CSS样式识别) const currentCourse = Utils.safeQuerySelector(CONSTANTS.SELECTORS.CURRENT_COURSE); if (!currentCourse) { this.logger.log('未找到当前课程'); return null; } // 获取课程进度信息 const progressSpan = Utils.safeQuerySelector(CONSTANTS.SELECTORS.PROGRESS_SPAN, currentCourse.parentElement); const progressText = progressSpan ? progressSpan.textContent : '进度:0.0%'; const progress = Utils.parseProgress(progressText); const courseInfo = { element: currentCourse.parentElement, // DOM元素引用 title: currentCourse.textContent.trim(), // 课程标题 progress: progress, // 进度数值(0-100) progressText: progressText // 原始进度文本 }; this.logCourseInfo(courseInfo); return courseInfo; } /** * 记录课程信息,用于调试 * @param {Object} courseInfo - 课程信息对象 */ logCourseInfo(courseInfo) { if (!this.logger.config.isDebugEnabled) return; this.logger.log('当前课程信息:', true); this.logger.log(`- 课程标题: ${courseInfo.title}`, true); this.logger.log(`- 进度文本: ${courseInfo.progressText}`, true); this.logger.log(`- 进度数值: ${courseInfo.progress}%`, true); } /** * 保存课程进度 - 关键业务功能 * 自动点击保存按钮,确保学习进度被记录到服务器 */ saveProgress() { const saveButton = Utils.safeQuerySelector(CONSTANTS.SELECTORS.SAVE_BUTTON); const button = saveButton?.closest('button'); if (button) { this.logger.log(`正在点击保存进度按钮: ${button.textContent.trim()}`, true); button.click(); // 模拟用户点击保存按钮 this.logger.log('自动保存进度'); } else { this.logger.log('未找到保存进度按钮'); this.logAvailableButtons(); } } /** * 记录页面中所有可用按钮,用于调试 */ logAvailableButtons() { if (!this.logger.config.isDebugEnabled) return; this.logger.log('页面按钮列表:', true); const buttons = Utils.safeQuerySelectorAll('button'); buttons.forEach((btn, index) => { this.logger.log(`按钮${index + 1}: ${btn.textContent.trim()}`, true); }); } /** * 检查当前科目的所有章节是否都已完成 - 核心业务逻辑 * 遍历所有章节,检查进度是否达到目标值(100%) * @returns {boolean} - 所有章节是否都已完成 */ areAllChaptersCompleted() { const chapters = Utils.safeQuerySelectorAll(CONSTANTS.SELECTORS.CHAPTERS); this.logger.log(`检查所有章节状态,共找到 ${chapters.length} 个章节`, true); for (const chapter of chapters) { // 获取每个章节的进度信息 const progressSpan = Utils.safeQuerySelector(CONSTANTS.SELECTORS.PROGRESS_SPAN, chapter); const progressText = progressSpan ? progressSpan.textContent : '进度:0.0%'; const progress = Utils.parseProgress(progressText); const chapterTitle = Utils.safeQuerySelector(CONSTANTS.SELECTORS.COURSE_TITLE, chapter)?.textContent.trim() || '未知章节'; this.logger.log(`章节: ${chapterTitle} - 进度: ${progressText}`, true); // 如果发现任何一个章节未完成,返回false if (progress < this.config.course.targetProgress) { return false; } } return true; // 所有章节都已完成 } /** * 获取课程列表数据 * @returns {Promise<Object|null>} - 课程列表数据或null */ async fetchCourseList() { try { const url = this.config.courseApiUrl; // 根据配置获取对应的API地址 return await this.apiClient.fetchCourseList(url); } catch (error) { this.logger.error('获取课程列表失败', error); return null; } } /** * 查找下一个未完成的科目 - 自动切换科目的核心逻辑 * 从服务器获取所有科目,找到第一个未完成的科目 * @returns {Promise<Object|null>} - 下一个未完成的科目或null */ async findNextUnfinishedSubject() { this.logger.log('开始查找未完成科目...', true); const courseList = await this.fetchCourseList(); if (!courseList?.data?.records) { this.logger.log('获取课程列表失败或列表为空'); return null; } this.logger.log(`共找到 ${courseList.data.records.length} 个科目`, true); // 查找第一个未完成的科目 const nextSubject = courseList.data.records.find(course => { // 通过比较已学时数和总学时数判断是否完成 const isCompleted = course.creditsEarned === course.credit; this.logSubjectInfo(course, isCompleted); return !isCompleted; }); return nextSubject; } /** * 记录科目信息,用于调试 * @param {Object} course - 科目数据 * @param {boolean} isCompleted - 是否已完成 */ logSubjectInfo(course, isCompleted) { if (!this.logger.config.isDebugEnabled) return; this.logger.log(`科目: ${course.courseware.coursewareName}`, true); this.logger.log(`- 已学时数: ${course.creditsEarned}/${course.credit}`, true); this.logger.log(`- 状态: ${isCompleted ? '已完成' : '未完成'}`, true); } /** * 导航到指定科目 - 自动跳转功能 * 构建科目URL并跳转到对应页面 * @param {Object} courseware - 科目课件信息 */ navigateToSubject(courseware) { const url = `https://www.fsa.gov.cn/videoChoose?newId=${courseware.id}`; this.logNavigationInfo(courseware, url); window.location.href = url; // 页面跳转 } /** * 记录导航信息,用于调试 * @param {Object} courseware - 科目课件信息 * @param {string} url - 跳转URL */ logNavigationInfo(courseware, url) { if (!this.logger.config.isDebugEnabled) return; this.logger.log('准备跳转到新科目:', true); this.logger.log(`- 科目ID: ${courseware.id}`, true); this.logger.log(`- 科目名称: ${courseware.coursewareName}`, true); this.logger.log(`- 跳转URL: ${url}`, true); } } /** * 页面管理器 - 处理不同页面的逻辑 * 负责协调视频控制器和课程管理器,实现自动挂课的主要业务流程 */ class PageManager { /** * 构造函数 * @param {ConfigManager} config - 配置管理器 * @param {LogManager} logger - 日志管理器 * @param {VideoController} videoController - 视频控制器 * @param {CourseManager} courseManager - 课程管理器 */ constructor(config, logger, videoController, courseManager) { this.config = config; this.logger = logger; this.videoController = videoController; this.courseManager = courseManager; } /** * 视频页面主循环 - 自动挂课的核心业务流程 * 定期执行以下操作:保存进度、检查视频状态、处理课程切换 */ async videoPageLoop() { this.logger.log('开始新一轮视频页面检查...', true); // 1. 保存当前学习进度到服务器 this.courseManager.saveProgress(); // 2. 检查视频播放状态,确保视频在播放 const videoStatus = this.videoController.getStatus(); if (videoStatus?.paused) { await this.videoController.play(); } // 3. 等待一段时间让进度更新 await Utils.sleep(CONSTANTS.INTERVALS.SAVE_PROGRESS); // 4. 处理课程进度和切换逻辑 await this.handleCourseProgress(); } /** * 处理课程进度和切换逻辑 - 核心业务决策 * 检查当前章节是否完成,决定是否需要切换章节或科目 */ async handleCourseProgress() { try { const currentInfo = this.courseManager.getCurrentCourseInfo(); if (!currentInfo) return; // 检查当前章节是否已达到完成标准 if (currentInfo.progress >= this.config.course.targetProgress) { this.logger.log('当前章节完成,检查其他章节...', true); // 检查所有章节是否都已完成 if (this.courseManager.areAllChaptersCompleted()) { this.logger.log('所有章节已完成,准备切换科目...'); await this.switchToNextSubject(); // 切换到下一个科目 } else { this.switchToNextChapter(); // 切换到下一个章节 } } } catch (error) { this.logger.error('处理课程切换时发生错误', error); } } /** * 文章页面主循环 - 处理文章阅读类型的课程 * 定期检查阅读状态,处理课程切换 */ async articlePageLoop() { this.logger.log('开始新一轮文章页面检查...', true); await Utils.sleep(CONSTANTS.INTERVALS.SAVE_PROGRESS); await this.handleArticleProgress(); } /** * 处理文章阅读进度 * 检查阅读状态,决定是否切换到下一个科目 */ async handleArticleProgress() { try { const statusElement = Utils.safeQuerySelector(CONSTANTS.SELECTORS.READING_STATUS); if (!statusElement) { this.logger.log('未找到阅读状态元素'); return; } const statusText = statusElement.innerText; this.logger.log(`当前阅读状态: ${statusText}`, true); if (statusText !== '已完成') { this.logger.log(`当前阅读时长:${statusText}`); } else { this.logger.log('所有章节已完成,准备切换科目...'); await this.switchToNextSubject(); } } catch (error) { this.logger.error('处理课程切换时发生错误', error); } } /** * 切换到下一个未完成的章节 - 章节级别的自动切换 * 遍历所有章节,找到第一个未完成的章节并点击 */ switchToNextChapter() { this.logger.log('开始查找下一个未完成章节...', true); const chapters = Utils.safeQuerySelectorAll(CONSTANTS.SELECTORS.CHAPTERS); for (const chapter of chapters) { // 获取章节进度信息 const progressSpan = Utils.safeQuerySelector(CONSTANTS.SELECTORS.PROGRESS_SPAN, chapter); const progressText = progressSpan ? progressSpan.textContent : '进度:0.0%'; const progress = Utils.parseProgress(progressText); const chapterTitle = Utils.safeQuerySelector(CONSTANTS.SELECTORS.COURSE_TITLE, chapter)?.textContent.trim() || '未知章节'; this.logger.log(`检查章节: ${chapterTitle} - 进度: ${progressText}`, true); // 找到第一个未完成的章节 if (progress < this.config.course.targetProgress) { this.logger.log('------------------------'); this.logger.log('准备切换到章节: ' + chapterTitle); this.logger.log('当前进度: ' + progressText); this.logger.log('------------------------'); // 点击章节标题进行切换 this.logger.log(`点击章节: ${chapterTitle}`, true); const titleElement = Utils.safeQuerySelector(CONSTANTS.SELECTORS.COURSE_TITLE, chapter); if (titleElement) { titleElement.click(); // 模拟用户点击 } break; } } } /** * 切换到下一个未完成的科目 - 科目级别的自动切换 * 这是自动挂课的最高级别切换,完成一个科目后自动进入下一个 */ async switchToNextSubject() { this.logger.log('开始查找下一个未完成科目...', true); const nextSubject = await this.courseManager.findNextUnfinishedSubject(); if (nextSubject) { this.logger.log(`找到下一个未完成的科目: ${nextSubject.courseware.coursewareName}`); this.logger.log(`当前进度: ${nextSubject.creditsEarned}/${nextSubject.credit}`); this.courseManager.navigateToSubject(nextSubject.courseware); // 跳转到新科目 } else { this.logger.log('恭喜!所有科目都已完成!'); } } initVideoPage() { this.logger.log('初始化视频播放页面...', true); setTimeout(() => { this.videoPageLoop(); this.logger.log(`设置主循环间隔: ${this.config.currentInterval}ms`, true); setInterval(() => this.videoPageLoop(), this.config.currentInterval); }, CONSTANTS.INTERVALS.INITIALIZATION); } initArticlePage() { this.logger.log('初始化文章阅读页面...', true); setTimeout(() => { this.clickStartReadingButton(); this.articlePageLoop(); this.logger.log(`设置主循环间隔: ${this.config.currentInterval}ms`, true); setInterval(() => this.articlePageLoop(), this.config.currentInterval); }, CONSTANTS.INTERVALS.INITIALIZATION); } clickStartReadingButton() { const buttons = Utils.safeQuerySelectorAll('button'); const startButton = buttons[1]; // 第二个按钮是开始阅读按钮 if (startButton) { this.logger.log('点击开始阅读按钮...', true); startButton.click(); } } initCourseChoosePage() { this.logger.log('初始化课程选择页面...', true); setTimeout(() => { const firstCourse = Utils.safeQuerySelector(CONSTANTS.SELECTORS.FIRST_COURSE); if (firstCourse) { this.logger.log(`找到第一条课程: ${firstCourse.textContent}`, true); firstCourse.click(); this.logger.log('已选择第一条课程'); this.logger.log('5秒后将刷新页面...', true); setTimeout(() => window.location.reload(), CONSTANTS.INTERVALS.PAGE_RELOAD); } else { this.logger.log('未找到课程选项'); this.logPageContent(); } }, CONSTANTS.INTERVALS.VIDEO_CHECK); } logPageContent() { if (!this.logger.config.isDebugEnabled) return; this.logger.log('页面内容:', true); const pageContent = Utils.safeQuerySelector('.page-content'); this.logger.log(pageContent ? pageContent.innerHTML : '未找到page-content', true); } } /** * 配置面板 - 处理用户界面配置 */ class ConfigPanel { constructor(config, logger) { this.config = config; this.logger = logger; this.isVisible = true; this.dragManager = new DragManager(); } create() { const panel = this.createPanelElement(); document.body.appendChild(panel); this.bindEvents(); this.enableDragging(); } createPanelElement() { const panel = document.createElement('div'); panel.innerHTML = ` <div id="config-panel" style=" position: fixed; top: 20px; right: 20px; z-index: 10000; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16px; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif; box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px); min-width: 280px; overflow: hidden; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); "> <!-- 头部 --> <div id="panel-header" style=" background: rgba(255, 255, 255, 0.1); padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; cursor: move; user-select: none; "> <div style="display: flex; align-items: center; gap: 10px; flex: 1;" id="panel-drag-handle"> <div style=" width: 24px; height: 24px; background: linear-gradient(45deg, #4ade80, #22c55e); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: white; font-weight: bold; ">⚙️</div> <div style="flex: 1;"> <div style="color: white; font-weight: 600; font-size: 15px;">脚本控制</div> <div style="color: rgba(255,255,255,0.7); font-size: 11px;">v2.2.0 • 拖拽移动</div> </div> </div> <div id="panel-toggle" style=" width: 20px; height: 20px; background: rgba(255, 255, 255, 0.2); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; cursor: pointer; transition: all 0.2s ease; " onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">−</div> </div> <!-- 主体内容 --> <div id="panel-body" style=" background: rgba(255, 255, 255, 0.95); padding: 20px; "> <!-- 调试模式 --> <div style="margin-bottom: 20px;"> <div style=" display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border-radius: 12px; border: 1px solid rgba(0, 0, 0, 0.05); cursor: pointer; transition: all 0.2s ease; " onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.1)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'"> <div style="display: flex; align-items: center; gap: 12px;"> <div id="debugIcon" style=" width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; ${this.config.isDebugEnabled ? 'background: linear-gradient(135deg, #22c55e, #16a34a); color: white;' : 'background: linear-gradient(135deg, #64748b, #475569); color: white;' } transition: all 0.3s ease; ">${this.config.isDebugEnabled ? '🟢' : '🔴'}</div> <div> <div style="font-weight: 600; color: #1e293b; font-size: 14px;">调试模式</div> <div style="color: #64748b; font-size: 12px;">显示详细运行日志</div> </div> </div> <div style=" position: relative; width: 48px; height: 24px; background: ${this.config.isDebugEnabled ? '#22c55e' : '#cbd5e1'}; border-radius: 12px; transition: all 0.3s ease; cursor: pointer; " id="debugToggle"> <div style=" position: absolute; top: 2px; left: ${this.config.isDebugEnabled ? '26px' : '2px'}; width: 20px; height: 20px; background: white; border-radius: 10px; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0,0,0,0.2); "></div> </div> </div> </div> <!-- 课程模式 --> <div style="margin-bottom: 16px;"> <label style=" display: block; font-weight: 600; color: #374151; font-size: 13px; margin-bottom: 8px; ">课程模式</label> <div style="position: relative;"> <select id="courseMode" style=" width: 100%; padding: 12px 16px; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 12px; font-size: 14px; color: #1e293b; font-weight: 500; cursor: pointer; transition: all 0.2s ease; appearance: none; background-image: url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path fill=\"%23374151\" d=\"M6 8L2 4h8L6 8z\"/></svg>'); background-repeat: no-repeat; background-position: right 12px center; " onfocus="this.style.borderColor='#667eea'; this.style.boxShadow='0 0 0 3px rgba(102, 126, 234, 0.1)'" onblur="this.style.borderColor='rgba(0,0,0,0.1)'; this.style.boxShadow='none'"> <option value="elective" ${this.config.course.mode === 'elective' ? 'selected' : ''}>📚 选修课程</option> <option value="required" ${this.config.course.mode === 'required' ? 'selected' : ''}>📖 必修课程</option> </select> </div> </div> <!-- 状态信息 --> <div style=" background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 12px; padding: 12px 16px; margin-top: 16px; "> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;"> <div style="color: #92400e; font-size: 14px;">ℹ️</div> <div style="color: #92400e; font-weight: 600; font-size: 13px;">运行状态</div> </div> <div style="color: #451a03; font-size: 12px; line-height: 1.4;"> 脚本正在自动运行中...<br> <span style="color: #92400e;">⏱️ 间隔: ${this.config.isDebugEnabled ? '5秒' : '20秒'}</span> </div> </div> </div> </div> `; return panel; } bindEvents() { // 面板收缩/展开 const panelToggle = document.getElementById('panel-toggle'); const panelBody = document.getElementById('panel-body'); const togglePanel = () => { this.isVisible = !this.isVisible; if (this.isVisible) { panelBody.style.display = 'block'; panelToggle.textContent = '−'; } else { panelBody.style.display = 'none'; panelToggle.textContent = '+'; } }; panelToggle?.addEventListener('click', (e) => { e.stopPropagation(); togglePanel(); }); // 调试模式切换 const debugToggle = document.getElementById('debugToggle'); const debugIcon = document.getElementById('debugIcon'); const updateDebugUI = () => { const toggle = document.getElementById('debugToggle'); const icon = document.getElementById('debugIcon'); const ball = toggle?.querySelector('div'); if (this.config.isDebugEnabled) { toggle.style.background = '#22c55e'; ball.style.left = '26px'; icon.style.background = 'linear-gradient(135deg, #22c55e, #16a34a)'; icon.textContent = '🟢'; } else { toggle.style.background = '#cbd5e1'; ball.style.left = '2px'; icon.style.background = 'linear-gradient(135deg, #64748b, #475569)'; icon.textContent = '🔴'; } }; debugToggle?.addEventListener('click', () => { this.config.toggleDebug(); updateDebugUI(); this.logger.log(`调试模式已${this.config.isDebugEnabled ? '开启' : '关闭'}`); }); // 课程模式切换 const courseModeSelect = document.getElementById('courseMode'); courseModeSelect?.addEventListener('change', (e) => { this.config.setCourseMode(e.target.value); this.logger.log(`已切换到${this.config.course.mode === 'required' ? '必修' : '选修'}模式`); }); } enableDragging() { const dragHandle = document.getElementById('panel-drag-handle'); const panel = document.getElementById('config-panel'); if (dragHandle && panel) { this.dragManager.makeDraggable(panel, dragHandle, 'config-panel'); } } } /** * 通知管理器 - 处理用户通知 */ class NotificationManager { static notifications = []; static maxNotifications = 3; static show(message, type = 'success', duration = CONSTANTS.UI.NOTIFICATION_DURATION) { const notification = this.createNotification(message, type); this.addNotification(notification); // 设置自动消失 setTimeout(() => { this.removeNotification(notification); }, duration); return notification; } static createNotification(message, type) { const notification = document.createElement('div'); // 获取类型相关的样式 const typeConfig = this.getTypeConfig(type); notification.style.cssText = ` position: fixed; top: 80px; right: 20px; background: ${typeConfig.background}; color: white; padding: 16px 20px; border-radius: 12px; z-index: 10001; box-shadow: 0 10px 25px ${typeConfig.shadow}, 0 0 0 1px rgba(255, 255, 255, 0.1); font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; font-size: 14px; max-width: 320px; min-width: 200px; word-wrap: break-word; backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.2); transform: translateX(100%); opacity: 0; transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer; `; notification.innerHTML = ` <div style="display: flex; align-items: flex-start; gap: 12px;"> <div style=" width: 20px; height: 20px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; font-size: 12px; margin-top: 1px; flex-shrink: 0; ">${typeConfig.icon}</div> <div style="flex: 1;"> <div style="font-weight: 600; margin-bottom: 2px;">${typeConfig.title}</div> <div style="opacity: 0.9; font-size: 13px; line-height: 1.4;">${message}</div> </div> <div style=" width: 16px; height: 16px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; font-size: 10px; cursor: pointer; flex-shrink: 0; transition: all 0.2s ease; " onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">×</div> </div> `; // 点击关闭 notification.addEventListener('click', () => { this.removeNotification(notification); }); document.body.appendChild(notification); // 触发进入动画 requestAnimationFrame(() => { notification.style.transform = 'translateX(0)'; notification.style.opacity = '1'; }); return notification; } static getTypeConfig(type) { const configs = { success: { background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', shadow: 'rgba(16, 185, 129, 0.4)', icon: '✓', title: '成功' }, error: { background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', shadow: 'rgba(239, 68, 68, 0.4)', icon: '✕', title: '错误' }, warning: { background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', shadow: 'rgba(245, 158, 11, 0.4)', icon: '⚠', title: '警告' }, info: { background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', shadow: 'rgba(59, 130, 246, 0.4)', icon: 'i', title: '提示' } }; return configs[type] || configs.success; } static addNotification(notification) { this.notifications.push(notification); this.updatePositions(); // 如果通知过多,移除最旧的 if (this.notifications.length > this.maxNotifications) { const oldest = this.notifications.shift(); this.removeNotification(oldest); } } static removeNotification(notification) { const index = this.notifications.indexOf(notification); if (index > -1) { this.notifications.splice(index, 1); // 退出动画 notification.style.transform = 'translateX(100%)'; notification.style.opacity = '0'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); this.updatePositions(); } } static updatePositions() { this.notifications.forEach((notification, index) => { const offset = index * 90; // 每个通知间距90px notification.style.top = `${80 + offset}px`; }); } // 便捷方法 static success(message, duration) { return this.show(message, 'success', duration); } static error(message, duration) { return this.show(message, 'error', duration); } static warning(message, duration) { return this.show(message, 'warning', duration); } static info(message, duration) { return this.show(message, 'info', duration); } } /** * 应用程序主类 - 自动挂课脚本的入口和协调中心 * 负责初始化所有组件,根据页面类型启动相应的业务流程 */ class AutoPlayApp { /** * 构造函数 - 初始化所有核心组件 * 按照依赖关系创建各个管理器实例 */ constructor() { // 基础组件 this.config = new ConfigManager(); // 配置管理器 this.logger = new LogManager(this.config); // 日志管理器 // 业务组件 this.videoController = new VideoController(this.logger); // 视频控制器 this.courseManager = new CourseManager(this.config, this.logger); // 课程管理器 this.pageManager = new PageManager(this.config, this.logger, this.videoController, this.courseManager); // 页面管理器 // UI组件 this.configPanel = new ConfigPanel(this.config, this.logger); // 配置面板 } /** * 应用程序初始化 - 启动自动挂课功能 * 创建UI界面,等待页面稳定,然后根据页面类型启动相应流程 */ async initialize() { try { // 1. 创建用户界面 this.configPanel.create(); // 2. 显示启动通知,告知用户脚本已开始工作 NotificationManager.success("脚本已启动,正在初始化..."); // 3. 等待页面完全加载和稳定 await Utils.sleep(CONSTANTS.INTERVALS.SAVE_PROGRESS); this.logger.log('等待2s后开始初始化'); // 4. 根据当前页面URL启动对应的业务流程 this.initializePageByUrl(); } catch (error) { this.logger.error('初始化失败', error); } } /** * 根据页面URL初始化对应的业务流程 * 福建干部网络学院有三种主要页面类型,需要不同的处理逻辑 */ initializePageByUrl() { const currentURL = window.location.href; if (currentURL.includes('/video?')) { // 视频学习页面 - 主要的挂课页面,包含视频播放和章节切换 this.pageManager.initVideoPage(); } else if (currentURL.includes('/videoChoose')) { // 课程选择页面 - 科目选择页面,自动选择第一个课程 this.pageManager.initCourseChoosePage(); } else if (currentURL.includes('/study-artical')) { // 文章阅读页面 - 文档类课程页面,需要等待阅读时间 this.pageManager.initArticlePage(); } else { this.logger.log('未知页面类型'); } } } /** * ======================================== * 应用程序入口 - 自动挂课脚本启动点 * ======================================== */ // 创建应用程序实例 const app = new AutoPlayApp(); // 根据页面加载状态决定初始化时机 if (document.readyState === 'loading') { // 页面还在加载中,等待DOM内容加载完成 document.addEventListener('DOMContentLoaded', () => app.initialize()); } else { // 页面已经加载完成,立即初始化 app.initialize(); } })();