Files
yianxue-assistant/谊安学题库导出.user.js
T
2026-10-10 02:03:40 +08:00

478 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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();
})();