超星通用题目提取器

从超星“查看已批阅作业”或类似考试回顾页面提取题目。支持自动提取和手动提取,带自定义提示。

// ==UserScript==
// @name         超星通用题目提取器
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  从超星“查看已批阅作业”或类似考试回顾页面提取题目。支持自动提取和手动提取,带自定义提示。
// @author       毫厘
// @match        *://*.chaoxing.com/exam-ans/exam/test/reVersionPaperMarkContentNew*
// @match        *://*.chaoxing.com/mooc-ans/work/selectWorkQuestionYiPiYue*
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() { // 使用立即执行函数表达式 (IIFE) 封装代码,避免污染全局作用域
    'use strict'; // 启用严格模式,有助于捕获常见错误

    // 用于存储自动提取设置的键名,确保版本更新后键名唯一性以避免冲突
    const AUTO_EXTRACT_KEY = 'chaoxingUniversalQuizAutoExtractEnabled_v3.0';

    // --- 辅助函数 (Combined and refined) ---

    /**
     * 规范化文本:移除多余空格,并将所有空白符(包括换行、制表符等)替换为单个空格,最后去除首尾空格。
     * @param {string} text - 待处理的原始文本。
     * @returns {string} 规范化后的文本。如果输入不是字符串,则返回空字符串。
     */
    function normalizeText(text) {
        if (typeof text !== 'string') return "";
        return text.replace(/\s+/g, ' ').trim();
    }

    /**
     * 规范化题目文本:
     * 1. 移除题目前缀,如 "【单选题】"。
     * 2. 移除题目末尾的分数提示,如 "(2.0 分)" 或 "(2 分)"。
     * 3. 标准化空括号,如将 "( )" 或 "( )" 统一为 "( )" 或 "( )"。
     * 4. 进行基础的 normalizeText 处理。
     * @param {string} rawText - 原始的题目文本。
     * @returns {string} 规范化后的题目文本。
     */
    function normalizeQuestionText(rawText) {
        if (!rawText) return "";
        // 移除题型前缀,例如 "【单选题】 "
        let text = rawText.replace(/^【.*?题】\s*/, '').trim();
        // 移除题干末尾的分数部分,例如 "( 2.0 分 )" 或 "( 2 分 )"
        text = text.replace(/\s*(\s*\d+(\.\d+)?\s*分\s*)\s*$/, '').trim();
        text = text.replace(/\s*\(\s*\d+(\.\d+)?\s*分\s*\)\s*$/, '').trim();
        // 将内容为空或只有空格/ 的括号标准化
        text = text.replace(/\(\s*( |\s)*\)/g, '( )'); // 半角括号
        text = text.replace(/(\s*( |\s)*)/g, '( )'); // 全角括号
        // 再次确保完全空的括号被标准化
        text = text.replace(/\(\s*\)/g, '( )').replace(/(\s*)/g, '( )');
        return normalizeText(text); // 最后进行通用文本规范化
    }

    /**
     * 从 DOM 元素中提取纯文本内容。
     * 该函数会克隆元素,移除其中的 <script> 和 <style> 标签,
     * 将 <p> 和 <br> 标签转换为空格(后续由 normalizeText 处理)或换行,
     * 然后获取其 textContent 或 innerText,并进行规范化处理。
     * @param {HTMLElement} element - 需要提取文本的 DOM 元素。
     * @returns {string} 清理和规范化后的纯文本内容。
     */
    function getCleanTextFromElement(element) {
        if (!element) return "";
        const clone = element.cloneNode(true); // 克隆节点以避免修改原始DOM

        // 移除脚本和样式标签,它们不应出现在题目内容中
        const scriptsAndStyles = clone.querySelectorAll('script, style');
        scriptsAndStyles.forEach(el => el.remove());

        // 将 <p> 和 <br> 标签转换成换行符,以保留段落结构和换行
        // 注意:innerHTML 操作可能引入XSS风险,但这里是处理已知页面,且是克隆节点
        let html = clone.innerHTML;
        html = html.replace(/<p[^>]*>/gi, '\n').replace(/<\/p>/gi, '\n').replace(/<br[^>]*>/gi, '\n');

        // 使用临时div来解析HTML并获取文本内容
        const tempDiv = document.createElement('div');
        tempDiv.innerHTML = html;

        // 获取文本,优先使用 textContent
        let text = normalizeText(tempDiv.textContent || tempDiv.innerText || "");
        // 移除连续的换行符,只保留一个
        return text.replace(/\n\s*\n/g, '\n').trim();
    }

    // --- 自定义悬浮通知 (from Homework Script) ---
    let notificationElement = null; // 存储通知DOM元素的引用,避免重复创建
    /**
     * 显示一个自定义的悬浮通知。
     * @param {string} message - 要显示的消息内容。
     * @param {boolean} [isError=false] - 是否为错误消息(影响背景颜色)。
     */
    function showCustomNotification(message, isError = false) {
        // 如果通知元素尚未创建,则创建并添加到body
        if (!notificationElement) {
            notificationElement = document.createElement('div');
            notificationElement.id = 'customNotificationGM'; // 用于CSS选择器
            document.body.appendChild(notificationElement);
        }
        notificationElement.textContent = message;
        notificationElement.style.backgroundColor = isError ? '#dc3545' : '#28a745'; // 红色表示错误,绿色表示成功

        // 实现简单的淡入淡出效果
        notificationElement.style.display = 'block';
        notificationElement.style.opacity = '0';
        let opacity = 0;
        const fadeInInterval = setInterval(function() {
            if (opacity < 0.95) {
                opacity += 0.1;
                notificationElement.style.opacity = opacity;
            } else {
                clearInterval(fadeInInterval);
                // 2.5秒后自动开始淡出
                setTimeout(() => {
                    let opacityOut = 0.95;
                    const fadeOutInterval = setInterval(function() {
                        if (opacityOut > 0) {
                            opacityOut -= 0.1;
                            notificationElement.style.opacity = opacityOut;
                        } else {
                            clearInterval(fadeOutInterval);
                            notificationElement.style.display = 'none';
                        }
                    }, 50); // 淡出动画的间隔
                }, 2500); // 通知显示持续时间
            }
        }, 50); // 淡入动画的间隔
    }

    // --- 主要提取逻辑 (Combined and generalized) ---
    /**
     * 提取并格式化页面上的所有题目。
     * @param {boolean} [isManualTrigger=false] - 是否为手动触发(影响通知内容)。
     * @returns {string|null} 提取并格式化后的题目字符串,如果未找到容器或题目则返回null。
     */
    function extractAndFormatQuestions(isManualTrigger = false) {
        let output = ""; // 用于累积提取结果的字符串
        // #ZyBottom 是超星题目区域的主要容器ID
        const mainQuizContainer = document.querySelector('#ZyBottom');

        if (!mainQuizContainer) {
            const msg = '错误:未能找到主要的题目容器 (ID: ZyBottom)。脚本可能无法在此页面工作。';
            if (isManualTrigger) showCustomNotification(msg, true);
            else console.warn(`油猴脚本:${msg}`);
            return null;
        }

        let currentQuestionTypeName = "未知题型"; // 默认题型名称
        let questionTypeHeaderFoundOverall = false; // 标记是否已找到并处理过题型标题
        const topLevelChildren = Array.from(mainQuizContainer.children); // 获取主容器下的所有直接子元素

        // 遍历主容器的直接子元素以识别题型标题和题目块
        for (const element of topLevelChildren) {
            // 1. 处理题型标题 (例如:"一、单选题")
            if (element.classList.contains('Cy_TItle1')) { // 题型标题通常使用此class
                questionTypeHeaderFoundOverall = true;
                const headerH2 = element.querySelector('h2');
                if (headerH2) {
                    let rawHeaderText = "";
                    // 提取h2中所有文本节点,直到遇到<em>标签(通常包含分数信息)
                    for (const node of headerH2.childNodes) {
                        if (node.nodeName.toUpperCase() === "EM") break;
                        rawHeaderText += node.textContent;
                    }
                    // 清理标题文本,移除序号和多余空格,例如 "一、 单选题" -> "单选题"
                    rawHeaderText = normalizeText(rawHeaderText).replace(/^一?[、.]\s*/, '').trim();

                    // 根据关键词判断题型
                    if (rawHeaderText.includes("单选题")) currentQuestionTypeName = "单选题";
                    else if (rawHeaderText.includes("多选题")) currentQuestionTypeName = "多选题";
                    else if (rawHeaderText.includes("判断题")) currentQuestionTypeName = "判断题";
                    else if (rawHeaderText.includes("填空题")) currentQuestionTypeName = "填空题";
                    else if (rawHeaderText.includes("简答题")) currentQuestionTypeName = "简答题";
                    else if (rawHeaderText.includes("资料题") || rawHeaderText.includes("材料题")) currentQuestionTypeName = "资料题";
                    else currentQuestionTypeName = rawHeaderText || "未知题型"; // 若无匹配,则使用原始文本

                    // 添加题型标题到输出,确保格式正确且不重复添加
                    if (output === "" || (output.length > 0 && !output.endsWith("\n\n") && output.lastIndexOf("## ") !== (output.length - `## ${currentQuestionTypeName}\n`.length))) {
                         if(output !== "" && !output.endsWith("\n")) output += "\n"; // 确保在新题型前有空行
                    }
                    if (!output.endsWith(`## ${currentQuestionTypeName}\n`)) { // 避免连续相同的题型标题
                        output += `## ${currentQuestionTypeName}\n`;
                    }
                }
            }
            // 2. 处理包含多个题目的区块 (常见于考试回顾页面)
            else if (element.classList.contains('CyBottom') && element.classList.contains('ans-cc')) {
                const questionsInBlock = element.querySelectorAll('.TiMu'); // .TiMu 是单个题目的通用class
                questionsInBlock.forEach(questionElementNode => {
                    let questionOutput = processSingleQuestion(questionElementNode, currentQuestionTypeName);
                    if (questionOutput) output += questionOutput;
                });
            }
            // 3. 处理直接作为子元素的单个题目
            else if (element.classList.contains('TiMu')) {
                // 如果到目前为止还没有输出过题型标题,并且输出为空或未包含 "## "
                if (!questionTypeHeaderFoundOverall && output.indexOf("## ") === -1) {
                    output += `## ${currentQuestionTypeName}\n`; // 使用当前(或默认)的题型标题
                    questionTypeHeaderFoundOverall = true; // 标记已处理过题型标题
                }
                let questionOutput = processSingleQuestion(element, currentQuestionTypeName);
                if (questionOutput) output += questionOutput;
            }
            // 4. 兼容处理:某些页面题目可能嵌套在其他类型的子元素中 (作业回顾页面常见)
            else {
                const nestedQuestions = element.querySelectorAll('.TiMu');
                 if (nestedQuestions.length > 0) {
                    if (!questionTypeHeaderFoundOverall && output.indexOf("## ") === -1) {
                         output += `## ${currentQuestionTypeName}\n`;
                         questionTypeHeaderFoundOverall = true;
                    }
                    nestedQuestions.forEach(qEl => {
                        let questionOutput = processSingleQuestion(qEl, currentQuestionTypeName);
                        if (questionOutput) output += questionOutput;
                    });
                }
            }
        }
         // 最终检查:如果整个过程中没有找到显式的题型标题,但提取到了题目,
         // 且输出中尚未包含任何 "## " 格式的题型标题,则尝试从第一个题目推断并添加。
        if (!questionTypeHeaderFoundOverall && mainQuizContainer.querySelector('.TiMu') && output.indexOf("## ") === -1) {
            // 尝试从第一个题目的题干中推断题型
            const firstQuestionTextDiv = mainQuizContainer.querySelector('.TiMu .Cy_TItle div.clearfix, .TiMu .Zy_TItle div.clearfix');
            if (firstQuestionTextDiv) {
                const firstQuestionRawText = getCleanTextFromElement(firstQuestionTextDiv);
                if (firstQuestionRawText.startsWith("【单选题】")) currentQuestionTypeName = "单选题";
                // 可在此处添加更多题型推断逻辑
            }
            output = `## ${currentQuestionTypeName}\n` + output; // 将推断的题型标题加在最前面
        }


        // 处理最终输出
        if (output) {
            GM_setClipboard(output); // 复制到剪贴板
            showCustomNotification(`题目已${isManualTrigger ? '手动' : '自动'}提取并成功复制到剪贴板!`);
            displayOutput(output); // 在页面上显示提取的内容
        } else {
            const msg = '未能找到或处理任何题目。请检查页面结构或在浏览器控制台查看错误信息。';
            if (isManualTrigger) showCustomNotification(msg, true);
            else console.warn(`油猴脚本:${msg}`);
        }
        return output;
    }

    /**
     * 处理单个题目元素,提取题号、题干、选项、答案和解析。
     * @param {HTMLElement} questionElement - 代表单个题目的 DOM 元素 (通常是 .TiMu)。
     * @param {string} questionTypeName - 当前题目的类型名称 (例如 "单选题")。
     * @returns {string} 格式化后的单个题目字符串,如果处理出错则返回错误提示。
     */
    function processSingleQuestion(questionElement, questionTypeName) {
        let qOutput = ""; // 用于累积单个题目的输出
        try {
            // --- 提取题号和题干 ---
            // 题目标题容器,可能是 .Cy_TItle (考试) 或 .Zy_TItle (作业)
            const questionTitleDiv = questionElement.querySelector('.Cy_TItle, .Zy_TItle');
            let questionNumberElement = null; // 题号元素
            let questionTextElement = null;   // 题干元素
            let questionNumber = "?.";        // 默认题号格式,统一使用 "." 结尾

            if (questionTitleDiv) { // 优先处理带有明确标题容器的结构
                questionNumberElement = questionTitleDiv.querySelector('i.fl'); // 题号通常在 i.fl 标签内
                if (questionNumberElement) {
                    questionNumber = normalizeText(questionNumberElement.textContent) + ".";
                    // 题干通常是题号元素的下一个兄弟元素,且class为clearfix
                    if (questionNumberElement.nextElementSibling && questionNumberElement.nextElementSibling.classList.contains('clearfix')) {
                        questionTextElement = questionNumberElement.nextElementSibling;
                    }
                }
                // 如果未能通过nextElementSibling找到题干,则尝试更复杂的查找逻辑(来自考试脚本)
                if (!questionTextElement) {
                    const potentialStems = questionTitleDiv.querySelectorAll('div.clearfix'); // 查找所有clearfix的div
                    if (potentialStems.length > 0) {
                        // 从后向前遍历,找到第一个不包含题号的clearfix div作为题干
                        for(let i = potentialStems.length - 1; i >= 0; i--) {
                            if(!potentialStems[i].querySelector('i.fl')) { // 确保不是另一个题号元素
                                questionTextElement = potentialStems[i];
                                break;
                            }
                        }
                        // 如果上述逻辑未找到,则默认使用第一个clearfix div
                        if(!questionTextElement && potentialStems.length > 0) questionTextElement = potentialStems[0];
                    }
                }
            } else { // 备用逻辑:如果题目元素下没有 .Cy_TItle 或 .Zy_TItle 容器
                //直接在 questionElement 下查找题号
                questionNumberElement = questionElement.querySelector(':scope > i.fl'); // :scope确保是直接子元素
                if (questionNumberElement) {
                    questionNumber = normalizeText(questionNumberElement.textContent) + ".";
                    if (questionNumberElement.nextElementSibling && questionNumberElement.nextElementSibling.classList.contains('clearfix')) {
                        questionTextElement = questionNumberElement.nextElementSibling;
                    }
                }
                // 如果题干仍未找到,则在 questionElement 的直接子元素中查找合适的 div.clearfix
                if (!questionTextElement) {
                     const potentialStems = questionElement.querySelectorAll(':scope > div.clearfix');
                     for (let div of potentialStems) {
                         // 确保这个div不是选项列表、答案区域,并且有足够的文本内容
                         if (!div.querySelector('.Cy_ulTop, .Zy_ulTop') && !div.querySelector('.Py_answer') && (div.textContent || "").trim().length > 5) {
                             questionTextElement = div;
                             break;
                         }
                     }
                }
            }

            let questionText = "题干未能提取"; // 默认题干文本
            if (questionTextElement) {
                let rawText = getCleanTextFromElement(questionTextElement);
                questionText = normalizeQuestionText(rawText); // 使用增强的题干清理函数
            }
            qOutput += questionNumber + " " + questionText + "\n"; // 输出: 题号. 题干

            // --- 提取选项 (选择题) ---
            // 选项列表容器,可能是 .Cy_ulTop li (考试) 或 .Zy_ulTop li (作业)
            let options = questionElement.querySelectorAll('.Cy_ulTop li');
            if (options.length === 0) { // 如果未找到 .Cy_ulTop li,则尝试 .Zy_ulTop li
                options = questionElement.querySelectorAll('.Zy_ulTop li');
            }

            let optionDetails = []; // 存储选项标签和文本,用于后续答案匹配
            if (options.length > 0) {
                options.forEach(optionLi => {
                    const optionLabelElement = optionLi.querySelector('i.fl'); // 例如: A, B, C
                    const rawOptionLabel = optionLabelElement ? normalizeText(optionLabelElement.textContent) : ""; // 获取原始标签文本

                    // 选项文本通常在 <a> 标签内,有时嵌套在 <p> 标签中
                    const optionTextAnchor = optionLi.querySelector('a'); // 优先查找a标签
                    let optText = "";
                    if (optionTextAnchor) {
                        const pInA = optionTextAnchor.querySelector('p');
                        optText = pInA ? normalizeText(pInA.textContent) : normalizeText(optionTextAnchor.textContent);
                    } else { // 如果没有a标签,尝试直接从li中获取文本 (排除标签部分)
                        let tempOptClone = optionLi.cloneNode(true);
                        if(tempOptClone.querySelector('i.fl')) tempOptClone.querySelector('i.fl').remove(); // 移除标签部分
                        optText = normalizeText(tempOptClone.textContent);
                    }
                    // 存储清理后的选项标签和文本
                    optionDetails.push({ label: rawOptionLabel.replace(/[:、.]\s*$/, ''), text: optText });
                    qOutput += rawOptionLabel + " " + optText + "\n"; // 输出: A. 选项内容
                });
            }

            // --- 提取正确答案 (根据题型进行不同处理) ---
            let finalCorrectAnswer = "未能提取"; // 默认答案
            // 尝试获取隐藏的答案元素(通常包含更准确的答案信息)
            const correctAnswerPElement = questionElement.querySelector('.Py_answer span.element-invisible-hidden p'); // 隐藏的答案文本
            const correctAnswerIconElement = questionElement.querySelector('.Py_answer span.element-invisible-hidden i.font20'); // 隐藏的答案图标 (判断题√×)
            // 填空题、简答题的答案通常在特定div中显示
            const revealedAnswerDiv = questionElement.querySelector(".Py_tk div[id^='div']"); // id以div开头的答案显示区

            if (questionTypeName === "单选题" || questionTypeName === "多选题") {
                if (correctAnswerPElement) { // 优先使用隐藏的答案文本
                    let correctAnswerTextFromHidden = normalizeText(correctAnswerPElement.textContent);
                    let foundMatch = false;
                    // 尝试将隐藏的答案文本与选项文本匹配,以获取选项标签 (A, B)
                    for (const opt of optionDetails) {
                        if (typeof opt.text === 'string' && typeof correctAnswerTextFromHidden === 'string' && opt.text === correctAnswerTextFromHidden) {
                            finalCorrectAnswer = opt.label;
                            foundMatch = true;
                            break;
                        }
                    }
                    // 如果文本不匹配,但隐藏文本本身是合法的选项标签 (如 "A", "ABC")
                    if (!foundMatch && /^[A-Z]+$/.test(correctAnswerTextFromHidden) && optionDetails.some(opt => opt.label === correctAnswerTextFromHidden)) {
                        finalCorrectAnswer = correctAnswerTextFromHidden;
                    } else if (!foundMatch) { // 如果都未匹配,直接使用隐藏的文本内容
                        finalCorrectAnswer = correctAnswerTextFromHidden;
                    }
                } else { // 如果没有隐藏的答案文本,尝试从可见的 "正确答案:" 提示中提取
                    const answerSpans = questionElement.querySelectorAll('.Py_answer span');
                    for (const span of answerSpans) {
                        const spanText = normalizeText(span.textContent);
                        if (spanText.startsWith('正确答案:')) {
                            let visibleAnswerText = spanText.replace('正确答案:', '').trim();
                            let foundVisibleMatch = false;
                            // 尝试将可见答案文本与选项标签或选项文本匹配
                            for (const opt of optionDetails) {
                                if (opt.label === visibleAnswerText) { finalCorrectAnswer = opt.label; foundVisibleMatch = true; break; }
                                else if (opt.text === visibleAnswerText) { finalCorrectAnswer = opt.label; foundVisibleMatch = true; break; }
                            }
                            if (!foundVisibleMatch) finalCorrectAnswer = visibleAnswerText; // 若无匹配,使用原始可见文本
                            break;
                        }
                    }
                }
            } else if (questionTypeName === "判断题") {
                if (correctAnswerIconElement) { // 优先使用隐藏的图标 (√, ×)
                    let symbol = correctAnswerIconElement.textContent.trim();
                    if (symbol === "√") finalCorrectAnswer = "对";
                    else if (symbol === "×") finalCorrectAnswer = "错";
                    else finalCorrectAnswer = symbol; // 如果不是标准符号,直接使用
                } else if (correctAnswerPElement) { // 备用:使用隐藏的文本 ("对", "错")
                    let textAns = normalizeText(correctAnswerPElement.textContent);
                     if (textAns === "对" || textAns.toLowerCase() === "true") finalCorrectAnswer = "对";
                     else if (textAns === "错" || textAns.toLowerCase() === "false") finalCorrectAnswer = "错";
                     else finalCorrectAnswer = textAns;
                } else { // 最后尝试可见的 "正确答案:" 提示
                    const answerSpans = questionElement.querySelectorAll('.Py_answer span');
                    for (const span of answerSpans) {
                        const spanText = normalizeText(span.textContent);
                        if (spanText.startsWith('正确答案:')) {
                            let textAnswer = spanText.replace('正确答案:', '').trim();
                            // 检查可见提示中是否直接包含图标
                            const iconInSpan = span.querySelector('i.font20');
                            if (iconInSpan) textAnswer = iconInSpan.textContent.trim();

                            if (textAnswer === "√" || textAnswer.toLowerCase() === "true" || textAnswer === "对") finalCorrectAnswer = "对";
                            else if (textAnswer === "×" || textAnswer.toLowerCase() === "false" || textAnswer === "错") finalCorrectAnswer = "错";
                            else finalCorrectAnswer = textAnswer;
                            break;
                        }
                    }
                }
            } else if (revealedAnswerDiv && (questionTypeName === "填空题" || questionTypeName === "简答题" || questionTypeName === "资料题")) {
                // 处理填空题、简答题、资料题的答案
                if (questionTypeName === "填空题") {
                    const parts = []; // 存储填空的各个部分
                    // 填空答案通常在 .font14 或直接子span中
                    revealedAnswerDiv.querySelectorAll("span.font14, div > span").forEach(span => {
                        const labelElement = span.querySelector('i.fb.red'); // 例如 "空1:"
                        if (labelElement && labelElement.textContent.includes("空:")) { // 带标签的填空
                            let clone = span.cloneNode(true);
                            clone.querySelector('i.fb.red').remove(); // 移除标签本身,只留答案
                            parts.push(normalizeText(clone.textContent));
                        }
                        // 不带标签,但也不是“查看答案”之类的链接
                        else if (!labelElement && span.textContent.trim() !== "" && !span.querySelector('a[onclick*="hideAndShow"]')) {
                             parts.push(normalizeText(span.textContent));
                        }
                    });
                    finalCorrectAnswer = parts.join('; '); // 用分号连接多个填空答案
                } else if (questionTypeName === "资料题") { // 资料题的答案可能包含多个子部分
                    const subAnswers = [];
                    // 资料题的答案结构可能更复杂
                    revealedAnswerDiv.querySelectorAll("span.font14, div.clearfix > span, div > span").forEach(span => {
                        const labelElement = span.querySelector('i.fb.red'); // 子题目的标签,如 "空1:" 或 "1:"
                        let partText = "";
                        if (labelElement && (labelElement.textContent.includes("空:") || labelElement.textContent.match(/\d+[::]/))) {
                            let label = normalizeText(labelElement.textContent).replace(/[::]\s*(&nbsp;)?/, ''); // 清理标签文本
                            let clone = span.cloneNode(true);
                            labelElement.remove(); // 移除标签
                            partText = `${label}: ${getCleanTextFromElement(clone)}`; // 输出 "标签: 答案"
                        } else {
                             if (!span.querySelector('a[onclick*="hideAndShow"]')) { // 避免“查看答案”链接
                                partText = getCleanTextFromElement(span);
                             }
                        }
                        if(partText && partText.trim()) subAnswers.push(partText.trim());
                    });
                    // 如果有子答案,则换行连接;否则使用整个div的文本
                    finalCorrectAnswer = subAnswers.length > 0 ? "\n" + subAnswers.join("\n\n") : getCleanTextFromElement(revealedAnswerDiv);
                } else { // 简答题
                    // 简答题答案通常在 revealedAnswerDiv 的 .clearfix 子div 或其本身
                    const ansContentDiv = revealedAnswerDiv.querySelector("div.clearfix") || revealedAnswerDiv;
                    finalCorrectAnswer = getCleanTextFromElement(ansContentDiv);
                }
            }

            // 针对选择题和判断题的最后一道防线:如果以上所有方法都未能提取到答案,
            // 再次检查 .Py_answer span 中是否有 "正确答案:"
            if (finalCorrectAnswer === "未能提取" && (questionTypeName === "单选题" || questionTypeName === "多选题" || questionTypeName === "判断题")) {
                 const answerSpans = questionElement.querySelectorAll('.Py_answer span');
                 for(let span of answerSpans){
                     const textContent = normalizeText(span.textContent);
                     if(textContent.startsWith('正确答案:')){
                         let visibleAnswerText = textContent.replace('正确答案:', '').trim();
                         // 针对选择题,如果答案是字母且选项中有此字母
                         if ((questionTypeName === "单选题" || questionTypeName === "多选题") && /^[A-Z]+$/.test(visibleAnswerText) && optionDetails.some(opt => opt.label === visibleAnswerText)) {
                             finalCorrectAnswer = visibleAnswerText;
                         // 针对判断题
                         } else if (questionTypeName === "判断题") {
                             if (visibleAnswerText === "√" || visibleAnswerText.toLowerCase() === "true" || visibleAnswerText === "对") finalCorrectAnswer = "对";
                             else if (visibleAnswerText === "×" || visibleAnswerText.toLowerCase() === "false" || visibleAnswerText === "错") finalCorrectAnswer = "错";
                             else finalCorrectAnswer = visibleAnswerText;
                         // 针对选择题,如果答案不是字母,尝试匹配选项文本
                         } else if (questionTypeName === "单选题" || questionTypeName === "多选题") {
                             let matched = false;
                             for (const opt of optionDetails) { if (opt.text === visibleAnswerText) { finalCorrectAnswer = opt.label; matched = true; break; } }
                             if (!matched) finalCorrectAnswer = visibleAnswerText; // 若无匹配,使用原始文本
                         } else {
                             finalCorrectAnswer = visibleAnswerText;
                         }
                         break; // 找到后即跳出循环
                     }
                 }
            }

            qOutput += "答案:" + finalCorrectAnswer + "\n"; // 输出: 答案:xxx

            // --- 提取题目解析 ---
            const explanationParent = questionElement.querySelector('.Py_addpy'); // 解析区域的父容器
            if (explanationParent) {
                const explanationElement = explanationParent.querySelector('.pingyu'); // 实际的解析文本容器
                 if (explanationElement) {
                    const explanationText = getCleanTextFromElement(explanationElement);
                    if (explanationText) { // 只有当解析文本非空时才输出
                        qOutput += "解析:" + explanationText + "\n"; // 输出: 解析:xxx
                    }
                }
            }
            qOutput += "\n"; // 每个题目结束后加一个空行,用于分隔
            return qOutput;

        } catch (e) { // 异常处理
            console.error("处理题目时出错:", e, questionElement);
            // 尝试获取出错题目的题号用于提示
            const qNumEl = questionElement.querySelector('.Cy_TItle i.fl, .Zy_TItle i.fl, :scope > i.fl');
            return `处理题目 ${qNumEl ? qNumEl.textContent.trim() : '未知'} 时出错。详情请查看控制台。\n\n`;
        }
    }

    // --- UI: 输出区域和复制按钮 ---
    let outputAreaElement = null; // 存储输出文本框的DOM引用
    let scriptContainerElement = null; // 存储脚本添加的整个UI容器的引用

    /**
     * 在页面上显示提取的题目内容。
     * @param {string} output - 要显示的格式化后的题目字符串。
     */
    function displayOutput(output) {
        // 如果UI元素尚未创建,则进行初始化
        if (!scriptContainerElement) {
            scriptContainerElement = document.createElement('div');
            scriptContainerElement.id = "universalExtractorContainerGM"; // 为整个UI容器设置ID

            outputAreaElement = document.createElement('textarea');
            outputAreaElement.id = 'extractionOutputAreaGM'; // 文本框ID
            outputAreaElement.readOnly = true; // 设置为只读
            scriptContainerElement.appendChild(outputAreaElement);

            const copyButtonElement = document.createElement('button');
            copyButtonElement.id = 'copyExtractionButtonGM'; // 复制按钮ID
            copyButtonElement.textContent = '复制内容到剪贴板';
            copyButtonElement.addEventListener('click', function() {
                GM_setClipboard(outputAreaElement.value); // 点击时复制文本框内容
                showCustomNotification('内容已再次复制到剪贴板!'); // 使用自定义通知提示
            });
            scriptContainerElement.appendChild(copyButtonElement);

            // 决定将UI容器插入到页面的哪个位置
            const targetContainer = document.querySelector('#ZyBottom'); // 尝试插入到主内容区之后
            const commonWrapper = document.querySelector('.wrap1000.clearfix.con'); // 备用插入点

            if (targetContainer && targetContainer.parentNode) {
                // 插入到 #ZyBottom 元素的后面
                targetContainer.parentNode.insertBefore(scriptContainerElement, targetContainer.nextSibling);
            } else if (commonWrapper) {
                 commonWrapper.appendChild(scriptContainerElement); // 作为 .wrap1000 的子元素
            } else {
                document.body.appendChild(scriptContainerElement); // 最后手段:添加到 body 的末尾
            }
        }
        outputAreaElement.value = output; // 设置文本框内容
        outputAreaElement.scrollTop = 0; // 滚动到文本框顶部,方便查看开头
    }

    // --- UI: 手动提取按钮 ---
    /**
     * 在页面上创建一个手动提取题目的按钮。
     */
    function createManualButton() {
        // 如果按钮已存在,则不重复创建
        if (document.getElementById('extractQuizButtonGM_merged')) return;

        const manualExtractButton = document.createElement('button');
        manualExtractButton.id = 'extractQuizButtonGM_merged'; // 按钮ID,确保唯一性
        manualExtractButton.textContent = '提取题目';
        // 按钮样式由 GM_addStyle 定义

        manualExtractButton.addEventListener('click', function() {
            const btn = this;
            const originalText = btn.textContent;
            const originalBg = btn.style.backgroundColor; // 保存原始背景色

            // 更新按钮状态为“正在提取”
            btn.textContent = '正在提取...';
            btn.disabled = true;
            btn.style.backgroundColor = '#5a6268'; // 设置为“忙碌”状态的颜色

            // 使用 setTimeout 允许浏览器UI更新按钮状态,然后再执行耗时操作
            setTimeout(() => {
                const outputGenerated = extractAndFormatQuestions(true); // 调用主提取函数,标记为手动触发
                if (outputGenerated) {
                    btn.textContent = '提取完成!';
                    btn.style.backgroundColor = '#218838'; // 成功颜色
                } else {
                    btn.textContent = '提取失败!';
                    btn.style.backgroundColor = '#c82333'; // 失败颜色
                }
                // 2.5秒后恢复按钮原始状态
                setTimeout(() => {
                    btn.textContent = originalText;
                    btn.style.backgroundColor = originalBg; // 恢复原始背景或默认样式定义的颜色
                    btn.disabled = false;
                }, 2500);
            }, 50); // 短暂延迟
        });
        document.body.appendChild(manualExtractButton); // 将按钮添加到body
    }

    // --- 油猴菜单设置项 & 自动提取逻辑 ---
    /**
     * 切换自动提取功能的开启/关闭状态。
     * 状态通过 GM_setValue/GM_getValue 持久化存储。
     */
    function toggleAutoExtract() {
        let isEnabled = GM_getValue(AUTO_EXTRACT_KEY, false); // 读取当前状态,默认为false
        GM_setValue(AUTO_EXTRACT_KEY, !isEnabled); // 切换状态并保存
        alert(`自动提取并复制功能已 ${!isEnabled ? '开启' : '关闭'}。\n请刷新页面以使设置生效。`);
        registerMenuCommands(); // 重新注册(不可用)菜单命令以更新标签文本(例如 ✅/❌)
    }

    /**
     * 注册(不可用)油猴脚本菜单命令,用于控制自动提取功能。
     * 菜单项的标签会根据当前自动提取状态动态显示。
     */
    function registerMenuCommands() {
        let isEnabled = GM_getValue(AUTO_EXTRACT_KEY, false);
        // 根据状态显示不同的前缀图标和文本
        GM_registerMenuCommand(`${isEnabled ? '✅' : '❌'} 自动提取题目 (当前: ${isEnabled ? '开启' : '关闭'}) - 点击切换`, toggleAutoExtract, 'a');
    }

    // --- 脚本初始化 ---
    registerMenuCommands(); // 页面加载时即注册(不可用)菜单命令
    createManualButton();   // 创建手动提取按钮

    // 页面加载完成后执行自动提取(如果已启用)
    window.addEventListener('load', function() {
        if (GM_getValue(AUTO_EXTRACT_KEY, false)) { // 检查自动提取是否已开启
            // 使用 setTimeout 延迟执行,给页面动态内容更多加载时间
            setTimeout(function() {
                console.log("油猴脚本:自动提取功能已开启,正在尝试提取...");
                extractAndFormatQuestions(false); // 调用主提取函数,标记为自动触发
            }, 1200); // 延迟1.2秒
        }
    });

    // --- 注入CSS样式 (用于脚本添加的UI元素) ---
    GM_addStyle(`
        #extractQuizButtonGM_merged { /* 手动提取按钮样式 (ID已更新) */
            position: fixed; top: 70px; right: 20px; z-index: 10001; /* 固定在右上角,高层级 */
            padding: 10px 15px; background-color: #007bff; color: white;
            border: none; border-radius: 5px; cursor: pointer;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2); /* 阴影效果 */
            transition: background-color 0.3s ease, transform 0.1s ease, opacity 0.3s ease; /* 过渡动画 */
        }
        #extractQuizButtonGM_merged:hover { background-color: #0056b3; opacity: 0.9; } /* 悬停效果 */
        #extractQuizButtonGM_merged:active { transform: scale(0.98); } /* 点击效果 */
        #extractQuizButtonGM_merged:disabled { background-color: #5a6268; cursor: not-allowed; } /* 禁用状态 */

        #universalExtractorContainerGM { /* 脚本UI总容器样式 (ID已更新) */
            margin-top: 25px; padding: 15px; background-color: #f0f0f0; /* 背景与内边距 */
            border-top: 1px solid #dee2e6; border-radius: 0 0 8px 8px; /* 边框与圆角 */
            clear: both; /* 清除浮动,避免影响页面原有布局 */
        }
        #extractionOutputAreaGM { /* 提取结果文本框样式 */
            width: 95%; min-height: 300px; max-height: 70vh; /* 宽高与最大高度限制 */
            margin: 20px auto; padding: 15px; border: 1px solid #ccc;
            border-radius: 4px; font-family: 'Courier New', Courier, monospace; /* 等宽字体 */
            font-size: 13px; line-height: 1.6; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
            resize: vertical; background-color: #fdfdfd; white-space: pre-wrap; /* 允许垂直调整大小,保留换行和空格 */
        }
        #copyExtractionButtonGM { /* 文本框下方的复制按钮样式 */
            display: block; margin: 10px auto 20px auto; padding: 12px 20px;
            background-color: #6c757d; color: white; border: none;
            border-radius: 4px; cursor: pointer; font-size: 15px;
            transition: background-color 0.2s ease-in-out;
        }
        #copyExtractionButtonGM:hover { background-color: #545b62; }

        #customNotificationGM { /* 自定义悬浮通知样式 */
            position: fixed; /* 固定定位 */
            top: 20px; /* 距顶部20px */
            left: 50%; /* 水平居中 */
            transform: translateX(-50%); /* 精确水平居中 */
            color: white;
            padding: 12px 25px;
            border-radius: 6px;
            box-shadow: 0 4px 10px rgba(0,0,0,0.15);
            z-index: 10002; /* 比提取按钮更高的层级 */
            font-size: 15px;
            display: none; /* 初始隐藏 */
            text-align: center;
            opacity: 0; /* 初始透明,配合JS实现淡入 */
        }
    `);

})(); // IIFE结束

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址