// ==UserScript== // @name 学习通课程任务助手 // @namespace local.chaoxing.course.helper // @version 1.2.3 // @description 修复脚本猫隔离环境的鼠标事件构造异常,支持封面播放器、多媒体、题库与详细进度 // @author JXY // @license MIT // @homepage https://scriptcat.org/ // @supportURL https://scriptcat.org/ // @match *://chaoxing.com/* // @match *://*.chaoxing.com/* // @match *://xueyinonline.com/* // @match *://*.xueyinonline.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_registerMenuCommand // @grant unsafeWindow // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const MESSAGE_TYPE = 'cx-course-helper-worker-v1'; const PAGE_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const IS_TOP = PAGE_WINDOW.top === PAGE_WINDOW; const IS_DIRECT_CHILD = !IS_TOP && PAGE_WINDOW.parent === PAGE_WINDOW.top; let controllerActive = IS_TOP; let parentControllerSeen = IS_TOP; let controllerMenuRegistered = false; const WORKER_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const STORE = { enabled: 'cx_helper_enabled', speed: 'cx_helper_speed', autoJump: 'cx_helper_auto_jump', bank: 'cx_helper_bank', unanswered: 'cx_helper_unanswered', logs: 'cx_helper_logs' }; const CONFIG = { defaultSpeed: 1, interval: 1500, workerTtl: 6500, navigationCooldown: 8000, completionStableTime: 2500, detailPageDelay: 1800, selectors: { questions: ['.TiMu', '.questionLi', '.quesItem', '.ans-ques'], optionGroups: [ '.Zy_ulTop > li', '.answerList > li', '.answerOption', '.option-item', 'label:has(input[type="radio"])', 'label:has(input[type="checkbox"])' ], textInputs: ['input[type="text"]', 'textarea'], submit: [ 'button.submitBtn', '.submitBtn', '.answer-submit', 'button[data-action="submit"]', 'button[type="submit"]' ], next: [ 'button.nextChapter', 'a.nextChapter', '.next-btn', 'button[title*="下一节"]', 'a[title*="下一节"]', 'button[aria-label*="下一"]', 'a[aria-label*="下一"]' ], catalog: [ '.chapter_unit a[href*="studentstudy"]', '.catalog_level a[href*="studentstudy"]', '.catalog_points a[href]', '.ncells a[href]', '#courseChapter .chapter_item a[href]', '#courseChapter [onclick*="chapter"]', '.chapter_item .catalog_name', 'a[href*="chapterId"][href*="courseId"]', '.posCatalog_name' ], currentCatalog: [ '.posCatalog_select', '.catalog_points .curr', '.chapter_unit .curr', '.catalog_level .curr', '.ncells .curr', '[aria-current="true"]' ], completed: [ '.completed', '.finish', '.finished', '.jobFinish', '.icon_Completed', '.ans-job-finished', '[data-status="completed"]', '[title*="已完成"]' ], incomplete: [ '.jobUnfinish', '.unfinished', '.icon-unfinish', '[data-status="unfinished"]', '[title*="未完成"]' ] } }; const FALLBACK_PREFIX = 'cx_helper_fallback:'; const storageApi = { get(key, fallback) { try { if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); } catch (error) { console.warn('[XXT] GM_getValue failed, using local storage', error); } try { const raw = localStorage.getItem(`${FALLBACK_PREFIX}${key}`); return raw === null ? fallback : JSON.parse(raw); } catch (_) { return fallback; } }, set(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } } catch (error) { console.warn('[XXT] GM_setValue failed, using local storage', error); } try { localStorage.setItem(`${FALLBACK_PREFIX}${key}`, JSON.stringify(value)); } catch (_) { // Storage can be unavailable in restricted third-party frames. } }, listen(key, callback) { try { if (typeof GM_addValueChangeListener === 'function') { return GM_addValueChangeListener(key, callback); } } catch (error) { console.warn('[XXT] GM_addValueChangeListener failed', error); } return null; }, registerMenu(label, callback) { try { if (typeof GM_registerMenuCommand === 'function') { return GM_registerMenuCommand(label, callback); } } catch (error) { console.warn('[XXT] GM_registerMenuCommand failed', error); } return null; } }; const state = { enabled: toBoolean(storageApi.get(STORE.enabled, false)), speed: clampSpeed(storageApi.get(STORE.speed, CONFIG.defaultSpeed)), autoJump: toBoolean(storageApi.get(STORE.autoJump, true)), bank: loadObject(STORE.bank), bankIndex: new Map(), unanswered: loadObject(STORE.unanswered), media: new WeakMap(), answeredQuestions: new WeakSet(), submittedQuestions: new WeakSet(), pageSubmitted: false, workers: new Map(), panel: null, progressWindow: null, progressOpen: false, timer: null, running: false, seenWork: false, stableSince: 0, lastNavigation: 0, detailJumpAttempted: false, detailExpandAttempted: false, detailPageEnteredAt: Date.now(), currentTaskKey: '', taskEnteredAt: Date.now(), ignoreWorkerUntil: 0, lastLog: '', lastLogAt: 0, lastError: '' }; function isController() { return controllerActive; } function announceController() { if (!isController()) return; try { for (let index = 0; index < PAGE_WINDOW.frames.length; index += 1) { PAGE_WINDOW.frames[index].postMessage({ type: MESSAGE_TYPE, kind: 'controller-present' }, '*'); } } catch (_) { // Cross-origin frames still accept postMessage when referenced directly. } } function activateController() { controllerActive = true; parentControllerSeen = true; if (document.body) createPanel(); if (!controllerMenuRegistered) { storageApi.registerMenu('启动/暂停助手', toggleEnabled); storageApi.registerMenu('导入本地题库', importBank); storageApi.registerMenu('导出未匹配题目', exportUnanswered); controllerMenuRegistered = true; } announceController(); } function deactivateController() { if (IS_TOP) return; controllerActive = false; state.panel?.remove(); state.progressWindow?.remove(); state.panel = null; state.progressWindow = null; state.progressOpen = false; state.workers.clear(); } function probeParentController() { if (!IS_DIRECT_CHILD || window.parent === window) return; window.parent.postMessage({ type: MESSAGE_TYPE, kind: 'controller-probe' }, '*'); } rebuildBankIndex(); migrateLegacyUnanswered(); function toBoolean(value) { if (typeof value === 'string') return value === 'true' || value === '1'; return Boolean(value); } function clampSpeed(value) { const number = Number(value); if (!Number.isFinite(number)) return CONFIG.defaultSpeed; return Math.max(0.5, Math.min(16, number)); } function loadObject(key) { const raw = storageApi.get(key, '{}'); try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch (_) { return {}; } } function saveObject(key, value) { storageApi.set(key, JSON.stringify(value)); } function normalize(value) { return String(value ?? '') .replace(/[\u00a0\r\n\t ]+/g, ' ') .replace(/[“”"'「」『』()()【】\[\]]/g, '') .replace(/^\s*\d+[.、]\s*/, '') .trim() .toLowerCase(); } function rebuildBankIndex() { state.bankIndex = new Map(); Object.entries(state.bank).forEach(([question, answer]) => { if (!question.startsWith('_unanswered:')) { const key = normalize(question); if (key) state.bankIndex.set(key, answer); } }); } function migrateLegacyUnanswered() { let changed = false; Object.keys(state.bank).forEach((key) => { if (!key.startsWith('_unanswered:')) return; const question = key.slice('_unanswered:'.length); if (question && !Object.prototype.hasOwnProperty.call(state.unanswered, question)) { state.unanswered[question] = ''; } delete state.bank[key]; changed = true; }); if (changed) { saveObject(STORE.bank, state.bank); saveObject(STORE.unanswered, state.unanswered); rebuildBankIndex(); } } function isVisible(node) { if (!(node instanceof Element)) return false; const style = getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function queryAllInOrder(selectors, root = document) { if (!selectors.length) return []; try { return Array.from(root.querySelectorAll(selectors.join(','))); } catch (_) { const nodes = []; selectors.forEach((selector) => { try { root.querySelectorAll(selector).forEach((node) => { if (!nodes.includes(node)) nodes.push(node); }); } catch (_) { // Ignore selectors unsupported by an older browser engine. } }); return nodes; } } function firstVisible(selectors, root = document) { return queryAllInOrder(selectors, root).find(isVisible) || null; } function clickElement(node) { if (!(node instanceof Element) || !isVisible(node)) return false; node.scrollIntoView({ block: 'center', behavior: 'auto' }); ['mouseover', 'mousedown', 'mouseup'].forEach((type) => { node.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); }); node.click(); return true; } function setNativeValue(input, value) { const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); if (descriptor && descriptor.set) descriptor.set.call(input, String(value ?? '')); else input.value = String(value ?? ''); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } function pushLog(message, force = false) { const now = Date.now(); if (!force && message === state.lastLog && now - state.lastLogAt < 4000) return; state.lastLog = message; state.lastLogAt = now; if (!isController()) { window.parent.postMessage({ type: MESSAGE_TYPE, kind: 'log', message, hops: 0 }, '*'); return; } const line = `${new Date().toLocaleTimeString()} ${message}`; const existing = storageApi.get(STORE.logs, []); const lines = Array.isArray(existing) ? existing : []; storageApi.set(STORE.logs, [line, ...lines].slice(0, 100)); updatePanel(); } function reportError(error) { const message = error instanceof Error ? error.message : String(error); state.lastError = message; pushLog(`运行异常:${message}`, true); } function bindStoreListeners() { storageApi.listen(STORE.enabled, (_name, _old, value) => { state.enabled = toBoolean(value); updatePanel(); }); storageApi.listen(STORE.speed, (_name, _old, value) => { state.speed = clampSpeed(value); updatePanel(); }); storageApi.listen(STORE.autoJump, (_name, _old, value) => { state.autoJump = toBoolean(value); updatePanel(); }); storageApi.listen(STORE.bank, () => { state.bank = loadObject(STORE.bank); rebuildBankIndex(); }); storageApi.listen(STORE.unanswered, () => { state.unanswered = loadObject(STORE.unanswered); }); } function getQuestionBlocks() { for (const selector of CONFIG.selectors.questions) { const candidates = Array.from(document.querySelectorAll(selector)).filter(isVisible); if (!candidates.length) continue; const blocks = candidates.map((node) => { if (!node.matches('.TiMu')) return node; const ownsAnswerControls = node.querySelector('.Zy_ulTop, .answerList, input, textarea'); return ownsAnswerControls ? node : node.closest('.questionLi, .quesItem, .ans-ques') || node; }); return blocks.filter((node, index) => blocks.indexOf(node) === index && isVisible(node)); } return []; } function getQuestionText(block) { const title = block.querySelector('.Zy_TItle, .quesTitle, .question-title, .mark_name, [data-question-title]'); if (title) return normalize(title.textContent); const clone = block.cloneNode(true); queryAllInOrder(CONFIG.selectors.optionGroups, clone).forEach((node) => node.remove()); queryAllInOrder(CONFIG.selectors.textInputs, clone).forEach((node) => node.remove()); return normalize(clone.textContent).slice(0, 500); } function findAnswer(question) { const key = normalize(question); if (!key) return null; if (state.bankIndex.has(key)) return state.bankIndex.get(key); if (key.length < 6) return null; for (const [candidate, answer] of state.bankIndex.entries()) { if (candidate.length >= 6 && (key.includes(candidate) || candidate.includes(key))) return answer; } return null; } function rememberUnanswered(question) { if (!question || Object.prototype.hasOwnProperty.call(state.unanswered, question)) return; state.unanswered[question] = ''; saveObject(STORE.unanswered, state.unanswered); pushLog(`未匹配题目:${question.slice(0, 36)}`); } function findOptionNodes(block) { for (const selector of CONFIG.selectors.optionGroups) { let nodes = []; try { nodes = Array.from(block.querySelectorAll(selector)).filter(isVisible); } catch (_) { nodes = []; } if (nodes.length) return nodes; } return []; } function optionText(node) { const preferred = node.querySelector('.after, .option-content, .answerText, .clearfix'); const text = normalize((preferred || node).textContent); return text.replace(/^[a-z][.、.]\s*/i, '').trim(); } function optionLetter(node) { const text = normalize(node.textContent); const match = text.match(/^([a-z])[.、.\s]/i); return match ? match[1].toLowerCase() : ''; } function isOptionSelected(node) { const input = node.matches('input') ? node : node.querySelector('input[type="radio"], input[type="checkbox"]'); if (input && input.checked) return true; if (node.getAttribute('aria-checked') === 'true' || node.getAttribute('aria-selected') === 'true') return true; return /(^|\s)(selected|checked|active|on)(\s|$)/i.test(node.className || ''); } function isQuestionCompleted(block) { if (state.submittedQuestions.has(block)) return true; if (block.matches('.completed, .finished, [data-status="completed"]')) return true; return Boolean(firstVisible(['.answerAnalysis', '.analysis', '.Py_answer', '.rightAnswer', '.result-right'], block)); } function answerQuestion(block) { const question = getQuestionText(block); if (isQuestionCompleted(block) || state.answeredQuestions.has(block)) { return { completed: true, pending: false, matched: true }; } const answer = findAnswer(question); if (answer === null || answer === undefined || answer === '') { rememberUnanswered(question); return { completed: false, pending: true, matched: false }; } const answers = (Array.isArray(answer) ? answer : [answer]).map(normalize).filter(Boolean); const options = findOptionNodes(block); let changed = false; let matchedCount = 0; if (options.length) { answers.forEach((wanted) => { const target = wanted.replace(/^[a-z][.、.]\s*/i, '').trim(); const node = options.find((option) => { const text = optionText(option); const letter = optionLetter(option); return Boolean(text) && (text === target || text.startsWith(target) || target.startsWith(text)) || Boolean(letter) && letter === wanted; }); if (!node) return; matchedCount += 1; if (!isOptionSelected(node)) changed = clickElement(node) || changed; }); } else { const inputs = queryAllInOrder(CONFIG.selectors.textInputs, block).filter(isVisible); inputs.forEach((input, index) => { const value = answers[index] ?? answers[0]; if (value !== undefined && normalize(input.value) !== value) { setNativeValue(input, Array.isArray(answer) ? answer[index] ?? answer[0] : answer); changed = true; } }); matchedCount = inputs.length ? Math.min(inputs.length, answers.length || 1) : 0; } const fullyMatched = matchedCount >= answers.length && answers.length > 0; if (!fullyMatched) { rememberUnanswered(question); return { completed: false, pending: true, matched: false }; } const submit = firstVisible(CONFIG.selectors.submit, block); if (submit && !state.submittedQuestions.has(block)) { clickElement(submit); state.submittedQuestions.add(block); } state.answeredQuestions.add(block); if (changed) pushLog(`已填写题目:${question.slice(0, 36)}`); return { completed: true, pending: false, matched: true }; } function processQuestions() { const blocks = getQuestionBlocks(); let completed = 0; let pending = 0; blocks.forEach((block) => { const result = answerQuestion(block); if (result.completed) completed += 1; if (result.pending) pending += 1; }); if (blocks.length && pending === 0 && !state.pageSubmitted) { const pageSubmit = firstVisible(CONFIG.selectors.submit); if (pageSubmit && !blocks.some((block) => block.contains(pageSubmit))) { clickElement(pageSubmit); state.pageSubmitted = true; pushLog('已提交当前页面题目'); } } return { total: blocks.length, completed, pending }; } function mediaCompleted(media) { const info = state.media.get(media); if (info?.completed) return true; const duration = Number(media.duration); const complete = media.ended || (Number.isFinite(duration) && duration > 0 && media.currentTime >= duration - 0.35); if (complete) { state.media.set(media, { ...info, completed: true, started: info?.started || false }); } return complete; } function prepareMedia(media, index) { let info = state.media.get(media); if (!info) { info = { started: false, completed: false, failedAt: 0, playAttemptAt: 0, uiAttemptAt: 0 }; state.media.set(media, info); media.addEventListener('ended', () => { info.completed = true; pushLog(`媒体 ${index + 1} 已完成`); }); } media.playbackRate = state.speed; media.muted = true; return info; } function findMediaPlayButton(media) { const selectors = [ '.vjs-big-play-button', '.vjs-play-control', '.ans-insertvideo-play', '.playButton', '.xgplayer-start', '.dplayer-play-icon', 'button[aria-label*="播放"]', '[title="播放"]' ]; const container = media.closest( '.ans-attach-ct, .video-js, .ans-video, .video-container, .xgplayer, .dplayer' ) || media.parentElement; return firstVisible(selectors, container || document) || firstVisible(selectors, document); } function markMediaStarted(info, index, total) { if (info.started) return; info.started = true; pushLog(`开始媒体 ${index + 1}/${total},${state.speed}x`); } function attemptMediaStart(media, info, index, total) { const now = Date.now(); if (now - info.uiAttemptAt >= 1200) { info.uiAttemptAt = now; const playButton = findMediaPlayButton(media); if (playButton) clickElement(playButton); } if (now - info.playAttemptAt < 1200) return; info.playAttemptAt = now; try { const result = media.play(); if (result && typeof result.then === 'function') { result.then(() => markMediaStarted(info, index, total)).catch(() => { info.failedAt = Date.now(); }); } else { markMediaStarted(info, index, total); } } catch (_) { info.failedAt = now; } } function processMedia() { const mediaList = Array.from(document.querySelectorAll('video, audio')).filter(isVisible); let completed = 0; let playing = 0; mediaList.forEach((media, index) => { const info = prepareMedia(media, index); if (mediaCompleted(media)) { completed += 1; return; } if (!media.paused) playing += 1; if (media.paused) attemptMediaStart(media, info, index, mediaList.length); }); return { total: mediaList.length, completed, playing }; } function buildWorkerStatus() { const media = processMedia(); const questions = processQuestions(); const hasWork = media.total > 0 || questions.total > 0; return { id: WORKER_ID, url: location.href, at: Date.now(), media, questions, hasWork, busy: media.completed < media.total || questions.pending > 0 }; } function publishWorkerStatus(status) { if (isController()) { if (Date.now() >= state.ignoreWorkerUntil) state.workers.set(WORKER_ID, status); return; } window.parent.postMessage({ type: MESSAGE_TYPE, kind: 'status', status, hops: 0 }, '*'); } function activeWorkerSummary() { const cutoff = Date.now() - CONFIG.workerTtl; for (const [id, worker] of state.workers.entries()) { if (worker.at < cutoff) state.workers.delete(id); } const workers = Array.from(state.workers.values()); const summary = workers.reduce((result, worker) => { result.mediaTotal += worker.media.total; result.mediaCompleted += worker.media.completed; result.questionTotal += worker.questions.total; result.questionCompleted += worker.questions.completed; result.questionPending += worker.questions.pending; result.hasWork = result.hasWork || worker.hasWork; result.busy = result.busy || worker.busy; return result; }, { mediaTotal: 0, mediaCompleted: 0, questionTotal: 0, questionCompleted: 0, questionPending: 0, hasWork: false, busy: false }); if (summary.hasWork) state.seenWork = true; return summary; } function hasCompletionMarker(node) { if (!node) return false; const className = node.className || ''; const title = node.getAttribute?.('title') || ''; if (/unfinished|unfinish/i.test(className) || /未完成/.test(title)) return false; if (firstVisible(CONFIG.selectors.incomplete, node)) return false; if (/completed|(^|[-_\s])finished($|[-_\s])|jobfinish|icon_completed/i.test(className)) return true; if (/已完成/.test(title)) return true; return Boolean(firstVisible(CONFIG.selectors.completed, node)); } function hasIncompleteMarker(node) { if (!node) return false; if (/unfinished|unfinish/i.test(node.className || '')) return true; if (/未完成/.test(node.getAttribute?.('title') || '')) return true; return Boolean(firstVisible(CONFIG.selectors.incomplete, node)); } function catalogEntries() { const candidates = queryAllInOrder(CONFIG.selectors.catalog).filter(isVisible); const entries = []; candidates.forEach((node) => { const clickable = node.closest('a[href]') || node; if (!entries.includes(clickable) && normalize(clickable.textContent).length > 0) entries.push(clickable); }); return entries; } function currentCatalogEntry(entries) { const current = firstVisible(CONFIG.selectors.currentCatalog); if (!current) return null; return entries.find((entry) => entry === current || entry.contains(current) || current.contains(entry)) || current; } function currentTaskMarkedComplete() { const current = firstVisible(CONFIG.selectors.currentCatalog); return current ? hasCompletionMarker(current) : false; } function taskKey() { const current = firstVisible(CONFIG.selectors.currentCatalog); if (!current) return ''; const clickable = current.closest('a[href]') || current.querySelector('a[href]') || current; return clickable.getAttribute?.('href') || clickable.getAttribute?.('data-id') || normalize(clickable.textContent); } function resetTaskProgress(ignoreMilliseconds = 0) { state.workers.clear(); state.seenWork = false; state.stableSince = 0; state.pageSubmitted = false; state.answeredQuestions = new WeakSet(); state.submittedQuestions = new WeakSet(); state.media = new WeakMap(); state.ignoreWorkerUntil = Date.now() + ignoreMilliseconds; state.taskEnteredAt = Date.now(); } function syncTaskContext() { const key = taskKey(); if (!key) return; if (!state.currentTaskKey) { state.currentTaskKey = key; return; } if (key !== state.currentTaskKey) { state.currentTaskKey = key; resetTaskProgress(500); pushLog(`已切换任务点:${normalize(key).slice(0, 30)}`); } } function isCourseDetailPage() { const path = location.pathname.toLowerCase(); if (/studentstudy|knowledge|chapterstudy/.test(path)) return false; if (/\/mycourse\/(studentcourse|tch)|\/course\/(detail|courseinfo)/.test(path)) return true; return Boolean(document.querySelector('.chapter_unit, .catalog_points, .catalog_level')) && catalogEntries().length > 0; } function jumpFromCourseDetail() { if (!isController() || !state.enabled || !state.autoJump || state.detailJumpAttempted) return false; if (!isCourseDetailPage() || Date.now() - state.detailPageEnteredAt < CONFIG.detailPageDelay) return false; const entries = catalogEntries(); const target = entries.find(hasIncompleteMarker) || entries.find((entry) => !hasCompletionMarker(entry)); if (!target) { if (!state.detailExpandAttempted) { const expander = firstVisible([ '#courseChapter .chapter_unit', '#courseChapter .chapterText', '.chapter_item .chapterText', '.catalog_level .chapterText' ]); if (expander) { state.detailExpandAttempted = true; state.detailPageEnteredAt = Date.now(); clickElement(expander); pushLog('已展开课程目录,正在查找任务点'); } } return false; } state.detailJumpAttempted = true; resetTaskProgress(1200); state.currentTaskKey = ''; state.lastNavigation = Date.now(); pushLog(`进入任务点:${normalize(target.textContent).slice(0, 30)}`); const clicked = clickElement(target); if (!clicked) state.detailJumpAttempted = false; return clicked; } function goToNextTask() { if (Date.now() - state.lastNavigation < CONFIG.navigationCooldown) return false; const next = firstVisible(CONFIG.selectors.next); if (next && !next.matches(':disabled, [aria-disabled="true"]')) { resetTaskProgress(1200); state.currentTaskKey = ''; state.lastNavigation = Date.now(); pushLog('进入下一个任务点'); return clickElement(next); } const entries = catalogEntries(); const current = currentCatalogEntry(entries); const index = entries.indexOf(current); const following = index >= 0 ? entries.slice(index + 1) : []; const target = following.find(hasIncompleteMarker) || following.find((entry) => !hasCompletionMarker(entry)); if (!target) return false; resetTaskProgress(1200); state.currentTaskKey = ''; state.lastNavigation = Date.now(); pushLog(`按目录进入:${normalize(target.textContent).slice(0, 30)}`); return clickElement(target); } function maybeAdvance(summary) { if (!isController() || !state.enabled || isCourseDetailPage()) return; if (Date.now() - state.taskEnteredAt < 4000) return; const completedWork = state.seenWork && !summary.busy && summary.questionPending === 0 && summary.mediaCompleted >= summary.mediaTotal; const ready = completedWork || (!state.seenWork && currentTaskMarkedComplete()); if (!ready) { state.stableSince = 0; return; } if (!state.stableSince) state.stableSince = Date.now(); if (Date.now() - state.stableSince < CONFIG.completionStableTime) return; if (goToNextTask()) state.stableSince = 0; } function toggleEnabled() { state.enabled = !state.enabled; resetTaskProgress(state.enabled ? 500 : 0); state.detailJumpAttempted = false; state.detailExpandAttempted = false; state.detailPageEnteredAt = Date.now(); storageApi.set(STORE.enabled, state.enabled); pushLog(state.enabled ? '已启动自动运行' : '已暂停自动运行', true); updatePanel(); } function importBank() { const raw = prompt('粘贴题库 JSON:{"题目":"答案"},多选题答案使用数组'); if (!raw) return; try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('顶层必须是 JSON 对象'); state.bank = { ...state.bank, ...parsed }; Object.keys(parsed).forEach((question) => delete state.unanswered[normalize(question)]); saveObject(STORE.bank, state.bank); saveObject(STORE.unanswered, state.unanswered); rebuildBankIndex(); pushLog(`已导入 ${Object.keys(parsed).length} 条题库记录`, true); } catch (error) { alert(`题库 JSON 解析失败:${error.message}`); } } function exportUnanswered() { const payload = JSON.stringify(state.unanswered, null, 2); prompt('复制未匹配题目,补充答案后重新导入:', payload); } function compactText(value) { return String(value || '').replace(/[\r\n\t ]+/g, ' ').trim(); } function currentTaskTitle() { const current = firstVisible(CONFIG.selectors.currentCatalog); const text = compactText(current?.textContent); return text || compactText(document.title) || '当前课程页面'; } function workerLocation(worker) { try { const url = new URL(worker.url); return `${url.hostname}${url.pathname}`; } catch (_) { return compactText(worker.url) || '页面地址未知'; } } function setMeter(root, completed, total) { const percent = total > 0 ? Math.round((completed / total) * 100) : 0; root.querySelector('.cxh-meter-value').textContent = `${completed}/${total}`; root.querySelector('.cxh-meter-fill').style.width = `${Math.max(0, Math.min(100, percent))}%`; } function courseProgress() { const entries = catalogEntries(); return { total: entries.length, completed: entries.filter(hasCompletionMarker).length }; } function renderProgressWindow(summary) { const root = state.progressWindow; if (!root || !state.progressOpen) return; const course = courseProgress(); root.querySelector('.cxh-current-task').textContent = currentTaskTitle(); setMeter(root.querySelector('[data-meter="tasks"]'), course.completed, course.total); setMeter(root.querySelector('[data-meter="media"]'), summary.mediaCompleted, summary.mediaTotal); setMeter(root.querySelector('[data-meter="questions"]'), summary.questionCompleted, summary.questionTotal); root.querySelector('.cxh-pending-value').textContent = String(summary.questionPending); root.querySelector('.cxh-unanswered-value').textContent = String(Object.keys(state.unanswered).length); const workerList = root.querySelector('.cxh-worker-list'); workerList.replaceChildren(); const workers = Array.from(state.workers.values()).sort((a, b) => a.url.localeCompare(b.url)); if (!workers.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = state.enabled ? '等待课程页面或播放器状态' : '启动后显示页面与播放器状态'; workerList.appendChild(empty); } else { workers.forEach((worker, index) => { const row = document.createElement('div'); row.className = 'cxh-worker-row'; const header = document.createElement('div'); header.className = 'cxh-worker-header'; const name = document.createElement('strong'); name.textContent = worker.id === WORKER_ID ? '主页面' : `子页面 ${index + 1}`; const status = document.createElement('span'); status.className = worker.busy ? 'cxh-state is-busy' : worker.hasWork ? 'cxh-state is-ready' : 'cxh-state is-idle'; status.textContent = worker.busy ? '处理中' : worker.hasWork ? '已就绪' : '等待'; header.append(name, status); const location = document.createElement('div'); location.className = 'cxh-worker-location'; location.textContent = workerLocation(worker); location.title = workerLocation(worker); const details = document.createElement('div'); details.className = 'cxh-worker-details'; details.textContent = `媒体 ${worker.media.completed}/${worker.media.total} · 题目 ${worker.questions.completed}/${worker.questions.total} · 待处理 ${worker.questions.pending}`; row.append(header, location, details); workerList.appendChild(row); }); } const logList = root.querySelector('.cxh-log-list'); logList.replaceChildren(); const storedLogs = storageApi.get(STORE.logs, []); const logs = Array.isArray(storedLogs) ? storedLogs.slice(0, 8) : []; if (!logs.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '暂无运行记录'; logList.appendChild(empty); } else { logs.forEach((line) => { const item = document.createElement('div'); item.className = 'cxh-log-item'; item.textContent = String(line); logList.appendChild(item); }); } root.querySelector('.cxh-updated-at').textContent = `更新于 ${new Date().toLocaleTimeString()}`; } function toggleProgressWindow(force) { if (!state.progressWindow) return; state.progressOpen = typeof force === 'boolean' ? force : !state.progressOpen; state.progressWindow.hidden = !state.progressOpen; state.panel?.querySelector('.cxh-open-progress')?.setAttribute('aria-expanded', String(state.progressOpen)); if (state.progressOpen) renderProgressWindow(activeWorkerSummary()); } function createPanel() { if (!isController() || state.panel?.isConnected || !document.body) return; if (state.panel && !state.panel.isConnected) state.panel = null; if (state.progressWindow && !state.progressWindow.isConnected) state.progressWindow = null; let style = document.querySelector('#cx-helper-style'); if (!style) { style = document.createElement('style'); style.id = 'cx-helper-style'; style.textContent = ` #cx-helper-panel{box-sizing:border-box;position:fixed;z-index:2147483647;right:16px;bottom:16px;width:252px;padding:12px;background:#172033;color:#eef3ff;font:13px/1.5 Arial,sans-serif;border:1px solid #415273;border-radius:8px;box-shadow:0 8px 24px #0006} #cx-helper-panel .cxh-title{font-size:15px;font-weight:700;margin-bottom:5px} #cx-helper-panel .cxh-status{min-height:38px;color:#c6d4ef;margin-bottom:7px;word-break:break-word} #cx-helper-panel .cxh-progress{color:#93a9cf;margin-bottom:8px} #cx-helper-panel .cxh-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} #cx-helper-panel .cxh-speed{width:66px;background:#0e1523;color:#fff;border:1px solid #526687;border-radius:4px;padding:2px 4px} #cx-helper-panel .cxh-actions{display:flex;gap:5px;flex-wrap:wrap} #cx-helper-panel .cxh-actions button{display:inline-flex;align-items:center;justify-content:center;width:auto;height:32px;min-width:0;min-height:0;margin:0;background:#2d6cdf;color:#fff;border:0;border-radius:4px;padding:5px 8px;font:13px/1 Arial,sans-serif;cursor:pointer} #cx-helper-panel .cxh-actions button:hover{filter:brightness(1.15)} #cx-helper-progress-window{box-sizing:border-box;position:fixed;z-index:2147483646;right:284px;bottom:16px;width:min(440px,calc(100vw - 316px));max-height:min(680px,calc(100vh - 32px));overflow:auto;padding:14px;background:#111827;color:#eef3ff;font:13px/1.5 Arial,sans-serif;border:1px solid #43506a;border-radius:8px;box-shadow:0 12px 30px #0008} #cx-helper-progress-window[hidden]{display:none} #cx-helper-progress-window .cxh-window-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px} #cx-helper-progress-window .cxh-window-title{font-size:16px;font-weight:700} #cx-helper-progress-window .cxh-close{width:30px;height:30px;padding:0;border:0;border-radius:4px;background:#293449;color:#fff;font-size:20px;line-height:30px;cursor:pointer} #cx-helper-progress-window .cxh-task-label,#cx-helper-progress-window .cxh-section-title{color:#94a3b8;font-size:12px;margin-bottom:4px} #cx-helper-progress-window .cxh-current-task{font-weight:700;margin-bottom:12px;overflow-wrap:anywhere} #cx-helper-progress-window .cxh-overview{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:14px} #cx-helper-progress-window .cxh-meter-head{display:flex;justify-content:space-between;margin-bottom:5px} #cx-helper-progress-window .cxh-meter-track{height:7px;background:#2b3548;border-radius:3px;overflow:hidden} #cx-helper-progress-window .cxh-meter-fill{height:100%;width:0;background:#22c55e;transition:width .2s ease} #cx-helper-progress-window [data-meter="tasks"] .cxh-meter-fill{background:#38bdf8} #cx-helper-progress-window [data-meter="questions"] .cxh-meter-fill{background:#f59e0b} #cx-helper-progress-window .cxh-counts{display:flex;gap:18px;margin-bottom:14px;color:#cbd5e1} #cx-helper-progress-window .cxh-counts strong{color:#fff} #cx-helper-progress-window .cxh-section{border-top:1px solid #2c374a;padding-top:11px;margin-top:11px} #cx-helper-progress-window .cxh-worker-row{padding:8px 0;border-bottom:1px solid #222d40} #cx-helper-progress-window .cxh-worker-header{display:flex;align-items:center;justify-content:space-between;gap:8px} #cx-helper-progress-window .cxh-state{font-size:12px;padding:1px 6px;border-radius:4px;background:#334155;color:#dbeafe} #cx-helper-progress-window .cxh-state.is-busy{background:#713f12;color:#fde68a} #cx-helper-progress-window .cxh-state.is-ready{background:#14532d;color:#bbf7d0} #cx-helper-progress-window .cxh-state.is-idle{background:#334155;color:#cbd5e1} #cx-helper-progress-window .cxh-worker-location{color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #cx-helper-progress-window .cxh-worker-details{color:#cbd5e1;margin-top:2px} #cx-helper-progress-window .cxh-log-item{padding:4px 0;border-bottom:1px solid #222d40;overflow-wrap:anywhere} #cx-helper-progress-window .cxh-empty{color:#94a3b8;padding:8px 0} #cx-helper-progress-window .cxh-updated-at{color:#64748b;font-size:12px;margin-top:10px;text-align:right} @media(max-width:760px){#cx-helper-progress-window{z-index:2147483647;left:12px;right:12px;top:12px;bottom:12px;width:auto;max-height:none}#cx-helper-progress-window .cxh-overview{grid-template-columns:1fr}} `; document.documentElement.appendChild(style); } const panel = document.createElement('div'); panel.id = 'cx-helper-panel'; panel.innerHTML = `