// ==UserScript== // @name 平顶山学院(青书学堂)自动学习脚本 // @namespace http://tampermonkey.net/ // @version 2026-06-10.2 // @description 自动进入青书学堂未完成课件,支持视频播放、文本课件停留和运行状态提示 // @author JK // @license MIT // @match https://degree.qingshuxuetang.com/pdsu/Student/Course/CourseStudy* // @match https://degree.qingshuxuetang.com/pdsu/Student/Course/CourseShow* // @icon https://assets.qsxt.info/frontend/prod/dist/mooc/favicon.ico // @updateURL https://git.zaobai.com/public/tampermonkey_script/raw/branch/master/qingshuxuetang_auto_play.user.js // @downloadURL https://git.zaobai.com/public/tampermonkey_script/raw/branch/master/qingshuxuetang_auto_play.user.js // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== (function () { "use strict"; // 当前页面地址,用来判断是在课程列表页还是课件播放页。 const url = window.location.href; // 视频课件的 video 标签选择器;如果网站改版导致找不到视频,优先检查这里。 const VIDEO_SELECTOR = "#vjs_video_3_html5_api"; // 文本课件容器选择器;easyXDM 中间的数字会变,所以这里用开头和结尾匹配。 const TEXT_SELECTOR = "[id^='easyXDM_default'][id$='_provider']"; // Tampermonkey 持久化配置的存储 key。 // 配置会存在脚本自己的存储区里,更新脚本代码不会覆盖这里的值。 const CONFIG_STORAGE_KEY = "qingshu_auto_config"; // 默认配置。第一次安装或重置配置后,会使用这些值。 const DEFAULT_CONFIG = { // 课件至少需要学习多少秒;默认 5 分钟。 minStudySeconds: 5 * 60, // 视频默认播放倍速;想改成 1.5 倍、3 倍可以从 Tampermonkey 菜单配置。 videoPlaybackRate: 2, // 提示消息默认显示时长,单位毫秒;4000 表示 4 秒后自动隐藏。 toastDefaultDuration: 4000, // 运行提醒间隔,单位秒;视频播放中、文本课件等待中都会按这个间隔提醒。 heartbeatSeconds: 10, // 课程列表最多等待多少秒;超过后认为可能请求失败,触发刷新。 courseListWaitSeconds: 60, // 课程列表加载失败时最多自动刷新几次。 maxCourseListRefreshCount: 3, }; // 读取 Tampermonkey 里保存过的配置,并和默认配置合并。 // 以后脚本新增配置项时,旧配置也能自动补上默认值。 const CONFIG = { ...DEFAULT_CONFIG, ...GM_getValue(CONFIG_STORAGE_KEY, {}), }; // 提示消息容器和样式标签的 id,通常不用改,除非和页面元素冲突。 const TOAST_CONTAINER_ID = "qingshu-auto-toast-container"; const TOAST_STYLE_ID = "qingshu-auto-toast-style"; // 记录课程列表自动刷新次数的 sessionStorage key。 // 带上 pathname/search,避免不同课程页面之间互相影响。 const COURSE_LIST_REFRESH_KEY = "qingshu_auto_course_list_refresh_count:" + location.pathname + location.search; let menuCommandIds = []; // 创建右下角提示消息容器和样式。 // 每次通知都会创建一个新的提示消息,所以多个通知会一起堆叠显示。 const ensureToastContainer = () => { let container = document.querySelector("#" + TOAST_CONTAINER_ID); if (!document.querySelector("#" + TOAST_STYLE_ID)) { const style = document.createElement("style"); style.id = TOAST_STYLE_ID; style.textContent = ` #${TOAST_CONTAINER_ID} { position: fixed; right: 18px; bottom: 18px; z-index: 2147483647; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; max-width: min(360px, calc(100vw - 36px)); pointer-events: none; } #${TOAST_CONTAINER_ID} .qingshu-toast { box-sizing: border-box; min-width: 220px; max-width: 100%; padding: 10px 12px; border-left: 4px solid #3b82f6; border-radius: 8px; background: rgba(17, 24, 39, 0.94); color: #fff; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24); font-size: 13px; line-height: 1.45; opacity: 0; transform: translateY(10px); transition: opacity 180ms ease, transform 180ms ease; pointer-events: auto; word-break: break-word; } #${TOAST_CONTAINER_ID} .qingshu-toast.show { opacity: 1; transform: translateY(0); } #${TOAST_CONTAINER_ID} .qingshu-toast.success { border-left-color: #22c55e; } #${TOAST_CONTAINER_ID} .qingshu-toast.warning { border-left-color: #f59e0b; } #${TOAST_CONTAINER_ID} .qingshu-toast.error { border-left-color: #ef4444; } `; (document.head || document.documentElement).appendChild(style); } if (!container) { container = document.createElement("div"); container.id = TOAST_CONTAINER_ID; (document.body || document.documentElement).appendChild(container); } return container; }; // 显示一条提示消息,同时保留 console 输出。 // type 可用 info、success、warning、error;duration 是自动隐藏时间。 const notify = ( message, type = "info", duration = CONFIG.toastDefaultDuration ) => { const log = type === "error" ? console.error : console.log; log(message); const container = ensureToastContainer(); const toast = document.createElement("div"); toast.className = "qingshu-toast " + type; toast.innerText = message; container.appendChild(toast); requestAnimationFrame(() => { toast.classList.add("show"); }); const removeToast = () => { toast.classList.remove("show"); setTimeout(() => { toast.remove(); }, 180); }; toast.addEventListener("click", removeToast); setTimeout(removeToast, duration); }; const saveConfig = (patch) => { Object.assign(CONFIG, patch); GM_setValue(CONFIG_STORAGE_KEY, CONFIG); registerConfigMenus(); notify("设置已保存,刷新页面后生效", "success", 6000); }; const promptNumberConfig = (label, key, unit, currentValue, toStoredValue) => { const input = prompt( label + "(当前:" + currentValue + unit + ")", currentValue ); if (input === null) { return; } const value = Number(input); if (!Number.isFinite(value) || value <= 0) { notify("设置失败:请输入大于0的数字", "error", 6000); return; } saveConfig({ [key]: toStoredValue ? toStoredValue(value) : value, }); }; // 在 Tampermonkey 菜单里提供配置入口。 // 这些值保存在脚本存储区,脚本更新不会覆盖它们。 const registerConfigMenus = () => { menuCommandIds.forEach((id) => { if (id) { GM_unregisterMenuCommand(id); } }); menuCommandIds = []; const menuTitle = (label, value, unit) => label + "(当前:" + value + unit + ")"; const registerMenu = (title, callback) => { const id = GM_registerMenuCommand(title, callback); menuCommandIds.push(id); }; registerMenu( menuTitle("设置视频倍速", CONFIG.videoPlaybackRate, "倍"), () => { promptNumberConfig( "设置视频倍速", "videoPlaybackRate", "倍", CONFIG.videoPlaybackRate ); } ); registerMenu( menuTitle("设置课件最小学习时间", CONFIG.minStudySeconds / 60, "分钟"), () => { promptNumberConfig( "设置课件最小学习时间", "minStudySeconds", "分钟", CONFIG.minStudySeconds / 60, (value) => value * 60 ); } ); registerMenu( menuTitle("设置运行提醒间隔", CONFIG.heartbeatSeconds, "秒"), () => { promptNumberConfig( "设置运行提醒间隔", "heartbeatSeconds", "秒", CONFIG.heartbeatSeconds ); } ); registerMenu( menuTitle("设置课程列表等待时间", CONFIG.courseListWaitSeconds, "秒"), () => { promptNumberConfig( "设置课程列表等待时间", "courseListWaitSeconds", "秒", CONFIG.courseListWaitSeconds ); } ); registerMenu( menuTitle( "设置课程列表自动刷新次数", CONFIG.maxCourseListRefreshCount, "次" ), () => { promptNumberConfig( "设置课程列表自动刷新次数", "maxCourseListRefreshCount", "次", CONFIG.maxCourseListRefreshCount, Math.floor ); } ); registerMenu( menuTitle("设置提示消息显示时间", CONFIG.toastDefaultDuration / 1000, "秒"), () => { promptNumberConfig( "设置提示消息显示时间", "toastDefaultDuration", "秒", CONFIG.toastDefaultDuration / 1000, (value) => value * 1000 ); } ); registerMenu("恢复默认设置", () => { GM_deleteValue(CONFIG_STORAGE_KEY); Object.assign(CONFIG, DEFAULT_CONFIG); registerConfigMenus(); notify("设置已重置,刷新页面后生效", "success", 6000); }); }; registerConfigMenus(); notify("脚本已启动,等待页面内容加载", "info"); // 等待某个 DOM 条件成立。 // 页面里的课程列表、视频、文本课件经常是后加载的,所以不用固定延迟, // 而是监听 DOM 变化,直到 getResult() 返回有效结果。 const waitForCondition = (getResult, timeoutMs) => { const result = getResult(); if (result) { return Promise.resolve(result); } return new Promise((resolve) => { let timeoutId; const finish = (result) => { observer.disconnect(); if (timeoutId) { clearTimeout(timeoutId); } resolve(result); }; const observer = new MutationObserver(() => { const result = getResult(); if (result) { finish(result); } }); observer.observe(document.documentElement, { childList: true, subtree: true, }); if (timeoutMs) { timeoutId = setTimeout(() => { finish(null); }, timeoutMs); } }); }; // CourseStudy 偶尔会因为请求失败(例如 429)导致课程列表一直不出现。 // 这里记录刷新次数:等 1 分钟还没有列表就刷新,最多刷新 3 次。 const refreshCourseStudyIfNeeded = () => { const refreshCount = Number(sessionStorage.getItem(COURSE_LIST_REFRESH_KEY) || 0); if (refreshCount >= CONFIG.maxCourseListRefreshCount) { notify( "课程列表等待超时,已刷新" + CONFIG.maxCourseListRefreshCount + "次仍未加载,停止自动刷新", "error", 8000 ); return; } const nextRefreshCount = refreshCount + 1; sessionStorage.setItem(COURSE_LIST_REFRESH_KEY, String(nextRefreshCount)); notify( "课程列表等待超过" + formatSeconds(CONFIG.courseListWaitSeconds) + ",准备刷新页面(第" + nextRefreshCount + "/" + CONFIG.maxCourseListRefreshCount + "次)", "warning", 8000 ); location.reload(); }; const clearCourseStudyRefreshCount = () => { sessionStorage.removeItem(COURSE_LIST_REFRESH_KEY); }; // 把“共4分钟49秒”“共1小时2分钟3秒”这类文字换算成秒数, // 方便判断是否已经学满 5 分钟。 const parseStudySeconds = (text) => { const hourMatch = text.match(/(\d+)\s*小时/); const minuteMatch = text.match(/(\d+)\s*分钟/); const secondMatch = text.match(/(\d+)\s*秒/); return ( (hourMatch ? Number(hourMatch[1]) * 3600 : 0) + (minuteMatch ? Number(minuteMatch[1]) * 60 : 0) + (secondMatch ? Number(secondMatch[1]) : 0) ); }; // 判断一个课程节点是否需要进入: // 1. 没有 study_being,说明还没学过,需要进入; // 2. 学过但累计时间低于 5 分钟,也需要再次进入; // 3. 学习时间达到 5 分钟及以上,就跳过。 const shouldEnterCourseware = (item) => { const mark = item.querySelector(".study_being"); if (!mark) { return true; } const studiedSeconds = parseStudySeconds(mark.innerText); return studiedSeconds < CONFIG.minStudySeconds; }; // 返回课程列表页。 // 优先点击页面自带的返回按钮;如果没找到,就用浏览器历史记录兜底。 const returnToStudyPage = () => { const backLink = document.querySelector(".back-link"); if (backLink) { backLink.click(); return; } history.back(); }; // 播放页可能是视频课件,也可能是文本课件。 // 这里会一直等到其中一种课件元素真正出现在页面上。 const waitForCoursewareElement = () => waitForCondition( () => document.querySelector(VIDEO_SELECTOR) || document.querySelector(TEXT_SELECTOR) ); // 把秒数格式化成“x分x秒”,用于运行提醒。 const formatSeconds = (seconds) => { if (!Number.isFinite(seconds) || seconds < 0) { return "未知"; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return minutes + "分" + remainingSeconds + "秒"; }; const onPageLoad = async () => { notify("页面加载完成,脚本开始运行", "info"); if (url.includes("CourseStudy")) { notify("进入课程列表页,等待课程列表加载", "info"); // 课程列表是异步渲染的,等到至少有一个课程节点后再开始遍历。 const items = await waitForCondition( () => { const list = document.querySelector("#list"); const items = list ? list.querySelectorAll("li > a.node") : []; return items.length > 0 ? items : null; }, CONFIG.courseListWaitSeconds * 1000 ); if (!items) { refreshCourseStudyIfNeeded(); return; } clearCourseStudyRefreshCount(); notify("已加载课程数量:" + items.length, "success"); // 从上到下找到第一个“没学过”或“学习不足配置时长”的课件并进入。 for (const item of items) { if (shouldEnterCourseware(item)) { const mark = item.querySelector(".study_being"); const studiedSeconds = mark ? parseStudySeconds(mark.innerText) : 0; notify( "进入未完成学习时长的课件:" + item.querySelector(".title").innerText + ",已学习:" + studiedSeconds + "秒", "success", 6000 ); item.click(); return; } } notify("所有课件均已达到学习时长,脚本停止", "success", 6000); } else if (url.includes("CourseShow")) { notify("进入课件页面,等待课件加载", "info"); // 等到播放页真正渲染出视频或文本课件,再决定如何处理。 const coursewareElement = await waitForCoursewareElement(); // 文本课件没有 video 标签,不需要播放;停留到配置的学习时长后返回列表页。 if (coursewareElement.matches(TEXT_SELECTOR)) { notify( "检测到文本课件,将停留" + formatSeconds(CONFIG.minStudySeconds) + "后返回课程列表", "warning", 6000 ); const textStartedAt = Date.now(); let textInterval; const notifyTextProgress = () => { const studiedSeconds = Math.floor((Date.now() - textStartedAt) / 1000); if (studiedSeconds >= CONFIG.minStudySeconds) { clearInterval(textInterval); notify("文本课件停留完成,返回课程列表", "success"); returnToStudyPage(); return; } notify( "文本课件学习中,已停留:" + formatSeconds(studiedSeconds) + ",剩余:" + formatSeconds(CONFIG.minStudySeconds - studiedSeconds), "info" ); }; textInterval = setInterval( notifyTextProgress, CONFIG.heartbeatSeconds * 1000 ); notifyTextProgress(); return; } // 视频课件需要周期性检查播放状态:启动播放、设置倍速、播完返回。 let videoMissingNotified = false; let lastVideoHeartbeatAt = 0; const videoInterval = setInterval(() => { const video = document.querySelector(VIDEO_SELECTOR); // 有些播放器会重建 video 元素,短暂找不到时继续等下一轮。 if (!video) { if (!videoMissingNotified) { notify("正在等待视频加载", "warning"); videoMissingNotified = true; } return; } videoMissingNotified = false; if (video.paused && video.currentTime === 0) { video.play(); notify("视频已开始播放", "success"); return; } if (video.currentTime >= video.duration - 1) { notify("视频播放结束,返回课程列表", "success"); clearInterval(videoInterval); returnToStudyPage(); } if (video.playbackRate !== CONFIG.videoPlaybackRate) { notify( "将视频倍速设置为" + CONFIG.videoPlaybackRate + "倍速", "success" ); video.playbackRate = CONFIG.videoPlaybackRate; } if ( Date.now() - lastVideoHeartbeatAt >= CONFIG.heartbeatSeconds * 1000 ) { lastVideoHeartbeatAt = Date.now(); notify( "视频正在播放,当前:" + formatSeconds(Math.floor(video.currentTime)) + " / 总时长:" + formatSeconds(Math.floor(video.duration || 0)), "info" ); } console.log("视频正在播放,当前时间:" + video.currentTime); }, 3000); } }; // 等待页面加载完成后执行 if (document.readyState === "complete") { onPageLoad(); } else { window.addEventListener("load", onPageLoad); } })();