// ==UserScript==
// @name 学习通一键导出
// @namespace local.xxt.userscript
// @version 1.0.0
// @description 学习通作业/考试页一键导出 Word(.docx) / Markdown / TXT(答案默认不导出)
// @author 祁桉
// @match *://*.chaoxing.com/*
// @match *://*.cldisk.com/*
// @grant none
// @run-at document-idle
// ==/UserScript==
//
// 由原 Edge 扩展(content.js + native_host.py)转换而来。
// 抓取逻辑沿用原插件已验证的 content.js;Word 改为浏览器内直接打包 .docx,
// 不再需要 nativeMessaging / 内置 Python / python-docx / RapidOCR。
// 注意:图片题 OCR 在纯前端版本中未实现(保留原图);如需 OCR 可后续接入。
(() => {
if (window.__xxtExporterInjected) return;
window.__xxtExporterInjected = true;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TYPES = '单选题|多选题|判断题|填空题|简答题|名词解释|论述题|案例分析';
async function scrollLoad() {
let lastH = 0;
for (let i = 0; i < 12; i++) {
window.scrollTo(0, document.documentElement.scrollHeight || document.body.scrollHeight);
await sleep(400);
const h = document.documentElement.scrollHeight || document.body.scrollHeight;
if (h === lastH) break;
lastH = h;
}
window.scrollTo(0, 0);
await sleep(300);
}
function parseQuestionsFromText(fullText) {
const normalized = (fullText || '').replace(/\u00a0/g, ' ').replace(/\r\n?/g, '\n');
const headerRe = new RegExp('\\n(?=\\s*\\d{1,3}\\s*[.、.::](?!\\d)\\s*(?:\\(?(?:' + TYPES + ')\\)?)?)', 'g');
const parts = normalized.split(headerRe).map((s) => s.trim()).filter(Boolean);
const questions = [];
for (const part of parts) {
const q = parseQuestionPart(part);
if (!q) continue;
const hasType = new RegExp('^(?:' + TYPES + ')$').test(q.type);
if (q.options.length || hasType || q.answer || q.myAnswer) {
questions.push(q);
}
}
return questions;
}
function extractTextAnswer(part, labels) {
const m = part.match(new RegExp(labels + '[::]'));
if (!m) return '';
const segment = part.slice(m.index + m[0].length);
const lines = segment.split(/\n+/).map((s) => s.trim());
for (const line of lines) {
if (!line) continue;
if (/^\d+分$/.test(line)) continue;
if (/^(我的答案|正确答案|参考答案|标准答案|AI讲解|知识点|排除法)/.test(line)) break;
return line;
}
return '';
}
function parseQuestionPart(part) {
const lines = part.split(/\n+/).map((s) => s.trim());
if (!lines.length) return null;
const header = lines[0] || '';
const hm = header.match(/^\s*(\d{1,3})\s*[.、.::](?!\d)\s*(?:\(([^)]*)\)\s*)?(.*)$/);
if (!hm) return null;
const number = parseInt(hm[1], 10);
let type = (hm[2] || '').trim();
let headerRest = (hm[3] || '').trim();
if (type) {
const tm = type.match(new RegExp('(' + TYPES + ')'));
type = tm ? tm[1] : type;
}
if (!type) {
const tm = headerRest.match(new RegExp('^(' + TYPES + ')'));
if (tm) {
type = tm[1];
headerRest = headerRest.slice(tm[0].length).trim();
}
}
if (!type) type = '题目';
const options = [];
const stemLines = headerRest ? [headerRest] : [];
let myAnswer = '';
let myText = '';
let correctAnswer = '';
let correctText = '';
let state = 'stem';
let currentOption = null;
const stopRe = /^\d+分$|^AI讲解$|^知识点[::]?|^排除法$|^[一二三四五六七八九十]+[.、]/;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (stopRe.test(line) || /^(答题卡|上一题|下一题|收藏|标记|纠错|展开|收起|题目列表|试卷列表)$/.test(line)) {
break;
}
const myM = line.match(/^我的答案[::]\s*([A-H])?/i);
if (myM) {
if (myM[1]) myAnswer = myM[1].toUpperCase();
state = 'my';
let j = i + 1;
while (j < lines.length && lines[j].startsWith(':')) {
if (!myText) myText = lines[j].slice(1).replace(/;+$/g, '').trim();
j++;
}
i = j - 1;
continue;
}
const correctM = line.match(/^(正确答案|参考答案|标准答案)[::]\s*([A-H])?/i);
if (correctM) {
if (correctM[2]) correctAnswer = correctM[2].toUpperCase();
state = 'correct';
let j = i + 1;
while (j < lines.length && lines[j].startsWith(':')) {
if (!correctText) correctText = lines[j].slice(1).replace(/;+$/g, '').trim();
j++;
}
i = j - 1;
continue;
}
if (/^(答案解析|解析)[::]/.test(line)) {
continue;
}
const optionM = line.match(/^([A-H])[.、.::)]\s*(.*)$/);
if (optionM) {
const letter = optionM[1].toUpperCase();
const optText = (optionM[2] || '').trim();
currentOption = optText ? `${letter}. ${optText}` : `${letter}. `;
options.push(currentOption);
state = 'options';
continue;
}
if (state === 'options' && currentOption) {
const appended = (options[options.length - 1].endsWith(' ') ? '' : ' ') + line;
options[options.length - 1] = options[options.length - 1] + appended;
continue;
}
if (line) {
stemLines.push(line);
}
}
let stem = stemLines.join(' ').trim();
if (!myAnswer) {
myAnswer = extractTextAnswer(part, '我的答案');
}
if (!correctAnswer) {
correctAnswer = extractTextAnswer(part, '正确答案|参考答案|标准答案');
}
return {
number,
type,
stem,
options,
answer: correctAnswer || '',
myAnswer: myAnswer || '',
analysis: ''
};
}
function buildStemItems(h) {
const qt = h.querySelector('.qtContent') || h.querySelector('.qtContent.workTextWrap');
if (!qt) return null;
const items = [];
let buf = '';
const flush = () => {
const t = buf.replace(/\s+/g, ' ').trim();
if (t) items.push({ type: 'text', text: t });
buf = '';
};
const walk = (el) => {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
buf += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (child.tagName === 'IMG') {
flush();
const src = child.getAttribute('data-original') || child.currentSrc || child.src || '';
if (src) items.push({ type: 'image', url: src, data_url: '' });
} else if (child.tagName === 'BR') {
buf += '\n';
} else {
walk(child);
}
}
}
};
walk(qt);
flush();
return items.length ? items : null;
}
function collectImagesByNumber() {
const map = {};
const heads = document.querySelectorAll('h3.mark_name');
for (const h of heads) {
const text = (h.innerText || '').trim();
const m = text.match(/^\s*(\d{1,3})\s*[.、.::]/);
if (!m) continue;
const num = parseInt(m[1], 10);
const questionImages = [];
h.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('data-original') || img.currentSrc || img.src || '';
if (src) questionImages.push({ url: src, data_url: '' });
});
const answerImages = [];
const optionImagesByLetter = {};
const seenAnswer = new Set();
const seenOption = new Set();
const answerSel = 'dd.rightAnswerContent.workTextWrap, dl.mark_fill.colorGreen';
const myAnswerSel = 'dd.textwrap.stuAnswerContent.reserve-newline, dl.mark_fill.colorDeep';
let node = h.nextElementSibling;
while (node && !(node.tagName === 'H3' && node.classList.contains('mark_name'))) {
const answerContainers = node.querySelectorAll(answerSel);
answerContainers.forEach((container) => {
container.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('data-original') || img.currentSrc || img.src || '';
if (src && !seenAnswer.has(src)) {
seenAnswer.add(src);
answerImages.push({ url: src, data_url: '' });
}
});
});
const optionLis = node.querySelectorAll('li.workTextWrap');
optionLis.forEach((li) => {
const t = (li.innerText || '').trim();
const lm = t.match(/^\s*([A-H])[.、.::)]/);
if (!lm) return;
const letter = lm[1].toUpperCase();
li.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('data-original') || img.currentSrc || img.src || '';
if (!src || seenOption.has(src)) return;
seenOption.add(src);
if (!optionImagesByLetter[letter]) optionImagesByLetter[letter] = [];
optionImagesByLetter[letter].push({ url: src, data_url: '' });
});
});
node = node.nextElementSibling;
}
const stemItems = buildStemItems(h);
if (questionImages.length || answerImages.length || Object.keys(optionImagesByLetter).length || stemItems) {
map[num] = { questionImages, answerImages, optionImagesByLetter, stemItems };
}
}
return map;
}
async function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(blob);
});
}
async function enrichImageData(img) {
if (img.data_url) return;
let src = img.url || '';
if (!src || src.startsWith('data:')) return;
if (src.startsWith('//')) src = location.protocol + src;
try {
const resp = await fetch(src, { credentials: 'include' });
if (resp.ok) {
const blob = await resp.blob();
img.data_url = await blobToDataURL(blob);
}
} catch (e) {
img.data_url = '';
}
}
async function attachImages(questions) {
const imageMap = collectImagesByNumber();
if (!Object.keys(imageMap).length) return questions;
for (const q of questions) {
const entry = imageMap[q.number];
if (!entry) continue;
const qImgs = entry.questionImages || [];
const aImgs = entry.answerImages || [];
const oMap = entry.optionImagesByLetter || {};
const oImgs = Object.values(oMap).flat();
for (const img of qImgs.concat(aImgs, oImgs)) {
await enrichImageData(img);
}
q.images = qImgs;
q.answer_images = aImgs;
q.option_images_by_letter = oMap;
if (entry.stemItems) q.stem_items = entry.stemItems;
}
return questions.filter((q) => q.stem || q.options.length || q.answer || (q.images && q.images.length) || (q.answer_images && q.answer_images.length) || (q.option_images_by_letter && Object.keys(q.option_images_by_letter).length));
}
function guessCourse() {
const selectors = ['.breadcrumb', '.crumbs', '.catalog_title', '.chapter', 'h1', '.title'];
for (const s of selectors) {
const el = document.querySelector(s);
const t = el && el.innerText ? el.innerText.trim() : '';
if (t && t.length < 80) return t;
}
const m = (document.title || '').match(/^(.+?)[-_—]\s*学习通/);
if (m) return m[1].trim();
return '';
}
function guessAssignmentName(fullText) {
const m = (fullText || '').match(/(?:考试详情|作业详情)\s*\n\s*(.+?)\s*\n\s*题量/);
if (m && m[1]) return m[1].trim();
return guessCourse();
}
function guessAssignmentKind(fullText) {
const t = fullText || '';
if (/考试详情|考试时间/.test(t)) return '考试';
if (/作业详情|作答时间/.test(t)) return '作业';
return '作业';
}
async function extract() {
await scrollLoad();
const fullText = document.body.innerText || '';
let questions = parseQuestionsFromText(fullText);
questions = await attachImages(questions);
if (!questions.length) {
return { ok: false, error: '没有解析到题目,请确认已打开学习通作业/考试页面并完全加载。' };
}
const assignmentName = guessAssignmentName(fullText);
const assignmentKind = guessAssignmentKind(fullText);
return {
ok: true,
title: document.title || '',
course: guessCourse(),
assignment_name: assignmentName,
assignment_kind: assignmentKind,
page_url: location.href,
questions,
};
}
// ================================================================
// userscript 版:界面 + 浏览器内导出
// 导出 Word 使用「自建最小 OOXML(.docx) 打包器」,图片按标准内嵌,
// 不依赖 nativeMessaging / 内置 Python / python-docx / RapidOCR。
// ================================================================
const CFG = { includeOptions: true, includeAnswer: false, includeImages: true };
let FAB = null, PANEL = null, TIP = null;
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
const imgSrc = (im) => (im && (im.data_url || im.url)) || '';
const nap = (ms) => new Promise((r) => setTimeout(r, ms));
const letterOf = (s) => { const t = (s || '').trim(); return t ? t[0].toUpperCase() : ''; };
function stamp() {
const d = new Date(); const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`;
}
function safeName(s) {
return (s || '').replace(/[\\/:*?"<>|\r\n\t]/g, '_').replace(/^[\s._]+|[\s._]+$/g, '').slice(0, 80);
}
function meta(res) {
const kind = res.assignment_kind || '作业';
const name = res.assignment_name || res.course || ('学习通' + kind);
return { kind, name, title: name + '(' + kind + ')' };
}
// ---------------- 通用下载 ----------------
function downloadBlob(filename, blob) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
function downloadText(filename, content, mime) {
downloadBlob(filename, new Blob(['\ufeff' + content], { type: mime + ';charset=utf-8' }));
}
// ---------------- Markdown / 文本 ----------------
function buildMarkdown(res) {
const qs = res.questions || []; const m = meta(res);
const out = ['# ' + m.title, '', '> 共 ' + qs.length + ' 题 · ' + new Date().toLocaleString(), ''];
qs.forEach((q, i) => {
out.push('## ' + (i + 1) + '. ' + (q.type || '题目'));
out.push(q.stem || '(图片题)');
if (CFG.includeImages) (q.images || []).forEach((im) => { const s = imgSrc(im); if (s) out.push(''); });
if (CFG.includeOptions) (q.options || []).forEach((o) => {
out.push('- ' + o);
if (CFG.includeImages) (((q.option_images_by_letter || {})[letterOf(o)]) || []).forEach((im) => { const s = imgSrc(im); if (s) out.push(' '); });
});
if (CFG.includeAnswer) out.push('> 答案:' + ((q.answer || '').trim() || '未显示答案'));
out.push('');
});
return out.join('\n');
}
function buildText(res) {
const qs = res.questions || [];
const out = [meta(res).title, '共 ' + qs.length + ' 题', ''];
qs.forEach((q, i) => {
out.push((i + 1) + '.【' + (q.type || '题目') + '】' + (q.stem || ''));
if (CFG.includeOptions) (q.options || []).forEach((o) => out.push(' ' + o));
if (CFG.includeAnswer) out.push(' 答案:' + ((q.answer || '').trim() || '未显示答案'));
out.push('');
});
return out.join('\n');
}
// ---------------- 最小 ZIP(store,无压缩) ----------------
let _crcTable = null;
function crc32(u8) {
if (!_crcTable) {
_crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); _crcTable[n] = c >>> 0; }
}
let crc = 0xFFFFFFFF;
for (let i = 0; i < u8.length; i++) crc = (crc >>> 8) ^ _crcTable[(crc ^ u8[i]) & 0xFF];
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function zipStore(entries) {
const enc = new TextEncoder();
const u16 = (n) => [n & 0xFF, (n >>> 8) & 0xFF];
const u32 = (n) => [n & 0xFF, (n >>> 8) & 0xFF, (n >>> 16) & 0xFF, (n >>> 24) & 0xFF];
const d = new Date();
const dt = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
const dd = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
const parts = [], central = [];
let offset = 0;
for (const e of entries) {
const nb = enc.encode(e.name), data = e.data, crc = crc32(data);
const lh = new Uint8Array([].concat(
u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(dt), u16(dd),
u32(crc), u32(data.length), u32(data.length), u16(nb.length), u16(0)));
parts.push(lh, nb, data);
central.push({ nb, crc, size: data.length, offset });
offset += lh.length + nb.length + data.length;
}
const cdParts = [];
let cdSize = 0;
for (const c of central) {
const cd = new Uint8Array([].concat(
u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(dt), u16(dd),
u32(c.crc), u32(c.size), u32(c.size), u16(c.nb.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(c.offset)));
cdParts.push(cd, c.nb);
cdSize += cd.length + c.nb.length;
}
const eocd = new Uint8Array([].concat(
u32(0x06054b50), u16(0), u16(0), u16(central.length), u16(central.length), u32(cdSize), u32(offset), u16(0)));
let total = eocd.length;
for (const p of parts) total += p.length;
for (const p of cdParts) total += p.length;
const out = new Uint8Array(total);
let pos = 0;
for (const p of parts.concat(cdParts, [eocd])) { out.set(p, pos); pos += p.length; }
return out;
}
// ---------------- 图片工具 ----------------
function b64ToU8(b64) {
const bin = atob(b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
function parseDataUrl(url) {
const m = /^data:image\/(png|jpe?g|gif|webp|bmp);base64,(.*)$/i.exec(url || '');
if (!m) return null;
const ext = m[1].toLowerCase().replace('jpeg', 'jpg');
try { return { ext: ext, u8: b64ToU8(m[2]) }; } catch (e) { return null; }
}
function imageSize(u8, ext) {
try {
if (ext === 'png') {
if (u8.length < 24) return null;
const w = (u8[16] << 24) | (u8[17] << 16) | (u8[18] << 8) | u8[19];
const h = (u8[20] << 24) | (u8[21] << 16) | (u8[22] << 8) | u8[23];
return (w > 0 && h > 0) ? { w: w, h: h } : null;
}
if (ext === 'gif') {
if (u8.length < 10) return null;
const w = u8[6] | (u8[7] << 8), h = u8[8] | (u8[9] << 8);
return (w > 0 && h > 0) ? { w: w, h: h } : null;
}
if (ext === 'jpg') {
let i = 2;
while (i + 9 < u8.length) {
if (u8[i] !== 0xFF) { i++; continue; }
const mk = u8[i + 1];
if (mk === 0xD8 || mk === 0x01 || (mk >= 0xD0 && mk <= 0xD7)) { i += 2; continue; }
const len = (u8[i + 2] << 8) | u8[i + 3];
if (mk >= 0xC0 && mk <= 0xCF && mk !== 0xC4 && mk !== 0xC8 && mk !== 0xCC) {
const h = (u8[i + 5] << 8) | u8[i + 6], w = (u8[i + 7] << 8) | u8[i + 8];
return (w > 0 && h > 0) ? { w: w, h: h } : null;
}
i += 2 + len;
}
}
if (ext === 'webp') {
if (u8.length < 30) return null;
if (u8[12] === 0x56 && u8[13] === 0x50 && u8[14] === 0x38 && u8[15] === 0x58) {
const w = 1 + (u8[24] | (u8[25] << 8) | (u8[26] << 16));
const h = 1 + (u8[27] | (u8[28] << 8) | (u8[29] << 16));
return { w: w, h: h };
}
}
} catch (e) {}
return null;
}
// ---------------- OOXML 片段 ----------------
const PTSZ = { title: 28, stem: 22, body: 20 };
function runXml(text, o) {
o = o || {};
const font = o.font || '宋体';
return '