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.7
// @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,
// 提示消息默认显示时长单位毫秒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;
// 创建右下角提示消息容器和样式。
// 每次通知都会创建一个新的提示消息,所以多个通知会一起堆叠显示。
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、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("设置提示消息显示时间", () => {
promptNumberConfig(
"设置提示消息显示时间",
"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);
}
})();