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

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

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

您需要先安装一个扩展,例如 篡改猴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.1.0
// @description  使用可配置的选择器来适配不同网站,支持复杂的输入格式。根据网站自动选择合适的选择器。
// @match        https://lms.ouchn.cn/exam/*
// @match        https://ks.wjx.top/vm/mBcE5Ax.aspx
// @match        https://www.wjx.cn/vm/eU7tjdY.aspx
// @match        https://www.szxuexiao.com/zuoti/html/33.html
// @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 = {
    "lms.ouchn.cn": {
      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 = JSON.parse(
    GM_getValue("domainSelectors", JSON.stringify(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));
  }

  function getCurrentDomain() {
    return window.location.hostname;
  }

  function getSelectorsForCurrentDomain() {
    const currentDomain = getCurrentDomain();
    return SELECTORS[currentDomain] || null;
  }

  // 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
      .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-200 text-blue-800";
      case "多选题":
        return "bg-green-200 text-green-800";
      case "判断题":
        return "bg-yellow-200 text-yellow-800";
      default:
        return "bg-gray-200 text-gray-800";
    }
  }

  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) {
    // 检查选项的类型
    const optionTypes = Array.from(options).map((option) => {
      const input =
        option.tagName.toLowerCase() === "input"
          ? option
          : option.querySelector("input");
      return input ? input.type : null;
    });

    // 根据选项类型确定题目类型
    if (optionTypes.every((type) => type === "radio")) {
      return optionTypes.length === 2 ? "判断题" : "单选题";
    } else if (optionTypes.every((type) => type === "checkbox")) {
      return "多选题";
    }

    // 如果无法通过input类型确定,尝试通过其他特征判断
    const optionTexts = Array.from(options).map((option) =>
      option.textContent.trim().toLowerCase()
    );
    if (optionTexts.includes("正确") && optionTexts.includes("错误")) {
      return "判断题";
    }

    // 如果仍然无法确定,返回未知类型
    return "未知类型";
  }

  function detectQuestions() {
    const currentSelectors = getSelectorsForCurrentDomain();
    if (!currentSelectors) {
      showSelectorWizard();
      return;
    }

    const subjectElements = document.querySelectorAll(
      currentSelectors.subjectContainer
    );
    if (subjectElements.length === 0) {
      const questionTexts = Array.from(
        document.querySelectorAll(currentSelectors.questionText)
      );
      const optionGroups = groupOptions(
        document.querySelectorAll(currentSelectors.options)
      );

      questions = questionTexts.map((text, index) => {
        const questionText = questionTexts[index].textContent.trim();
        const options = optionGroups[index];
        if (!text || !options) {
          return null;
        }

        const questionType = determineQuestionType(text, options);

        return {
          type: questionType,
          optionCount: options.length,
          text: questionText,
          index: index + 1,
        };
      });
      console.log(questions);
    } else {
      questions = Array.from(subjectElements)
        .map((subject, index) => {
          const questionText = subject
            .querySelector(currentSelectors.questionText)
            ?.textContent.trim();
          const options = subject.querySelectorAll(currentSelectors.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.length > 0;
    updateQuestionsPreview();
    if (isQuestionDetected) {
      showMessage(`检测到 ${questions.length} 道题目`, "success");
    } else {
      showMessage("未检测到题目,请配置选择器或重新检测", "error");
    }
  }

  function groupOptions(options) {
    const groups = {};
    options.forEach((option) => {
      if (!groups[option.name]) {
        groups[option.name] = [];
      }
      groups[option.name].push(option);
    });
    return Object.values(groups);
  }

  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 true;
  }

  async function fillAnswers() {
    const currentSelectors = getSelectorsForCurrentDomain();
    if (!currentSelectors) {
        showMessage("未找到当前网站的选择器配置,请先配置选择器", "error");
        return;
    }

    const bulkInput = document.getElementById("bulk-input");
    const answers = parseAnswers(bulkInput.value);
    let filledCount = 0;

    const subjectContainers = document.querySelectorAll(currentSelectors.subjectContainer);
    const useSubjectLogic = subjectContainers.length > 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)) {
            if (useSubjectLogic) {
                const subject = subjectContainers[question.index - 1];
                if (subject) {
                    filledCount += await fillAnswerForSubject(subject, answer, currentSelectors);
                }
            } else {
                filledCount += await fillAnswerForOptionGroup(i, answer, currentSelectors);
            }
        }
    }

    showMessage(`已填写 ${filledCount} 个答案`, "success");
}

