// GENERATED FILE - do not edit directly. // Source of truth: src/*.js. Rebuild with: node tools/build.mjs // Build inputs: 44 modules. // ==UserScript== // @name 学习通课程任务助手 // @namespace local.chaoxing.course.helper // @version 4.0.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 GM_xmlhttpRequest // @connect * // @grant unsafeWindow // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const SCRIPT_VERSION = '4.0.0'; const BANK_SCHEMA_VERSION = 1; const TODO_EXPORT_SCHEMA_VERSION = 2; const SETTINGS_SCHEMA_VERSION = 1; const EXECUTION_EXPORT_SCHEMA_VERSION = 1; const STORAGE_SCHEMA_VERSION = 4; const DATA_LIMITS = Object.freeze({ bankFileBytes: 5 * 1024 * 1024, settingsFileBytes: 256 * 1024, bankEntries: 10000, questionLength: 2000, answerLength: 20000, collapsedCourses: 200, backupEntries: 6 }); 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', volume: 'cx_helper_volume', 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', navigationMode: 'cx_helper_navigation_mode', executionJournal: 'cx_helper_execution_journal', importBackup: 'cx_helper_import_backup', // 4.0.0 新增:异步答案缓存、远程题库 Provider 与复核队列。 resolutionCache: 'cx_helper_resolution_cache', providers: 'cx_helper_providers', providerStats: 'cx_helper_provider_stats', reviewQueue: 'cx_helper_review_queue', crawlReport: 'cx_helper_crawl_report' }; 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, todoRenderBatch: 200, // 4.0.0:题库管理界面检索结果一次最多渲染多少条。 bankRenderBatch: 100, // 4.0.0:相似度落在 review 区间的题目进入复核队列,而不是静默填写。 questionReviewEnabled: PAGE_WINDOW.__XXT_TEST_REVIEW_DISABLED__ !== true, selectors: { questions: [ '.TiMu', '.questionLi', '.quesItem', '.ans-ques', '.judge-question', '.combination-question', '.ans-videoquiz', '.line-question', '.cloze-question', '.reading-question' ], optionGroups: [ '.Zy_ulTop > li', '.answerList > li', '.answerOption', '.option-item', '.ans-videoquiz-opt label', '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"]', '#videoquiz-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:'; // 回退信封可能因为清理失败而残留。若残留值比 GM 主后端更新,它会永久遮蔽后续 // 所有 GM 写入;因此记录「本次会话已成功写回主后端」的标记,命中时忽略残留信封。 const primaryBackendConfirmed = new Set(); const storageApi = { get(key, fallback) { if (!primaryBackendConfirmed.has(key)) { 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); // 主后端写入成功:立即标记,即使清理回退信封失败也不能再读旧值。 primaryBackendConfirmed.add(key); 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); } primaryBackendConfirmed.delete(key); 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)), volume: Math.max(0, Math.min(1, Number(storageApi.get(STORE.volume, 0)) || 0)), autoJump: toBoolean(storageApi.get(STORE.autoJump, true)), bank: loadObject(STORE.bank), bankIndex: new Map(), bankCollisions: [], unanswered: loadObject(STORE.unanswered), todos: loadScopedTodos(), scanSession: loadObject(STORE.scanSession), queuePaused: toBoolean(storageApi.get(STORE.queuePaused, false)), queueNavigationAt: 0, queueFinishedLogged: false, navigationMode: ['next', 'incomplete', 'manual'].includes(storageApi.get(STORE.navigationMode, 'next')) ? storageApi.get(STORE.navigationMode, 'next') : 'next', finalState: null, intervention: null, executionJournal: loadArray(STORE.executionJournal), executionRecovered: false, operationRevision: 0, operationTimers: new Set(), auxiliaryRuntime: new Map(), domObserver: null, observedBody: null, domRevision: 0, lastMutationAt: Date.now(), domCycleScheduled: false, media: new WeakMap(), knownMedia: new Set(), answeredQuestions: new WeakSet(), submittedQuestions: new WeakSet(), pageSubmitted: false, // 4.0.0:整页提交改用「题块指纹」而不是全局布尔,支持一页多个提交域。 pageSubmittedFingerprint: '', 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', todoRenderLimit: 200, todoRevision: 0, todoRenderSignature: '', todoRenderCount: 0, logFilter: 'all', // 4.0.0:题库管理界面与异步解析器状态。 bankSearch: '', bankRenderLimit: 100, bankManagerOpen: false, bankManagerTab: 'search', reviewQueue: loadArray(STORE.reviewQueue), resolutionCache: loadObject(STORE.resolutionCache), resolutionStats: emptyResolutionStats(), providers: [], providerStats: loadObject(STORE.providerStats), lastCrawlReport: loadObject(STORE.crawlReport), 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: '' }; state.navigatorLockSupported = Boolean(IS_TOP && navigator.locks?.request); state.navigatorLockHeld = !state.navigatorLockSupported; state.navigatorLockPending = false; state.navigatorLockAttemptAt = 0; state.navigatorLockRelease = null; state.navigatorLockContext = ''; state.navigatorLockRequestId = 0; state.coordinationChannel = null; function invalidateOperations() { state.operationRevision += 1; state.operationTimers.forEach((timer) => window.clearTimeout(timer)); state.operationTimers.clear(); state.domCycleScheduled = false; return state.operationRevision; } function scheduleOperation(callback, delay) { const revision = state.operationRevision; const timer = window.setTimeout(() => { state.operationTimers.delete(timer); if (revision !== state.operationRevision) return; callback(); }, delay); state.operationTimers.add(timer); return timer; } function ensureDomObserver() { if (!document.body || state.observedBody === document.body) return; state.domObserver?.disconnect(); state.observedBody = document.body; state.lastMutationAt = Date.now(); state.domObserver = new MutationObserver((mutations) => { const externalMutation = mutations.some((mutation) => { const target = mutation.target instanceof Element ? mutation.target : mutation.target.parentElement; return !target?.closest?.('#cx-helper-panel,#cx-helper-progress-window,#cx-helper-todo-window,#cx-helper-log-window,#cx-helper-style'); }); if (!externalMutation) return; state.domRevision += 1; state.lastMutationAt = Date.now(); if (state.domCycleScheduled) return; state.domCycleScheduled = true; scheduleOperation(() => { state.domCycleScheduled = false; runCycle(); }, 120); }); state.domObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data', 'data-attachment', 'src', 'class', 'style', 'data-status'] }); } function discoveryStatus() { const registry = pageAttachmentDescriptors().filter((item) => item.job); const descriptor = frameAttachmentDescriptor(); const identities = new Set(); if (descriptor.identity) identities.add(descriptor.identity); document.querySelectorAll('iframe[data],iframe[data-attachment]').forEach((frame) => { const raw = frame.getAttribute('data') || frame.getAttribute('data-attachment'); if (!raw) return; try { const data = JSON.parse(raw); const jobId = compactText(data.jobid || data._jobid); const match = registry.find((item) => item.jobId === jobId); if (match?.identity) identities.add(match.identity); else if (jobId) identities.add(`job:${jobId}`); } catch (_) { /* Ignore malformed iframe metadata. */ } }); return { revision: state.domRevision, stableForMs: Date.now() - state.lastMutationAt, expected: registry.length, identities: Array.from(identities) }; } function isController() { return controllerActive; } function coordinationContext() { return { account: detectAccountKey(), term: currentTermKey() }; } function announceLeaseChange() { if (!state.coordinationChannel) return; try { state.coordinationChannel.postMessage({ type: 'lease-change', ...coordinationContext() }); } catch (_) { /* Ignore channel teardown races. */ } } function navigatorLockName() { const context = coordinationContext(); return `cx-helper-controller:${context.account || 'anonymous'}:${context.term}`; } function ensureNavigatorLock() { // 账号或学期变化时先释放旧上下文锁,再由下一轮调度申请新锁。 if (!state.navigatorLockSupported) return true; const lockName = navigatorLockName(); if (state.navigatorLockHeld && state.navigatorLockContext === lockName) return true; if (state.navigatorLockHeld) { state.navigatorLockRequestId += 1; state.navigatorLockHeld = false; state.navigatorLockPending = false; state.navigatorLockContext = ''; state.navigatorLockRelease?.(); state.navigatorLockRelease = null; state.navigatorLockAttemptAt = Date.now(); return false; } const now = Date.now(); if (state.navigatorLockPending || now - state.navigatorLockAttemptAt < 750) return false; state.navigatorLockPending = true; state.navigatorLockAttemptAt = now; state.navigatorLockContext = lockName; const requestId = ++state.navigatorLockRequestId; navigator.locks.request(lockName, { mode: 'exclusive', ifAvailable: true }, async (lock) => { if (requestId !== state.navigatorLockRequestId) return; if (!lock) { state.navigatorLockPending = false; state.navigatorLockHeld = false; state.navigatorLockContext = ''; return; } state.navigatorLockHeld = true; state.tabPrimary = false; window.setTimeout(runCycle, 0); await new Promise((resolve) => { state.navigatorLockRelease = resolve; }); if (requestId !== state.navigatorLockRequestId) return; state.navigatorLockHeld = false; state.navigatorLockPending = false; state.navigatorLockContext = ''; state.navigatorLockRelease = null; }).catch(() => { if (requestId !== state.navigatorLockRequestId) return; state.navigatorLockSupported = false; state.navigatorLockHeld = true; state.navigatorLockPending = false; state.navigatorLockContext = ''; window.setTimeout(runCycle, 0); }); return false; } function demoteTabPrimary() { // 所有失去主控权的路径统一撤销旧操作和媒体许可。 const wasPrimary = state.tabPrimary; state.tabPrimary = false; state.leaseConfirmedAt = 0; if (!wasPrimary) return; invalidateOperations(); cancelTaskHandlers('lease-lost'); revokeMediaPermit(); pauseLocalMedia(); } function initializeTabCoordination() { if (!IS_TOP || state.coordinationChannel || typeof BroadcastChannel !== 'function') return; try { state.coordinationChannel = new BroadcastChannel('cx-helper-controller-v1'); state.coordinationChannel.addEventListener('message', (event) => { const data = event.data; const context = coordinationContext(); if (data?.type !== 'lease-change' || data.account !== context.account || data.term !== context.term) return; handleTabLeaseChange(); window.setTimeout(runCycle, 0); }); } catch (_) { state.coordinationChannel = null; } } function ensureTabLease() { if (!IS_TOP) return true; if (!ensureNavigatorLock()) { demoteTabPrimary(); return false; } 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); announceLeaseChange(); 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); announceLeaseChange(); 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; } demoteTabPrimary(); 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; demoteTabPrimary(); updatePanel(activeWorkerSummary()); } function releaseTabLease() { invalidateOperations(); cancelTaskHandlers('pagehide'); state.domObserver?.disconnect(); state.domObserver = null; state.observedBody = null; 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 }); announceLeaseChange(); } state.navigatorLockRelease?.(); state.coordinationChannel?.close(); state.coordinationChannel = null; } 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(); recoverExecutionState(); if (document.body) createPanel(); if (!controllerMenuRegistered) { storageApi.registerMenu('启动/暂停助手', toggleEnabled); // 4.0.0:把「待办生产」和「题库管理」放进菜单。旧版菜单只有三条, // 且没有任何能生成待办的入口——这是 P0 缺陷的一半原因。 storageApi.registerMenu('从当前目录生成待办', () => { const result = generateTodosFromCatalog(); alert(result.ok ? `扫描 ${result.scanned} 项:新增 ${result.added} 条,刷新 ${result.refreshed} 条,共 ${result.total} 条` : `生成失败:${result.error}`); updatePanel(); }); storageApi.registerMenu('打开题库管理', () => toggleBankManager(true)); storageApi.registerMenu('导入本地题库', importBank); storageApi.registerMenu('导出未匹配题目', exportUnanswered); controllerMenuRegistered = true; } announceController(); } function deactivateController() { if (IS_TOP) return; controllerActive = false; invalidateOperations(); cancelTaskHandlers('controller-deactivated'); state.panel?.remove(); state.progressWindow?.remove(); state.todoWindow?.remove(); state.bankWindow?.remove(); state.logWindow?.remove(); state.panel = null; state.progressWindow = null; state.todoWindow = null; state.bankWindow = null; state.logWindow = null; state.progressOpen = false; state.todoOpen = false; state.bankManagerOpen = 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' }, '*'); } // 注:题库索引与旧数据迁移原本在此处立即执行。4.0.0 把它们移入 boot(), // 因为新的匹配引擎依赖 16-text-match.js 的顶层常量(MATCH_THRESHOLDS 等), // 而该模块在本文件之后求值;在此处调用会命中暂时性死区。 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); Object.keys(backups) .sort((left, right) => Number(left.split(':').at(-1)) - Number(right.split(':').at(-1))) .slice(0, Math.max(0, Object.keys(backups).length - DATA_LIMITS.backupEntries)) .forEach((oldKey) => delete backups[oldKey]); 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*(?:第\s*)?\d+\s*(?:题)?[.、::]?\s*/, '') .replace(/^\s*(?:单选题|多选题|判断题|填空题|简答题|名词解释|论述题|计算题|连线题|完形填空|阅读理解)\s*[::]?\s*/i, '') .replace(/\s*[((]?\s*\d+(?:\.\d+)?\s*分\s*[))]?\s*$/i, '') .trim() .toLowerCase(); } function rebuildBankIndex() { const index = buildBankIndex(state.bank); state.bankIndex = index; state.bankCollisions = index.collisions; return index; } 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}`; storageApi.set(STORE.logs, [line, ...storedLogs()].slice(0, 100)); updatePanel(); } function storedLogs() { const logs = storageApi.get(STORE.logs, []); return Array.isArray(logs) ? logs : []; } function reportError(error) { const message = error instanceof Error ? error.message : String(error); state.lastError = message; pushLog(`运行异常:${message}`, true); } function recordExecutionEvent(type, details = {}) { const todo = currentRunningTodo(); const entry = { at: Date.now(), type, todoId: todo?.id || details.todoId || '', title: todo?.title || details.title || '', stage: todo?.stage || details.stage || '', attachmentId: todo?.attachmentId || details.attachmentId || '', reason: compactText(details.reason || '').slice(0, 240) }; const previous = state.executionJournal[0]; if (previous && previous.type === entry.type && previous.todoId === entry.todoId && previous.stage === entry.stage && previous.reason === entry.reason && entry.at - previous.at < 2000) return; state.executionJournal = [entry, ...state.executionJournal].slice(0, 120); storageApi.set(STORE.executionJournal, state.executionJournal); } function recoverExecutionState() { if (state.executionRecovered) return; state.executionRecovered = true; const todo = currentRunningTodo(); if (!todo) return; if (!todo.stage) todo.stage = 'running'; if (todo.stage === 'waiting-intervention') { state.queuePaused = true; state.enabled = false; storageApi.set(STORE.queuePaused, true); storageApi.set(STORE.enabled, false); state.lastError = todo.error || '任务等待人工处理'; } else if (todo.stage === 'navigating' && todoMatchesLocation(todo)) { todo.stage = 'running'; } saveTodos(); recordExecutionEvent('recovered', { reason: `恢复阶段 ${todo.stage}` }); } 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.volume, (_name, _old, value) => { state.volume = Math.max(0, Math.min(1, Number(value) || 0)); document.querySelectorAll('video,audio').forEach((media) => { media.volume = state.volume; media.muted = state.volume === 0; }); updatePanel(); }); storageApi.listen(STORE.autoJump, (_name, _old, value) => { state.autoJump = toBoolean(value); updatePanel(); }); storageApi.listen(STORE.navigationMode, (_name, _old, value) => { if (['next', 'incomplete', 'manual'].includes(value)) state.navigationMode = value; updatePanel(); }); storageApi.listen(STORE.bank, () => { state.bank = loadObject(STORE.bank); rebuildBankIndex(); }); // 另一个标签页改了题库配置时同步过来,避免两个标签用不同的题库作答。 storageApi.listen(STORE.providers, () => { initializeProviders(); if (state.bankManagerOpen) renderBankManager(); }); storageApi.listen(STORE.unanswered, () => { state.unanswered = loadObject(STORE.unanswered); }); storageApi.listen(STORE.todos, () => { state.todos = loadScopedTodos(); state.todoRevision += 1; updatePanel(); }); storageApi.listen(STORE.todoScopes, () => { state.todos = loadScopedTodos(); state.todoRevision += 1; 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); } /* 匹配裁决阈值。accept 以上直接作答;accept 与 review 之间写入复核队列,由用户 * 在题库管理界面确认;review 以下视为未匹配。分开这两条线的意义是:宁可少填一 * 道题,也不要把一个「看起来像」的答案静默提交上去。 */ const MATCH_THRESHOLDS = Object.freeze({ accept: 0.86, review: 0.6, /** 包含匹配生效所需的最小长度比,防止短键吞掉长题干。 */ containmentFloor: 0.72, /** 参与模糊匹配的最短归一化长度,太短的题干直接走精确通道。 */ fuzzyMinLength: 6, /** 每次模糊匹配最多考察的候选数量,避免题库变大后单次匹配退化。 */ candidateLimit: 400 }); /** 题型关键词表。用于从题干前缀剥离题型标注,以及推断题型。 */ const QUESTION_TYPE_TOKENS = Object.freeze([ '单选题', '多选题', '判断题', '填空题', '简答题', '名词解释', '论述题', '计算题', '连线题', '连线', '完形填空', '阅读理解', '不定项选择题', '多项选择题', '单项选择', '多选', '单选', '判断', '填空', '问答' ]); /* ------------------------------------------------------------------ * * 1. 字符级归一化 * ------------------------------------------------------------------ */ /** 全角 ASCII(!到~)转半角,并统一全角空格。 */ function toHalfWidth(value) { return String(value ?? '').replace(/[\uff01-\uff5e]/g, (character) => String.fromCharCode(character.charCodeAt(0) - 0xfee0) ).replace(/\u3000/g, ' '); } /** 把各种引号、括号、连字符收敛成同一套半角符号,避免同一道题因排版差异分家。 */ function unifyPunctuation(value) { return String(value ?? '') .replace(/[\u201c\u201d\u2033\u301d\u301e]/g, '"') .replace(/[\u2018\u2019\u2032]/g, "'") .replace(/[\uff08\u3010\u3014\u3016\u3008\u300a\u300c\u300e]/g, '(') .replace(/[\uff09\u3011\u3015\u3017\u3009\u300b\u300d\u300f]/g, ')') .replace(/[\u2014\u2013\u2212\uff0d]/g, '-') .replace(/[\uff0c\u3001]/g, ',') .replace(/[\uff1a]/g, ':') .replace(/[\uff1b]/g, ';') .replace(/[\uff1f]/g, '?') .replace(/[\uff01]/g, '!') .replace(/[\uff0e\u3002]/g, '.'); } /** 去掉「第 3 题」「3.」这类序号前缀。只在句首生效,不会误伤正文里的数字。 */ function stripQuestionNumberPrefix(value) { return String(value ?? '') .replace(/^\s*(?:第\s*)?\d{1,4}\s*(?:题|小题)?\s*[.、,::))]?\s*/, '') .replace(/^\s*[([【]\s*\d{1,4}\s*[)\]】]\s*/, ''); } /** 去掉「单选题」「多选题:」这类题型标注前缀。 */ function stripQuestionTypePrefix(value) { const pattern = new RegExp(`^\\s*(?:${QUESTION_TYPE_TOKENS.join('|')})\\s*[::.、]?\\s*`); return String(value ?? '').replace(pattern, ''); } /** 去掉结尾的「(2分)」「[5分]」「(1.5 分)」。 */ function stripScoreSuffix(value) { return String(value ?? '').replace(/\s*[([【]\s*\d+(?:\.\d+)?\s*分\s*[)\]】]\s*$/i, ''); } /** * 切掉题干后面附着的选项列表。 * * 页面上的题干抽出来往往带着 `A.甲 B.乙 C.丙 D.丁`,而题库里的键通常只有题干。 * 直接比较会让两者永远对不上;把选项也一起归一化又会让「选项顺序不同」的同一道 * 题被判定为不同。这里选择在检测到**两个以上**选项标记时截断,出现一个标记时 * 不动手(`A.` 可能只是正文的一部分)。 */ function cutOptionSection(value) { const text = String(value ?? ''); const marker = /(?:^|[\s,;])([A-Ha-h])\s*[.、,)]\s*\S/g; const positions = []; let match = marker.exec(text); while (match) { positions.push(match.index); match = marker.exec(text); } if (positions.length < 2) return text; const offset = text.slice(positions[0]).indexOf(match?.[0] ?? '') >= 0 ? positions[0] : positions[0]; const head = text.slice(0, offset).trim(); // 截断后剩余内容过短说明这些标记只是正文里的编号,保守放弃截断。 return head.length >= 4 ? head : text; } /** 收敛空白与大小写。中文没有大小写,这里主要服务英文题干。 */ function collapseSpace(value) { return String(value ?? '').replace(/[\u00a0\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); } /** * 剔除不可见字符。 * * 来源:《学习通题库数据源调研报告》§3.3 第 1 条 —— CxKitty 已明确在做这一步, * 报告称这些字符「肉眼看不出,却是搜索失败的头号原因」。实测中最常见的是 * U+2002(EN SPACE)、U+200B(零宽空格)、U+FEFF(BOM/零宽不换行空格)、U+200E/200F * (方向标记)。它们在题库文本里往往来自网页抓取,两边只要有一侧带、另一侧不带, * 精确匹配就会整条落空,而人工核对时完全看不出差别。 */ function stripInvisible(value) { return String(value ?? '') // 零宽与方向控制字符:直接删除。 .replace(/[\u200b\u200c\u200d\u200e\u200f\u2060\ufeff]/g, '') // 各种 Unicode 空格(含 EN/EM SPACE、窄空格、表意空格):收敛成普通空格。 .replace(/[\u2000-\u200a\u202f\u205f\u3000]/g, ' '); } /* ------------------------------------------------------------------ * * 2. 对外归一化入口 * ------------------------------------------------------------------ */ /** * 严格归一化:用于建立精确索引与精确查表。 * * 与旧的 `normalize()` 的关键区别在于**保留结构性标点**。 * * 旧实现把 `[“”"'「」『』()()【】\[\]]` 全部删掉再折叠空白,于是 * `下列()说法正确` 与 `下列 说法正确` 归一化成同一个键,题库越大越容易静默串题 * (REVIEW_CRITIQUE S6)。我第一版重构其实犯过同一个错误——把括号统一成半角后 * 又在下一次 `replace` 里连同其它标点一起删掉了,测试当场抓了出来。 * * 现在的口径是:**精确键保守、模糊通道召回**。 * - 精确键只做「排版归一」(全角转半角、引号括号统一)并保留 `()` 与引号, * 所以空括号 `()` 是题干的一部分,不会被抹平;假阳性极少。 * - 由标点差异造成的漏召回,交给相似度通道补回来(该通道会剥掉全部标点, * Dice 系数通常仍在 0.9 以上,落在 accept 区间)。 * * 这个分工同时满足两个互相拉扯的目标:精确通道零误配,整体召回不下降。 */ function normalizeQuestion(value) { let text = stripInvisible(value); text = toHalfWidth(text); text = unifyPunctuation(text); text = collapseSpace(text); text = stripQuestionNumberPrefix(text); text = stripQuestionTypePrefix(text); text = stripScoreSuffix(text); text = cutOptionSection(text); // 只保留:中文、拉丁字母数字、以及结构性标点 ( ) " ' 。 // 其余标点(逗号、句号、问号、破折号等)删除——它们是纯排版噪声。 return text .replace(/[^\u4e00-\u9fa5a-zA-Z0-9()"'\s]/g, '') .replace(/\s+/g, '') .toLowerCase(); } /** * 停用词剔除用的宽松归一化:在严格键基础上再删掉全部标点与空白。 * 仅用于**候选召回**与爬虫粗判重,不用于最终裁决。 */ function normalizeLoose(value) { return normalizeQuestion(value).replace(/[^\u4e00-\u9fa5a-z0-9]+/g, ''); } /** * 生成检索用的字符 n-gram 集合。 * 中文按 2-gram 切分(中文最小语义单位是双字词),ASCII 按词切分。 * 两种语言混排时同时产出,交给后续的 Dice / Jaccard 融合。 */ function textGramSet(value) { const text = normalizeLoose(value); const grams = new Set(); if (!text) return grams; const cjk = text.replace(/[^\u4e00-\u9fa5]/g, ''); for (let index = 0; index + 1 < cjk.length; index += 1) grams.add(cjk.slice(index, index + 2)); if (cjk.length === 1) grams.add(cjk); // 英文与数字按词切分;同时保留整段 ASCII 片段作为一个特征,避免短词被略过。 const words = text.replace(/[\u4e00-\u9fa5]/g, ' ').split(/\s+/).filter(Boolean); words.forEach((word) => grams.add(word)); if (words.length > 1) grams.add(words.join('')); return grams; } /* ------------------------------------------------------------------ * * 3. 相似度 * ------------------------------------------------------------------ */ /** Sørensen–Dice 系数,作用于 n-gram 集合。 */ function diceCoefficient(left, right) { if (!left.size || !right.size) return 0; let shared = 0; const [small, large] = left.size <= right.size ? [left, right] : [right, left]; small.forEach((gram) => { if (large.has(gram)) shared += 1; }); return (2 * shared) / (left.size + right.size); } /** Jaccard 系数,对短文本比 Dice 更严格。 */ function jaccardCoefficient(left, right) { if (!left.size || !right.size) return 0; let shared = 0; const [small, large] = left.size <= right.size ? [left, right] : [right, left]; small.forEach((gram) => { if (large.has(gram)) shared += 1; }); return shared / (left.size + right.size - shared); } /** * 带预算上限的编辑距离。超过 `budget` 就提前返回 budget+1,避免长题干把匹配 * 时间拖成 O(n*m) 的平方级开销。 */ function boundedLevenshtein(left, right, budget) { const a = String(left); const b = String(right); if (Math.abs(a.length - b.length) > budget) return budget + 1; let previous = Array.from({ length: b.length + 1 }, (_, index) => index); for (let i = 1; i <= a.length; i += 1) { const current = [i]; let rowMin = i; for (let j = 1; j <= b.length; j += 1) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; const value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost); current.push(value); if (value < rowMin) rowMin = value; } if (rowMin > budget) return budget + 1; previous = current; } return previous[b.length]; } /** * 综合相似度:0 到 1。 * * 返回 `{ score, strategy }`,strategy 记录是哪个通道给出这个分数,便于诊断界面 * 解释「为什么这道题被判成匹配」或者「为什么没匹配上」。 */ function similarityScore(left, right) { const a = normalizeQuestion(left); const b = normalizeQuestion(right); if (!a || !b) return { score: 0, strategy: 'empty' }; if (a === b) return { score: 1, strategy: 'exact' }; const gramsA = textGramSet(left); const gramsB = textGramSet(right); const dice = diceCoefficient(gramsA, gramsB); // 包含通道只在两条文本长度可比时生效。旧实现直接 `includes` 就算命中, // 一道 8 字短题能把整页长题干吞掉;这里用长度比做闸门。 const shorter = a.length <= b.length ? a : b; const longer = a.length <= b.length ? b : a; const containment = longer.includes(shorter) ? shorter.length / longer.length : 0; const containmentScore = containment >= MATCH_THRESHOLDS.containmentFloor ? MATCH_THRESHOLDS.containmentFloor + (containment - MATCH_THRESHOLDS.containmentFloor) * ((1 - MATCH_THRESHOLDS.containmentFloor) / (1 - MATCH_THRESHOLDS.containmentFloor)) * 0.999 : 0; const jaccard = jaccardCoefficient(gramsA, gramsB); // 编辑距离只对较短文本启用;长题干走 n-gram 已经足够,且更便宜。 const budget = Math.max(2, Math.floor(longer.length * 0.25)); const editScore = longer.length <= 60 ? Math.max(0, 1 - boundedLevenshtein(a, b, budget) / Math.max(a.length, b.length)) : 0; const candidates = [ { score: containmentScore, strategy: 'containment' }, { score: dice * 0.97, strategy: 'dice' }, { score: jaccard * 0.95, strategy: 'jaccard' }, { score: editScore * 0.93, strategy: 'edit' } ].sort((x, y) => y.score - x.score); return { score: Math.min(1, candidates[0].score), strategy: candidates[0].strategy }; } /* ------------------------------------------------------------------ * * 4. 索引与检索 * ------------------------------------------------------------------ */ /** * 建立题库索引。 * * keyIndex Map(严格归一化键 -> 记录) —— 精确通道 * gramIndex Map(2-gram -> Set(严格归一化键)) —— 模糊通道的候选倒排表 * * 倒排表把模糊匹配从「扫描全部 10000 条」降成「只看共享字串的几十条」。旧实现的 * `for (const [candidate, answer] of bankIndex.entries())` 在每次匹配时全表遍历, * 是题干数量上去以后的主要卡顿来源之一。 */ function buildBankIndex(records) { const keyIndex = new Map(); const gramIndex = new Map(); const collisions = []; Object.entries(records || {}).forEach(([question, answer]) => { if (String(question).startsWith('_unanswered:')) return; const key = normalizeQuestion(question); if (!key) return; if (keyIndex.has(key)) { const previous = keyIndex.get(key); // 归一化碰撞是旧版静默丢数据的根因。这里记录下来交给导入预览告警, // 而不是用后写覆盖先写。 collisions.push({ key, kept: previous.question, dropped: question }); return; } keyIndex.set(key, { question, answer, key }); textGramSet(question).forEach((gram) => { if (!gramIndex.has(gram)) gramIndex.set(gram, new Set()); gramIndex.get(gram).add(key); }); }); return { keyIndex, gramIndex, collisions }; } /** 用倒排表召回候选键,按共享 gram 数量降序截断。 */ function candidateKeys(question, index, limit = MATCH_THRESHOLDS.candidateLimit) { if (!index?.gramIndex?.size) return []; const grams = Array.from(textGramSet(question)); const votes = new Map(); grams.forEach((gram) => { const bucket = index.gramIndex.get(gram); if (!bucket) return; bucket.forEach((key) => votes.set(key, (votes.get(key) || 0) + 1)); }); return Array.from(votes.entries()) .sort((left, right) => right[1] - left[1]) .slice(0, limit) .map(([key]) => key); } /** * 在索引里为一道题找最佳答案。 * * 返回 `{ answer, score, strategy, question, verdict }`,verdict 取 * `accept` / `review` / `reject`。调用方只需判断 verdict,不必自己比阈值。 * * `options` 可携带页面侧已知信息(optionCount / singleChoiceOnly),用于横向校验。 */ function matchInIndex(question, index, options = {}) { if (!question || !index?.keyIndex?.size) return null; const exactKey = normalizeQuestion(question); if (!exactKey) return null; const exact = index.keyIndex.get(exactKey); if (exact) { const check = crossCheckMatch(question, exact, options); // 精确命中也要过横向校验:报告 §3.3 反例 2 说明「同题干可能对应多个不同答案」, // 题库里的那一条未必就是当前这道题的答案。 if (!check.ok) { return { answer: exact.answer, score: 1, strategy: 'exact', question: exact.question, verdict: 'review', reason: check.reason }; } return { answer: exact.answer, score: 1, strategy: 'exact', question: exact.question, verdict: 'accept' }; } if (exactKey.length < MATCH_THRESHOLDS.fuzzyMinLength) return null; let best = null; candidateKeys(question, index).forEach((key) => { const record = index.keyIndex.get(key); if (!record) return; const { score, strategy } = similarityScore(question, record.question); if (!best || score > best.score) { best = { answer: record.answer, score, strategy, question: record.question, record }; } }); if (!best) return null; let verdict = best.score >= MATCH_THRESHOLDS.accept ? 'accept' : best.score >= MATCH_THRESHOLDS.review ? 'review' : 'reject'; let reason = ''; // 横向校验只降级、不升级:模糊通道的候选本来就更可疑。 if (verdict === 'accept') { const check = crossCheckMatch(question, best.record, options); if (!check.ok) { verdict = 'review'; reason = check.reason; } } return { answer: best.answer, score: best.score, strategy: best.strategy, question: best.question, verdict, reason }; } /* ------------------------------------------------------------------ * * 5. 填空题作答计划(S1 修复) * ------------------------------------------------------------------ */ /** * 决定「几个空、几个答案」怎么对应。 * * 返回 `{ fills, verdict, reason }`:fills 是逐空的值数组;verdict 为 * `accept` 时才可以提交,其余情况交给调用方记为待复核。 * * 旧行为:`const value = answers[index] ?? answers[0]`,3 空 1 答案时把同一个值 * 填进三个空,并且 `matchedCount = min(3, 1) = 1 >= answers.length` 判定为完全 * 匹配,于是静默提交一个必然错误的答案。这里把该组合显式判为 `review`。 */ function planFillInBlank(controlCount, rawAnswer) { const values = (Array.isArray(rawAnswer) ? rawAnswer : [rawAnswer]) .flatMap((item) => String(item ?? '').split('|')) .map((item) => item.trim()) .filter(Boolean); const count = Number(controlCount) || 0; if (count === 0) return { fills: [], verdict: 'reject', reason: '页面没有可填写的控件' }; if (!values.length) return { fills: [], verdict: 'reject', reason: '答案为空' }; if (values.length === count) { return { fills: values, verdict: 'accept', reason: '答案与空位一一对应' }; } if (count === 1) { // 多个答案但只有一个空:题型与答案不匹配,填第一个也只是猜。 return { fills: [values[0]], verdict: values.length === 1 ? 'accept' : 'review', reason: values.length === 1 ? '单空单答案' : `单空收到 ${values.length} 个答案` }; } if (values.length === 1) { return { fills: [values[0]], verdict: 'review', reason: `${count} 个空位只匹配到 1 个答案,已停止自动填写` }; } // 答案多于空位:按顺序填前 count 个,但标记复核。 return { fills: values.slice(0, count), verdict: 'review', reason: `空位 ${count} 个但匹配到 ${values.length} 个答案` }; } /* ------------------------------------------------------------------ * * 6. 横向校验(防误判) * * 来源:《学习通题库数据源调研报告》§3.4 末尾列出的 5 条「必须叠加的横向校验」。 * 报告从实测数据中挖出四类包含匹配反例(共享前缀题干答案不同、同题干多答案、 * 选项文本污染题干、括号全半角不一致),并给出结论: * 「包含匹配只应作为候选召回手段,绝不能单独作为判分依据」。 * * 本模块的结构已经体现了这一点(包含匹配的分数被压到阈值附近、只参与召回), * 下面补上报告要求的其余几项显式校验。 * ------------------------------------------------------------------ */ /** 判断题的合法答案集合。报告 §3.2:需接受 对|错|正确|错误|√|× 全集。 */ const JUDGE_ANSWER_TOKENS = Object.freeze([ '对', '错', '正确', '错误', '是', '否', '√', '×', '✓', '✗', 'T', 'F', 'TRUE', 'FALSE', 'Y', 'N' ]); /** 判断题答案是否合法。非法则整条命中作废(报告校验第 5 条)。 */ function isLegalJudgeAnswer(answer) { const values = (Array.isArray(answer) ? answer : [answer]) .map((item) => String(item ?? '').trim()) .filter(Boolean); if (!values.length) return false; return values.every((value) => JUDGE_ANSWER_TOKENS.includes(value.toUpperCase()) || JUDGE_ANSWER_TOKENS.includes(value)); } /** * 从题干形态推断题型。 * * 与 30-catalog.js 的 `inferTaskType`(按目录标题猜「视频/音频/题目」)不同, * 这里猜的是**题目的作答形态**,用于横向校验: * blank —— 题干含空括号占位(`( )`、`____`) * judge —— 题干含「判断」「对错」等词 * choice —— 其余(单选/多选由答案个数区分) * 报告 §3.2 指出学习通自测导出**没有显式题型字段**,只能这样推断。 */ function inferQuestionShape(question) { const text = collapseSpace(toHalfWidth(stripInvisible(question))); if (/判断|对错|是否正确|对还是错|是否|对不对/.test(text)) return 'judge'; // 以「吗/么」收尾的疑问句在题库里几乎都是判断题。 // 但选择题的题干也可能这样收尾,所以先排除带选项标记的文本, // 避免把四选一误判成判断题(那只会让它无谓地进复核队列)。 const looksLikeChoice = /(?:^|[\s,;])[A-Da-d]\s*[.、,)]\s*\S/.test(text); if (!looksLikeChoice && /[吗么][??]?$/.test(text)) return 'judge'; if (/_{2,}|[((]\s*[))]/.test(text)) return 'blank'; return 'choice'; } /** * 对一次候选命中做横向校验。 * * 返回 `{ ok, reason }`。任一项不通过就降级为 reject —— 宁可少填一道题, * 也不要把 A 题的答案填到 B 题上。 */ function crossCheckMatch(question, record, options = {}) { if (!record) return { ok: false, reason: '无候选' }; const shape = inferQuestionShape(question); // 校验 5:判断题答案形态必须合法。 if (shape === 'judge' && !isLegalJudgeAnswer(record.answer)) { return { ok: false, reason: '题干是判断题但候选答案不是对错值' }; } // 校验 2:选项数一致。只在页面侧选项数已知时生效。 const knownOptions = Number(options.optionCount || 0); if (knownOptions > 0 && Array.isArray(record.options) && record.options.length > 0 && record.options.length !== knownOptions) { return { ok: false, reason: `选项数不一致(页面 ${knownOptions} / 库内 ${record.options.length})` }; } // 校验 1(部分):单选答案不得匹配到多选题目。 // 题干是选择题且库内答案含多个选项字母,但页面形态明显是单空选择时降级。 if (shape === 'choice' && options.singleChoiceOnly) { const { letters } = parseChoiceAnswer(record.answer); if (letters.length > 1) return { ok: false, reason: '页面为单选但候选题库答案为多选' }; } // 校验 4:数字保留校验。数字常是答案本体(报告 §3.3 第 9 条:不应删除数字)。 // 题干里的数字集合必须与候选一致,否则「20—80毫克」与「80毫克」这类题会互相命中。 const questionNumbers = extractNumbers(question); if (questionNumbers.length) { const candidateNumbers = extractNumbers(record.question); const missing = questionNumbers.filter((value) => !candidateNumbers.includes(value)); if (missing.length) return { ok: false, reason: `数字不一致(缺少 ${missing.join('、')})` }; } return { ok: true, reason: '' }; } /** * 抽取题干里的「正文数字」,用于数字保留校验。 * * 关键:必须先去掉题号与分值再抽取。 * `第3题 单选题:太阳从哪边升起?(2分)` 里的 3 和 2 是卷面元数据,不是题干内容; * 如果把它们算进数字集合,任何带题号或分值的页面题干都会因为「候选缺 3、2」被误判 * 为数字不一致而白白降级到复核(本项目实测踩到过这个坑)。 * 真正需要保住的是「20—80毫克」「100摄氏度」这类**正文里的数字**——它们常常就是 * 答案本体,也正是调研报告 §3.3 反例 1(酒精含量题)互相串答案的原因。 */ function extractNumbers(value) { let text = collapseSpace(toHalfWidth(stripInvisible(value))); text = stripQuestionNumberPrefix(text); text = stripScoreSuffix(text); text = stripQuestionTypePrefix(text); return (text.match(/\d+(?:\.\d+)?%?/g) || []); } /* ------------------------------------------------------------------ * * 7. 选项匹配 * ------------------------------------------------------------------ */ /** * 把题库里的答案解析成「选项序号集合」。 * * 支持三种常见写法: * "A" —— 字母 * "AC" —— 连写字母 * "A,C" / "A C" / "A、C" —— 分隔字母 * ["A","C"] —— 数组 * 以及直接写选项全文(返回 `texts` 交给调用方做子串匹配)。 */ function parseChoiceAnswer(rawAnswer) { const items = (Array.isArray(rawAnswer) ? rawAnswer : [rawAnswer]).map((item) => String(item ?? '').trim()).filter(Boolean); const letters = []; const texts = []; items.forEach((item) => { const compact = item.replace(/[\s,,、;;/]+/g, '').toUpperCase(); if (/^[A-H]{1,8}$/.test(compact)) { compact.split('').forEach((letter) => { if (!letters.includes(letter)) letters.push(letter); }); return; } const loose = item.match(/[A-H]/g); // 只有整项都是「字母+分隔符」时才当字母答案,避免把正文里的英文字母当选项。 if (loose && item.replace(/[A-Ha-h\s,,、;;/.]/g, '') === '') { loose.forEach((letter) => { if (!letters.includes(letter.toUpperCase())) letters.push(letter.toUpperCase()); }); return; } texts.push(item); }); return { letters, texts }; } /* ------------------------------------------------------------------ * * 7. 诊断 * ------------------------------------------------------------------ */ /** 生成一段人类可读的匹配解释,供题库管理界面与诊断包使用。 */ function explainMatch(question, match) { if (!match) return `无候选:${String(question).slice(0, 40)}`; return [ `判定 ${match.verdict}`, `策略 ${match.strategy}`, `相似度 ${(match.score * 100).toFixed(1)}%`, `库内题干「${String(match.question).slice(0, 40)}」` ].join(' · '); } const RESOLUTION_STATES = Object.freeze([ 'discovered', 'requested', 'resolved', 'filled', 'submitted', 'confirmed', 'error' ]); /** 解析缓存容量上限。超出后按「最近最少使用」淘汰,避免题库变大后拖慢存储写入。 */ const RESOLUTION_CACHE_LIMIT = 4000; /** 同一个题干并发请求的上限,防止一个页面几十道题同时打出去被对端限速。 */ const RESOLVER_CONCURRENCY = 3; /** * 正在进行中的答案请求,键为题干归一化键。 * * 放在模块级而不是 state 里:它是纯粹的「飞行中」瞬时状态,不应该被持久化, * 也不应该在多标签之间同步。标签关闭时随页面一起消失正是想要的行为。 */ const resolutionInflight = new Set(); function emptyResolutionStats() { return { discovered: 0, requested: 0, resolved: 0, filled: 0, submitted: 0, confirmed: 0, error: 0, review: 0 }; } /** 题干 -> 缓存键。使用严格归一化,与题库索引同一口径。 */ function resolutionKey(question) { return normalizeQuestion(question); } /** * 查询解析缓存与本地题库。 * * 返回 `{ state, answer, score, strategy, source, verdict, reason }`: * state='resolved' 且 verdict='accept' 可以填写 * state='resolved' 且 verdict='review' 转人工复核,不填写 * state='pending' 等待异步结果,本轮跳过 * state='error' 所有 Provider 都失败 */ function resolveQuestion(question) { const key = resolutionKey(question); if (!key) return { state: 'error', reason: '题干为空' }; // 1. 内存索引(同步、最快、覆盖本地题库) const localMatch = matchInIndex(question, state.bankIndex); if (localMatch && localMatch.verdict === 'accept') { return { state: 'resolved', answer: localMatch.answer, score: localMatch.score, strategy: localMatch.strategy, source: 'local', verdict: 'accept' }; } // 2. 异步缓存(远程或人工确认过的结果) const cached = state.resolutionCache[key]; if (cached) { if (cached.verdict === 'reject') { // 明确判负的结果同样缓存,避免每次周期重复请求同一个失败题干。 return { state: 'resolved', verdict: 'reject', source: cached.source || 'cache', score: cached.score || 0, reason: '缓存判定不匹配' }; } return { state: 'resolved', answer: cached.answer, score: cached.score || 1, strategy: cached.strategy || 'cache', source: cached.source || 'cache', verdict: cached.verdict || 'accept' }; } // 3. 本地库有候选但相似度不够:交给人工复核,不再请求远程(远程大概率更差) if (localMatch && localMatch.verdict === 'review') { return { state: 'resolved', answer: localMatch.answer, score: localMatch.score, strategy: localMatch.strategy, source: 'local', verdict: 'review', reason: `相似度 ${(localMatch.score * 100).toFixed(1)}% 低于自动填写阈值` }; } // 4. 需要异步请求。 // // 关键分支:如果用户没有配置任何启用的远程题库(这是默认状态),本地查表就是 // 最终结论,必须**立即**判定为未命中。否则每道题都要空等一个调度周期去问一个 // 不存在的服务,把「默认零网络」变成一个纯粹的性能损失,并且会让未匹配题目 // 延迟一轮才被记录。 if (!state.providers.some((provider) => provider.enabled)) { return { state: 'resolved', verdict: 'reject', source: 'local', score: 0, reason: '本地题库未匹配(未配置远程题库)' }; } if (resolutionInflight.has(key)) return { state: 'pending', reason: '答案请求进行中' }; return { state: 'pending', reason: '等待远程题库', needsRequest: true }; } /** 把一次解析结果写入缓存并落盘(节流)。 */ let resolutionFlushTimer = 0; function storeResolution(question, payload) { const key = resolutionKey(question); if (!key) return; state.resolutionCache[key] = { ...payload, question: String(question).slice(0, 300), at: Date.now() }; const keys = Object.keys(state.resolutionCache); if (keys.length > RESOLUTION_CACHE_LIMIT) { keys.sort((a, b) => Number(state.resolutionCache[a].at || 0) - Number(state.resolutionCache[b].at || 0)); keys.slice(0, keys.length - RESOLUTION_CACHE_LIMIT).forEach((old) => delete state.resolutionCache[old]); } if (!resolutionFlushTimer) { // 一页可能有几十道题几乎同时回来,攒 800ms 落盘一次。 resolutionFlushTimer = window.setTimeout(() => { resolutionFlushTimer = 0; saveObject(STORE.resolutionCache, state.resolutionCache); }, 800); } } /** * 发起异步答案请求。 * * 由主循环在发现 `needsRequest` 时调用。请求成功且判定为 accept 时写入缓存, * 下一周期主循环即可填写。失败不抛错,只记录一次日志并写入 reject 缓存, * 避免同一道题无限重试把对端打挂。 */ function requestResolution(question, meta = {}) { const key = resolutionKey(question); if (!key || resolutionInflight.has(key)) return; if (resolutionInflight.size >= RESOLVER_CONCURRENCY) return; resolutionInflight.add(key); state.resolutionStats.requested += 1; const started = Date.now(); queryAnswerProviders(question, meta).then((outcome) => { resolutionInflight.delete(key); const elapsed = Date.now() - started; if (!outcome || !outcome.answer) { state.resolutionStats.error += 1; storeResolution(question, { verdict: 'reject', source: outcome?.source || 'none', score: 0 }); pushLog(`题目未命中题库:${String(question).slice(0, 32)}(${outcome?.reason || '无候选'},${elapsed}ms)`, false, 'question'); return; } state.resolutionStats.resolved += 1; storeResolution(question, { answer: outcome.answer, score: outcome.score ?? 1, strategy: outcome.strategy || 'provider', source: outcome.source, verdict: outcome.verdict || 'accept' }); pushLog( `命中题库:${String(question).slice(0, 32)} · ${outcome.source} · ${(Number(outcome.score ?? 1) * 100).toFixed(0)}% · ${elapsed}ms`, false, 'question' ); }).catch((error) => { resolutionInflight.delete(key); state.resolutionStats.error += 1; storeResolution(question, { verdict: 'reject', source: 'error', score: 0 }); pushLog(`答案请求失败:${error instanceof Error ? error.message : String(error)}`, true, 'error'); }); } /** * 在本轮 DOM 扫描中登记发现的题目,并在需要时触发异步请求。 * 返回是否已经可以进入填写阶段。 */ function trackQuestionDiscovery(question) { const resolution = resolveQuestion(question); if (resolution.state === 'pending') { if (resolution.needsRequest) requestResolution(question, { from: location.href }); return { ready: false, resolution }; } return { ready: true, resolution }; } /** 手工写入一条答案(题库管理界面使用),并立即让缓存生效。 */ function keepManualAnswer(question, answer) { if (!question || !answer) return false; storeResolution(question, { answer, verdict: 'accept', source: 'manual', score: 1, strategy: 'manual' }); state.bank[question] = answer; saveObject(STORE.bank, state.bank); state.bankIndex = buildBankIndex(state.bank); renderBankManager(); return true; } /** 删除一条缓存/题库记录。 */ function forgetAnswer(question) { const key = resolutionKey(question); delete state.resolutionCache[key]; Object.keys(state.bank).forEach((stored) => { if (stored === question || normalizeQuestion(stored) === key) delete state.bank[stored]; }); saveObject(STORE.bank, state.bank); saveObject(STORE.resolutionCache, state.resolutionCache); state.bankIndex = buildBankIndex(state.bank); renderBankManager(); return true; } /** Provider 类型。本地与人工不产生网络请求。 */ const PROVIDER_KINDS = Object.freeze(['local', 'manual', 'http']); /** 单个 provider 的默认预算。 */ const PROVIDER_DEFAULTS = Object.freeze({ timeoutMs: 6000, minIntervalMs: 800, maxRequestsPerQuestion: 1, enabled: true }); /** * 声明式响应取值。 * * path 形如 `data.answer`、`data.list.0.content`,只用点号与数字下标, * 不解析任何表达式。`||` 表示按顺序取第一个存在的值。 */ function readJsonPath(payload, path) { if (!path || typeof path !== 'string') return undefined; const alternatives = path.split('||').map((item) => item.trim()).filter(Boolean); for (const candidate of alternatives) { if (candidate === '$') return payload; let cursor = payload; let failed = false; for (const segment of candidate.split('.')) { if (cursor === null || cursor === undefined) { failed = true; break; } if (Array.isArray(cursor)) { const index = Number(segment); if (!Number.isInteger(index)) { failed = true; break; } cursor = cursor[index]; } else if (typeof cursor === 'object') { if (!Object.prototype.hasOwnProperty.call(cursor, segment)) { failed = true; break; } cursor = cursor[segment]; } else { failed = true; break; } } if (!failed && cursor !== undefined && cursor !== null && cursor !== '') return cursor; } return undefined; } /** 把任意 JSON 值压成「答案」字符串或字符串数组;不接受对象,从结构上排除函数与原型污染。 */ function coerceAnswer(value) { if (value === null || value === undefined) return null; if (typeof value === 'string') return value.trim() || null; if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (Array.isArray(value)) { const items = value.map(coerceAnswer).filter(Boolean); return items.length ? (items.length === 1 ? items[0] : items) : null; } // 常见包装形态:{answer: '...'} / {content: '...'} / {value: '...'} if (typeof value === 'object') { for (const key of ['answer', 'content', 'value', 'text', 'result']) { const nested = coerceAnswer(value[key]); if (nested) return nested; } } return null; } /* ------------------------------------------------------------------ * * 1. Provider 定义与校验 * ------------------------------------------------------------------ */ /** * 规范化一个用户填写的 provider 定义。 * * 拒绝非 http(s) 协议(挡住 file:// 与 javascript:),拒绝缺失 answerPath 的定义, * 并把所有数值字段钳到合理范围。任何不合规的定义都返回 `{ error }` 而不是抛错, * 因为它的来源是用户手填的表单。 */ function normalizeProvider(input) { if (!input || typeof input !== 'object') return { error: '定义必须是对象' }; const url = String(input.url || '').trim(); if (!/^https?:\/\//i.test(url)) return { error: '地址必须以 http:// 或 https:// 开头' }; const answerPath = String(input.answerPath || '').trim(); if (!answerPath) return { error: '必须声明答案字段路径(answerPath)' }; const clamp = (value, fallback, min, max) => { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(min, Math.min(max, number)); }; return { provider: { name: String(input.name || '').trim().slice(0, 60) || new URL(url).hostname, url: url.slice(0, 500), method: String(input.method || 'POST').toUpperCase() === 'GET' ? 'GET' : 'POST', questionParam: String(input.questionParam || 'question').slice(0, 60), questionPath: String(input.questionPath || 'question').slice(0, 200), answerPath: answerPath.slice(0, 200), scorePath: String(input.scorePath || '').slice(0, 200), headers: normalizeProviderHeaders(input.headers), timeoutMs: clamp(input.timeoutMs, PROVIDER_DEFAULTS.timeoutMs, 1000, 30000), enabled: input.enabled === undefined ? true : toBoolean(input.enabled) } }; } /** 表头白名单式清洗:只允许字符串值,键名限制为 token 字符,长度设上限。 */ function normalizeProviderHeaders(headers) { if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return {}; const result = {}; let count = 0; for (const [key, value] of Object.entries(headers)) { if (count >= 12) break; const name = String(key).trim(); if (!/^[A-Za-z0-9-]{1,64}$/.test(name)) continue; // 明确禁止危险表头:改这些表头会让脚本变成请求伪造工具。 if (/^(host|content-length|connection|cookie|origin|referer)$/i.test(name)) continue; result[name] = String(value ?? '').slice(0, 500); count += 1; } return result; } /* ------------------------------------------------------------------ * * 2. Provider 持久化 * ------------------------------------------------------------------ */ /** * 从本地存储读取用户配置的题库。 * * 每条都过一遍 `normalizeProvider`:存储里的内容可能来自旧版本、被手工改过、 * 或协议已经不再合法(例如早期版本允许过的非 http 地址),读取时统一校验, * 不合法的条目直接丢弃而不是带进运行期。 */ function loadProviders() { const stored = loadArray(STORE.providers); const valid = []; stored.forEach((item) => { const { provider } = normalizeProvider(item); if (provider) valid.push(provider); }); return valid; } function saveProviders() { saveObject(STORE.providers, state.providers); } /** * 初始化题库配置。由 boot() 调用。 * * 说明:4.0.0 初版把 `state.providers` 直接写成 `[]` 就再没读过存储, * `loadProviders` 定义了却从未被调用 —— 结果是用户添加的远程题库在刷新页面后 * 全部消失(功能形同不存在),而且不报任何错。此处补上加载, * 并额外检查是否有因为校验不通过而被丢弃的条目,丢弃时给出可见日志。 */ function initializeProviders() { const stored = loadArray(STORE.providers); state.providers = loadProviders(); const dropped = stored.length - state.providers.length; if (dropped > 0) { pushLog(`有 ${dropped} 条远程题库配置未通过校验,已忽略`, true, 'error'); } return state.providers.length; } /** 追加或覆盖一个 provider。同名视为覆盖,便于用户改地址。 */ function upsertProvider(input) { const { provider, error } = normalizeProvider(input); if (error) return { ok: false, error }; const index = state.providers.findIndex((item) => item.name === provider.name); if (index >= 0) state.providers[index] = provider; else state.providers.push(provider); state.providerStats[provider.name] = state.providerStats[provider.name] || { sent: 0, ok: 0, failed: 0, lastError: '', lastAt: 0 }; saveProviders(); renderBankManager(); return { ok: true, provider }; } function removeProvider(name) { state.providers = state.providers.filter((item) => item.name !== name); delete state.providerStats[name]; saveProviders(); renderBankManager(); return true; } function toggleProvider(name, enabled) { const provider = state.providers.find((item) => item.name === name); if (!provider) return false; provider.enabled = enabled === undefined ? !provider.enabled : toBoolean(enabled); saveProviders(); renderBankManager(); return true; } /* ------------------------------------------------------------------ * * 3. 请求通道 * ------------------------------------------------------------------ */ /** GET 时把题干拼进 query,POST 时放进 JSON body。 */ function providerRequestShape(provider, question) { const body = {}; body[provider.questionParam] = question; if (provider.method === 'GET') { const target = new URL(provider.url); target.searchParams.set(provider.questionParam, question); return { url: target.href, body: null }; } return { url: provider.url, body: JSON.stringify(body) }; } /** * 统一 HTTP 出口。优选 GM_xmlhttpRequest(脚本管理器提供的跨域能力), * 否则退回 fetch。返回 Promise<{ ok, status, text, error }>。 */ function httpRequest({ url, method, body, headers, timeoutMs }) { const mergedHeaders = { 'Content-Type': 'application/json', ...headers }; if (typeof GM_xmlhttpRequest === 'function') { return new Promise((resolve) => { let settled = false; const finish = (value) => { if (!settled) { settled = true; resolve(value); } }; try { GM_xmlhttpRequest({ url, method, headers: mergedHeaders, data: body || undefined, timeout: timeoutMs, onload: (response) => finish({ ok: response.status >= 200 && response.status < 300, status: response.status, text: String(response.responseText || '') }), ontimeout: () => finish({ ok: false, status: 0, text: '', error: '请求超时' }), onerror: () => finish({ ok: false, status: 0, text: '', error: '网络错误' }) }); } catch (error) { finish({ ok: false, status: 0, text: '', error: error instanceof Error ? error.message : String(error) }); } }); } if (typeof fetch === 'function') { const controller = typeof AbortController === 'function' ? new AbortController() : null; const timer = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : 0; return fetch(url, { method, headers: mergedHeaders, body: body || undefined, signal: controller?.signal }).then(async (response) => ({ ok: response.ok, status: response.status, text: await response.text() })).catch((error) => ({ ok: false, status: 0, text: '', error: error instanceof Error ? error.message : String(error) })).finally(() => { if (timer) window.clearTimeout(timer); }); } return Promise.resolve({ ok: false, status: 0, text: '', error: '当前环境没有可用的请求通道' }); } /** 单个 provider 的主动限速:同一 provider 两次请求之间至少间隔 minIntervalMs。 */ function providerRateGate(provider) { const stat = state.providerStats[provider.name]; const now = Date.now(); const wait = Math.max(0, provider.minIntervalMs - (now - Number(stat?.lastAt || 0))); if (wait <= 0) return Promise.resolve(); return new Promise((resolve) => { window.setTimeout(resolve, wait); }); } function noteProviderResult(provider, ok, error = '') { const stat = state.providerStats[provider.name] || { sent: 0, ok: 0, failed: 0, lastError: '', lastAt: 0 }; stat.sent += 1; stat.lastAt = Date.now(); if (ok) stat.ok += 1; else { stat.failed += 1; stat.lastError = error.slice(0, 200); } state.providerStats[provider.name] = stat; } /** 向一个 provider 查询一道题。 */ async function queryProvider(provider, question) { if (!provider.enabled) return null; await providerRateGate(provider); const shape = providerRequestShape(provider, question); const response = await httpRequest({ url: shape.url, method: provider.method, body: shape.body, headers: provider.headers, timeoutMs: provider.timeoutMs }); if (!response.ok) { noteProviderResult(provider, false, response.error || `HTTP ${response.status}`); return null; } let payload; try { payload = JSON.parse(response.text); } catch (_) { noteProviderResult(provider, false, '响应不是 JSON'); return null; } const answer = coerceAnswer(readJsonPath(payload, provider.answerPath)); if (!answer) { noteProviderResult(provider, false, '未取到答案字段'); return null; } const rawScore = Number(readJsonPath(payload, provider.scorePath)); noteProviderResult(provider, true); return { answer, // 未声明 scorePath 时给 0.9:远程命中算「足够可信但低于本地精确命中」。 score: Number.isFinite(rawScore) ? Math.max(0, Math.min(1, rawScore)) : 0.9, strategy: 'provider', source: `remote:${provider.name}` }; } /** * 依序遍历所有启用的远程 provider,返回第一个命中。 * * Provider 之间串行而非并发:并发会让「哪个 provider 的答案被采用」变得不确定, * 而答案正确性比延迟重要。第一个命中的 provider 胜出,顺序即用户排序。 */ async function queryAnswerProviders(question) { const enabled = state.providers.filter((provider) => provider.enabled); if (!enabled.length) return { answer: null, source: 'none', reason: '未配置远程题库' }; let lastError = ''; for (const provider of enabled) { try { const outcome = await queryProvider(provider, question); if (outcome) return outcome; lastError = state.providerStats[provider.name]?.lastError || '无结果'; } catch (error) { lastError = error instanceof Error ? error.message : String(error); noteProviderResult(provider, false, lastError); } } return { answer: null, source: 'none', reason: lastError || '全部题库未命中' }; } /** 测试一个 provider 的连通性。用于题库管理界面的「测试」按钮。 */ async function probeProvider(provider) { const started = Date.now(); const outcome = await queryProvider(provider, '__XXT_PROVIDER_PROBE__'); const elapsed = Date.now() - started; return { ok: Boolean(outcome), elapsed, answer: outcome?.answer ?? null, error: outcome ? '' : (state.providerStats[provider.name]?.lastError || '未命中') }; } /** * 从 provider 批量拉取题库(用于「接入同行的整库」而不是逐题查询)。 * * 约定 provider.bulkPath 存在时,GET provider.url 返回一个记录对象或数组, * 按 `recordsPath` 取到 `{ question: answer }` 或 `[{question, answer}]`。 * 拉回来的内容会经过与手工导入完全相同的校验和归一化流程,不会绕过导入限制。 */ async function fetchProviderBulk(provider, recordsPath = '') { if (!provider) return { ok: false, error: '未指定题库' }; const response = await httpRequest({ url: provider.url, method: 'GET', body: null, headers: provider.headers, timeoutMs: Math.max(provider.timeoutMs, 20000) }); if (!response.ok) { return { ok: false, error: response.error || `HTTP ${response.status}` }; } let payload; try { payload = JSON.parse(response.text); } catch (_) { return { ok: false, error: '响应不是 JSON' }; } const collected = recordsPath ? readJsonPath(payload, recordsPath) : payload; if (!collected) return { ok: false, error: '未取到题库字段' }; const records = {}; if (Array.isArray(collected)) { collected.forEach((item) => { if (!item || typeof item !== 'object') return; const question = item.question || item.title || item.q; const answer = coerceAnswer(item.answer || item.a || item.content); if (question && answer) records[String(question).slice(0, DATA_LIMITS.questionLength)] = answer; }); } else if (typeof collected === 'object') { Object.entries(collected).forEach(([question, answer]) => { const value = coerceAnswer(answer); if (question && value) records[String(question).slice(0, DATA_LIMITS.questionLength)] = value; }); } const count = Object.keys(records).length; if (!count) return { ok: false, error: '题库为空' }; return { ok: true, records, count, source: `remote-bulk:${provider.name}` }; } 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, .ans-videoquiz-title, .ans-videoquiz-question, [data-question-title]'); const imageHints = Array.from((title || block).querySelectorAll('img')).map((image) => { const alt = compactText(image.getAttribute('alt')); if (alt) return `图片 ${alt}`; try { return `图片 ${new URL(image.src, location.href).pathname.split('/').filter(Boolean).at(-1) || ''}`; } catch (_) { return ''; } }).filter(Boolean); if (title) return normalize([title.textContent, ...imageHints].join(' ')); 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, ...imageHints].join(' ')).slice(0, 500); } /** 本地题库查询。返回 `{ answer, score, strategy, question, verdict }` 或 null。 */ function findLocalBankAnswer(question) { return matchInIndex(question, state.bankIndex); } const LOCAL_BANK_PROVIDER = Object.freeze({ id: 'local-json', schemaVersion: BANK_SCHEMA_VERSION, readOnly: true, // 兼容旧调用方:只取答案字符串。 find: (question) => { const match = findLocalBankAnswer(question); return match && match.verdict === 'accept' ? match.answer : null; }, // 新版调用方:拿到完整裁决信息。 resolve: findLocalBankAnswer, export: (records = state.bank) => ({ schemaVersion: BANK_SCHEMA_VERSION, provider: 'local-json', exportedAt: new Date().toISOString(), records }) }); function activeBankProvider() { return LOCAL_BANK_PROVIDER; } /** * 兼容入口。旧签名返回答案或 null;4.0.0 的答题流程改用 resolveQuestion, * 这里保留是为了不破坏已有的测试 API 与外部调用。 */ 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)}`); } /** * 把一道「有候选但相似度不足」的题写入复核队列。 * * 这是 S6 的正面修复:旧版要么填错要么静默丢弃,用户在「未匹配题目」里看到的 * 是一句干巴巴的题干,没有任何线索。复核队列保留候选答案与相似度,用户在题库 * 管理界面一眼就能判断「是不是同一道题」。 */ function rememberReview(question, match) { if (!question || !match) return; const key = resolutionKey(question); const existing = state.reviewQueue.find((item) => item.key === key); const entry = { key, question: String(question).slice(0, 500), candidate: String(match.question || '').slice(0, 500), answer: match.answer, score: match.score, strategy: match.strategy, source: match.source || 'local', at: Date.now() }; if (existing) Object.assign(existing, entry); else state.reviewQueue.push(entry); // 复核队列上限 500 条,超出丢最旧的。 if (state.reviewQueue.length > 500) { state.reviewQueue.sort((a, b) => Number(a.at || 0) - Number(b.at || 0)); state.reviewQueue = state.reviewQueue.slice(state.reviewQueue.length - 500); } saveObject(STORE.reviewQueue, state.reviewQueue); } /** 用户确认复核条目:写入题库并清出队列。 */ function acceptReview(key) { const entry = state.reviewQueue.find((item) => item.key === key); if (!entry) return false; keepManualAnswer(entry.question, entry.answer); state.reviewQueue = state.reviewQueue.filter((item) => item.key !== key); saveObject(STORE.reviewQueue, state.reviewQueue); if (typeof renderBankManager === 'function') renderBankManager(); return true; } /** 用户否决策复核条目:只清出队列,不写入题库。 */ function rejectReview(key) { const before = state.reviewQueue.length; state.reviewQueue = state.reviewQueue.filter((item) => item.key !== key); saveObject(STORE.reviewQueue, state.reviewQueue); if (typeof renderBankManager === 'function') renderBankManager(); return state.reviewQueue.length < before; } 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); // 剥掉前导的选项字母。裸字母选项(textContent === 'A')剥完为空串, // 调用方据此回退到字母通道。 return text.replace(/^[a-z]\s*[.、.)\]]?\s*/i, '').trim(); } /** * 抽取选项字母。 * * 4.0.0 修复:旧正则 `/^([a-z])[.、.\s]/` 要求字母后必须跟一个分隔符或空白, * 于是 ``(textContent 恰好是 "A")取不到 * 字母。旧版是靠文本通道 `text === target` 侥幸匹配上的,一旦答案走字母通道就 * 会漏。现在允许字母后是行尾,同时排除「单词首字母」这种误判(后面跟字母时不取)。 */ 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)); } /** * 按答案文本/字母选中选项。 * * 相比旧版的改进:区分「字母命中」与「文本命中」,并把未命中的答案数返回给调用 * 方。旧版只统计 matchedCount 就丢掉未命中项,导致「5 个答案命中 1 个」也可能 * 因为 matchedCount>=answers.length 的反向比较而不成立却被静默接受。 */ function selectChoiceOptions(block, rawAnswer) { const options = findOptionNodes(block); if (!options.length) return { handled: false }; const { letters, texts } = parseChoiceAnswer(rawAnswer); const selected = []; const missed = []; // 1. 字母答案:直接按字母定位选项。 letters.forEach((letter) => { const node = options.find((option) => optionLetter(option).toUpperCase() === letter); if (!node) { missed.push(letter); return; } if (!isOptionSelected(node) && clickElement(node)) selected.push(letter); }); // 2. 文本答案:在选项文本里找最匹配的一项。 // 裸字母选项(文本为空)跳过,它们已经由字母通道覆盖。 texts.forEach((wanted) => { const target = normalizeQuestion(wanted); if (!target) { missed.push(wanted); return; } let best = null; options.forEach((option) => { const text = optionText(option); if (!text) return; const { score } = similarityScore(text, wanted); if (!best || score > best.score) best = { node: option, score }; }); // 选项文本匹配要求明显更高:选项都很短,0.7 以下基本是误配。 if (!best || best.score < 0.7) { missed.push(wanted); return; } if (!isOptionSelected(best.node) && clickElement(best.node)) selected.push(wanted); }); const expected = letters.length + texts.length; return { handled: true, selectedCount: selected.length, expectedCount: expected, missed, ok: expected > 0 && missed.length === 0 }; } /** * 填空题作答。返回 `{ handled, ok, reason, filledCount }`。 * * 关键修复(S1):不再用 `?? answers[0]` 兜底。空位与答案数不匹配时 * planFillInBlank 给出 verdict='review',此处返回 ok=false 并把原因带出去, * 调用方会写入复核队列而不是提交。 */ function fillTextControls(block, rawAnswer) { const inputs = queryAllInOrder(CONFIG.selectors.textInputs, block).filter(isVisible); const richFrames = Array.from(block.querySelectorAll('iframe')).filter((frame) => { try { return Boolean(frame.contentDocument?.body); } catch (_) { return false; } }); const controlCount = inputs.length + richFrames.length; if (!controlCount) return { handled: false }; const plan = planFillInBlank(controlCount, rawAnswer); if (plan.verdict !== 'accept') { return { handled: true, ok: false, reason: plan.reason, filledCount: 0, verdict: plan.verdict }; } let filled = 0; inputs.forEach((input, index) => { const value = plan.fills[index]; if (value === undefined) return; if (normalize(input.value) !== normalizeQuestion(value)) { setNativeValue(input, value); filled += 1; } }); richFrames.forEach((frame, index) => { const value = plan.fills[inputs.length + index]; if (value === undefined) return; const body = frame.contentDocument.body; if (normalize(body.innerText || body.textContent) !== normalizeQuestion(value)) { body.textContent = String(value); body.dispatchEvent(new Event('input', { bubbles: true })); body.dispatchEvent(new Event('change', { bubbles: true })); filled += 1; } }); return { handled: true, ok: true, filledCount: filled, verdict: 'accept', reason: plan.reason }; } 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 }; } // 解析链:内存索引 → 异步缓存 → 远程 Provider(异步预取,下一轮命中)。 const { ready, resolution } = trackQuestionDiscovery(question); if (!ready) { // 等待异步结果。旧版这里会记成 discovered 并计入「未匹配」,让用户以为 // 题库里没有这道题;现在明确区分「还没回来」和「确实没有」。 const waiting = resolution.state === 'pending'; return { completed: false, pending: true, matched: false, stage: waiting ? 'requested' : 'discovered', reason: resolution.reason || '等待题库响应', awaitingProvider: waiting }; } if (resolution.verdict === 'reject' || resolution.answer === null || resolution.answer === undefined) { rememberUnanswered(question); return { completed: false, pending: true, matched: false, stage: 'discovered', reason: resolution.reason || '题库未匹配' }; } // 相似度落在复核区间:不填、不提交,交给用户裁决(S6 的正面修复)。 if (resolution.verdict === 'review' && CONFIG.questionReviewEnabled) { rememberReview(question, resolution); return { completed: false, pending: true, matched: true, stage: 'matched', reason: `待复核:${resolution.reason || `相似度 ${(resolution.score * 100).toFixed(1)}%`}`, review: true }; } // 选择题与填空题走不同通道;先试选项,页面没有选项控件时再试文本控件。 const choice = selectChoiceOptions(block, resolution.answer); let outcome = choice; if (!choice.handled) { const fill = fillTextControls(block, resolution.answer); outcome = fill.handled ? fill : { handled: true, ok: false, reason: '页面没有可用的作答控件' }; } if (!outcome.ok) { // 答案拿到了但填不进去:这既不是「未匹配」也不是「已填写」。记入复核队列, // 让用户能看到「题库说答案是什么、页面长什么样」。 rememberReview(question, { ...resolution, reason: outcome.reason }); return { completed: false, pending: true, matched: true, stage: 'matched', reason: outcome.reason || '答案与页面控件不匹配' }; } const submit = firstVisible(CONFIG.selectors.submit, block); if (submit && !state.submittedQuestions.has(block)) { clickElement(submit); state.submittedQuestions.add(block); } state.answeredQuestions.add(block); if (outcome.selectedCount || outcome.filledCount) { pushLog(`已填写题目:${question.slice(0, 36)}(${resolution.source || 'local'})`); } return { completed: true, pending: false, matched: true, stage: submit ? 'submitted' : 'filled', source: resolution.source || 'local' }; } function processQuestions() { // 每次扫描重建题块列表;顺序即页面顺序,保证多空填空的答案顺序稳定。 const blocks = activeCourseTemplate().question(); let completed = 0; let pending = 0; const stages = { discovered: 0, requested: 0, matched: 0, filled: 0, submitted: 0, confirmed: 0 }; const items = []; 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; items.push({ title: getQuestionText(block), stage: result.stage, matched: result.matched, reason: result.reason || '', source: result.source || '', review: Boolean(result.review) }); }); state.resolutionStats.filled += stages.filled + stages.submitted + stages.confirmed; state.resolutionStats.review = state.reviewQueue.length; // 整页提交(S2 修复)。 // // 旧版用 `state.pageSubmitted` 一个页面级布尔,一页有多个独立提交域时第二域 // 永久被跳过。现在按「本轮题块指纹」判断:指纹变化即视为新的提交域,可以再次 // 尝试整页提交;同一批题块只提交一次。 const pageFingerprint = simpleHash(blocks.map((block) => getQuestionText(block)).join('||')); const hasPageSubmit = pending === 0 && blocks.some((block) => { const submit = firstVisible(CONFIG.selectors.submit, block); return !submit; }); if (blocks.length && pending === 0 && state.pageSubmittedFingerprint !== pageFingerprint) { const pageSubmit = firstVisible(CONFIG.selectors.submit); if (pageSubmit && !blocks.some((block) => block.contains(pageSubmit))) { clickElement(pageSubmit); state.pageSubmittedFingerprint = pageFingerprint; pushLog('已提交当前页面题目'); } else if (hasPageSubmit) { // 没有独立整页提交按钮、且各题块自己没提交按钮:无法提交,明确记录而不是 // 假装成功。 state.pageSubmittedFingerprint = pageFingerprint; } } return { total: blocks.length, completed, pending, stages, items }; } 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 cleanTaskDisplayName(value) { let text = compactText(value); if (!text) return ''; if (/^https?:\/\//i.test(text)) { try { text = new URL(text).pathname.split('/').filter(Boolean).at(-1) || ''; } catch (_) { /* Keep raw text. */ } } else if (/[\\/]/.test(text)) { text = text.split(/[\\/]/).filter(Boolean).at(-1) || text; } text = text.replace(/[?#].*$/, ''); try { text = decodeURIComponent(text); } catch (_) { /* Keep undecoded name. */ } text = compactText(text) .replace(/^(?:视频|音频|文件)(?:名称)?\s*[::]\s*/i, '') .replace(/\.(?:mp4|m4v|mov|avi|mkv|flv|wmv|webm|mp3|m4a|aac|wav|ogg)$/i, '') .trim(); const token = text.replace(/[\s_\-—]+/g, ''); if (!token || /^(?:(?:文本|text)(?:[一二三四五六七八九十百\d]+)?)+$/i.test(token)) return ''; if (/^(?:视频|音频|任务点|课程视频|当前任务|未命名|无标题)\d*$/i.test(token)) return ''; return text.slice(0, 180); } function mediaTaskTitleFromDocument() { const selectors = [ '[data-video-name]', '[data-filename]', '.ans-video-title', '.video-title', '.ans-attach-title', '.file-name', '.fileName', '.attachment-name' ]; for (const node of queryAllInOrder(selectors).filter(isVisible)) { const name = cleanTaskDisplayName( node.getAttribute?.('data-video-name') || node.getAttribute?.('data-filename') || node.getAttribute?.('title') || node.textContent ); if (name) return name; } for (const media of document.querySelectorAll('video, audio')) { const name = mediaElementTitle(media); if (name) return name; } return ''; } function mediaElementTitle(media) { return cleanTaskDisplayName( media?.getAttribute?.('data-video-name') || media?.getAttribute?.('data-filename') || media?.getAttribute?.('data-title') || media?.getAttribute?.('title') || media?.getAttribute?.('aria-label') || mediaSource(media) ); } 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); info.endedHandler = () => { const current = state.media.get(media); if (!current || current.listenerToken !== info.listenerToken) return; current.completed = true; pushLog(`媒体 ${index + 1} 已完成`); }; media.addEventListener('ended', info.endedHandler); } media.playbackRate = state.speed; media.volume = state.volume; media.muted = state.volume === 0; 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; scheduleOperation(() => { 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, title: mediaElementTitle(media), 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, title: mediaElementTitle(media), 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, title: mediaElementTitle(media), 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 cancelMediaOperations(reason = 'cancelled') { for (const media of state.knownMedia) { const info = state.media.get(media); if (info?.endedHandler) media.removeEventListener('ended', info.endedHandler); pauseMediaElement(media); } if (reason !== 'not-detected') state.knownMedia.clear(); return true; } function cancelQuestionOperations(reason = 'cancelled') { if (reason === 'not-detected') return true; state.answeredQuestions = new WeakSet(); state.submittedQuestions = new WeakSet(); state.pageSubmitted = false; state.pageSubmittedFingerprint = ''; return true; } function emptyAuxiliaryStatus() { return { total: 0, completed: 0, pending: 0, items: [], issue: null }; } function currentAttachmentModule() { return normalize(frameAttachmentDescriptor().module); } function auxiliaryRuntime(type) { const descriptor = frameAttachmentDescriptor(); const key = `${type}:${descriptor.identity || location.href}`; if (!state.auxiliaryRuntime.has(key)) state.auxiliaryRuntime.set(key, { key, type, startedAt: Date.now(), lastActionAt: 0, index: 0 }); return state.auxiliaryRuntime.get(key); } function cancelAuxiliaryOperations(type, reason) { if (reason === 'not-detected') return true; for (const [key, runtime] of state.auxiliaryRuntime.entries()) { if (!type || runtime.type === type || runtime.type === 'finish') state.auxiliaryRuntime.delete(key); } return true; } function invokeFinishJob() { const runtime = auxiliaryRuntime('finish'); if (runtime.finishedInvoked) return true; let finish = null; try { finish = PAGE_WINDOW.finishJob; } catch (_) { finish = null; } if (typeof finish !== 'function') return false; finish.call(PAGE_WINDOW); runtime.finishedInvoked = true; return true; } function detectTimedReadingTask() { return /timereader|timing/.test(location.href) || Boolean(document.querySelector('iframe[name="bookifame"][src*="timing"]')); } function detectReadingTask() { if (detectTimedReadingTask()) return false; const module = currentAttachmentModule(); return /insertbook|insertdoc|document|read/.test(module) || Boolean(document.querySelector('#img.imglook,.ans-book,.ans-document,[data-module="insertdoc"],[data-module="insertbook"]')); } function processReadingTask() { const descriptor = frameAttachmentDescriptor(); if (descriptor.passed) return { total: 1, completed: 1, pending: 0, items: [{ type: 'reading', stage: 'confirmed', title: descriptor.title }], issue: null }; const runtime = auxiliaryRuntime('reading'); document.scrollingElement?.scrollTo?.(0, document.scrollingElement.scrollHeight); const invoked = invokeFinishJob(); runtime.stage = invoked ? 'waiting-confirm' : 'running'; return { total: 1, completed: invoked ? 1 : 0, pending: invoked ? 0 : 1, items: [{ type: 'reading', stage: runtime.stage, title: descriptor.title || '阅读任务' }], issue: invoked ? null : { type: 'reading-wait', label: '阅读任务处理中', advice: '等待页面提供完成接口', blocking: false } }; } function processTimedReadingTask() { const descriptor = frameAttachmentDescriptor(); if (descriptor.passed) return { total: 1, completed: 1, pending: 0, items: [{ type: 'timed-reading', stage: 'confirmed', title: descriptor.title }], issue: null }; const runtime = auxiliaryRuntime('timed-reading'); const frame = document.querySelector('iframe[name="bookifame"][src*="timing"]'); let timing = 60; try { timing = Number(new URL(frame?.src || location.href).searchParams.get('timing')) || 60; } catch (_) { timing = 60; } const requiredMs = Number(PAGE_WINDOW.__XXT_TEST_TIMED_READING_MS__) || Math.max(3000, (timing + 3) * 3000); const elapsed = Date.now() - runtime.startedAt; const completed = elapsed >= requiredMs; if (completed) invokeFinishJob(); return { total: 1, completed: completed ? 1 : 0, pending: completed ? 0 : 1, items: [{ type: 'timed-reading', stage: completed ? 'waiting-confirm' : 'running', elapsed, requiredMs, title: descriptor.title || '计时阅读' }], issue: null }; } function detectPptTask() { const module = currentAttachmentModule(); return /insertflash|ppt|presentation/.test(module) || Boolean(document.querySelector('.swiper-container,.swiper-slide,.ppt-container')); } function processPptTask() { const descriptor = frameAttachmentDescriptor(); const slides = Array.from(document.querySelectorAll('.swiper-container .swiper-slide,.ppt-container .slide')); const total = Math.max(1, slides.length); if (descriptor.passed) return { total: 1, completed: 1, pending: 0, items: [{ type: 'ppt', stage: 'confirmed', index: total, total, title: descriptor.title }], issue: null }; const runtime = auxiliaryRuntime('ppt'); const mediaBusy = Array.from(document.querySelectorAll('video,audio')).some((media) => !media.ended); if (!mediaBusy && runtime.index < total - 1 && Date.now() - runtime.lastActionAt >= 900) { let advanced = false; try { if (typeof PAGE_WINDOW.swiperNext === 'function') { PAGE_WINDOW.swiperNext(); advanced = true; } } catch (_) { /* Fall through to a visible next button. */ } if (!advanced) advanced = clickElement(firstVisible(['.swiper-button-next','.ppt-next','button[title*="下一页"]'])); if (advanced) { runtime.index += 1; runtime.lastActionAt = Date.now(); } } const completed = runtime.index >= total - 1 && !mediaBusy; if (completed) invokeFinishJob(); return { total: 1, completed: completed ? 1 : 0, pending: completed ? 0 : 1, items: [{ type: 'ppt', stage: completed ? 'waiting-confirm' : 'running', index: runtime.index + 1, total, title: descriptor.title || 'PPT' }], issue: null }; } function detectHyperlinkTask() { const module = currentAttachmentModule(); return /hyperlink|link/.test(module) || Boolean(document.querySelector('#hyperlink,[data-module="hyperlink"]')); } function processHyperlinkTask() { const descriptor = frameAttachmentDescriptor(); if (descriptor.passed) return { total: 1, completed: 1, pending: 0, items: [{ type: 'hyperlink', stage: 'confirmed', title: descriptor.title }], issue: null }; return { total: 1, completed: 0, pending: 1, items: [{ type: 'hyperlink', stage: 'waiting-intervention', title: descriptor.title || '链接任务' }], issue: { type: 'hyperlink', label: '检测到链接任务', advice: '请确认链接内容后手动打开', blocking: true } }; } function combineAuxiliaryStatuses(results) { const statuses = ['reading', 'timed-reading', 'ppt', 'hyperlink'].map((id) => results[id]).filter(Boolean); return statuses.reduce((combined, status) => { combined.total += status.total; combined.completed += status.completed; combined.pending += status.pending; combined.items.push(...status.items); if (!combined.issue && status.issue) combined.issue = status.issue; return combined; }, emptyAuxiliaryStatus()); } function pauseMediaElement(media) { try { media?.pause?.(); return true; } catch (_) { return false; } } const TASK_HANDLERS = Object.freeze([ Object.freeze({ id: 'media', priority: 10, apiVersion: TASK_HANDLER_API_VERSION, detect: () => activeCourseTemplate().media().length > 0, run: () => processMedia(), status: (result) => result || { total: 0, completed: 0, failed: 0, failureReason: '', items: [] }, cancel: (reason) => cancelMediaOperations(reason) }), Object.freeze({ id: 'questions', priority: 20, apiVersion: TASK_HANDLER_API_VERSION, detect: () => activeCourseTemplate().question().length > 0, run: () => processQuestions(), status: (result) => result || { total: 0, completed: 0, pending: 0, stages: { discovered: 0, requested: 0, matched: 0, filled: 0, submitted: 0, confirmed: 0 }, items: [] }, cancel: (reason) => cancelQuestionOperations(reason) }), Object.freeze({ id: 'reading', priority: 30, apiVersion: TASK_HANDLER_API_VERSION, detect: detectReadingTask, run: processReadingTask, status: (result) => result || emptyAuxiliaryStatus(), cancel: (reason) => cancelAuxiliaryOperations('reading', reason) }), Object.freeze({ id: 'timed-reading', priority: 40, apiVersion: TASK_HANDLER_API_VERSION, detect: detectTimedReadingTask, run: processTimedReadingTask, status: (result) => result || emptyAuxiliaryStatus(), cancel: (reason) => cancelAuxiliaryOperations('timed-reading', reason) }), Object.freeze({ id: 'ppt', priority: 50, apiVersion: TASK_HANDLER_API_VERSION, detect: detectPptTask, run: processPptTask, status: (result) => result || emptyAuxiliaryStatus(), cancel: (reason) => cancelAuxiliaryOperations('ppt', reason) }), Object.freeze({ id: 'hyperlink', priority: 90, apiVersion: TASK_HANDLER_API_VERSION, detect: detectHyperlinkTask, run: processHyperlinkTask, status: (result) => result || emptyAuxiliaryStatus(), cancel: (reason) => cancelAuxiliaryOperations('hyperlink', reason) }) ]); /** 按 priority 升序的执行顺序,模块初始化时算一次。 */ const TASK_HANDLER_ORDER = Object.freeze( [...TASK_HANDLERS].sort((left, right) => left.priority - right.priority) ); function runTaskHandlers() { return Object.fromEntries(TASK_HANDLER_ORDER.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_HANDLER_ORDER.forEach((handler) => handler.cancel(reason)); } function frameAttachmentDescriptor() { const frames = []; try { let current = PAGE_WINDOW; for (let depth = 0; depth < 4 && current && current !== current.top; depth += 1) { const frame = current.frameElement; if (frame) frames.push(frame); current = current.parent; } } catch (_) { if (!frames.length) return correlateAttachmentDescriptor({ source: 'cross-origin-frame', jobId: '', module: '', title: '', status: '', passed: false }); } if (!frames.length) return correlateAttachmentDescriptor({ source: 'document', jobId: '', module: '', title: mediaTaskTitleFromDocument(), status: '', passed: false }); const frame = frames.find((item) => item.getAttribute('data') || item.getAttribute('data-attachment')) || frames[0]; 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 parseNestedObject = (value) => { if (value && typeof value === 'object') return value; if (typeof value !== 'string' || !value.trim()) return {}; try { return JSON.parse(value); } catch (_) { return {}; } }; const nested = [payload, ...['property', 'defaults', 'attachment', 'params', 'data'] .map((key) => parseNestedObject(payload[key]))]; const value = (keys) => { for (const object of nested) { const found = keys.map((key) => object?.[key]) .find((item) => item !== null && item !== undefined && item !== ''); if (found !== undefined) return found; } return keys.map((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']); const payloadTitle = cleanTaskDisplayName(value([ 'name', 'title', 'filename', 'fileName', 'videoName', 'videoTitle', 'objectName', 'displayName' ])); return correlateAttachmentDescriptor({ source: raw ? (frame === frames[0] ? 'frame-data' : 'ancestor-frame-data') : 'frame-attributes', jobId: compactText(value(['jobid', 'jobId', 'job-id'])).slice(0, 160), module: compactText(value(['module', 'type'])).slice(0, 80), title: payloadTitle || mediaTaskTitleFromDocument(), status: status.slice(0, 80), passed: passedValue === true || passedValue === 1 || /^(true|1|passed|completed|finish(?:ed)?)$/i.test(compactText(passedValue || status)) }); } function attachmentProperty(attachment) { if (!attachment || typeof attachment !== 'object') return {}; if (attachment.property && typeof attachment.property === 'object') return attachment.property; if (typeof attachment.property === 'string') { try { return JSON.parse(attachment.property); } catch (_) { return {}; } } return {}; } function attachmentDescriptor(attachment) { const property = attachmentProperty(attachment); const jobId = compactText(attachment?.jobid || attachment?._jobid || property._jobid || property.jobid).slice(0, 160); const mid = compactText(attachment?.mid || property.mid).slice(0, 160); const objectId = compactText(attachment?.objectid || attachment?.objectId || property.objectid || property.objectId).slice(0, 160); const title = cleanTaskDisplayName( property.name || property.title || property.filename || property.fileName || attachment?.name || attachment?.title ); const module = compactText(attachment?.module || property.module || property.type).slice(0, 80); const passedRaw = attachment?.isPassed ?? attachment?.passed ?? property.isPassed ?? property.passed; const jobRaw = attachment?.job ?? property.job; const passed = passedRaw === true || passedRaw === 1 || /^(true|1|passed|completed|finish(?:ed)?)$/i.test(compactText(passedRaw)); const job = jobRaw === true || jobRaw === 1 || /^(true|1|job)$/i.test(compactText(jobRaw)); return { jobId, mid, objectId, title, module, job, passed, identity: mid ? `mid:${mid}` : objectId ? `object:${objectId}` : jobId ? `job:${jobId}` : '' }; } function pageAttachmentDescriptors() { const candidates = []; const add = (value) => { if (!Array.isArray(value)) return; value.forEach((item) => { const descriptor = attachmentDescriptor(item); if (descriptor.identity && !candidates.some((existing) => existing.identity === descriptor.identity)) candidates.push(descriptor); }); }; try { add(PAGE_WINDOW.attachments); } catch (_) { /* Ignore inaccessible attachment registry. */ } try { add(PAGE_WINDOW.parent?.attachments); } catch (_) { /* Ignore cross-origin parent. */ } try { add(PAGE_WINDOW.top?.attachments); } catch (_) { /* Ignore cross-origin top window. */ } return candidates; } function correlateAttachmentDescriptor(descriptor = {}) { const registry = pageAttachmentDescriptors(); const match = registry.find((item) => descriptor.jobId && item.jobId === descriptor.jobId) || registry.find((item) => descriptor.mid && item.mid === descriptor.mid) || registry.find((item) => descriptor.objectId && item.objectId === descriptor.objectId); const merged = { ...descriptor, jobId: match?.jobId || descriptor.jobId || '', mid: match?.mid || descriptor.mid || '', objectId: match?.objectId || descriptor.objectId || '', title: match?.title || descriptor.title || '', module: descriptor.module || match?.module || '', job: match ? match.job : Boolean(descriptor.job), passed: Boolean(descriptor.passed || match?.passed), registryMatched: Boolean(match) }; merged.identity = merged.mid ? `mid:${merged.mid}` : merged.objectId ? `object:${merged.objectId}` : merged.jobId ? `job:${merged.jobId}` : ''; return merged; } function uniqueWorkersByAttachment(workers) { const result = []; const identities = new Map(); [...workers].sort((left, right) => compareFramePaths(left.framePath, right.framePath)).forEach((worker) => { worker.attachment = correlateAttachmentDescriptor(worker.attachment || {}); const identity = worker.attachment.identity; if (!identity) { result.push(worker); return; } const existingIndex = identities.get(identity); if (existingIndex === undefined) { identities.set(identity, result.length); result.push(worker); return; } const existing = result[existingIndex]; const existingScore = Number(existing.media?.total || 0) + Number(existing.questions?.total || 0); const workerScore = Number(worker.media?.total || 0) + Number(worker.questions?.total || 0); if (workerScore > existingScore) result[existingIndex] = worker; }); return result; } function emptyWorkerStatus() { return { id: WORKER_ID, url: location.href, framePath: FRAME_PATH, attachment: frameAttachmentDescriptor(), discovery: discoveryStatus(), 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 }, items: [] }, auxiliary: emptyAuxiliaryStatus(), hasWork: false, busy: false }; } function buildWorkerStatus() { const results = runTaskHandlers(); const media = results.media; const questions = results.questions; const auxiliary = combineAuxiliaryStatuses(results); recordAdapterSuccess(media.total, questions.total); const hasWork = media.total > 0 || questions.total > 0 || auxiliary.total > 0; const issue = auxiliary.issue || classifyPageIssue(media); return { id: WORKER_ID, url: location.href, framePath: FRAME_PATH, attachment: frameAttachmentDescriptor(), discovery: discoveryStatus(), at: Date.now(), media, questions, auxiliary, issue, hasWork, busy: media.completed < media.total || questions.pending > 0 || auxiliary.pending > 0 }; } function classifyPageIssue(mediaStatus) { const text = compactText(document.body?.innerText || ''); const mediaError = Array.from(document.querySelectorAll('video, audio')).find((item) => item.error)?.error; const visible = (selectors) => Boolean(firstVisible(selectors)); if (visible(['#fcqrimg[src]', '.chapterVideoFaceMaskDiv', '.face-recognition', '[class*="faceMask"]']) || /人脸识别|人脸验证/.test(text)) { return { type: 'face', label: '需要人脸验证', advice: '完成人脸验证后继续队列', blocking: true }; } const videoQuiz = firstVisible(['.ans-videoquiz', '.ans-videoquiz-opt']) || firstVisible(['#videoquiz-submit'])?.closest('.ans-videoquiz'); if ((videoQuiz && !state.answeredQuestions.has(videoQuiz)) || (!videoQuiz && /视频内题目|视频弹题/.test(text))) { return { type: 'video-quiz', label: '检测到视频内题目', advice: '完成题目后继续播放', blocking: true }; } if (visible(['.posCatalog_active .chapter-lock', '#prev_tab .prev_ul .curr .flag']) || /闯关模式/.test(text)) { return { type: 'breaking', label: '课程处于闯关模式', advice: '确认当前章节任务后再继续', blocking: true }; } 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 handleBlockingIssue(summary) { const issue = summary?.issue; if (!isController() || !issue?.blocking || state.intervention?.type === issue.type) return false; state.intervention = { ...issue, at: Date.now(), taskKey: state.currentTaskKey || taskKey() }; const todo = currentRunningTodo(); if (todo) { todo.stage = 'waiting-intervention'; todo.error = issue.label; todo.updatedAt = Date.now(); saveTodos(); } pauseQueueForError(`${issue.label}:${issue.advice}`); return true; } function selectorSelfCheck(status) { const relevant = /studentstudy|chapterstudy|knowledge/i.test(location.pathname) && /课程|任务|视频|音频|题目/.test(compactText(document.body?.innerText || '')); if (status.discoveryPending) { state.selectorMisses = 0; if (state.lastError === '页面模板待适配') state.lastError = ''; return; } 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 = uniqueWorkersByAttachment(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 = uniqueWorkersByAttachment(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; result.auxiliaryTotal += Number(worker.auxiliary?.total || 0); result.auxiliaryCompleted += Number(worker.auxiliary?.completed || 0); result.auxiliaryPending += Number(worker.auxiliary?.pending || 0); 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, auxiliaryTotal: 0, auxiliaryCompleted: 0, auxiliaryPending: 0, issue: null, hasWork: false, busy: false }); const discoveredIdentities = new Set(workers.flatMap((worker) => worker.discovery?.identities || [])); summary.discoveryExpected = Math.max(0, ...workers.map((worker) => Number(worker.discovery?.expected || 0))); summary.discoverySeen = discoveredIdentities.size; summary.discoveryStableForMs = workers.length ? Math.min(...workers.map((worker) => Number(worker.discovery?.stableForMs || 0))) : 0; summary.discoveryPending = summary.discoveryExpected > summary.discoverySeen && summary.discoveryStableForMs < CONFIG.selectorGraceTime; if (summary.hasWork) state.seenWork = true; syncRunningTodoDisplayName(workers); 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) { invalidateOperations(); cancelTaskHandlers('queue-error'); state.queuePaused = true; state.enabled = false; storageApi.set(STORE.queuePaused, true); storageApi.set(STORE.enabled, false); revokeMediaPermit(); pauseLocalMedia(); state.lastError = reason; const todo = currentRunningTodo(); if (todo && todo.stage !== 'waiting-intervention') { todo.stage = 'failed'; todo.error = reason; todo.updatedAt = Date.now(); saveTodos(); } recordExecutionEvent('paused', { 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), stage: 'detected', 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() { state.todoRevision += 1; 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))); } /** * 标记待办上下文已变化。 * * 4.0.0 修复(REVIEW_CRITIQUE 第 4 节 S4、第 7 节 P0-3) * ---------------------------------------------------- * 旧实现是 `state.todos = []; saveTodos();` —— **清空并落盘**,然后提示 * 「请重新扫描」。而 1.6.0 起课程扫描就已下线、UI 里也没有扫描入口,于是这个 * 错误分支的实际效果是:把用户数据删掉,再指引用户去点一个不存在的按钮。 * * 更糟的是它的触发条件并不罕见:每个周期的 validateTodoContext 都会调用,而 * 账号/学期/平台/课程列表指纹任何一项变化都会命中。 * * 现在改为**只标记,不删除**: * - 队列数据完整保留,已完成记录、失败重试次数、截止时间都不丢; * - 暂停队列,避免在新账号下继续跑旧队列(这是原意,保留); * - 面板状态带显示原因,并指向真实存在的「从目录生成待办」按钮; * - 用户想清空时点「清空待办」,那里有显式二次确认。 */ function invalidateTodoContext(reason) { state.todos = state.todos.map((todo) => ({ ...todo, contextWarning: reason })); state.scanSession = { ...state.scanSession, status: 'context_changed', contextChangedReason: reason, contextChangedAt: Date.now() }; saveTodos(); saveScanSession(); pauseQueueForError(`待办上下文已变化:${reason}(数据已保留,可继续执行或重新生成待办)`); pushLog(`待办上下文变化:${reason}(已保留 ${state.todos.length} 条待办)`, true); updatePanel(); } 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}`); scheduleOperation(() => { const running = currentRunningTodo(); if (!state.enabled || state.queuePaused || running?.id !== todo.id) return; 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.stage = 'running'; todo.startedAt = Date.now(); saveTodos(); recordExecutionEvent('started'); } } 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.stage = 'completed'; todo.completedAt = Date.now(); todo.completionSource = completionSource; const summary = activeWorkerSummary(); todo.completionSummary = { durationMs: todo.startedAt ? Math.max(0, todo.completedAt - todo.startedAt) : 0, media: { completed: summary.mediaCompleted, total: summary.mediaTotal }, questions: { completed: summary.questionCompleted, total: summary.questionTotal }, auxiliary: { completed: summary.auxiliaryCompleted, total: summary.auxiliaryTotal } }; saveTodos(); recordExecutionEvent('completed', { todoId: todo.id, title: todo.title, stage: todo.stage, attachmentId: todo.attachmentId, reason: completionSource }); 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 currentTaskCompletionEvidence(todo = null) { const current = firstVisible(CONFIG.selectors.currentCatalog); let catalogComplete = Boolean(current && activeCourseTemplate().completion(current)); const catalogIncomplete = Boolean(current && hasIncompleteMarker(current)); const statusNode = firstVisible([ '.ans-job-finished', '.jobFinish', '.job-finished', '[data-status="completed"]', '[data-job-status="completed"]', '.courseTaskStatus', '.task-status-completed' ]); const bannerComplete = Boolean(statusNode) || Array.from(document.querySelectorAll('body *')).some((node) => node.children.length === 0 && isVisible(node) && /^(任务点|任务)已完成$/.test(compactText(node.textContent)) ); if (todo && !catalogComplete) { const titles = [todo.title, todo.originalTitle].map(normalize).filter(Boolean); const targetUrl = todo.url || ''; const entry = scanCatalogEntries().find((candidate) => (targetUrl && candidate.url === targetUrl) || titles.some((title) => normalize(candidate.title) === title) ); catalogComplete = Boolean(entry && activeCourseTemplate().completion(entry.node)); } const workers = uniqueWorkersByAttachment(Array.from(state.workers.values())); const attachments = workers.map((worker) => correlateAttachmentDescriptor(worker.attachment || {})) .filter((item) => item.identity && (item.job || item.passed)); const scoped = todo?.attachmentId ? attachments.filter((item) => item.identity === todo.attachmentId) : attachments; const attachmentComplete = scoped.length > 0 && scoped.every((item) => item.passed) && Date.now() - state.taskEnteredAt >= CONFIG.taskWarmupTime; const sources = []; if (attachmentComplete) sources.push('附件 isPassed'); if (catalogComplete) sources.push('目录标记'); if (bannerComplete) sources.push('页面提示'); const conflict = attachmentComplete && catalogIncomplete ? '附件已完成,但当前目录仍显示未完成' : ''; return { complete: attachmentComplete || catalogComplete || bannerComplete, source: sources.join(' + ') || '', conflict, attachmentComplete, catalogComplete, bannerComplete }; } function currentTaskMarkedComplete() { return currentTaskCompletionEvidence().complete; } function currentTodoMarkedComplete(todo) { if (!todo || !todoMatchesLocation(todo)) return false; return currentTaskCompletionEvidence(todo).complete; } function completeTodoFromEvidence(todo, fallbackSource) { const evidence = currentTaskCompletionEvidence(todo); if (!evidence.complete) return false; if (evidence.conflict && todo) { todo.completionConflict = evidence.conflict; todo.completionEvidence = { attachment: evidence.attachmentComplete, catalog: evidence.catalogComplete, banner: evidence.bannerComplete }; saveTodos(); pushLog(`完成状态冲突:${evidence.conflict}`, true, 'error'); } return completeRunningTodoAndAdvance(evidence.source || fallbackSource); } 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) { invalidateOperations(); cancelTaskHandlers('task-reset'); 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.pageSubmittedFingerprint = ''; state.answeredQuestions = new WeakSet(); state.submittedQuestions = new WeakSet(); state.media = new WeakMap(); state.ignoreWorkerUntil = Date.now() + ignoreMilliseconds; state.taskEnteredAt = Date.now(); state.finalState = null; state.intervention = null; } 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 finalKey = taskKey() || location.href; if (state.finalState?.taskKey === finalKey) return false; if (state.navigationMode === 'manual') { state.finalState = { status: 'manual', taskKey: finalKey, at: Date.now() }; pushLog('当前任务已完成,按设置停留在本页', true, 'navigation'); return false; } const entries = catalogEntries(); const current = currentCatalogEntry(entries); const index = entries.indexOf(current); const following = index >= 0 ? entries.slice(index + 1) : []; const catalogTarget = following.find(hasIncompleteMarker) || following.find((entry) => !hasCompletionMarker(entry)); const next = firstVisible(CONFIG.selectors.next); const target = state.navigationMode === 'incomplete' ? catalogTarget : next && !next.matches(':disabled, [aria-disabled="true"]') ? next : catalogTarget; if (!target) { state.finalState = { status: 'complete', taskKey: finalKey, at: Date.now() }; pushLog('已到达最后一个可执行任务点', true, 'navigation'); return false; } const clicked = clickElement(target); if (!clicked) return false; const usedNextButton = target === next; resetTaskProgress(1200); state.currentTaskKey = ''; state.lastNavigation = Date.now(); if (usedNextButton) { pushLog('进入下一个任务点'); } else { 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)) { completeTodoFromEvidence(runningTodo, '服务端确认'); return; } if (completedWork) { const confirmationKey = runningTodo.id || runningTodo.url || runningTodo.title; if (state.completionAwaitTaskKey !== confirmationKey) { state.completionAwaitTaskKey = confirmationKey; state.completionAwaitStartedAt = Date.now(); runningTodo.stage = 'waiting-confirm'; runningTodo.updatedAt = Date.now(); saveTodos(); recordExecutionEvent('waiting-confirm'); pushLog('本地处理完成,等待平台确认', true); } if (Date.now() - state.completionAwaitStartedAt >= CONFIG.serverCompletionTimeout) { const reason = '平台尚未确认任务完成'; pauseQueueForError(reason); } return; } } if (goToNextTask()) state.stableSince = 0; } /** * 把目录条目转成待办并合并进队列。 * * 合并规则: * - 已存在同 id 的待办保留其 status/stage/完成时间,只刷新标题等描述字段; * - 新增条目追加到末尾,courseOrder / taskOrder 沿用目录顺序; * - 已经是 completed 的任务不会被重新激活(这是旧版「已完成任务被误判为未完成」 * 问题的正面修复)。 */ function mergeTodos(existing, incoming) { const byId = new Map(); existing.forEach((todo) => { if (todo?.id) byId.set(todo.id, todo); }); let added = 0; let refreshed = 0; const merged = existing.map((todo) => ({ ...todo })); incoming.forEach((candidate) => { if (!candidate?.id) return; const previous = byId.get(candidate.id); if (!previous) { merged.push(candidate); byId.set(candidate.id, candidate); added += 1; return; } const target = merged.find((todo) => todo.id === candidate.id); if (!target) return; // 只覆盖描述性字段,状态类字段由原记录说了算。 const before = JSON.stringify([target.title, target.type, target.url, target.deadline]); target.title = candidate.title || target.title; target.type = candidate.type || target.type; target.url = candidate.url || target.url; target.deadline = candidate.deadline || target.deadline; target.contextKey = candidate.contextKey || target.contextKey; target.refreshedAt = Date.now(); if (JSON.stringify([target.title, target.type, target.url, target.deadline]) !== before) refreshed += 1; }); return { todos: merged, added, refreshed }; } /** * 从当前课程目录生成待办。 * * 这是「待办队列的生产者」。它必须能在两种页面工作: * - 课程详情页(目录已渲染,无附件注册表):用目录标记判断完成状态; * - 任务页(当前打开的是某个任务点):只生成当前课程未完成的任务。 * * 返回一份统计对象,供 UI 显示「新增 N 条」。 */ function generateTodosFromCatalog(options = {}) { if (!isController()) return { ok: false, error: '只有主控标签页可以生成待办' }; const entries = scanCatalogEntries(); if (!entries.length) { return { ok: false, error: '当前页面没有识别到课程目录,请先打开课程目录或课程详情页' }; } // 课程身份取自当前 URL;详情页没有 courseId 时退回页面标题。 const currentUrl = location.href; const courseId = courseIdentity(currentUrl); const courseName = compactText( document.querySelector('.course-title, .courseTitle, h1, .Mconright h3')?.textContent ) || compactText(document.title) || '当前课程'; const course = { id: courseId, name: courseName.slice(0, 160), url: currentUrl, order: 0 }; const contextKey = scanContextKey(state.scanSession.context || buildScanContext([])); const generated = extractPendingTodos(course, entries).filter((todo) => { // 已完成的任务点不再生成新待办(旧版本会重复生成)。 if (options.includeCompleted) return true; return todo.status === 'pending'; }); if (!generated.length) { return { ok: true, added: 0, refreshed: 0, total: state.todos.length, scanned: entries.length, note: '目录中所有任务点都已完成' }; } const { todos, added, refreshed } = mergeTodos(state.todos, generated); state.todos = todos; state.scanSession = { ...state.scanSession, status: 'manual', lastGenerateAt: Date.now(), context: state.scanSession.context || buildScanContext([]) }; saveTodos(); saveScanSession(); pushLog(`从目录生成待办:目录 ${entries.length} 项,新增 ${added} 条,刷新 ${refreshed} 条`, true, 'system'); updatePanel(); return { ok: true, added, refreshed, total: state.todos.length, scanned: entries.length }; } /** 导入一份待办 JSON(与 exportTodos 对称)。 */ function importTodosObject(payload) { const source = Array.isArray(payload) ? payload : payload?.tasks; if (!Array.isArray(source)) throw new Error('待办文件必须是数组,或是包含 tasks 数组的对象'); if (!source.length) throw new Error('待办文件没有记录'); if (source.length > DATA_LIMITS.bankEntries) throw new Error(`待办最多允许 ${DATA_LIMITS.bankEntries} 条`); const valid = source.filter((item) => item && typeof item === 'object' && (item.title || item.url)); if (!valid.length) throw new Error('待办文件没有可用记录(每条至少需要 title 或 url)'); const normalized = valid.map((item, index) => ({ schemaVersion: STORAGE_SCHEMA_VERSION, id: String(item.id || `todo-import-${simpleHash(`${item.url || ''}|${item.title || ''}|${index}`)}`), courseId: String(item.courseId || 'imported'), courseName: String(item.courseName || '导入的待办').slice(0, 160), courseOrder: Number.isFinite(Number(item.courseOrder)) ? Number(item.courseOrder) : 0, taskOrder: Number.isFinite(Number(item.taskOrder)) ? Number(item.taskOrder) : index, title: String(item.title || item.url).slice(0, 180), type: String(item.type || '任务点').slice(0, 20), stage: 'detected', url: String(item.url || '').slice(0, 2000), status: ['pending', 'running', 'completed', 'failed', 'skipped'].includes(item.status) ? item.status : 'pending', source: '导入', deadline: String(item.deadline || '').slice(0, 40), contextKey: '', scannedAt: Date.now(), completedAt: Number(item.completedAt) || 0 })); const { todos, added } = mergeTodos(state.todos, normalized); state.todos = todos; saveTodos(); pushLog(`待办导入 ${normalized.length} 条,新增 ${added} 条`, true, 'system'); updatePanel(); return { imported: normalized.length, added, total: state.todos.length }; } async function importTodosFile(file) { if (!file) return null; if (Number(file.size || 0) > DATA_LIMITS.bankFileBytes) throw new Error('待办文件不能超过 5 MB'); const parsed = JSON.parse(String(await file.text()).replace(/^\ufeff/, '')); const source = Array.isArray(parsed) ? parsed : parsed?.tasks; const count = Array.isArray(source) ? source.length : 0; if (typeof confirm === 'function' && !confirm(`准备导入 ${count} 条待办,将与现有队列合并(同 id 保留原状态)。\n\n继续?`)) { return { cancelled: true }; } return importTodosObject(parsed); } /** * 清空待办。 * * 这是不可逆操作,因此必须是**用户显式点击**才能到达,而不是某个后台分支的 * 副作用。旧版把它藏在 `invalidateTodoContext` 里,是本轮修复的第 3 项。 */ function clearTodos(reason = '用户清空') { const count = state.todos.length; state.todos = []; state.todoRevision += 1; saveTodos(); state.scanSession = { ...state.scanSession, status: 'manual', lastClearReason: reason, lastClearAt: Date.now() }; saveScanSession(); pushLog(`已清空 ${count} 条待办(${reason})`, true, 'system'); updatePanel(); return count; } /** 当前上下文是否与待办记录一致。不一致只用来提示,不再删数据。 */ function todoContextStatus() { const context = state.scanSession.context; if (!state.todos.length) return { ok: true, message: '' }; if (!context) return { ok: false, message: '待办缺少账号与学期信息,如需重建请点击「从目录生成待办」' }; const reason = scanContextMismatch(context); if (reason) return { ok: false, message: `${reason};待办已保留,可继续执行或生成新待办` }; const expectedKey = scanContextKey(context); const mismatched = state.todos.filter((todo) => todo.contextKey && todo.contextKey !== expectedKey).length; if (mismatched) return { ok: false, message: `${mismatched} 条待办属于其它账号或学期,已标记保留` }; return { ok: true, message: '' }; } 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(); } /** * 解析一段 JSON 文本,容忍 UTF-8 BOM。 * * 浏览器里 `File.text()` 按 Encoding Standard 解码时会自动剥掉 BOM,所以 * 「题库文件」按钮走的是安全路径。但**粘贴**进来的文本可能带 BOM:用户从 * 记事本或 PowerShell 导出的文件里复制时,U+FEFF 会跟着进剪贴板,而 * `JSON.parse` 遇到它会直接抛 "Unexpected token"。 * * 同一类问题在离线采集工具里是硬故障(Node 的 readFileSync 不剥 BOM,而 * Windows 生成 JSON 的常见做法都会写 BOM),那边已由 stripBom 处理。这里 * 在入口处统一剥一次,两侧行为保持一致。 */ function parseJsonText(raw) { return JSON.parse(String(raw ?? '').replace(/^\ufeff/, '')); } function importBank() { const raw = prompt('粘贴题库 JSON:{"题目":"答案"},多选题答案使用数组'); if (!raw) return; try { const parsed = parseJsonText(raw); const preview = previewBankImport(parsed); if (typeof confirm === 'function' && !confirm(`准备导入 ${preview.imported} 条:新增 ${preview.added},冲突覆盖 ${preview.conflicts},不变 ${preview.unchanged}。\n\n继续?`)) return; importBankObject(parsed); } catch (error) { alert(`题库 JSON 解析失败:${error.message}`); } } function prepareBankImport(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('题库文件没有记录'); if (entries.length > DATA_LIMITS.bankEntries) throw new Error(`题库最多允许 ${DATA_LIMITS.bankEntries} 条记录`); const validAnswer = (answer) => { const values = Array.isArray(answer) ? answer : [answer]; return values.length > 0 && values.every((value) => typeof value === 'string' || typeof value === 'number') && JSON.stringify(answer).length <= DATA_LIMITS.answerLength; }; const invalid = entries.filter(([question, answer]) => !normalize(question) || String(question).length > DATA_LIMITS.questionLength || !validAnswer(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; const added = entries.filter(([question]) => !Object.prototype.hasOwnProperty.call(state.bank, question)).length; return { envelope, records, entries, conflicts, added, unchanged: entries.length - conflicts - added }; } function previewBankImport(parsed) { const plan = prepareBankImport(parsed); return { schemaVersion: BANK_SCHEMA_VERSION, sourceSchemaVersion: Number(plan.envelope.schemaVersion || 0), imported: plan.entries.length, added: plan.added, conflicts: plan.conflicts, unchanged: plan.unchanged, totalAfter: Object.keys(state.bank).length + plan.added }; } function saveImportBackup(kind) { // 备份失败时终止导入,避免覆盖后留下不可回滚状态。 const payload = kind === 'bank' ? { bank: state.bank, unanswered: state.unanswered } : { settings: settingsPayload().settings }; const serialized = JSON.stringify({ schemaVersion: 1, kind, at: Date.now(), payload }); if (!storageApi.set(STORE.importBackup, serialized)) throw new Error('本地空间不足,导入已取消'); return true; } function importBankObject(parsed) { const plan = prepareBankImport(parsed); const { envelope, records, entries, conflicts } = plan; saveImportBackup('bank'); 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; if (Number(file.size || 0) > DATA_LIMITS.bankFileBytes) throw new Error('题库文件不能超过 5 MB'); const parsed = parseJsonText(await file.text()); const preview = previewBankImport(parsed); if (typeof confirm === 'function' && !confirm(`准备导入 ${preview.imported} 条:新增 ${preview.added},冲突覆盖 ${preview.conflicts},不变 ${preview.unchanged}。\n\n继续?`)) return { ...preview, cancelled: true }; return importBankObject(parsed); } 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 settingsPayload() { return { schemaVersion: SETTINGS_SCHEMA_VERSION, exportedAt: new Date().toISOString(), settings: { speed: state.speed, volume: state.volume, autoJump: state.autoJump, navigationMode: state.navigationMode, uiState: state.uiState } }; } function prepareSettingsImport(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('设置文件必须是 JSON 对象'); const source = payload.settings && typeof payload.settings === 'object' && !Array.isArray(payload.settings) ? payload.settings : payload; return { speed: clampSpeed(source.speed ?? state.speed), volume: Math.max(0, Math.min(1, Number(source.volume ?? state.volume) || 0)), autoJump: source.autoJump === undefined ? state.autoJump : toBoolean(source.autoJump), navigationMode: ['next', 'incomplete', 'manual'].includes(source.navigationMode) ? source.navigationMode : state.navigationMode, uiState: source.uiState && typeof source.uiState === 'object' && !Array.isArray(source.uiState) ? sanitizeUiState(source.uiState) : state.uiState }; } function previewSettingsImport(payload) { const next = prepareSettingsImport(payload); const current = settingsPayload().settings; const changed = Object.keys(next).filter((key) => JSON.stringify(next[key]) !== JSON.stringify(current[key])); return { schemaVersion: SETTINGS_SCHEMA_VERSION, changed, changedCount: changed.length, settings: next }; } function applyPreparedSettings(next) { state.speed = next.speed; state.volume = next.volume; state.autoJump = next.autoJump; state.navigationMode = next.navigationMode; state.uiState = next.uiState; storageApi.set(STORE.speed, state.speed); storageApi.set(STORE.volume, state.volume); storageApi.set(STORE.autoJump, state.autoJump); storageApi.set(STORE.navigationMode, state.navigationMode); saveUiState(); updatePanel(); } function importSettingsObject(payload) { const next = prepareSettingsImport(payload); saveImportBackup('settings'); applyPreparedSettings(next); recordExecutionEvent('settings-imported'); return settingsPayload(); } function exportSettings(download = true) { const text = JSON.stringify(settingsPayload(), null, 2); if (download) downloadText('chaoxing-helper-settings.json', text); return text; } async function importSettingsFile(file) { if (!file) return null; if (Number(file.size || 0) > DATA_LIMITS.settingsFileBytes) throw new Error('设置文件不能超过 256 KB'); const parsed = JSON.parse(String(await file.text()).replace(/^\ufeff/, '')); const preview = previewSettingsImport(parsed); if (typeof confirm === 'function' && !confirm(`设置文件将修改 ${preview.changedCount} 项:${preview.changed.join('、') || '无变化'}。\n\n继续?`)) return { ...preview, cancelled: true }; return importSettingsObject(parsed); } function rollbackLastImport() { const backup = loadObject(STORE.importBackup); if (!['bank', 'settings'].includes(backup.kind) || !backup.payload) return { restored: false, reason: '没有可撤销的导入' }; if (backup.kind === 'bank') { state.bank = backup.payload.bank && typeof backup.payload.bank === 'object' ? backup.payload.bank : {}; state.unanswered = backup.payload.unanswered && typeof backup.payload.unanswered === 'object' ? backup.payload.unanswered : {}; saveObject(STORE.bank, state.bank); saveObject(STORE.unanswered, state.unanswered); rebuildBankIndex(); } else { applyPreparedSettings(prepareSettingsImport({ settings: backup.payload.settings || {} })); } saveObject(STORE.importBackup, {}); recordExecutionEvent('import-rolled-back', { reason: backup.kind }); pushLog(backup.kind === 'bank' ? '已撤销最近一次题库导入' : '已撤销最近一次设置导入', true); updatePanel(); return { restored: true, kind: backup.kind }; } function sanitizeUiState(value) { const clean = { panelMinimized: Boolean(value.panelMinimized) }; if (value.todoWindow && typeof value.todoWindow === 'object' && !Array.isArray(value.todoWindow)) { clean.todoWindow = Object.fromEntries(['width', 'height', 'left', 'top', 'scrollTop'] .map((key) => [key, Number(value.todoWindow[key])]) .filter(([, number]) => Number.isFinite(number)) .map(([key, number]) => [key, Math.max(0, Math.min(10000, number))])); } if (value.collapsedCourses && typeof value.collapsedCourses === 'object' && !Array.isArray(value.collapsedCourses)) { clean.collapsedCourses = Object.fromEntries(Object.entries(value.collapsedCourses) .filter(([key, collapsed]) => key.length <= 200 && typeof collapsed === 'boolean') .slice(0, DATA_LIMITS.collapsedCourses)); } return clean; } function diagnosticPayload() { const workers = Array.from(state.workers.values()).map((worker) => ({ framePath: worker.framePath, attachment: redactDiagnosticValue(worker.attachment), media: redactDiagnosticValue(worker.media), questions: redactDiagnosticValue(worker.questions), auxiliary: redactDiagnosticValue(worker.auxiliary), discovery: redactDiagnosticValue(worker.discovery), 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, settings: SETTINGS_SCHEMA_VERSION, storage: STORAGE_SCHEMA_VERSION }, executionJournal: state.executionJournal.slice(0, 30).map((entry) => redactDiagnosticValue(entry)), failureSummary: executionFailureSummary().slice(0, 10), 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: storedLogs().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, seen = new WeakSet(), depth = 0) { if (depth > 8) return '[truncated]'; if (Array.isArray(value)) { if (seen.has(value)) return '[circular]'; seen.add(value); return value.map((item) => redactDiagnosticValue(item, seen, depth + 1)); } if (value && typeof value === 'object') { if (seen.has(value)) return '[circular]'; seen.add(value); return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactDiagnosticValue(item, seen, depth + 1)])); } return typeof value === 'string' ? redactDiagnosticText(value) : value; } function diagnosticPreview() { const logs = storedLogs(); const raw = [state.lastError, ...logs.slice(0, 30)].join('\n'); return { logLines: Math.min(30, 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 toggleBankManager(force) { const next = force === undefined ? !state.bankManagerOpen : Boolean(force); state.bankManagerOpen = next; if (next) { if (!state.bankWindow?.isConnected) createBankManagerWindow(); renderBankManager(); } state.bankWindow && (state.bankWindow.hidden = !next); const button = state.panel?.querySelector('.cxh-open-bank'); if (button) button.setAttribute('aria-expanded', next ? 'true' : 'false'); // 与其它视图互斥,避免三个浮窗叠在一起。 if (next) { toggleProgressWindow(false); toggleTodoWindow(false); toggleLogWindow(false); } return next; } /** 搜索题库。空查询返回前 N 条,便于浏览。 */ function searchBank(query, limit = CONFIG.bankRenderBatch) { const needle = normalizeQuestion(query); const entries = Object.entries(state.bank).filter(([question]) => !question.startsWith('_unanswered:')); if (!needle) { return { total: entries.length, items: entries.slice(0, limit).map(([question, answer]) => ({ question, answer, score: 1, strategy: 'browse' })) }; } const scored = []; entries.forEach(([question, answer]) => { const { score, strategy } = similarityScore(query, question); // 答案里也搜一遍:用户经常记得答案记不住题干。 const answerText = Array.isArray(answer) ? answer.join(' ') : String(answer ?? ''); const answerHit = normalizeQuestion(answerText).includes(needle) && needle.length >= 2; if (score >= 0.34 || answerHit) { scored.push({ question, answer, score: answerHit ? Math.max(score, 0.5) : score, strategy: answerHit ? 'answer' : strategy }); } }); scored.sort((left, right) => right.score - left.score); return { total: scored.length, items: scored.slice(0, limit) }; } /** 题库统计:条数、题型分布、碰撞数与复核队列长度。 */ function bankStats() { const questions = Object.keys(state.bank).filter((key) => !key.startsWith('_unanswered:')); const stats = { total: questions.length, single: 0, multiple: 0, fill: 0, other: 0, collisions: (state.bankCollisions || []).length, review: state.reviewQueue.length, unanswered: Object.keys(state.unanswered).length }; questions.forEach((question) => { const answer = state.bank[question]; if (Array.isArray(answer) && answer.length > 1) stats.multiple += 1; else if (String(answer ?? '').includes('|')) stats.fill += 1; else if (/^[A-H]{1,8}$/i.test(String(answer ?? '').replace(/[\s,,、]/g, ''))) stats.single += 1; else stats.other += 1; }); return stats; } /** 创建题库管理窗口(只创建一次,之后复用 DOM)。 */ function createBankManagerWindow() { if (!isController() || !document.body) return null; if (state.bankWindow?.isConnected) return state.bankWindow; const section = document.createElement('section'); section.id = 'cx-helper-bank-window'; section.hidden = true; section.setAttribute('aria-label', '题库管理'); section.innerHTML = `
题库管理
`; document.body.appendChild(section); state.bankWindow = section; section.querySelector('.cxh-bank-close').addEventListener('click', () => toggleBankManager(false)); section.querySelectorAll('[data-bank-tab]').forEach((button) => { button.addEventListener('click', () => { state.bankManagerTab = button.dataset.bankTab; renderBankManager(); }); }); let searchTimer = 0; section.querySelector('.cxh-bank-search').addEventListener('input', (event) => { state.bankSearch = event.target.value; state.bankRenderLimit = CONFIG.bankRenderBatch; window.clearTimeout(searchTimer); // 输入防抖:搜索会对整个题库做相似度计算,逐字符触发在大题库上会卡。 searchTimer = window.setTimeout(() => renderBankManager(), 220); }); section.querySelector('.cxh-bank-add').addEventListener('click', () => { const question = prompt('题干(必须与页面显示的题干一致,可用「未匹配题目」里的原文):'); if (!question) return; const answer = prompt('答案(多选用 A,C 或数组;多空用 | 分隔):'); if (!answer) return; const parsed = answer.includes('|') ? answer.split('|').map((item) => item.trim()) : answer; keepManualAnswer(question.trim(), parsed); pushLog(`题库新增:${question.slice(0, 32)}`, true, 'question'); }); section.querySelector('.cxh-provider-save').addEventListener('click', () => { const payload = { name: section.querySelector('.cxh-provider-name').value, url: section.querySelector('.cxh-provider-url').value, answerPath: section.querySelector('.cxh-provider-answer').value, questionPath: section.querySelector('.cxh-provider-question').value, scorePath: section.querySelector('.cxh-provider-score').value }; const result = upsertProvider(payload); if (!result.ok) { alert(`题库配置无效:${result.error}`); return; } // 保存后立即探测一次,让用户马上知道地址对不对。 probeProvider(result.provider).then((probe) => { alert(probe.ok ? `已保存并测试通过:${probe.elapsed}ms 返回“${String(probe.answer).slice(0, 40)}”` : `已保存,但测试未命中:${probe.error}(${probe.elapsed}ms)`); renderBankManager(); }); }); section.querySelector('.cxh-bank-export-all').addEventListener('click', () => { downloadText('xxt-bank.json', exportBankPayload()); }); section.querySelector('.cxh-bank-import-todos').addEventListener('click', () => section.querySelector('.cxh-bank-todo-file').click()); section.querySelector('.cxh-bank-todo-file').addEventListener('change', async (event) => { try { const result = await importTodosFile(event.target.files?.[0]); if (result && !result.cancelled) alert(`待办导入完成:新增 ${result.added} 条,当前共 ${result.total} 条`); } catch (error) { alert(`待办导入失败:${error.message}`); } event.target.value = ''; }); return section; } /** 渲染题库管理窗口。按当前 tab 只更新对应面板。 */ function renderBankManager() { const root = state.bankWindow; if (!root || !state.bankManagerOpen) return; const stats = bankStats(); root.querySelector('.cxh-bank-review-count').textContent = String(stats.review); root.querySelector('.cxh-bank-stats').textContent = `题库 ${stats.total} 条 · 单选 ${stats.single} · 多选 ${stats.multiple} · 填空 ${stats.fill} · 其它 ${stats.other}` + ` · 待复核 ${stats.review} · 未匹配 ${stats.unanswered}` + (stats.collisions ? ` · 归一化碰撞 ${stats.collisions}(已被合并,请检查)` : ''); root.querySelectorAll('[data-bank-tab]').forEach((button) => { button.setAttribute('aria-current', button.dataset.bankTab === state.bankManagerTab ? 'true' : 'false'); }); root.querySelectorAll('.cxh-bank-pane').forEach((pane) => { pane.hidden = pane.dataset.pane !== state.bankManagerTab; }); if (state.bankManagerTab === 'search') renderBankSearchPane(root); else if (state.bankManagerTab === 'review') renderBankReviewPane(root); else if (state.bankManagerTab === 'providers') renderBankProviderPane(root); else renderBankStatsPane(root); } function renderBankSearchPane(root) { const list = root.querySelector('.cxh-bank-list'); const { total, items } = searchBank(state.bankSearch, state.bankRenderLimit); list.replaceChildren(); if (!items.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = state.bankSearch ? '没有匹配的题目' : '题库为空,可导入题库文件或从采集产物导入'; list.appendChild(empty); return; } items.forEach((item) => { const row = document.createElement('div'); row.className = 'cxh-bank-row'; const question = document.createElement('div'); question.className = 'cxh-bank-question'; question.textContent = item.question; const answer = document.createElement('div'); answer.className = 'cxh-bank-answer'; answer.textContent = Array.isArray(item.answer) ? item.answer.join(' / ') : String(item.answer ?? ''); const meta = document.createElement('div'); meta.className = 'cxh-bank-meta'; meta.textContent = item.strategy === 'browse' ? '题库记录' : `匹配 ${(item.score * 100).toFixed(0)}% · ${item.strategy}`; const actions = document.createElement('div'); actions.className = 'cxh-bank-actions'; const edit = document.createElement('button'); edit.type = 'button'; edit.textContent = '编辑'; edit.addEventListener('click', () => { const next = prompt(`修改答案(当前:${answer.textContent})`, answer.textContent); if (next === null || !next.trim()) return; const parsed = next.includes('|') ? next.split('|').map((part) => part.trim()) : next.trim(); keepManualAnswer(item.question, parsed); pushLog(`题库更新:${item.question.slice(0, 32)}`, true, 'question'); }); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'cxh-bank-remove'; remove.textContent = '删除'; remove.addEventListener('click', () => { if (!confirm(`删除这道题?\n${item.question.slice(0, 80)}`)) return; forgetAnswer(item.question); pushLog(`题库删除:${item.question.slice(0, 32)}`, true, 'question'); }); actions.append(edit, remove); row.append(question, answer, meta, actions); list.appendChild(row); }); if (total > items.length) { const more = document.createElement('button'); more.type = 'button'; more.className = 'cxh-todo-load-more'; more.textContent = `加载更多(${items.length}/${total})`; more.addEventListener('click', () => { state.bankRenderLimit += CONFIG.bankRenderBatch; renderBankManager(); }); list.appendChild(more); } } function renderBankReviewPane(root) { const list = root.querySelector('.cxh-bank-review-list'); list.replaceChildren(); if (!state.reviewQueue.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '没有待复核的题目'; list.appendChild(empty); return; } // 相似度高的排前面:最可能是同一道题的先让用户确认。 [...state.reviewQueue].sort((a, b) => Number(b.score || 0) - Number(a.score || 0)).forEach((entry) => { const row = document.createElement('div'); row.className = 'cxh-bank-row'; const question = document.createElement('div'); question.className = 'cxh-bank-question'; question.textContent = `页面上:${entry.question}`; const candidate = document.createElement('div'); candidate.className = 'cxh-bank-answer'; candidate.textContent = `题库候选:${entry.candidate} → ${Array.isArray(entry.answer) ? entry.answer.join(' / ') : entry.answer}`; const meta = document.createElement('div'); meta.className = 'cxh-bank-meta'; meta.textContent = `相似度 ${(Number(entry.score || 0) * 100).toFixed(1)}% · ${entry.strategy} · 来源 ${entry.source}`; const actions = document.createElement('div'); actions.className = 'cxh-bank-actions'; const accept = document.createElement('button'); accept.type = 'button'; accept.textContent = '确认为同题'; accept.addEventListener('click', () => { // 把页面上看到的题干作为新键写入,答案沿用候选答案。 keepManualAnswer(entry.question, entry.answer); rejectReview(entry.key); pushLog(`复核通过:${entry.question.slice(0, 32)}`, true, 'question'); }); const reject = document.createElement('button'); reject.type = 'button'; reject.className = 'cxh-bank-remove'; reject.textContent = '不是同题'; reject.addEventListener('click', () => { rejectReview(entry.key); pushLog(`复核否决:${entry.question.slice(0, 32)}`, false, 'question'); }); actions.append(accept, reject); row.append(question, candidate, meta, actions); list.appendChild(row); }); } function renderBankProviderPane(root) { const list = root.querySelector('.cxh-provider-list'); list.replaceChildren(); if (!state.providers.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '未配置远程题库,当前只使用本地题库'; list.appendChild(empty); return; } state.providers.forEach((provider) => { const stat = state.providerStats[provider.name] || {}; const row = document.createElement('div'); row.className = 'cxh-provider-row'; const title = document.createElement('div'); title.className = 'cxh-bank-question'; title.textContent = `${provider.name} ${provider.enabled ? '' : '(已停用)'}`; const detail = document.createElement('div'); detail.className = 'cxh-bank-meta'; detail.textContent = `${provider.method} ${provider.url} · 答案 ${provider.answerPath}` + ` · 请求 ${stat.sent || 0} 成功 ${stat.ok || 0} 失败 ${stat.failed || 0}` + (stat.lastError ? ` · 最近错误:${stat.lastError}` : ''); const actions = document.createElement('div'); actions.className = 'cxh-bank-actions'; const test = document.createElement('button'); test.type = 'button'; test.textContent = '测试'; test.addEventListener('click', async () => { test.disabled = true; test.textContent = '测试中…'; const probe = await probeProvider(provider); test.disabled = false; test.textContent = '测试'; alert(probe.ok ? `连通正常:${probe.elapsed}ms,返回“${String(probe.answer).slice(0, 60)}”` : `未命中:${probe.error}(${probe.elapsed}ms)`); renderBankManager(); }); const toggle = document.createElement('button'); toggle.type = 'button'; toggle.textContent = provider.enabled ? '停用' : '启用'; toggle.addEventListener('click', () => toggleProvider(provider.name)); const pull = document.createElement('button'); pull.type = 'button'; pull.textContent = '拉取全库'; pull.addEventListener('click', async () => { if (!confirm(`将从 ${provider.url} 拉取整个题库并合并到本地(同题干覆盖)。\n继续?`)) return; pull.disabled = true; pull.textContent = '拉取中…'; const result = await fetchProviderBulk(provider); pull.disabled = false; pull.textContent = '拉取全库'; if (!result.ok) { alert(`拉取失败:${result.error}`); return; } try { const imported = importBankObject({ records: result.records }); alert(`已合并 ${imported.imported} 条,当前题库共 ${imported.total} 条`); } catch (error) { alert(`导入失败:${error.message}`); } }); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'cxh-bank-remove'; remove.textContent = '移除'; remove.addEventListener('click', () => { if (!confirm(`移除题库「${provider.name}」?`)) return; removeProvider(provider.name); }); actions.append(test, toggle, pull, remove); row.append(title, detail, actions); list.appendChild(row); }); } function renderBankStatsPane(root) { const stats = bankStats(); const detail = root.querySelector('.cxh-bank-detail'); detail.replaceChildren(); const rows = [ ['题库总数', `${stats.total} 条`], ['题型分布', `单选 ${stats.single} · 多选 ${stats.multiple} · 填空 ${stats.fill} · 其它 ${stats.other}`], ['未匹配题目', `${stats.unanswered} 条(可在控制面板导出后补充答案)`], ['待复核队列', `${stats.review} 条`], ['归一化碰撞', `${stats.collisions} 条(不同原文归一到同一键,已保留先写入的一条)`], ['远程题库', `${state.providers.length} 个,启用 ${state.providers.filter((item) => item.enabled).length} 个`], ['解析缓存', `${Object.keys(state.resolutionCache).length} 条(命中过的题干会缓存,重开浏览器不必重新请求)`], ['解析统计', `请求 ${state.resolutionStats.requested} · 命中 ${state.resolutionStats.resolved} · 失败 ${state.resolutionStats.error}`] ]; rows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'cxh-bank-stat-row'; const key = document.createElement('strong'); key.textContent = label; const val = document.createElement('span'); val.textContent = value; row.append(key, val); detail.appendChild(row); }); const crawl = root.querySelector('.cxh-bank-crawl'); crawl.replaceChildren(); const report = state.lastCrawlReport; if (report && report.fetched !== undefined) { const title = document.createElement('div'); title.className = 'cxh-bank-crawl-title'; title.textContent = '最近一次题库采集报告'; const body = document.createElement('div'); body.className = 'cxh-bank-meta'; const boundaries = Object.entries(report.boundaries || {}).map(([kind, count]) => `${kind}=${count}`).join(' ') || '无'; body.textContent = `请求 ${report.fetched} · 失败 ${report.failed} · 新增 ${report.newRecords} · 边界 ${boundaries}` + (report.finishedAt ? ` · ${report.finishedAt}` : ''); crawl.append(title, body); } else { const hint = document.createElement('div'); hint.className = 'cxh-bank-meta'; hint.textContent = '尚无采集报告。运行 node tools/bank-crawler.mjs 后,用「导入待办文件」导入产物 bank/bank-import.json。'; crawl.appendChild(hint); } } function compactText(value) { return String(value || '').replace(/[\r\n\t ]+/g, ' ').trim(); } function detectedMediaTask(workers = Array.from(state.workers.values())) { const ordered = [...workers].sort((left, right) => { const leftActive = left.id === state.mediaPermit.workerId ? 1 : 0; const rightActive = right.id === state.mediaPermit.workerId ? 1 : 0; if (leftActive !== rightActive) return rightActive - leftActive; const leftMedia = Number(left.media?.total || 0) > 0 ? 1 : 0; const rightMedia = Number(right.media?.total || 0) > 0 ? 1 : 0; if (leftMedia !== rightMedia) return rightMedia - leftMedia; return compareFramePaths(left.framePath, right.framePath); }); for (const worker of ordered) { const items = [...(worker.media?.items || [])].sort((left, right) => { const leftActive = worker.id === state.mediaPermit.workerId && left.index === state.mediaPermit.index ? 1 : 0; const rightActive = worker.id === state.mediaPermit.workerId && right.index === state.mediaPermit.index ? 1 : 0; return rightActive - leftActive || left.index - right.index; }); for (const item of items) { const title = cleanTaskDisplayName(item.title); if (title) return { title, type: item.type === 'audio' ? '音频' : '视频', workerId: worker.id, attachment: worker.attachment || {} }; } const title = cleanTaskDisplayName(worker.attachment?.title); const module = normalize(worker.attachment?.module); if (title && /video|audio|media|视频|音频/.test(module)) { return { title, type: /audio|音频/.test(module) ? '音频' : '视频', workerId: worker.id, attachment: worker.attachment || {} }; } } const localTitle = mediaTaskTitleFromDocument(); return localTitle ? { title: localTitle, type: document.querySelector('audio') ? '音频' : '视频', workerId: WORKER_ID, attachment: frameAttachmentDescriptor() } : null; } function syncRunningTodoDisplayName(workers) { if (!isController()) return false; const todo = currentRunningTodo(); const detected = detectedMediaTask(workers); if (!todo || !detected) return false; let changed = false; const titleChanged = detected.title && normalize(todo.title) !== normalize(detected.title); if (titleChanged) { if (!todo.originalTitle) todo.originalTitle = todo.title || ''; todo.title = detected.title; todo.type = detected.type; todo.titleSource = '视频附件'; todo.titleUpdatedAt = Date.now(); changed = true; } const attachment = correlateAttachmentDescriptor(detected.attachment || {}); for (const [key, value] of Object.entries({ attachmentId: attachment.identity, jobId: attachment.jobId, mid: attachment.mid, objectId: attachment.objectId })) { if (value && todo[key] !== value) { todo[key] = value; changed = true; } } if (!changed) return false; saveTodos(); if (titleChanged) pushLog(`识别${detected.type}名称:${detected.title}`, true, 'media'); return true; } function currentTaskTitle() { const mediaTask = detectedMediaTask(); if (mediaTask?.title) return mediaTask.title; const current = firstVisible(CONFIG.selectors.currentCatalog); const text = cleanTaskDisplayName(current?.getAttribute?.('title') || current?.textContent); const todoTitle = cleanTaskDisplayName(currentRunningTodo()?.title); return text || todoTitle || cleanTaskDisplayName(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(); const allWorkers = uniqueWorkersByAttachment(Array.from(state.workers.values())); const task = detectedMediaTask(allWorkers); const attachment = correlateAttachmentDescriptor(task?.attachment || allWorkers.find((worker) => worker.attachment?.identity)?.attachment || {}); root.querySelector('.cxh-current-meta').textContent = attachment.identity ? `${task?.type || attachment.module || '任务'} · ${attachment.identity} · ${attachment.registryMatched ? '附件已关联' : '页面识别'}` : ''; 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); root.querySelector('.cxh-auxiliary-value').textContent = `${summary.auxiliaryCompleted}/${summary.auxiliaryTotal}`; 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 = allWorkers.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.auxiliary?.completed || 0}/${worker.auxiliary?.total || 0} · 待处理 ${worker.questions.pending + Number(worker.auxiliary?.pending || 0)}`; row.append(header, location, details); workerList.appendChild(row); }); } const questionList = root.querySelector('.cxh-question-list'); questionList.replaceChildren(); const stageLabels = { discovered: '未匹配', matched: '已匹配', filled: '已填写', submitted: '已提交', confirmed: '已确认' }; const questionItems = workers.flatMap((worker) => worker.questions?.items || []); if (!questionItems.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '当前没有题目'; questionList.appendChild(empty); } else { questionItems.forEach((item) => { const row = document.createElement('div'); row.className = 'cxh-question-row'; const title = document.createElement('div'); title.className = 'cxh-question-title'; title.textContent = item.title || '未命名题目'; const stage = document.createElement('span'); stage.className = 'cxh-question-stage'; stage.textContent = stageLabels[item.stage] || item.stage || '等待'; row.append(title, stage); if (item.reason) { const reason = document.createElement('div'); reason.className = 'cxh-question-reason'; reason.textContent = item.reason; row.appendChild(reason); } questionList.appendChild(row); }); } const logList = root.querySelector('.cxh-log-list'); logList.replaceChildren(); const logs = 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); if (state.progressOpen && state.logOpen) toggleLogWindow(false); if (state.progressOpen && state.bankManagerOpen) toggleBankManager(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 lines = storedLogs().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; if (state.logOpen && state.progressOpen) toggleProgressWindow(false); if (state.logOpen && state.todoOpen) toggleTodoWindow(false); if (state.logOpen && state.bankManagerOpen) toggleBankManager(false); state.logWindow.hidden = !state.logOpen; state.panel?.querySelector('.cxh-open-log')?.setAttribute('aria-expanded', String(state.logOpen)); if (state.logOpen) renderLogWindow(); } function exportLogs() { const text = storedLogs().slice().reverse().join('\n'); downloadText('chaoxing-helper.log.txt', text, 'text/plain'); } function executionFailureSummary() { const groups = new Map(); const seen = new Set(); const add = (todoId, reason, at) => { const normalized = compactText(reason).slice(0, 240); if (!normalized) return; const occurrence = `${todoId || 'unknown'}|${normalized}`; if (seen.has(occurrence)) return; seen.add(occurrence); const group = groups.get(normalized) || { reason: normalized, count: 0, lastAt: 0, todoIds: [] }; group.count += 1; group.lastAt = Math.max(group.lastAt, Number(at) || 0); if (todoId && !group.todoIds.includes(todoId)) group.todoIds.push(todoId); groups.set(normalized, group); }; state.executionJournal.forEach((entry) => { if (entry.type === 'paused' && entry.reason) add(entry.todoId, entry.reason, entry.at); }); state.todos.forEach((todo) => { if ((todo.status === 'failed' || todo.stage === 'failed' || todo.error) && todo.error) { add(todo.id, todo.error, todo.updatedAt || todo.failedAt || 0); } }); return Array.from(groups.values()).sort((left, right) => right.count - left.count || right.lastAt - left.lastAt); } function exportExecutionJournal(download = true) { const text = JSON.stringify({ schemaVersion: EXECUTION_EXPORT_SCHEMA_VERSION, scriptVersion: SCRIPT_VERSION, exportedAt: new Date().toISOString(), scope: todoScopeKey(), failures: executionFailureSummary(), records: redactDiagnosticValue(state.executionJournal) }, null, 2); if (download) downloadText('chaoxing-execution-journal.json', text); return text; } function todoStatusText(status) { if (status === 'running') return '进行中'; if (status === 'completed') return '已完成'; if (status === 'failed') return '失败'; if (status === 'skipped') return '已跳过'; return '排队'; } function todoStageText(stage) { return ({ detected: '已识别', navigating: '正在打开', running: '处理中', 'waiting-confirm': '等待平台确认', 'waiting-intervention': '等待人工处理', completed: '已完成', failed: '失败', skipped: '已跳过' })[stage] || ''; } 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.stage = 'detected'; todo.error = ''; todo.failedAt = 0; saveTodos(); recordExecutionEvent('retried', { todoId: todo.id, title: todo.title, stage: todo.stage }); 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.stage = 'skipped'; todo.skippedAt = Date.now(); todo.completionSource = '用户手动跳过'; if (wasRunning) { state.queueNavigationAt = 0; state.currentTaskKey = ''; resetTaskProgress(0); } saveTodos(); recordExecutionEvent('skipped', { todoId: todo.id, title: todo.title, stage: todo.stage }); activateNextTodo(); pushLog(`已跳过:${todo.courseName} / ${todo.title}`, true); } function completionProgress(summary, key) { const group = summary && typeof summary === 'object' ? summary[key] : null; return { completed: Math.max(0, Number(group?.completed) || 0), total: Math.max(0, Number(group?.total) || 0) }; } function completionProgressText(summary, key) { const progress = completionProgress(summary, key); return `${progress.completed}/${progress.total}`; } 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 : '', mediaProgress: todo.completionSummary ? completionProgressText(todo.completionSummary, 'media') : '', questionProgress: todo.completionSummary ? completionProgressText(todo.completionSummary, 'questions') : '', auxiliaryProgress: todo.completionSummary ? completionProgressText(todo.completionSummary, 'auxiliary') : '', 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) => { let cell = String(value ?? ''); if (/^[\s]*[=+\-@]/.test(cell)) cell = `'${cell}`; return `"${cell.replace(/"/g, '""')}"`; }; const columns = ['course', 'chapter', 'type', 'status', 'deadline', 'source', 'completionSource', 'durationMs', 'mediaProgress', 'questionProgress', 'auxiliaryProgress', '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 list = root.querySelector('.cxh-todo-list'); // 无数据或筛选变化时复用现有任务行,避免每秒重建大列表。 const renderSignature = simpleHash(JSON.stringify({ revision: state.todoRevision, journalAt: state.executionJournal[0]?.at || 0, enabled: state.enabled, queuePaused: state.queuePaused, search: state.todoSearch, status: state.todoStatusFilter, type: state.todoTypeFilter, limit: state.todoRenderLimit, collapsed: state.uiState.collapsedCourses || {} })); if (state.todoRenderSignature === renderSignature && list.childNodes.length > 0) return; state.todoRenderSignature = renderSignature; state.todoRenderCount += 1; 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 failureNode = root.querySelector('.cxh-todo-failure-summary'); const failures = executionFailureSummary().slice(0, 3); failureNode.hidden = failures.length === 0; failureNode.textContent = failures.length ? `主要失败:${failures.map((item) => `${item.reason} ×${item.count}`).join(';')}` : ''; list.replaceChildren(); if (!sorted.length) { const empty = document.createElement('div'); empty.className = 'cxh-empty'; empty.textContent = '当前没有待办任务'; list.appendChild(empty); return; } const allGroups = new Map(); sorted.forEach((todo) => { const key = `${todo.courseOrder}:${todo.courseName}`; if (!allGroups.has(key)) allGroups.set(key, []); allGroups.get(key).push(todo); }); const groups = new Map(); sorted.slice(0, state.todoRenderLimit).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(allGroups.get(key) || 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 || '本地待办'; const stage = todoStageText(todo.stage); const completion = todo.completionSummary; const summaryText = completion ? ` · 媒体 ${completionProgressText(completion, 'media')} · 题目 ${completionProgressText(completion, 'questions')} · 其他 ${completionProgressText(completion, 'auxiliary')}` : ''; meta.textContent = `${todo.type || '任务点'} · ${todoStatusText(todo.status)}${stage ? ` · ${stage}` : ''} · 来源 ${source}${summaryText}${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); }); if (state.todoRenderLimit < sorted.length) { const more = document.createElement('button'); more.type = 'button'; more.className = 'cxh-todo-load-more'; more.textContent = `加载更多(${Math.min(state.todoRenderLimit, sorted.length)}/${sorted.length})`; more.addEventListener('click', () => { state.todoRenderLimit += CONFIG.todoRenderBatch; renderTodoWindow(); }); list.appendChild(more); } } function toggleTodoWindow(force) { if (!state.todoWindow) return; state.todoOpen = typeof force === 'boolean' ? force : !state.todoOpen; if (state.todoOpen && state.progressOpen) toggleProgressWindow(false); if (state.todoOpen && state.logOpen) toggleLogWindow(false); if (state.todoOpen && state.bankManagerOpen) toggleBankManager(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) { invalidateOperations(); cancelTaskHandlers('queue-paused'); 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 togglePanelMinimized(force) { if (!state.panel) return; const minimized = typeof force === 'boolean' ? force : !state.panel.classList.contains('is-minimized'); state.panel.classList.toggle('is-minimized', minimized); const button = state.panel.querySelector('.cxh-minimize'); if (button) { button.textContent = minimized ? '+' : '\u2212'; button.title = minimized ? '展开控制面板' : '最小化控制面板'; button.setAttribute('aria-label', button.title); button.setAttribute('aria-expanded', String(!minimized)); } state.uiState.panelMinimized = minimized; saveUiState(); } 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.bankWindow?.remove(); state.logWindow?.remove(); state.panel = null; state.progressWindow = null; state.todoWindow = null; state.bankWindow = 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,#cx-helper-panel *{box-sizing:border-box;letter-spacing:0} #cx-helper-panel{position:fixed;z-index:2147483647;right:16px;bottom:16px;width:312px;background:#fff;color:#273142;font:13px/1.5 Arial,'Microsoft YaHei',sans-serif;border:1px solid #cbd3df;border-radius:8px;box-shadow:0 12px 32px #1f29372b;overflow:hidden} #cx-helper-panel .cxh-panel-header{height:54px;display:flex;align-items:center;gap:10px;padding:9px 10px;background:#fff;border-bottom:1px solid #e5e9f0;user-select:none} #cx-helper-panel .cxh-brand-mark{display:grid;place-items:center;width:32px;height:32px;flex:0 0 32px;border-radius:6px;background:#1677ff;color:#fff;font-size:15px;font-weight:700} #cx-helper-panel .cxh-brand-copy{min-width:0;flex:1} #cx-helper-panel .cxh-brand-line{display:flex;align-items:center;gap:6px;min-width:0} #cx-helper-panel .cxh-title{font-size:14px;font-weight:700;color:#1f2937;white-space:nowrap} #cx-helper-panel .cxh-version{font-size:10px;font-weight:600;color:#64748b;background:#eef2f7;border-radius:3px;padding:1px 4px} #cx-helper-panel .cxh-subtitle{font-size:11px;color:#7b8798;margin-top:1px} #cx-helper-panel .cxh-minimize{display:grid;place-items:center;width:28px;height:28px;min-width:28px;padding:0;border:0;border-radius:4px;background:transparent;color:#667085;font:18px/1 Arial,sans-serif;cursor:pointer} #cx-helper-panel .cxh-minimize:hover{background:#f0f3f7;color:#1f2937} #cx-helper-panel .cxh-panel-body{padding:10px} #cx-helper-panel.is-minimized{width:218px} #cx-helper-panel.is-minimized .cxh-panel-body{display:none} #cx-helper-panel .cxh-status-band{display:grid;grid-template-columns:8px minmax(0,1fr);align-items:start;gap:8px;padding:8px 9px;background:#f6f8fb;border-left:3px solid #1677ff} #cx-helper-panel .cxh-status-dot{width:8px;height:8px;margin-top:6px;border-radius:50%;background:#98a2b3} #cx-helper-panel[data-run-state="running"] .cxh-status-dot{background:#16a34a;box-shadow:0 0 0 3px #dcfce7} #cx-helper-panel[data-run-state="error"] .cxh-status-dot{background:#dc2626;box-shadow:0 0 0 3px #fee2e2} #cx-helper-panel[data-run-state="readonly"] .cxh-status-dot{background:#d97706;box-shadow:0 0 0 3px #fef3c7} #cx-helper-panel .cxh-status{min-height:20px;color:#344054;word-break:break-word} #cx-helper-panel .cxh-progress{padding:7px 9px;color:#667085;border-bottom:1px solid #edf0f4;white-space:normal} #cx-helper-panel .cxh-nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;padding:9px 0;border-bottom:1px solid #edf0f4} #cx-helper-panel .cxh-nav button{display:block;width:100%;height:30px;min-width:0;min-height:0;margin:0;padding:4px 6px;border:1px solid transparent;border-radius:4px;background:transparent;color:#526075;font:12px/1 Arial,'Microsoft YaHei',sans-serif;cursor:pointer} #cx-helper-panel .cxh-nav button:hover{background:#f1f5f9;color:#1677ff} #cx-helper-panel .cxh-nav button[aria-expanded="true"]{background:#eaf3ff;border-color:#b9d7ff;color:#0969da;font-weight:700} #cx-helper-panel .cxh-section-label{margin:9px 0 5px;color:#98a2b3;font-size:11px;font-weight:700;text-transform:uppercase} #cx-helper-panel .cxh-row{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:34px;padding:4px 2px;color:#344054} #cx-helper-panel .cxh-speed,#cx-helper-panel .cxh-navigation-mode{width:118px;height:28px;background:#f7f9fc;color:#273142;border:1px solid #cbd3df;border-radius:4px;padding:3px 7px;outline:none} #cx-helper-panel .cxh-speed{width:72px} #cx-helper-panel .cxh-speed:focus,#cx-helper-panel .cxh-navigation-mode:focus{border-color:#1677ff;box-shadow:0 0 0 2px #dbeafe} #cx-helper-panel .cxh-volume-control{display:flex;align-items:center;gap:7px;width:138px} #cx-helper-panel .cxh-volume{width:96px;accent-color:#1677ff} #cx-helper-panel .cxh-volume-value{width:34px;color:#667085;font-size:11px;text-align:right} #cx-helper-panel .cxh-auto-jump{appearance:none;width:36px;height:20px;flex:0 0 36px;margin:0;padding:2px;border:0;border-radius:10px;background:#cbd5e1;cursor:pointer;transition:background .15s ease} #cx-helper-panel .cxh-auto-jump::before{display:block;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0003;content:'';transition:transform .15s ease} #cx-helper-panel .cxh-auto-jump:checked{background:#1677ff} #cx-helper-panel .cxh-auto-jump:checked::before{transform:translateX(16px)} #cx-helper-panel .cxh-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} #cx-helper-panel .cxh-actions button{display:inline-flex;align-items:center;justify-content:center;width:100%;height:30px;min-width:0;min-height:0;margin:0;padding:4px 7px;border:1px solid #cbd3df;border-radius:4px;background:#fff;color:#475467;font:12px/1 Arial,'Microsoft YaHei',sans-serif;cursor:pointer} #cx-helper-panel .cxh-actions button:hover{border-color:#79b3ff;background:#f3f8ff;color:#0969da} #cx-helper-panel .cxh-actions .cxh-toggle{grid-column:1/-1;height:34px;border-color:#1677ff;background:#1677ff;color:#fff;font-weight:700} #cx-helper-panel .cxh-actions .cxh-toggle:hover{background:#0969da;color:#fff} #cx-helper-progress-window,#cx-helper-todo-window,#cx-helper-log-window{letter-spacing:0} #cx-helper-progress-window{box-sizing:border-box;position:fixed;z-index:2147483646;right:344px;bottom:16px;width:min(460px,calc(100vw - 376px));max-height:min(700px,calc(100vh - 32px));overflow:auto;padding:14px;background:#fff;color:#273142;font:13px/1.5 Arial,'Microsoft YaHei',sans-serif;border:1px solid #cbd3df;border-radius:8px;box-shadow:0 16px 38px #1f293733} #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:15px;font-weight:700;color:#1f2937} #cx-helper-progress-window .cxh-close{width:28px;height:28px;padding:0;border:0;border-radius:4px;background:#f1f4f8;color:#667085;font-size:19px;line-height:28px;cursor:pointer} #cx-helper-progress-window .cxh-task-label,#cx-helper-progress-window .cxh-section-title{color:#98a2b3;font-size:11px;font-weight:700;margin-bottom:4px} #cx-helper-progress-window .cxh-current-task{font-weight:700;margin-bottom:12px;overflow-wrap:anywhere} #cx-helper-progress-window .cxh-current-meta{color:#7b8798;font-size:11px;margin-top:-8px;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:6px;background:#e8edf3;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:#1677ff} #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:#667085} #cx-helper-progress-window .cxh-counts strong{color:#1f2937} #cx-helper-progress-window .cxh-media-time{padding:7px 9px;margin-bottom:8px;background:#f3f7fc;color:#344054;border-left:3px solid #1677ff} #cx-helper-progress-window .cxh-issue{padding:8px 9px;margin-bottom:8px;background:#fff1f2;color:#b42318;border:1px solid #fecdd3;border-radius:4px} #cx-helper-progress-window .cxh-issue[hidden]{display:none} #cx-helper-progress-window .cxh-section{border-top:1px solid #e5e9f0;padding-top:11px;margin-top:11px} #cx-helper-progress-window .cxh-worker-row{padding:8px 0;border-bottom:1px solid #edf0f4} #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:11px;padding:2px 6px;border-radius:3px;background:#eef2f7;color:#526075} #cx-helper-progress-window .cxh-state.is-busy{background:#fff4d6;color:#9a6700} #cx-helper-progress-window .cxh-state.is-ready{background:#dcfce7;color:#18794e} #cx-helper-progress-window .cxh-state.is-idle{background:#eef2f7;color:#667085} #cx-helper-progress-window .cxh-worker-location{color:#7b8798;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #cx-helper-progress-window .cxh-worker-details{color:#526075;margin-top:2px} #cx-helper-progress-window .cxh-log-item{padding:4px 0;border-bottom:1px solid #edf0f4;overflow-wrap:anywhere} #cx-helper-progress-window .cxh-question-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:6px 0;border-bottom:1px solid #edf0f4} #cx-helper-progress-window .cxh-question-title{overflow-wrap:anywhere} #cx-helper-progress-window .cxh-question-stage{align-self:start;font-size:11px;padding:2px 6px;border-radius:3px;background:#eef2f7;color:#526075} #cx-helper-progress-window .cxh-question-reason{grid-column:1/-1;color:#b42318;font-size:11px} #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:#fff;color:#475467;border:1px solid #cbd3df;border-radius:4px;padding:4px 7px} #cx-helper-progress-window .cxh-empty{color:#98a2b3;padding:8px 0} #cx-helper-progress-window .cxh-updated-at{color:#98a2b3;font-size:11px;margin-top:10px;text-align:right} #cx-helper-todo-window{box-sizing:border-box;position:fixed;z-index:2147483646;right:344px;bottom:16px;width:min(560px,calc(100vw - 376px));height:min(720px,calc(100vh - 32px));min-width:360px;min-height:300px;resize:both;overflow:auto;padding:14px;background:#fff;color:#273142;font:13px/1.5 Arial,'Microsoft YaHei',sans-serif;border:1px solid #cbd3df;border-radius:8px;box-shadow:0 16px 38px #1f293733} #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:15px;font-weight:700;color:#1f2937} #cx-helper-todo-window .cxh-todo-close{width:28px;height:28px;padding:0;border:0;border-radius:4px;background:#f1f4f8;color:#667085;font-size:19px;line-height:28px;cursor:pointer} #cx-helper-todo-window .cxh-todo-scan-status{color:#7b8798;margin-bottom:10px} #cx-helper-todo-window .cxh-todo-summary{display:flex;gap:18px;padding:9px 0;border-top:1px solid #e5e9f0;border-bottom:1px solid #e5e9f0;color:#667085} #cx-helper-todo-window .cxh-todo-summary strong{color:#1f2937} #cx-helper-todo-window .cxh-todo-failure-summary{padding:7px 0;color:#b42318;border-bottom:1px solid #fee2e2;overflow-wrap:anywhere} #cx-helper-todo-window .cxh-todo-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0} #cx-helper-todo-window .cxh-todo-load-more{display:block;width:100%;height:34px;margin:10px 0;border:1px solid #b9d7ff;border-radius:4px;background:#f3f8ff;color:#0969da;cursor:pointer} #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:1px solid #b9d7ff;border-radius:4px;background:#f3f8ff;color:#0969da;font:12px/1 Arial,'Microsoft YaHei',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:#f7f9fc;color:#273142;border:1px solid #cbd3df;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:#344054;border-bottom:1px solid #dfe5ec;cursor:pointer} #cx-helper-todo-window .cxh-todo-course-summary{padding:5px 0;color:#7b8798;font-size:12px;overflow-wrap:anywhere} #cx-helper-todo-window .cxh-todo-course-title::before{content:'▾ ';color:#98a2b3} #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 #edf0f4} #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:#7b8798;font-size:12px;margin-top:2px} #cx-helper-todo-window .cxh-todo-status{font-size:11px;padding:2px 6px;border-radius:3px;background:#eef2f7;color:#526075} #cx-helper-todo-window .cxh-todo-status.is-running{background:#fff4d6;color:#9a6700} #cx-helper-todo-window .cxh-todo-status.is-completed{background:#dcfce7;color:#18794e} #cx-helper-todo-window .cxh-empty{color:#98a2b3;padding:14px 0} #cx-helper-log-window{box-sizing:border-box;position:fixed;z-index:2147483646;right:344px;bottom:16px;width:min(640px,calc(100vw - 376px));height:min(540px,calc(100vh - 32px));display:flex;flex-direction:column;background:#fff;color:#344054;font:13px/1.65 Consolas,'Microsoft YaHei',monospace;border:1px solid #cbd3df;border-radius:8px;box-shadow:0 16px 38px #1f293733;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:#f7f9fc;color:#344054;border-bottom:1px solid #dfe5ec;font-family:Arial,'Microsoft YaHei',sans-serif} #cx-helper-log-window .cxh-live-title{font-size:15px;font-weight:700} #cx-helper-log-window .cxh-live-actions{display:flex;gap:6px} #cx-helper-log-window button{border:1px solid #cbd3df;border-radius:4px;background:#fff;color:#475467;padding:4px 8px;cursor:pointer} #cx-helper-log-window .cxh-log-output{flex:1;overflow:auto;padding:9px 10px;scrollbar-color:#aeb8c5 #f1f4f8} #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:#98a2b3;flex:0 0 auto} #cx-helper-log-window .cxh-live-label{color:#0969da;flex:0 0 auto} #cx-helper-log-window .cxh-live-log.is-error .cxh-live-label{color:#dc2626} #cx-helper-log-window .cxh-live-log.is-media .cxh-live-label{color:#526075} #cx-helper-bank-window{box-sizing:border-box;position:fixed;z-index:2147483646;right:344px;bottom:16px;width:min(680px,calc(100vw - 376px));height:min(720px,calc(100vh - 32px));display:flex;flex-direction:column;padding:14px;background:#fff;color:#273142;font:13px/1.5 Arial,'Microsoft YaHei',sans-serif;border:1px solid #cbd3df;border-radius:8px;box-shadow:0 16px 38px #1f293733} #cx-helper-bank-window[hidden]{display:none} #cx-helper-bank-window .cxh-bank-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;margin:10px 0 8px;padding-bottom:8px;border-bottom:1px solid #e5e9f0} #cx-helper-bank-window .cxh-bank-tabs button{height:28px;min-width:0;padding:4px 6px;border:1px solid transparent;border-radius:4px;background:transparent;color:#526075;font:12px/1 Arial,'Microsoft YaHei',sans-serif;cursor:pointer} #cx-helper-bank-window .cxh-bank-tabs button:hover{background:#f1f5f9;color:#1677ff} #cx-helper-bank-window .cxh-bank-tabs button[aria-current="true"]{background:#eaf3ff;border-color:#b9d7ff;color:#0969da;font-weight:700} #cx-helper-bank-window .cxh-bank-stats{padding:7px 9px;margin-bottom:9px;background:#f6f8fb;color:#475467;border-left:3px solid #1677ff;overflow-wrap:anywhere} #cx-helper-bank-window .cxh-bank-pane{flex:1;overflow:auto;min-height:0} #cx-helper-bank-window .cxh-bank-pane[hidden]{display:none} #cx-helper-bank-window .cxh-bank-searchbar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;margin-bottom:8px} #cx-helper-bank-window .cxh-bank-searchbar input{min-width:0;background:#f7f9fc;color:#273142;border:1px solid #cbd3df;border-radius:4px;padding:6px 8px} #cx-helper-bank-window .cxh-bank-row{padding:9px 0;border-bottom:1px solid #edf0f4} #cx-helper-bank-window .cxh-bank-question{font-weight:600;color:#1f2937;overflow-wrap:anywhere} #cx-helper-bank-window .cxh-bank-answer{color:#0969da;margin-top:3px;overflow-wrap:anywhere} #cx-helper-bank-window .cxh-bank-meta{color:#7b8798;font-size:11px;margin-top:3px;overflow-wrap:anywhere} #cx-helper-bank-window .cxh-bank-actions{display:flex;gap:6px;margin-top:6px} #cx-helper-bank-window .cxh-bank-actions button{height:26px;padding:3px 9px;border:1px solid #b9d7ff;border-radius:4px;background:#f3f8ff;color:#0969da;font:12px/1 Arial,'Microsoft YaHei',sans-serif;cursor:pointer} #cx-helper-bank-window .cxh-bank-actions button.cxh-bank-remove{border-color:#fecaca;background:#fff1f2;color:#b42318} #cx-helper-bank-window .cxh-bank-actions button:disabled{opacity:.5;cursor:default} #cx-helper-bank-window .cxh-bank-review-hint,#cx-helper-bank-window .cxh-provider-hint{padding:7px 9px;margin-bottom:9px;background:#fff7ed;color:#9a6700;border-left:3px solid #f59e0b;font-size:12px} #cx-helper-bank-window .cxh-provider-form{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:9px} #cx-helper-bank-window .cxh-provider-form input{min-width:0;background:#f7f9fc;color:#273142;border:1px solid #cbd3df;border-radius:4px;padding:6px 8px;font-size:12px} #cx-helper-bank-window .cxh-provider-form button{grid-column:1/-1;height:30px;border:1px solid #1677ff;border-radius:4px;background:#1677ff;color:#fff;font-weight:700;cursor:pointer} #cx-helper-bank-window .cxh-provider-row{padding:9px 0;border-bottom:1px solid #edf0f4} #cx-helper-bank-window .cxh-bank-stat-row{display:flex;gap:10px;justify-content:space-between;padding:6px 0;border-bottom:1px solid #edf0f4;overflow-wrap:anywhere} #cx-helper-bank-window .cxh-bank-crawl-title{font-weight:700;color:#344054;margin:12px 0 4px} #cx-helper-bank-window .cxh-bank-danger{display:flex;gap:7px;margin-top:14px;padding-top:12px;border-top:1px solid #e5e9f0} #cx-helper-bank-window .cxh-bank-danger button{height:30px;padding:4px 10px;border:1px solid #cbd3df;border-radius:4px;background:#fff;color:#475467;cursor:pointer} @media(max-width:760px){#cx-helper-panel{right:10px;bottom:10px;width:min(312px,calc(100vw - 20px))}#cx-helper-panel.is-minimized{width:218px}#cx-helper-progress-window,#cx-helper-todo-window,#cx-helper-bank-window,#cx-helper-log-window{z-index:2147483647;left:10px;right:10px;top:10px;bottom:10px;width:auto;height:auto;min-width:0;max-height:none}#cx-helper-progress-window .cxh-overview{grid-template-columns:1fr}#cx-helper-todo-window .cxh-todo-filters{grid-template-columns:1fr 1fr}#cx-helper-todo-window .cxh-todo-filters input{grid-column:1/-1}#cx-helper-todo-window .cxh-todo-item{grid-template-columns:minmax(0,1fr) auto}#cx-helper-todo-window .cxh-todo-open{grid-column:2}#cx-helper-bank-window .cxh-bank-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}#cx-helper-bank-window .cxh-provider-form{grid-template-columns:1fr}} `; document.documentElement.appendChild(style); } const panel = document.createElement('div'); panel.id = 'cx-helper-panel'; panel.innerHTML = `
学
学习通课程助手
v${SCRIPT_VERSION}
课程任务控制台
运行设置
任务操作
`; 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阅读/PPT 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-volume').addEventListener('input', (event) => { state.volume = Math.max(0, Math.min(1, Number(event.target.value) / 100)); storageApi.set(STORE.volume, state.volume); }); 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-navigation-mode').addEventListener('change', (event) => { state.navigationMode = event.target.value; state.finalState = null; storageApi.set(STORE.navigationMode, state.navigationMode); }); panel.querySelector('.cxh-toggle').addEventListener('click', toggleEnabled); panel.querySelector('.cxh-minimize').addEventListener('click', () => togglePanelMinimized()); 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-open-bank').addEventListener('click', () => toggleBankManager()); // 4.0.0:待办队列的生产入口。3.5.0 的致命缺陷是队列没有任何生产者, // 这两个按钮是修复的核心部分。 panel.querySelector('.cxh-generate-todos').addEventListener('click', () => { const result = generateTodosFromCatalog(); if (!result.ok) { alert(`生成待办失败:${result.error}`); return; } alert(result.added || result.refreshed ? `已扫描目录 ${result.scanned} 项:新增 ${result.added} 条,刷新 ${result.refreshed} 条,待办共 ${result.total} 条` : `目录中 ${result.scanned} 项都已处理:${result.note || '没有新的待办'}`); updatePanel(); }); panel.querySelector('.cxh-import-todos').addEventListener('click', () => panel.querySelector('.cxh-todo-file').click()); panel.querySelector('.cxh-todo-file').addEventListener('change', async (event) => { try { const result = await importTodosFile(event.target.files?.[0]); if (result && !result.cancelled) alert(`待办导入完成:新增 ${result.added} 条,当前共 ${result.total} 条`); } catch (error) { alert(`待办导入失败:${error.message}`); } event.target.value = ''; }); panel.querySelector('.cxh-clear-todos').addEventListener('click', () => { if (!state.todos.length) { alert('当前没有待办'); return; } // 清空是不可逆操作,必须显式确认。旧版把它藏在 contextKey 校验失败分支里 // 自动执行,是本轮修复的缺陷之一。 if (!confirm(`确认清空全部 ${state.todos.length} 条待办?此操作不可撤销。\n(已完成的记录也会一起删除)`)) return; clearTodos('用户确认清空'); updatePanel(); }); 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); panel.querySelector('.cxh-export-settings').addEventListener('click', () => exportSettings(true)); panel.querySelector('.cxh-import-settings').addEventListener('click', () => panel.querySelector('.cxh-settings-file').click()); panel.querySelector('.cxh-rollback-import').addEventListener('click', () => rollbackLastImport()); panel.querySelector('.cxh-settings-file').addEventListener('change', async (event) => { try { await importSettingsFile(event.target.files?.[0]); } catch (error) { alert(`设置导入失败:${error.message}`); } event.target.value = ''; }); 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; state.todoRenderLimit = CONFIG.todoRenderBatch; renderTodoWindow(); }); todoWindow.querySelector('.cxh-todo-status-filter').addEventListener('change', (event) => { state.todoStatusFilter = event.target.value; state.todoRenderLimit = CONFIG.todoRenderBatch; renderTodoWindow(); }); todoWindow.querySelector('.cxh-todo-type-filter').addEventListener('change', (event) => { state.todoTypeFilter = event.target.value; state.todoRenderLimit = CONFIG.todoRenderBatch; renderTodoWindow(); }); todoWindow.querySelector('.cxh-export-todo-json').addEventListener('click', () => exportTodos('json')); todoWindow.querySelector('.cxh-export-todo-csv').addEventListener('click', () => exportTodos('csv')); todoWindow.querySelector('.cxh-export-execution').addEventListener('click', () => exportExecutionJournal(true)); 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 = () => { if (!todoWindow.isConnected) return; 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(); }; let todoLayoutTimer = 0; const scheduleTodoLayoutSave = () => { window.clearTimeout(todoLayoutTimer); todoLayoutTimer = window.setTimeout(persistTodoLayout, 150); }; 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', scheduleTodoLayoutSave, { passive: true }); state.panel = panel; state.progressWindow = progressWindow; state.todoWindow = todoWindow; state.logWindow = logWindow; togglePanelMinimized(Boolean(state.uiState.panelMinimized)); 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-volume').value = String(Math.round(state.volume * 100)); state.panel.querySelector('.cxh-volume-value').textContent = `${Math.round(state.volume * 100)}%`; state.panel.querySelector('.cxh-auto-jump').checked = state.autoJump; state.panel.querySelector('.cxh-navigation-mode').value = state.navigationMode; state.panel.querySelector('.cxh-rollback-import').disabled = !['bank', 'settings'].includes(loadObject(STORE.importBackup).kind); state.panel.querySelector('.cxh-status').textContent = !state.tabPrimary ? '只读标签页:另一标签正在执行队列' : state.lastError ? `异常:${state.lastError}` : state.lastLog || (state.enabled ? '运行中' : '已暂停'); state.panel.dataset.runState = !state.tabPrimary ? 'readonly' : state.lastError ? 'error' : state.enabled ? 'running' : 'paused'; const course = courseProgress(); const todoPending = state.todos.filter((todo) => todo.status === 'pending').length; const todoRunning = state.todos.filter((todo) => todo.status === 'running').length; // 4.0.0:状态带里补上「上下文是否仍然一致」的提示,以及题库条数。 // 旧版在 contextKey 不匹配时直接清空待办并提示「请重新扫描」,而 UI 里没有 // 扫描入口;现在改为不删数据、只提示,并指向真实存在的按钮。 const contextStatus = todoContextStatus(); const contextSuffix = contextStatus.ok ? '' : ` · ${contextStatus.message}`; state.panel.querySelector('.cxh-progress').textContent = `待办 ${todoRunning}/${todoPending + todoRunning} · 任务 ${course.completed}/${course.total}` + ` · 媒体 ${summary.mediaCompleted}/${summary.mediaTotal}` + ` · 题库 ${Object.keys(state.bank).length}${state.reviewQueue.length ? `(待复核 ${state.reviewQueue.length})` : ''}` + contextSuffix; renderProgressWindow(summary); renderTodoWindow(); renderLogWindow(); if (state.bankManagerOpen) renderBankManager(); } function isRelatedFrameSource(source) { if (!source || source === window) return false; if (window.parent !== window && source === window.parent) return true; try { for (let index = 0; index < window.frames.length; index += 1) { if (window.frames[index] === source) return true; } } catch (_) { return false; } return false; } function onMessage(event) { const data = event.data; if (!data || data.type !== MESSAGE_TYPE || !isRelatedFrameSource(event.source)) 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(summary); handleBlockingIssue(summary); 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 { ensureDomObserver(); 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)) { completeTodoFromEvidence(activeTodo, '页面标记'); updatePanel(activeWorkerSummary()); return; } if (state.enabled) { const status = buildWorkerStatus(); publishWorkerStatus(status); } else { cancelTaskHandlers('disabled'); publishWorkerStatus(emptyWorkerStatus()); } if (isController()) { jumpFromCourseDetail(); selectMediaPermit(); const summary = activeWorkerSummary(); selectorSelfCheck(summary); handleBlockingIssue(summary); handleMediaFailure(summary); maybeAdvance(summary); updatePanel(summary); } } catch (error) { reportError(error); } finally { state.running = false; } } function boot() { // 4.0.0:题库索引与旧键迁移从模块求值期移到这里。 // 新索引依赖 16-text-match.js 的顶层常量,而 10-lifecycle.js(原本的调用点) // 在它之前求值,在那个位置调用会命中暂时性死区。放进 boot 既修掉顺序问题, // 也让「迁移只由实际启动的页面执行一次」这一约束自然成立。 rebuildBankIndex(); migrateLegacyUnanswered(); // 读取用户配置的远程题库。必须在首个 runCycle 之前完成,否则第一轮会因为 // state.providers 为空而把题目直接判成「未配置远程题库 → 未匹配」。 initializeProviders(); disableLegacyScannerState(); ensureDomObserver(); initializeTabCoordination(); 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 < STORAGE_SCHEMA_VERSION) { state.todos = state.todos.map((todo, index) => ({ ...todo, schemaVersion: STORAGE_SCHEMA_VERSION, id: todo.id || `todo-${index}-${simpleHash(todo.url || todo.title)}`, status: ['pending', 'running', 'completed', 'failed', 'skipped'].includes(todo.status) ? todo.status : 'pending', stage: todo.stage || (todo.status === 'completed' ? 'completed' : todo.status === 'running' ? 'running' : 'detected') })); saveTodos(); storageApi.set(STORE.schema, STORAGE_SCHEMA_VERSION); } 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, STORE.executionJournal, STORE.importBackup] .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, exportExecutionJournal, executionFailureSummary, settingsPayload, importSettingsObject, exportSettings, recoverExecutionState, resetTaskProgress, 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, previewBankImport, exportBankPayload, previewSettingsImport, rollbackLastImport, activeBankProvider, exportDiagnostic, diagnosticPreview, summarizeCourseTodos, adapterHealth: () => ({ ...state.adapterHealth }), buildWorkerStatus, frameAttachmentDescriptor, pageAttachmentDescriptors, currentTaskCompletionEvidence, activeWorkerSummary, goToNextTask, invalidateOperations, taskHandlers: () => TASK_HANDLERS.map((handler) => ({ id: handler.id, apiVersion: handler.apiVersion, priority: handler.priority, methods: ['detect', 'run', 'status', 'cancel'].filter((method) => typeof handler[method] === 'function') })), migrateStorage, classifyPageIssue, // ---- 4.0.0 新增测试面 ---- // 待办生产入口(P0 修复的验证点) generateTodosFromCatalog, importTodosObject, clearTodos, todoContextStatus, // 匹配引擎与 S1 填空修复 matchQuestion: (question) => { const match = findLocalBankAnswer(question); return match ? { ...match, answer: match.answer } : null; }, planFillInBlank, similarityScore, normalizeQuestion, inferQuestionShape, crossCheckMatch, bankCollisions: () => (state.bankCollisions || []).map((item) => ({ ...item })), // 异步解析器 resolveQuestion, resolutionCacheSize: () => Object.keys(state.resolutionCache).length, // Provider 注册表 listProviders: () => state.providers.map((provider) => ({ ...provider })), addProvider: (input) => upsertProvider(input), removeProvider, toggleProvider, queryProviderByName: (name, question) => { const provider = state.providers.find((item) => item.name === name); return provider ? queryProvider(provider, question) : Promise.resolve(null); }, providerStats: () => ({ ...state.providerStats }), // 题库管理界面 searchBank: (query) => searchBank(query), bankStats: () => bankStats(), rememberReview, acceptReview, rejectReview, keepManualAnswer, forgetAnswer, renderBankManager, debugSnapshot: () => ({ permit: { ...state.mediaPermit }, summary: activeWorkerSummary(), queueNavigationAt: state.queueNavigationAt, currentTaskKey: state.currentTaskKey, operationRevision: state.operationRevision, operationTimers: state.operationTimers.size, finalState: state.finalState ? { ...state.finalState } : null, intervention: state.intervention ? { ...state.intervention } : null, navigationMode: state.navigationMode, executionRecovered: state.executionRecovered, executionJournal: state.executionJournal.slice(), knownMedia: state.knownMedia.size, domRevision: state.domRevision, domCycleScheduled: state.domCycleScheduled, navigatorLockSupported: state.navigatorLockSupported, navigatorLockHeld: state.navigatorLockHeld, navigatorLockContext: state.navigatorLockContext, coordinationChannel: Boolean(state.coordinationChannel), todoRenderCount: state.todoRenderCount }) }; } if (document.body) safeBoot(); else window.addEventListener('DOMContentLoaded', safeBoot, { once: true }); })();