// ==UserScript== // @name 学习公社:课程顺序播放助手 // @namespace local.enaea.course-helper // @version 1.1.0 // @description 当前分集100%且视频暂停时切换;刷新课程列表核对,通过网站原有按钮继续。 // @match https://study.enaea.edu.cn/circleIndexRedirect.do* // @match https://study.enaea.edu.cn/viewerforccvideo.do* // @run-at document-idle // @noframes // @grant GM_getValue // @grant GM_setValue // @grant GM_getTab // @grant GM_saveTab // @grant GM_addValueChangeListener // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; // 只读取页面进度;不调用计时接口,不修改倍速、播放位置或在场检测。 const SETTINGS = { saveWaitMs: 20000, refreshGapMs: 15000, maxChecks: 4, pageWaitMs: 20000, heartbeatTimeoutMs: 90000, stalledMs: 60000, progressWaitMs: 90000, }; function parseProgress(value) { const match = String(value ?? '').trim().match(/^(\d+(?:\.\d+)?)\s*[%%]?$/); if (!match) return null; const n = Number(match[1]); return Number.isFinite(n) && n >= 0 && n <= 100 ? n : null; } function agreeProgress(text, attribute) { const displayed = parseProgress(text); const stored = parseProgress(attribute); return displayed !== null && (stored === null || displayed === stored) ? displayed : null; } function chooseCourse(courses) { if (courses.some(c => c.progress === null || !c.id || !c.url)) { throw new Error('课程行或进度识别失败,已暂停,请检查页面。'); } return courses.find(c => c.progress < 100) || null; } function signalMatches(run, signal) { return Boolean(run && signal && run.current && signal.runId === run.id && signal.nonce === run.current.nonce && signal.courseId === run.current.id); } function playerMatches(run, courseId, nonce) { return Boolean(run && run.current && nonce && run.current.id === courseId && run.current.nonce === nonce); } function nextEpisode(episodes, endedIds) { if (episodes.some(e => e.progress === null || !e.id)) { throw new Error('分集进度识别失败,请手动检查课程目录。'); } return episodes.find(e => e.progress < 100 && !endedIds.includes(e.id)) || null; } function verificationResult(progress, checks) { if (progress === 100) return 'continue'; return checks >= SETTINGS.maxChecks ? 'pause' : 'refresh'; } function canAdvanceEpisode(progress, video) { return Boolean(progress === 100 && video && video.paused === true && !video.error); } function nonceForCourse(run, courseId) { return run && ['running', 'paused'].includes(run.status) && run.current?.id === courseId ? run.current.nonce : null; } // 供本地检查关键规则;浏览器中不会进入这个分支。 if (typeof module === 'object' && module.exports) { module.exports = { parseProgress, agreeProgress, chooseCourse, signalMatches, playerMatches, nextEpisode, verificationResult, canAdvanceEpisode, nonceForCourse }; return; } const params = new URL(location.href).searchParams; const circleId = params.get('circleId'); const isList = location.pathname === '/circleIndexRedirect.do' && params.get('action') === 'toNewMyClass' && params.get('type') === 'course'; const isVideo = location.pathname === '/viewerforccvideo.do'; if (!circleId || (!isList && !isVideo)) return; const key = `enaea-helper:v1:${circleId}`; const signalKey = `${key}:signal`; const getRun = () => GM_getValue(key, null); const saveRun = run => GM_setValue(key, run); const uid = () => crypto.randomUUID(); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); let tabId; let busy = false; let lastSignalAt = Date.now(); let status; let localNonce = null; let videoRef = null; let endedTimer = null; let reported = false; let triedPlay = false; let playingAt = Date.now(); let lastTime = -1; let lastReadyAt = 0; let endedIds = []; let stoppedVideo = false; let waitingForProgressAt = 0; function visible(el) { return Boolean(el && el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden'); } function challengePresent() { const dialogs = document.querySelectorAll('[role="dialog"], .layui-layer, .ui-dialog, .dialog, .popbox'); return Array.from(dialogs).some(el => visible(el) && /验证码|人机验证|身份核验|随堂测[试验]|请.{0,12}(回答|答题|确认|点击)|是否.{0,12}(学习|观看)|长时间未操作/.test(el.innerText)); } function setStatus(message) { status.textContent = message; } function pause(reason) { const run = getRun(); if (!run || run.status === 'stopped' || run.status === 'done') return; run.status = 'paused'; run.message = reason; saveRun(run); setStatus(reason); } function send(type, message = '') { const run = getRun(); if (!playerMatches(run, params.get('courseId'), localNonce)) return; GM_setValue(signalKey, { type, message, runId: run.id, nonce: localNonce, courseId: params.get('courseId'), tabId, at: Date.now(), sequence: uid() }); } function readCourses() { return Array.from(document.querySelectorAll('#J_myOptionRecords a.golearn')).map(button => { const row = button.closest('tr'); const rawUrl = button.getAttribute('data-vurl'); const u = rawUrl ? new URL(rawUrl, location.href) : null; const valid = u && u.origin === location.origin && u.pathname === '/viewerforccvideo.do' && u.searchParams.get('circleId') === circleId && button.getAttribute('data-view') === 'true'; return { id: button.getAttribute('courseid-id'), title: row?.querySelector('.course-title')?.textContent.trim() || '未命名课程', progress: agreeProgress(row?.querySelector('.progressvalue')?.textContent, button.getAttribute('data-progress')), url: valid ? u.href : null }; }); } function readEpisodes() { return Array.from(document.querySelectorAll('li.cvtb-MCK-course-content')).map(el => ({ id: el.getAttribute('data-id'), progress: parseProgress(el.querySelector('.cvtb-MCK-CsCt-studyProgress')?.textContent), element: el, })); } function currentEpisode() { return readEpisodes().find(episode => episode.element.classList.contains('current')) || null; } function clickCourse(course) { const button = Array.from(document.querySelectorAll('#J_myOptionRecords a.golearn')) .find(el => el.getAttribute('courseid-id') === course.id); const rawUrl = button?.getAttribute('data-vurl'); if (!rawUrl || new URL(rawUrl, location.href).href !== course.url) { throw new Error('课程按钮或链接已变化,请刷新列表后继续。'); } // 调用网站原有事件,让网站自行复用它的播放标签页。 button.click(); } function pageNumber() { return document.querySelector('#J_myOptionRecords_paginate .paginate_active')?.textContent.trim() || '1'; } function enabledPageButton(selector) { const el = document.querySelector(selector); return el && !el.classList.contains('paginate_button_disabled') ? el : null; } function listLoaded() { const table = document.querySelector('#J_myOptionRecords'); const processing = document.querySelector('#J_myOptionRecords_processing'); return Boolean(table && !visible(processing) && !/内容正在加载|请稍等/.test(table.innerText) && (readCourses().length || /暂无|没有|无数据|没有找到/.test(table.innerText))); } function owns(run) { return run && run.owner === tabId && run.listUrl === location.href && run.status === 'running'; } async function waitFor(predicate) { const end = Date.now() + SETTINGS.pageWaitMs; while (Date.now() < end) { if (!owns(getRun())) throw new Error('队列已暂停或控制页已改变。'); if (challengePresent()) throw new Error('出现答题或确认弹窗,请手动处理后继续。'); if (predicate()) return; await sleep(300); } throw new Error('列表加载或翻页超时,请检查网络后继续。'); } async function goPage(selector) { const button = enabledPageButton(selector); if (!button) return false; const before = pageNumber(); const ids = readCourses().map(c => c.id).join(','); button.click(); await waitFor(() => listLoaded() && pageNumber() !== before && readCourses().map(c => c.id).join(',') !== ids); return true; } async function scanPages(targetId = null) { await waitFor(listLoaded); await goPage('#J_myOptionRecords_first'); const pages = new Set(); let courseCount = 0; do { const page = pageNumber(); if (pages.has(page)) throw new Error('翻页没有前进,已暂停。'); pages.add(page); const courses = readCourses(); courseCount += courses.length; // 未找到目标时也校验所有行,避免把未知进度当成完成。 const candidate = chooseCourse(courses); const result = targetId ? courses.find(c => c.id === targetId) : candidate; if (result) return result; } while (await goPage('#J_myOptionRecords_next')); if (targetId) throw new Error('刷新后找不到刚学的课程,请切到“所有课程”后继续。'); if (!courseCount) throw new Error('课程列表为空,不能确认全部课程已完成。'); return null; } async function listTick() { if (busy) return; let run = getRun(); if (!owns(run)) { setStatus(run ? run.message || (run.status === 'running' ? '另一张列表页正在控制队列。' : '队列已暂停。') : '先关闭其他视频页,再点击“开始 / 继续”。'); return; } busy = true; try { if (challengePresent()) throw new Error('出现答题或确认弹窗,请手动处理后继续。'); if (run.phase === 'playing') { setStatus(`播放中:${run.current.title}`); if (Date.now() - lastSignalAt > SETTINGS.heartbeatTimeoutMs) { throw new Error('视频页未响应,请检查是否被关闭、脚本是否生效或是否需要点击播放。'); } return; } if (run.phase === 'verify') { if (run.awaitingCheck) { const course = await scanPages(run.current.id); run = getRun(); if (!owns(run)) return; run.awaitingCheck = false; const result = verificationResult(course.progress, run.checks); if (result === 'continue') { run.phase = 'scan'; run.current = null; run.message = '已确认100%,准备下一课。'; saveRun(run); // 等待视频页收到核对结果。 await sleep(1500); } else if (result === 'pause') { throw new Error(`刷新${run.checks}次后仍为${course.progress ?? '未知'}%,已暂停。请检查分集进度或实际计时。`); } else { run.checkAt = Date.now() + SETTINGS.refreshGapMs; saveRun(run); } } run = getRun(); if (!owns(run)) return; if (run.phase === 'verify') { setStatus(`等待保存 / 刷新核对:${run.current.title}(第${run.checks + 1}次)`); if (Date.now() >= run.checkAt) { run.checks += 1; run.awaitingCheck = true; saveRun(run); // 刷新前保存状态,避免刷新后重新打开同一课。 location.reload(); } return; } } const course = await scanPages(); run = getRun(); if (!owns(run)) return; if (!course) { run.status = 'done'; run.message = '已检查全部分页:当前课程列表均为100%。'; saveRun(run); return; } run.phase = 'playing'; run.current = { ...course, nonce: uid(), playerId: null }; run.message = `播放中:${course.title}`; saveRun(run); lastSignalAt = Date.now(); clickCourse(course); } catch (error) { if (owns(getRun())) pause(error.message); } finally { busy = false; } } function handleSignal(signal) { const run = getRun(); if (!owns(run) || !signalMatches(run, signal) || run.phase !== 'playing') return; if (run.current.playerId && run.current.playerId !== signal.tabId) return; if (!run.current.playerId) { run.current.playerId = signal.tabId; saveRun(run); } lastSignalAt = Date.now(); if (signal.type === 'problem') pause(signal.message); if (signal.type === 'course-ended') { run.phase = 'verify'; run.checks = 0; run.awaitingCheck = false; run.checkAt = Date.now() + SETTINGS.saveWaitMs; run.message = '学习页100%且视频暂停,等待保存后刷新核对。'; saveRun(run); } } function attemptPlay(video) { if (triedPlay || video.ended) return; triedPlay = true; // 首次已在播放也消耗这次尝试;以后平台暂停时不能自动解除。 if (!video.paused) return; Promise.resolve(video.play()).catch(() => send('problem', '浏览器阻止自动播放,请手动点击播放后点击“开始 / 继续”。')); } function handleEnded() { if (endedTimer || reported) return; const run = getRun(); if (!playerMatches(run, params.get('courseId'), localNonce) || run.status !== 'running') return; const current = currentEpisode(); if (!current?.id) { send('problem', '无法识别当前分集,请检查课程目录。'); return; } // ended 事件仅触发检查;100%和暂停两项都必须满足。 if (!canAdvanceEpisode(current.progress, videoRef)) return; const episodeId = current.id; setStatus('本集100%且已暂停,等待平台保存进度。'); endedTimer = setTimeout(() => { endedTimer = null; const latest = getRun(); if (!playerMatches(latest, params.get('courseId'), localNonce) || latest.status !== 'running') return; try { if (challengePresent()) throw new Error('出现答题或确认弹窗,请手动处理后继续。'); const confirmed = currentEpisode(); if (confirmed?.id !== episodeId || !canAdvanceEpisode(confirmed.progress, videoRef)) return; if (!endedIds.includes(episodeId)) endedIds.push(episodeId); const episodes = readEpisodes(); if (!episodes.length) throw new Error('课程分集目录为空,已暂停。'); const next = nextEpisode(episodes, endedIds); if (next) { next.element.click(); triedPlay = false; playingAt = Date.now(); lastTime = -1; waitingForProgressAt = 0; setStatus('已切换下一分集。'); } else { reported = true; send('course-ended'); setStatus('等待课程列表刷新确认100%。'); } } catch (error) { send('problem', error.message); } }, SETTINGS.saveWaitMs); } function playerTick() { const run = getRun(); if (!playerMatches(run, params.get('courseId'), localNonce)) { if (videoRef && !videoRef.paused) videoRef.pause(); setStatus(localNonce ? '此课已结束或队列已停止。' : '此页未由助手打开;从课程列表启动队列。'); return; } if (run.current.playerId && run.current.playerId !== tabId) { videoRef?.pause(); setStatus('该课程已有另一张视频页,请关闭这张重复页。'); return; } if (run.status !== 'running') { if (videoRef && !videoRef.paused) videoRef.pause(); stoppedVideo = true; if (endedTimer) { clearTimeout(endedTimer); endedTimer = null; } setStatus(run.message || '队列已暂停。'); return; } if (stoppedVideo) { stoppedVideo = false; triedPlay = false; playingAt = Date.now(); waitingForProgressAt = 0; } if (Date.now() - lastReadyAt > 5000 && !reported) { send('heartbeat'); lastReadyAt = Date.now(); } if (reported) return; if (challengePresent()) { send('problem', '出现答题、验证码或确认弹窗,请手动处理后继续。'); return; } const video = document.querySelector('#J_CC_videoPlayerDiv video'); if (!video) { if (Date.now() - playingAt > SETTINGS.stalledMs) send('problem', '没有找到视频播放器,请检查课程是否加载成功。'); return; } if (video !== videoRef) { videoRef?.removeEventListener('ended', handleEnded); videoRef = video; video.addEventListener('ended', handleEnded); triedPlay = false; playingAt = Date.now(); lastTime = -1; waitingForProgressAt = 0; } if (video.error) { send('problem', '视频加载失败,请检查网络后继续。'); return; } const episode = currentEpisode(); if (canAdvanceEpisode(episode?.progress, video)) { handleEnded(); return; } if (video.ended) { waitingForProgressAt ||= Date.now(); setStatus(`视频已结束,等待当前分集显示100%(目前${episode?.progress ?? '未知'}%)。`); if (Date.now() - waitingForProgressAt > SETTINGS.progressWaitMs) { send('problem', '视频结束后仍不满足100%且暂停的条件,请检查分集进度。'); } return; } waitingForProgressAt = 0; attemptPlay(video); if (video.currentTime !== lastTime) { lastTime = video.currentTime; playingAt = Date.now(); } else if (!endedTimer && Date.now() - playingAt > SETTINGS.stalledMs) { send('problem', '视频超过60秒没有前进,请检查暂停、缓冲或在场确认。'); return; } if (!endedTimer) setStatus(`顺序播放中;平台分集进度:${readEpisodes().map(e => e.progress ?? '?').join(' / ')}%`); } function startOrResume() { let run = getRun(); if (isVideo) { if (!playerMatches(run, params.get('courseId'), localNonce)) return; if (run.status === 'paused') { run.status = 'running'; run.message = '已继续。'; saveRun(run); } triedPlay = false; playingAt = Date.now(); playerTick(); return; } if (run?.status === 'running' && (run.owner !== tabId || run.listUrl !== location.href)) { setStatus('另一张列表页正在运行。请在那里操作,或先停止队列。'); return; } if (run?.status === 'paused' && run.owner === tabId && run.listUrl === location.href) { run.status = 'running'; run.message = '已继续。'; if (run.phase === 'verify') { run.checks = 0; run.awaitingCheck = false; run.checkAt = Date.now(); } } else { run = { id: uid(), owner: tabId, listUrl: location.href, status: 'running', phase: 'scan', current: null, message: '正在查找未完成课程。' }; } lastSignalAt = Date.now(); saveRun(run); // 使用“所有课程”,避免未完成过滤使核对目标消失。 const allTab = document.querySelector('.customcur-tab[data-status=""]'); if (allTab && !allTab.classList.contains('customcur-tab-select')) allTab.click(); listTick(); } function makePanel() { const panel = document.createElement('section'); panel.id = 'enaea-helper-panel'; panel.style.cssText = 'position:fixed;right:16px;bottom:18px;z-index:2147483647;width:285px;padding:14px;background:#fff;color:#222;border:1px solid #aaa;border-radius:10px;box-shadow:0 3px 15px #0003;font:14px/1.5 system-ui;'; const title = document.createElement('strong'); title.textContent = '学习公社 · 顺序播放助手'; panel.append(title); status = document.createElement('p'); status.style.cssText = 'margin:10px 0;overflow-wrap:anywhere;'; panel.append(status); const actions = [['开始 / 继续', startOrResume], ['暂停', () => pause('已手动暂停。')], ['停止', () => { const run = getRun(); if (run) { run.status = 'stopped'; run.message = '已停止;再次开始会重新扫描课程。'; saveRun(run); } }]]; for (const [label, action] of actions) { const button = document.createElement('button'); button.textContent = label; button.style.cssText = 'cursor:pointer;padding:6px 9px;margin:0 5px 5px 0;border:1px solid #bbb;border-radius:5px;background:#f5f5f5;color:#222;'; button.addEventListener('click', action); panel.append(button); } document.body.append(panel); } GM_getTab(tab => { tab.enaeaHelperId ||= uid(); tabId = tab.enaeaHelperId; GM_saveTab(tab); makePanel(); if (isList) { GM_addValueChangeListener(signalKey, (_name, _old, signal) => handleSignal(signal)); GM_addValueChangeListener(key, () => { if (!busy) listTick(); }); // 跨刷新恢复:只消费当前课程的消息,旧视频事件不能推进新队列。 handleSignal(GM_getValue(signalKey, null)); setInterval(listTick, 1000); listTick(); } else { // 网站原有学习链接不带助手参数,从当前队列绑定这门课程。 localNonce = nonceForCourse(getRun(), params.get('courseId')); GM_addValueChangeListener(key, () => playerTick()); setInterval(playerTick, 1000); playerTick(); } }); })();