// ==UserScript== // @name 雨课堂刷课助手 // @namespace http://tampermonkey.net/ // @version 3.3.9 // @description 针对雨课堂视频进行自动播放,配置AI自动答题(基于原作者 cochle 的开源脚本修改) // @author cochle (原作者);本分支在原脚本上迭代 // @license GPL3 // @match *://*.yuketang.cn/* // @match *://*.gdufemooc.cn/* // @run-at document-start // @icon http://yuketang.cn/favicon.ico // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect api.openai.com // @connect api.moonshot.cn // @connect api.deepseek.com // @connect dashscope.aliyuncs.com // @connect api.anthropic.com // @connect * // @connect cdn.jsdelivr.net // @connect unpkg.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 // ==/UserScript== /* * 致谢: * 本脚本最初由 cochle 编写并开源(GPL3)。 * 当前文件在其基础上做了功能与文案迭代(倍速按钮、中文日志、学习空间 forum 处理等)。 * 保留原作者署名;若你再分发,请继续保留原作者信息与许可证。 */ (() => { 'use strict'; let panel; // UI 面板实例后置初始化 // ---- 脚本配置,用户可修改 ---- const Config = { version: '3.3.9', // 版本号 playbackRate: 2, // 视频播放倍速(启动时会读 localStorage) pptInterval: 3000, // ppt翻页间隔 storageKeys: { // 使用者勿动 progress: '[雨课堂脚本]刷课进度信息', ai: 'ykt_ai_conf', proClassCount: 'pro_lms_classCount', feature: 'ykt_feature_conf', // 是否开启AI作答/自动评论 pendingAutoStart: 'ykt_pending_auto_start', playbackRate: 'ykt_playback_rate' } }; // 页面/路由英文名 → 中文(面板日志用) const TypeLabel = { video: '视频', audio: '音频', exercise: '作业', graph: '知识图谱', discuss: '讨论', discussion: '讨论', forum: '讨论区', topic: '话题', shipin: '视频', zuoye: '作业', tuwen: '图文', taolun: '讨论', kejian: '课件', ketang: '课堂', piliang: '批量区', kaoshi: '考试', lms: '学习模块', leaf: '学习单元', chapter: '章节' }; const Utils = { // 短暂睡眠,等待网页加载 sleep: (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)), // 将一个 JSON 字符串解析为 JavaScript 对象 safeJSONParse(value, fallback) { try { return JSON.parse(value); } catch (_) { return fallback; } }, // 每隔一段时间检查某个条件是否满足(通过 checker 函数),如果满足就成功返回;如果超时仍未满足,就失败返回 poll(checker, { interval = 1000, timeout = 20000 } = {}) { return new Promise(resolve => { const start = Date.now(); const timer = setInterval(() => { if (checker()) { clearInterval(timer); resolve(true); return; } if (Date.now() - start > timeout) { clearInterval(timer); resolve(false); } }, interval); }); }, // 使用UI课程完成度来判别是否完成课程 isProgressDone(text) { if (!text) return false; return text.includes('100%') || text.includes('99%') || text.includes('98%') || text.includes('已完成'); }, // 主要是规避firefox会创建多个iframe的问题 inIframe() { return window.top !== window.self; }, // 下滑到最底部,触发课程加载 scrollToBottom(containerSelector) { const el = document.querySelector(containerSelector); if (el) el.scrollTop = el.scrollHeight; }, getCurrentClassroomId() { const query = new URLSearchParams(location.search); const queryId = query.get('classroom_id'); if (queryId) return queryId; const path = location.pathname; return path.match(/^\/ai-workspace\/lms-graph\/([^/]+)/)?.[1] || path.match(/^\/v2\/web\/studentLog\/([^/]+)/)?.[1] || path.match(/\/(\d+)\/studycontent$/)?.[1] || ''; }, returnUrl() { // 得到课程开始的url if (location.pathname.includes('/v2/web/studentLog/') || location.pathname.includes('pro/lms/')) { return location.href } return "" }, isSupportedLearningPage() { const path = location.pathname; return path.includes('/ai-workspace/lms-graph/') || path.includes('/v2/web/') || path.includes('/pro/lms/'); }, waitForMountTarget(timeout = 15000) { const getTarget = () => document.body || document.documentElement; const existing = getTarget(); if (existing) return Promise.resolve(existing); return new Promise(resolve => { let done = false; const finish = () => { if (done) return; done = true; observer.disconnect(); clearTimeout(timer); resolve(getTarget()); }; const observer = new MutationObserver(() => { if (getTarget()) finish(); }); observer.observe(document, { childList: true, subtree: true }); document.addEventListener('DOMContentLoaded', finish, { once: true }); window.addEventListener('load', finish, { once: true }); const timer = setTimeout(finish, timeout); }); }, async getDDL() { const element = 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) { await new Promise(resolve => element.addEventListener('loadedmetadata', resolve, { once: true })); duration = Number(element.duration); } const elementDurationMs = duration * 1000; // 转为秒 const timeout = Math.max(elementDurationMs * 3, 10_000); // 至少 10 秒(防极短视频); return timeout; }, // 关闭雨课堂的挂机/离开检测弹窗,避免遮罩拦截刷课流程 dismissPopups() { const wrappers = document.querySelectorAll('.el-dialog__wrapper, .el-message-box__wrapper'); for (const wrapper of wrappers) { const style = getComputedStyle(wrapper); const rect = wrapper.getBoundingClientRect(); if (style.display === 'none' || style.visibility === 'hidden' || rect.width === 0) continue; const text = wrapper.innerText || ''; const buttons = [...wrapper.querySelectorAll('button')]; const clickBtn = label => { const btn = buttons.find(b => (b.innerText || '').trim().includes(label)); if (btn) btn.click(); }; if (text.includes('好好学习') || text.includes('继续观看')) { clickBtn('继续观看'); } else if (text.includes('报告老师')) { clickBtn('取消'); } } } }; // ---- 存储工具 ---- const Store = { 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] }; }, 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)); }, getAIConf() { const raw = localStorage.getItem(Config.storageKeys.ai); const saved = Utils.safeJSONParse(raw, {}) || {}; const conf = { url: saved.url ?? "https://api.deepseek.com/chat/completions", key: saved.key ?? "sk-xxxxxxx", model: saved.model ?? "deepseek-flash", apiFormat: saved.apiFormat ?? "openai", // openai 或 anthropic authMethod: saved.authMethod ?? "bearer", // bearer 或 x-api-key }; localStorage.setItem(Config.storageKeys.ai, JSON.stringify(conf)); return conf; }, setAIConf(conf) { localStorage.setItem(Config.storageKeys.ai, JSON.stringify(conf)); }, 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, }; localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf)); return conf; }, setFeatureConf(conf) { localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf)); }, getPlaybackRate() { const value = Number(localStorage.getItem(Config.storageKeys.playbackRate)); return value === 1 || value === 2 ? value : 2; }, setPlaybackRate(rate) { const value = rate === 1 ? 1 : 2; localStorage.setItem(Config.storageKeys.playbackRate, String(value)); Config.playbackRate = value; return value; }, getPendingAutoStart() { const raw = localStorage.getItem(Config.storageKeys.pendingAutoStart); const saved = Utils.safeJSONParse(raw, null); if (!saved || !saved.classroomId || !saved.ts) return null; if (Date.now() - saved.ts > 30 * 60 * 1000) { localStorage.removeItem(Config.storageKeys.pendingAutoStart); return null; } return saved; }, setPendingAutoStart(classroomId = '', returnUrl = '') { if (!classroomId) return; const prev = this.getPendingAutoStart() || {}; localStorage.setItem(Config.storageKeys.pendingAutoStart, JSON.stringify({ classroomId, returnUrl: returnUrl || prev.returnUrl || '', ts: Date.now() })); }, clearPendingAutoStart() { localStorage.removeItem(Config.storageKeys.pendingAutoStart); }, }; // ---- UI 面板 ---- function createPanel() { const iframe = document.createElement('iframe'); iframe.style.position = 'fixed'; iframe.style.top = '40px'; iframe.style.left = '40px'; iframe.style.width = '520px'; iframe.style.height = '380px'; iframe.style.zIndex = '999999'; iframe.style.border = '1px solid #a3a3a3'; iframe.style.borderRadius = '10px'; iframe.style.background = '#fff'; iframe.style.overflow = 'hidden'; iframe.style.boxShadow = '6px 4px 17px 2px #000000'; iframe.setAttribute('frameborder', '0'); iframe.setAttribute('id', 'ykt-helper-iframe'); iframe.setAttribute('allowtransparency', 'true'); const mountTarget = document.body || document.documentElement; if (!mountTarget) { throw new Error('面板挂载点不存在'); } mountTarget.appendChild(iframe); const doc = iframe.contentDocument || iframe.contentWindow.document; doc.open(); doc.write(`
展开
倍速
`); 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'), btnStop: doc.getElementById('btn-stop'), btnReload: doc.getElementById('btn-reload'), settings: doc.getElementById('settings'), saveSettings: doc.getElementById('save_settings'), closeSettings: doc.getElementById('close_settings'), aiUrlInput: doc.getElementById('ai_url'), aiKeyInput: doc.getElementById('ai_key'), aiModelInput: doc.getElementById('ai_model'), aiFormatSelect: doc.getElementById('ai_format'), authMethodSelect: doc.getElementById('auth_method'), featureAutoAI: doc.getElementById('feature_auto_ai'), featureAutoComment: doc.getElementById('feature_auto_comment'), minimality: doc.getElementById('minimality'), question: doc.getElementById('question'), miniBasic: doc.getElementById('mini-basic'), btnSpeed1: doc.getElementById('btn-speed-1'), btnSpeed2: doc.getElementById('btn-speed-2') }; let isDragging = false; let startX = 0, startY = 0, startLeft = 0, startTop = 0; const hostWindow = window.parent || 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(); }); 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 = miniSize + 'px'; iframe.style.height = miniSize + 'px'; }; 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', () => { window.parent.alert('雨课堂刷课助手\n原作者:cochle(GPL3)\n本分支在其开源脚本上迭代\n请勿用于违规用途'); }); const log = message => { const li = doc.createElement('li'); li.innerText = message; ui.info.appendChild(li); if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); }; const warn = message => { const li = doc.createElement('li'); li.innerText = '⚠️警告:' + message; ui.info.appendChild(li); if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); }; const error = message => { const li = doc.createElement('li'); li.innerText = '🚨报错:' + message; ui.info.appendChild(li); if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); }; const defaultAI = { url: 'https://api.deepseek.com/chat/completions', key: 'sk-xxxxxxx', model: 'deepseek-flash', apiFormat: 'openai', authMethod: 'bearer' }; const loadAIConf = () => { const saved = Store.getAIConf(); ui.aiUrlInput.value = saved.url || defaultAI.url; ui.aiKeyInput.value = saved.key || defaultAI.key; ui.aiModelInput.value = saved.model || defaultAI.model; ui.aiFormatSelect.value = saved.apiFormat || defaultAI.apiFormat; ui.authMethodSelect.value = saved.authMethod || defaultAI.authMethod; }; const loadFeatureConf = () => { const saved = Store.getFeatureConf(); ui.featureAutoAI.checked = saved.autoAI; ui.featureAutoComment.checked = saved.autoComment; }; loadAIConf(); loadFeatureConf(); // ---- 倍速按钮:单倍 / 双倍(实时生效) ---- // ScriptCat/油猴沙箱里 instanceof 可能对不上页面元素,改用 tagName;并优先拿页面真实 document const getPageDoc = () => { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.document) { return unsafeWindow.document; } } catch (_) { } return document; }; const collectMediaInDoc = doc => { const list = []; if (!doc) return list; try { list.push(...doc.querySelectorAll('video, audio')); } catch (_) { } try { [...doc.querySelectorAll('iframe')].forEach(frame => { try { list.push(...collectMediaInDoc(frame.contentDocument)); } catch (_) { } }); } catch (_) { } return list; }; const forceMediaRate = rate => { const medias = collectMediaInDoc(getPageDoc()); let hit = 0; medias.forEach(media => { const tag = (media && media.tagName ? media.tagName : '').toUpperCase(); if (tag !== 'VIDEO' && tag !== 'AUDIO') return; try { media.defaultPlaybackRate = rate; media.playbackRate = rate; hit++; } catch (_) { } }); return hit; }; // 持续守护:播放器若把速度改回去,这里再按目标倍速点一次菜单 let rateGuardTimer = null; const startRateGuard = () => { if (rateGuardTimer) return; rateGuardTimer = setInterval(() => { const rate = Config.playbackRate; const v = document.querySelector('video'); if (!v) return; if (Math.abs(Number(v.playbackRate) - rate) < 0.05) return; try { Player.applySpeed(); } catch (_) { } try { v.playbackRate = rate; } catch (_) { } }, 800); }; const applyPlaybackRateToPage = rate => { Config.playbackRate = rate; Store.setPlaybackRate(rate); // 走播放器真实倍速菜单(li[data-speed]),不要只写 video.playbackRate try { Player.applySpeed(); } catch (_) { } forceMediaRate(rate); startRateGuard(); refreshSpeedButtons(rate, true); const sample = collectMediaInDoc(getPageDoc()).find(m => (m.tagName || '').toUpperCase() === 'VIDEO'); const nowRate = sample ? Number(sample.playbackRate) : NaN; log(`▶️ 倍速已切换为 ${rate === 1 ? '单倍' : '双倍'}(${rate}x),当前 video=${Number.isFinite(nowRate) ? nowRate + 'x' : '未找到'}`); }; const refreshSpeedButtons = (rateArg, animate = false) => { const rate = rateArg != null ? rateArg : Store.getPlaybackRate(); Config.playbackRate = rate; [ui.btnSpeed1, ui.btnSpeed2].forEach(btn => { if (!btn) return; const btnRate = Number(btn.getAttribute('data-rate')); const on = btnRate === rate; btn.classList.toggle('active', on); btn.setAttribute('aria-pressed', on ? 'true' : 'false'); if (on && animate) { btn.classList.remove('just-clicked'); void btn.offsetWidth; btn.classList.add('just-clicked'); } }); }; refreshSpeedButtons(); // 页面已有视频时也启动守护,保证默认倍速生效 startRateGuard(); ui.btnSpeed1 && (ui.btnSpeed1.onclick = () => applyPlaybackRateToPage(1)); ui.btnSpeed2 && (ui.btnSpeed2.onclick = () => applyPlaybackRateToPage(2)); ui.btnSetting.onclick = () => { loadAIConf(); loadFeatureConf(); ui.settings.style.display = 'block'; }; ui.closeSettings.onclick = () => { ui.settings.style.display = 'none'; }; ui.saveSettings.onclick = () => { const conf = { url: ui.aiUrlInput.value.trim(), key: ui.aiKeyInput.value.trim(), model: ui.aiModelInput.value.trim(), apiFormat: ui.aiFormatSelect.value, authMethod: ui.authMethodSelect.value }; Store.setAIConf(conf); const featureConf = { autoAI: ui.featureAutoAI.checked, autoComment: ui.featureAutoComment.checked }; Store.setFeatureConf(featureConf); ui.settings.style.display = 'none'; log('✅ AI 配置已保存'); }; ui.btnClear.onclick = () => { Store.removeProgress(window.parent.location.href); localStorage.removeItem(Config.storageKeys.proClassCount); Store.clearPendingAutoStart(); log('已清除当前课程的刷课进度缓存'); }; // 停止刷课:清除自动恢复标记后刷新页面,刷新后脚本回到空闲状态(进度缓存保留) ui.btnStop.onclick = () => { Store.clearPendingAutoStart(); log('已停止刷课,页面即将刷新'); window.parent.location.reload(); }; // 重新加载:重建自动恢复标记后刷新页面,刷新后自动恢复刷课(停止后点击同样生效) ui.btnReload.onclick = () => { Store.setPendingAutoStart(Utils.getCurrentClassroomId()); log('正在重新加载脚本...'); window.parent.location.reload(); }; let startHandler = null; let running = false; const invokeStart = () => { if (running) { log('已在刷课中,忽略重复启动'); return; } running = true; log('启动中...'); ui.btnStart.innerText = '刷课中...'; startHandler && startHandler(); }; // 后面赋值给panel return { ...ui, log, warn, error, setStartHandler(fn) { startHandler = fn; ui.btnStart.onclick = invokeStart; }, start() { invokeStart(); }, resetStartButton(text = '开始刷课') { ui.btnStart.innerText = text; if (text !== '刷课中...') running = false; } }; } // ---- 播放器工具 ---- const Player = { isNearEnd(media, threshold = 1) { if (!media) return false; const duration = Number(media.duration || 0); const currentTime = Number(media.currentTime || 0); return Number.isFinite(duration) && duration > 1 && currentTime > 0 && duration - currentTime <= threshold; }, applySpeed() { const rate = Config.playbackRate; const video = document.querySelector('video'); if (video && Math.abs(Number(video.playbackRate) - rate) < 0.05) return; // 雨课堂倍速菜单:xt-speedlist > li[data-speed="1|1.25|1.5|2"] const speedWrap = document.getElementsByTagName('xt-speedbutton')[0]; const items = [...document.querySelectorAll('xt-speedlist li')]; const target = items.find(li => { const s = parseFloat(li.getAttribute('data-speed')); return Number.isFinite(s) && Math.abs(s - rate) < 0.05; }); if (speedWrap && target) { const mousemove = document.createEvent('MouseEvent'); mousemove.initMouseEvent('mousemove', true, true, unsafeWindow, 0, 10, 10, 10, 10, 0, 0, 0, 0, 0, null); speedWrap.dispatchEvent(mousemove); target.click(); setTimeout(() => { const v = document.querySelector('video'); if (v) { try { v.playbackRate = rate; } catch (_) { } } }, 80); } else if (video) { video.playbackRate = rate; } }, 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; media.play(); media.volume = 0; media.playbackRate = Config.playbackRate; }, observePause(video, shouldResume = () => true) { if (!video) return () => { }; const canResume = () => shouldResume() && !video.ended && !this.isNearEnd(video); // 自动播放 const playVideo = () => { if (!canResume()) return; video.play().catch(e => { if (!canResume()) return; console.warn('自动播放失败:', e); setTimeout(playVideo, 3000); }); }; playVideo(); // 直接监听 pause 事件,不依赖播放器 UI 元素 const onPause = () => { if (canResume()) playVideo(); }; video.addEventListener('pause', onPause); // 定时兜底:防止 pause 事件被拦截 const timer = setInterval(() => { if (video.paused && canResume()) playVideo(); }, 5000); // 播放器 UI 观察:按钮被点击暂停时 tip 变为「播放」 const target = document.getElementsByClassName('play-btn-tip')[0]; let observer = null; if (target) { observer = new MutationObserver(list => { for (const mutation of list) { if (mutation.type === 'childList' && target.innerText === '播放' && canResume()) { video.play(); } } }); observer.observe(target, { childList: true }); } return () => { video.removeEventListener('pause', onPause); clearInterval(timer); if (observer) observer.disconnect(); }; }, waitForEnd(media, timeout = 0) { return new Promise(resolve => { if (!media) return resolve(); if (media.ended) return resolve(); let timer; const onEnded = () => { clearTimeout(timer); resolve(); }; media.addEventListener('ended', onEnded, { once: true }); if (timeout > 0) { timer = setTimeout(() => { media.removeEventListener('ended', onEnded); resolve(); }, timeout); } }); } }; // ---- ai-workspace 路由工具 ---- const AiWorkspace = { normalizeText(text) { return String(text || '').replace(/\s+/g, ' ').trim(); }, isVisibleElement(element) { if (!element || element.nodeType !== 1) return false; const view = element.ownerDocument?.defaultView || window; const style = view.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; }, getRoute() { const match = location.pathname.match(/^\/ai-workspace\/lms-graph\/([^/]+)\/([^/]+)\/([^/?#]+)/); if (!match) return null; const [, classroomId, type, leafId] = match; const query = new URLSearchParams(location.search); return { classroomId, type, leafId, nodeId: query.get('node_id') || '' }; }, getMediaCandidates() { return [...document.querySelectorAll('video, audio')].filter(media => { const tag = (media && media.tagName ? media.tagName : '').toUpperCase(); if (tag !== 'VIDEO' && tag !== 'AUDIO') return false; const rect = media.getBoundingClientRect(); const isVisible = rect.width > 0 && rect.height > 0; return isVisible || tag === 'AUDIO'; }); }, getMedia() { const candidates = this.getMediaCandidates(); if (!candidates.length) return document.querySelector('video') || document.querySelector('audio'); const score = media => { const rect = media.getBoundingClientRect(); const area = rect.width * rect.height; const playingBoost = !media.paused && !media.ended ? 1_000_000 : 0; const currentBoost = Number(media.currentTime || 0); return playingBoost + area + currentBoost; }; return [...candidates].sort((a, b) => score(b) - score(a))[0]; }, isPlayerDone(media, { startTime = 0, minPlayedDelta = 0 } = {}) { if (!media) return false; const currentTime = Number(media?.currentTime || 0); const duration = Number(media?.duration || 0); const playedDelta = Math.max(0, currentTime - startTime); if (playedDelta < minPlayedDelta) return false; if (media?.ended) return true; if (duration > 1 && currentTime > 0 && duration - currentTime <= 1) return true; const display = document.querySelector('.xt_video_player_current_time_display')?.innerText?.trim() || ''; const [current, total] = display.split(' / ').map(text => text?.trim()); return Boolean(playedDelta >= minPlayedDelta && current && total && current === total); }, keepAlive(shouldResume = () => true) { let lastMedia = null; const tick = () => { if (!shouldResume()) return; const media = this.getMedia(); if (!media) return; if (lastMedia !== media) { lastMedia = media; media.addEventListener('pause', tick); } media.muted = true; media.defaultMuted = true; media.volume = 0; media.playbackRate = Config.playbackRate; if (media.paused && !media.ended && !Player.isNearEnd(media)) { media.play().catch(() => { }); } }; const timer = setInterval(tick, 500); document.addEventListener('visibilitychange', tick); window.addEventListener('focus', tick); tick(); return () => { clearInterval(timer); if (lastMedia) lastMedia.removeEventListener('pause', tick); document.removeEventListener('visibilitychange', tick); window.removeEventListener('focus', tick); }; }, getActiveLeafTitle() { const active = document.querySelector('.leaf-item.is-active'); if (!active) return ''; const title = active.querySelector('.leaf-item-title')?.innerText?.replace(/\s+/g, ' ').trim(); return title || active.innerText?.replace(/\s+/g, ' ').trim() || ''; }, getExerciseDocument() { const localHasExercise = document.querySelector('#app .container-body .container-problem') || document.querySelector('#app .container-problem') || document.querySelector('.container-problem'); if (localHasExercise) return document; const frames = [...document.querySelectorAll('iframe')]; for (const frame of frames) { try { const doc = frame.contentDocument; if (!doc?.body) continue; if ( doc.querySelector('.container-problem') || doc.querySelector('.subject-item') || doc.querySelector('.item-body') ) { return doc; } } catch (_) { // ignore cross-document access failures } } return null; }, getExerciseContainer() { const exerciseDoc = this.getExerciseDocument(); return exerciseDoc?.querySelector('#app .container-body .container-problem') || exerciseDoc?.querySelector('#app .container-problem') || exerciseDoc?.querySelector('.container-problem') || null; }, getExerciseQuestionTabs(root = this.getExerciseContainer(), { visibleOnly = true } = {}) { if (!root) return []; const selectors = [ '.subject-item.J_order', '.subject-item', '.problem-index-item', '.question-index-item', '[class*="subject-item"]', '[class*="problem-index"]', '[class*="question-index"]' ].join(','); const all = [...root.querySelectorAll(selectors)]; return all.filter((el, index, arr) => { if (visibleOnly && !this.isVisibleElement(el)) return false; if (arr.indexOf(el) !== index) return false; const text = this.normalizeText(el.innerText); return text && text.length <= 20; }); }, getExerciseQuestionBody(root = this.getExerciseContainer()) { if (!root) return null; const itemType = root.querySelector('.item-type'); if (itemType?.parentElement && this.isVisibleElement(itemType.parentElement)) return itemType.parentElement; const selectors = [ '.item-body', '.problem-content', '.question-content', '.problem-main', '.problem-body', '.question-body', '[class*="problem-content"]', '[class*="question-content"]', '[class*="problem-body"]', '[class*="question-body"]' ]; for (const selector of selectors) { const match = [...root.querySelectorAll(selector)].find(el => this.isVisibleElement(el)); if (match) return match; } return root; }, isExerciseAnswered(root = this.getExerciseContainer()) { if (!root) return false; const disabledSubmit = [...root.querySelectorAll('.el-button.is-disabled, button[disabled]')] .find(el => /提交|提交答案/.test(this.normalizeText(el.innerText || ''))); if (disabledSubmit) return true; const statusSelectors = [ '.result', '.status', '.answer-status', '[class*="result"]', '[class*="status"]' ]; for (const selector of statusSelectors) { const statusNode = [...root.querySelectorAll(selector)] .find(el => this.isVisibleElement(el) && /已完成|已作答|已提交|回答正确|回答错误/.test(this.normalizeText(el.innerText))); if (statusNode) return true; } return false; }, getExerciseActionButton(root = this.getExerciseContainer(), pattern = /提交|保存|确认|确定|下一题|下一道|下一步|完成本题/) { if (!root) return null; const selectors = 'button, .el-button, [role="button"], [class*="button"]'; const nodes = [ ...root.querySelectorAll(selectors), ...document.querySelectorAll(selectors) ]; return nodes.find(el => this.isVisibleElement(el) && pattern.test(this.normalizeText(el.innerText))); }, getAllScourse() { // 获得ai-workspace的课程列表 const list = document?.querySelectorAll(".nav-item-leaf-box") if (!list) panel.warn("没有发现课程资源") return list } }; // ---- 防切屏 ---- function preventScreenCheck() { const win = unsafeWindow; const blackList = new Set(['visibilitychange', 'blur', 'pagehide']); win._addEventListener = win.addEventListener; win.addEventListener = (...args) => blackList.has(args[0]) ? undefined : win._addEventListener(...args); document._addEventListener = document.addEventListener; document.addEventListener = (...args) => blackList.has(args[0]) ? undefined : document._addEventListener(...args); Object.defineProperties(document, { hidden: { value: false }, visibilityState: { value: 'visible' }, hasFocus: { value: () => true }, onvisibilitychange: { get: () => undefined, set: () => { } }, onblur: { get: () => undefined, set: () => { } } }); Object.defineProperties(win, { onblur: { get: () => undefined, set: () => { } }, onpagehide: { get: () => undefined, set: () => { } } }); } // ---- OCR & AI ---- const Solver = { visionDisabled: false, decryptFontCss: '', decryptFontLoading: null, decryptFontLoaded: false, async ensureDecryptFont(doc) { if (this.decryptFontCss) return this.decryptFontCss; if (this.decryptFontLoading) return this.decryptFontLoading; this.decryptFontLoading = (async () => { try { let fontUrl = ''; for (const scope of [doc, document]) { if (!scope || fontUrl) continue; for (const style of scope.querySelectorAll('style')) { const text = style.textContent || ''; if (!text.includes('exam-data-decrypt-font')) continue; const m = text.match(/url\(\s*["']?([^"')]+)["']?\s*\)/); if (m && m[1]) { fontUrl = m[1]; try { fontUrl = new URL(fontUrl, scope.baseURI || location.href).href; } catch (_) { } break; } } } if (!fontUrl) { for (const view of [doc && doc.defaultView, window].filter(Boolean)) { try { const entry = view.performance.getEntriesByType('resource') .find(e => /exam_font|fe_font\/product/i.test(e.name)); if (entry && entry.name) { fontUrl = entry.name; break; } } catch (_) { } } } if (!fontUrl) return ''; let dataUrl = ''; try { const resp = await fetch(fontUrl); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); dataUrl = `data:font/ttf;base64,${this.bufferToBase64(await resp.arrayBuffer())}`; } catch (err) { console.warn('fetch 解密字体失败,尝试 GM 请求:', err); try { const buffer = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: fontUrl, responseType: 'arraybuffer', timeout: 60000, onload: res => { if (res.status === 200 && res.response instanceof ArrayBuffer) { resolve(res.response); return; } reject(new Error(`HTTP ${res.status}`)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')) }); }); dataUrl = `data:font/ttf;base64,${this.bufferToBase64(buffer)}`; } catch (err2) { console.warn('GM 获取解密字体失败,将直接引用原地址:', err2); } } const src = dataUrl || fontUrl; try { const FontFaceCtor = (doc && doc.defaultView && doc.defaultView.FontFace) || FontFace; const face = new FontFaceCtor('exam-data-decrypt-font', `url(${src})`); const loaded = await face.load(); if (doc && doc.fonts) doc.fonts.add(loaded); this.decryptFontLoaded = true; } catch (_) { } this.decryptFontCss = `@font-face { font-family: "exam-data-decrypt-font"; src: url("${src}"); }`; panel.log(dataUrl ? '已加载题目解密字体' : '解密字体已按原地址注入'); } catch (err) { console.error('ensureDecryptFont error:', err); this.decryptFontLoading = null; } return this.decryptFontCss; })(); return this.decryptFontLoading; }, bufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i += 0x8000) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); } return btoa(binary); }, async capture(element) { if (!element) return null; try { panel.log('正在生成题目图片...'); await this.ensureDecryptFont(element.ownerDocument); const textCanvas = this.renderTextCanvas(element); if (textCanvas) return textCanvas; panel.log('文本渲染不可用,改用常规截图'); return await html2canvas(element, { useCORS: true, logging: false, scale: 2, backgroundColor: '#ffffff' }); } catch (err) { console.error('capture error:', err); panel.log(`截图失败: ${err.message || '网络错误'}`); return null; } }, collectTextSegments(element) { const doc = element.ownerDocument; const segs = []; const walk = node => { if (node.nodeType === 3) { const text = (node.nodeValue || '').replace(/\s+/g, ' '); if (!text.trim()) return; const parent = node.parentElement; const encrypted = !!(parent && parent.closest('.xuetangx-com-encrypted-font')); const last = segs[segs.length - 1]; if (last && !last.newline && last.encrypted === encrypted) last.text += text; else segs.push({ text, encrypted }); return; } if (node.nodeType !== 1) return; const el = node; try { const style = doc.defaultView.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden') return; } catch (_) { } if (el.tagName === 'BR') { segs.push({ newline: true }); return; } const isBlock = ['P', 'LI', 'DIV', 'SECTION', 'TR', 'H1', 'H2', 'H3', 'H4', 'UL', 'OL'].includes(el.tagName); if (isBlock && segs.length && !segs[segs.length - 1].newline) segs.push({ newline: true }); for (const child of el.childNodes) walk(child); if (isBlock && segs.length && !segs[segs.length - 1].newline) segs.push({ newline: true }); }; walk(element); return segs; }, renderTextCanvas(element) { const doc = element.ownerDocument; const segs = this.collectTextSegments(element); if (!segs.length) return null; const hasEncrypted = segs.some(s => s.encrypted); if (hasEncrypted && !this.decryptFontLoaded) return null; const scale = 2, fontSize = 22, lineHeight = 36, pad = 20, maxWidth = 880; const canvas = doc.createElement('canvas'); const ctx = canvas.getContext('2d'); const examFont = `${fontSize}px "exam-data-decrypt-font"`; const normalFont = `${fontSize}px "Microsoft YaHei", "PingFang SC", SimSun, sans-serif`; const lines = [[]]; let lineWidth = 0; for (const seg of segs) { if (seg.newline) { if (lines[lines.length - 1].length) { lines.push([]); lineWidth = 0; } continue; } const font = (seg.encrypted && hasEncrypted) ? examFont : normalFont; ctx.font = font; for (const ch of seg.text) { const w = ctx.measureText(ch).width; if (lineWidth + w > maxWidth && lineWidth > 0) { lines.push([]); lineWidth = 0; } const lastLine = lines[lines.length - 1]; const lastRun = lastLine[lastLine.length - 1]; if (lastRun && lastRun.font === font) lastRun.text += ch; else lastLine.push({ text: ch, font }); lineWidth += w; } } const nonEmpty = lines.filter(l => l.length); if (!nonEmpty.length) return null; canvas.width = (maxWidth + pad * 2) * scale; canvas.height = (nonEmpty.length * lineHeight + pad * 2) * scale; ctx.scale(scale, scale); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000000'; ctx.textBaseline = 'top'; nonEmpty.forEach((runs, i) => { let x = pad; const y = pad + i * lineHeight; for (const run of runs) { ctx.font = run.font; ctx.fillText(run.text, x, y); x += ctx.measureText(run.text).width; } }); return canvas; }, async recognizeCanvas(canvas) { if (!canvas) return ''; panel.log('正在 OCR 识别 (首轮较慢)...'); const { data: { text } } = await Tesseract.recognize(canvas, 'chi_sim', { logger: m => { if (m.status === 'downloading tesseract lang') { console.log(`正在下载语言包 ${(m.progress * 100).toFixed(0)}%`); } } }); return text.replace(/\s+/g, ' ').trim(); }, async recognize(element) { if (!element) return '无元素'; try { const canvas = await this.capture(element); if (!canvas) return 'OCR识别出错'; return await this.recognizeCanvas(canvas); } catch (err) { console.error('OCR error:', err); panel.log(`OCR 失败: ${err.message || '网络错误'}`); return 'OCR识别出错'; } }, getOptionElements(container) { if (!container) return []; let options = [...container.querySelectorAll(':scope > li, :scope > label.el-radio, :scope > label.el-checkbox, :scope > .option-item, :scope > .answer-item, :scope > [class*="option-item"], :scope > [class*="answer-item"]')]; if (!options.length) { options = [...container.querySelectorAll('li, .option-item, .answer-item, [class*="option-item"], [class*="answer-item"]')]; } return options; }, async askAI(ocrText, optionCount = 0, canvas = null) { const saved = Store.getAIConf(); const API_URL = saved.url; const API_KEY = saved.key; const MODEL_NAME = saved.model; const API_FORMAT = saved.apiFormat || 'openai'; const AUTH_METHOD = saved.authMethod || 'bearer'; if (!API_KEY || API_KEY.includes('sk-xxxx')) { const msg = '⚠️ 请在 [AI配置] 中填写有效的 API Key'; panel.log(msg); throw new Error(msg); } const maxChar = String.fromCharCode(65 + optionCount - 1); const rangeStr = optionCount ? `A-${maxChar}` : 'A-D'; const promptHead = `请解答下面的题目。规则: 1. 本题共有 ${optionCount || '若干'} 个选项,按题目中出现顺序对应 ${rangeStr}。 2. 多选题请选出所有正确选项,单选题只选一个,判断题答案为“对”或“错”。 3. 先写一句 30 字以内的判断依据,然后另起一行,严格按「正确答案:X」格式输出最终答案(X 为 A、ABD、对、错等)。`; const systemPrompt = "你是严谨的阅卷老师,先给一句简短依据,最后输出「正确答案:X」。"; const authHeader = AUTH_METHOD === 'x-api-key' ? { 'x-api-key': API_KEY } : { 'Authorization': `Bearer ${API_KEY}` }; let imageDataUrl = ''; if (canvas) { try { imageDataUrl = canvas.toDataURL('image/png'); } catch (err) { console.error('toDataURL error:', err); panel.log('截图导出失败,改用 OCR 文本'); } } const isDeepSeek = /deepseek/i.test(API_URL) || /deepseek/i.test(MODEL_NAME); const request = (withImage, text = '') => new Promise((resolve, reject) => { const userText = withImage ? `${promptHead}\n题目在图片中,请直接阅读图片作答。` : `${promptHead}\n题目内容(OCR 结果,可能有错字乱码):\n${text}`; if (API_FORMAT === 'anthropic') { const headers = { 'Content-Type': 'application/json', ...authHeader }; if (API_URL.includes('api.anthropic.com')) { headers['anthropic-version'] = '2023-06-01'; } const userContent = withImage ? [ { type: 'text', text: userText }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: imageDataUrl.split(',')[1] } } ] : userText; const requestBody = { model: MODEL_NAME, max_tokens: 1024, system: systemPrompt, messages: [ { role: 'user', content: userContent } ] }; console.log('[AI请求] URL:', API_URL, '图片直读:', withImage); panel.log(`请求 ${API_URL}${withImage ? '(图片直读)' : ''}...`); GM_xmlhttpRequest({ method: 'POST', url: API_URL, headers, data: JSON.stringify(requestBody), timeout: 120000, onload: res => { console.log('[AI响应] Status:', res.status); console.log('[AI响应] Response:', res.responseText); if (res.status === 200) { try { const json = JSON.parse(res.responseText); const answerText = json.content?.[0]?.text || json.choices?.[0]?.message?.content; resolve(answerText); } catch (e) { reject('JSON 解析失败'); } } else { const err = `请求失败: HTTP ${res.status} - ${String(res.responseText || '').slice(0, 200)}`; panel.log(err); reject(err); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时') }); } else { const userContent = withImage ? [ { type: 'text', text: userText }, { type: 'image_url', image_url: { url: imageDataUrl } } ] : userText; GM_xmlhttpRequest({ method: 'POST', url: API_URL, headers: { 'Content-Type': 'application/json', ...authHeader }, data: JSON.stringify({ model: MODEL_NAME, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userContent } ], temperature: 0.1, stream: false, ...(isDeepSeek ? { thinking: { type: 'disabled' } } : {}) }), timeout: 120000, onload: res => { if (res.status === 200) { try { const json = JSON.parse(res.responseText); const answerText = json.choices[0].message.content; resolve(answerText); } catch (e) { reject('JSON 解析失败'); } } else { const err = `请求失败: HTTP ${res.status} - ${String(res.responseText || '').slice(0, 200)}`; panel.log(err); reject(err); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时') }); } }); if (imageDataUrl && !this.visionDisabled) { try { return await request(true); } catch (err) { this.visionDisabled = true; panel.log(`图片直读失败(${err}),改用 OCR 文本重试`); } } let text = String(ocrText || ''); if (!text && canvas) { try { text = await this.recognizeCanvas(canvas); } catch (err) { console.error('OCR error:', err); panel.log(`OCR 失败: ${err.message || '网络错误'}`); } } if (!text) throw new Error('无可用题目内容'); return request(false, text); }, async autoSelectAndSubmit(aiResponse, itemBodyElement) { const text = String(aiResponse || '').replace(/[*`_#]/g, ''); const matches = [...text.matchAll(/(?:正确|最终|参考)?答案[是为::\s]*([A-F][A-F、,,和\s]*|[对错]|正确|错误)/gi)]; let match = matches[matches.length - 1]; if (!match) { const fallback = [...text.matchAll(/(?:应选|选择|选)\s*[::]?\s*([A-F][A-F、,,和\s]*)/gi)]; match = fallback[fallback.length - 1]; } if (!match) { match = text.trim().match(/^([A-F]{1,6}|对|错|正确|错误)[。.!!~~\s]*$/i); } if (!match) { panel.log(`⚠️ 未提取到有效答案(AI 原话:${text.slice(0, 60)})`); return false; } const raw = match[1].trim(); const isJudge = /^(对|错|正确|错误)$/.test(raw); const letters = raw.toUpperCase().replace(/[^A-F]/g, ''); const map = { 'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 4, 'F': 5 }; let targetIndices = []; if (isJudge) { targetIndices = (raw === '对' || raw === '正确') ? [0] : [1]; } else { for (const char of letters) { if (map[char] !== undefined) targetIndices.push(map[char]); } } if (!targetIndices.length) { panel.log(`⚠️ 答案「${raw}」无法匹配选项,请人工检查`); return false; } panel.log(`✅ AI 建议选:${isJudge ? raw : letters}`); const listContainer = itemBodyElement.querySelector('.list-inline.list-unstyled-radio') || itemBodyElement.querySelector('.list-unstyled.list-unstyled-radio') || itemBodyElement.querySelector('.list-unstyled') || itemBodyElement.querySelector('ul.list') || itemBodyElement.querySelector('[class*="option-list"]') || itemBodyElement.querySelector('[class*="answer-list"]') || itemBodyElement.querySelector('.el-radio-group') || itemBodyElement.querySelector('.el-checkbox-group') || itemBodyElement.querySelector('ul') || itemBodyElement.querySelector('[role="radiogroup"]'); if (!listContainer) { panel.log('⚠️ 未找到选项容器'); return false; } const options = this.getOptionElements(listContainer); for (const idx of targetIndices) { if (!options[idx]) continue; const clickable = options[idx].querySelector('label.el-radio') || options[idx].querySelector('label.el-checkbox') || options[idx].querySelector('.el-radio__label') || options[idx].querySelector('.el-checkbox__label') || options[idx].querySelector('[role="radio"]') || options[idx].querySelector('[role="checkbox"]') || options[idx].querySelector('input') || options[idx]; clickable.click(); await Utils.sleep(150); } const submitBtn = (() => { const ownerDocument = itemBodyElement.ownerDocument || document; const roots = [itemBodyElement.parentElement, itemBodyElement, ownerDocument].filter(Boolean); const matchText = text => /提交|保存|确认|确定|提交答案/.test(text); for (const root of roots) { const local = root.querySelectorAll('button, .el-button, [role="button"]'); for (const btn of local) { if (btn.offsetParent !== null && matchText(btn.innerText || '')) return btn; } } const global = ownerDocument.querySelectorAll('.el-button.el-button--primary.el-button--medium'); for (const btn of global) { if (matchText(btn.innerText || '') && btn.offsetParent !== null) return btn; } return null; })(); if (submitBtn) { panel.log('正在提交...'); submitBtn.click(); return true; } panel.log('⚠️ 未找到提交按钮,请人工检查该题'); return false; } }; // ---- v2 逻辑 ---- class V2Runner { constructor(panel) { this.panel = panel; this.baseUrl = location.href; const { current } = Store.getProgress(this.baseUrl); this.outside = current.outside; this.inside = current.inside; this.shouldStop = false; } updateProgress(outside, inside = 0) { this.outside = outside; this.inside = inside; Store.setProgress(this.baseUrl, outside, inside); } async waitForExternalHandoff(timeout = 1200) { await Utils.sleep(timeout); if (document.visibilityState === 'hidden' || !document.hasFocus()) { this.shouldStop = true; this.panel.log('已交给新页面继续,返回目录页后会自动续跑'); return true; } return false; } checkCompletionStatus(statusBox, statusText) { // 1. 检查明确的完成状态文本 if (statusText.includes('已完成') || statusText.includes('已读')) { return true; } // 2. 检查明确的未完成状态文本 if (statusText.includes('未开始') || statusText.includes('未读') || statusText.includes('进行中')) { return false; } // 3. 检查学习进度数字比例 const progressMatch = statusText.match(/(\d+)\/(\d+)/); if (progressMatch) { const [, current, total] = progressMatch; const currentNum = parseInt(current, 10); const totalNum = parseInt(total, 10); // 根据数字进度判断:相等且大于0表示已完成 return currentNum === totalNum && totalNum > 0; } // 默认返回false(未完成) return false; } async run() { this.panel.log(`检测到已播放到第 ${this.outside} 集,继续刷课...`); // 在课件页恢复时直接续播当前内容,不重新走列表流程 if (location.pathname.includes('/studentCards/')) { const videoBox = document.querySelector('.video-box'); const boxText = videoBox?.innerText || ''; if ((videoBox || document.querySelector('video')) && !boxText.includes('已完成')) { this.panel.log('检测到当前课件页,直接续播当前内容'); await this.waitCoursewareVideo(); history.back(); await Utils.sleep(1000); } } while (true) { await this.autoSlide(); const list = document.querySelector('.logs-list')?.childNodes; if (!list || !list.length) { // 可能停留在课件页:跳回目录页继续,避免无限重试 const pending = Store.getPendingAutoStart(); const returnUrl = pending?.returnUrl || (pending?.classroomId ? `/v2/web/studentLog/${pending.classroomId}` : ''); if (returnUrl && !location.pathname.includes('/studentLog/')) { this.panel.log('当前页面无课程列表,返回目录页继续'); location.href = returnUrl; return; } this.panel.log('未找到课程列表,稍后重试'); await Utils.sleep(2000); continue; } console.log(`当前集数:${this.outside}/全部集数${list.length}`); if (this.outside >= list.length) { this.panel.log('课程刷完啦 🎉'); this.panel.resetStartButton('刷完啦~'); Store.removeProgress(this.baseUrl); Store.clearPendingAutoStart(); break; } const course = list[this.outside]?.querySelector('.content-box')?.querySelector('section'); if (!course) { this.panel.log('未找到当前课程节点,跳过'); this.updateProgress(this.outside + 1, 0); continue; } const type = course.querySelector('.tag')?.querySelector('use')?.getAttribute('xlink:href') || 'piliang'; const typeKey = String(type).replace('#icon-', '').replace('#icon--', ''); const typeLabel = TypeLabel[typeKey] || typeKey || '未知'; const title = course.querySelector('h2')?.innerText?.trim() || `第${this.outside + 1}项`; // 预检查完成状态 const statusBox = course.querySelector('.statistics-box .aside'); const statusText = statusBox?.innerText || ''; // 判断是否已完成 let isCompleted = this.checkCompletionStatus(statusBox, statusText); if (isCompleted) { this.panel.log(`✅ ${title} 已完成,跳过`); this.updateProgress(this.outside + 1, 0); continue; } this.panel.log(`刷课状态:第 ${this.outside + 1}/${list.length} 个,类型「${typeLabel}」,标题:${title}`); if (type.includes('shipin')) { await this.handleVideo(course); } else if (type.includes('piliang')) { await this.handleBatch(course, list); } else if (type.includes('ketang')) { await this.handleClassroom(course); } else if (type.includes('kejian')) { await this.handleCourseware(course); } else if (type.includes('kaoshi')) { this.panel.log('考试区域脚本会被屏蔽,已跳过'); this.updateProgress(this.outside + 1, 0); } else { this.panel.log('非视频/批量/课件/考试,已跳过'); this.updateProgress(this.outside + 1, 0); } if (this.shouldStop) return; } } async autoSlide() { const frequency = Math.floor((this.outside + 1) / 20) + 1; for (let i = 0; i < frequency; i++) { Utils.scrollToBottom('.viewContainer'); await Utils.sleep(800); } } async handleVideo(course) { course.click(); if (await this.waitForExternalHandoff(1500)) return; await Utils.sleep(3000); const progressNode = document.querySelector('.progress-wrap')?.querySelector('.text'); const title = document.querySelector('.title')?.innerText || '视频'; const isDeadline = document.querySelector('.box')?.innerText.includes('已过考核截止时间'); if (isDeadline) this.panel.log(`${title} 已过截止,进度不再增加,将直接跳过`); Player.applySpeed(); Player.mute(); const stopObserve = Player.observePause(document.querySelector('video')); await Utils.poll(() => { Utils.dismissPopups(); return isDeadline || Utils.isProgressDone(progressNode?.innerHTML); }, { interval: 5000, timeout: await Utils.getDDL() }); stopObserve(); this.updateProgress(this.outside + 1, 0); history.back(); await Utils.sleep(1200); } async handleBatch(course, list) { const expandBtn = course.querySelector('.sub-info')?.querySelector('.gray')?.querySelector('span'); if (!expandBtn) { this.panel.log('未找到批量展开按钮,跳过'); this.updateProgress(this.outside + 1, 0); return; } expandBtn.click(); await Utils.sleep(1200); const activities = list[this.outside]?.querySelector('.leaf_list__wrap')?.querySelectorAll('.activity__wrap') || []; let idx = this.inside; this.panel.log(`进入批量区,内部进度 ${idx}/${activities.length}`); while (idx < activities.length) { const item = activities[idx]; if (!item) break; const tagText = item.querySelector('.tag')?.innerText || ''; const tagHref = item.querySelector('.tag')?.querySelector('use')?.getAttribute('xlink:href') || ''; const title = item.querySelector('h2')?.innerText || `第${idx + 1}项`; // 检查当前项目的完成状态 const statusBox = item.querySelector('.statistics-box .aside'); const statusText = statusBox?.innerText || ''; const isCompleted = this.checkCompletionStatus(statusBox, statusText); if (isCompleted) { this.panel.log(`✅ ${title} 已完成,跳过`); idx++; this.updateProgress(this.outside, idx); continue; } if (tagText === '音频') { idx = await this.playAudioItem(item, title, idx); } else if (tagHref.includes('shipin')) { idx = await this.playVideoItem(item, title, idx); } else if (tagHref.includes('tuwen') || tagHref.includes('taolun')) { idx = await this.autoCommentItem(item, tagHref.includes('tuwen') ? '图文' : '讨论', idx); } else if (tagHref.includes('zuoye')) { idx = await this.handleHomework(item, idx); } else { this.panel.log(`类型未知,已跳过:${title}`); idx++; this.updateProgress(this.outside, idx); } if (this.shouldStop) return; } this.updateProgress(this.outside + 1, 0); await Utils.sleep(1000); } async playAudioItem(item, title, idx) { this.panel.log(`开始播放音频:${title}`); item.click(); if (await this.waitForExternalHandoff()) return idx; await Utils.sleep(2500); Player.applyMediaDefault(document.querySelector('audio')); const progressNode = document.querySelector('.progress-wrap')?.querySelector('.text'); await Utils.poll(() => { Utils.dismissPopups(); return Utils.isProgressDone(progressNode?.innerHTML); }, { interval: 3000, timeout: await Utils.getDDL() }); this.panel.log(`${title} 播放完成`); idx++; this.updateProgress(this.outside, idx); history.back(); await Utils.sleep(1500); return idx; } async playVideoItem(item, title, idx) { this.panel.log(`开始播放视频:${title}`); item.click(); if (await this.waitForExternalHandoff()) return idx; await Utils.sleep(2500); Player.applySpeed(); Player.mute(); const stopObserve = Player.observePause(document.querySelector('video')); const progressNode = document.querySelector('.progress-wrap')?.querySelector('.text'); await Utils.poll(() => { Utils.dismissPopups(); return Utils.isProgressDone(progressNode?.innerHTML); }, { interval: 3000, timeout: await Utils.getDDL() }); stopObserve(); this.panel.log(`${title} 播放完成`); idx++; this.updateProgress(this.outside, idx); history.back(); await Utils.sleep(1500); return idx; } async autoCommentItem(item, typeText, idx) { const title = item.querySelector('h2')?.innerText || ''; this.panel.log(`开始处理${typeText}:${title}`); item.click(); // 若新开标签进入学习空间,本页失焦则交棒,由学习空间逻辑接手 if (await this.waitForExternalHandoff(1500)) return idx; await Utils.sleep(1200); const featureFlags = Store.getFeatureConf(); if (!featureFlags.autoComment) { this.panel.log(`${typeText}「${title}」已点开;未开启自动回复,不发帖,直接下一项`); idx++; this.updateProgress(this.outside, idx); history.back(); await Utils.sleep(1000); return idx; } // 开启了自动评论功能,执行评论逻辑 window.scrollTo(0, document.body.scrollHeight); await Utils.sleep(800); window.scrollTo(0, 0); const commentSelectors = ['#new_discuss .new_discuss_list .cont_detail', '.new_discuss_list dd .cont_detail', '.cont_detail.word-break']; let firstComment = ''; for (let retry = 0; retry < 30 && !firstComment; retry++) { for (const sel of commentSelectors) { const list = document.querySelectorAll(sel); for (const node of list) { if (node?.innerText?.trim()) { firstComment = node.innerText.trim(); break; } } if (firstComment) break; } if (!firstComment) await Utils.sleep(500); } if (!firstComment) { this.panel.log('未找到评论内容,跳过该项'); } else { const input = document.querySelector('.el-textarea__inner'); if (input) { input.value = firstComment; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await Utils.sleep(800); const sendBtn = document.querySelector('.el-button.submitComment') || document.querySelector('.publish_discuss .postBtn button') || document.querySelector('.el-button--primary'); if (sendBtn && !sendBtn.disabled && !sendBtn.classList.contains('is-disabled')) { sendBtn.click(); this.panel.log(`已在${typeText}区发表评论`); } else { this.panel.log('发送按钮不可用或不存在'); } } else { this.panel.log('未找到评论输入框,跳过'); } } idx++; this.updateProgress(this.outside, idx); history.back(); await Utils.sleep(1000); return idx; } async handleHomework(item, idx) { const featureFlags = Store.getFeatureConf(); if (!featureFlags.autoAI) { this.panel.log('已关闭AI自动答题,跳过该项'); idx++; this.updateProgress(this.outside, idx); return idx; } this.panel.log('进入作业,启动 AI 作答'); item.click(); await Utils.sleep(1500); let i = 0; const maxRetry = 3; // 最大重试次数 while (true) { const items = document.querySelectorAll('.subject-item.J_order'); if (i >= items.length) { this.panel.log(`所有题目处理完毕,共 ${items.length} 题,准备交卷`); break; } const listItem = items[i]; listItem.scrollIntoView({ behavior: 'smooth', block: 'center' }); listItem.click(); await Utils.sleep(1800); const disabled = document.querySelectorAll('.el-button.el-button--info.is-disabled.is-plain'); if (disabled.length > 0) { this.panel.log(`第 ${i + 1} 题已完成,跳过...`); i++; continue; } const targetEl = document.querySelector('.item-type')?.parentElement || document.querySelector('.item-body'); let optionCount = 0; const listContainer = targetEl?.querySelector('.list-inline.list-unstyled-radio') || targetEl?.querySelector('.list-unstyled.list-unstyled-radio') || targetEl?.querySelector('.el-radio-group') || targetEl?.querySelector('.el-checkbox-group') || targetEl?.querySelector('ul.list'); if (listContainer) optionCount = Solver.getOptionElements(listContainer).length; const questionCanvas = await Solver.capture(targetEl); if (questionCanvas) { let retryCount = 0; let success = false; while (retryCount < maxRetry && !success) { try { if (retryCount > 0) { this.panel.log(`🔄 第 ${i + 1} 题重试 ${retryCount}/${maxRetry}...`); } panel.log('🤖 请求 AI 获取答案...'); const aiText = await Solver.askAI('', optionCount, questionCanvas); const ok = await Solver.autoSelectAndSubmit(aiText, targetEl); if (!ok) throw new Error('自动作答未完成'); success = true; } catch (err) { retryCount++; this.panel.log(`AI 答题失败:${err}`); if (retryCount < maxRetry) { this.panel.log(`等待 5 秒后重试...`); await Utils.sleep(5000); } else { this.panel.log(`⚠️ 第 ${i + 1} 题重试 ${maxRetry} 次后仍失败,跳过`); } } } } await Utils.sleep(1500); i++; } idx++; this.updateProgress(this.outside, idx); history.back(); await Utils.sleep(1200); return idx; } async handleClassroom(course) { this.panel.log('进入课堂模式...'); course.click(); await Utils.sleep(5000); const iframe = document.querySelector('iframe.lesson-report-mobile'); if (!iframe || !iframe.contentDocument) { this.panel.log('未找到课堂 iframe,跳过'); this.updateProgress(this.outside + 1, 0); return; } const video = iframe.contentDocument.querySelector('video'); const audio = iframe.contentDocument.querySelector('audio'); if (video) { Player.applyMediaDefault(video); await Player.waitForEnd(video); } if (audio) { Player.applyMediaDefault(audio); await Player.waitForEnd(audio); } this.updateProgress(this.outside + 1, 0); history.go(-1); await Utils.sleep(1200); } // 等待课件视频播放完毕;播放器被关闭(弹窗关闭/元素销毁)时自动重新打开 async waitCoursewareVideo() { const deadline = await Utils.getDDL(); const start = Date.now(); let boundVideo = null; let stopObserve = () => { }; let reopenAttempts = 0; try { while (Date.now() - start < deadline) { Utils.dismissPopups(); const video = document.querySelector('video'); const display = document.querySelector('.xt_video_player_current_time_display'); if (!video) { // 播放器被关闭或视频元素被销毁,重新打开视频框 const videoBox = document.querySelector('.video-box'); if (videoBox && !videoBox.innerText.includes('已完成')) { this.panel.log('播放器被关闭,正在重新打开'); videoBox.click(); boundVideo = null; } reopenAttempts++; if (reopenAttempts >= 4) { this.panel.log('播放器恢复失败,刷新页面重试'); location.reload(); return false; } await Utils.sleep(2000); continue; } reopenAttempts = 0; if (!display) { // 播放器加载中,等待渲染 await Utils.sleep(800); continue; } if (video !== boundVideo) { stopObserve(); Player.applySpeed(); Player.mute(); boundVideo = video; stopObserve = Player.observePause(video); } const times = display.innerText || ''; const [nowTime, totalTime] = times.split(' / '); if (nowTime && totalTime && nowTime === totalTime) return true; await Utils.sleep(800); } return false; } finally { stopObserve(); } } async handleCourseware(course) { const tableData = course.parentNode?.parentNode?.parentNode?.__vue__?.tableData; const deadlinePassed = (tableData?.deadline || tableData?.end) ? (tableData.deadline < Date.now() || tableData.end < Date.now()) : false; if (deadlinePassed) { this.panel.log(`${course.querySelector('h2')?.innerText || '课件'} 已结课,跳过`); this.updateProgress(this.outside + 1, 0); return; } course.click(); await Utils.sleep(3000); // 检测"查看课件"按钮(课件概况页专用) const checkBtn = document.querySelector('.ppt_img_box .check') || document.querySelector('p.check'); if (checkBtn && checkBtn.innerText?.trim() === '查看课件') { this.panel.log('检测到"查看课件"按钮,正在点击...'); checkBtn.click(); await Utils.sleep(2000); } const classType = document.querySelector('.el-card__header')?.innerText || ''; const className = document.querySelector('.dialog-header')?.firstElementChild?.innerText || '课件'; if (classType.includes('PPT')) { const slides = document.querySelector('.swiper-wrapper')?.children || []; this.panel.log(`开始播放 PPT:${className}`); for (let i = 0; i < slides.length; i++) { slides[i].click(); this.panel.log(`${className}:第 ${i + 1} 张`); await Utils.sleep(Config.pptInterval); } await Utils.sleep(Config.pptInterval); const videoBoxes = document.querySelectorAll('.video-box'); if (videoBoxes?.length) { this.panel.log('PPT 中有视频,继续播放'); for (let i = 0; i < videoBoxes.length; i++) { if (videoBoxes[i].innerText === '已完成') { this.panel.log(`第 ${i + 1} 个视频已完成,跳过`); continue; } videoBoxes[i].click(); await Utils.sleep(2000); await this.waitCoursewareVideo(); } } this.panel.log(`${className} 已播放完毕`); } else { const videoBox = document.querySelector('.video-box'); if (videoBox) { videoBox.click(); await Utils.sleep(1800); await this.waitCoursewareVideo(); this.panel.log(`${className} 视频播放完毕`); } } this.updateProgress(this.outside + 1, 0); history.back(); await Utils.sleep(1000); } } // ---- pro/lms 旧版(仅做转发) ---- class ProOldRunner { constructor(panel) { this.panel = panel; } run() { this.panel.log('准备打开新标签页...'); const leafDetail = document.querySelectorAll('.leaf-detail'); let classCount = Store.getProClassCount() - 1; while (leafDetail[classCount] && !leafDetail[classCount].firstChild.querySelector('i').className.includes('shipin')) { classCount++; Store.setProClassCount(classCount + 1); this.panel.log('课程不属于视频,已跳过'); } leafDetail[classCount]?.click(); } } // ---- pro/lms 新版(主要逻辑) ---- class ProNewRunner { constructor(panel) { this.panel = panel; } async run() { preventScreenCheck(); let classCount = Store.getProClassCount(); while (true) { this.panel.log(`准备播放第 ${classCount} 集...`); await Utils.sleep(2000); const className = document.querySelector('.header-bar')?.firstElementChild?.innerText || ''; const classType = document.querySelector('.header-bar')?.firstElementChild?.firstElementChild?.getAttribute('class') || ''; const classStatus = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || ''; if (classType.includes('tuwen') && !classStatus.includes('已读')) { this.panel.log(`正在阅读:${className}`); await Utils.sleep(2000); } else if (classType.includes('taolun')) { this.panel.log(`讨论区暂不自动发帖,${className}`); await Utils.sleep(2000); } else if (classType.includes('shipin') && !classStatus.includes('100%')) { this.panel.log(`2s 后开始播放:${className}`); await Utils.sleep(2000); let statusTimer; let videoTimer; try { statusTimer = setInterval(() => { const status = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || ''; if (status.includes('100%') || status.includes('99%') || status.includes('98%') || status.includes('已完成')) { this.panel.log(`${className} 播放完毕`); clearInterval(statusTimer); statusTimer = null; } }, 200); const videoWaitStart = Date.now(); videoTimer = setInterval(() => { const video = document.querySelector('video'); if (video) { setTimeout(() => { Player.applySpeed(); Player.mute(); Player.observePause(video); }, 2000); clearInterval(videoTimer); videoTimer = null; } else if (Date.now() - videoWaitStart > 20000) { location.reload(); } }, 5000); await Utils.sleep(8000); await Utils.poll(() => { const status = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || ''; return status.includes('100%') || status.includes('99%') || status.includes('98%') || status.includes('已完成'); }, { interval: 1000, timeout: await Utils.getDDL() }); } finally { if (statusTimer) clearInterval(statusTimer); if (videoTimer) clearInterval(videoTimer); } } else if (classType.includes('zuoye')) { this.panel.log(`进入作业:${className}(暂无自动答题)`); await Utils.sleep(2000); } else if (classType.includes('kaoshi')) { this.panel.log(`进入考试:${className}(不会自动答题)`); await Utils.sleep(2000); } else if (classType.includes('ketang')) { this.panel.log(`进入课堂:${className}(暂无自动功能)`); await Utils.sleep(2000); } else { this.panel.log(`已看过:${className}`); await Utils.sleep(2000); } this.panel.log(`第 ${classCount} 集播放完毕`); classCount++; Store.setProClassCount(classCount); const nextBtn = document.querySelector('.btn-next'); if (nextBtn) { const event1 = new Event('mousemove', { bubbles: true }); event1.clientX = 9999; event1.clientY = 9999; nextBtn.dispatchEvent(event1); nextBtn.dispatchEvent(new Event('click')); } else { localStorage.removeItem(Config.storageKeys.proClassCount); this.panel.log('课程播放完毕 🎉'); Store.clearPendingAutoStart(); this.panel.resetStartButton('开始刷课'); break; } } } } // ---- ai-workspace 新版学习空间 ---- class AiWorkspaceRunner { constructor(panel) { this.panel = panel; } getExerciseQuestionLabel(root) { const tabs = AiWorkspace.getExerciseQuestionTabs(root); const active = tabs.find(tab => /active|current|selected|is-active/.test(tab.className)); return AiWorkspace.normalizeText(active?.innerText || ''); } // 获取要跳转回去的目标地址 getReturnUrl() { const pending = Store.getPendingAutoStart(); const route = AiWorkspace.getRoute(); if (!pending || !route) return ''; if (pending.classroomId !== route.classroomId) return ''; console.log(`returnUrl:${pending.returnUrl}`) return pending.returnUrl || ''; } async autoSelect() { // 进入学习空间的方式有两种:可以处理两种不同的逻辑,增加兼容性 const returnUrl = this.getReturnUrl() // 1. 从学习目录(v2/pro/lms)新开标签页进入后,处理完跳回目录 if (returnUrl) { await this.returnToSource(returnUrl) } else { // 2. 直接在学习空间页面点击「开始刷课」:按左侧目录找当前项,再点下一项 this.panel.log('检测到是从学习空间页面点击开始刷课,继续下一学习单元'); this.source = AiWorkspace.getAllScourse(); const boxes = Array.from(this.source || []); if (!boxes.length) { this.panel.warn('未找到左侧学习目录,停止'); return; } let activateIndex = boxes.findIndex(el => { const leaf = el.querySelector('.leaf-item') || el.firstElementChild || el; return leaf.classList.contains('is-active') || !!el.querySelector('.leaf-item.is-active') || !!el.querySelector('.is-active'); }); if (activateIndex < 0) { activateIndex = boxes.findIndex(el => (el.innerText || '').includes(AiWorkspace.getActiveLeafTitle())); } this.activateIndex = activateIndex; const nextIndex = activateIndex < 0 ? 0 : activateIndex + 1; this.panel.log(`当前目录序号:${activateIndex < 0 ? '未识别' : activateIndex + 1}/${boxes.length},准备进入第 ${nextIndex + 1} 项`); await this.handleNext(nextIndex) } } // 获取父窗口对象 window.opener getSourceWindow() { try { if (!window.opener || window.opener.closed) return null; if (window.opener.location.origin !== location.origin) return null; return window.opener; } catch (_) { return null; } } async returnToSource(returnUrl) { this.panel.log('媒体播放完成,返回课程目录页继续匹配'); await Utils.sleep(1200); const sourceWindow = this.getSourceWindow(); console.log(sourceWindow); if (sourceWindow) { try { sourceWindow.location.href = returnUrl; sourceWindow.focus(); window.close(); return true; } catch (e) { console.error("跳转父窗口异常", e); } } // if (location.href !== returnUrl) { // location.href = returnUrl; // } else { // history.back(); // } // return true; } async handleMedia(route) { const title = AiWorkspace.getActiveLeafTitle() || `${route.type} ${route.leafId}`; this.panel.log(`开始播放:${title}`); const ready = await Utils.poll(() => Boolean(AiWorkspace.getMedia()), { interval: 500, timeout: 20000 }); let media = AiWorkspace.getMedia(); if (!ready || !media) { this.panel.log('未找到视频/音频元素,停止当前轮次'); return false; } const playbackState = { completed: false }; const shouldResume = () => !playbackState.completed; let stopObserve = () => { }; if (media.tagName.toLowerCase() === 'video') { Player.applySpeed(); Player.mute(); stopObserve = Player.observePause(media, shouldResume); } else { Player.applyMediaDefault(media); } const stopKeepAlive = AiWorkspace.keepAlive(shouldResume); this.panel.log(`已接管播放器:${media.tagName.toLowerCase()},目标倍速 ${Config.playbackRate}x,静音开启`); try { let startTime = Number(media.currentTime || 0); const started = await Utils.poll(() => { const currentMedia = AiWorkspace.getMedia(); if (currentMedia) media = currentMedia; if (!media) return false; const currentTime = Number(media.currentTime || 0); return currentTime > startTime + 0.5 || (!media.paused && media.readyState >= 2 && currentTime > startTime + 0.2); }, { interval: 500, timeout: 15000 }); if (!started) { this.panel.log('未确认到视频实际开始播放,停止当前轮次'); return false; } startTime = Number(media.currentTime || 0); let resolveEnded; const endedPromise = new Promise(resolve => { resolveEnded = resolve; }); const onEnded = () => { playbackState.completed = true; resolveEnded(true); }; media.addEventListener('ended', onEnded); const done = await Promise.race([ endedPromise, Utils.poll(() => { if (playbackState.completed) return true; const currentMedia = AiWorkspace.getMedia(); if (currentMedia) media = currentMedia; if (AiWorkspace.isPlayerDone(media, { startTime, minPlayedDelta: 3 })) { playbackState.completed = true; return true; } return false; }, { interval: 1000, timeout: await Utils.getDDL() }) ]); media.removeEventListener('ended', onEnded); playbackState.completed = true; if (!done) { this.panel.log('等待播放完成超时,停止当前轮次'); return false; } } finally { stopObserve(); stopKeepAlive(); } this.panel.log(`${title} 播放完成`); return true; } async solveExerciseQuestion(root, label = '') { const questionRoot = AiWorkspace.getExerciseQuestionBody(root); if (!questionRoot) { this.panel.log('未找到题目容器,停止当前轮次'); return false; } if (AiWorkspace.isExerciseAnswered(questionRoot)) { this.panel.log(`${label || '当前题目'} 已完成,跳过`); return true; } const listContainer = questionRoot.querySelector('.list-inline.list-unstyled-radio') || questionRoot.querySelector('.list-unstyled.list-unstyled-radio') || questionRoot.querySelector('.list-unstyled') || questionRoot.querySelector('ul.list') || questionRoot.querySelector('[class*="option-list"]') || questionRoot.querySelector('[class*="answer-list"]') || questionRoot.querySelector('.el-radio-group') || questionRoot.querySelector('.el-checkbox-group') || questionRoot.querySelector('ul') || questionRoot.querySelector('[role="radiogroup"]'); const optionCount = Solver.getOptionElements(listContainer).length; if (!optionCount) { this.panel.log(`${label || '当前题目'} 未找到选项,跳过`); return false; } const questionCanvas = await Solver.capture(questionRoot); if (!questionCanvas) { this.panel.log(`${label || '当前题目'} 截图失败,跳过`); return false; } const maxRetry = 3; for (let retryCount = 0; retryCount < maxRetry; retryCount++) { try { if (retryCount > 0) this.panel.log(`${label || '当前题目'} 重试 ${retryCount}/${maxRetry - 1}`); this.panel.log('🤖 请求 AI 获取答案...'); const aiText = await Solver.askAI('', optionCount, questionCanvas); const ok = await Solver.autoSelectAndSubmit(aiText, questionRoot); if (!ok) throw new Error('自动作答未完成'); await Utils.sleep(1200); return true; } catch (err) { this.panel.log(`AI 答题失败:${err}`); if (retryCount < maxRetry - 1) await Utils.sleep(5000); } } return false; } async advanceExerciseQuestion(root, previousFingerprint = '') { const currentRoot = AiWorkspace.getExerciseContainer() || root; const currentQuestion = AiWorkspace.getExerciseQuestionBody(currentRoot); const currentFingerprint = AiWorkspace.normalizeText(currentQuestion?.innerText || '').slice(0, 120); if (currentFingerprint && currentFingerprint !== previousFingerprint) { this.panel.log('页面已自动进入下一题,不再点击下一题'); return true; } const nextBtn = AiWorkspace.getExerciseActionButton(currentRoot, /下一题|下一道|下一步/); if (!nextBtn) return false; nextBtn.click(); return Utils.poll(() => { const latestRoot = AiWorkspace.getExerciseContainer() || currentRoot; const questionRoot = AiWorkspace.getExerciseQuestionBody(latestRoot); const fingerprint = AiWorkspace.normalizeText(questionRoot?.innerText || '').slice(0, 120); return fingerprint && fingerprint !== previousFingerprint; }, { interval: 500, timeout: 5000 }); } async handleExercise(route) { const featureFlags = Store.getFeatureConf(); if (!featureFlags.autoAI) { this.panel.log('已关闭 AI 自动答题,作业将直接跳过'); return true; } const ready = await Utils.poll(() => Boolean(AiWorkspace.getExerciseContainer()), { interval: 500, timeout: 20000 }); const root = AiWorkspace.getExerciseContainer(); if (!ready || !root) { this.panel.log('未找到作业容器,停止当前轮次'); return false; } this.panel.log(`开始处理作业:${AiWorkspace.getActiveLeafTitle() || route.leafId}`); const tabs = AiWorkspace.getExerciseQuestionTabs(root, { visibleOnly: false }); if (tabs.length) { this.panel.log(`检测到题目索引 ${tabs.length} 个,按题号顺序作答`); for (let i = 0; i < tabs.length; i++) { const currentRoot = AiWorkspace.getExerciseContainer() || root; const currentTabs = AiWorkspace.getExerciseQuestionTabs(currentRoot, { visibleOnly: false }); const currentTab = currentTabs[i]; if (!currentTab) { this.panel.log(`第 ${i + 1} 题入口不存在,跳过`); continue; } currentTab.scrollIntoView({ block: 'nearest' }); await Utils.sleep(400); currentTab.click(); await Utils.sleep(1200); await this.solveExerciseQuestion(AiWorkspace.getExerciseContainer() || currentRoot, `第 ${i + 1} 题`); } return true; } this.panel.log('未找到题号列表,尝试只处理当前题并按下一题推进'); let previousFingerprint = ''; for (let i = 0; i < 20; i++) { const currentRoot = AiWorkspace.getExerciseContainer() || root; const questionRoot = AiWorkspace.getExerciseQuestionBody(currentRoot); const fingerprint = AiWorkspace.normalizeText(questionRoot?.innerText || '').slice(0, 120); if (!fingerprint) break; if (i > 0 && fingerprint === previousFingerprint) break; await this.solveExerciseQuestion(currentRoot, this.getExerciseQuestionLabel(currentRoot) || `第 ${i + 1} 题`); previousFingerprint = fingerprint; const moved = await this.advanceExerciseQuestion(currentRoot, fingerprint); if (!moved) break; } return true; } // 讨论 / forum 页:点开后视为已学习,再进入下一单元 async handleForum(route) { const title = AiWorkspace.getActiveLeafTitle() || TypeLabel[route.type] || '讨论'; this.panel.log(`进入讨论区:${title}`); await Utils.sleep(1500); try { window.scrollTo(0, document.body.scrollHeight); await Utils.sleep(600); window.scrollTo(0, 0); } catch (_) { } this.panel.log(`讨论「${title}」已浏览,进入下一学习单元(不自动发言)`); await Utils.sleep(800); return true; } // 直接在ai-workspace页面处理课程的逻辑 async handleNext(count) { const boxes = Array.from(this.source || AiWorkspace.getAllScourse() || []); if (!boxes.length) { this.panel.warn('课程列表为空'); return; } if (count >= boxes.length) { this.panel.log('课程刷完啦 🎉'); this.panel.resetStartButton('刷完啦~'); Store.clearPendingAutoStart(); return; } const next = boxes[count]; const title = (next.querySelector('.leaf-item-title') || next).innerText?.replace(/\s+/g, ' ').trim() || `第${count + 1}项`; this.panel.log(`进入下一单元:${title}`); const clickable = next.querySelector('.leaf-item') || next.firstElementChild || next; clickable.click(); await Utils.sleep(2000); await this.run(false) } async run(preventScreenCheckSwitch = true) { // 仅开启一次防切屏 if (preventScreenCheckSwitch) preventScreenCheck(); const route = AiWorkspace.getRoute(); if (!route) { this.panel.log('当前页面已离开学习空间'); return; } if (!route.leafId) { this.panel.log('未能识别当前知识点'); return; } let ok = false; const rawType = String(route.type || '').toLowerCase(); const isDiscussLike = rawType.includes('discuss') || rawType.includes('forum') || rawType.includes('taolun') || rawType.includes('topic'); if (route.type === 'video' || route.type === 'audio') { ok = await this.handleMedia(route); } else if (route.type === 'exercise') { ok = await this.handleExercise(route); } else if (isDiscussLike) { ok = await this.handleForum(route); } else { const typeLabel = TypeLabel[route.type] || route.type; this.panel.log(`当前类型为「${typeLabel}」,暂不自动处理,已跳过`); await Utils.sleep(2000); ok = true; } if (!ok) this.panel.warn("(该视频可能已经刷完了),即将跳过开始下一个"); // 继续下一个 await this.autoSelect() } } // ---- 路由 ---- function start() { // ---- ai-workspace获取课程根目录信息并保存(处理完一个课程重定向到根目录) ---- const classroomId = Utils.getCurrentClassroomId(); const returnUrl = Utils.returnUrl() Store.setPendingAutoStart(classroomId, returnUrl); const aiRoute = AiWorkspace.getRoute(); if (aiRoute) { const typeLabel = TypeLabel[aiRoute.type] || aiRoute.type; panel.log(`正在匹配处理逻辑:学习空间/${typeLabel}`); new AiWorkspaceRunner(panel).run(); return; } // ---- ai-workspace end const url = location.host; const path = location.pathname.split('/'); const matchURL = `${url}${path[0]}/${path[1]}/${path[2]}`; panel.log(`正在匹配处理逻辑:${matchURL}`); if (matchURL.includes('yuketang.cn/v2/web') || matchURL.includes('gdufemooc.cn/v2/web')) { new V2Runner(panel).run(); } else if (matchURL.includes('yuketang.cn/pro/lms') || matchURL.includes('gdufemooc.cn/pro/lms')) { if (document.querySelector('.btn-next')) { new ProNewRunner(panel).run(); } else { new ProOldRunner(panel).run(); } } else { panel.resetStartButton('开始刷课'); panel.log('当前页面非刷课页面,应匹配:学习目录(/v2/web)、旧版学习页(/pro/lms)或学习空间(/ai-workspace/lms-graph)'); } } // ---- 启动 ---- async function boot() { if (Utils.inIframe()) return; await Utils.waitForMountTarget(); try { Config.playbackRate = Store.getPlaybackRate(); panel = createPanel(); panel.log(`雨课堂刷课助手 v${Config.version} 已加载`); panel.log(`当前默认倍速:${Config.playbackRate === 1 ? '单倍' : '双倍'}(${Config.playbackRate}x)`); panel.setStartHandler(start); const pendingAutoStart = Store.getPendingAutoStart(); const currentClassroomId = Utils.getCurrentClassroomId(); if ( pendingAutoStart && Utils.isSupportedLearningPage() && currentClassroomId && pendingAutoStart.classroomId === currentClassroomId ) { panel.log(`检测到跨页面跳转,自动恢复刷课:课堂 ${currentClassroomId}`); setTimeout(() => panel.start(), 1200); } } catch (err) { console.error('面板初始化失败:', err); } } boot(); })();