【自制】问卷星输入答案自动填写

使用可配置的选择器来适配不同网站,支持复杂的输入格式。

当前为 2024-07-11 提交的版本,查看 最新版本

// ==UserScript==
// @name         【自制】问卷星输入答案自动填写
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  使用可配置的选择器来适配不同网站,支持复杂的输入格式。
// @match        https://lms.ouchn.cn/exam/*
// @match        https://ks.wjx.top/vm/mBcE5Ax.aspx
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    // 全局变量和常量
    let questions = [];
    let isQuestionDetected = false;
    const GLOBAL = {
        fillAnswerDelay: 300,
        debounceDelay: 300,
    };

    const DEFAULT_SELECTORS = {
        subjectContainer: '.exam-subjects > ol > li.subject',
        questionText: '.subject-description',
        options: '.subject-options input[type="radio"], .subject-options input[type="checkbox"]',
        answerElement: '.answer-options',
    };

    let SELECTORS = {...DEFAULT_SELECTORS};

    // 工具函数
    function debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // UI 相关函数
    function createMainInterface() {
        const container = document.createElement('div');
        container.id = 'auto-fill-container';
        container.className = 'fixed top-5 right-5 bg-white p-6 rounded-lg shadow-xl w-96 max-w-[90%] transition-all duration-300 ease-in-out';
        container.innerHTML = `
        <h3 class="text-2xl font-bold mb-4 text-gray-800">自动填写答案</h3>
        <div id="status-message" class="mb-4 text-sm font-medium text-gray-600"></div>
        <div id="main-content">
            <textarea id="bulk-input" class="w-full h-32 p-3 mb-4 border border-gray-300 rounded-md resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="输入答案,如: A,B,C 或 1-3(ABC,DEF,GHI)"></textarea>
            <div id="questions-preview" class="max-h-64 overflow-y-auto mb-4 bg-gray-50 rounded-md p-3"></div>
            <div class="grid grid-cols-2 gap-3">
                <button id="fillButton" class="col-span-2 bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded transition duration-300">填写答案</button>
                <button id="clearButton" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded transition duration-300">清空输入</button>
                <button id="pasteButton" class="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4 rounded transition duration-300">粘贴识别</button>
                <button id="configButton" class="bg-purple-500 hover:bg-purple-600 text-white font-medium py-2 px-4 rounded transition duration-300">配置选择器</button>
                <button id="detectButton" class="bg-yellow-500 hover:bg-yellow-600 text-white font-medium py-2 px-4 rounded transition duration-300">智能识别</button>
            </div>
        </div>
    `;

        document.body.appendChild(container);

        // 添加事件监听器
        document.getElementById('fillButton').addEventListener('click', fillAnswers);
        document.getElementById('clearButton').addEventListener('click', clearInputs);
        document.getElementById('pasteButton').addEventListener('click', pasteAndRecognize);
        document.getElementById('configButton').addEventListener('click', showSelectorWizard);
        document.getElementById('detectButton').addEventListener('click', smartDetectAnswers);
        document.getElementById('bulk-input').addEventListener('input', debounce(updateQuestionsPreview, GLOBAL.debounceDelay));
    }

    function updateQuestionsPreview() {
        const bulkInput = document.getElementById('bulk-input');
        const questionsPreview = document.getElementById('questions-preview');
        const answers = parseAnswers(bulkInput.value);

        // 添加自定义样式
        if (!document.getElementById('custom-question-preview-style')) {
            const style = document.createElement('style');
            style.id = 'custom-question-preview-style';
            style.textContent = `
            .question-row {
                transition: all 0.3s ease;
            }
            .question-row:hover {
                transform: translateY(-2px);
                box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            }
        `;
            document.head.appendChild(style);
        }

        questionsPreview.innerHTML = questions
            .filter(q => q.type !== '未知类型')
            .map((q, i) => {
            const answer = answers[i] || '-';
            const isValid = validateAnswer(answer, q);
            const isFilled = answer !== '-';
            const backgroundClass = isFilled ? (isValid ? 'bg-green-100' : 'bg-red-100') : 'bg-gray-100';
            const answerColorClass = isFilled ? (isValid ? 'text-green-600' : 'text-red-600') : 'text-gray-600';

            return `
            <div class="question-row flex items-center mb-2 p-2 rounded ${backgroundClass}">
                <div class="flex-none w-8 text-right mr-2">
                    <span class="font-bold text-gray-700">${i + 1}.</span>
                </div>
                <div class="flex-grow flex items-center overflow-hidden">
                    <span class="text-xs px-2 py-1 rounded mr-2 ${getTypeColor(q.type)}">${q.type}</span>
                    <span class="text-sm text-gray-600 truncate flex-grow" title="${q.text}">
                        ${q.text.length > 10 ? q.text.substring(0, 10) + '...' : q.text}
                    </span>
                    <span class="font-semibold ml-2 ${answerColorClass}">${answer}</span>
                </div>
            </div>
        `;
        }).join('');
    }

    function getTypeColor(type) {
        switch(type) {
            case '单选题': return 'bg-blue-500 text-white';
            case '多选题': return 'bg-purple-500 text-white';
            case '判断题': return 'bg-yellow-500 text-white';
            default: return 'bg-gray-500 text-white';
        }
    }

    function showMessage(message, type = 'info', duration = 0) {
        const statusElement = document.getElementById('status-message');
        statusElement.textContent = message;
        statusElement.className = `mb-4 text-sm font-medium p-3 rounded ${
        type === 'error' ? 'bg-red-100 text-red-700' :
        type === 'success' ? 'bg-green-100 text-green-700' :
        'bg-blue-100 text-blue-700'
    }`;

        if (duration > 0) {
            setTimeout(() => {
                statusElement.textContent = '';
                statusElement.className = 'mb-4 text-sm font-medium text-gray-600';
            }, duration);
        }
    }

    // 核心功能函数
    function determineQuestionType(subject, options) {
        if (options.length === 2 && options[0].type === 'radio' && options[1].type === 'radio') {
            return '判断题';
        } else if (options.length > 0 && options[0].type === 'radio') {
            return '单选题';
        } else if (options.length > 0 && options[0].type === 'checkbox') {
            return '多选题';
        }
        return '未知类型';
    }

    function detectQuestions() {
        const subjectElements = document.querySelectorAll(SELECTORS.subjectContainer);

        questions = Array.from(subjectElements)
            .map((subject, index) => {
            const questionText = subject.querySelector(SELECTORS.questionText)?.textContent.trim();
            const options = subject.querySelectorAll(SELECTORS.options);

            if (!questionText || options.length === 0) {
                return null;
            }

            let questionType = determineQuestionType(subject, options);

            return {
                type: questionType,
                optionCount: options.length,
                text: questionText,
                index: index + 1
            };
        })
            .filter(q => q !== null);

        isQuestionDetected = questions.filter(q => q.type !== '未知类型').length > 0;
        updateQuestionsPreview();
        if (isQuestionDetected) {
            showMessage(`检测到 ${questions.filter(q => q.type !== '未知类型').length} 道题目`, 'success');
        } else {
            showMessage('未检测到题目,请配置选择器或重新检测', 'error');
        }
    }

    function parseAnswers(input) {
        if (!input.trim()) {
            return [];
        }
        input = input.replace(/\s/g, '').toUpperCase();

        const patterns = [
            {
                regex: /(\d+)-(\d+)([A-Z]+)/,
                process: (match, answers) => {
                    const [_, start, end, choices] = match;
                    for (let i = parseInt(start); i <= parseInt(end); i++) {
                        answers[i - 1] = choices[i - parseInt(start)] || '';
                    }
                }
            },
            {
                regex: /(\d+)([A-Z]+)/,
                process: (match, answers) => {
                    const [_, number, choices] = match;
                    answers[parseInt(number) - 1] = choices;
                }
            },
            {
                regex: /([A-Z]+)/,
                process: (match, answers) => {
                    answers.push(match[1]);
                }
            }
        ];

        let answers = [];
        const segments = input.split(',');

        segments.forEach(segment => {
            let matched = false;
            for (const pattern of patterns) {
                const match = segment.match(pattern.regex);
                if (match) {
                    pattern.process(match, answers);
                    matched = true;
                    break;
                }
            }
            if (!matched) {
                showMessage(`无法解析的输入段: ${segment}`, 'error');
            }
        });

        return answers;
    }

    function validateAnswer(answer, question) {
        if (!answer || answer === '') return true;
        const options = answer.split('');
        if (question.type === '单选题' || question.type === '判断题') {
            return options.length === 1 && options[0].charCodeAt(0) - 64 <= question.optionCount;
        } else if (question.type === '多选题') {
            return options.every(option => option.charCodeAt(0) - 64 <= question.optionCount);
        }
        return false;
    }

    async function fillAnswers() {
        const bulkInput = document.getElementById('bulk-input');
        const answers = parseAnswers(bulkInput.value);
        let filledCount = 0;

        for (let i = 0; i < questions.length; i++) {
            if (i >= answers.length) break;

            const question = questions[i];
            const answer = answers[i];

            if (answer && validateAnswer(answer, question)) {
                const subject = document.querySelector(`${SELECTORS.subjectContainer}:nth-child(${question.index})`);
                const options = subject.querySelectorAll(SELECTORS.options);

                for (let optionIndex = 0; optionIndex < options.length; optionIndex++) {
                    const option = options[optionIndex];
                    const optionLetter = String.fromCharCode(65 + optionIndex);
                    const shouldBeChecked = answer.includes(optionLetter);

                    if (shouldBeChecked !== option.checked) {
                        option.click();
                        await sleep(GLOBAL.fillAnswerDelay);
                        filledCount++;
                    }
                }
            }
        }

        showMessage(`已成功填写 ${filledCount} 道题目的答案`, 'success');
    }

    function clearInputs() {
        document.getElementById('bulk-input').value = '';
        updateQuestionsPreview();
        showMessage('输入已清空', 'info');
    }

    async function pasteAndRecognize() {
        try {
            const text = await navigator.clipboard.readText();
            const bulkInput = document.getElementById('bulk-input');
            bulkInput.value = text;
            updateQuestionsPreview();
            showMessage('已从剪贴板粘贴并识别答案', 'success');
        } catch (err) {
            showMessage('无法访问剪贴板,请手动粘贴', 'error');
        }
    }

    function smartDetectAnswers() {
        const subjectContainers = document.querySelectorAll(SELECTORS.subjectContainer);
        console.log(`找到 ${subjectContainers.length} 个题目容器`);
        const detectedAnswers = questions.filter(q => q.type !== '未知类型').map((question, index) => {
            const subject = document.querySelector(`${SELECTORS.subjectContainer}:nth-child(${question.index})`);
            if (!subject) return '';

            const answerElement = subject.querySelector(SELECTORS.answerElement);
            if (!answerElement) return '';

            const answerText = answerElement.textContent.trim();
            if (!answerText) return '';

            return processAnswer(answerText, question.type);
        });

        const bulkInput = document.getElementById('bulk-input');
        bulkInput.value = detectedAnswers.join(',');
        updateQuestionsPreview();
        showMessage('已智能识别当前答案', 'success');
    }

    function processAnswer(answerText, questionType) {
        answerText = answerText.toUpperCase();

        switch (questionType) {
            case '单选题':
                return answerText.match(/[A-Z]/)?.[0] || '';
            case '多选题':
                return answerText.match(/[A-Z]/g)?.join('') || '';
            case '判断题':
                if (answerText.includes('对') || answerText.includes('A') || answerText === 'T') {
                    return 'A';
                } else if (answerText.includes('错') || answerText.includes('B') || answerText === 'F') {
                    return 'B';
                }
                return '';
            default:
                return answerText;
        }
    }

    // 新的选择器配置工具
    function showSelectorWizard() {
        const wizard = document.createElement('div');
        wizard.id = 'selector-wizard';
        wizard.className = 'fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-xl z-50 w-96 max-w-[90%]';
        wizard.innerHTML = `
            <div class="wizard-header flex justify-between items-center mb-4">
                <h3 class="text-2xl font-bold text-gray-800">DOM 选择器配置</h3>
                <button id="close-wizard" class="text-gray-500 hover:text-gray-700">×</button>
            </div>
            <div class="wizard-body">
                ${createSelectorInput('subjectContainer', '题目容器选择器')}
                ${createSelectorInput('questionText', '问题文本选择器')}
                ${createSelectorInput('options', '选项选择器')}
                ${createSelectorInput('answerElement', '答案元素选择器')}
                <div class="selector-input mt-4 flex items-center">
                    <input type="text" id="selector-input" class="w-2/3 px-3 py-2 border border-gray-300 rounded-md placeholder="输入CSS选择器" readonly>
                    <button id="test-selector" class="w-1/3 ml-2 bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded transition duration-300">测试</button>
                </div>
                <div id="selector-results" class="mt-2 text-sm text-gray-600"></div>
                <div class="wizard-controls mt-4 flex justify-between gap-2">
                    <button id="pick-element" class="flex-grow bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded transition duration-300">选择元素</button>
                    <button id="reset-selectors" class="flex-grow bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded transition duration-300">重置</button>
                    <button id="save-selector" class="flex-grow bg-purple-500 hover:bg-purple-600 text-white font-bold py-2 px-4 rounded transition duration-300">保存</button>
                </div>
            </div>
        `;
        document.body.appendChild(wizard);

        let highlightedElements = [];
        let pickerActive = false;
        let currentSelectorType = '';

        function highlightElements(elements) {
            highlightedElements.forEach(el => el.style.outline = '');
            highlightedElements = Array.from(elements);
            highlightedElements.forEach(el => {
                el.style.outline = '2px solid red';
            });
        }

        function clearHighlights() {
            highlightedElements.forEach(el => el.style.outline = '');
            highlightedElements = [];
        }

        function testSelector() {
            const selector = document.getElementById('selector-input').value;
            const elements = document.querySelectorAll(selector);
            const results = document.getElementById('selector-results');
            results.textContent = `找到 ${elements.length} 个元素`;
            highlightElements(elements);
        }

        function pickElement(e) {
            if (!pickerActive) return;
            e.preventDefault();
            e.stopPropagation();
            const path = e.composedPath();
            const selector = uniqueSelector(path[0]);
            document.getElementById('selector-input').value = selector;
            if (currentSelectorType) {
                document.getElementById(currentSelectorType).value = selector;
            }
            testSelector();
            pickerActive = false;
        }

        function uniqueSelector(el) {
            if (el.id) return '#' + el.id;
            if (el.className) return '.' + el.className.split(' ').join('.');
            let selector = el.tagName.toLowerCase();
            let siblings = Array.from(el.parentNode.children);
            if (siblings.length > 1) {
                let index = siblings.indexOf(el) + 1;
                selector += `:nth-child(${index})`;
            }
            return el.parentNode ? uniqueSelector(el.parentNode) + ' > ' + selector : selector;
        }

        document.getElementById('test-selector').addEventListener('click', testSelector);
        document.getElementById('pick-element').addEventListener('click', () => {
            pickerActive = true;
            showMessage('请点击页面元素以选择', 'info');
        });
        document.addEventListener('click', pickElement, true);
        document.getElementById('save-selector').addEventListener('click', () => {
            SELECTORS = {
                subjectContainer: document.getElementById('subjectContainer').value,
                questionText: document.getElementById('questionText').value,
                options: document.getElementById('options').value,
                answerElement: document.getElementById('answerElement').value
            };
            GM_setValue('customSelectors', JSON.stringify(SELECTORS));
            clearHighlights();
            wizard.remove();
            showMessage('选择器配置已保存,正在重新检测题目', 'success');
            detectQuestions();
        });
        document.getElementById('close-wizard').addEventListener('click', () => {
            clearHighlights();
            wizard.remove();
        });
        document.getElementById('reset-selectors').addEventListener('click', () => {
            SELECTORS = {...DEFAULT_SELECTORS};
            ['subjectContainer', 'questionText', 'options', 'answerElement'].forEach(id => {
                document.getElementById(id).value = DEFAULT_SELECTORS[id];
            });
            document.getElementById('selector-input').value = '';
            clearHighlights();
            showMessage('选择器已重置为默认值', 'info');
        });

        // 为每个选择器输入框添加事件监听器
        ['subjectContainer', 'questionText', 'options', 'answerElement'].forEach(id => {
            document.getElementById(id).addEventListener('focus', () => {
                currentSelectorType = id;
                document.getElementById('selector-input').value = document.getElementById(id).value;
            });
        });
    }

    function createSelectorInput(id, label) {
        return `
            <div class="mb-4">
                <label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">${label}</label>
                <input id="${id}" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" value="${SELECTORS[id]}">
            </div>
        `;
    }

    // 初始化函数
    function init() {
        // 加载 Tailwind CSS
        const tailwindCSS = `https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css`;
        const link = document.createElement('link');
        link.href = tailwindCSS;
        link.rel = 'stylesheet';
        document.head.appendChild(link);

        // 加载自定义选择器
        const savedSelectors = GM_getValue('customSelectors');
        if (savedSelectors) {
            SELECTORS = JSON.parse(savedSelectors);
        }

        // 创建主界面
        createMainInterface();

        // 延迟执行检测题目,确保页面完全加载
        setTimeout(detectQuestions, 2000);
    }

    // 当 DOM 加载完成时初始化脚本
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();