// ==UserScript== // @name 雨课堂刷课助手 v2.7.9 // @namespace http://tampermonkey.net/ // @version 2.7.9 // @description 针对雨课堂视频进行自动播放,配置题库自动答题 // @author 叶屿 // @license GPL3 // @antifeature payment 题库答题功能需要验证码(免费)或激活码(付费),视频播放等基础功能完全免费 // @match *://yuketang.cn/* // @match *://*.yuketang.cn/* // @match *://gdufemooc.cn/* // @match *://*.gdufemooc.cn/* // @match *://xuetangx.com/* // @match *://*.xuetangx.com/* // @include /^https?:\/\/(?:[^\/]+\.)?(?:yuketang\.cn|gdufemooc\.cn|xuetangx\.com)\/.*$/ // @run-at document-start // @icon http://yuketang.cn/favicon.ico // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect qsy.iano.cn // @connect open.bigmodel.cn // @connect ark.cn-beijing.volces.com // @connect api.deepseek.com // @connect * // @connect yuketang.cn // @connect *.yuketang.cn // @connect gdufemooc.cn // @connect *.gdufemooc.cn // @connect xuetangx.com // @connect *.xuetangx.com // @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js // @require https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js // @require https://cdn.jsdelivr.net/npm/opentype.js@1.3.4/dist/opentype.min.js // ==/UserScript== /* * ========================================== * 雨课堂刷课助手 v2.7.9 * ========================================== * * 【功能说明】 * 1. 视频自动播放(支持倍速、静音、防暂停) * 2. 作业自动答题(OCR识别 + 题库查询) * 3. 考试自动答题(支持停止/继续) * 4. 双模式题库(免费验证码 / 付费激活码) * * 【积分购买】 * 联系微信:C919irt * 价格表:50积分=2元,100积分=4元,150积分=6元,200积分=8元,500积分=18元 * 说明:每次答题消耗1积分,积分永久有效 * * 【付费声明】 * 本脚本基础功能(视频播放、进度保存)完全免费 * 题库答题功能需要验证码(免费24小时)或激活码(付费永久) * 付费仅用于题库API调用成本,不强制购买 * * 【免责声明】 * 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为 * 使用本脚本造成的任何后果由使用者自行承担 * * 【版权信息】 * 作者:叶屿 | 版本:v2.7.9 | 更新:2026-10-13 * * ========================================== */ (() => { 'use strict'; let panel; // UI 面板实例后置初始化 let isRunning = false; // 标记是否正在运行 let stopRequested = false; // 标记是否请求停止 // 当前受控任务的 Promise。仅依靠 isRunning 无法防止旧 runner 在异步收尾 // 时被再次启动,因此生命周期判断统一以此引用为准。 let activeRunPromise = null; let AI_PROVIDERS = {}; // ---- 脚本配置,用户可修改 ---- const Config = { version: '2.7.9', // 版本号 playbackRate: 2, // 视频播放倍速 pptInterval: 3000, // ppt翻页间隔 safety: { actionDelayMin: 450, actionDelayMax: 1600, answerDelayMin: 2800, answerDelayMax: 6200, aiUnlockedDelayMin: 2600, aiUnlockedDelayMax: 5200, aiLockedDelayMin: 35000, aiLockedDelayMax: 43000, aiSettleDelayMin: 1200, aiSettleDelayMax: 2600, breakEvery: 8, breakMin: 12000, breakMax: 28000, slideJitter: 1800 }, storageKeys: { // 使用者勿动 progress: '[雨课堂脚本]刷课进度信息', deviceId: 'ykt_device_id', activationCode: 'ykt_activation_code', answerMode: 'ykt_answer_mode', // 答题模式:free/paid verifyValidUntil: 'ykt_verify_valid_until', // 验证码有效期 verifyProof: 'ykt_verify_proof', verifySessionToken: 'ykt_verify_session_token', proClassCount: 'pro_lms_classCount', feature: 'ykt_feature_conf', // 是否开启题库作答/自动评论 runState: 'ykt_run_state', aiApiKey: 'ykt_ai_api_key', aiProvider: 'ykt_ai_provider', aiCustomUrl: 'ykt_ai_custom_url', aiCustomModel: 'ykt_ai_custom_model', aiUnlockCode: 'ykt_ai_unlock_code', aiUnlockUntil: 'ykt_ai_unlock_until', aiUnlockProof: 'ykt_ai_unlock_proof', aiUnlockSessionToken: 'ykt_ai_unlock_session_token', aiLastCallAt: 'ykt_ai_last_call_at', playbackRate: 'ykt_playback_rate', unmatchedFallback: 'ykt_unmatched_fallback', answerProgress: 'ykt_answer_progress', studyPurposeConfirmed: 'ykt_study_purpose_confirmed' } }; // 授权 proof 的签名包含脚本版本。升级脚本时仍兼容最近版本生成的 // proof,避免用户仅因更新脚本就被迫重新验证;新 proof 始终使用 // Config.version 签名。 const LEGACY_PROOF_VERSIONS = Object.freeze(['2.7.8', '2.7.7', '2.7.6', '2.7.5', '2.7.4']); function getRainHostFamily(hostname = location.hostname) { const host = String(hostname || '').toLowerCase(); if (/(^|\.)yuketang\.cn$/.test(host)) return 'yuketang.cn'; if (/(^|\.)gdufemooc\.cn$/.test(host)) return 'gdufemooc.cn'; if (/(^|\.)xuetangx\.com$/.test(host)) return 'xuetangx.com'; return ''; } function isRainClassroomHost(hostname = location.hostname) { return !!getRainHostFamily(hostname); } function isV2WebPage() { return isRainClassroomHost() && /\/v2\/web(?:\/|$)/i.test(location.pathname); } function isProLmsPage() { return isRainClassroomHost() && /\/pro\/lms(?:\/|$)/i.test(location.pathname); } function isStudentCardsCoursewarePage() { const pathname = location.pathname; return isRainClassroomHost() && /\/v2\/web\/studentcards?\//i.test(pathname) && /\/(?:ppt|pdf|doc|document|office|courseware|file)(?:\/|$)/i.test(pathname); } function isYktStudentQuizPage() { return /\/v2\/web\/student(?:quiz|exam|exercise|homework)(?:\/|$)/i.test(location.pathname); } function isYktExerciseLikePage() { const host = location.host.toLowerCase(); const pathname = location.pathname.toLowerCase(); const path = pathname.split('/').filter(Boolean); return (isRainClassroomHost() && ( host.includes('exam.') || host.includes('-exam.') || path.includes('exam') || path.includes('exercise') || path.includes('homework') || path.includes('ai-workspace') || isYktStudentQuizPage() )); } const Utils = { // 短暂睡眠,等待网页加载 sleep: (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)), // 可取消等待:返回 true 表示正常等待结束,false 表示用户已请求停止。 // 保持 sleep 的旧 API 不变,只有需要响应停止的流程显式使用该方法。 sleepCancellable(ms = 1000, opts = {}) { const cancelOnStop = opts.cancelOnStop !== false; return new Promise(resolve => { if (cancelOnStop && stopRequested) { resolve(false); return; } const startedAt = Date.now(); let timer = null; let watcher = null; const finish = result => { if (timer) clearTimeout(timer); if (watcher) clearInterval(watcher); resolve(result); }; timer = setTimeout(() => finish(true), Math.max(0, Number(ms) || 0)); if (cancelOnStop) { watcher = setInterval(() => { if (stopRequested) finish(false); else if (Date.now() - startedAt >= Math.max(0, Number(ms) || 0)) finish(true); }, Math.min(250, Math.max(40, Number(ms) || 40))); } }); }, rand(min, max) { return Math.floor(min + Math.random() * Math.max(0, max - min)); }, async humanSleep(min = Config.safety.actionDelayMin, max = Config.safety.actionDelayMax) { await this.sleep(this.rand(min, max)); }, async safeClick(el, opts = {}) { if (!el || stopRequested) return false; const { scroll = true, beforeMin = Config.safety.actionDelayMin, beforeMax = Config.safety.actionDelayMax, afterMin = 350, afterMax = 1100, guard = null } = opts; const allowed = () => !stopRequested && (!guard || guard()); try { if (scroll && el.scrollIntoView) { el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); await this.humanSleep(250, 750); } if (!allowed()) return false; await this.humanSleep(beforeMin, beforeMax); if (!allowed()) return false; for (const type of ['mouseover', 'mousedown', 'mouseup']) { try { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); } catch (_) { } } if (!allowed()) return false; el.click(); await this.humanSleep(afterMin, afterMax); return !stopRequested; } catch (_) { if (!allowed()) return false; try { el.click(); return !stopRequested; } catch (e) { return false; } } }, async answerCooldown(usedAI = false, logger = null) { const isAIMode = (typeof Store !== 'undefined' && Store.getAnswerMode && Store.getAnswerMode() === 'ai') || usedAI; if (isAIMode) { const ms = this.rand(Config.safety.aiSettleDelayMin, Config.safety.aiSettleDelayMax); if (logger) logger(`🤖 AI 作答后稍等 ${Math.round(ms / 1000)} 秒`); await this.sleep(ms); return ms; } const ms = this.rand(Config.safety.answerDelayMin, Config.safety.answerDelayMax); await this.sleep(ms); return ms; }, async maybeStudyBreak(doneCount, logger = null) { if (!doneCount || doneCount % Config.safety.breakEvery !== 0) return; const ms = this.rand(Config.safety.breakMin, Config.safety.breakMax); if (logger) logger(`⏳ 稍作停顿 ${Math.round(ms / 1000)} 秒,降低连续操作频率`); await this.sleep(ms); }, collectAccessibleDocuments(rootDoc = document, maxDepth = 3) { 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; }, // 将一个 JSON 字符串解析为 JavaScript 对象 safeJSONParse(value, fallback) { try { return JSON.parse(value); } catch (_) { return fallback; } }, hashString(value) { const str = String(value || ''); 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)); }, // 每隔一段时间检查某个条件是否满足(通过 checker 函数),如果满足就成功返回;如果超时仍未满足,就失败返回 poll(checker, { interval = 1000, timeout = 20000, cancelOnStop = false } = {}) { return new Promise(resolve => { const start = Date.now(); const timer = setInterval(() => { if (cancelOnStop && stopRequested) { clearInterval(timer); resolve(false); return; } if (checker()) { clearInterval(timer); resolve(true); return; } if (Date.now() - start > timeout) { clearInterval(timer); resolve(false); } }, interval); }); }, // 使用UI课程完成度来判别是否完成课程 isProgressDone(text) { // 雨课堂会在媒体尚未播完时先显示 98%/99%(截图中的“观看覆盖率”也可能 // 只有 75%)。这些值不能作为切课条件,否则会直接跳到下一集。只有明确 // 的 100% 或完成文案才算平台进度完成。 if (!text) return false; const normalized = String(text).replace(/\s+/g, ''); if (/未(?:完成|完成学习)/.test(normalized)) return false; return /(?:^|[^\d])100(?:\.0+)?\s*[%%]/.test(normalized) || /已完成|完成学习|学习完成|播放完成|观看完成/.test(normalized); }, isTransientError(err) { const text = String(err?.message || err || ''); return /网络|超时|timeout|timed\s*out|network|failed\s*to\s*fetch|请求失败|请求超时|HTTP\s*(0|5\d\d)/i.test(text); }, // 主要是规避firefox会创建多个iframe的问题 inIframe() { return window.top !== window.self; }, // 下滑到最底部,触发课程加载 scrollToBottom(containerSelector) { const el = document.querySelector(containerSelector); if (el) el.scrollTop = el.scrollHeight; }, stableProgressKey(kind = 'page', href = location.href) { try { const url = new URL(href, location.href); let pathname = url.pathname.replace(/\/+$/, '') || '/'; if (/\/v2\/web\/studentquiz\//i.test(pathname)) { pathname = pathname.replace(/\/\d+$/i, ''); } const params = new URLSearchParams(url.search || ''); ['_', 't', 'ts', 'time', 'timestamp', 'random', 'rand', 'from', 'scene'].forEach(key => params.delete(key)); const query = Array.from(params.entries()) .sort((a, b) => (a[0] + a[1]).localeCompare(b[0] + b[1])) .map(pair => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1])}`) .join('&'); return `${kind}:${url.hostname.toLowerCase()}${pathname.toLowerCase()}${query ? '?' + query : ''}`; } catch (_) { return `${kind}:${String(href || '').split('#')[0]}`; } }, async getDDL(target = null) { const element = target?.tagName ? target : document.querySelector('video') || document.querySelector('audio'); const fallback = 180_000; if (!element) return fallback; let duration = Number(element.duration); if (!Number.isFinite(duration) || duration <= 0) { // 某些播放器永远不会派发 loadedmetadata;最多等待 5 秒,并且停止时 // 立即结束,避免外层 poll 在这里无限挂起。 await new Promise(resolve => { let settled = false; let timer = null; let watcher = null; const finish = () => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (watcher) clearInterval(watcher); try { element.removeEventListener?.('loadedmetadata', finish); } catch (_) { } resolve(); }; try { element.addEventListener?.('loadedmetadata', finish, { once: true }); } catch (_) { } timer = setTimeout(finish, 5000); watcher = setInterval(() => { if (stopRequested) finish(); }, 250); }); duration = Number(element.duration); } if (stopRequested) return 1000; const elementDurationMs = duration * 1000; return Number.isFinite(elementDurationMs) && elementDurationMs > 0 ? Math.max(elementDurationMs * 3, 10_000) : fallback; } }; // ---- 存储工具 ---- const Store = { peekProgress(url) { const raw = localStorage.getItem(Config.storageKeys.progress); const all = Utils.safeJSONParse(raw, {}) || {}; return all[url] || null; }, getProgress(url) { const raw = localStorage.getItem(Config.storageKeys.progress); const all = Utils.safeJSONParse(raw, {}) || { url: { outside: 0, inside: 0 } }; if (!all[url]) { all[url] = { outside: 0, inside: 0 }; localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all)); } return { all, current: all[url] }; }, migrateProgress(stableUrl, legacyUrl) { const stable = this.peekProgress(stableUrl); const legacy = legacyUrl ? this.peekProgress(legacyUrl) : null; const current = stable || legacy || { outside: 0, inside: 0 }; if (!stable && legacy) this.setProgress(stableUrl, legacy.outside || 0, legacy.inside || 0); return current; }, setProgress(url, outside, inside = 0) { const raw = localStorage.getItem(Config.storageKeys.progress); const all = Utils.safeJSONParse(raw, {}); all[url] = { outside, inside }; localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all)); }, removeProgress(url) { const raw = localStorage.getItem(Config.storageKeys.progress); const all = Utils.safeJSONParse(raw, {}); delete all[url]; localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all)); }, getAnswerProgress(key) { const raw = localStorage.getItem(Config.storageKeys.answerProgress); const all = Utils.safeJSONParse(raw, {}) || {}; const record = all[key]; if (!record) return { index: 0, total: 0, updatedAt: 0 }; if (record.updatedAt && Date.now() - Number(record.updatedAt) > 7 * 24 * 60 * 60 * 1000) { delete all[key]; localStorage.setItem(Config.storageKeys.answerProgress, JSON.stringify(all)); return { index: 0, total: 0, updatedAt: 0 }; } return { index: Math.max(0, Number(record.index) || 0), total: Math.max(0, Number(record.total) || 0), updatedAt: Number(record.updatedAt) || 0, confirmedThrough: Math.max(0, Number(record.confirmedThrough) || 0) }; }, setAnswerProgress(key, index, total = 0, confirmedThrough = null) { const raw = localStorage.getItem(Config.storageKeys.answerProgress); const all = Utils.safeJSONParse(raw, {}) || {}; all[key] = { index: Math.max(0, Number(index) || 0), total: Math.max(0, Number(total) || 0), updatedAt: Date.now(), confirmedThrough: Math.max(0, Number(confirmedThrough ?? all[key]?.confirmedThrough) || 0) }; localStorage.setItem(Config.storageKeys.answerProgress, JSON.stringify(all)); }, removeAnswerProgress(key) { const raw = localStorage.getItem(Config.storageKeys.answerProgress); const all = Utils.safeJSONParse(raw, {}) || {}; delete all[key]; localStorage.setItem(Config.storageKeys.answerProgress, JSON.stringify(all)); }, getDeviceId() { let deviceId = localStorage.getItem(Config.storageKeys.deviceId); if (!deviceId) { deviceId = 'ykt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); localStorage.setItem(Config.storageKeys.deviceId, deviceId); } return deviceId; }, getActivationCode() { return localStorage.getItem(Config.storageKeys.activationCode) || ''; }, setActivationCode(code) { localStorage.setItem(Config.storageKeys.activationCode, code); }, getAnswerMode() { return localStorage.getItem(Config.storageKeys.answerMode) || 'free'; }, setAnswerMode(mode) { localStorage.setItem(Config.storageKeys.answerMode, mode); }, normalizeValidUntil(timestamp) { let value = Number(timestamp); if (!Number.isFinite(value) || value <= 0) return 0; // 服务端约定 Unix 秒,兼容偶尔返回的毫秒时间戳。 if (value > 1e12) value /= 1000; return Math.floor(value); }, getVerifyValidUntil() { return this.normalizeValidUntil(localStorage.getItem(Config.storageKeys.verifyValidUntil)); }, makeAccessProof(scope, code, validUntil) { const issuedAt = Date.now(); const nonce = Math.random().toString(36).slice(2) + issuedAt.toString(36); const family = getRainHostFamily() || 'rain'; const deviceId = this.getDeviceId(); const payload = { scope, family, deviceId, validUntil: this.normalizeValidUntil(validUntil), issuedAt, nonce, ua: Utils.hashString(navigator.userAgent || ''), codeHash: Utils.hashString(String(code || '').trim()) }; const source = [ payload.scope, payload.family, payload.deviceId, payload.validUntil, payload.issuedAt, payload.nonce, payload.ua, Config.version, payload.codeHash ].join('|'); payload.sig = Utils.hashString(source); try { return btoa(JSON.stringify(payload)); } catch (_) { return ''; } }, readAccessProof(key) { const raw = localStorage.getItem(key) || ''; if (!raw) return null; try { return JSON.parse(atob(raw)); } catch (_) { return null; } }, isAccessProofValid(scope, key, validUntil) { const until = this.normalizeValidUntil(validUntil); if (until <= Date.now() / 1000) return false; const payload = this.readAccessProof(key); 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 !== (getRainHostFamily() || 'rain')) return false; if (payload.ua !== Utils.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 versions = [Config.version, ...LEGACY_PROOF_VERSIONS] .filter((version, index, list) => version && list.indexOf(version) === index); // 旧版本曾直接把服务端返回值写入签名,兼容其可能使用的毫秒值; // 新版本统一以秒值签名。 const validUntilValues = [...new Set([ String(payload.validUntil), String(this.normalizeValidUntil(payload.validUntil)) ])]; return versions.some(version => validUntilValues.some(validUntilValue => { const source = [ payload.scope, payload.family, payload.deviceId, validUntilValue, payload.issuedAt, payload.nonce, payload.ua, version, payload.codeHash ].join('|'); return payload.sig === Utils.hashString(source); })); }, clearVerifySession() { localStorage.removeItem(Config.storageKeys.verifyValidUntil); localStorage.removeItem(Config.storageKeys.verifyProof); localStorage.removeItem(Config.storageKeys.verifySessionToken); }, setVerifyValidUntil(timestamp, code = '', sessionToken = '') { let validUntil = this.normalizeValidUntil(timestamp); const oldProof = this.readAccessProof(Config.storageKeys.verifyProof); // 会话复核没有原验证码,不能只改有效期而留下旧 proof;保留 // proof 中的期限可使旧签名继续有效,同时不丢失 token。 if (!code && oldProof) { const proofUntil = this.normalizeValidUntil(oldProof.validUntil); if (proofUntil) validUntil = proofUntil; } localStorage.setItem(Config.storageKeys.verifyValidUntil, String(validUntil)); if (code) { localStorage.setItem(Config.storageKeys.verifyProof, this.makeAccessProof('free-answer', code, validUntil)); // 新验证码未返回 token 时不沿用旧会话,避免串用旧身份。 localStorage.setItem(Config.storageKeys.verifySessionToken, String(sessionToken || '')); } else if (sessionToken) { localStorage.setItem(Config.storageKeys.verifySessionToken, String(sessionToken)); } }, getVerifySessionToken() { return localStorage.getItem(Config.storageKeys.verifySessionToken) || ''; }, isVerifyValid() { const validUntil = this.getVerifyValidUntil(); const ok = this.isAccessProofValid('free-answer', Config.storageKeys.verifyProof, validUntil); if (!ok && validUntil) this.clearVerifySession(); return ok; }, getProClassCount() { const value = localStorage.getItem(Config.storageKeys.proClassCount); return value ? Number(value) : 1; }, setProClassCount(count) { localStorage.setItem(Config.storageKeys.proClassCount, count); }, getFeatureConf() { const raw = localStorage.getItem(Config.storageKeys.feature); const saved = Utils.safeJSONParse(raw, {}) || {}; const conf = { autoAI: saved.autoAI ?? false, autoComment: saved.autoComment ?? false, commentMode: saved.commentMode ?? 'copy', // 'copy' = 复制他人评论, 'ai' = AI生成评论 skipLive: saved.skipLive ?? false, }; localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf)); return conf; }, setFeatureConf(conf) { localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf)); }, getRunState() { const raw = localStorage.getItem(Config.storageKeys.runState); return Utils.safeJSONParse(raw, null); }, setRunState(status) { const state = { status, lastActiveTime: Date.now() }; localStorage.setItem(Config.storageKeys.runState, JSON.stringify(state)); }, clearRunState() { localStorage.removeItem(Config.storageKeys.runState); }, touchRunState() { const state = this.getRunState(); if (state && state.status === 'running') { state.lastActiveTime = Date.now(); localStorage.setItem(Config.storageKeys.runState, JSON.stringify(state)); } }, getAIApiKey() { return localStorage.getItem(Config.storageKeys.aiApiKey) || ''; }, setAIApiKey(key) { localStorage.setItem(Config.storageKeys.aiApiKey, key); }, getAIProvider() { const provider = localStorage.getItem(Config.storageKeys.aiProvider) || 'zhipu'; return ['zhipu', 'doubao', 'deepseek', 'deepseek_v4', 'custom'].includes(provider) ? provider : 'zhipu'; }, setAIProvider(provider) { localStorage.setItem(Config.storageKeys.aiProvider, provider); }, getAICustomUrl() { return localStorage.getItem(Config.storageKeys.aiCustomUrl) || ''; }, setAICustomUrl(url) { localStorage.setItem(Config.storageKeys.aiCustomUrl, String(url || '').trim()); }, getAICustomModel() { return localStorage.getItem(Config.storageKeys.aiCustomModel) || ''; }, setAICustomModel(model) { localStorage.setItem(Config.storageKeys.aiCustomModel, String(model || '').trim()); }, getAIUnlockCode() { return localStorage.getItem(Config.storageKeys.aiUnlockCode) || ''; }, setAIUnlockCode(code) { localStorage.setItem(Config.storageKeys.aiUnlockCode, Utils.hashString(String(code || '').trim())); }, getAIUnlockUntil() { return this.normalizeValidUntil(localStorage.getItem(Config.storageKeys.aiUnlockUntil)); }, clearAIUnlockSession() { localStorage.removeItem(Config.storageKeys.aiUnlockUntil); localStorage.removeItem(Config.storageKeys.aiUnlockProof); localStorage.removeItem(Config.storageKeys.aiUnlockCode); localStorage.removeItem(Config.storageKeys.aiUnlockSessionToken); }, setAIUnlockUntil(timestamp, code = '', sessionToken = '') { let validUntil = this.normalizeValidUntil(timestamp); const oldProof = this.readAccessProof(Config.storageKeys.aiUnlockProof); // 复核没有原解锁码,保留旧 proof 的期限与签名;同时保留旧 token。 if (!code && oldProof) { const proofUntil = this.normalizeValidUntil(oldProof.validUntil); if (proofUntil) validUntil = proofUntil; } localStorage.setItem(Config.storageKeys.aiUnlockUntil, String(validUntil)); if (code) { localStorage.setItem(Config.storageKeys.aiUnlockProof, this.makeAccessProof('ai-unlock', code, validUntil)); localStorage.setItem(Config.storageKeys.aiUnlockSessionToken, String(sessionToken || '')); } else if (sessionToken) { localStorage.setItem(Config.storageKeys.aiUnlockSessionToken, String(sessionToken)); } }, getAIUnlockSessionToken() { return localStorage.getItem(Config.storageKeys.aiUnlockSessionToken) || ''; }, isAIUnlocked() { const until = this.getAIUnlockUntil(); const ok = this.isAccessProofValid('ai-unlock', Config.storageKeys.aiUnlockProof, until); if (!ok && until) this.clearAIUnlockSession(); return ok; }, getAILastCallAt() { return Number(localStorage.getItem(Config.storageKeys.aiLastCallAt)) || 0; }, setAILastCallAt(timestamp = Date.now()) { localStorage.setItem(Config.storageKeys.aiLastCallAt, String(timestamp)); }, getPlaybackRate() { return Number(localStorage.getItem(Config.storageKeys.playbackRate)) || 2; }, setPlaybackRate(rate) { localStorage.setItem(Config.storageKeys.playbackRate, rate); Config.playbackRate = rate; }, getUnmatchedFallback() { // 题库未匹配时的兜底策略:ai(调用AI,按题型支持多选) | skip(跳过) return localStorage.getItem(Config.storageKeys.unmatchedFallback) || 'ai'; }, setUnmatchedFallback(strategy) { localStorage.setItem(Config.storageKeys.unmatchedFallback, strategy); }, isStudyPurposeConfirmed() { return localStorage.getItem(Config.storageKeys.studyPurposeConfirmed) === '1'; }, setStudyPurposeConfirmed() { localStorage.setItem(Config.storageKeys.studyPurposeConfirmed, '1'); } }; // 统一解析当前 AI 配置,供面板与答题/评论模块共同使用。 // 放在 IIFE 顶层,避免 AnswerModule 在 createPanel 作用域外调用时不可见。 function getAIProviderConfig() { const provider = Store.getAIProvider(); if (provider === 'custom') { return { key: provider, name: '自定义接口', model: Store.getAICustomModel(), url: Store.getAICustomUrl(), free: false, desc: '用户自定义的 OpenAI 兼容 Chat Completions 接口', link: '', linkText: '', keyHint: '请填写接口地址、模型名称和 API Key' }; } return { key: provider, ...(AI_PROVIDERS[provider] || AI_PROVIDERS.zhipu) }; } function createAIRequestBody(providerConf, prompt, temperature, maxTokens = 200) { const body = { model: providerConf.model, messages: [{ role: 'user', content: prompt }], temperature, max_tokens: maxTokens, stream: false }; // 官方文档:https://api-docs.deepseek.com/zh-cn/api/create-chat-completion // 新模型默认开启思考;短答案明确使用非思考模式,保留额度用于最终正文。 if (providerConf.thinking) body.thinking = { type: providerConf.thinking }; return body; } function getAIResponseText(json) { const choice = json?.choices?.[0] || {}; if (choice.finish_reason === 'length') { throw new Error('AI 输出达到长度上限,内容不完整,请调整模型或输出额度后重试'); } if (['content_filter', 'insufficient_system_resource', 'aborted', 'tool_calls'].includes(choice.finish_reason)) { throw new Error('AI 未正常生成最终文本,请稍后重试或调整模型'); } let content = choice.message?.content ?? choice.text ?? ''; if (Array.isArray(content)) { content = content.map(part => typeof part === 'string' ? part : (part?.text || '')).join(''); } return String(content || '').trim(); } const AccessGuard = { // 复核结果:true=明确有效,false=明确拒绝,null=网络/网关/格式等临时失败。 // null 不能覆盖仍在有效期内的本地 proof。 _sessionCache: new Map(), _sessionRequests: new Map(), _sessionCacheTtl: 60 * 1000, _sessionTransientTtl: 15 * 1000, _getSessionIdentity(scope, token) { const proof = scope === 'free-answer' ? localStorage.getItem(Config.storageKeys.verifyProof) || '' : localStorage.getItem(Config.storageKeys.aiUnlockProof) || ''; const validUntil = scope === 'free-answer' ? Store.getVerifyValidUntil() : Store.getAIUnlockUntil(); const deviceId = Store.getDeviceId(); return { scope, token: String(token || ''), deviceId: String(deviceId || ''), proof: String(proof || ''), validUntil: Store.normalizeValidUntil(validUntil), key: [scope, String(token || ''), String(deviceId || ''), String(proof || ''), Store.normalizeValidUntil(validUntil)].join('|') }; }, _isCurrentIdentity(identity) { if (!identity) return false; const token = identity.scope === 'free-answer' ? Store.getVerifySessionToken() : Store.getAIUnlockSessionToken(); return this._getSessionIdentity(identity.scope, token).key === identity.key; }, _cacheResult(identity, result, ttl) { if (!identity) return; const now = Date.now(); const localUntilMs = identity.validUntil > 0 ? identity.validUntil * 1000 : 0; const maxTtl = localUntilMs > now ? localUntilMs - now : ttl; this._sessionCache.set(identity.key, { result, expiresAt: now + Math.max(1000, Math.min(ttl, maxTtl || ttl)) }); if (this._sessionCache.size > 24) { const oldest = [...this._sessionCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt)[0]; if (oldest) this._sessionCache.delete(oldest[0]); } }, _readCached(identity) { const entry = identity && this._sessionCache.get(identity.key); if (!entry) return undefined; if (entry.expiresAt <= Date.now()) { this._sessionCache.delete(identity.key); return undefined; } return entry.result; }, _clearSessionCache(scope) { for (const [key] of this._sessionCache) { if (String(key).startsWith(scope + '|')) this._sessionCache.delete(key); } for (const [key] of this._sessionRequests) { if (String(key).startsWith(scope + '|')) this._sessionRequests.delete(key); } }, checkServerSession(scope, token) { const identity = this._getSessionIdentity(scope, token); // 没有 session_token 的旧版授权依赖本地 proof,不因无法复核而降级。 if (!identity.token) return Promise.resolve(null); const cached = this._readCached(identity); if (cached !== undefined) return Promise.resolve(cached); const existing = this._sessionRequests.get(identity.key); if (existing) return existing; const request = new Promise(resolve => { let settled = false; const finish = result => { if (settled) return; settled = true; // 旧请求完成时不影响已换过验证码/解锁码的新身份。 if (!this._isCurrentIdentity(identity)) { resolve(null); return; } this._cacheResult(identity, result, result === null ? this._sessionTransientTtl : this._sessionCacheTtl); resolve(result); }; try { 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(identity.deviceId), 'session_token=' + encodeURIComponent(identity.token) ].join('&'), timeout: 12000, onload: res => { // 401/403 是明确拒绝;其它非 2xx 可能是网关/临时错误,保留本地授权。 if (res.status === 401 || res.status === 403) { finish(false); return; } try { const json = JSON.parse(res.responseText); const validFlag = json.data?.valid; const sessionValid = validFlag === true || validFlag === 1 || String(validFlag).toLowerCase() === 'true' || String(validFlag) === '1'; if (res.status === 200 && (json.code === 1 || String(json.code) === '1') && sessionValid) { // 在更新有效期/proof 之前再次确认身份,避免旧请求覆盖 // 用户刚输入的新验证码或解锁码。 if (!this._isCurrentIdentity(identity)) { finish(null); return; } if (scope === 'free-answer') { Store.setVerifyValidUntil(json.data.valid_until || Store.getVerifyValidUntil()); } else { Store.setAIUnlockUntil(json.data.valid_until || Store.getAIUnlockUntil()); } finish(true); } else if (res.status === 200 && (json.code === 0 || String(json.code) === '0' || json.data?.valid === false || String(json.data?.valid) === 'false')) { finish(false); } else { finish(null); } } catch (_) { finish(null); } }, onerror: () => finish(null), ontimeout: () => finish(null) }); } catch (_) { finish(null); } }); this._sessionRequests.set(identity.key, request); request.then(() => { if (this._sessionRequests.get(identity.key) === request) this._sessionRequests.delete(identity.key); }); return request; }, async requireFreeAnswerAccess() { if (!Store.isVerifyValid()) { Store.clearVerifySession(); panel?.log?.('⚠️ 免费题库验证码未验证、已过期或本地凭据异常,请重新验证'); throw '验证码未验证或已过期'; } const identity = this._getSessionIdentity('free-answer', Store.getVerifySessionToken()); const ok = await this.checkServerSession('free-answer', identity.token); if (ok === false) { if (this._isCurrentIdentity(identity)) Store.clearVerifySession(); panel?.log?.('⚠️ 免费题库授权会话已被服务端拒绝,请重新验证验证码'); throw '验证码授权无效或已过期'; } // null 表示临时网络故障,继续使用仍有效的本地 proof。 if (!Store.isVerifyValid()) throw '验证码未验证或已过期'; return true; }, async requireAIUnlockAccess() { if (!Store.isAIUnlocked()) { Store.clearAIUnlockSession(); panel?.updateAIUnlockStatus?.(); return false; } const identity = this._getSessionIdentity('ai-unlock', Store.getAIUnlockSessionToken()); // 没有 session_token 时只依赖本地 proof + 有效期,兼容旧版解锁。 if (!identity.token) return true; const handleRejected = ok => { if (ok !== false || !this._isCurrentIdentity(identity)) return; Store.clearAIUnlockSession(); panel?.log?.('⚠️ AI解锁会话已被服务端拒绝,请重新输入解锁码'); panel?.updateAIUnlockStatus?.(); }; const cached = this._readCached(identity); if (cached === false) { handleRejected(false); return false; } else if (cached !== true) { // 远端复核后台进行,避免题间每次等待 12 秒;临时失败只短暂退避。 this.checkServerSession('ai-unlock', identity.token).then(handleRejected); } return true; }, async getAIThrottleWindow() { const unlocked = await this.requireAIUnlockAccess(); return unlocked ? { unlocked: true, min: Config.safety.aiUnlockedDelayMin, max: Config.safety.aiUnlockedDelayMax } : { unlocked: false, min: Config.safety.aiLockedDelayMin, max: Config.safety.aiLockedDelayMax }; }, async beforeAIRequest(logger = null) { const throwIfStopped = () => { if (typeof stopRequested !== 'undefined' && stopRequested) { const error = new Error('已请求停止'); error.code = 'STOP_REQUESTED'; throw error; } }; throwIfStopped(); let throttle = await this.getAIThrottleWindow(); throwIfStopped(); // 远端复核在后台完成时,解锁可能在等待期间过期或被拒绝。 // 每轮等待结束重新读取窗口,锁定后必须按慢速间隔重新计算。 while (true) { const lastAt = Store.getAILastCallAt(); const requiredGap = Utils.rand(throttle.min, throttle.max); const waitMs = Math.max(0, lastAt + requiredGap - Date.now()); if (waitMs > 0) { if (logger) { logger(throttle.unlocked ? `🤖 AI快速模式冷却中,${Math.ceil(waitMs / 1000)} 秒后继续` : `🤖 AI防封限速中,${Math.ceil(waitMs / 1000)} 秒后继续(解锁后可加速)`); } let remaining = waitMs; while (remaining > 0) { throwIfStopped(); const slice = Math.min(remaining, 250); await new Promise(resolve => setTimeout(resolve, slice)); remaining -= slice; } throwIfStopped(); } else if (!throttle.unlocked && logger) { logger('🤖 AI防封慢速模式:未解锁时请求间隔约35-43秒'); } const latestThrottle = await this.getAIThrottleWindow(); throwIfStopped(); if (latestThrottle.unlocked !== throttle.unlocked) { throttle = latestThrottle; continue; } return latestThrottle; } }, markAIRequestDone() { Store.setAILastCallAt(Date.now()); } }; // ---- UI 面板 ---- function createPanel() { const iframe = document.createElement('iframe'); iframe.style.position = 'fixed'; iframe.style.top = '40px'; iframe.style.left = '40px'; iframe.style.width = '480px'; iframe.style.height = '520px'; iframe.style.zIndex = '999999'; iframe.style.border = '1px solid #a3a3a3'; iframe.style.borderRadius = '12px'; iframe.style.background = '#fff'; iframe.style.overflow = 'hidden'; iframe.style.boxShadow = '0 10px 40px rgba(0,0,0,0.2)'; iframe.setAttribute('frameborder', '0'); iframe.setAttribute('id', 'ykt-helper-iframe'); iframe.setAttribute('allowtransparency', 'true'); document.body.appendChild(iframe); const doc = iframe.contentDocument || iframe.contentWindow.document; doc.open(); doc.write(`
📚
雨课堂助手
💳
付费题库
需要激活码
🆓
免费题库
需要验证码
🤖
AI答题
需要API Key
扫码获取验证码
扫码观看广告获取验证码
验证后免费使用24小时
题库未匹配到答案时怎么处理。盲蒙单选 A 在多选题下"少选不得分"反而拖累正确率,所以只保留 AI 兜底(按题型返回单/多选)或跳过。AI 学习空间连续跳过太多题会让"下一题"按钮被锁,建议优先 AI。
`); doc.close(); const ui = { iframe, doc, panel: doc.getElementById('panel'), header: doc.getElementById('header'), info: doc.getElementById('info'), btnStart: doc.getElementById('btn-start'), btnClear: doc.getElementById('btn-clear'), btnSetting: doc.getElementById('btn-setting'), settings: doc.getElementById('settings'), saveSettings: doc.getElementById('save_settings'), closeSettings: doc.getElementById('close_settings'), modePaid: doc.getElementById('mode-paid'), modeFree: doc.getElementById('mode-free'), modeAI: doc.getElementById('mode-ai'), aiConfigSection: doc.getElementById('ai-config-section'), aiApiKeyInput: doc.getElementById('ai_api_key'), aiProviderSelect: doc.getElementById('ai_provider'), aiCustomConfig: doc.getElementById('ai_custom_config'), aiCustomUrlInput: doc.getElementById('ai_custom_url'), aiCustomModelInput: doc.getElementById('ai_custom_model'), unmatchedFallback: doc.getElementById('unmatched_fallback'), aiProviderInfo: doc.getElementById('ai_provider_info'), aiUnlockCode: doc.getElementById('ai_unlock_code'), aiUnlockBtn: doc.getElementById('ai_unlock_btn'), aiUnlockStatus: doc.getElementById('ai_unlock_status'), activationCodeSection: doc.getElementById('activation-code-section'), activationCodeInput: doc.getElementById('activation_code'), activateBtn: doc.getElementById('activate_btn'), activateStatus: doc.getElementById('activate_status'), creditsDisplay: doc.getElementById('credits_display'), wechatCopy: doc.getElementById('wechat_copy'), paidTrialBtn: doc.getElementById('paid-trial-btn'), paidTrialModal: doc.getElementById('paid-trial-modal'), paidTrialClose: doc.getElementById('paid-trial-close'), paidTrialDone: doc.getElementById('paid-trial-done'), feedbackWechat: doc.getElementById('feedback_wechat'), verifyCodeSection: doc.getElementById('verify-code-section'), verifyCodeInput: doc.getElementById('verify_code'), verifyBtn: doc.getElementById('verify_btn'), verifyStatus: doc.getElementById('verify_status'), featureAutoAI: doc.getElementById('feature_auto_ai'), featureAutoComment: doc.getElementById('feature_auto_comment'), commentModeCopy: doc.getElementById('comment_mode_copy'), commentModeAI: doc.getElementById('comment_mode_ai'), commentModeSection: doc.getElementById('comment_mode_section'), featureSkipLive: doc.getElementById('feature_skip_live'), playbackRate: doc.getElementById('playback_rate'), minimality: doc.getElementById('minimality'), question: doc.getElementById('question'), miniBasic: doc.getElementById('mini-basic'), miniIcon: doc.getElementById('mini-icon'), miniText: doc.getElementById('mini-text'), purposeDialog: doc.getElementById('purpose-dialog'), purposeScroll: doc.getElementById('purpose-scroll'), purposeReadHint: doc.getElementById('purpose-read-hint'), purposeCheck: doc.getElementById('purpose-check'), purposeCancel: doc.getElementById('purpose-cancel'), purposeConfirm: doc.getElementById('purpose-confirm') }; let isDragging = false; let startX = 0, startY = 0, startLeft = 0, startTop = 0; const hostWindow = (() => { try { if (window.parent && window.parent !== window && window.parent.document) return window.parent; } catch (_) { } return window; })(); const onMove = e => { if (!isDragging) return; const deltaX = e.screenX - startX; const deltaY = e.screenY - startY; const maxLeft = Math.max(0, hostWindow.innerWidth - iframe.offsetWidth); const maxTop = Math.max(0, hostWindow.innerHeight - iframe.offsetHeight); iframe.style.left = Math.min(Math.max(0, startLeft + deltaX), maxLeft) + 'px'; iframe.style.top = Math.min(Math.max(0, startTop + deltaY), maxTop) + 'px'; }; const stopDrag = () => { if (!isDragging) return; isDragging = false; iframe.style.transition = ''; doc.body.style.userSelect = ''; }; // 标题栏拖拽 ui.header.addEventListener('mousedown', e => { isDragging = true; startX = e.screenX; startY = e.screenY; startLeft = parseFloat(iframe.style.left) || 0; startTop = parseFloat(iframe.style.top) || 0; iframe.style.transition = 'none'; doc.body.style.userSelect = 'none'; e.preventDefault(); }); // 缩小按钮拖拽 ui.miniBasic.addEventListener('mousedown', e => { isDragging = true; startX = e.screenX; startY = e.screenY; startLeft = parseFloat(iframe.style.left) || 0; startTop = parseFloat(iframe.style.top) || 0; iframe.style.transition = 'none'; doc.body.style.userSelect = 'none'; e.preventDefault(); e.stopPropagation(); // 防止触发点击事件 }); doc.addEventListener('mousemove', onMove); hostWindow.addEventListener('mousemove', onMove); doc.addEventListener('mouseup', stopDrag); hostWindow.addEventListener('mouseup', stopDrag); hostWindow.addEventListener('blur', stopDrag); const normalSize = { width: parseFloat(iframe.style.width), height: parseFloat(iframe.style.height) }; const miniSize = 64; let isMinimized = false; const enterMini = () => { if (isMinimized) return; isMinimized = true; ui.panel.style.display = 'none'; ui.miniBasic.classList.add('show'); iframe.style.width = '80px'; iframe.style.height = '64px'; }; const exitMini = () => { if (!isMinimized) return; isMinimized = false; ui.panel.style.display = ''; ui.miniBasic.classList.remove('show'); iframe.style.width = normalSize.width + 'px'; iframe.style.height = normalSize.height + 'px'; }; ui.minimality.addEventListener('click', enterMini); ui.miniBasic.addEventListener('click', exitMini); ui.question.addEventListener('click', () => { hostWindow.alert('雨课堂助手 v2.7.9\n作者:叶屿\n\n功能说明:\n- 自动播放视频/音频\n- 题库自动答题\n- AI智能答题(智谱、豆包、DeepSeek、自定义接口)\n- AI限速解锁(验证码24h极速答题)\n- AI智能评论/自动回复\n- 自动评论回复'); }); let paidTrialReturnFocus = null; const closePaidTrialModal = (restoreFocus = true) => { if (!ui.paidTrialModal.classList.contains('is-open')) return; ui.paidTrialModal.classList.remove('is-open'); ui.paidTrialModal.setAttribute('aria-hidden', 'true'); if (restoreFocus && paidTrialReturnFocus && typeof paidTrialReturnFocus.focus === 'function') { paidTrialReturnFocus.focus(); } paidTrialReturnFocus = null; }; const openPaidTrialModal = () => { paidTrialReturnFocus = doc.activeElement; ui.paidTrialModal.classList.add('is-open'); ui.paidTrialModal.setAttribute('aria-hidden', 'false'); ui.paidTrialClose.focus(); }; ui.paidTrialBtn.addEventListener('click', openPaidTrialModal); ui.paidTrialClose.addEventListener('click', () => closePaidTrialModal()); ui.paidTrialDone.addEventListener('click', () => closePaidTrialModal()); ui.paidTrialModal.addEventListener('click', e => { if (e.target === ui.paidTrialModal) closePaidTrialModal(); }); doc.addEventListener('keydown', e => { if (e.key === 'Escape' && ui.paidTrialModal.classList.contains('is-open')) { e.preventDefault(); closePaidTrialModal(); } }); const log = message => { const li = doc.createElement('li'); li.innerText = message; ui.info.appendChild(li); // 限制最多 100 条日志,防止课程内容多时爆内存 while (ui.info.children.length > 100) { ui.info.removeChild(ui.info.firstChild); } if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); }; let purposeDialogResolver = null; const updatePurposeConfirmState = () => { ui.purposeConfirm.disabled = ui.purposeCheck.disabled || !ui.purposeCheck.checked; }; const markPurposeReadIfScrolled = () => { const bottomReached = ui.purposeScroll.scrollTop + ui.purposeScroll.clientHeight >= ui.purposeScroll.scrollHeight - 4; if (bottomReached) { ui.purposeCheck.disabled = false; ui.purposeReadHint.textContent = '已阅读到末尾,可以勾选确认。'; } updatePurposeConfirmState(); }; const closePurposeDialog = confirmed => { ui.purposeDialog.style.display = 'none'; if (purposeDialogResolver) { const resolve = purposeDialogResolver; purposeDialogResolver = null; resolve(confirmed); } }; const showStudyPurposeDialog = () => { if (purposeDialogResolver) return new Promise(resolve => { const previous = purposeDialogResolver; purposeDialogResolver = confirmed => { previous(confirmed); resolve(confirmed); }; }); return new Promise(resolve => { purposeDialogResolver = resolve; ui.purposeCheck.checked = false; ui.purposeCheck.disabled = true; ui.purposeConfirm.disabled = true; ui.purposeReadHint.textContent = '请先阅读到末尾,再勾选确认。'; ui.purposeDialog.style.display = 'flex'; ui.purposeScroll.scrollTop = 0; setTimeout(() => { markPurposeReadIfScrolled(); ui.purposeScroll.focus(); }, 0); }); }; ui.purposeScroll.addEventListener('scroll', markPurposeReadIfScrolled); ui.purposeCheck.addEventListener('change', updatePurposeConfirmState); ui.purposeCancel.addEventListener('click', () => closePurposeDialog(false)); ui.purposeConfirm.addEventListener('click', () => { if (!ui.purposeConfirm.disabled) closePurposeDialog(true); }); doc.addEventListener('keydown', e => { if (e.key === 'Escape' && ui.purposeDialog.style.display === 'flex') closePurposeDialog(false); }); // 查询积分 const queryCredits = async () => { const deviceId = Store.getDeviceId(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://qsy.iano.cn/index.php?s=/api/question_bank/credits&device_id=${deviceId}`, timeout: 15000, onload: res => { if (res.status === 200) { try { const json = JSON.parse(res.responseText); if (json.code === 1) { resolve(json.data); } else { reject(json.msg || '查询失败'); } } catch (e) { reject('JSON 解析失败'); } } else { reject(`请求失败: HTTP ${res.status}`); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时') }); }); }; // 更新积分显示 const updateCreditsDisplay = async () => { try { const data = await queryCredits(); ui.creditsDisplay.textContent = `${data.remaining_credits} 积分`; } catch (err) { ui.creditsDisplay.textContent = '-- 积分'; } }; // 验证验证码 const verifyCode = async (code, scope = 'free-answer') => { 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(scope), 'device_id=' + encodeURIComponent(Store.getDeviceId()) ].join('&'), timeout: 15000, onload: res => { if (res.status === 200) { try { const json = JSON.parse(res.responseText); if (json.code === 1 && json.data.valid) { resolve(json); } else { reject(json.msg || '验证码无效或已过期'); } } catch (e) { reject('JSON 解析失败'); } } else { reject(`请求失败: HTTP ${res.status}`); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时') }); }); }; // 激活题库激活码 const activateCode = async (code, deviceId) => { return new Promise((resolve, reject) => { 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: deviceId }), timeout: 15000, onload: res => { if (res.status === 200) { try { const json = JSON.parse(res.responseText); if (json.code === 1) { resolve(json); } else { reject(json.msg || '激活失败'); } } catch (e) { reject('JSON 解析失败'); } } else { reject(`请求失败: HTTP ${res.status}`); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时') }); }); }; // 切换答题模式 const switchAnswerMode = (mode) => { Store.setAnswerMode(mode); ui.modePaid.classList.remove('selected'); ui.modeFree.classList.remove('selected'); ui.modeAI.classList.remove('selected'); ui.activationCodeSection.style.display = 'none'; ui.verifyCodeSection.style.display = 'none'; ui.aiConfigSection.style.display = 'none'; if (mode === 'paid') { ui.modePaid.classList.add('selected'); ui.activationCodeSection.style.display = 'block'; updateCreditsDisplay(); } else if (mode === 'ai') { ui.modeAI.classList.add('selected'); ui.aiConfigSection.style.display = 'block'; ui.aiApiKeyInput.value = Store.getAIApiKey(); ui.aiProviderSelect.value = Store.getAIProvider(); if (ui.aiCustomUrlInput) ui.aiCustomUrlInput.value = Store.getAICustomUrl(); if (ui.aiCustomModelInput) ui.aiCustomModelInput.value = Store.getAICustomModel(); updateAIProviderInfo(); updateAIUnlockStatus(); } else { ui.modeFree.classList.add('selected'); ui.verifyCodeSection.style.display = 'block'; } }; // 绑定答题模式切换事件 ui.modePaid.addEventListener('click', () => switchAnswerMode('paid')); ui.modeFree.addEventListener('click', () => switchAnswerMode('free')); ui.modeAI.addEventListener('click', () => switchAnswerMode('ai')); // 绑定微信号复制 ui.wechatCopy.addEventListener('click', () => { const wechat = 'C919irt'; const textarea = doc.createElement('textarea'); textarea.value = wechat; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; doc.body.appendChild(textarea); textarea.select(); try { doc.execCommand('copy'); ui.wechatCopy.textContent = '✅ 已复制'; setTimeout(() => { ui.wechatCopy.textContent = 'C919irt 📋'; }, 2000); } catch (err) { console.error('复制失败', err); } doc.body.removeChild(textarea); }); // 绑定反馈微信号复制 ui.feedbackWechat.addEventListener('click', () => { const wechat = 'C919irt'; const textarea = doc.createElement('textarea'); textarea.value = wechat; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; doc.body.appendChild(textarea); textarea.select(); try { doc.execCommand('copy'); ui.feedbackWechat.textContent = '✅ 已复制'; setTimeout(() => { ui.feedbackWechat.textContent = 'C919irt 📋'; }, 2000); } catch (err) { console.error('复制失败', err); } doc.body.removeChild(textarea); }); // 绑定激活按钮 ui.activateBtn.addEventListener('click', async () => { const code = ui.activationCodeInput.value.trim(); if (!code) { ui.activateStatus.className = 'verify-status error'; ui.activateStatus.textContent = '❌ 请输入激活码'; ui.activateStatus.style.display = 'block'; return; } ui.activateBtn.disabled = true; ui.activateBtn.textContent = '激活中...'; ui.activateStatus.style.display = 'none'; try { const deviceId = Store.getDeviceId(); const result = await activateCode(code, deviceId); Store.setActivationCode(code); ui.activateStatus.className = 'verify-status success'; ui.activateStatus.textContent = `✅ 激活成功!获得 ${result.data.credits} 积分`; ui.activateStatus.style.display = 'block'; ui.activationCodeInput.value = ''; await updateCreditsDisplay(); // 更新积分显示 log(`✅ 激活成功!获得 ${result.data.credits} 积分`); } catch (err) { ui.activateStatus.className = 'verify-status error'; ui.activateStatus.textContent = `❌ ${err}`; ui.activateStatus.style.display = 'block'; } finally { ui.activateBtn.disabled = false; ui.activateBtn.textContent = '激活'; } }); // 绑定验证码验证按钮 ui.verifyBtn.addEventListener('click', async () => { const vcode = ui.verifyCodeInput.value.trim(); if (!vcode || vcode.length !== 4) { ui.verifyStatus.className = 'verify-status error'; ui.verifyStatus.textContent = '❌ 请输入4位验证码'; ui.verifyStatus.style.display = 'block'; return; } ui.verifyBtn.disabled = true; ui.verifyBtn.textContent = '验证中...'; ui.verifyStatus.style.display = 'none'; try { const result = await verifyCode(vcode, 'free-answer'); Store.setVerifyValidUntil(result.data.valid_until, vcode, result.data.session_token || ''); ui.verifyStatus.className = 'verify-status success'; ui.verifyStatus.textContent = `✅ 验证成功!有效期至 ${result.data.valid_until_str}`; ui.verifyStatus.style.display = 'block'; ui.verifyCodeInput.value = ''; ui.verifyCodeInput.disabled = true; log(`✅ 验证码验证成功!有效期至 ${result.data.valid_until_str}`); } catch (err) { ui.verifyStatus.className = 'verify-status error'; ui.verifyStatus.textContent = `❌ ${err}`; ui.verifyStatus.style.display = 'block'; } finally { ui.verifyBtn.disabled = false; ui.verifyBtn.textContent = '验证'; } }); // 支持回车键验证 ui.verifyCodeInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { ui.verifyBtn.click(); } }); // ---- AI多模型 Provider 信息配置 ---- // 仅保留内置的 DeepSeek、豆包、智谱,另提供一个 OpenAI 兼容自定义接口。 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/', linkText: '前往智谱 AI 开放平台注册/登录获取 API Key →', keyHint: '进入控制台 → API Keys → 创建 API Key → 复制' }, doubao: { name: '豆包 Doubao-lite', 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 → 复制' }, deepseek: { name: 'DeepSeek Flash(V4.1)', model: 'deepseek-flash', url: 'https://api.deepseek.com/chat/completions', thinking: 'disabled', answerMaxTokens: 2048, commentMaxTokens: 512, timeout: 60000, free: false, desc: 'DeepSeek 官方 Flash 模型,当前为 V4.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', thinking: 'disabled', answerMaxTokens: 2048, commentMaxTokens: 512, timeout: 60000, free: false, desc: 'DeepSeek 官方 Pro 模型。使用非思考模式直接生成答案,与 Flash 共用 API Key,费用以官方定价为准。', link: 'https://platform.deepseek.com/', linkText: '前往 DeepSeek 开放平台注册/登录获取 API Key →', keyHint: '与 Flash 使用同一平台 API Key,可直接切换' } }; // 更新AI Provider信息显示 const updateAIProviderInfo = () => { const provider = ui.aiProviderSelect.value; const info = AI_PROVIDERS[provider]; if (provider === 'custom') { ui.aiProviderInfo.innerHTML = '
自定义 OpenAI 兼容接口,可接入其他模型服务。自定义
💡 请填写接口地址、模型名称和下方 API Key。
'; if (ui.aiCustomConfig) ui.aiCustomConfig.style.display = 'block'; return; } if (ui.aiCustomConfig) ui.aiCustomConfig.style.display = 'none'; if (!info) return; const freeTag = info.free ? '免费' : '按量计费'; ui.aiProviderInfo.innerHTML = `
${info.desc} ${freeTag}
🔗 ${info.linkText}
💡 操作:${info.keyHint}
`; }; let aiUnlockUiState = null; // 更新AI解锁状态显示 const updateAIUnlockStatus = () => { const unlocked = Store.isAIUnlocked(); aiUnlockUiState = unlocked; if (unlocked) { const until = Store.getAIUnlockUntil(); const date = new Date(until * 1000); const dateStr = date.toLocaleString('zh-CN'); ui.aiUnlockStatus.innerHTML = `✅ 已解锁,有效期至 ${dateStr}(AI请求间隔≈2.6-5.2秒)`; ui.aiUnlockCode.disabled = true; ui.aiUnlockBtn.disabled = true; } else { ui.aiUnlockStatus.innerHTML = `⏳ 未解锁,当前AI请求间隔35-43秒`; ui.aiUnlockCode.disabled = false; ui.aiUnlockBtn.disabled = false; } }; // AI Provider 切换事件 ui.aiProviderSelect.addEventListener('change', () => { Store.setAIProvider(ui.aiProviderSelect.value); updateAIProviderInfo(); }); // AI 解锁按钮事件 ui.aiUnlockBtn.addEventListener('click', async () => { const code = ui.aiUnlockCode.value.trim(); if (!code || code.length !== 4) { ui.aiUnlockStatus.innerHTML = '❌ 请输入4位解锁码'; return; } ui.aiUnlockBtn.disabled = true; ui.aiUnlockBtn.textContent = '验证中...'; try { const result = await verifyCode(code, 'ai-unlock'); Store.setAIUnlockCode(code); Store.setAIUnlockUntil(result.data.valid_until, code, result.data.session_token || ''); ui.aiUnlockCode.value = ''; updateAIUnlockStatus(); log(`🚀 AI限速已解锁!有效期至 ${result.data.valid_until_str}`); } catch (err) { ui.aiUnlockStatus.innerHTML = `❌ ${err}`; } finally { ui.aiUnlockBtn.disabled = false; ui.aiUnlockBtn.textContent = '解锁'; } }); // AI 解锁码回车支持 ui.aiUnlockCode.addEventListener('keypress', (e) => { if (e.key === 'Enter') ui.aiUnlockBtn.click(); }); // 检查验证码状态 const checkVerifyStatus = () => { if (Store.isVerifyValid()) { const validUntil = Store.getVerifyValidUntil(); const date = new Date(validUntil * 1000); const dateStr = date.toLocaleString('zh-CN'); ui.verifyStatus.className = 'verify-status success'; ui.verifyStatus.textContent = `✅ 已验证,有效期至 ${dateStr}`; ui.verifyCodeInput.disabled = true; ui.verifyBtn.disabled = true; } else { ui.verifyCodeInput.disabled = false; ui.verifyBtn.disabled = false; ui.verifyStatus.style.display = 'none'; } }; const loadAnswerMode = () => { const mode = Store.getAnswerMode(); switchAnswerMode(mode); if (mode === 'free') { checkVerifyStatus(); } else { updateCreditsDisplay(); // 付费模式加载时查询积分 } }; const loadActivationCode = () => { ui.activationCodeInput.value = Store.getActivationCode(); }; const loadFeatureConf = () => { const saved = Store.getFeatureConf(); ui.featureAutoAI.checked = saved.autoAI; ui.featureAutoComment.checked = saved.autoComment; ui.featureSkipLive.checked = saved.skipLive; // 加载评论模式 if (saved.autoComment) { ui.commentModeSection.style.display = 'block'; } else { ui.commentModeSection.style.display = 'none'; } const commentMode = saved.commentMode || 'copy'; if (ui.commentModeCopy) ui.commentModeCopy.checked = (commentMode === 'copy'); if (ui.commentModeAI) ui.commentModeAI.checked = (commentMode === 'ai'); // 更新AI提示 const aiHint = doc.getElementById('ai_comment_hint'); if (aiHint) aiHint.style.display = (commentMode === 'ai') ? 'block' : 'none'; }; // 评论复选框切换 → 显示/隐藏回复方式选项 ui.featureAutoComment.addEventListener('change', () => { ui.commentModeSection.style.display = ui.featureAutoComment.checked ? 'block' : 'none'; }); // 评论模式单选切换 → 显示/隐藏AI提示 const commentModeRadios = doc.querySelectorAll('input[name="comment_mode"]'); commentModeRadios.forEach(radio => { radio.addEventListener('change', () => { const aiHint = doc.getElementById('ai_comment_hint'); if (aiHint) aiHint.style.display = (radio.value === 'ai' && radio.checked) ? 'block' : 'none'; }); }); loadAnswerMode(); loadActivationCode(); loadFeatureConf(); // 有效期可能在面板打开期间自然到期;定时刷新只在状态变化时 // 重绘,避免干扰用户正在输入解锁码或等待验证的界面。 const aiUnlockStatusTimer = setInterval(() => { const unlocked = Store.isAIUnlocked(); if (unlocked !== aiUnlockUiState) updateAIUnlockStatus(); }, 1000); ui.btnSetting.onclick = () => { loadAnswerMode(); loadActivationCode(); loadFeatureConf(); ui.playbackRate.value = String(Store.getPlaybackRate()); if (ui.unmatchedFallback) ui.unmatchedFallback.value = Store.getUnmatchedFallback(); ui.settings.style.display = 'block'; }; ui.closeSettings.onclick = () => { ui.settings.style.display = 'none'; }; ui.saveSettings.onclick = async () => { const mode = Store.getAnswerMode(); // 保存兜底策略 if (ui.unmatchedFallback) Store.setUnmatchedFallback(ui.unmatchedFallback.value); // 保存播放倍速 const rate = parseFloat(ui.playbackRate.value) || 2; Store.setPlaybackRate(rate); // 设置面板可能在视频播放期间打开;保存后立即应用到当前页面及同源 // iframe 的媒体节点,播放器后续重建/播放时由 lockRate 继续保持。 Player.applySpeed(document); // 保存功能配置(含评论模式) const commentMode = (ui.commentModeAI && ui.commentModeAI.checked) ? 'ai' : 'copy'; const featureConf = { autoAI: ui.featureAutoAI.checked, autoComment: ui.featureAutoComment.checked, commentMode: commentMode, skipLive: ui.featureSkipLive.checked }; Store.setFeatureConf(featureConf); // 保存AI API Key if (Store.getAnswerMode() === 'ai') { Store.setAIApiKey(ui.aiApiKeyInput.value.trim()); if (ui.aiCustomUrlInput) Store.setAICustomUrl(ui.aiCustomUrlInput.value.trim()); if (ui.aiCustomModelInput) Store.setAICustomModel(ui.aiCustomModelInput.value.trim()); } // 检查激活状态并给出提示 if (featureConf.autoAI) { if (mode === 'paid') { try { const data = await queryCredits(); if (data.remaining_credits <= 0) { log('⚠️ 积分不足,请先购买激活码充值'); } else { log(`✅ 题库配置已保存 (付费模式,剩余 ${data.remaining_credits} 积分)`); } } catch (err) { log('⚠️ 题库配置已保存,但未检测到激活码,请先激活'); } } else if (mode === 'ai') { const customConfigMissing = Store.getAIProvider() === 'custom' && (!Store.getAICustomUrl() || !Store.getAICustomModel()); if (!Store.getAIApiKey()) { log('⚠️ 题库配置已保存,但未填写AI API Key'); } else if (customConfigMissing) { log('⚠️ 题库配置已保存,但自定义接口地址或模型名称为空'); } else { log('✅ 题库配置已保存 (AI答题模式)'); } } else { // 免费模式 if (!Store.isVerifyValid()) { log('⚠️ 题库配置已保存,但验证码未验证或已过期,请先验证'); } else { log('✅ 题库配置已保存 (免费模式)'); } } } else { log('✅ 题库配置已保存'); } ui.settings.style.display = 'none'; }; ui.btnClear.onclick = () => { let currentHref = location.href; try { if (window.parent && window.parent !== window && window.parent.location?.href) { currentHref = window.parent.location.href; } } catch (_) { } Store.removeProgress(currentHref); Store.removeProgress(Utils.stableProgressKey('v2-course', currentHref)); Store.removeProgress(Utils.stableProgressKey('pro-course', currentHref)); Store.removeAnswerProgress(Utils.stableProgressKey('answer:generic', currentHref)); Store.removeAnswerProgress(Utils.stableProgressKey('answer:studentQuiz', currentHref)); Store.removeAnswerProgress(Utils.stableProgressKey('answer:aiWorkspace', currentHref)); Store.removeAnswerProgress(Utils.stableProgressKey('answer:v2-homework', currentHref)); localStorage.removeItem(Config.storageKeys.proClassCount); log('已清除当前页面的刷课/答题进度缓存'); }; // 获取公告 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 colors = { info: '#3b82f6', warning: '#f59e0b', error: '#ef4444', success: '#22c55e' }; var bgColors = { info: '#eff6ff', warning: '#fffbeb', error: '#fef2f2', success: '#f0fdf4' }; var color = colors[res.data.type] || '#3b82f6'; var bgColor = bgColors[res.data.type] || '#eff6ff'; var rawContent = String(res.data.content || ''); var noticeImages = []; if (Array.isArray(res.data.images)) { noticeImages = res.data.images; } else if (typeof res.data.images === 'string' && res.data.images.trim()) { try { var parsedImages = JSON.parse(res.data.images); noticeImages = Array.isArray(parsedImages) ? parsedImages : [res.data.images]; } catch (_) { noticeImages = res.data.images.split(/[\n,]+/); } } if (res.data.image_url) noticeImages.push(res.data.image_url); rawContent = rawContent.replace(/!\[[^\]]*\]\(([^)]+)\)/g, function(_, url) { noticeImages.push(url); return ''; }).trim(); var normalizeImageUrl = function(url) { url = String(url || '').trim(); if (!url) return ''; if (/^https?:\/\//i.test(url) || /^data:image\//i.test(url)) return url; if (/^\/\//.test(url)) return 'https:' + url; try { return new URL(url, 'https://qsy.iano.cn/').href; } catch (_) {} return ''; }; noticeImages = noticeImages.map(normalizeImageUrl).filter(function(url, idx, arr) { return url && arr.indexOf(url) === idx; }).slice(0, 6); var noticeEl = doc.createElement('div'); noticeEl.style.cssText = 'margin:8px 0;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.7;border:2px solid '+color+';background:linear-gradient(135deg,'+bgColor+',white);color:#333;position:relative;box-shadow:0 2px 12px rgba(0,0,0,0.08);animation:noticeSlideIn 0.5s ease;'; var styleTag = doc.createElement('style'); styleTag.textContent = '@keyframes noticeSlideIn{from{opacity:0;transform:translateY(-10px);}to{opacity:1;transform:translateY(0);}} @keyframes noticePulse{0%,100%{transform:scale(1);}50%{transform:scale(1.15);}}'; doc.head.appendChild(styleTag); var header = doc.createElement('div'); header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:10px;'; var titleEl = doc.createElement('div'); titleEl.style.cssText = 'font-weight:700;font-size:14px;color:'+color+';display:flex;align-items:center;gap:6px;min-width:0;'; var iconEl = doc.createElement('span'); iconEl.style.cssText = 'animation:noticePulse 1.5s infinite;display:inline-block;'; iconEl.textContent = '📢'; var titleText = doc.createElement('span'); titleText.textContent = res.data.title || '公告'; titleEl.appendChild(iconEl); titleEl.appendChild(titleText); var closeEl = doc.createElement('span'); closeEl.style.cssText = 'cursor:pointer;font-size:18px;color:#999;line-height:1;padding:0 2px;'; closeEl.innerHTML = '×'; header.appendChild(titleEl); header.appendChild(closeEl); noticeEl.appendChild(header); if (rawContent) { var bodyEl = doc.createElement('div'); bodyEl.style.cssText = 'font-size:13px;color:#444;white-space:pre-wrap;word-break:break-word;'; bodyEl.textContent = rawContent; noticeEl.appendChild(bodyEl); } if (noticeImages.length) { var imageBox = doc.createElement('div'); imageBox.style.cssText = 'display:flex;flex-direction:column;gap:8px;margin-top:10px;'; noticeImages.forEach(function(url) { var img = doc.createElement('img'); img.src = url; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; img.style.cssText = 'max-width:100%;max-height:260px;object-fit:contain;border-radius:8px;border:1px solid rgba(0,0,0,0.08);background:#fff;cursor:pointer;'; img.onclick = function() { window.open(url, '_blank'); }; imageBox.appendChild(img); }); noticeEl.appendChild(imageBox); } closeEl.onclick = function(){ noticeEl.style.display='none'; }; var infoEl = doc.getElementById('info'); if (infoEl && infoEl.parentNode) { infoEl.parentNode.insertBefore(noticeEl, infoEl); } } } catch(e) {} } }); } catch(e) {} // 后面赋值给panel return { ...ui, log, // 授权复核在后台完成后由 AccessGuard 调用,及时同步面板状态。 updateAIUnlockStatus, setStartHandler(fn) { ui.btnStart.onclick = async () => { // 如果正在运行,点击停止 if (isRunning || activeRunPromise) { if (!stopRequested) { stopRequested = true; log('⏸️ 正在停止...'); ui.btnStart.innerText = '停止中...'; } else { log('⏳ 上一任务仍在停止收尾,请稍候...'); } // 旧 runner 尚未退出前禁止再次启动;其 finally 会在退出后恢复按钮。 ui.btnStart.disabled = true; return; } const startText = String(ui.btnStart.innerText || ''); const isAnswerStart = isYktExerciseLikePage() || startText.includes('答题'); if (!isAnswerStart && !Store.isStudyPurposeConfirmed()) { ui.btnStart.disabled = true; const allowed = await showStudyPurposeDialog(); ui.btnStart.disabled = false; if (!allowed) { log('已取消启动:本功能只能用于课程复习和学习回看'); return; } Store.setStudyPurposeConfirmed(); } log('启动中...'); isRunning = true; stopRequested = false; // 检查是否是答题页面 if (isYktExerciseLikePage()) { ui.btnStart.innerText = '⏸️ 停止答题'; } else { ui.btnStart.innerText = '⏸️ 停止刷课'; } ui.btnStart.disabled = false; Store.setRunState('running'); startMiniStatusUpdate(panel); // 必须等待整个 runner 链路退出,避免点击停止后旧任务仍在后台继续。 try { await runManaged(fn, '手动任务'); } finally { // runManaged 已完成最终清理;这里再兜底恢复按钮,防止启动阶段异常 // 导致界面永久保持禁用。 if (!activeRunPromise && !isRunning) ui.btnStart.disabled = false; } }; }, resetStartButton(text = '开始刷课') { const stopWasRequested = stopRequested; ui.btnStart.innerText = text; ui.btnStart.disabled = false; if (text.includes('刷完') || text.includes('完成')) { Store.setRunState('completed'); } else if (text.includes('停止') || text.includes('继续')) { Store.setRunState('stopped'); } // runner 可能在 finally 前先调用 resetStartButton。此时不能释放运行锁, // 也不能清掉 stopRequested,否则用户马上点击会启动第二个任务。 if (activeRunPromise) { isRunning = true; ui.btnStart.disabled = true; stopRequested = stopWasRequested || stopRequested; return; } isRunning = false; // 没有活动任务时只保留已经请求的停止状态,下一次真正启动时再清除。 stopRequested = stopWasRequested; }, updateMiniStatus(icon, text) { if (ui.miniIcon) ui.miniIcon.innerText = icon; if (ui.miniText) ui.miniText.innerText = text; } }; } // ---- 播放器工具 ---- const Player = { _normalizeRate(rate) { const value = Number(rate); return Number.isFinite(value) && value > 0 ? Math.min(16, Math.max(0.25, value)) : 2; }, _documents(root = document) { try { return Utils.collectAccessibleDocuments(root || document); } catch (_) { return [root || document]; } }, _setMediaRate(media, rate) { if (!media) return false; const value = this._normalizeRate(rate); try { media.defaultPlaybackRate = value; } catch (_) { } try { media.playbackRate = value; } catch (_) { } return Number.isFinite(Number(media.playbackRate)) && Math.abs(Number(media.playbackRate) - value) < 0.01; }, // 雨课堂播放器会在 loadedmetadata/play 后把倍速重置回 1x。为当前媒体 // 节点绑定一个轻量守护器,在这些时机及播放期间回写用户选择的倍速。 lockRate(media, rate = Config.playbackRate) { if (!media) return () => { }; const value = this._normalizeRate(rate); const previous = media.__yktRateGuard; if (previous && previous.rate === value && typeof previous.dispose === 'function') { this._setMediaRate(media, value); return previous.dispose; } if (previous?.dispose) previous.dispose(); let disposed = false; let timer = null; const ensure = () => { if (disposed) return; if (media.isConnected === false) { dispose(); return; } const current = Number(media.playbackRate); if (!Number.isFinite(current) || Math.abs(current - value) > 0.01) this._setMediaRate(media, value); }; const events = ['loadedmetadata', 'durationchange', 'canplay', 'play', 'playing', 'ratechange']; events.forEach(type => media.addEventListener?.(type, ensure)); timer = setInterval(ensure, 600); const dispose = () => { if (disposed) return; disposed = true; if (timer) clearInterval(timer); events.forEach(type => media.removeEventListener?.(type, ensure)); if (media.__yktRateGuard?.dispose === dispose) delete media.__yktRateGuard; }; media.__yktRateGuard = { rate: value, dispose }; ensure(); return dispose; }, applySpeed(root = document, media = null) { const rate = this._normalizeRate(Config.playbackRate); const docs = this._documents(root); const medias = []; if (media) medias.push(media); for (const doc of docs) { try { medias.push(...Array.from(doc.querySelectorAll('video, audio'))); } catch (_) { } } const guardDisposers = []; [...new Set(medias)].forEach(node => { this._setMediaRate(node, rate); guardDisposers.push(this.lockRate(node, rate)); }); // 兼容旧版 xt 播放器:倍速选项只在控件菜单中存在时才触发,且每个 // iframe 都要分别处理,不能只查顶层 document。 for (const doc of docs) { try { const speedWrap = doc.getElementsByTagName('xt-speedbutton')[0]; const speedOptions = Array.from(doc.querySelectorAll('xt-speedlist xt-button')); const speedBtn = speedOptions.find(option => { const raw = option.getAttribute('data-speed') || option.getAttribute('keyt') || option.innerText || ''; const parsed = parseFloat(String(raw).replace(/x$/i, '')); return Number.isFinite(parsed) && Math.abs(parsed - rate) < 0.01; }) || speedOptions[0] || doc.getElementsByTagName('xt-speedlist')[0]?.firstElementChild?.firstElementChild; if (!speedBtn || !speedWrap) continue; speedBtn.setAttribute('data-speed', String(rate)); speedBtn.setAttribute('keyt', `${rate}.00`); speedBtn.innerText = `${rate}.00X`; const view = doc.defaultView || window; speedWrap.dispatchEvent(new view.MouseEvent('mousemove', { bubbles: true, cancelable: true })); speedBtn.click(); } catch (_) { } } return () => guardDisposers.forEach(dispose => dispose?.()); }, mute() { const muteBtn = document.querySelector('#video-box > div > xt-wrap > xt-controls > xt-inner > xt-volumebutton > xt-icon'); if (muteBtn) muteBtn.click(); const video = document.querySelector('video'); if (video) video.volume = 0; }, applyMediaDefault(media) { if (!media) return; // 平台偶尔会给媒体节点保留 loop;刷课场景必须以自然结束为终态。 media.loop = false; media.volume = 0; const disposeRate = this.applySpeed(media.ownerDocument || document, media); if (this.isFinished(media)) { disposeRate?.(); return disposeRate; } if (stopRequested) return disposeRate; const p = media.play(); if (p !== undefined) { p.catch(e => { if (e.name !== 'AbortError') console.warn('[雨课堂助手] applyMediaDefault play失败:', e); setTimeout(() => { if (!stopRequested && !this.isFinished(media)) media.play().catch(() => {}); }, 1500); }); } return disposeRate; }, isFinished(media, epsilon = 0.75) { if (!media) return false; if (media.ended) return true; const duration = Number(media.duration); const currentTime = Number(media.currentTime); return Number.isFinite(duration) && duration > 0 && Number.isFinite(currentTime) && currentTime >= duration - epsilon; }, observePause(video, opts = {}) { if (!video) return () => { }; const shouldResume = typeof opts.shouldResume === 'function' ? opts.shouldResume : () => true; const ownerDoc = video.ownerDocument || document; const target = ownerDoc.getElementsByClassName('play-btn-tip')[0]; let disposed = false; let terminal = this.isFinished(video); let isPlayPending = false; let replayTimer = null; const onEnded = () => { terminal = true; isPlayPending = false; if (replayTimer) clearTimeout(replayTimer); replayTimer = null; }; video.addEventListener('ended', onEnded); const canResume = () => !disposed && !terminal && !this.isFinished(video) && shouldResume(); // 安全播放:防止重叠的 play() 调用导致 AbortError const safePlay = (delay = 500) => { if (isPlayPending || !canResume()) return; isPlayPending = true; replayTimer = setTimeout(() => { replayTimer = null; if (!canResume()) { isPlayPending = false; return; } if (video.paused) { const p = video.play(); if (p !== undefined) { p.then(() => { isPlayPending = false; }) .catch(e => { isPlayPending = false; if (e.name !== 'AbortError') { console.warn('[雨课堂助手] 自动播放失败:', e); } if (canResume()) safePlay(1500); }); } else { isPlayPending = false; } } else { isPlayPending = false; } }, delay); }; if (!target) { return () => { disposed = true; if (replayTimer) clearTimeout(replayTimer); video.removeEventListener('ended', onEnded); }; } safePlay(1500); const observer = new MutationObserver(list => { for (const mutation of list) { if (mutation.type === 'childList' && target.innerText === '播放' && canResume()) { safePlay(800); } } }); observer.observe(target, { childList: true }); return () => { disposed = true; if (replayTimer) clearTimeout(replayTimer); replayTimer = null; observer.disconnect(); video.removeEventListener('ended', onEnded); }; }, /** * 等待媒体真正结束。 * 返回 true 表示收到了 ended/播放头到达末尾,false 表示超时、停止或媒体无效。 * 旧调用只传 media/timeout 仍然兼容;默认给没有 timeout 的课堂媒体一个有界等待。 */ waitForEnd(media, timeout = 0, opts = {}) { const cancelOnStop = opts.cancelOnStop !== false; if (!media) return Promise.resolve(false); const duration = Number(media.duration); const fallbackTimeout = Number.isFinite(duration) && duration > 0 ? Math.max(duration * 3000, 10_000) : 180_000; const deadline = Date.now() + (Number(timeout) > 0 ? Number(timeout) : fallbackTimeout); return new Promise(resolve => { if (this.isFinished(media)) return resolve(true); let timer = null; let watcher = null; let settled = false; const finish = result => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (watcher) clearInterval(watcher); try { media.removeEventListener?.('ended', onEnded); } catch (_) { } resolve(result); }; const onEnded = () => finish(true); try { media.addEventListener?.('ended', onEnded, { once: true }); } catch (_) { } timer = setTimeout(() => finish(false), Math.max(0, deadline - Date.now())); watcher = setInterval(() => { if (this.isFinished(media)) { finish(true); } else if (cancelOnStop && stopRequested) { try { media.pause?.(); } catch (_) { } finish(false); } else if (Date.now() >= deadline) { finish(false); } }, 250); }); } }; const CoursewareNavigator = { getRoots() { return Utils.collectAccessibleDocuments(document); }, isDisabled(el) { if (!el) return true; const cls = String(el.className || ''); return !!( el.disabled || el.getAttribute?.('disabled') !== null || el.getAttribute?.('aria-disabled') === 'true' || /\b(disabled|is-disabled|swiper-button-disabled)\b/i.test(cls) ); }, isVisible(el) { if (!el || !el.getBoundingClientRect) return false; const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return false; const win = el.ownerDocument?.defaultView || window; const style = win.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; }, normalizeText(el) { return (el?.innerText || el?.textContent || el?.getAttribute?.('aria-label') || el?.getAttribute?.('title') || '') .replace(/\s+/g, '') .trim(); }, getViewport(doc) { const win = doc?.defaultView || window; const root = doc?.documentElement || document.documentElement; return { width: win.innerWidth || root?.clientWidth || 1920, height: win.innerHeight || root?.clientHeight || 1080 }; }, extractSlideNumber(el) { const attrs = ['data-index', 'data-page', 'data-page-no', 'data-page-index', 'aria-label', 'title']; const values = attrs.map(name => el?.getAttribute?.(name) || '').filter(Boolean); values.push(this.normalizeText(el).slice(0, 60)); for (const raw of values) { const text = String(raw || '').replace(/\s+/g, ''); let match = text.match(/^(?:第)?(\d{1,4})(?:页)?(?:$|[^\d])/); if (!match) match = text.match(/(?:page|slide|ppt|页码|第)(\d{1,4})(?:页)?/i); if (match) { const n = Number(match[1]); if (Number.isFinite(n) && n >= 0 && n <= 9999) return n; } } return 0; }, slideKey(el, fallback = 0) { const n = this.extractSlideNumber(el); if (n) return `n:${n}`; const img = el?.querySelector?.('img,source'); const mediaSrc = img?.currentSrc || img?.src || img?.srcset || img?.getAttribute?.('src') || img?.getAttribute?.('srcset') || ''; if (mediaSrc) return `img:${mediaSrc.slice(0, 180)}`; const style = (el?.getAttribute?.('style') || '') + ' ' + (el?.querySelector?.('[style*="background-image"]')?.getAttribute?.('style') || ''); const bg = style.match(/background-image\s*:\s*[^;]+/i)?.[0] || ''; if (bg) return `bg:${bg.slice(0, 180)}`; const text = this.normalizeText(el).slice(0, 80); if (text) return `t:${text}`; const rect = el.getBoundingClientRect(); return `r:${Math.round(rect.left)}:${Math.round(rect.top)}:${Math.round(rect.width)}:${Math.round(rect.height)}:${fallback}`; }, toSlideClickTarget(node) { if (!node?.closest) return node; const selector = [ 'li', 'button', 'a', '[role="button"]', '[tabindex]', '[class*="thumb"]', '[class*="thumbnail"]', '[class*="preview"]', '[class*="slide"]', '[class*="page-item"]', '[class*="pageList"]', '[class*="outline"]', '[class*="catalog"]' ].join(','); const target = node.closest(selector); if (!target || /^(HTML|BODY)$/i.test(target.tagName || '')) return node; const rect = target.getBoundingClientRect(); const vp = this.getViewport(target.ownerDocument); const tooLarge = rect.width > Math.max(520, vp.width * 0.5) || rect.height > Math.max(360, vp.height * 0.5); return tooLarge ? node : target; }, isLikelySlideNode(node, explicit = false) { if (!node || !node.getBoundingClientRect || /^(HTML|BODY)$/i.test(node.tagName || '')) return false; if (!this.isVisible(node)) return false; const rect = node.getBoundingClientRect(); const vp = this.getViewport(node.ownerDocument); if (rect.width < 16 || rect.height < 16) return false; if (rect.width > Math.max(520, vp.width * 0.46) || rect.height > Math.max(320, vp.height * 0.42)) return false; const cls = String(node.className || ''); const role = node.getAttribute?.('role') || ''; const tag = String(node.tagName || '').toUpperCase(); const strongClassSignal = /thumb|thumbnail|preview|ppt|page-list|page_item|page-item|slide-list|outline|catalog/i.test(cls); const weakClassSignal = /slide|swiper-slide|page/i.test(cls); const numberSignal = !!this.extractSlideNumber(node); const hasMedia = !!(node.querySelector?.('img,canvas,video,svg,[style*="background-image"]') || /background-image/i.test(node.getAttribute?.('style') || '')); const clickableSignal = ['LI', 'BUTTON', 'A'].includes(tag) || role === 'button' || node.getAttribute?.('tabindex') !== null || typeof node.onclick === 'function'; const leftPanel = rect.left >= 60 && rect.left <= Math.max(460, vp.width * 0.42) && rect.top >= 60; const compact = rect.width <= Math.max(360, vp.width * 0.32) && rect.height <= Math.max(220, vp.height * 0.28); const containerSignal = !!node.closest?.('[class*="thumb"],[class*="thumbnail"],[class*="preview"],[class*="outline"],[class*="catalog"],[class*="page-list"],[class*="slide-list"],[class*="ppt"]'); if (leftPanel && compact && (hasMedia || numberSignal || strongClassSignal || clickableSignal)) return true; if (containerSignal && (hasMedia || numberSignal || strongClassSignal) && compact) return true; if (explicit && strongClassSignal && (hasMedia || numberSignal || clickableSignal || compact)) return true; if (explicit && weakClassSignal && compact && (hasMedia || numberSignal)) return true; return false; }, sortSlideNodes(nodes) { const unique = []; for (const node of nodes) { if (!node || unique.includes(node)) continue; if (unique.some(exist => exist.contains?.(node) && exist !== node)) continue; const childIndex = unique.findIndex(exist => node.contains?.(exist) && exist !== node); if (childIndex >= 0) unique[childIndex] = node; else unique.push(node); } return unique.sort((a, b) => { const na = this.extractSlideNumber(a); const nb = this.extractSlideNumber(b); if (na && nb && na !== nb) return na - nb; const ra = a.getBoundingClientRect(); const rb = b.getBoundingClientRect(); return (ra.top - rb.top) || (ra.left - rb.left); }); }, collectOutlineSlideNodes(doc) { const nodes = []; let containers = []; try { containers = Array.from(doc.querySelectorAll([ 'aside', 'nav', 'section', 'ul', 'ol', 'div', '[class*="outline"]', '[class*="catalog"]', '[class*="thumb"]', '[class*="thumbnail"]', '[class*="preview"]', '[class*="slide-list"]', '[class*="page-list"]', '[class*="ppt"]' ].join(','))).slice(0, 1500); } catch (_) { return nodes; } const vp = this.getViewport(doc); for (const container of containers) { if (!this.isVisible(container)) continue; const rect = container.getBoundingClientRect(); if (rect.width < 60 || rect.height < 60) continue; if (rect.width > Math.max(480, vp.width * 0.5) || rect.left > Math.max(520, vp.width * 0.48)) continue; const cls = String(container.className || ''); const text = this.normalizeText(container).slice(0, 120); const classSignal = /outline|catalog|thumb|thumbnail|preview|slide-list|page-list|ppt/i.test(cls); const textSignal = /大纲|目录|缩略图|幻灯片|共\d{1,4}页/i.test(text); if (!classSignal && !textSignal) continue; let children = []; try { children = Array.from(container.querySelectorAll('li,button,a,[role="button"],[tabindex],div[class],section[class]')); } catch (_) { } for (const child of children) { const target = this.toSlideClickTarget(child); if (this.isLikelySlideNode(target, false) && !nodes.includes(target)) nodes.push(target); } } return this.sortSlideNodes(nodes); }, findTotalPageHint(roots) { let total = 0; for (const doc of roots) { const text = doc.body?.innerText || ''; const patterns = [ /学习进度\s*[::]?\s*\d{1,4}\s*[\//]\s*(\d{1,4})/, /大纲\s*共\s*(\d{1,4})\s*页/, /共\s*(\d{1,4})\s*页/, /\d{1,4}\s*[\//]\s*(\d{1,4})/ ]; for (const pattern of patterns) { const match = text.match(pattern); const n = Number(match?.[1] || 0); if (Number.isFinite(n) && n > total && n <= 9999) total = n; } } return total; }, findSlideScroller(roots) { const selector = [ '[class*="outline"]', '[class*="catalog"]', '[class*="thumb"]', '[class*="thumbnail"]', '[class*="preview"]', '[class*="slide-list"]', '[class*="page-list"]', '[class*="ppt"]', 'aside', 'nav', 'ul', 'ol', 'section', 'div' ].join(','); for (const doc of roots) { let nodes = []; try { nodes = Array.from(doc.querySelectorAll(selector)).slice(0, 1500); } catch (_) { continue; } const vp = this.getViewport(doc); for (const node of nodes) { if (!this.isVisible(node)) continue; if (node.scrollHeight <= node.clientHeight + 60) continue; const rect = node.getBoundingClientRect(); if (rect.left > Math.max(520, vp.width * 0.48) || rect.width > Math.max(520, vp.width * 0.5)) continue; const cls = String(node.className || ''); const text = this.normalizeText(node).slice(0, 120); const signal = /outline|catalog|thumb|thumbnail|preview|slide-list|page-list|ppt/i.test(cls) || /大纲|目录|缩略图|幻灯片|共\d{1,4}页/i.test(text) || node.querySelector?.('img,canvas'); if (signal) return node; } } return null; }, scrollSlideList(roots) { const scroller = this.findSlideScroller(roots); if (!scroller) return false; const maxTop = scroller.scrollHeight - scroller.clientHeight; if (scroller.scrollTop >= maxTop - 6) return false; const before = scroller.scrollTop; scroller.scrollTop = Math.min(maxTop, before + Math.max(180, Math.floor(scroller.clientHeight * 0.82))); return scroller.scrollTop !== before; }, snapshot(roots) { const parts = []; for (const doc of roots) { try { const text = doc.body?.innerText || ''; const page = text.match(/(?:第\s*)?(\d{1,4})\s*(?:\/|/|页\/|页,共)\s*(\d{1,4})/); const active = doc.querySelector([ '.swiper-slide-active', '[class*="slide"][class*="active"]', '[class*="page-item"][class*="active"]', '[class*="thumbnail"][class*="active"]', '[class*="thumb"][class*="active"]', '[aria-current="page"]' ].join(',')); const activeMedia = active?.querySelector?.('img,source'); const wrapper = doc.querySelector('.swiper-wrapper,[class*="slide-track"],[class*="page-track"]'); const scrollable = this.findScrollableReader([doc]); parts.push([ page ? `${page[1]}/${page[2]}` : '', active ? [ active.getAttribute?.('data-index') || '', active.getAttribute?.('data-page') || '', active.getAttribute?.('aria-label') || '', activeMedia?.currentSrc || activeMedia?.src || '', this.normalizeText(active).slice(0, 80) ].join(':') : '', wrapper?.getAttribute?.('style') || '', scrollable ? `${scrollable.scrollTop}/${scrollable.scrollHeight}` : '' ].join('|')); } catch (_) { } } return parts.join('::'); }, findSlideNodes(roots) { const selectors = [ '.swiper-wrapper .swiper-slide', '.swiper-wrapper > *', '.ppt-outline li', '.ppt-page-list li', '.ppt-slide-list li', '.slide-list li', '.thumb-list li', '.thumbnail-list li', '[class*="outline"] li', '[class*="catalog"] li', '[class*="preview"] li', '[class*="slide"] li', '[class*="ppt"] li', '[class*="thumbnail"] li', '[class*="thumb"] li', '[class*="page-list"] li', '[class*="thumbnail"]', '[class*="thumb"]', '[aria-label*="页"]', '[title*="页"]' ]; const nodes = []; for (const doc of roots) { for (const sel of selectors) { try { for (const node of Array.from(doc.querySelectorAll(sel))) { const target = this.toSlideClickTarget(node); if (this.isLikelySlideNode(target, true) && !nodes.includes(target)) nodes.push(target); } } catch (_) { } } for (const node of this.collectOutlineSlideNodes(doc)) { if (!nodes.includes(node)) nodes.push(node); } } return this.sortSlideNodes(nodes).slice(0, 300); }, findViewerContainer(node) { if (!node?.closest) return null; return node.closest([ '.swiper-container', '.swiper', '.ppt-container', '.ppt-viewer', '.courseware-viewer', '.document-viewer', '.preview-container', '[class*="ppt-viewer"]', '[class*="courseware-viewer"]', '[class*="slide-container"]', '[class*="page-container"]', '[class*="document-viewer"]' ].join(',')); }, findNextButton(roots) { const selector = [ '.swiper-button-next', '.next-page', '.page-next', '[data-action="next-page"]', '[aria-label*="下一页"]', '[title*="下一页"]', 'button', 'a', '[role="button"]', '.el-button', '.ant-btn' ].join(','); for (const doc of roots) { let nodes = []; try { nodes = Array.from(doc.querySelectorAll(selector)); } catch (_) { continue; } const visible = nodes.filter(node => this.isVisible(node) && !this.isDisabled(node)); const byText = visible.find(node => { const text = this.normalizeText(node); const cls = String(node.className || ''); const aria = (node.getAttribute?.('aria-label') || node.getAttribute?.('title') || '').replace(/\s+/g, ''); // .btn-next 在雨课堂外层表示“下一节课程”,绝不能作为 PPT 翻页按钮。 if (/(^|\s)btn-next(?:\s|$)/i.test(cls)) return false; const viewer = this.findViewerContainer(node); const explicitText = /^(下一页|下一张|下一屏|下页)(?:[((]|$)/.test(text) || /^(下一页|下一张|下一屏|下页)(?:[((]|$)/.test(aria) || /^(next)$/i.test(text || aria); const pageClass = /swiper-button-next|next-page|page-next/i.test(cls); return !!viewer && (explicitText || pageClass); }); if (byText) return byText; } return null; }, getPagePosition(roots) { for (const doc of roots) { try { const next = this.findNextButton([doc]); const viewer = this.findViewerContainer(next); const text = viewer?.innerText || viewer?.textContent || doc.body?.innerText || ''; const patterns = [ /(?:第\s*)?(\d{1,4})\s*(?:\/|/|页\/|页,共)\s*(\d{1,4})/, /当前\s*(\d{1,4})\s*页[^\d]+共\s*(\d{1,4})\s*页/, /学习进度\s*[::]?\s*(\d{1,4})\s*[\//]\s*(\d{1,4})/ ]; for (const pattern of patterns) { const match = text.match(pattern); const current = Number(match?.[1] || 0); const total = Number(match?.[2] || 0); if (current > 0 && total >= current && total <= 9999) return { current, total }; } } catch (_) { } } return null; }, isActiveSlide(slide, roots) { if (!slide) return false; const cls = String(slide.className || ''); if (/\b(active|is-active|current|swiper-slide-active)\b/i.test(cls)) return true; if (slide.getAttribute?.('aria-current') === 'page') return true; const page = this.extractSlideNumber(slide); const position = this.getPagePosition(roots); return !!(page && position && page === position.current); }, findScrollableReader(roots) { const selectors = [ '.pdfViewer', '.pdf-viewer', '.doc-viewer', '.document-viewer', '.scroll-container', '.lms-graph-article', '.content-wrap', '.preview-container', '.viewer-container', '.ppt-container', '.swiper-container', '.el-dialog__body', '.dialog-body', '.content', 'main', 'body' ]; for (const doc of roots) { for (const sel of selectors) { let nodes = []; try { nodes = Array.from(doc.querySelectorAll(sel)); } catch (_) { continue; } for (const node of nodes) { if (!this.isVisible(node) && node.tagName !== 'BODY') continue; if (node.tagName === 'BODY') { const text = node.innerText || ''; const docSignal = node.querySelector('canvas,.page,.pdf-page,[class*="page"],[class*="pdf"],[class*="doc"],img') || /(?:第\s*)?\d{1,4}\s*(?:\/|/|页\/|页,共)\s*\d{1,4}/.test(text); if (!docSignal) continue; } if (node.scrollHeight > node.clientHeight + 80) return node; } } } return null; }, hasReader(roots = this.getRoots()) { if (this.findSlideNodes(roots).length > 1) return true; const reader = this.findScrollableReader(roots); if (reader && reader.scrollHeight > reader.clientHeight * 1.4) return true; if (this.findNextButton(roots)) return true; return false; }, async playEmbeddedMedia(panel) { if (stopRequested) return false; let roots = this.getRoots(); const videoBoxes = []; for (const doc of roots) { try { videoBoxes.push(...Array.from(doc.querySelectorAll('.video-box,[class*="video-box"],[class*="videoBox"]')) .filter(box => this.isVisible(box) && !/已完成/.test(box.innerText || ''))); } catch (_) { } } for (let i = 0; i < videoBoxes.length; i++) { if (stopRequested) return false; panel?.log?.(`课件内视频 ${i + 1}/${videoBoxes.length} 打开播放`); if (!await Utils.safeClick(videoBoxes[i])) return false; if (!await Utils.sleepCancellable(1200)) return false; } roots = this.getRoots(); const medias = []; for (const doc of roots) { try { medias.push(...Array.from(doc.querySelectorAll('video,audio'))); } catch (_) { } } for (let i = 0; i < medias.length; i++) { if (stopRequested) return false; const media = medias[i]; if (!media || media.ended) continue; panel?.log?.(`课件内媒体 ${i + 1}/${medias.length} 开始播放`); const rateGuard = Player.applyMediaDefault(media); let completed = false; try { const duration = Number(media.duration); const timeout = Number.isFinite(duration) && duration > 0 ? Math.max(duration * 3000, 10_000) : 180_000; completed = await Player.waitForEnd(media, timeout, { cancelOnStop: true }); } finally { rateGuard?.(); } if (!completed) { panel?.log?.(`⚠️ 课件内媒体 ${i + 1} 未确认播放完成,已停留当前课件`); return false; } if (!await Utils.sleepCancellable(Config.safety.actionDelayMin, { cancelOnStop: true })) return false; } if (stopRequested) return false; return true; }, async turnBySlides(panel, className, roots) { const firstSlides = this.findSlideNodes(roots); if (firstSlides.length <= 1) return false; const totalHint = this.findTotalPageHint(roots); const seen = new Set(); let visited = 0; let emptyRounds = 0; panel?.log?.(`开始翻阅课件:${className},共 ${totalHint || firstSlides.length} 页`); for (let round = 0; round < 40; round++) { if (stopRequested) return false; roots = round === 0 ? roots : this.getRoots(); const slides = this.findSlideNodes(roots); const freshSlides = []; slides.forEach((slide, idx) => { const key = this.slideKey(slide, idx); if (seen.has(key)) return; seen.add(key); freshSlides.push(slide); }); if (!freshSlides.length) { if (this.scrollSlideList(roots)) { emptyRounds++; await Utils.humanSleep(600, 1200); if (emptyRounds < 3) continue; } return totalHint ? visited >= totalHint : visited > 1 && visited >= seen.size; } emptyRounds = 0; for (const slide of freshSlides) { if (stopRequested) return false; if (this.isActiveSlide(slide, roots)) { await Utils.humanSleep(Config.pptInterval, Config.pptInterval + Config.safety.slideJitter); visited++; panel?.log?.(`${className}:第 ${visited}/${totalHint || Math.max(slides.length, visited)} 页`); if (totalHint && visited >= totalHint) return true; continue; } const before = this.snapshot(roots); const clicked = await Utils.safeClick(slide, { scroll: false, beforeMin: Config.pptInterval, beforeMax: Config.pptInterval + Config.safety.slideJitter, afterMin: 500, afterMax: 1300 }); if (!clicked) return false; const changed = await Utils.poll(() => { const freshRoots = this.getRoots(); const current = this.snapshot(freshRoots); return (!!before && !!current && current !== before) || this.isActiveSlide(slide, freshRoots); }, { interval: 250, timeout: 5000 }); if (!changed) { panel?.log?.(`${className}:缩略页点击后画面未变化,停止以免误判完成`); return false; } visited++; panel?.log?.(`${className}:第 ${visited}/${totalHint || Math.max(slides.length, visited)} 页`); if (totalHint && visited >= totalHint) return true; } roots = this.getRoots(); const scrolled = this.scrollSlideList(roots); if (!scrolled && (!totalHint || visited >= slides.length)) { return totalHint ? visited >= totalHint : visited > 1 && visited >= seen.size; } if (scrolled) await Utils.humanSleep(500, 1200); } return false; }, async turnByNextButton(panel, className, roots) { let turned = 0; const initialPosition = this.getPagePosition(roots); if (initialPosition && initialPosition.current >= initialPosition.total) return true; for (let i = 0; i < 300; i++) { if (stopRequested) return false; roots = this.getRoots(); const nextBtn = this.findNextButton(roots); if (!nextBtn) return false; const before = this.snapshot(roots); if (!before) { panel?.log?.(`${className}:无法读取当前页状态,已停止以免误点课程“下一节”`); return false; } const clicked = await Utils.safeClick(nextBtn, { scroll: false, beforeMin: Config.pptInterval, beforeMax: Config.pptInterval + Config.safety.slideJitter, afterMin: 700, afterMax: 1600 }); if (!clicked) return false; let cur = ''; const changed = await Utils.poll(() => { const freshRoots = this.getRoots(); cur = this.snapshot(freshRoots); return !!cur && cur !== before; }, { interval: 250, timeout: 5000 }); if (!changed) { panel?.log?.(`${className}:点击后页面未变化,停止以免误判完成`); return false; } turned++; panel?.log?.(`${className}:已翻到第 ${turned + 1} 页`); const freshRoots = this.getRoots(); const position = this.getPagePosition(freshRoots); if (position && position.current >= position.total) return true; if (!this.findNextButton(freshRoots)) { await Utils.sleep(800); if (!this.findNextButton(this.getRoots())) return true; } } return false; }, async scrollReader(panel, className, roots, opts = {}) { const reader = this.findScrollableReader(roots); if (!reader) return false; const cls = String(reader.className || ''); const dedicatedReader = /pdf|doc|document|viewer|preview|courseware|reader/i.test(cls); if (reader.tagName === 'BODY' && !opts.allowBody) return false; if (reader.tagName !== 'BODY' && !dedicatedReader && !opts.allowGeneric) return false; let turned = 0; let lastTop = -1; panel?.log?.(`检测到文档阅读器,开始滚动阅读:${className}`); for (let i = 0; i < 120; i++) { if (stopRequested) return false; const maxTop = reader.scrollHeight - reader.clientHeight; if (reader.scrollTop >= maxTop - 8 || reader.scrollTop === lastTop) break; lastTop = reader.scrollTop; reader.scrollTop = Math.min(maxTop, reader.scrollTop + Math.max(240, Math.floor(reader.clientHeight * 0.82))); turned++; await Utils.humanSleep(Config.pptInterval, Config.pptInterval + Config.safety.slideJitter); } if (turned > 0) panel?.log?.(`${className}:文档滚动阅读完成`); return turned > 0; }, async run(panel, className = '课件', classType = '') { await Utils.sleep(1200); let roots = this.getRoots(); const typeHint = `${classType} ${className}`; const readerLikely = /ppt|课件|文档|pdf|slide|doc|阅读|tuwen|图文/i.test(typeHint) || this.hasReader(roots); if (!readerLikely) return false; const hasSlideStructure = this.findSlideNodes(roots).length > 0 || !!this.findNextButton(roots); const scrollDocumentLike = /pdf|doc|文档|阅读|tuwen|图文/i.test(typeHint) && !/ppt|slide|幻灯/i.test(typeHint); let handled = false; const slideHandled = await this.turnBySlides(panel, className, roots); handled = slideHandled || handled; if (!slideHandled) { roots = this.getRoots(); handled = await this.turnByNextButton(panel, className, roots) || handled; } if (!handled && !hasSlideStructure && scrollDocumentLike) { roots = this.getRoots(); handled = await this.scrollReader(panel, className, roots, { allowBody: true, allowGeneric: true }) || handled; } if (handled) { const mediaHandled = await this.playEmbeddedMedia(panel); if (!mediaHandled) { panel?.log?.('⚠️ 课件内媒体未播放完整,已停留当前课件'); return false; } panel?.log?.(`${className} 翻阅完成`); } return handled; } }; // ---- 防切屏 ---- let screenCheckPrevented = false; function preventScreenCheck() { if (screenCheckPrevented) return; screenCheckPrevented = true; const win = unsafeWindow || window; const blackList = new Set(['visibilitychange', 'blur', 'pagehide']); const winAddEventListener = typeof win.addEventListener === 'function' ? win.addEventListener : null; const docAddEventListener = typeof document.addEventListener === 'function' ? document.addEventListener : null; if (winAddEventListener) { try { win.addEventListener = function(...args) { if (blackList.has(args[0])) return undefined; return winAddEventListener.apply(win, args); }; } catch (_) { } } if (docAddEventListener) { try { document.addEventListener = function(...args) { if (blackList.has(args[0])) return undefined; return docAddEventListener.apply(document, args); }; } catch (_) { } } const defineSafely = (target, property, descriptor) => { try { Object.defineProperty(target, property, descriptor); } catch (_) { } }; defineSafely(document, 'hidden', { value: false, writable: true, configurable: true }); defineSafely(document, 'visibilityState', { value: 'visible', writable: true, configurable: true }); defineSafely(document, 'hasFocus', { value: () => true, writable: true, configurable: true }); defineSafely(document, 'onvisibilitychange', { get: () => undefined, set: () => { }, configurable: true }); defineSafely(document, 'onblur', { get: () => undefined, set: () => { }, configurable: true }); defineSafely(win, 'onblur', { get: () => undefined, set: () => { }, configurable: true }); defineSafely(win, 'onpagehide', { get: () => undefined, set: () => { }, configurable: true }); } // ---- 字体反混淆 ---- let glyphHashMap = null; // SHA-1哈希 -> 原始Unicode码点 let fontCharMap = null; // 混淆字符 -> 原始字符 let glyphHashMapPromise = null; // 映射表加载Promise let deobfuscationPromise = null; // 反混淆进行中的Promise // FontFace 构造函数拦截:捕获 new FontFace(family, ArrayBuffer) 形式注入的字体 // AI学习空间的混淆字体很可能从 JS 直接以 ArrayBuffer 注入而不走 @font-face / 网络请求 const capturedFontBuffers = []; // [{ family, buffer }] function setupFontFaceHook() { try { const win = unsafeWindow; const OriginalFontFace = win.FontFace; if (!OriginalFontFace) { console.warn('[雨课堂助手] FontFace API 不可用'); return; } const HookedFontFace = new Proxy(OriginalFontFace, { construct(target, args) { try { const family = args[0]; const source = args[1]; if (source instanceof ArrayBuffer) { capturedFontBuffers.push({ family, buffer: source }); console.log('[雨课堂助手] 🎯 拦截 FontFace(ArrayBuffer):', family, 'bytes:', source.byteLength); } else if (source && ArrayBuffer.isView(source)) { const sliced = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength); capturedFontBuffers.push({ family, buffer: sliced }); console.log('[雨课堂助手] 🎯 拦截 FontFace(TypedArray):', family, 'bytes:', source.byteLength); } else if (typeof source === 'string') { const m = source.match(/url\(["\']?([^"\')]+)["\']?\)/); if (m) console.log('[雨课堂助手] 🎯 拦截 FontFace(URL):', family, m[1]); } } catch (e) { console.warn('[雨课堂助手] FontFace hook 异常:', e); } return Reflect.construct(target, args, target); } }); win.FontFace = HookedFontFace; } catch (e) { console.warn('[雨课堂助手] setupFontFaceHook 失败:', e); } } function loadGlyphHashMap() { glyphHashMapPromise = new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://qsy.iano.cn/original_glyph_to_uni_v2.json', onload: (res) => { try { glyphHashMap = JSON.parse(res.responseText); console.log('[雨课堂助手] 字形映射表加载成功, 共', Object.keys(glyphHashMap).length, '个字形'); resolve(glyphHashMap); } catch (e) { console.error('[雨课堂助手] 解析字形映射表失败:', e); reject(e); } }, onerror: (err) => { console.error('[雨课堂助手] 加载字形映射表失败:', err); reject(err); } }); }); return glyphHashMapPromise; } async function sha1Hex(str) { const data = new TextEncoder().encode(str); const hashBuffer = await crypto.subtle.digest('SHA-1', data); return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); } async function buildFontCharMapFromBuffer(buffer, label) { if (!glyphHashMap) { console.warn('[雨课堂助手] 字形映射表未加载,跳过反混淆'); return null; } try { const font = opentype.parse(buffer); const charMap = {}; let matchCount = 0; let glyphsUsable = 0; for (let i = 0; i < font.glyphs.length; i++) { const glyph = font.glyphs.get(i); if (!glyph.unicode || !glyph.path || !glyph.path.commands || glyph.path.commands.length === 0) continue; glyphsUsable++; const pathStr = glyph.path.toPathData(); if (!pathStr) continue; const hash = await sha1Hex(pathStr); const originalUnicode = glyphHashMap[hash]; if (originalUnicode !== undefined && originalUnicode !== glyph.unicode) { charMap[String.fromCodePoint(glyph.unicode)] = String.fromCodePoint(originalUnicode); matchCount++; } } console.log('[雨课堂助手] 字体反混淆映射建立完成:', matchCount, '个字符需要还原 (总字形:', font.glyphs.length, '有unicode+path:', glyphsUsable, label || '', ')'); if (matchCount > 0) fontCharMap = charMap; return charMap; } catch (e) { console.error('[雨课堂助手] 解析字体失败:', label, e); return null; } } async function buildFontCharMap(fontUrl) { if (!glyphHashMap) { console.warn('[雨课堂助手] 字形映射表未加载,跳过反混淆'); return {}; } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: fontUrl, responseType: 'arraybuffer', onload: async (res) => { try { const charMap = await buildFontCharMapFromBuffer(res.response, 'URL: ' + fontUrl); if (charMap === null) { reject(new Error('font parse failed')); return; } resolve(charMap); } catch (e) { console.error('[雨课堂助手] 解析混淆字体失败:', e); reject(e); } }, onerror: (err) => { console.error('[雨课堂助手] 下载混淆字体失败:', err); reject(err); } }); }); } // 对整段文本逐字替换(仅在拿不到 HTML 上下文时使用,例如已渲染后的 DOM innerText) function deobfuscateText(text) { if (!fontCharMap || !text) return text; return Array.from(text).map(ch => fontCharMap[ch] || ch).join(''); } // 从页面 CSS 中发现可能的混淆字体 URL(AI学习空间等场景 JSON Hook 拿不到 fontUrl 时使用) function discoverEncryptedFontUrls() { const candidates = new Set(); const isLikelyEncrypted = (text) => /encrypt(ed)?|xuetangx|yuketang|exam[_-]?data[_-]?decrypt|exam[_-]?font/i.test(text || ''); // 优先扫描 styleSheets(拿到的是已解析后的 CSSRule,比较干净) for (const sheet of Array.from(document.styleSheets || [])) { let rules; try { rules = sheet.cssRules || sheet.rules; } catch (_) { continue; /* 跨域样式表无法读取 */ } if (!rules) continue; for (const rule of Array.from(rules)) { // CSSRule.FONT_FACE_RULE === 5 if (rule.type !== 5) continue; const family = (rule.style?.getPropertyValue?.('font-family') || '').replace(/['"]/g, ''); const src = rule.style?.getPropertyValue?.('src') || ''; if (!isLikelyEncrypted(family) && !isLikelyEncrypted(src)) continue; const re = /url\(["']?([^"')]+\.(?:woff2?|ttf|otf)[^"')]*)["']?\)/gi; let m; while ((m = re.exec(src)) !== null) candidates.add(m[1]); } } // 兜底:扫