// ==UserScript== // @name 超星学习通作业/考试一键导出为Word(docx) // @namespace https://scriptcat.org/ // @version 3.0.5 // @description 适配脚本猫的学习通作业/考试导出脚本,自动抓取题目、选项、正确答案、解析,并生成排版优化后的 Word 文档。 // @author Restrl. // @match *://*.chaoxing.com/mooc2/work/view* // @match *://*.chaoxing.com/mooc-ans/mooc2/work/view* // @match *://*.chaoxing.com/exam/test/reVersionTestStartNew* // @match *://*.chaoxing.com/exam-ans/* // @require https://cdn.jsdelivr.net/npm/docx@7.1.0/build/index.js // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js // @grant GM_xmlhttpRequest // ==/UserScript== (function () { 'use strict'; const { AlignmentType, BorderStyle, Document, HeadingLevel, ImageRun, Packer, Paragraph, ShadingType, TextRun, } = docx; const STYLE = { font: 'Microsoft YaHei', title: '1F2937', muted: '6B7280', border: 'E5E7EB', answer: '00A870', analysis: '667085', analysisBg: 'F7F9FC', type: '64748B', }; const EMPTY_LINE = new Paragraph({ spacing: { after: 120 } }); const RUN_META = new WeakMap(); function addExportButton() { if (document.querySelector('#chaoxingWordExportBtn')) return; const btn = document.createElement('button'); btn.id = 'chaoxingWordExportBtn'; btn.type = 'button'; btn.innerText = '导出 Word'; btn.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 112px; padding: 10px 18px; background: #2563eb; color: #fff; border: 0; border-radius: 8px; cursor: pointer; box-shadow: 0 8px 20px rgba(37,99,235,.25); font-weight: 700; font-size: 14px; letter-spacing: 0; `; btn.onclick = startExport; document.body.appendChild(btn); } function setButtonState(text, disabled) { const btn = document.querySelector('#chaoxingWordExportBtn'); if (!btn) return; btn.innerText = text; btn.disabled = disabled; btn.style.opacity = disabled ? '.72' : '1'; btn.style.cursor = disabled ? 'wait' : 'pointer'; } async function urlToBlob(url) { return new Promise((resolve) => { if (!url) { resolve(null); return; } if (url.startsWith('data:image')) { fetch(url).then((res) => res.blob()).then(resolve).catch(() => resolve(null)); return; } GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload: (res) => resolve(res.response), onerror: () => resolve(null), }); }); } function getImageSize(img) { const width = img.naturalWidth || Number(img.getAttribute('width')) || 400; const height = img.naturalHeight || Number(img.getAttribute('height')) || 300; const maxWidth = 420; const maxHeight = 260; const scale = Math.min(maxWidth / width, maxHeight / height, 1); return { width: Math.round(width * scale), height: Math.round(height * scale), }; } function createTextRun(text, options = {}) { const run = new TextRun({ text, font: STYLE.font, size: options.size || 22, bold: options.bold || false, italics: options.italics || false, color: options.color || STYLE.title, subScript: options.subScript, superScript: options.superScript, }); RUN_META.set(run, { text, options }); return run; } function createLineBreakRun() { const run = new TextRun({ text: '\n', font: STYLE.font }); RUN_META.set(run, { text: '\n', options: {} }); return run; } async function parseRichText(element, options = {}) { const runs = []; if (!element) return runs; for (const node of element.childNodes) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent.replace(/\s+/g, ' '); if (text.trim()) { runs.push(createTextRun(text, options)); } continue; } if (node.nodeType !== Node.ELEMENT_NODE) continue; const tag = node.tagName; if (tag === 'IMG') { const imgBlob = await urlToBlob(node.src); if (!imgBlob) continue; const imgBuffer = await imgBlob.arrayBuffer(); runs.push(createLineBreakRun()); runs.push(new ImageRun({ data: imgBuffer, transformation: getImageSize(node), })); runs.push(createLineBreakRun()); continue; } if (tag === 'BR') { runs.push(createLineBreakRun()); continue; } if (['SCRIPT', 'STYLE', 'INPUT', 'BUTTON'].includes(tag)) continue; const childOptions = { ...options, bold: options.bold || ['STRONG', 'B'].includes(tag), italics: options.italics || ['EM', 'I'].includes(tag), subScript: tag === 'SUB' ? true : options.subScript, superScript: tag === 'SUP' ? true : options.superScript, }; runs.push(...await parseRichText(node, childOptions)); if (['P', 'DIV'].includes(tag)) { runs.push(createLineBreakRun()); } } return trimRuns(runs); } function trimRuns(runs) { const result = [...runs]; while (result.length && RUN_META.get(result[0])?.text === '\n') result.shift(); while (result.length && RUN_META.get(result[result.length - 1])?.text === '\n') result.pop(); if (result.length) { const firstMeta = RUN_META.get(result[0]); if (firstMeta && firstMeta.text !== '\n') { const trimmedText = firstMeta.text.replace(/^\s+/, ''); result[0] = createTextRun(trimmedText, firstMeta.options); } } if (result.length) { const lastMeta = RUN_META.get(result[result.length - 1]); if (lastMeta && lastMeta.text !== '\n') { const trimmedText = lastMeta.text.replace(/\s+$/, ''); result[result.length - 1] = createTextRun(trimmedText, lastMeta.options); } } return result.filter((run) => RUN_META.get(run)?.text !== ''); } function getQuestionNodes() { const selectors = ['.aiArea', '.questionLi', '.TiMu']; for (const selector of selectors) { const nodes = document.querySelectorAll(selector); if (nodes.length > 0) return Array.from(nodes); } return []; } function getAssignmentTitle() { const selectors = ['h2.mark_title', '.mark_title', '.Cy_TItle .clearfix', 'h1', 'title']; for (const selector of selectors) { const titleNode = document.querySelector(selector); const title = titleNode && titleNode.innerText ? titleNode.innerText.trim() : ''; if (title) return title; } const fallback = document.title || '作业导出'; return fallback.split(/[|\-_]/)[0].trim() || '作业导出'; } function safeFileName(name) { return name.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim() || '学习通作业导出'; } function cleanLabelText(text) { return text .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .replace(/^[::\s]+/, '') .replace(/^(?:正确答案|参考答案)[::\s]*/g, '') .replace(/^答案[::\s]+/g, '') .replace(/^(?:我的答案|你的答案|学生答案)[::\s]*/g, '') .trim(); } function cleanAnswerLine(text) { return text .replace(/\u00a0/g, ' ') .replace(/[ \t\f\v]+/g, ' ') .replace(/^[::\s]+/, '') .replace(/^(?:正确答案|参考答案)[::\s]*/g, '') .replace(/^答案[::\s]+/g, '') .replace(/^(?:我的答案|你的答案|学生答案)[::\s]*/g, '') .trim(); } function answerLinesFromText(answerText) { const lines = answerText .replace(/\r/g, '\n') .split(/\n+/) .map(cleanAnswerLine) .filter(Boolean); const mergedLines = []; for (let i = 0; i < lines.length; i++) { if (/^(?:[((]\d+[))]|\d+[.、.])$/.test(lines[i]) && lines[i + 1]) { mergedLines.push(`${lines[i]} ${lines[i + 1]}`); i += 1; } else { mergedLines.push(lines[i]); } } return mergedLines; } function hasCorrectAnswerLabel(text) { return /(?:正确答案|参考答案)/.test(text || ''); } function hasStudentAnswerLabel(text) { return /(?:我的答案|你的答案|学生答案)/.test(text || ''); } function stripNonTeacherAnswerTail(text) { return text .replace(/(?:答案解析|解析|我的答案|你的答案|学生答案)[::]\s*[\s\S]*$/g, '') .replace(/(?:^|\n)\s*(?:答案解析|解析|我的答案|你的答案|学生答案)\s*[\s\S]*$/g, '') .trim(); } function normalizeAnswerText(text) { if (!text) return ''; let value = text.replace(/\u00a0/g, ' ').replace(/\r/g, '\n').trim(); value = value.replace(/答案解析[::][\s\S]*$/g, '').trim(); const correctAnswerMatch = value.match(/(?:正确答案|参考答案)[::\s]*([\s\S]*)/); if (correctAnswerMatch) { value = correctAnswerMatch[1].trim(); } else { const genericAnswerMatch = value.match(/(?:^|\n)答案[::\s]*([\s\S]*)/); if (genericAnswerMatch) value = genericAnswerMatch[1].trim(); } value = stripNonTeacherAnswerTail(value); value = value .replace(/(?:正确答案|参考答案)[::]?\s*/g, '') .replace(/(?:我的答案|你的答案|学生答案)[::]?\s*/g, '') .replace(/^[::\s]+/, '') .replace(/\n{3,}/g, '\n\n') .trim(); return answerLinesFromText(value).join('\n'); } function getDirectText(element) { if (!element) return ''; return Array.from(element.childNodes) .filter((node) => node.nodeType === Node.TEXT_NODE) .map((node) => node.textContent) .join(' ') .trim(); } async function getQuestionTitleRuns(titleNode) { const typeNode = titleNode.querySelector('.colorShallow'); const contentNode = titleNode.querySelector('.qtContent'); const runs = []; if (typeNode) { runs.push(createTextRun(cleanLabelText(typeNode.innerText), { color: STYLE.type, size: 21, })); runs.push(createTextRun(' ', { size: 21 })); } if (contentNode) { runs.push(...await parseRichText(contentNode)); } else { const cloneTitle = titleNode.cloneNode(true); const indexSpan = cloneTitle.querySelector('span:first-child'); if (indexSpan && /^\d+/.test(indexSpan.innerText)) indexSpan.remove(); for (const node of cloneTitle.childNodes) { if (node.nodeType === Node.TEXT_NODE) { node.textContent = node.textContent.replace(/^[\s\d.、.]+/, ''); break; } } runs.push(...await parseRichText(cloneTitle)); } return trimRuns(runs); } function getQuestionType(titleNode) { const typeNode = titleNode.querySelector('.colorShallow'); const text = typeNode ? typeNode.innerText : titleNode.innerText; const match = text.match(/[((]([^))]+题)[))]/); return match ? match[1] : ''; } function isObjectiveQuestion(questionType) { return /(?:单选|多选|选择|判断|不定项)/.test(questionType || ''); } function isScoreLine(text) { return /^(?:得分|分值)?[::]?\s*\d+(?:\.\d+)?\s*分$/.test(text || ''); } function formatChoiceLetters(lettersText) { const letters = (lettersText || '').replace(/[^A-Ha-h]/g, '').toUpperCase(); if (!letters) return ''; return letters.length === 1 ? letters : letters.split('').join('、'); } function extractChoiceLetters(text) { const value = (text || '').trim(); const exactMatch = value.match(/^[A-Ha-h](?:\s*[、,,;;/|\s]+\s*[A-Ha-h])*$/); if (exactMatch) return formatChoiceLetters(value); const prefixedMatch = value.match(/^([A-Ha-h])\s*[.、.::]\s*\S+/); return prefixedMatch ? prefixedMatch[1].toUpperCase() : ''; } function normalizeComparableText(text) { return (text || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, '') .replace(/^[A-Ha-h]\s*[.、.::]\s*/, '') .replace(/[.。;;,,、::]/g, '') .trim(); } function getOptionLetterByContent(qBox, answerText) { const normalizedAnswer = normalizeComparableText(answerText); if (!normalizedAnswer) return ''; const optionNodes = qBox.querySelectorAll('ul.mark_letter li, .mark_letter li'); for (const optionNode of optionNodes) { const rawText = (optionNode.innerText || '').trim(); const letterMatch = rawText.match(/^\s*([A-Ha-h])\s*[.、.::]/); if (!letterMatch) continue; const optionText = normalizeComparableText(rawText); if (optionText && (optionText === normalizedAnswer || normalizedAnswer.includes(optionText))) { return letterMatch[1].toUpperCase(); } } return ''; } function normalizeObjectiveAnswerText(answerText, questionType, qBox) { const lines = normalizeAnswerText(answerText) .split(/\n+/) .map(cleanAnswerLine) .map((line) => line.replace(/(?:得分|分值)?[::]?\s*\d+(?:\.\d+)?\s*分/g, '').trim()) .filter((line) => line && !isScoreLine(line)); if (!lines.length) return ''; if (/判断/.test(questionType || '')) { const judgeMatch = lines.join(' ').match(/(?:正确|错误|对|错|√|×|✓|✗)/); if (judgeMatch) return judgeMatch[0]; } for (const line of lines) { const letters = extractChoiceLetters(line); if (letters) return letters; } const inferredLetter = getOptionLetterByContent(qBox, lines.join(' ')); return inferredLetter || lines[0]; } function normalizeAnswerByQuestionType(answerText, questionType, qBox) { if (!answerText) return ''; if (isObjectiveQuestion(questionType)) { return normalizeObjectiveAnswerText(answerText, questionType, qBox); } return normalizeAnswerText(answerText); } function getLabeledAnswerText(text) { if (!hasCorrectAnswerLabel(text)) return ''; const rawLines = text .replace(/\u00a0/g, ' ') .split(/\n+/) .map((line) => line.trim()) .filter(Boolean); const correctIndex = rawLines.findIndex((line) => hasCorrectAnswerLabel(line)); if (correctIndex < 0) return normalizeAnswerText(text); const answerTail = rawLines.slice(correctIndex); const endIndex = answerTail.findIndex((line, index) => ( index > 0 && /答案解析|解析|我的答案|你的答案|学生答案/.test(line) )); const answerLines = endIndex >= 0 ? answerTail.slice(0, endIndex) : answerTail; return normalizeAnswerText(answerLines.join('\n')); } function getCandidateAnswerText(node, allowUnlabeled, questionType, qBox) { if (!node) return ''; const rawText = node.innerText || ''; const labeledText = getLabeledAnswerText(rawText); if (labeledText) return normalizeAnswerByQuestionType(labeledText, questionType, qBox); if (!allowUnlabeled) return ''; if (hasStudentAnswerLabel(rawText)) return ''; return normalizeAnswerByQuestionType(rawText, questionType, qBox); } function getAnswerText(qBox, questionType) { const ansDiv = qBox.querySelector('.mark_answer'); if (!ansDiv) return ''; const reliableSelectors = [ '.mark_key .colorGreen', '.mark_key .colorDeep', '.mark_key', '.rightAnswerContent', ]; for (const selector of reliableSelectors) { const node = ansDiv.querySelector(selector); const text = getCandidateAnswerText(node, true, questionType, qBox); if (text) return text; } const labelRequiredSelectors = [ '.mark_fill', '.answerCon', ]; for (const selector of labelRequiredSelectors) { const node = ansDiv.querySelector(selector); const text = getCandidateAnswerText(node, false, questionType, qBox); if (text) return text; } const labeledText = getLabeledAnswerText(ansDiv.innerText || ''); if (labeledText) return normalizeAnswerByQuestionType(labeledText, questionType, qBox); return ''; } async function getAnalysisRuns(qBox) { const analysisDiv = qBox.querySelector('.analysisDiv, .mark_answer_analysis'); if (!analysisDiv) return []; const analysisContent = analysisDiv.querySelector('.qtAnalysis, .text, .analysisContent') || analysisDiv; const clone = analysisContent.cloneNode(true); const label = getDirectText(clone); if (/^(答案解析|解析)[::]?/.test(label)) { for (const node of Array.from(clone.childNodes)) { if (node.nodeType === Node.TEXT_NODE) { node.textContent = node.textContent.replace(/^(答案解析|解析)[::]?\s*/, ''); break; } } } return parseRichText(clone, { color: STYLE.analysis, size: 21 }); } function answerParagraphs(answerText) { const answerLines = answerLinesFromText(answerText); if (answerLines.length <= 1) { return [new Paragraph({ children: [ createTextRun('正确答案:', { bold: true, color: STYLE.answer, size: 22, }), createTextRun(answerLines[0] || answerText, { bold: true, color: STYLE.answer, size: 22, }), ], spacing: { before: 90, after: 70 }, indent: { left: 420 }, })]; } return [ new Paragraph({ children: [ createTextRun('正确答案:', { bold: true, color: STYLE.answer, size: 22, }), ], spacing: { before: 90, after: 35 }, indent: { left: 420 }, }), ...answerLines.map((line, index) => new Paragraph({ children: [ createTextRun(line, { bold: true, color: STYLE.answer, size: 22, }), ], spacing: { before: 0, after: index === answerLines.length - 1 ? 70 : 20, }, indent: { left: 720, hanging: 160 }, })), ]; } function analysisParagraph(analysisRuns) { return new Paragraph({ children: [ createTextRun('答案解析:', { bold: true, color: STYLE.analysis, size: 21, }), ...analysisRuns, ], spacing: { before: 80, after: 160 }, indent: { left: 420 }, shading: { type: ShadingType.CLEAR, fill: STYLE.analysisBg, color: 'auto', }, border: { left: { style: BorderStyle.SINGLE, size: 12, color: 'D7DEE8', }, }, }); } function optionParagraph(optionRuns) { return new Paragraph({ children: optionRuns.length ? optionRuns : [createTextRun('')], indent: { left: 620, hanging: 220 }, spacing: { before: 30, after: 30 }, }); } function questionParagraph(index, titleRuns) { return new Paragraph({ children: [ createTextRun(`${index}. `, { bold: true, color: STYLE.title, size: 24, }), ...titleRuns, ], spacing: { before: 220, after: 100 }, border: { top: { style: BorderStyle.SINGLE, size: 6, color: STYLE.border, space: 12, }, }, }); } function summaryParagraph(questionCount, typeCount) { const summary = Object.entries(typeCount) .map(([type, count]) => `${type} ${count} 道`) .join(' / '); return new Paragraph({ children: [ createTextRun(`共 ${questionCount} 道题`, { color: STYLE.muted, size: 20, }), ...(summary ? [createTextRun(` · ${summary}`, { color: STYLE.muted, size: 20, })] : []), ], alignment: AlignmentType.CENTER, spacing: { before: 60, after: 260 }, }); } function createDocument(title, children) { return new Document({ styles: { paragraphStyles: [ { id: 'Normal', name: 'Normal', run: { font: STYLE.font, size: 22, color: STYLE.title, }, paragraph: { spacing: { line: 330, lineRule: 'auto' }, }, }, ], }, sections: [{ properties: { page: { size: { width: 11906, height: 16838, }, margin: { top: 1134, right: 1134, bottom: 1134, left: 1134, }, }, }, children: [ new Paragraph({ children: [ createTextRun(title, { color: '2563EB', size: 32, bold: true, }), ], heading: HeadingLevel.HEADING_1, alignment: AlignmentType.CENTER, spacing: { before: 200, after: 120 }, }), ...children, ], }], }); } async function buildDocumentChildren(questions) { const docChildren = []; const typeCount = {}; let exportedIndex = 0; for (const qBox of questions) { const titleNode = qBox.querySelector('h3.mark_name, .mark_name'); if (!titleNode) continue; exportedIndex += 1; const questionType = getQuestionType(titleNode); if (questionType) typeCount[questionType] = (typeCount[questionType] || 0) + 1; const titleRuns = await getQuestionTitleRuns(titleNode); docChildren.push(questionParagraph(exportedIndex, titleRuns)); if (questionType !== '填空题') { const optionUl = qBox.querySelector('ul.mark_letter, .mark_letter'); if (optionUl) { const options = optionUl.querySelectorAll('li'); for (const opt of options) { docChildren.push(optionParagraph(await parseRichText(opt))); } } } const answerText = getAnswerText(qBox, questionType); if (answerText) { docChildren.push(...answerParagraphs(answerText)); } const analysisRuns = await getAnalysisRuns(qBox); if (analysisRuns.length) { docChildren.push(analysisParagraph(analysisRuns)); } else { docChildren.push(EMPTY_LINE); } } return { children: [summaryParagraph(exportedIndex, typeCount), ...docChildren], count: exportedIndex, }; } async function startExport() { setButtonState('处理中...', true); try { const docTitle = getAssignmentTitle(); const questions = getQuestionNodes(); if (!questions.length) { alert('没有找到题目,请确认当前页面已经加载完成。'); setButtonState('导出 Word', false); return; } const { children, count } = await buildDocumentChildren(questions); const doc = createDocument(docTitle, children); const blob = await Packer.toBlob(doc); saveAs(blob, `${safeFileName(docTitle)}.docx`); console.log(`已导出 ${count} 道题目`); setButtonState('导出成功', true); setTimeout(() => setButtonState('导出 Word', false), 2400); } catch (error) { console.error(error); alert('导出出错,详细错误请看 F12 控制台。'); setButtonState('导出失败', false); } } window.addEventListener('load', addExportButton); })();