// ==UserScript== // @name 学习通刷课助手 v1.5.5 // @namespace http://tampermonkey.net/ // @version 1.5.5 // @description 学习通视频自动播放+自动答题一站式助手 // @author 叶屿 // @license GPL3 // @antifeature payment 题库答题功能需要验证码(免费)或激活码(付费),视频播放等基础功能完全免费 // @match *://*.chaoxing.com/* // @match *://chaoxing.com/* // @match *://*.xuexitong.com/* // @match *://xuexitong.com/* // @exclude *://*.chaoxing.com/ananas/modules/pdf/* // @exclude *://*.chaoxing.com/ananas/modules/ppt/* // @exclude *://*.chaoxing.com/ananas/modules/doc/* // @exclude *://*.chaoxing.com/ananas/modules/book/* // @exclude *://*.chaoxing.com/ananas/modules/audio/* // @exclude *://*.xuexitong.com/ananas/modules/pdf/* // @exclude *://*.xuexitong.com/ananas/modules/ppt/* // @exclude *://*.xuexitong.com/ananas/modules/doc/* // @exclude *://*.xuexitong.com/ananas/modules/book/* // @exclude *://*.xuexitong.com/ananas/modules/audio/* // @run-at document-end // @icon https://www.chaoxing.com/favicon.ico // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_getResourceText // @grant unsafeWindow // @connect *.chaoxing.com // @connect *.xuexitong.com // @connect qsy.iano.cn // @connect open.bigmodel.cn // @connect dashscope.aliyuncs.com // @connect ark.cn-beijing.volces.com // @connect spark-api-open.xf-yun.com // @connect aip.baidubce.com // @connect api.deepseek.com // @connect api.moonshot.cn // @connect api.openai.com // @connect generativelanguage.googleapis.com // @connect api.groq.com // @connect api.siliconflow.cn // @connect api.lingyiwanwu.com // @connect api.minimax.chat // @connect api.stepfun.com // @connect api.baichuan-ai.com // @connect chizhu-dl.github.io // @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js // @require https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js // @require https://cdn.jsdelivr.net/combine/gh/photopea/Typr.js@15aa12ffa6cf39e8788562ea4af65b42317375fb/src/Typr.min.js,gh/photopea/Typr.js@f4fcdeb8014edc75ab7296bd85ac9cde8cb30489/src/Typr.U.min.js // @resource fontTable https://chizhu-dl.github.io/font-decrypt/Table.json // ==/UserScript== /* * ========================================== * 学习通刷课助手 v1.5.5 * ========================================== * * 【功能说明】 * 1. 视频自动播放(支持倍速、自动推进章节、自动跳过测验) * 2. 作业自动答题(题库查询 + AI 答题) * 3. 考试自动答题(支持停止/继续) * 4. 双模式题库(免费验证码 / 付费激活码) * * 【积分购买】 * 联系微信:C919irt * 价格表:50积分=2元,100积分=4元,150积分=6元,200积分=8元,500积分=18元 * 说明:每次答题消耗1积分,积分永久有效 * * 【付费声明】 * 本脚本基础功能(视频播放)完全免费 * 题库答题功能需要验证码(免费24小时)或激活码(付费永久) * 付费仅用于题库API调用成本,不强制购买 * * 【免责声明】 * 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为 * 使用本脚本造成的任何后果由使用者自行承担 * * 【版权信息】 * 作者:叶屿 | 版本:v1.5.5 | 更新:2026-09-27 * * 【v1.5.0 修复】 * - 修复作业/章测答完后部分模板不会自动提交的问题 * - 增强视频中途弹题识别、自动作答与继续播放 * - 加强免费验证码与 AI 解锁码会话守护 * - 支持后台图片公告展示与复习用途确认弹窗 * * 【v1.5.2 修复】 * - 修复填空题 AI 兜底返回“答案:”空壳时被误判为成功的问题 * - 增强括号填空题 prompt 与 AI 答案解析,空答案自动重试一次 * - 修复逐题答题时误触整页提交导致后续题目被跳过的问题 * - 限制确认按钮查找范围,避免页面内容被误当成提交确认 * - 保留 v1.5.1 激活凭据兼容校验,升级版本无需重复激活 * * 【v1.5.3 修复】 * - 学习目标等静态无视频内容自动推进到下一节 * - PPT、PDF、课件支持翻页、缩略页和滚动浏览,完成后再推进 * - 跨域文档无法读取内部状态时采用有界等待,避免永久卡住 * - 严格区分课程“下一节”、文档“下一页”和测验“下一题” * - 仅刷新视频 iframe,避免 PPT/PDF 被反复刷新回第一页 * - 加强 SPA 页面切换验证,避免章节树回退造成连续跳节 * - 保留已完成视频的来源标记,避免切换内容时重复播放旧视频 * * 【v1.5.4 修复】 * - 顶层只运行一个完整控制器,跨域视频 iframe 使用无面板工作端 * - 播放前检查媒体源,空源等待加载,不再直接触发无支持源错误 * - 仅在浏览器阻止自动播放时静音重试,无效媒体源采用有界重载并继续任务 * * 【v1.5.5 修复】 * - 隔离章节切换前的旧播放器消息,避免重复推进或重复播放 * - 播放命令、播放器重载和工作帧心跳增加校验与有界超时 * - 单个坏视频源优先跳到同课时下一个视频,不再卡在当前任务点 * * ========================================== */ (() => { 'use strict'; const SCRIPT_VERSION = '1.5.5'; const LEGACY_PROOF_VERSIONS = ['1.5.4', '1.5.3', '1.5.2', '1.5.1']; const CXUtils = { safeJSONParse(value, fallback = null) { try { return JSON.parse(value); } catch (_) { return fallback; } }, hashString(value) { const str = String(value || ''); if (typeof CryptoJS !== 'undefined' && CryptoJS.SHA256) { return CryptoJS.SHA256(str).toString(); } let h1 = 0x811c9dc5; let h2 = 0x01000193; for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); h1 ^= c; h1 = Math.imul(h1, 0x01000193); h2 ^= c + i; h2 = Math.imul(h2, 0x85ebca6b); } return ((h1 >>> 0).toString(36) + (h2 >>> 0).toString(36)); }, listAccessibleDocs(rootDoc = document, maxDepth = 4) { const docs = []; const seen = new Set(); const visit = (doc, depth) => { if (!doc || seen.has(doc) || depth > maxDepth) return; seen.add(doc); docs.push(doc); let frames = []; try { frames = Array.from(doc.querySelectorAll('iframe,frame')); } catch (_) { return; } for (const frame of frames) { try { const childDoc = frame.contentDocument || frame.contentWindow?.document; if (childDoc) visit(childDoc, depth + 1); } catch (_) {} } }; visit(rootDoc, 0); return docs; }, hostFamily() { const host = String(location.hostname || '').toLowerCase(); if (/(^|\.)chaoxing\.com$/.test(host)) return 'chaoxing.com'; if (/(^|\.)xuexitong\.com$/.test(host)) return 'xuexitong.com'; return 'chaoxing'; } }; // 只让顶层页面运行完整控制器。子 frame 中仅视频任务继续加载为媒体 worker, // 普通页面、PPT/PDF/文档预览会在业务模块和常驻定时器创建前退出。 let _isTopLevelPage = false; try { _isTopLevelPage = window.top === window.self; } catch (_) {} let _canTopAccessThisFrame = _isTopLevelPage; if (!_isTopLevelPage) { try { // 能读取 top.document 说明同源,顶层控制器可直接操作此 frame,无需第二个 owner。 _canTopAccessThisFrame = !!window.top.document; } catch (_) { _canTopAccessThisFrame = false; } } const _frameUrl = String(location.href || ''); let _frameHasDocumentMarker = false; try { _frameHasDocumentMarker = !!document.querySelector('.ans-doc, .ans-ppt, .pdf-viewer, .ppt-viewer, .document-reader, .pdfViewer, .document-viewer'); } catch (_) {} const _isDocumentFrame = _frameHasDocumentMarker || /(?:\/ananas\/modules\/(?:pdf|ppt|doc|book)\/|\.(?:pdf|ppt|pptx|doc|docx)(?:[?#]|$)|(?:^|[\/_.?=&-])(?:pdf|pptx?|docx?|book|office|document)(?:[\/_.?=&-]|$))/i.test(_frameUrl); let _frameHasVideo = false; try { _frameHasVideo = !!document.querySelector('video'); } catch (_) {} const _hasVideoUrlHint = /(?:\/ananas\/modules\/video\/|(?:^|[\/_.?=&-])(?:video|player|m3u8|mp4|flv|hls)(?:[\/_.?=&-]|$))/i.test(_frameUrl); const _isMediaWorkerFrame = !_isTopLevelPage && !_canTopAccessThisFrame && !_isDocumentFrame && (_frameHasVideo || _hasVideoUrlHint); if (!_isTopLevelPage && !_isMediaWorkerFrame) return; // AI多模型 Provider 配置 const AI_PROVIDERS = { zhipu: { name: '智谱GLM-4-Flash', model: 'glm-4-flash', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', free: true, desc: '智谱AI免费模型,速度快,准确率较高', link: 'https://www.bigmodel.cn/invite?icode=upWK8Rq09RynLhZ8CwWAqf2gad6AKpjZefIo3dVEQyA%3D', linkText: '前往智谱AI开放平台注册/登录获取API Key →', keyHint: '进入控制台 → API Keys → 创建API Key → 复制' }, deepseek: { name: 'DeepSeek-V3', model: 'deepseek-chat', url: 'https://api.deepseek.com/chat/completions', free: false, desc: 'DeepSeek高性价比模型,百万token仅需1元,答题准确率最高', link: 'https://platform.deepseek.com/', linkText: '前往DeepSeek开放平台注册/登录获取API Key →', keyHint: '登录后 → 左侧API Keys → 创建API Key → 复制' }, deepseek_v4: { name: 'DeepSeek-V4-Pro', model: 'deepseek-v4-pro', url: 'https://api.deepseek.com/chat/completions', free: false, desc: 'DeepSeek最新旗舰模型(1.6T参数/1M上下文),答题准确率显著高于V3,限时75折', link: 'https://platform.deepseek.com/', linkText: '前往DeepSeek开放平台注册/登录获取API Key →', keyHint: '登录后 → 左侧API Keys → 创建API Key → 复制(与V3共用同一Key)' }, qwen: { name: '通义千问Qwen-Plus', model: 'qwen-plus', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', free: false, desc: '阿里云通义千问,新用户有免费额度,知识面广', link: 'https://dashscope.console.aliyun.com/', linkText: '前往阿里云百炼平台注册/登录获取API Key →', keyHint: '登录后 → 右上角API-KEY → 创建API Key → 复制' }, doubao: { name: '豆包Doubao-lite-32k', model: 'doubao-lite-32k', url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', free: true, desc: '字节跳动豆包免费模型,速度极快', link: 'https://console.volcengine.com/ark/', linkText: '前往火山引擎Ark平台注册/登录获取API Key →', keyHint: '登录 → API Key管理 → 创建API Key → 复制(需先开通模型)' }, spark: { name: '讯飞星火Spark Lite', model: 'spark-lite', url: 'https://spark-api-open.xf-yun.com/v1/chat/completions', free: true, desc: '科大讯飞星火免费模型,中文理解能力强', link: 'https://console.xfyun.cn/', linkText: '前往讯飞开放平台注册/登录获取API Key →', keyHint: '登录 → 控制台 → 星火大模型 → 创建应用 → 获取APIKey和APISecret' }, kimi: { name: 'Kimi/Moonshot', model: 'moonshot-v1-8k', url: 'https://api.moonshot.cn/v1/chat/completions', free: false, desc: 'Moonshot Kimi模型,长文本理解能力强,新用户有免费额度', link: 'https://platform.moonshot.cn/', linkText: '前往Moonshot开放平台注册/登录获取API Key →', keyHint: '登录 → 左侧API Key管理 → 新建API Key → 复制' }, gpt: { name: 'ChatGPT (GPT-4o-mini)', model: 'gpt-4o-mini', url: 'https://api.openai.com/v1/chat/completions', free: false, desc: 'OpenAI GPT-4o-mini,全球最流行AI,综合能力强,需科学上网', link: 'https://platform.openai.com/api-keys', linkText: '前往OpenAI平台注册/登录获取API Key →', keyHint: '登录 → API Keys → Create new secret key → 复制(需科学上网)' }, gemini: { name: 'Google Gemini Flash', model: 'gemini-2.0-flash', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', free: true, desc: 'Google Gemini 2.0 Flash免费模型,速度快,多语言能力强,需科学上网', link: 'https://aistudio.google.com/apikey', linkText: '前往Google AI Studio获取API Key →', keyHint: '登录Google账号 → 点击Get API Key → Create → 复制(需科学上网)' }, groq: { name: 'Groq (Llama 3.3 70B)', model: 'llama-3.3-70b-versatile', url: 'https://api.groq.com/openai/v1/chat/completions', free: true, desc: 'Groq超快推理引擎,免费使用Llama 3.3 70B,速度全网最快,需科学上网', link: 'https://console.groq.com/keys', linkText: '前往Groq控制台注册/登录获取API Key →', keyHint: '登录 → API Keys → Create API Key → 复制(需科学上网)' }, siliconflow: { name: '硅基流动 (Qwen3-8B)', model: 'Qwen/Qwen3-8B', url: 'https://api.siliconflow.cn/v1/chat/completions', free: true, desc: '硅基流动聚合平台,免费调用多种开源模型,国内直连无需科学上网', link: 'https://cloud.siliconflow.cn/', linkText: '前往硅基流动平台注册/登录获取API Key →', keyHint: '登录 → 左侧API密钥 → 创建API Key → 复制' }, yi: { name: '零一万物 (Yi-Lightning)', model: 'yi-lightning', url: 'https://api.lingyiwanwu.com/v1/chat/completions', free: false, desc: '零一万物Yi系列模型,性价比高,中英文能力出色', link: 'https://platform.lingyiwanwu.com/', linkText: '前往零一万物开放平台注册/登录获取API Key →', keyHint: '登录 → API Key管理 → 创建API Key → 复制' }, minimax: { name: 'MiniMax (abab6.5s)', model: 'abab6.5s-chat', url: 'https://api.minimax.chat/v1/text/chatcompletion_v2', free: false, desc: 'MiniMax大模型,新用户赠送免费额度,对话能力强', link: 'https://platform.minimaxi.com/', linkText: '前往MiniMax开放平台注册/登录获取API Key →', keyHint: '登录 → 接口密钥 → 创建新的密钥 → 复制' }, stepfun: { name: '阶跃星辰 (Step-1-Flash)', model: 'step-1-flash', url: 'https://api.stepfun.com/v1/chat/completions', free: true, desc: '阶跃星辰免费模型,推理能力强,适合复杂题目', link: 'https://platform.stepfun.com/', linkText: '前往阶跃星辰开放平台注册/登录获取API Key →', keyHint: '登录 → API Key → 创建API Key → 复制' }, baichuan: { name: '百川智能 (Baichuan4)', model: 'Baichuan4', url: 'https://api.baichuan-ai.com/v1/chat/completions', free: false, desc: '百川智能大模型,新用户赠送免费token,中文能力优秀', link: 'https://platform.baichuan-ai.com/', linkText: '前往百川智能开放平台注册/登录获取API Key →', keyHint: '登录 → API Keys → 创建API Key → 复制' } }; // ========================================== // ConfigStore - 配置存储模块 // ========================================== const ConfigStore = { // AES 加密内置密钥(补齐到 16 字节) _key: CryptoJS.enc.Utf8.parse('chaoxing_cfg_key'), _iv: CryptoJS.enc.Utf8.parse('chaoxing_cfg_key'), // 通用存取(GM_setValue / GM_getValue) get(key) { const raw = GM_getValue(key, undefined); if (raw === undefined) return undefined; try { return JSON.parse(raw); } catch (_) { return raw; } }, set(key, value) { GM_setValue(key, JSON.stringify(value)); }, remove(key) { GM_deleteValue(key); }, clearAll() { this.remove('cx_video_config'); this.remove('cx_answer_config'); try { GM_deleteValue(this._deviceIdKey); } catch (_) {} try { localStorage.removeItem('cx_device_id'); } catch (_) {} try { localStorage.removeItem('cx_run_state'); } catch (_) {} try { localStorage.removeItem('cx_study_purpose_confirmed'); } catch (_) {} }, // 密码加密 / 解密(AES/CBC/PKCS7) encryptPassword(plaintext) { return CryptoJS.AES.encrypt(plaintext, this._key, { iv: this._iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString(); }, decryptPassword(ciphertext) { const bytes = CryptoJS.AES.decrypt(ciphertext, this._key, { iv: this._iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return bytes.toString(CryptoJS.enc.Utf8); }, // 设备 ID 使用脚本级 GM 存储,跨 chaoxing/xuexitong 子域刷新仍保持一致。 _deviceIdKey: 'cx_device_id_global', _isValidDeviceId(value) { const id = String(value == null ? '' : value).trim(); return id.length > 0 && id.length <= 128 ? id : ''; }, _readGlobalDeviceId() { try { return this._isValidDeviceId(GM_getValue(this._deviceIdKey, '')); } catch (_) { return ''; } }, _writeGlobalDeviceId(id) { try { GM_setValue(this._deviceIdKey, id); } catch (_) {} }, _readLocalDeviceId() { try { return this._isValidDeviceId(localStorage.getItem('cx_device_id')); } catch (_) { return ''; } }, _writeLocalDeviceId(id) { try { localStorage.setItem('cx_device_id', id); } catch (_) {} }, _recoverDeviceIdFromProof() { // 首次切换到新子域时,旧配置仍可能带有可验证的设备 ID,先迁移它。 let cfg = null; try { cfg = this.get('cx_answer_config'); } catch (_) {} const proofs = [cfg && cfg.verifyProof, cfg && cfg.aiUnlockProof]; for (const raw of proofs) { const payload = this.readAccessProof(raw); const id = this._isValidDeviceId(payload && payload.deviceId); if (!id || !payload || !payload.scope || !payload.sig || !payload.nonce || !payload.issuedAt || !payload.codeHash) continue; const until = this.normalizeValidUntil(payload.validUntil); if (until <= Date.now() / 1000 || payload.family !== CXUtils.hostFamily()) continue; if (payload.ua !== CXUtils.hashString(navigator.userAgent || '')) continue; if (Date.now() - Number(payload.issuedAt) > 30 * 60 * 60 * 1000) continue; const proofVersions = [SCRIPT_VERSION, ...LEGACY_PROOF_VERSIONS]; const validSignature = proofVersions.some(version => { const source = [ payload.scope, payload.family, payload.deviceId, payload.validUntil, payload.issuedAt, payload.nonce, payload.ua, version, payload.codeHash ].join('|'); return payload.sig === CXUtils.hashString(source); }); if (validSignature) return id; } return ''; }, _generateDeviceId() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); }, getDeviceId() { let id = this._readGlobalDeviceId(); if (!id) id = this._recoverDeviceIdFromProof(); if (!id) id = this._readLocalDeviceId(); if (!id) id = this._generateDeviceId(); // GM 是跨子域的主存储;同步当前 origin 以兼容旧版本代码。 this._writeGlobalDeviceId(id); this._writeLocalDeviceId(id); return id; }, // 视频播放配置 getVideoConfig() { return this.get('cx_video_config') || { playbackRate: 2, autoAdvance: true, autoSkipQuiz: true, autoAnswerQuiz: true, quizMode: 'free' }; }, setVideoConfig(config) { this.set('cx_video_config', config); }, // 答题模块配置 getAnswerConfig() { return this.get('cx_answer_config') || { mode: 'free', verifyValidUntil: 0, verifyProof: '', verifySessionToken: '', activationCode: '', aiApiKey: '', aiUnlockUntil: 0, aiUnlockProof: '', aiUnlockSessionToken: '' }; }, setAnswerConfig(config) { this.set('cx_answer_config', config); }, normalizeValidUntil(timestamp) { let value = Number(timestamp); if (!Number.isFinite(value) || value <= 0) return 0; // API 约定使用 Unix 秒,兼容偶尔返回的毫秒时间戳。 if (value > 1e12) value /= 1000; return Math.floor(value); }, makeAccessProof(scope, code, validUntil) { const issuedAt = Date.now(); const nonce = Math.random().toString(36).slice(2) + issuedAt.toString(36); const payload = { scope, family: CXUtils.hostFamily(), deviceId: this.getDeviceId(), validUntil: this.normalizeValidUntil(validUntil), issuedAt, nonce, ua: CXUtils.hashString(navigator.userAgent || ''), codeHash: CXUtils.hashString(String(code || '').trim()) }; const source = [ payload.scope, payload.family, payload.deviceId, payload.validUntil, payload.issuedAt, payload.nonce, payload.ua, SCRIPT_VERSION, payload.codeHash ].join('|'); payload.sig = CXUtils.hashString(source); try { return btoa(JSON.stringify(payload)); } catch (_) { return ''; } }, readAccessProof(raw) { if (!raw) return null; try { return JSON.parse(atob(raw)); } catch (_) { return null; } }, isAccessProofValid(scope, rawProof, validUntil) { const until = this.normalizeValidUntil(validUntil); if (until <= Date.now() / 1000) return false; const payload = this.readAccessProof(rawProof); if (!payload || payload.scope !== scope) return false; if (this.normalizeValidUntil(payload.validUntil) !== until) return false; if (payload.deviceId !== this.getDeviceId()) return false; if (payload.family !== CXUtils.hostFamily()) return false; if (payload.ua !== CXUtils.hashString(navigator.userAgent || '')) return false; if (!payload.sig || !payload.nonce || !payload.issuedAt || !payload.codeHash) return false; if (Date.now() - Number(payload.issuedAt) > 30 * 60 * 60 * 1000) return false; const proofVersions = [SCRIPT_VERSION, ...LEGACY_PROOF_VERSIONS]; return proofVersions.some(version => { const source = [ payload.scope, payload.family, payload.deviceId, payload.validUntil, payload.issuedAt, payload.nonce, payload.ua, version, payload.codeHash ].join('|'); return payload.sig === CXUtils.hashString(source); }); }, clearVerifySession() { const cfg = this.getAnswerConfig(); cfg.verifyValidUntil = 0; cfg.verifyProof = ''; cfg.verifySessionToken = ''; this.setAnswerConfig(cfg); }, setVerifyValidUntil(timestamp, code = '', sessionToken = '') { const cfg = this.getAnswerConfig(); let validUntil = this.normalizeValidUntil(timestamp); // 会话复核没有原验证码,不能只改有效期而留下旧 proof。 if (!code && cfg.verifyProof) { const proof = this.readAccessProof(cfg.verifyProof); const proofUntil = this.normalizeValidUntil(proof && proof.validUntil); if (proofUntil && proofUntil !== validUntil) validUntil = proofUntil; } cfg.verifyValidUntil = validUntil; if (code) cfg.verifyProof = this.makeAccessProof('free-answer', code, validUntil); if (sessionToken) cfg.verifySessionToken = sessionToken; this.setAnswerConfig(cfg); }, getVerifySessionToken() { return this.getAnswerConfig().verifySessionToken || ''; }, isVerifyValid() { const cfg = this.getAnswerConfig(); const ok = this.isAccessProofValid('free-answer', cfg.verifyProof, cfg.verifyValidUntil); if (!ok && cfg.verifyValidUntil) this.clearVerifySession(); return ok; }, // 题库未命中时的兜底策略:ai(AI兜底,默认) | skip(跳过) getUnmatchedFallback() { const cfg = this.getAnswerConfig(); return cfg.unmatchedFallback || 'ai'; }, setUnmatchedFallback(strategy) { const cfg = this.getAnswerConfig(); cfg.unmatchedFallback = strategy; this.setAnswerConfig(cfg); }, // AI Provider & Unlock 存储 getAIProvider() { const cfg = this.getAnswerConfig(); return cfg.aiProvider || 'zhipu'; }, setAIProvider(provider) { const cfg = this.getAnswerConfig(); cfg.aiProvider = provider; this.setAnswerConfig(cfg); }, getAIUnlockUntil() { const cfg = this.getAnswerConfig(); return this.normalizeValidUntil(cfg.aiUnlockUntil); }, clearAIUnlockSession() { const cfg = this.getAnswerConfig(); cfg.aiUnlockUntil = 0; cfg.aiUnlockProof = ''; cfg.aiUnlockSessionToken = ''; this.setAnswerConfig(cfg); }, setAIUnlockUntil(timestamp, code = '', sessionToken = '') { const cfg = this.getAnswerConfig(); let validUntil = this.normalizeValidUntil(timestamp); // 会话复核没有原解锁码,不能只改有效期而留下旧 proof。 if (!code && cfg.aiUnlockProof) { const proof = this.readAccessProof(cfg.aiUnlockProof); const proofUntil = this.normalizeValidUntil(proof && proof.validUntil); if (proofUntil && proofUntil !== validUntil) validUntil = proofUntil; } cfg.aiUnlockUntil = validUntil; if (code) cfg.aiUnlockProof = this.makeAccessProof('ai-unlock', code, validUntil); if (sessionToken) cfg.aiUnlockSessionToken = sessionToken; this.setAnswerConfig(cfg); }, getAIUnlockSessionToken() { return this.getAnswerConfig().aiUnlockSessionToken || ''; }, isAIUnlocked() { const cfg = this.getAnswerConfig(); const ok = this.isAccessProofValid('ai-unlock', cfg.aiUnlockProof, cfg.aiUnlockUntil); if (!ok && cfg.aiUnlockUntil) this.clearAIUnlockSession(); return ok; }, isStudyPurposeConfirmed() { return localStorage.getItem('cx_study_purpose_confirmed') === '1'; }, setStudyPurposeConfirmed() { localStorage.setItem('cx_study_purpose_confirmed', '1'); }, // 运行状态(localStorage) getRunState() { const raw = localStorage.getItem('cx_run_state'); if (!raw) return null; try { return JSON.parse(raw); } catch (_) { return null; } }, setRunState(state) { localStorage.setItem('cx_run_state', JSON.stringify(state)); }, clearRunState() { localStorage.removeItem('cx_run_state'); } }; const AccessGuard = { checkServerSession(scope, token) { return new Promise(resolve => { if (!token) { resolve(false); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/code/check_session', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: [ 'scope=' + encodeURIComponent(scope), 'device_id=' + encodeURIComponent(ConfigStore.getDeviceId()), 'session_token=' + encodeURIComponent(token) ].join('&'), timeout: 12000, onload: res => { try { const json = JSON.parse(res.responseText); if (res.status === 200 && json.code === 1 && json.data && json.data.valid) { if (scope === 'free-answer') { ConfigStore.setVerifyValidUntil(json.data.valid_until || ConfigStore.getAnswerConfig().verifyValidUntil); } else if (scope === 'ai-unlock') { ConfigStore.setAIUnlockUntil(json.data.valid_until || ConfigStore.getAIUnlockUntil()); } resolve(true); } else { resolve(false); } } catch (_) { resolve(false); } }, onerror: () => resolve(false), ontimeout: () => resolve(false) }); }); }, async requireFreeAnswerAccess() { if (!ConfigStore.isVerifyValid()) { ConfigStore.clearVerifySession(); Logger.log('⚠️ 免费验证码未验证、已过期或本地凭据异常,请重新验证', 'error'); return false; } const ok = await this.checkServerSession('free-answer', ConfigStore.getVerifySessionToken()); if (!ok) { ConfigStore.clearVerifySession(); Logger.log('⚠️ 免费验证码会话复核失败,请重新验证', 'error'); return false; } return true; }, async requireAIUnlockAccess() { if (!ConfigStore.isAIUnlocked()) { ConfigStore.clearAIUnlockSession(); return false; } const ok = await this.checkServerSession('ai-unlock', ConfigStore.getAIUnlockSessionToken()); if (!ok) { ConfigStore.clearAIUnlockSession(); return false; } return true; } }; // ========================================== // Logger - 日志模块 // ========================================== const Logger = { _entries: [], _onLog: null, log(message, level = 'info') { const entry = { timestamp: new Date().toISOString(), level, message }; this._entries.push(entry); if (this._entries.length > 50) { this._entries.shift(); } if (typeof this._onLog === 'function') { this._onLog(entry); } }, clearLogs() { this._entries = []; }, getEntries() { return this._entries; } }; // ========================================== // UIPanel - UI 面板模块 // ========================================== const UIPanel = { _iframe: null, _doc: null, _isMinimized: false, _activeTab: 'video', _settingsVisible: false, _running: false, _startTime: null, _timerInterval: null, _purposeResolver: null, // 2.1 - init(): 创建 iframe 并注入面板 HTML/CSS init() { if (this._iframe) return; const iframe = document.createElement('iframe'); iframe.id = 'cx-helper-iframe'; iframe.setAttribute('frameborder', '0'); iframe.setAttribute('allowtransparency', 'true'); Object.assign(iframe.style, { position: 'fixed', top: '20px', right: '20px', left: 'auto', width: '480px', height: '520px', zIndex: '999999', border: 'none', borderRadius: '14px', background: '#fff', overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.12), 0 8px 20px rgba(0,0,0,0.06)' }); document.body.appendChild(iframe); this._iframe = iframe; const doc = iframe.contentDocument || iframe.contentWindow.document; this._doc = doc; doc.open(); doc.write(this._buildHTML()); doc.close(); // 绑定事件 this._bindEvents(); // 启用标题栏拖拽 this.enableDrag(doc.getElementById('cx-header')); // 连接 Logger Logger._onLog = (entry) => this.log(entry.message, entry.level); // 渲染已有日志 Logger.getEntries().forEach(e => this.log(e.message, e.level)); }, // 2.1 - destroy(): 移除 iframe destroy() { if (this._iframe) { this._iframe.remove(); this._iframe = null; this._doc = null; } if (this._timerInterval) { clearInterval(this._timerInterval); this._timerInterval = null; } Logger._onLog = null; }, // 2.2 - enableDrag(): 绑定标题栏拖拽 enableDrag(handleEl) { if (!handleEl || !this._iframe) return; const iframe = this._iframe; const doc = this._doc; let isDragging = false; let startX = 0, startY = 0, startLeft = 0, startTop = 0; const onMove = (e) => { if (!isDragging) return; const dx = e.screenX - startX; const dy = e.screenY - startY; const maxLeft = Math.max(0, window.innerWidth - iframe.offsetWidth); const maxTop = Math.max(0, window.innerHeight - iframe.offsetHeight); iframe.style.left = Math.min(Math.max(0, startLeft + dx), maxLeft) + 'px'; iframe.style.top = Math.min(Math.max(0, startTop + dy), maxTop) + 'px'; iframe.style.right = 'auto'; }; const stopDrag = () => { if (!isDragging) return; isDragging = false; iframe.style.transition = ''; doc.body.style.userSelect = ''; }; handleEl.addEventListener('mousedown', (e) => { isDragging = true; startX = e.screenX; startY = e.screenY; // Convert right-positioned to left-positioned for dragging const rect = iframe.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; iframe.style.left = startLeft + 'px'; iframe.style.right = 'auto'; iframe.style.transition = 'none'; doc.body.style.userSelect = 'none'; e.preventDefault(); }); doc.addEventListener('mousemove', onMove); window.addEventListener('mousemove', onMove); doc.addEventListener('mouseup', stopDrag); window.addEventListener('mouseup', stopDrag); window.addEventListener('blur', stopDrag); }, // 2.3 - minimize(): 收缩为 50×50 悬浮图标 minimize() { if (this._isMinimized || !this._iframe) return; this._isMinimized = true; const doc = this._doc; doc.getElementById('cx-panel').style.display = 'none'; doc.getElementById('cx-mini').classList.add('show'); this._iframe.style.width = '50px'; this._iframe.style.height = '50px'; this._iframe.style.borderRadius = '50%'; }, // 2.3 - restore(): 恢复完整面板 restore() { if (!this._isMinimized || !this._iframe) return; this._isMinimized = false; const doc = this._doc; doc.getElementById('cx-panel').style.display = ''; doc.getElementById('cx-mini').classList.remove('show'); this._iframe.style.width = '480px'; this._iframe.style.height = '520px'; this._iframe.style.borderRadius = '14px'; }, // 2.4 - switchTab(): 切换标签页 switchTab(tab) { if (!this._doc) return; this._activeTab = tab; const doc = this._doc; // 更新标签按钮样式 doc.querySelectorAll('.cx-tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tab); }); // 更新内容区 doc.querySelectorAll('.cx-tab-content').forEach(el => { el.style.display = el.dataset.tab === tab ? 'block' : 'none'; }); }, // 2.5 - showSettings(): 显示设置面板 showSettings() { if (!this._doc) return; this._settingsVisible = !this._settingsVisible; this._doc.getElementById('cx-settings').style.display = this._settingsVisible ? 'block' : 'none'; }, // 2.6 - log(): 在日志区域追加日志 log(message, level = 'info') { if (!this._doc) return; const logArea = this._doc.getElementById('cx-log-area'); if (!logArea) return; const div = this._doc.createElement('div'); div.className = 'cx-log-entry cx-log-' + level; const time = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const icon = level === 'success' ? '✓' : level === 'error' ? '✗' : '·'; div.textContent = `${icon} ${time} ${message}`; logArea.appendChild(div); // 限制最多 50 条 while (logArea.children.length > 50) { logArea.removeChild(logArea.firstChild); } logArea.scrollTop = logArea.scrollHeight; }, // 2.6 - clearLogs(): 清空日志区域 clearLogs() { if (!this._doc) return; const logArea = this._doc.getElementById('cx-log-area'); if (logArea) logArea.innerHTML = ''; }, // 2.6 - setStatus(): 设置状态栏文字 setStatus(text, type = 'info') { if (!this._doc) return; const el = this._doc.getElementById('cx-status-text'); if (!el) return; el.textContent = text; el.className = 'cx-status-text cx-status-' + type; }, // 2.6 - setRunning(): 设置运行状态 setRunning(isRunning) { this._running = isRunning; if (!this._doc) return; const indicator = this._doc.getElementById('cx-status-indicator'); const timerEl = this._doc.getElementById('cx-status-timer'); // 先清理旧的定时器,防止内存泄漏 if (this._timerInterval) { clearInterval(this._timerInterval); this._timerInterval = null; } if (isRunning) { this._startTime = Date.now(); if (indicator) indicator.className = 'cx-status-indicator running'; this._timerInterval = setInterval(() => { if (timerEl && this._startTime) { const elapsed = Math.floor((Date.now() - this._startTime) / 1000); const h = String(Math.floor(elapsed / 3600)).padStart(2, '0'); const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0'); const s = String(elapsed % 60).padStart(2, '0'); timerEl.textContent = `${h}:${m}:${s}`; } }, 1000); } else { this._startTime = null; if (indicator) indicator.className = 'cx-status-indicator'; if (timerEl) timerEl.textContent = '00:00:00'; if (this._timerInterval) { clearInterval(this._timerInterval); this._timerInterval = null; } } }, // 2.6 - setProgress(): 设置进度 setProgress(current, total) { if (!this._doc) return; const el = this._doc.getElementById('cx-status-progress'); if (el) el.textContent = total > 0 ? `${current}/${total}` : ''; }, // 2.6 - setCountdown(): 设置倒计时 setCountdown(seconds) { if (!this._doc) return; const el = this._doc.getElementById('cx-status-countdown'); if (!el) return; if (seconds > 0) { const h = String(Math.floor(seconds / 3600)).padStart(2, '0'); const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0'); const s = String(seconds % 60).padStart(2, '0'); el.textContent = `倒计时 ${h}:${m}:${s}`; el.style.display = 'inline'; } else { el.textContent = ''; el.style.display = 'none'; } }, // 2.6 - setButtonEnabled(): 设置按钮启用/禁用 setButtonEnabled(buttonId, enabled) { if (!this._doc) return; const btn = this._doc.getElementById(buttonId); if (btn) btn.disabled = !enabled; }, // 2.6 - setButtonLoading(): 设置按钮加载状态 setButtonLoading(buttonId, loading) { if (!this._doc) return; const btn = this._doc.getElementById(buttonId); if (!btn) return; if (loading) { btn._origText = btn._origText || btn.textContent; btn.textContent = '处理中...'; btn.disabled = true; } else { btn.textContent = btn._origText || btn.textContent; btn.disabled = false; } }, _updatePurposeConfirmState() { const doc = this._doc; if (!doc) return; const check = doc.getElementById('cx-purpose-check'); const confirm = doc.getElementById('cx-purpose-confirm'); if (confirm && check) confirm.disabled = check.disabled || !check.checked; }, _markPurposeReadIfScrolled() { const doc = this._doc; if (!doc) return; const scroll = doc.getElementById('cx-purpose-scroll'); const check = doc.getElementById('cx-purpose-check'); const hint = doc.getElementById('cx-purpose-hint'); if (!scroll || !check || !hint) return; if (scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 4) { check.disabled = false; hint.textContent = '已阅读到末尾,可以勾选确认。'; } this._updatePurposeConfirmState(); }, _closePurposeDialog(confirmed) { const doc = this._doc; if (!doc) return; const dialog = doc.getElementById('cx-purpose-dialog'); if (dialog) dialog.style.display = 'none'; if (confirmed) ConfigStore.setStudyPurposeConfirmed(); if (this._purposeResolver) { const resolve = this._purposeResolver; this._purposeResolver = null; resolve(!!confirmed); } }, confirmStudyPurpose() { if (ConfigStore.isStudyPurposeConfirmed()) return Promise.resolve(true); const doc = this._doc; if (!doc) return Promise.resolve(false); return new Promise(resolve => { this._purposeResolver = resolve; const dialog = doc.getElementById('cx-purpose-dialog'); const scroll = doc.getElementById('cx-purpose-scroll'); const check = doc.getElementById('cx-purpose-check'); const confirm = doc.getElementById('cx-purpose-confirm'); const hint = doc.getElementById('cx-purpose-hint'); if (check) { check.checked = false; check.disabled = true; } if (confirm) confirm.disabled = true; if (hint) hint.textContent = '请先阅读到末尾,再勾选确认。'; if (dialog) dialog.style.display = 'flex'; if (scroll) { scroll.scrollTop = 0; setTimeout(() => { this._markPurposeReadIfScrolled(); try { scroll.focus(); } catch (_) {} }, 0); } }); }, // 内部:构建面板 HTML _buildHTML() { return `
📚
📖 学习通刷课助手 v1.5.5
⚙️ −
⚠️ 有BUG及时反馈需提供账号,联系微信:C919irt 📋
视频助手
自动答题
当前章节: 未开始
播放进度: 0%
⚙️ 播放设置
播放倍速
⚙️ 设置 ✕
视频助手
答题模式
数据管理

