298 lines
9.8 KiB
JavaScript
298 lines
9.8 KiB
JavaScript
// ==UserScript==
|
||
// @name 平顶山学院(青书学堂)自动学习脚本
|
||
// @namespace http://tampermonkey.net/
|
||
// @version 2026-06-03.1
|
||
// @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;
|
||
|
||
// 创建右下角 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)
|
||
);
|
||
|
||
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);
|
||
setTimeout(() => {
|
||
notify("文本课件等待完成,返回准备页面", "success");
|
||
returnToStudyPage();
|
||
}, MIN_STUDY_SECONDS * 1000);
|
||
return;
|
||
}
|
||
|
||
// 视频课件需要周期性检查播放状态:启动播放、设置倍速、播完返回。
|
||
let videoMissingNotified = false;
|
||
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;
|
||
}
|
||
|
||
console.log("视频正在播放,当前时间:" + video.currentTime);
|
||
}, 3000);
|
||
}
|
||
};
|
||
|
||
// 等待页面加载完成后执行
|
||
if (document.readyState === "complete") {
|
||
onPageLoad();
|
||
} else {
|
||
window.addEventListener("load", onPageLoad);
|
||
}
|
||
})();
|