434 lines
14 KiB
JavaScript
434 lines
14 KiB
JavaScript
// ==UserScript==
|
||
// @name 平顶山学院(青书学堂)自动学习脚本
|
||
// @namespace http://tampermonkey.net/
|
||
// @version 2026-06-05.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;
|
||
|
||
// 课件至少需要学习多少秒;默认 5 分钟。
|
||
const MIN_STUDY_SECONDS = 5 * 60;
|
||
|
||
// 视频课件的 video 标签选择器;如果网站改版导致找不到视频,优先检查这里。
|
||
const VIDEO_SELECTOR = "#vjs_video_3_html5_api";
|
||
|
||
// 视频默认播放倍速;想改成 1.5 倍、3 倍就改这个值。
|
||
const VIDEO_PLAYBACK_RATE = 2;
|
||
|
||
// 文本课件容器选择器;easyXDM 中间的数字会变,所以这里用开头和结尾匹配。
|
||
const TEXT_SELECTOR = "[id^='easyXDM_default'][id$='_provider']";
|
||
|
||
// toast 容器和样式标签的 id,通常不用改,除非和页面元素冲突。
|
||
const TOAST_CONTAINER_ID = "qingshu-auto-toast-container";
|
||
const TOAST_STYLE_ID = "qingshu-auto-toast-style";
|
||
|
||
// toast 默认显示时长,单位毫秒;4000 表示 4 秒后自动隐藏。
|
||
const TOAST_DEFAULT_DURATION = 4000;
|
||
|
||
// 运行心跳提示间隔,单位秒;视频播放中、文本课件等待中都会按这个间隔提醒。
|
||
const HEARTBEAT_SECONDS = 10;
|
||
|
||
// 课程列表最多等待多少秒;超过后认为可能请求失败,触发刷新。
|
||
const COURSE_LIST_WAIT_SECONDS = 60;
|
||
|
||
// 课程列表加载失败时最多自动刷新几次。
|
||
const MAX_COURSE_LIST_REFRESH_COUNT = 3;
|
||
|
||
// 记录课程列表自动刷新次数的 sessionStorage key。
|
||
// 带上 pathname/search,避免不同课程页面之间互相影响。
|
||
const COURSE_LIST_REFRESH_KEY =
|
||
"qingshu_auto_course_list_refresh_count:" +
|
||
location.pathname +
|
||
location.search;
|
||
|
||
// 创建右下角 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, 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 >= MAX_COURSE_LIST_REFRESH_COUNT) {
|
||
notify(
|
||
"课程列表等待超时,已刷新3次仍未加载,停止自动刷新",
|
||
"error",
|
||
8000
|
||
);
|
||
return;
|
||
}
|
||
|
||
const nextRefreshCount = refreshCount + 1;
|
||
sessionStorage.setItem(COURSE_LIST_REFRESH_KEY, String(nextRefreshCount));
|
||
notify(
|
||
"课程列表等待超过1分钟,准备刷新页面(第" +
|
||
nextRefreshCount +
|
||
"/" +
|
||
MAX_COURSE_LIST_REFRESH_COUNT +
|
||
"次)",
|
||
"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 < 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;
|
||
},
|
||
COURSE_LIST_WAIT_SECONDS * 1000
|
||
);
|
||
|
||
if (!items) {
|
||
refreshCourseStudyIfNeeded();
|
||
return;
|
||
}
|
||
|
||
clearCourseStudyRefreshCount();
|
||
|
||
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 !== VIDEO_PLAYBACK_RATE) {
|
||
notify("将视频倍速设置为" + VIDEO_PLAYBACK_RATE + "倍速", "success");
|
||
video.playbackRate = VIDEO_PLAYBACK_RATE;
|
||
}
|
||
|
||
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);
|
||
}
|
||
})();
|