// ==UserScript==
// @name 六局云学堂学习助手 V1.0
// @namespace Violentmonkey Scripts
// @match *://crsg.jianxianexuetang.cn/study/sub_cm/course/detail*
// @match *://crsg.jianxianexuetang.cn/study/sub_cm/content/detail*
// @grant none
// @run-at document-start
// @version 1.0
// @author 石港中心试验室出品
// @description (v1.0 Refactored) 仅保留自动播放和跳转模块 (脚本 A)。实现课程自动播放、PPT 自动浏览及跨章跳转,并包含强制从第一节课启动逻辑。
// ==/UserScript==
(function() {
"use strict";
// =========================================================================
// ## 常量配置
// =========================================================================
const CONFIG = {
// 版本信息
VERSION: "v1.0",
// 时间间隔配置
INTERVALS: {
CHECK: 5000,
REPORT: 5 * 60 * 1000,
RETRY: 3000,
VIDEO_WAIT: 5000,
PPT_WAIT: 5000,
MONITOR_DELAY: 10000,
AUTO_JUMP_DELAY: 1500,
AUTO_JUMP_TIMEOUT: 5000,
STARTUP_DELAY: 1500,
DETECT_DELAY: 1000,
CONTENT_DETECT_DELAY: 3000,
SCROLL_DELAY: 1000,
TIME_EXPAND_DELAY: 1000,
POPUP_CHECK_INTERVAL: 1000,
POPUP_AUTO_CLOSE_DELAY: 500
},
// 选择器配置
SELECTORS: {
VIDEO: 'video',
PPT_SLIDES: '.img-item.document-item',
PPT_CONTAINER: '.content-box.y-box',
PPT_TIME_CONTAINER: '.gapDiv.font-btn-color.pd-l-5',
PPT_TIME_ELEMENT: '.theme-color1',
VIDEO_REMAINING: 'div.gapDiv > div > span',
PHASE_LIST: '.phase-list .phase-item',
CONTENT_LIST: '.content-list .content-item',
ACTIVE_ITEM: '.content-item.active',
FIRST_ITEM: '.phase-list .content-item',
PANEL_HEADER: '#xuexi-header',
PANEL_BODY: '#xuexi-body',
// 弹窗相关选择器
POPUP_CONTAINER: '.el-dialog__wrapper, .ant-modal-root, .v-modal, .modal-mask, .popup-mask',
POPUP_DIALOG: '.el-dialog, .ant-modal, .v-modal, .modal, .popup',
POPUP_CONFIRM_BTN: '.el-button--primary, .ant-btn-primary, .confirm-btn, .ok-btn, .btn-primary',
POPUP_CLOSE_BTN: '.el-dialog__headerbtn, .ant-modal-close, .close-btn, .modal-close',
POPUP_OK_BTN: 'button:contains("确定"), button:contains("确认"), button:contains("OK"), button:contains("ok")',
POPUP_YES_BTN: 'button:contains("是"), button:contains("Yes"), button:contains("yes")',
POPUP_CONTENT: '.el-dialog__body, .ant-modal-body, .modal-body, .popup-content'
},
// UI配置
UI: {
PANEL_WIDTH: 260,
PANEL_MIN_WIDTH: 120,
PANEL_HEIGHT: 36,
PANEL_POSITION: { top: '50px', left: '50px' },
BACKGROUND_URL: 'https://picsum.photos/seed/professional-blue/800/600.jpg'
},
// 功能开关
FLAGS: {
SKIP_VIDEOS_WITHOUT_REQUIREMENT: false,
AUTO_HANDLE_POPUP: true,
POPUP_DEBUG_MODE: false
}
};
// =========================================================================
// ## 工具类和辅助函数
// =========================================================================
class Logger {
constructor(container) {
this.container = container;
this.logs = [];
}
log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const prefixes = {
info: 'ℹ️',
success: '✅',
warn: '⚠️',
error: '❌'
};
const prefix = prefixes[type] || '•';
const logEntry = `[${timestamp}] ${prefix} ${message}`;
this.logs.push(logEntry);
if (this.container) {
const line = document.createElement('div');
line.textContent = logEntry;
this.container.appendChild(line);
this.container.scrollTo({
top: this.container.scrollHeight,
behavior: 'smooth'
});
}
console.log(`[六局云学堂 ${CONFIG.VERSION}] ${message}`);
}
info(message) { this.log(message, 'info'); }
success(message) { this.log(message, 'success'); }
warn(message) { this.log(message, 'warn'); }
error(message) { this.log(message, 'error'); }
copyToClipboard() {
return navigator.clipboard.writeText(this.logs.join('\n'));
}
getLogs() {
return this.logs.join('\n');
}
}
class TimeUtils {
static formatSecondsToChinese(seconds) {
seconds = Math.round(seconds);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `(${minutes}分${remainingSeconds}秒)`;
}
static parseTimeToSeconds(timeText) {
let totalSeconds = 0;
const minuteMatch = timeText.match(/(\d+)分/);
if (minuteMatch?.[1]) {
totalSeconds += parseInt(minuteMatch[1], 10) * 60;
}
const secondMatch = timeText.match(/(\d+)秒/);
if (secondMatch?.[1]) {
totalSeconds += parseInt(secondMatch[1], 10);
}
return totalSeconds;
}
}
class DOMUtils {
static waitForElement(selector, timeout = 10000) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
static async scrollToElement(element, options = {}) {
if (!element) return;
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
...options
});
}
static async slowScroll(container, targetScrollTop, duration) {
return new Promise(resolve => {
const startScrollTop = container.scrollTop;
const distance = targetScrollTop - startScrollTop;
let startTime = null;
function easeOutQuad(t) {
return t * (2 - t);
}
function animation(currentTime) {
if (startTime === null) startTime = currentTime;
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
const easedProgress = easeOutQuad(progress);
container.scrollTop = startScrollTop + distance * easedProgress;
if (elapsedTime < duration) {
requestAnimationFrame(animation);
} else {
container.scrollTop = targetScrollTop;
resolve();
}
}
requestAnimationFrame(animation);
});
}
}
// =========================================================================
// ## 弹窗监控器类
// =========================================================================
class PopupMonitor {
constructor(logger) {
this.logger = logger;
this.monitoringTimer = null;
this.handledPopups = new Set();
this.isMonitoring = false;
}
startMonitoring() {
if (this.isMonitoring || !CONFIG.FLAGS.AUTO_HANDLE_POPUP) return;
this.isMonitoring = true;
this.logger.info('[弹窗] 开始监控弹窗...');
this.monitoringTimer = setInterval(() => {
this.checkAndHandlePopups();
}, CONFIG.INTERVALS.POPUP_CHECK_INTERVAL);
}
stopMonitoring() {
if (this.monitoringTimer) {
clearInterval(this.monitoringTimer);
this.monitoringTimer = null;
}
this.isMonitoring = false;
this.handledPopups.clear();
this.logger.info('[弹窗] 停止监控弹窗');
}
async checkAndHandlePopups() {
try {
// 检测各种类型的弹窗
const popups = [
...this.findElementsBySelectors([
CONFIG.SELECTORS.POPUP_CONTAINER,
CONFIG.SELECTORS.POPUP_DIALOG
]),
...this.findElementsByContent()
];
for (const popup of popups) {
if (this.shouldHandlePopup(popup)) {
await this.handlePopup(popup);
}
}
} catch (error) {
this.logger.error(`[弹窗] 检测过程中出错: ${error.message}`);
}
}
findElementsBySelectors(selectors) {
const elements = [];
for (const selector of selectors) {
try {
const found = document.querySelectorAll(selector);
elements.push(...Array.from(found));
} catch (error) {
// 忽略无效选择器
}
}
return elements;
}
findElementsByContent() {
const elements = [];
// 查找包含特定文本的元素
const popupKeywords = ['确定', '确认', '提示', '警告', '通知', '消息', 'OK', 'ok', 'Yes', 'yes'];
for (const keyword of popupKeywords) {
const xpath = `//*[contains(text(), '${keyword}') and (self::div or self::p or self::span)]`;
try {
const result = document.evaluate(xpath, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for (let i = 0; i < result.snapshotLength; i++) {
const element = result.snapshotItem(i);
// 检查是否是弹窗内容的一部分
const parentDialog = element.closest('.el-dialog, .ant-modal, .v-modal, .modal, .popup');
if (parentDialog) {
elements.push(parentDialog);
} else if (window.getComputedStyle(element).position === 'fixed') {
// 可能是独立弹窗
elements.push(element.parentElement || element);
}
}
} catch (error) {
// 忽略xpath错误
}
}
return elements;
}
shouldHandlePopup(popup) {
if (!popup) return false;
// 检查是否已经处理过
const popupId = this.getPopupId(popup);
if (this.handledPopups.has(popupId)) return false;
// 检查是否可见
if (!this.isPopupVisible(popup)) return false;
return true;
}
getPopupId(popup) {
// 生成弹窗的唯一标识
const id = popup.id || popup.className || popup.textContent?.substring(0, 50) || Math.random().toString(36);
return `${id}_${popup.tagName}`;
}
isPopupVisible(popup) {
try {
const style = window.getComputedStyle(popup);
const rect = popup.getBoundingClientRect();
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0 &&
rect.top >= 0;
} catch (error) {
return false;
}
}
async handlePopup(popup) {
const popupId = this.getPopupId(popup);
this.handledPopups.add(popupId);
try {
// 记录弹窗信息
const popupText = this.extractPopupText(popup);
this.logger.info(`[弹窗] 检测到弹窗: "${popupText.substring(0, 50)}${popupText.length > 50 ? '...' : ''}"`);
if (CONFIG.FLAGS.POPUP_DEBUG_MODE) {
this.logger.info(`[弹窗] 调试模式 - 弹窗信息: ${popup.outerHTML.substring(0, 200)}...`);
}
// 尝试自动处理弹窗
const handled = await this.autoHandlePopup(popup);
if (handled) {
this.logger.success(`[弹窗] 成功处理弹窗`);
} else {
this.logger.warn(`[弹窗] 无法自动处理弹窗,将等待5秒后重试`);
// 5秒后从处理列表中移除,以便重试
setTimeout(() => {
this.handledPopups.delete(popupId);
}, 5000);
}
} catch (error) {
this.logger.error(`[弹窗] 处理弹窗时出错: ${error.message}`);
this.handledPopups.delete(popupId);
}
}
extractPopupText(popup) {
const textElements = popup.querySelectorAll('p, div, span, h1, h2, h3, h4, h5, h6');
const texts = Array.from(textElements).map(el => el.textContent?.trim()).filter(text => text);
return texts.join(' ').substring(0, 200);
}
async autoHandlePopup(popup) {
// 策略1: 查找确认按钮
let confirmButton = this.findConfirmButton(popup);
if (confirmButton) {
await this.clickButton(confirmButton, '确认');
return true;
}
// 策略2: 查找OK按钮
let okButton = this.findOkButton(popup);
if (okButton) {
await this.clickButton(okButton, 'OK');
return true;
}
// 策略3: 查找是按钮
let yesButton = this.findYesButton(popup);
if (yesButton) {
await this.clickButton(yesButton, '是');
return true;
}
// 策略4: 查找主要按钮
let primaryButton = this.findPrimaryButton(popup);
if (primaryButton) {
await this.clickButton(primaryButton, '主要');
return true;
}
// 策略5: 查找关闭按钮
let closeButton = this.findCloseButton(popup);
if (closeButton) {
await this.clickButton(closeButton, '关闭');
return true;
}
// 策略6: 如果只有一个按钮,点击它
const allButtons = popup.querySelectorAll('button, .btn, [role="button"]');
if (allButtons.length === 1) {
await this.clickButton(allButtons[0], '唯一');
return true;
}
return false;
}
findConfirmButton(popup) {
const selectors = CONFIG.SELECTORS.POPUP_CONFIRM_BTN.split(', ');
for (const selector of selectors) {
try {
const button = popup.querySelector(selector);
if (button && this.isButtonVisible(button)) {
return button;
}
} catch (error) {
// 忽略选择器错误
}
}
// 查找包含确认文本的按钮
return this.findButtonByText(popup, ['确定', '确认', '确认']);
}
findOkButton(popup) {
return this.findButtonByText(popup, ['OK', 'ok', '确定', '好的']);
}
findYesButton(popup) {
return this.findButtonByText(popup, ['是', 'Yes', 'yes', '确定']);
}
findPrimaryButton(popup) {
const selectors = [
'.el-button--primary',
'.ant-btn-primary',
'.btn-primary',
'.primary-btn',
'[type="submit"]'
];
for (const selector of selectors) {
try {
const button = popup.querySelector(selector);
if (button && this.isButtonVisible(button)) {
return button;
}
} catch (error) {
// 忽略选择器错误
}
}
return null;
}
findCloseButton(popup) {
const selectors = CONFIG.SELECTORS.POPUP_CLOSE_BTN.split(', ');
for (const selector of selectors) {
try {
const button = popup.querySelector(selector);
if (button && this.isButtonVisible(button)) {
return button;
}
} catch (error) {
// 忽略选择器错误
}
}
return null;
}
findButtonByText(popup, texts) {
for (const text of texts) {
// 使用CSS选择器查找包含文本的元素
const xpath = `//button[contains(text(), '${text}')] | //*[@role="button"][contains(text(), '${text}')] | //*[contains(@class, 'btn')][contains(text(), '${text}')]`;
try {
const result = document.evaluate(xpath, popup, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const button = result.singleNodeValue;
if (button && this.isButtonVisible(button)) {
return button;
}
} catch (error) {
// 忽略xpath错误
}
}
// 备用方法:遍历所有按钮
const allButtons = popup.querySelectorAll('button, .btn, [role="button"]');
for (const button of allButtons) {
if (button.textContent && button.textContent.trim().includes(texts[0]) && this.isButtonVisible(button)) {
return button;
}
}
return null;
}
isButtonVisible(button) {
try {
const style = window.getComputedStyle(button);
const rect = button.getBoundingClientRect();
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
!button.disabled &&
rect.width > 0 &&
rect.height > 0;
} catch (error) {
return false;
}
}
async clickButton(button, buttonType) {
try {
this.logger.info(`[弹窗] 点击${buttonType}按钮: "${button.textContent?.trim() || button.className}"`);
// 滚动到按钮位置
button.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 等待一小段时间
await this.delay(CONFIG.INTERVALS.POPUP_AUTO_CLOSE_DELAY);
// 创建鼠标事件
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
clientX: button.getBoundingClientRect().left + 5,
clientY: button.getBoundingClientRect().top + 5
});
// 触发点击事件
button.dispatchEvent(clickEvent);
// 备用方法:直接调用click方法
if (typeof button.click === 'function') {
button.click();
}
return true;
} catch (error) {
this.logger.error(`[弹窗] 点击${buttonType}按钮失败: ${error.message}`);
return false;
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 手动处理弹窗的方法
async handleCurrentPopup() {
const popups = [
...this.findElementsBySelectors([
CONFIG.SELECTORS.POPUP_CONTAINER,
CONFIG.SELECTORS.POPUP_DIALOG
]),
...this.findElementsByContent()
];
if (popups.length === 0) {
this.logger.info('[弹窗] 当前没有检测到弹窗');
return false;
}
for (const popup of popups) {
if (this.shouldHandlePopup(popup)) {
await this.handlePopup(popup);
return true;
}
}
return false;
}
}
// =========================================================================
// ## UI管理类
// =========================================================================
class UIPanel {
constructor() {
this.panel = null;
this.header = null;
this.body = null;
this.logger = null;
this.isMinimized = false;
this.isDragging = false;
this.dragOffset = { x: 0, y: 0 };
}
init() {
this.createPanel();
this.setupStyles();
this.setupDragFunctionality();
this.setupMinimizeToggle();
this.setupCopyButton();
this.logger = new Logger(this.body);
this.logger.info('[系统] 六局云学堂已启动,等待内容加载...');
return this.logger;
}
createPanel() {
this.panel = document.createElement('div');
this.panel.innerHTML = `
`;
Object.assign(this.panel.style, {
position: 'fixed',
top: CONFIG.UI.PANEL_POSITION.top,
left: CONFIG.UI.PANEL_POSITION.left,
width: `${CONFIG.UI.PANEL_WIDTH}px`,
background: 'rgba(30,30,30,0.35)',
backgroundImage: `url("${CONFIG.UI.BACKGROUND_URL}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundBlendMode: 'overlay',
border: '1px solid rgba(255,255,255,0.25)',
borderRadius: '10px',
backdropFilter: 'blur(12px)',
color: '#fff',
zIndex: 9999999,
userSelect: 'none',
boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
overflow: 'hidden'
});
document.body.appendChild(this.panel);
this.header = this.panel.querySelector(CONFIG.SELECTORS.PANEL_HEADER);
this.body = this.panel.querySelector(CONFIG.SELECTORS.PANEL_BODY);
}
setupStyles() {
const style = document.createElement('style');
style.textContent = `
#xuexi-body::-webkit-scrollbar { width: 6px; }
#xuexi-body::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius:10px; }
#xuexi-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius:10px; }
#xuexi-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.5); }
`;
document.head.appendChild(style);
}
setupDragFunctionality() {
this.header.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.dragOffset.x = e.clientX - this.panel.offsetLeft;
this.dragOffset.y = e.clientY - this.panel.offsetTop;
const handleMouseMove = (e) => {
if (!this.isDragging) return;
this.panel.style.left = `${e.clientX - this.dragOffset.x}px`;
this.panel.style.top = `${e.clientY - this.dragOffset.y}px`;
};
const handleMouseUp = () => {
this.isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
}
setupMinimizeToggle() {
this.header.addEventListener('dblclick', () => {
this.isMinimized = !this.isMinimized;
if (this.isMinimized) {
this.body.style.display = 'none';
this.panel.style.width = `${CONFIG.UI.PANEL_MIN_WIDTH}px`;
this.panel.style.height = `${CONFIG.UI.PANEL_HEIGHT}px`;
this.panel.style.boxShadow = '0 2px 6px rgba(0,0,0,0.15)';
this.header.style.fontSize = '12px';
this.header.style.padding = '4px 0';
this.header.textContent = '六局云学堂(最小化)';
this.copyBtn.style.display = 'none';
this.popupBtn.style.display = 'none';
} else {
this.body.style.display = 'block';
this.panel.style.width = `${CONFIG.UI.PANEL_WIDTH}px`;
this.panel.style.height = 'auto';
this.panel.style.boxShadow = '0 4px 20px rgba(0,0,0,0.3)';
this.header.style.fontSize = '14px';
this.header.style.padding = '6px 0';
this.header.textContent = `学六局云学堂 ${CONFIG.VERSION}`;
this.copyBtn.style.display = 'flex';
this.popupBtn.style.display = 'flex';
}
});
}
setupControlButtons() {
// 复制日志按钮
this.copyBtn = document.createElement('div');
this.copyBtn.textContent = '📋';
Object.assign(this.copyBtn.style, {
position: 'absolute',
top: '6px',
right: '8px',
cursor: 'pointer',
color: '#fff',
fontWeight: 'bold',
userSelect: 'none',
fontSize: '12px',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
});
this.copyBtn.title = '点击复制日志';
this.panel.appendChild(this.copyBtn);
this.copyBtn.addEventListener('click', async () => {
try {
await this.logger.copyToClipboard();
this.logger.success('日志已复制到剪贴板');
} catch (error) {
this.logger.error('复制失败,请手动复制');
}
});
// 弹窗处理按钮
this.popupBtn = document.createElement('div');
this.popupBtn.textContent = '🎯';
Object.assign(this.popupBtn.style, {
position: 'absolute',
top: '6px',
right: '30px',
cursor: 'pointer',
color: '#fff',
fontWeight: 'bold',
userSelect: 'none',
fontSize: '12px',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
});
this.popupBtn.title = '手动处理弹窗';
this.panel.appendChild(this.popupBtn);
// 弹窗按钮点击事件将在外部设置
}
setupCopyButton() {
// 重定向到新的控制按钮设置方法
this.setupControlButtons();
}
setPopupButtonCallback(callback) {
if (this.popupBtn && callback) {
this.popupBtn.addEventListener('click', callback);
}
}
}
// =========================================================================
// ## 内容检测器类
// =========================================================================
class ContentDetector {
constructor(logger) {
this.logger = logger;
}
checkPptElements() {
const slides = document.querySelectorAll(CONFIG.SELECTORS.PPT_SLIDES);
if (slides.length === 0) return false;
const scrollContainer = document.querySelector(CONFIG.SELECTORS.PPT_CONTAINER);
const firstSlideImg = slides[0].querySelector('img');
return !!(scrollContainer && firstSlideImg);
}
detectContentType() {
if (this.checkPptElements()) {
return 'ppt';
} else if (document.querySelector(CONFIG.SELECTORS.VIDEO)) {
return 'video';
}
return 'unknown';
}
getVideoElement() {
return document.querySelector(CONFIG.SELECTORS.VIDEO);
}
getPptElements() {
return {
slides: document.querySelectorAll(CONFIG.SELECTORS.PPT_SLIDES),
container: document.querySelector(CONFIG.SELECTORS.PPT_CONTAINER),
timeContainer: document.querySelector(CONFIG.SELECTORS.PPT_TIME_CONTAINER),
timeElement: document.querySelector(CONFIG.SELECTORS.PPT_TIME_ELEMENT)
};
}
getVideoRemainingElement() {
return document.querySelector(CONFIG.SELECTORS.VIDEO_REMAINING);
}
getCurrentActiveItem() {
return document.querySelector(CONFIG.SELECTORS.ACTIVE_ITEM);
}
getFirstItem() {
return document.querySelector(CONFIG.SELECTORS.FIRST_ITEM);
}
getCourseStructure() {
const phaseList = [...document.querySelectorAll(CONFIG.SELECTORS.PHASE_LIST)];
const currentActive = this.getCurrentActiveItem();
if (!currentActive) return null;
const currentPhase = currentActive.closest('.phase-item');
const contentList = [...currentPhase.querySelectorAll(CONFIG.SELECTORS.CONTENT_LIST)];
const currentIndex = contentList.indexOf(currentActive);
const phaseIndex = phaseList.indexOf(currentPhase);
return {
phaseList,
currentPhase,
contentList,
currentIndex,
phaseIndex,
currentActive
};
}
}
// =========================================================================
// ## 视频处理器类
// =========================================================================
class VideoHandler {
constructor(logger, detector) {
this.logger = logger;
this.detector = detector;
this.lastVideoSrc = null;
this.progressTimer = null;
this.lastProgressReport = 0;
}
async handlePlayback() {
const video = this.detector.getVideoElement();
if (!video) {
this.logger.error('[视频] 启动失败,