// ==UserScript==
// @name EnaeaAsst-学习公社16X速全自动
// @namespace http://tampermonkey.net/
// @version 10.0.0
// @license MIT
// @description A fully-featured, robust, and intelligent learning assistant for ENAEA. Handles all playback scenarios and errors gracefully.
// @author [email protected], KKG&GM&CL
// @match https://study.enaea.edu.cn/circleIndexRedirect.do*
// @match https://study.enaea.edu.cn/viewerforccvideo.do*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @run-at document-end
// ==/UserScript==
(async function() {
'use strict';
// =================================================================
// --- 1. 配置区 (CONSTANTS) ---
// =================================================================
const CONFIG = {
TICK_INTERVAL: 3000, // 主循环心跳间隔(毫秒)
PLAY_DEBOUNCE_PERIOD: 5000, // 播放指令的防抖冷却时间(毫秒),防止过于频繁的点击
MAX_PLAY_ATTEMPTS: 20, // 单个视频播放失败的最大尝试次数(触发熔断)
MAX_COURSE_FAILURES: 3, // 单个课程被熔断的最大次数(触发跳过)
STATE_STORAGE_KEY: 'enaea_helper_state_v9_0', // 用户设置的永久状态存储Key
STATUS_FLAG_KEY: 'enaea_helper_task_status_v9_0', // 用于跨页面通信的状态旗帜Key
FAILED_COURSES_KEY: 'enaea_helper_failed_courses_v9_4',// 熔断失败课程的计数器存储Key
SPEED_TIERS: [16.0, 10.0, 8.0, 4.0, 2.0], // 速度阶梯,用于在高倍速无效时自动降速
VIDEO_PAGE_IDENTIFIER: 'viewerforccvideo.do', // 视频播放页的URL标识
DIRECTORY_PAGE_IDENTIFIER: 'circleIndexRedirect.do', // 课程目录页的URL标识
VIDEO_SIDEBAR_ITEM_SELECTOR: '.cvtb-MCK-course-content', // 视频页-侧边栏课程项
VIDEO_SIDEBAR_PROGRESS_SELECTOR: '.cvtb-MCK-CsCt-studyProgress',// 视频页-侧边栏课程项的进度
DIRECTORY_COURSE_ROW_SELECTOR: '#J_listContent tbody tr', // 目录页-课程列表的行
DIRECTORY_PROGRESS_COLUMN_INDEX: 4, // 目录页-“学习进度”列的索引(从0开始)
DIRECTORY_ACTION_COLUMN_INDEX: 5, // 目录页-“操作”按钮列的索引
PROGRESS_COMPLETE_TEXT_VIDEO: '100', // 进度100%的文本标识
PLAYER_HOST_SELECTOR: '#J_CC_videoPlayerDiv', // 播放器宿主容器(可能包含Shadow DOM)
BIG_PLAY_BUTTON_SELECTOR: 'xg-start', // 初始大播放按钮的选择器
CONTROL_BAR_PLAY_BUTTON_SELECTOR: 'xg-play' // 控制栏播放/暂停按钮的选择器
};
// =================================================================
// --- 2. 状态区 (GLOBAL STATE) ---
// =================================================================
let STATE = {
maxPlaybackRate: 16.0, // 用户期望的最高播放速率
isEvaluatingProgress: false, // 状态锁:是否正在评估视频进度,防止竞速
forceContinue: false // 状态锁:用户是否点击了“继续学习”,以无视总时长限制
};
let sessionState = {}; // 用于存储每个视频URL的独立状态 (速率、进度、尝试次数等)
// =================================================================
// --- 3. 函数定义区 (FUNCTIONS) ---
// =================================================================
// --- 状态管理函数 ---
async function loadState() { try { const permanentState = await GM_getValue(CONFIG.STATE_STORAGE_KEY, { maxPlaybackRate: 16.0 }); STATE.maxPlaybackRate = permanentState.maxPlaybackRate; sessionState = await GM_getValue(CONFIG.STATE_STORAGE_KEY + '_session', {}); } catch (e) { console.error("【脚本错误】加载状态失败:", e); STATE.maxPlaybackRate = 16.0; } }
async function saveSessionState() { try { await GM_setValue(CONFIG.STATE_STORAGE_KEY + '_session', sessionState); } catch (e) { console.error("【脚本错误】保存会话状态失败:", e); } }
async function savePermanentState() { try { await GM_setValue(CONFIG.STATE_STORAGE_KEY, { maxPlaybackRate: STATE.maxPlaybackRate }); } catch (e) { console.error("【脚本错误】保存永久状态失败:", e); } }
// --- UI 及播放器辅助函数 ---
function updateStatus(message) { const el = document.getElementById('helper-status-display'); if (el) el.textContent = message; }
function applyPlaybackRate(video, rate) { if (video && video.playbackRate !== rate) video.playbackRate = rate; }
function updateActiveButtons() { document.querySelectorAll('#playback-rate-buttons .rate-btn').forEach(b => b.classList.toggle('active', parseFloat(b.dataset.rate) === STATE.maxPlaybackRate)); }
function getPlayerContext() { const playerHost = document.querySelector(CONFIG.PLAYER_HOST_SELECTOR); if (!playerHost) return null; return playerHost.shadowRoot || playerHost; }
function clickPlayButton(context) { if (!context) return false; const bigPlayBtn = context.querySelector(CONFIG.BIG_PLAY_BUTTON_SELECTOR); if (bigPlayBtn && bigPlayBtn.offsetParent !== null) { bigPlayBtn.click(); return true; } const controlBarPlayBtn = context.querySelector(CONFIG.CONTROL_BAR_PLAY_BUTTON_SELECTOR); if (controlBarPlayBtn) { controlBarPlayBtn.click(); return true; } return false; }
/**
* @description 创建并显示控制UI面板
*/
function createControlPanel() {
if (document.getElementById('ai-helper-panel')) return;
const panelHTML = `
<div id="ai-helper-panel">
<div class="panel-title">智能学习助手 v10.0.0</div>
<div class="panel-status">
<strong>状态:</strong> <span id="helper-status-display">初始化...</span>
</div>
<div class="panel-controls">
<strong>最高期望速度:</strong>
<div id="playback-rate-buttons">
${CONFIG.SPEED_TIERS.map(r => `<button class="rate-btn" data-rate="${r}">${r}x</button>`).join('')}
</div>
<button id="force-continue-btn" style="display: none; grid-column: span 5; background-color: #28a745; color: white; margin-top: 10px; border: none;">继续学习 (无视时长)</button>
</div>
<div class="panel-warning">脚本将从此速度开始尝试,并根据情况自动降速</div>
</div>`;
GM_addStyle(` #ai-helper-panel { position: fixed; bottom: 20px; right: 20px; width: 320px; background-color: rgba(247, 247, 247, 0.9); border: 1px solid #ccc; border-radius: 12px; box-shadow: 0 6px 12px rgba(0,0,0,0.15); z-index: 99999; font-family: sans-serif; font-size: 14px; padding: 15px; color: #333; transition: opacity 0.3s; backdrop-filter: blur(5px); } #ai-helper-panel:hover { opacity: 1; } #ai-helper-panel .panel-title { font-size: 16px; font-weight: bold; color: #007bff; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee; } #ai-helper-panel .panel-controls { margin-bottom: 12px; display: grid; } #ai-helper-panel .panel-controls strong { display: block; margin-bottom: 8px; font-size: 13px; } #playback-rate-buttons { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; } #ai-helper-panel button { background-color: #fff; border: 1px solid #007bff; color: #007bff; padding: 6px 0; border-radius: 6px; cursor: pointer; transition: background-color 0.2s, color 0.2s; text-align: center; } #ai-helper-panel button.active { background-color: #007bff; color: #fff; font-weight: bold; } #ai-helper-panel .panel-warning { font-size: 11px; color: #999; text-align: center; margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px; } `);
document.body.insertAdjacentHTML('beforeend', panelHTML);
document.getElementById('playback-rate-buttons').addEventListener('click', async (e) => { const target = e.target.closest('button.rate-btn'); if (!target) return; const newRate = parseFloat(target.dataset.rate); STATE.maxPlaybackRate = newRate; updateActiveButtons(); await savePermanentState(); const playerContext = getPlayerContext(); if (playerContext) { const video = playerContext.querySelector('video'); if (video) { const pageUrl = window.location.href; if (!sessionState[pageUrl]) sessionState[pageUrl] = {}; sessionState[pageUrl].rate = newRate; await saveSessionState(); applyPlaybackRate(video, newRate); updateStatus(`速率已手动设置为: ${newRate}x`); } } });
document.getElementById('force-continue-btn').addEventListener('click', () => { STATE.forceContinue = true; document.getElementById('force-continue-btn').style.display = 'none'; updateStatus('强制继续模式已激活...'); mainTick(); });
updateActiveButtons();
}
// --- 核心页面逻辑 ---
/**
* @description 在视频页,处理当前课程分集的跳转。如果全系列完成,则通知目录页刷新。
*/
async function playNextUncompletedVideo() { for (const item of document.querySelectorAll(CONFIG.VIDEO_SIDEBAR_ITEM_SELECTOR)) { if (!item.querySelector(CONFIG.VIDEO_SIDEBAR_PROGRESS_SELECTOR)?.innerText.includes(CONFIG.PROGRESS_COMPLETE_TEXT_VIDEO)) { if (!item.classList.contains('current')) { item.click(); } return; } } updateStatus('当前系列课程已全部完成!通知目录页刷新...'); await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'refresh_needed'); window.close(); }
/**
* @description 目录页的核心处理函数,负责寻找下一个可学习的课程。
*/
async function handleDirectoryPage() {
// 步骤1:处理特殊状态(页面在后台、需要刷新、学习中)
if (document.hidden) { updateStatus('目录页在后台,暂停操作'); return; }
const taskStatus = await GM_getValue(CONFIG.STATUS_FLAG_KEY, 'initial');
if (taskStatus === 'refresh_needed') { await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'initial'); updateStatus('课程已完成,正在刷新目录以获取最新进度...'); location.reload(); return; }
if (taskStatus === 'studying') { updateStatus('视频学习中,本页暂停...'); return; }
// 步骤2:检查并满足“总学时”要求
const requireP = Array.from(document.querySelectorAll('.class-survey .require')).find(p => p.textContent.includes('要求'));
const completeP = Array.from(document.querySelectorAll('.class-survey .require')).find(p => p.textContent.includes('已学'));
if (requireP && completeP) {
const requiredMinutes = parseFloat(requireP.textContent.match(/[\d.]+/)?.[0] || 0);
const completedMinutes = parseFloat(completeP.textContent.match(/[\d.]+/)?.[0] || 0);
if (completedMinutes >= requiredMinutes && !STATE.forceContinue) {
updateStatus('学习总时长已达标!');
document.getElementById('force-continue-btn').style.display = 'block';
await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'all_done');
return;
}
}
// 步骤3:检查并设置页面大小为100
const pageSizeDisplay = document.querySelector('#J_myOptionRecords_customlength .disval');
if (pageSizeDisplay && pageSizeDisplay.textContent.trim() !== '100') { updateStatus('调整分页为100条/页...'); const dropdownTrigger = document.querySelector('#J_myOptionRecords_customlength'); if (dropdownTrigger) { dropdownTrigger.click(); setTimeout(() => { const option100 = document.querySelector('#J_myOptionRecords_customlength .dropmenu li[data="100"]'); if (option100) { option100.click(); } }, 200); } return; }
const courseRows = document.querySelectorAll(CONFIG.DIRECTORY_COURSE_ROW_SELECTOR);
if (courseRows.length === 0) { updateStatus('等待目录列表加载...'); return; }
const failedCounts = await GM_getValue(CONFIG.FAILED_COURSES_KEY, {});
// 步骤4:核心寻路逻辑:单次遍历,找到第一个可学习的课程
for (const row of courseRows) {
const progressCell = row.querySelectorAll('td')[CONFIG.DIRECTORY_PROGRESS_COLUMN_INDEX];
if (!progressCell) continue;
const progressValue = parseFloat(progressCell.textContent);
if (isNaN(progressValue) || progressValue >= 100) continue;
const actionCell = row.querySelectorAll('td')[CONFIG.DIRECTORY_ACTION_COLUMN_INDEX];
if (!actionCell) continue;
const actionButton = actionCell.querySelector('a[data-vurl*="courseId="]');
if (!actionButton) continue;
let courseId;
try {
courseId = new URL(actionButton.dataset.vurl, window.location.origin).searchParams.get('courseId');
} catch (e) { continue; }
if (!courseId) continue;
if ((failedCounts[courseId] || 0) >= CONFIG.MAX_COURSE_FAILURES) continue;
const courseTitle = (row.querySelector('a[title]') || actionButton).textContent.trim();
updateStatus(`找到未完成任务 [${courseTitle}]`);
await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'studying');
actionButton.click();
return;
}
// 步骤5:若循环结束仍未找到,则宣告全部完成
updateStatus('恭喜!所有可用课程均已完成。');
await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'all_done');
}
/**
* @description 视频播放页的核心处理函数
*/
async function handleVideoPage() {
if (STATE.isEvaluatingProgress) { updateStatus('评估进度中,请稍候...'); return; }
const playerContext = getPlayerContext();
if (!playerContext) { updateStatus('等待播放器宿主加载...'); return; }
const video = playerContext.querySelector('video');
const currentItem = document.querySelector(CONFIG.VIDEO_SIDEBAR_ITEM_SELECTOR + '.current');
if (!video || !currentItem) { updateStatus('等待视频和课程信息加载...'); return; }
const progressEl = currentItem.querySelector(CONFIG.VIDEO_SIDEBAR_PROGRESS_SELECTOR);
if (!progressEl) { updateStatus('等待进度元素加载...'); return; }
const progressText = progressEl.innerText.trim();
const pageUrl = window.location.href;
let videoState = sessionState[pageUrl];
if (!videoState) { videoState = { rate: STATE.maxPlaybackRate, lastProgress: 0, lastPlayAttemptTime: 0, playAttemptCounter: 0 }; sessionState[pageUrl] = videoState; await saveSessionState(); }
if (typeof videoState.playAttemptCounter === 'undefined') { videoState.playAttemptCounter = 0; }
if (progressText.includes(CONFIG.PROGRESS_COMPLETE_TEXT_VIDEO)) { updateStatus(`本集已完成 (100%),寻找下一集...`); delete sessionState[pageUrl]; await saveSessionState(); await playNextUncompletedVideo(); return; }
const currentProgress = parseFloat(progressText) || 0;
if (currentProgress > videoState.lastProgress) { videoState.lastProgress = currentProgress; videoState.playAttemptCounter = 0; await saveSessionState(); }
if (!video.seeking && video.duration > 0 && video.currentTime >= video.duration - 0.5) {
video.pause(); STATE.isEvaluatingProgress = true; updateStatus(`播放完毕,评估进度 (${currentProgress}%)...`);
const progressElementOfFinishedVideo = currentItem.querySelector(CONFIG.VIDEO_SIDEBAR_PROGRESS_SELECTOR);
setTimeout(async () => {
const latestProgress = parseFloat(progressElementOfFinishedVideo ? progressElementOfFinishedVideo.innerText.trim() : videoState.lastProgress);
if (latestProgress <= videoState.lastProgress) {
const currentSpeedIndex = CONFIG.SPEED_TIERS.indexOf(videoState.rate);
if (currentSpeedIndex < CONFIG.SPEED_TIERS.length - 1) { videoState.rate = CONFIG.SPEED_TIERS[currentSpeedIndex + 1]; updateStatus(`进度未动,降速至 ${videoState.rate}x 后重试...`); }
else { updateStatus(`已是最低速 (${videoState.rate}x),但进度仍未动...`); }
} else { videoState.lastProgress = latestProgress; }
await saveSessionState();
const seekAndPlay = () => { clickPlayButton(playerContext); STATE.isEvaluatingProgress = false; };
video.addEventListener('seeked', seekAndPlay, { once: true });
setTimeout(() => { STATE.isEvaluatingProgress = false; }, 3000);
video.currentTime = video.duration * (videoState.lastProgress / 100);
applyPlaybackRate(video, videoState.rate);
}, 2000);
return;
}
applyPlaybackRate(video, videoState.rate);
if (video.paused) {
const lastAttempt = videoState.lastPlayAttemptTime || 0;
if (Date.now() - lastAttempt > CONFIG.PLAY_DEBOUNCE_PERIOD) {
updateStatus(`检测到暂停,尝试自动播放 (第 ${videoState.playAttemptCounter + 1} 次)...`);
if (clickPlayButton(playerContext)) {
videoState.lastPlayAttemptTime = Date.now();
videoState.playAttemptCounter++;
await saveSessionState();
if (videoState.playAttemptCounter > CONFIG.MAX_PLAY_ATTEMPTS) {
updateStatus(`已连续尝试 ${CONFIG.MAX_PLAY_ATTEMPTS} 次播放失败,放弃此课程...`);
const failedCounts = await GM_getValue(CONFIG.FAILED_COURSES_KEY, {});
const courseId = new URL(pageUrl).searchParams.get('courseId');
if (courseId) { failedCounts[courseId] = (failedCounts[courseId] || 0) + 1; }
await GM_setValue(CONFIG.FAILED_COURSES_KEY, failedCounts);
await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'initial');
window.close();
return;
}
}
} else { updateStatus('等待播放器响应...'); }
} else {
updateStatus(`播放中 (速率: ${video.playbackRate}x, 进度: ${currentProgress}%)`);
}
}
/**
* @description 主心跳函数,作为任务路由,根据当前页面URL分发任务。
*/
async function mainTick() { if (window.location.href.includes(CONFIG.DIRECTORY_PAGE_IDENTIFIER)) { await handleDirectoryPage(); } else if (window.location.href.includes(CONFIG.VIDEO_PAGE_IDENTIFIER)) { await handleVideoPage(); } else { updateStatus('在未知页面或等待跳转...'); } }
// =================================================================
// --- 4. 启动区 (ENTRY POINT) ---
// =================================================================
(async function() {
try {
console.log('EnaeaAssistant-学习公社16X速全自动 V10.0.0 已加载。');
await loadState();
createControlPanel();
if (window.location.href.includes(CONFIG.DIRECTORY_PAGE_IDENTIFIER)) {
await GM_setValue(CONFIG.STATUS_FLAG_KEY, 'initial');
if (!sessionStorage.getItem('enaea_session_started')) {
await GM_setValue(CONFIG.FAILED_COURSES_KEY, {}); // 新会话开始时,清空失败计数器
sessionStorage.setItem('enaea_session_started', 'true');
}
}
setInterval(mainTick, CONFIG.TICK_INTERVAL);
} catch (error) {
console.error("【脚本启动时发生严重错误】:", error);
alert("脚本启动失败!请按F12查看控制台报错信息。");
}
})();
})();