tampermonkey_script/qingshuxuetang_auto_play.us...

565 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name 平顶山学院(青书学堂)自动学习脚本
// @namespace http://tampermonkey.net/
// @version 2026-06-05.5
// @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://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 GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// ==/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,
// toast 默认显示时长单位毫秒4000 表示 4 秒后自动隐藏。
toastDefaultDuration: 4000,
// 运行心跳提示间隔,单位秒;视频播放中、文本课件等待中都会按这个间隔提醒。
heartbeatSeconds: 10,
// 课程列表最多等待多少秒;超过后认为可能请求失败,触发刷新。
courseListWaitSeconds: 60,
// 课程列表加载失败时最多自动刷新几次。
maxCourseListRefreshCount: 3,
};
// 读取 Tampermonkey 里保存过的配置,并和默认配置合并。
// 以后脚本新增配置项时,旧配置也能自动补上默认值。
const CONFIG = {
...DEFAULT_CONFIG,
...GM_getValue(CONFIG_STORAGE_KEY, {}),
};
// toast 容器和样式标签的 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;
// 创建右下角 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、errorduration 是自动隐藏时间。
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);
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 = () => {
GM_registerMenuCommand("设置视频倍速", () => {
promptNumberConfig(
"设置视频倍速",
"videoPlaybackRate",
"倍",
CONFIG.videoPlaybackRate
);
});
GM_registerMenuCommand("设置最小学习时间", () => {
promptNumberConfig(
"设置最小学习时间",
"minStudySeconds",
"分钟",
CONFIG.minStudySeconds / 60,
(value) => value * 60
);
});
GM_registerMenuCommand("设置心跳提示间隔", () => {
promptNumberConfig(
"设置心跳提示间隔",
"heartbeatSeconds",
"秒",
CONFIG.heartbeatSeconds
);
});
GM_registerMenuCommand("设置课程列表等待时间", () => {
promptNumberConfig(
"设置课程列表等待时间",
"courseListWaitSeconds",
"秒",
CONFIG.courseListWaitSeconds
);
});
GM_registerMenuCommand("设置课程列表最大刷新次数", () => {
promptNumberConfig(
"设置课程列表最大刷新次数",
"maxCourseListRefreshCount",
"次",
CONFIG.maxCourseListRefreshCount,
Math.floor
);
});
GM_registerMenuCommand("设置 toast 显示时长", () => {
promptNumberConfig(
"设置 toast 显示时长",
"toastDefaultDuration",
"秒",
CONFIG.toastDefaultDuration / 1000,
(value) => value * 1000
);
});
GM_registerMenuCommand("重置脚本配置", () => {
GM_deleteValue(CONFIG_STORAGE_KEY);
Object.assign(CONFIG, DEFAULT_CONFIG);
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);
}
})();