// ==UserScript== // @name 学习通课程任务助手 // @namespace local.chaoxing.course.helper // @version 2.6.0 // @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 SCRIPT_VERSION = '2.6.0'; const BANK_SCHEMA_VERSION = 1; const TODO_EXPORT_SCHEMA_VERSION = 1; 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; const FRAME_PATH = (() => { const path = []; let current = PAGE_WINDOW; try { while (current !== current.top) { const parent = current.parent; let frameIndex = -1; for (let index = 0; index < parent.frames.length; index += 1) { if (parent.frames[index] === current) { frameIndex = index; break; } } path.unshift(frameIndex < 0 ? Number.MAX_SAFE_INTEGER : frameIndex); current = parent; } } catch (_) { path.unshift(Number.MAX_SAFE_INTEGER); } return path; })(); 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 TAB_ID = `tab-${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', uiState: 'cx_helper_ui_state', adapterHealth: 'cx_helper_adapter_health', storageBackup: 'cx_helper_storage_backup', schema: 'cx_helper_schema', tabLease: 'cx_helper_tab_lease', todos: 'cx_helper_todos', todoScopes: 'cx_helper_todo_scopes', scanSession: 'cx_helper_scan_session', queuePaused: 'cx_helper_queue_paused' }; const CONFIG = { defaultSpeed: 1, interval: 1000, workerTtl: 6500, mediaStallThreshold: Number(PAGE_WINDOW.__XXT_TEST_MEDIA_STALL_MS__) || 30000, mediaRetryLimit: Number(PAGE_WINDOW.__XXT_TEST_MEDIA_RETRY_LIMIT__) || 3, navigationCooldown: 8000, completionStableTime: Number(PAGE_WINDOW.__XXT_TEST_COMPLETION_STABLE_MS__) || 2500, taskWarmupTime: Number(PAGE_WINDOW.__XXT_TEST_TASK_WARMUP_MS__) || 4000, serverCompletionTimeout: Number(PAGE_WINDOW.__XXT_TEST_SERVER_CONFIRM_MS__) || 30000, tabLeaseTtl: Number(PAGE_WINDOW.__XXT_TEST_TAB_LEASE_MS__) || 5000, storageWarningBytes: Number(PAGE_WINDOW.__XXT_TEST_STORAGE_WARNING_BYTES__) || 4 * 1024 * 1024, selectorGraceTime: Number(PAGE_WINDOW.__XXT_TEST_SELECTOR_GRACE_MS__) || 12000, detailPageDelay: 1800, selectors: { questions: ['.TiMu', '.questionLi', '.quesItem', '.ans-ques', '.judge-question', '.combination-question'], optionGroups: [ '.Zy_ulTop > li', '.answerList > li', '.answerOption', '.option-item', 'label:has(input[type="radio"])', 'label:has(input[type="checkbox"])' ], textInputs: ['input[type="text"]', 'textarea', 'select'], 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*="下一"]' ], 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 COURSE_TEMPLATES = { 'chaoxing-new': { detect: ['#courseChapter', '.course-card', '.courseItem', '.course-item'], courseLinks: ['.course-card a[href]', '.courseItem a[href]', '.course-item a[href]'], courseCards: ['.course-card', '.courseItem', '.course-item', '.course_item'], catalog: ['#courseChapter .chapter_item a[href]', '#courseChapter [onclick*="chapter"]', '.chapter_item .catalog_name'] }, 'chaoxing-legacy': { detect: ['.chapter_unit', '.catalog_level', '.ncells', '.Mconright'], courseLinks: ['.Mconright a[href]', 'li a[href*="courseId="]'], courseCards: ['.Mconright', 'li', 'article'], catalog: ['.chapter_unit a[href*="studentstudy"]', '.catalog_level a[href*="studentstudy"]', '.ncells a[href]'] }, 'portal-iframe': { detect: [], courseLinks: ['a[href*="courseId="]', 'a[href*="clazzid="]', 'a[href*="/mycourse/"]'], courseCards: ['.course-card', '.courseItem', '.Mconright', 'li', 'article'], catalog: ['#courseChapter .chapter_item a[href]', '.chapter_unit a[href*="studentstudy"]', '.catalog_level a[href*="studentstudy"]', '.ncells a[href]'] }, xueyin: { detect: ['.catalog_points', '.posCatalog_name'], courseLinks: ['a[href*="courseId="]', 'a[href*="clazzid="]', 'a[href*="/mycourse/"]'], courseCards: ['.course-card', '.courseItem', 'li', 'article'], catalog: ['.catalog_points a[href]', '.posCatalog_name', 'a[href*="chapterId"][href*="courseId"]'] }, generic: { detect: [], courseLinks: ['a[href*="courseId="]', 'a[href*="courseid="]', 'a[href*="clazzid="]', 'a[href*="/mycourse/"]'], courseCards: ['.courseItem', '.course-item', '.course_item', '.course-card', '.Mconright', 'li', 'article'], 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' ] } }; const PLATFORM_ADAPTER_API_VERSION = 1; const TASK_HANDLER_API_VERSION = 1; const PLATFORM_ADAPTERS = Object.fromEntries(Object.entries(COURSE_TEMPLATES).map(([id, selectors]) => [id, Object.freeze({ id, apiVersion: PLATFORM_ADAPTER_API_VERSION, selectors, detect: () => id === 'generic' || selectors.detect.some((selector) => document.querySelector(selector)), catalog: () => scanCatalogEntries(), media: () => Array.from(document.querySelectorAll('video, audio')).filter(isVisible), completion: (node) => hasCompletionMarker(node), question: () => getQuestionBlocks() })])); const FALLBACK_PREFIX = 'cx_helper_fallback:'; const FALLBACK_META_PREFIX = 'cx_helper_fallback_meta:'; const storageApi = { get(key, fallback) { try { const marker = localStorage.getItem(`${FALLBACK_META_PREFIX}${key}`); if (marker) { const envelope = JSON.parse(marker); if (envelope && Number(envelope.revision) > 0) return envelope.value; } } catch (_) { // Ignore malformed fallback metadata and continue with the primary backend. } 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) { const revision = Date.now() * 1000 + Math.floor(Math.random() * 1000); try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); try { localStorage.removeItem(`${FALLBACK_META_PREFIX}${key}`); localStorage.removeItem(`${FALLBACK_PREFIX}${key}`); } catch (_) { /* Ignore fallback cleanup failures. */ } return true; } } catch (error) { console.warn('[XXT] GM_setValue failed, using local storage', error); } try { const envelope = { revision, value }; localStorage.setItem(`${FALLBACK_PREFIX}${key}`, JSON.stringify(value)); localStorage.setItem(`${FALLBACK_META_PREFIX}${key}`, JSON.stringify(envelope)); return true; } catch (_) { PAGE_WINDOW.__XXT_STORAGE_WARNING__ = '本地存储空间不足或不可用'; return false; } }, 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, true)), 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), todos: loadScopedTodos(), scanSession: loadObject(STORE.scanSession), queuePaused: toBoolean(storageApi.get(STORE.queuePaused, false)), queueNavigationAt: 0, queueFinishedLogged: false, media: new WeakMap(), knownMedia: new Set(), answeredQuestions: new WeakSet(), submittedQuestions: new WeakSet(), pageSubmitted: false, workers: new Map(), mediaPermit: { workerId: '', index: -1, revision: 0 }, mediaPermitRevision: 0, lastMediaFailureKey: '', panel: null, progressWindow: null, progressOpen: false, todoWindow: null, todoOpen: false, logWindow: null, logOpen: false, logAutoScroll: true, todoSearch: '', todoStatusFilter: 'all', todoTypeFilter: 'all', logFilter: 'all', uiState: loadObject(STORE.uiState), adapterHealth: loadObject(STORE.adapterHealth), tabPrimary: !IS_TOP, leaseClaimId: '', leaseConfirmedAt: 0, selectorMisses: 0, storageMigrated: false, timer: null, running: false, seenWork: false, stableSince: 0, completionAwaitStartedAt: 0, completionAwaitTaskKey: '', lastNavigation: 0, detailJumpAttempted: false, detailExpandAttempted: false, detailPageEnteredAt: Date.now(), currentTaskKey: '', taskEnteredAt: Date.now(), ignoreWorkerUntil: 0, lastLog: '', lastLogAt: 0, lastError: '' }; function isController() { return controllerActive; } function ensureTabLease() { if (!IS_TOP) return true; const now = Date.now(); const lease = loadObject(STORE.tabLease); if (lease.owner === TAB_ID && lease.claimId && lease.claimId === state.leaseClaimId) { const active = { ...lease, phase: 'active', expiresAt: now + CONFIG.tabLeaseTtl }; saveObject(STORE.tabLease, active); const verified = loadObject(STORE.tabLease); state.tabPrimary = verified.owner === TAB_ID && verified.claimId === state.leaseClaimId; if (state.tabPrimary) state.leaseConfirmedAt = now; return state.tabPrimary; } if (!lease.owner || Number(lease.expiresAt || 0) <= now) { const claimId = `${TAB_ID}:${now}:${Math.random().toString(36).slice(2)}`; state.leaseClaimId = claimId; const candidate = { owner: TAB_ID, claimId, phase: 'candidate', claimedAt: now, expiresAt: now + CONFIG.tabLeaseTtl, account: detectAccountKey(), term: currentTermKey() }; saveObject(STORE.tabLease, candidate); const verified = loadObject(STORE.tabLease); state.tabPrimary = verified.owner === TAB_ID && verified.claimId === claimId; state.leaseConfirmedAt = state.tabPrimary ? now : 0; return state.tabPrimary; } if (lease.owner === TAB_ID && !state.leaseClaimId) { state.leaseClaimId = lease.claimId || `${TAB_ID}:legacy`; state.tabPrimary = true; state.leaseConfirmedAt = now; return true; } if (state.tabPrimary) { revokeMediaPermit(); pauseLocalMedia(); } state.tabPrimary = false; return false; } function handleTabLeaseChange() { if (!IS_TOP) return; const lease = loadObject(STORE.tabLease); const ownsLease = lease.owner === TAB_ID && lease.claimId && lease.claimId === state.leaseClaimId; if (ownsLease || !lease.owner || Number(lease.expiresAt || 0) <= Date.now()) return; state.tabPrimary = false; state.leaseConfirmedAt = 0; revokeMediaPermit(); pauseLocalMedia(); updatePanel(activeWorkerSummary()); } function releaseTabLease() { if (!IS_TOP) return; const lease = loadObject(STORE.tabLease); if (lease.owner === TAB_ID && (!state.leaseClaimId || lease.claimId === state.leaseClaimId)) { saveObject(STORE.tabLease, { owner: '', claimId: '', phase: 'released', expiresAt: 0 }); } } function broadcastToChildren(message) { try { for (let index = 0; index < PAGE_WINDOW.frames.length; index += 1) { PAGE_WINDOW.frames[index].postMessage(message, '*'); } } catch (_) { // Cross-origin frames still accept postMessage when referenced directly. } } function announceController() { if (!isController()) return; broadcastToChildren({ type: MESSAGE_TYPE, kind: 'controller-present' }); } function activateController() { controllerActive = true; parentControllerSeen = true; if (!state.storageMigrated) migrateStorage(); 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.todoWindow?.remove(); state.logWindow?.remove(); state.panel = null; state.progressWindow = null; state.todoWindow = null; state.logWindow = null; state.progressOpen = false; state.todoOpen = false; state.logOpen = 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 (_) { backupCorruptValue(key, raw); return {}; } } function loadArray(key) { const raw = storageApi.get(key, '[]'); try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; return Array.isArray(parsed) ? parsed : []; } catch (_) { backupCorruptValue(key, raw); return []; } } function backupCorruptValue(key, raw) { if (key === STORE.storageBackup) return; const existing = storageApi.get(STORE.storageBackup, {}); const backups = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; backups[`${key}:${Date.now()}`] = String(raw).slice(0, 200000); storageApi.set(STORE.storageBackup, backups); } function todoScopeKey() { return `${detectAccountKey() || 'anonymous'}|${currentTermKey()}|${scanPlatformKey()}`; } function loadScopedTodos() { const scopes = loadObject(STORE.todoScopes); const key = todoScopeKey(); if (Array.isArray(scopes[key])) return scopes[key]; if (Object.keys(scopes).length > 0 && detectAccountKey()) return []; return loadArray(STORE.todos); } 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 : input instanceof HTMLSelectElement ? HTMLSelectElement.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, category = '') { 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 inferred = category || (/异常|失败|错误|超时/.test(message) ? 'error' : /媒体|播放|视频|音频/.test(message) ? 'media' : /题目|题库|答案/.test(message) ? 'question' : /进入|跳转|打开/.test(message) ? 'navigation' : 'system'); const line = `${new Date().toLocaleTimeString()} [${inferred}] ${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); }); storageApi.listen(STORE.todos, () => { state.todos = loadScopedTodos(); updatePanel(); }); storageApi.listen(STORE.todoScopes, () => { state.todos = loadScopedTodos(); updatePanel(); }); storageApi.listen(STORE.scanSession, () => { state.scanSession = loadObject(STORE.scanSession); updatePanel(); }); storageApi.listen(STORE.queuePaused, (_name, _old, value) => { state.queuePaused = toBoolean(value); updatePanel(); }); storageApi.listen(STORE.tabLease, handleTabLeaseChange); } function getQuestionBlocks() { const candidates = queryAllInOrder(CONFIG.selectors.questions).filter(isVisible); const blocks = candidates.map((node) => { if (!node.matches('.TiMu')) return node; const ownsAnswerControls = node.querySelector('.Zy_ulTop, .answerList, input, textarea, select'); return ownsAnswerControls ? node : node.closest('.questionLi, .quesItem, .ans-ques') || node; }); const unique = blocks.filter((node, index) => blocks.indexOf(node) === index && isVisible(node)); const hasControls = (node) => Boolean(node.querySelector('input, textarea, select, button.submitBtn, .submitBtn')); return unique.filter((node) => !unique.some((other) => other !== node && node.contains(other) && hasControls(other))); } 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 findLocalBankAnswer(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; } const LOCAL_BANK_PROVIDER = Object.freeze({ id: 'local-json', schemaVersion: BANK_SCHEMA_VERSION, readOnly: true, find: findLocalBankAnswer, export: (records = state.bank) => ({ schemaVersion: BANK_SCHEMA_VERSION, provider: 'local-json', exportedAt: new Date().toISOString(), records }) }); function activeBankProvider() { return LOCAL_BANK_PROVIDER; } function findAnswer(question) { return activeBankProvider().find(question); } 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 (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)) { return { completed: true, pending: false, matched: true, stage: 'confirmed' }; } if (state.answeredQuestions.has(block)) { const stage = state.submittedQuestions.has(block) ? 'submitted' : 'filled'; return { completed: true, pending: false, matched: true, stage }; } const answer = findAnswer(question); if (answer === null || answer === undefined || answer === '') { rememberUnanswered(question); return { completed: false, pending: true, matched: false, stage: 'discovered' }; } 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: true, stage: 'matched' }; } 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, stage: submit ? 'submitted' : 'filled' }; } function processQuestions() { const blocks = activeCourseTemplate().question(); let completed = 0; let pending = 0; const stages = { discovered: 0, matched: 0, filled: 0, submitted: 0, confirmed: 0 }; blocks.forEach((block) => { const result = answerQuestion(block); if (result.completed) completed += 1; if (result.pending) pending += 1; if (Object.prototype.hasOwnProperty.call(stages, result.stage)) stages[result.stage] += 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, stages }; } 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 mediaSource(media) { return String(media.currentSrc || media.src || media.querySelector?.('source[src]')?.src || ''); } function prepareMedia(media, index) { let info = state.media.get(media); const source = mediaSource(media); if (info && info.source !== source) { Object.assign(info, { source, sourceRevision: Number(info.sourceRevision || 0) + 1, started: false, completed: false, startFailures: 0, playAttemptAt: 0, uiAttemptAt: 0, lastCurrentTime: Number(media.currentTime) || 0, lastProgressAt: Date.now(), stallRetries: 0, failed: false, failureReason: '' }); pushLog(`媒体 ${index + 1} 已切换资源,重新接管`, true, 'media'); } if (!info) { info = { source, sourceRevision: 1, listenerToken: `media-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, started: false, completed: false, startFailures: 0, playAttemptAt: 0, uiAttemptAt: 0, lastCurrentTime: Number(media.currentTime) || 0, lastProgressAt: Date.now(), stallRetries: 0, failed: false, failureReason: '' }; state.media.set(media, info); media.addEventListener('ended', () => { const current = state.media.get(media); if (current) current.completed = true; pushLog(`媒体 ${index + 1} 已完成`); }); } media.playbackRate = state.speed; media.muted = true; return info; } function updateMediaHealth(media, info, permitted, index) { const now = Date.now(); const currentTime = Number(media.currentTime) || 0; if (currentTime > Number(info.lastCurrentTime || 0) + 0.15) { info.lastCurrentTime = currentTime; info.lastProgressAt = now; return; } if (!info.lastProgressAt) info.lastProgressAt = now; if (!permitted || !info.started || media.paused || info.failed) return; if (now - info.lastProgressAt < CONFIG.mediaStallThreshold) return; info.stallRetries = Number(info.stallRetries || 0) + 1; info.lastProgressAt = now; pauseMediaElement(media); if (info.stallRetries >= CONFIG.mediaRetryLimit) { info.failed = true; info.failureReason = `媒体 ${index + 1} 播放无进度,已重试 ${info.stallRetries} 次`; pushLog(info.failureReason, true); return; } info.started = false; info.playAttemptAt = 0; info.uiAttemptAt = 0; pushLog(`媒体 ${index + 1} 播放卡住,自动重试 ${info.stallRetries}/${CONFIG.mediaRetryLimit}`, true); } function resetFailedMedia() { document.querySelectorAll('video, audio').forEach((media) => { const info = state.media.get(media); if (!info?.failed) return; info.failed = false; info.failureReason = ''; info.stallRetries = 0; info.startFailures = 0; info.started = false; info.lastCurrentTime = Number(media.currentTime) || 0; info.lastProgressAt = Date.now(); info.playAttemptAt = 0; info.uiAttemptAt = 0; }); } 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; info.startFailures = 0; pushLog(`开始媒体 ${index + 1}/${total},${state.speed}x`); } function recordMediaStartFailure(info, index) { info.startFailures = Number(info.startFailures || 0) + 1; if (info.startFailures < CONFIG.mediaRetryLimit) return; info.failed = true; info.failureReason = `媒体 ${index + 1} 启动失败,已重试 ${info.startFailures} 次`; pushLog(info.failureReason, true); } function attemptMediaStart(media, info, index, total) { const now = Date.now(); const needsPlayerButton = Number(media.readyState || 0) < 2 || Number(info.startFailures || 0) > 0; if (needsPlayerButton && now - info.uiAttemptAt >= 1200) { info.uiAttemptAt = now; const playButton = findMediaPlayButton(media); if (playButton && clickElement(playButton)) { info.playAttemptAt = now; window.setTimeout(() => { if (state.media.get(media) !== info || !media.isConnected || info.completed || info.failed) return; if (!media.paused) { markMediaStarted(info, index, total); return; } try { const result = media.play(); if (result && typeof result.then === 'function') { result.then(() => markMediaStarted(info, index, total)).catch(() => recordMediaStartFailure(info, index)); } else { markMediaStarted(info, index, total); } } catch (_) { recordMediaStartFailure(info, index); } }, 150); return; } } 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(() => recordMediaStartFailure(info, index)); } else { markMediaStarted(info, index, total); } } catch (_) { recordMediaStartFailure(info, index); } } function processMedia() { const mediaList = activeCourseTemplate().media(); for (const oldMedia of state.knownMedia) { if (oldMedia.isConnected && mediaList.includes(oldMedia)) continue; pauseMediaElement(oldMedia); state.knownMedia.delete(oldMedia); } mediaList.forEach((media) => state.knownMedia.add(media)); let completed = 0; const items = []; mediaList.forEach((media, index) => { const info = prepareMedia(media, index); const complete = mediaCompleted(media); const permitted = state.mediaPermit.workerId === WORKER_ID && state.mediaPermit.index === index; if (complete) { completed += 1; items.push({ index, type: media.tagName.toLowerCase(), completed: true, sourceRevision: info.sourceRevision, listenerToken: info.listenerToken, currentTime: Number(media.currentTime) || 0, duration: Number(media.duration) || 0, speed: Number(media.playbackRate) || state.speed }); return; } updateMediaHealth(media, info, permitted, index); if (info.failed) { items.push({ index, completed: false, failed: true, failureReason: info.failureReason, sourceRevision: info.sourceRevision, listenerToken: info.listenerToken, retries: info.stallRetries }); return; } if (!permitted && !media.paused) { pauseMediaElement(media); } if (permitted && media.paused) attemptMediaStart(media, info, index, mediaList.length); items.push({ index, type: media.tagName.toLowerCase(), completed: false, failed: false, sourceRevision: info.sourceRevision, listenerToken: info.listenerToken, currentTime: Number(media.currentTime) || 0, duration: Number(media.duration) || 0, speed: Number(media.playbackRate) || state.speed }); }); const failedItems = items.filter((item) => item.failed); return { total: mediaList.length, completed, failed: failedItems.length, failureReason: failedItems[0]?.failureReason || '', items }; } function pauseLocalMedia() { document.querySelectorAll('video, audio').forEach((media) => { if (!media.paused) pauseMediaElement(media); }); } function pauseMediaElement(media) { try { media?.pause?.(); return true; } catch (_) { return false; } } const TASK_HANDLERS = Object.freeze([ Object.freeze({ id: 'media', apiVersion: TASK_HANDLER_API_VERSION, detect: () => activeCourseTemplate().media().length > 0, run: () => processMedia(), status: (result) => result || { total: 0, completed: 0, failed: 0, failureReason: '', items: [] }, cancel: () => pauseLocalMedia() }), Object.freeze({ id: 'questions', apiVersion: TASK_HANDLER_API_VERSION, detect: () => activeCourseTemplate().question().length > 0, run: () => processQuestions(), status: (result) => result || { total: 0, completed: 0, pending: 0, stages: { discovered: 0, matched: 0, filled: 0, submitted: 0, confirmed: 0 } }, cancel: () => true }) ]); function runTaskHandlers() { return Object.fromEntries(TASK_HANDLERS.map((handler) => { if (!handler.detect()) { handler.cancel('not-detected'); return [handler.id, handler.status(null)]; } return [handler.id, handler.status(handler.run())]; })); } function cancelTaskHandlers(reason = 'cancelled') { TASK_HANDLERS.forEach((handler) => handler.cancel(reason)); } function frameAttachmentDescriptor() { let frame = null; try { frame = PAGE_WINDOW.frameElement; } catch (_) { return { source: 'cross-origin-frame', jobId: '', module: '', status: '', passed: false }; } if (!frame) return { source: 'document', jobId: '', module: '', status: '', passed: false }; let payload = {}; const raw = frame.getAttribute('data') || frame.getAttribute('data-attachment') || ''; if (raw) { try { payload = JSON.parse(raw); } catch (_) { try { payload = JSON.parse(decodeURIComponent(raw)); } catch (_) { payload = {}; } } } const value = (keys) => keys.map((key) => payload[key] ?? frame.getAttribute(`data-${key}`)).find((item) => item !== null && item !== undefined) ?? ''; const status = compactText(value(['status', 'jobstatus', 'job-status'])); const passedValue = value(['passed', 'pass', 'isPassed', 'ispassed']); return { source: raw ? 'frame-data' : 'frame-attributes', jobId: compactText(value(['jobid', 'jobId', 'job-id'])).slice(0, 160), module: compactText(value(['module', 'type'])).slice(0, 80), status: status.slice(0, 80), passed: passedValue === true || passedValue === 1 || /^(true|1|passed|completed|finish(?:ed)?)$/i.test(compactText(passedValue || status)) }; } function emptyWorkerStatus() { return { id: WORKER_ID, url: location.href, framePath: FRAME_PATH, attachment: frameAttachmentDescriptor(), at: Date.now(), media: { total: 0, completed: 0, items: [] }, questions: { total: 0, completed: 0, pending: 0, stages: { discovered: 0, matched: 0, filled: 0, submitted: 0, confirmed: 0 } }, hasWork: false, busy: false }; } function buildWorkerStatus() { const results = runTaskHandlers(); const media = results.media; const questions = results.questions; recordAdapterSuccess(media.total, questions.total); const hasWork = media.total > 0 || questions.total > 0; const issue = classifyPageIssue(media); return { id: WORKER_ID, url: location.href, framePath: FRAME_PATH, attachment: frameAttachmentDescriptor(), at: Date.now(), media, questions, issue, hasWork, busy: media.completed < media.total || questions.pending > 0 }; } function classifyPageIssue(mediaStatus) { const text = compactText(document.body?.innerText || ''); const mediaError = Array.from(document.querySelectorAll('video, audio')).find((item) => item.error)?.error; if (/登录|重新登录|login|会话.*失效/i.test(text) && /登录|login/i.test(location.href + text)) { return { type: 'login', label: '登录状态失效', advice: '重新登录后继续队列' }; } if (/验证码|滑块验证|安全验证|captcha/i.test(text)) { return { type: 'captcha', label: '需要人工验证', advice: '完成页面验证后继续队列' }; } if (/试看|仅可试看|购买后观看|无权观看/i.test(text)) { return { type: 'preview', label: '播放权限受限', advice: '检查课程权限或联系课程管理员' }; } if (mediaError) { const labels = { 1: '播放被中止', 2: '媒体网络错误', 3: '媒体解码错误', 4: '媒体格式不支持' }; return { type: 'player', label: labels[mediaError.code] || '播放器错误', advice: '刷新播放器或切换清晰度后继续' }; } if (Number(mediaStatus?.failed || 0) > 0) { return { type: 'loading', label: mediaStatus.failureReason || '媒体加载超时', advice: '检查网络后继续队列' }; } if (/网络异常|加载失败|network error|请求超时/i.test(text)) { return { type: 'network', label: '页面网络异常', advice: '检查网络并刷新当前任务页' }; } return null; } function selectorSelfCheck(status) { const relevant = /studentstudy|chapterstudy|knowledge/i.test(location.pathname) && /课程|任务|视频|音频|题目/.test(compactText(document.body?.innerText || '')); if (!relevant || status.hasWork || currentTaskMarkedComplete()) { state.selectorMisses = 0; if (state.lastError === '页面模板待适配') state.lastError = ''; return; } if (Date.now() - state.taskEnteredAt < CONFIG.selectorGraceTime) return; state.selectorMisses += 1; if (state.selectorMisses >= 3) state.lastError = '页面模板待适配'; } 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 compareFramePaths(left = [], right = []) { const length = Math.max(left.length, right.length); for (let index = 0; index < length; index += 1) { const a = left[index] ?? -1; const b = right[index] ?? -1; if (a !== b) return a - b; } return 0; } function selectMediaPermit() { if (!isController()) return; const workers = Array.from(state.workers.values()) .sort((a, b) => compareFramePaths(a.framePath, b.framePath)); const candidates = []; let hasFailure = false; workers.forEach((worker) => { (worker.media.items || []).forEach((item) => { if (item.failed) hasFailure = true; else if (!item.completed) candidates.push({ workerId: worker.id, index: item.index }); }); }); const selected = !hasFailure && candidates[0] ? candidates[0] : { workerId: '', index: -1 }; if (selected.workerId === state.mediaPermit.workerId && selected.index === state.mediaPermit.index) return; state.mediaPermitRevision += 1; state.mediaPermit = { workerId: selected.workerId, index: selected.index, revision: state.mediaPermitRevision }; enforceMediaPermit(state.mediaPermit); broadcastToChildren({ type: MESSAGE_TYPE, kind: 'media-permit', permit: state.mediaPermit }); } function enforceMediaPermit(permit = state.mediaPermit) { const visibleMedia = Array.from(document.querySelectorAll('video, audio')).filter(isVisible); const target = permit.workerId === WORKER_ID ? visibleMedia[permit.index] : null; document.querySelectorAll('video, audio').forEach((media) => { if (media === target || media.paused) return; pauseMediaElement(media); }); } function revokeMediaPermit() { if (!state.mediaPermit.workerId && state.mediaPermit.index === -1) return; state.mediaPermitRevision += 1; state.mediaPermit = { workerId: '', index: -1, revision: state.mediaPermitRevision }; enforceMediaPermit(state.mediaPermit); broadcastToChildren({ type: MESSAGE_TYPE, kind: 'media-permit', permit: state.mediaPermit }); } function isScanBlockingStatus(status) { return ['requested', 'scanning', 'paused', 'scan_error'].includes(status); } function disableLegacyScannerState() { if (!isScanBlockingStatus(state.scanSession.status)) return; state.scanSession = { status: 'manual', context: state.scanSession.context || null, sourceUrl: state.scanSession.sourceUrl || '', scannerRemovedAt: Date.now() }; saveScanSession(); revokeMediaPermit(); } 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.mediaFailed += Number(worker.media.failed || 0); if (!result.mediaFailureReason && worker.media.failureReason) { result.mediaFailureReason = worker.media.failureReason; } result.questionTotal += worker.questions.total; result.questionCompleted += worker.questions.completed; result.questionPending += worker.questions.pending; if (!result.issue && worker.issue) result.issue = worker.issue; result.hasWork = result.hasWork || worker.hasWork; result.busy = result.busy || worker.busy; return result; }, { mediaTotal: 0, mediaCompleted: 0, mediaFailed: 0, mediaFailureReason: '', questionTotal: 0, questionCompleted: 0, questionPending: 0, issue: null, hasWork: false, busy: false }); if (summary.hasWork) state.seenWork = true; return summary; } function handleMediaFailure(summary) { if (!isController() || Number(summary.mediaFailed || 0) < 1) return false; const reason = summary.mediaFailureReason || '媒体播放长时间没有进度'; const key = `${state.currentTaskKey}|${reason}`; if (state.lastMediaFailureKey === key && state.queuePaused) return true; state.lastMediaFailureKey = key; pauseQueueForError(reason); updatePanel(summary); return true; } function pauseQueueForError(reason) { state.queuePaused = true; state.enabled = false; storageApi.set(STORE.queuePaused, true); storageApi.set(STORE.enabled, false); revokeMediaPermit(); pauseLocalMedia(); state.lastError = reason; pushLog(`待办已暂停:${reason}`, true); } 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 (CONFIG.selectors.incomplete.some((selector) => node.querySelector?.(selector))) return false; if (/completed|(^|[-_\s])finished($|[-_\s])|jobfinish|icon_completed/i.test(className)) return true; if (/已完成/.test(title)) return true; return CONFIG.selectors.completed.some((selector) => Boolean(node.querySelector?.(selector))); } function hasIncompleteMarker(node) { if (!node) return false; if (/unfinished|unfinish/i.test(node.className || '')) return true; if (/未完成/.test(node.getAttribute?.('title') || '')) return true; return CONFIG.selectors.incomplete.some((selector) => Boolean(node.querySelector?.(selector))); } function catalogEntries() { return activeCourseTemplate().catalog().filter((entry) => isVisible(entry.node)).map((entry) => entry.node); } function simpleHash(value) { let hash = 2166136261; const text = String(value || ''); for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); } function currentAcademicYear() { const now = new Date(); const year = now.getFullYear(); const start = now.getMonth() >= 7 ? year : year - 1; return { start, end: start + 1 }; } function currentTermNumber() { const month = new Date().getMonth(); return month >= 7 || month <= 0 ? 1 : 2; } function currentTermKey() { const year = currentAcademicYear(); return `${year.start}-${year.end}-${currentTermNumber()}`; } function detectAccountKey() { try { const url = new URL(location.href); for (const key of ['userId', 'userid', 'uid', 'puid', 'pUid']) { const value = compactText(url.searchParams.get(key)); if (value) return value.slice(0, 120); } } catch (_) { // Continue with DOM account hints. } const node = document.querySelector( '[data-userid], [data-user-id], [data-uid], input[name="userId"], input[name="uid"], meta[name="user-id"]' ); return compactText( node?.getAttribute?.('data-userid') || node?.getAttribute?.('data-user-id') || node?.getAttribute?.('data-uid') || node?.getAttribute?.('value') || node?.getAttribute?.('content') ).slice(0, 120); } function scanPlatformKey(hostname = location.hostname) { const host = normalize(hostname); if (host.includes('xueyinonline')) return 'xueyinonline'; if (host.includes('chaoxing')) return 'chaoxing'; return host; } function activeCourseTemplate() { if (scanPlatformKey() === 'xueyinonline') return PLATFORM_ADAPTERS.xueyin; if (FRAME_PATH.length > 0) return PLATFORM_ADAPTERS['portal-iframe']; for (const id of ['chaoxing-new', 'chaoxing-legacy']) { const adapter = PLATFORM_ADAPTERS[id]; if (adapter.detect()) return adapter; } return PLATFORM_ADAPTERS.generic; } function templateSelectors(kind) { return Array.from(new Set(activeCourseTemplate().selectors[kind] || [])); } function templateNodes(kind) { const adapter = activeCourseTemplate(); const primary = queryAllInOrder(adapter.selectors[kind] || []); if (primary.length || adapter.id === 'generic') return primary; return queryAllInOrder(COURSE_TEMPLATES.generic[kind] || []); } function adapterDomSignature(adapter = activeCourseTemplate()) { const nodes = ['catalog', 'courseLinks'].flatMap((kind) => queryAllInOrder(adapter.selectors[kind] || []).slice(0, 8)); return simpleHash(nodes.map((node) => [ node.tagName, node.id, String(node.className || '').slice(0, 120), node.getAttribute?.('data-status') || '' ].join('|')).join('||')); } function recordAdapterSuccess(mediaCount = 0, questionCount = 0) { const adapter = activeCourseTemplate(); const catalogCount = adapter.catalog().length; if (mediaCount + questionCount + catalogCount < 1) return; const previous = state.adapterHealth[adapter.id] || {}; const signature = adapterDomSignature(adapter); const now = Date.now(); if (previous.domSignature === signature && now - Number(previous.lastSuccessAt || 0) < 60000) return; state.adapterHealth[adapter.id] = { apiVersion: adapter.apiVersion, domSignature: signature, lastSuccessAt: now, counts: { catalog: catalogCount, media: mediaCount, questions: questionCount } }; saveObject(STORE.adapterHealth, state.adapterHealth); } function isCourseLink(link) { const rawHref = link?.getAttribute?.('href') || ''; if (!rawHref || /^javascript:/i.test(rawHref)) return false; try { const url = new URL(rawHref, location.href); const hasIdentity = url.searchParams.has('courseId') || url.searchParams.has('courseid') || url.searchParams.has('clazzid') || url.searchParams.has('clazzId'); const knownPath = /\/mycourse\/|studentcourse|studentstudy|coursehome/i.test(url.pathname); return hasIdentity && knownPath; } catch (_) { return false; } } function courseListFingerprint(courses) { return simpleHash((Array.isArray(courses) ? courses : []) .map((course) => `${course.id}|${compactText(course.name)}`) .join('||')); } function buildScanContext(courses) { return { accountKey: detectAccountKey(), termKey: currentTermKey(), platformKey: scanPlatformKey(), courseFingerprint: courseListFingerprint(courses) }; } function scanContextKey(context) { if (!context || typeof context !== 'object') return ''; return simpleHash([ context.accountKey || '', context.termKey || '', context.platformKey || '', context.courseFingerprint || '' ].join('|')); } function scanContextMismatch(context, currentCourses = null) { if (!context || typeof context !== 'object') return ''; if (context.termKey && context.termKey !== currentTermKey()) return '学期已变化'; if (context.platformKey && context.platformKey !== scanPlatformKey()) return '课程平台已变化'; const accountKey = detectAccountKey(); if (context.accountKey && accountKey && context.accountKey !== accountKey) return '登录账号已变化'; if (Array.isArray(currentCourses) && context.courseFingerprint && context.courseFingerprint !== courseListFingerprint(currentCourses)) return '课程列表已变化'; return ''; } function courseIdentity(urlValue) { try { const url = new URL(urlValue, location.href); const courseId = url.searchParams.get('courseId') || url.searchParams.get('courseid') || ''; const clazzId = url.searchParams.get('clazzid') || url.searchParams.get('clazzId') || ''; return courseId || clazzId ? `${courseId}:${clazzId}` : `${url.origin}${url.pathname}`; } catch (_) { return normalize(urlValue); } } function isCurrentSemesterCourse(cardText) { const text = compactText(cardText); if (/已结课|已结束|课程结束|已归档|历史课程|已过期/.test(text)) return false; const year = currentAcademicYear(); const ranges = Array.from(text.matchAll(/(20\d{2})\s*[-—至/]\s*(20\d{2})/g)); if (ranges.length && !ranges.some((match) => Number(match[1]) === year.start && Number(match[2]) === year.end)) { return false; } const firstTerm = /第\s*[一1]\s*学期|秋季|秋学期/.test(text); const secondTerm = /第\s*[二2]\s*学期|春季|春学期/.test(text); if (firstTerm !== secondTerm) { if (firstTerm && currentTermNumber() !== 1) return false; if (secondTerm && currentTermNumber() !== 2) return false; } return true; } function courseNameFromCard(card, link) { const titleNode = card.querySelector( '.course-name, .courseName, .course_name, .name, .Mconright h3, h3, h4, [title]' ); return compactText( titleNode?.getAttribute?.('title') || titleNode?.textContent || link.getAttribute('title') || link.textContent ).slice(0, 160); } function discoverCurrentSemesterCourses() { const links = templateNodes('courseLinks') .filter((link) => isVisible(link) && isCourseLink(link) && !link.closest('#cx-helper-panel, #cx-helper-progress-window, #cx-helper-todo-window')); const courses = []; const seen = new Set(); links.forEach((link) => { const rawHref = link.getAttribute('href') || ''; if (!rawHref || /^javascript:/i.test(rawHref)) return; let url; try { url = new URL(rawHref, location.href).href; } catch (_) { return; } const card = link.closest(templateSelectors('courseCards').join(',')) || link; const cardText = compactText(card.textContent); if (!isCurrentSemesterCourse(cardText)) return; const name = courseNameFromCard(card, link); if (!name) return; const id = courseIdentity(url); if (!id || seen.has(id)) return; seen.add(id); courses.push({ id, name, url, order: courses.length }); }); return courses; } function isCourseHomePage() { const path = location.pathname.toLowerCase(); if (/studentstudy|studentcourse|\/mycourse\/tch|knowledge|chapterstudy/.test(path)) return false; return discoverCurrentSemesterCourses().length > 0; } function scanCatalogEntries() { const candidates = templateNodes('catalog'); const entries = []; candidates.forEach((node) => { const clickable = node.closest('a[href]') || node.querySelector?.('a[href]') || node; const title = compactText(clickable.textContent || node.textContent); if (!title || entries.some((entry) => entry.node === clickable)) return; const href = clickable.getAttribute?.('href') || ''; let url = ''; if (href && !/^javascript:/i.test(href)) { try { url = new URL(href, location.href).href; } catch (_) { url = ''; } } entries.push({ node: clickable, title, url, order: entries.length }); }); return entries; } function inferTaskType(entry) { const text = normalize(`${entry.title} ${entry.node.className || ''} ${entry.url}`); if (/视频|video/.test(text)) return '视频'; if (/音频|audio/.test(text)) return '音频'; if (/测验|作业|考试|quiz|work/.test(text)) return '题目'; if (/文档|阅读|ppt|pdf|document/.test(text)) return '文档'; return '任务点'; } function createTaskDescriptor(course, entry, index, contextKey) { return { schemaVersion: 3, id: `todo-${simpleHash(`${course.id}|${entry.url}|${entry.title}`)}`, courseId: course.id, courseName: course.name, courseOrder: course.order, taskOrder: index, title: entry.title.slice(0, 180), type: inferTaskType(entry), deadline: detectDeadline(entry.node?.textContent || ''), url: entry.url || course.url, status: 'pending', source: '目录标记', sourceRevision: simpleHash(`${entry.url}|${entry.title}|${entry.node?.className || ''}`), scannedAt: Date.now(), completedAt: 0, contextKey }; } function extractPendingTodos(course, catalog = scanCatalogEntries()) { const contextKey = scanContextKey(state.scanSession.context); return catalog .filter((entry) => hasIncompleteMarker(entry.node) || !hasCompletionMarker(entry.node)) .filter((entry) => entry.url || hasIncompleteMarker(entry.node)) .map((entry, index) => createTaskDescriptor(course, entry, index, contextKey)); } function saveTodos() { const scopes = loadObject(STORE.todoScopes); scopes[todoScopeKey()] = state.todos; saveObject(STORE.todoScopes, scopes); storageApi.set(STORE.todos, JSON.stringify(state.todos)); } function detectDeadline(value = document.body?.innerText || '') { const text = compactText(value); const match = text.match(/(?:截止(?:时间)?|deadline)\s*[::]?\s*(20\d{2}[年\-/.]\d{1,2}[月\-/.]\d{1,2}(?:日)?(?:\s+\d{1,2}:\d{2})?)/i); if (!match) return ''; return match[1].replace(/[年/.]/g, '-').replace('月', '-').replace('日', '').trim(); } function syncRunningTodoDeadline() { const todo = currentRunningTodo(); if (!todo || todo.deadline) return; const deadline = detectDeadline(); if (!deadline) return; todo.deadline = deadline; saveTodos(); } function saveScanSession() { storageApi.set(STORE.scanSession, JSON.stringify(state.scanSession)); } function currentRunningTodo() { return state.todos.find((todo) => todo.status === 'running') || null; } function nextPendingTodo() { return state.todos .filter((todo) => todo.status === 'pending') .sort((a, b) => a.courseOrder - b.courseOrder || a.taskOrder - b.taskOrder)[0] || null; } function todoMatchesLocation(todo) { if (!todo?.url) return false; try { const target = new URL(todo.url, location.href); const current = new URL(location.href); if (target.href === current.href) return true; const targetCourse = target.searchParams.get('courseId') || target.searchParams.get('courseid'); const currentCourse = current.searchParams.get('courseId') || current.searchParams.get('courseid'); const targetChapter = target.searchParams.get('chapterId') || target.searchParams.get('knowledgeId'); const currentChapter = current.searchParams.get('chapterId') || current.searchParams.get('knowledgeId'); if (targetCourse && currentCourse && targetCourse === currentCourse && targetChapter && currentChapter && targetChapter === currentChapter) { return true; } } catch (_) { // Fall through to title matching for onclick-only task entries. } const currentTitle = normalize(currentTaskTitle()); const todoTitle = normalize(todo.title); return Boolean(todoTitle && currentTitle && (currentTitle.includes(todoTitle) || todoTitle.includes(currentTitle))); } function invalidateTodoContext(reason) { state.todos = []; saveTodos(); state.scanSession = { ...state.scanSession, status: 'context_changed', contextChangedReason: reason, contextChangedAt: Date.now() }; saveScanSession(); pauseQueueForError(reason); pushLog(`待办已清空:${reason}`, true); } function validateTodoContext() { if (!state.todos.length) return true; const context = state.scanSession.context; if (!context) { invalidateTodoContext('旧待办缺少账号和学期信息,请重新扫描'); return false; } const homeCourses = isCourseHomePage() ? discoverCurrentSemesterCourses() : null; const reason = scanContextMismatch(context, homeCourses); const expectedKey = scanContextKey(context); const storedMismatch = state.todos.some((todo) => todo.contextKey && todo.contextKey !== expectedKey); if (reason || storedMismatch) { invalidateTodoContext(reason || '待办所属账号或学期与当前环境不一致'); return false; } if (state.todos.some((todo) => !todo.contextKey)) { state.todos = state.todos.map((todo) => ({ ...todo, contextKey: expectedKey })); saveTodos(); } return true; } function navigateForQueue(todo) { if (!todo?.url || Date.now() - state.queueNavigationAt < 2000) return false; state.queueNavigationAt = Date.now(); pushLog(`进入待办:${todo.courseName} / ${todo.title}`); window.setTimeout(() => location.assign(todo.url), 150); return true; } function activateNextTodo() { if (state.queuePaused || !state.enabled) return null; let todo = currentRunningTodo(); if (!todo) { todo = nextPendingTodo(); if (todo) { todo.status = 'running'; todo.startedAt = Date.now(); saveTodos(); } } if (!todo) { if (state.todos.length && !state.queueFinishedLogged) { state.queueFinishedLogged = true; pushLog('本地待办队列已全部完成', true); } return null; } state.queueFinishedLogged = false; if (!todoMatchesLocation(todo)) navigateForQueue(todo); return todo; } function markRunningTodoComplete(completionSource = '服务端确认') { const todo = currentRunningTodo(); if (!todo) return false; todo.status = 'completed'; todo.completedAt = Date.now(); todo.completionSource = completionSource; saveTodos(); pushLog(`完成待办:${todo.courseName} / ${todo.title}`, true); state.queueNavigationAt = 0; state.stableSince = 0; state.completionAwaitStartedAt = 0; state.completionAwaitTaskKey = ''; if (/平台尚未确认任务完成/.test(state.lastError)) state.lastError = ''; return true; } function completeRunningTodoAndAdvance(completionSource) { if (!markRunningTodoComplete(completionSource)) return false; const nextTodo = activateNextTodo(); if (!nextTodo) goToNextTask(); return true; } function processTodoQueue() { if (!isController()) return null; if (!validateTodoContext()) return null; if (state.queuePaused || !state.enabled) { pauseLocalMedia(); return currentRunningTodo(); } return activateNextTodo(); } 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); if (current && activeCourseTemplate().completion(current)) return true; const statusNode = firstVisible([ '.ans-job-finished', '.jobFinish', '.job-finished', '[data-status="completed"]', '[data-job-status="completed"]', '.courseTaskStatus', '.task-status-completed' ]); if (statusNode) return true; return Array.from(document.querySelectorAll('body *')).some((node) => node.children.length === 0 && isVisible(node) && /^(任务点|任务)已完成$/.test(compactText(node.textContent)) ); } function currentTodoMarkedComplete(todo) { if (!todo || !todoMatchesLocation(todo)) return false; if (currentTaskMarkedComplete()) return true; const targetUrl = todo.url || ''; const entry = scanCatalogEntries().find((candidate) => { if (targetUrl && candidate.url === targetUrl) return true; const title = normalize(todo.title); return title && normalize(candidate.title) === title; }); return Boolean(entry && activeCourseTemplate().completion(entry.node)); } 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.mediaPermitRevision += 1; state.mediaPermit = { workerId: '', index: -1, revision: state.mediaPermitRevision }; enforceMediaPermit(state.mediaPermit); if (isController()) { broadcastToChildren({ type: MESSAGE_TYPE, kind: 'media-permit', permit: state.mediaPermit }); } state.seenWork = false; state.stableSince = 0; state.completionAwaitStartedAt = 0; state.completionAwaitTaskKey = ''; 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"]')) { const clicked = clickElement(next); if (!clicked) return false; resetTaskProgress(1200); state.currentTaskKey = ''; state.lastNavigation = Date.now(); pushLog('进入下一个任务点'); return true; } 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; const clicked = clickElement(target); if (!clicked) return false; resetTaskProgress(1200); state.currentTaskKey = ''; state.lastNavigation = Date.now(); pushLog(`按目录进入:${normalize(target.textContent).slice(0, 30)}`); return true; } function maybeAdvance(summary) { if (!isController() || !state.enabled || isCourseDetailPage()) return; if (Date.now() - state.taskEnteredAt < CONFIG.taskWarmupTime) 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; state.completionAwaitStartedAt = 0; state.completionAwaitTaskKey = ''; return; } if (!state.stableSince) state.stableSince = Date.now(); if (Date.now() - state.stableSince < CONFIG.completionStableTime) return; const runningTodo = currentRunningTodo(); if (runningTodo) { if (currentTodoMarkedComplete(runningTodo)) { completeRunningTodoAndAdvance('服务端确认'); return; } if (completedWork) { const confirmationKey = runningTodo.id || runningTodo.url || runningTodo.title; if (state.completionAwaitTaskKey !== confirmationKey) { state.completionAwaitTaskKey = confirmationKey; state.completionAwaitStartedAt = Date.now(); pushLog('本地处理完成,等待平台确认', true); } if (Date.now() - state.completionAwaitStartedAt >= CONFIG.serverCompletionTimeout) { const reason = '平台尚未确认任务完成'; pauseQueueForError(reason); } return; } } if (goToNextTask()) state.stableSince = 0; } function toggleEnabled() { state.enabled = !state.enabled; state.queuePaused = !state.enabled; resetTaskProgress(state.enabled ? 500 : 0); state.detailJumpAttempted = false; state.detailExpandAttempted = false; state.detailPageEnteredAt = Date.now(); storageApi.set(STORE.enabled, state.enabled); storageApi.set(STORE.queuePaused, state.queuePaused); pushLog(state.enabled ? '已启动自动运行' : '已暂停自动运行', true); updatePanel(); } function importBank() { const raw = prompt('粘贴题库 JSON:{"题目":"答案"},多选题答案使用数组'); if (!raw) return; try { importBankObject(JSON.parse(raw)); } catch (error) { alert(`题库 JSON 解析失败:${error.message}`); } } function importBankObject(parsed) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('顶层必须是 JSON 对象'); const envelope = parsed.records && typeof parsed.records === 'object' && !Array.isArray(parsed.records) ? parsed : { schemaVersion: 0, provider: 'legacy-json', records: parsed }; const records = envelope.records; const entries = Object.entries(records); if (!entries.length) throw new Error('题库文件没有记录'); const invalid = entries.filter(([question, answer]) => !normalize(question) || !(typeof answer === 'string' || typeof answer === 'number' || Array.isArray(answer))); if (invalid.length) throw new Error(`有 ${invalid.length} 条记录格式错误`); const conflicts = entries.filter(([question, answer]) => Object.prototype.hasOwnProperty.call(state.bank, question) && JSON.stringify(state.bank[question]) !== JSON.stringify(answer) ).length; state.bank = { ...state.bank, ...records }; entries.forEach(([question]) => { const normalized = normalize(question); Object.keys(state.unanswered).forEach((storedQuestion) => { if (storedQuestion === question || normalize(storedQuestion) === normalized) delete state.unanswered[storedQuestion]; }); }); saveObject(STORE.bank, state.bank); saveObject(STORE.unanswered, state.unanswered); rebuildBankIndex(); const result = { schemaVersion: BANK_SCHEMA_VERSION, sourceSchemaVersion: Number(envelope.schemaVersion || 0), provider: activeBankProvider().id, imported: entries.length, conflicts, total: Object.keys(state.bank).length }; pushLog(`题库导入 ${result.imported} 条,覆盖冲突 ${result.conflicts} 条,现有 ${result.total} 条`, true, 'question'); return result; } async function importBankFile(file) { if (!file) return null; const text = await file.text(); return importBankObject(JSON.parse(text)); } function exportUnanswered() { const payload = JSON.stringify({ schemaVersion: BANK_SCHEMA_VERSION, provider: activeBankProvider().id, exportedAt: new Date().toISOString(), records: state.unanswered }, null, 2); prompt('复制未匹配题目,补充答案后重新导入:', payload); } function exportBankPayload() { return JSON.stringify(activeBankProvider().export(), null, 2); } function diagnosticPayload() { const workers = Array.from(state.workers.values()).map((worker) => ({ framePath: worker.framePath, media: worker.media, questions: worker.questions, issue: redactDiagnosticValue(worker.issue), location: redactDiagnosticText(workerLocation(worker)) })); return { version: SCRIPT_VERSION, at: new Date().toISOString(), platform: scanPlatformKey(), template: activeCourseTemplate().id, enabled: state.enabled, queuePaused: state.queuePaused, schemaVersions: { bank: BANK_SCHEMA_VERSION, todoExport: TODO_EXPORT_SCHEMA_VERSION }, bankProvider: activeBankProvider().id, adapterHealth: redactDiagnosticValue(state.adapterHealth), primaryTab: state.tabPrimary, todoCounts: state.todos.reduce((result, todo) => { result[todo.status] = (result[todo.status] || 0) + 1; return result; }, {}), lastError: redactDiagnosticText(state.lastError), workers, logs: (storageApi.get(STORE.logs, []) || []).slice(0, 30).map(redactDiagnosticText) }; } function redactDiagnosticText(value) { return String(value || '') .replace(/https?:\/\/[^\s]+/gi, (raw) => { try { const url = new URL(raw); return `${url.origin}${url.pathname}`; } catch (_) { return '[URL]'; } }) .replace(/([?&](?:token|ticket|uid|userid|account|phone|mobile|session|enc)\s*=)[^&#\s]+/gi, '$1[REDACTED]') .replace(/\b1[3-9]\d{9}\b/g, '[PHONE]') .replace(/\b(?:\d{15,18}[\dXx]|[A-Za-z0-9_-]{24,})\b/g, '[ID]'); } function redactDiagnosticValue(value) { if (Array.isArray(value)) return value.map(redactDiagnosticValue); if (value && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactDiagnosticValue(item)])); } return typeof value === 'string' ? redactDiagnosticText(value) : value; } function diagnosticPreview() { const raw = [state.lastError, ...(storageApi.get(STORE.logs, []) || []).slice(0, 30)].join('\n'); return { logLines: Math.min(30, (storageApi.get(STORE.logs, []) || []).length), workerCount: state.workers.size, todoCount: state.todos.length, sensitiveMatches: { url: (raw.match(/https?:\/\/[^\s]+/gi) || []).length, phone: (raw.match(/\b1[3-9]\d{9}\b/g) || []).length, longId: (raw.match(/\b[A-Za-z0-9_-]{24,}\b/g) || []).length } }; } function exportDiagnostic(download = true) { if (download) { const preview = diagnosticPreview(); const accepted = typeof confirm !== 'function' || confirm( `诊断包包含 ${preview.logLines} 条日志、${preview.workerCount} 个页面状态和 ${preview.todoCount} 条待办统计。\n` + `检测到 URL ${preview.sensitiveMatches.url} 处、手机号 ${preview.sensitiveMatches.phone} 处、长标识 ${preview.sensitiveMatches.longId} 处,导出时会自动隐藏。\n\n继续导出?` ); if (!accepted) return ''; } const text = JSON.stringify(diagnosticPayload(), null, 2); if (download) downloadText('chaoxing-diagnostic.json', text); return text; } 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 issue = root.querySelector('.cxh-issue'); issue.hidden = !summary.issue; issue.textContent = summary.issue ? `${summary.issue.label}:${summary.issue.advice}` : ''; const activeMedia = Array.from(state.workers.values()) .flatMap((worker) => worker.media.items || []) .find((item) => !item.completed && !item.failed) || Array.from(state.workers.values()).flatMap((worker) => worker.media.items || []).at(-1); const formatTime = (seconds) => { const value = Math.max(0, Math.floor(Number(seconds) || 0)); return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')}`; }; root.querySelector('.cxh-media-time').textContent = activeMedia ? `${activeMedia.type === 'audio' ? '音频' : '视频'} ${formatTime(activeMedia.currentTime)} / ${formatTime(activeMedia.duration)} · 剩余 ${formatTime(Math.max(0, activeMedia.duration - activeMedia.currentTime))} · ${activeMedia.speed || state.speed}x` : '当前没有媒体'; 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'; const types = (worker.media.items || []).map((item) => item.type === 'audio' ? '音频' : '视频'); details.textContent = `媒体 ${worker.media.completed}/${worker.media.total}${types.length ? `(${types.join('、')})` : ''} · 题目 ${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 : []) .filter((line) => state.logFilter === 'all' || String(line).includes(`[${state.logFilter}]`)) .slice(0, 20); 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; if (state.progressOpen && state.todoOpen) toggleTodoWindow(false); state.progressWindow.hidden = !state.progressOpen; state.panel?.querySelector('.cxh-open-progress')?.setAttribute('aria-expanded', String(state.progressOpen)); if (state.progressOpen) renderProgressWindow(activeWorkerSummary()); } function renderLogWindow() { const root = state.logWindow; if (!root || !state.logOpen) return; const output = root.querySelector('.cxh-log-output'); const stored = storageApi.get(STORE.logs, []); const lines = (Array.isArray(stored) ? stored : []).slice(0, 200).reverse(); output.replaceChildren(); const labelMap = { error: '错误', media: '日志', question: '信息', navigation: '信息', system: '信息' }; lines.forEach((line) => { const text = String(line); const match = text.match(/^(\S+)\s+\[([^\]]+)\]\s*(.*)$/); const row = document.createElement('div'); row.className = `cxh-live-log is-${match?.[2] || 'system'}`; const time = document.createElement('span'); time.className = 'cxh-live-time'; time.textContent = match?.[1] || ''; const label = document.createElement('span'); label.className = 'cxh-live-label'; label.textContent = `[${labelMap[match?.[2]] || '日志'}]`; const message = document.createElement('span'); message.textContent = match?.[3] || text; row.append(time, label, message); output.appendChild(row); }); if (!lines.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '暂无日志输出'; output.appendChild(empty); } root.querySelector('.cxh-log-follow').textContent = state.logAutoScroll ? '暂停滚动' : '继续滚动'; if (state.logAutoScroll) output.scrollTop = output.scrollHeight; } function toggleLogWindow(force) { if (!state.logWindow) return; state.logOpen = typeof force === 'boolean' ? force : !state.logOpen; state.logWindow.hidden = !state.logOpen; state.panel?.querySelector('.cxh-open-log')?.setAttribute('aria-expanded', String(state.logOpen)); if (state.logOpen) renderLogWindow(); } function exportLogs() { const stored = storageApi.get(STORE.logs, []); const text = (Array.isArray(stored) ? stored : []).slice().reverse().join('\n'); downloadText('chaoxing-helper.log.txt', text, 'text/plain'); } function todoStatusText(status) { if (status === 'running') return '进行中'; if (status === 'completed') return '已完成'; if (status === 'failed') return '失败'; if (status === 'skipped') return '已跳过'; return '排队'; } function summarizeCourseTodos(todos) { const sortedFailures = todos.filter((todo) => todo.error).sort((a, b) => Number(b.failedAt || b.updatedAt || 0) - Number(a.failedAt || a.updatedAt || 0) ); return { total: todos.length, completed: todos.filter((todo) => todo.status === 'completed').length, pending: todos.filter((todo) => todo.status === 'pending' || todo.status === 'running').length, failed: todos.filter((todo) => todo.status === 'failed').length, lastFailure: sortedFailures[0]?.error || '' }; } function saveUiState() { saveObject(STORE.uiState, state.uiState); } function retryTodo(todo) { todo.status = 'pending'; todo.error = ''; todo.failedAt = 0; saveTodos(); if (!state.queuePaused && state.enabled) activateNextTodo(); pushLog(`重新排队:${todo.courseName} / ${todo.title}`, true); } function skipTodo(todo) { const wasRunning = currentRunningTodo()?.id === todo.id; todo.status = 'skipped'; todo.skippedAt = Date.now(); todo.completionSource = '用户手动跳过'; if (wasRunning) { state.queueNavigationAt = 0; state.currentTaskKey = ''; resetTaskProgress(0); } saveTodos(); activateNextTodo(); pushLog(`已跳过:${todo.courseName} / ${todo.title}`, true); } function todoExportRows() { return [...state.todos] .sort((a, b) => a.courseOrder - b.courseOrder || a.taskOrder - b.taskOrder) .map((todo) => ({ course: todo.courseName || '', chapter: todo.title || '', type: todo.type || '任务点', status: todoStatusText(todo.status), deadline: todo.deadline || '', source: todo.source || '', completionSource: todo.completionSource || '', durationMs: todo.completedAt && todo.startedAt ? todo.completedAt - todo.startedAt : '', error: todo.error || '' })); } function downloadText(filename, text, type = 'application/json') { const blob = new Blob([text], { type: `${type};charset=utf-8` }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); } function exportTodos(format = 'json', download = true) { const rows = todoExportRows(); let text; if (format === 'csv') { const quote = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`; const columns = ['course', 'chapter', 'type', 'status', 'deadline', 'source', 'completionSource', 'durationMs', 'error']; text = [columns.join(','), ...rows.map((row) => columns.map((key) => quote(row[key])).join(','))].join('\n'); } else { text = JSON.stringify({ schemaVersion: TODO_EXPORT_SCHEMA_VERSION, exportedAt: new Date().toISOString(), tasks: rows }, null, 2); } if (download) downloadText(`chaoxing-todos.${format}`, text, format === 'csv' ? 'text/csv' : 'application/json'); return text; } function renderTodoWindow() { const root = state.todoWindow; if (!root || !state.todoOpen) return; const query = normalize(state.todoSearch); const sorted = [...state.todos].sort((a, b) => a.courseOrder - b.courseOrder || a.taskOrder - b.taskOrder ).filter((todo) => { if (query && !normalize(`${todo.courseName} ${todo.title}`).includes(query)) return false; if (state.todoStatusFilter !== 'all' && todo.status !== state.todoStatusFilter) return false; if (state.todoTypeFilter !== 'all' && (todo.type || '任务点') !== state.todoTypeFilter) return false; return true; }); const counts = state.todos.reduce((result, todo) => { result[todo.status] = (result[todo.status] || 0) + 1; return result; }, { pending: 0, running: 0, completed: 0 }); root.querySelector('.cxh-todo-pending').textContent = String(counts.pending || 0); root.querySelector('.cxh-todo-running').textContent = String(counts.running || 0); root.querySelector('.cxh-todo-completed').textContent = String(counts.completed || 0); const queueButton = root.querySelector('.cxh-queue-toggle'); queueButton.textContent = state.queuePaused || !state.enabled ? '继续队列' : '暂停队列'; const list = root.querySelector('.cxh-todo-list'); list.replaceChildren(); if (!sorted.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '当前没有待办任务'; list.appendChild(empty); return; } const groups = new Map(); sorted.forEach((todo) => { const key = `${todo.courseOrder}:${todo.courseName}`; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(todo); }); groups.forEach((todos, key) => { const group = document.createElement('section'); group.className = 'cxh-todo-course'; const collapsed = Boolean(state.uiState.collapsedCourses?.[key]); group.classList.toggle('is-collapsed', collapsed); const title = document.createElement('div'); title.className = 'cxh-todo-course-title'; const courseStats = summarizeCourseTodos(todos); title.textContent = `${key.slice(key.indexOf(':') + 1)} · 完成 ${courseStats.completed}/${courseStats.total}`; title.tabIndex = 0; title.addEventListener('click', () => { state.uiState.collapsedCourses = state.uiState.collapsedCourses || {}; state.uiState.collapsedCourses[key] = !state.uiState.collapsedCourses[key]; saveUiState(); renderTodoWindow(); }); group.appendChild(title); const courseSummary = document.createElement('div'); courseSummary.className = 'cxh-todo-course-summary'; courseSummary.textContent = courseStats.lastFailure ? `待处理 ${courseStats.pending} · 失败 ${courseStats.failed} · 最近失败:${courseStats.lastFailure}` : `待处理 ${courseStats.pending} · 失败 ${courseStats.failed}`; group.appendChild(courseSummary); todos.forEach((todo) => { const row = document.createElement('div'); row.className = `cxh-todo-item is-${todo.status}`; const content = document.createElement('div'); content.className = 'cxh-todo-content'; const taskTitle = document.createElement('div'); taskTitle.className = 'cxh-todo-title'; taskTitle.textContent = todo.title; const meta = document.createElement('div'); meta.className = 'cxh-todo-meta'; const deadline = todo.deadline ? ` · 截止 ${todo.deadline}` : ''; const source = todo.completionSource || todo.source || '本地待办'; meta.textContent = `${todo.type || '任务点'} · ${todoStatusText(todo.status)} · 来源 ${source}${deadline}${todo.error ? ` · ${todo.error}` : ''}`; if (todo.deadline && Date.parse(todo.deadline) - Date.now() < 3 * 86400000) row.classList.add('is-urgent'); content.append(taskTitle, meta); const status = document.createElement('span'); status.className = `cxh-todo-status is-${todo.status}`; status.textContent = todoStatusText(todo.status); const open = document.createElement('button'); open.type = 'button'; open.className = 'cxh-todo-open'; open.textContent = '打开'; open.disabled = !todo.url; open.addEventListener('click', () => { if (todo.url) location.assign(todo.url); }); row.append(content, status, open); if (todo.status === 'failed') { const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'cxh-todo-retry'; retry.textContent = '重试'; retry.addEventListener('click', () => retryTodo(todo)); const skip = document.createElement('button'); skip.type = 'button'; skip.className = 'cxh-todo-skip'; skip.textContent = '跳过'; skip.addEventListener('click', () => skipTodo(todo)); row.append(retry, skip); } group.appendChild(row); }); list.appendChild(group); }); } function toggleTodoWindow(force) { if (!state.todoWindow) return; state.todoOpen = typeof force === 'boolean' ? force : !state.todoOpen; if (state.todoOpen && state.progressOpen) toggleProgressWindow(false); state.todoWindow.hidden = !state.todoOpen; state.panel?.querySelector('.cxh-open-todos')?.setAttribute('aria-expanded', String(state.todoOpen)); if (state.todoOpen) renderTodoWindow(); } function toggleTodoQueue() { state.queuePaused = !(state.queuePaused || !state.enabled); state.enabled = !state.queuePaused; storageApi.set(STORE.queuePaused, state.queuePaused); storageApi.set(STORE.enabled, state.enabled); if (state.queuePaused) pauseLocalMedia(); else { resetFailedMedia(); broadcastToChildren({ type: MESSAGE_TYPE, kind: 'reset-media-failures' }); state.lastMediaFailureKey = ''; state.completionAwaitStartedAt = 0; state.completionAwaitTaskKey = ''; if (/媒体.*(无进度|卡住)|平台尚未确认任务完成/.test(state.lastError)) state.lastError = ''; activateNextTodo(); } pushLog(state.queuePaused ? '已暂停待办队列' : '已继续待办队列', true); updatePanel(); } function createPanel() { if (!isController() || !document.body) return; if (state.panel?.isConnected && state.progressWindow?.isConnected && state.todoWindow?.isConnected && state.logWindow?.isConnected) return; state.panel?.remove(); state.progressWindow?.remove(); state.todoWindow?.remove(); state.logWindow?.remove(); state.panel = null; state.progressWindow = null; state.todoWindow = null; state.logWindow = 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-media-time{padding:7px 9px;margin-bottom:8px;background:#1f2937;color:#dbeafe;border-radius:4px} #cx-helper-progress-window .cxh-issue{padding:8px 9px;margin-bottom:8px;background:#4c1d1d;color:#fecaca;border:1px solid #7f1d1d;border-radius:4px} #cx-helper-progress-window .cxh-issue[hidden]{display:none} #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-log-controls{display:flex;gap:6px;margin-bottom:6px} #cx-helper-progress-window .cxh-log-controls select,#cx-helper-progress-window .cxh-log-controls button{background:#293449;color:#fff;border:1px solid #43506a;border-radius:4px;padding:4px 7px} #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} #cx-helper-todo-window{box-sizing:border-box;position:fixed;z-index:2147483646;right:284px;bottom:16px;width:min(520px,calc(100vw - 316px));height:min(720px,calc(100vh - 32px));min-width:360px;min-height:300px;resize:both;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-todo-window[hidden]{display:none} #cx-helper-todo-window .cxh-todo-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} #cx-helper-todo-window .cxh-todo-header{cursor:move;user-select:none} #cx-helper-todo-window .cxh-todo-window-title{font-size:16px;font-weight:700} #cx-helper-todo-window .cxh-todo-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-todo-window .cxh-todo-scan-status{color:#94a3b8;margin-bottom:10px} #cx-helper-todo-window .cxh-todo-summary{display:flex;gap:18px;padding:9px 0;border-top:1px solid #2c374a;border-bottom:1px solid #2c374a;color:#cbd5e1} #cx-helper-todo-window .cxh-todo-summary strong{color:#fff} #cx-helper-todo-window .cxh-todo-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0} #cx-helper-todo-window .cxh-todo-controls button,#cx-helper-todo-window .cxh-todo-open,#cx-helper-todo-window .cxh-todo-retry,#cx-helper-todo-window .cxh-todo-skip{display:inline-flex;align-items:center;justify-content:center;width:auto;height:30px;min-width:0;min-height:0;margin:0;padding:4px 8px;border:0;border-radius:4px;background:#2d6cdf;color:#fff;font:13px/1 Arial,sans-serif;cursor:pointer} #cx-helper-todo-window .cxh-todo-filters{display:grid;grid-template-columns:minmax(130px,1fr) auto auto;gap:7px;margin:10px 0} #cx-helper-todo-window .cxh-todo-filters input,#cx-helper-todo-window .cxh-todo-filters select{min-width:0;background:#0e1523;color:#fff;border:1px solid #526687;border-radius:4px;padding:5px 7px} #cx-helper-todo-window button:disabled{opacity:.45;cursor:default} #cx-helper-todo-window .cxh-todo-course{margin-top:12px} #cx-helper-todo-window .cxh-todo-course-title{font-weight:700;padding:6px 0;color:#e2e8f0;border-bottom:1px solid #334155;cursor:pointer} #cx-helper-todo-window .cxh-todo-course-summary{padding:5px 0;color:#94a3b8;font-size:12px;overflow-wrap:anywhere} #cx-helper-todo-window .cxh-todo-course-title::before{content:'▾ ';color:#94a3b8} #cx-helper-todo-window .cxh-todo-course.is-collapsed .cxh-todo-course-title::before{content:'▸ '} #cx-helper-todo-window .cxh-todo-course.is-collapsed .cxh-todo-item,#cx-helper-todo-window .cxh-todo-course.is-collapsed .cxh-todo-course-summary{display:none} #cx-helper-todo-window .cxh-todo-item{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;padding:9px 0;border-bottom:1px solid #222d40} #cx-helper-todo-window .cxh-todo-item.is-completed{opacity:.65} #cx-helper-todo-window .cxh-todo-item.is-urgent{border-left:3px solid #f59e0b;padding-left:7px} #cx-helper-todo-window .cxh-todo-title{overflow-wrap:anywhere} #cx-helper-todo-window .cxh-todo-meta{color:#94a3b8;font-size:12px;margin-top:2px} #cx-helper-todo-window .cxh-todo-status{font-size:12px;padding:1px 6px;border-radius:4px;background:#334155;color:#cbd5e1} #cx-helper-todo-window .cxh-todo-status.is-running{background:#713f12;color:#fde68a} #cx-helper-todo-window .cxh-todo-status.is-completed{background:#14532d;color:#bbf7d0} #cx-helper-todo-window .cxh-empty{color:#94a3b8;padding:14px 0} #cx-helper-log-window{box-sizing:border-box;position:fixed;z-index:2147483646;left:16px;bottom:16px;width:min(620px,calc(100vw - 316px));height:min(520px,calc(100vh - 32px));display:flex;flex-direction:column;background:#151515;color:#ddd;font:14px/1.65 Consolas,'Microsoft YaHei',monospace;border:1px solid #555;border-radius:6px;box-shadow:0 12px 30px #0008;overflow:hidden} #cx-helper-log-window[hidden]{display:none} #cx-helper-log-window .cxh-live-header{display:flex;align-items:center;justify-content:space-between;padding:9px 11px;background:#f4f4f4;color:#555;border-bottom:1px solid #bbb;font-family:Arial,'Microsoft YaHei',sans-serif} #cx-helper-log-window .cxh-live-title{font-size:16px;font-weight:700} #cx-helper-log-window .cxh-live-actions{display:flex;gap:6px} #cx-helper-log-window button{border:1px solid #777;border-radius:4px;background:#fff;color:#333;padding:4px 8px;cursor:pointer} #cx-helper-log-window .cxh-log-output{flex:1;overflow:auto;padding:9px 10px;scrollbar-color:#888 #222} #cx-helper-log-window .cxh-live-log{display:flex;gap:7px;align-items:baseline;white-space:pre-wrap;overflow-wrap:anywhere} #cx-helper-log-window .cxh-live-time{color:#777;flex:0 0 auto} #cx-helper-log-window .cxh-live-label{color:#2f8de4;flex:0 0 auto} #cx-helper-log-window .cxh-live-log.is-error .cxh-live-label{color:#ff6868} #cx-helper-log-window .cxh-live-log.is-media .cxh-live-label{color:#c7c7c7} @media(max-width:760px){#cx-helper-progress-window,#cx-helper-todo-window,#cx-helper-log-window{z-index:2147483647;left:12px;right:12px;top:12px;bottom:12px;width:auto;height:auto;max-height:none}#cx-helper-progress-window .cxh-overview{grid-template-columns:1fr}#cx-helper-todo-window .cxh-todo-item{grid-template-columns:minmax(0,1fr) auto}#cx-helper-todo-window .cxh-todo-open{grid-column:2}} `; document.documentElement.appendChild(style); } const panel = document.createElement('div'); panel.id = 'cx-helper-panel'; panel.innerHTML = `
学习通课程助手
`; const progressWindow = document.createElement('section'); progressWindow.id = 'cx-helper-progress-window'; progressWindow.hidden = true; progressWindow.setAttribute('aria-label', '课程进度'); progressWindow.innerHTML = `
课程进度
当前任务
任务点0/0
媒体0/0
题目0/0
待处理题目 0未匹配题目 0
当前没有媒体
页面与播放器
最近记录
`; const todoWindow = document.createElement('section'); todoWindow.id = 'cx-helper-todo-window'; todoWindow.hidden = true; todoWindow.setAttribute('aria-label', '本地待办列表'); todoWindow.innerHTML = `
本地待办
排队 0 进行中 0 已完成 0
`; const logWindow = document.createElement('section'); logWindow.id = 'cx-helper-log-window'; logWindow.hidden = true; logWindow.setAttribute('aria-label', '日志输出'); logWindow.innerHTML = `
日志输出
`; document.body.appendChild(panel); document.body.appendChild(progressWindow); document.body.appendChild(todoWindow); document.body.appendChild(logWindow); progressWindow.hidden = !state.progressOpen; todoWindow.hidden = !state.todoOpen; logWindow.hidden = !state.logOpen; panel.querySelector('.cxh-speed').addEventListener('change', (event) => { state.speed = clampSpeed(event.target.value); event.target.value = state.speed; storageApi.set(STORE.speed, state.speed); }); panel.querySelector('.cxh-auto-jump').addEventListener('change', (event) => { state.autoJump = event.target.checked; state.detailJumpAttempted = false; storageApi.set(STORE.autoJump, state.autoJump); }); panel.querySelector('.cxh-toggle').addEventListener('click', toggleEnabled); panel.querySelector('.cxh-open-todos').addEventListener('click', () => toggleTodoWindow()); panel.querySelector('.cxh-open-progress').addEventListener('click', () => toggleProgressWindow()); panel.querySelector('.cxh-open-log').addEventListener('click', () => toggleLogWindow()); panel.querySelector('.cxh-import').addEventListener('click', importBank); panel.querySelector('.cxh-import-file').addEventListener('click', () => panel.querySelector('.cxh-bank-file').click()); panel.querySelector('.cxh-bank-file').addEventListener('change', async (event) => { try { await importBankFile(event.target.files?.[0]); } catch (error) { alert(`题库文件导入失败:${error.message}`); } event.target.value = ''; }); panel.querySelector('.cxh-export').addEventListener('click', exportUnanswered); progressWindow.querySelector('.cxh-close').addEventListener('click', () => toggleProgressWindow(false)); progressWindow.querySelector('.cxh-log-filter').addEventListener('change', (event) => { state.logFilter = event.target.value; renderProgressWindow(activeWorkerSummary()); }); progressWindow.querySelector('.cxh-clear-logs').addEventListener('click', () => { storageApi.set(STORE.logs, []); renderProgressWindow(activeWorkerSummary()); }); progressWindow.querySelector('.cxh-diagnostic').addEventListener('click', () => exportDiagnostic()); todoWindow.querySelector('.cxh-todo-close').addEventListener('click', () => toggleTodoWindow(false)); todoWindow.querySelector('.cxh-queue-toggle').addEventListener('click', toggleTodoQueue); todoWindow.querySelector('.cxh-todo-search').addEventListener('input', (event) => { state.todoSearch = event.target.value; renderTodoWindow(); }); todoWindow.querySelector('.cxh-todo-status-filter').addEventListener('change', (event) => { state.todoStatusFilter = event.target.value; renderTodoWindow(); }); todoWindow.querySelector('.cxh-todo-type-filter').addEventListener('change', (event) => { state.todoTypeFilter = event.target.value; renderTodoWindow(); }); todoWindow.querySelector('.cxh-export-todo-json').addEventListener('click', () => exportTodos('json')); todoWindow.querySelector('.cxh-export-todo-csv').addEventListener('click', () => exportTodos('csv')); logWindow.querySelector('.cxh-live-close').addEventListener('click', () => toggleLogWindow(false)); logWindow.querySelector('.cxh-log-follow').addEventListener('click', () => { state.logAutoScroll = !state.logAutoScroll; renderLogWindow(); }); logWindow.querySelector('.cxh-live-clear').addEventListener('click', () => { storageApi.set(STORE.logs, []); renderLogWindow(); updatePanel(); }); logWindow.querySelector('.cxh-live-export').addEventListener('click', exportLogs); const savedTodoLayout = state.uiState.todoWindow || {}; if (savedTodoLayout.width) todoWindow.style.width = `${savedTodoLayout.width}px`; if (savedTodoLayout.height) todoWindow.style.height = `${savedTodoLayout.height}px`; if (Number.isFinite(Number(savedTodoLayout.left)) && Number.isFinite(Number(savedTodoLayout.top))) { const maxLeft = Math.max(0, window.innerWidth - todoWindow.offsetWidth); const maxTop = Math.max(0, window.innerHeight - todoWindow.offsetHeight); todoWindow.style.left = `${Math.max(0, Math.min(maxLeft, Number(savedTodoLayout.left)))}px`; todoWindow.style.top = `${Math.max(0, Math.min(maxTop, Number(savedTodoLayout.top)))}px`; todoWindow.style.right = 'auto'; todoWindow.style.bottom = 'auto'; } todoWindow.scrollTop = Number(savedTodoLayout.scrollTop) || 0; const persistTodoLayout = () => { const rect = todoWindow.getBoundingClientRect(); state.uiState.todoWindow = { width: todoWindow.offsetWidth, height: todoWindow.offsetHeight, left: Math.round(rect.left), top: Math.round(rect.top), scrollTop: todoWindow.scrollTop }; saveUiState(); }; todoWindow.querySelector('.cxh-todo-header').addEventListener('mousedown', (event) => { if (event.button !== 0 || event.target.closest('button')) return; const rect = todoWindow.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; const move = (moveEvent) => { const left = Math.max(0, Math.min(window.innerWidth - todoWindow.offsetWidth, moveEvent.clientX - offsetX)); const top = Math.max(0, Math.min(window.innerHeight - todoWindow.offsetHeight, moveEvent.clientY - offsetY)); todoWindow.style.left = `${left}px`; todoWindow.style.top = `${top}px`; todoWindow.style.right = 'auto'; todoWindow.style.bottom = 'auto'; }; const stop = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', stop); persistTodoLayout(); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', stop, { once: true }); }); todoWindow.addEventListener('mouseup', persistTodoLayout); todoWindow.addEventListener('scroll', persistTodoLayout, { passive: true }); state.panel = panel; state.progressWindow = progressWindow; state.todoWindow = todoWindow; state.logWindow = logWindow; updatePanel(); } function updatePanel(summary = activeWorkerSummary()) { if (!state.panel) return; state.panel.querySelector('.cxh-toggle').textContent = state.enabled ? '暂停' : '启动'; state.panel.querySelector('.cxh-speed').value = state.speed; state.panel.querySelector('.cxh-auto-jump').checked = state.autoJump; state.panel.querySelector('.cxh-status').textContent = !state.tabPrimary ? '只读标签页:另一标签正在执行队列' : state.lastError ? `异常:${state.lastError}` : state.lastLog || (state.enabled ? '运行中' : '已暂停'); const course = courseProgress(); const todoPending = state.todos.filter((todo) => todo.status === 'pending').length; const todoRunning = state.todos.filter((todo) => todo.status === 'running').length; state.panel.querySelector('.cxh-progress').textContent = `待办 ${todoRunning}/${todoPending + todoRunning} · 任务 ${course.completed}/${course.total} · 媒体 ${summary.mediaCompleted}/${summary.mediaTotal}`; renderProgressWindow(summary); renderTodoWindow(); renderLogWindow(); } function onMessage(event) { const data = event.data; if (!data || data.type !== MESSAGE_TYPE) return; if (data.kind === 'reset-media-failures') { resetFailedMedia(); broadcastToChildren(data); return; } if (data.kind === 'controller-probe') { if (isController() && event.source && typeof event.source.postMessage === 'function') { event.source.postMessage({ type: MESSAGE_TYPE, kind: 'controller-present' }, '*'); } return; } if (data.kind === 'controller-present') { parentControllerSeen = true; if (!IS_TOP && isController()) deactivateController(); return; } if (data.kind === 'media-permit' && data.permit) { if (Number(data.permit.revision || 0) >= Number(state.mediaPermit.revision || 0)) { state.mediaPermit = data.permit; enforceMediaPermit(state.mediaPermit); } broadcastToChildren(data); return; } if (!isController()) { const hops = Number(data.hops || 0); if (hops < 8 && window.parent !== window) { window.parent.postMessage({ ...data, hops: hops + 1 }, '*'); } return; } if (data.kind === 'status' && data.status?.id) { if (Date.now() < state.ignoreWorkerUntil) return; state.workers.set(data.status.id, data.status); selectMediaPermit(); const summary = activeWorkerSummary(); selectorSelfCheck({ hasWork: summary.hasWork }); handleMediaFailure(summary); maybeAdvance(summary); updatePanel(summary); } else if (data.kind === 'log' && typeof data.message === 'string') { pushLog(data.message); } } function runCycle() { if (state.running) return; state.running = true; try { if (isController()) { if (!state.panel?.isConnected || !state.progressWindow?.isConnected || !state.todoWindow?.isConnected || !state.logWindow?.isConnected) createPanel(); if (!ensureTabLease()) { pauseLocalMedia(); updatePanel(activeWorkerSummary()); return; } syncTaskContext(); } const activeTodo = isController() ? processTodoQueue() : null; if (activeTodo && !todoMatchesLocation(activeTodo)) { pauseLocalMedia(); publishWorkerStatus(emptyWorkerStatus()); updatePanel(activeWorkerSummary()); return; } if (activeTodo) syncRunningTodoDeadline(); if (activeTodo && currentTodoMarkedComplete(activeTodo)) { completeRunningTodoAndAdvance('页面标记'); updatePanel(activeWorkerSummary()); return; } if (state.enabled) { const status = buildWorkerStatus(); publishWorkerStatus(status); } else { cancelTaskHandlers('disabled'); publishWorkerStatus(emptyWorkerStatus()); } if (isController()) { jumpFromCourseDetail(); selectMediaPermit(); const summary = activeWorkerSummary(); selectorSelfCheck({ hasWork: summary.hasWork }); handleMediaFailure(summary); maybeAdvance(summary); updatePanel(summary); } } catch (error) { reportError(error); } finally { state.running = false; } } function boot() { disableLegacyScannerState(); window.addEventListener('message', onMessage); if (IS_TOP) { activateController(); } else if (IS_DIRECT_CHILD) { probeParentController(); window.setTimeout(probeParentController, 250); window.setTimeout(() => { if (!parentControllerSeen) activateController(); }, 700); } bindStoreListeners(); window.addEventListener('pagehide', releaseTabLease, { once: true }); runCycle(); if (!state.timer) state.timer = window.setInterval(runCycle, CONFIG.interval); } function migrateStorage() { const schema = Number(storageApi.get(STORE.schema, 0)) || 0; if (schema < 3) { state.todos = state.todos.map((todo, index) => ({ ...todo, schemaVersion: 3, id: todo.id || `todo-${index}-${simpleHash(todo.url || todo.title)}`, status: ['pending', 'running', 'completed', 'failed', 'skipped'].includes(todo.status) ? todo.status : 'pending' })); saveTodos(); storageApi.set(STORE.schema, 3); } const scopes = loadObject(STORE.todoScopes); const term = currentTermKey(); let cleaned = false; Object.keys(scopes).forEach((key) => { if (!key.includes(`|${term}|`)) { delete scopes[key]; cleaned = true; } }); if (cleaned) saveObject(STORE.todoScopes, scopes); const estimated = [STORE.bank, STORE.unanswered, STORE.logs, STORE.todos, STORE.todoScopes, STORE.scanSession] .reduce((total, key) => total + JSON.stringify(storageApi.get(key, '')).length, 0); if (estimated > CONFIG.storageWarningBytes || PAGE_WINDOW.__XXT_STORAGE_WARNING__) { state.lastError = '本地存储接近容量限制,请先导出待办或诊断包'; } state.storageMigrated = true; } function showBootFailure(error) { console.error('[XXT] startup failed', error); if (!document.body || document.querySelector('#cx-helper-boot-error')) return; const notice = document.createElement('div'); notice.id = 'cx-helper-boot-error'; notice.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:320px;padding:12px;background:#7f1d1d;color:#fff;border:1px solid #fecaca;border-radius:6px;font:13px/1.5 Arial,sans-serif;word-break:break-word'; notice.textContent = `学习通课程助手启动异常:${error instanceof Error ? error.message : String(error)}`; document.body.appendChild(notice); } function safeBoot() { try { boot(); } catch (error) { showBootFailure(error); } } if (PAGE_WINDOW.__XXT_ENABLE_TEST_API__) { PAGE_WINDOW.__XXT_TEST_API__ = { discoverCurrentSemesterCourses, extractPendingTodos, isCurrentSemesterCourse, compareFramePaths, activateNextTodo, markRunningTodoComplete, processTodoQueue, todoMatchesLocation, scanCatalogEntries, buildScanContext, scanContextMismatch, activeCourseTemplate, templateSelectors, exportTodos, retryTodo, skipTodo, skipTodoById: (id) => { const todo = state.todos.find((item) => item.id === id); return todo ? skipTodo(todo) : false; }, todoScopeKey, ensureTabLease, storageGet: (key, fallback) => storageApi.get(key, fallback), storageSet: (key, value) => storageApi.set(key, value), importBankObject, exportBankPayload, activeBankProvider, exportDiagnostic, diagnosticPreview, summarizeCourseTodos, adapterHealth: () => ({ ...state.adapterHealth }), buildWorkerStatus, frameAttachmentDescriptor, taskHandlers: () => TASK_HANDLERS.map((handler) => ({ id: handler.id, apiVersion: handler.apiVersion, methods: ['detect', 'run', 'status', 'cancel'].filter((method) => typeof handler[method] === 'function') })), migrateStorage, classifyPageIssue, debugSnapshot: () => ({ permit: { ...state.mediaPermit }, summary: activeWorkerSummary(), queueNavigationAt: state.queueNavigationAt, currentTaskKey: state.currentTaskKey }) }; } if (document.body) safeBoot(); else window.addEventListener('DOMContentLoaded', safeBoot, { once: true }); })();