清除所有存储数据,包括账号、密码、配置和日志。此操作不可撤销。

就绪
00:00:00
`; }, // 内部:构建 CSS _buildCSS() { return ` html, body { margin:0; padding:0; overflow:hidden; height:100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif; color: #2c3e50; background: transparent; font-size: 13px; } * { box-sizing:border-box; } /* 最小化图标 */ .cx-mini { position:absolute; inset:0; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; border-radius:50%; display:none; align-items:center; justify-content:center; cursor:pointer; font-size:22px; box-shadow:0 4px 16px rgba(99,102,241,0.45); transition:all .3s; } .cx-mini:hover { transform:scale(1.1); box-shadow:0 6px 22px rgba(99,102,241,0.55); } .cx-mini.show { display:flex; } /* 主面板 */ .cx-panel { width:100%; height:100%; background:#f8fafc; border-radius:16px; position:relative; overflow:hidden; display:flex; flex-direction:column; max-height:100vh; } /* 标题栏 */ .cx-header { height:48px; background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 50%,#a78bfa 100%); color:#fff; display:flex; align-items:center; justify-content:space-between; padding:0 18px; cursor:move; flex-shrink:0; user-select:none; } .cx-header-title { font-size:14px; font-weight:700; letter-spacing:0.3px; text-shadow:0 1px 3px rgba(0,0,0,0.12); } .cx-header-tools { display:flex; gap:12px; } .cx-tool-btn { cursor:pointer; font-size:15px; opacity:.85; transition:all .2s; padding:4px 6px; border-radius:6px; } .cx-tool-btn:hover { opacity:1; transform:scale(1.12); background:rgba(255,255,255,0.18); } /* 标签栏 */ .cx-tab-bar { display:flex; background:#fff; border-bottom:1px solid #e2e8f0; flex-shrink:0; padding:0 10px; } .cx-tab-btn { flex:1; text-align:center; padding:10px 0; font-size:12.5px; cursor:pointer; color:#94a3b8; transition:all .25s; border-bottom:2px solid transparent; font-weight:500; margin:0 2px; border-radius:6px 6px 0 0; } .cx-tab-btn:hover { color:#6366f1; background:rgba(99,102,241,0.04); } .cx-tab-btn.active { color:#6366f1; border-bottom-color:#6366f1; font-weight:700; background:#fff; } /* 反馈提示条 */ .cx-feedback-tip { background:#fffbeb; border-bottom:1px solid #fde68a; padding:6px 14px; font-size:11px; color:#92400e; line-height:1.5; flex-shrink:0; } .cx-feedback-wechat { display:inline-block; background:#fff; padding:1px 8px; border-radius:4px; border:1px solid #e2e8f0; cursor:pointer; transition:all .2s; font-weight:600; font-size:11px; margin-left:2px; } .cx-feedback-wechat:hover { border-color:#6366f1; color:#6366f1; background:#eef2ff; } /* 内容区 — 关键:允许滚动 */ .cx-body { flex:1 1 0; overflow-y:auto; overflow-x:hidden; padding:14px; min-height:0; background:#f8fafc; } .cx-body::-webkit-scrollbar { width:5px; } .cx-body::-webkit-scrollbar-track { background:transparent; } .cx-body::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:10px; } .cx-body::-webkit-scrollbar-thumb:hover { background:#94a3b8; } /* 设置面板 */ .cx-settings { display:none; position:absolute; top:48px; left:0; width:100%; height:calc(100% - 48px); background:#f8fafc; z-index:99; overflow-y:auto; box-sizing:border-box; } .cx-settings::-webkit-scrollbar { width:5px; } .cx-settings::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; } .cx-settings-header { display:flex; justify-content:space-between; align-items:center; padding:14px 18px; background:#fff; border-bottom:1px solid #e2e8f0; font-weight:700; font-size:14px; color:#1e293b; } .cx-settings-close { cursor:pointer; font-size:16px; color:#94a3b8; transition:all .2s; width:28px; height:28px; display:flex; align-items:center; justify-content:center; border-radius:8px; } .cx-settings-close:hover { color:#ef4444; background:#fef2f2; } .cx-settings-body { padding:14px; } .cx-settings-section { background:#fff; border-radius:12px; padding:16px; margin-bottom:12px; box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; } .cx-settings-section:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); } .cx-settings-title { font-weight:700; font-size:13px; margin-bottom:10px; color:#1e293b; } .cx-settings-danger-zone { border:1px solid #fecaca; background:#fef2f2; } .cx-form-item { margin-bottom:10px; } .cx-form-item label { font-size:13px; color:#64748b; } .cx-form-item select { padding:7px 12px; border:1.5px solid #e2e8f0; border-radius:8px; font-size:13px; background:#fff; margin-left:8px; outline:none; transition:border-color .2s; } .cx-form-item select:focus { border-color:#6366f1; } .cx-checkbox-label { display:flex; align-items:center; gap:8px; cursor:pointer; padding:6px 8px; border-radius:8px; transition:background .2s; } .cx-checkbox-label:hover { background:#eef2ff; } .cx-checkbox-label input { margin:0; width:16px; height:16px; cursor:pointer; accent-color:#6366f1; } .cx-radio-group { display:flex; flex-direction:column; gap:4px; } .cx-radio-option { display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:8px; cursor:pointer; transition:background .2s; font-size:13px; } .cx-radio-option:hover { background:#eef2ff; } .cx-radio-option input { margin:0; cursor:pointer; accent-color:#6366f1; } .cx-settings-footer { display:flex; gap:10px; justify-content:center; margin-top:14px; } /* 按钮 */ .cx-btn { padding:9px 20px; border:none; border-radius:10px; font-size:12.5px; font-weight:600; cursor:pointer; transition:all .2s; letter-spacing:0.2px; } .cx-btn:hover { transform:translateY(-1px); box-shadow:0 4px 12px rgba(0,0,0,0.08); } .cx-btn:active { transform:translateY(0); } .cx-btn-primary { background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; } .cx-btn-primary:hover { box-shadow:0 4px 16px rgba(99,102,241,0.35); } .cx-btn-secondary { background:#f1f5f9; color:#64748b; } .cx-btn-secondary:hover { background:#e2e8f0; box-shadow:0 2px 8px rgba(0,0,0,0.04); } .cx-btn-success { background:linear-gradient(135deg,#10b981,#059669); color:#fff; } .cx-btn-success:hover { box-shadow:0 4px 16px rgba(16,185,129,0.35); } .cx-btn-warning { background:linear-gradient(135deg,#f59e0b,#d97706); color:#fff; } .cx-btn-warning:hover { box-shadow:0 4px 16px rgba(245,158,11,0.35); } .cx-btn-danger { background:linear-gradient(135deg,#ef4444,#dc2626); color:#fff; width:100%; } .cx-btn-danger:hover { box-shadow:0 4px 16px rgba(239,68,68,0.35); } .cx-btn-info { background:#f1f5f9; color:#64748b; } .cx-btn-info:hover { background:#e2e8f0; } .cx-btn:disabled { opacity:.45; cursor:not-allowed; transform:none; box-shadow:none; } /* 日志区域 */ .cx-log-wrap { flex-shrink:0; position:relative; border-top:1px solid #e2e8f0; background:#f8fafc; } .cx-log-area { height:100px; overflow-y:auto; padding:8px 14px; box-sizing:border-box; font-size:11px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif; mask-image:linear-gradient(to bottom, transparent 0%, black 20%, black 100%); -webkit-mask-image:linear-gradient(to bottom, transparent 0%, black 20%, black 100%); } .cx-log-area::-webkit-scrollbar { width:3px; } .cx-log-area::-webkit-scrollbar-track { background:transparent; } .cx-log-area::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; } .cx-log-entry { padding:3px 8px; margin:2px 0; line-height:1.5; border-radius:6px; font-size:11px; animation:cx-log-in .3s ease; } @keyframes cx-log-in { from{opacity:0;transform:translateY(4px);} to{opacity:1;transform:translateY(0);} } .cx-log-info { color:#64748b; background:#f1f5f9; } .cx-log-success { color:#059669; background:#ecfdf5; } .cx-log-error { color:#dc2626; background:#fef2f2; } /* 状态栏 */ .cx-status-bar { height:32px; background:#fff; border-top:1px solid #e2e8f0; display:flex; align-items:center; justify-content:space-between; padding:0 14px; font-size:11px; color:#94a3b8; flex-shrink:0; } .cx-status-left { display:flex; align-items:center; gap:8px; } .cx-status-right { display:flex; align-items:center; } .cx-status-indicator { width:7px; height:7px; border-radius:50%; background:#cbd5e1; display:inline-block; } .cx-status-indicator.running { background:#10b981; animation:cx-pulse 1.5s infinite; } @keyframes cx-pulse { 0%,100%{opacity:1;} 50%{opacity:.4;} } .cx-status-text { color:#64748b; } .cx-status-text.cx-status-info { color:#64748b; } .cx-status-text.cx-status-success { color:#10b981; } .cx-status-text.cx-status-error { color:#ef4444; } .cx-status-progress { color:#6366f1; font-weight:700; } .cx-status-countdown { color:#f59e0b; font-weight:700; } .cx-status-timer { font-family:'JetBrains Mono',Consolas,"Courier New",monospace; color:#94a3b8; font-size:11px; } /* 表单通用 */ .cx-form-row { display:flex; flex-direction:column; gap:4px; } .cx-form-row-inline { flex-direction:row; gap:10px; } .cx-form-col { flex:1; display:flex; flex-direction:column; gap:4px; } .cx-form-label { font-size:12px; color:#64748b; font-weight:600; } .cx-required { color:#ef4444; margin-left:2px; } .cx-input { padding:9px 12px; border:1.5px solid #e2e8f0; border-radius:10px; font-size:13px; outline:none; transition:all .2s; box-sizing:border-box; width:100%; background:#fff; } .cx-input:focus { border-color:#6366f1; box-shadow:0 0 0 3px rgba(99,102,241,0.1); } .cx-input.cx-input-error { border-color:#ef4444; box-shadow:0 0 0 3px rgba(239,68,68,0.1); } .cx-form-error { font-size:11px; color:#ef4444; min-height:14px; } /* 加载动画 */ .cx-loading::after { content:''; display:inline-block; width:12px; height:12px; border:2px solid rgba(255,255,255,0.4); border-top-color:#fff; border-radius:50%; animation:cx-spin .6s linear infinite; margin-left:6px; vertical-align:middle; } @keyframes cx-spin { to{transform:rotate(360deg);} } /* 视频面板 */ .cx-video-panel { display:flex; flex-direction:column; gap:12px; } .cx-video-status-card { background:#fff; border-radius:14px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; } .cx-video-status-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); } .cx-video-info-row { display:flex; align-items:center; margin-bottom:8px; font-size:13px; } .cx-video-label { color:#94a3b8; min-width:80px; flex-shrink:0; font-weight:500; } .cx-video-value { color:#1e293b; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .cx-video-progress-wrap { margin-top:6px; } .cx-video-progress-track { height:6px; background:#e2e8f0; border-radius:3px; overflow:hidden; } .cx-video-progress-bar { height:100%; background:linear-gradient(90deg,#6366f1,#10b981); border-radius:3px; transition:width .5s ease; min-width:0; } .cx-video-actions { display:flex; gap:10px; } .cx-video-actions .cx-btn { flex:1; padding:11px 0; font-size:13px; } /* 答题模块 */ .cx-answer-panel { display:flex; flex-direction:column; gap:10px; } .cx-answer-mode-bar { display:flex; gap:4px; background:#fff; border-radius:12px; padding:4px; border:1px solid #e2e8f0; } .cx-answer-mode-option { flex:1; display:flex; align-items:center; justify-content:center; gap:4px; padding:8px 4px; border-radius:10px; cursor:pointer; font-size:12px; font-weight:600; transition:all .25s; text-align:center; color:#94a3b8; } .cx-answer-mode-option:hover { background:#eef2ff; color:#6366f1; } .cx-answer-mode-option input { display:none; } .cx-answer-mode-option:has(input:checked) { background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; box-shadow:0 2px 8px rgba(99,102,241,0.3); } .cx-answer-area { } .cx-answer-card { background:#fff; border-radius:14px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; } .cx-answer-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); } .cx-answer-card-title { font-weight:700; font-size:13px; color:#1e293b; margin-bottom:12px; } .cx-free-qr-placeholder { display:flex; flex-direction:column; align-items:center; gap:8px; padding:20px; background:#f8fafc; border-radius:12px; border:1.5px dashed #c7d2fe; } .cx-free-qr-icon { font-size:36px; } .cx-free-qr-text { font-size:12px; color:#94a3b8; } .cx-input-group { display:flex; gap:8px; align-items:center; } .cx-btn-sm { padding:8px 16px; font-size:12px; } .cx-btn-xs { padding:5px 10px; font-size:11px; border-radius:6px; } .cx-answer-status { font-size:12px; margin-top:10px; min-height:16px; } .cx-paid-credits-row { display:flex; align-items:center; gap:10px; padding:12px 14px; background:#f8fafc; border-radius:10px; border:1px solid #e2e8f0; } .cx-paid-credits-label { font-size:13px; color:#64748b; } .cx-paid-credits-value { font-size:20px; font-weight:800; color:#6366f1; } .cx-answer-actions { display:flex; gap:8px; margin-top:4px; } .cx-answer-actions .cx-btn { flex:1; } /* 购买信息 */ .cx-buy-info { background:#fffbeb; padding:12px; border-radius:10px; border:1px solid #fde68a; margin-top:12px; font-size:12px; line-height:1.6; } .cx-buy-info-title { font-weight:700; color:#d97706; margin-bottom:8px; font-size:13px; } .cx-buy-info-wechat { margin-bottom:4px; color:#5a6577; } .cx-wechat-copy { display:inline-flex; align-items:center; gap:4px; background:#fff; padding:3px 10px; border-radius:6px; border:1px solid #dce1e8; cursor:pointer; transition:all .2s; font-weight:600; font-size:12px; } .cx-wechat-copy:hover { border-color:#667eea; background:#f0f2ff; color:#667eea; } .cx-price-table { margin-top:6px; font-size:11px; color:#6b7280; line-height:1.8; } /* AI 答题提示 */ .cx-ai-hint { font-size:12px; color:#64748b; margin-bottom:12px; line-height:1.6; } .cx-ai-link { color:#6366f1; text-decoration:none; font-weight:500; } .cx-ai-link:hover { text-decoration:underline; } .cx-ai-free-tip { margin-top:8px; font-size:11px; color:#94a3b8; } /* 视频内联设置卡片 */ .cx-video-settings-card { background:#fff; border-radius:14px; padding:14px 16px; margin-top:12px; box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; } .cx-video-settings-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); } .cx-card-title { font-weight:700; font-size:13px; color:#1e293b; margin-bottom:10px; } .cx-inline-setting { display:flex; align-items:center; padding:6px 0; } .cx-setting-label { font-size:13px; color:#64748b; margin-right:10px; font-weight:500; } .cx-select-inline { padding:6px 10px; border:1.5px solid #e2e8f0; border-radius:10px; font-size:13px; background:#fff; outline:none; transition:border-color .2s; cursor:pointer; } .cx-select-inline:focus { border-color:#6366f1; } .cx-toggle-label { display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; color:#64748b; padding:2px 0; } .cx-toggle-label input { margin:0; width:16px; height:16px; cursor:pointer; accent-color:#6366f1; } /* 用途确认弹窗 */ .cx-purpose-dialog { display:none; position:absolute; inset:0; z-index:150; background:rgba(15,23,42,.48); align-items:center; justify-content:center; padding:18px; backdrop-filter:blur(2px); } .cx-purpose-card { width:100%; max-width:430px; max-height:92%; background:#fff; border-radius:14px; border:1px solid #e2e8f0; box-shadow:0 22px 54px rgba(15,23,42,.24); padding:18px; display:flex; flex-direction:column; gap:12px; } .cx-purpose-head h3 { margin:0 0 6px; font-size:16px; color:#111827; } .cx-purpose-head p { margin:0; font-size:12px; color:#64748b; line-height:1.6; } .cx-purpose-scroll { max-height:132px; overflow:auto; padding:12px; border:1px solid #dbe3ee; border-radius:10px; background:#f8fafc; color:#334155; font-size:12px; line-height:1.7; outline:none; } .cx-purpose-scroll p { margin:0 0 10px; } .cx-purpose-scroll p:last-child { margin-bottom:0; } .cx-purpose-hint { margin:0; font-size:12px; color:#d97706; font-weight:700; } .cx-purpose-check { display:flex; gap:9px; align-items:flex-start; font-size:12px; color:#334155; line-height:1.6; } .cx-purpose-check input { width:18px; height:18px; margin-top:1px; flex:0 0 auto; accent-color:#2563eb; } .cx-purpose-actions { display:flex; gap:12px; } .cx-purpose-actions button { flex:1; min-height:42px; border-radius:10px; border:1px solid #cbd5e1; font-size:13px; font-weight:800; cursor:pointer; transition:all .15s; } .cx-purpose-actions button:disabled { opacity:.5; cursor:not-allowed; } .cx-purpose-cancel { background:#f8fafc; color:#334155; } .cx-purpose-confirm { background:#2563eb; color:#fff; border-color:#2563eb; } /* 底部留白防止滚动不到底 */ .cx-tab-content { padding-bottom:8px; } `; }, // 内部:绑定事件 _bindEvents() { const doc = this._doc; // 最小化按钮 doc.getElementById('cx-btn-minimize').addEventListener('click', () => this.minimize()); // 最小化图标点击恢复 doc.getElementById('cx-mini').addEventListener('click', () => this.restore()); doc.getElementById('cx-purpose-scroll')?.addEventListener('scroll', () => this._markPurposeReadIfScrolled()); doc.getElementById('cx-purpose-check')?.addEventListener('change', () => this._updatePurposeConfirmState()); doc.getElementById('cx-purpose-cancel')?.addEventListener('click', () => this._closePurposeDialog(false)); doc.getElementById('cx-purpose-confirm')?.addEventListener('click', () => { const btn = doc.getElementById('cx-purpose-confirm'); if (btn && !btn.disabled) this._closePurposeDialog(true); }); doc.addEventListener('keydown', e => { if (e.key === 'Escape' && doc.getElementById('cx-purpose-dialog')?.style.display === 'flex') { this._closePurposeDialog(false); } }); // 反馈微信号复制 doc.getElementById('cx-feedback-wechat')?.addEventListener('click', () => { const el = doc.getElementById('cx-feedback-wechat'); const text = 'C919irt'; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => { el.textContent = '已复制 ✅'; setTimeout(() => { el.textContent = 'C919irt 📋'; }, 1500); }).catch(() => {}); } }); // 设置按钮 doc.getElementById('cx-btn-settings').addEventListener('click', () => this.showSettings()); // 设置面板关闭 doc.getElementById('cx-settings-close').addEventListener('click', () => { this._settingsVisible = false; doc.getElementById('cx-settings').style.display = 'none'; }); // 设置面板保存 doc.getElementById('cx-settings-save').addEventListener('click', () => { // 保存视频配置 const playbackRate = parseFloat(doc.getElementById('cx-cfg-playback-rate').value) || 2; const autoAdvance = doc.getElementById('cx-cfg-auto-advance').checked; const autoSkipQuiz = doc.getElementById('cx-cfg-auto-skip-quiz').checked; const autoAnswerQuiz = doc.getElementById('cx-cfg-auto-answer-quiz')?.checked !== false; const quizMode = doc.getElementById('cx-cfg-quiz-mode')?.value || 'free'; ConfigStore.setVideoConfig({ playbackRate, autoAdvance, autoSkipQuiz, autoAnswerQuiz, quizMode }); // 立即更新当前视频速度 if (typeof VideoModule !== 'undefined' && VideoModule.isPlaying()) { VideoModule.setPlaybackRate(playbackRate); } // 同步到内联设置控件 const rateInline = doc.getElementById('cx-video-rate-inline'); if (rateInline) rateInline.value = String(playbackRate); const advInline = doc.getElementById('cx-video-advance-inline'); if (advInline) advInline.checked = autoAdvance; const skipInline = doc.getElementById('cx-video-skip-quiz-inline'); if (skipInline) skipInline.checked = autoSkipQuiz; const autoAnswerInline = doc.getElementById('cx-video-auto-answer-quiz-inline'); if (autoAnswerInline) autoAnswerInline.checked = autoAnswerQuiz; const quizModeInline = doc.getElementById('cx-video-quiz-mode-inline'); if (quizModeInline) { quizModeInline.value = quizMode; quizModeInline.disabled = !autoAnswerQuiz; } // 保存答题模式配置 const answerMode = doc.querySelector('input[name="cx-answer-mode"]:checked')?.value || 'free'; const answerCfg = ConfigStore.getAnswerConfig(); answerCfg.mode = answerMode; ConfigStore.setAnswerConfig(answerCfg); Logger.log('设置已保存', 'success'); this._settingsVisible = false; doc.getElementById('cx-settings').style.display = 'none'; }); // 设置面板取消 doc.getElementById('cx-settings-cancel').addEventListener('click', () => { this._settingsVisible = false; doc.getElementById('cx-settings').style.display = 'none'; }); // 标签页切换 doc.querySelectorAll('.cx-tab-btn').forEach(btn => { btn.addEventListener('click', () => this.switchTab(btn.dataset.tab)); }); // 播放倍速即时更新 doc.getElementById('cx-cfg-playback-rate').addEventListener('change', (e) => { const rate = parseFloat(e.target.value) || 2; if (typeof VideoModule !== 'undefined' && VideoModule.isPlaying()) { VideoModule.setPlaybackRate(rate); } }); // 清除数据按钮 doc.getElementById('cx-btn-clear-data')?.addEventListener('click', () => { if (confirm('确定要清除所有数据吗?此操作不可撤销,将删除所有账号、密码、配置和日志。')) { ConfigStore.clearAll(); Logger.clearLogs(); this.clearLogs(); this.setRunning(false); this.setStatus('数据已清除', 'info'); Logger.log('所有数据已清除', 'success'); } }); // 视频内联设置 — 倍速 doc.getElementById('cx-video-rate-inline')?.addEventListener('change', (e) => { const rate = parseFloat(e.target.value) || 2; const cfg = ConfigStore.getVideoConfig(); cfg.playbackRate = rate; ConfigStore.setVideoConfig(cfg); // 同步设置面板 const settingsRate = doc.getElementById('cx-cfg-playback-rate'); if (settingsRate) settingsRate.value = String(rate); if (typeof VideoModule !== 'undefined' && VideoModule.isPlaying()) { VideoModule.setPlaybackRate(rate); } Logger.log(`播放倍速已设为 ${rate}x`, 'info'); }); // 视频内联设置 — 自动推进 doc.getElementById('cx-video-advance-inline')?.addEventListener('change', (e) => { const cfg = ConfigStore.getVideoConfig(); cfg.autoAdvance = e.target.checked; ConfigStore.setVideoConfig(cfg); const settingsCb = doc.getElementById('cx-cfg-auto-advance'); if (settingsCb) settingsCb.checked = e.target.checked; Logger.log(`自动推进章节: ${e.target.checked ? '开启' : '关闭'}`, 'info'); }); // 视频内联设置 — 跳过弹窗测验 doc.getElementById('cx-video-skip-quiz-inline')?.addEventListener('change', (e) => { const cfg = ConfigStore.getVideoConfig(); cfg.autoSkipQuiz = e.target.checked; ConfigStore.setVideoConfig(cfg); const settingsCb = doc.getElementById('cx-cfg-auto-skip-quiz'); if (settingsCb) settingsCb.checked = e.target.checked; Logger.log(`自动跳过弹窗测验: ${e.target.checked ? '开启' : '关闭'}`, 'info'); }); // 视频内联设置 — 自动答章节测验复选框 doc.getElementById('cx-video-auto-answer-quiz-inline')?.addEventListener('change', (e) => { const cfg = ConfigStore.getVideoConfig(); cfg.autoAnswerQuiz = e.target.checked; ConfigStore.setVideoConfig(cfg); // 联动下拉框禁用状态 const modeSelect = doc.getElementById('cx-video-quiz-mode-inline'); if (modeSelect) modeSelect.disabled = !e.target.checked; // 同步设置面板 const settingsCb = doc.getElementById('cx-cfg-auto-answer-quiz'); if (settingsCb) settingsCb.checked = e.target.checked; const settingsSelect = doc.getElementById('cx-cfg-quiz-mode'); if (settingsSelect) settingsSelect.disabled = !e.target.checked; Logger.log(`自动答章节测验: ${e.target.checked ? '开启' : '关闭'}`, 'info'); }); // 视频内联设置 — 章节测验模式 doc.getElementById('cx-video-quiz-mode-inline')?.addEventListener('change', (e) => { const cfg = ConfigStore.getVideoConfig(); cfg.quizMode = e.target.value; ConfigStore.setVideoConfig(cfg); const settingsSelect = doc.getElementById('cx-cfg-quiz-mode'); if (settingsSelect) settingsSelect.value = e.target.value; const modeNames = { free: '免费题库', paid: '付费题库', ai: 'AI 答题' }; Logger.log(`章节测验模式: ${modeNames[e.target.value] || e.target.value}`, 'info'); }); // 二维码图片加载失败时显示文字占位 const qrImg = doc.getElementById('cx-free-qr-img'); const qrFallback = doc.getElementById('cx-free-qr-fallback'); if (qrImg) { qrImg.onload = () => { qrImg.style.display = 'block'; if (qrFallback) qrFallback.style.display = 'none'; }; qrImg.onerror = () => { qrImg.style.display = 'none'; if (qrFallback) qrFallback.style.display = 'block'; }; } // 微信号复制 const wechatCopy = doc.getElementById('cx-wechat-copy'); if (wechatCopy) { wechatCopy.addEventListener('click', () => { const text = 'C919irt'; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => { wechatCopy.textContent = '已复制 ✅'; setTimeout(() => { wechatCopy.textContent = 'C919irt 📋'; }, 1500); }).catch(() => { wechatCopy.textContent = 'C919irt 📋'; }); } }); } } }; // ========================================== // FrameMediaBridge - 顶层控制器 / 视频 frame worker 通信 // ========================================== const FrameMediaBridge = { CHANNEL: 'cx-learning-helper-media-v1', _session: '', _workerId: '', _workerSession: '', _workerChapterEpoch: 0, _workerPlayToken: '', _controllerOrigin: '*', _workers: new Map(), _activeWorkerId: '', _controllerReady: false, _workerReady: false, _chapterEpoch: 1, _playSequence: 0, _reloadedSources: new WeakSet(), _lastWorkerReportAt: 0, _workerReportTimer: null, _workerObserver: null, _newId(prefix) { let random = ''; try { const bytes = new Uint32Array(2); crypto.getRandomValues(bytes); random = Array.from(bytes, value => value.toString(36)).join(''); } catch (_) { random = Math.random().toString(36).slice(2); } return `${prefix}-${Date.now().toString(36)}-${random}`; }, _isTrustedOrigin(origin) { try { const host = new URL(origin).hostname.toLowerCase(); return /(^|\.)(?:chaoxing|xuexitong)\.com$/.test(host); } catch (_) { return false; } }, _applyWorkerSnapshot(worker, snapshot) { if (!worker || !snapshot || typeof snapshot !== 'object') return; if ('hasVideo' in snapshot) worker.hasVideo = !!snapshot.hasVideo; if ('hasMore' in snapshot) worker.hasMore = !!snapshot.hasMore; if ('progress' in snapshot) { const progress = Number(snapshot.progress); if (Number.isFinite(progress)) worker.progress = Math.max(0, Math.min(100, progress)); } const allowedStates = ['missing', 'pending', 'ready', 'playing', 'finished', 'unsupported', 'unavailable', 'reloading', 'awaiting']; if (allowedStates.includes(snapshot.state)) worker.state = snapshot.state; }, _findSourceFrameOrder(source) { let order = 0; let found = -1; const seen = new Set(); const visit = (doc, depth) => { if (!doc || seen.has(doc) || depth > 4 || found >= 0) return; seen.add(doc); let frames = []; try { frames = Array.from(doc.querySelectorAll('iframe,frame')); } catch (_) { return; } for (const frame of frames) { const currentOrder = order++; let frameWindow = null; try { frameWindow = frame.contentWindow; } catch (_) {} if (frameWindow === source) { found = currentOrder; return; } try { const childDoc = frame.contentDocument || frameWindow?.document; if (childDoc) visit(childDoc, depth + 1); } catch (_) {} if (found >= 0) return; } }; visit(document, 0); return found; }, initController() { if (this._controllerReady || _isMediaWorkerFrame) return; this._controllerReady = true; this._session = this._newId('controller'); window.addEventListener('message', event => this._handleControllerMessage(event)); }, _handleControllerMessage(event) { const data = event?.data; if (!data || data.channel !== this.CHANNEL || data.direction !== 'worker') return; if (!this._isTrustedOrigin(event.origin) || !event.source) return; if (data.type === 'HELLO') { const workerId = String(data.workerId || ''); if (!workerId) return; // iframe reload 后 WindowProxy 保持不变,用新 worker id 替换旧记录。 for (const [oldId, oldWorker] of this._workers) { if (oldId !== workerId && oldWorker.source === event.source) { this._workers.delete(oldId); if (this._activeWorkerId === oldId) this._activeWorkerId = workerId; } } const worker = this._workers.get(workerId) || { id: workerId, source: event.source, origin: event.origin, state: 'pending', hasVideo: false, videoHint: !!data.videoHint, progress: 0, lastSeen: 0, playToken: '', playAcknowledged: false }; worker.source = event.source; worker.origin = event.origin; worker.videoHint = !!data.videoHint; worker.lastSeen = Date.now(); this._applyWorkerSnapshot(worker, data.snapshot); this._workers.set(workerId, worker); this._send(worker, 'INIT', { playbackRate: ConfigStore.getVideoConfig().playbackRate || 2, chapterEpoch: this._chapterEpoch }); if (VideoModule._playing && !VideoModule._videoEl) { setTimeout(() => VideoModule._tryPlayVideo(), 0); } return; } const worker = this._workers.get(String(data.workerId || '')); if (!worker || worker.source !== event.source || worker.origin !== event.origin || data.session !== this._session) return; const messageEpoch = Number(data.chapterEpoch || 0); if (messageEpoch !== this._chapterEpoch) return; if (data.type === 'LOG') { if (worker.playToken && data.playToken !== worker.playToken) return; worker.lastSeen = Date.now(); const entry = data.entry || {}; if (entry.message) Logger.log(`[视频帧] ${entry.message}`, entry.level || 'info'); return; } if (data.type === 'STATE') { // RELOAD 后旧 document 可能短暂上报,必须等待新 worker 的 HELLO。 if (worker.state === 'reloading') return; if (worker.playToken && data.playToken !== worker.playToken) return; worker.lastSeen = Date.now(); const wasUsable = this._isWorkerUsable(worker); this._applyWorkerSnapshot(worker, data.snapshot); if (worker.playToken && data.playToken === worker.playToken && worker.state === 'playing') { worker.playAcknowledged = true; } delete worker.awaitingSince; if (!wasUsable && this._isWorkerUsable(worker) && VideoModule._playing && !VideoModule._videoEl) { setTimeout(() => VideoModule._tryPlayVideo(), 0); } return; } if (data.type !== 'EVENT') return; if (worker.id !== this._activeWorkerId || !worker.playToken || data.playToken !== worker.playToken) return; // 同一坏源事件可能由尚未取消的旧重试链重复上报;首次 RELOAD 已足够。 if (data.event === 'SOURCE_EXHAUSTED' && worker.state === 'reloading') return; worker.lastSeen = Date.now(); this._applyWorkerSnapshot(worker, data.snapshot); if (data.event === 'ENDED') { const hasMore = !!worker.hasMore; worker.state = hasMore && ['ready', 'pending', 'unsupported'].includes(worker.state) ? worker.state : (hasMore ? 'ready' : 'finished'); worker.hasVideo = hasMore; worker.playToken = ''; worker.playAcknowledged = false; this._activeWorkerId = ''; VideoModule._handleRemoteVideoEnded(); } else if (data.event === 'SOURCE_EXHAUSTED') { // 一个跨域 frame 可以包含多个视频;坏源只跳过当前视频,继续处理剩余视频。 if (worker.hasMore) { worker.state = ['ready', 'pending', 'unsupported'].includes(worker.state) ? worker.state : 'ready'; worker.hasVideo = true; worker.playToken = ''; worker.playAcknowledged = false; this._activeWorkerId = ''; VideoModule._handleRemoteVideoUnavailable(data.detail || '当前视频源不可用'); return; } if (!this._reloadedSources.has(worker.source)) { this._reloadedSources.add(worker.source); worker.state = 'reloading'; Logger.log('跨域视频源持续不可用,已重载一次视频播放器', 'info'); if (this._send(worker, 'COMMAND', { command: 'RELOAD', chapterEpoch: this._chapterEpoch, playToken: worker.playToken })) { const workerId = worker.id; const source = worker.source; const epoch = this._chapterEpoch; setTimeout(() => { const current = this._workers.get(workerId); if (!current || current.source !== source || current.state !== 'reloading' || this._chapterEpoch !== epoch) return; current.state = 'unavailable'; current.playToken = ''; current.playAcknowledged = false; if (this._activeWorkerId === workerId) this._activeWorkerId = ''; VideoModule._handleRemoteVideoUnavailable('视频播放器重载后未响应'); }, 12000); } else { worker.state = 'unavailable'; worker.playToken = ''; worker.playAcknowledged = false; this._activeWorkerId = ''; VideoModule._handleRemoteVideoUnavailable('无法重载视频播放器'); } } else { worker.state = 'unavailable'; worker.playToken = ''; worker.playAcknowledged = false; this._activeWorkerId = ''; VideoModule._handleRemoteVideoUnavailable(data.detail || ''); } } }, _send(worker, type, payload = {}) { try { worker.source.postMessage({ channel: this.CHANNEL, direction: 'controller', type, workerId: worker.id, session: this._session, payload }, worker.origin); return true; } catch (_) { return false; } }, _watchActiveWorker(workerId, playToken) { const maxSilence = 90000; const check = () => { const worker = this._workers.get(workerId); if (!worker || this._activeWorkerId !== workerId || worker.playToken !== playToken || worker.chapterEpoch !== this._chapterEpoch) return; const silence = Date.now() - Number(worker.lastSeen || 0); if (silence >= maxSilence) { worker.state = 'unavailable'; worker.playToken = ''; worker.playAcknowledged = false; this._activeWorkerId = ''; VideoModule._handleRemoteVideoUnavailable('视频工作帧连接超时'); return; } setTimeout(check, Math.max(10000, maxSilence - silence)); }; setTimeout(check, 30000); }, _isWorkerUsable(worker) { if (!worker || !worker.source) return false; if (worker.state === 'finished' || worker.state === 'unavailable' || worker.state === 'reloading' || worker.state === 'awaiting') return false; return worker.hasVideo || worker.videoHint || worker.state === 'ready' || worker.state === 'pending' || worker.state === 'unsupported'; }, hasVideoWorker() { return Array.from(this._workers.values()).some(worker => this._isWorkerUsable(worker)); }, playNextWorker() { let worker = this._workers.get(this._activeWorkerId); // token 存在即表示该 PLAY 尚未终结,pending/ready 状态也不能重复下发。 if (this._isWorkerUsable(worker) && worker.playToken && worker.chapterEpoch === this._chapterEpoch) return true; const now = Date.now(); for (const [workerId, candidate] of this._workers) { if (candidate.state === 'awaiting' && now - Number(candidate.awaitingSince || now) > 5000) { this._workers.delete(workerId); } } const candidates = Array.from(this._workers.values()) .filter(candidate => this._isWorkerUsable(candidate)) .sort((left, right) => { const leftOrder = this._findSourceFrameOrder(left.source); const rightOrder = this._findSourceFrameOrder(right.source); if (leftOrder >= 0 && rightOrder >= 0) return leftOrder - rightOrder; if (leftOrder >= 0) return -1; if (rightOrder >= 0) return 1; return Number(right.lastSeen || 0) - Number(left.lastSeen || 0); }); const cfg = ConfigStore.getVideoConfig(); for (worker of candidates) { this._activeWorkerId = worker.id; worker.state = 'starting'; worker.chapterEpoch = this._chapterEpoch; worker.playToken = `${this._chapterEpoch}-${++this._playSequence}-${this._newId('play')}`; worker.playAcknowledged = false; const sent = this._send(worker, 'COMMAND', { command: 'PLAY', chapterEpoch: this._chapterEpoch, playToken: worker.playToken, playbackRate: cfg.playbackRate || 2, autoSkipQuiz: cfg.autoSkipQuiz !== false, autoAnswerQuiz: cfg.autoAnswerQuiz !== false, quizMode: cfg.quizMode || 'free' }); if (!sent) { worker.state = 'unavailable'; worker.playToken = ''; worker.playAcknowledged = false; this._activeWorkerId = ''; continue; } const workerId = worker.id; const playToken = worker.playToken; const epoch = this._chapterEpoch; worker.lastSeen = Date.now(); this._watchActiveWorker(workerId, playToken); setTimeout(() => { const current = this._workers.get(workerId); if (!current || this._chapterEpoch !== epoch || this._activeWorkerId !== workerId || current.playToken !== playToken) return; if (current.playAcknowledged || current.state === 'playing') return; this._send(current, 'COMMAND', { command: 'STOP', chapterEpoch: epoch, playToken }); current.state = 'unavailable'; current.playToken = ''; current.playAcknowledged = false; this._activeWorkerId = ''; VideoModule._handleRemoteVideoUnavailable('视频工作帧未响应'); }, 30000); return true; } return false; }, stopWorkers() { for (const worker of this._workers.values()) { this._send(worker, 'COMMAND', { command: 'STOP', chapterEpoch: this._chapterEpoch, playToken: worker.playToken || '' }); worker.playToken = ''; worker.playAcknowledged = false; if (['starting', 'playing', 'pending', 'ready'].includes(worker.state)) worker.state = 'ready'; } this._activeWorkerId = ''; }, setPlaybackRate(rate) { for (const worker of this._workers.values()) { this._send(worker, 'COMMAND', { command: 'SET_RATE', chapterEpoch: this._chapterEpoch, playbackRate: rate }); } }, getActiveProgress() { const worker = this._workers.get(this._activeWorkerId); return worker && Number.isFinite(Number(worker.progress)) ? Number(worker.progress) : null; }, resetChapter() { this._chapterEpoch++; this._playSequence = 0; this._activeWorkerId = ''; this._reloadedSources = new WeakSet(); for (const worker of this._workers.values()) { worker.state = 'awaiting'; worker.awaitingSince = Date.now(); worker.hasVideo = false; worker.progress = 0; worker.playToken = ''; worker.playAcknowledged = false; worker.chapterEpoch = this._chapterEpoch; this._send(worker, 'COMMAND', { command: 'RESET', chapterEpoch: this._chapterEpoch }); const workerId = worker.id; const chapterEpoch = this._chapterEpoch; setTimeout(() => { const current = this._workers.get(workerId); if (current === worker && current.state === 'awaiting' && current.chapterEpoch === chapterEpoch) { this._workers.delete(workerId); } }, 6000); } }, initWorker() { if (this._workerReady || !_isMediaWorkerFrame) return; this._workerReady = true; this._workerId = this._newId('worker'); window.addEventListener('message', event => this._handleWorkerMessage(event)); Logger._onLog = entry => { if (this._workerSession) this._workerPost('LOG', { entry }); }; const scheduleReport = () => this._scheduleWorkerReport(); try { if (typeof MutationObserver === 'function' && document.documentElement) { this._workerObserver = new MutationObserver(scheduleReport); this._workerObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); } document.addEventListener('loadedmetadata', scheduleReport, true); document.addEventListener('canplay', scheduleReport, true); } catch (_) {} this._announceWorker(0); }, _scheduleWorkerReport(delay = 120) { if (!this._workerSession) return; if (this._workerReportTimer) clearTimeout(this._workerReportTimer); this._workerReportTimer = setTimeout(() => { this._workerReportTimer = null; this.reportWorkerState(true); }, delay); }, _announceWorker(attempt) { if (this._workerSession || attempt >= 5) return; this._workerPost('HELLO', { videoHint: _hasVideoUrlHint, snapshot: this._workerSnapshot() }, true); setTimeout(() => this._announceWorker(attempt + 1), 750 * (attempt + 1)); }, _handleWorkerMessage(event) { const data = event?.data; if (!data || data.channel !== this.CHANNEL || data.direction !== 'controller') return; if (event.source !== window.top || !this._isTrustedOrigin(event.origin)) return; if (data.workerId !== this._workerId) return; if (data.type === 'INIT') { this._workerSession = String(data.session || ''); this._workerChapterEpoch = Number(data.payload?.chapterEpoch || 0); this._workerPlayToken = ''; this._controllerOrigin = event.origin; const rate = Number(data.payload?.playbackRate || 2); const video = VideoModule._findVideoElement(); if (video) video.playbackRate = rate; this.reportWorkerState(true); return; } if (!this._workerSession || data.session !== this._workerSession || event.origin !== this._controllerOrigin) return; if (data.type !== 'COMMAND') return; const command = data.payload?.command; const commandEpoch = Number(data.payload?.chapterEpoch || 0); if (command === 'RESET') { // RESET 是唯一允许推进 epoch 的命令;延迟到达的旧 RESET 不能回退 worker。 if (!commandEpoch || commandEpoch < this._workerChapterEpoch) return; this._workerChapterEpoch = commandEpoch; this._workerPlayToken = ''; VideoModule.stop(); VideoModule._videoEl = null; VideoModule._resetVideoSourceWait(true); this._scheduleWorkerReport(800); setTimeout(() => this.reportWorkerState(true), 2500); setTimeout(() => this.reportWorkerState(true), 5000); return; } if (!commandEpoch || commandEpoch !== this._workerChapterEpoch) return; if (command === 'PLAY') { const playToken = String(data.payload?.playToken || ''); if (!playToken) return; this._workerPlayToken = playToken; const cfg = ConfigStore.getVideoConfig(); cfg.playbackRate = Number(data.payload?.playbackRate || cfg.playbackRate || 2); cfg.autoSkipQuiz = data.payload?.autoSkipQuiz !== false; cfg.autoAnswerQuiz = data.payload?.autoAnswerQuiz !== false; cfg.quizMode = data.payload?.quizMode || cfg.quizMode || 'free'; ConfigStore.setVideoConfig(cfg); if (!VideoModule._playing) VideoModule.start(); else VideoModule._tryPlayVideo(); this.reportWorkerState(true); } else if (command === 'STOP') { const stopToken = String(data.payload?.playToken || ''); if (stopToken && this._workerPlayToken && stopToken !== this._workerPlayToken) return; this._workerPlayToken = ''; VideoModule.stop(); this.reportWorkerState(true); } else if (command === 'SET_RATE') { const rate = Number(data.payload?.playbackRate || 2); const video = VideoModule._videoEl || VideoModule._findVideoElement(); if (video) video.playbackRate = rate; } else if (command === 'RELOAD') { if (!this._workerPlayToken || data.payload?.playToken !== this._workerPlayToken) return; try { window.location.reload(); } catch (_) {} } }, _workerPost(type, payload = {}, hello = false, context = null) { try { window.top.postMessage({ channel: this.CHANNEL, direction: 'worker', type, workerId: this._workerId, session: hello ? '' : this._workerSession, chapterEpoch: context?.chapterEpoch ?? this._workerChapterEpoch, playToken: context?.playToken ?? this._workerPlayToken, ...payload }, hello ? '*' : this._controllerOrigin); } catch (_) {} }, _workerSnapshot() { const all = VideoModule._findAllVideoElements(); const remaining = all.filter(video => video.dataset?.cxFinished !== '1' && video.dataset?.cxSourceUnavailable !== '1' && !video.ended); const video = VideoModule._videoEl || VideoModule._findVideoElement(); const mediaState = video ? VideoModule._getVideoMediaState(video) : null; const progress = video && Number(video.duration) > 0 ? Math.max(0, Math.min(100, Math.round((Number(video.currentTime) / Number(video.duration)) * 100))) : 0; return { hasVideo: remaining.length > 0, hasMore: remaining.length > 0, state: video && !video.paused && !video.ended ? 'playing' : (mediaState?.state || (all.length ? 'finished' : (_hasVideoUrlHint ? 'pending' : 'missing'))), progress }; }, reportWorkerState(force = false) { if (!_isMediaWorkerFrame || !this._workerSession) return; const now = Date.now(); if (!force && now - this._lastWorkerReportAt < 900) return; this._lastWorkerReportAt = now; this._workerPost('STATE', { snapshot: this._workerSnapshot() }); }, notifyWorkerEnded() { if (!_isMediaWorkerFrame) return; const context = { chapterEpoch: this._workerChapterEpoch, playToken: this._workerPlayToken }; setTimeout(() => this._workerPost('EVENT', { event: 'ENDED', snapshot: this._workerSnapshot() }, false, context), 0); }, notifyWorkerSourceExhausted(detail = '') { if (!_isMediaWorkerFrame) return; this._workerPost('EVENT', { event: 'SOURCE_EXHAUSTED', detail: String(detail || ''), snapshot: this._workerSnapshot() }); } }; // ========================================== // VideoModule - 视频自动播放模块 // ========================================== const VideoModule = { _playing: false, _currentChapter: '', _progress: 0, _videoEl: null, _antiPauseHandlers: [], _pauseCheckInterval: null, _quizCheckInterval: null, _progressInterval: null, _loadRetryCount: 0, _maxLoadRetries: 3, _userPaused: false, _quizAnswering: false, _lastQuizCloseTime: 0, _lastPauseTime: 0, _tryingPlay: false, _pendingPlayTimeout: null, _advanceInFlight: false, _lastNextControl: null, _lastNextSnapshot: '', _snapshotNodeIds: new WeakMap(), _nextSnapshotNodeId: 1, _documentStateKey: '', _documentNoProgressCount: 0, _sourceWaitVideo: null, _sourceWaitCount: 0, _maxSourceWaits: 8, _sourceFrameReloaded: false, _sourceReloadedTargets: new WeakSet(), // 6.1 - start(): 开始自动播放 start() { if (this._playing) return; this._playing = true; const cfg = ConfigStore.getVideoConfig(); Logger.log('视频自动播放已启动', 'success'); UIPanel.setRunning(true); UIPanel.setStatus('运行中', 'success'); this._loadRetryCount = 0; this._userPaused = false; this._advanceInFlight = false; this._lastNextControl = null; this._lastNextSnapshot = ''; this._documentStateKey = ''; this._documentNoProgressCount = 0; this._resetVideoSourceWait(true); this.installAntiPause(); this.playCurrentVideo(); // 启动视频中途弹题/测验弹窗检测 if (cfg.autoSkipQuiz || cfg.autoAnswerQuiz !== false) { this._quizCheckInterval = setInterval(() => this.detectAndCloseQuiz(), 2000); } // 启动进度更新 this._progressInterval = setInterval(() => this._updateProgress(), 1000); this._updateVideoUI(); }, // 6.1 - stop(): 停止自动播放 stop() { const activeVideo = this._videoEl; if (!this._playing) { try { if (activeVideo && !activeVideo.paused && !activeVideo.ended) activeVideo.pause(); } catch (_) {} return; } this._playing = false; if (!_isMediaWorkerFrame) FrameMediaBridge.stopWorkers(); this.removeAntiPause(); try { if (activeVideo && !activeVideo.paused && !activeVideo.ended) activeVideo.pause(); } catch (_) {} if (this._quizCheckInterval) { clearInterval(this._quizCheckInterval); this._quizCheckInterval = null; } if (this._progressInterval) { clearInterval(this._progressInterval); this._progressInterval = null; } if (this._pauseCheckInterval) { clearInterval(this._pauseCheckInterval); this._pauseCheckInterval = null; } if (this._pendingPlayTimeout) { clearTimeout(this._pendingPlayTimeout); this._pendingPlayTimeout = null; } this._tryingPlay = false; this._advanceInFlight = false; this._videoEl = null; this._documentStateKey = ''; this._documentNoProgressCount = 0; this._resetVideoSourceWait(true); Logger.log('视频自动播放已停止', 'info'); UIPanel.setRunning(false); UIPanel.setStatus('已停止', 'info'); this._updateVideoUI(); }, // 6.1 - playCurrentVideo(): 检测页面视频元素并播放 async playCurrentVideo() { if (!this._playing) return; this._loadRetryCount = 0; await this._tryPlayVideo(); }, // 内部:尝试查找并播放视频,15秒超时重试 async _tryPlayVideo() { if (!this._playing) return; if (this._tryingPlay) { Logger.log('_tryPlayVideo 已在执行中,跳过重复调用', 'info'); return; } this._tryingPlay = true; try { await this._tryPlayVideoInner(); } finally { this._tryingPlay = false; } }, async _tryPlayVideoInner() { if (!this._playing) return; const video = this._findVideoElement(); if (video) { const mediaState = this._getVideoMediaState(video); if (mediaState.state !== 'ready') { this._queueVideoSourceRetry(video, mediaState); return; } this._videoEl = video; const cfg = ConfigStore.getVideoConfig(); video.playbackRate = cfg.playbackRate || 2; video.muted = false; let played = false; try { await video.play(); played = true; } catch (error) { // 只有浏览器的自动播放限制适合通过静音重试;空源/坏源静音没有意义。 if (this._isAutoplayBlockedError(error)) { video.muted = true; try { await video.play(); played = true; Logger.log('视频已静音播放(浏览器限制)', 'info'); } catch (retryError) { this._queueVideoSourceRetry(video, this._getVideoMediaState(video), retryError); return; } } else { this._queueVideoSourceRetry(video, this._getVideoMediaState(video), error); return; } } if (!played || !this._playing) return; this._resetVideoSourceWait(); this._loadRetryCount = 0; Logger.log(`视频开始播放,倍速 ${video.playbackRate}x`, 'success'); this._updateChapterName(); this._bindVideoEvents(video); this._installVideoPauseHook(video); this._updateVideoUI(); return; } // 视频未找到,检查是否为章节测验页面(等待iframe加载) await new Promise(r => setTimeout(r, 3000)); if (!this._playing) return; // 跨域视频由其 iframe 内的轻量 worker 播放,结束后再由顶层统一推进章节。 if (!_isMediaWorkerFrame && FrameMediaBridge.playNextWorker()) { Logger.log('已连接跨域视频播放器,等待媒体源就绪', 'info'); UIPanel.setStatus('正在连接视频播放器', 'info'); return; } if (_isMediaWorkerFrame) { if (this._loadRetryCount < this._maxLoadRetries) { Logger.log(`视频工作帧尚未创建播放器,5秒后重试 (${this._loadRetryCount + 1}/${this._maxLoadRetries})`, 'info'); await new Promise(resolve => setTimeout(resolve, 5000)); if (!this._playing) return; this._loadRetryCount++; await this._tryPlayVideoInner(); } else { const detail = '视频工作帧内未检测到视频元素'; Logger.log(detail, 'error'); FrameMediaBridge.notifyWorkerSourceExhausted(detail); this.stop(); } return; } const quizDoc = this._findChapterQuizDoc(); if (quizDoc) { const videoCfg = ConfigStore.getVideoConfig(); const autoAnswer = videoCfg.autoAnswerQuiz !== false; const quizMode = videoCfg.quizMode || 'free'; if (autoAnswer) { const modeNames = { free: '免费题库', paid: '付费题库', ai: 'AI 答题' }; Logger.log(`检测到章节测验,模式: ${modeNames[quizMode] || quizMode}`, 'info'); } else { Logger.log('检测到章节测验,未开启自动答题,跳过', 'info'); } await this._autoAnswerChapterQuiz(quizDoc); return; } // 学习目标、PPT、PDF、课件等页面本来就没有 video,直接走内容推进。 // 只有存在明确的非视频内容证据时才提前推进,避免误跳仍在加载的视频。 if (await this._handleNonVideoContent(false)) return; // 视频未找到,等待加载 if (this._loadRetryCount < this._maxLoadRetries) { Logger.log(`未检测到视频元素,15秒后重试 (${this._loadRetryCount + 1}/${this._maxLoadRetries})`, 'info'); await new Promise(resolve => setTimeout(resolve, 15000)); if (!this._playing) return; this._loadRetryCount++; // 刷新当前章节iframe this._refreshCurrentFrame(); await new Promise(resolve => setTimeout(resolve, 3000)); if (!this._playing) return; await this._tryPlayVideoInner(); } else { // 重试耗尽后再检查一次明确的非视频内容;未知空白页不能盲目跳过。 if (await this._handleNonVideoContent(true)) return; Logger.log('视频加载失败,且未识别到学习目标或文档内容', 'error'); this.stop(); UIPanel.setStatus('视频加载失败', 'error'); } }, // 内部:查找下一个**未播完**的视频元素(包括iframe内部,最多4层) // 一个课时页面可能放多个视频任务点(截图:3. xxx / 4. xxx),需要逐个播 _findVideoElement() { const all = this._findAllVideoElements(); let pending = null; let unsupported = null; for (const v of all) { if (v.isConnected === false || v.hidden || v.getAttribute?.('aria-hidden') === 'true') continue; try { const style = (v.ownerDocument?.defaultView || window).getComputedStyle(v); if (style.display === 'none' || style.visibility === 'hidden') continue; if (v.closest?.('[hidden], .hide, .hidden, [aria-hidden="true"]')) continue; } catch (_) {} const source = this._getVideoSourceKey(v); if (v.dataset.cxSourceUnavailable === '1') { const unavailableSource = v.dataset.cxSourceUnavailableSrc || ''; // SPA 复用 video 节点时,新媒体源仍应重新尝试。 if (source && source !== unavailableSource) { delete v.dataset.cxSourceUnavailable; delete v.dataset.cxSourceUnavailableSrc; } else { continue; } } if (v.dataset.cxFinished === '1') { const finishedSource = v.dataset.cxFinishedSrc || ''; // SPA 可能复用同一个 video 节点;仅当媒体来源确实变化时清除完成态。 if (finishedSource && source && finishedSource !== source) { delete v.dataset.cxFinished; delete v.dataset.cxFinishedSrc; } else { continue; } } if (v.ended) { v.dataset.cxFinished = '1'; if (source) v.dataset.cxFinishedSrc = source; continue; } const mediaState = this._getVideoMediaState(v); if (mediaState.state === 'ready') return v; if (mediaState.state === 'pending' && !pending) pending = v; if (mediaState.state === 'unsupported' && !unsupported) unsupported = v; } return pending || unsupported; }, _getVideoSourceKey(video) { if (!video) return ''; try { return String( video.currentSrc || video.src || video.getAttribute?.('src') || video.querySelector?.('source[src]')?.src || '' ).trim(); } catch (_) { return ''; } }, _getVideoMediaState(video) { if (!video) return { state: 'pending', source: '', errorCode: 0, networkState: 0, readyState: 0 }; const source = this._getVideoSourceKey(video); let hasSrcObject = false; try { hasSrcObject = !!video.srcObject; } catch (_) {} const errorCode = Number(video.error?.code || 0); const networkState = Number(video.networkState ?? 0); // HTMLMediaElement 始终提供 readyState;测试替身没有该字段时按已有来源处理。 const readyState = typeof video.readyState === 'number' ? Number(video.readyState) : ((!!source || hasSrcObject) ? 4 : 0); const unsupported = errorCode === 4 || (networkState === 3 && !!source && !hasSrcObject); const hasSource = !!source || hasSrcObject; const state = unsupported ? 'unsupported' : (hasSource && readyState >= 1 ? 'ready' : 'pending'); return { state, source, hasSrcObject, errorCode, networkState, readyState }; }, _isVideoSourceReady(video) { return this._getVideoMediaState(video).state === 'ready'; }, _isAutoplayBlockedError(error) { return String(error?.name || '') === 'NotAllowedError'; }, _isSourcePlayError(error) { const name = String(error?.name || ''); const message = String(error?.message || error || ''); return name === 'NotSupportedError' || name === 'AbortError' || /no supported sources|source.*not supported|MEDIA_ERR_SRC_NOT_SUPPORTED/i.test(message); }, _getVideoReloadTarget(video) { if (!video) return null; try { return video.ownerDocument?.defaultView?.frameElement || video; } catch (_) { return video; } }, _resetVideoSourceWait(resetReloadHistory = false) { this._sourceWaitVideo = null; this._sourceWaitCount = 0; this._sourceFrameReloaded = false; if (resetReloadHistory) this._sourceReloadedTargets = new WeakSet(); }, _queueVideoSourceRetry(video, mediaState = null, playError = null) { if (!this._playing) return; if (this._sourceWaitVideo !== video) { this._sourceWaitVideo = video; this._sourceWaitCount = 0; const reloadTarget = this._getVideoReloadTarget(video); this._sourceFrameReloaded = !!(reloadTarget && this._sourceReloadedTargets.has(reloadTarget)); } this._sourceWaitCount++; this._videoEl = null; const state = mediaState || this._getVideoMediaState(video); if (state.state === 'pending' && state.source && state.readyState < 1 && video?.dataset) { if (video.dataset.cxLoadRequestedSrc !== state.source) { video.dataset.cxLoadRequestedSrc = state.source; try { video.load?.(); } catch (_) {} } } const sourceFailure = state.state === 'unsupported' || this._isSourcePlayError(playError); const waitLabel = sourceFailure ? '视频源暂不可用' : '等待视频源加载'; if (this._sourceWaitCount <= this._maxSourceWaits) { if (this._sourceWaitCount === 1 || this._sourceWaitCount === this._maxSourceWaits) { Logger.log(`${waitLabel},稍后重试 (${this._sourceWaitCount}/${this._maxSourceWaits})`, 'info'); } UIPanel.setStatus(waitLabel, 'info'); if (this._pendingPlayTimeout) clearTimeout(this._pendingPlayTimeout); this._pendingPlayTimeout = setTimeout(() => { this._pendingPlayTimeout = null; if (this._playing) this._tryPlayVideo(); }, 2000); return; } if (!this._sourceFrameReloaded) { this._sourceFrameReloaded = true; if (this._refreshCurrentFrame(true)) { const reloadTarget = this._getVideoReloadTarget(video); if (reloadTarget) this._sourceReloadedTargets.add(reloadTarget); this._sourceWaitCount = 0; Logger.log('视频源持续不可用,已重载一次视频播放器', 'info'); UIPanel.setStatus('正在重载视频播放器', 'info'); if (this._pendingPlayTimeout) clearTimeout(this._pendingPlayTimeout); this._pendingPlayTimeout = setTimeout(() => { this._pendingPlayTimeout = null; if (this._playing) this._tryPlayVideo(); }, 3000); return; } } const detail = String(playError?.message || '').trim(); Logger.log(`视频源不可用${detail ? `: ${detail}` : ''}`, 'error'); if (video?.dataset) { video.dataset.cxSourceUnavailable = '1'; const source = this._getVideoSourceKey(video); if (source) video.dataset.cxSourceUnavailableSrc = source; } this._sourceWaitVideo = null; this._sourceWaitCount = 0; const nextVideo = this._findVideoElement(); if (nextVideo && nextVideo !== video) { Logger.log('已跳过不可用的视频源,继续下一个视频任务点', 'info'); UIPanel.setStatus('继续下一个视频', 'info'); this._pendingPlayTimeout = setTimeout(() => { this._pendingPlayTimeout = null; if (this._playing) this._tryPlayVideo(); }, 1000); return; } if (_isMediaWorkerFrame) { FrameMediaBridge.notifyWorkerSourceExhausted(detail); return; } if (FrameMediaBridge.playNextWorker()) { Logger.log('已跳过不可用的视频源,继续跨域视频任务点', 'info'); UIPanel.setStatus('继续下一个视频', 'info'); return; } const cfg = ConfigStore.getVideoConfig(); if (cfg.autoAdvance !== false) { Logger.log('当前视频源不可用,继续检查测验或下一章节', 'info'); UIPanel.setStatus('跳过不可用视频', 'info'); this._pendingPlayTimeout = setTimeout(() => { this._pendingPlayTimeout = null; if (!this._playing) return; if (cfg.autoAnswerQuiz !== false) this._checkAndHandleChapterQuiz(); else this.advanceToNextChapter(); }, 1000); } else { UIPanel.setStatus('视频源不可用', 'error'); } }, // 收集当前页面(含所有可访问 iframe)的全部 video 元素 _findAllVideoElements() { const result = []; const collect = (doc) => { try { doc.querySelectorAll('video').forEach(v => result.push(v)); } catch (_) {} }; collect(document); const searchIframes = (doc, depth) => { if (depth > 4) return; try { const iframes = doc.querySelectorAll('iframe'); for (const iframe of iframes) { try { if (iframe.id === 'cx-helper-iframe' || !this._isUsableNavigationElement(iframe)) continue; const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (!iframeDoc) continue; collect(iframeDoc); searchIframes(iframeDoc, depth + 1); } catch (_) { /* 跨域iframe忽略 */ } } } catch (_) {} }; searchIframes(document, 1); return result; }, _isUsableNavigationElement(el) { if (!el || el.disabled || el.getAttribute?.('aria-disabled') === 'true') return false; const cls = String(el.className || ''); if (/\b(disabled|is-disabled|forbid|readonly)\b/i.test(cls)) return false; try { const win = el.ownerDocument?.defaultView || window; const style = win.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; } catch (_) {} const rect = el.getBoundingClientRect?.(); return !(rect && rect.width === 0 && rect.height === 0); }, _controlText(el) { const raw = el?.innerText || el?.textContent || el?.value || el?.getAttribute?.('aria-label') || el?.getAttribute?.('title') || ''; return String(raw).replace(/\s+/g, '').replace(/[›»→>]+$/g, ''); }, // 查找课程级“下一节”,明确排除 PDF/PPT 阅读器内的“下一页”。 _findPageNextButton() { const docs = CXUtils.listAccessibleDocs(document, 4); const selectors = [ 'button', 'a', '[role="button"]', 'input[type="button"]', 'input[type="submit"]', '[onclick]', '[data-action*="next" i]', '[class*="next" i]', '[id*="next" i]' ].join(','); const exactNext = /^(下一节|下一节课|下一小节|下一章|下一课|下一个任务)$/; const excludedContext = [ '#cx-helper-iframe', '[role="dialog"]', '.modal', '.layui-layer', '.question', '.quiz', '.exam', '.test', '.ans-attach-ct', '.pagination', '.swiper', '.pdf-viewer', '.ppt-viewer', '.document-reader' ].join(','); for (const doc of docs) { if (!doc || doc === UIPanel._doc) continue; let candidates = []; try { candidates = Array.from(doc.querySelectorAll(selectors)); } catch (_) {} for (const el of candidates) { if (!this._isUsableNavigationElement(el)) continue; const text = this._controlText(el); if (!exactNext.test(text)) continue; try { if (el.closest?.(excludedContext)) continue; } catch (_) {} return el; } } return null; }, _detectNonVideoContentType() { const docs = CXUtils.listAccessibleDocs(document, 4); const documentSelectors = [ 'embed[type*="pdf" i]', 'object[type*="pdf" i]', 'iframe[src*=".pdf" i]', 'iframe[src*=".ppt" i]', 'iframe[src*="/pdf/" i]', 'iframe[src*="/ppt/" i]', 'iframe[src*="office" i]', 'iframe[module*="insertppt" i]', 'iframe[module*="insertpdf" i]', 'iframe[module*="insertdoc" i]', 'iframe[module*="insertbook" i]', '.ans-doc', '.ans-ppt', '.pdf-viewer', '.ppt-viewer', '.document-reader' ].join(','); const activeSelectors = [ '[role="tab"][aria-selected="true"]', '.prev_ul li.active', '.prev_ul li.on', '.prev_tab li.active', '.prev_tab li.on', '.chapter_tab .active', '.chapter_tab .on', '.ans-cc-tab .active', '.ans-cc-tab .on', '.tab-item.active', '.tab-item.on', '[class*="tab"] button.active', '[class*="tab"] a.active', '[class*="tab"] li.active', '[class*="tab"] .active', '[class*="tab"] .on' ].join(','); const contentHeadingSelectors = [ 'main h1', 'main h2', 'main h3', '.main-content h1', '.main-content h2', '.content-title', '.chapter-title', '.knowledge-title' ].join(','); const nonVideoLabel = /(?:学习|教学|课程)(?:目标|要求)|PPT|PDF|课件|文档|图文|阅读|资料|附件|讲义/i; const goalHeading = /^(?:\d+(?:\.\d+)*[、.\s]*)?(?:学习|教学|课程)(?:目标|要求)$/; for (const doc of docs) { if (!doc || doc === UIPanel._doc) continue; let markers = []; try { markers = Array.from(doc.querySelectorAll(documentSelectors)); } catch (_) {} if (markers.some(el => this._isUsableNavigationElement(el))) return 'PPT/PDF/文档'; let activeItems = []; try { activeItems = Array.from(doc.querySelectorAll(activeSelectors)); } catch (_) {} for (const item of activeItems) { if (!this._isUsableNavigationElement(item)) continue; const text = this._controlText(item); const match = text.match(nonVideoLabel); if (match) return match[0].toUpperCase(); } let headings = []; try { headings = Array.from(doc.querySelectorAll(contentHeadingSelectors)); } catch (_) {} for (const heading of headings) { if (!this._isUsableNavigationElement(heading)) continue; const text = this._controlText(heading); if (goalHeading.test(text)) return text.replace(/^\d+(?:\.\d+)*[、.]*/, '') || '学习目标'; } } return ''; }, _isDocumentContentType(contentType) { return /PPT|PDF|文档|课件|讲义/i.test(String(contentType || '')); }, _collectDocumentContexts() { const docs = CXUtils.listAccessibleDocs(document, 4); const contexts = []; const seen = new Set(); const framePattern = /(?:\/ananas\/modules\/(?:pdf|ppt|doc|book)\/|\.(?:pdf|ppt|pptx|doc|docx)(?:[?#]|$)|(?:^|[\/_.?=&-])(?:pdf|pptx?|docx?|book|office)(?:[\/_.?=&-]|$)|[?&](?:type|module)=(?:pdf|ppt|doc|book))/i; const markerSelector = [ '.ans-doc', '.ans-ppt', '.pdf-viewer', '.ppt-viewer', '.document-reader', '.pdfViewer', '.ppt-container', '.document-viewer', 'embed[type*="pdf" i]', 'object[type*="pdf" i]', 'iframe[src*=".pdf" i]', 'iframe[src*=".ppt" i]', 'iframe[src*="/pdf/" i]', 'iframe[src*="/ppt/" i]', 'iframe[src*="office" i]', 'iframe[module*="insertppt" i]', 'iframe[module*="insertpdf" i]', 'iframe[module*="insertdoc" i]', 'iframe[module*="insertbook" i]' ].join(','); const addContext = (doc, root, frame) => { const key = root || doc; if (!doc || seen.has(key)) return; seen.add(key); contexts.push({ doc, root: root || doc, frame: frame || null }); }; for (const doc of docs) { if (!doc || doc === UIPanel._doc) continue; let frame = null; let frameSrc = ''; try { frame = doc.defaultView?.frameElement || null; frameSrc = String(frame?.src || frame?.getAttribute?.('src') || ''); } catch (_) {} if (framePattern.test(frameSrc) && (!frame || this._isUsableNavigationElement(frame))) { addContext(doc, doc.documentElement || doc.body || doc, frame); } let markers = []; try { markers = Array.from(doc.querySelectorAll(markerSelector)); } catch (_) {} for (const marker of markers) { if (!this._isUsableNavigationElement(marker)) continue; let root = marker; try { root = marker.closest?.('.ans-attach-ct, .ans-job, .ans-cc, .task-point, .courseware, .attachment') || marker; } catch (_) {} addContext(doc, root, frame); try { const childDoc = marker.contentDocument || marker.contentWindow?.document; if (childDoc) addContext(childDoc, childDoc.documentElement || childDoc.body || childDoc, marker); } catch (_) {} } } return contexts; }, _hasDocumentCompletionEvidence(contexts) { const completedSelector = [ '.ans-job-finished', '.ans-job-icon.finished', '.ans-job-icon.job-finished', '.ans-job-icon[class*="finish" i]', '.job-finished', '.task-finished', '.task-complete', '[data-finished="true"]', '[data-status="finished"]', '[data-job-status="finished"]' ].join(','); for (const context of contexts) { const scope = context.root?.querySelectorAll ? context.root : context.doc; try { if (scope.matches?.(completedSelector)) return true; const markers = Array.from(scope.querySelectorAll(completedSelector)); if (markers.some(marker => marker.getAttribute?.('aria-hidden') !== 'true')) return true; } catch (_) {} } return false; }, _readDocumentPageProgress(contexts) { const indicatorSelector = [ '.page-num', '.page-number', '.page-count', '.page-index', '.current-page', '.ppt-page', '.pdf-page-number', '[class*="pageNum"]', '[class*="page-num"]', '[class*="page-number"]', '.pagination', '.swiper-pagination' ].join(','); for (const context of contexts) { const scope = context.root?.querySelectorAll ? context.root : context.doc; // PDF.js 使用两个独立控件表示页码,不能依赖合并后的 "1/10" 文本。 try { const currentEl = scope.querySelector?.('#pageNumber'); const totalEl = scope.querySelector?.('#numPages'); const current = Number.parseInt(String(currentEl?.value || currentEl?.textContent || '').match(/\d{1,4}/)?.[0] || '0', 10); const total = Number.parseInt(String(totalEl?.value || totalEl?.textContent || '').match(/\d{1,4}/)?.[0] || '0', 10); if (current > 0 && total > 0 && current <= total) { return { current, total, key: `${current}/${total}` }; } } catch (_) {} let indicators = []; try { indicators = Array.from(scope.querySelectorAll(indicatorSelector)); } catch (_) {} for (const indicator of indicators) { const text = String(indicator.innerText || indicator.textContent || indicator.value || '').replace(/\s+/g, ' '); const match = text.match(/(?:第\s*)?(\d{1,4})\s*(?:页\s*)?(?:\/|/|of|页[,,]?\s*共|共)\s*(\d{1,4})/i) || text.match(/当前\s*(\d{1,4})\s*页[^\d]{0,12}共\s*(\d{1,4})\s*页/i); if (!match) continue; const current = Number(match[1]); const total = Number(match[2]); if (current > 0 && total > 0 && current <= total) { return { current, total, key: `${current}/${total}` }; } } const slides = this._findDocumentSlides([{ ...context, root: scope }]); if (slides.length > 0) { const activeIndex = slides.findIndex(slide => { const cls = String(slide.className || ''); return /(?:^|\s)(?:active|current|on|swiper-slide-active)(?:\s|$)/i.test(cls) || /^(?:true|page)$/.test(String(slide.getAttribute?.('aria-current') || '')); }); if (activeIndex >= 0) { return { current: activeIndex + 1, total: slides.length, key: `${activeIndex + 1}/${slides.length}` }; } } } return { current: 0, total: 0, key: '' }; }, _findDocumentSlides(contexts) { const selector = [ '.swiper-wrapper .swiper-slide', '.ppt-page-list li', '.ppt-slide-list li', '.slide-list li', '.thumb-list li', '.thumbnail-list li', '.page-list li', '[class*="slide-list"] li', '[class*="page-list"] li', '[class*="thumbnail"]', '[class*="thumb"]' ].join(','); const slides = []; const seen = new Set(); for (const context of contexts) { const scope = context.root?.querySelectorAll ? context.root : context.doc; let nodes = []; try { nodes = Array.from(scope.querySelectorAll(selector)); } catch (_) {} for (const node of nodes) { if (!node || seen.has(node)) continue; const text = String(node.innerText || node.textContent || '').trim(); const hasMedia = !!node.querySelector?.('img, canvas, svg, [style*="background-image"]'); const numbered = /(?:第\s*)?\d{1,4}\s*页|^\s*\d{1,4}\s*$/.test(text) || !!node.getAttribute?.('data-page') || !!node.getAttribute?.('data-index'); if (!hasMedia && !numbered && !/swiper-slide/i.test(String(node.className || ''))) continue; seen.add(node); slides.push(node); } } return slides.slice(0, 500); }, _isDocumentNextControl(el) { if (!el) return false; const text = this._controlText(el); const aria = String(el.getAttribute?.('aria-label') || el.getAttribute?.('title') || '').replace(/\s+/g, ''); const cls = String(el.className || ''); const id = String(el.id || el.getAttribute?.('id') || ''); return /^(下一页|下页|下一张|下一屏|后一页|NEXT)$/i.test(text) || /^(下一页|下页|下一张|下一屏|后一页|NEXT)$/i.test(aria) || id === 'next' || /(?:swiper-button-next|next-page|page-next|nextPage|nextBtn|turnPageRight|rightArrow|mkeRbtn)(?:\s|$)/i.test(cls) || /next-page/i.test(String(el.getAttribute?.('data-action') || '')); }, _isDocumentControlDisabled(el) { if (!el) return true; const cls = String(el.className || ''); if (!!el.disabled || el.getAttribute?.('aria-disabled') === 'true' || /(?:^|\s)(?:disabled|is-disabled|swiper-button-disabled|forbid)(?:\s|$)/i.test(cls)) return true; try { const win = el.ownerDocument?.defaultView || window; const style = win.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return true; } catch (_) {} const rect = el.getBoundingClientRect?.(); return !!(rect && rect.width === 0 && rect.height === 0); }, _isLegacyDocumentNext(el) { if (!el || !/(?:^|\s)mkeRbtn(?:\s|$)/i.test(String(el.className || ''))) return false; try { return !!el.closest?.('.imglook'); } catch (_) { return false; } }, _findDocumentNextControl(contexts, includeDisabled = false) { const selector = [ '.imglook > .mkeRbtn', '#next', '.swiper-button-next', '.next-page', '.page-next', '.nextPage', '.nextBtn', '.turnPageRight', '.rightArrow', '[data-action="next-page"]', '[aria-label*="下一页"]', '[title*="下一页"]', '[aria-label*="下一张"]', '[title*="下一张"]', '[aria-label*="下一屏"]', '[title*="下一屏"]', 'button', 'a', '[role="button"]', '[onclick]' ].join(','); for (const context of contexts) { const scope = context.root?.querySelectorAll ? context.root : context.doc; let candidates = []; try { candidates = Array.from(scope.querySelectorAll(selector)); } catch (_) {} for (const candidate of candidates) { if (!this._isDocumentNextControl(candidate)) continue; if (includeDisabled) return candidate; if (!this._isDocumentControlDisabled(candidate) && this._isUsableNavigationElement(candidate)) return candidate; } } return null; }, _findDocumentScrollTarget(contexts) { const selector = [ '#viewerContainer', '.pdfViewer', '.pdf-viewer', '.document-reader', '.document-viewer', '.viewerContainer', '.view-container', '[class*="scroll"]' ].join(','); const candidates = []; const seen = new Set(); const add = el => { if (!el || seen.has(el)) return; seen.add(el); const scrollHeight = Number(el.scrollHeight || 0); const clientHeight = Number(el.clientHeight || 0); if (scrollHeight > clientHeight + 20) candidates.push(el); }; for (const context of contexts) { const scope = context.root?.querySelectorAll ? context.root : context.doc; try { Array.from(scope.querySelectorAll(selector)).forEach(add); } catch (_) {} try { add(context.doc.scrollingElement); } catch (_) {} } candidates.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight)); return candidates[0] || null; }, _documentProgressSnapshot(contexts) { const parts = []; const progress = this._readDocumentPageProgress(contexts); if (progress.key) parts.push(progress.key); for (const context of contexts) { try { const src = String(context.frame?.src || context.frame?.getAttribute?.('src') || ''); if (src) parts.push(src); const scope = context.root?.querySelector ? context.root : context.doc; const active = scope.querySelector?.('.swiper-slide-active, .page-item.active, .thumbnail.active, [aria-current="page"]'); if (active) { parts.push([ this._controlText(active).slice(0, 80), active.getAttribute?.('data-index') || active.getAttribute?.('data-page') || '', active.getAttribute?.('id') || '' ].join(':')); } const legacyImage = scope.querySelector?.('.imglook img'); if (legacyImage) { parts.push([ 'legacy-image', String(legacyImage.currentSrc || legacyImage.src || legacyImage.getAttribute?.('data-src') || legacyImage.getAttribute?.('data-original') || '').slice(0, 300), legacyImage.getAttribute?.('data-page') || legacyImage.getAttribute?.('data-index') || '', String(legacyImage.getAttribute?.('style') || '').match(/background-image\s*:[^;]+/i)?.[0] || '' ].join(':')); } } catch (_) {} } const scrollTarget = this._findDocumentScrollTarget(contexts); if (scrollTarget) { parts.push(`scroll:${Math.round(Number(scrollTarget.scrollTop || 0))}/${Math.round(Number(scrollTarget.scrollHeight || 0))}`); } return parts.join('|'); }, _documentHasRenderedPage(contexts) { const selector = 'canvas, .page, .pdf-page, .ppt-page, .swiper-slide, .slide, embed[type*="pdf" i], object[type*="pdf" i]'; return contexts.some(context => { const scope = context.root?.querySelector ? context.root : context.doc; try { return !!scope.querySelector(selector); } catch (_) { return false; } }); }, _documentNoProgressResult(status, limit = 3) { this._documentNoProgressCount++; if (this._documentNoProgressCount >= limit) { return { complete: true, moved: false, status: `${status},已等待 ${this._documentNoProgressCount} 轮后继续` }; } return { complete: false, moved: false, status: `${status}(等待 ${this._documentNoProgressCount}/${limit})` }; }, async _progressDocumentContent() { let contexts = this._collectDocumentContexts(); if (contexts.length === 0) { this._documentStateKey = 'document-context-missing'; return this._documentNoProgressResult('文档正在加载'); } if (this._hasDocumentCompletionEvidence(contexts)) { return { complete: true, moved: false, status: '平台已标记完成' }; } let progress = this._readDocumentPageProgress(contexts); if (progress.total > 0 && progress.current >= progress.total) { return { complete: true, moved: false, status: `已浏览到第 ${progress.total} 页` }; } const before = this._documentProgressSnapshot(contexts); const next = this._findDocumentNextControl(contexts); if (next) { try { next.scrollIntoView?.({ block: 'center' }); next.click(); } catch (_) { return this._documentNoProgressResult('文档翻页按钮暂不可用'); } await new Promise(resolve => setTimeout(resolve, 1200)); if (!this._playing) return { complete: false, moved: false, status: '已停止' }; contexts = this._collectDocumentContexts(); progress = this._readDocumentPageProgress(contexts); const after = this._documentProgressSnapshot(contexts); const moved = !!after && after !== before; this._documentStateKey = after || before; if (this._hasDocumentCompletionEvidence(contexts) || (progress.total > 0 && progress.current >= progress.total)) { return { complete: true, moved: true, status: progress.total ? `已浏览到第 ${progress.total} 页` : '平台已标记完成' }; } if (!moved) return this._documentNoProgressResult('等待文档完成翻页'); this._documentNoProgressCount = 0; return { complete: false, moved: true, status: progress.key ? `正在浏览第 ${progress.current}/${progress.total} 页` : '已翻到下一页' }; } const disabledNext = this._findDocumentNextControl(contexts, true); if (disabledNext && this._isDocumentControlDisabled(disabledNext) && (this._isLegacyDocumentNext(disabledNext) || progress.current > 0 || this._documentNoProgressCount > 0)) { return { complete: true, moved: false, status: '文档已到末页' }; } const slides = this._findDocumentSlides(contexts); if (slides.length > 1) { let activeIndex = slides.findIndex(slide => { const cls = String(slide.className || ''); return /(?:^|\s)(?:active|is-active|current|on|swiper-slide-active)(?:\s|$)/i.test(cls) || /^(?:true|page)$/.test(String(slide.getAttribute?.('aria-current') || '')); }); if (activeIndex < 0 && progress.current > 0) activeIndex = Math.min(slides.length - 1, progress.current - 1); if (activeIndex >= slides.length - 1) { return { complete: true, moved: false, status: `已浏览到第 ${slides.length} 页` }; } const targetIndex = activeIndex >= 0 ? activeIndex + 1 : 1; const slide = slides[targetIndex]; const clickTarget = slide?.querySelector?.('button, a, [role="button"], img') || slide; try { clickTarget?.scrollIntoView?.({ block: 'nearest' }); clickTarget?.click?.(); } catch (_) { return this._documentNoProgressResult('课件缩略页暂不可用'); } await new Promise(resolve => setTimeout(resolve, 1200)); contexts = this._collectDocumentContexts(); const after = this._documentProgressSnapshot(contexts); const moved = !!after && after !== before; this._documentStateKey = after || before; if (!moved) return this._documentNoProgressResult('等待课件缩略页切换'); this._documentNoProgressCount = 0; return { complete: false, moved: true, status: `正在浏览第 ${targetIndex + 1}/${slides.length} 页` }; } const scrollTarget = this._findDocumentScrollTarget(contexts); if (scrollTarget) { const oldTop = Number(scrollTarget.scrollTop || 0); const maxTop = Math.max(0, Number(scrollTarget.scrollHeight || 0) - Number(scrollTarget.clientHeight || 0)); if (oldTop < maxTop - 4) { const nextTop = Math.min(maxTop, oldTop + Math.max(240, Number(scrollTarget.clientHeight || 600) * 0.85)); try { if (typeof scrollTarget.scrollTo === 'function') scrollTarget.scrollTo({ top: nextTop, behavior: 'smooth' }); else scrollTarget.scrollTop = nextTop; scrollTarget.dispatchEvent?.(new Event('scroll', { bubbles: true })); } catch (_) { try { scrollTarget.scrollTop = nextTop; } catch (_) {} } await new Promise(resolve => setTimeout(resolve, 900)); const moved = Number(scrollTarget.scrollTop || 0) > oldTop; this._documentStateKey = this._documentProgressSnapshot(contexts); if (!moved) return this._documentNoProgressResult('等待文档滚动'); this._documentNoProgressCount = 0; return { complete: false, moved: true, status: nextTop >= maxTop - 4 ? '已滚动到文档末页,等待完成状态' : '正在向下浏览文档' }; } return { complete: true, moved: false, status: '文档已浏览到底' }; } const snapshot = this._documentProgressSnapshot(contexts); if (snapshot === this._documentStateKey) { return this._documentNoProgressResult( this._documentHasRenderedPage(contexts) ? '单页文档已停留' : '无法读取文档内部状态' ); } this._documentNoProgressCount = 0; this._documentStateKey = snapshot; return { complete: false, moved: false, status: '等待文档页码或完成状态' }; }, _scheduleDocumentRecheck(delay = 2500) { if (this._pendingPlayTimeout) clearTimeout(this._pendingPlayTimeout); this._pendingPlayTimeout = setTimeout(() => { this._pendingPlayTimeout = null; if (this._playing) this._tryPlayVideo(); }, delay); }, async _handleNonVideoContent(retriesExhausted = false) { const contentType = this._detectNonVideoContentType(); if (!contentType) return false; const cfg = ConfigStore.getVideoConfig(); const label = contentType; if (!cfg.autoAdvance) { Logger.log(`检测到${label},自动推进已关闭,等待手动操作`, 'info'); UIPanel.setStatus(`${label},等待手动操作`, 'info'); return true; } if (this._isDocumentContentType(contentType)) { const result = await this._progressDocumentContent(); if (!this._playing) return true; if (!result.complete) { const waitText = retriesExhausted && !result.moved ? `${result.status},继续等待` : result.status; Logger.log(`${label}:${waitText}`, 'info'); UIPanel.setStatus(waitText, 'info'); this._scheduleDocumentRecheck(result.moved ? 1800 : 3000); return true; } Logger.log(`${label}浏览完成(${result.status}),准备推进到下一节`, 'success'); UIPanel.setStatus(`${label}已完成,准备下一节`, 'success'); await new Promise(resolve => setTimeout(resolve, 1200)); } else { Logger.log(`检测到${label},短暂停留后推进到下一节`, 'info'); UIPanel.setStatus(`${label},准备下一节`, 'info'); await new Promise(resolve => setTimeout(resolve, 1500)); } if (!this._playing) return true; this._documentStateKey = ''; this._documentNoProgressCount = 0; const advanced = this.advanceToNextChapter(); if (!advanced) { Logger.log(`${label}处理完成,但未找到下一节`, 'info'); this.stop(); UIPanel.setStatus('未找到下一节', 'info'); } return true; }, _snapshotNodeId(node) { if (!node || (typeof node !== 'object' && typeof node !== 'function')) return ''; try { if (!this._snapshotNodeIds.has(node)) this._snapshotNodeIds.set(node, this._nextSnapshotNodeId++); return this._snapshotNodeIds.get(node); } catch (_) { return ''; } }, _lessonSnapshot() { const parts = [`url:${String(location.href || '')}`]; const docs = CXUtils.listAccessibleDocs(document, 3); const currentSelectors = [ '.posCatalog_active', '.currents', '[aria-current="page"]', '[role="tab"][aria-selected="true"]', '.prev_ul .active', '.prev_tab .active', '.chapter_tab .active', '.ans-cc-tab .active', '.tab-item.active', '.tab-item.on', '[class*="tab"] button.active', '[class*="tab"] a.active', '[class*="tab"] li.active', '[class*="tab"] .active', '[class*="tab"] .on' ].join(','); for (const doc of docs) { if (!doc || doc === UIPanel._doc) continue; try { parts.push(`title:${String(doc.title || '').replace(/\s+/g, ' ').trim().slice(0, 120)}`); Array.from(doc.querySelectorAll(currentSelectors)).slice(0, 6).forEach(el => { parts.push([ `current#${this._snapshotNodeId(el)}`, this._controlText(el), el.getAttribute?.('data-id') || '', el.getAttribute?.('data-knowledge-id') || '', el.getAttribute?.('href') || '' ].join(':')); }); const headingSelectors = [ 'main h1', 'main h2', 'main h3', '.main-content h1', '.main-content h2', '.content-title', '.chapter-title', '.knowledge-title', '.course-title', '.task-title' ].join(','); Array.from(doc.querySelectorAll(headingSelectors)).slice(0, 8).forEach(el => { parts.push([ `heading#${this._snapshotNodeId(el)}`, this._controlText(el).slice(0, 160), el.getAttribute?.('data-id') || el.getAttribute?.('data-knowledge-id') || '', el.getAttribute?.('id') || '' ].join(':')); }); const taskSelectors = [ '[data-jobid]', '[data-job-id]', '[data-objectid]', '[data-object-id]', '[data-attachment-id]', '[data-knowledge-id]', '[data-task-id]' ].join(','); Array.from(doc.querySelectorAll(taskSelectors)).slice(0, 12).forEach(el => { parts.push([ `task#${this._snapshotNodeId(el)}`, el.getAttribute?.('data-jobid') || el.getAttribute?.('data-job-id') || '', el.getAttribute?.('data-objectid') || el.getAttribute?.('data-object-id') || '', el.getAttribute?.('data-attachment-id') || '', el.getAttribute?.('data-knowledge-id') || '', el.getAttribute?.('data-task-id') || '' ].join(':')); }); const contentSelectors = '.ans-attach-ct, .chapter-content, .course-content, .main-content, main'; Array.from(doc.querySelectorAll(contentSelectors)).slice(0, 5).forEach(el => { const text = String(el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 180); parts.push(`content#${this._snapshotNodeId(el)}:${text}`); }); Array.from(doc.querySelectorAll('iframe[src],object[data],embed[src]')).slice(0, 8).forEach(el => { if (el.id === 'cx-helper-iframe') return; parts.push([ `frame#${this._snapshotNodeId(el)}`, el.getAttribute?.('src') || el.getAttribute?.('data') || '', el.getAttribute?.('module') || '', el.getAttribute?.('data-jobid') || el.getAttribute?.('data-objectid') || '' ].join(':')); }); } catch (_) {} } return parts.join('|'); }, // 内部:刷新当前章节iframe _refreshCurrentFrame(includeBrokenVideo = false, targetVideo = this._sourceWaitVideo) { const docs = CXUtils.listAccessibleDocs(document, 4); for (const doc of docs) { let iframes = []; try { iframes = Array.from(doc.querySelectorAll('iframe')); } catch (_) {} for (const iframe of iframes) { if (iframe.id === 'cx-helper-iframe') continue; try { const src = String(iframe.src || iframe.getAttribute?.('src') || ''); const moduleName = String(iframe.getAttribute?.('module') || ''); let frameVideos = null; let hasDocumentMarker = false; try { const childDoc = iframe.contentDocument || iframe.contentWindow?.document; if (childDoc) { frameVideos = Array.from(childDoc.querySelectorAll?.('video') || []); hasDocumentMarker = !!childDoc.querySelector?.('.ans-doc, .ans-ppt, .pdf-viewer, .ppt-viewer, .document-reader, .pdfViewer, .document-viewer'); } } catch (_) { frameVideos = null; } const isDocumentFrame = /(?:\/ananas\/modules\/(?:pdf|ppt|doc|book)\/|\.(?:pdf|ppt|pptx|doc|docx)(?:[?#]|$)|(?:^|[\/_.?=&-])(?:pdf|pptx?|docx?|book|office)(?:[\/_.?=&-]|$))/i.test(src) || /insert(?:ppt|pdf|doc|book)/i.test(moduleName) || hasDocumentMarker; if (isDocumentFrame) continue; const looksLikeVideoFrame = ( /\/ananas\/modules\/video\//i.test(src) || /(?:^|[\/_.?=&-])(video|player|m3u8|mp4|flv|hls)(?:[\/_.?=&-]|$)/i.test(src) ); const containsTarget = !!(targetVideo && frameVideos?.includes?.(targetVideo)); const allVideosUnavailable = !!(frameVideos?.length) && frameVideos.every(video => { if (video.dataset?.cxFinished === '1' || video.ended) return false; return this._getVideoMediaState(video).state !== 'ready'; }); // 常规重试只刷新尚未创建 video 的播放器;媒体源重试耗尽时允许刷新 // 包含目标坏节点(或全部节点均无可用源)的纯视频 iframe 一次。 const shouldReloadBroken = includeBrokenVideo && (containsTarget || allVideosUnavailable); if (looksLikeVideoFrame && src && (!frameVideos || frameVideos.length === 0 || shouldReloadBroken)) { iframe.src = src; return true; } } catch (_) { /* 跨域iframe只按 src 特征判断 */ } } } return false; }, // 内部:在iframe中查找章节测验页面(支持最多4层嵌套iframe) _findChapterQuizDoc() { const quizSelectors = '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId, .TiMu, .mark_table'; // 先检查主页面 if (document.querySelector(quizSelectors)) return document; // 递归搜索iframe,最多4层深度 const searchIframes = (doc, depth) => { if (depth > 4) return null; try { const iframes = doc.querySelectorAll('iframe'); for (const iframe of iframes) { try { const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (!iframeDoc) continue; if (iframeDoc.querySelector(quizSelectors)) return iframeDoc; // 递归搜索更深层 const found = searchIframes(iframeDoc, depth + 1); if (found) return found; } catch (_) { /* 跨域iframe忽略 */ } } } catch (_) {} return null; }; return searchIframes(document, 1); }, // 内部:自动答章节测验 async _autoAnswerChapterQuiz(quizDoc) { // 防止重复调用 if (this._quizAnswering) { Logger.log('章节测验正在进行中,跳过重复调用', 'info'); return; } this._quizAnswering = true; try { await this._doAutoAnswerChapterQuiz(quizDoc); } finally { this._quizAnswering = false; } }, async _doAutoAnswerChapterQuiz(quizDoc) { const videoCfg = ConfigStore.getVideoConfig(); const autoAnswer = videoCfg.autoAnswerQuiz !== false; const quizMode = videoCfg.quizMode || 'free'; // 未勾选自动答题:直接推进下一章 if (!autoAnswer) { Logger.log('章节测验:未开启自动答题,推进下一章节', 'info'); if (this._playing) { setTimeout(() => this.advanceToNextChapter(), 2000); } return; } // 检查对应答题模式是否可用 const answerCfg = ConfigStore.getAnswerConfig(); if (quizMode === 'free') { if (!ConfigStore.isVerifyValid()) { Logger.log('章节测验:免费验证码未验证或已过期,跳过答题', 'info'); if (this._playing) { setTimeout(() => this.advanceToNextChapter(), 2000); } return; } } else if (quizMode === 'ai') { if (!answerCfg.aiApiKey) { Logger.log('章节测验:未配置AI API Key,跳过答题', 'info'); if (this._playing) { setTimeout(() => this.advanceToNextChapter(), 2000); } return; } } // 付费模式不做前置检查,直接尝试答题(积分不足时API会返回错误) // 临时切换答题模式为视频设置中选择的模式(不持久化) const prevMode = AnswerModule._mode; AnswerModule._mode = quizMode; const containers = quizDoc.querySelectorAll( '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId' ); if (containers.length === 0) { Logger.log('章节测验未找到题目,跳过', 'info'); AnswerModule._mode = prevMode; if (this._playing) { setTimeout(() => this.advanceToNextChapter(), 2000); } return; } Logger.log(`章节测验共 ${containers.length} 题,开始自动答题(模式: ${quizMode})`, 'info'); for (let i = 0; i < containers.length; i++) { if (!this._playing) return; const container = containers[i]; // 跳过已完成题目 if (AnswerModule._isQuestionCompleted(container)) { Logger.log(`章节测验第 ${i + 1} 题已完成,跳过`, 'info'); continue; } const _qzLabel = `章节测验第 ${i + 1} 题`; try { const qData = await Solver.recognize(container); if (!qData.question) { Logger.log(`${_qzLabel} 识别失败,跳过`, 'error'); continue; } const bankResult = await AnswerModule._queryAnswer(qData.question, qData.options, qData.type); // 智能匹配 + AI 兜底(_applyAnswer 内部已处理无命中场景) const ok = await AnswerModule._applyAnswer(qData, container, _qzLabel, bankResult); if (ok) { // 章节测验逐题自动提交 await Solver._clickSubmit(container); Logger.log(`${_qzLabel} 已作答`, 'success'); } } catch (e) { Logger.log(`${_qzLabel} 异常: ${e.message || e}`, 'error'); } // 题间延迟(AI模式:解锁后1.5秒,未解锁35秒;其他1-2秒) if (i < containers.length - 1) { const delay = await AnswerModule._getQuestionDelay(); if (quizMode === 'ai' && delay > 10000) Logger.log(`AI模式冷却中,${Math.round(delay/1000)}秒后继续(解锁后可加速)...`, 'info'); await new Promise(r => setTimeout(r, delay)); } } await Solver._clickFinalSubmit(quizDoc, '章节测验'); // 答完推进下一章,恢复之前的答题模式 AnswerModule._mode = prevMode; if (this._playing) { Logger.log('章节测验完成,推进下一章节', 'info'); await new Promise(r => setTimeout(r, 3000)); this.advanceToNextChapter(); } }, // 内部:绑定视频事件 _bindVideoEvents(video) { // 防止重复绑定 ended 监听器(_tryPlayVideoInner 可能在同一 video 上多次调用) if (video.dataset.cxEndedBound === '1') return; video.dataset.cxEndedBound = '1'; // 监听视频结束事件 const onEnded = () => { if (!this._playing) return; Logger.log('当前视频播放完毕', 'success'); // 标记此视频已完成,避免下次查找时重复返回 video.dataset.cxFinished = '1'; const finishedSource = this._getVideoSourceKey(video); if (finishedSource) video.dataset.cxFinishedSrc = finishedSource; // 清除之前的待执行播放定时器 if (this._pendingPlayTimeout) { clearTimeout(this._pendingPlayTimeout); this._pendingPlayTimeout = null; } if (_isMediaWorkerFrame) { this._videoEl = null; FrameMediaBridge.notifyWorkerEnded(); return; } const cfg = ConfigStore.getVideoConfig(); if (!cfg.autoAdvance) { Logger.log('自动推进已关闭,等待手动操作', 'info'); UIPanel.setStatus('视频播放完毕', 'info'); return; } // 优先检查:当前课时页面是否还有未播放的其他视频任务点 // (学习通一个课时常放多个视频,截图:3. xxx / 4. xxx) const nextVideo = this._findVideoElement(); if (nextVideo && nextVideo !== video) { Logger.log('检测到同课时还有其他视频任务点,继续播放下一个', 'info'); this._videoEl = null; // 清当前引用,避免脏状态 this._pendingPlayTimeout = setTimeout(() => { if (!this._playing) return; // 滚动到下一个视频,让 lazy-loaded 的播放器初始化 try { nextVideo.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {} // 等 1.5s 让 scroll + 任务点上报完成 setTimeout(() => this._tryPlayVideo(), 1500); }, 1500); return; } if (FrameMediaBridge.playNextWorker()) { Logger.log('检测到同课时还有跨域视频任务点,继续播放', 'info'); this._videoEl = null; return; } // 当前课时所有视频均已播完 → 检查测验或推进下一章 const autoAnswer = cfg.autoAnswerQuiz !== false; if (autoAnswer) { setTimeout(() => this._checkAndHandleChapterQuiz(), 1500); } else { setTimeout(() => this.advanceToNextChapter(), 1500); } }; video.addEventListener('ended', onEnded); // 保存引用以便清理 this._antiPauseHandlers.push({ target: video, event: 'ended', handler: onEnded, capture: false }); }, _handleRemoteVideoEnded() { if (!this._playing) return; Logger.log('跨域视频播放完毕', 'success'); const cfg = ConfigStore.getVideoConfig(); if (cfg.autoAdvance === false) { UIPanel.setStatus('视频播放完毕', 'info'); return; } if (FrameMediaBridge.playNextWorker()) { Logger.log('检测到同课时还有其他视频任务点,继续播放下一个', 'info'); return; } if (cfg.autoAnswerQuiz !== false) { setTimeout(() => this._checkAndHandleChapterQuiz(), 1500); } else { setTimeout(() => this.advanceToNextChapter(), 1500); } }, _handleRemoteVideoUnavailable(detail = '') { if (!this._playing) return; Logger.log(`跨域视频源不可用${detail ? `: ${detail}` : ''},继续后续任务`, 'error'); if (FrameMediaBridge.playNextWorker()) return; const cfg = ConfigStore.getVideoConfig(); if (cfg.autoAdvance === false) { UIPanel.setStatus('视频源不可用', 'error'); return; } if (cfg.autoAnswerQuiz !== false) { setTimeout(() => this._checkAndHandleChapterQuiz(), 1000); } else { setTimeout(() => this.advanceToNextChapter(), 1000); } }, // 6.1 - setPlaybackRate(): 设置倍速 setPlaybackRate(rate) { const video = this._videoEl || this._findVideoElement(); if (video) { video.playbackRate = rate; Logger.log(`播放倍速已设置为 ${rate}x`, 'info'); } if (!_isMediaWorkerFrame) FrameMediaBridge.setPlaybackRate(rate); // 同步到配置 const cfg = ConfigStore.getVideoConfig(); cfg.playbackRate = rate; ConfigStore.setVideoConfig(cfg); }, // 6.2 - installAntiPause(): 安装防暂停钩子 installAntiPause() { this.removeAntiPause(); // 拦截 visibilitychange(capture阶段阻止传播) const onVisibilityChange = (e) => { e.stopImmediatePropagation(); e.preventDefault(); }; document.addEventListener('visibilitychange', onVisibilityChange, true); this._antiPauseHandlers.push({ target: document, event: 'visibilitychange', handler: onVisibilityChange, capture: true }); // 拦截 mouseout const onMouseOut = (e) => { if (this._playing) { e.stopImmediatePropagation(); } }; document.addEventListener('mouseout', onMouseOut, true); this._antiPauseHandlers.push({ target: document, event: 'mouseout', handler: onMouseOut, capture: true }); // 拦截 blur const onBlur = (e) => { if (this._playing) { e.stopImmediatePropagation(); } }; window.addEventListener('blur', onBlur, true); this._antiPauseHandlers.push({ target: window, event: 'blur', handler: onBlur, capture: true }); // 监听视频 pause 事件 — 非用户主动暂停时自动恢复 this._installVideoPauseHook(); // 3秒超时自动恢复:定时检查视频状态 this._pauseCheckInterval = setInterval(() => { if (!this._playing) return; const video = this._videoEl || this._findVideoElement(); if (video && video.paused && !video.ended && !this._userPaused && this._isVideoSourceReady(video)) { const pauseDuration = Date.now() - this._lastPauseTime; if (this._lastPauseTime > 0 && pauseDuration >= 3000) { Logger.log('视频暂停超过3秒,自动恢复播放', 'info'); video.play().catch(error => { if (this._isSourcePlayError(error)) this._queueVideoSourceRetry(video, this._getVideoMediaState(video), error); }); this._lastPauseTime = 0; } } }, 1000); Logger.log('防中断机制已安装', 'info'); }, // 内部:安装视频pause事件钩子 _installVideoPauseHook(targetVideo = null) { const hookPause = (video) => { if (!video || video.dataset.cxPauseBound === '1') return; video.dataset.cxPauseBound = '1'; const onPause = () => { if (!this._playing || video.ended) return; if (this._userPaused) return; this._lastPauseTime = Date.now(); // 短暂延迟后恢复,避免与正常seek冲突 setTimeout(() => { if (this._playing && video.paused && !video.ended && !this._userPaused && this._isVideoSourceReady(video)) { video.play().catch(error => { if (this._isSourcePlayError(error)) this._queueVideoSourceRetry(video, this._getVideoMediaState(video), error); }); } }, 500); }; video.addEventListener('pause', onPause); this._antiPauseHandlers.push({ target: video, event: 'pause', handler: onPause, capture: false }); }; // 对当前视频安装 const video = targetVideo || this._videoEl || this._findVideoElement(); if (video) hookPause(video); }, // 6.2 - removeAntiPause(): 移除所有钩子 removeAntiPause() { this._antiPauseHandlers.forEach(({ target, event, handler, capture }) => { try { target.removeEventListener(event, handler, capture); if (event === 'ended' && target.dataset) delete target.dataset.cxEndedBound; if (event === 'pause' && target.dataset) delete target.dataset.cxPauseBound; } catch (_) {} }); this._antiPauseHandlers = []; if (this._pauseCheckInterval) { clearInterval(this._pauseCheckInterval); this._pauseCheckInterval = null; } this._lastPauseTime = 0; }, // 6.3 - _checkAndHandleChapterQuiz(): 视频播完后检查当前页面是否有章节测验iframe async _checkAndHandleChapterQuiz() { if (!this._playing) return; // 学习通的章节页面结构:一个章节下有多个iframe任务点(视频、测验等) // 视频播完后,测验iframe可能已经在当前页面上了 Logger.log('检查当前页面是否有章节测验...', 'info'); // 等待一下让页面稳定 await new Promise(r => setTimeout(r, 1000)); if (!this._playing) return; const quizDoc = this._findChapterQuizDoc(); if (quizDoc) { Logger.log('在当前页面检测到章节测验,开始答题', 'info'); await this._autoAnswerChapterQuiz(quizDoc); return; // _autoAnswerChapterQuiz 完成后会自己调 advanceToNextChapter } // 当前页面没有测验,尝试查找页面上的测验tab/任务点链接并点击 // 学习通页面上方有任务点tab,如 "1 视频" "2 章节测验" const tabSelectors = [ '.prev_ul li', '.ans-attach-ct .ans-job-icon', '.prev_tab li', '.chapter_tab li', '.ans-cc-tab li', '[class*="tab"] li' ]; let quizTab = null; // 在主页面查找 for (const sel of tabSelectors) { const tabs = document.querySelectorAll(sel); for (const tab of tabs) { const text = tab.textContent?.trim() || ''; if (/测验|作业|quiz|test/i.test(text)) { quizTab = tab; break; } } if (quizTab) break; } // 在iframe中查找 if (!quizTab) { const iframes = document.querySelectorAll('iframe'); for (const iframe of iframes) { try { const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (!iframeDoc) continue; for (const sel of tabSelectors) { const tabs = iframeDoc.querySelectorAll(sel); for (const tab of tabs) { const text = tab.textContent?.trim() || ''; if (/测验|作业|quiz|test/i.test(text)) { quizTab = tab; break; } } if (quizTab) break; } } catch (_) {} if (quizTab) break; } } if (quizTab) { const tabText = quizTab.textContent?.trim().substring(0, 20) || '测验'; Logger.log(`找到测验tab: ${tabText},点击进入`, 'info'); const clickTarget = quizTab.querySelector('a, span, div') || quizTab; clickTarget.click(); // 多次尝试检测测验,每次等待递增(iframe加载可能较慢) for (let attempt = 1; attempt <= 5; attempt++) { await new Promise(r => setTimeout(r, 2000 + attempt * 1000)); if (!this._playing) return; const quizDoc2 = this._findChapterQuizDoc(); if (quizDoc2) { Logger.log('测验页面已加载,开始答题', 'info'); await this._autoAnswerChapterQuiz(quizDoc2); return; } } Logger.log('多次尝试后仍未检测到题目,推进下一章', 'info'); } else { Logger.log('当前章节无测验,推进下一章', 'info'); } this.advanceToNextChapter(); }, // 6.3 - advanceToNextChapter(): 优先推进页面“下一节”,再回退章节树 advanceToNextChapter(skipPageNext = false) { if (!this._playing) return false; const cfg = ConfigStore.getVideoConfig(); if (!cfg.autoAdvance) { Logger.log('自动推进已关闭', 'info'); return false; } if (this._advanceInFlight) return true; if (this._pendingPlayTimeout) { clearTimeout(this._pendingPlayTimeout); this._pendingPlayTimeout = null; } // SPA 切换时旧视频 DOM 可能仍存在,保留来源关联的完成标记可防止重复播放。 this._videoEl = null; if (!skipPageNext) { const pageNext = this._findPageNextButton(); const beforeSnapshot = this._lessonSnapshot(); const alreadyTried = pageNext && pageNext === this._lastNextControl && beforeSnapshot === this._lastNextSnapshot; if (pageNext && !alreadyTried) { const label = this._controlText(pageNext) || '下一节'; Logger.log(`点击课程页“${label}”`, 'info'); this._lastNextControl = pageNext; this._lastNextSnapshot = beforeSnapshot; this._advanceInFlight = true; try { this._resetVideoSourceWait(true); if (!_isMediaWorkerFrame) FrameMediaBridge.resetChapter(); pageNext.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); pageNext.click(); } catch (error) { this._advanceInFlight = false; Logger.log(`点击“${label}”失败: ${error.message || error}`, 'error'); return this.advanceToNextChapter(true); } // 学习通 SPA 可能复用 URL 和 iframe,只替换正文。连续短轮询标题、任务标识、 // 内容根及节点身份,避免一次快照漏报后又点章节树而连续跳过两节。 const verifyPageChange = remainingChecks => { this._pendingPlayTimeout = null; if (!this._playing) { this._advanceInFlight = false; return; } const afterSnapshot = this._lessonSnapshot(); const controlDetached = pageNext.isConnected === false; const controlUnavailable = controlDetached || !this._isUsableNavigationElement(pageNext); if (afterSnapshot !== beforeSnapshot || controlUnavailable) { this._advanceInFlight = false; this._loadRetryCount = 0; this._documentStateKey = ''; this._documentNoProgressCount = 0; this._tryPlayVideo(); return; } if (remainingChecks > 0) { this._pendingPlayTimeout = setTimeout(() => verifyPageChange(remainingChecks - 1), 500); return; } this._advanceInFlight = false; Logger.log(`“${label}”点击后页面持续未变化,改用章节树推进`, 'info'); const fallbackAdvanced = this.advanceToNextChapter(true); if (!fallbackAdvanced && this._playing) { this.stop(); UIPanel.setStatus('未找到下一节', 'info'); } }; this._pendingPlayTimeout = setTimeout(() => verifyPageChange(12), 500); this._updateVideoUI(); return true; } } let courseTree = null; const docs = CXUtils.listAccessibleDocs(document, 4); for (const doc of docs) { try { courseTree = doc.getElementById?.('coursetree'); } catch (_) { courseTree = null; } if (courseTree) break; } if (!courseTree) { Logger.log('未找到课程页“下一节”或章节树 (#coursetree)', 'error'); return false; } const nodeSelectors = [ 'li[onclick]', 'span.posCatalog_name[onclick]', 'div.posCatalog_name[onclick]', 'a[href*="chapterId"]', '[role="treeitem"][data-id]', '[data-knowledge-id]' ].join(','); const allNodes = Array.from(new Set(courseTree.querySelectorAll(nodeSelectors))); if (allNodes.length === 0) { const fallbackNodes = Array.from(courseTree.querySelectorAll('[onclick]')); if (fallbackNodes.length === 0) { Logger.log('章节树中未找到可点击节点', 'error'); return false; } allNodes.push(...fallbackNodes); } let currentIdx = -1; for (let i = 0; i < allNodes.length; i++) { const node = allNodes[i]; if (node.classList.contains('posCatalog_active') || node.classList.contains('currents') || node.closest('.posCatalog_active') || node.closest('.currents') || node.querySelector('.posCatalog_active') || node.parentElement?.classList.contains('posCatalog_active')) { currentIdx = i; } } if (currentIdx === -1) { const urlMatch = location.href.match(/[?&]chapterId=(\d+)/); if (urlMatch) { const chapterId = urlMatch[1]; const idPattern = new RegExp(`(^|\\D)${chapterId}(?=\\D|$)`); for (let i = 0; i < allNodes.length; i++) { const source = [ allNodes[i].getAttribute?.('onclick') || '', allNodes[i].getAttribute?.('href') || '', allNodes[i].getAttribute?.('data-id') || '', allNodes[i].getAttribute?.('data-knowledge-id') || '' ].join('|'); if (idPattern.test(source)) { currentIdx = i; break; } } } } if (currentIdx === -1) { Logger.log('未能定位当前章节,停止树形导航以避免回到第一节', 'error'); return false; } let nextChapter = null; for (let i = currentIdx + 1; i < allNodes.length; i++) { const node = allNodes[i]; const li = node.closest('li') || node; const isCompleted = li.classList.contains('finished') || !!li.querySelector('.icon-finish, .roundpoint.done, .catalog_finished, .roundpointStudent.done, .icon_Ede_Stuednt'); if (isCompleted) { const skipName = node.textContent?.trim().substring(0, 20) || ''; Logger.log(`跳过已完成: ${skipName}`, 'info'); continue; } nextChapter = node; break; } if (!nextChapter) { Logger.log('所有章节已完成', 'success'); this.stop(); UIPanel.setStatus('所有章节已完成', 'success'); this._updateVideoUI(); return true; } const chapterName = nextChapter.textContent?.trim().substring(0, 30) || '下一章节'; Logger.log(`推进到: ${chapterName}`, 'info'); this._currentChapter = chapterName; this._resetVideoSourceWait(true); if (!_isMediaWorkerFrame) FrameMediaBridge.resetChapter(); nextChapter.click(); this._pendingPlayTimeout = setTimeout(() => { this._pendingPlayTimeout = null; if (this._playing) { this._loadRetryCount = 0; this._tryPlayVideo(); } }, 3000); this._updateVideoUI(); return true; }, _findVideoPopupQuizDoc() { const docs = CXUtils.listAccessibleDocs(document, 4); const popupSelectors = [ '.ans-videoquiz', '[id^="videoquiz"]', '.videoquiz', '.video-quiz', '.popboxes', '.layui-layer', '.xubox_layer', '.mvp-question', '.topicWrap', '.ans-dialog' ]; const questionSelectors = '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId, .TiMu, .mark_table'; for (const doc of docs) { for (const sel of popupSelectors) { let nodes = []; try { nodes = Array.from(doc.querySelectorAll(sel)); } catch (_) {} for (const node of nodes) { if (!Solver._isVisibleElement(node)) continue; const hasQuestion = node.querySelector(questionSelectors) || node.querySelector('input[type="radio"], input[type="checkbox"], textarea, input[type="text"]') || /单选|多选|判断|填空|题目|提交|确定|继续/i.test(node.innerText || node.textContent || ''); if (hasQuestion) return { doc, root: node }; } } } return null; }, async _canUseQuizMode(quizMode, label) { const answerCfg = ConfigStore.getAnswerConfig(); if (quizMode === 'free' && !ConfigStore.isVerifyValid()) { Logger.log(`${label}:免费验证码未验证或已过期,无法自动答题`, 'info'); return false; } if (quizMode === 'ai' && !answerCfg.aiApiKey) { Logger.log(`${label}:未配置AI API Key,无法自动答题`, 'info'); return false; } return true; }, async _answerVideoPopupQuizIfNeeded() { if (!this._playing || this._quizAnswering) return false; const cfg = ConfigStore.getVideoConfig(); if (cfg.autoAnswerQuiz === false) return false; const found = this._findVideoPopupQuizDoc(); if (!found) return false; this._quizAnswering = true; const quizMode = cfg.quizMode || 'free'; const prevMode = AnswerModule._mode; AnswerModule._mode = quizMode; try { if (!(await this._canUseQuizMode(quizMode, '视频弹题'))) return false; const scope = found.root || found.doc; const containers = Array.from(scope.querySelectorAll( '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId, .TiMu, .mark_table' )); const fallbackContainer = containers.length ? null : scope; const targets = containers.length ? containers : [fallbackContainer].filter(Boolean); if (targets.length === 0) return false; Logger.log(`检测到视频中途弹题,共 ${targets.length} 题,开始自动作答`, 'info'); for (let i = 0; i < targets.length; i++) { const container = targets[i]; if (AnswerModule._isQuestionCompleted(container)) continue; const label = `视频弹题第 ${i + 1} 题`; const qData = await Solver.recognize(container); if (!qData.question) { Logger.log(`${label} 识别失败,跳过`, 'error'); continue; } const bankResult = await AnswerModule._queryAnswer(qData.question, qData.options, qData.type); const ok = await AnswerModule._applyAnswer(qData, container, label, bankResult); if (ok) { await Solver._clickSubmit(container); Logger.log(`${label} 已作答`, 'success'); } if (i < targets.length - 1) { const delay = await AnswerModule._getQuestionDelay(); if (quizMode === 'ai' && delay > 10000) Logger.log(`AI模式冷却中,${Math.round(delay/1000)}秒后继续(解锁后可加速)...`, 'info'); await new Promise(r => setTimeout(r, delay)); } } await Solver._clickFinalSubmit(found.doc, '视频弹题'); const continueBtn = Solver._findClickableByText(found.doc, ['继续播放', '继续学习', '确定', '关闭', '完成', '提交']); if (continueBtn) { try { continueBtn.click(); } catch (_) {} } this._lastQuizCloseTime = Date.now(); setTimeout(() => { const video = this._videoEl || this._findVideoElement(); if (this._playing && video && video.paused && !video.ended && this._isVideoSourceReady(video)) { video.play().catch(error => { if (this._isSourcePlayError(error)) this._queueVideoSourceRetry(video, this._getVideoMediaState(video), error); }); } }, 800); return true; } catch (e) { Logger.log('视频弹题处理异常: ' + (e.message || e), 'error'); return false; } finally { AnswerModule._mode = prevMode; this._quizAnswering = false; } }, // 6.4 - detectAndCloseQuiz(): 检测并关闭视频中弹出的测验弹窗(不影响章节测验页面) async detectAndCloseQuiz() { if (!this._playing) return false; const cfg = ConfigStore.getVideoConfig(); const canSkip = cfg.autoSkipQuiz !== false; const canAnswer = cfg.autoAnswerQuiz !== false; if (!canSkip && !canAnswer) return false; // 冷却时间:避免频繁关闭 if (Date.now() - this._lastQuizCloseTime < 3000) return false; if (canAnswer && await this._answerVideoPopupQuizIfNeeded()) return true; if (!canSkip) return false; // 仅匹配视频内弹出的测验弹窗,不匹配章节测验页面元素 const videoQuizSelectors = [ '.ans-videoquiz-close', '#videoquiz-close', '[id^="videoquiz"] .close_btn', '.ans-videoquiz .x-btn', '.ans-videoquiz .ans-videoquiz-close' ]; const tryClose = (doc) => { for (const sel of videoQuizSelectors) { const el = doc.querySelector(sel); if (el && el.offsetParent !== null) { // 确认是弹窗覆盖层(position:fixed/absolute 且 z-index 较高),而非页面内容 const container = el.closest('.ans-videoquiz, [id^="videoquiz"], .popboxes'); if (!container) continue; const style = window.getComputedStyle(container); const pos = style.position; if (pos !== 'fixed' && pos !== 'absolute') continue; el.click(); this._lastQuizCloseTime = Date.now(); Logger.log('已自动关闭视频弹窗测验', 'info'); // 关闭后继续播放 setTimeout(() => { if (this._playing) { const video = this._videoEl || this._findVideoElement(); if (video && video.paused && !video.ended && this._isVideoSourceReady(video)) { video.play().catch(error => { if (this._isSourcePlayError(error)) this._queueVideoSourceRetry(video, this._getVideoMediaState(video), error); }); } } }, 500); return true; } } return false; }; // 主页面 if (tryClose(document)) return true; // iframe中查找 const iframes = document.querySelectorAll('iframe'); for (const iframe of iframes) { try { const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (iframeDoc && tryClose(iframeDoc)) return true; } catch (_) {} } return false; }, // 6.6 - getCurrentChapter(): 返回当前章节名称 getCurrentChapter() { return this._currentChapter || '未知章节'; }, // 6.6 - getProgress(): 返回 0-100 进度百分比 getProgress() { return this._progress; }, // isPlaying(): 返回是否正在播放 isPlaying() { return this._playing; }, // 内部:更新章节名称 _updateChapterName() { // 从章节树获取当前活跃章节名称 const courseTree = document.getElementById('coursetree'); if (courseTree) { const active = courseTree.querySelector('.posCatalog_active, .currents, .active'); if (active) { this._currentChapter = active.textContent?.trim().substring(0, 30) || '当前章节'; return; } } // 备选:从页面标题获取 const title = document.title || ''; if (title) { this._currentChapter = title.substring(0, 30); } }, // 内部:更新播放进度 _updateProgress() { const video = this._videoEl || this._findVideoElement(); if (video && video.duration > 0) { this._progress = Math.round((video.currentTime / video.duration) * 100); } else if (!_isMediaWorkerFrame) { const remoteProgress = FrameMediaBridge.getActiveProgress(); this._progress = remoteProgress == null ? 0 : remoteProgress; } else { this._progress = 0; } if (_isMediaWorkerFrame) FrameMediaBridge.reportWorkerState(); this._updateVideoUI(); }, // 6.5 & 6.6 - 更新视频标签页UI _updateVideoUI() { const doc = UIPanel._doc; if (!doc) return; const chapterEl = doc.getElementById('cx-video-chapter'); const progressEl = doc.getElementById('cx-video-progress'); const progressBar = doc.getElementById('cx-video-progress-bar'); const startBtn = doc.getElementById('cx-btn-video-start'); const stopBtn = doc.getElementById('cx-btn-video-stop'); if (chapterEl) chapterEl.textContent = this._currentChapter || '未开始'; if (progressEl) progressEl.textContent = `${this._progress}%`; if (progressBar) progressBar.style.width = `${this._progress}%`; if (startBtn) startBtn.disabled = this._playing; if (stopBtn) stopBtn.disabled = !this._playing; }, // 绑定视频模块事件 bindEvents() { const doc = UIPanel._doc; if (!doc) return; // 开始播放 doc.getElementById('cx-btn-video-start')?.addEventListener('click', async () => { const btn = doc.getElementById('cx-btn-video-start'); if (btn) btn.disabled = true; const allowed = await UIPanel.confirmStudyPurpose(); if (btn) btn.disabled = false; if (!allowed) { Logger.log('已取消启动:本功能只能用于课程复习和学习回看', 'info'); return; } this.start(); }); // 停止播放 doc.getElementById('cx-btn-video-stop')?.addEventListener('click', () => { this._userPaused = true; this.stop(); }); } }; // ========================================== // FontDecryptor - font-cxsecret 字体解密模块 // ========================================== const FontDecryptor = { _table: null, // MD5 → Unicode 映射表 _cache: {}, // fontUrl → { charMap: {fakeCode → realChar} } _loading: null, // 当前加载 Promise // 初始化映射表(从 @resource 加载) async _ensureTable() { if (this._table) return; if (this._loading) return await this._loading; this._loading = (async () => { try { const json = GM_getResourceText('fontTable'); if (json) { this._table = JSON.parse(json); Logger.log('字体映射表加载成功,共 ' + Object.keys(this._table).length + ' 条', 'info'); } } catch (e) { Logger.log('字体映射表加载失败: ' + (e.message || e), 'error'); this._table = {}; } })(); await this._loading; }, // 从页面样式表中查找 font-cxsecret 的 woff 字体 URL _findFontUrl(doc) { // 搜索多个文档层级的样式表(测验可能在深层iframe中,字体定义在不同层) const docsToSearch = [doc || document]; // 也搜索主页面和所有可访问的iframe if (doc && doc !== document) docsToSearch.push(document); try { const iframes = document.querySelectorAll('iframe'); for (const f of iframes) { try { const fd = f.contentDocument || f.contentWindow?.document; if (fd && !docsToSearch.includes(fd)) docsToSearch.push(fd); // 嵌套iframe if (fd) { const innerFrames = fd.querySelectorAll('iframe'); for (const inner of innerFrames) { try { const innerDoc = inner.contentDocument || inner.contentWindow?.document; if (innerDoc && !docsToSearch.includes(innerDoc)) docsToSearch.push(innerDoc); // 再深一层 if (innerDoc) { const deepFrames = innerDoc.querySelectorAll('iframe'); for (const deep of deepFrames) { try { const deepDoc = deep.contentDocument || deep.contentWindow?.document; if (deepDoc && !docsToSearch.includes(deepDoc)) docsToSearch.push(deepDoc); } catch (_) {} } } } catch (_) {} } } } catch (_) {} } } catch (_) {} for (const searchDoc of docsToSearch) { try { const baseUrl = searchDoc.baseURI || searchDoc.location?.href || location.href; for (const sheet of searchDoc.styleSheets) { try { for (const rule of sheet.cssRules || []) { if (rule instanceof CSSFontFaceRule || (rule.cssText && /font-face/.test(rule.cssText))) { const text = rule.cssText || ''; if (/font-cxsecret|cxsecret/i.test(text)) { const m = text.match(/url\s*\(\s*['"]?([^'")\s]+?)['"]?\s*\)/i); if (m) { let url = m[1]; // 正确解析相对URL if (url.startsWith('//')) { url = location.protocol + url; } else if (url.startsWith('/')) { // 使用样式表所在文档的 origin try { const origin = new URL(baseUrl).origin; url = origin + url; } catch (_) { url = location.origin + url; } } else if (!url.startsWith('http')) { // 相对路径,基于样式表所在文档的URL try { url = new URL(url, baseUrl).href; } catch (_) { url = location.origin + '/' + url; } } Logger.log('找到字体URL: ' + url.substring(0, 80), 'info'); return url; } } } } } catch (e) { /* 跨域样式表忽略 */ } } } catch (_) {} } return null; }, // 下载字体文件并解析(支持 data: URI 和 http URL) _fetchFont(url) { // data: URI 直接解码 if (url.startsWith('data:')) { const base64Match = url.match(/base64,([\w\W]+?)$/); if (base64Match) { const data = atob(base64Match[1]); const buffer = new Uint8Array(data.length); for (let i = 0; i < data.length; i++) { buffer[i] = data.charCodeAt(i); } return Promise.resolve(buffer.buffer); } return Promise.reject(new Error('无法解析 data URI')); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', onload: (resp) => { if (resp.status === 200 && resp.response) { resolve(resp.response); } else { reject(new Error('字体下载失败: HTTP ' + resp.status)); } }, onerror: (e) => reject(new Error('字体下载网络错误')) }); }); }, // 将 glyph 路径数据序列化为字符串用于 MD5(与 Table.json 生成方式一致) _glyphPathHash(font, glyphIndex) { if (typeof Typr === 'undefined' || !Typr.U) return null; const path = Typr.U.glyphToPath(font, glyphIndex); if (!path || !path.cmds || path.cmds.length === 0) return null; // 使用 JSON.stringify + MD5 后8位(与参考脚本和 Table.json 一致) return CryptoJS.MD5(JSON.stringify(path)).toString().slice(24); }, // 构建字符映射: fakeCharCode → realChar async buildCharMap(doc) { await this._ensureTable(); if (!this._table || Object.keys(this._table).length === 0) return null; const fontUrl = this._findFontUrl(doc); if (!fontUrl) { Logger.log('未找到 font-cxsecret 字体 URL', 'warn'); return null; } // 缓存检查 if (this._cache[fontUrl]) return this._cache[fontUrl]; try { Logger.log('正在下载并解析 font-cxsecret 字体...', 'info'); const buffer = await this._fetchFont(fontUrl); if (typeof Typr === 'undefined') { Logger.log('Typr.js 未加载,无法解析字体', 'error'); return null; } const fonts = Typr.parse(buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer); const font = fonts[0]; if (!font) { Logger.log('字体解析失败', 'error'); return null; } // 遍历 CJK 汉字范围(与参考脚本一致) const charMap = {}; let decoded = 0; for (let i = 19968; i < 40870; i++) { const glyphIdx = Typr.U.codeToGlyph(font, i); if (!glyphIdx) continue; const hash = this._glyphPathHash(font, glyphIdx); if (!hash) continue; if (this._table[hash] !== undefined) { charMap[i] = String.fromCharCode(this._table[hash]); decoded++; } } Logger.log('字体解密完成,成功映射 ' + decoded + ' 个字符', 'info'); this._cache[fontUrl] = charMap; return charMap; } catch (e) { Logger.log('字体解密失败: ' + (e.message || e), 'error'); return null; } }, // 解密文本:将 DOM 元素中的混淆文字还原 decryptText(text, charMap) { if (!charMap || !text) return text; let result = ''; for (const ch of text) { const code = ch.codePointAt(0); result += charMap[code] || ch; } return result; }, // 解密 DOM 元素的文本内容 async decryptElement(el, doc) { if (!el) return ''; const charMap = await this.buildCharMap(doc); if (!charMap || Object.keys(charMap).length === 0) return ''; // 直接替换 .font-cxsecret 元素的 HTML(与参考脚本一致) const secretEls = el.querySelectorAll('.font-cxsecret'); if (secretEls.length > 0) { for (const secretEl of secretEls) { let html = secretEl.innerHTML; for (const [code, realChar] of Object.entries(charMap)) { const fakeChar = String.fromCharCode(Number(code)); html = html.split(fakeChar).join(realChar); } secretEl.innerHTML = html; secretEl.classList.remove('font-cxsecret'); } } return this.decryptText(el.textContent || '', charMap); }, // 对整个文档执行字体解密(替换所有 .font-cxsecret 元素) async decryptDocument(doc) { if (!doc) return; const charMap = await this.buildCharMap(doc); if (!charMap || Object.keys(charMap).length === 0) return; const secretEls = doc.querySelectorAll('.font-cxsecret'); if (secretEls.length === 0) return; Logger.log(`正在解密 ${secretEls.length} 个 font-cxsecret 元素`, 'info'); for (const secretEl of secretEls) { let html = secretEl.innerHTML; for (const [code, realChar] of Object.entries(charMap)) { const fakeChar = String.fromCharCode(Number(code)); html = html.split(fakeChar).join(realChar); } secretEl.innerHTML = html; secretEl.classList.remove('font-cxsecret'); } Logger.log('font-cxsecret 元素解密完成', 'success'); } }; // ========================================== // Solver - 答案求解器模块(题目识别与答案匹配) // ========================================== const Solver = { // 题目类型常量 TYPE_SINGLE: 0, // 单选 TYPE_MULTI: 1, // 多选 TYPE_FILL: 2, // 填空 TYPE_JUDGE: 3, // 判断 TYPE_OTHER: 4, // 其他 // ---- 8.1 readFromDOM ---- readFromDOM(element) { const result = { question: '', options: [], type: this.TYPE_OTHER }; if (!element) return result; result.type = this._detectType(element); result.question = this._extractQuestion(element); result.options = this._extractOptions(element); return result; }, // xuexitong 特化:沿 DOM 查找「最近」的 .mark_name(题目标题容器) // 标题 textContent 形如「41. (多选题, 2.0 分) 从人文的角度来看...」 // 查找顺序:容器内 → 前兄弟 → 父级前兄弟 → 整 DOM 前置最近 _findNearestMarkName(el) { if (!el) return null; // 0) el 自身就是 .mark_name(当容器选择器恰好返回 h3.mark_name 时) if (el.classList && el.classList.contains('mark_name')) return el; // 1) 容器内部 try { const inner = el.querySelector('.mark_name'); if (inner) return inner; } catch (_) {} // 2) 前兄弟(沿父链回溯 6 层) let cur = el; for (let depth = 0; depth < 6 && cur; depth++) { let sib = cur.previousElementSibling; while (sib) { if (sib.classList && sib.classList.contains('mark_name')) return sib; try { const all = sib.querySelectorAll && sib.querySelectorAll('.mark_name'); if (all && all.length > 0) return all[all.length - 1]; // 兄弟内最后一个(最接近 el) } catch (_) {} sib = sib.previousElementSibling; } cur = cur.parentElement; } // 3) 整文档前置兜底:取 DOM 顺序上所有前置 .mark_name 的最后一个 try { const doc = el.ownerDocument || document; const all = doc.querySelectorAll('.mark_name'); let nearest = null; const POS_PRECEDING = 2; // Node.DOCUMENT_POSITION_PRECEDING for (const mn of all) { if (el.compareDocumentPosition(mn) & POS_PRECEDING) nearest = mn; else break; // NodeList 已按 DOM 顺序,一旦遇到非前置即可停止 } if (nearest) return nearest; } catch (_) {} return null; }, // 从 mark_name 元素中抽取可能的题型标识字符串(含自身 textContent + 所有子 aria-label) _extractMarkNameTypeText(mn) { if (!mn) return ''; let t = mn.textContent || ''; try { mn.querySelectorAll('[aria-label]').forEach(s => { const a = s.getAttribute('aria-label'); if (a) t += ' ' + a; }); } catch (_) {} return t; }, // xuexitong 特化:查找最近的 .type_tit(section 标题,如 h2.type_tit「二、 多选题」) // 整卷预览模式下 type_tit 在容器内部;列表模式下在父级的前兄弟中 _findNearestTypeTit(el) { if (!el) return null; // 1) 容器内部 try { const inner = el.querySelector('.type_tit, h2.type_tit'); if (inner) return inner; } catch (_) {} // 2) 前兄弟(含父级) let cur = el; for (let depth = 0; depth < 6 && cur; depth++) { let sib = cur.previousElementSibling; while (sib) { if (sib.classList && sib.classList.contains('type_tit')) return sib; try { const found = sib.querySelector && sib.querySelector('.type_tit'); if (found) return found; } catch (_) {} sib = sib.previousElementSibling; } cur = cur.parentElement; } return null; }, _detectType(el) { const cls = el.className || ''; // [P1] 输入框类型:最可靠信号(每道题自己的 input 决定题型),checkbox 纯存在 → MULTI const checkboxes = el.querySelectorAll('input[type="checkbox"]'); const radios = el.querySelectorAll('input[type="radio"]'); if (checkboxes.length > 0 && radios.length === 0) return this.TYPE_MULTI; // [P2 ⭐ 最高优先] 查找 .mark_name(题目标题)+ .type_tit(section 标题) // xuexitong 实测: 所有题型的容器 class 都带 singleQuesId,不可靠! // 但 h3.mark_name 的 text/aria-label 有「(多选题, 2.0 分)」 // h2.type_tit 有「二、 多选题 (共 15题,30.0 分)」 // 这两个才是可靠来源 → 提到最前 const nearestMark = this._findNearestMarkName(el); if (nearestMark) { const mt = this._extractMarkNameTypeText(nearestMark); if (/多选|多项选择|不定项/.test(mt)) return this.TYPE_MULTI; if (/判断|是非/.test(mt)) return this.TYPE_JUDGE; if (/填空/.test(mt)) return this.TYPE_FILL; if (/单选|单项选择/.test(mt)) return this.TYPE_SINGLE; } // [P2b] 查找 .type_tit section 标题(整卷预览=容器内,列表模式=父级子元素) const typeTit = this._findNearestTypeTit(el); if (typeTit) { const tt = typeTit.textContent || ''; if (/多选|多项选择|不定项/.test(tt)) return this.TYPE_MULTI; if (/判断|是非/.test(tt)) return this.TYPE_JUDGE; if (/填空/.test(tt)) return this.TYPE_FILL; if (/单选|单项选择/.test(tt)) return this.TYPE_SINGLE; } // [P3] 容器 class 标注(⚠ 去掉 singleQuesId —— xuexitong 所有题型都有此 class) if (/multipleQuesId/i.test(cls)) return this.TYPE_MULTI; if (/judgementQuesId|judge|panduan/i.test(cls)) return this.TYPE_JUDGE; if (/completionQuesId|fill|tiankong/i.test(cls)) return this.TYPE_FILL; // 注意:不再检测 singleQuesId / single // [P4] 提取「题目自身」的题型标签(仅限题目标题元素) const titleSelectors = ['.mark_name', '.Zy_TItle', '.questionType', '.question_title', '.topic-txt', '.stem', '.question-title', '.quesTitle', '.type_tit']; let titleText = ''; for (const sel of titleSelectors) { const n = el.querySelector(sel); if (n) titleText += ' ' + (n.textContent || ''); } if (titleText) { if (/多选|多项选择|不定项/.test(titleText)) return this.TYPE_MULTI; if (/判断|是非/.test(titleText)) return this.TYPE_JUDGE; if (/填空/.test(titleText)) return this.TYPE_FILL; if (/单选|单项选择/.test(titleText)) return this.TYPE_SINGLE; } // [P5] 前面最近的 section 标题(支持 h2/h3;兼容旧版 .newTestType 和 .type_tit) const checkSectionHeader = (start) => { let p = start; let depth = 0; while (p && depth < 50) { const tag = p.tagName; const isSectionHead = (tag === 'H2' || tag === 'H3' || tag === 'H4') || (p.classList && (p.classList.contains('newTestType') || p.classList.contains('type_tit'))); if (isSectionHead) { const hText = p.textContent || ''; if (/多选|多项选择|不定项/.test(hText)) return this.TYPE_MULTI; if (/判断|是非/.test(hText)) return this.TYPE_JUDGE; if (/填空/.test(hText)) return this.TYPE_FILL; if (/单选|单项选择/.test(hText)) return this.TYPE_SINGLE; break; } p = p.previousElementSibling; depth++; } return null; }; const headerType = checkSectionHeader(el.previousElementSibling) || (el.parentElement && checkSectionHeader(el.parentElement.previousElementSibling)); if (headerType !== null) return headerType; // [P5] 整体 textContent 计数比较(取前 300 字避免干扰;多选优先) const fullText = (el.textContent || '').slice(0, 300); const multiN = (fullText.match(/多选|多项选择|不定项/g) || []).length; const singleN = (fullText.match(/单选|单项选择/g) || []).length; if (multiN > 0 && multiN >= singleN) return this.TYPE_MULTI; if (singleN > 0) return this.TYPE_SINGLE; if (/判断|是非/.test(fullText)) return this.TYPE_JUDGE; if (/填空/.test(fullText)) return this.TYPE_FILL; // [P6] 最终 fallback:按 input 推断 if (radios.length > 0) { const opts = this._extractOptions(el); if (radios.length === 2 && opts.length === 2) { const joined = opts.join(''); if (/[对错√×✓✗TF]/.test(joined) || /正确|错误|true|false/i.test(joined)) { return this.TYPE_JUDGE; } } // 如果既有 radio 又有 checkbox(罕见自定义),按 checkbox 优先 if (checkboxes.length > 0) return this.TYPE_MULTI; return this.TYPE_SINGLE; } if (checkboxes.length > 0) return this.TYPE_MULTI; const textInputs = el.querySelectorAll('input[type="text"], textarea'); if (textInputs.length > 0) return this.TYPE_FILL; // 诊断:所有策略都没识别出来 —— 记录一次性日志帮助排查 try { const preview = (el.textContent || '').replace(/\s+/g, ' ').slice(0, 60); if (typeof Logger !== 'undefined' && Logger && Logger.log) { Logger.log(`⚠ 题型识别失败,容器预览: ${preview}...(将按 OTHER 处理)`, 'info'); } } catch (_) {} return this.TYPE_OTHER; }, _extractQuestion(el) { // 优先从 .mark_name 或 .Zy_TItle 提取 const selectors = [ 'h3 .clearfix', 'h3 div', '.Cy_TItle .clearfix', '.Zy_TItle .clearfix', '.topic-txt', '.mark_name', '.Zy_TItle', '.questionContent', '.question_title', '.stem', 'h3' ]; for (const sel of selectors) { const node = el.querySelector(sel); if (node) { // 检测 font-cxsecret 字体混淆 if (node.classList?.contains('font-cxsecret') || node.querySelector('.font-cxsecret')) { // 字体混淆:尝试从 aria-label 获取真实文字 const ariaLabel = node.getAttribute('aria-label'); if (ariaLabel) return this._cleanQuestionText(ariaLabel); // 备选:用 innerText 可能经过浏览器渲染后是正确的(部分情况) // 标记需要 OCR return ''; } const text = node.textContent.trim(); if (text) return this._cleanQuestionText(text); } } // 备选:取容器内第一段有意义的文本 const firstP = el.querySelector('p, h3, h4, .title'); if (firstP) { // 同样检测字体混淆 if (firstP.classList?.contains('font-cxsecret') || firstP.closest?.('.font-cxsecret')) { const ariaLabel = firstP.getAttribute('aria-label'); if (ariaLabel) return this._cleanQuestionText(ariaLabel); return ''; } const text = firstP.textContent.trim(); if (text) return this._cleanQuestionText(text); } return ''; }, _cleanQuestionText(text) { let s = String(text || ''); // 解码 HTML 实体(双重编码 &quot; → " → ",循环最多 3 次) for (let i = 0; i < 3; i++) { if (!/&(quot|apos|lt|gt|amp|nbsp|#\d+|#x[0-9a-f]+);/i.test(s)) break; const prev = s; s = s.replace(/"/g, '"') .replace(/'/g, "'") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .replace(/&/g, '&'); if (s === prev) break; } // 移除题号前缀(如 "1." "【单选题】" 等) return s.replace(/\s+/g, ' ') .replace(/^[\s\d]+[.、.)]\s*/, '') .replace(/^[((]\s*\d+\s*[))]\s*/, '') .replace(/^【?\s*(单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题)\s*】?\s*/, '') .replace(/^(单选题|多选题|判断题|填空题|单项选择题|多项选择题)[】\]))]\s*/, '') // 去掉题目中/末尾的分值角标,如 "(5分)"/"(10.0分)" .replace(/[((]\s*\d+(\.\d+)?\s*分\s*[))]/g, '') .trim(); }, // 提取选项对(text + element + input)—— text 与 element 严格对齐,避免 input 索引错位 // 学习通常见问题:DOM 中可能存在隐藏 radio / 多余 radio,导致 inputs[1] 不一定对应视觉上的 B // 通过此方法返回的 pairs 保证 pairs[idx].text === pageOptions[idx],并提供对应的 element/input 用于点击 _extractOptionPairs(el) { if (!el) return []; const hasFontSecret = el.querySelector('.font-cxsecret') !== null; const pickText = (node, input) => { if (!node && !input) return ''; // 字体混淆时优先 aria-label if (hasFontSecret) { const aria = (node && node.getAttribute('aria-label')) || (input && input.getAttribute('aria-label')); if (aria) return this._cleanOptionText(aria.trim()); } const raw = (node ? (node.innerText || node.textContent || '') : '').trim(); return this._cleanOptionText(raw); }; const seen = new Set(); const out = []; const pushPair = (text, element, input) => { if (!text) return; if (seen.has(text)) return; seen.add(text); out.push({ text, element, input }); }; // 策略 1:从 radio/checkbox 出发,找最近的可点击祖先容器 const inputs = el.querySelectorAll('input[type="radio"], input[type="checkbox"]'); if (inputs && inputs.length > 0) { for (const input of inputs) { const ancestor = input.closest('label, li, .answerBg, .option, .clearfix, .option_li, .answer_p, .textDIV, .eidtDiv, .topic-option-list li, .Zy_ulTop li'); const element = ancestor || input.parentElement || input; const text = pickText(element, input); pushPair(text, element, input); } if (out.length > 0) return out; } // 策略 2:纯文本选项容器(没有 input 的情况,例如 CSS 模拟单选) const liItems = el.querySelectorAll( '.stem_answer .answerBg .answer_p, .Cy_ulTop .clearfix, .Zy_ulTop li .after, ' + 'ul li .after, .topic-option-list li, .Zy_ulTop li, .answerBg li, .option_li, .answer_p, .textDIV, .eidtDiv' ); for (const li of liItems) { const text = pickText(li, null); pushPair(text, li, null); } if (out.length > 0) return out; // 策略 3:label 容器 const labels = el.querySelectorAll('label.fl, label.option, .answerBg label'); for (const label of labels) { const input = label.querySelector('input[type="radio"], input[type="checkbox"]'); const text = pickText(label, input); pushPair(text, label, input); } if (out.length > 0) return out; // 策略 4:含输入的容器 const inputContainers = el.querySelectorAll('.Zy_ulBottom li, .answerList li, div.option'); for (const c of inputContainers) { const input = c.querySelector('input[type="radio"], input[type="checkbox"]'); const text = pickText(c, input); pushPair(text, c, input); } return out; }, _extractOptions(el) { // 复用 _extractOptionPairs 保证与 _selectOptions 的索引完全对齐 const pairs = this._extractOptionPairs(el); if (pairs.length > 0) return pairs.map(p => p.text); // 极端兜底:保留原始多策略实现,避免回归 const options = []; const hasFontSecret = el.querySelector('.font-cxsecret') !== null; // 优先从 .Zy_ulTop li 提取 const liItems = el.querySelectorAll( '.stem_answer .answerBg .answer_p, .Cy_ulTop .clearfix, .Zy_ulTop li .after, ' + 'ul li .after, .topic-option-list li, .Zy_ulTop li, .answerBg li, .option_li, .answer_p, .textDIV, .eidtDiv' ); if (liItems.length > 0) { liItems.forEach(li => { if (hasFontSecret) { // 字体混淆:优先用 aria-label const ariaLabel = li.getAttribute('aria-label'); if (ariaLabel) { options.push(this._cleanOptionText(ariaLabel)); return; } } const text = this._cleanOptionText(li.textContent.trim()); if (text) options.push(text); }); if (options.length > 0) return [...new Set(options)]; } // 备选:从 label 元素提取 const labels = el.querySelectorAll('label.fl, label.option, .answerBg label'); if (labels.length > 0) { labels.forEach(label => { if (hasFontSecret) { const ariaLabel = label.getAttribute('aria-label'); if (ariaLabel) { options.push(this._cleanOptionText(ariaLabel)); return; } } const text = this._cleanOptionText(label.textContent.trim()); if (text) options.push(text); }); if (options.length > 0) return [...new Set(options)]; } // 再备选:从包含 radio/checkbox 的容器提取 const inputContainers = el.querySelectorAll('.Zy_ulBottom li, .answerList li, div.option'); inputContainers.forEach(container => { if (hasFontSecret) { const ariaLabel = container.getAttribute('aria-label'); if (ariaLabel) { options.push(this._cleanOptionText(ariaLabel)); return; } } const text = this._cleanOptionText(container.textContent.trim()); if (text) options.push(text); }); return [...new Set(options)]; }, _cleanOptionText(text) { // 移除选项前缀(如 "A." "A、" "A " 等) return text.replace(/^[A-Za-z][.、::\s]\s*/, '').trim(); }, // ---- 8.2 recognizeByOCR ---- async recognizeByOCR(element) { const result = { question: '', options: [], type: this.TYPE_OTHER }; if (!element) return result; // 查找题目区域中的图片 const images = element.querySelectorAll('img'); if (images.length === 0) return result; try { // 识别第一张图片作为题目 const mainImg = images[0]; const ocrResult = await this._ocrImage(mainImg); if (ocrResult) { result.question = ocrResult.trim(); } // 如果有多张图片,后续图片可能是选项 for (let i = 1; i < images.length; i++) { const optText = await this._ocrImage(images[i]); if (optText && optText.trim()) { result.options.push(optText.trim()); } } // 尝试从 DOM 补充类型检测 result.type = this._detectType(element); } catch (e) { Logger.log('OCR 识别失败: ' + (e.message || e), 'error'); } return result; }, async _ocrImage(imgEl) { if (!imgEl || !imgEl.src) return ''; try { // Tesseract.js v2 API if (typeof Tesseract !== 'undefined' && Tesseract.recognize) { const { data } = await Tesseract.recognize(imgEl.src, 'chi_sim'); return data.text || ''; } } catch (e) { Logger.log('Tesseract OCR 错误: ' + (e.message || e), 'error'); } return ''; }, // recognize(): 综合识别(先 DOM,失败则字体解密,最后 OCR) async recognize(element) { const domResult = this.readFromDOM(element); if (domResult.question) return domResult; // DOM 提取失败(可能是 font-cxsecret 字体混淆),优先尝试字体解密 const hasFontSecret = element.querySelector('.font-cxsecret') !== null; if (hasFontSecret) { Logger.log('检测到 font-cxsecret 字体混淆,尝试字体解密...', 'info'); try { const doc = element.ownerDocument || document; // 先对整个文档执行字体解密(直接替换 .font-cxsecret 元素的 HTML) await FontDecryptor.decryptDocument(doc); // 解密后重新用 DOM 方式读取 const decryptedResult = this.readFromDOM(element); if (decryptedResult.question) { Logger.log('字体解密成功: ' + decryptedResult.question.slice(0, 30) + '...', 'info'); return decryptedResult; } } catch (e) { Logger.log('字体解密异常: ' + (e.message || e), 'error'); } } // 字体解密失败,回退到 html2canvas + OCR if (typeof html2canvas !== 'undefined' && typeof Tesseract !== 'undefined') { Logger.log('尝试 OCR 识别...', 'info'); try { let canvas = await html2canvas(element, { useCORS: true, logging: false, scale: 2, backgroundColor: '#ffffff' }); const { data: { text } } = await Tesseract.recognize(canvas, 'chi_sim'); canvas.width = 0; canvas.height = 0; canvas = null; const lines = text.split('\n').map(l => l.trim()).filter(l => l); let question = ''; const options = []; const optionPattern = /^[A-F][.、::\s]\s*(.+)/; for (const line of lines) { const match = line.match(optionPattern); if (match) { options.push(match[1].trim()); } else if (!question) { question = this._cleanQuestionText(line); } } if (question) { return { question, options: options.length > 0 ? options : domResult.options, type: domResult.type || this._detectType(element) }; } } catch (e) { Logger.log('OCR 识别失败: ' + (e.message || e), 'error'); } } // 最后尝试图片 OCR Logger.log('DOM 提取失败,尝试图片 OCR 识别...', 'info'); return await this.recognizeByOCR(element); }, // ---- 8.3 matchAnswer ---- matchAnswer(answers, pageOptions) { if (!answers || answers.length === 0 || !pageOptions || pageOptions.length === 0) return []; const matched = []; for (const answer of answers) { const ans = answer.trim(); if (!ans) continue; // 策略1:判断题特殊处理 const judgeIdx = this._matchJudge(ans); if (judgeIdx >= 0 && judgeIdx < pageOptions.length) { matched.push(judgeIdx); continue; } // 策略2:纯字母答案映射(A/AC/BCD → 索引) const letterIndices = this._matchLetters(ans); if (letterIndices.length > 0) { const valid = letterIndices.filter(i => i < pageOptions.length); matched.push(...valid); continue; } // 策略3:精确匹配 const exactIdx = this._matchExact(ans, pageOptions); if (exactIdx >= 0) { matched.push(exactIdx); continue; } // 策略4:包含匹配 const containIdx = this._matchContain(ans, pageOptions); if (containIdx >= 0) { matched.push(containIdx); continue; } // 策略5:相似度匹配(>60%) const simIdx = this._matchSimilarity(ans, pageOptions); if (simIdx >= 0) { matched.push(simIdx); continue; } } // 去重 return [...new Set(matched)]; }, _matchJudge(ans) { const truePatterns = /^(对|正确|√|✓|T|true|是|right|yes)$/i; const falsePatterns = /^(错|错误|×|✗|F|false|否|wrong|no)$/i; if (truePatterns.test(ans)) return 0; if (falsePatterns.test(ans)) return 1; return -1; }, _matchLetters(ans) { // 仅当答案是纯字母(A-Z)时才匹配 if (!/^[A-Za-z]+$/.test(ans)) return []; // 排除可能是单词的情况(超过4个字母且不全是大写) if (ans.length > 4 && ans !== ans.toUpperCase()) return []; return ans.toUpperCase().split('').map(ch => ch.charCodeAt(0) - 65); }, _matchExact(ans, pageOptions) { const normalized = ans.toLowerCase().trim(); for (let i = 0; i < pageOptions.length; i++) { if (pageOptions[i].toLowerCase().trim() === normalized) return i; } return -1; }, _matchContain(ans, pageOptions) { const normalized = ans.toLowerCase().trim(); // 答案是选项的子串 for (let i = 0; i < pageOptions.length; i++) { const opt = pageOptions[i].toLowerCase().trim(); if (opt.includes(normalized) || normalized.includes(opt)) return i; } return -1; }, _matchSimilarity(ans, pageOptions) { let bestIdx = -1; let bestScore = 0.6; // 阈值 60% for (let i = 0; i < pageOptions.length; i++) { const score = this._similarity(ans, pageOptions[i]); if (score > bestScore) { bestScore = score; bestIdx = i; } } return bestIdx; }, // 字符串相似度(基于编辑距离) _similarity(a, b) { const s1 = a.toLowerCase().trim(); const s2 = b.toLowerCase().trim(); if (s1 === s2) return 1; if (!s1 || !s2) return 0; const maxLen = Math.max(s1.length, s2.length); if (maxLen === 0) return 1; const dist = this._editDistance(s1, s2); return 1 - dist / maxLen; }, _editDistance(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (a[i - 1] === b[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } } return dp[m][n]; }, // ---- 8.3.1 万能脚本算法移植:智能匹配辅助函数 ---- // 文本归一化(同万能脚本 formatString) // 修复: // 1) 原 guard `includes('img')` 会误伤含 "img" 子串的普通文字;改为精确的 `') .replace(/ /g, ' ') .replace(/&/g, '&'); // 全角转半角 src = src.replace(/[\uff01-\uff5e]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 65248)); return src.replace(/\s+/g, ' ') .replace(/[\u201c\u201d]/g, '"') .replace(/[\u2018\u2019]/g, "'") .replace(/\u3002/g, '.') .replace(/[,.?:!;]$/, '') .trim(); }, // 判断答案文本表示"对/正确" _isTrue(str) { return Boolean(String(str).match(/(^|,)(正确|是|对|√|T|ri|true|A)(,|$)/)); }, // 判断答案文本表示"错/错误" _isFalse(str) { return Boolean(String(str).match(/(^|,)(错误|否|错|×|F|不是|wr|false|B)(,|$)/)); }, // 判断是否为纯字母答案(如 A、ABC) _isPlainAnswer(answer) { if (!answer || answer.length > 8 || !/[A-Z]/.test(answer)) return false; let min = 0; for (let i = 0; i < answer.length; i++) { if (answer.charCodeAt(i) < min) return false; min = answer.charCodeAt(i); } return true; }, // 投票:在多个候选组中取出现次数最多的组合 _getMost(arr) { if (!arr || arr.length === 0) return undefined; const reversed = [...arr].reverse(); const hash = {}; let m = 0; let trueEl; for (const el of reversed) { const key = JSON.stringify(el); hash[key] = (hash[key] || 0) + 1; if (hash[key] >= m) { m = hash[key]; trueEl = el; } } return trueEl; }, // 取最长的组合(多选题兜底) _getLong(arr) { if (!arr || arr.length === 0) return undefined; let len = 0; let ele; for (const a of arr) { if (a.length > len) { len = a.length; ele = a; } } return ele || (arr.length > 0 ? arr[0] : []); }, // 判断题:根据页面选项内容动态判定"对/错"对应的索引 _findJudgeIndexByOptions(isTrue, pageOptions) { if (!Array.isArray(pageOptions) || pageOptions.length === 0) return -1; // 优先精确判断每个选项 for (let i = 0; i < pageOptions.length; i++) { const opt = String(pageOptions[i] || '').trim(); if (isTrue && this._isTrue(opt)) return i; if (!isTrue && this._isFalse(opt)) return i; } // 兜底:约定 [对, 错] 顺序 return isTrue ? 0 : 1; }, // ---- 8.3.2 智能匹配(移植万能脚本算法)---- // groups: 二维答案组数组(success:false 时由后端返回多组相似答案,success:true 时只有一组) // pageOptions: 当前页面的选项文本数组 // type: TYPE_SINGLE / TYPE_MULTI / TYPE_JUDGE / TYPE_FILL / TYPE_OTHER matchAnswerSmart(groups, pageOptions, type) { if (!Array.isArray(groups) || groups.length === 0) return []; if (type === this.TYPE_FILL) return []; // 填空题不通过此函数 const targetOptionsList = []; for (const answers of groups) { if (!Array.isArray(answers) || answers.length === 0) continue; if (type === this.TYPE_JUDGE) { if (targetOptionsList.length > 3) break; const targetOptions = new Set(); const joined = answers.join(','); if (this._isTrue(joined)) { const idx = this._findJudgeIndexByOptions(true, pageOptions); if (idx >= 0) targetOptions.add(idx); } else if (this._isFalse(joined)) { const idx = this._findJudgeIndexByOptions(false, pageOptions); if (idx >= 0) targetOptions.add(idx); } if (targetOptions.size > 0) targetOptionsList.push(targetOptions); } else if (type === this.TYPE_SINGLE || type === this.TYPE_MULTI || type === this.TYPE_OTHER) { const beautifulOptions = (pageOptions || []).map(o => this._formatString(o).toLowerCase().replace(/\s/g, '')); const targetOptions = new Set(); for (const ans of answers) { const ansStr = String(ans || '').trim(); if (!ansStr) continue; // 纯字母答案(A/AB/ABC) if (ansStr.length <= 4 && this._isPlainAnswer(ansStr.toUpperCase())) { for (const ch of ansStr.toUpperCase()) { const idx = ch.charCodeAt(0) - 65; if (idx >= 0 && idx < (pageOptions || []).length) targetOptions.add(idx); } continue; } // 完整文本匹配 const val = this._formatString(ansStr).toLowerCase().replace(/\s/g, ''); const optIndex = beautifulOptions.indexOf(val); if (optIndex >= 0) targetOptions.add(optIndex); } // 单选无命中或多选不足2个 → 启用相似度匹配(>65%) if ((type === this.TYPE_SINGLE && targetOptions.size === 0) || (type === this.TYPE_MULTI && targetOptions.size < 2) || (type === this.TYPE_OTHER && targetOptions.size === 0)) { if (targetOptionsList.length === 0) { for (const ans of answers) { const val = this._formatString(String(ans || '')).toLowerCase(); if (val.length >= 3 && !val.includes(' this._similarity(val, opt) * 100); const maxScore = Math.max(...ratings, 0); if (maxScore > 65) targetOptions.add(ratings.indexOf(maxScore)); } } } } if (targetOptions.size > 0) targetOptionsList.push(targetOptions); } } // 投票:对于单选/判断,选出现次数最多的单一答案;多选选出现最多的组合 const sortArr = targetOptionsList.map(s => Array.from(s).sort((a, b) => a - b)); let items; if (type === this.TYPE_SINGLE || type === this.TYPE_JUDGE || type === this.TYPE_OTHER) { items = this._getMost(sortArr.filter(i => i.length === 1)); if (!items || items.length === 0) { items = this._getMost(sortArr.filter(i => i.length > 0)); } } else if (type === this.TYPE_MULTI) { items = this._getMost(sortArr.filter(i => i.length > 1)); if (!items || items.length === 0) { items = this._getLong(sortArr.filter(i => i.length > 0)); } } return items || []; }, // ---- 8.4 autoSelectAndSubmit ---- async autoSelectAndSubmit(answers, container) { if (!container) { Logger.log('无法解析答案:容器元素为空', 'error'); return; } // 提取页面选项文本 const pageOptions = this._extractOptions(container); if (pageOptions.length === 0 && this._detectType(container) !== this.TYPE_FILL) { Logger.log('无法解析答案:未找到页面选项', 'error'); return; } const type = this._detectType(container); // 填空题特殊处理 if (type === this.TYPE_FILL) { this._fillBlanks(answers, container); return; } // 匹配答案 const matchedIndices = this.matchAnswer(answers, pageOptions); if (matchedIndices.length === 0) { Logger.log('无法解析答案:匹配失败,跳过该题', 'error'); return; } // 选中对应选项 this._selectOptions(matchedIndices, container, type); // 点击提交按钮 await this._clickSubmit(container); }, // 检测某个选项对当前是否已被选中(兼容 input.checked / 容器的 .is-checked 等标记) _isPairSelected(pair) { if (!pair) return false; const { element, input } = pair; if (input && input.checked) return true; if (element) { const cls = String(element.className || ''); if (/\b(is-checked|checked|active|selected|cur)\b/.test(cls)) return true; if (element.getAttribute && element.getAttribute('aria-checked') === 'true') return true; try { // 子节点中存在已选中标识(学习通常用 .is-checked 子标签表达选中态) if (element.querySelector && element.querySelector('.is-checked, [aria-checked="true"], input:checked')) return true; } catch (_) {} } return false; }, _selectOptions(indices, container, type) { // 通过 _extractOptionPairs 获取严格对齐的 (text, element, input) 三元组, // 避免 inputs[1] 因隐藏/多余 radio 而错位(典型表现:日志显示选 B 但页面选中 A) const pairs = this._extractOptionPairs(container); const labels = []; const skipped = []; // 单次点击切换选项的勾选状态(设置为 desired),已是 desired 则跳过避免反选 const setPairChecked = (pair, desired) => { if (!pair) return false; const current = this._isPairSelected(pair); if (current === desired) return false; // 已是目标状态,避免再次点击触发 toggle 取消 const { element, input } = pair; try { if (element && typeof element.click === 'function') element.click(); } catch (_) {} if (input) { try { if (input.checked !== desired) input.checked = desired; input.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {} } return true; }; // 多选题:先取消现已勾选但本题答案不需要的选项(防止脏选项保留) if (type === Solver.TYPE_MULTI && pairs.length > 0) { const indexSet = new Set(indices); for (let i = 0; i < pairs.length; i++) { if (!indexSet.has(i) && this._isPairSelected(pairs[i])) { setPairChecked(pairs[i], false); labels.push('-' + String.fromCharCode(65 + i)); } } } // 选中目标选项;已选中则跳过点击避免取消 indices.forEach(idx => { if (idx >= 0 && idx < pairs.length) { const wasSelected = this._isPairSelected(pairs[idx]); if (wasSelected) { skipped.push(String.fromCharCode(65 + idx)); labels.push(String.fromCharCode(65 + idx) + '(已选)'); return; } setPairChecked(pairs[idx], true); labels.push(String.fromCharCode(65 + idx)); return; } // 兜底:_extractOptionPairs 失败时回退到 input 索引点击(同样避免对已选项的 toggle) const inputs = container.querySelectorAll('input[type="radio"], input[type="checkbox"]'); if (idx < inputs.length) { const input = inputs[idx]; if (input.checked) { skipped.push(String.fromCharCode(65 + idx)); labels.push(String.fromCharCode(65 + idx) + '(已选)'); return; } try { input.click(); if (!input.checked) input.checked = true; input.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {} labels.push(String.fromCharCode(65 + idx) + '(idx)'); } }); const summary = labels.length > 0 ? labels.join(',') : '(无变化)'; const skipNote = skipped.length > 0 ? `(${skipped.join(',')} 已是正确答案,跳过点击避免取消)` : ''; Logger.log(`已选择选项: ${summary}${skipNote}`, 'info'); }, _fillBlanks(answers, container) { const inputs = container.querySelectorAll('input[type="text"], textarea'); answers.forEach((ans, i) => { if (i < inputs.length) { inputs[i].value = ans.trim(); inputs[i].dispatchEvent(new Event('input', { bubbles: true })); inputs[i].dispatchEvent(new Event('change', { bubbles: true })); } }); Logger.log(`已填写 ${Math.min(answers.length, inputs.length)} 个填空`, 'info'); }, _isVisibleElement(el) { if (!el || el.disabled || el.getAttribute('aria-disabled') === 'true') return false; const cls = String(el.className || ''); if (/\b(disabled|is-disabled|forbid|readonly)\b/i.test(cls)) return false; try { const win = el.ownerDocument?.defaultView || window; const style = win.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; } catch (_) {} const rect = el.getBoundingClientRect?.(); if (rect && rect.width === 0 && rect.height === 0) return false; return true; }, _findClickableByText(doc, textPatterns, root = null) { doc = doc || document; const scope = root || doc; const selectors = [ 'button', 'a', '[role="button"]', 'input[type="button"]', 'input[type="submit"]', '.btn', '.Btn', '.jb_btn', '.Btn_blue_01', '.Btn_blue_1', '.save_btn', '.submit_btn', '.el-button', '.ant-btn', '.layui-layer-btn a', '.popbtn', '.sure', '.confirm' ]; let candidates = []; try { candidates = Array.from(scope.querySelectorAll(selectors.join(','))); } catch (_) {} const patterns = textPatterns.map(t => String(t).replace(/\s+/g, '')); for (const el of candidates) { if (!this._isVisibleElement(el)) continue; const value = el.value || ''; const text = ((el.innerText || el.textContent || value || '') + '').replace(/\s+/g, ''); const aria = (el.getAttribute?.('aria-label') || '').replace(/\s+/g, ''); if (!text && !aria) continue; if (patterns.some(p => text === p || text.includes(p) || aria === p || aria.includes(p))) return el; } return null; }, async _confirmSubmitDialogs(doc) { doc = doc || document; await new Promise(r => setTimeout(r, 500)); const docs = CXUtils.listAccessibleDocs(document, 4); if (!docs.includes(doc)) docs.unshift(doc); const patterns = ['确定', '确认', '继续提交', '提交', '保存', '我知道了', '知道了']; // 只在弹窗根节点内找确认按钮,不能把页面主内容中的“提交作业” // 当成确认按钮再次点击。最终提交后的确认弹窗仍会被覆盖到。 const dialogSelectors = [ '.layui-layer', '.layui-layer-dialog', '.xubox_layer', '.ui-dialog', '.modal', '.modal-dialog', '.el-dialog', '.ant-modal', '[role="dialog"]', '.popbox', '.popDiv', '.popup', '.popup-container', '.ans-dialog', '.confirm-dialog', '.alert-dialog', '#confirmBox', '#dialog' ]; const roots = []; const seenRoots = new Set(); for (const d of docs) { for (const selector of dialogSelectors) { let nodes = []; try { nodes = Array.from(d.querySelectorAll(selector)); } catch (_) {} for (const root of nodes) { if (root && !seenRoots.has(root)) { seenRoots.add(root); roots.push({ doc: d, root }); } } } } for (const item of roots) { const btn = this._findClickableByText(item.doc, patterns, item.root); if (btn) { try { btn.click(); Logger.log('已确认提交弹窗', 'info'); await new Promise(r => setTimeout(r, 600)); return true; } catch (_) {} } } return false; }, async _clickSubmit(container) { if (!container || typeof container.querySelector !== 'function') return false; const doc = container.ownerDocument || document; // 逐题保存只能查当前题目容器。页面级“提交作业/交卷”由 // _clickFinalSubmit 在全部题目处理完后统一执行,不能在这里回退到 doc, // 否则标准作业页会在第一题后误点整份作业的全局提交按钮。 const submitSelectors = [ '.jb_btn.jb_btn_92', '.jb_btn', '#submitBtn', '#saveBtn', '#submit_button', 'a.jb_btn', 'button[type="submit"]', 'input[type="submit"]', '.Btn_blue_01', '.Btn_blue_1', '.saveWork', '.save_btn', '.submit_btn', '.submitBtn', '.btnSubmit', '.saveYl', '.commitBtn' ]; let submitBtn = null; for (const sel of submitSelectors) { try { submitBtn = container?.querySelector(sel); } catch (_) { submitBtn = null; } if (submitBtn && this._isVisibleElement(submitBtn)) break; submitBtn = null; } if (!submitBtn) { submitBtn = this._findClickableByText( doc, ['提交', '保存', '提交答案', '保存答案', '确定', '完成'], container ); } if (submitBtn) { await new Promise(r => setTimeout(r, 300)); try { submitBtn.click(); } catch (_) {} Logger.log('已点击提交/保存按钮', 'info'); await this._confirmSubmitDialogs(doc); return true; } return false; }, async _clickFinalSubmit(doc, label = '任务') { doc = doc || document; const btn = this._findClickableByText(doc, [ '提交作业', '提交测验', '提交考试', '提交试卷', '提交答案', '提交', '保存并提交', '保存提交', '完成', '交卷' ]) || this._findClickableByText(document, [ '提交作业', '提交测验', '提交考试', '提交试卷', '提交答案', '提交', '保存并提交', '保存提交', '完成', '交卷' ]); if (!btn) { Logger.log(`${label}未找到最终提交按钮`, 'info'); return false; } await new Promise(r => setTimeout(r, 500)); try { btn.click(); } catch (_) {} Logger.log(`${label}已点击最终提交按钮`, 'success'); await this._confirmSubmitDialogs(doc); return true; } }; // ========================================== // AnswerModule - 答题模块(三种答题模式) // ========================================== const AnswerModule = { _mode: 'free', // 当前模式: 'free' | 'paid' | 'ai' _answering: false, _stopRequested: false, _currentQuestion: 0, _totalQuestions: 0, // AI模式题间延迟(毫秒):解锁后1.5秒,未解锁35秒;其他模式1-2秒 async _getQuestionDelay() { if (this._mode === 'ai') { if (await AccessGuard.requireAIUnlockAccess()) { return 1500 + Math.random() * 500; // 解锁后 1.5-2秒 } return 35000 + Math.random() * 2000; // 未解锁 35-37秒 } return 1000 + Math.random() * 1000; // 1-2秒 }, // ---- 模式管理 ---- setMode(mode) { this._mode = mode; const cfg = ConfigStore.getAnswerConfig(); cfg.mode = mode; ConfigStore.setAnswerConfig(cfg); this._updateModeUI(); Logger.log(`答题模式已切换为: ${mode === 'free' ? '免费题库' : mode === 'paid' ? '付费题库' : 'AI 答题'}`, 'info'); }, getMode() { return this._mode; }, // ---- 9.1 免费验证码模式 ---- verifyCode(code) { return new Promise((resolve) => { const doc = UIPanel._doc; const verifyBtn = doc?.getElementById('cx-btn-verify'); if (verifyBtn) { verifyBtn.disabled = true; verifyBtn.textContent = '验证中...'; } GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/code/verify', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: [ 'code=' + encodeURIComponent(code), 'scope=' + encodeURIComponent('free-answer'), 'device_id=' + encodeURIComponent(ConfigStore.getDeviceId()) ].join('&'), onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data && data.data.valid) { const validUntil = ConfigStore.normalizeValidUntil(data.data.valid_until); const validUntilStr = data.data.valid_until_str || new Date(validUntil * 1000).toLocaleString('zh-CN'); ConfigStore.setVerifyValidUntil(validUntil, code, data.data.session_token || ''); Logger.log(`验证成功,有效期至 ${validUntilStr}`, 'success'); this._updateFreeStatus(true, validUntilStr); resolve({ success: true, validUntil, validUntilStr }); } else { const msg = data.msg || '验证码无效或已过期'; Logger.log(`验证失败: ${msg}`, 'error'); this._updateFreeStatus(false, msg); resolve({ success: false, message: msg }); } } catch (e) { Logger.log('验证响应解析失败', 'error'); this._updateFreeStatus(false, '验证响应解析失败'); resolve({ success: false, message: '响应解析失败' }); } finally { if (verifyBtn) { verifyBtn.textContent = '验证'; // 如果验证成功且有效期内,保持禁用 if (!this.isVerifyValid()) { verifyBtn.disabled = false; } } } }, onerror: () => { Logger.log('验证请求失败', 'error'); this._updateFreeStatus(false, '网络请求失败'); if (verifyBtn) { verifyBtn.disabled = false; verifyBtn.textContent = '验证'; } resolve({ success: false, message: '网络请求失败' }); } }); }); }, isVerifyValid() { return ConfigStore.isVerifyValid(); }, _updateFreeStatus(success, message) { const doc = UIPanel._doc; if (!doc) return; const statusEl = doc.getElementById('cx-free-status'); const codeInput = doc.getElementById('cx-free-code'); const verifyBtn = doc.getElementById('cx-btn-verify'); if (!statusEl) return; if (success && this.isVerifyValid()) { statusEl.textContent = `✅ 验证成功,有效期至 ${message}`; statusEl.style.color = '#67c23a'; if (codeInput) codeInput.disabled = true; if (verifyBtn) verifyBtn.disabled = true; } else if (success === false) { statusEl.textContent = `❌ ${message}`; statusEl.style.color = '#f56c6c'; if (codeInput) codeInput.disabled = false; if (verifyBtn) verifyBtn.disabled = false; } else { statusEl.textContent = ''; if (codeInput) codeInput.disabled = false; if (verifyBtn) verifyBtn.disabled = false; } }, // 检查免费模式有效期,过期后重新启用输入 _checkFreeExpiry() { if (this._mode !== 'free') return; const cfg = ConfigStore.getAnswerConfig(); const validUntil = cfg.verifyValidUntil || 0; if (validUntil > 0 && !ConfigStore.isVerifyValid()) { // 已过期 ConfigStore.clearVerifySession(); this._updateFreeStatus(null); const doc = UIPanel._doc; if (doc) { const statusEl = doc.getElementById('cx-free-status'); if (statusEl) { statusEl.textContent = '⚠️ 验证码已过期,请重新验证'; statusEl.style.color = '#e6a23c'; } } Logger.log('免费模式验证码已过期', 'info'); } }, // ---- 9.2 付费积分模式 ---- activateCode(code) { return new Promise((resolve) => { const doc = UIPanel._doc; const activateBtn = doc?.getElementById('cx-btn-activate'); if (activateBtn) { activateBtn.disabled = true; activateBtn.textContent = '激活中...'; } GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/activate', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ code: code, device_id: ConfigStore.getDeviceId() }), onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data) { const credits = data.data.credits || 0; const remaining = data.data.remaining_credits || 0; const msg = data.data.message || `恭喜您获得 ${credits} 积分`; Logger.log(`激活成功: ${msg}`, 'success'); this._updateCreditsDisplay(remaining); this._showPaidStatus(true, msg); resolve({ success: true, credits, remaining }); } else { const msg = data.msg || '激活码无效或已使用'; Logger.log(`激活失败: ${msg}`, 'error'); this._showPaidStatus(false, msg); resolve({ success: false, message: msg }); } } catch (e) { Logger.log('激活响应解析失败', 'error'); this._showPaidStatus(false, '激活响应解析失败'); resolve({ success: false, message: '响应解析失败' }); } finally { if (activateBtn) { activateBtn.disabled = false; activateBtn.textContent = '激活'; } } }, onerror: () => { Logger.log('激活请求失败', 'error'); this._showPaidStatus(false, '网络请求失败'); if (activateBtn) { activateBtn.disabled = false; activateBtn.textContent = '激活'; } resolve({ success: false, message: '网络请求失败' }); } }); }); }, getCredits() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `https://qsy.iano.cn/index.php?s=/api/question_bank/credits&device_id=${encodeURIComponent(ConfigStore.getDeviceId())}`, onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data) { const remaining = data.data.remaining_credits || 0; this._updateCreditsDisplay(remaining); resolve(remaining); } else { resolve(0); } } catch (e) { resolve(0); } }, onerror: () => resolve(0) }); }); }, _updateCreditsDisplay(credits) { const doc = UIPanel._doc; if (!doc) return; const el = doc.getElementById('cx-paid-credits'); if (el) el.textContent = credits; }, _showPaidStatus(success, message) { const doc = UIPanel._doc; if (!doc) return; const statusEl = doc.getElementById('cx-paid-status'); if (!statusEl) return; statusEl.textContent = success ? `✅ ${message}` : `❌ ${message}`; statusEl.style.color = success ? '#67c23a' : '#f56c6c'; }, // ---- 9.3 AI 答题模式 ---- setAIApiKey(key) { const cfg = ConfigStore.getAnswerConfig(); cfg.aiApiKey = key; ConfigStore.setAnswerConfig(cfg); Logger.log('AI API Key 已保存', 'success'); }, askAI(question, options, type) { return new Promise((resolve) => { const cfg = ConfigStore.getAnswerConfig(); const apiKey = cfg.aiApiKey; if (!apiKey) { Logger.log('请先配置 AI API Key', 'error'); resolve({ answers: [], remaining: -1 }); return; } // 获取当前选择的AI模型 const providerKey = ConfigStore.getAIProvider(); const providerConf = AI_PROVIDERS[providerKey] || AI_PROVIDERS['zhipu']; // 构造 prompt const prompt = this._buildAIPrompt(question, options, type); const buildRetryPrompt = () => { const q = String(question || '').trim(); if (type === Solver.TYPE_FILL) { return [ '上一次没有给出有效填空答案。请重新作答,并且必须输出非空答案。', '只输出应填入括号、横线或空格处的内容;多个空用 || 分隔。', '不要输出“答案:”、解释、标点空壳或空字符串。', `题目:${q}`, ].join('\n'); } return prompt + '\n\n请重新作答,只输出最终答案,不要解释。'; }; let requestUrl = providerConf.url; const requestHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; Logger.log(`🤖 正在使用 ${providerConf.name} 答题...`, 'info'); const sendRequest = (promptText, retried = false) => { const requestBody = { model: providerConf.model, messages: [{ role: 'user', content: promptText }], temperature: 0.1, max_tokens: 200 }; GM_xmlhttpRequest({ method: 'POST', url: requestUrl, headers: requestHeaders, data: JSON.stringify(requestBody), timeout: 30000, onload: (resp) => { try { if (resp.status === 401) { Logger.log('AI API Key 无效(401 Unauthorized)', 'error'); resolve({ answers: [], remaining: -1 }); return; } if (resp.status === 429) { Logger.log('AI 请求频率超限(429 Too Many Requests)', 'error'); resolve({ answers: [], remaining: -1 }); return; } const data = JSON.parse(resp.responseText); if (data.choices && data.choices.length > 0) { let content = data.choices[0].message?.content || ''; content = this._cleanAIContent(content); const answers = this._parseAIAnswer(content, type); if (answers.length > 0) { Logger.log(`AI 返回答案: ${answers.join(', ')}`, 'success'); resolve({ answers, remaining: -1 }); return; } if (!retried && type === Solver.TYPE_FILL) { Logger.log('AI 首次返回空答案,正在用填空题强化提示重试...', 'info'); sendRequest(buildRetryPrompt(), true); return; } Logger.log('AI 返回内容为空或无法提取有效答案', 'error'); resolve({ answers: [], remaining: -1 }); } else { Logger.log('AI 返回内容为空', 'error'); resolve({ answers: [], remaining: -1 }); } } catch (e) { Logger.log('AI 响应解析失败: ' + (e.message || e), 'error'); resolve({ answers: [], remaining: -1 }); } }, onerror: () => { Logger.log('AI 请求失败(网络错误)', 'error'); resolve({ answers: [], remaining: -1 }); }, ontimeout: () => { Logger.log('AI 请求超时', 'error'); resolve({ answers: [], remaining: -1 }); } }); }; sendRequest(prompt, false); }); }, _buildAIPrompt(question, options, type) { const typeNames = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '题目' }; const typeName = typeNames[type] || '题目'; let prompt = `请回答以下${typeName},`; switch (type) { case Solver.TYPE_SINGLE: prompt += '请只回答选项字母(如 A),不要包含其他内容。\n'; break; case Solver.TYPE_MULTI: prompt += '请回答所有正确选项的字母,多个选项之间用 || 分隔(如 A||C||D),不要包含其他内容。\n'; break; case Solver.TYPE_JUDGE: prompt += '请只回答"对"或"错",不要包含其他内容。\n'; break; case Solver.TYPE_FILL: prompt += '请只回答填空内容,多个空之间用 || 分隔,不要包含其他内容。题目里的 ()、()、横线、空格都表示需要补全的位置;严禁返回空答案,无法完全确定也必须给出最可能答案。\n'; break; default: prompt += '请直接回答答案,不要包含解释。\n'; } prompt += `\n题目:${question}\n`; if (options && options.length > 0) { prompt += '选项:\n'; options.forEach((opt, i) => { prompt += `${String.fromCharCode(65 + i)}. ${opt}\n`; }); } return prompt; }, _cleanAIContent(content) { return String(content || '') .trim() .replace(/^```(?:json|text)?\s*/i, '') .replace(/```$/i, '') .replace(/^\s*(?:答案|答|最终答案|填空内容)\s*[::]\s*/i, '') .trim(); }, _parseAIAnswer(content, type) { let text = this._cleanAIContent(content); if (!text) return []; // 填空题:兼容 "||" / JSON / 引号 / "应填:xxx" 等格式,并过滤空壳答案 if (type === Solver.TYPE_FILL) { const normalizeFill = (s) => String(s || '') .replace(/^\s*(?:答案|答|填空内容|应填|填写|填入|空\d*|第\d+空)\s*[::]\s*/i, '') .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '') .trim(); try { const json = JSON.parse(text); if (Array.isArray(json)) { const arr = json.map(normalizeFill).filter(Boolean); if (arr.length) return arr; } if (json && typeof json === 'object') { const values = Object.values(json).map(normalizeFill).filter(Boolean); if (values.length) return values; } } catch (_) {} const stripped = normalizeFill(text); if (!stripped || /^(?:无|空|不知道|不确定|无法确定|略|N\/A|NA|null|undefined)$/i.test(stripped)) return []; if (stripped.includes('||')) { return stripped.split('||').map(normalizeFill).filter(Boolean); } const lines = stripped.split(/\n+/).map(normalizeFill).filter(Boolean); if (lines.length > 1) return lines; const quoted = [...stripped.matchAll(/[“"']([^“”"']{1,80})[”"']/g)] .map(m => normalizeFill(m[1])) .filter(Boolean); if (quoted.length) return quoted; const colon = stripped.match(/(?:应填|填入|填写|答案(?:是|为)?)[::]\s*([^。;;\n]+)/i); if (colon && normalizeFill(colon[1])) return [normalizeFill(colon[1])]; return [stripped]; } // 多选题:兼容多种格式 —— "A||C||D" / "A,C,D" / "A、C、D" / "ACD" / "答案是 A 和 C" if (type === Solver.TYPE_MULTI) { // 先尝试 || 分隔 if (text.includes('||')) { const parts = text.split('||').map(s => s.trim()).filter(Boolean); if (parts.length > 0) return parts; } // 正则提取所有 A-Z 字母(去重,按字母顺序) // 先做一次预处理:去掉常见 noise 词语("答案"/"选项"等) const cleaned = text.replace(/答案|选项|正确|的?字母是?|应?该?选/g, ' '); const letters = cleaned.match(/[A-Ha-h]/g); if (letters && letters.length > 0) { const uniq = [...new Set(letters.map(c => c.toUpperCase()))]; uniq.sort(); return uniq; } return []; } // 单选题:提取第一个字母 if (type === Solver.TYPE_SINGLE) { const match = text.match(/[A-Ha-h]/); if (match) return [match[0].toUpperCase()]; } // 判断题 if (type === Solver.TYPE_JUDGE) { if (/对|正确|√|true|是|right|yes/i.test(text)) return ['对']; if (/错|错误|×|false|否|wrong|no/i.test(text)) return ['错']; } // 默认返回整个文本 return [text]; }, _parseBankAnswers(rawAnswers, success, options = []) { if (!Array.isArray(rawAnswers) || rawAnswers.length === 0) return []; const cleanList = list => list .map(a => String(a)) .map(a => a.trim()) .filter(a => a); if (typeof rawAnswers[0] === 'number') { const uniqueIndices = [...new Set(rawAnswers)]; return uniqueIndices .filter(idx => Number.isInteger(idx) && idx >= 0) .map(idx => options[idx] || String.fromCharCode(65 + idx)) .filter(a => a); } if (success) { return cleanList(rawAnswers); } const groups = Array.isArray(rawAnswers[0]) ? rawAnswers : [rawAnswers]; for (const group of groups) { const cleaned = cleanList(Array.isArray(group) ? group : [group]); if (cleaned.length > 0) return cleaned; } return []; }, // 解析为 2D 答案组数组(用于智能匹配的投票),保留所有候选组 _parseBankAnswerGroups(rawAnswers, success, options = []) { if (!Array.isArray(rawAnswers) || rawAnswers.length === 0) return []; const cleanList = list => list.map(a => String(a)).map(a => a.trim()).filter(a => a); // 数字索引 → 单组文本 if (typeof rawAnswers[0] === 'number') { const uniqueIndices = [...new Set(rawAnswers)]; const texts = uniqueIndices .filter(idx => Number.isInteger(idx) && idx >= 0) .map(idx => options[idx] || String.fromCharCode(65 + idx)) .filter(a => a); return texts.length ? [texts] : []; } // success: true,1D 文本 → 单组 if (success) { const cleaned = cleanList(rawAnswers); return cleaned.length ? [cleaned] : []; } // success: false,2D 文本 → 多个候选组 const raw2D = Array.isArray(rawAnswers[0]) ? rawAnswers : [rawAnswers]; return raw2D .map(g => cleanList(Array.isArray(g) ? g : [g])) .filter(g => g.length > 0); }, // ---- 答题查询接口(供后续任务使用) ---- askFreeBank(question, options, type) { return new Promise(async (resolve) => { const accessOk = await AccessGuard.requireFreeAnswerAccess(); if (!accessOk) { resolve({ answers: [], remaining: -1 }); return; } const _normQ = Solver._formatString(question); if (_normQ !== question) { Logger.log(`[免费题库] 已归一化题干: ${_normQ.slice(0, 30)}... (原: ${question.slice(0, 20)}...)`, 'info'); } else { Logger.log(`[免费题库] 查询: ${question.slice(0, 30)}...`, 'info'); } GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/code/free_answer', headers: { 'Content-Type': 'application/json' }, // 与万能脚本 formatSearchAnswer 字段对齐 + formatString 归一化(去全角/HTML实体/尾标点)以提升缓存命中 data: JSON.stringify({ session_token: ConfigStore.getVerifySessionToken(), device_id: ConfigStore.getDeviceId(), plat: null, qid: null, question: Solver._formatString(question), options: (options || []).map(o => Solver._formatString(o)), options_id: [], type, location: location.href }), onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data && data.data.result) { const rawAnswers = data.data.result.answers || []; const success = data.data.result.success; const finalAnswers = this._parseBankAnswers(rawAnswers, success, options); const finalGroups = this._parseBankAnswerGroups(rawAnswers, success, options); Logger.log(`[免费题库] 返回 ${finalAnswers.length} 个有效答案 (${finalGroups.length} 组候选)`, 'info'); resolve({ answers: finalAnswers, groups: finalGroups, remaining: -1 }); } else { Logger.log(`[免费题库] API返回: code=${data.code}, msg=${data.message || data.msg || ''}`, 'info'); if (/验证码|授权|session|会话|过期|无效/i.test(data.message || data.msg || '')) { ConfigStore.clearVerifySession(); this._updateFreeStatus(false, data.message || data.msg || '验证码授权无效,请重新验证'); } resolve({ answers: [], remaining: -1 }); } } catch (e) { Logger.log(`[免费题库] 解析失败: ${resp.responseText?.slice(0,100)}`, 'error'); resolve({ answers: [], remaining: -1 }); } }, onerror: () => resolve({ answers: [], remaining: -1 }) }); }); }, askPaidBank(question, options, type) { return new Promise(async (resolve) => { // 检查积分余额 const credits = await this.getCredits(); if (credits <= 0) { Logger.log('积分不足,请充值后再试', 'error'); resolve({ answers: [], remaining: 0 }); return; } const _normQ = Solver._formatString(question); if (_normQ !== question) { Logger.log(`[付费题库] 已归一化题干(余${credits}积分): ${_normQ.slice(0, 30)}... (原: ${question.slice(0, 20)}...)`, 'info'); } else { Logger.log(`[付费题库] 查询(余${credits}积分): ${question.slice(0, 30)}...`, 'info'); } GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/answer', headers: { 'Content-Type': 'application/json' }, // 与万能脚本 formatSearchAnswer 字段对齐 + formatString 归一化(去全角/HTML实体/尾标点)以提升缓存命中 data: JSON.stringify({ device_id: ConfigStore.getDeviceId(), plat: null, qid: null, question: Solver._formatString(question), options: (options || []).map(o => Solver._formatString(o)), options_id: [], type, location: location.href }), timeout: 15000, onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data?.result) { const remaining = data.data.remaining_credits || 0; const rawAnswers = data.data.result.answers || []; const success = data.data.result.success; const finalAnswers = this._parseBankAnswers(rawAnswers, success, options); const finalGroups = this._parseBankAnswerGroups(rawAnswers, success, options); Logger.log(`[付费题库] 返回 ${finalAnswers.length} 个有效答案 (${finalGroups.length} 组候选), 余${remaining}积分`, 'info'); this._updateCreditsDisplay(remaining); resolve({ answers: finalAnswers, groups: finalGroups, remaining }); } else { const msg = data.msg || ''; Logger.log(`[付费题库] API返回: code=${data.code}, msg=${msg}`, 'info'); // 系统积分池耗尽,回退到免费题库 if (msg.includes('系统积分') || msg.includes('管理员充值')) { Logger.log('[付费题库] 系统积分池耗尽,自动切换免费题库查询...', 'info'); this._fallbackFreeQuery(question, options, type, credits, resolve); } else { resolve({ answers: [], remaining: credits }); } } } catch (e) { Logger.log(`[付费题库] 解析失败: ${resp.responseText?.slice(0,100)}`, 'error'); resolve({ answers: [], remaining: credits }); } }, onerror: () => { Logger.log('[付费题库] 网络请求失败', 'error'); resolve({ answers: [], remaining: credits }); }, ontimeout: () => { Logger.log('[付费题库] 请求超时', 'error'); resolve({ answers: [], remaining: credits }); } }); }); }, async _fallbackFreeQuery(question, options, type, credits, resolve) { const accessOk = await AccessGuard.requireFreeAnswerAccess(); if (!accessOk) { Logger.log('[免费题库回退] 免费验证码未验证或授权无效,已停止回退查询', 'error'); resolve({ answers: [], remaining: credits }); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/code/free_answer', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ session_token: ConfigStore.getVerifySessionToken(), device_id: ConfigStore.getDeviceId(), plat: null, qid: null, question: Solver._formatString(question), options: (options || []).map(o => Solver._formatString(o)), options_id: [], type, location: location.href }), timeout: 15000, onload: (freeResp) => { try { const freeData = JSON.parse(freeResp.responseText); if (freeData.code === 1 && freeData.data && freeData.data.result) { const answers = freeData.data.result.answers || []; const finalAnswers = this._parseBankAnswers(answers, freeData.data.result.success, options); const finalGroups = this._parseBankAnswerGroups(answers, freeData.data.result.success, options); if (finalAnswers.length > 0) { Logger.log(`[免费题库回退] 查询成功,返回 ${finalAnswers.length} 个有效答案`, 'info'); resolve({ answers: finalAnswers, groups: finalGroups, remaining: credits }); return; } } if (/验证码|授权|session|会话|过期|无效/i.test(freeData.message || freeData.msg || '')) { ConfigStore.clearVerifySession(); this._updateFreeStatus(false, freeData.message || freeData.msg || '验证码授权无效,请重新验证'); } Logger.log('[免费题库回退] 未找到匹配答案', 'info'); resolve({ answers: [], remaining: credits }); } catch (e) { Logger.log('[免费题库回退] 解析失败', 'error'); resolve({ answers: [], remaining: credits }); } }, onerror: () => { Logger.log('[免费题库回退] 网络请求失败', 'error'); resolve({ answers: [], remaining: credits }); }, ontimeout: () => { Logger.log('[免费题库回退] 请求超时', 'error'); resolve({ answers: [], remaining: credits }); } }); }, // ---- 跨 iframe 查找题目所在文档(作业/考试页通常嵌套在 iframe 里)---- _findQuizDoc() { const sel = '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId, .TiMu, .mark_table, .exam-sidebar, .leftItems, .questionList, .TiMu-list'; if (document.querySelector(sel)) return document; const search = (doc, depth) => { if (depth > 4) return null; try { const iframes = doc.querySelectorAll('iframe'); for (const iframe of iframes) { try { const idoc = iframe.contentDocument || iframe.contentWindow?.document; if (!idoc) continue; if (idoc.querySelector(sel)) return idoc; const found = search(idoc, depth + 1); if (found) return found; } catch (_) { /* 跨域 iframe 忽略 */ } } } catch (_) {} return null; }; return search(document, 1) || document; // 兜底返回 document,避免后续 null 引用 }, // ---- 10.1 / 10.2 答题查询路由 ---- async _queryAnswer(question, options, type) { switch (this._mode) { case 'free': return this.askFreeBank(question, options, type); case 'paid': return this.askPaidBank(question, options, type); case 'ai': return this.askAI(question, options, type); default: return { answers: [], remaining: -1 }; } }, // ---- 10.1 作业自动答题 ---- async startHomework() { if (this._answering) { Logger.log('答题任务正在进行中', 'info'); return; } // 跨 iframe 找到题目所在文档 const doc = this._findQuizDoc(); const containers = doc.querySelectorAll( '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId' ); if (containers.length === 0) { Logger.log('未检测到题目,请确认当前页面为作业页面(已搜索同源 iframe 子页面)', 'error'); return; } this._answering = true; this._stopRequested = false; this._totalQuestions = containers.length; this._currentQuestion = 0; UIPanel.setRunning(true); UIPanel.setProgress(0, this._totalQuestions); Logger.log(`检测到 ${this._totalQuestions} 道题目,开始答题...`, 'info'); // 更新按钮状态 this._setAnswerButtonsRunning(true); for (let i = 0; i < containers.length; i++) { if (this._stopRequested) { Logger.log('答题已停止', 'info'); break; } this._currentQuestion = i + 1; UIPanel.setProgress(this._currentQuestion, this._totalQuestions); Logger.log(`正在处理第 ${this._currentQuestion}/${this._totalQuestions} 题`, 'info'); const container = containers[i]; // 跳过已完成题目 if (this._isQuestionCompleted(container)) { Logger.log(`第 ${this._currentQuestion} 题已完成,跳过`, 'info'); continue; } const _label = `第 ${this._currentQuestion} 题`; try { // 识别题目 const qData = await Solver.recognize(container); if (!qData.question) { Logger.log(`${_label} 识别失败,跳过`, 'error'); continue; } // 查询答案(题库 / AI 模式) const bankResult = await this._queryAnswer(qData.question, qData.options, qData.type); // 智能匹配 + AI 兜底 const ok = await this._applyAnswer(qData, container, _label, bankResult); if (ok) { // 作业模式自动提交本题 await Solver._clickSubmit(container); Logger.log(`${_label} 已作答`, 'success'); } } catch (e) { Logger.log(`${_label} 处理异常: ${e.message || e}`, 'error'); } // 题间延迟(AI模式:解锁后1.5秒,未解锁35秒;其他1-2秒) if (i < containers.length - 1 && !this._stopRequested) { const delay = await AnswerModule._getQuestionDelay(); if (AnswerModule._mode === 'ai' && delay > 10000) Logger.log(`AI模式冷却中,${Math.round(delay/1000)}秒后继续(解锁后可加速)...`, 'info'); await this._delay(delay); } } const wasStopped = this._stopRequested; this._answering = false; this._stopRequested = false; UIPanel.setRunning(false); this._setAnswerButtonsRunning(false); if (!wasStopped) { await Solver._clickFinalSubmit(doc, '作业'); Logger.log('答题完成,已尝试自动提交作业', 'success'); } }, // ---- 10.2 考试自动答题 ---- async startExam() { if (this._answering) { Logger.log('答题任务正在进行中', 'info'); return; } this._answering = true; this._stopRequested = false; UIPanel.setRunning(true); this._setAnswerButtonsRunning(true); // 优先尝试切换到「整卷浏览」模式(所有题目一次性渲染,速度最快) const switched = await this._trySwitchToFullPaperView(); if (switched) { await this._delay(800); // 等待 DOM 重排 Logger.log('✅ 已切换到整卷浏览模式', 'success'); } // 检测考试页面布局 const isPaginated = this._isExamPaginated(); Logger.log(`检测到考试布局: ${isPaginated ? '分页模式' : '整卷/列表模式'}`, 'info'); try { if (isPaginated) { await this._processExamPaginated(); } else { await this._processExamList(); } } catch (e) { Logger.log(`考试答题异常: ${e.message || e}`, 'error'); } const wasStopped = this._stopRequested; this._answering = false; this._stopRequested = false; UIPanel.setRunning(false); this._setAnswerButtonsRunning(false); if (!wasStopped) { await Solver._clickFinalSubmit(this._findQuizDoc(), '考试/章测'); Logger.log('答题完成,已尝试自动提交', 'success'); } }, // 在 doc 全文范围按文本查找可点击按钮(跨多种学习通模板) _findButtonByText(doc, textPatterns) { if (!doc) return null; const sels = ['button', 'a', '[role="button"]', '.btn', '.Btn', '.jb_btn', '.Btn_blue_01', '.Btn_blue_02', '.el-button', '.ant-btn']; let candidates = []; try { candidates = Array.from(doc.querySelectorAll(sels.join(','))); } catch (_) {} const patterns = textPatterns.map(t => String(t).replace(/\s+/g, '')); for (const el of candidates) { const text = ((el.innerText || el.textContent || '') + '').replace(/\s+/g, ''); const aria = (el.getAttribute('aria-label') || '').replace(/\s+/g, ''); if (!text && !aria) continue; const matched = patterns.some(p => text === p || text.includes(p) || aria === p || aria.includes(p)); if (!matched) continue; if (el.disabled) continue; if (el.getAttribute('aria-disabled') === 'true') continue; const cls = String(el.className || ''); if (/\b(disabled|is-disabled)\b/.test(cls)) continue; // 视觉可见性兜底 try { const style = el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView.getComputedStyle(el) : null; if (style && (style.display === 'none' || style.visibility === 'hidden')) continue; } catch (_) {} return el; } return null; }, // 尝试切换到「整卷浏览/整卷预览」模式 —— 整卷模式下所有题目一次性渲染,答题速度最快 async _trySwitchToFullPaperView() { const doc = this._findQuizDoc(); const btn = this._findButtonByText(doc, ['整卷预览', '整卷浏览', '查看整卷', '整卷模式']); if (!btn) return false; Logger.log('🔍 检测到「整卷浏览」按钮,切换到整卷模式以加速答题...', 'info'); try { btn.click(); } catch (_) { return false; } await this._delay(2500); return true; }, // 点击"下一题"按钮推进,返回是否成功 _clickNextQuestionButton() { const doc = this._findQuizDoc(); const btn = this._findButtonByText(doc, ['下一题', '下一道', '下一个']); if (!btn) return false; try { btn.click(); return true; } catch (_) { return false; } }, // 检测是否为分页模式考试 _isExamPaginated() { // 分页模式:有侧边栏题号导航(跨 iframe 查找;兼容 chaoxing/xuexitong 多版本) const doc = this._findQuizDoc(); const sidebarSel = '.exam-sidebar, .leftItems, .questionList, .TiMu-list, ' + '.examPaperNum-list, .examNum-list, .markBox, .test-num-list, ' + '.exam-paper-num, .examPaper, .questionStatus, .nav-list, .num-list'; const sidebar = doc.querySelector(sidebarSel); if (sidebar) { const navItems = sidebar.querySelectorAll('a, li, .item, .TiMu-item, .examNum, .quesNum, .questionItem, .examNumItem, .markItem, .examPaperNum-item'); if (navItems.length > 0) return true; } // 兜底:判断是否存在「下一题」按钮——这强烈暗示当前是分页/单页模式 if (this._findButtonByText(doc, ['下一题', '下一道'])) return true; return false; }, // 分页模式:使用「下一题」按钮 + 侧栏导航双重推进,最多答 N 题(N=侧栏数 / 100 兜底) async _processExamPaginated() { const doc = this._findQuizDoc(); const sidebarSel = '.exam-sidebar, .leftItems, .questionList, .TiMu-list, ' + '.examPaperNum-list, .examNum-list, .markBox, .test-num-list, ' + '.exam-paper-num, .examPaper, .questionStatus, .nav-list, .num-list'; const sidebar = doc.querySelector(sidebarSel); let navItems = sidebar ? Array.from(sidebar.querySelectorAll('a, li, .item, .TiMu-item, .examNum, .quesNum, .questionItem, .examNumItem, .markItem, .examPaperNum-item')) : []; // 过滤掉无文本/明显不是题号的元素(题号通常是 1-3 位数字,或仅图标) navItems = navItems.filter(it => { const t = ((it.innerText || it.textContent || '') + '').trim(); return t.length > 0 && t.length < 30; }); const totalGuess = navItems.length || 100; this._totalQuestions = totalGuess; this._currentQuestion = 0; UIPanel.setProgress(0, this._totalQuestions); Logger.log(`检测到 ${navItems.length || '未知数量'} 道题目${navItems.length === 0 ? '(侧栏未识别,按"下一题"按钮逐题推进)' : ''}`, 'info'); let lastQuestionText = ''; for (let i = 0; i < totalGuess; i++) { if (this._stopRequested) { Logger.log('答题已停止', 'info'); break; } this._currentQuestion = i + 1; UIPanel.setProgress(this._currentQuestion, this._totalQuestions); // 若侧栏可识别,且该题已作答,则跳过 if (i < navItems.length) { const navItem = navItems[i]; if (navItem && (navItem.classList.contains('is-checked') || navItem.querySelector('.is-checked'))) { Logger.log(`第 ${this._currentQuestion} 题已作答,跳过`, 'info'); // 推进到下一题 const advanced = this._clickNextQuestionButton(); if (!advanced && i + 1 < navItems.length && navItems[i + 1]) navItems[i + 1].click(); await this._delay(800); continue; } } Logger.log(`正在处理第 ${this._currentQuestion}/${this._totalQuestions} 题`, 'info'); const container = this._findCurrentExamQuestion(doc); if (!container) { Logger.log(`第 ${this._currentQuestion} 题容器未找到,跳过`, 'error'); // 尝试推进 const advanced = this._clickNextQuestionButton(); if (!advanced) break; await this._delay(1200); continue; } // 记录当前题干,用于推进后检测题目是否变化(避免重复处理同一题) const beforeText = ((container.innerText || container.textContent || '') + '').slice(0, 100); await this._processOneQuestion(container, this._currentQuestion); if (i >= totalGuess - 1 || this._stopRequested) break; // 推进:先点「下一题」,失败再点侧栏 navItem let advanced = this._clickNextQuestionButton(); if (!advanced && i + 1 < navItems.length && navItems[i + 1]) { try { navItems[i + 1].click(); advanced = true; } catch (_) {} } if (!advanced) { Logger.log('⚠️ 找不到「下一题」按钮且侧栏无下一题,停止', 'info'); break; } // 题间延迟(AI模式:解锁后1.5秒,未解锁35秒;其他1-2秒) const delay = await AnswerModule._getQuestionDelay(); if (AnswerModule._mode === 'ai' && delay > 10000) Logger.log(`AI模式冷却中,${Math.round(delay/1000)}秒后继续(解锁后可加速)...`, 'info'); await this._delay(delay); // 等题目内容更新(最多再等 3 秒) const updatedDoc = this._findQuizDoc(); for (let waited = 0; waited < 3000; waited += 300) { const c2 = this._findCurrentExamQuestion(updatedDoc); const t2 = c2 ? ((c2.innerText || c2.textContent || '') + '').slice(0, 100) : ''; if (t2 && t2 !== beforeText) break; await this._delay(300); } lastQuestionText = beforeText; } }, // 列表/整卷模式:所有题目在同一页面 async _processExamList() { const doc = this._findQuizDoc(); const containers = doc.querySelectorAll( '.exam-main .questionLi, .exam-main .singleQuesId, .exam-main .multipleQuesId, ' + '.exam-main .judgementQuesId, .exam-main .completionQuesId, ' + '.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId, ' + '.examItem, .questionItem, .examQ, .examQuestion, .subject-item, ' + '.exam-paper-question, .question-item, .quesItem, .quesBody' ); if (containers.length === 0) { Logger.log('未检测到题目', 'error'); return; } this._totalQuestions = containers.length; this._currentQuestion = 0; UIPanel.setProgress(0, this._totalQuestions); Logger.log(`检测到 ${this._totalQuestions} 道题目`, 'info'); for (let i = 0; i < containers.length; i++) { if (this._stopRequested) { Logger.log('答题已停止', 'info'); break; } this._currentQuestion = i + 1; UIPanel.setProgress(this._currentQuestion, this._totalQuestions); Logger.log(`正在处理第 ${this._currentQuestion}/${this._totalQuestions} 题`, 'info'); const container = containers[i]; // 跳过已作答题目 if (container.classList.contains('is-checked') || container.querySelector('.is-checked')) { Logger.log(`第 ${this._currentQuestion} 题已作答,跳过`, 'info'); continue; } await this._processOneQuestion(container, this._currentQuestion); // 题间延迟(AI模式:解锁后1.5秒,未解锁35秒;其他1-2秒) if (i < containers.length - 1 && !this._stopRequested) { const delay = await AnswerModule._getQuestionDelay(); if (AnswerModule._mode === 'ai' && delay > 10000) Logger.log(`AI模式冷却中,${Math.round(delay/1000)}秒后继续(解锁后可加速)...`, 'info'); await this._delay(delay); } } }, // 题库未命中时的 AI 兜底(仅在非 ai 模式下被调用;ai 模式本身已经走 askAI) async _aiFallback(qData, questionLabel) { const strategy = ConfigStore.getUnmatchedFallback(); if (strategy === 'skip') { Logger.log(`${questionLabel} 题库未命中,按设置跳过`, 'info'); return null; } // 默认 ai 兜底 const cfg = ConfigStore.getAnswerConfig(); if (!cfg.aiApiKey) { Logger.log(`🤖 ${questionLabel} 想用 AI 兜底但未配置 API Key,跳过(请在 AI 答题模式中保存 API Key)`, 'info'); return null; } if (!Array.isArray(qData.options) || qData.options.length === 0) { if (qData.type !== Solver.TYPE_FILL && qData.type !== Solver.TYPE_JUDGE) { Logger.log(`🤖 ${questionLabel} 想用 AI 兜底但选项为空,跳过`, 'info'); return null; } } try { const typeMap = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '其他' }; Logger.log(`🤖 ${questionLabel} 题库未命中,调用 AI 兜底(题型: ${typeMap[qData.type] || '未知'})...`, 'info'); const aiResult = await this.askAI(qData.question, qData.options || [], qData.type); if (aiResult && Array.isArray(aiResult.answers) && aiResult.answers.length > 0) { Logger.log(`🤖 ${questionLabel} AI 返回 ${aiResult.answers.length} 个答案:${aiResult.answers.join(' / ')}`, 'success'); return aiResult.answers; } Logger.log(`🤖 ${questionLabel} AI 未返回有效答案,跳过`, 'info'); return null; } catch (err) { Logger.log(`🤖 ${questionLabel} AI 兜底失败:${err.message || err}`, 'error'); return null; } }, // 选择并应用答案(智能匹配 + AI 兜底) // 返回 true 表示成功作答,false 表示跳过 async _applyAnswer(qData, container, questionLabel, bankResult) { const pageOptions = (qData.options && qData.options.length > 0) ? qData.options : Solver._extractOptions(container); const type = (qData.type !== undefined) ? qData.type : Solver._detectType(container); // 填空题:直接填写 if (type === Solver.TYPE_FILL) { const fillAnswers = (bankResult && bankResult.answers && bankResult.answers.length > 0) ? bankResult.answers : null; let answers = fillAnswers; if (!answers) { answers = await this._aiFallback(qData, questionLabel); } if (!answers || answers.length === 0) { Logger.log(`${questionLabel} 填空题无答案,跳过`, 'error'); return false; } Solver._fillBlanks(answers, container); return true; } // 选择题:先用智能匹配 const groups = (bankResult && Array.isArray(bankResult.groups) && bankResult.groups.length > 0) ? bankResult.groups : (bankResult && Array.isArray(bankResult.answers) && bankResult.answers.length > 0 ? [bankResult.answers] : []); let matchedIndices = groups.length > 0 ? Solver.matchAnswerSmart(groups, pageOptions, type) : []; // 智能匹配失败 → 兼容旧的 matchAnswer 再试一次 if (matchedIndices.length === 0 && bankResult && bankResult.answers && bankResult.answers.length > 0) { matchedIndices = Solver.matchAnswer(bankResult.answers, pageOptions); } // 仍然失败 → AI 兜底(若策略为 skip 或未配置 Key 则跳过本题,绝不随机猜) if (matchedIndices.length === 0) { const strategy = ConfigStore.getUnmatchedFallback(); if (strategy === 'ai') { const aiAnswers = await this._aiFallback(qData, questionLabel); if (aiAnswers && aiAnswers.length > 0) { matchedIndices = Solver.matchAnswerSmart([aiAnswers], pageOptions, type); if (matchedIndices.length === 0) { matchedIndices = Solver.matchAnswer(aiAnswers, pageOptions); } } } } if (matchedIndices.length === 0) { Logger.log(`${questionLabel} 答案匹配失败,跳过`, 'error'); return false; } Solver._selectOptions(matchedIndices, container, type); return true; }, // 处理单道题目(考试模式共用) async _processOneQuestion(container, questionNum) { const label = `第 ${questionNum} 题`; try { const qData = await Solver.recognize(container); if (!qData.question) { Logger.log(`${label} 识别失败,跳过`, 'error'); return; } // 查询答案(题库 / AI 模式) const bankResult = await this._queryAnswer(qData.question, qData.options, qData.type); // 智能选择并应用答案(无命中自动 AI 兜底) const ok = await this._applyAnswer(qData, container, label, bankResult); if (ok) Logger.log(`${label} 已作答`, 'success'); } catch (e) { Logger.log(`${label} 处理异常: ${e.message || e}`, 'error'); } }, // 查找当前显示的考试题目容器(doc 参数允许指定 iframe 内的 document) _findCurrentExamQuestion(doc) { doc = doc || this._findQuizDoc(); // 分页模式下,当前显示的题目(旧版 chaoxing 类名) const visible = doc.querySelector( '.questionLi:not([style*="display: none"]):not([style*="display:none"]), ' + '.singleQuesId:not([style*="display: none"]), ' + '.multipleQuesId:not([style*="display: none"]), ' + '.judgementQuesId:not([style*="display: none"]), ' + '.completionQuesId:not([style*="display: none"])' ); if (visible) return visible; // 备选:查找 .exam-main 下的活跃题目 const active = doc.querySelector('.exam-main .active, .exam-main .current'); if (active) return active; // 新版 xuexitong:尝试更多类名 const newSel = doc.querySelector('.examItem, .questionItem, .examQ, .examQuestion, .subject-item, .question-item, .quesItem, .quesBody, .exam-paper-question'); if (newSel) return newSel; // 再备选:取第一个题目容器 return doc.querySelector('.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId'); }, // 检查题目是否已完成 _isQuestionCompleted(container) { if (!container) return false; // 检查 .is-checked 标记 if (container.classList.contains('is-checked') || container.querySelector('.is-checked')) return true; // 检查已选中的选项 const checked = container.querySelector('input[type="radio"]:checked, input[type="checkbox"]:checked'); if (checked) return true; // 检查填空题已填写 const textInputs = container.querySelectorAll('input[type="text"], textarea'); for (const input of textInputs) { if (input.value && input.value.trim()) return true; } return false; }, // 更新答题按钮状态 _setAnswerButtonsRunning(running) { const doc = UIPanel._doc; if (!doc) return; const homeworkBtn = doc.getElementById('cx-btn-start-homework'); const examBtn = doc.getElementById('cx-btn-start-exam'); const stopBtn = doc.getElementById('cx-btn-stop-answer'); if (homeworkBtn) homeworkBtn.disabled = running; if (examBtn) examBtn.disabled = running; if (stopBtn) stopBtn.disabled = !running; }, // 延迟工具 _delay(ms) { return new Promise(r => setTimeout(r, ms)); }, // ---- 9.4 UI 相关 ---- _updateModeUI() { const doc = UIPanel._doc; if (!doc) return; const mode = this._mode; // 更新 radio 选中状态 doc.querySelectorAll('input[name="cx-answer-mode-tab"]').forEach(radio => { radio.checked = radio.value === mode; }); // 显示/隐藏对应配置区域 doc.getElementById('cx-answer-free-area').style.display = mode === 'free' ? 'block' : 'none'; doc.getElementById('cx-answer-paid-area').style.display = mode === 'paid' ? 'block' : 'none'; doc.getElementById('cx-answer-ai-area').style.display = mode === 'ai' ? 'block' : 'none'; }, // 答题状态 isAnswering() { return this._answering; }, getCurrentQuestion() { return this._currentQuestion; }, getTotalQuestions() { return this._totalQuestions; }, stopAnswering() { this._stopRequested = true; }, // 绑定答题模块事件 bindEvents() { const doc = UIPanel._doc; if (!doc) return; // 模式切换(答题标签页内的 radio) doc.querySelectorAll('input[name="cx-answer-mode-tab"]').forEach(radio => { radio.addEventListener('change', (e) => { this.setMode(e.target.value); }); }); // 免费模式:验证按钮 doc.getElementById('cx-btn-verify')?.addEventListener('click', () => { const code = doc.getElementById('cx-free-code')?.value?.trim(); if (!code || code.length !== 4) { this._updateFreeStatus(false, '请输入4位验证码'); return; } this.verifyCode(code); }); // 免费模式:回车键验证 doc.getElementById('cx-free-code')?.addEventListener('keypress', (e) => { if (e.key === 'Enter') doc.getElementById('cx-btn-verify')?.click(); }); // 付费模式:激活按钮 doc.getElementById('cx-btn-activate')?.addEventListener('click', () => { const code = doc.getElementById('cx-paid-code')?.value?.trim(); if (!code) { this._showPaidStatus(false, '请输入激活码'); return; } this.activateCode(code); }); // 付费模式:刷新积分 doc.getElementById('cx-btn-refresh-credits')?.addEventListener('click', () => { this.getCredits(); Logger.log('正在查询积分余额...', 'info'); }); // AI 模式:保存 API Key doc.getElementById('cx-btn-save-aikey')?.addEventListener('click', () => { const key = doc.getElementById('cx-ai-apikey')?.value?.trim(); if (!key) { Logger.log('请输入 API Key', 'error'); return; } this.setAIApiKey(key); const statusEl = doc.getElementById('cx-ai-status'); if (statusEl) { statusEl.textContent = '✅ API Key 已保存'; statusEl.style.color = '#67c23a'; } }); // AI 模式:Provider 切换 const providerSelect = doc.getElementById('cx-ai-provider'); if (providerSelect) { providerSelect.addEventListener('change', () => { ConfigStore.setAIProvider(providerSelect.value); this._updateAIProviderInfo(); }); } // 题库未命中兜底策略切换 const fallbackSelect = doc.getElementById('cx-unmatched-fallback'); if (fallbackSelect) { fallbackSelect.addEventListener('change', () => { ConfigStore.setUnmatchedFallback(fallbackSelect.value); Logger.log(`兜底策略已切换为: ${fallbackSelect.value === 'ai' ? 'AI 兜底' : '跳过'}`, 'info'); }); } // AI 模式:解锁按钮 const unlockBtn = doc.getElementById('cx-ai-unlock-btn'); const unlockCodeInput = doc.getElementById('cx-ai-unlock-code'); if (unlockBtn && unlockCodeInput) { unlockBtn.addEventListener('click', async () => { const code = unlockCodeInput.value.trim(); if (!code || code.length !== 4) { const s = doc.getElementById('cx-ai-unlock-status'); if (s) { s.textContent = '❌ 请输入4位解锁码'; s.style.color = '#f56c6c'; } return; } unlockBtn.disabled = true; unlockBtn.textContent = '验证中...'; try { const result = await this._verifyAIUnlockCode(code); if (result.success) { ConfigStore.setAIUnlockUntil(result.validUntil, code, result.sessionToken || ''); unlockCodeInput.value = ''; Logger.log(`🚀 AI限速已解锁!有效期至 ${result.validUntilStr}`, 'success'); } this._updateAIUnlockStatus(); } catch (err) { const s = doc.getElementById('cx-ai-unlock-status'); if (s) { s.textContent = '❌ ' + (err.message || err); s.style.color = '#f56c6c'; } } finally { unlockBtn.disabled = ConfigStore.isAIUnlocked(); unlockBtn.textContent = '解锁'; } }); // 回车支持 unlockCodeInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') unlockBtn.click(); }); } // 同步设置面板中的答题模式 radio doc.querySelectorAll('input[name="cx-answer-mode"]').forEach(radio => { radio.addEventListener('change', (e) => { this.setMode(e.target.value); // 同步答题标签页的 radio const tabRadio = doc.querySelector(`input[name="cx-answer-mode-tab"][value="${e.target.value}"]`); if (tabRadio) tabRadio.checked = true; }); }); // 作业答题按钮 doc.getElementById('cx-btn-start-homework')?.addEventListener('click', () => { this.startHomework(); }); // 考试答题按钮 doc.getElementById('cx-btn-start-exam')?.addEventListener('click', () => { this.startExam(); }); // 停止答题按钮 doc.getElementById('cx-btn-stop-answer')?.addEventListener('click', () => { this.stopAnswering(); Logger.log('正在停止答题(当前题目处理完后停止)...', 'info'); }); }, // AI 解锁验证 _verifyAIUnlockCode(code) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/code/verify', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: [ 'code=' + encodeURIComponent(code), 'scope=' + encodeURIComponent('ai-unlock'), 'device_id=' + encodeURIComponent(ConfigStore.getDeviceId()) ].join('&'), onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data && data.data.valid) { const validUntil = ConfigStore.normalizeValidUntil(data.data.valid_until); const validUntilStr = data.data.valid_until_str || new Date(validUntil * 1000).toLocaleString('zh-CN'); resolve({ success: true, validUntil, validUntilStr, sessionToken: data.data.session_token || '' }); } else { reject(data.msg || '验证码无效或已过期'); } } catch (e) { reject('验证响应解析失败'); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时') }); }); }, // 更新 AI Provider 信息 _updateAIProviderInfo() { const doc = UIPanel._doc; if (!doc) return; const providerKey = ConfigStore.getAIProvider(); const info = AI_PROVIDERS[providerKey]; if (!info) return; const infoEl = doc.getElementById('cx-ai-provider-info'); if (!infoEl) return; const freeTag = info.free ? '免费' : '低价'; infoEl.innerHTML = `
${info.desc} ${freeTag}
🔗 ${info.linkText}
💡 操作:${info.keyHint}
`; }, // 更新 AI 解锁状态 _updateAIUnlockStatus() { const doc = UIPanel._doc; if (!doc) return; const statusEl = doc.getElementById('cx-ai-unlock-status'); const codeInput = doc.getElementById('cx-ai-unlock-code'); const unlockBtn = doc.getElementById('cx-ai-unlock-btn'); if (ConfigStore.isAIUnlocked()) { const until = ConfigStore.getAIUnlockUntil(); const dateStr = new Date(until * 1000).toLocaleString('zh-CN'); if (statusEl) { statusEl.innerHTML = `✅ 已解锁,有效期至 ${dateStr}(答题间隔≈1.5秒)`; } if (codeInput) codeInput.disabled = true; if (unlockBtn) unlockBtn.disabled = true; } else { if (statusEl) { statusEl.innerHTML = '⏳ 未解锁,当前答题间隔35秒'; } if (codeInput) codeInput.disabled = false; if (unlockBtn) unlockBtn.disabled = false; } }, // 加载配置并恢复 UI 状态 loadConfig() { const cfg = ConfigStore.getAnswerConfig(); this._mode = cfg.mode || 'free'; this._updateModeUI(); // 恢复免费模式状态 if (this.isVerifyValid()) { const validUntil = ConfigStore.normalizeValidUntil(cfg.verifyValidUntil); const validUntilStr = new Date(validUntil * 1000).toLocaleString('zh-CN'); this._updateFreeStatus(true, validUntilStr); } // 恢复 AI API Key 和 Provider const doc = UIPanel._doc; if (doc) { if (cfg.aiApiKey) { const keyInput = doc.getElementById('cx-ai-apikey'); if (keyInput) keyInput.value = cfg.aiApiKey; } const providerSelect = doc.getElementById('cx-ai-provider'); if (providerSelect) { providerSelect.value = ConfigStore.getAIProvider(); } const fallbackSelect = doc.getElementById('cx-unmatched-fallback'); if (fallbackSelect) { fallbackSelect.value = ConfigStore.getUnmatchedFallback(); } this._updateAIProviderInfo(); this._updateAIUnlockStatus(); } // 同步设置面板中的答题模式 if (doc) { const settingsRadio = doc.querySelector(`input[name="cx-answer-mode"][value="${this._mode}"]`); if (settingsRadio) settingsRadio.checked = true; } // 如果是付费模式,查询积分 if (this._mode === 'paid') { this.getCredits(); } // 定期检查免费模式有效期 setInterval(() => this._checkFreeExpiry(), 60000); } }; // ========================================== // 脚本入口 — 初始化流程 (12.1, 12.2, 12.3) // ========================================== /** * 运行状态管理器 (12.2) * 负责保存/恢复运行状态,控制自动化任务的开始/停止 */ const RunStateManager = { _timerInterval: null, /** 启动模块并保存运行状态 */ startModule(moduleName) { const state = { status: 'running', activeModule: moduleName, lastActiveTime: Date.now(), startTime: Date.now() }; ConfigStore.setRunState(state); UIPanel.setRunning(true); UIPanel.setStatus('运行中', 'success'); }, /** 停止当前模块并清除运行状态(2秒内完成) */ stopModule() { const state = ConfigStore.getRunState(); if (!state || state.status !== 'running') return; const moduleName = state.activeModule; ConfigStore.clearRunState(); // 停止对应模块 if (moduleName === 'video') { VideoModule.stop(); } else if (moduleName === 'answer') { AnswerModule.stopAnswering(); } UIPanel.setRunning(false); UIPanel.setStatus('已停止', 'info'); }, /** 页面加载后检查是否需要自动恢复 (12.2) */ tryAutoResume() { const state = ConfigStore.getRunState(); if (!state || state.status !== 'running') return; const elapsed = Date.now() - (state.lastActiveTime || 0); // 5分钟内的运行状态才自动恢复 if (elapsed > 5 * 60 * 1000) { Logger.log('上次运行状态已过期(超过5分钟),不自动恢复', 'info'); ConfigStore.clearRunState(); return; } const moduleName = state.activeModule; Logger.log(`检测到上次运行状态,自动恢复模块: ${moduleName}`, 'info'); if (moduleName === 'video') { if (!ConfigStore.isStudyPurposeConfirmed()) { Logger.log('上次视频任务未完成用途确认,不自动恢复', 'info'); ConfigStore.clearRunState(); return; } UIPanel.switchTab('video'); // 延迟启动,等待页面完全加载 setTimeout(() => { VideoModule.start(); this.startModule('video'); }, 1500); } else if (moduleName === 'answer') { UIPanel.switchTab('answer'); Logger.log('答题模块需要手动重新启动', 'info'); ConfigStore.clearRunState(); } }, /** 定期更新 lastActiveTime 保持状态活跃 */ keepAlive() { const state = ConfigStore.getRunState(); if (state && state.status === 'running') { state.lastActiveTime = Date.now(); ConfigStore.setRunState(state); } } }; /** * 脚本初始化 (12.1) * 页面匹配 → DOM 就绪 → 读取配置 → 注入 UI → 绑定模块 → 恢复运行状态 */ async function initScript() { const initStart = Date.now(); try { // 1. 页面匹配检查(@match 已过滤,此处仅做兜底) const url = window.location.href; if (!/chaoxing\.com|xuexitong\.com/i.test(url) && !/chaoxing|学习通|超星/i.test(document.title || '')) { return; // 非超星页面,不初始化 } if (_isMediaWorkerFrame) { FrameMediaBridge.initWorker(); return; } FrameMediaBridge.initController(); // 2. 等待 DOM 就绪 if (!document.body) { await new Promise(resolve => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', resolve, { once: true }); } else { resolve(); } }); } // 3. 读取配置 const videoCfg = ConfigStore.getVideoConfig(); const answerCfg = ConfigStore.getAnswerConfig(); Logger.log('学习通刷课助手 v1.5.5 已加载', 'success'); // 4. 注入 UI 面板 UIPanel.init(); // 4.5 获取公告 try { GM_xmlhttpRequest({ method: 'GET', url: 'https://qsy.iano.cn/index.php?s=/admin/unified_manage/scriptannouncementapi', timeout: 5000, onload: function(resp) { try { var res = JSON.parse(resp.responseText); if (res.code === 1 && res.data && res.data.enabled) { var _doc = UIPanel._doc; if (!_doc) return; var colors = { info: '#3b82f6', warning: '#f59e0b', error: '#ef4444', success: '#22c55e' }; var bgColors = { info: '#eff6ff', warning: '#fffbeb', error: '#fef2f2', success: '#f0fdf4' }; var c = colors[res.data.type] || '#3b82f6'; var bg = bgColors[res.data.type] || '#eff6ff'; var styleTag = _doc.createElement('style'); styleTag.textContent = '@keyframes cxNoticeIn{from{opacity:0;transform:translateY(-8px);}to{opacity:1;transform:translateY(0);}} @keyframes cxPulse{0%,100%{transform:scale(1);}50%{transform:scale(1.15);}}'; _doc.head.appendChild(styleTag); var el = _doc.createElement('div'); el.style.cssText = 'margin:0 14px 10px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.7;border:2px solid '+c+';background:linear-gradient(135deg,'+bg+',#fff);color:#333;box-shadow:0 2px 12px rgba(0,0,0,0.06);animation:cxNoticeIn 0.5s ease;position:relative;'; var head = _doc.createElement('div'); head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;'; var title = _doc.createElement('div'); title.style.cssText = 'font-weight:700;font-size:14px;color:'+c+';display:flex;align-items:center;gap:6px;'; var pulse = _doc.createElement('span'); pulse.style.cssText = 'animation:cxPulse 1.5s infinite;display:inline-block;'; pulse.textContent = '📢'; title.appendChild(pulse); title.appendChild(_doc.createTextNode(res.data.title || '公告')); var close = _doc.createElement('span'); close.style.cssText = 'cursor:pointer;font-size:18px;color:#999;line-height:1;padding:0 2px;'; close.textContent = '×'; close.onclick = function(){ el.style.display = 'none'; }; head.appendChild(title); head.appendChild(close); el.appendChild(head); var contentBox = _doc.createElement('div'); contentBox.style.cssText = 'font-size:13px;color:#444;white-space:pre-wrap;overflow-wrap:anywhere;'; contentBox.textContent = res.data.content || ''; el.appendChild(contentBox); var noticeImages = Array.isArray(res.data.images) ? res.data.images.slice() : []; if (!noticeImages.length && res.data.image_url) noticeImages = [res.data.image_url]; noticeImages = noticeImages.map(function(url) { try { return new URL(String(url || ''), 'https://qsy.iano.cn/').href; } catch (_) { return ''; } }).filter(Boolean); if (noticeImages.length) { var imageBox = _doc.createElement('div'); imageBox.style.cssText = 'display:grid;gap:8px;margin-top:10px;'; noticeImages.forEach(function(url) { var img = _doc.createElement('img'); img.src = url; img.alt = '公告图片'; img.style.cssText = 'width:100%;max-height:220px;object-fit:contain;border-radius:8px;border:1px solid #e2e8f0;background:#fff;'; imageBox.appendChild(img); }); el.appendChild(imageBox); } var body = _doc.querySelector('.cx-body'); if (body && body.firstChild) { body.insertBefore(el, body.firstChild); } } } catch(e) {} } }); } catch(e) {} // 5. 绑定所有模块事件并加载配置 VideoModule.bindEvents(); AnswerModule.bindEvents(); AnswerModule.loadConfig(); // 加载视频配置到设置面板 const doc = UIPanel._doc; if (doc) { const rateSelect = doc.getElementById('cx-cfg-playback-rate'); if (rateSelect) rateSelect.value = String(videoCfg.playbackRate || 2); const autoAdvance = doc.getElementById('cx-cfg-auto-advance'); if (autoAdvance) autoAdvance.checked = videoCfg.autoAdvance !== false; const autoSkipQuiz = doc.getElementById('cx-cfg-auto-skip-quiz'); if (autoSkipQuiz) autoSkipQuiz.checked = videoCfg.autoSkipQuiz !== false; const autoAnswerQuiz = doc.getElementById('cx-cfg-auto-answer-quiz'); if (autoAnswerQuiz) autoAnswerQuiz.checked = videoCfg.autoAnswerQuiz !== false; const quizModeSelect = doc.getElementById('cx-cfg-quiz-mode'); if (quizModeSelect) { quizModeSelect.value = videoCfg.quizMode || 'free'; quizModeSelect.disabled = videoCfg.autoAnswerQuiz === false; } // 同步内联视频设置控件 const rateInline = doc.getElementById('cx-video-rate-inline'); if (rateInline) rateInline.value = String(videoCfg.playbackRate || 2); const advanceInline = doc.getElementById('cx-video-advance-inline'); if (advanceInline) advanceInline.checked = videoCfg.autoAdvance !== false; const skipQuizInline = doc.getElementById('cx-video-skip-quiz-inline'); if (skipQuizInline) skipQuizInline.checked = videoCfg.autoSkipQuiz !== false; const autoAnswerInline = doc.getElementById('cx-video-auto-answer-quiz-inline'); if (autoAnswerInline) autoAnswerInline.checked = videoCfg.autoAnswerQuiz !== false; const quizModeInline = doc.getElementById('cx-video-quiz-mode-inline'); if (quizModeInline) { quizModeInline.value = videoCfg.quizMode || 'free'; quizModeInline.disabled = videoCfg.autoAnswerQuiz === false; } } // 6. 增强视频模块的 start/stop 以集成运行状态管理 (12.2) const _origVideoStart = VideoModule.start.bind(VideoModule); const _origVideoStop = VideoModule.stop.bind(VideoModule); VideoModule.start = function() { _origVideoStart(); if (VideoModule._playing) { RunStateManager.startModule('video'); } }; VideoModule.stop = function() { _origVideoStop(); ConfigStore.clearRunState(); }; // 增强答题模块的 startHomework/startExam/stopAnswering const _origStartHomework = AnswerModule.startHomework.bind(AnswerModule); const _origStartExam = AnswerModule.startExam.bind(AnswerModule); const _origStopAnswering = AnswerModule.stopAnswering.bind(AnswerModule); AnswerModule.startHomework = async function() { await _origStartHomework(); if (AnswerModule._answering) { RunStateManager.startModule('answer'); } }; AnswerModule.startExam = async function() { await _origStartExam(); if (AnswerModule._answering) { RunStateManager.startModule('answer'); } }; AnswerModule.stopAnswering = function() { _origStopAnswering(); ConfigStore.clearRunState(); UIPanel.setRunning(false); UIPanel.setStatus('已停止', 'info'); }; // 7. 定期更新运行状态的 lastActiveTime(每30秒) setInterval(() => RunStateManager.keepAlive(), 30000); // 8. 检查保存的运行状态,决定是否自动恢复 RunStateManager.tryAutoResume(); const elapsed = Date.now() - initStart; Logger.log(`初始化完成,耗时 ${elapsed}ms`, 'success'); } catch (err) { console.error('[学习通刷课助手] 初始化错误:', err); Logger.log('初始化失败: ' + (err.message || String(err)), 'error'); } } // 启动初始化 initScript(); })();