478 lines
23 KiB
JavaScript
478 lines
23 KiB
JavaScript
// ==UserScript==
|
||
// @name 谊安学练习题库自动导出
|
||
// @namespace yianxue-question-bank
|
||
// @version 1.0.0
|
||
// @description 进入练习后自动导出题干、选项、参考答案和解析;读取隐藏答案。
|
||
// @match http://10.119.67.7/clientcn/*
|
||
// @match https://10.119.67.7/clientcn/*
|
||
// @match file:///*
|
||
// @run-at document-idle
|
||
// @grant none
|
||
// @noframes
|
||
// ==/UserScript==
|
||
|
||
/* QuestionBankCore: 从已保存或当前练习页面读取题目,始终以“参考答案”为准。 */
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
const SELECTORS = {
|
||
question: '.pc_examtest_content',
|
||
stem: '.pc_examtest_content_question',
|
||
option: '.pc_examtest_content_list_des',
|
||
label: '.examtest_content_list_des_optionsort',
|
||
reference: '.examtest_content_answer_left',
|
||
explanation: '.pc_examtest_content_resolve',
|
||
card: '.examtestcard_count_defult'
|
||
};
|
||
const CONTROL = '.pc_examtest_content_question_showanswer,script,style,button,input,select,textarea,iframe,object,embed,link';
|
||
const TYPE_RE = /^\s*[\[【](单选题?|多选题?|判断题?|填空题?|简答题?|问答题?)[\]】]\s*/;
|
||
const NUMBER_RE = /^\s*(\d+)\s*[..、]\s*/;
|
||
|
||
function normalize(value) {
|
||
return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim();
|
||
}
|
||
|
||
function cleanClone(node) {
|
||
if (!node) return null;
|
||
const copy = node.cloneNode(true);
|
||
copy.querySelectorAll(CONTROL).forEach(function (item) { item.remove(); });
|
||
return copy;
|
||
}
|
||
|
||
function textOf(node) {
|
||
const copy = cleanClone(node);
|
||
return copy ? normalize(copy.textContent) : '';
|
||
}
|
||
|
||
function savedSourceURL(doc) {
|
||
const walker = doc.createTreeWalker(doc, 128);
|
||
let comment;
|
||
while ((comment = walker.nextNode())) {
|
||
const match = comment.nodeValue.match(/saved from url=\(\d+\)([\s\S]+?)\s*$/i);
|
||
if (match) return match[1].trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function usableURL(value) {
|
||
return /^(https?:|file:|blob:)/i.test(value || '') ? value : '';
|
||
}
|
||
|
||
function resolveURL(raw, base) {
|
||
if (!raw) return '';
|
||
raw = raw.trim();
|
||
if (/^(javascript|vbscript):/i.test(raw) || /^data:(?!image\/)/i.test(raw)) return '';
|
||
try { return new URL(raw, base || undefined).href; } catch (_) { return raw; }
|
||
}
|
||
|
||
function originalAttribute(node, name) {
|
||
return node.getAttribute('data-qb-original-' + name) || node.getAttribute(name) || '';
|
||
}
|
||
|
||
function baseURL(doc, sourceUrl) {
|
||
const base = doc.querySelector('base');
|
||
return base ? resolveURL(originalAttribute(base, 'href'), sourceUrl) || sourceUrl : sourceUrl;
|
||
}
|
||
|
||
function imageResources(node, base) {
|
||
if (!node) return [];
|
||
return Array.from(node.querySelectorAll('img')).map(function (img) {
|
||
const originalSrc = originalAttribute(img, 'src') || img.getAttribute('data-src') || img.getAttribute('data-original') || '';
|
||
return {
|
||
src: originalSrc,
|
||
url: resolveURL(originalSrc, base),
|
||
alt: normalize(img.getAttribute('alt'))
|
||
};
|
||
}).filter(function (item) { return item.url; });
|
||
}
|
||
|
||
function uniqueImages(resources) {
|
||
return Array.from(new Set(resources.map(function (item) { return item.url; })));
|
||
}
|
||
|
||
// HTML 仅作为附加数据导出。预览界面只使用 textContent,不插入这些 HTML。
|
||
function staticHTML(node, base) {
|
||
const copy = cleanClone(node);
|
||
if (!copy) return '';
|
||
const linkURLs = Array.from(copy.querySelectorAll('a')).map(function (link) { return resolveURL(originalAttribute(link, 'href'), base); });
|
||
copy.querySelectorAll('*').forEach(function (element) {
|
||
Array.from(element.attributes).forEach(function (attr) {
|
||
if (/^on|^_ng|^ng-|^style$|^data-qb-original-/i.test(attr.name)) element.removeAttribute(attr.name);
|
||
});
|
||
});
|
||
const sourceImages = node.querySelectorAll('img');
|
||
copy.querySelectorAll('img').forEach(function (img, index) {
|
||
const source = sourceImages[index];
|
||
const raw = source && (originalAttribute(source, 'src') || source.getAttribute('data-src') || source.getAttribute('data-original'));
|
||
const url = resolveURL(raw, base);
|
||
if (url) img.setAttribute('src', url); else img.removeAttribute('src');
|
||
img.removeAttribute('srcset');
|
||
});
|
||
copy.querySelectorAll('a').forEach(function (link, index) {
|
||
const href = linkURLs[index];
|
||
if (href) link.setAttribute('href', href); else link.removeAttribute('href');
|
||
});
|
||
return copy.innerHTML.trim();
|
||
}
|
||
|
||
function inferType(stem, options) {
|
||
const match = stem.match(TYPE_RE);
|
||
if (match) return match[1].replace(/题$/, '');
|
||
if (options.some(function (option) { return option.node.querySelector('.examtest_content_list_des_multiplesort'); })) return '多选';
|
||
const texts = options.map(function (option) { return option.text; });
|
||
if (texts.length === 2 && texts.every(function (value) { return /^(正确|错误|对|错|是|否)$/.test(value); })) return '判断';
|
||
return '未知';
|
||
}
|
||
|
||
function readAnswer(box) {
|
||
const reference = box.querySelector(SELECTORS.reference);
|
||
if (!reference) return '';
|
||
const pieces = Array.from(reference.querySelectorAll('.answer')).map(textOf).filter(Boolean);
|
||
if (pieces.length) {
|
||
// 多选可保存为一个 ABC span,也可分别保存为 A、B、C 多个 span。
|
||
if (pieces.every(function (piece) { return /^[A-Z\s,,、;;]+$/i.test(piece); })) return pieces.join('').replace(/[\s,,、;;]+/g, '').toUpperCase();
|
||
return pieces.join(';');
|
||
}
|
||
return textOf(reference).replace(/^\s*参考答案\s*[::]\s*/, '').trim();
|
||
}
|
||
|
||
function answerOptions(answer, options) {
|
||
if (!answer) return [];
|
||
const normalizedAnswer = answer.replace(/[\s,,、;;]+/g, '').toUpperCase();
|
||
const letterLabels = options.every(function (option) { return /^[A-Z]$/i.test(option.label); });
|
||
if (letterLabels && /^[A-Z]+$/.test(normalizedAnswer)) {
|
||
return options.filter(function (option) { return normalizedAnswer.includes(option.label.toUpperCase()); });
|
||
}
|
||
const pieces = answer.split(/[\s,,、;;]+/).filter(Boolean);
|
||
return options.filter(function (option) { return pieces.includes(option.label) || pieces.includes(option.text); });
|
||
}
|
||
|
||
function extract(doc, sourceUrl) {
|
||
if (!doc || typeof doc.querySelectorAll !== 'function') throw new TypeError('extract 需要 Document 对象。');
|
||
const source = usableURL(sourceUrl) || usableURL(doc.__questionBankSourceURL) || savedSourceURL(doc) || usableURL(doc.URL) || '';
|
||
const resourceBase = baseURL(doc, source);
|
||
const title = textOf(doc.querySelector('.pc_examtest_title')) || normalize(doc.title) || '练习题库';
|
||
const questions = Array.from(doc.querySelectorAll(SELECTORS.question)).map(function (box, index) {
|
||
const stemNode = box.querySelector(SELECTORS.stem);
|
||
const rawStem = textOf(stemNode);
|
||
const optionItems = Array.from(box.querySelectorAll(SELECTORS.option)).map(function (node, optionIndex) {
|
||
const label = textOf(node.querySelector(SELECTORS.label)) || String.fromCharCode(65 + optionIndex);
|
||
const copy = cleanClone(node);
|
||
copy.querySelectorAll(SELECTORS.label).forEach(function (item) { item.remove(); });
|
||
// 保存网页把选项图片放在与文字相邻的 _list_img 内。
|
||
const container = node.parentElement;
|
||
const resources = imageResources(container || node, resourceBase);
|
||
return { node: node, label: label, text: normalize(copy.textContent), images: uniqueImages(resources), imageResources: resources, html: staticHTML(copy, resourceBase) };
|
||
});
|
||
const type = inferType(rawStem, optionItems);
|
||
const withoutType = rawStem.replace(TYPE_RE, '');
|
||
const numberMatch = withoutType.match(NUMBER_RE);
|
||
const number = numberMatch ? Number(numberMatch[1]) : index + 1;
|
||
const question = withoutType.replace(NUMBER_RE, '').trim();
|
||
const options = optionItems.map(function (option) {
|
||
return { label: option.label, text: option.text, images: option.images, imageResources: option.imageResources, html: option.html };
|
||
});
|
||
const answer = readAnswer(box);
|
||
const matchedOptions = answerOptions(answer, options);
|
||
const resolveNode = box.querySelector(SELECTORS.explanation);
|
||
const explanation = textOf(resolveNode).replace(/^\s*(?:题目解析|答案解析|解析)\s*[::]\s*/, '').trim();
|
||
const questionResources = imageResources(stemNode, resourceBase);
|
||
const explanationResources = imageResources(resolveNode, resourceBase);
|
||
const allResources = questionResources.concat(options.flatMap(function (option) { return option.imageResources; }), explanationResources);
|
||
return {
|
||
number: number,
|
||
type: type,
|
||
question: question,
|
||
options: options,
|
||
answer: answer,
|
||
answerText: matchedOptions.map(function (option) { return option.label + '. ' + (option.text || '[图片选项]'); }).join(';'),
|
||
explanation: explanation,
|
||
images: uniqueImages(allResources),
|
||
questionImages: uniqueImages(questionResources),
|
||
explanationImages: uniqueImages(explanationResources),
|
||
imageResources: allResources,
|
||
questionHTML: staticHTML(stemNode, resourceBase),
|
||
explanationHTML: staticHTML(resolveNode, resourceBase)
|
||
};
|
||
});
|
||
const cardCount = doc.querySelectorAll(SELECTORS.card).length;
|
||
const expectedTotal = cardCount || null;
|
||
const missingAnswers = questions.filter(function (question) { return !question.answer; }).map(function (question) { return question.number; });
|
||
return {
|
||
title: title,
|
||
sourceUrl: source,
|
||
resourceBaseUrl: resourceBase,
|
||
exportedAt: new Date().toISOString(),
|
||
total: questions.length,
|
||
answered: questions.length - missingAnswers.length,
|
||
missingAnswers: missingAnswers,
|
||
expectedTotal: expectedTotal,
|
||
complete: questions.length > 0 && missingAnswers.length === 0 && (expectedTotal === null || expectedTotal === questions.length),
|
||
questions: questions
|
||
};
|
||
}
|
||
|
||
// 中和可能加载外部资源的属性后才交给 DOMParser;原值用于生成资源链接。
|
||
// 此函数不会把解析结果插入当前文档,也不执行保存网页中的任何脚本。
|
||
function parseHTML(html, sourceUrl) {
|
||
if (typeof DOMParser === 'undefined') throw new Error('此功能需要浏览器 DOMParser。');
|
||
let inert = String(html).replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '').replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, '');
|
||
inert = inert.replace(/<[a-z][^>]*>/gi, function (tag) {
|
||
return tag.replace(/\s(src|srcset|href|poster|data|style|background|action|formaction)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, function (_, name, value) {
|
||
return ' data-qb-original-' + name.toLowerCase() + '=' + value;
|
||
}).replace(/\s(on[a-z]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||
});
|
||
const doc = new DOMParser().parseFromString(inert, 'text/html');
|
||
doc.__questionBankSourceURL = sourceUrl || '';
|
||
return doc;
|
||
}
|
||
|
||
function csvCell(value) {
|
||
let text = String(value == null ? '' : value);
|
||
// Excel 会在引号包裹的 CSV 单元格中依旧识别公式;前置单引号使其按文本打开。
|
||
if (/^[\s\uFEFF]*[=+\-@]/.test(text)) text = "'" + text;
|
||
return '"' + text.replace(/"/g, '""') + '"';
|
||
}
|
||
|
||
function toCSV(bank) {
|
||
const labels = [];
|
||
bank.questions.forEach(function (question) {
|
||
question.options.forEach(function (option) { if (!labels.includes(option.label)) labels.push(option.label); });
|
||
});
|
||
const header = ['题号', '题型', '题目'].concat(labels.map(function (label) { return '选项' + label; }), ['全部选项', '参考答案', '答案对应选项', '题目解析', '图片链接', '来源网址', '题库名称', '完整性', '网页题数', '缺失答案题号']);
|
||
const rows = bank.questions.map(function (question) {
|
||
return [question.number, question.type, question.question].concat(labels.map(function (label) {
|
||
const option = question.options.find(function (item) { return item.label === label; });
|
||
return option ? [option.text].concat(option.images).filter(Boolean).join('\n') : '';
|
||
}), [question.options.map(function (option) { return option.label + '. ' + option.text + (option.images.length ? '\n' + option.images.join('\n') : ''); }).join('\n'), question.answer, question.answerText, question.explanation, question.images.join('\n'), bank.sourceUrl, bank.title, bank.complete ? (bank.expectedTotal === null ? '当前页面完整(网页题数未知)' : '完整') : '不完整', bank.expectedTotal === null ? '未知' : bank.expectedTotal, bank.missingAnswers.join('、')]);
|
||
});
|
||
return '\uFEFF' + [header].concat(rows).map(function (row) { return row.map(csvCell).join(','); }).join('\r\n') + '\r\n';
|
||
}
|
||
|
||
function toJSON(bank) { return JSON.stringify(bank, null, 2); }
|
||
|
||
function toText(bank) {
|
||
const completeness = bank.complete ? (bank.expectedTotal === null ? '当前页面完整(网页题数未知)' : '完整') : '不完整';
|
||
const lines = [bank.title, '来源:' + (bank.sourceUrl || '本地网页'), '完整性:' + completeness, '题目:' + bank.total + ';有参考答案:' + bank.answered, '网页答题卡题数:' + (bank.expectedTotal === null ? '未知,只能核对当前页面' : bank.expectedTotal), '缺失答案题号:' + (bank.missingAnswers.length ? bank.missingAnswers.join('、') : '无')];
|
||
if (bank.expectedTotal !== null && bank.total < bank.expectedTotal) lines.push('缺失题目数:' + (bank.expectedTotal - bank.total));
|
||
lines.push('');
|
||
bank.questions.forEach(function (question) {
|
||
lines.push(question.number + '. [' + question.type + '] ' + question.question);
|
||
question.questionImages.forEach(function (url) { lines.push('题目图片:' + url); });
|
||
question.options.forEach(function (option) {
|
||
lines.push(option.label + '. ' + option.text);
|
||
option.images.forEach(function (url) { lines.push('选项图片:' + url); });
|
||
});
|
||
lines.push('参考答案:' + (question.answer || '网页未提供'));
|
||
if (question.answerText) lines.push('对应选项:' + question.answerText);
|
||
lines.push('题目解析:' + (question.explanation || '网页未提供'));
|
||
question.explanationImages.forEach(function (url) { lines.push('解析图片:' + url); });
|
||
lines.push('');
|
||
});
|
||
return lines.join('\r\n');
|
||
}
|
||
|
||
function typeCounts(bank) {
|
||
return bank.questions.reduce(function (counts, question) { counts[question.type] = (counts[question.type] || 0) + 1; return counts; }, {});
|
||
}
|
||
|
||
function filenameFor(bank, format) {
|
||
const name = (bank.title || '练习题库').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').replace(/[. ]+$/, '').slice(0, 100) || '练习题库';
|
||
return name + '_题库_' + bank.total + '题' + (bank.complete ? '' : '_不完整') + '.' + format;
|
||
}
|
||
|
||
function download(bank, format) {
|
||
format = String(format || 'csv').toLowerCase();
|
||
const formats = { csv: { encode: toCSV, mime: 'text/csv;charset=utf-8' }, json: { encode: toJSON, mime: 'application/json;charset=utf-8' }, txt: { encode: toText, mime: 'text/plain;charset=utf-8' } };
|
||
if (!formats[format]) throw new Error('导出格式仅支持 csv、json、txt。');
|
||
const filename = filenameFor(bank, format);
|
||
const blob = new Blob([formats[format].encode(bank)], { type: formats[format].mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = root.document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
root.document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
// 留出时间让浏览器接管下载后再回收 URL。
|
||
root.setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
|
||
return filename;
|
||
}
|
||
|
||
root.QuestionBankCore = { version: '1.0.0', selectors: SELECTORS, extract: extract, parseHTML: parseHTML, savedSourceURL: savedSourceURL, resolveURL: resolveURL, toCSV: toCSV, toJSON: toJSON, toText: toText, typeCounts: typeCounts, filenameFor: filenameFor, download: download };
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// 源代码:构建后连同 question-bank-core.js 一起装入单个油猴脚本。
|
||
if (window.YianQuestionBankExporter) return;
|
||
const core = window.QuestionBankCore;
|
||
if (!core) throw new Error('题库提取核心未加载');
|
||
const config = { autoExport: true, format: 'csv', settleMs: 1800 };
|
||
const completed = new Set();
|
||
let state = null;
|
||
let panel = null;
|
||
let statusNode = null;
|
||
let debounceTimer = null;
|
||
let previousPractice = false;
|
||
let previousRoot = null;
|
||
let previousSignature = '';
|
||
|
||
function routeInfo() {
|
||
const url = new URL(location.href);
|
||
let params = url.searchParams;
|
||
let path = url.pathname;
|
||
if (url.hash.startsWith('#/')) {
|
||
const hashUrl = new URL(url.hash.slice(1), url.origin);
|
||
params = hashUrl.searchParams;
|
||
path = hashUrl.pathname;
|
||
}
|
||
const root = document.querySelector('app-examtest, .pc_examtest');
|
||
const local = url.protocol === 'file:';
|
||
const practice = !!root && (local
|
||
? /[?&]flag=exercise(?:&|$)/.test(core.savedSourceURL(document))
|
||
: params.get('flag') === 'exercise' && /\/exam\/[^/]+\/questions\/?$/.test(path));
|
||
return { practice, root, title: params.get('title') || '', key: path + '|' + (params.get('title') || ''), local };
|
||
}
|
||
|
||
function fingerprint(bank) {
|
||
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q =>
|
||
[q.number, q.type, q.question, q.options, q.answer, q.explanation, q.images])]);
|
||
}
|
||
|
||
function setStatus(message) {
|
||
if (statusNode && statusNode.textContent !== message) statusNode.textContent = message;
|
||
}
|
||
|
||
function createPanel() {
|
||
if (panel) return;
|
||
panel = document.createElement('div');
|
||
panel.id = 'yian-question-bank-exporter';
|
||
panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483600;max-width:min(330px,calc(100vw - 32px));padding:14px;border:1px solid #d5dde5;border-radius:12px;background:#fff;color:#233447;box-shadow:0 4px 20px #0002;font:13px/1.6 system-ui,sans-serif;text-align:left';
|
||
const heading = document.createElement('strong');
|
||
heading.textContent = '练习题库导出';
|
||
statusNode = document.createElement('div');
|
||
statusNode.setAttribute('role', 'status');
|
||
statusNode.style.cssText = 'margin:6px 0;white-space:pre-line';
|
||
const actions = document.createElement('div');
|
||
actions.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
||
for (const format of ['csv', 'json', 'txt']) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.textContent = '导出 ' + format.toUpperCase();
|
||
button.style.cssText = 'cursor:pointer;border:1px solid #d5dde5;border-radius:6px;background:#f5f8fa;padding:5px 9px;color:#233447;font:inherit';
|
||
button.addEventListener('click', () => {
|
||
try { exportCurrent(format); }
|
||
catch (error) { setStatus('导出失败:' + error.message); }
|
||
});
|
||
actions.append(button);
|
||
}
|
||
const label = document.createElement('label');
|
||
label.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:8px;cursor:pointer';
|
||
const checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.checked = config.autoExport;
|
||
checkbox.addEventListener('change', () => {
|
||
config.autoExport = checkbox.checked;
|
||
tick();
|
||
});
|
||
label.append(checkbox, document.createTextNode('进入练习后自动导出 CSV'));
|
||
panel.append(heading, statusNode, actions, label);
|
||
document.body.append(panel);
|
||
}
|
||
|
||
function exportCurrent(format = config.format) {
|
||
if (!routeInfo().practice) throw new Error('请先进入练习页面');
|
||
const bank = core.extract(document, location.href);
|
||
if (!bank.total) throw new Error('页面还没有题目,请稍后重试');
|
||
// 手动导出可保存不完整内容,文件及提示都会标记缺失。
|
||
core.download(bank, format);
|
||
setStatus((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title + '\n'
|
||
+ bank.total + ' 题,' + bank.answered + ' 个答案'
|
||
+ (bank.complete ? '。' : ',请检查缺失内容。')
|
||
+ '\n若未出现文件,请点击上方导出按钮。');
|
||
return bank;
|
||
}
|
||
|
||
function tick() {
|
||
const route = routeInfo();
|
||
if (!route.practice) {
|
||
if (panel) panel.remove();
|
||
panel = statusNode = null;
|
||
state = null;
|
||
previousPractice = false;
|
||
previousRoot = route.root;
|
||
return;
|
||
}
|
||
createPanel();
|
||
const bank = core.extract(document, location.href);
|
||
const signature = fingerprint(bank);
|
||
const now = Date.now();
|
||
if (!state || state.key !== route.key) {
|
||
// SPA 改 URL 后旧题目可能仍留在 DOM,等待题目或组件更新。
|
||
const transitioning = !!previousRoot && !!(previousPractice || previousSignature)
|
||
&& previousRoot === route.root && previousSignature === signature;
|
||
state = { key: route.key, signature, since: now, initialRoot: route.root,
|
||
initialSignature: signature, waitingForReplacement: transitioning };
|
||
}
|
||
if (state.waitingForReplacement && (route.root !== state.initialRoot || signature !== state.initialSignature)) {
|
||
state.waitingForReplacement = false;
|
||
state.since = now;
|
||
}
|
||
previousPractice = true;
|
||
previousRoot = route.root;
|
||
previousSignature = signature;
|
||
if (state.signature !== signature) {
|
||
state.signature = signature;
|
||
state.since = now;
|
||
}
|
||
if (state.waitingForReplacement || (route.title && route.title.trim() !== bank.title.trim())) {
|
||
setStatus('等待本次练习的题目加载…');
|
||
return;
|
||
}
|
||
if (!bank.total || now - state.since < config.settleMs) {
|
||
setStatus('正在读取题目与隐藏答案…');
|
||
return;
|
||
}
|
||
if (bank.expectedTotal !== null && bank.expectedTotal !== bank.total) {
|
||
setStatus('已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。\n若页面分页,请逐页导出;手动导出会标记不完整。');
|
||
return;
|
||
}
|
||
if (!bank.complete) {
|
||
setStatus('已读取 ' + bank.total + ' 题,缺少 ' + bank.missingAnswers.length + ' 个答案。\n自动导出等待答案加载;可手动保存现有内容。');
|
||
return;
|
||
}
|
||
const exportKey = state.key + '|' + signature;
|
||
if (completed.has(exportKey)) return;
|
||
if (!config.autoExport) {
|
||
setStatus(bank.title + '\n' + bank.total + ' 题,答案齐全,可点击导出。');
|
||
return;
|
||
}
|
||
try {
|
||
exportCurrent(config.format);
|
||
completed.add(exportKey);
|
||
} catch (error) {
|
||
config.autoExport = false;
|
||
const checkbox = panel.querySelector('input');
|
||
if (checkbox) checkbox.checked = false;
|
||
setStatus('自动下载失败:' + error.message + '\n请点击导出按钮重试。');
|
||
console.error('[谊安学题库导出]', error);
|
||
}
|
||
}
|
||
|
||
const observer = new MutationObserver(records => {
|
||
if (records.every(record => panel && (record.target === panel || panel.contains(record.target)))) return;
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(tick, 300);
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||
const timer = setInterval(tick, 750); // 同时覆盖 Angular 路由和返回/前进。
|
||
window.YianQuestionBankExporter = Object.freeze({
|
||
exportCurrent,
|
||
collectCurrent: () => core.extract(document, location.href),
|
||
stop: () => { observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); if (panel) panel.remove(); }
|
||
});
|
||
tick();
|
||
})();
|