// ==UserScript== // @name 学习通一键导出 // @namespace local.xxt.userscript // @version 1.3.0 // @description 学习通作业/考试页一键导出 Word(.docx):图片位置与原插件一致;OCR 仅用于纯文字图,公式/图形自动留原图 // @author 祁桉 // @match *://*.chaoxing.com/* // @match *://*.cldisk.com/* // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @connect chaoxing.com // @connect cldisk.com // @connect cdn.jsdelivr.net // @connect tessdata.projectnaptha.com // @connect unpkg.com // @connect * // @require https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js // @run-at document-idle // ==/UserScript== // // 由原 Edge 扩展(content.js + native_host.py)转换而来,排版规则逐条对齐原版: // - 题干有内嵌图 -> 图文按原顺序同一段;无内嵌图 -> 题干成段后再单独插图(不再重复插) // - 题干内嵌图高 0.45";独立题图 <=2.0"x0.9";选项/答案图高 0.65"、宽<=1.8" // - 图片默认保留原图;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, }; } // ================================================================ // 【补丁 1】图片获取:绕过 CORS // 原插件真正靠的是 native_host.py 里带 Referer 的 urllib 下载; // 油猴里 fetch 跨域会被拦,导致图片只剩一个网址。这里用 GM_xmlhttpRequest。 // ================================================================ async function enrichImageData(img) { if (!img || img.data_url) return; let src = img.url || ''; if (!src || src.startsWith('data:')) return; if (src.startsWith('//')) src = location.protocol + src; try { src = new URL(src, location.href).href; } catch (e) {} const gm = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest : (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null; if (gm) { const got = await new Promise((resolve) => { try { gm({ method: 'GET', url: src, responseType: 'blob', headers: { Referer: location.href }, withCredentials: true, timeout: 30000, onload: async (res) => { try { if (res && res.status >= 200 && res.status < 300 && res.response) { if (res.response instanceof Blob) return resolve(await blobToDataURL(res.response)); if (res.response instanceof ArrayBuffer) return resolve(await blobToDataURL(new Blob([res.response]))); } resolve(''); } catch (e) { resolve(''); } }, onerror: () => resolve(''), ontimeout: () => resolve('') }); } catch (e) { resolve(''); } }); if (got) { img.data_url = got; return; } } try { const resp = await fetch(src, { credentials: 'include' }); if (resp && resp.ok) img.data_url = await blobToDataURL(await resp.blob()); } catch (e) {} } // ================================================================ // 【补丁 2】图片文字识别 OCR(带质量闸门) // 只有「像正常文字」时才用 OCR 结果;公式 / 图形 / 化学式 / 表格 等 // 会被闸门判定为不合格,自动改为放原图(和原版"直接留原图"的结果一致)。 // ================================================================ const OCR_MIN_CONF = 70; // 平均置信度低于此值 -> 判为图形/公式,用原图 const OCR_MIN_CHARS = 4; // 有效字符太少 -> 用原图 const OCR_MAX_SYMBOL_RATIO = 0.4; // 符号占比过高(公式/化学式/图形)-> 用原图 let _ocrWorker = null, _ocrLoading = null; async function getOcrWorker() { if (_ocrWorker) return _ocrWorker; if (!_ocrLoading) { _ocrLoading = (async () => { if (typeof Tesseract === 'undefined') throw new Error('OCR 组件未加载(可能没联网)'); const w = await Tesseract.createWorker('chi_sim+eng'); _ocrWorker = w; return w; })().catch((e) => { _ocrLoading = null; throw e; }); } return _ocrLoading; } function collectOcrTargets(res) { const items = [], seen = new Set(); const push = (im) => { if (!im) return; const k = im.data_url || ''; if (!k.startsWith('data:') || seen.has(k)) return; seen.add(k); items.push(im); }; (res.questions || []).forEach((q) => { (q.stem_items || []).forEach((it) => { if (it.type === 'image') push(it); }); (q.images || []).forEach(push); const om = q.option_images_by_letter || {}; Object.keys(om).forEach((k) => (om[k] || []).forEach(push)); (q.answer_images || []).forEach(push); }); return items; } function ocrQualityOk(out) { const d = (out && out.data) || {}; const raw = (((d.text == null ? '' : d.text)) + '').trim(); const compact = raw.replace(/\s+/g, ''); const alnum = compact.replace(/[^\u4e00-\u9fa5A-Za-z0-9]/g, ''); const symbolRatio = compact.length ? (1 - (alnum.length / compact.length)) : 1; const conf = (typeof d.confidence === 'number') ? d.confidence : 0; const ok = !!raw && conf >= OCR_MIN_CONF && alnum.length >= OCR_MIN_CHARS && symbolRatio <= OCR_MAX_SYMBOL_RATIO; return { ok: ok, text: raw.replace(/\s+/g, ' '), conf: Math.round(conf), symbolRatio: symbolRatio }; } async function runOcrOnImages(res, onProgress) { const targets = collectOcrTargets(res); if (!targets.length) return { total: 0, ok: 0, skipped: 0, failed: false }; let worker; try { worker = await getOcrWorker(); } catch (e) { return { total: targets.length, ok: 0, skipped: 0, failed: true, error: (e && e.message) || String(e) }; } let ok = 0; for (let i = 0; i < targets.length; i++) { try { const out = await worker.recognize(targets[i].data_url); const q = ocrQualityOk(out); if (q.ok) { targets[i].ocr_text = q.text; targets[i].ocr_conf = q.conf; ok++; } } catch (e) {} if (onProgress) onProgress(i + 1, targets.length); } return { total: targets.length, ok: ok, skipped: targets.length - ok, failed: false }; } // ================================================================ // userscript 版:界面 + 浏览器内导出 // 导出 Word 使用「自建最小 OOXML(.docx) 打包器」,图片按标准内嵌, // 不依赖 nativeMessaging / 内置 Python / python-docx / RapidOCR。 // ================================================================ const CFG = { includeOptions: true, includeAnswer: false, includeImages: true, includeOcr: 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('![](' + s + ')'); }); 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(' ![](' + s + ')'); }); }); 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 '' + (o.bold ? '' : '') + (o.color ? '' : '') + '' + '' + esc(text) + ''; } function pXml(text, o) { o = o || {}; const ppr = '' + '' + (o.indent ? '' : '') + (o.align ? '' : '') + ''; return '' + ppr + runXml(text, o) + ''; } function drawingRun(im) { return '' + '' + '' + '' + '' + '' + '' + '' + ''; } function drawingPara(im) { return '' + drawingRun(im) + ''; } // ---------------- 生成 .docx ---------------- function buildDocx(res) { const qs = res.questions || []; const m = meta(res); const media = [], rels = []; let seq = 0; const IN = 914400; function addImg(imEl, mode) { const parsed = parseDataUrl(imgSrc(imEl)); if (!parsed) return null; seq++; const fname = 'image' + seq + '.' + parsed.ext; const rid = 'rIdImg' + seq; media.push({ name: 'word/media/' + fname, data: parsed.u8 }); rels.push({ id: rid, target: 'media/' + fname }); const nat = imageSize(parsed.u8, parsed.ext) || { w: 640, h: 400 }; const aspect = (nat.w > 0 && nat.h > 0) ? (nat.w / nat.h) : 1.6; let wi, hi; if (mode === 'stem') { hi = 0.45; wi = hi * aspect; if (wi > 6.0) { wi = 6.0; hi = wi / aspect; } } else if (mode === 'option' || mode === 'answer') { hi = 0.65; wi = hi * aspect; if (wi > 1.8) { wi = 1.8; hi = wi / aspect; } } else { wi = 2.0; hi = wi / aspect; if (hi > 0.9) { hi = 0.9; wi = hi * aspect; } } return { rid: rid, cx: Math.max(9525, Math.round(wi * IN)), cy: Math.max(9525, Math.round(hi * IN)), seq: seq }; } const body = []; body.push(pXml(m.title, { bold: true, size: PTSZ.title, font: '微软雅黑', align: 'center', after: 200 })); qs.forEach((q, i) => { if (q.stem_items && q.stem_items.length) { const runs = [runXml((i + 1) + '.【' + (q.type || '题目') + '】', { bold: true, size: PTSZ.stem })]; q.stem_items.forEach((it) => { if (it.type === 'image') { if (it.ocr_text) runs.push(runXml('【图片文字】' + it.ocr_text, { size: PTSZ.stem })); else { const im = addImg(it, 'stem'); if (im) runs.push(drawingRun(im)); } } else runs.push(runXml(it.text || '', { size: PTSZ.stem })); }); body.push('' + runs.join('') + ''); } else { body.push(pXml((i + 1) + '.【' + (q.type || '题目') + '】' + (q.stem || '(图片题,见下方原图)'), { bold: true, size: PTSZ.stem, before: 160, after: 80 })); if (CFG.includeImages) { (q.images || []).forEach((im) => { if (im && im.ocr_text) { body.push(pXml('【图片文字】' + im.ocr_text, { size: PTSZ.body, color: '333333', indent: 360, after: 80 })); return; } const r = addImg(im, 'block'); if (r) body.push(drawingPara(r)); else if (im && im.url) body.push(pXml('(图片:' + im.url + ')', { size: 18, color: '888888', indent: 360, after: 60 })); }); } } if (CFG.includeOptions) { (q.options || []).forEach((o) => { body.push(pXml(o, { size: PTSZ.body, indent: 360, after: 40 })); if (CFG.includeImages) { (((q.option_images_by_letter || {})[letterOf(o)]) || []).forEach((im) => { if (im && im.ocr_text) { body.push(pXml('【图片文字】' + im.ocr_text, { size: PTSZ.body, color: '333333', indent: 360, after: 40 })); return; } const r = addImg(im, 'option'); if (r) body.push(drawingPara(r)); }); } }); } if (CFG.includeAnswer) { const ans = (q.answer || '').trim(); body.push(pXml('答案:' + (ans || '未显示答案'), { bold: true, size: PTSZ.body, color: '0A7A2F', indent: 360, before: 40, after: 80 })); } if (CFG.includeImages) { (q.answer_images || []).forEach((im) => { if (im && im.ocr_text) { body.push(pXml('【图片文字】' + im.ocr_text, { size: PTSZ.body, color: '333333', indent: 360, after: 60 })); return; } const r = addImg(im, 'answer'); if (r) body.push(drawingPara(r)); }); } if (i !== qs.length - 1) body.push(''); }); const docXml = '' + '' + body.join('') + '' + ''; const docRels = '' + '' + rels.map((r) => '').join('') + ''; const contentTypes = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; const rootRels = '' + '' + '' + ''; const enc = new TextEncoder(); const entries = [ { name: '[Content_Types].xml', data: enc.encode(contentTypes) }, { name: '_rels/.rels', data: enc.encode(rootRels) }, { name: 'word/document.xml', data: enc.encode(docXml) }, { name: 'word/_rels/document.xml.rels', data: enc.encode(docRels) }, ]; media.forEach((md) => entries.push({ name: md.name, data: md.data })); return zipStore(entries); } // ---------------- 导出流程 ---------------- let panelWasOpen = false; function hideUI() { panelWasOpen = !!(PANEL && PANEL.style.display === 'block'); if (PANEL) PANEL.style.display = 'none'; if (FAB) FAB.style.display = 'none'; } function showUI() { if (FAB) FAB.style.display = ''; if (PANEL && panelWasOpen) PANEL.style.display = 'block'; } async function runExport(kind) { if (TIP) TIP.textContent = '正在读取页面题目,请稍候…(长页面会自动滚动加载)'; hideUI(); await nap(60); let res; try { res = await extract(); } catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; } finally { showUI(); } if (!res || !res.ok) { if (TIP) TIP.textContent = (res && res.error) || '提取失败,请确认在作业/考试页。'; return; } let ocrMsg = ''; if (CFG.includeOcr) { const targets = collectOcrTargets(res); if (targets.length) { if (TIP) TIP.textContent = '正在识别图片文字 0/' + targets.length + '…(首次会下载 OCR 模型,请稍等)'; const r = await runOcrOnImages(res, (done, total) => { if (TIP) TIP.textContent = '正在识别图片文字 ' + done + '/' + total + '…'; }); if (r.failed) ocrMsg = '(OCR 不可用,已全部用原图)'; else ocrMsg = '(识别为文字 ' + r.ok + ' 张,其余 ' + r.skipped + ' 张用原图)'; } } const m = meta(res); const base = safeName(m.name) || ('学习通' + m.kind); try { if (kind === 'docx') { const bytes = buildDocx(res); downloadBlob(base + '(' + m.kind + ').docx', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })); } else if (kind === 'md') { downloadText(base + '.md', buildMarkdown(res), 'text/markdown'); } else if (kind === 'txt') { downloadText(base + '.txt', buildText(res), 'text/plain'); } else if (kind === 'copy') { await navigator.clipboard.writeText(buildText(res)); } } catch (e) { if (TIP) TIP.textContent = '导出出错:' + (e && e.message ? e.message : e); return; } if (TIP) TIP.textContent = '完成:共 ' + res.questions.length + ' 题' + (CFG.includeAnswer ? '(含答案)' : '(未含答案)') + ocrMsg; } // ---------------- 界面 ---------------- function buildUI() { if (document.getElementById('xxt-fab')) return; const style = document.createElement('style'); style.textContent = [ '#xxt-fab{position:fixed;right:22px;bottom:96px;z-index:2147483000;background:#ff6b4a;color:#fff;', 'border:none;border-radius:24px;padding:11px 18px;font:700 15px/1.2 "Microsoft YaHei",sans-serif;', 'cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.25);}', '#xxt-panel{position:fixed;right:22px;bottom:150px;z-index:2147483000;width:288px;background:#fff;', 'color:#243;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,.28);padding:16px;display:none;', 'font:13px/1.5 "Microsoft YaHei",sans-serif;}', '#xxt-panel h3{margin:0 0 10px;font-size:15px;color:#1f2a44;}', '#xxt-panel label{display:flex;align-items:center;gap:6px;margin:7px 0;cursor:pointer;color:#334;}', '#xxt-panel .row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}', '#xxt-panel button{flex:1 1 46%;padding:9px 8px;border:1px solid #e2e6ee;border-radius:9px;', 'background:#f7f9fc;cursor:pointer;font:13px "Microsoft YaHei",sans-serif;}', '#xxt-panel button:hover{background:#eef2f8;}', '#xxt-panel .primary{background:#24304a;color:#fff;border-color:#24304a;}', '#xxt-tip{color:#8a91a3;margin-top:10px;line-height:1.5;min-height:18px;}', ].join(''); document.head.appendChild(style); FAB = document.createElement('button'); FAB.id = 'xxt-fab'; FAB.textContent = '导出题目'; document.body.appendChild(FAB); PANEL = document.createElement('div'); PANEL.id = 'xxt-panel'; PANEL.innerHTML = '

学习通一键导出 v1.0

' + '' + '' + '' + '' + '
' + '' + '' + '' + '' + '
' + '
'; document.body.appendChild(PANEL); TIP = PANEL.querySelector('#xxt-tip'); FAB.addEventListener('click', () => { PANEL.style.display = PANEL.style.display === 'block' ? 'none' : 'block'; }); PANEL.querySelector('#xxt-opt').addEventListener('change', (e) => (CFG.includeOptions = e.target.checked)); PANEL.querySelector('#xxt-ans').addEventListener('change', (e) => (CFG.includeAnswer = e.target.checked)); PANEL.querySelector('#xxt-img').addEventListener('change', (e) => (CFG.includeImages = e.target.checked)); PANEL.querySelector('#xxt-ocr').addEventListener('change', (e) => (CFG.includeOcr = e.target.checked)); PANEL.querySelector('#xxt-word').addEventListener('click', () => runExport('docx')); PANEL.querySelector('#xxt-md').addEventListener('click', () => runExport('md')); PANEL.querySelector('#xxt-txt').addEventListener('click', () => runExport('txt')); PANEL.querySelector('#xxt-copy').addEventListener('click', () => runExport('copy')); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', buildUI); } else { buildUI(); } })();