async function fillAnswerForSubject(subject, answer, currentSelectors) {
    let filledCount = 0;
    const options = subject.querySelectorAll(currentSelectors.options);

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

        const input = option.tagName.toLowerCase() === "input" ? option : option.querySelector("input");
        if (input && shouldBeChecked !== input.checked) {
            const label = option.closest("span") || option;
            label.click();
            await sleep(GLOBAL.fillAnswerDelay);
            filledCount++;
        }
    }

    return filledCount;
}

async function fillAnswerForOptionGroup(questionIndex, answer, currentSelectors) {
    let filledCount = 0;
    const options = groupOptions(document.querySelectorAll(currentSelectors.options))[questionIndex];

    if (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++;
            }
        }
    }

    return filledCount;
}

  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 currentSelectors = getSelectorsForCurrentDomain();
    if (!currentSelectors) {
      showMessage("未找到当前网站的选择器配置,请先配置选择器", "error");
      return;
    }
    const subjectContainers = document.querySelectorAll(
      currentSelectors.subjectContainer
    );
    const detectedAnswers = questions.map((question, index) => {
      const subject = document.querySelector(
        `${currentSelectors.subjectContainer}:nth-child(${question.index})`
      );
      if (!subject) return "";

      const answerElement = subject.querySelector(
        currentSelectors.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 currentDomain = getCurrentDomain();
    const currentSelectors = SELECTORS[currentDomain] || {};
    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">
                <div class="mb-4">
                    <label class="block text-sm font-medium text-gray-700 mb-1">当前网站</label>
                    <input type="text" id="current-domain" class="w-full px-3 py-2 border border-gray-300 rounded-md" value="${currentDomain}" readonly>
                </div>
                ${createSelectorInput(
                  "subjectContainer",
                  "题目容器选择器",
                  currentSelectors.subjectContainer
                )}
                ${createSelectorInput(
                  "questionText",
                  "问题文本选择器",
                  currentSelectors.questionText
                )}
                ${createSelectorInput(
                  "options",
                  "选项选择器",
                  currentSelectors.options
                )}
                ${createSelectorInput(
                  "answerElement",
                  "答案元素选择器",
                  currentSelectors.answerElement
                )}
                <div class="wizard-controls mt-4 flex justify-between gap-2">
                    <button id="test-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);

    document
      .getElementById("test-selectors")
      .addEventListener("click", testSelectors);
    document
      .getElementById("save-selector")
      .addEventListener("click", saveSelectors);
    document
      .getElementById("close-wizard")
      .addEventListener("click", () => wizard.remove());
  }

  function testSelectors() {
    const testResults = {};
    ["subjectContainer", "questionText", "options", "answerElement"].forEach(
      (selectorType) => {
        const selector = document.getElementById(selectorType).value;
        const elements = document.querySelectorAll(selector);
        testResults[selectorType] = elements.length;
      }
    );

    let message = "测试结果:\n";
    for (const [type, count] of Object.entries(testResults)) {
      message += `${type}: 找到 ${count} 个元素\n`;
    }
    alert(message);
  }

  function saveSelectors() {
    const currentDomain = getCurrentDomain();
    SELECTORS[currentDomain] = {
      subjectContainer: document.getElementById("subjectContainer").value,
      questionText: document.getElementById("questionText").value,
      options: document.getElementById("options").value,
      answerElement: document.getElementById("answerElement").value,
    };
    GM_setValue("domainSelectors", JSON.stringify(SELECTORS));
    document.getElementById("selector-wizard").remove();
    showMessage("选择器配置已保存,正在重新检测题目", "success");
    detectQuestions();
  }

  function createSelectorInput(id, label, value = "") {
    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="${value}">
            </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);

    // 创建主界面
    createMainInterface();

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

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