Greasy Fork

Greasy Fork is available in English.

组卷网试卷提取

从组卷网提取试卷内容并导出为Word,支持图片处理

// ==UserScript==
// @name         组卷网试卷提取
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  从组卷网提取试卷内容并导出为Word,支持图片处理
// @icon         https://toolb.cn/favicon/zujuan.xkw.com
// @author       pansoul
// @license      MIT
// @match        https://zujuan.xkw.com/zujuan/*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @connect      staticzujuan.xkw.com
// @connect      cdn*.xkw.com
// ==/UserScript==

(function() {
    'use strict';


    GM_addStyle(`
        .xkw-exporter {
            position: fixed;
            top: 100px;
            right: 20px;
            z-index: 9999;
            background: #4285f4;
            color: #fff;
            padding: 10px 18px 10px 42px; /* 预留左侧 icon 空间 */
            border-radius: 6px;
            box-shadow: 0 4px 10px rgba(0,0,0,0.25);
            font-family: Arial, sans-serif;
            font-size: 14px;
            cursor: pointer;
            transition: background 0.25s;
        }
        .xkw-exporter::before {
            content: '';
            position: absolute;
            left: 12px;
            top: 50%;
            width: 18px;
            height: 18px;
            transform: translateY(-50%);
            background: url('https://toolb.cn/favicon/zujuan.xkw.com') no-repeat center/contain;
        }
        .xkw-exporter:hover {
            background: #3367d6;
        }
        .xkw-exporter-panel {
            position: fixed;
            top: 150px;
            right: 20px;
            z-index: 9998;
            background: #ffffff;
            border-radius: 8px;
            box-shadow: 0 8px 20px rgba(0,0,0,0.15);
            padding: 20px 22px 18px;
            width: 330px;
            display: none;
            font-family: 'Microsoft YaHei', Arial, sans-serif;
        }
        .xkw-exporter-panel h3 {
            margin: 0 0 12px 0;
            font-size: 18px;
            border-bottom: 1px solid #e0e0e0;
            padding-bottom: 10px;
        }
        .xkw-exporter-panel button {
            display: block;
            width: 100%;
            margin: 10px 0;
            padding: 10px 0;
            background: #4285f4;
            color: #fff;
            border: none;
            border-radius: 4px;
            font-size: 15px;
            cursor: pointer;
            transition: background 0.25s;
        }
        .xkw-exporter-panel button:hover {
            background: #3367d6;
        }
        .xkw-exporter-panel .close {
            position: absolute;
            top: 10px;
            right: 10px;
            cursor: pointer;
            font-size: 18px;
        }
        .xkw-exporter-panel .options {
            margin: 10px 0;
        }
        .xkw-exporter-panel .options label {
            display: block;
            margin: 5px 0;
        }
        .xkw-exporter-progress {
            position: fixed;
            top: 200px;
            right: 20px;
            z-index: 9997;
            background: rgba(0,0,0,0.7);
            color: white;
            padding: 10px;
            border-radius: 4px;
            display: none;
        }
    `);


    window.addEventListener('load', function() {
        setTimeout(initExporter, 1000);
    });

    function initExporter() {

        const exporterBtn = document.createElement('div');
        exporterBtn.className = 'xkw-exporter';
        exporterBtn.textContent = '导出试卷';
        exporterBtn.addEventListener('click', toggleExporterPanel);
        document.body.appendChild(exporterBtn);


        const exporterPanel = document.createElement('div');
        exporterPanel.className = 'xkw-exporter-panel';
        exporterPanel.innerHTML = `
            <span class="close">&times;</span>
            <h3>试卷导出工具</h3>
            <div class="options">
                <label><input type="checkbox" id="download-images" checked> 下载图片(Word格式)</label>
                <label><input type="checkbox" id="format-equations" checked> 格式化公式</label>
            </div>
            <button id="export-word">导出为Word</button>
        `;
        document.body.appendChild(exporterPanel);


        const progressDiv = document.createElement('div');
        progressDiv.className = 'xkw-exporter-progress';
        progressDiv.innerHTML = '处理中...';
        document.body.appendChild(progressDiv);


        exporterPanel.querySelector('.close').addEventListener('click', function() {
            exporterPanel.style.display = 'none';
        });


        document.getElementById('export-word').addEventListener('click', function() {
            exportPaper('word');
        });
    }

    function toggleExporterPanel() {
        const panel = document.querySelector('.xkw-exporter-panel');
        panel.style.display = panel.style.display === 'none' || panel.style.display === '' ? 'block' : 'none';
    }

    function showProgress(message) {
        const progress = document.querySelector('.xkw-exporter-progress');
        progress.textContent = message;
        progress.style.display = 'block';
    }

    function hideProgress() {
        const progress = document.querySelector('.xkw-exporter-progress');
        progress.style.display = 'none';
    }


    function extractPaperContent() {
        showProgress('正在提取试卷内容...');


        const downloadImages = document.getElementById('download-images').checked;
        const formatEquations = document.getElementById('format-equations').checked;


        const paperTitle = document.querySelector('.paper-title .main-title')?.textContent.trim() || '未命名试卷';


        const paperContent = {
            title: paperTitle,
            sections: [],
            options: {
                includeAnswers: false,
                includeExplanations: false,
                downloadImages,
                formatEquations
            }
        };


        let questionGlobalIndex = 1;


        const questionTypes = document.querySelectorAll('.ques-type');

        questionTypes.forEach((typeSection, typeIndex) => {
            const currentSection = {
                type: '',
                index: '',
                questions: []
            };

            const questions = typeSection.querySelectorAll('.ques-item');

            questions.forEach((question, qIndex) => {

                let qContent = question.querySelector('.exam-item__cnt')?.innerHTML.trim() || '';


                if (!qContent) {
                    return;
                }


                const qNumber = `${questionGlobalIndex}.`;
                questionGlobalIndex++;



                {
                    const temp = document.createElement('div');
                    temp.innerHTML = qContent;
                    const idx = temp.querySelector('.quesindex');
                    if (idx) idx.parentNode.removeChild(idx);
                    qContent = temp.innerHTML;
                }


                qContent = qContent.replace(/^\s*(?:<[^>]+>\s*)*\d+\s*[..、]?\s*/, '');


                const options = [];
                const optionsTable = question.querySelector('table[name="optionsTable"]');
                if (optionsTable) {
                    const optionRows = optionsTable.querySelectorAll('tr');
                    optionRows.forEach(row => {
                        const cells = row.querySelectorAll('td');
                        cells.forEach(cell => {
                            options.push(cell.innerHTML);
                        });
                    });


                    const tempDiv = document.createElement('div');
                    tempDiv.innerHTML = qContent;
                    const contentOptionsTable = tempDiv.querySelector('table[name="optionsTable"]');
                    if (contentOptionsTable) {
                        contentOptionsTable.parentNode.removeChild(contentOptionsTable);
                    }
                    qContent = tempDiv.innerHTML;
                }

                const images = [];
                if (downloadImages) {
                    const imgElements = question.querySelectorAll('img');
                    imgElements.forEach(img => {
                        if (img.src && !img.src.startsWith('data:')) {
                            images.push({
                                src: img.src,
                                alt: img.alt || '',
                                width: img.width || 0,
                                height: img.height || 0
                            });
                        }
                    });
                }

                // 不再获取答案和解析
                const answer = '';
                const explanation = '';

                currentSection.questions.push({
                    number: qNumber,
                    content: qContent,
                    options: options,
                    answer: answer,
                    explanation: explanation,
                    images: images
                });
            });

            if (currentSection.questions.length > 0) {
                const typeName = typeSection.querySelector('.questypename')?.textContent.trim() || `题型${typeIndex + 1}`;
                const rawTypeIndex = typeSection.querySelector('.questypeindex b')?.textContent.trim() || `${typeIndex + 1}、`;
                const typeIndex2 = rawTypeIndex.replace(/^\d+[、\.]\s*\d+[、\.]/, match => {
                    const firstNum = match.match(/^\d+/)[0];
                    return `${firstNum}、`;
                });

                currentSection.type = typeName;
                currentSection.index = typeIndex2;

                paperContent.sections.push(currentSection);
            }
        });

        hideProgress();
        return paperContent;
    }


    function paperToHTML(paperContent) {
        let html = `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <title>${paperContent.title}</title>
            <style>
                @page {
                    size: A4;
                    margin: 2cm;
                }
                body {
                    font-family: "SimSun", "Microsoft YaHei", Arial, sans-serif;
                    line-height: 1.6;
                    font-size: 12pt;
                    width: 21cm;
                    margin: 0 auto;
                }
                .paper-title {
                    text-align: center;
                    font-size: 18pt;
                    font-weight: bold;
                    margin: 20px 0 30px 0;
                }
                .section-title {
                    font-weight: bold;
                    margin: 20px 0 15px 0;
                    font-size: 14pt;
                }
                .question {
                    margin-bottom: 20px;
                    page-break-inside: avoid;
                }
                .question-content {
                    margin-bottom: 8px;
                }
                .options {
                    margin-left: 2em;
                }
                .option {
                    margin-bottom: 5px;
                }
                .answer {
                    color: #d81e06;
                    margin-top: 8px;
                    font-weight: bold;
                }
                .explanation {
                    color: #1e88e5;
                    margin-top: 8px;
                    border-left: 3px solid #1e88e5;
                    padding-left: 10px;
                }
                img {
                    max-width: 90%;
                    display: block;
                    margin: 10px auto;
                }
                table {
                    border-collapse: collapse;
                    width: 100%;
                }
                table, th, td {
                    border: 1px solid #ddd;
                }
                th, td {
                    padding: 8px;
                    text-align: left;
                }
                @media print {
                    .page-break {
                        page-break-before: always;
                    }
                    body {
                        font-size: 12pt;
                    }
                    .no-print {
                        display: none;
                    }
                }
            </style>
        </head>
        <body>
            <div class="paper-title">${paperContent.title}</div>
        `;


        const nonEmptySections = paperContent.sections.filter(section => section.questions && section.questions.length > 0);

        nonEmptySections.forEach((section, sectionIndex) => {
            html += `<div class="section-title">${section.index} ${section.type}</div>`;

            section.questions.forEach((question, qIndex) => {
                html += `<div class="question">`;
                html += `<div class="question-content">${question.number} ${cleanHTML(question.content)}</div>`;

                if (question.options.length > 0) {
                    html += `<div class="options">`;
                    const optionRows = [];
                    for (let i = 0; i < question.options.length; i += 2) {
                        const row = [question.options[i]];
                        if (i + 1 < question.options.length) {
                            row.push(question.options[i + 1]);
                        }
                        optionRows.push(row);
                    }

                    html += `<table border="0" cellpadding="5" cellspacing="0" style="border:none;">`;
                    optionRows.forEach(row => {
                        html += `<tr>`;
                        row.forEach(option => {
                            html += `<td style="border:none;">${cleanHTML(option)}</td>`;
                        });
                        if (row.length === 1) {
                            html += `<td style="border:none;"></td>`;
                        }
                        html += `</tr>`;
                    });
                    html += `</table>`;
                    html += `</div>`;
                }

                // 不再显示答案和解析

                html += `</div>`;

                if ((qIndex + 1) % 10 === 0 && qIndex < section.questions.length - 1) {
                    html += `<div class="page-break"></div>`;
                }
            });

            if (sectionIndex < nonEmptySections.length - 1) {
                html += `<div class="page-break"></div>`;
            }
        });

        html += `
        <script>

        function fixMathFormulas() {
            // 替换常见的数学符号
            const mathSymbols = {
                '\\\\frac{': '(', // 分数开始
                '}': ')', // 括号结束
                '\\\\sqrt{': '√(', // 平方根
                '\\\\le': '≤', // 小于等于
                '\\\\ge': '≥', // 大于等于
                '\\\\neq': '≠', // 不等于
                '\\\\alpha': 'α', // 希腊字母
                '\\\\beta': 'β',
                '\\\\gamma': 'γ',
                '\\\\delta': 'δ',
                '\\\\pi': 'π',
                '\\\\infty': '∞', // 无穷
                '\\\\times': '×', // 乘号
                '\\\\div': '÷', // 除号
                '\\\\pm': '±', // 正负号
                '\\\\cdot': '·', // 点乘
                '\\\\ldots': '...', // 省略号
                '\\\\equiv': '≡', // 恒等于
                '\\\\cong': '≅', // 全等于
                '\\\\approx': '≈', // 约等于
                '\\\\triangle': '△', // 三角形
                '\\\\angle': '∠', // 角
                '\\\\perp': '⊥', // 垂直
                '\\\\parallel': '∥', // 平行
                '\\\\sim': '∼', // 相似
                '\\\\partial': '∂', // 偏导数
                '\\\\int': '∫', // 积分
                '\\\\sum': '∑', // 求和
                '\\\\prod': '∏', // 求积
                '\\\\lim': 'lim', // 极限
                '\\\\rightarrow': '→', // 箭头
                '\\\\leftarrow': '←',
                '\\\\Rightarrow': '⇒',
                '\\\\Leftarrow': '⇐',
                '\\\\Leftrightarrow': '⇔',
                '\\\\leftrightarrow': '↔',
                '\\\\subset': '⊂', // 集合
                '\\\\supset': '⊃',
                '\\\\subseteq': '⊆',
                '\\\\supseteq': '⊇',
                '\\\\cup': '∪',
                '\\\\cap': '∩',
                '\\\\in': '∈',
                '\\\\notin': '∉',
                '\\\\emptyset': '∅',
                '\\\\mathbb{R}': 'ℝ', // 特殊集合
                '\\\\mathbb{Z}': 'ℤ',
                '\\\\mathbb{N}': 'ℕ',
                '\\\\mathbb{Q}': 'ℚ',
                '\\\\mathbb{C}': 'ℂ'
            };

            // 替换数学符号
            const elements = document.querySelectorAll('.question-content, .option, .answer, .explanation');
            elements.forEach(el => {
                let html = el.innerHTML;
                for (const [symbol, replacement] of Object.entries(mathSymbols)) {
                    const regex = new RegExp(symbol.replace(/\\/g, '\\\\'), 'g');
                    html = html.replace(regex, replacement);
                }
                el.innerHTML = html;
            });

            // 处理上下标
            const superscriptRegex = /<sup>(.*?)<\/sup>/g;
            const subscriptRegex = /<sub>(.*?)<\/sub>/g;
            elements.forEach(el => {
                let html = el.innerHTML;
                html = html.replace(superscriptRegex, '^($1)');
                html = html.replace(subscriptRegex, '_($1)');
                el.innerHTML = html;
            });

            // 移除知识点链接和提示
            const knowledgeLinks = document.querySelectorAll('.knowledge-point a, a[class*="knowledge"], [class*="知识点"]');
            knowledgeLinks.forEach(link => {
                if (link.parentNode) {
                    link.parentNode.removeChild(link);
                }
            });

            // 移除知识点提示
            const knowledgePoints = document.querySelectorAll('.knowledge-point, [class*="knowledge"], [class*="知识点"]');
            knowledgePoints.forEach(point => {
                if (point.parentNode) {
                    point.parentNode.removeChild(point);
                }
            });

            // 移除题型编号中的重复
            const sectionTitles = document.querySelectorAll('.section-title');
            sectionTitles.forEach(title => {
                title.textContent = title.textContent.replace(/^(\d+)[、\.]\s*\d+[、\.]/, '$1、');
            });

            // 移除题目编号中的重复
            const questionContents = document.querySelectorAll('.question-content');
            questionContents.forEach(content => {
                const firstPart = content.innerHTML.split(' ')[0];
                if (firstPart && firstPart.match(/^\d+\./)) {
                    content.innerHTML = content.innerHTML.replace(/^(\d+)\.\s*\d+\./, '$1.');
                }
            });
        }

        // 页面加载完成后执行
        window.addEventListener('load', fixMathFormulas);
        </script>
        </body>
        </html>
        `;

        return html;
    }


    function paperToText(paperContent) {
        let text = `${paperContent.title}\n\n`;


        const nonEmptySections = paperContent.sections.filter(section => section.questions && section.questions.length > 0);

        nonEmptySections.forEach(section => {

            let sectionTitle = section.index + ' ' + section.type;
            sectionTitle = sectionTitle.replace(/^(\d+)[、\.]\s*\d+[、\.]/, '$1、');

            text += `${sectionTitle}\n\n`;

            section.questions.forEach(question => {

                let questionNumber = question.number;
                questionNumber = questionNumber.replace(/^(\d+)[..、]\s*\d+[..、]/, '$1.');

                text += `${questionNumber} ${stripHTML(question.content)}\n`;

                if (question.options.length > 0) {
                    question.options.forEach((option, index) => {
                        text += `   ${stripHTML(option)}\n`;
                    });
                }

                // 不再显示答案和解析

                text += `\n`;
            });

            text += `\n`;
        });

        return text;
    }


    async function processImages(paperContent) {
        if (!paperContent.options.downloadImages) return paperContent;

        showProgress('正在处理图片...');

        const imagePromises = [];
        const imageMap = new Map();


        paperContent.sections.forEach(section => {
            section.questions.forEach(question => {
                question.images.forEach(img => {
                    if (!imageMap.has(img.src)) {
                        imageMap.set(img.src, null);
                        const promise = fetchImageAsBase64(img.src)
                            .then(base64 => {
                                imageMap.set(img.src, base64);
                            })
                            .catch(err => {
                                console.error(`Failed to fetch image: ${img.src}`, err);
                            });
                        imagePromises.push(promise);
                    }
                });
            });
        });


        await Promise.all(imagePromises);


        paperContent.sections.forEach(section => {
            section.questions.forEach(question => {

                const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/g;
                question.content = question.content.replace(imgRegex, (match, src) => {
                    const base64 = imageMap.get(src);
                    if (base64) {
                        return match.replace(src, base64);
                    }
                    return match;
                });


                question.options = question.options.map(option => {
                    return option.replace(imgRegex, (match, src) => {
                        const base64 = imageMap.get(src);
                        if (base64) {
                            return match.replace(src, base64);
                        }
                        return match;
                    });
                });


                // 不再处理答案和解析中的图片
            });
        });

        hideProgress();
        return paperContent;
    }


    function fetchImageAsBase64(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'arraybuffer',
                onload: function(response) {
                    try {
                        let binary = '';
                        const bytes = new Uint8Array(response.response);
                        const len = bytes.byteLength;
                        for (let i = 0; i < len; i++) {
                            binary += String.fromCharCode(bytes[i]);
                        }


                        let mimeType = 'image/jpeg';
                        if (url.endsWith('.png')) {
                            mimeType = 'image/png';
                        } else if (url.endsWith('.gif')) {
                            mimeType = 'image/gif';
                        } else if (url.endsWith('.svg')) {
                            mimeType = 'image/svg+xml';
                        }

                        const base64 = 'data:' + mimeType + ';base64,' + btoa(binary);
                        resolve(base64);
                    } catch (e) {
                        reject(e);
                    }
                },
                onerror: function(error) {
                    reject(error);
                }
            });
        });
    }


    async function exportPaper(format) {
        let paperContent = extractPaperContent();

        if (format === 'word' && paperContent.options.downloadImages) {
            paperContent = await processImages(paperContent);
        }

        const fileName = `${paperContent.title}_${new Date().toISOString().slice(0, 10)}`;

        showProgress(`正在导出为${format.toUpperCase()}格式...`);

        switch (format) {
            case 'word':
                const html = paperToHTML(paperContent);

                const blob = new Blob([html], {type: 'text/html'});
                const url = URL.createObjectURL(blob);


                const a = document.createElement('a');
                a.href = url;
                a.download = `${fileName}.doc`;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);


                setTimeout(() => URL.revokeObjectURL(url), 100);
                break;

            case 'txt':
                const text = paperToText(paperContent);
                const txtBlob = new Blob([text], {type: 'text/plain'});
                const txtUrl = URL.createObjectURL(txtBlob);

                const txtLink = document.createElement('a');
                txtLink.href = txtUrl;
                txtLink.download = `${fileName}.txt`;
                document.body.appendChild(txtLink);
                txtLink.click();
                document.body.removeChild(txtLink);

                setTimeout(() => URL.revokeObjectURL(txtUrl), 100);
                break;

            case 'json':
                const json = JSON.stringify(paperContent, null, 2);
                const jsonBlob = new Blob([json], {type: 'application/json'});
                const jsonUrl = URL.createObjectURL(jsonBlob);

                const jsonLink = document.createElement('a');
                jsonLink.href = jsonUrl;
                jsonLink.download = `${fileName}.json`;
                document.body.appendChild(jsonLink);
                jsonLink.click();
                document.body.removeChild(jsonLink);

                setTimeout(() => URL.revokeObjectURL(jsonUrl), 100);
                break;
        }

        hideProgress();
        alert(`已成功导出为${format.toUpperCase()}格式!`);
    }


    function cleanHTML(html) {
        if (!html) return '';

        return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
    }


    function stripHTML(html) {
        if (!html) return '';
        const temp = document.createElement('div');
        temp.innerHTML = html;
        return temp.textContent || temp.innerText || '';
    }


    function formatEquations(html) {
        if (!html) return '';

        return html;
    }
})();