// ==UserScript==
// @name 磨题帮爬题
// @namespace https://tampermonkey.net/
// @version 1.11
// @description 自动抓取磨题帮题库,支持单选、多选、判断、填空,提取题目导出TXT/Excel
// @match https://www.motibang.com/*
// @grant none
// @run-at document-end
// ==/UserScript==
(async function () {
'use strict';
const MAX_FAIL = 5, PANEL_WIDTH = 280, DELAY_ANSWER = 800, DELAY_NEXT = 1000, DELAY_RETRY = 400;
const NEXT_RETRY = 5, NEXT_INTERVAL = 300, ANIM_DURATION = 300, TIMEOUT = 12000, BTN_HEIGHT = '38px';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const cleanFileName = str => (str || '题库').replace(/[\/\\:*?"<>|]/g, '_');
function isClickable(el) {
if (!el) return false;
if (el.disabled) return false;
if (el.offsetParent === null) return false;
const s = window.getComputedStyle(el);
if (s.visibility === 'hidden' || s.opacity === '0' || s.display === 'none') return false;
return true;
}
async function withTimeout(p, ms, fb = null) {
let t; const tp = new Promise((_, r) => { t = setTimeout(() => r(new Error('Timeout')), ms); });
try { const r = await Promise.race([p, tp]); clearTimeout(t); return r; }
catch (e) { clearTimeout(t); return fb; }
}
let isRunning = false, output = '', records = [], count = 0, totalQuestions = 0, failCount = 0;
let examTitle = '', isDark = false, currentStatus = '', isMinimized = false, exportExpanded = false;
const seenQuestions = new Set();
let panelBodyHeight = 0;
function getTheme() { return { panelBg: isDark ? 'rgba(30,30,30,0.96)' : '#fff', 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' }; }
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 = `
`;
document.body.appendChild(panel);
const dragHandle = document.getElementById('dragHandle'), panelTitleWrap = document.getElementById('panelTitleWrap'), statusText = document.getElementById('statusText');
const minBtn = document.getElementById('minBtn'), darkBtn = document.getElementById('darkBtn'), panelBody = document.getElementById('panelBody');
const progressRow = document.getElementById('progressRow'), statusEl = document.getElementById('status'), startStopBtn = document.getElementById('startStopBtn');
const exportBtn = document.getElementById('exportBtn'), exportSub = document.getElementById('exportSub'), exportTxtBtn = document.getElementById('exportTxtBtn'), exportCsvBtn = document.getElementById('exportCsvBtn');
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; }
function cachePanelHeight() { if (!isMinimized) { const pmh = panelBody.style.maxHeight, po = panelBody.style.overflow; panelBody.style.maxHeight = 'none'; panelBody.style.overflow = 'visible'; panelBodyHeight = panelBody.scrollHeight; panelBody.style.maxHeight = pmh; panelBody.style.overflow = po; } }
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, 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(); };
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(); }
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] || '单选题'; }
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; }
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();
// 表头:题型, 题干, 选项A, 选项B, ..., 答案
const headers = ['题型', '题干', ...sortedKeys, '答案'];
let csv = '\uFEFF' + headers.join(',') + '\n';
records.forEach(r => {
const row = [typeToText(r.type), escapeCSV(r.question)];
// 选择题填充选项列,填空/判断留空
sortedKeys.forEach(k => {
row.push(escapeCSV((r.type === 'single' || r.type === 'multiple') ? (r.options[k] || '') : ''));
});
row.push(escapeCSV(r.answer));
csv += row.join(',') + '\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导出完成');
}
async function resetAllRecords() {
try {
const moreBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '更多' && isClickable(b));
if (!moreBtn) { setStatus('⚠️ 未找到更多按钮'); return false; }
moreBtn.click(); await sleep(600);
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();
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);
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; }
async function waitAnswerReady(to = 3000) {
const st = Date.now();
while (Date.now() - st < to) {
if (document.querySelector('.button.correct.choice-abc')) return true;
const fa = document.querySelector('.fillblank-answer');
if (fa && fa.textContent.trim()) return true;
if (/正确答案[::]\s*[对错正确错误是否√×TFYNtfyn]/.test(document.body.innerText)) return true;
await sleep(200);
}
return false;
}
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') || document.querySelector('.fillblank-input')) 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'; }
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(); }
function getAnswer(type) {
if (type === 'fillblank') { const fa = document.querySelector('.fillblank-answer'); return fa ? fa.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() : '';
}
function getOptions() { const o = {}; document.querySelectorAll('.choice-option').forEach(el => { const b = el.querySelector('.button.choice-abc'), 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]); }
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 = type === 'fillblank' ? {} : getOptions();
if (!question) { setStatus('⚠️ 未识别题目'); return null; }
if (!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'; }
startStopBtn.onclick = async function () {
collapseExport();
if (isRunning) { isRunning = false; startStopBtn.textContent = '继续抓取'; startStopBtn.style.background = '#f59e0b'; startStopBtn.style.color = '#fff'; setStatus('已暂停'); return; }
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('就绪');
})();