// ==UserScript== // @name 磨题帮爬题 // @namespace https://tampermonkey.net/ // @version 1.0 // @description 自动抓取磨题帮题库,支持单选、多选、判断、填空,提取题目导出TXT/Excel // @match https://www.motibang.com/* // @grant none // @run-at document-end // ==/UserScript== (async function () { 'use strict'; // ========== 常量配置 ========== const MAX_FAIL = 5; // 连续抓取失败上限,超过则自动停止 const PANEL_WIDTH = 280; // 控制面板宽度(像素) const DELAY_ANSWER = 800; // 点击"看答案"后等待渲染的时间(毫秒) const DELAY_NEXT = 1000; // 点击"下一页"后等待加载的时间(毫秒) const DELAY_RETRY = 400; // 失败重试前的等待时间(毫秒) const NEXT_RETRY = 5; // 查找"下一页"按钮的最大重试次数 const NEXT_INTERVAL = 300; // 查找"下一页"按钮的重试间隔(毫秒) const ANIM_DURATION = 300; // UI 动画持续时间(毫秒) const TIMEOUT = 12000; // 单题抓取的超时时间(毫秒) const BTN_HEIGHT = '38px'; // 面板按钮统一高度 // ========== 通用工具函数 ========== /** 异步延时函数,返回指定毫秒后 resolve 的 Promise */ const sleep = ms => new Promise(r => setTimeout(r, ms)); /** 清理文件名中的非法字符(Windows/Mac 兼容) */ const cleanFileName = str => (str || '题库').replace(/[\/\\:*?"<>|]/g, '_'); /** * 判断 DOM 元素当前是否可以被点击 * 条件:存在、未禁用、可见、未被隐藏 */ function isClickable(el) { if (!el) return false; if (el.disabled) return false; if (el.offsetParent === null) return false; const style = window.getComputedStyle(el); if (style.visibility === 'hidden' || style.opacity === '0') return false; if (style.display === 'none') return false; return true; } /** * 为异步操作添加超时保护 * 如果 promise 在 ms 毫秒内未完成,返回 fallback */ async function withTimeout(promise, ms, fallback = null) { let timer; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Timeout')), ms); }); try { const result = await Promise.race([promise, timeoutPromise]); clearTimeout(timer); return result; } catch (e) { clearTimeout(timer); return fallback; } } // ========== 全局运行状态 ========== let isRunning = false; // 是否正在抓取中 let output = ''; // TXT 导出内容的累积字符串 let records = []; // Excel 导出的题目记录数组 let count = 0; // 已成功抓取的题目数量 let totalQuestions = 0; // 题库总题数(从页面解析) let failCount = 0; // 连续抓取失败计数 let examTitle = ''; // 试卷标题(用于导出文件名) let isDark = false; // 深色模式开关 let currentStatus = ''; // 当前状态栏文本 let isMinimized = false; // 面板是否已最小化 let exportExpanded = false; // 导出子面板是否已展开 const seenQuestions = new Set(); // 已抓取题目去重集合 let panelBodyHeight = 0; // 面板本体高度缓存(用于折叠动画) // ========== 主题配色方案 ========== /** 根据当前 isDark 状态返回主题配色对象 */ function getTheme() { return { panelBg: isDark ? 'rgba(30,30,30,0.96)' : '#ffffff', panelShadow: isDark ? '0 4px 22px rgba(0,0,0,0.65)' : '0 4px 22px rgba(0,0,0,0.17)', headerBg: isDark ? 'rgba(50,50,55,0.98)' : '#f0f2f5', headerBorder: isDark ? '#555' : '#dee2e6', textColor: isDark ? '#e8e8e8' : '#1a1a2e', subTextColor: isDark ? '#aaa' : '#6c757d', btnColor: isDark ? '#ddd' : '#495057', }; } /** 将当前主题应用到面板的所有子元素 */ function applyTheme() { const t = getTheme(); panel.style.background = t.panelBg; panel.style.boxShadow = t.panelShadow; dragHandle.style.background = t.headerBg; dragHandle.style.borderBottomColor = t.headerBorder; panelTitleWrap.style.color = t.textColor; darkBtn.style.color = t.btnColor; minBtn.style.color = t.btnColor; progressRow.style.color = t.textColor; statusEl.style.color = t.subTextColor; } // ========== 控制面板 DOM 构建 ========== /** 创建整个控制面板并插入页面 */ const panel = document.createElement('div'); panel.style.cssText = `position:fixed;top:80px;right:28px;z-index:2147483647;border-radius:14px;width:${PANEL_WIDTH}px;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;cursor:default;user-select:none;overflow:visible;touch-action:none;transition:box-shadow ${ANIM_DURATION}ms ease,background ${ANIM_DURATION}ms ease;`; panel.innerHTML = `
📝磨题帮爬题
进度:0/0
就绪
`; document.body.appendChild(panel); // ========== DOM 元素引用缓存 ========== const dragHandle = document.getElementById('dragHandle'); // 拖拽手柄 const panelTitleWrap = document.getElementById('panelTitleWrap'); // 标题栏文字区域 const statusText = document.getElementById('statusText'); // 最小化时显示的状态文字 const minBtn = document.getElementById('minBtn'); // 最小化按钮 const darkBtn = document.getElementById('darkBtn'); // 深色模式按钮 const panelBody = document.getElementById('panelBody'); // 面板内容区域 const progressRow = document.getElementById('progressRow'); // 进度显示行 const statusEl = document.getElementById('status'); // 状态文字元素 const startStopBtn = document.getElementById('startStopBtn'); // 开始/暂停按钮 const exportBtn = document.getElementById('exportBtn'); // 导出按钮 const exportSub = document.getElementById('exportSub'); // 导出子面板 const exportTxtBtn = document.getElementById('exportTxtBtn'); // TXT 导出按钮 const exportCsvBtn = document.getElementById('exportCsvBtn'); // Excel 导出按钮 // ========== 面板高度缓存 ========== /** 在面板展开状态下缓存其真实高度,供折叠动画使用 */ function cachePanelHeight() { if (!isMinimized) { const pmh = panelBody.style.maxHeight; const po = panelBody.style.overflow; panelBody.style.maxHeight = 'none'; panelBody.style.overflow = 'visible'; panelBodyHeight = panelBody.scrollHeight; panelBody.style.maxHeight = pmh; panelBody.style.overflow = po; } } // ========== 导出子面板展开/收起 ========== /** 展开导出子面板,显示 TXT 和 Excel 两个按钮 */ function expandExport() { if (exportExpanded) { collapseExport(); return; } exportExpanded = true; exportBtn.style.opacity = '0'; exportBtn.style.pointerEvents = 'none'; exportSub.style.transform = 'translateY(0) scale(1)'; exportSub.style.opacity = '1'; exportSub.style.pointerEvents = 'auto'; } /** 收起导出子面板,只显示"导出题库"主按钮 */ function collapseExport() { exportExpanded = false; exportSub.style.transform = 'translateY(-10px) scale(0.88)'; exportSub.style.opacity = '0'; exportSub.style.pointerEvents = 'none'; exportBtn.style.opacity = '1'; exportBtn.style.pointerEvents = 'auto'; } // 绑定导出按钮事件 exportBtn.onclick = (e) => { e.stopPropagation(); expandExport(); }; exportTxtBtn.onclick = (e) => { e.stopPropagation(); collapseExport(); saveTxt(); }; exportCsvBtn.onclick = (e) => { e.stopPropagation(); collapseExport(); saveExcel(); }; // 拖拽标题栏或点击标题栏时自动收起导出子面板 dragHandle.addEventListener('mousedown', collapseExport); dragHandle.addEventListener('touchstart', collapseExport, { passive: false }); // ========== 面板拖拽功能 ========== let isDragging = false, startX, startY, startLeft, startTop; /** 获取鼠标或触摸事件的客户端坐标 */ function getPos(e) { return e.touches?.length > 0 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : { x: e.clientX, y: e.clientY }; } /** 拖拽开始:记录起始位置,暂停面板过渡动画 */ function dragStart(e) { if (e.target.closest('button')) return; e.preventDefault(); isDragging = true; const p = getPos(e); startX = p.x; startY = p.y; const r = panel.getBoundingClientRect(); startLeft = r.left; startTop = r.top; panel.style.transition = 'none'; } /** 拖拽移动:实时更新面板位置,限制在视口范围内 */ function dragMove(e) { if (!isDragging) return; e.preventDefault(); const p = getPos(e); let l = startLeft + p.x - startX; let t = startTop + p.y - startY; l = Math.max(0, Math.min(l, window.innerWidth - PANEL_WIDTH)); t = Math.max(0, Math.min(t, window.innerHeight - 60)); panel.style.left = l + 'px'; panel.style.top = t + 'px'; panel.style.right = 'auto'; } /** 拖拽结束:恢复面板过渡动画 */ function dragEnd() { if (isDragging) { isDragging = false; requestAnimationFrame(() => { panel.style.transition = `box-shadow ${ANIM_DURATION}ms ease,background ${ANIM_DURATION}ms ease`; }); } } // 绑定拖拽事件(同时支持鼠标和触摸) dragHandle.addEventListener('mousedown', dragStart); document.addEventListener('mousemove', dragMove); document.addEventListener('mouseup', dragEnd); dragHandle.addEventListener('touchstart', dragStart, { passive: false }); document.addEventListener('touchmove', dragMove, { passive: false }); document.addEventListener('touchend', dragEnd); document.addEventListener('touchcancel', dragEnd); // ========== 深色模式切换 ========== darkBtn.onclick = (e) => { e.stopPropagation(); collapseExport(); isDark = !isDark; darkBtn.textContent = isDark ? '●' : '○'; applyTheme(); }; // ========== 面板最小化/展开 ========== /** 切换面板最小化状态,使用 max-height 动画实现平滑折叠 */ async function toggleMinimize() { collapseExport(); isMinimized = !isMinimized; if (isMinimized) { cachePanelHeight(); panelBody.style.maxHeight = panelBodyHeight + 'px'; panelBody.style.opacity = '1'; void panelBody.offsetHeight; // 强制回流 panelBody.style.maxHeight = '0px'; panelBody.style.opacity = '0'; panelBody.style.padding = '0 14px'; panelBody.style.pointerEvents = 'none'; dragHandle.style.borderRadius = '14px'; dragHandle.style.borderBottom = 'none'; minBtn.textContent = '+'; } else { if (panelBodyHeight === 0) cachePanelHeight(); panelBody.style.maxHeight = '0px'; panelBody.style.opacity = '0'; panelBody.style.padding = '0 14px'; void panelBody.offsetHeight; panelBody.style.maxHeight = panelBodyHeight + 'px'; panelBody.style.opacity = '1'; panelBody.style.padding = '10px 14px 14px'; panelBody.style.pointerEvents = 'auto'; dragHandle.style.borderRadius = '14px 14px 0 0'; dragHandle.style.borderBottom = '1px solid'; minBtn.textContent = '−'; } updateTitle(); applyTheme(); } minBtn.onclick = (e) => { e.stopPropagation(); toggleMinimize(); }; // ========== 界面状态更新 ========== /** 更新标题栏:最小化时在标题栏右侧显示当前状态 */ function updateTitle() { if (isMinimized && currentStatus) { statusText.style.display = 'inline-block'; statusText.textContent = currentStatus; } else { statusText.style.display = 'none'; statusText.textContent = ''; } } /** 刷新进度显示(已抓取数 / 总题数) */ function setProgress() { const el = document.getElementById('progress'); if (el) el.innerText = `${count}/${totalQuestions || '?'}`; updateTitle(); } /** 设置底部状态栏文本,同时更新标题栏(最小化时可见) */ function setStatus(s) { currentStatus = s; if (statusEl) statusEl.innerText = s; updateTitle(); } // ========== 导出功能 ========== /** 导出为 TXT 文件(UTF-8 BOM 编码,兼容 Windows 记事本) */ function saveTxt() { if (!output) { setStatus('⚠️ 暂无数据'); return; } const b = new Blob(['\uFEFF' + output], { type: "text/plain;charset=utf-8" }); const u = URL.createObjectURL(b); const a = document.createElement("a"); a.href = u; a.download = `${cleanFileName(examTitle)}_${count}题.txt`; a.click(); URL.revokeObjectURL(u); setStatus('✅ TXT导出完成'); } /** 题型代码转中文名称 */ function typeToText(t) { const m = { single: '单选题', multiple: '多选题', judge: '判断题', fillblank: '填空题' }; return m[t] || '单选题'; } /** CSV 字段值转义:处理逗号、引号、换行符 */ function escapeCSV(s) { if (!s) return ''; const x = s.replace(/[\r\n]+/g, ' ').trim(); if (x.includes(',') || x.includes('"')) return `"${x.replace(/"/g, '""')}"`; return x; } /** 导出为 Excel (CSV) 文件,每个选项单独一列 */ function saveExcel() { if (records.length === 0) { setStatus('⚠️ 暂无数据'); return; } // 收集所有题目中出现过的选项字母 const allKeys = new Set(); records.forEach(r => { if (r.type === 'single' || r.type === 'multiple') { Object.keys(r.options).forEach(k => allKeys.add(k)); } }); const sortedKeys = Array.from(allKeys).sort(); // 构建表头 let csv = '\uFEFF题干,题型,' + sortedKeys.join(',') + ',答案\n'; // 逐行填充数据 records.forEach(r => { const q = escapeCSV(r.question); const t = typeToText(r.type); const optionCols = sortedKeys.map(k => escapeCSV(r.options[k] || '')); const a = escapeCSV(r.answer); csv += `${q},${t},${optionCols.join(',')},${a}\n`; }); const b = new Blob([csv], { type: "text/csv;charset=utf-8" }); const u = URL.createObjectURL(b); const a = document.createElement("a"); a.href = u; a.download = `${cleanFileName(examTitle)}_${count}题.csv`; a.click(); URL.revokeObjectURL(u); setStatus('✅ Excel导出完成'); } // ========== 清空答题记录并跳回第 1 题 ========== /** * 执行完整流程:点击"更多"→"清空所有答题记录"→确认弹窗点"是"→点击答题卡第 1 题 * 确保每次抓取都从第 1 题开始,避免因历史答题记录导致抓取不完整 */ async function resetAllRecords() { try { // 1. 点击"更多"按钮打开设置菜单 const moreBtn = Array.from(document.querySelectorAll('button')).find( b => b.textContent.trim() === '更多' && isClickable(b) ); if (!moreBtn) { setStatus('⚠️ 未找到更多按钮'); return false; } moreBtn.click(); await sleep(600); // 2. 在菜单中点击"清空所有答题记录" const clearBtn = Array.from(document.querySelectorAll('.clear-btn')).find( b => b.textContent.trim() === '清空所有答题记录' && b.offsetParent !== null ); if (!clearBtn) { moreBtn.click(); setStatus('⚠️ 未找到清空按钮'); return false; } clearBtn.click(); // 3. 轮询等待确认弹窗的"是"按钮出现(Element UI 弹窗渲染可能有延迟) let confirmBtn = null; const st = Date.now(); while (Date.now() - st < 3000) { confirmBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '是' && b.classList.contains('el-button--primary') && b.offsetParent !== null ); if (confirmBtn) break; await sleep(150); } if (!confirmBtn) { setStatus('⚠️ 未找到确认按钮'); return false; } confirmBtn.click(); setStatus('🔄 已清空记录'); await sleep(1500); // 4. 点击右侧答题卡中的第 1 题 const firstQ = Array.from(document.querySelectorAll('.question-col.pointer')).find( el => el.textContent.trim() === '1' && isClickable(el) ); if (firstQ) { firstQ.click(); setStatus('✅ 已跳回第1题'); await sleep(1000); } return true; } catch (e) { console.error('清空记录失败', e); return false; } } // ========== 页面元素定位与交互 ========== /** 点击"看答案"按钮(精确匹配文字,排除"更多"按钮) */ function clickShowAnswer() { const b = Array.from(document.querySelectorAll('button')).find(b => ['看答案', '查看答案', '显示答案'].includes(b.textContent.trim()) && isClickable(b) ); if (b) { b.click(); return true; } return false; } /** 等待正确答案渲染完成(支持选择题的 .correct 标记、填空答案、判断文本) */ async function waitAnswerReady(to = 3000) { const st = Date.now(); while (Date.now() - st < to) { if (document.querySelector('.button.correct.choice-abc')) return true; if (document.querySelector('.fillblank-answer')) return true; if (/正确答案[::]\s*[对错正确错误是否√×TFYNtfyn]/.test(document.body.innerText)) return true; await sleep(200); } return false; } /** 查找并点击"下一页"按钮(排除 disabled 状态) */ async function waitNextBtn() { for (let i = 0; i < NEXT_RETRY; i++) { const b = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '下一页' && isClickable(b) && !b.classList.contains('is-disabled') ); if (b) { b.click(); return true; } await sleep(NEXT_INTERVAL); } return false; } // ========== 题目数据提取 ========== /** 检测当前题目类型(填空 > 判断 > 多选 > 单选) */ function detectType() { if (document.querySelector('.fillblank-answer')) return 'fillblank'; const t = document.body.innerText; if (/正确答案[::]\s*[对错正确错误是否√×TFYNtfyn]/.test(t)) return 'judge'; const c = document.querySelectorAll('.button.correct.choice-abc'); if (c.length > 1) return 'multiple'; if (c.length === 1) return 'single'; if (/单选题/.test(t)) return 'single'; if (/多选题/.test(t)) return 'multiple'; return 'single'; } /** * 提取题干文本 * 优先从 .desc-container .content 内的 div 提取, * 兼容答完题(有 .color-1)和未答题(无 .color-1)两种状态 */ function getQuestion() { const q = document.querySelector('.question-content'); if (!q) return ''; const d = q.querySelector('.desc-container .content div') || q.querySelector('.desc-container .content'); if (d) return d.textContent.trim(); const t = q.textContent || ''; const m = t.match(/\d+\/\d+\s*(.+?)(?:A|$)/); if (m) return m[1].trim(); return t.split(/[A-F]\./)[0].replace(/^\d+\/\d+\s*/, '').trim(); } /** * 提取答案 * 选择题从 .button.correct.choice-abc 提取选项字母, * 判断题映射常见文字到"对/错", * 填空题从 .fillblank-answer 提取 */ function getAnswer(type) { if (type === 'fillblank') { const f = document.querySelector('.fillblank-answer'); return f ? f.textContent.trim() : ''; } const c = document.querySelectorAll('.button.correct.choice-abc'); if (c.length > 0) return Array.from(c).map(e => e.textContent.trim()).join(''); const t = document.body.innerText; if (type === 'judge') { const j = { '对': '对', '错': '错', '正确': '对', '错误': '错', '是': '对', '否': '错', '√': '对', '×': '错', 'T': '对', 'F': '错', 'Y': '对', 'N': '错', 't': '对', 'f': '错', 'y': '对', 'n': '错' }; const m = t.match(/正确答案[::]\s*([对错正确错误是否√×TFYNtfyn]+)/); if (m) return j[m[1].trim()] || m[1].trim(); } const m = t.match(/正确答案[是::\s]*([A-F]+)/i); return m ? m[1].toUpperCase() : ''; } /** * 提取选项键值对 * 从 .choice-option 容器中匹配选项字母按钮和选项文字 */ function getOptions() { const o = {}; document.querySelectorAll('.choice-option').forEach(el => { const b = el.querySelector('.button.choice-abc'); const d = el.querySelector('.desc-container .content div') || el.querySelector('.desc-container .content'); if (b && d) o[b.textContent.trim()] = d.textContent.trim(); }); return o; } /** 提取试卷标题(从包含"更多"的容器文本中截取) */ function getExamTitle() { const w = document.querySelector('[class*="question"]'); if (!w) return ''; const t = w.textContent || ''; const m = t.match(/^(.+?)更多/); if (m) return m[1].trim(); return t.split('更多')[0].trim(); } /** 从页面文本中解析题目总数(匹配 "当前/总数" 格式) */ function getTotal() { const m = document.body.innerText.match(/(\d+)\/(\d+)/); if (m) totalQuestions = parseInt(m[2]); } /** 等待题目区域加载完成(检查 .question-content 是否有内容) */ async function waitQuestionReady() { for (let i = 0; i < 20; i++) { const q = document.querySelector('.question-content'); if (q && q.textContent.trim()) return true; await sleep(300); } return false; } // ========== 单题抓取流程 ========== /** * 抓取当前页面显示的一道题目 * 流程:点击看答案 → 等待渲染 → 检测题型 → 提取题干/答案/选项 → 去重 */ async function grabOne() { try { clickShowAnswer(); if (!await waitAnswerReady()) { setStatus('⚠️ 答案未加载'); return null; } const type = detectType(); const question = getQuestion(); const answer = getAnswer(type); const options = getOptions(); if (!question) { setStatus('⚠️ 未识别题目'); return null; } if (type !== 'fillblank' && !answer) { setStatus('⚠️ 无答案'); return null; } if (seenQuestions.has(question)) { setStatus('⏭️ 跳过重复题'); return null; } seenQuestions.add(question); return { type, question, answer, options }; } catch (e) { console.error('抓取异常', e); setStatus('⚠️ 抓取异常'); return null; } } /** 将开始/暂停按钮恢复为初始的"开始抓取"状态 */ function resetBtnOnly() { isRunning = false; startStopBtn.textContent = '开始抓取'; startStopBtn.style.background = '#2563eb'; startStopBtn.style.color = '#fff'; } // ========== 主抓取循环 ========== /** * 开始/暂停按钮点击处理 * 首次点击:清空旧记录 → 跳回第 1 题 → 初始化 → 逐题抓取 * 暂停后再次点击:从当前题继续 */ startStopBtn.onclick = async function () { collapseExport(); // 暂停逻辑 if (isRunning) { isRunning = false; startStopBtn.textContent = '继续抓取'; startStopBtn.style.background = '#f59e0b'; startStopBtn.style.color = '#fff'; setStatus('已暂停'); return; } // 首次启动:清空旧记录并跳回第 1 题 if (count === 0) { setStatus('🔄 清空答题记录...'); await resetAllRecords(); setStatus('⏳ 等待页面加载...'); if (!await waitQuestionReady()) { setStatus('❌ 页面未加载完成'); return; } output = ''; records = []; failCount = 0; seenQuestions.clear(); examTitle = getExamTitle(); if (examTitle) output += examTitle + '\n\n'; getTotal(); } else { failCount = 0; } // 开始抓取 isRunning = true; startStopBtn.textContent = '暂停'; startStopBtn.style.background = '#dc2626'; startStopBtn.style.color = '#fff'; setStatus('🚀 抓取中'); setProgress(); // 主循环:逐题抓取 while (isRunning) { let data = null; try { data = await withTimeout(grabOne(), TIMEOUT); } catch (e) { data = null; } // 抓取失败处理 if (!data || !data.answer) { failCount++; setStatus(`⚠️ 抓取失败 ${failCount}/${MAX_FAIL}`); if (failCount >= MAX_FAIL) { setStatus('❌ 连续抓取失败,自动停止'); if (output) saveTxt(); resetBtnOnly(); return; } await sleep(DELAY_RETRY); if (!await waitNextBtn()) { setStatus('✅ 抓取完成'); resetBtnOnly(); return; } await sleep(DELAY_NEXT); continue; } // 抓取成功:记录数据并追加到导出内容 failCount = 0; count++; records.push(data); if (data.type === 'fillblank') { output += `${count}. ${data.question}\n答案:${data.answer}\n\n`; } else if (data.type === 'judge') { output += `${count}. ${data.question}\n答案:${data.answer}\n\n`; } else { const keys = Object.keys(data.options).sort(); output += `${count}. ${data.question}\n${keys.map(k => `${k}. ${data.options[k]}`).join('\n')}\n答案:${data.answer}\n\n`; } setProgress(); await sleep(DELAY_RETRY); if (!await waitNextBtn()) { setStatus('✅ 抓取完成'); resetBtnOnly(); return; } await sleep(DELAY_NEXT); } }; // ========== 初始化 ========== setTimeout(() => cachePanelHeight(), 100); applyTheme(); setStatus('就绪'); })();