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