// ==UserScript== // @name 中国矿业大学-实验慕课小助手 (labsafe专版 v4.0) // @namespace http://labsafe.cumt.edu.cn/ // @version 4.0 // @description 默认2倍速、自动答题提交、修复目录假跳转(行级坐标去重+精确节数递进+可视化高亮框验证) // @match *://labsafe.cumt.edu.cn/* // @match *://dygx.cumt.edu.cn/* // @match *://*.cumt.edu.cn/* // @run-at document-end // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; if (window.__CUMT_LABSAFE_V4__) return; window.__CUMT_LABSAFE_V4__ = true; const CONFIG = { running: true, targetRate: parseFloat(localStorage.getItem('cumt_v4_rate')) || 2.0, lockRate: true, quizMode: 'rotate', customSubmitSelector: localStorage.getItem('cumt_v4_submit_sel') || '', // 存储绑定目录的几何与选择器特征 bindMeta: JSON.parse(localStorage.getItem('cumt_v4_bind_meta') || 'null'), currentIndex: parseInt(localStorage.getItem('cumt_v4_cur_idx') || '0', 10), totalCount: 0, lastJumpTime: 0, lastQuizHash: '', tryOptionIndex: 0, internalRateChanging: false }; function getAllDocuments() { const docs = [document]; document.querySelectorAll('iframe').forEach(ifr => { try { if (ifr.contentDocument) docs.push(ifr.contentDocument); } catch (e) {} }); return docs; } function isVisible(el) { if (!el || !el.getBoundingClientRect) return false; if (el.closest && el.closest('#cumt-helper-panel')) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) { return false; } const rect = el.getBoundingClientRect(); return rect.width > 4 && rect.height > 4; } // 强力真实点击:同时派发鼠标事件序列 + 原生 el.click()(原生 click 会自动向父级冒泡触发 Vue @click) function fireNativeClick(el) { if (!el) return false; try { const win = el.ownerDocument.defaultView || window; const rect = el.getBoundingClientRect(); const clientX = rect.left + rect.width / 2; const clientY = rect.top + rect.height / 2; ['pointerdown', 'mousedown', 'pointerup', 'mouseup'].forEach(type => { el.dispatchEvent(new win.MouseEvent(type, { bubbles: true, cancelable: true, view: win, clientX, clientY })); }); // 关键:调用原生 click() 方法,确保 Vue / React / 原生 a 标签 100% 触发 if (typeof el.click === 'function') { el.click(); } else { el.dispatchEvent(new win.MouseEvent('click', { bubbles: true, cancelable: true, view: win })); } if (el.tagName === 'INPUT') { el.checked = true; el.dispatchEvent(new win.Event('change', { bubbles: true })); el.dispatchEvent(new win.Event('input', { bubbles: true })); } return true; } catch (e) { try { el.click(); } catch (err) {} return true; } } // 获取当前播放视频的特征签名(用于校验点击后视频是否真正发生了切换) function getVideoSignature() { const docs = getAllDocuments(); for (const doc of docs) { const v = doc.querySelector('video'); if (v) { return `${location.href}|${v.currentSrc || v.src || ''}|${v.duration || 0}`; } } return location.href; } // 提取元素的静态类名(剔除动态高亮类名) function getStaticClasses(el) { if (!el || !el.className || typeof el.className !== 'string') return []; return el.className.trim().split(/\s+/).filter(c => c && !/(^|[-_])(active|current|playing|selected|on|hover|focus|open|expand|checked|this)($|[-_])/i.test(c) ); } function buildSimpleSelector(el) { if (!el || el.nodeType !== 1) return ''; let sel = el.tagName.toLowerCase(); const cls = getStaticClasses(el); if (cls.length > 0) { sel += '.' + cls.slice(0, 2).map(c => CSS.escape(c)).join('.'); } return sel; } // ==================== 核心:智能提取目录列表(纵向坐标行级去重) ==================== // 给定用户点击的目录元素 clickedEl,构建可匹配整列目录每一节对应位置的元数据 function createBindMeta(clickedEl) { const rect = clickedEl.getBoundingClientRect(); const leafSel = buildSimpleSelector(clickedEl); const parentEl = clickedEl.parentElement; const parentSel = parentEl && parentEl.tagName !== 'BODY' ? buildSimpleSelector(parentEl) : ''; const grandEl = parentEl ? parentEl.parentElement : null; const grandSel = grandEl && grandEl.tagName !== 'BODY' ? buildSimpleSelector(grandEl) : ''; let fullSel = leafSel; if (parentSel) fullSel = `${parentSel} > ${fullSel}`; if (grandSel) fullSel = `${grandSel} > ${fullSel}`; return { selector: fullSel, fallbackSelector: parentSel ? `${parentSel} > ${clickedEl.tagName.toLowerCase()}` : leafSel, left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height), tagName: clickedEl.tagName }; } // 根据绑定元数据,获取页面上所有课程小节(按纵向行坐标严格去重,每行只保留 1 个节点) function getBoundLessonItems() { if (!CONFIG.bindMeta) return []; const meta = CONFIG.bindMeta; const docs = getAllDocuments(); for (const doc of docs) { let rawNodes = Array.from(doc.querySelectorAll(meta.selector)).filter(isVisible); if (rawNodes.length < 2 && meta.fallbackSelector) { rawNodes = Array.from(doc.querySelectorAll(meta.fallbackSelector)).filter(isVisible); } // 过滤条件:必须与绑定元素处于同一侧边栏列(水平 X 坐标相近),排除顶栏或其它区域无关元素 const colFiltered = rawNodes.filter(el => { const r = el.getBoundingClientRect(); const sameColumn = Math.abs(r.left - meta.left) < 120; const reasonableHeight = r.height >= 12 && r.height <= 120; const hasText = (el.innerText || el.textContent || '').trim().length > 0; return sameColumn && reasonableHeight && hasText; }); // 关键:按垂直高度(top)排序,并将同一水平行(top 相差小于 12px)的多个元素合并去重! colFiltered.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); const deduped = []; for (const el of colFiltered) { const r = el.getBoundingClientRect(); if (deduped.length === 0) { deduped.push(el); } else { const prev = deduped[deduped.length - 1]; const prevRect = prev.getBoundingClientRect(); // 如果两个元素在同一行(垂直高度差 < 12px)或存在包含关系,只保留宽度更接近点击目标的那一个 if (Math.abs(r.top - prevRect.top) < 12 || prev.contains(el) || el.contains(prev)) { if (Math.abs(r.width - meta.width) < Math.abs(prevRect.width - meta.width)) { deduped[deduped.length - 1] = el; } } else { deduped.push(el); } } } if (deduped.length > 0) { CONFIG.totalCount = deduped.length; updateIndexDisplay(); return deduped; } } return []; } // 对目标目录项执行穿透式点击(先点目标本身,若 350ms 后视频未切换,再点同行内的标题/链接/按钮) function clickLessonTarget(targetEl, indexLabel) { if (!targetEl) return; // 闪烁橙色边框提示正在点击哪一节 const oldOutline = targetEl.style.outline; targetEl.style.outline = '3px solid #f97316'; setTimeout(() => { targetEl.style.outline = oldOutline || ''; }, 2500); targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); const beforeSig = getVideoSignature(); // 第一阶段:直接点击绑定的精确元素(事件会自动向上冒泡到行容器) fireNativeClick(targetEl); setStatus(`⏭️ 正在切换至第 ${indexLabel} 节: ${(targetEl.innerText || '').trim().slice(0, 14)}...`); // 第二阶段:350ms 后检查视频是否已切换;若未切换,穿透点击该行容器内的所有可交互子节点 setTimeout(() => { const afterSig = getVideoSignature(); if (afterSig === beforeSig) { const rowContainer = targetEl.closest('li, dd, .el-tree-node__content, .el-menu-item, [class*="item"], [class*="lesson"], [class*="video"], [class*="catalog"]') || targetEl.parentElement || targetEl; const clickables = rowContainer.querySelectorAll('a, button, [class*="title"], [class*="name"], [class*="play"], span'); clickables.forEach(sub => { if (sub !== targetEl && isVisible(sub)) { fireNativeClick(sub); } }); // 补充触发双击(部分树形组件需要 dblclick 或对行容器直接 click) if (rowContainer !== targetEl) fireNativeClick(rowContainer); } }, 350); } // ==================== 1. 自动跳转下一节 ==================== function goToNextVideo(isManual = false) { const now = Date.now(); if (!isManual && now - CONFIG.lastJumpTime < 5000) return false; CONFIG.lastJumpTime = now; const docs = getAllDocuments(); // 先关闭可能阻挡切集的确认弹窗 for (const doc of docs) { const modalBtn = doc.querySelector('.el-message-box__btns .el-button--primary, .el-dialog__footer .el-button--primary, .layui-layer-btn0, .ant-modal-footer .ant-btn-primary'); if (modalBtn && isVisible(modalBtn)) { fireNativeClick(modalBtn); } } // 优先使用绑定的目录列表,严格按 CONFIG.currentIndex + 1 顺序推进 const items = getBoundLessonItems(); if (items.length > 0) { const nextIdx = CONFIG.currentIndex + 1; if (nextIdx < items.length) { CONFIG.currentIndex = nextIdx; localStorage.setItem('cumt_v4_cur_idx', String(nextIdx)); updateIndexDisplay(); clickLessonTarget(items[nextIdx], `${nextIdx + 1}/${items.length}`); return true; } else { setStatus(`🎉 当前目录全部 ${items.length} 节已播完!`); return false; } } // 若尚未绑定目录,尝试寻找页面上显式的“下一节/下一集”按钮 for (const doc of docs) { const allBtns = doc.querySelectorAll('button, a, span, div, li'); const nextRegex = /^(下一节|下一集|下一课|下一个|下一视频|继续下一节)$/; for (const btn of allBtns) { if (!isVisible(btn)) continue; const txt = (btn.innerText || btn.textContent || '').replace(/\s+/g, ''); if (nextRegex.test(txt)) { fireNativeClick(btn); setStatus(`⏭️ 已点击「${txt}」`); return true; } } } setStatus('⚠️ 请先点下方紫色【🎯绑定当前节】再点右侧正在播的这节标题'); return false; } // ==================== 2. 自动答题与点击提交 ==================== function findSubmitButton(doc) { if (CONFIG.customSubmitSelector) { const btns = Array.from(doc.querySelectorAll(CONFIG.customSubmitSelector)).filter(isVisible); if (btns.length > 0) return btns[btns.length - 1]; } const knownSelectors = [ '.el-dialog__footer .el-button--primary', '.el-message-box__btns .el-button--primary', '.ant-modal-footer .ant-btn-primary', '.layui-layer-btn0', 'button[type="submit"]' ]; for (const sel of knownSelectors) { const btns = Array.from(doc.querySelectorAll(sel)).filter(isVisible); if (btns.length > 0) return btns[btns.length - 1]; } const candidates = doc.querySelectorAll('button, a, input[type="button"], div[class*="btn"], span[class*="btn"], .el-button, .ant-btn, .btn'); const targetRegex = /^(提交|提交答案|确认提交|确定|确认|继续|继续学习|完成|下一题|回答|我知道了)$/; for (const el of candidates) { if (!isVisible(el)) continue; const txt = (el.innerText || el.value || el.textContent || '').replace(/\s+/g, ''); if (targetRegex.test(txt)) return el; } return null; } function checkAndSolveQuiz() { const docs = getAllDocuments(); for (const doc of docs) { const options = Array.from(doc.querySelectorAll( 'input[type="radio"], input[type="checkbox"], .el-radio, .el-checkbox, .ant-radio-wrapper, .ant-checkbox-wrapper, .layui-form-radio, .layui-form-checkbox, [class*="option-item"], [class*="question-option"]' )).filter(el => isVisible(el) || (el.parentElement && isVisible(el.parentElement))); if (options.length > 0) { setStatus('📝 检测到弹题,正在作答...'); const quizBox = options[0].closest('.el-dialog, .ant-modal, .layui-layer, .modal, form, [class*="quiz"], [class*="question"], body'); const currentHash = (quizBox ? quizBox.innerText.slice(0, 60) : '') + options.length; if (currentHash !== CONFIG.lastQuizHash) { CONFIG.lastQuizHash = currentHash; CONFIG.tryOptionIndex = 0; } const idx = CONFIG.quizMode === 'first' ? 0 : (CONFIG.tryOptionIndex % options.length); const targetOption = options[idx]; const clickTarget = isVisible(targetOption) ? targetOption : targetOption.parentElement; fireNativeClick(clickTarget); if (targetOption.tagName === 'INPUT' && targetOption !== clickTarget) { fireNativeClick(targetOption); } const isCheckbox = targetOption.type === 'checkbox' || /checkbox/i.test(targetOption.className); if (isCheckbox && options.length > 1 && idx === 0) { const secondTarget = isVisible(options[1]) ? options[1] : options[1].parentElement; setTimeout(() => fireNativeClick(secondTarget), 150); } setTimeout(() => { const submitBtn = findSubmitButton(doc); if (submitBtn) { fireNativeClick(submitBtn); setStatus(`✅ 已选第 ${idx + 1} 项并点击提交`); setTimeout(() => { const confirmBtn = findSubmitButton(doc); if (confirmBtn) fireNativeClick(confirmBtn); }, 1000); } else { setStatus('⚠️ 已选答案,请点下方【🎯绑定提交】'); } CONFIG.tryOptionIndex++; }, 550); return true; } } return false; } // ==================== 3. 视频倍速与自动连播监控 ==================== function applyPlaybackRate(video, rate) { if (!video) return; CONFIG.internalRateChanging = true; video.playbackRate = rate; setTimeout(() => { CONFIG.internalRateChanging = false; }, 200); } function monitorVideo() { const docs = getAllDocuments(); let foundVideo = false; // 同步刷新目录总节数显示 if (CONFIG.bindMeta && CONFIG.totalCount === 0) { getBoundLessonItems(); } for (const doc of docs) { const videos = doc.querySelectorAll('video'); for (const video of videos) { foundVideo = true; video.muted = true; if (!video.__cumt_v4_bound__) { video.__cumt_v4_bound__ = true; video.addEventListener('ratechange', () => { if (!CONFIG.internalRateChanging && video.playbackRate > 0) { if (video.playbackRate === 1.0 && CONFIG.targetRate !== 1.0 && video.currentTime < 3) { applyPlaybackRate(video, CONFIG.targetRate); } else { CONFIG.targetRate = video.playbackRate; localStorage.setItem('cumt_v4_rate', CONFIG.targetRate); const rateSelect = document.getElementById('cumt-rate-select'); if (rateSelect) rateSelect.value = String(CONFIG.targetRate); } } }); video.addEventListener('ended', () => { if (CONFIG.running) { setStatus('🎉 本节播放完毕,正在切换下一节...'); setTimeout(() => goToNextVideo(false), 1000); } }); } if (CONFIG.lockRate && Math.abs(video.playbackRate - CONFIG.targetRate) > 0.05) { applyPlaybackRate(video, CONFIG.targetRate); } if (video.ended || (video.duration > 10 && video.currentTime >= video.duration - 0.6)) { setStatus('🎉 本节已完成,准备切换下一节...'); goToNextVideo(false); return; } if (video.paused) { video.play().catch(() => { const playBtn = doc.querySelector('.vjs-big-play-button, .prism-big-play-btn, .dplayer-play-icon, .el-icon-video-play, [class*="play-btn"]'); if (playBtn && isVisible(playBtn)) fireNativeClick(playBtn); }); } const pct = video.duration ? Math.round((video.currentTime / video.duration) * 100) : 0; setStatus(`▶️ 播放中 (${pct}%) | 倍速: ${video.playbackRate}x`); } } if (!foundVideo) { setStatus('🔍 等待进入视频播放页...'); } } // ==================== 4. 悬浮控制面板与目录拾取器 ==================== function updateIndexDisplay() { const el = document.getElementById('cumt-idx-display'); if (!el) return; if (CONFIG.bindMeta && CONFIG.totalCount > 0) { el.textContent = `第 ${CONFIG.currentIndex + 1} / ${CONFIG.totalCount} 节`; el.style.color = '#4ade80'; } else { el.textContent = '未绑定目录'; el.style.color = '#fbbf24'; } } function createPanel() { const panel = document.createElement('div'); panel.id = 'cumt-helper-panel'; panel.style.cssText = ` position: fixed; right: 18px; bottom: 18px; z-index: 2147483647; width: 280px; background: #1e293b; color: #f8fafc; border-radius: 10px; padding: 12px; font-size: 13px; font-family: system-ui, -apple-system, sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.4); border: 1px solid #334155; line-height: 1.5; user-select: none; `; panel.innerHTML = `
⚡ 矿大安全课助手 v4.0
正在初始化...
当前定位:
未绑定目录
播放倍速:
`; document.body.appendChild(panel); updateIndexDisplay(); // 节数微调按钮 document.getElementById('cumt-idx-prev').onclick = () => { if (CONFIG.currentIndex > 0) { CONFIG.currentIndex--; localStorage.setItem('cumt_v4_cur_idx', String(CONFIG.currentIndex)); updateIndexDisplay(); } }; document.getElementById('cumt-idx-next').onclick = () => { if (CONFIG.currentIndex + 1 < CONFIG.totalCount) { CONFIG.currentIndex++; localStorage.setItem('cumt_v4_cur_idx', String(CONFIG.currentIndex)); updateIndexDisplay(); } }; const rateSelect = document.getElementById('cumt-rate-select'); rateSelect.value = CONFIG.lockRate ? String(CONFIG.targetRate) : '0'; rateSelect.onchange = function () { const val = parseFloat(this.value); if (val === 0) { CONFIG.lockRate = false; setStatus('👌 已交由网页原生播放器控制倍速'); } else { CONFIG.lockRate = true; CONFIG.targetRate = val; localStorage.setItem('cumt_v4_rate', val); getAllDocuments().forEach(doc => { doc.querySelectorAll('video').forEach(v => applyPlaybackRate(v, val)); }); setStatus(`⚡ 倍速已设为 ${val}x`); } }; document.getElementById('cumt-toggle-btn').onclick = function () { CONFIG.running = !CONFIG.running; this.textContent = CONFIG.running ? '运行中' : '已暂停'; this.style.background = CONFIG.running ? '#22c55e' : '#ef4444'; setStatus(CONFIG.running ? '▶️ 已恢复自动运行' : '⏸️ 脚本已暂停'); }; document.getElementById('cumt-test-next').onclick = () => { goToNextVideo(true); }; document.getElementById('cumt-clear-bind').onclick = () => { CONFIG.customSubmitSelector = ''; CONFIG.bindMeta = null; CONFIG.currentIndex = 0; CONFIG.totalCount = 0; localStorage.removeItem('cumt_v4_submit_sel'); localStorage.removeItem('cumt_v4_bind_meta'); localStorage.removeItem('cumt_v4_cur_idx'); updateIndexDisplay(); setStatus('🗑️ 已清除绑定,请重新点【🎯绑定当前节】'); }; document.getElementById('cumt-pick-submit').onclick = () => startPicker('submit'); document.getElementById('cumt-pick-list').onclick = () => startPicker('list'); } function setStatus(msg) { const el = document.getElementById('cumt-status'); if (el) el.textContent = msg; } // 监听用户在网页目录上的手动点击,自动同步更新“当前第几节” document.addEventListener('click', (e) => { if (!CONFIG.bindMeta || (e.target.closest && e.target.closest('#cumt-helper-panel'))) return; const items = getBoundLessonItems(); if (items.length > 0) { const clickedRect = e.target.getBoundingClientRect(); const matchIdx = items.findIndex(item => { if (item === e.target || item.contains(e.target) || e.target.contains(item)) return true; const r = item.getBoundingClientRect(); return Math.abs(r.top - clickedRect.top) < 12 && Math.abs(r.left - clickedRect.left) < 120; }); if (matchIdx !== -1 && matchIdx !== CONFIG.currentIndex) { CONFIG.currentIndex = matchIdx; localStorage.setItem('cumt_v4_cur_idx', String(matchIdx)); updateIndexDisplay(); } } }, true); // 可视化拾取器 function startPicker(type) { setStatus( type === 'submit' ? '👆 请点击弹窗上的【提交/确定】按钮...' : '👆 请点击课程目录中【正在播放的这一节标题文字】...' ); const docs = getAllDocuments(); let lastHover = null; function onMove(e) { if (e.target.closest && e.target.closest('#cumt-helper-panel')) return; if (lastHover && lastHover !== e.target) { lastHover.style.outline = lastHover.__old_outline__ || ''; } lastHover = e.target; lastHover.__old_outline__ = lastHover.style.outline; lastHover.style.outline = '3px solid #ef4444'; } function onClick(e) { if (e.target.closest && e.target.closest('#cumt-helper-panel')) return; e.preventDefault(); e.stopPropagation(); if (lastHover) lastHover.style.outline = lastHover.__old_outline__ || ''; cleanup(); if (type === 'submit') { const sel = buildSimpleSelector(e.target); CONFIG.customSubmitSelector = sel; localStorage.setItem('cumt_v4_submit_sel', sel); setStatus(`✅ 已绑定提交按钮: ${sel}`); fireNativeClick(e.target); } else { const meta = createBindMeta(e.target); CONFIG.bindMeta = meta; localStorage.setItem('cumt_v4_bind_meta', JSON.stringify(meta)); const items = getBoundLessonItems(); const targetTop = e.target.getBoundingClientRect().top; let idx = items.findIndex(item => Math.abs(item.getBoundingClientRect().top - targetTop) < 12); if (idx === -1) idx = 0; CONFIG.currentIndex = idx; CONFIG.totalCount = items.length; localStorage.setItem('cumt_v4_cur_idx', String(idx)); updateIndexDisplay(); // 用绿色虚线框短暂高亮所有识别出的课程小节,方便肉眼确认 items.forEach((item, i) => { const old = item.style.outline; item.style.outline = i === idx ? '3px solid #38bdf8' : '2px dashed #22c55e'; setTimeout(() => { item.style.outline = old || ''; }, 3000); }); setStatus(`✅ 识别到 ${items.length} 节课程(已绿框高亮),当前第 ${idx + 1} 节`); } } function cleanup() { docs.forEach(d => { d.removeEventListener('mousemove', onMove, true); d.removeEventListener('click', onClick, true); }); } docs.forEach(d => { d.addEventListener('mousemove', onMove, true); d.addEventListener('click', onClick, true); }); } // ==================== 启动主循环 ==================== createPanel(); setInterval(() => { if (!CONFIG.running) return; const hasQuiz = checkAndSolveQuiz(); if (!hasQuiz) { monitorVideo(); } }, 1800); })();