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

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

目前為 2024-07-12 提交的版本,檢視 最新版本

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