// ==UserScript== // @name 蕴瑜课堂自动答题助手 // @namespace https://github.com/yourname // @version 1123115811.00 // @description 修复单选题模糊匹配误判,严格基于标准化文本相等匹配 // @match https://courses.gdut.edu.cn/mod/quiz/review.php* // @match https://courses.gdut.edu.cn/mod/quiz/summary.php* // @match https://courses.gdut.edu.cn/mod/quiz/attempt.php* // @icon https://courses.gdut.edu.cn/pluginfile.php/1/theme_lambda2/favicon/1780371956/favicon.ico // @grant GM_setValue // @grant GM_getValue // @license MIT // ==/UserScript== (function() { 'use strict'; const CONFIG = { AUTO_SUBMIT: true, SUBMIT_DELAY_SECONDS: 5, SHOW_CANCEL_BUTTON: true, }; function log(...args) { console.log('[答题助手]', ...args); } function getQuizId() { const urlParams = new URLSearchParams(window.location.search); let cmid = urlParams.get('cmid'); if (!cmid) { const cmidElem = document.querySelector('[data-cmid]'); if (cmidElem) cmid = cmidElem.getAttribute('data-cmid'); } return cmid || 'unknown'; } // 标准化文本:去除标点符号、空格,保留汉字、字母、数字 function normalizeForMatch(text) { return text.toLowerCase() .replace(/[,,。??!!;;::、""''《》【】()()\[\]{}·…\-—]/g, '') .replace(/\s+/g, ''); } // 严格相等匹配(标准化后) function isStrictMatch(text1, text2) { if (text1 === text2) return true; const norm1 = normalizeForMatch(text1); const norm2 = normalizeForMatch(text2); return norm1 === norm2; } function getQuestionNumber(queDiv) { const idMatch = queDiv.id.match(/-(\d+)$/); if (idMatch) return parseInt(idMatch[1], 10); const qnoSpan = queDiv.querySelector('.qno'); if (qnoSpan) { const num = parseInt(qnoSpan.innerText, 10); if (!isNaN(num)) return num; } return null; } function getQuestionText(queDiv) { const qtextDiv = queDiv.querySelector('.qtext'); return qtextDiv ? qtextDiv.innerText.trim().replace(/\s+/g, ' ') : ''; } function getOptionsList(queDiv) { const options = []; const answerDiv = queDiv.querySelector('.answer'); if (!answerDiv) return options; const items = answerDiv.querySelectorAll('.r0, .r1'); for (let item of items) { let input = item.querySelector('input[type="checkbox"], input[type="radio"]'); if (!input) continue; let textSpan = item.querySelector('.flex-fill'); let rawText = textSpan ? textSpan.innerText.trim() : item.innerText.trim(); const match = rawText.match(/^[A-Z]\.\s*(.*)$/); if (match) rawText = match[1].trim(); options.push({ element: input, text: rawText, value: input.value, container: item }); } return options; } function detectQuestionType(queDiv) { if (queDiv.classList.contains('truefalse')) return 'truefalse'; if (queDiv.classList.contains('multichoiceset')) return 'multi'; if (queDiv.classList.contains('multichoice')) return 'single'; const legend = queDiv.querySelector('.prompt'); if (legend) { const text = legend.innerText; if (text.includes('选择一项或多项')) return 'multi'; if (text.includes('选择一项')) return 'single'; } return 'unknown'; } // 提取正确答案(优先 correct 类,否则从 .rightanswer 解析) function getCorrectAnswers(queDiv) { const type = detectQuestionType(queDiv); const outcome = queDiv.querySelector('.outcome'); if (!outcome) return []; const options = getOptionsList(queDiv); // 策略1:优先使用 correct 类(最准确) const correctByClass = []; for (let opt of options) { if (opt.container.classList.contains('correct')) { correctByClass.push(opt.text); } } if (correctByClass.length > 0) { return correctByClass; } // 策略2:从 .rightanswer 文本解析 const rightDiv = outcome.querySelector('.rightanswer'); if (!rightDiv) return []; let fullText = rightDiv.innerText.trim(); fullText = fullText.replace(/^正确答案是[::]\s*/, ''); if (type === 'truefalse') { const match = fullText.match(/[“"]([对错])[”"]/); return match ? [match[1]] : []; } else if (type === 'single') { // 单选题:返回完整文本,不做拆分 return [fullText]; } else { // 多选题 // 按标点拆分,并去除编号前缀 let parts = fullText.split(/[;;,,、]+/).map(s => s.trim()).filter(s => s); parts = parts.map(p => p.replace(/^[A-Z]\.\s*/, '')); return parts; } } function saveAnswersFromReview() { const quizId = getQuizId(); if (quizId === 'unknown') return; const questions = document.querySelectorAll('.que'); if (questions.length === 0) return; const answersMap = {}; for (let que of questions) { const qno = getQuestionNumber(que); const qText = getQuestionText(que); const type = detectQuestionType(que); let correctTexts = getCorrectAnswers(que); if (correctTexts.length === 0) continue; // 验证有效性:确保答案文本能够匹配到选项中的某一项 const options = getOptionsList(que); let validCorrect = []; if (type === 'single') { const target = correctTexts[0]; const matched = options.find(opt => isStrictMatch(opt.text, target)); if (matched) { validCorrect = [matched.text]; // 使用选项中的原文本 } else { log(`题目 #${qno} 答案 "${target}" 未匹配到任何选项,跳过`); continue; } } else { for (let corr of correctTexts) { const matched = options.some(opt => isStrictMatch(opt.text, corr)); if (matched) validCorrect.push(corr); else log(`题目 #${qno} 的答案 "${corr}" 未匹配到选项,跳过该项`); } if (validCorrect.length === 0) continue; } const key = qno !== null ? `qnum_${qno}` : qText; answersMap[key] = { type: type, correctTexts: validCorrect, text: qText, qno: qno }; log(`✅ 记录 ${type} 题 #${qno} : ${validCorrect.join(' | ')}`); } if (Object.keys(answersMap).length) { GM_setValue(`quiz_${quizId}`, JSON.stringify(answersMap)); showNotification(`已记录 ${Object.keys(answersMap).length} 道题`); } else { showNotification('未提取到任何答案', 3000); } } // 答题页面填充(使用严格匹配) function applyAnswersToAttempt() { const quizId = getQuizId(); if (quizId === 'unknown') return 0; const stored = GM_getValue(`quiz_${quizId}`); if (!stored) { showNotification('未找到答案,请先访问回顾页面', 3000); return 0; } let answersMap; try { answersMap = JSON.parse(stored); } catch(e) { return 0; } const questions = document.querySelectorAll('.que'); let applied = 0; for (let que of questions) { const qno = getQuestionNumber(que); let key = qno !== null ? `qnum_${qno}` : null; let answerInfo = key ? answersMap[key] : null; if (!answerInfo) { const qText = getQuestionText(que); if (qText) { answerInfo = Object.values(answersMap).find(info => info.text === qText); } } if (!answerInfo) continue; const correctTexts = answerInfo.correctTexts; const options = getOptionsList(que); if (options.length === 0) continue; const hasCheckbox = options.some(opt => opt.element.type === 'checkbox'); const hasRadio = options.some(opt => opt.element.type === 'radio'); if (hasRadio && correctTexts.length === 1) { const target = options.find(opt => isStrictMatch(opt.text, correctTexts[0])); if (target) { target.element.checked = true; applied++; log(`✅ 题 #${qno} 已选中: ${target.text}`); } else { log(`⚠️ 题 #${qno} 未找到匹配选项: ${correctTexts[0]}`); } } else if (hasCheckbox) { let changed = false; for (let opt of options) { const should = correctTexts.some(corr => isStrictMatch(opt.text, corr)); if (should && !opt.element.checked) { opt.element.checked = true; changed = true; } else if (!should && opt.element.checked) { opt.element.checked = false; changed = true; } } if (changed) { applied++; log(`✅ 题 #${qno} 多选已勾选: ${correctTexts.join(', ')}`); } } } showNotification(`已填充 ${applied} 道题`); return applied; } // ========== 自动提交(保持不变)========== let submitTimer = null; let cancelDiv = null; function getTwoStepButton() { const btns = document.querySelectorAll('button, input[type="submit"]'); for (let btn of btns) { const text = (btn.innerText || btn.value || '').trim(); if (text === '全部提交并结束') return btn; } return document.querySelector('button[id*="single_button"]'); } function simpleSubmit() { const selectors = [ '#mod_quiz-next-nav', '.mod_quiz-next-nav', 'input[value="结束试答…"]', 'input[value="结束试答"]', '.submitbtns input[type="submit"]' ]; for (let sel of selectors) { let btn = document.querySelector(sel); if (btn) { btn.click(); log('简单提交'); return true; } } return false; } async function twoStepSubmit() { const firstBtn = getTwoStepButton(); if (!firstBtn) return false; firstBtn.click(); let modalConfirm = null; for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 100)); modalConfirm = document.querySelector('.modal-footer button[data-action="save"]'); if (modalConfirm) break; } if (modalConfirm) { modalConfirm.click(); return true; } return false; } async function autoSubmit() { const success = await twoStepSubmit(); if (!success) simpleSubmit(); showNotification('已提交答案', 2000); } function showCancelBar(seconds) { if (!CONFIG.SHOW_CANCEL_BUTTON) return; if (cancelDiv) cancelDiv.remove(); cancelDiv = document.createElement('div'); cancelDiv.style.cssText = 'position:fixed;bottom:80px;right:20px;background:#ff9800;color:#fff;padding:10px 16px;border-radius:8px;z-index:10000;font-size:14px;cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,0.3)'; cancelDiv.innerHTML = `⏰ 自动提交倒计时 ${seconds} 秒,点击取消`; cancelDiv.onclick = () => { if (submitTimer) clearTimeout(submitTimer); cancelDiv.remove(); cancelDiv = null; showNotification('已取消自动提交', 1500); }; document.body.appendChild(cancelDiv); let remaining = seconds; const interval = setInterval(() => { if (!cancelDiv) { clearInterval(interval); return; } remaining--; if (remaining <= 0) { clearInterval(interval); cancelDiv.remove(); cancelDiv = null; } else { cancelDiv.innerHTML = `⏰ 自动提交倒计时 ${remaining} 秒,点击取消`; } }, 1000); } function scheduleAutoSubmit(filledCount) { if (!CONFIG.AUTO_SUBMIT) return; const total = document.querySelectorAll('.que').length; if (filledCount < total) { showNotification(`只填充了 ${filledCount}/${total} 道题,不自动提交`, 3000); return; } showCancelBar(CONFIG.SUBMIT_DELAY_SECONDS); submitTimer = setTimeout(autoSubmit, CONFIG.SUBMIT_DELAY_SECONDS * 1000); } function addFloatingButton() { const btn = document.createElement('div'); btn.innerHTML = '📝 答题助手'; btn.style.cssText = 'position:fixed;bottom:20px;right:20px;background:#2196f3;color:#fff;padding:8px 12px;border-radius:20px;cursor:pointer;z-index:9999;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3)'; btn.onclick = () => { if (location.href.includes('/review.php')) saveAnswersFromReview(); else if (location.href.includes('/attempt.php')) { const cnt = applyAnswersToAttempt(); if (cnt > 0) scheduleAutoSubmit(cnt); } }; document.body.appendChild(btn); } function showNotification(msg, duration = 2000) { const div = document.createElement('div'); div.innerText = msg; div.style.cssText = 'position:fixed;bottom:20px;right:20px;background:#4caf50;color:#fff;padding:10px 16px;border-radius:8px;z-index:9999;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.2)'; document.body.appendChild(div); setTimeout(() => div.remove(), duration); } function init() { if (location.href.includes('/review.php')) { const timer = setInterval(() => { if (document.querySelector('.que')) { clearInterval(timer); saveAnswersFromReview(); } }, 500); } else if (location.href.includes('/attempt.php')) { const timer = setInterval(() => { if (document.querySelector('.que')) { clearInterval(timer); const cnt = applyAnswersToAttempt(); if (cnt > 0) scheduleAutoSubmit(cnt); } }, 500); } addFloatingButton(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();