// ==UserScript==
// @name 学习通-随堂测验/章节测试/作业/考试题目一键提取导出pdf/word/txt/markdown格式文档
// @license GPL-3.0
// @version 1.7.20
// @run-at document-start
// @description 一键提取学习通作业题目,支持富文本(图文混排)及 Word/PDF/TXT/MD 四种格式导出、按课程任务点自动逐个提取章节测验、题目勾选后按范围导出,答案/错题收集,独立题库区(新建/存取/编辑/手动添加/导入/四种格式导出/题库对比),暗色模式,快捷键,iframe 提取
// @author suyu
// @icon http://pan-yz.chaoxing.com/favicon.ico
// @match *://*.chaoxing.com/*
// @match *://*.edu.cn/*
// @require https://unpkg.com/docx@8.5.0/build/index.umd.js
// @require https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js
// @require https://scriptcat.org/lib/668/2.0/TyprMd5.js
// @resource XXT_FONT_TABLE https://cdn.ocsjs.com/resources/font/table.json
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect cdn.ocsjs.com
// ==/UserScript==
(function () {
'use strict';
// ==================== iframe 跨窗口通信 ====================
// 存储从 iframe 通过 postMessage 发来的题目数据。
// 题目页面切换时旧 iframe 可能还会继续发送轮询结果,因此同时记录
// 消息来源和时间,避免顶层面板一直沿用上一个随堂练习的数据。
window.__xxt_iframe_data = null;
window.__xxt_iframe_context = null;
// 最近一次 iframe 结果的上下文和导航序号不会随 extract() 清空,
// 用于区分任务切换时迟到的 iframe 回传。
window.__xxt_last_iframe_context = null;
window.__xxt_last_iframe_source = null;
window.__xxt_last_navigation_source = null;
window.__xxt_navigation_serial = 0;
// 页面/任务切换时由顶层窗口设置,负责清空面板中的旧结果
window.__xxt_reset_extraction = null;
function currentWindowContext() {
let href = '', title = '', frameKey = '';
try { href = window.location.href; } catch (e) {}
try { title = document.title || ''; } catch (e) {}
try {
const frame = window.frameElement;
if (frame) {
const siblings = frame.parentElement ? Array.from(frame.parentElement.children) : [];
frameKey = `${frame.id || ''}|${frame.name || ''}|${frame.getAttribute('src') || ''}|${siblings.indexOf(frame)}`;
}
} catch (e) {}
return { href, title, frameKey };
}
function getDirectChildIframes(root = document) {
if (!root || !root.querySelectorAll) return [];
return Array.from(root.querySelectorAll('iframe')).filter(frame => frame.ownerDocument === root);
}
function isDirectChildFrameSource(source) {
if (!source) return false;
return getDirectChildIframes(document).some(frame => {
try { return frame.contentWindow === source; } catch (e) { return false; }
});
}
function relayFrameMessageToParent(message) {
const relayContexts = Array.isArray(message.relayContexts) ? [...message.relayContexts] : [];
relayContexts.push(currentWindowContext());
try { window.parent.postMessage({ ...message, relayContexts }, '*'); } catch (e) {}
}
// iframe 消息逐层由直属父窗口转发。每层都会验证来源并保留页面上下文,
// 避免切换题目后迟到的 iframe 结果覆盖当前缓存。
window.addEventListener('message', function(e) {
if (!e.data || !['xxt-iframe-navigation', 'xxt-iframe-result'].includes(e.data.type)) return;
if (!isDirectChildFrameSource(e.source)) return;
if (window.top !== window.self) {
relayFrameMessageToParent(e.data);
return;
}
if (e.data.type === 'xxt-iframe-navigation') {
window.__xxt_navigation_serial = (window.__xxt_navigation_serial || 0) + 1;
window.__xxt_last_navigation_source = e.source || null;
window.__xxt_last_navigation_context = e.data.context || null;
window.__xxt_last_navigation_at = Date.now();
window.__xxt_navigation_started_at = Date.now();
if (window.__xxt_reset_extraction) window.__xxt_reset_extraction();
return;
}
if (!e.data.data) return;
const messageHref = e.data.context && e.data.context.href ? e.data.context.href : '';
const messageSignature = iframeDataSignature(e.data.data);
if (window.__xxt_ignore_iframe_signature && messageSignature &&
messageSignature === window.__xxt_ignore_iframe_signature &&
Date.now() - (window.__xxt_navigation_started_at || 0) < 6000) {
return;
}
window.__xxt_iframe_data = e.data.data;
window.__xxt_last_iframe_source = e.source || null;
window.__xxt_iframe_context = {
href: messageHref,
title: e.data.context && e.data.context.title ? e.data.context.title : '',
relayContexts: Array.isArray(e.data.relayContexts) ? e.data.relayContexts : [],
receivedAt: Date.now()
};
window.__xxt_last_iframe_context = { ...window.__xxt_iframe_context };
window.__xxt_last_iframe_message_at = Date.now();
window.__xxt_last_iframe_signature = messageSignature;
});
// 向当前文档下所有 iframe 请求重新提取。跨域 iframe 不能直接读取 DOM,
// 但仍可以安全地 postMessage;中间 iframe 会继续向其子 iframe 转发请求。
function relayIframeExtractRequest(message, root) {
const frames = (root || document).querySelectorAll('iframe');
frames.forEach(frame => {
try { frame.contentWindow.postMessage(message, '*'); } catch (e) {}
});
}
function requestIframeExtraction(requestId, targetWindows, extra = {}) {
const id = requestId || `xxt-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const message = { type: 'xxt-request-extract', requestId: id, sentAt: Date.now(), ...extra };
const targets = Array.isArray(targetWindows) ? [...new Set(targetWindows.filter(Boolean))] : [];
if (targets.length) {
targets.forEach(target => { try { target.postMessage(message, '*'); } catch (e) {} });
} else {
relayIframeExtractRequest(message);
}
return id;
}
function emptyExtractResult() {
return {
results: { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] },
typeOrder: [], wrongCount: 0, hasMyAnswer: false, hasCorrectAnswer: false
};
}
// ==================== 样式注入 ====================
const css = `
/* ============ 设计变量(浅色) ============ */
#xxt-panel, #xxt-settings-modal, #xxt-history-modal, #xxt-editor-modal, #xxt-bank-modal, #xxt-save-bank-modal, #xxt-batch-modal, #xxt-preview-modal, #xxt-batch-progress-card {
--xxt-grad: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
--xxt-grad-soft: linear-gradient(135deg, rgba(99,102,241,0.10) 0%, rgba(236,72,153,0.10) 100%);
--xxt-accent: #5b3ad8;
--xxt-accent-strong: #4a2dc7;
--xxt-surface: #ffffff;
--xxt-surface-solid: #ffffff;
--xxt-border: #e5e7eb;
--xxt-shadow: 0 20px 48px -12px rgba(49,46,129,0.22), 0 8px 18px -8px rgba(15,23,42,0.10);
--xxt-text: #111827;
--xxt-text-soft: #4b5563;
--xxt-radius: 18px;
}
/* 全局字体与渲染优化 */
#xxt-panel, #xxt-settings-modal, #xxt-history-modal, #xxt-editor-modal, #xxt-bank-modal, #xxt-save-bank-modal, #xxt-batch-modal, #xxt-preview-modal, #xxt-batch-progress-card {
font-family: "PingFang SC","Microsoft YaHei","微软雅黑",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
/* ===== 固定显示的主面板(毛玻璃) ===== */
#xxt-panel {
position: fixed; top: 72px; right: 20px; z-index: 99999;
width: min(384px, calc(100vw - 16px)); box-sizing: border-box;
background: #ffffff;
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid var(--xxt-border);
border-radius: 22px;
box-shadow: var(--xxt-shadow);
padding: 22px 20px 20px;
font-size: 13px; color: var(--xxt-text);
max-height: calc(100vh - 16px); overflow-y: auto;
}
#xxt-panel::-webkit-scrollbar { width: 6px; }
#xxt-panel::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #c4b5fd, #f0abfc);
border-radius: 6px; border: 1px solid transparent;
background-clip: padding-box;
}
#xxt-panel::-webkit-scrollbar-track { background: transparent; }
/* ===== 标题栏 ===== */
#xxt-panel .xxt-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; padding-bottom: 14px;
border-bottom: 1px solid var(--xxt-border);
cursor: move; user-select: none;
}
#xxt-panel .xxt-header h3 {
font-size: 16px; font-weight: 800; color: var(--xxt-text);
margin: 0; letter-spacing: 0.3px;
background: var(--xxt-grad);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
#xxt-panel .xxt-bank-entry {
width: 100%; min-height: 68px; margin: -3px 0 14px; padding: 10px 13px;
border: 1px solid rgba(124,92,240,0.24); border-radius: 16px;
background: var(--xxt-grad-soft); color: var(--xxt-text); cursor: pointer;
display: flex; align-items: center; gap: 11px; text-align: left;
font-family: inherit; transition: all 0.24s cubic-bezier(.4,0,.2,1);
box-shadow: 0 6px 16px -11px rgba(91,58,216,0.45);
}
#xxt-panel .xxt-bank-entry:hover {
border-color: rgba(124,92,240,0.52); transform: translateY(-2px);
box-shadow: 0 13px 24px -12px rgba(91,58,216,0.55); filter: saturate(1.08);
}
#xxt-panel .xxt-bank-entry:active { transform: translateY(0) scale(0.99); }
#xxt-panel .xxt-bank-entry:focus-visible { outline: none; box-shadow: 0 0 0 4px rgba(124,92,240,0.20); }
#xxt-panel .xxt-bank-entry-icon {
width: 42px; height: 42px; flex: 0 0 42px; border-radius: 13px;
display: flex; align-items: center; justify-content: center; font-size: 22px;
background: #fff; box-shadow: 0 6px 12px -8px rgba(91,58,216,0.55);
}
#xxt-panel .xxt-bank-entry-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 2px; }
#xxt-panel .xxt-bank-entry-copy strong { font-size: 14px; line-height: 1.3; font-weight: 800; color: var(--xxt-accent-strong); }
#xxt-panel .xxt-bank-entry-copy small { font-size: 11px; line-height: 1.4; color: var(--xxt-text-soft); font-weight: 600; }
#xxt-panel .xxt-bank-entry-arrow { font-size: 24px; line-height: 1; color: var(--xxt-accent); transition: transform 0.22s ease; }
#xxt-panel .xxt-bank-entry:hover .xxt-bank-entry-arrow { transform: translateX(3px); }
/* ===== 星夜题库资源入口 ===== */
#xxt-panel .xxt-tiku-promo {
margin: 0 0 10px; padding: 9px 10px;
border: 1px solid rgba(14,165,233,.28); border-radius: 14px;
background: linear-gradient(135deg, rgba(14,165,233,.09), rgba(16,185,129,.08));
box-shadow: 0 7px 18px -14px rgba(14,116,144,.52);
}
#xxt-panel .xxt-tiku-promo-top { display:flex; align-items:center; gap:8px; min-width:0; }
#xxt-panel .xxt-tiku-promo-icon {
width:30px; height:30px; flex:0 0 30px; border-radius:10px;
display:grid; place-items:center; background:#fff; color:#0284c7; font-size:16px;
box-shadow:0 5px 11px -8px rgba(2,132,199,.65);
}
#xxt-panel .xxt-tiku-promo-copy { display:flex; flex:1; min-width:0; flex-direction:column; gap:1px; }
#xxt-panel .xxt-tiku-promo-copy strong { color:#0369a1; font-size:12.5px; line-height:1.3; font-weight:800; }
#xxt-panel .xxt-tiku-promo-copy small { color:#52708a; font-size:10px; line-height:1.35; font-weight:600; }
#xxt-panel .xxt-tiku-promo-actions { display:flex; gap:6px; margin-top:8px; }
#xxt-panel .xxt-tiku-promo-link {
display:inline-flex; align-items:center; justify-content:center; min-height:27px; flex:1;
padding:0 8px; border:1px solid rgba(2,132,199,.52); border-radius:9px;
color:#0369a1; background:rgba(255,255,255,.68); text-decoration:none;
font-size:10.5px; font-weight:800; line-height:1; transition:all .2s ease;
}
#xxt-panel .xxt-tiku-promo-link:hover { transform:translateY(-1px); background:#0284c7; border-color:#0284c7; color:#fff; box-shadow:0 7px 14px -8px rgba(2,132,199,.68); }
#xxt-panel .xxt-tiku-promo-link:focus-visible { outline:none; box-shadow:0 0 0 3px rgba(14,165,233,.24); }
/* ===== 提取按钮(渐变主按钮) ===== */
#xxt-panel .xxt-btn-extract {
display: block; width: 100%; padding: 13px; border: none;
border-radius: 14px; font-size: 14.5px; font-weight: 700; cursor: pointer;
margin-bottom: 12px; transition: all 0.28s cubic-bezier(.4,0,.2,1);
background: var(--xxt-grad); color: #fff;
box-shadow: 0 10px 22px -8px rgba(124,92,240,0.55);
letter-spacing: 1.5px;
}
#xxt-panel .xxt-btn-extract:hover {
transform: translateY(-2px);
box-shadow: 0 16px 30px -8px rgba(124,92,240,0.7);
filter: brightness(1.05);
}
#xxt-panel .xxt-btn-extract:active { transform: translateY(0) scale(0.98); }
#xxt-panel .xxt-btn-extract:disabled {
background: linear-gradient(135deg,#cbd5e1,#cbd5e1);
box-shadow: none; cursor: not-allowed; transform: none; filter: none;
}
#xxt-panel .xxt-btn-batch {
width: 100%; padding: 10px 12px; margin: -3px 0 12px; border-radius: 13px;
border: 1.5px dashed rgba(91,58,216,.54); background: rgba(124,92,240,.055);
color: var(--xxt-accent-strong); cursor: pointer; font: 700 12.5px/1.35 inherit;
transition: all .22s ease;
}
#xxt-panel .xxt-btn-batch:hover { background: var(--xxt-grad-soft); border-style: solid; transform: translateY(-1px); box-shadow: 0 9px 18px -12px rgba(91,58,216,.6); }
#xxt-panel .xxt-btn-batch:disabled { cursor: not-allowed; opacity: .52; transform: none; box-shadow: none; }
/* ===== 整门课提取进度(主面板内) ===== */
#xxt-panel .xxt-batch-live {
margin:-2px 0 12px; padding:11px 12px; border:1px solid rgba(91,58,216,.32);
border-radius:14px; background:var(--xxt-grad-soft); color:var(--xxt-text); box-sizing:border-box;
}
#xxt-panel .xxt-batch-live-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
#xxt-panel .xxt-batch-live-title { font-size:12.5px; font-weight:800; }
#xxt-panel .xxt-batch-live-state { flex:0 0 auto; padding:3px 7px; border-radius:99px; background:rgba(91,58,216,.12); color:var(--xxt-accent-strong); font-size:10.5px; font-weight:800; }
#xxt-panel .xxt-batch-live-state.is-paused { background:rgba(245,158,11,.14); color:#b45309; }
#xxt-panel .xxt-batch-live-state.is-done { background:rgba(16,185,129,.14); color:#047857; }
#xxt-panel .xxt-batch-live-current { margin-top:8px; color:var(--xxt-accent-strong); font-size:12px; font-weight:800; line-height:1.45; }
#xxt-panel .xxt-batch-live-detail { margin-top:3px; color:var(--xxt-text-soft); font-size:10.8px; line-height:1.45; }
#xxt-panel .xxt-batch-live-bar { height:6px; margin:9px 0 10px; overflow:hidden; border-radius:99px; background:rgba(91,58,216,.16); }
#xxt-panel .xxt-batch-live-bar > i { display:block; width:0; height:100%; border-radius:inherit; background:var(--xxt-grad); transition:width .25s ease; }
#xxt-panel .xxt-batch-live-actions { display:flex; flex-wrap:wrap; gap:7px; }
#xxt-panel .xxt-batch-live-actions button { flex:1 1 92px; border:1px solid var(--xxt-accent); border-radius:9px; padding:7px 9px; background:#fff; color:var(--xxt-accent-strong); cursor:pointer; font:800 11px/1.3 inherit; }
#xxt-panel .xxt-batch-live-actions button:hover { border-color:transparent; background:var(--xxt-grad); color:#fff; }
#xxt-panel .xxt-batch-live-actions button[data-batch-live-stop] { border-color:#ef4444; color:#dc2626; }
#xxt-panel .xxt-batch-live-actions button[data-batch-live-stop]:hover { background:#ef4444; }
#xxt-panel .xxt-batch-live-actions button:disabled { opacity:.5; cursor:not-allowed; }
#xxt-panel .xxt-batch-live-log { margin-top:9px; border-top:1px solid rgba(91,58,216,.18); padding-top:8px; }
#xxt-panel .xxt-batch-live-log summary { color:var(--xxt-text-soft); cursor:pointer; font-size:11px; font-weight:800; list-style:none; user-select:none; }
#xxt-panel .xxt-batch-live-log summary::-webkit-details-marker { display:none; }
#xxt-panel .xxt-batch-live-log summary::before { content:'›'; display:inline-block; margin-right:5px; color:var(--xxt-accent); font-size:14px; line-height:0; transform:rotate(0deg); transition:transform .18s ease; }
#xxt-panel .xxt-batch-live-log[open] summary::before { transform:rotate(90deg); }
#xxt-panel .xxt-batch-live-log-list { max-height:134px; margin-top:7px; overflow:auto; padding-right:3px; }
#xxt-panel .xxt-batch-log-item { display:grid; grid-template-columns:auto 1fr; gap:6px; padding:4px 0; color:var(--xxt-text-soft); font-size:10.5px; line-height:1.45; }
#xxt-panel .xxt-batch-log-item + .xxt-batch-log-item { border-top:1px dashed rgba(91,58,216,.13); }
#xxt-panel .xxt-batch-log-time { color:#9ca3af; font-variant-numeric:tabular-nums; white-space:nowrap; }
#xxt-panel .xxt-batch-log-item.is-warn { color:#b45309; }
#xxt-panel .xxt-batch-log-item.is-error { color:#dc2626; }
/* ===== 状态提示(药丸标签) ===== */
#xxt-panel .xxt-status {
text-align: center; padding: 10px 14px; font-size: 12.5px;
margin-bottom: 12px; border-radius: 12px; font-weight: 600;
letter-spacing: 0.3px;
}
.xxt-status-ok { color: #047857; background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.25); }
.xxt-status-loading { color: #2563eb; background: rgba(59,130,246,0.10); border: 1px solid rgba(59,130,246,0.22); }
.xxt-status-err { color: #dc2626; background: rgba(239,68,68,0.10); border: 1px solid rgba(239,68,68,0.22); }
.xxt-status-warn { color: #b45309; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.25); }
/* ===== 统计卡片(渐变描边) ===== */
#xxt-panel .xxt-stat {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px;
margin: 14px 0 16px;
}
#xxt-panel .xxt-stat-item {
text-align: center; padding: 12px 4px 9px;
background: var(--xxt-surface-solid); border-radius: 14px;
border: 1px solid var(--xxt-border);
box-shadow: 0 4px 12px -6px rgba(15,23,42,0.12);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
#xxt-panel .xxt-stat-item:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px -8px rgba(124,92,240,0.4);
}
#xxt-panel .xxt-stat-item .xxt-num {
font-size: 23px; font-weight: 800; line-height: 1.15;
background: var(--xxt-grad);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
#xxt-panel .xxt-stat-item .xxt-label {
font-size: 11px; color: #4b5563; margin-top: 3px; font-weight: 600;
}
/* ===== 分隔区块(柔和卡片) ===== */
#xxt-panel .xxt-section {
background: var(--xxt-surface-solid); border-radius: 16px; padding: 14px 16px;
border: 1px solid var(--xxt-border); margin-top: 12px;
box-shadow: 0 4px 14px -8px rgba(15,23,42,0.08);
}
/* ===== 文件名输入 ===== */
#xxt-panel .xxt-filename {
width: 100%; padding: 11px 14px; border: 1.5px solid var(--xxt-border);
border-radius: 12px; font-size: 12.5px; color: var(--xxt-text);
box-sizing: border-box; outline: none; transition: all 0.22s ease;
background: #fff;
}
#xxt-panel .xxt-filename::placeholder { color: #b6bcc8; }
#xxt-panel .xxt-filename:focus {
border-color: var(--xxt-accent);
box-shadow: 0 0 0 4px rgba(124,92,240,0.14);
}
/* ===== 格式选择(胶囊分段) ===== */
#xxt-panel .xxt-format-row {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 12px;
font-size: 12.5px; color: #4b5563; font-weight: 600;
}
#xxt-panel .xxt-format-row label {
cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
padding: 6px 16px; border-radius: 20px; border: 1.5px solid var(--xxt-border);
background: #fff; transition: all 0.22s cubic-bezier(.4,0,.2,1); font-size: 12px;
}
#xxt-panel .xxt-format-row label:hover { border-color: rgba(124,92,240,0.4); transform: translateY(-1px); }
#xxt-panel .xxt-format-row input[type="radio"] {
width: 14px; height: 14px; cursor: pointer; accent-color: var(--xxt-accent);
}
#xxt-panel .xxt-format-row label:has(input:checked) {
color: #fff; border-color: transparent; background: var(--xxt-grad);
box-shadow: 0 6px 14px -6px rgba(124,92,240,0.6);
}
/* ===== 操作按钮组 ===== */
#xxt-panel .xxt-actions {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px;
}
#xxt-panel .xxt-actions .xxt-btn {
padding: 11px; border-radius: 14px; font-size: 13px; font-weight: 700;
transition: all 0.24s ease;
}
#xxt-panel .xxt-selection-summary {
margin: -2px 0 11px; padding: 8px 10px; border-radius: 10px;
background: rgba(15,23,42,.035); color: var(--xxt-text-soft); font-size: 11px; font-weight: 700;
}
#xxt-panel .xxt-selection-summary.xxt-selection-limited { background: rgba(124,92,240,.10); color: var(--xxt-accent-strong); }
.xxt-btn-outline {
background: #fff; color: var(--xxt-accent-strong);
border: 1.5px solid var(--xxt-accent) !important;
}
.xxt-btn-outline:hover {
background: var(--xxt-grad); color: #fff; border-color: transparent !important;
box-shadow: 0 8px 18px -8px rgba(124,92,240,0.6); transform: translateY(-1px);
}
/* ===== 开关选项(复选框) ===== */
#xxt-panel .xxt-toggle {
display: flex; align-items: flex-start; gap: 11px; margin-top: 12px;
padding: 12px 14px; border-radius: 14px;
background: var(--xxt-surface-solid); border: 1px solid var(--xxt-border);
cursor: pointer; transition: all 0.22s ease;
}
#xxt-panel .xxt-toggle:hover { border-color: rgba(124,92,240,0.35); transform: translateX(2px); }
#xxt-panel .xxt-toggle .xxt-checkbox-wrap {
flex-shrink: 0; width: 20px; height: 20px; margin-top: 1px;
border: 2px solid #cbd5e1; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
transition: all 0.22s cubic-bezier(.34,1.56,.64,1); background: #fff;
}
#xxt-panel .xxt-toggle input { display: none; }
#xxt-panel .xxt-toggle input:checked + .xxt-checkbox-wrap {
background: var(--xxt-grad); border-color: transparent;
box-shadow: 0 4px 10px -3px rgba(124,92,240,0.6);
}
#xxt-panel .xxt-toggle input:checked + .xxt-checkbox-wrap::after {
content: ''; width: 5px; height: 10px;
border: solid #fff; border-width: 0 2.5px 2.5px 0;
transform: rotate(45deg) translateY(-1px);
}
#xxt-panel .xxt-toggle span {
font-size: 12.5px; color: #1f2937; line-height: 1.5; font-weight: 600;
}
/* 题库导入勾选时禁用打乱/答案选项,不隐藏 */
#xxt-panel .xxt-toggle.xxt-disabled {
opacity: 0.45; pointer-events: none; user-select: none;
}
/* ===== 错题提示 ===== */
#xxt-panel .xxt-wrong-hint {
font-size: 11.5px; color: #dc2626; margin-top: 10px;
padding: 10px 14px; background: rgba(239,68,68,0.08); border-radius: 12px;
border: 1px solid rgba(239,68,68,0.2); font-weight: 600;
animation: xxt-shake 0.4s ease-in-out;
}
@keyframes xxt-shake {
0%,100% { transform: translateX(0); }
25% { transform: translateX(-3px); }
50% { transform: translateX(3px); }
75% { transform: translateX(-2px); }
}
.xxt-hidden { display: none !important; }
/* ===== QQ 群反馈入口 ===== */
#xxt-panel .xxt-feedback-link {
display: inline-flex; align-items: center; justify-content: center;
height: 28px; padding: 0 8px; border-radius: 9px;
background: linear-gradient(135deg, #12b7f5, #1286e8);
color: #fff !important; text-decoration: none;
font-size: 11px; font-weight: 700; white-space: nowrap;
box-shadow: 0 4px 10px -5px rgba(18,134,232,0.75);
transition: all 0.22s ease;
}
#xxt-panel .xxt-feedback-link:hover {
filter: brightness(1.08); transform: translateY(-1px);
box-shadow: 0 7px 14px -6px rgba(18,134,232,0.85);
}
#xxt-panel .xxt-feedback-link:active { transform: translateY(0) scale(0.97); }
/* ===== 设置齿轮图标 ===== */
#xxt-panel .xxt-settings-btn {
width: 28px; height: 28px; border: none;
background: rgba(15,23,42,0.05);
border-radius: 50%; cursor: pointer; display: flex; align-items: center;
justify-content: center; transition: all 0.25s ease; margin-right: 4px;
color: #9ca3af; padding: 0;
}
#xxt-panel .xxt-settings-btn:hover { background: rgba(124,92,240,0.14); color: var(--xxt-accent); transform: rotate(40deg); }
#xxt-panel .xxt-settings-btn svg { width: 16px; height: 16px; fill: currentColor; }
/* 所有脚本浮窗都可从标题栏拖动;表单控件和关闭按钮仍保持正常点击。 */
#xxt-panel.xxt-draggable .xxt-header,
#xxt-settings-modal .xxt-draggable .xxt-modal-header,
#xxt-history-modal .xxt-draggable .xxt-modal-header,
#xxt-batch-modal .xxt-draggable .xxt-modal-header,
#xxt-bank-modal .xxt-draggable .xxt-modal-header,
#xxt-save-bank-modal .xxt-draggable .xxt-modal-header,
#xxt-preview-modal .xxt-draggable .xxt-modal-header,
#xxt-editor-modal .xxt-draggable .xxt-modal-header,
#xxt-batch-progress-card.xxt-draggable .xxt-batch-card-head { cursor: grab; touch-action: none; user-select: none; }
.xxt-dragging, .xxt-dragging * { cursor: grabbing !important; }
/* ===== 设置弹窗遮罩 ===== */
#xxt-settings-modal {
position: fixed; inset: 0; z-index: 100000;
background: rgba(15,23,42,0.42);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity 0.28s ease;
}
#xxt-settings-modal.open { opacity: 1; pointer-events: auto; }
/* ===== 设置弹窗主体 ===== */
#xxt-settings-modal .xxt-modal-box {
background: #ffffff;
border: 1px solid var(--xxt-border);
border-radius: 22px; width: 348px;
padding: 26px; box-shadow: var(--xxt-shadow);
transform: translateY(16px) scale(0.97); transition: transform 0.3s cubic-bezier(.16,1,.3,1);
color: var(--xxt-text);
}
#xxt-settings-modal.open .xxt-modal-box { transform: translateY(0) scale(1); }
#xxt-settings-modal .xxt-modal-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; padding-bottom: 16px;
border-bottom: 1px solid var(--xxt-border);
}
#xxt-settings-modal .xxt-modal-header h3 {
font-size: 16px; font-weight: 800; color: var(--xxt-text); margin: 0;
background: var(--xxt-grad); -webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
#xxt-settings-modal .xxt-modal-close {
width: 28px; height: 28px; border: none; background: rgba(15,23,42,0.05);
border-radius: 50%; font-size: 15px; color: #9ca3af; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.22s ease;
}
#xxt-settings-modal .xxt-modal-close:hover { background: #ef4444; color: #fff; transform: rotate(90deg); }
/* ===== 设置弹窗内容行 ===== */
#xxt-settings-modal .xxt-setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-radius: 14px;
background: var(--xxt-surface-solid); border: 1px solid var(--xxt-border);
margin-bottom: 10px; font-size: 13px; color: #1f2937; font-weight: 600;
box-shadow: 0 3px 10px -6px rgba(15,23,42,0.08);
}
#xxt-settings-modal .xxt-setting-label { font-weight: 600; white-space: nowrap; }
#xxt-settings-modal .xxt-theme-group { display: flex; gap: 6px; }
#xxt-settings-modal .xxt-theme-btn {
padding: 5px 14px; border-radius: 18px; border: 1.5px solid var(--xxt-border);
background: #fff; color: var(--xxt-text-soft); font-size: 11px; cursor: pointer;
transition: all 0.22s ease; font-weight: 600;
}
#xxt-settings-modal .xxt-theme-btn:hover { border-color: rgba(124,92,240,0.4); color: var(--xxt-accent); }
#xxt-settings-modal .xxt-theme-btn.active {
background: var(--xxt-grad); color: #fff; border-color: transparent;
box-shadow: 0 6px 14px -6px rgba(124,92,240,0.6);
}
#xxt-settings-modal .xxt-shortcut-display {
display: flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 10px;
background: rgba(124,92,240,0.08); border: 1px solid rgba(124,92,240,0.2);
cursor: pointer; transition: all 0.22s ease; font-size: 12px; color: #4b5563;
}
#xxt-settings-modal .xxt-shortcut-display:hover { border-color: var(--xxt-accent); }
#xxt-settings-modal .xxt-shortcut-keys {
color: var(--xxt-accent-strong); font-weight: 700; font-family: ui-monospace, monospace;
padding: 2px 8px; background: rgba(124,92,240,0.12); border-radius: 6px;
}
#xxt-settings-modal .xxt-shortcut-hint { color: #9ca3af; font-size: 11px; }
/* ===== 历史记录弹窗 ===== */
#xxt-history-modal {
position: fixed; inset: 0; z-index: 100001;
background: rgba(15,23,42,0.42); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity 0.28s ease;
}
#xxt-history-modal.open { opacity: 1; pointer-events: auto; }
#xxt-history-modal .xxt-modal-box {
background: #ffffff;
border: 1px solid var(--xxt-border);
border-radius: 22px; width: 420px; max-height: 540px;
padding: 26px; box-shadow: var(--xxt-shadow);
transform: translateY(16px) scale(0.97); transition: transform 0.3s cubic-bezier(.16,1,.3,1);
display: flex; flex-direction: column;
color: var(--xxt-text);
}
#xxt-history-modal.open .xxt-modal-box { transform: translateY(0) scale(1); }
#xxt-history-modal .xxt-modal-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; padding-bottom: 14px;
border-bottom: 1px solid var(--xxt-border); flex-shrink: 0;
}
#xxt-history-modal .xxt-modal-header h3 {
font-size: 16px; font-weight: 800; color: var(--xxt-text); margin: 0;
background: var(--xxt-grad); -webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
#xxt-history-modal .xxt-modal-close {
width: 28px; height: 28px; border: none; background: rgba(15,23,42,0.05);
border-radius: 50%; font-size: 15px; color: #9ca3af; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.22s ease;
}
#xxt-history-modal .xxt-modal-close:hover { background: #ef4444; color: #fff; transform: rotate(90deg); }
#xxt-history-modal .xxt-history-list { flex: 1; overflow-y: auto; }
#xxt-history-modal .xxt-history-list::-webkit-scrollbar { width: 6px; }
#xxt-history-modal .xxt-history-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #c4b5fd, #f0abfc); border-radius: 6px;
}
#xxt-history-modal .xxt-history-empty {
text-align: center; color: #aab1c0; padding: 48px 0; font-size: 13px; font-weight: 500;
}
#xxt-history-modal .xxt-history-item {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-radius: 16px;
background: var(--xxt-surface-solid); border: 1px solid var(--xxt-border);
margin-bottom: 10px; transition: all 0.22s ease; cursor: pointer;
box-shadow: 0 3px 10px -6px rgba(15,23,42,0.08);
}
#xxt-history-modal .xxt-history-item:hover {
border-color: rgba(124,92,240,0.4);
transform: translateY(-2px);
box-shadow: 0 10px 22px -10px rgba(124,92,240,0.45);
}
#xxt-history-modal .xxt-history-info { flex: 1; min-width: 0; }
#xxt-history-modal .xxt-history-title {
font-size: 13px; font-weight: 700; color: #374151;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#xxt-history-modal .xxt-history-meta {
font-size: 11px; color: #9ca3af; margin-top: 4px; font-weight: 500;
}
#xxt-history-modal .xxt-history-delete {
width: 30px; height: 30px; border: none; background: transparent;
border-radius: 50%; cursor: pointer; color: #cbd5e1; font-size: 16px;
display: flex; align-items: center; justify-content: center;
transition: all 0.22s ease; flex-shrink: 0; margin-left: 8px;
}
#xxt-history-modal .xxt-history-delete:hover { background: rgba(239,68,68,0.12); color: #ef4444; }
#xxt-history-modal .xxt-history-download {
width: 30px; height: 30px; border: none; background: transparent;
border-radius: 50%; cursor: pointer; color: #cbd5e1; font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: all 0.22s ease; flex-shrink: 0; margin-left: 4px;
}
#xxt-history-modal .xxt-history-download:hover { background: rgba(124,92,240,0.14); color: var(--xxt-accent); }
/* ===== 整门课章节测验批量提取 ===== */
#xxt-batch-modal {
position:fixed; inset:0; z-index:100006; background:rgba(15,23,42,.42); backdrop-filter:blur(4px);
display:flex; align-items:center; justify-content:center; opacity:0; pointer-events:none; transition:opacity .28s ease;
}
#xxt-batch-modal.open { opacity:1; pointer-events:auto; }
#xxt-batch-modal .xxt-batch-box {
width:min(620px,94vw); max-height:88vh; padding:22px; box-sizing:border-box; background:#fff;
border:1px solid var(--xxt-border); border-radius:22px; box-shadow:var(--xxt-shadow); color:var(--xxt-text);
display:flex; flex-direction:column; transform:translateY(16px) scale(.97); transition:transform .3s cubic-bezier(.16,1,.3,1);
}
#xxt-batch-modal.open .xxt-batch-box { transform:translateY(0) scale(1); }
#xxt-batch-modal .xxt-modal-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:14px; margin-bottom:12px; border-bottom:1px solid var(--xxt-border); }
#xxt-batch-modal .xxt-modal-header h3 { margin:0; font-size:16px; font-weight:800; color:var(--xxt-text); }
#xxt-batch-modal .xxt-modal-close { width:28px; height:28px; border:0; border-radius:50%; background:rgba(15,23,42,.05); color:#9ca3af; cursor:pointer; font-size:15px; }
#xxt-batch-modal .xxt-modal-close:hover { background:#ef4444; color:#fff; }
#xxt-batch-modal .xxt-batch-note { padding:10px 12px; margin-bottom:11px; border-radius:11px; background:var(--xxt-grad-soft); color:var(--xxt-text-soft); font-size:12px; line-height:1.6; }
#xxt-batch-modal .xxt-batch-toolbar { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
#xxt-batch-modal .xxt-batch-toolbar button, #xxt-batch-modal .xxt-batch-actions button {
border:1px solid var(--xxt-accent); border-radius:9px; padding:8px 11px; background:#fff; color:var(--xxt-accent-strong); cursor:pointer; font:700 12px/1.35 inherit;
}
#xxt-batch-modal .xxt-batch-toolbar button:hover, #xxt-batch-modal .xxt-batch-actions button:hover { background:var(--xxt-grad); color:#fff; border-color:transparent; }
#xxt-batch-modal .xxt-batch-toolbar button:disabled, #xxt-batch-modal .xxt-batch-actions button:disabled { cursor:not-allowed; opacity:.48; }
#xxt-batch-modal .xxt-batch-list { overflow:auto; min-height:110px; max-height:46vh; padding:2px 4px 6px 0; }
#xxt-batch-modal .xxt-batch-empty { padding:32px 10px; color:#9ca3af; text-align:center; font-size:12px; line-height:1.7; }
#xxt-batch-modal .xxt-batch-item { display:flex; gap:10px; align-items:center; padding:10px 11px; margin-bottom:7px; border:1px solid var(--xxt-border); border-radius:12px; background:var(--xxt-surface-solid); transition:all .2s ease; }
#xxt-batch-modal .xxt-batch-item:hover { border-color:rgba(124,92,240,.42); }
#xxt-batch-modal .xxt-batch-item input { width:16px; height:16px; flex:0 0 auto; accent-color:var(--xxt-accent); cursor:pointer; }
#xxt-batch-modal .xxt-batch-item-main { min-width:0; flex:1; }
#xxt-batch-modal .xxt-batch-item-title { color:var(--xxt-text); font-size:12.5px; line-height:1.45; font-weight:700; }
#xxt-batch-modal .xxt-batch-item-meta { margin-top:3px; color:#9ca3af; font-size:10.5px; line-height:1.35; }
#xxt-batch-modal .xxt-batch-item-state { flex:0 0 auto; color:#9ca3af; font-size:11px; font-weight:700; }
#xxt-batch-modal .xxt-batch-item.is-current { border-color:rgba(124,92,240,.6); background:var(--xxt-grad-soft); }
#xxt-batch-modal .xxt-batch-item.is-current .xxt-batch-item-state { color:var(--xxt-accent-strong); }
#xxt-batch-modal .xxt-batch-item.is-done { border-color:rgba(16,185,129,.32); }
#xxt-batch-modal .xxt-batch-item.is-done .xxt-batch-item-state { color:#047857; }
#xxt-batch-modal .xxt-batch-item.is-failed { border-color:rgba(245,158,11,.38); }
#xxt-batch-modal .xxt-batch-item.is-failed .xxt-batch-item-state { color:#b45309; }
#xxt-batch-modal .xxt-batch-actions { display:flex; align-items:center; justify-content:space-between; gap:10px; padding-top:13px; margin-top:5px; border-top:1px solid var(--xxt-border); }
#xxt-batch-modal .xxt-batch-actions .xxt-batch-stop { border-color:#ef4444; color:#dc2626; }
#xxt-batch-modal .xxt-batch-actions .xxt-batch-stop:hover { background:#ef4444; color:#fff; }
#xxt-batch-modal .xxt-batch-progress { flex:1; min-width:0; color:var(--xxt-text-soft); font-size:11px; line-height:1.45; }
/* 批量运行时不遮挡学习通页面:进度固定在右侧,用户仍可观察题目加载。 */
#xxt-batch-progress-card {
position:fixed; top:148px; right:18px; z-index:100007; width:min(310px,calc(100vw - 36px));
box-sizing:border-box; padding:14px 15px; border:1px solid rgba(124,92,240,.35);
border-radius:16px; background:rgba(255,255,255,.96); backdrop-filter:blur(18px);
box-shadow:0 16px 38px rgba(49,46,129,.20); color:var(--xxt-text); display:none;
}
#xxt-batch-progress-card.open { display:block; }
#xxt-batch-progress-card .xxt-batch-card-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
#xxt-batch-progress-card .xxt-batch-card-title { font-size:13px; font-weight:800; }
#xxt-batch-progress-card .xxt-batch-card-close { width:24px; height:24px; border:0; border-radius:50%; background:rgba(15,23,42,.06); color:#9ca3af; cursor:pointer; }
#xxt-batch-progress-card .xxt-batch-card-note { padding:8px 9px; margin-bottom:9px; border-radius:9px; background:var(--xxt-grad-soft); color:var(--xxt-text-soft); font-size:11px; line-height:1.5; }
#xxt-batch-progress-card .xxt-batch-card-current { color:var(--xxt-accent-strong); font-size:12px; font-weight:700; line-height:1.45; word-break:break-word; }
#xxt-batch-progress-card .xxt-batch-card-detail { margin-top:4px; color:var(--xxt-text-soft); font-size:11px; line-height:1.45; }
#xxt-batch-progress-card .xxt-batch-card-bar { height:6px; margin:10px 0 8px; overflow:hidden; border-radius:99px; background:#ede9fe; }
#xxt-batch-progress-card .xxt-batch-card-bar > i { display:block; width:0; height:100%; border-radius:inherit; background:var(--xxt-grad); transition:width .25s ease; }
#xxt-batch-progress-card .xxt-batch-card-actions { display:flex; justify-content:flex-end; gap:8px; }
#xxt-batch-progress-card button[data-batch-card-stop] { border:1px solid #ef4444; border-radius:8px; padding:6px 10px; background:#fff; color:#dc2626; cursor:pointer; font:700 11px/1.3 inherit; }
#xxt-batch-progress-card button[data-batch-card-stop]:hover { background:#ef4444; color:#fff; }
[data-xxt-theme="dark"] #xxt-batch-progress-card { background:rgba(30,30,46,.96); border-color:rgba(167,139,250,.45); }
[data-xxt-theme="dark"] #xxt-batch-progress-card .xxt-batch-card-close { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-batch-progress-card button[data-batch-card-stop] { background:#181825; color:#f87171; border-color:#f87171; }
/* ===== 题库区弹窗 ===== */
#xxt-bank-modal {
position: fixed; inset: 0; z-index: 100003;
background: rgba(15,23,42,0.42); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .28s ease;
}
#xxt-bank-modal.open { opacity: 1; pointer-events: auto; }
#xxt-bank-modal .xxt-bank-box {
background:#fff; border:1px solid var(--xxt-border); border-radius:22px;
width:min(980px,94vw); max-height:90vh; padding:22px; box-shadow:var(--xxt-shadow);
color:var(--xxt-text); transform:translateY(16px) scale(.97);
transition:transform .3s cubic-bezier(.16,1,.3,1); display:flex; flex-direction:column;
}
#xxt-bank-modal.open .xxt-bank-box { transform:translateY(0) scale(1); }
#xxt-bank-modal .xxt-modal-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:14px; margin-bottom:12px; border-bottom:1px solid var(--xxt-border); }
#xxt-bank-modal .xxt-modal-header h3 { margin:0; font-size:16px; font-weight:800; color:var(--xxt-text); }
#xxt-bank-modal .xxt-modal-close { width:28px; height:28px; border:0; border-radius:50%; background:rgba(15,23,42,.05); color:#9ca3af; cursor:pointer; font-size:15px; }
#xxt-bank-modal .xxt-modal-close:hover { background:#ef4444; color:#fff; }
#xxt-bank-modal .xxt-bank-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:12px; }
#xxt-bank-modal select, #xxt-bank-modal input[type="text"], #xxt-bank-modal input[type="number"] { box-sizing:border-box; border:1px solid var(--xxt-border); border-radius:9px; padding:8px 10px; background:#fff; color:var(--xxt-text); font:12px/1.4 inherit; }
#xxt-bank-modal select { flex:1 1 220px; min-width:180px; }
#xxt-bank-modal .xxt-bank-btn { border:1px solid var(--xxt-accent); background:#fff; color:var(--xxt-accent-strong); border-radius:9px; padding:8px 11px; cursor:pointer; font-size:12px; font-weight:700; }
#xxt-bank-modal .xxt-bank-btn:hover { background:var(--xxt-grad); color:#fff; border-color:transparent; }
#xxt-bank-modal .xxt-bank-btn.xxt-danger { border-color:#ef4444; color:#dc2626; }
#xxt-bank-modal .xxt-bank-btn.xxt-danger:hover { background:#ef4444; color:#fff; }
#xxt-bank-modal .xxt-bank-info { padding:10px 12px; border-radius:11px; background:var(--xxt-grad-soft); color:var(--xxt-text-soft); font-size:12px; margin-bottom:10px; }
#xxt-bank-modal .xxt-bank-search { display:flex; gap:8px; margin-bottom:10px; }
#xxt-bank-modal .xxt-bank-search input { flex:1; }
#xxt-bank-modal .xxt-bank-list { overflow:auto; min-height:120px; max-height:52vh; padding:2px 4px 8px 0; }
#xxt-bank-modal .xxt-bank-type-group { margin:0 0 15px; }
#xxt-bank-modal .xxt-bank-type-head { display:flex; align-items:baseline; justify-content:space-between; gap:10px; padding:7px 3px 8px; margin-bottom:8px; border-bottom:1px solid var(--xxt-border); }
#xxt-bank-modal .xxt-bank-type-title { color:var(--xxt-accent-strong); font-size:14px; font-weight:800; }
#xxt-bank-modal .xxt-bank-type-count { color:var(--xxt-text-soft); font-size:11px; white-space:nowrap; }
#xxt-bank-modal .xxt-bank-card { border:1px solid var(--xxt-border); border-radius:13px; padding:11px 12px; margin-bottom:8px; background:var(--xxt-surface-solid); }
#xxt-bank-modal .xxt-bank-card-head { display:flex; justify-content:space-between; gap:8px; align-items:center; margin-bottom:8px; }
#xxt-bank-modal .xxt-bank-card-number { display:inline-flex; align-items:center; min-height:22px; padding:0 8px; border-radius:999px; background:var(--xxt-grad-soft); color:var(--xxt-accent-strong); font-size:11px; font-weight:800; }
#xxt-bank-modal .xxt-bank-card-meta { color:var(--xxt-text-soft); font-size:11px; }
#xxt-bank-modal .xxt-bank-question-stem { color:var(--xxt-text); font-size:13px; font-weight:700; line-height:1.65; white-space:normal; overflow-wrap:anywhere; }
#xxt-bank-modal .xxt-bank-options { display:grid; gap:5px; margin-top:9px; padding:8px 9px; border-radius:9px; background:rgba(91,58,216,.055); }
#xxt-bank-modal .xxt-bank-options-label { color:var(--xxt-text-soft); font-size:10px; font-weight:800; letter-spacing:.04em; }
#xxt-bank-modal .xxt-bank-option { display:grid; grid-template-columns:26px minmax(0,1fr); gap:5px; color:var(--xxt-text); font-size:12px; line-height:1.6; overflow-wrap:anywhere; }
#xxt-bank-modal .xxt-bank-option-letter { color:var(--xxt-accent-strong); font-weight:800; }
#xxt-bank-modal .xxt-bank-option-text { min-width:0; }
#xxt-bank-modal .xxt-bank-no-options { margin-top:9px; color:var(--xxt-text-soft); font-size:11px; }
#xxt-bank-modal .xxt-bank-answer { display:grid; grid-template-columns:38px minmax(0,1fr); gap:7px; align-items:start; margin-top:9px; padding:8px 9px; border:1px solid rgba(16,185,129,.28); border-radius:9px; background:rgba(16,185,129,.08); color:#047857; font-size:12px; line-height:1.6; overflow-wrap:anywhere; }
#xxt-bank-modal .xxt-bank-answer-label { font-weight:800; }
#xxt-bank-modal .xxt-bank-answer-value { min-width:0; font-weight:700; }
#xxt-bank-modal .xxt-bank-muted { color:var(--xxt-text-soft); font-weight:500; }
#xxt-bank-modal .xxt-bank-card-actions { display:flex; gap:5px; flex-wrap:wrap; margin-top:8px; }
#xxt-bank-modal .xxt-bank-card-actions button { border:1px solid var(--xxt-border); border-radius:7px; padding:5px 8px; background:transparent; color:var(--xxt-text-soft); cursor:pointer; font-size:11px; }
#xxt-bank-modal .xxt-bank-card-actions button:hover { border-color:var(--xxt-accent); color:var(--xxt-accent-strong); }
#xxt-bank-modal .xxt-bank-empty { text-align:center; color:#9ca3af; padding:34px 0; font-size:12px; }
#xxt-bank-modal .xxt-bank-pagination { display:flex; align-items:center; justify-content:center; gap:9px; padding:10px 0 4px; color:var(--xxt-text-soft); font-size:11px; }
#xxt-bank-modal .xxt-bank-pagination button { min-width:54px; border:1px solid var(--xxt-border); border-radius:7px; padding:5px 8px; background:transparent; color:var(--xxt-text-soft); cursor:pointer; font:inherit; }
#xxt-bank-modal .xxt-bank-pagination button:hover:not(:disabled) { border-color:var(--xxt-accent); color:var(--xxt-accent-strong); }
#xxt-bank-modal .xxt-bank-pagination button:disabled { opacity:.42; cursor:not-allowed; }
#xxt-bank-modal .xxt-bank-compare { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding-top:10px; border-top:1px solid var(--xxt-border); }
#xxt-bank-modal .xxt-bank-compare-status { width:100%; color:var(--xxt-text-soft); font-size:11px; white-space:pre-wrap; }
/* ===== 存入题库引导弹窗 ===== */
#xxt-save-bank-modal {
position:fixed; inset:0; z-index:100005;
background:rgba(15,23,42,.42); backdrop-filter:blur(4px);
display:flex; align-items:center; justify-content:center;
opacity:0; pointer-events:none; transition:opacity .28s ease;
}
#xxt-save-bank-modal.open { opacity:1; pointer-events:auto; }
#xxt-save-bank-modal .xxt-save-bank-box {
width:min(440px,94vw); padding:22px; box-sizing:border-box;
background:#fff; border:1px solid var(--xxt-border); border-radius:22px;
box-shadow:var(--xxt-shadow); color:var(--xxt-text);
transform:translateY(16px) scale(.97); transition:transform .3s cubic-bezier(.16,1,.3,1);
}
#xxt-save-bank-modal.open .xxt-save-bank-box { transform:translateY(0) scale(1); }
#xxt-save-bank-modal .xxt-modal-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:14px; margin-bottom:12px; border-bottom:1px solid var(--xxt-border); }
#xxt-save-bank-modal .xxt-modal-header h3 { margin:0; font-size:16px; font-weight:800; color:var(--xxt-text); }
#xxt-save-bank-modal .xxt-modal-close { width:28px; height:28px; border:0; border-radius:50%; background:rgba(15,23,42,.05); color:#9ca3af; cursor:pointer; font-size:15px; }
#xxt-save-bank-modal .xxt-modal-close:hover { background:#ef4444; color:#fff; }
#xxt-save-bank-modal .xxt-save-bank-summary { margin:0 0 14px; padding:10px 12px; border-radius:11px; background:var(--xxt-grad-soft); color:var(--xxt-text-soft); font-size:12px; line-height:1.6; }
#xxt-save-bank-modal .xxt-save-bank-mode { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:13px; }
#xxt-save-bank-modal .xxt-save-bank-mode button { border:1px solid var(--xxt-border); border-radius:10px; padding:9px 8px; background:var(--xxt-surface-solid); color:var(--xxt-text-soft); cursor:pointer; font:700 12px/1.35 inherit; transition:all .2s ease; }
#xxt-save-bank-modal .xxt-save-bank-mode button:hover { border-color:var(--xxt-accent); color:var(--xxt-accent-strong); }
#xxt-save-bank-modal .xxt-save-bank-mode button.active { border-color:transparent; background:var(--xxt-grad); color:#fff; box-shadow:0 6px 14px -7px rgba(99,102,241,.7); }
#xxt-save-bank-modal .xxt-save-bank-field { margin-bottom:12px; }
#xxt-save-bank-modal .xxt-save-bank-field label { display:block; margin-bottom:6px; color:var(--xxt-text-soft); font-size:12px; font-weight:700; }
#xxt-save-bank-modal select, #xxt-save-bank-modal input { box-sizing:border-box; width:100%; border:1px solid var(--xxt-border); border-radius:9px; padding:9px 10px; background:#fff; color:var(--xxt-text); font:13px/1.4 inherit; outline:0; }
#xxt-save-bank-modal select:focus, #xxt-save-bank-modal input:focus { border-color:var(--xxt-accent); box-shadow:0 0 0 3px rgba(124,92,240,.14); }
#xxt-save-bank-modal .xxt-save-bank-hint { min-height:18px; margin:0 0 14px; color:var(--xxt-text-soft); font-size:11px; line-height:1.55; }
#xxt-save-bank-modal .xxt-save-bank-actions { display:flex; justify-content:flex-end; gap:10px; padding-top:14px; border-top:1px solid var(--xxt-border); }
#xxt-save-bank-modal .xxt-save-bank-actions button { border-radius:10px; padding:9px 18px; cursor:pointer; font-weight:700; }
#xxt-save-bank-modal .xxt-save-bank-cancel { border:1px solid var(--xxt-border); background:transparent; color:var(--xxt-text-soft); }
#xxt-save-bank-modal .xxt-save-bank-confirm { border:0; background:var(--xxt-grad); color:#fff; }
#xxt-save-bank-modal .xxt-save-bank-confirm:disabled { cursor:not-allowed; opacity:.48; }
/* ===== 导出预览弹窗 ===== */
#xxt-preview-modal {
position: fixed; inset: 0; z-index: 100005;
background: rgba(15,23,42,0.42); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .28s ease;
}
#xxt-preview-modal.open { opacity: 1; pointer-events: auto; }
#xxt-preview-modal .xxt-preview-box {
background: #fff; border: 1px solid var(--xxt-border); border-radius: 22px;
width: min(920px, 94vw); max-height: 90vh; padding: 22px;
box-shadow: var(--xxt-shadow); display:flex; flex-direction:column;
color:var(--xxt-text); transform:translateY(16px) scale(.97);
transition:transform .3s cubic-bezier(.16,1,.3,1);
}
#xxt-preview-modal.open .xxt-preview-box { transform:translateY(0) scale(1); }
#xxt-preview-modal .xxt-modal-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:14px; margin-bottom:10px; border-bottom:1px solid var(--xxt-border); flex-shrink:0; }
#xxt-preview-modal .xxt-modal-header h3 { margin:0; font-size:16px; font-weight:800; color:var(--xxt-text); }
#xxt-preview-modal .xxt-modal-close { width:28px; height:28px; border:0; border-radius:50%; background:rgba(15,23,42,.05); color:#9ca3af; cursor:pointer; font-size:15px; }
#xxt-preview-modal .xxt-modal-close:hover { background:#ef4444; color:#fff; }
#xxt-preview-modal .xxt-preview-meta { color:var(--xxt-text-soft); font-size:12px; line-height:1.55; margin:0 0 10px; }
#xxt-preview-modal .xxt-preview-content { overflow:auto; min-height:180px; max-height:65vh; padding:4px 6px 10px 2px; }
#xxt-preview-modal .xxt-preview-content::-webkit-scrollbar { width:6px; }
#xxt-preview-modal .xxt-preview-content::-webkit-scrollbar-thumb { background:linear-gradient(180deg,#c4b5fd,#f0abfc); border-radius:6px; }
#xxt-preview-modal .xxt-preview-document { background:#fff; color:#111827; border:1px solid #e5e7eb; border-radius:12px; padding:24px 28px; font:14px/1.75 "Microsoft YaHei","PingFang SC",Arial,sans-serif; }
#xxt-preview-modal .xxt-preview-document h1 { margin:0 0 22px; text-align:center; font-size:22px; line-height:1.4; }
#xxt-preview-modal .xxt-preview-document h2 { margin:20px 0 10px; padding-bottom:4px; border-bottom:1px solid #d1d5db; font-size:16px; }
#xxt-preview-modal .xxt-preview-question { margin:0 0 16px; padding-bottom:12px; border-bottom:1px dashed #e5e7eb; break-inside:avoid; }
#xxt-preview-modal .xxt-preview-stem { font-size:14px; }
#xxt-preview-modal .xxt-preview-option { padding-left:24px; }
#xxt-preview-modal .xxt-preview-answer { margin-top:5px; padding:6px 9px; color:#047857; background:#ecfdf5; border-radius:7px; font-size:12px; }
#xxt-preview-modal .xxt-preview-wrong { margin-left:7px; padding:2px 6px; color:#b91c1c; background:#fee2e2; border-radius:5px; font-size:11px; font-weight:700; }
#xxt-preview-modal .xxt-preview-wrong-block { margin-top:18px; padding-top:12px; border-top:1px solid #9ca3af; }
#xxt-preview-modal .xxt-preview-wrong-item { margin:0 0 10px; padding:8px 10px; background:#fff7ed; border-left:3px solid #f97316; border-radius:6px; font-size:12px; }
#xxt-preview-modal .xxt-preview-code { margin:0; white-space:pre-wrap; word-break:break-word; background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px; padding:16px; color:#1f2937; font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace; }
#xxt-preview-modal .xxt-preview-document img { display:block; max-width:100%; max-height:260px; height:auto; margin:6px 0; object-fit:contain; }
#xxt-preview-modal .xxt-preview-pager { display:flex; align-items:center; justify-content:center; gap:10px; padding-top:10px; color:var(--xxt-text-soft); font-size:11px; flex-shrink:0; }
#xxt-preview-modal .xxt-preview-pager button, #xxt-preview-modal .xxt-preview-download { border:1px solid var(--xxt-accent); border-radius:9px; padding:7px 12px; background:#fff; color:var(--xxt-accent-strong); cursor:pointer; font:700 12px/1.3 inherit; }
#xxt-preview-modal .xxt-preview-pager button:hover, #xxt-preview-modal .xxt-preview-download:hover { background:var(--xxt-grad); color:#fff; border-color:transparent; }
#xxt-preview-modal .xxt-preview-pager button:disabled { opacity:.45; cursor:not-allowed; }
#xxt-preview-modal .xxt-preview-actions { display:flex; justify-content:flex-end; gap:10px; padding-top:14px; margin-top:4px; border-top:1px solid var(--xxt-border); flex-shrink:0; }
#xxt-preview-modal .xxt-preview-cancel { border:1px solid var(--xxt-border); border-radius:10px; padding:9px 18px; background:transparent; color:var(--xxt-text-soft); cursor:pointer; font-weight:700; }
#xxt-preview-modal .xxt-preview-download { border:0; background:var(--xxt-grad); color:#fff; padding:9px 18px; }
/* ===== 在线查看/编辑弹窗 ===== */
#xxt-editor-modal {
position: fixed; inset: 0; z-index: 100004;
background: rgba(15,23,42,0.42); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity 0.28s ease;
}
#xxt-editor-modal.open { opacity: 1; pointer-events: auto; }
#xxt-editor-modal .xxt-editor-box {
background: #fff; border: 1px solid var(--xxt-border); border-radius: 22px;
width: min(900px, 94vw); max-height: 90vh; padding: 22px;
box-shadow: var(--xxt-shadow); display: flex; flex-direction: column;
color: var(--xxt-text); transform: translateY(16px) scale(.97);
transition: transform .3s cubic-bezier(.16,1,.3,1);
}
#xxt-editor-modal.open .xxt-editor-box { transform: translateY(0) scale(1); }
#xxt-editor-modal .xxt-modal-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:14px; margin-bottom:12px; border-bottom:1px solid var(--xxt-border); flex-shrink:0; }
#xxt-editor-modal .xxt-modal-header h3 { margin:0; font-size:16px; font-weight:800; color:var(--xxt-text); }
#xxt-editor-modal .xxt-modal-close { width:28px; height:28px; border:0; border-radius:50%; background:rgba(15,23,42,.05); color:#9ca3af; cursor:pointer; font-size:15px; }
#xxt-editor-modal .xxt-modal-close:hover { background:#ef4444; color:#fff; }
#xxt-editor-modal .xxt-editor-note { color:#6b7280; font-size:12px; margin:0 0 12px; }
#xxt-editor-modal .xxt-editor-selection-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:0 0 12px; padding:10px 12px; border:1px solid var(--xxt-border); border-radius:12px; background:var(--xxt-grad-soft); }
#xxt-editor-modal .xxt-editor-selection-toolbar button { border:1px solid var(--xxt-accent); border-radius:8px; padding:6px 9px; background:#fff; color:var(--xxt-accent-strong); cursor:pointer; font:700 11px/1.3 inherit; }
#xxt-editor-modal .xxt-editor-selection-toolbar button:hover { background:var(--xxt-grad); color:#fff; border-color:transparent; }
#xxt-editor-modal .xxt-editor-selection-count { margin-left:auto; color:var(--xxt-text-soft); font-size:11px; font-weight:700; }
#xxt-editor-modal .xxt-editor-list { overflow-y:auto; padding:2px 5px 8px 0; }
#xxt-editor-modal .xxt-editor-list::-webkit-scrollbar { width:6px; }
#xxt-editor-modal .xxt-editor-list::-webkit-scrollbar-thumb { background:linear-gradient(180deg,#c4b5fd,#f0abfc); border-radius:6px; }
#xxt-editor-modal .xxt-editor-type { margin:14px 0 8px; color:var(--xxt-accent-strong); font-size:14px; font-weight:800; }
#xxt-editor-modal .xxt-editor-card { background:var(--xxt-surface-solid); border:1px solid var(--xxt-border); border-radius:15px; padding:14px; margin-bottom:12px; }
#xxt-editor-modal .xxt-editor-card.xxt-editor-unselected { opacity:.58; }
#xxt-editor-modal .xxt-editor-select { width:17px; height:17px; accent-color:var(--xxt-accent); cursor:pointer; flex:0 0 auto; }
#xxt-editor-modal .xxt-editor-label { display:block; color:var(--xxt-text-soft); font-size:12px; font-weight:700; margin:0 0 5px; }
#xxt-editor-modal textarea, #xxt-editor-modal input[type="text"] { box-sizing:border-box; width:100%; border:1px solid var(--xxt-border); border-radius:9px; padding:8px 10px; background:#fff; color:var(--xxt-text); font:13px/1.55 inherit; outline:0; resize:vertical; }
#xxt-editor-modal textarea:focus, #xxt-editor-modal input[type="text"]:focus { border-color:var(--xxt-accent); box-shadow:0 0 0 3px rgba(124,92,240,.14); }
#xxt-editor-modal .xxt-editor-stem { min-height:58px; }
#xxt-editor-modal .xxt-editor-answer { min-height:42px; }
#xxt-editor-modal .xxt-editor-option { display:grid; grid-template-columns:32px 1fr 30px; gap:7px; align-items:start; margin-top:7px; }
#xxt-editor-modal .xxt-editor-letter { padding:8px 0; font-weight:800; color:var(--xxt-accent); text-align:center; }
#xxt-editor-modal .xxt-editor-remove { border:0; background:transparent; color:#9ca3af; cursor:pointer; font-size:16px; padding:7px 0; }
#xxt-editor-modal .xxt-editor-remove:hover { color:#ef4444; }
#xxt-editor-modal .xxt-editor-add { margin-top:8px; border:1px dashed var(--xxt-accent); background:transparent; color:var(--xxt-accent-strong); border-radius:8px; padding:5px 10px; cursor:pointer; font-size:12px; }
#xxt-editor-modal .xxt-editor-actions { display:flex; justify-content:flex-end; gap:10px; padding-top:14px; margin-top:4px; border-top:1px solid var(--xxt-border); flex-shrink:0; }
#xxt-editor-modal .xxt-editor-actions button { border-radius:10px; padding:9px 20px; cursor:pointer; font-weight:700; }
#xxt-editor-modal .xxt-editor-save { border:0; background:var(--xxt-grad); color:#fff; }
#xxt-editor-modal .xxt-editor-cancel { border:1px solid var(--xxt-border); background:transparent; color:var(--xxt-text-soft); }
/* ===== 响应式微调 ===== */
@media screen and (max-height: 700px) {
#xxt-panel { top: 40px; max-height: 92vh; }
#xxt-panel .xxt-stat { gap: 4px; }
#xxt-panel .xxt-stat-item { padding: 8px 2px 5px; }
#xxt-panel .xxt-stat-item .xxt-num { font-size: 19px; }
}
/* ===== 主面板紧凑布局 =====
* 提取完成后只保留最常用的结果、操作和批量进度;格式、答案和乱序
* 收进可展开的“导出设置”,避免在课程页上遮住过多内容。
*/
#xxt-panel {
width:min(360px, calc(100vw - 16px)); padding:14px 14px 13px;
border-radius:18px; max-height:calc(100vh - 86px);
}
#xxt-panel .xxt-header { margin-bottom:10px; padding-bottom:9px; }
#xxt-panel .xxt-header h3 { font-size:14px; letter-spacing:0; }
#xxt-panel .xxt-feedback-link { height:24px; padding:0 7px; font-size:10px; }
#xxt-panel .xxt-settings-btn { width:25px; height:25px; margin-right:0; }
#xxt-panel .xxt-settings-btn svg { width:14px; height:14px; }
#xxt-panel .xxt-bank-entry { min-height:48px; margin:0 0 8px; padding:7px 9px; gap:8px; border-radius:12px; }
#xxt-panel .xxt-bank-entry-icon { width:31px; height:31px; flex-basis:31px; border-radius:9px; font-size:17px; }
#xxt-panel .xxt-bank-entry-copy strong { font-size:12.5px; }
#xxt-panel .xxt-bank-entry-copy small { font-size:10px; }
#xxt-panel .xxt-bank-entry-arrow { font-size:18px; }
#xxt-panel .xxt-tiku-promo { margin:0 0 8px; padding:7px 8px; border-radius:12px; }
#xxt-panel .xxt-tiku-promo-top { gap:7px; }
#xxt-panel .xxt-tiku-promo-icon { width:28px; height:28px; flex-basis:28px; border-radius:9px; font-size:15px; }
#xxt-panel .xxt-tiku-promo-copy strong { font-size:11.5px; }
#xxt-panel .xxt-tiku-promo-copy small { font-size:9.5px; }
#xxt-panel .xxt-tiku-promo-actions { gap:5px; margin-top:6px; }
#xxt-panel .xxt-tiku-promo-link { min-height:24px; padding:0 6px; border-radius:8px; font-size:9.5px; }
#xxt-panel .xxt-primary-actions { display:grid; grid-template-columns:1fr 1fr; gap:7px; margin-bottom:8px; }
#xxt-panel .xxt-primary-actions .xxt-btn-extract,
#xxt-panel .xxt-primary-actions .xxt-btn-batch { min-height:39px; margin:0; padding:8px 7px; border-radius:11px; font-size:12px; line-height:1.25; letter-spacing:0; }
#xxt-panel .xxt-primary-actions .xxt-btn-batch { border-style:solid; font-weight:800; }
#xxt-panel .xxt-batch-live { margin:0 0 8px; padding:9px 10px; border-radius:12px; }
#xxt-panel .xxt-batch-live-title { font-size:11.5px; }
#xxt-panel .xxt-batch-live-state { padding:2px 6px; font-size:10px; }
#xxt-panel .xxt-batch-live-current { margin-top:5px; font-size:11.5px; }
#xxt-panel .xxt-batch-live-detail { margin-top:2px; font-size:10px; }
#xxt-panel .xxt-batch-live-bar { height:5px; margin:7px 0 8px; }
#xxt-panel .xxt-batch-live-actions { gap:6px; }
#xxt-panel .xxt-batch-live-actions button { padding:6px 7px; font-size:10.5px; }
#xxt-panel .xxt-batch-live-log { margin-top:7px; padding-top:6px; }
#xxt-panel .xxt-batch-live-log-list { max-height:96px; }
#xxt-panel .xxt-status { margin-bottom:8px; padding:7px 9px; font-size:11px; }
#xxt-panel .xxt-stat { gap:6px; margin:8px 0 9px; }
#xxt-panel .xxt-stat-item { padding:7px 3px 6px; border-radius:10px; }
#xxt-panel .xxt-stat-item .xxt-num { font-size:18px; }
#xxt-panel .xxt-stat-item .xxt-label { margin-top:1px; font-size:10px; }
#xxt-panel .xxt-result-actions { margin-bottom:8px !important; }
#xxt-panel .xxt-result-actions .xxt-btn { padding:8px 6px !important; border-radius:10px !important; font-size:11px !important; }
#xxt-panel .xxt-selection-summary { margin:0 0 8px; padding:6px 8px; border-radius:9px; font-size:10.5px; }
#xxt-panel .xxt-export-settings { margin-top:0; border:1px solid var(--xxt-border); border-radius:11px; background:var(--xxt-surface-solid); overflow:hidden; }
#xxt-panel .xxt-export-settings summary { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:9px 10px; cursor:pointer; color:var(--xxt-accent-strong); font-size:11px; font-weight:800; list-style:none; user-select:none; }
#xxt-panel .xxt-export-settings summary::-webkit-details-marker { display:none; }
#xxt-panel .xxt-export-settings summary::before { content:'›'; margin-right:5px; font-size:15px; line-height:0; transition:transform .18s ease; }
#xxt-panel .xxt-export-settings[open] summary::before { transform:rotate(90deg); }
#xxt-panel .xxt-export-settings summary small { margin-left:auto; color:var(--xxt-text-soft); font-size:10px; font-weight:600; }
#xxt-panel .xxt-export-settings-body { padding:0 9px 9px; border-top:1px solid var(--xxt-border); }
#xxt-panel .xxt-export-settings .xxt-section { margin-top:8px; padding:8px 9px; border-radius:10px; box-shadow:none; }
#xxt-panel .xxt-export-settings .xxt-filename { padding:8px 9px; font-size:11px; }
#xxt-panel .xxt-export-settings .xxt-format-row { gap:5px; margin-top:7px; font-size:10.5px; }
#xxt-panel .xxt-export-settings .xxt-format-row label { gap:3px; padding:4px 7px; border-radius:12px; font-size:10px; }
#xxt-panel .xxt-export-settings .xxt-format-row input[type="radio"] { width:12px; height:12px; }
#xxt-panel .xxt-export-settings .xxt-toggle { gap:7px; margin-top:6px; padding:7px 8px; border-radius:9px; }
#xxt-panel .xxt-export-settings .xxt-toggle .xxt-checkbox-wrap { width:16px; height:16px; border-radius:5px; }
#xxt-panel .xxt-export-settings .xxt-toggle span { font-size:11px; line-height:1.35; }
#xxt-panel .xxt-actions { grid-template-columns:repeat(3, 1fr); gap:6px; margin-top:8px; }
#xxt-panel .xxt-actions .xxt-btn { padding:8px 4px; border-radius:10px; font-size:10.5px; }
#xxt-panel .xxt-wrong-hint { margin-top:6px; padding:7px 8px; font-size:10.5px; }
`;
// document-start 时 document.head 在少数页面还未创建,优先挂到根节点,
// 避免样式注入异常导致整段脚本中断。
function appendUserscriptStyle(styleElement) {
const target = document.head || document.documentElement;
if (target) target.appendChild(styleElement);
else document.addEventListener('DOMContentLoaded', () => {
(document.head || document.documentElement).appendChild(styleElement);
}, { once: true });
}
const style = document.createElement('style');
style.textContent = css;
appendUserscriptStyle(style);
// ==================== 暗色模式 CSS ====================
const darkCSS = `
[data-xxt-theme="dark"] #xxt-panel {
--xxt-surface: #1e1e2e;
--xxt-surface-solid: #181825;
--xxt-border: #313244;
--xxt-shadow: 0 20px 48px -12px rgba(0,0,0,0.55), 0 8px 18px -8px rgba(0,0,0,0.4);
--xxt-text: #f5f7fb;
--xxt-text-soft: #b8c0d8;
color: var(--xxt-text);
background: #1e1e2e;
}
[data-xxt-theme="dark"] #xxt-panel::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #7c3aed, #db2777);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-header {
border-bottom-color: rgba(205,214,244,0.10);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-header h3 {
-webkit-text-fill-color: #cdd6f4; color: #cdd6f4;
background: none;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-bank-entry {
background: linear-gradient(135deg, rgba(124,58,237,0.18), rgba(219,39,119,0.13));
border-color: rgba(196,181,253,0.26); color: #f5f7fb;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-bank-entry:hover { border-color: rgba(196,181,253,0.58); }
[data-xxt-theme="dark"] #xxt-panel .xxt-bank-entry-icon { background: #181825; }
[data-xxt-theme="dark"] #xxt-panel .xxt-bank-entry-copy strong { color: #e9d5ff; }
[data-xxt-theme="dark"] #xxt-panel .xxt-bank-entry-copy small { color: #a6adc8; }
[data-xxt-theme="dark"] #xxt-panel .xxt-bank-entry-arrow { color: #c4b5fd; }
[data-xxt-theme="dark"] #xxt-panel .xxt-tiku-promo { background:linear-gradient(135deg, rgba(14,116,144,.22), rgba(5,150,105,.16)); border-color:rgba(125,211,252,.30); }
[data-xxt-theme="dark"] #xxt-panel .xxt-tiku-promo-icon { background:#181825; color:#7dd3fc; }
[data-xxt-theme="dark"] #xxt-panel .xxt-tiku-promo-copy strong { color:#bae6fd; }
[data-xxt-theme="dark"] #xxt-panel .xxt-tiku-promo-copy small { color:#a6c6d8; }
[data-xxt-theme="dark"] #xxt-panel .xxt-tiku-promo-link { color:#bae6fd; background:rgba(24,24,37,.74); border-color:rgba(125,211,252,.48); }
[data-xxt-theme="dark"] #xxt-panel .xxt-tiku-promo-link:hover { background:#0284c7; border-color:#0284c7; color:#fff; }
[data-xxt-theme="dark"] #xxt-panel .xxt-btn-batch { background:rgba(124,58,237,.10); border-color:rgba(196,181,253,.54); color:#ddd6fe; }
[data-xxt-theme="dark"] #xxt-panel .xxt-batch-live { border-color:rgba(196,181,253,.40); background:rgba(124,58,237,.12); }
[data-xxt-theme="dark"] #xxt-panel .xxt-batch-live-title { color:#f5f3ff; }
[data-xxt-theme="dark"] #xxt-panel .xxt-batch-live-current { color:#ddd6fe; }
[data-xxt-theme="dark"] #xxt-panel .xxt-batch-live-detail { color:#bac6dd; }
[data-xxt-theme="dark"] #xxt-panel .xxt-batch-live-actions button { background:#181825; color:#ddd6fe; border-color:#a78bfa; }
[data-xxt-theme="dark"] #xxt-batch-modal .xxt-batch-box { background:#1e1e2e; --xxt-surface-solid:#181825; --xxt-border:#313244; --xxt-text:#f5f7fb; --xxt-text-soft:#b8c0d8; }
[data-xxt-theme="dark"] #xxt-batch-modal .xxt-modal-header { border-bottom-color:rgba(205,214,244,.10); }
[data-xxt-theme="dark"] #xxt-batch-modal .xxt-modal-close { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-batch-modal .xxt-batch-toolbar button, [data-xxt-theme="dark"] #xxt-batch-modal .xxt-batch-actions button { background:#181825; color:#c4b5fd; border-color:#a78bfa; }
[data-xxt-theme="dark"] #xxt-batch-modal .xxt-batch-item { background:#181825; border-color:#313244; }
[data-xxt-theme="dark"] #xxt-batch-modal .xxt-batch-item-title { color:#f5f7fb; }
[data-xxt-theme="dark"] #xxt-panel .xxt-btn-extract {
background: var(--xxt-grad); color: #fff;
box-shadow: 0 10px 22px -8px rgba(168,85,247,0.6);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-btn-extract:hover {
box-shadow: 0 16px 30px -8px rgba(168,85,247,0.8);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-btn-extract:disabled { background: #45475a; }
[data-xxt-theme="dark"] .xxt-status-ok { color: #4ade80; background: rgba(16,185,129,0.14); border-color: rgba(16,185,129,0.3); }
[data-xxt-theme="dark"] .xxt-status-loading { color: #93c5fd; background: rgba(59,130,246,0.14); border-color: rgba(96,165,250,0.3); }
[data-xxt-theme="dark"] .xxt-status-err { color: #f87171; background: rgba(239,68,68,0.14); border-color: rgba(239,68,68,0.3); }
[data-xxt-theme="dark"] .xxt-status-warn { color: #fbbf24; background: rgba(245,158,11,0.14); border-color: rgba(245,158,11,0.3); }
[data-xxt-theme="dark"] #xxt-panel .xxt-stat-item {
background: #181825; border-color: rgba(205,214,244,0.10);
box-shadow: 0 4px 12px -6px rgba(0,0,0,0.4);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-stat-item:hover { border-color: #a78bfa; }
[data-xxt-theme="dark"] #xxt-panel .xxt-stat-item .xxt-num {
-webkit-text-fill-color: #c4b5fd; color: #c4b5fd; background: none;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-stat-item .xxt-label { color: #a6adc8; }
[data-xxt-theme="dark"] #xxt-panel .xxt-section {
background: #181825; border-color: rgba(205,214,244,0.10);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-filename {
border-color: rgba(205,214,244,0.14); color: #cdd6f4; background: #11111b;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-filename:focus {
border-color: #a78bfa;
box-shadow: 0 0 0 4px rgba(167,139,250,0.18);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-format-row { color: #a6adc8; }
[data-xxt-theme="dark"] #xxt-panel .xxt-format-row label {
border-color: rgba(205,214,244,0.14); background: #181825; color: #cdd6f4;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-format-row label:hover { border-color: rgba(167,139,250,0.5); }
[data-xxt-theme="dark"] #xxt-panel .xxt-format-row label:has(input:checked) {
color: #fff; border-color: transparent; background: var(--xxt-grad);
}
[data-xxt-theme="dark"] .xxt-btn-outline {
background: #181825; color: #c4b5fd; border-color: #a78bfa !important;
}
[data-xxt-theme="dark"] .xxt-btn-outline:hover {
background: var(--xxt-grad); color: #fff; border-color: transparent !important;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-toggle {
background: #181825; border-color: rgba(205,214,244,0.10);
}
[data-xxt-theme="dark"] #xxt-panel .xxt-toggle:hover { border-color: rgba(167,139,250,0.5); }
[data-xxt-theme="dark"] #xxt-panel .xxt-toggle .xxt-checkbox-wrap {
border-color: #585b70; background: #11111b;
}
[data-xxt-theme="dark"] #xxt-panel .xxt-toggle span { color: #cdd6f4; }
[data-xxt-theme="dark"] #xxt-panel .xxt-selection-summary { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-panel .xxt-selection-summary.xxt-selection-limited { background:rgba(167,139,250,.16); color:#ddd6fe; }
[data-xxt-theme="dark"] #xxt-panel .xxt-wrong-hint {
color: #f87171; background: rgba(239,68,68,0.14); border-color: rgba(239,68,68,0.3);
}
/* ===== 设置弹窗暗色 ===== */
[data-xxt-theme="dark"] #xxt-panel .xxt-settings-btn { color: #a6adc8; }
[data-xxt-theme="dark"] #xxt-panel .xxt-settings-btn:hover { background: rgba(167,139,250,0.18); color: #c4b5fd; }
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-modal-box {
--xxt-surface-solid: #1e1e2e;
--xxt-border: #313244;
--xxt-shadow: 0 20px 48px -12px rgba(0,0,0,0.6);
background: #1e1e2e;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-modal-header { border-bottom-color: rgba(205,214,244,0.10); }
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-modal-header h3 {
-webkit-text-fill-color: #cdd6f4; color: #cdd6f4; background: none;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-modal-close {
background: rgba(205,214,244,0.08); color: #a6adc8;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-modal-close:hover { background: #ef4444; color: #fff; }
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-setting-row {
background: #181825; border-color: #313244; color: #f5f7fb;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-theme-btn {
background: #313244; color: #a6adc8; border-color: #45475a;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-theme-btn:hover { border-color: #585b70; color: #cdd6f4; }
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-theme-btn.active {
background: var(--xxt-grad); color: #fff; border-color: transparent;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-shortcut-display {
background: rgba(167,139,250,0.12); border-color: rgba(167,139,250,0.25); color: #cdd6f4;
}
[data-xxt-theme="dark"] #xxt-settings-modal .xxt-shortcut-keys {
color: #c4b5fd; background: rgba(167,139,250,0.18);
}
/* ===== 历史记录弹窗暗色 ===== */
[data-xxt-theme="dark"] #xxt-history-modal .xxt-modal-box {
--xxt-surface-solid: #1e1e2e;
--xxt-border: #313244;
--xxt-shadow: 0 20px 48px -12px rgba(0,0,0,0.6);
background: #1e1e2e;
}
[data-xxt-theme="dark"] #xxt-history-modal .xxt-modal-header { border-bottom-color: rgba(205,214,244,0.10); }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-modal-header h3 {
-webkit-text-fill-color: #cdd6f4; color: #cdd6f4; background: none;
}
[data-xxt-theme="dark"] #xxt-history-modal .xxt-modal-close {
background: rgba(205,214,244,0.08); color: #a6adc8;
}
[data-xxt-theme="dark"] #xxt-history-modal .xxt-modal-close:hover { background: #ef4444; color: #fff; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-empty { color: #585b70; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-item {
background: #181825; border-color: rgba(205,214,244,0.10);
}
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-item:hover { border-color: rgba(167,139,250,0.5); }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-title { color: #cdd6f4; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-meta { color: #a6adc8; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-delete { color: #585b70; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-delete:hover { background: rgba(239,68,68,0.18); color: #f87171; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-download { color: #585b70; }
[data-xxt-theme="dark"] #xxt-history-modal .xxt-history-download:hover { background: rgba(167,139,250,0.18); color: #c4b5fd; }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-box { background:#1e1e2e; --xxt-surface-solid:#181825; --xxt-border:#313244; --xxt-text:#f5f7fb; --xxt-text-soft:#b8c0d8; }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-modal-header { border-bottom-color:rgba(205,214,244,.10); }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-modal-close { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-bank-modal select, [data-xxt-theme="dark"] #xxt-bank-modal input { background:#11111b; color:#cdd6f4; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-btn { background:#181825; color:#c4b5fd; border-color:#a78bfa; }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-card { background:#181825; border-color:rgba(205,214,244,.10); }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-type-head { border-bottom-color:rgba(205,214,244,.14); }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-type-title, [data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-card-number, [data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-option-letter { color:#ddd6fe; }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-card-number { background:rgba(124,58,237,.20); }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-options { background:rgba(124,58,237,.12); }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-answer { color:#a7f3d0; border-color:rgba(52,211,153,.32); background:rgba(16,185,129,.13); }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-card-actions button { color:#a6adc8; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-bank-modal .xxt-bank-pagination button { color:#a6adc8; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-save-bank-box { background:#1e1e2e; --xxt-surface-solid:#181825; --xxt-border:#313244; --xxt-text:#f5f7fb; --xxt-text-soft:#b8c0d8; }
[data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-modal-header, [data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-save-bank-actions { border-color:rgba(205,214,244,.10); }
[data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-modal-close { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-save-bank-modal select, [data-xxt-theme="dark"] #xxt-save-bank-modal input { background:#11111b; color:#cdd6f4; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-save-bank-mode button, [data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-save-bank-cancel { background:#181825; color:#cdd6f4; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-save-bank-modal .xxt-save-bank-mode button.active { background:var(--xxt-grad); color:#fff; border-color:transparent; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-box { background:#1e1e2e; --xxt-surface-solid:#181825; --xxt-border:#313244; --xxt-text:#f5f7fb; --xxt-text-soft:#b8c0d8; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-modal-header { border-bottom-color:rgba(205,214,244,.10); }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-modal-close { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-note { color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-selection-toolbar { border-color:#313244; background:rgba(124,58,237,.14); }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-selection-toolbar button { background:#181825; color:#c4b5fd; border-color:#a78bfa; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-type { color:#c4b5fd; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-card { background:#181825; border-color:#313244; }
[data-xxt-theme="dark"] #xxt-editor-modal textarea, [data-xxt-theme="dark"] #xxt-editor-modal input[type="text"] { background:#11111b; color:#cdd6f4; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-editor-modal .xxt-editor-cancel { color:#cdd6f4; border-color:#45475a; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-box { background:#1e1e2e; --xxt-surface-solid:#181825; --xxt-border:#313244; --xxt-text:#f5f7fb; --xxt-text-soft:#b8c0d8; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-modal-header { border-bottom-color:rgba(205,214,244,.10); }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-modal-close { background:rgba(205,214,244,.08); color:#a6adc8; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-document { background:#181825; color:#f5f7fb; border-color:#313244; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-document h2 { border-bottom-color:#45475a; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-question { border-bottom-color:#313244; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-answer { color:#6ee7b7; background:rgba(16,185,129,.14); }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-wrong { color:#fca5a5; background:rgba(239,68,68,.18); }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-wrong-item { background:rgba(249,115,22,.13); border-left-color:#fb923c; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-code { background:#11111b; border-color:#313244; color:#cdd6f4; }
[data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-pager button, [data-xxt-theme="dark"] #xxt-preview-modal .xxt-preview-cancel { background:#181825; color:#cdd6f4; border-color:#45475a; }
`;
const darkStyle = document.createElement('style');
darkStyle.textContent = darkCSS;
appendUserscriptStyle(darkStyle);
// ==================== 设置存储 ====================
const SETTINGS_KEY = 'xxt_settings';
const DEFAULT_SETTINGS = {
theme: 'auto', // 'auto' | 'light' | 'dark'
shortcut: {
ctrl: true, shift: true, alt: false, key: 'e'
},
exportConfig: { // 上一次的导出设置
format: 'word', // 'word' | 'pdf' | 'txt' | 'md'
withAnswers: false,
shuffle: false,
bankImport: false
},
enableDrag: true, // 所有脚本浮窗拖拽,默认开启
rememberPanelPosition: true, // 记住所有窗口位置,默认开启
panelPosition: null, // 记住的面板位置 { left, top }
floatingPositions: {}, // 各浮窗位置 { panel: { left, top }, ... }
bankThreshold: 90 // 题库对比阈值(50-100)
};
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) {
const saved = JSON.parse(raw);
return {
theme: saved.theme || DEFAULT_SETTINGS.theme,
shortcut: { ...DEFAULT_SETTINGS.shortcut, ...(saved.shortcut || {}) },
exportConfig: { ...DEFAULT_SETTINGS.exportConfig, ...(saved.exportConfig || {}) },
// 旧版本只支持主面板拖拽且默认关闭;升级后默认开启全部浮窗拖拽。
enableDrag: saved.allUiDragInitialized ? saved.enableDrag !== false : DEFAULT_SETTINGS.enableDrag,
rememberPanelPosition: saved.rememberPanelPosition !== undefined ? saved.rememberPanelPosition : DEFAULT_SETTINGS.rememberPanelPosition,
panelPosition: saved.panelPosition || null,
floatingPositions: saved.floatingPositions && typeof saved.floatingPositions === 'object' ? saved.floatingPositions : {},
allUiDragInitialized: true,
bankThreshold: Math.max(50, Math.min(100, Number(saved.bankThreshold) || DEFAULT_SETTINGS.bankThreshold))
};
}
} catch (e) { /* ignore */ }
return {
...DEFAULT_SETTINGS,
shortcut: { ...DEFAULT_SETTINGS.shortcut },
exportConfig: { ...DEFAULT_SETTINGS.exportConfig },
enableDrag: DEFAULT_SETTINGS.enableDrag,
rememberPanelPosition: DEFAULT_SETTINGS.rememberPanelPosition,
panelPosition: null,
floatingPositions: {},
allUiDragInitialized: true,
bankThreshold: DEFAULT_SETTINGS.bankThreshold
};
}
function saveSettings(settings) {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch (e) { /* ignore */ }
}
// 保存导出配置(格式、附加答案、打乱、题库导入)
function saveExportConfig(els) {
currentSettings.exportConfig = {
format: getFormat(els),
withAnswers: els.chkAnswers ? els.chkAnswers.checked : false,
shuffle: els.chkShuffle ? els.chkShuffle.checked : false,
bankImport: els.chkBankImport ? els.chkBankImport.checked : false
};
saveSettings(currentSettings);
}
let currentSettings = loadSettings();
// 统一的浮窗拖动器:主面板、右侧进度卡和所有弹窗共用,避免每个组件各写一套
// 鼠标事件。拖到边缘时会保留至少一小段可见区域,位置按设置保存。
const xxtDraggableNodes = new WeakSet();
function getXxtFloatingPosition(key) {
const positions = currentSettings.floatingPositions || {};
if (positions[key] && Number.isFinite(positions[key].left) && Number.isFinite(positions[key].top)) return positions[key];
if (key === 'panel' && currentSettings.panelPosition &&
Number.isFinite(currentSettings.panelPosition.left) && Number.isFinite(currentSettings.panelPosition.top)) {
return currentSettings.panelPosition;
}
return null;
}
function applyXxtFloatingPosition(element, position) {
if (!element || !position) return;
const rect = element.getBoundingClientRect();
// 浏览器窗口尺寸变化后,旧的坐标可能把浮窗完全带出屏幕。
// 恢复位置时先限制到可见范围,保证标题栏始终够得到。
const width = rect.width || 0;
const height = rect.height || 96;
const maxLeft = Math.max(8, window.innerWidth - width - 8);
const maxTop = Math.max(8, window.innerHeight - Math.min(height, Math.max(96, window.innerHeight - 16)) - 8);
element.style.position = 'fixed';
element.style.left = `${Math.max(8, Math.min(maxLeft, Number(position.left) || 8))}px`;
element.style.top = `${Math.max(8, Math.min(maxTop, Number(position.top) || 8))}px`;
element.style.right = 'auto';
element.style.bottom = 'auto';
// 弹窗原本通过 transform 做居中/入场动画;拖动后由 left/top 接管位置。
element.style.transform = 'none';
}
function enableXxtDragging(element, handle, key) {
if (!element || !handle || xxtDraggableNodes.has(element)) return;
xxtDraggableNodes.add(element);
element.classList.add('xxt-draggable');
const savedPosition = getXxtFloatingPosition(key);
if (savedPosition) applyXxtFloatingPosition(element, savedPosition);
let drag = null;
const isInteractive = target => target !== handle && !!(target && target.closest && target.closest('button, a, input, select, textarea, label, [contenteditable="true"], [data-no-drag]'));
const finish = event => {
if (!drag) return;
const moved = drag.moved;
drag = null;
element.classList.remove('xxt-dragging');
element.style.transition = '';
document.removeEventListener('pointermove', move, true);
document.removeEventListener('pointerup', finish, true);
document.removeEventListener('pointercancel', finish, true);
if (!moved || !currentSettings.rememberPanelPosition) return;
const position = { left: parseFloat(element.style.left), top: parseFloat(element.style.top) };
currentSettings.floatingPositions = { ...(currentSettings.floatingPositions || {}), [key]: position };
if (key === 'panel') currentSettings.panelPosition = position;
saveSettings(currentSettings);
// 拖动标题栏后不应触发弹窗关闭等 click 行为。
if (event) {
const cancelClick = clickEvent => { clickEvent.preventDefault(); clickEvent.stopPropagation(); document.removeEventListener('click', cancelClick, true); };
document.addEventListener('click', cancelClick, true);
}
};
const move = event => {
if (!drag || event.pointerId !== drag.pointerId) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
drag.moved = true;
const maxLeft = Math.max(8, window.innerWidth - drag.width - 8);
const maxTop = Math.max(8, window.innerHeight - Math.min(drag.height, 96) - 8);
element.style.left = `${Math.max(8, Math.min(maxLeft, drag.left + dx))}px`;
element.style.top = `${Math.max(8, Math.min(maxTop, drag.top + dy))}px`;
if (handle !== element) event.preventDefault();
};
handle.addEventListener('pointerdown', event => {
// 主面板始终允许拖动;“所有浮窗可拖拽”设置只影响其余弹窗。
if ((!currentSettings.enableDrag && key !== 'panel') || event.button !== 0 || isInteractive(event.target)) return;
const rect = element.getBoundingClientRect();
applyXxtFloatingPosition(element, { left: rect.left, top: rect.top });
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, left: rect.left, top: rect.top, width: rect.width, height: rect.height, moved: false };
element.classList.add('xxt-dragging');
element.style.transition = 'none';
document.addEventListener('pointermove', move, true);
document.addEventListener('pointerup', finish, true);
document.addEventListener('pointercancel', finish, true);
event.preventDefault();
});
}
// ==================== 主题切换 ====================
function applyTheme(theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.setAttribute('data-xxt-theme', 'dark');
} else if (theme === 'light') {
root.removeAttribute('data-xxt-theme');
} else {
// auto: 跟随系统
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.setAttribute('data-xxt-theme', 'dark');
} else {
root.removeAttribute('data-xxt-theme');
}
}
}
applyTheme(currentSettings.theme);
// 监听系统主题变化(仅在 auto 模式下生效)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (currentSettings.theme === 'auto') {
applyTheme('auto');
}
});
// ==================== 历史记录 ====================
const HISTORY_KEY = 'xxt_history';
const MAX_HISTORY = 10;
function loadHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) { return []; }
}
function saveHistory(history) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
} catch (e) { /* ignore */ }
}
function addToHistory(extractedData, fmt, withAnswers, withWrong, shuffle, bankImport, outputText) {
const history = loadHistory();
let totalQ = 0;
for (const qtype of extractedData.typeOrder) {
totalQ += (extractedData.results[qtype] || []).length;
}
const entry = {
id: Date.now(),
title: extractedData.title || '未命名',
date: new Date().toLocaleString('zh-CN'),
format: fmt,
withAnswers: withAnswers,
withWrong: withWrong,
shuffle: shuffle,
bankImport: bankImport || false,
totalQuestions: totalQ,
typeOrder: extractedData.typeOrder,
results: extractedData.results,
wrongCount: extractedData.wrongCount,
hasMyAnswer: extractedData.hasMyAnswer,
hasCorrectAnswer: extractedData.hasCorrectAnswer || false,
outputText: outputText || ''
};
// 去重:相同标题+相同格式的旧记录替换
const filtered = history.filter(h => !(h.title === entry.title && h.format === entry.format));
filtered.unshift(entry);
if (filtered.length > MAX_HISTORY) filtered.length = MAX_HISTORY;
saveHistory(filtered);
return filtered;
}
function deleteHistoryById(id) {
const history = loadHistory().filter(h => h.id !== id);
saveHistory(history);
return history;
}
// ==================== 独立题库区 ====================
// 题库与“下载历史”完全分离。优先使用 ScriptCat/Tampermonkey 的 GM 存储,
// 这样在 chaoxing.com、edu.cn 等不同页面来源之间也能共享;未提供 GM 存储时
// 回退到 localStorage,并在导入/导出中提供可靠的跨域迁移方式。
const QUESTION_BANK_KEY = 'xxt_question_banks_v1';
const QUESTION_BANK_TYPES = ['单选', '多选', '填空', '判断', '简答'];
const QUESTION_BANK_MAX_BYTES = 8 * 1024 * 1024;
let questionBankStateCache = null;
// 其他标签页修改题库后,当前页下次打开题库会重新读取外部存储。
window.addEventListener('storage', event => {
if (event.key === QUESTION_BANK_KEY) questionBankStateCache = null;
});
// 题库 API 是同步链路;如果管理器暴露 Promise 版 GM API,就固定回退到
// localStorage,避免“本次写入落到 localStorage、下次读取却拿到旧 GM 值”。
let gmStorageIsSync = null;
function readQuestionBankValue() {
try {
if (typeof GM_getValue === 'function' && gmStorageIsSync !== false) {
const value = GM_getValue(QUESTION_BANK_KEY, '');
if (!value || typeof value.then !== 'function') {
gmStorageIsSync = true;
if (value) return value;
} else {
gmStorageIsSync = false;
}
}
} catch (e) { gmStorageIsSync = false; }
try { return localStorage.getItem(QUESTION_BANK_KEY) || ''; } catch (e) { return ''; }
}
function writeQuestionBankValue(value) {
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
const serializedBytes = typeof TextEncoder === 'function'
? new TextEncoder().encode(serialized).length
: new Blob([serialized]).size;
if (serializedBytes > QUESTION_BANK_MAX_BYTES) {
throw new Error('题库数据超过 8MB,请先导出备份或拆分题库');
}
let gmWritten = false;
try {
if (typeof GM_setValue === 'function' && gmStorageIsSync !== false) {
const result = GM_setValue(QUESTION_BANK_KEY, serialized);
if (!result || typeof result.then !== 'function') {
gmStorageIsSync = true;
gmWritten = true;
} else {
gmStorageIsSync = false;
}
}
} catch (e) { gmStorageIsSync = false; }
if (!gmWritten) {
try { localStorage.setItem(QUESTION_BANK_KEY, serialized); gmWritten = true; } catch (e) {
throw new Error('题库存储失败,可能已达到浏览器存储上限');
}
}
return true;
}
function makeEmptyQuestionBank(name) {
const now = new Date().toISOString();
return {
id: `bank-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: String(name || '默认题库').trim().substring(0, 80) || '默认题库',
createdAt: now, updatedAt: now,
typeOrder: [],
results: Object.fromEntries(QUESTION_BANK_TYPES.map(type => [type, []]))
};
}
function normalizeBankRichContent(content) {
let source = content;
if (!Array.isArray(source)) {
if (typeof source === 'string' || typeof source === 'number') source = [{ type: 'text', text: String(source) }];
else if (source && typeof source === 'object' && Array.isArray(source.content)) source = source.content;
else if (source && typeof source === 'object' && (source.text || source.value)) source = [{ type: 'text', text: String(source.text || source.value) }];
else return [];
}
return normalizeRichContent(source.map(part => {
if (typeof part === 'string' || typeof part === 'number') return { type: 'text', text: String(part).substring(0, 20000) };
if (!part || typeof part !== 'object') return null;
if (part.type === 'text') return { type: 'text', text: String(part.text || '').substring(0, 20000) };
if (part.type === 'break') return { type: 'break' };
if (part.type === 'image' && part.url) {
return { type: 'image', url: String(part.url).substring(0, 4096), alt: String(part.alt || '').substring(0, 300) };
}
return null;
}).filter(Boolean));
}
function normalizeImportedBankType(value, question) {
const raw = String(value || '').toLowerCase();
const detected = detectTypeFromText(value);
if (detected) return detected;
if (/multiple|multi[_ -]?choice|checkbox/.test(raw)) return '多选';
if (/single|radio|choice/.test(raw)) return '单选';
if (/judge|true[_ -]?false/.test(raw)) return '判断';
if (/fill|blank/.test(raw)) return '填空';
if (/essay|subjective|short[_ -]?answer/.test(raw)) return '简答';
const options = Array.isArray(question && question.options) ? question.options : [];
const answer = String(question && (question.correctAnswer || question.answer || question.rightAnswer) || '');
if (options.length > 1 && (/[,,、;;]/.test(answer) || /^[A-H]{2,}$/i.test(answer.replace(/\s/g, '')))) return '多选';
if (options.length === 2 && /^(正确|错误|对|错|√|×|true|false)$/i.test(answer.trim())) return '判断';
return options.length ? '单选' : '填空';
}
function getImportedBankResultLists(source) {
const grouped = Object.fromEntries(QUESTION_BANK_TYPES.map(type => [type, []]));
const rawResults = source && source.results;
const flatQuestions = Array.isArray(rawResults) ? rawResults
: Array.isArray(source && source.questions) ? source.questions
: Array.isArray(source && source.items) ? source.items : null;
if (flatQuestions) {
flatQuestions.forEach(question => {
const type = normalizeImportedBankType(question && (question.typeName || question.type || question.questionType), question);
grouped[type].push(question);
});
return grouped;
}
if (rawResults && typeof rawResults === 'object') {
Object.entries(rawResults).forEach(([rawType, list]) => {
if (!Array.isArray(list)) return;
list.forEach(question => {
const type = QUESTION_BANK_TYPES.includes(rawType) ? rawType : normalizeImportedBankType(rawType, question);
grouped[type].push(question);
});
});
}
return grouped;
}
function normalizeBankQuestion(question) {
if (!question || typeof question !== 'object') return null;
const rawStem = hasRichContent(question.stemContent) ? question.stemContent
: question.stem ?? question.question ?? question.title ?? '';
const rawStemText = typeof rawStem === 'string' || typeof rawStem === 'number' ? String(rawStem).trim() : '';
const stemContent = normalizeBankRichContent(rawStem);
const stem = formatRichForText(stemContent).trim() || rawStemText;
if (!stem && !hasRichContent(stemContent)) return null;
const rawOptions = Array.isArray(question.options) ? question.options
: Array.isArray(question.option) ? question.option
: Array.isArray(question.choices) ? question.choices : [];
const options = rawOptions.map((option, index) => {
const rawText = typeof option === 'string' || typeof option === 'number' ? String(option)
: String((option && (option.text ?? option.value ?? option.option ?? option.name)) || '');
const prefixed = rawText.match(/^\s*([A-H])\s*(?:[.、.::]|\s+)\s*/i);
const optionValue = prefixed ? rawText.slice(prefixed[0].length) : (option && option.content != null ? option.content : rawText);
const rawOptionText = typeof optionValue === 'string' || typeof optionValue === 'number' ? String(optionValue).trim() : '';
const content = normalizeBankRichContent(optionValue);
const letter = String((option && (option.letter || option.key || option.label)) || (prefixed && prefixed[1]) || String.fromCharCode(65 + index)).trim().toUpperCase().substring(0, 1);
return { letter, text: richTextOnly(content) || rawOptionText, content };
}).filter(option => /^[A-H]$/.test(option.letter) && (option.text || hasRichContent(option.content)));
const rawAnswer = hasRichContent(question.correctAnswerContent) ? question.correctAnswerContent
: question.correctAnswer ?? question.answer ?? question.rightAnswer ?? question.correct ?? '';
const correctAnswerContent = stripAnswerLabel(normalizeBankRichContent(rawAnswer));
const answer = (richTextOnly(correctAnswerContent) || String(rawAnswer || '').trim())
.replace(/^\s*(?:答案|正确答案|answer)\s*[:::]?\s*/i, '').trim();
// 不把某一次作答/错题状态带进可复用题库,避免题库载入后误显示旧错题。
return {
stem, stemContent, options,
correctAnswer: answer, correctAnswerContent,
myAnswer: '', isWrong: false
};
}
function normalizeQuestionBank(bank) {
const source = bank && typeof bank === 'object' ? bank : makeEmptyQuestionBank('默认题库');
const normalized = makeEmptyQuestionBank(source.name || source.exam_id || '默认题库');
normalized.id = String(source.id || normalized.id);
normalized.createdAt = source.createdAt || normalized.createdAt;
normalized.updatedAt = source.updatedAt || normalized.updatedAt;
const rawResults = getImportedBankResultLists(source);
const typeOrder = Array.isArray(source.typeOrder) ? source.typeOrder : [];
const order = [...QUESTION_BANK_TYPES.filter(type => typeOrder.includes(type)), ...QUESTION_BANK_TYPES.filter(type => !typeOrder.includes(type))];
const seen = new Set();
order.forEach(type => {
const list = Array.isArray(rawResults[type]) ? rawResults[type] : [];
normalized.results[type] = list.map(normalizeBankQuestion).filter(Boolean).filter(question => {
const identity = getQuestionIdentity(type, question) || `${type}|${richContentIdentity(question.stemContent)}`;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
if (normalized.results[type].length) normalized.typeOrder.push(type);
});
return normalized;
}
function makeDefaultQuestionBankState() {
const bank = makeEmptyQuestionBank('默认题库');
return { schemaVersion: 1, activeBankId: bank.id, banks: [bank] };
}
function normalizeQuestionBankState(raw) {
let source = raw;
if (typeof source === 'string') {
try { source = JSON.parse(source); } catch (e) { source = null; }
}
if (!source || typeof source !== 'object') return makeDefaultQuestionBankState();
// 单库备份使用 {schemaVersion, bank},完整备份使用 {banks:[...]}。
if (source.bank && typeof source.bank === 'object') source = { schemaVersion: 1, banks: [source.bank], activeBankId: source.bank.id };
// 兼容 OCS { exam_id, questions },以及直接导出的题目数组。
if (!Array.isArray(source.banks) && Array.isArray(source.questions)) {
source = { schemaVersion: 1, banks: [{ name: source.exam_id || '导入题库', questions: source.questions }] };
}
if (Array.isArray(source) && !source.some(item => item && typeof item === 'object' && (item.results || item.questions || item.name))) {
source = { schemaVersion: 1, banks: [{ name: '导入题库', results: source }] };
}
// 兼容参考脚本的 {题库名: [题目]} 结构,导入后统一转换为本脚本格式。
if (!Array.isArray(source.banks) && !Array.isArray(source)) {
const banks = Object.entries(source).filter(([, value]) => Array.isArray(value)).map(([name, results]) => normalizeQuestionBank({ name, results }));
if (banks.length) return { schemaVersion: 1, activeBankId: banks[0].id, banks };
}
const banks = (Array.isArray(source.banks) ? source.banks : Array.isArray(source) ? source : [])
.map(normalizeQuestionBank).filter(Boolean);
if (!banks.length) return makeDefaultQuestionBankState();
const activeBankId = banks.some(bank => bank.id === source.activeBankId) ? source.activeBankId : banks[0].id;
return { schemaVersion: 1, activeBankId, banks };
}
function loadQuestionBankState(force = false) {
if (questionBankStateCache && !force) return questionBankStateCache;
questionBankStateCache = normalizeQuestionBankState(readQuestionBankValue());
// 首次使用立即建立默认题库;如果已有旧数据,规范化后再保存一次。
try { writeQuestionBankValue(JSON.stringify(questionBankStateCache)); } catch (e) { /* UI 操作时再提示 */ }
return questionBankStateCache;
}
function saveQuestionBankState(state) {
const normalized = normalizeQuestionBankState(state);
normalized.banks.forEach(bank => { bank.updatedAt = bank.updatedAt || new Date().toISOString(); });
writeQuestionBankValue(JSON.stringify(normalized));
questionBankStateCache = normalized;
return normalized;
}
function getActiveQuestionBank() {
const state = loadQuestionBankState();
return state.banks.find(bank => bank.id === state.activeBankId) || state.banks[0] || null;
}
function setActiveQuestionBank(id) {
const state = loadQuestionBankState();
if (!state.banks.some(bank => bank.id === id)) return false;
state.activeBankId = id;
saveQuestionBankState(state);
return true;
}
function bankQuestionCount(bank) {
return QUESTION_BANK_TYPES.reduce((sum, type) => sum + ((bank && bank.results && bank.results[type]) || []).length, 0);
}
function bankTotalCount(state = loadQuestionBankState()) {
return state.banks.reduce((sum, bank) => sum + bankQuestionCount(bank), 0);
}
function bankQuestionIdentity(type, question) {
const base = getQuestionIdentity(type, question);
if (base) return base;
return `${type}|${richContentIdentity(questionContent(question))}|${(question.options || []).map(option => richContentIdentity(optionContent(option))).join('|')}`;
}
function buildExtractedDataFromBank(bank) {
const normalized = normalizeQuestionBank(bank);
const results = Object.fromEntries(QUESTION_BANK_TYPES.map(type => [type, (normalized.results[type] || []).map(question => ({ ...question }))]));
const data = {
title: normalized.name, typeOrder: normalized.typeOrder.slice(), results,
text: '', textWithAnswers: '', textWrong: '', textMD: '',
textWithAnswersMD: '', textWrongMD: ''
};
return refreshExtractedOutputCache(data);
}
function mergeExtractedDataIntoBank(bank, data) {
const target = normalizeQuestionBank(bank);
const seen = new Map();
QUESTION_BANK_TYPES.forEach(type => (target.results[type] || []).forEach(question => seen.set(bankQuestionIdentity(type, question), question)));
let added = 0, skipped = 0, answersFilled = 0;
Object.entries((data && data.results) || {}).forEach(([type, questions]) => {
if (!QUESTION_BANK_TYPES.includes(type)) return;
if (!Array.isArray(target.results[type])) target.results[type] = [];
questions.forEach(rawQuestion => {
const question = normalizeBankQuestion(rawQuestion);
if (!question) return;
const identity = bankQuestionIdentity(type, question);
const existing = seen.get(identity);
if (existing) {
skipped++;
if (!hasRichContent(answerContent(existing)) && hasRichContent(answerContent(question))) {
existing.correctAnswerContent = question.correctAnswerContent;
existing.correctAnswer = question.correctAnswer;
answersFilled++;
}
return;
}
target.results[type].push(question);
seen.set(identity, question);
if (!target.typeOrder.includes(type)) target.typeOrder.push(type);
added++;
});
});
target.updatedAt = new Date().toISOString();
return { bank: normalizeQuestionBank(target), added, skipped, answersFilled };
}
function createExtractedDataFromResults(results, typeOrder, title) {
const data = { title: title || '学习通题库', results: {}, typeOrder: [] };
QUESTION_BANK_TYPES.forEach(type => { data.results[type] = Array.isArray(results && results[type]) ? results[type] : []; });
(typeOrder || QUESTION_BANK_TYPES).forEach(type => {
if (data.results[type] && data.results[type].length && !data.typeOrder.includes(type)) data.typeOrder.push(type);
});
return refreshExtractedOutputCache(data);
}
// ==================== 快捷键 ====================
function formatShortcutLabel(sc) {
const parts = [];
if (sc.ctrl) parts.push('Ctrl');
if (sc.shift) parts.push('Shift');
if (sc.alt) parts.push('Alt');
parts.push(sc.key.toUpperCase());
return parts.join(' + ');
}
function isShortcutMatch(e, sc) {
return e.ctrlKey === sc.ctrl &&
e.shiftKey === sc.shift &&
e.altKey === sc.alt &&
e.key.toLowerCase() === sc.key.toLowerCase();
}
document.addEventListener('keydown', (e) => {
// 如果正在录制快捷键,忽略
if (window.__xxt_recording) return;
if (!isShortcutMatch(e, currentSettings.shortcut)) return;
e.preventDefault();
const panel = document.getElementById('xxt-panel');
const btnExtract = document.getElementById('xxt-btnExtract');
if (!panel) return;
// 触发提取
if (btnExtract && !btnExtract.disabled) {
btnExtract.click();
}
});
// ==================== 提取逻辑 ====================
const TYPE_MAP = {
'多选题': '多选', '单选题': '单选', '选择题': '单选',
'填空题': '填空', '判断题': '判断', '简答题': '简答',
'论述题': '简答', '问答题': '简答',
};
function detectTypeFromText(text) {
const value = String(text || '');
for (const [label, type] of Object.entries(TYPE_MAP)) {
if (value.includes(label)) return type;
}
// 随堂练习部分版本使用英文题型名或“正误题”等别名。
if (/单项选择|single.?choice|radio/i.test(value)) return '单选';
if (/多项选择|multiple.?choice|checkbox/i.test(value)) return '多选';
if (/正误题|是非题|true.?false|judg(e|ment)/i.test(value)) return '判断';
if (/填空|fill.?blank/i.test(value)) return '填空';
if (/论述|问答|简答|essay|subjective/i.test(value)) return '简答';
return null;
}
// 兼容随堂练习不同版本的题型属性命名。
function questionAttributeText(el) {
if (!el || !el.getAttribute) return '';
const names = [
'typeName', 'typename', 'data-type-name', 'data-question-type',
'data-type', 'questiontype', 'questionType', 'qtype', 'type'
];
return names.map(name => el.getAttribute(name) || '').filter(Boolean).join(' ');
}
function findQuestionType(question, stemEl) {
if (!question) return null;
const attrType = detectTypeFromText(questionAttributeText(question));
if (attrType) return attrType;
const typeSelectors = [
'.newZy_TItle', '.questionType', '.question_type', '.typeName',
'.type_name', '.qtype', '[data-role="question-type"]', '[data-role="type"]'
];
for (const selector of typeSelectors) {
const typeEl = question.querySelector(selector);
const type = typeEl && detectTypeFromText(typeEl.textContent || '');
if (type) return type;
}
// 有些随堂练习把“【单选题】”直接放在题干前面。
const ownText = question.textContent || '';
const ownType = detectTypeFromText(ownText);
if (ownType) return ownType;
if (stemEl) {
const stemType = detectTypeFromText(stemEl.textContent || '');
if (stemType) return stemType;
}
// 题型标题可能是题目容器的前一个兄弟节点。
let node = question;
for (let depth = 0; node && depth < 4; depth++, node = node.parentElement) {
let previous = node.previousElementSibling;
for (let count = 0; previous && count < 3; count++, previous = previous.previousElementSibling) {
const text = previous.textContent || '';
if (!/(题|选择|判断|填空|问答|论述)/.test(text)) continue;
const type = detectTypeFromText(text);
if (type) return type;
}
}
return null;
}
function findQuestionStemElement(question) {
if (!question) return null;
const selectors = [
'.mark_name', '.qtContent', '.Zy_TItle .qtContent',
'.questionTitle', '.question_title', '.question-title',
'.q_title', '.que_title', '.stem', '.questionStem',
'[data-role="question"]', '[data-role="stem"]'
];
for (const selector of selectors) {
const el = question.querySelector(selector);
if (el && (el.textContent || '').trim() || el && el.querySelector('img')) return el;
}
return null;
}
function extractQuestionNumber(question, stemEl) {
const attrNames = ['data-index', 'data-num', 'data-number', 'questionindex', 'index', 'num'];
for (const name of attrNames) {
const value = question && question.getAttribute && question.getAttribute(name);
const match = String(value || '').match(/\d+/);
if (match) return parseInt(match[0], 10);
}
const selectors = ['.q_num', '.questionNum', '.question_num', '.num', 'i.fl'];
for (const selector of selectors) {
const el = question && question.querySelector(selector);
const match = el && (el.textContent || '').match(/\d+/);
if (match) return parseInt(match[0], 10);
}
const text = stemEl ? stemEl.textContent || '' : question && question.textContent || '';
const match = text.match(/^\s*(\d+)\s*[.、))]/);
return match ? parseInt(match[1], 10) : 0;
}
// ==================== 富文本提取基础设施 ====================
// 块级标签:遍历子节点后自动追加换行,保证图文混排时段落结构正确
const RICH_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'DD', 'DT', 'TR', 'TABLE', 'SECTION']);
// 多属性 fallback 解析图片 URL,兼容学习通各种图片加载方式
function resolveImageUrl(img) {
if (!img) return '';
const attrs = ['src', 'data-src', 'data-original', 'origin-src', 'fileid'];
for (const attr of attrs) {
const value = img.getAttribute(attr);
if (value && value.trim()) return value.trim();
}
return img.currentSrc || img.src || '';
}
// 规范化富文本数组:合并相邻文本节点、去首尾空行、压缩空白
function normalizeRichContent(parts) {
const normalized = [];
const pushText = (text) => {
if (!text) return;
const value = text.replace(/\u00a0/g, ' ').replace(/[ \t\r\f]+/g, ' ');
if (!value) return;
const last = normalized[normalized.length - 1];
if (last && last.type === 'text') last.text += value;
else normalized.push({ type: 'text', text: value });
};
const pushBreak = () => {
const last = normalized[normalized.length - 1];
if (!last || last.type !== 'break') normalized.push({ type: 'break' });
};
for (const part of parts || []) {
if (!part) continue;
if (part.type === 'text') {
pushText(part.text);
} else if (part.type === 'image' && part.url) {
normalized.push(part);
} else if (part.type === 'break') {
pushBreak();
}
}
while (normalized[0] && normalized[0].type === 'break') normalized.shift();
while (normalized[normalized.length - 1] && normalized[normalized.length - 1].type === 'break') normalized.pop();
return normalized;
}
// ==================== 章节测验加密字体还原 ====================
// 学习通使用 font-cxsecret 把乱码 Unicode 映射到真实汉字字形。
// 不能从 cmap 或 glyph 名称猜原字,改用 OCS/ScriptCat 432 的方案:
// Typr 解析内嵌字体,计算每个中文字形路径的 MD5,再查映射表。
const fontCipherState = {
// 顶层页面和多个 iframe 可能同时初始化;按 Document 隔离并发准备,
// 避免 iframe 复用另一个文档的 Promise 而跳过自身解密。
preparingByDocument: new WeakMap(),
// 学习通 font-cxsecret 的字形路径 → 原始汉字映射(OCS/ScriptCat 432 同源方案)
secretMap: null,
secretMapLoading: null,
// 每个文档单独缓存当前 font-cxsecret 字体的“乱码码点 → 原字”映射。
// 页面动态追加题目时仍会继续处理新出现的 .font-cxsecret 节点。
secretReplacementsByDocument: new WeakMap(),
};
function getUserscriptGlobal(name) {
// @require 脚本在不同脚本管理器中可能挂到沙箱、页面 window 或
// unsafeWindow;逐个取值,避免“依赖已加载但当前作用域取不到”的情况。
const candidates = [];
try { if (typeof unsafeWindow !== 'undefined') candidates.push(unsafeWindow); } catch (e) {}
try { if (typeof globalThis !== 'undefined') candidates.push(globalThis); } catch (e) {}
try { if (typeof window !== 'undefined') candidates.push(window); } catch (e) {}
for (const scope of candidates) {
try { if (scope && scope[name]) return scope[name]; } catch (e) {}
}
return null;
}
function getTyprApi() {
try { if (typeof Typr !== 'undefined' && Typr) return Typr; } catch (e) {}
return getUserscriptGlobal('Typr');
}
function getMd5Api() {
try { if (typeof md5 !== 'undefined' && md5) return md5; } catch (e) {}
return getUserscriptGlobal('md5');
}
function base64ToUint8Array(base64) {
const data = window.atob(String(base64 || '').replace(/\s/g, ''));
const buffer = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) buffer[i] = data.charCodeAt(i);
return buffer;
}
async function loadCxSecretFontMap() {
if (fontCipherState.secretMap) return fontCipherState.secretMap;
if (fontCipherState.secretMapLoading) return fontCipherState.secretMapLoading;
// 优先使用 @resource,避免页面 CSP/CORS 阻止跨域 fetch;网络加载作为兜底。
try {
if (typeof GM_getResourceText === 'function') {
const bundled = GM_getResourceText('XXT_FONT_TABLE');
if (bundled) {
const map = JSON.parse(bundled);
if (map && typeof map === 'object') {
fontCipherState.secretMap = map;
return map;
}
}
}
} catch (e) {}
const tableUrl = 'https://cdn.ocsjs.com/resources/font/table.json';
const parseMap = value => {
const map = typeof value === 'string' ? JSON.parse(value) : value;
return map && typeof map === 'object' ? map : {};
};
const loadByRequest = () => new Promise((resolve, reject) => {
try {
if (typeof GM_xmlhttpRequest !== 'function') return reject(new Error('GM_xmlhttpRequest unavailable'));
GM_xmlhttpRequest({
method: 'GET', url: tableUrl, timeout: 30000,
onload: response => {
if (response.status >= 200 && response.status < 300) {
try { resolve(parseMap(response.responseText)); } catch (e) { reject(e); }
} else reject(new Error(`字体映射表加载失败:${response.status}`));
},
onerror: () => reject(new Error('字体映射表网络请求失败')),
ontimeout: () => reject(new Error('字体映射表请求超时')),
});
} catch (e) { reject(e); }
});
fontCipherState.secretMapLoading = loadByRequest().catch(() => fetch(tableUrl, {
credentials: 'omit',
})).then(response => {
if (response && !response.ok) throw new Error(`字体映射表加载失败:${response.status}`);
return response && typeof response.json === 'function' ? response.json() : response;
}).then(parseMap).then(map => {
fontCipherState.secretMap = map;
return map;
}).catch(error => {
// 不缓存失败状态,稍后页面网络恢复时允许再次尝试。
fontCipherState.secretMapLoading = null;
window.__xxt_font_cipher_debug = { ok: false, reason: String(error && error.message || error) };
return {};
});
return fontCipherState.secretMapLoading;
}
function extractEmbeddedFontBase64(cssText) {
const css = String(cssText || '');
const marker = /base64\s*,/ig;
let found;
while ((found = marker.exec(css))) {
const rest = css.slice(marker.lastIndex);
// 以 CSS data URL 的引号、右括号、分号或 format() 为结束;
// 不能用“Base64 字符类”直接贪吃,因为 format 也是字母,会被误纳入字体。
const candidate = (rest.match(/^([\s\S]*?)(?=['")]|;|\s+format\s*\()/i) || ['', ''])[1]
.replace(/\s/g, '');
if (candidate.length < 64) continue;
try {
const bytes = base64ToUint8Array(candidate);
const header = String.fromCharCode(...bytes.slice(0, 4));
// sfnt/TrueType 头为 00 01 00 00;OTF、TTC 也可由 Typr 解析。
// TyprMd5.js 的 Typr.parse 直接支持 sfnt/OTF/TTC;它不支持
// WOFF 容器,因此不要把 WOFF 当作可解析字体交给 Typr。
if (header === '\u0000\u0001\u0000\u0000' || header === 'OTTO' || header === 'ttcf') {
return candidate;
}
} catch (e) {}
}
return '';
}
function getCxSecretTargets(root) {
const scope = root && root.querySelectorAll ? root : document;
// 旧版模板直接给元素添加 class;新版可能只通过 class 的组合形式,
// 或仅通过 font-family 继承加密字体。
const classTargets = Array.from(scope.querySelectorAll('.font-cxsecret, [class*="font-cxsecret"]'));
// OCS 的 getSecretFont 会优先取 .after:加密字体容器有时同时包含
// 选项按钮/伪元素,直接改写整个容器会破坏按钮结构。
const targets = new Set(classTargets.map(element => element.querySelector('.after') || element));
if (scope.querySelector('style') && !targets.size) {
Array.from(scope.querySelectorAll('body *')).forEach(element => {
try {
if (/font-cxsecret/i.test(getComputedStyle(element).fontFamily || '')) targets.add(element);
} catch (e) {}
});
}
// 若父元素已经命中,则不再重复扫描其中的子元素,避免重复改写文本节点。
return {
classTargets,
targets: Array.from(targets).filter(element => !Array.from(targets)
.some(parent => parent !== element && parent.contains(element))),
};
}
async function decodeCxSecretFont(root) {
const doc = root && root.nodeType === Node.DOCUMENT_NODE
? root
: (root && root.ownerDocument) || document;
const targetInfo = getCxSecretTargets(doc);
const targets = targetInfo.targets;
if (!targets.length) {
window.__xxt_font_cipher_debug = {
ok: false,
reason: '未找到 font-cxsecret 目标元素',
classTargets: targetInfo.classTargets.length,
styles: doc.querySelectorAll ? doc.querySelectorAll('style').length : 0,
};
return 0;
}
// 兼容章节测验把 @font-face 放在 body 或动态容器中的模板。遍历所有
// 相关 style,不能只取第一个无 base64 的样式。
let encodedFont = '';
for (const style of Array.from(doc.querySelectorAll('style'))) {
if (!/font-cxsecret/i.test(style.textContent || '')) continue;
// Base64 中允许换行;按 OCS/ScriptCat 的实现,以 data URL 的
// 引号、右括号或分号作为结束,不能在普通空白处提前截断。
encodedFont = extractEmbeddedFontBase64(style.textContent || '');
// 保留 OCS/ScriptCat 的宽松兜底:个别页面的 CSS data URL 没有
// 标准引号/分号,且字体头部可能由浏览器自动处理,不能因此放弃。
if (!encodedFont) {
const fallback = (style.textContent || '').match(/base64,([\w\W]+?)['"]/i);
if (fallback && fallback[1]) encodedFont = fallback[1].replace(/\s/g, '');
}
if (encodedFont) break;
}
const typr = getTyprApi();
const md5Fn = getMd5Api();
if (!encodedFont || !typr || !md5Fn) {
window.__xxt_font_cipher_debug = { ok: false, reason: '依赖或内嵌字体未找到', hasTargets: targets.length, hasStyle: !!encodedFont };
return 0;
}
let cached = fontCipherState.secretReplacementsByDocument.get(doc);
if (!cached || cached.signature !== encodedFont) {
const table = await loadCxSecretFontMap();
if (!table || !Object.keys(table).length) return 0;
try {
// 与 OCS 和 ScriptCat「超星字体解密」相同:不能从 cmap 或 glyph 名
// 推断原字,必须以绘制路径的 MD5 到原始汉字表中反查。
const parsed = typr.parse(base64ToUint8Array(encodedFont));
// Typr.js 的 parse 返回字体数组;兼容不同打包版本的单对象返回值。
const font = Array.isArray(parsed) ? parsed[0] : parsed;
if (!font) return 0;
const replacements = new Map();
for (let codePoint = 19968; codePoint < 40870; codePoint++) {
const glyph = typr.U.codeToGlyph(font, codePoint);
if (!glyph) continue;
const path = typr.U.glyphToPath(font, glyph);
const fingerprint = md5Fn(JSON.stringify(path)).slice(24);
const original = Number(table[fingerprint]);
if (Number.isInteger(original) && original >= 0x4e00 && original <= 0x9fff) {
replacements.set(codePoint, String.fromCharCode(original));
}
}
if (!replacements.size) {
window.__xxt_font_cipher_debug = { ok: false, reason: '字形指纹未匹配到映射表', fontBytes: encodedFont.length };
return 0;
}
cached = { signature: encodedFont, replacements };
fontCipherState.secretReplacementsByDocument.set(doc, cached);
} catch (e) {
window.__xxt_font_cipher_debug = {
ok: false,
reason: String(e && e.message || e),
fontBytes: encodedFont.length,
hasTypr: !!typr,
hasMd5: !!md5Fn,
};
return 0;
}
}
const replacements = cached.replacements;
window.__xxt_font_cipher_debug = { ok: true, targets: targets.length, replacements: replacements.size, fontBytes: encodedFont.length };
targets.forEach(element => {
// 只替换文本节点,保留选项按钮、图片、公式等既有 DOM 结构。
const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const replaced = Array.from(node.nodeValue || '', char => {
const replacement = replacements.get(char.codePointAt(0));
return replacement || char;
}).join('');
if (replaced !== node.nodeValue) node.nodeValue = replaced;
});
});
// 即使实际文字节点位于 .after,也必须从其原始容器移除加密字体类。
// 否则轮询/MutationObserver 再次运行时会把已经还原的汉字二次映射。
targetInfo.classTargets.forEach(element => element.classList.remove('font-cxsecret'));
return replacements.size;
}
async function prepareFontCipherDecoder(root) {
// 完成原文替换后,后续 extractRichContent/textContent 会自然得到正确文字。
const doc = root && root.nodeType === Node.DOCUMENT_NODE
? root
: (root && root.ownerDocument) || document;
const pending = fontCipherState.preparingByDocument.get(doc);
if (pending) return pending;
const promise = decodeCxSecretFont(doc);
fontCipherState.preparingByDocument.set(doc, promise);
try {
return await promise;
} finally {
if (fontCipherState.preparingByDocument.get(doc) === promise) {
fontCipherState.preparingByDocument.delete(doc);
}
}
}
function decodeFontCipherText(text, element) {
const raw = String(text || '');
if (!raw || !element) return raw;
// 不再读取 data-value/data-content/title 等祖先属性:学习通的选项容器
// 经常把整条选项放进这些属性,逐个文本节点读取会造成“同一选项重复多次”。
// DOM 预替换遗漏时,再按真正应用了 font-cxsecret 的元素按需还原。
const doc = element.ownerDocument || document;
const cached = fontCipherState.secretReplacementsByDocument.get(doc);
if (!cached || !cached.replacements) return raw;
let isCipherText = false;
for (let node = element; node && node.nodeType === Node.ELEMENT_NODE; node = node.parentElement) {
if (node.matches && node.matches('.font-cxsecret, [class*="font-cxsecret"]')) {
isCipherText = true;
break;
}
}
if (!isCipherText) {
try { isCipherText = /font-cxsecret/i.test(getComputedStyle(element).fontFamily || ''); } catch (e) {}
}
if (!isCipherText) return raw;
return Array.from(raw, char => cached.replacements.get(char.codePointAt(0)) || char).join('');
}
// 按 DOM 顺序提取富文本内容:保留文字、图片和换行的原始顺序
function extractRichContent(el) {
const parts = [];
if (!el) return parts;
const walk = (node) => {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
parts.push({ type: 'text', text: decodeFontCipherText(node.nodeValue || '', node.parentElement) });
return;
}
if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) return;
const tag = node.nodeType === Node.ELEMENT_NODE ? node.tagName : '';
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return;
if (tag === 'IMG') {
const url = resolveImageUrl(node);
if (url) parts.push({ type: 'image', url, alt: node.getAttribute('alt') || '' });
return;
}
if (tag === 'BR') {
parts.push({ type: 'break' });
return;
}
const beforeLen = parts.length;
node.childNodes.forEach(walk);
if (tag && RICH_BLOCK_TAGS.has(tag) && parts.length > beforeLen) parts.push({ type: 'break' });
};
el.childNodes.forEach(walk);
return normalizeRichContent(parts);
}
// 将富文本数组转为纯文本,图片通过回调格式化
function richContentToText(content, imageFormatter) {
let text = '';
for (const part of content || []) {
if (part.type === 'text') text += part.text;
else if (part.type === 'image') text += imageFormatter ? imageFormatter(part.url, part) : '';
else if (part.type === 'break') text += '\n';
}
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
// 从富文本首段剥离选项字母(A~H),返回字母和剩余内容
function stripOptionPrefix(content) {
const parts = (content || []).map(part => part.type === 'text' ? { ...part } : part);
let letter = '';
for (const part of parts) {
if (part.type !== 'text') continue;
if (!part.text.trim()) continue;
const match = part.text.match(/^\s*([A-H])\s*[\.、.]?\s*/i);
if (match) {
letter = match[1].toUpperCase();
part.text = part.text.slice(match[0].length);
}
break;
}
return { letter, content: normalizeRichContent(parts) };
}
// 剥离题干前缀:题号(如"1.")和题型标签(如"(单选题)")
function stripQuestionPrefix(content) {
const parts = (content || []).map(part => part.type === 'text' ? { ...part } : part);
let strippedNumber = false;
let strippedType = false;
for (const part of parts) {
if (part.type !== 'text') continue;
// 去除末尾的分数标记,如 (2.0)
part.text = part.text.replace(/\s*\(\d+\.\d+\)\s*$/, '');
if (!strippedNumber) {
const before = part.text;
part.text = part.text.replace(/^\s*\d+\.\s*/, '');
if (part.text !== before || part.text.trim()) strippedNumber = true;
}
if (!strippedType) {
const before = part.text;
// 剥离已知题型标签(支持圆括号、方头括号、半角括号),避免误伤选项字母
part.text = part.text.replace(/^\s*[[\[(【(](单选题|多选题|填空题|判断题|简答题|论述题|问答题|选择题)[]\])】)]\s*/, '');
if (part.text !== before || part.text.trim()) strippedType = true;
}
}
return normalizeRichContent(parts);
}
// 剥离答案标签前缀(如"正确答案:")
function stripAnswerLabel(content) {
const parts = (content || []).map(part => part.type === 'text' ? { ...part } : part);
for (const part of parts) {
if (part.type !== 'text') continue;
if (!part.text.trim()) continue;
part.text = part.text.replace(/^\s*(正确答案|参考答案|答案)\s*[::]?\s*/, '');
break;
}
return normalizeRichContent(parts);
}
// 判断富文本是否有实质内容(文本或图片)
function hasRichContent(content) {
return (content || []).some(part => part.type === 'image' || (part.type === 'text' && part.text.trim()));
}
// 从“我的答案:B / 正确答案:C”所在的实际节点提取答案。
// 学习通的作业详情、考试阅卷页会频繁调整 class,但标签文本保持稳定。
function extractLabeledAnswerContent(qRoot, label) {
if (!qRoot) return [];
// 有些阅卷页把“正确答案”和答案值放在相邻 span,或使用零宽字符而没有冒号;
// 标签本身仍可作为锚点,后续从整题文本中取其后的真实答案。
const labelPattern = label === 'correct'
? /(?:正确\s*答案|参考\s*答案)(?!解析)\s*(?:[::]\s*)?/
: /我的\s*答案\s*(?:[::]\s*)?/;
// 题干中可能出现“下列哪个是正确答案?”之类的普通问句;它不是答案标签。
// 只要标签后紧跟问号,就不能把题干误识别为正确答案。
const isQuestionPhrase = (text, matched) => label === 'correct'
&& /^[\s]*[??]/.test(String(text || '').slice((matched.index || 0) + matched[0].length));
const candidates = Array.from(qRoot.querySelectorAll('*')).filter(el => {
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
const matched = text.match(labelPattern);
if (!matched || isQuestionPhrase(text, matched)) return false;
// 只取最内层、确实含该标签的答案节点,避免整个题目容器带入选项。
return !Array.from(el.children).some(child => labelPattern.test((child.textContent || '').replace(/\s+/g, ' ').trim()));
});
for (const el of candidates) {
const content = extractRichContent(el);
const cleaned = (content || []).map(part => part.type === 'text'
? { ...part, text: part.text.replace(labelPattern, '') }
: part);
const normalized = normalizeRichContent(cleaned);
if (hasRichContent(normalized)) return normalized;
}
// 某些新版阅卷页把“标签”和答案分别置于相邻 span;此时没有一个
// 最内层节点同时带完整文本,改从当前题目的可见文本中精确截取答案。
const plainText = (qRoot.textContent || '').replace(/[\u00a0\s]+/g, ' ').trim();
const matched = Array.from(plainText.matchAll(new RegExp(labelPattern.source, 'g')))
.find(match => !isQuestionPhrase(plainText, match));
if (!matched) return [];
let tail = plainText.slice((matched.index || 0) + matched[0].length);
// 同一答案条中通常还紧随“我的答案/正确答案”、对错图标文本和得分。
tail = tail.split(/(?=(?:我的\s*答案|正确\s*答案|参考\s*答案|答案解析|本题得分|得分|分值)\s*(?:[::]\s*)?)/)[0];
// 选择题答案必须只取 A-H,防止把“0分”等页面排版文本带入答案。
const choice = tail.match(/^\s*([A-H](?:\s*[,,、;;|/]\s*[A-H])*)/i);
if (choice) return [{ type: 'text', text: choice[1].replace(/[\s,、;;|/]/g, '').toUpperCase() }];
const judgement = tail.match(/^\s*(√|✓|×|✕|✖|正确|错误|对|错|是|否|true|false)/i);
if (judgement) return [{ type: 'text', text: judgement[1] }];
// 填空或简答的答案保留到得分/状态标记之前。
const text = tail.replace(/\s*(?:[√✓×✕✖]|\d+(?:\.\d+)?\s*分).*$/, '').trim();
return text ? [{ type: 'text', text }] : [];
}
// 提取正确答案为富文本数组,保留图片和公式
function extractCorrectAnswerContent(qLi) {
const markAnswer = qLi.querySelector('.mark_answer');
if (markAnswer) {
const contents = [];
markAnswer.querySelectorAll('.rightAnswerContent').forEach(el => {
const content = stripAnswerLabel(extractRichContent(el));
if (hasRichContent(content)) {
if (contents.length > 0) contents.push({ type: 'text', text: ';' });
contents.push(...content);
}
});
if (contents.length > 0) return normalizeRichContent(contents);
const markKey = markAnswer.querySelector('.mark_key');
if (markKey) {
const rightEl = markKey.querySelector('.colorGreen');
if (rightEl) {
const content = stripAnswerLabel(extractRichContent(rightEl));
if (hasRichContent(content)) return content;
}
}
const greenFill = markAnswer.querySelector('.mark_fill.colorGreen');
if (greenFill) {
const dds = greenFill.querySelectorAll('dd');
const fillContents = [];
dds.forEach(dd => {
const content = stripAnswerLabel(extractRichContent(dd));
if (hasRichContent(content)) {
if (fillContents.length > 0) fillContents.push({ type: 'text', text: ';' });
fillContents.push(...content);
}
});
if (fillContents.length > 0) return normalizeRichContent(fillContents);
}
const fallbackSelectors = ['.mark_key .colorGreen', '.mark_fill.colorGreen'];
for (const selector of fallbackSelectors) {
const el = markAnswer.querySelector(selector);
if (!el) continue;
const content = stripAnswerLabel(extractRichContent(el));
if (hasRichContent(content)) return content;
}
}
// 不依赖 class 的最终回退,兼容截图中的“正确答案:C”新阅卷样式。
return extractLabeledAnswerContent(qLi, 'correct');
}
function extractCorrectAnswer(qLi) {
return richContentToText(extractCorrectAnswerContent(qLi), () => '').replace(/\s+/g, ' ').trim();
}
// 适配器:兼容新旧数据格式,统一返回富文本数组
function questionContent(q) {
if (hasRichContent(q.stemContent)) return q.stemContent;
const parts = q.stem ? [{ type: 'text', text: q.stem }] : [];
if (q.images && q.images.length) {
q.images.forEach(url => parts.push({ type: 'break' }, { type: 'image', url }));
}
return normalizeRichContent(parts);
}
function optionContent(opt) {
if (hasRichContent(opt.content)) return opt.content;
return opt.text ? [{ type: 'text', text: opt.text }] : [];
}
function answerContent(q) {
if (hasRichContent(q.correctAnswerContent)) return q.correctAnswerContent;
return q.correctAnswer ? [{ type: 'text', text: q.correctAnswer }] : [];
}
// 编辑器使用可逆的纯文本表示:换行直接显示为编辑框中的自然换行,
// 图片仍使用〔图片1〕占位符,保存时可以恢复原图片。
function richContentToEditableText(content) {
let imageIndex = 0;
let text = '';
for (const part of content || []) {
if (part.type === 'text') text += part.text || '';
else if (part.type === 'break') text += '\n';
else if (part.type === 'image') text += `〔图片${++imageIndex}〕`;
}
return text.replace(/\n{3,}/g, '\n\n').trim();
}
function editableTextToRichContent(text, originalContent) {
const originals = (originalContent || []).filter(part => part.type === 'image');
const parts = [];
const value = String(text || '').replace(/\r\n?/g, '\n');
const token = /〔图片(\d+)〕/g;
let last = 0;
const pushText = (s) => { if (s) parts.push({ type: 'text', text: s }); };
const pushValue = (s) => {
const lines = String(s).split(/\n/);
lines.forEach((line, i) => {
pushText(line);
if (i < lines.length - 1) parts.push({ type: 'break' });
});
};
let match;
while ((match = token.exec(value))) {
pushValue(value.slice(last, match.index));
const image = originals[parseInt(match[1], 10) - 1];
if (image) parts.push({ ...image });
else pushText(match[0]);
last = match.index + match[0].length;
}
pushValue(value.slice(last));
return normalizeRichContent(parts);
}
function rebuildDerivedQuestionFields(q) {
applyScoreDerivedAnswer(q);
q.stem = formatRichForText(questionContent(q));
q.options = (q.options || []).map(opt => ({
...opt,
text: richTextOnly(optionContent(opt)),
}));
q.correctAnswer = richTextOnly(answerContent(q));
return q;
}
function toScoreNumber(value) {
const matched = String(value ?? '').replace(/,/g, '.').match(/-?\d+(?:\.\d+)?/);
if (!matched) return null;
const score = Number(matched[0]);
return Number.isFinite(score) ? score : null;
}
// 已批阅的章节测验/考试页有时只显示“我的答案 + 单题得分”,不展示正确答案。
// 得分只用于内部判定错题;当页面没有答案时,把“我的答案”直接放入可编辑的
// 附加答案,方便用户在题库/导出前自行修改,而不把得分写进界面或导出内容。
function applyScoreDerivedAnswer(q) {
if (!q) return q;
const score = toScoreNumber(q.score);
const maxScore = toScoreNumber(q.maxScore);
const scoreKnown = score !== null && maxScore !== null && maxScore > 0;
q.score = score === null ? null : score;
q.maxScore = maxScore === null ? null : maxScore;
// 未满分即说明该题没有完全答对;0 分是最常见的明确错题情形。
q.wrongByScore = !!(scoreKnown && score < maxScore);
q.partialByScore = !!(scoreKnown && score > 0 && score < maxScore);
q.fullScoreByScore = !!(scoreKnown && score > 0 && Math.abs(score - maxScore) < 0.000001);
const editableAnswer = String(q.myAnswer || '').trim();
if (editableAnswer && !hasRichContent(answerContent(q))) {
q.correctAnswerContent = [{ type: 'text', text: editableAnswer }];
q.correctAnswer = editableAnswer;
// 保留来源供内部判断:满分时是已确认答案,其他情况是可编辑的我的作答。
q.correctAnswerSource = q.fullScoreByScore ? 'full-score' : 'my-answer-editable';
}
return q;
}
function unavailableCorrectAnswerText(q) {
if (q && q.wrongByScore) return '(页面未显示正确答案)';
return '(未找到答案)';
}
function isDataAnswerWrong(q, qtype) {
applyScoreDerivedAnswer(q);
if (q.wrongByScore) return true;
if (qtype === '简答' || !q.myAnswer) return false;
const correct = richTextOnly(answerContent(q));
if (!correct) return false;
return !answersEqual(q.myAnswer, correct, qtype);
}
function recalculateExtractedMeta(data) {
if (!data || !data.results) return data;
let total = 0, wrongCount = 0, hasMyAnswer = false, hasCorrectAnswer = false;
const order = data.typeOrder || Object.keys(data.results);
for (const qtype of order) {
const questions = data.results[qtype] || [];
total += questions.length;
for (const q of questions) {
rebuildDerivedQuestionFields(q);
if (q.myAnswer) hasMyAnswer = true;
if (hasRichContent(answerContent(q))) hasCorrectAnswer = true;
q.isWrong = isDataAnswerWrong(q, qtype);
if (q.isWrong) wrongCount++;
}
}
data.total = total;
data.breakdown = Object.fromEntries(Object.entries(data.results).map(([k, v]) => [k, (v || []).length]));
data.wrongCount = wrongCount;
data.hasMyAnswer = hasMyAnswer;
data.hasCorrectAnswer = hasCorrectAnswer;
return data;
}
function refreshExtractedOutputCache(data) {
if (!data) return data;
recalculateExtractedMeta(data);
data.text = formatOutput(data.results, data.typeOrder);
data.textWithAnswers = formatOutputWithAnswers(data.results, data.typeOrder);
data.textWrong = formatWrongQuestionsTXT(data.results, data.typeOrder);
data.textMD = formatOutputMD(data.results, data.typeOrder);
data.textWithAnswersMD = formatOutputWithAnswersMD(data.results, data.typeOrder);
data.textWrongMD = formatWrongQuestionsMD(data.results, data.typeOrder);
return data;
}
// Markdown URL 转义,防止特殊字符破坏图片语法
function escapeMarkdownAlt(text) {
return (text || '图片').replace(/[\[\]\n\r]/g, ' ').trim() || '图片';
}
function escapeMarkdownUrl(url) {
// 编码 URL 中的特殊字符,防止破坏 Markdown 图片语法,保留已编码部分
return (url || '').replace(/[()\\]/g, (ch) => '%' + ch.charCodeAt(0).toString(16).toUpperCase());
}
// 富文本格式化:TXT/MD 分别处理
function formatRichForText(content) {
return richContentToText(content, (url) => `\n[图片: ${url}]\n`);
}
function formatRichForMD(content) {
return richContentToText(content, (url, part) => `\n})\n`);
}
// 富文本转纯文本(忽略图片)
function richTextOnly(content) {
return richContentToText(content || [], () => '').replace(/\s+/g, ' ').trim();
}
// 从旧版 li 元素提取选项(富文本)
function extractOptionFromLegacyLi(li, index) {
const parsed = stripOptionPrefix(extractRichContent(li));
const letter = parsed.letter || String.fromCharCode(65 + index);
const text = richTextOnly(parsed.content);
if (letter && (text || hasRichContent(parsed.content))) {
return { letter, text, content: parsed.content };
}
return null;
}
// 从新版 .answerBg 元素提取选项(富文本)
function extractOptionFromAnswerBg(bg, index) {
const numOption = bg.querySelector('.num_option');
const answerP = bg.querySelector('.answer_p');
if (numOption && answerP) {
const letter = (numOption.getAttribute('data') || numOption.textContent.trim() || String.fromCharCode(65 + index)).trim().toUpperCase();
const content = extractRichContent(answerP);
const text = richTextOnly(content);
if (letter && (text || hasRichContent(content))) return { letter, text, content };
}
return null;
}
// 兼容已完成考试的新版阅卷页:该页面通常用 .Zy_ulTop > li,
// 而不是旧版限定的 .Zy_ulTop.qtDetail > li。
function extractChoiceOptions(qDiv) {
const options = [];
const seenNodes = new Set();
const byLetter = new Map();
const sources = [
['.Zy_ulTop.qtDetail > li', 'legacy'],
['.Zy_ulTop > li', 'legacy'],
['.mark_letter > li', 'legacy'],
['.answerBg', 'answerBg'],
['.answer_bg', 'practice']
];
sources.forEach(([selector, kind]) => {
qDiv.querySelectorAll(selector).forEach(node => {
if (seenNodes.has(node)) return;
seenNodes.add(node);
const parsed = kind === 'answerBg'
? extractOptionFromAnswerBg(node, options.length)
: kind === 'legacy'
? extractOptionFromLegacyLi(node, options.length)
: extractPracticeOption(node, options.length);
if (!parsed || !/^[A-H]$/i.test(parsed.letter || '')) return;
parsed.letter = parsed.letter.toUpperCase();
const previous = byLetter.get(parsed.letter);
if (!previous) {
byLetter.set(parsed.letter, parsed);
options.push(parsed);
} else if (!hasRichContent(previous.content) && hasRichContent(parsed.content)) {
const position = options.indexOf(previous);
if (position >= 0) options[position] = parsed;
byLetter.set(parsed.letter, parsed);
}
});
});
return options.sort((a, b) => a.letter.localeCompare(b.letter));
}
function extractMyAnswer(qLi, qtype) {
const markAnswer = qLi.querySelector('.mark_answer');
if (markAnswer) {
// 单选/多选/判断:从 .mark_key 中提取
const markKey = markAnswer.querySelector('.mark_key');
if (markKey) {
const mySpan = markKey.querySelector('.colorDeep .stuAnswerContent');
if (mySpan && mySpan.textContent.trim()) return mySpan.textContent.trim();
}
// 填空:从 .mark_fill.colorDeep 中提取
const deepFill = markAnswer.querySelector('.mark_fill.colorDeep');
if (deepFill) {
const spans = deepFill.querySelectorAll('.stuAnswerContent');
if (spans.length > 0) return Array.from(spans).map(s => s.textContent.trim()).join(';');
}
}
return richTextOnly(extractLabeledAnswerContent(qLi, 'my'));
}
function normalizeAnswerText(answer) {
return String(answer || '')
.replace(/^\s*(?:我的答案|正确答案|答案)\s*[::]\s*/i, '')
.replace(/[\u00a0\s]+/g, '')
.replace(/[,、;;]/g, ',')
.trim()
.toLowerCase();
}
function normalizeChoiceAnswer(answer) {
const normalized = normalizeAnswerText(answer).replace(/[,|/\\]+/g, '');
return /^[a-h]+$/i.test(normalized)
? [...new Set(normalized.toUpperCase().split(''))].sort().join('')
: normalized;
}
function normalizeJudgementAnswer(answer) {
const normalized = normalizeAnswerText(answer).replace(/[。.!!]/g, '');
if (/^(?:√|✓|对|正确|true|t|yes|是)$/i.test(normalized)) return 'true';
if (/^(?:×|✕|✖|错|错误|不正确|false|f|no|否)$/i.test(normalized)) return 'false';
return normalized;
}
function answersEqual(myAnswer, correctAnswer, qtype) {
if (qtype === '单选' || qtype === '多选') {
return normalizeChoiceAnswer(myAnswer) === normalizeChoiceAnswer(correctAnswer);
}
if (qtype === '判断') {
return normalizeJudgementAnswer(myAnswer) === normalizeJudgementAnswer(correctAnswer);
}
return normalizeAnswerText(myAnswer) === normalizeAnswerText(correctAnswer);
}
function isAnswerWrong(qLi, myAnswer, correctAnswer, qtype) {
if (qtype === '简答') return false; // 简答题不纳入错题统计
if (!myAnswer) return false; // 没有我的答案,不算错题
// 优先使用页面明确给出的判题状态,避免填空题同义答案等被字符串比较误判
if (qLi.querySelector('.marking_dui, [class*="marking_dui"], .icon-dui, [class*="icon-dui"], .rightIcon, .answerRight, .icon-right')) return false;
if (qLi.querySelector('.marking_cuo, [class*="marking_cuo"], .icon-cuo, [class*="icon-cuo"], .wrongIcon, .answerWrong, .icon-wrong')) return true;
if (!correctAnswer) return false;
return !answersEqual(myAnswer, correctAnswer, qtype);
}
// Fisher-Yates 洗牌算法,同类型题目内部打乱
function shuffleQuestions(results, typeOrder) {
const shuffled = {};
for (const qtype of typeOrder) {
const arr = [...(results[qtype] || [])];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
shuffled[qtype] = arr;
}
return shuffled;
}
function extract(options) {
const preferIframe = !!(options && options.preferIframe);
// 收到 iframe 的新消息后必须先消费这份消息,不能先从父页面或尚未
// 完成切换的旧 iframe 中提取,否则切换随堂练习时会重复上一页题目。
if (preferIframe) {
const iframeData = window.__xxt_iframe_data;
window.__xxt_iframe_data = null;
window.__xxt_iframe_context = null;
if (iframeData && hasQuestions(iframeData)) return iframeData;
// 主动向 iframe 请求最新数据却没有收到响应时,不能再递归读取可能
// 尚未替换的旧 iframe,否则仍会导出上一份随堂练习。
if (options && options.iframeOnly) return emptyExtractResult();
}
// 优先从当前文档提取
const topResult = extractFromRoot(document);
if (hasQuestions(topResult)) return topResult;
// 检查 iframe 通过 postMessage 发来的最新数据(跨域 iframe 回传)
const iframeData = window.__xxt_iframe_data;
window.__xxt_iframe_data = null; // 每次提取后清空,确保下次拿到最新数据
window.__xxt_iframe_context = null;
if (iframeData && hasQuestions(iframeData)) {
return iframeData;
}
// 学生学习页面:题目在多层嵌套 iframe 中,递归查找(同源 iframe)
const iframeResult = extractFromIframesRecursive(document);
if (iframeResult) return iframeResult;
// 兜底:返回空结果
return emptyExtractResult();
}
// 用整页题目内容标识一次 iframe 结果,避免页面切换时把上一页结果
// 当成当前页。完整签名可降低不同题目页发生碰撞的概率。
function iframeDataSignature(data) {
if (!data || !data.results) return '';
let total = 0;
let hash = 2166136261;
const add = (value) => {
const text = String(value || '');
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
// 字段边界不能省略,例如“ab + c”和“a + bc”必须得到不同指纹。
hash ^= 124;
hash = Math.imul(hash, 16777619);
};
for (const type of Object.keys(data.results)) {
const questions = data.results[type] || [];
total += questions.length;
add(type);
add(questions.length);
for (const q of questions) {
add(String(q.stem || richTextOnly(q.stemContent || []) || '').replace(/\s+/g, ' ').trim());
for (const option of (q.options || [])) {
add(option.letter || '');
add(String(option.text || richTextOnly(option.content || []) || '').replace(/\s+/g, ' ').trim());
}
}
}
return `${total}|${(hash >>> 0).toString(36)}`;
}
// 检查提取结果是否包含题目
function hasQuestions(result) {
return !!(result && result.results && Object.values(result.results).some(arr => Array.isArray(arr) && arr.length > 0));
}
function richContentIdentity(content) {
return (content || []).map(part => {
if (!part) return '';
if (part.type === 'image') return `[图片:${String(part.url || '').slice(0, 4096)}:${String(part.alt || '').slice(0, 120)}]`;
if (part.type === 'break') return '\n';
return String(part.text || '');
}).join('').replace(/\s+/g, ' ').trim();
}
function getQuestionIdentity(type, question) {
if (!question) return '';
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const stem = normalize(question.stem || richContentIdentity(question.stemContent || []));
if (!stem) return '';
const options = (question.options || []).map(option =>
`${normalize(option && option.letter)}:${normalize(option && (option.text || richContentIdentity(option.content || [])))}`
).join('|');
return `${type}|${stem}|${options}`;
}
// 读取随堂练习页面中的作答内容(未提交时通常为空)。
function extractPracticeMyAnswer(question, qtype) {
if (!question) return '';
const checked = Array.from(question.querySelectorAll('input[type="radio"]:checked, input[type="checkbox"]:checked'));
if (checked.length > 0) {
const values = checked.map(input => {
const value = input.getAttribute('data-value') || input.getAttribute('value') || '';
if (/^[A-H]$/i.test(value.trim())) return value.trim().toUpperCase();
const label = input.closest('label') || input.parentElement;
const parsed = label ? stripOptionPrefix(extractRichContent(label)) : null;
return parsed && parsed.letter ? parsed.letter : value.trim();
}).filter(Boolean);
return values.join(qtype === '多选' ? ',' : '');
}
const textarea = question.querySelector('textarea');
if (textarea && textarea.value.trim()) return textarea.value.trim();
const textInput = question.querySelector('input:not([type="radio"]):not([type="checkbox"]):not([type="hidden"])');
if (textInput && textInput.value) return textInput.value.trim();
// 已批阅的作业详情页不会保留 radio/checkbox 的 checked 状态,而是把
// “我的答案”放在 .mark_answer 或独立标签中;此时复用阅卷页解析。
return extractMyAnswer(question, qtype);
}
function extractPracticeCorrectAnswer(question) {
if (!question) return [];
const answer = question.querySelector(
'.correctAnswer, .correct-answer, .rightAnswer, .right-answer, [data-correct-answer]'
);
if (answer) {
const content = stripAnswerLabel(extractRichContent(answer));
if (hasRichContent(content)) return content;
}
const value = question.getAttribute('data-correct-answer') || question.getAttribute('correctAnswer');
if (value) return [{ type: 'text', text: value }];
// /work/view 已批阅页常被前面的“随堂练习”分支先识别到 .questionLi,
// 但正确答案实际在 .mark_answer/.mark_key 或“正确答案:C”标签中。
// 不能因为页面先命中练习结构就遗漏这部分阅卷信息。
return extractCorrectAnswerContent(question);
}
function extractPracticeOption(option, index) {
if (!option) return null;
const explicitLetter = option.getAttribute('data-letter')
|| option.getAttribute('data-option')
|| option.getAttribute('data-value')
|| option.querySelector('.num_option, .option-letter, .optionLetter, .letter');
const content = stripOptionPrefix(extractRichContent(option));
let letter = content.letter;
if (!letter) {
const raw = typeof explicitLetter === 'string'
? explicitLetter
: explicitLetter && explicitLetter.textContent;
const match = String(raw || '').trim().match(/^([A-H])(?:[.、.))])?$/i);
if (match) letter = match[1].toUpperCase();
}
if (!letter) letter = String.fromCharCode(65 + index);
const text = richTextOnly(content.content);
if (!text && !hasRichContent(content.content)) return null;
return { letter, text, content: content.content };
}
// 新版考试阅卷页(exam-ans ... reVersionPaperMarkContentNew)使用
// .u-questionItem / .seeTitle / .titleBox .el-tag 结构。页面可能没有
// 正确答案,但会明确显示本题分值与得分。
function extractQuestionScoreInfo(questionEl, titleEl) {
if (!questionEl) return { score: null, maxScore: null };
const titleCandidates = [titleEl,
questionEl.querySelector('.seeTitle, .Zy_TItle, .mark_name, .Py-m1-title, .question-title')
].filter(Boolean);
const titleText = titleCandidates.map(el => (el.textContent || '').replace(/\s+/g, ' ').trim()).join(' ');
let maxScore = null;
const titleMatch = titleText.match(/[((][^))]*?(\d+(?:\.\d+)?)\s*分\s*[))]/)
|| titleText.match(/(?:单选题|多选题|填空题|判断题|简答题|论述题|问答题)[^\d]{0,12}(\d+(?:\.\d+)?)\s*分/);
if (titleMatch) maxScore = toScoreNumber(titleMatch[1]);
const scoreSelectors = [
'.titleBox .el-tag',
'.mark_answer [class*="score"]', '.newAnswerBx [class*="score"]',
'.myAnswerBx [class*="score"]', '.answerDet [class*="score"]',
'.question-score', '.mark_score',
'[data-score]'
];
const seen = new Set();
for (const selector of scoreSelectors) {
for (const scoreEl of questionEl.querySelectorAll(selector)) {
if (seen.has(scoreEl)) continue;
seen.add(scoreEl);
const rawScore = scoreEl.getAttribute('data-score') || scoreEl.getAttribute('score') || scoreEl.textContent;
const score = toScoreNumber(rawScore);
if (score !== null) return { score, maxScore };
}
}
// 兼容没有语义 class 的旧版:分数通常是答案区里的最内层“1.0 分”。
const answerArea = questionEl.querySelector('.mark_answer, .newAnswerBx, .answerDet, .answer-detail, .answerDetail');
if (answerArea) {
const leaf = Array.from(answerArea.querySelectorAll('*')).find(el => {
if (el.children.length) return false;
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
return /^(?:本题)?得分\s*[::]?\s*\d+(?:\.\d+)?\s*分?$/.test(text)
|| /^\d+(?:\.\d+)?\s*分$/.test(text);
});
if (leaf) return { score: toScoreNumber(leaf.textContent), maxScore };
}
return { score: null, maxScore };
}
function applyQuestionScoreInfo(q, questionEl, titleEl) {
if (!q) return q;
const scoreInfo = extractQuestionScoreInfo(questionEl, titleEl);
if (toScoreNumber(q.score) === null) q.score = scoreInfo.score;
if (toScoreNumber(q.maxScore) === null) q.maxScore = scoreInfo.maxScore;
return applyScoreDerivedAnswer(q);
}
function stripScoreReviewQuestionPrefix(content) {
const parts = (content || []).map(part => part.type === 'text' ? { ...part } : part);
for (const part of parts) {
if (part.type !== 'text' || !part.text.trim()) continue;
part.text = part.text
.replace(/^\s*\d+\s*[.、]\s*/, '')
.replace(/^\s*[((]\s*(?:单选题|多选题|填空题|判断题|简答题|论述题|问答题|选择题)(?:\s*[,,]\s*\d+(?:\.\d+)?\s*分)?\s*[))]\s*/, '');
break;
}
return normalizeRichContent(parts);
}
function extractScoreReviewOptions(questionEl) {
const selectors = [
'.optionList .el-radio-group label', '.optionList .el-checkbox-group label',
'.optionList label', '.option-list label'
];
const seen = new Set();
const options = [];
selectors.forEach(selector => {
questionEl.querySelectorAll(selector).forEach(node => {
if (seen.has(node)) return;
seen.add(node);
const parsed = extractPracticeOption(node, options.length);
if (parsed && /^[A-H]$/i.test(parsed.letter || '')) {
parsed.letter = parsed.letter.toUpperCase();
if (!options.some(option => option.letter === parsed.letter)) options.push(parsed);
}
});
});
return options.sort((a, b) => a.letter.localeCompare(b.letter));
}
function extractScoreReviewMyAnswer(questionEl, qtype, options) {
const checked = [];
questionEl.querySelectorAll('.optionList .is-checked, .option-list .is-checked, input[type="radio"]:checked, input[type="checkbox"]:checked').forEach(node => {
const label = node.matches('label') ? node : (node.closest('label') || (node.parentElement && node.parentElement.closest('label')) || node);
if (!label || checked.includes(label)) return;
checked.push(label);
});
const letters = checked.map((label, index) => {
const parsed = extractPracticeOption(label, index);
if (parsed && /^[A-H]$/i.test(parsed.letter || '')) return parsed.letter.toUpperCase();
const text = richTextOnly(extractRichContent(label));
const option = (options || []).find(item => text.includes(item.text) || item.text.includes(text));
return option ? option.letter : '';
}).filter(Boolean);
const selectedAnswer = [...new Set(letters)].join(qtype === '多选' ? ',' : '');
if (selectedAnswer) return selectedAnswer;
// 新版已批阅页面常把选项做成只读文本,已选状态不会保留在 radio/checkbox 上。
// 此时直接读取“我的答案:C”,仍然只作为本题作答记录;是否正确由页面
// 给出的答案或内部得分判断,不把分数展示到导出内容中。
return richTextOnly(extractLabeledAnswerContent(questionEl, 'my'));
}
function extractScoreReviewCorrectAnswer(questionEl) {
const direct = questionEl.querySelector('.answerDet .answer, .correctAnswer, .correct-answer, .rightAnswer, .right-answer, [data-correct-answer]');
if (direct) {
const content = stripAnswerLabel(extractRichContent(direct));
if (hasRichContent(content)) return content;
}
return extractCorrectAnswerContent(questionEl);
}
function extractFromScoreReviewRoot(root) {
const questionEls = Array.from(root.querySelectorAll('.u-questionItem')).filter(question =>
question.querySelector('.seeTitle') || question.querySelector('.titleBox')
);
if (!questionEls.length) return null;
const results = { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] };
const typeOrder = [];
let wrongCount = 0;
let hasMyAnswer = false;
let hasCorrectAnswer = false;
questionEls.forEach((questionEl, index) => {
const titleEl = questionEl.querySelector('.seeTitle, .question-title, .titleBox');
if (!titleEl) return;
const titleText = (titleEl.textContent || '').replace(/\s+/g, ' ').trim();
const qtype = detectTypeFromText(titleText) || findQuestionType(questionEl, titleEl);
if (!qtype || !results[qtype]) return;
if (!typeOrder.includes(qtype)) typeOrder.push(qtype);
const stemSource = titleEl.querySelector('.ql-editor') || titleEl;
const stemContent = stripScoreReviewQuestionPrefix(stripQuestionPrefix(extractRichContent(stemSource)));
if (!hasRichContent(stemContent)) return;
const options = extractScoreReviewOptions(questionEl);
const myAnswer = extractScoreReviewMyAnswer(questionEl, qtype, options);
const correctAnswerContent = extractScoreReviewCorrectAnswer(questionEl);
const correctAnswer = richTextOnly(correctAnswerContent);
const numberMatch = titleText.match(/^\s*(\d{1,4})\s*[.、]/);
const data = {
qnum: numberMatch ? Number(numberMatch[1]) : index + 1,
stem: formatRichForText(stemContent), stemContent, options,
correctAnswer, correctAnswerContent, myAnswer,
isWrong: false
};
applyQuestionScoreInfo(data, questionEl, titleEl);
data.isWrong = isDataAnswerWrong(data, qtype);
if (myAnswer) hasMyAnswer = true;
if (hasRichContent(answerContent(data))) hasCorrectAnswer = true;
if (data.isWrong) wrongCount++;
results[qtype].push(data);
});
if (!hasQuestions({ results })) return null;
return { results, typeOrder, wrongCount, hasMyAnswer, hasCorrectAnswer };
}
// 随堂练习 answerQuestion2 页面:题号、题型和题干在同一标题行,选项是普通 li。
function extractFromQuizPracticeRoot(root) {
const typePattern = /^(?:[[\[【((].*(题)[]\]】))]|(?:单选题|多选题|填空题|判断题|简答题|选择题))$/;
const typePredicate = el => {
const text = (el.textContent || '').trim();
if (text.length > 20 || !detectTypeFromText(text) || !typePattern.test(text)) return false;
// 只保留最内层文本节点,避免同一标签的祖先元素重复计数。
return !Array.from(el.children || []).some(child => typePredicate(child));
};
const numberPredicate = el => /^\s*\d+\s*[.、))]\s*$/.test((el.textContent || '').trim());
const typeElements = Array.from(root.querySelectorAll('*')).filter(typePredicate);
if (typeElements.length === 0) return null;
const results = { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] };
const typeOrder = [];
let wrongCount = 0;
let hasMyAnswer = false;
let hasCorrectAnswer = false;
const allElements = Array.from(root.querySelectorAll('*'));
const allOptions = Array.from(root.querySelectorAll('li'));
const elementIndex = new Map(allElements.map((el, index) => [el, index]));
const submitBoundary = allElements.find(el => {
const text = (el.textContent || '').trim();
return text === '提交' && el.children.length <= 2;
}) || null;
const submitIndex = submitBoundary ? elementIndex.get(submitBoundary) : allElements.length;
const isBetween = (index, start, end) => index > start && index < end;
const isLeafText = el => {
if (!el || el.closest('li')) return false;
const text = (el.textContent || '').trim();
if (!text || numberPredicate(el) || typePredicate(el)) return false;
return el.children.length === 0 || Array.from(el.children).every(child => ['BR', 'IMG'].includes(child.tagName));
};
typeElements.forEach((typeEl, index) => {
const typeIndex = elementIndex.get(typeEl);
const nextType = typeElements[index + 1] || null;
const nextTypeIndex = nextType ? elementIndex.get(nextType) : submitIndex;
const sectionType = detectTypeFromText(typeEl.textContent || '');
if (!sectionType) return;
if (!typeOrder.includes(sectionType)) typeOrder.push(sectionType);
// 题号是题型标签前最近的纯数字标题;优先从同一标题行的兄弟节点读取。
const titleParent = typeEl.parentElement;
const titleSiblings = titleParent ? Array.from(titleParent.children) : [];
const siblingTypeIndex = titleSiblings.indexOf(typeEl);
let numberEl = titleSiblings.slice(0, siblingTypeIndex).reverse().find(numberPredicate) || null;
if (!numberEl) {
numberEl = allElements.slice(0, allElements.indexOf(typeEl)).reverse().find(numberPredicate) || null;
}
const firstOption = allOptions.find(li => {
const liIndex = elementIndex.get(li);
return isBetween(liIndex, typeIndex, nextTypeIndex) && liIndex < submitIndex;
});
const firstOptionIndex = firstOption ? elementIndex.get(firstOption) : nextTypeIndex;
let stemContent = [];
try {
// 题干容器有时同时包住选项,不能只筛“无子节点”的元素;
// 用 Range 截取题型标签到第一项之间的内容,避免把选项文本带入题干。
const rangeDocument = root.ownerDocument || root;
const range = rangeDocument.createRange();
range.setStartAfter(typeEl);
if (firstOption) range.setEndBefore(firstOption);
else if (nextType) range.setEndBefore(nextType);
else range.setEndAfter(typeEl);
stemContent = stripQuestionPrefix(extractRichContent(range.cloneContents()));
} catch (e) {
const stemEl = allElements.find(el => {
const elIndex = elementIndex.get(el);
return isBetween(elIndex, typeIndex, firstOptionIndex) && isLeafText(el);
}) || null;
stemContent = stemEl ? stripQuestionPrefix(extractRichContent(stemEl)) : [];
}
if (!hasRichContent(stemContent)) return;
const options = [];
allOptions.forEach((li, optionIndex) => {
const liIndex = elementIndex.get(li);
if (!isBetween(liIndex, typeIndex, nextTypeIndex) || liIndex >= submitIndex) return;
const parsed = extractPracticeOption(li, optionIndex);
if (parsed && /^[A-H]$/.test(parsed.letter)) options.push(parsed);
});
const uniqueOptions = Array.from(new Map(options.map(option => [option.letter, option])).values())
.sort((a, b) => a.letter.localeCompare(b.letter));
// 答案输入控件若存在,限定在当前题目区间内读取。
const rangeElements = allElements.filter(el => {
const elIndex = elementIndex.get(el);
return isBetween(elIndex, typeIndex, nextTypeIndex) && elIndex < submitIndex;
});
const answerRoot = rangeElements[0] ? rangeElements[0].parentElement : titleParent;
const myAnswer = extractPracticeMyAnswer(answerRoot, sectionType);
const correctAnswerContent = extractPracticeCorrectAnswer(answerRoot);
const correctAnswer = richContentToText(correctAnswerContent, () => '').replace(/\s+/g, ' ').trim();
if (myAnswer) hasMyAnswer = true;
const data = {
qnum: numberEl ? parseInt((numberEl.textContent || '').match(/\d+/)?.[0] || '0', 10) : index + 1,
stem: formatRichForText(stemContent), stemContent, options: uniqueOptions,
correctAnswer, correctAnswerContent, myAnswer, isWrong: false
};
applyQuestionScoreInfo(data, answerRoot, titleParent || typeEl.parentElement);
const wrong = isDataAnswerWrong(data, sectionType);
data.isWrong = wrong;
if (hasRichContent(answerContent(data))) hasCorrectAnswer = true;
if (wrong) wrongCount++;
results[sectionType].push(data);
});
const total = Object.values(results).reduce((sum, list) => sum + list.length, 0);
if (total === 0) return null;
return { results, typeOrder, wrongCount, hasMyAnswer, hasCorrectAnswer };
}
// 随堂练习的题目结构在不同课程中差异较大,统一从题目属性、题干和选项推断。
function extractFromPracticeRoot(root) {
const quizResult = extractFromQuizPracticeRoot(root);
if (quizResult) return quizResult;
const selectors = [
'.questionLi', '.questionItem', '.question-item', '.exam-question',
'.testQuestion', '.test-question', '.stuQuestion', '.question_box',
'[data-question-id]', '[data-question-type]', '[data-questiontype]'
];
const questions = [];
const seen = new Set();
selectors.forEach(selector => {
root.querySelectorAll(selector).forEach(question => {
if (seen.has(question)) return;
const stemEl = findQuestionStemElement(question);
const type = findQuestionType(question, stemEl);
if (!stemEl || !type) return;
seen.add(question);
questions.push({ question, stemEl, type });
});
});
if (questions.length === 0) return null;
const results = { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] };
const typeOrder = [];
let wrongCount = 0;
let hasMyAnswer = false;
let hasCorrectAnswer = false;
questions.forEach(({ question, stemEl, type }) => {
const stemContent = stripQuestionPrefix(extractRichContent(stemEl));
if (!hasRichContent(stemContent)) return;
const stem = formatRichForText(stemContent);
const optionSelectors = [
'.answerBg', '.answer_bg', '.option', '.option-item', '.answerOption',
'.answer-option', '.answer_box', '.answerBox', 'li[data-option]',
'li[data-letter]', 'label.option', 'label.answer'
];
const options = [];
const optionSeen = new Set();
optionSelectors.forEach(selector => {
question.querySelectorAll(selector).forEach(option => {
if (optionSeen.has(option)) return;
optionSeen.add(option);
const parsed = selector === '.answerBg'
? extractOptionFromAnswerBg(option, options.length)
: extractPracticeOption(option, options.length);
if (parsed) options.push(parsed);
});
});
options.sort((a, b) => a.letter.localeCompare(b.letter));
const myAnswer = extractPracticeMyAnswer(question, type);
const correctAnswerContent = extractPracticeCorrectAnswer(question);
const correctAnswer = richContentToText(correctAnswerContent, () => '').replace(/\s+/g, ' ').trim();
if (myAnswer) hasMyAnswer = true;
if (!typeOrder.includes(type)) typeOrder.push(type);
const data = {
qnum: extractQuestionNumber(question, stemEl),
stem, stemContent, options,
correctAnswer, correctAnswerContent,
myAnswer, isWrong: false
};
applyQuestionScoreInfo(data, question, stemEl);
const wrong = isDataAnswerWrong(data, type);
data.isWrong = wrong;
if (hasRichContent(answerContent(data))) hasCorrectAnswer = true;
if (wrong) wrongCount++;
results[type].push(data);
});
if (!hasQuestions({ results })) return null;
return { results, typeOrder, wrongCount, hasMyAnswer, hasCorrectAnswer };
}
// 从章节测验/考试页面(.TiMu.newTiMu 结构)提取题目
// 手机端 /work/phone/work 使用 .Py-mian1 结构,桌面选择器在这里完全不存在。
// 只读题干、题型、选项和已显示答案,不触发提交或答题。
function extractFromPhoneWorkRoot(root) {
const questions = Array.from(root.querySelectorAll('.Py-mian1'));
if (!questions.length) return null;
// 使用题库内部统一的类型键(而不是页面显示的“单选题”等标签),
// 否则批量结果合并时会被 QUESTION_BANK_TYPES 过滤掉。
const typeMap = { '0': '单选', '1': '多选', '2': '填空', '3': '判断' };
const results = { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] };
const typeOrder = [];
let wrongCount = 0;
let hasMyAnswer = false;
let hasCorrectAnswer = false;
questions.forEach((question, index) => {
const title = question.querySelector('.Py-m1-title, .Py-m1-title.fs16');
if (!title) return;
const inputs = Array.from(question.querySelectorAll('input[id*="answertype"], input[name*="answertype"]'));
const typeValue = inputs.map(input => input.value || input.getAttribute('value')).find(value => typeMap[String(value)] !== undefined);
const sectionType = typeMap[String(typeValue)] || detectTypeFromText(title.textContent || '');
if (!sectionType || !results[sectionType]) return;
if (!typeOrder.includes(sectionType)) typeOrder.push(sectionType);
const stemContent = stripQuestionPrefix(extractRichContent(title));
if (!hasRichContent(stemContent)) return;
let options = [];
if (sectionType === '单选' || sectionType === '多选') {
options = Array.from(question.querySelectorAll('.answerList li')).map((li, optionIndex) => {
const parsed = stripOptionPrefix(extractRichContent(li));
const marker = li.querySelector('em[id-param], em[data-value], .num_option');
const rawLetter = marker && (marker.getAttribute('id-param') || marker.getAttribute('data-value') || marker.textContent);
const letterMatch = String(rawLetter || '').trim().match(/[A-H]/i) || String(parsed.letter || '').match(/[A-H]/i);
const letter = letterMatch ? letterMatch[0].toUpperCase() : String.fromCharCode(65 + optionIndex);
const text = richTextOnly(parsed.content);
return text || hasRichContent(parsed.content) ? { letter, text, content: parsed.content } : null;
}).filter(Boolean).filter((item, itemIndex, list) => list.findIndex(other => other.letter === item.letter) === itemIndex);
} else if (sectionType === '判断题') {
options = [{ letter: 'A', text: '对', content: [{ type: 'text', text: '对' }] }, { letter: 'B', text: '错', content: [{ type: 'text', text: '错' }] }];
}
const correctAnswerContent = extractCorrectAnswerContent(question);
const correctAnswer = richTextOnly(correctAnswerContent);
const myAnswer = extractPracticeMyAnswer(question, sectionType);
if (myAnswer) hasMyAnswer = true;
if (correctAnswer) hasCorrectAnswer = true;
const numberMatch = (title.textContent || '').match(/(?:^|\s)(\d{1,4})\s*[.、.)]/);
const data = {
qnum: numberMatch ? Number(numberMatch[1]) : index + 1,
stem: formatRichForText(stemContent), stemContent, options,
correctAnswer, correctAnswerContent, myAnswer,
isWrong: false
};
applyQuestionScoreInfo(data, question, title);
data.isWrong = isDataAnswerWrong(data, sectionType);
if (hasRichContent(answerContent(data))) hasCorrectAnswer = true;
if (data.isWrong) wrongCount++;
results[sectionType].push(data);
});
if (!hasQuestions({ results })) return null;
return { results, typeOrder, wrongCount, hasMyAnswer, hasCorrectAnswer };
}
function extractFromTiMuRoot(root) {
// 章节测验在不同版本中,题目容器可能是 #ZyBottom .aiArea,
// 也可能直接以 .TiMu 挂在页面上。两种结构都作为候选。
let areas = Array.from(root.querySelectorAll('#ZyBottom .aiArea, .aiArea'));
if (areas.length === 0) {
areas = Array.from(root.querySelectorAll('.TiMu.newTiMu, .TiMu'));
}
if (areas.length === 0) return null;
const results = { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] };
const typeOrder = [];
let wrongCount = 0;
let hasAnyMyAnswer = false;
let hasCorrectAnswer = false;
let currentType = null;
// 提取章节测验页面中我的答案
function extractMyAnswerTiMu(qDiv) {
const myAnswerBx = qDiv.querySelector('.newAnswerBx .myAnswerBx');
const con = myAnswerBx && myAnswerBx.querySelector('.myAnswer .answerCon');
if (con && con.textContent.trim()) return con.textContent.trim();
// /mooc-ans/mooc2/work/view 等作业详情页的答案位于 .mark_answer;
// 页面同样可能带 .TiMu/.aiArea,不能因优先走本分支而漏掉它。
return extractMyAnswer(qDiv, '');
}
// 提取章节测验页面中正确答案的富文本内容
function extractCorrectAnswerContentTiMu(qDiv) {
const correctBx = qDiv.querySelector('.newAnswerBx .correctAnswerBx');
const con = correctBx && correctBx.querySelector('.correctAnswer .answerCon');
if (con) {
const content = stripAnswerLabel(extractRichContent(con));
if (hasRichContent(content)) return content;
}
// 回退到作业详情的 .mark_answer/.mark_key/.colorGreen 结构。
return extractCorrectAnswerContent(qDiv);
}
// 判断章节测验页面中是否答错
function isAnswerWrongTiMu(qDiv, myAnswer, correctAnswer, qtype) {
// 复用通用错题判断:同时支持 marking_dui/cuo 以及作业详情页的
// icon-dui/cuo、rightIcon/wrongIcon 等状态图标,最后才比对答案。
return isAnswerWrong(qDiv, myAnswer, correctAnswer, qtype);
}
const seenTiMu = new Set();
areas.forEach(area => {
// 题型标题通常位于 aiArea 内部或其前一个兄弟节点。
const typeTit = area.querySelector('.newTestType')
|| (area.previousElementSibling && area.previousElementSibling.querySelector('.newTestType'));
if (typeTit) {
currentType = detectTypeFromText(typeTit.textContent || '');
if (currentType && !typeOrder.includes(currentType)) typeOrder.push(currentType);
}
// 一个 aiArea 可能包含多道 .TiMu;旧逻辑只取第一道,导致章节题量偏少。
const qDivs = area.matches('.TiMu.newTiMu, .TiMu')
? [area]
: Array.from(area.querySelectorAll('.TiMu.newTiMu, .TiMu'));
qDivs.forEach(qDiv => {
if (!qDiv || seenTiMu.has(qDiv)) return;
seenTiMu.add(qDiv);
const titleDiv = qDiv.querySelector('.Zy_TItle');
if (!titleDiv) return;
const numEl = titleDiv.querySelector('i.fl');
const qnum = numEl ? parseInt(numEl.textContent.trim(), 10) || 0 : 0;
const qtContent = titleDiv.querySelector('.qtContent');
if (!qtContent) return;
const stemContent = stripQuestionPrefix(extractRichContent(qtContent));
if (!hasRichContent(stemContent)) return;
const stem = formatRichForText(stemContent);
// 题型识别:优先使用最近遇到的题型标题,其次从题干标签识别
let sectionType = currentType;
if (!sectionType) {
const typeLabel = qtContent.querySelector('.newZy_TItle');
// 部分新版章节测验不再使用 .newZy_TItle,题型以“【单选题】”
// 等文本直接写在题干中;此时从整个题干识别。
sectionType = detectTypeFromText(`${typeLabel ? typeLabel.textContent : ''} ${qtContent.textContent || ''}`);
}
if (!sectionType) return;
if (!typeOrder.includes(sectionType)) typeOrder.push(sectionType);
// 已完成考试的新版阅卷页常为 .Zy_ulTop > li,旧版才带 qtDetail。
// 不再只限定 qtDetail,否则题干正常但所有选择题选项会为空。
const options = extractChoiceOptions(qDiv);
const myAnswer = extractMyAnswerTiMu(qDiv);
if (myAnswer) hasAnyMyAnswer = true;
const correctAnswerContent = extractCorrectAnswerContentTiMu(qDiv);
const correctAnswer = richContentToText(correctAnswerContent, () => '').replace(/\s+/g, ' ').trim();
const data = {
qnum, stem, stemContent, options,
correctAnswer, correctAnswerContent,
myAnswer, isWrong: false
};
applyQuestionScoreInfo(data, qDiv, titleDiv);
const wrong = isAnswerWrongTiMu(qDiv, myAnswer, data.correctAnswer, sectionType)
|| isDataAnswerWrong(data, sectionType);
data.isWrong = wrong;
if (hasRichContent(answerContent(data))) hasCorrectAnswer = true;
if (wrong) wrongCount++;
results[sectionType].push(data);
});
});
if (!hasQuestions({ results })) return null;
const allQuestions = Object.values(results).flat();
window.__xxt_exam_option_debug = {
href: location.href,
questionCount: allQuestions.length,
choiceQuestionCount: allQuestions.filter(q => q.options && q.options.length).length,
answerQuestionCount: allQuestions.filter(q => q.correctAnswer).length,
wrongCount,
emptyChoiceQuestions: allQuestions
.filter(q => !q.options || !q.options.length)
.filter(q => /单选|多选/.test(String(q.stem || '')))
.slice(0, 20)
.map(q => q.qnum || 0),
answers: allQuestions.slice(0, 20).map(q => ({
qnum: q.qnum || 0, myAnswer: q.myAnswer || '', correctAnswer: q.correctAnswer || '', isWrong: !!q.isWrong
}))
};
return { results, typeOrder, wrongCount, hasMyAnswer: hasAnyMyAnswer, hasCorrectAnswer };
}
// 从指定文档/根节点提取题目(支持 .mark_item、.questionLi、.TiMu.newTiMu 三种结构,富文本版本)
function extractFromRoot(root) {
// 先识别新版考试阅卷页。它同样可能有通用题目 class,必须在
// 通用练习分支之前读取单题得分,才能将 0 分题可靠归入错题。
const scoreReviewResult = extractFromScoreReviewRoot(root);
if (scoreReviewResult) return scoreReviewResult;
const phoneWorkResult = extractFromPhoneWorkRoot(root);
if (phoneWorkResult) return phoneWorkResult;
// 优先章节测验/考试页面结构
const tiMuResult = extractFromTiMuRoot(root);
if (tiMuResult) return tiMuResult;
// 与原脚本一致:已批阅作业页的 .mark_item 必须优先于通用
// .questionLi/随堂练习分支。两者会同时出现,若先走练习分支就会
// 只提取题目和选项、漏掉 .mark_answer 中的正确答案。
const markItems = root.querySelectorAll('.mark_item');
if (markItems.length === 0) {
const practiceResult = extractFromPracticeRoot(root);
if (practiceResult) return practiceResult;
}
const results = { '单选': [], '多选': [], '填空': [], '判断': [], '简答': [] };
const typeOrder = [];
// 优先 .mark_item 结构(带答案的作业详情页)
if (markItems.length > 0) {
let wrongCount = 0;
let hasAnyMyAnswer = false;
let hasCorrectAnswer = false;
markItems.forEach(markItem => {
const typeTit = markItem.querySelector('.type_tit');
if (!typeTit) return;
const sectionType = detectTypeFromText(typeTit.textContent || '');
if (!sectionType) return;
if (!typeOrder.includes(sectionType)) typeOrder.push(sectionType);
const questionLis = markItem.querySelectorAll('.questionLi');
questionLis.forEach(qLi => {
const qtContent = qLi.querySelector('.qtContent');
if (!qtContent) return;
// 富文本提取题干
const stemContent = stripQuestionPrefix(extractRichContent(qtContent));
if (!hasRichContent(stemContent)) return;
const stem = formatRichForText(stemContent);
// 作业详情页不总是使用 .mark_letter;复用所有考试结构的选项选择器。
const options = extractChoiceOptions(qLi);
const correctAnswerContent = extractCorrectAnswerContent(qLi);
const correctAnswer = richContentToText(correctAnswerContent, () => '').replace(/\s+/g, ' ').trim();
const myAnswer = extractMyAnswer(qLi, sectionType);
if (myAnswer) hasAnyMyAnswer = true;
const data = {
stem, stemContent,
options, correctAnswer, correctAnswerContent,
myAnswer, isWrong: false
};
applyQuestionScoreInfo(data, qLi, qtContent);
const wrong = isAnswerWrong(qLi, myAnswer, data.correctAnswer, sectionType)
|| isDataAnswerWrong(data, sectionType);
data.isWrong = wrong;
if (hasRichContent(answerContent(data))) hasCorrectAnswer = true;
if (wrong) wrongCount++;
results[sectionType].push(data);
});
});
return { results, typeOrder, wrongCount, hasMyAnswer: hasAnyMyAnswer, hasCorrectAnswer };
}
// 新版页面 .questionLi 结构(学习页面,通常无答案)
const questionLis = root.querySelectorAll('.questionLi');
if (questionLis.length > 0) {
questionLis.forEach(qLi => {
const stemEl = findQuestionStemElement(qLi);
const typeName = `${questionAttributeText(qLi)} ${qLi.getAttribute('typeName') || ''}`;
const sectionType = detectTypeFromText(typeName) || findQuestionType(qLi, stemEl);
if (!sectionType) return;
if (!typeOrder.includes(sectionType)) typeOrder.push(sectionType);
const qData = extractFromQuestionLi(qLi, sectionType);
if (qData) results[sectionType].push(qData);
});
return { results, typeOrder, wrongCount: 0, hasMyAnswer: false, hasCorrectAnswer: false };
}
return { results, typeOrder, wrongCount: 0, hasMyAnswer: false, hasCorrectAnswer: false };
}
// 从单个 .questionLi 提取题目(富文本版本)
function extractFromQuestionLi(qLi, sectionType) {
const markName = findQuestionStemElement(qLi);
if (!markName) return null;
const stemContent = stripQuestionPrefix(extractRichContent(markName));
if (!hasRichContent(stemContent)) return null;
const stem = formatRichForText(stemContent);
const options = [];
const optionSelectors = [
'.answerBg', '.answer_bg', '.option', '.option-item', '.answerOption',
'.answer-option', '.answer_box', '.answerBox', 'li[data-option]',
'li[data-letter]', 'label.option', 'label.answer'
];
const seenOptions = new Set();
optionSelectors.forEach(selector => {
qLi.querySelectorAll(selector).forEach((option, i) => {
if (seenOptions.has(option)) return;
seenOptions.add(option);
const parsed = selector === '.answerBg'
? extractOptionFromAnswerBg(option, options.length)
: extractPracticeOption(option, options.length);
if (parsed) options.push(parsed);
});
});
options.sort((a, b) => a.letter.localeCompare(b.letter));
return {
stem, stemContent, options,
correctAnswer: '', correctAnswerContent: [],
myAnswer: '', isWrong: false
};
}
// 递归遍历所有 iframe(含嵌套 iframe)查找题目
function extractFromIframesRecursive(root) {
const iframes = root.querySelectorAll('iframe');
for (const iframe of iframes) {
let doc;
try {
doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
} catch (e) { continue; }
if (!doc) continue;
const result = extractFromRoot(doc);
if (hasQuestions(result)) return result;
const nested = extractFromIframesRecursive(doc);
if (nested) return nested;
}
return null;
}
// ==================== TXT 格式化(富文本) ====================
function formatOutput(results, typeOrder, withAnswers = false) {
const typeLabels = {
'单选': '单选题', '多选': '多选题', '填空': '填空题',
'判断': '判断题', '简答': '简答题',
};
const typeNumbers = ['一', '二', '三', '四', '五', '六'];
let output = '';
let globalNum = 0;
let sectionIdx = 0;
for (const qtype of typeOrder) {
const questions = results[qtype];
if (!questions || questions.length === 0) continue;
const label = typeLabels[qtype];
const num = typeNumbers[sectionIdx] || sectionIdx + 1;
output += `${num}. ${label}(共${questions.length}题)\n`;
for (const q of questions) {
globalNum++;
output += `${globalNum}. ${formatRichForText(questionContent(q))}\n`;
if (q.options && q.options.length > 0) {
for (const opt of q.options) {
output += `${opt.letter}. ${formatRichForText(optionContent(opt))}\n`;
}
}
if (withAnswers) {
const answer = formatRichForText(answerContent(q)) || unavailableCorrectAnswerText(q);
output += `答案:${answer}\n`;
}
output += '\n';
}
sectionIdx++;
}
return output.trim();
}
function formatAnswersTXT(results, typeOrder) {
const typeLabels = { '单选': '单选题', '多选': '多选题', '填空': '填空题', '判断': '判断题', '简答': '简答题' };
const typeNumbers = ['一', '二', '三', '四', '五', '六'];
let output = '';
let globalNum = 0;
let sectionIdx = 0;
for (const qtype of typeOrder) {
const questions = results[qtype];
if (!questions || questions.length === 0) continue;
const num = typeNumbers[sectionIdx] || sectionIdx + 1;
output += `${num}、${typeLabels[qtype]}\n\n`;
sectionIdx++;
for (const q of questions) {
globalNum++;
const answer = formatRichForText(answerContent(q)) || unavailableCorrectAnswerText(q);
if (qtype === '填空' && answer.includes(';')) {
const parts = answer.split(';').map(p => p.trim().replace(/^\(\d+\)\s*/, ''));
output += `${globalNum}. \n`;
parts.forEach((part, i) => { output += `(${i + 1}) ${part}\n`; });
output += '\n';
} else {
output += `${globalNum}. ${answer}\n\n`;
}
}
}
return output.trim();
}
function formatOutputWithAnswers(results, typeOrder) {
return formatOutput(results, typeOrder, true);
}
function formatWrongQuestionsTXT(results, typeOrder) {
let output = '';
output += '\n\n\n';
output += '========================================\n';
output += ' 错题汇总\n';
output += '========================================\n\n';
let globalNum = 0;
for (const qtype of typeOrder) {
const questions = results[qtype];
if (!questions || questions.length === 0) continue;
if (qtype === '简答') { globalNum += questions.length; continue; }
for (const q of questions) {
globalNum++;
if (!q.isWrong) continue;
const typeLabel = qtype === '填空' ? '填空题' : '题目';
output += `${globalNum}. (${typeLabel})${formatRichForText(questionContent(q))}\n`;
output += ` 我的答案: ${q.myAnswer || '无'}\n`;
output += ` 正确答案: ${formatRichForText(answerContent(q)) || unavailableCorrectAnswerText(q)}\n\n`;
}
}
return output.replace(/\n+$/, '');
}
// ==================== Markdown 格式化(富文本) ====================
function formatOutputMD(results, typeOrder, withAnswers = false) {
const typeLabels = {
'单选': '单选题', '多选': '多选题', '填空': '填空题',
'判断': '判断题', '简答': '简答题',
};
const typeNumbers = ['一', '二', '三', '四', '五', '六'];
let output = '';
let globalNum = 0;
let sectionIdx = 0;
for (const qtype of typeOrder) {
const questions = results[qtype];
if (!questions || questions.length === 0) continue;
const label = typeLabels[qtype];
const num = typeNumbers[sectionIdx] || sectionIdx + 1;
output += `### ${num}、${label}(共${questions.length}题)\n\n`;
for (const q of questions) {
globalNum++;
output += `**${globalNum}.** ${formatRichForMD(questionContent(q))}\n\n`;
if (q.options && q.options.length > 0) {
for (const opt of q.options) {
output += `- ${opt.letter}. ${formatRichForMD(optionContent(opt))}\n`;
}
output += '\n';
} else {
output += '\n';
}
if (withAnswers) {
const answer = formatRichForMD(answerContent(q)) || unavailableCorrectAnswerText(q);
output += `> **答案:** ${answer}\n\n`;
}
}
sectionIdx++;
}
return output.trim();
}
function formatAnswersMD(results, typeOrder) {
let output = '';
let globalNum = 0;
for (const qtype of typeOrder) {
const questions = results[qtype];
if (!questions || questions.length === 0) continue;
const typeLabels = { '单选': '单选题', '多选': '多选题', '填空': '填空题', '判断': '判断题', '简答': '简答题' };
output += `**${typeLabels[qtype]}**\n\n`;
for (const q of questions) {
globalNum++;
const answer = formatRichForMD(answerContent(q)) || unavailableCorrectAnswerText(q);
if (qtype === '填空' && answer.includes(';')) {
const parts = answer.split(';').map(p => p.trim().replace(/^\(\d+\)\s*/, ''));
output += `${globalNum}. \n`;
parts.forEach((part, i) => { output += ` (${i + 1}) ${part} \n`; });
output += '\n';
} else {
output += `${globalNum}. ${answer} \n`;
}
}
output += '\n';
}
return output.trim();
}
function formatOutputWithAnswersMD(results, typeOrder) {
return formatOutputMD(results, typeOrder, true);
}
function formatWrongQuestionsMD(results, typeOrder) {
let output = '\n\n\n---\n\n';
output += '## 错题汇总\n\n';
let globalNum = 0;
for (const qtype of typeOrder) {
const questions = results[qtype];
if (!questions || questions.length === 0) continue;
if (qtype === '简答') { globalNum += questions.length; continue; }
for (const q of questions) {
globalNum++;
if (!q.isWrong) continue;
output += `**${globalNum}.** ${formatRichForMD(questionContent(q))}\n\n`;
output += `- 我的答案: ${q.myAnswer || '无'}\n`;
output += `- 正确答案: ${formatRichForMD(answerContent(q)) || unavailableCorrectAnswerText(q)}\n\n`;
}
}
return output.replace(/\n+$/, '');
}
// ==================== PDF 文档生成(富文本) ====================
function pdfEscape(text) {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML.replace(/\n/g, '
');
}
function pdfRichHTML(content) {
return (content || []).map(part => {
if (part.type === 'text') return pdfEscape(part.text || '');
if (part.type === 'break') return '
';
if (part.type === 'image' && part.url) {
return ``;
}
return '';
}).join('');
}
function buildPdfHTML(results, typeOrder, title, withAnswers, withWrong) {
const typeHeaders = { '单选': '一、单项选择题', '多选': '二、多项选择题', '填空': '三、填空题', '判断': '四、判断题', '简答': '五、简答题' };
let globalNum = 0;
let html = `
可修改题干、选项和正确答案;勾选题目后,下载、复制和存入题库只使用所选题目。图片以〔图片1〕形式保留。
${previewEscapeHtml(lines.slice(start, start + chunkSize).join('\n'))}`;
} else {
const labels = { '单选': '单选题', '多选': '多选题', '填空': '填空题', '判断': '判断题', '简答': '简答题' };
html = `