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

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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
    }
})();