// ==UserScript== // @name 平顶山学院(青书学堂)自动学习脚本 // @namespace http://tampermonkey.net/ // @version 2026-06-03.2 // @description try to take over the world! // @author You // @match https://degree.qingshuxuetang.com/pdsu/Student/Course/CourseStudy* // @match https://degree.qingshuxuetang.com/pdsu/Student/Course/CourseShow* // @icon https://www.google.com/s2/favicons?sz=64&domain=qingshuxuetang.com // @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 none // ==/UserScript== (function () { "use strict"; const url = window.location.href; const MIN_STUDY_SECONDS = 5 * 60; const VIDEO_SELECTOR = "#vjs_video_3_html5_api"; const TEXT_SELECTOR = "[id^='easyXDM_default'][id$='_provider']"; const TOAST_CONTAINER_ID = "qingshu-auto-toast-container"; const TOAST_STYLE_ID = "qingshu-auto-toast-style"; const TOAST_DEFAULT_DURATION = 4000; const HEARTBEAT_SECONDS = 10; // 创建右下角 toast 容器和样式。 // 每次通知都会创建一个新的 toast,所以多个通知会一起堆叠显示。 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; }; // 显示一条 toast,同时保留 console 输出。 // type 可用 info、success、warning、error;duration 是自动隐藏时间。 const notify = (message, type = "info", duration = TOAST_DEFAULT_DURATION) => { 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); }; notify("插件加载完成,等待界面渲染", "info"); // 等待某个 DOM 条件成立。 // 页面里的课程列表、视频、文本课件经常是后加载的,所以不用固定延迟, // 而是监听 DOM 变化,直到 getResult() 返回有效结果。 const waitForCondition = (getResult) => { const result = getResult(); if (result) { return Promise.resolve(result); } return new Promise((resolve) => { const observer = new MutationObserver(() => { const result = getResult(); if (result) { observer.disconnect(); resolve(result); } }); observer.observe(document.documentElement, { childList: true, subtree: true, }); }); }; // 把“共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 < MIN_STUDY_SECONDS; }; // 返回课程列表页。 // 优先点击页面自带的返回按钮;如果没找到,就用浏览器历史记录兜底。 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; }); notify("获取到课程数量:" + items.length, "success"); // 从上到下找到第一个“没学过”或“学习不足5分钟”的课件并进入。 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 标签,不需要播放;停留 5 分钟后返回列表页。 if (coursewareElement.matches(TEXT_SELECTOR)) { notify("检测到文本课件,等待5分钟后返回准备页面", "warning", 6000); const textStartedAt = Date.now(); let textInterval; const notifyTextProgress = () => { const studiedSeconds = Math.floor((Date.now() - textStartedAt) / 1000); if (studiedSeconds >= MIN_STUDY_SECONDS) { clearInterval(textInterval); notify("文本课件等待完成,返回准备页面", "success"); returnToStudyPage(); return; } notify( "文本课件学习中,已等待:" + formatSeconds(studiedSeconds) + ",剩余:" + formatSeconds(MIN_STUDY_SECONDS - studiedSeconds), "info" ); }; textInterval = setInterval( notifyTextProgress, HEARTBEAT_SECONDS * 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 === 1) { notify("将视频倍速设置为2倍速", "success"); video.playbackRate = 2; } if (Date.now() - lastVideoHeartbeatAt >= HEARTBEAT_SECONDS * 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); } })();