// ==UserScript== // @name 超星学习通作业/考试一键导出为Word(docx) // @namespace https://scriptcat.org/ // @version 3.1.1 // @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(); const EXPORT_WIDGET_ID = 'chaoxingWordExportWidget'; const EXPORT_BUTTON_ID = 'chaoxingWordExportBtn'; const EXPORT_STYLE_ID = 'chaoxingWordExportStyles'; const EXPORT_STATE_KEY = 'chaoxing-word-export-widget-state-v1'; const VIEWPORT_MARGIN = 12; let isExporting = false; function loadExportWidgetState() { const fallback = { collapsed: true, includeAnswers: true, left: null, top: null, }; try { const saved = JSON.parse(localStorage.getItem(EXPORT_STATE_KEY)); if (!saved || typeof saved !== 'object') return fallback; return { collapsed: typeof saved.collapsed === 'boolean' ? saved.collapsed : fallback.collapsed, includeAnswers: typeof saved.includeAnswers === 'boolean' ? saved.includeAnswers : fallback.includeAnswers, left: Number.isFinite(saved.left) ? saved.left : null, top: Number.isFinite(saved.top) ? saved.top : null, }; } catch (error) { return fallback; } } function saveExportWidgetState(patch) { try { localStorage.setItem(EXPORT_STATE_KEY, JSON.stringify({ ...loadExportWidgetState(), ...patch, })); } catch (error) { // 页面禁用存储时,悬浮工具仍可正常使用。 } } function addExportWidgetStyles() { if (document.getElementById(EXPORT_STYLE_ID)) return; const style = document.createElement('style'); style.id = EXPORT_STYLE_ID; style.textContent = ` #${EXPORT_WIDGET_ID} { position: fixed; z-index: 2147483000; width: 254px; max-width: calc(100vw - 24px); overflow: hidden; box-sizing: border-box; color: #1f2937; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 8px; box-shadow: 0 12px 30px rgba(15, 23, 42, .16); font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 14px; line-height: 1.4; letter-spacing: 0; } #${EXPORT_WIDGET_ID}, #${EXPORT_WIDGET_ID} * { box-sizing: border-box; letter-spacing: 0; } #${EXPORT_WIDGET_ID}[data-collapsed="true"] { width: 48px; height: 48px; border-color: #1d4ed8; background: #2563eb; box-shadow: 0 8px 20px rgba(37, 99, 235, .26); } #${EXPORT_WIDGET_ID}.is-dragging { box-shadow: 0 14px 34px rgba(15, 23, 42, .24); } #${EXPORT_WIDGET_ID} .cx-word-export-handle { display: flex; align-items: center; width: 100%; height: 48px; margin: 0; padding: 0 10px; color: #1f2937; background: #ffffff; border: 0; border-radius: 0; outline: 0; cursor: move; touch-action: none; user-select: none; font: inherit; text-align: left; } #${EXPORT_WIDGET_ID}[data-collapsed="true"] .cx-word-export-handle { justify-content: center; padding: 0; color: #ffffff; background: #2563eb; cursor: grab; } #${EXPORT_WIDGET_ID} .cx-word-export-handle:focus-visible { outline: 3px solid rgba(37, 99, 235, .32); outline-offset: -3px; } #${EXPORT_WIDGET_ID} .cx-word-export-mark { display: inline-flex; flex: 0 0 30px; align-items: center; justify-content: center; width: 30px; height: 30px; color: #ffffff; background: #2563eb; border-radius: 6px; font-size: 15px; font-weight: 800; } #${EXPORT_WIDGET_ID}[data-collapsed="true"] .cx-word-export-mark { background: transparent; font-size: 18px; } #${EXPORT_WIDGET_ID} .cx-word-export-title { flex: 1 1 auto; min-width: 0; margin-left: 9px; overflow: hidden; color: #1f2937; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } #${EXPORT_WIDGET_ID} .cx-word-export-collapse { flex: 0 0 28px; color: #64748b; font-size: 20px; font-weight: 400; text-align: center; } #${EXPORT_WIDGET_ID}[data-collapsed="true"] .cx-word-export-title, #${EXPORT_WIDGET_ID}[data-collapsed="true"] .cx-word-export-collapse, #${EXPORT_WIDGET_ID}[data-collapsed="true"] .cx-word-export-panel { display: none; } #${EXPORT_WIDGET_ID} .cx-word-export-panel { padding: 12px; border-top: 1px solid #e5e7eb; background: #ffffff; user-select: none; } #${EXPORT_WIDGET_ID} .cx-word-export-fieldset { min-width: 0; margin: 0 0 12px; padding: 0; border: 0; } #${EXPORT_WIDGET_ID} .cx-word-export-legend { margin: 0 0 7px; padding: 0; color: #475569; font-size: 12px; font-weight: 700; } #${EXPORT_WIDGET_ID} .cx-word-export-modes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; padding: 2px; background: #eef2f7; border: 1px solid #dbe3ef; border-radius: 7px; } #${EXPORT_WIDGET_ID} .cx-word-export-mode { position: relative; min-width: 0; margin: 0; cursor: pointer; } #${EXPORT_WIDGET_ID} .cx-word-export-mode input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } #${EXPORT_WIDGET_ID} .cx-word-export-mode span { display: flex; align-items: center; justify-content: center; width: 100%; min-width: 0; height: 34px; padding: 0 8px; overflow: hidden; color: #64748b; border-radius: 5px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } #${EXPORT_WIDGET_ID} .cx-word-export-mode input:checked + span { color: #1d4ed8; background: #ffffff; box-shadow: 0 1px 3px rgba(15, 23, 42, .12); } #${EXPORT_WIDGET_ID} .cx-word-export-mode input:focus-visible + span { outline: 2px solid #2563eb; outline-offset: -2px; } #${EXPORT_WIDGET_ID} .cx-word-export-mode input:disabled + span { cursor: wait; opacity: .62; } #${EXPORT_WIDGET_ID} #${EXPORT_BUTTON_ID} { display: flex; align-items: center; justify-content: center; width: 100%; min-width: 0; height: 40px; margin: 0; padding: 0 14px; color: #ffffff; background: #2563eb; border: 0; border-radius: 7px; outline: 0; cursor: pointer; font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; } #${EXPORT_WIDGET_ID} #${EXPORT_BUTTON_ID}:hover:not(:disabled) { background: #1d4ed8; } #${EXPORT_WIDGET_ID} #${EXPORT_BUTTON_ID}:focus-visible { outline: 3px solid rgba(37, 99, 235, .3); outline-offset: 2px; } #${EXPORT_WIDGET_ID} #${EXPORT_BUTTON_ID}:disabled { cursor: wait; opacity: .68; } `; document.head.appendChild(style); } function clampExportWidgetPosition(widget, left, top) { const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - widget.offsetWidth - VIEWPORT_MARGIN); const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - widget.offsetHeight - VIEWPORT_MARGIN); const nextLeft = Math.min(Math.max(left, VIEWPORT_MARGIN), maxLeft); const nextTop = Math.min(Math.max(top, VIEWPORT_MARGIN), maxTop); widget.style.left = `${Math.round(nextLeft)}px`; widget.style.top = `${Math.round(nextTop)}px`; widget.style.right = 'auto'; widget.style.bottom = 'auto'; } function saveCurrentExportWidgetPosition(widget) { const rect = widget.getBoundingClientRect(); saveExportWidgetState({ left: Math.round(rect.left), top: Math.round(rect.top) }); } function setExportWidgetCollapsed(widget, collapsed, persist = true) { const currentRect = widget.getBoundingClientRect(); const rightInset = Math.max(VIEWPORT_MARGIN, window.innerWidth - currentRect.right); const handle = widget.querySelector('.cx-word-export-handle'); widget.dataset.collapsed = String(collapsed); handle.setAttribute('aria-expanded', String(!collapsed)); handle.setAttribute('aria-label', collapsed ? '展开 Word 导出工具' : '收起 Word 导出工具'); handle.title = collapsed ? '点击展开;按住可拖动' : '点击收起;按住可拖动'; requestAnimationFrame(() => { clampExportWidgetPosition( widget, window.innerWidth - rightInset - widget.offsetWidth, currentRect.top, ); if (persist) { const rect = widget.getBoundingClientRect(); saveExportWidgetState({ collapsed, left: Math.round(rect.left), top: Math.round(rect.top), }); } }); } function bindExportWidgetDrag(widget, handle) { let dragState = null; let suppressClick = false; handle.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; const rect = widget.getBoundingClientRect(); dragState = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top, moved: false, }; try { handle.setPointerCapture(event.pointerId); } catch (error) { // 部分页面或浏览器不会保留指针捕获,document 监听仍会接管拖动。 } event.stopPropagation(); }); document.addEventListener('pointermove', (event) => { if (!dragState || event.pointerId !== dragState.pointerId) return; const deltaX = event.clientX - dragState.startX; const deltaY = event.clientY - dragState.startY; if (!dragState.moved && Math.hypot(deltaX, deltaY) < 5) return; dragState.moved = true; widget.classList.add('is-dragging'); clampExportWidgetPosition( widget, dragState.startLeft + deltaX, dragState.startTop + deltaY, ); event.preventDefault(); event.stopPropagation(); }, true); const finishDrag = (event) => { if (!dragState || event.pointerId !== dragState.pointerId) return; const moved = dragState.moved; dragState = null; widget.classList.remove('is-dragging'); try { if (handle.hasPointerCapture(event.pointerId)) { handle.releasePointerCapture(event.pointerId); } } catch (error) { // 指针捕获已由页面或浏览器释放时无需额外处理。 } if (moved) { saveCurrentExportWidgetPosition(widget); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); } event.stopPropagation(); }; document.addEventListener('pointerup', finishDrag, true); document.addEventListener('pointercancel', finishDrag, true); handle.addEventListener('click', (event) => { event.stopPropagation(); if (suppressClick) { event.preventDefault(); return; } setExportWidgetCollapsed(widget, widget.dataset.collapsed !== 'true'); }); } function addExportWidget() { if (document.getElementById(EXPORT_WIDGET_ID)) return; addExportWidgetStyles(); const state = loadExportWidgetState(); const widget = document.createElement('aside'); widget.id = EXPORT_WIDGET_ID; widget.dataset.collapsed = String(state.collapsed); widget.setAttribute('aria-label', 'Word 导出工具'); widget.innerHTML = `
导出内容
`; const handle = widget.querySelector('.cx-word-export-handle'); const modeInputs = widget.querySelectorAll('input[name="chaoxingWordExportMode"]'); const selectedMode = state.includeAnswers ? 'with-answers' : 'without-answers'; widget.querySelector(`input[value="${selectedMode}"]`).checked = true; modeInputs.forEach((input) => { input.addEventListener('change', () => { if (input.checked) { saveExportWidgetState({ includeAnswers: input.value === 'with-answers' }); } }); }); widget.querySelector(`#${EXPORT_BUTTON_ID}`).addEventListener('click', () => startExport()); widget.addEventListener('click', (event) => event.stopPropagation()); bindExportWidgetDrag(widget, handle); document.body.appendChild(widget); const initialLeft = state.left === null ? window.innerWidth - widget.offsetWidth - 20 : state.left; const initialTop = state.top === null ? Math.round((window.innerHeight - widget.offsetHeight) / 2) : state.top; clampExportWidgetPosition(widget, initialLeft, initialTop); setExportWidgetCollapsed(widget, state.collapsed, false); let resizeTimer = null; window.addEventListener('resize', () => { const rect = widget.getBoundingClientRect(); clampExportWidgetPosition(widget, rect.left, rect.top); clearTimeout(resizeTimer); resizeTimer = setTimeout(() => saveCurrentExportWidgetPosition(widget), 120); }); } function setButtonState(text, disabled) { const widget = document.getElementById(EXPORT_WIDGET_ID); const btn = document.getElementById(EXPORT_BUTTON_ID); if (!btn) return; btn.textContent = text; btn.disabled = disabled; btn.setAttribute('aria-busy', String(disabled && text === '处理中...')); widget.querySelectorAll('input[name="chaoxingWordExportMode"]').forEach((input) => { input.disabled = disabled; }); } function shouldIncludeAnswers() { const selected = document.querySelector( `#${EXPORT_WIDGET_ID} input[name="chaoxingWordExportMode"]:checked`, ); return !selected || selected.value === 'with-answers'; } 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]; } const choiceLetters = []; for (const line of lines) { const letters = extractChoiceLetters(line); if (!letters) continue; for (const letter of letters.match(/[A-H]/g) || []) { if (!choiceLetters.includes(letter)) choiceLetters.push(letter); } } if (choiceLetters.length) { return /单选/.test(questionType || '') ? choiceLetters[0] : formatChoiceLetters(choiceLetters.join('')); } 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 getCombinedCandidateAnswerText(container, selector, allowUnlabeled, questionType, qBox) { const matchedNodes = Array.from(container.querySelectorAll(selector)); const topLevelNodes = matchedNodes.filter((node) => !matchedNodes.some((otherNode) => ( otherNode !== node && otherNode.contains(node) ))); const answerParts = []; for (const node of topLevelNodes) { const text = getCandidateAnswerText(node, allowUnlabeled, questionType, qBox); if (text) answerParts.push(text); } if (!answerParts.length) return ''; return normalizeAnswerByQuestionType(answerParts.join('\n'), 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 text = getCombinedCandidateAnswerText(ansDiv, selector, true, questionType, qBox); if (text) return text; } const labelRequiredSelectors = [ '.mark_fill', '.answerCon', ]; for (const selector of labelRequiredSelectors) { const text = getCombinedCandidateAnswerText(ansDiv, selector, 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, ], }], }); } function getOptionNodeForExport(optionNode, includeAnswers) { if (includeAnswers) return optionNode; const clone = optionNode.cloneNode(true); const possibleReviewMarks = clone.querySelectorAll( 'img, i, em, span, strong, [data-correct], [data-answer]', ); possibleReviewMarks.forEach((node) => { const className = typeof node.className === 'string' ? node.className : ''; const hint = [ className, node.getAttribute('title'), node.getAttribute('aria-label'), node.getAttribute('alt'), node.getAttribute('data-correct'), node.getAttribute('data-answer'), ].filter(Boolean).join(' '); const text = (node.innerText || node.textContent || '').replace(/\s+/g, '').trim(); const hasReviewHint = /(?:^|[\s_-])(?:correct|wrong|right|error|dui|cuo)(?:answer|icon|mark)?(?:$|[\s_-])|正确|错误/i.test(hint) || node.hasAttribute('data-correct'); if (hasReviewHint && text.length <= 8) { node.remove(); } }); return clone; } async function buildDocumentChildren(questions, includeAnswers = true) { 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) { const optionNode = getOptionNodeForExport(opt, includeAnswers); docChildren.push(optionParagraph(await parseRichText(optionNode))); } } } if (includeAnswers) { 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); } } else { docChildren.push(EMPTY_LINE); } } return { children: [summaryParagraph(exportedIndex, typeCount), ...docChildren], count: exportedIndex, }; } async function startExport() { if (isExporting) return; isExporting = true; const includeAnswers = shouldIncludeAnswers(); setButtonState('处理中...', true); try { const docTitle = getAssignmentTitle(); const questions = getQuestionNodes(); if (!questions.length) { alert('没有找到题目,请确认当前页面已经加载完成。'); isExporting = false; setButtonState('导出 Word', false); return; } const versionSuffix = includeAnswers ? '' : '(无答案版)'; const { children, count } = await buildDocumentChildren(questions, includeAnswers); const doc = createDocument(`${docTitle}${versionSuffix}`, children); const blob = await Packer.toBlob(doc); saveAs(blob, `${safeFileName(docTitle)}${versionSuffix}.docx`); console.log(`已导出 ${count} 道题目(${includeAnswers ? '含答案' : '无答案'})`); setButtonState('导出成功', true); setTimeout(() => { isExporting = false; setButtonState('导出 Word', false); }, 2400); } catch (error) { console.error(error); alert('导出出错,详细错误请看 F12 控制台。'); setButtonState('导出失败', true); setTimeout(() => { isExporting = false; setButtonState('导出 Word', false); }, 2400); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', addExportWidget, { once: true }); } else { addExportWidget(); } })();