// ==UserScript== // @name 超星视频助手 // @namespace local.chaoxing.video-helper // @version 1.0.0 // @description 鼠标移出不停播,自动二倍速静音,顺序播放视频,切换章节,选择 A 并提交章节题和视频题。 // @match https://*.chaoxing.com/mycourse/studentstudy* // @match https://*.chaoxing.com/mooc-ans/mycourse/studentstudy* // @match https://*.chaoxing.com/mooc-ans/knowledge/cards* // @match https://*.chaoxing.com/knowledge/cards* // @match https://*.chaoxing.com/ananas/modules/video/index.html* // @match https://*.chaoxing.com/ananas/modules/work/index.html* // @match https://*.chaoxing.com/mooc-ans/api/work* // @run-at document-start // @grant none // @inject-into page // ==/UserScript== (() => { 'use strict'; const STORE_KEY = `cx-video-helper`; const FLAG = '__cxVideoHelperV1__'; const GUARD = '__cxVideoHelperGuardV1__'; const DEFAULTS = { running: true, autoNext: true, autoAnswer: true }; let root; try { root = window.top; const url = new URL(root.location.href); if ((url.searchParams.get('courseId'))) return; } catch { // 本课程的播放器和作业页已确认同源;无法读取的跨域页面不参与自动操作。 return; } const active = () => root.document.documentElement?.dataset.cxHelperRunning !== 'false'; function guardWindow(win) { if (win[GUARD]) return; win[GUARD] = true; const stopDeparture = event => { if (!active()) return; const leaving = /^(mouseout|mouseleave)$/.test(event.type) && !event.relatedTarget; const losingFocus = event.type === 'blur' && (event.target === win || event.target === win.document); if (leaving || losingFocus || event.type === 'visibilitychange') { event.stopImmediatePropagation(); } }; // 平台的 studyControl 在顶层 window 监听 relatedTarget 为空的 mouseout。 // 提前截住这个事件,保留播放器自身的 pause,题目暂停和手动暂停仍可生效。 for (const type of ['mouseout', 'mouseleave', 'blur', 'visibilitychange']) { win.addEventListener(type, stopDeparture, true); win.document.addEventListener(type, stopDeparture, true); } } guardWindow(window); if (window !== root || window[FLAG]) return; window[FLAG] = true; let settings = { ...DEFAULTS }; try { const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); for (const key of Object.keys(DEFAULTS)) { if (typeof saved[key] === 'boolean') settings[key] = saved[key]; } } catch { /* 存储不可用时使用默认设置。 */ } let videoStates = new WeakMap(); let quizStates = new WeakMap(); let workStates = new WeakMap(); const manualPaused = new WeakSet(); const watchedDocs = new WeakSet(); let currentCard = null; let cardSettled = false; let cardTimer; let advancePending = false; let advanceTimer; let navigationPending = false; let navigationTimer; let ticking = false; let statusNode; let runButton; let message = '等待课程内容加载'; const text = node => (node?.textContent || '').replace(/\s+/g, ' ').trim(); const log = value => { message = value; if (statusNode) statusNode.textContent = value; }; function save() { if (document.documentElement) { document.documentElement.dataset.cxHelperRunning = String(settings.running); } try { localStorage.setItem(STORE_KEY, JSON.stringify(settings)); } catch {} if (runButton) runButton.textContent = settings.running ? '暂停助手' : '启动助手'; } // getClientRects 判断是否实际显示;不要求位于视口内,下面的视频也能顺序播放。 function visible(node) { if (!node?.isConnected || !node.getClientRects().length) return false; const win = node.ownerDocument.defaultView; for (let parent = node; parent; parent = parent.parentElement) { const style = win.getComputedStyle(parent); if (style.display === 'none' || style.visibility === 'hidden' || parent.hidden) return false; } try { const frame = win.frameElement; return !frame || visible(frame); } catch { return false; } } function enabled(node) { return !!node && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && !node.classList.contains('disabled') && visible(node); } function watchDocument(doc) { if (watchedDocs.has(doc)) return; watchedDocs.add(doc); guardWindow(doc.defaultView); doc.addEventListener('click', event => { if (!event.isTrusted) return; const target = event.target?.closest?.('video,.vjs-play-control,.vjs-big-play-button'); if (!target) return; const container = target.closest('.video-js'); const video = target.tagName === 'VIDEO' ? target : container?.querySelector('video'); if (!video) return; if (video.paused) { manualPaused.delete(video); const state = videoStates.get(video); if (state) state.blocked = false; } else { manualPaused.add(video); } }, true); } function collectDocuments(start) { const docs = []; const seen = new Set(); const visit = doc => { if (!doc || seen.has(doc)) return; seen.add(doc); docs.push(doc); watchDocument(doc); for (const frame of doc.querySelectorAll('iframe')) { if (!visible(frame)) continue; try { visit(frame.contentDocument); } catch {} } }; visit(start); return docs; } function verificationVisible(doc) { return ['#verifyCodeWin', '#verifyCodeTipWin', '#captcha', '.geetest_panel', '.tcaptcha', 'iframe[src*="captcha"]', 'iframe[src*="verifycode"]'] .some(selector => [...doc.querySelectorAll(selector)].some(visible)); } function selectA(question) { const custom = [...question.querySelectorAll( 'li[onclick*="addChoice"],li[onclick*="addMultipleChoice"]' )].filter(visible); if (custom.length) { const letter = node => text(node.querySelector('label.before,.before,label')).toUpperCase(); const option = custom.find(node => /^A[.、::]?$/.test(letter(node))); if (!option) return false; const answer = question.querySelector('input[name^="answer"]:not([name^="answertype"])'); const type = question.querySelector('input[name^="answertype"]')?.value; const selected = node => { const label = letter(node).replace(/[.、::]/g, ''); if (node.querySelector('input:checked') || node.matches('.check_answer,.checked,.selected') || node.querySelector('.check_answer,.checked,.selected')) return true; const value = (answer?.value || '').toUpperCase(); if (type === '3') { return label === 'A' && /^(A|TRUE|1)$/.test(value); } return value.replace(/[,;\s]/g, '').includes(label); }; // 多选题也只留 A。点击页面原控件,让平台同步隐藏答案字段。 if (type === '1' || option.getAttribute('onclick')?.includes('addMultipleChoice')) { for (const other of custom) { if (other !== option && selected(other)) other.click(); } } if (!selected(option)) option.click(); if (type === '1') return (answer?.value || '').replace(/[,;\s]/g, '').toUpperCase() === 'A'; return selected(option); } const inputs = [...question.querySelectorAll('input[type="radio"],input[type="checkbox"]')] .filter(input => !input.disabled && visible(input.closest('label,li') || input)); if (!inputs.length) return false; const option = inputs.find(input => { const label = input.closest('label') || input.closest('li') || input.parentElement; return /^A(?:[.、::\s]|$)/i.test(text(label)) || input.value.toUpperCase() === 'A'; }); if (!option) return false; if (option.type === 'checkbox') { for (const other of inputs) { if (other !== option && other.checked) other.click(); } } if (!option.checked) option.click(); return option.checked && !inputs.some(input => input !== option && input.checked); } function processVideoQuiz(quiz) { const signature = text(quiz.querySelector('.tkItem_title')) + text(quiz.querySelector('.tkItem_ul')); let state = quizStates.get(quiz); if (!state || state.signature !== signature) { state = { signature, submitted: false, continued: false }; quizStates.set(quiz, state); } if (!settings.autoAnswer) { log('视频题已暂停播放,请手动答题'); return; } const resultShown = ['#spanHas', '#spanNot', '#spanNotBack', '#spanNotBackPoint'] .some(selector => [...quiz.querySelectorAll(selector)].some(visible)); if (state.submitted && resultShown) { const next = quiz.querySelector('#videoquiz-continue,.ans-videoquiz-continue'); if (!state.continued && enabled(next)) { state.continued = true; next.click(); log('视频题已提交,继续播放'); } else if (!enabled(next)) { log('已选 A;平台要求重答或回看,请手动处理'); } return; } if (state.submitted) { log('视频题已选 A,等待提交结果'); return; } const question = quiz.querySelector('.tkItem') || quiz; if (!selectA(question)) { log('视频题没有可操作的 A 选项,请手动处理'); return; } const submit = quiz.querySelector('#videoquiz-submit'); if (!enabled(submit)) { log('已选 A,等待视频题提交按钮'); return; } // 在 click 之前上锁,避免 AJAX 返回前重复提交。 state.submitted = true; submit.click(); log('视频题已选 A 并提交'); } function workState(doc) { let state = workStates.get(doc); if (!state) { state = { submitted: false, confirmed: false }; workStates.set(doc, state); } return state; } function processWork(doc) { const questions = [...doc.querySelectorAll('.TiMu')].filter(visible); const state = workState(doc); const rejected = doc.querySelector('#submitBack'); if (visible(rejected)) { settings.running = false; save(); log('全部选 A 未达到及格线,助手已暂停,请手动处理'); return 'blocked'; } const confirmation = doc.querySelector('#confirmSubWin'); if (visible(confirmation)) { if (settings.autoAnswer && state.submitted && !state.confirmed) { const button = confirmation.querySelector('[onclick*="submitCheckTimes"]'); if (enabled(button)) { state.confirmed = true; button.click(); log('已确认提交章节题'); } } return 'busy'; } // 不根据“点击成功”判断完成;等待平台实际显示已完成或提交成功。 const editable = questions.some(q => q.querySelector( 'li[onclick*="addChoice"],li[onclick*="addMultipleChoice"],input[type="radio"],input[type="checkbox"]' )); const completed = !editable && ( /作业已完成|已提交|提交成功|已完成|查看作业/.test(text(doc.body)) || /查看作业|作业详情/.test(doc.title) || (questions.length > 0 && /成绩|得分|正确答案|我的答案/.test(text(doc.body))) ); if (completed) return 'done'; if (!questions.length) return 'loading'; if (!settings.autoAnswer) { log('章节题等待手动答题'); return 'busy'; } if (state.submitted) { log('章节题已选 A,等待平台提交结果'); return 'busy'; } // 填空、简答等没有 A 的题型会返回 false,此时不提交整份作业。 const selections = questions.map(selectA); if (!selections.every(Boolean)) { log('存在没有 A 选项或尚未选中的题目,请手动处理'); return 'busy'; } const submit = [...doc.querySelectorAll('.btnSubmit.workBtnIndex,[onclick*="btnBlueSubmit"]')] .find(enabled); if (!submit) { log('所有章节题已选 A,等待提交按钮'); return 'busy'; } state.submitted = true; submit.click(); log('章节题全部选 A 并提交'); return 'busy'; } function advance(card) { if (!settings.running || !settings.autoNext || card !== currentCard || navigationPending) return; const tabs = [...document.querySelectorAll('li[id^="dct"]')].filter(node => /^dct\d+$/.test(node.id)); let afterCurrent = false; let nextTab; for (const tab of tabs) { if (afterCurrent && /视频|测试|试题|练习|测验/.test(text(tab)) && enabled(tab)) { nextTab = tab; break; } if (tab.classList.contains('active')) afterCurrent = true; } const nextChapter = [...document.querySelectorAll('#prevNextFocusNext,.nextChapter')].find(enabled); const target = nextTab || nextChapter; if (!target) { log('当前任务已结束,未找到下一节按钮'); return; } navigationPending = true; log(nextTab ? `切换到${text(nextTab)}` : '切换到下一节'); target.click(); clearTimeout(navigationTimer); navigationTimer = setTimeout(() => { if (card === currentCard && navigationPending) { settings.running = false; save(); log('页面切换尚未完成,助手已暂停,请检查页面提示'); } }, 12000); } function scheduleAdvance(card) { if (!settings.autoNext) { log('当前任务已结束,自动切换已关闭'); return; } if (advancePending || navigationPending || !cardSettled) return; advancePending = true; log('当前任务已结束,等待平台保存进度后切换'); advanceTimer = setTimeout(() => { advancePending = false; // 重新检查题目、验证码和视频结束状态,不直接使用五秒前的状态。 if (card === currentCard && settings.running) tick(true); }, 5000); } function prepareVideo(video) { let state = videoStates.get(video); const source = video.currentSrc || video.getAttribute('src') || video.querySelector('source')?.src || ''; if (!state || state.source !== source) { state = { source, playing: false, blocked: false }; videoStates.set(video, state); manualPaused.delete(video); } try { if (video.playbackRate !== 2) video.playbackRate = 2; if (video.defaultPlaybackRate !== 2) video.defaultPlaybackRate = 2; if (!video.muted) video.muted = true; if (!video.defaultMuted) video.defaultMuted = true; } catch {} return state; } function tick(readyToAdvance = false) { if (ticking || !settings.running) return; ticking = true; try { const allDocs = collectDocuments(document); if (allDocs.some(verificationVisible)) { log('遇到验证码,请手动完成,完成后助手会继续'); return; } const cardFrame = document.querySelector('#iframe'); let card; try { card = cardFrame?.contentDocument; } catch {} if (!card || !card.body || card.readyState === 'loading') { log('等待课程内容加载'); return; } if (card !== currentCard) { clearTimeout(cardTimer); clearTimeout(advanceTimer); clearTimeout(navigationTimer); currentCard = card; advancePending = false; navigationPending = false; cardSettled = false; cardTimer = setTimeout(() => { if (card === currentCard) cardSettled = true; }, 4000); } if (navigationPending) return; const docs = collectDocuments(card); const quizzes = docs.flatMap(doc => [...doc.querySelectorAll('.ans-videoquiz')]).filter(visible); if (quizzes.length) { quizzes.forEach(processVideoQuiz); return; } const workDocs = docs.filter(doc => { if (/\/(?:mooc-ans\/)?api\/work(?:$|[/?])/.test(doc.URL)) return true; // api/work 可能重定向到 doHomeWorkNew;通过已确认的作业 iframe 识别, // 避免只按最终 URL 判断而漏掉章节题。 try { return doc.defaultView.frameElement?.id === 'frame_content' && /\/ananas\/modules\/work\//.test(doc.defaultView.parent.location.pathname); } catch { return false; } }); const workResults = workDocs.map(processWork); if (!settings.running || workResults.some(result => result !== 'done')) return; const videos = docs.flatMap(doc => [...doc.querySelectorAll('video')]).filter(visible); const pending = videos.find(video => !video.ended); if (pending) { // 第二个视频即使被其他代码启动,也先暂停;已结束的第一个不会重新播放。 for (const video of videos) { if (video !== pending && !video.paused && !video.ended) video.pause(); } const state = prepareVideo(pending); if (manualPaused.has(pending)) { log('视频已手动暂停,点击播放器继续'); return; } if (state.blocked) { log('浏览器拦截自动播放,请点击视频播放按钮'); return; } if (!pending.paused) { log('正在顺序播放:2 倍速 · 静音'); return; } if (!state.playing && pending.readyState > 0) { state.playing = true; Promise.resolve(pending.play()).catch(error => { if (error?.name !== 'AbortError') { state.blocked = true; log('浏览器拦截自动播放,请点击视频播放按钮'); } }).finally(() => { state.playing = false; }); } return; } if (videos.length || workResults.length) { if (readyToAdvance) advance(card); else scheduleAdvance(card); return; } const mediaWaiting = docs.some(doc => [...doc.querySelectorAll('iframe')].some(frame => /\/modules\/(?:video|work)\//.test(frame.getAttribute('src') || '') )); const currentTab = document.querySelector('li[id^="dct"].active'); if (mediaWaiting || /视频|测试|试题|练习|测验/.test(text(currentTab))) { log('等待视频或题目加载;如显示权限提示,请手动处理'); return; } // 用户手动打开资料页时,转向之后的视频或题目页;不提交资料页上的表单。 if (readyToAdvance) advance(card); else scheduleAdvance(card); } catch (error) { console.warn('[超星视频助手]', error); log('页面结构暂未识别,请查看控制台提示'); } finally { ticking = false; } } function mountPanel() { if (!document.body || document.querySelector('#cx-video-helper-panel')) return; const panel = document.createElement('aside'); panel.id = 'cx-video-helper-panel'; panel.style.cssText = 'position:fixed;right:16px;bottom:18px;z-index:2147483647;width:270px;' + 'padding:14px;background:#f8faf7;color:#172017;border:1px solid #aab5a8;border-radius:8px;' + 'font:13px/1.65 system-ui,sans-serif;box-shadow:0 3px 14px #0002'; const title = document.createElement('strong'); title.textContent = '超星视频助手'; panel.append(title); statusNode = document.createElement('div'); statusNode.style.cssText = 'margin:8px 0;white-space:normal'; statusNode.setAttribute('aria-live', 'polite'); statusNode.textContent = message; panel.append(statusNode); for (const [key, labelText] of [['autoNext', '自动切换视频/章节'], ['autoAnswer', '答题只选 A 并自动提交']]) { const label = document.createElement('label'); label.style.cssText = 'display:block;cursor:pointer;margin:3px 0'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = settings[key]; input.addEventListener('change', () => { settings[key] = input.checked; save(); }); label.append(input, ` ${labelText}`); panel.append(label); } runButton = document.createElement('button'); runButton.type = 'button'; runButton.style.cssText = 'margin-top:10px;padding:5px 12px;border:1px solid #6b7f64;' + 'border-radius:4px;background:#dbe9d4;color:#172017;cursor:pointer'; runButton.addEventListener('click', () => { settings.running = !settings.running; clearTimeout(navigationTimer); if (settings.running) { // 明确点击重启后允许重新尝试被浏览器阻止的播放和未成功的提交。 videoStates = new WeakMap(); quizStates = new WeakMap(); workStates = new WeakMap(); navigationPending = false; advancePending = false; } else { clearTimeout(advanceTimer); advancePending = false; } save(); log(settings.running ? '助手已启动' : '助手已暂停,可手动操作页面'); if (settings.running) tick(); }); panel.append(runButton); document.body.append(panel); save(); } save(); document.addEventListener('DOMContentLoaded', () => { mountPanel(); tick(); }, { once: true }); if (document.readyState !== 'loading') { mountPanel(); tick(); } setInterval(() => { mountPanel(); tick(); }, 1200); })();