feat: add automatic practice question bank export

This commit is contained in:
薇星幻海
2026-10-10 02:03:40 +08:00
commit e265983170
9 changed files with 1325 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# 原始下载网页、压缩包和题库不提交到源代码仓库。
网页样本/
导出结果/
*.zip
node_modules/
.DS_Store
+59
View File
@@ -0,0 +1,59 @@
# 谊安学练习题库导出
进入谊安学“练习”页面后自动下载 CSV,包含题干、全部选项、参考答案、答案对应选项、题目解析及图片链接。也可手动导出 JSON、TXT。
脚本读取网页中已加载的参考答案,包括隐藏答案,不点击选项或“查看答案”,不提交练习。样本中的“查看答案”会记录错题,因此采用直接读取的方式。
## 在线自动导出
1. 在 Chrome / Edge 浏览器的油猴(Tampermonkey)中新建脚本。
2. 将 `谊安学题库导出.user.js` 的全部内容粘贴进去,保存并启用。
3. 刷新谊安学网页,正常登录,点击进入“练习”。
4. 等待题目和答案加载完毕,CSV 会自动下载至浏览器下载目录,右下角显示导出状态。也可点击 CSV / JSON / TXT 按钮重新导出。
脚本匹配 `http://10.119.67.7/clientcn/*` 和对应 HTTPS 地址,仅在 `/exam/<id>/questions?flag=exercise` 页面自动导出;考试页面不会触发。站点地址变化时,修改脚本顶部的 `@match` 地址。
如果脚本未运行,请检查油猴是否启用、浏览器是否允许扩展运行用户脚本。如果提示已发起下载但没有文件,点击右下角导出按钮;浏览器若提示下载权限,允许本页面下载。
## 从已下载网页导出
1. 解压原始网页压缩包。
2. 双击本项目的 `离线导出题库.html`,保持它与 `question-bank-core.js` 放在同一目录。
3. 选择解压得到的 `谊安学.html`,查看题目数和答案完整性,点击所需导出格式。
本地工具只解析 HTML 文本,不运行原网站脚本、不连接原网站。不需要登录或安装 Node.js。输入的是解压后的 HTML 文件。
油猴脚本也支持原始保存网页;需要在浏览器扩展设置中开启“允许访问文件网址”。通常优先使用上面的离线工具,避免原网页脚本重新启动后清空已保存内容。
## 导出内容和限制
- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。
- 参考答案与用户已选择的答案分开处理,导出的是网页提供的参考答案。缺失答案保留为空并提示,不推测答案。
- 自动导出等待题目内容稳定,并核对答题卡数量;存在缺题或缺答案时不会自动下载,手动导出会标记不完整。没有答题卡数量时,只能核对当前 DOM 中的题目,不能保证包含网站未加载的题目。
- 本版本针对提供的桌面练习页。分页或滚动后才加载的题目,需要先加载全部内容;未加载的内容无法从 DOM 读取。
- 图片以链接导出,不会自动打包图片文件。保存网页中的相对图片地址按原始网页地址解析,离线时这些地址可能不可访问。
- 提供的样本为“安全领导力-1”,有 120 题,单选、多选、判断各 40 题;全部参考答案存在,其中 112 题答案被隐藏。样本的 120 处解析文本均为空,导出时也为空。
- 同一次页面运行中,相同练习内容只自动下载一次;重新刷新或手动点击导出仍可下载。
## 开发和验证
`question-bank-core.js` 是 DOM 提取与文件导出核心;`practice-exporter.js` 负责练习页面识别、加载等待、状态面板和自动下载。发布用油猴脚本已构建,可直接安装。
修改源文件后运行:
```powershell
npm run build
```
浏览器验证依赖 Playwright 和本机 Chrome,可执行:
```powershell
npm install
npm test
```
测试使用合成题目模拟隐藏答案、错误选项、延迟加载、页面切换、缺题、缺答案和下载,且不会连接谊安学服务器。若本地存在 `网页样本/考试答案/谊安学.html`,还会核对该样本的 120 道题。
原始压缩包、解压网页和实际题库文件已在 `.gitignore` 中排除,不会随源代码提交。
远程仓库:`ssh://git@192.168.10.31:2222/qishizhe/yianxue-shauti.git`。
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync, writeFileSync } from 'node:fs';
const metadata = `// ==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==
`;
const core = readFileSync(new URL('./question-bank-core.js', import.meta.url), 'utf8');
const automation = readFileSync(new URL('./practice-exporter.js', import.meta.url), 'utf8');
writeFileSync(new URL('./谊安学题库导出.user.js', import.meta.url), metadata + '\n' + core + '\n' + automation, 'utf8');
console.log('已生成 谊安学题库导出.user.js');
+14
View File
@@ -0,0 +1,14 @@
{
"name": "yianxue-question-bank-exporter",
"version": "1.0.0",
"private": true,
"description": "谊安学练习题库自动导出与保存网页离线解析",
"scripts": {
"build": "node build-userscript.mjs",
"pretest": "node build-userscript.mjs",
"test": "node tests/verify.cjs"
},
"devDependencies": {
"playwright": "1.62.1"
}
}
+173
View File
@@ -0,0 +1,173 @@
(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();
})();
+290
View File
@@ -0,0 +1,290 @@
/* 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);
+195
View File
@@ -0,0 +1,195 @@
/* 真实浏览器验证:只启动本机测试服务,不请求原网站。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8');
const scriptSource = fs.readFileSync(path.join(root, '谊安学题库导出.user.js'), 'utf8');
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa');
fs.mkdirSync(qaDir, { recursive: true });
const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html');
function option(label, text, selected = false) {
return `<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des${selected ? ' examtest_content_list_des_checked' : ''}" onclick="window.answerClicks++"><span class="examtest_content_list_des_optionsort">${label}</span>${text}</div></div>`;
}
function fixture(title = '模拟练习', { missingAnswer = false, cardCount = 3 } = {}) {
const content = [
{ type: '单选', stem: '包含引号的项目 "甲,乙"?', options: [option('A', '错误选择', true), option('B', '正确选项')], answer: '<span class="answer">B</span>', explanation: '请依据参考答案。' },
{ type: '多选', stem: '选择符合条件的项目。', options: ['A', 'B', 'C', 'D', 'E'].map(x => option(x, '项目' + x)), answer: '<span class="answer">A</span><span class="answer">E</span>', explanation: '' },
{ type: '判断', stem: '隐藏答案应能读取。', options: [option('A', '对'), option('B', '错')], answer: missingAnswer ? '' : '<span class="answer">B</span>', explanation: '' }
].map((q, i) => `<div class="pc_exam_list" app-exam-question><a id="examtest${i}"></a><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p><span>[${q.type}]</span> ${i + 1}. ${q.stem}<span class="pc_examtest_content_question_showanswer" onclick="window.answerClicks++">查看答案</span></p></div><div class="pc_examtest_content_list">${q.options.join('')}</div><div class="hidden"><div class="pc_examtest_content_answer_top"><div class="examtest_content_answer_left">参考答案:${q.answer}</div><div>答错了</div></div><div class="pc_examtest_content_resolve">题目解析:<span>${q.explanation}</span></div></div></div></div>`).join('');
return `<!doctype html><title>模拟谊安学练习</title><style>.hidden{display:none}body{font:16px system-ui;margin:30px}.pc_examtest_content{padding:15px;border-bottom:1px solid #ddd}.pc_examtest_title{font-size:24px}</style><app-examtest><div class="pc_examtest_title">${title}</div>${content}<div class="pc_examtest_card">${Array.from({ length: cardCount }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div></app-examtest>`;
}
function parseCSV(text) {
text = text.replace(/^\uFEFF/, '');
const rows = []; let row = []; let cell = ''; let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '"') {
if (quoted && text[i + 1] === '"') { cell += '"'; i++; }
else quoted = !quoted;
} else if (!quoted && ch === ',') { row.push(cell); cell = ''; }
else if (!quoted && (ch === '\r' || ch === '\n')) {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell); rows.push(row); row = []; cell = '';
} else cell += ch;
}
if (cell || row.length) { row.push(cell); rows.push(row); }
assert.equal(quoted, false, 'CSV 引号需成对');
return rows;
}
(async function main() {
const server = http.createServer((req, res) => {
const requestURL = new URL(req.url, 'http://localhost');
if (requestURL.pathname.endsWith('/question-bank-core.js')) {
res.writeHead(200, { 'Content-Type': 'text/javascript;charset=utf-8' }); res.end(coreSource);
} else if (requestURL.pathname.startsWith('/tool/')) {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fs.readFileSync(path.join(root, '离线导出题库.html')));
} else {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fixture(requestURL.searchParams.get('title') || '模拟练习'));
}
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1366, height: 900 } });
const page = await context.newPage();
const errors = []; page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
const downloads = []; page.on('download', download => downloads.push(download));
const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise';
await page.goto(practiceURL('模拟练习'));
await page.evaluate(() => { window.answerClicks = 0; });
const firstDownload = page.waitForEvent('download');
await page.addScriptTag({ content: scriptSource });
const downloaded = await firstDownload;
await downloaded.saveAs(path.join(qaDir, 'synthetic.csv'));
const bank = await page.evaluate(() => QuestionBankCore.extract(document));
assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true);
assert.equal(bank.questions[0].answer, 'B'); assert.equal(bank.questions[0].options[0].text, '错误选择');
assert.equal(bank.questions[1].answer, 'AE'); assert.equal(bank.questions[1].options.length, 5);
assert.equal(bank.questions[2].answerText, 'B. 错'); assert.equal(bank.questions[0].explanation, '请依据参考答案。');
assert(bank.questions.every(q => !q.question.includes('查看答案')));
assert.equal(await page.evaluate(() => window.answerClicks), 0);
const csv = fs.readFileSync(path.join(qaDir, 'synthetic.csv'), 'utf8');
assert.equal(csv.charCodeAt(0), 0xFEFF);
const rows = parseCSV(csv); assert.equal(rows.length, 4);
assert(rows.every(row => row.length === rows[0].length));
assert.equal(rows[1][rows[0].indexOf('题目')], '包含引号的项目 "甲,乙"?');
assert(rows[0].includes('选项E'));
const formulaCSV = await page.evaluate(() => {
const b = QuestionBankCore.extract(document); b.questions[0].question = '=HYPERLINK("http://invalid")'; return QuestionBankCore.toCSV(b);
});
assert(parseCSV(formulaCSV)[1][2].startsWith("'="));
await page.waitForTimeout(2400); assert.equal(downloads.length, 1, '同一题库不应重复自动下载');
await page.screenshot({ path: path.join(qaDir, 'practice-desktop.png') });
console.log('PASS 隐藏答案、错误已选项隔离、多选、判断、完整CSV与单次自动下载');
// 新 URL 与新 DOM 在下一次扫描前一同更新,仍应成功自动导出。
const nextDownload = page.waitForEvent('download');
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest'));
}, { url: practiceURL('第二份练习'), html: fixture('第二份练习') });
assert.equal((await nextDownload).suggestedFilename(), '第二份练习_题库_3题.csv');
console.log('PASS SPA 页面与地址同步切换自动导出');
// URL 先改变、旧内容仍在时不得导出旧题;题目延后到达再导出。
const beforeDelayed = downloads.length;
await page.evaluate(url => history.pushState({}, '', url), practiceURL('延迟练习'));
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeDelayed);
const delayedDownload = page.waitForEvent('download');
await page.evaluate(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('app-examtest').innerHTML = doc.querySelector('app-examtest').innerHTML;
}, fixture('延迟练习'));
assert.equal((await delayedDownload).suggestedFilename(), '延迟练习_题库_3题.csv');
console.log('PASS SPA 旧页面残留与延迟加载等待');
const beforeMissing = downloads.length;
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
}, { url: practiceURL('缺答案练习'), html: fixture('缺答案练习', { missingAnswer: true }) });
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeMissing);
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('缺少 1 个答案'));
const manual = page.waitForEvent('download'); await page.getByRole('button', { name: '导出 CSV', exact: true }).click();
assert((await manual).suggestedFilename().includes('_不完整'));
const missing = await page.evaluate(() => QuestionBankCore.extract(document));
assert.equal(missing.complete, false); assert.deepEqual(missing.missingAnswers, [3]);
console.log('PASS 缺答案不自动下载,手动导出标记不完整');
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
}, { url: practiceURL('缺题练习'), html: fixture('缺题练习', { cardCount: 4 }) });
const beforeCount = downloads.length;
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeCount);
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('3 / 4'));
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
await page.waitForTimeout(1100); assert.equal(await page.locator('#yian-question-bank-exporter').count(), 0);
assert.equal(await page.evaluate(() => window.answerClicks), 0);
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture();
const tool = await context.newPage();
tool.on('pageerror', error => errors.push(error.message));
tool.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
const toolURL = require('node:url').pathToFileURL(path.join(root, '离线导出题库.html')).href;
await tool.goto(toolURL);
assert.equal(tool.url(), toolURL);
assert.equal(await tool.title(), '离线导出题库');
assert(await tool.getByRole('heading', { name: '从已保存的练习网页导出题库' }).isVisible());
const sourceRequests = [];
tool.on('request', request => { if (!request.url().startsWith(origin)) sourceRequests.push(request.url()); });
await tool.locator('#file').setInputFiles({ name: '谊安学.html', mimeType: 'text/html', buffer: Buffer.from(sampleHTML) });
await tool.locator('#csv').waitFor({ state: 'visible' });
await tool.waitForFunction(() => !document.getElementById('csv').disabled);
assert.equal(sourceRequests.length, 0, '解析网页时不应请求原网站资源');
const realBank = await tool.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), sampleHTML);
if (fs.existsSync(samplePath)) {
assert.equal(realBank.total, 120); assert.equal(realBank.answered, 120); assert.equal(realBank.expectedTotal, 120);
assert.equal(realBank.complete, true);
assert.deepEqual(realBank.questions.reduce((o, q) => (o[q.type] = (o[q.type] || 0) + 1, o), {}), { 单选: 40, 多选: 40, 判断: 40 });
assert.equal(realBank.questions[0].answer, 'C'); assert.equal(realBank.questions[0].options[0].text, '2种');
assert.equal(realBank.questions[20].answer, 'AB'); assert.equal(realBank.questions[40].answerText, 'A. 对');
assert.equal(realBank.questions[100].answerText, 'B. 错');
assert(realBank.questions.every(q => q.explanation === ''));
assert.deepEqual(realBank.questions.map(q => q.number), Array.from({ length: 120 }, (_, i) => i + 1));
console.log('PASS 真实样本120题120答案,三种题型各40题、全部解析为空');
}
for (const format of ['csv', 'json', 'txt']) {
const promise = tool.waitForEvent('download'); await tool.locator('#' + format).click();
const file = await promise; await file.saveAs(path.join(qaDir, file.suggestedFilename()));
assert((await file.failure()) === null);
}
await tool.screenshot({ path: path.join(qaDir, 'offline-desktop.png'), fullPage: true });
await tool.setViewportSize({ width: 390, height: 844 });
assert(await tool.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
await tool.screenshot({ path: path.join(qaDir, 'offline-mobile.png'), fullPage: true });
console.log('PASS 离线工具文件导入、三格式下载、无联网、桌面与手机布局');
// 验证原网页的文档级保存注释可以激活 file: 模式。
const local = await context.newPage();
const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习');
const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML);
await local.goto(require('node:url').pathToFileURL(localPath).href);
const localDownload = local.waitForEvent('download'); await local.addScriptTag({ content: scriptSource });
assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv');
assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n'));
console.log('PASS 本地保存网页自动识别与浏览器错误检查');
console.log('验证截图和下载文件:' + qaDir);
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+92
View File
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'">
<title>离线导出题库</title>
<style>
:root{font-family:system-ui,"Microsoft YaHei",sans-serif;color:#182a3a;background:#f4f7fa;line-height:1.65}*{box-sizing:border-box}body{margin:0;padding:40px 20px}.wrap{max-width:980px;margin:auto}.eyebrow{color:#326b95;font-size:13px;font-weight:700;letter-spacing:.12em}h1{font-size:32px;margin:8px 0 10px}p{margin:8px 0;color:#526577}.card{background:#fff;border:1px solid #dce5ed;border-radius:15px;margin:25px 0;padding:26px}.drop{display:block;border:2px dashed #a5becf;border-radius:12px;background:#f8fbfd;padding:30px;text-align:center;cursor:pointer}.drop.drag{border-color:#267eae;background:#eaf5fc}.drop strong{display:block;font-size:19px}.drop small{display:block;color:#687c8a;margin-top:4px}input[type=file]{max-width:100%;margin:18px 0 4px}button{font:inherit;font-weight:600;border:1px solid #c3d2dd;background:#fff;color:#22455f;padding:9px 17px;border-radius:8px;cursor:pointer}button.primary{background:#236d98;color:#fff;border-color:#236d98}button:disabled{opacity:.4;cursor:default}button:hover:not(:disabled){filter:brightness(.96)}.buttons{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.status{padding:13px 16px;border-radius:9px;background:#edf3f7;white-space:pre-wrap;overflow-wrap:anywhere}.status.success{background:#e9f5ef;color:#17613e}.status.warning{background:#fff4dc;color:#755414}.status.error{background:#fff0ee;color:#a13228}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.stat{padding:15px;background:#f5f8fa;border-radius:10px}.stat b{display:block;font-size:27px;line-height:1.3}.stat span{color:#637785;font-size:13px}h2{font-size:21px;margin:0 0 12px}h3{font-size:16px;margin:0 0 8px}.preview{padding:18px 0;border-top:1px solid #e1e8ee}.preview p{color:#2f4354;margin:4px 0;white-space:pre-wrap;overflow-wrap:anywhere}.preview .answer{color:#17613e;font-weight:600}.meta{font-size:13px;overflow-wrap:anywhere}.hidden{display:none}.footer{font-size:13px;color:#687b89}@media(max-width:600px){body{padding:22px 12px}h1{font-size:26px}.card{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.drop{padding:22px 12px}}
</style>
</head>
<body>
<main class="wrap">
<div class="eyebrow">LOCAL QUESTION BANK EXPORTER</div>
<h1>从已保存的练习网页导出题库</h1>
<p>先解压下载的压缩包,再选择其中的 HTML 文件。隐藏的参考答案也能读取,无需逐题点击“查看答案”。</p>
<section class="card">
<label id="drop" class="drop" for="file"><strong>选择或拖入练习网页</strong><small>支持 .html / .htm;所有处理都在本机完成</small><input id="file" type="file" accept=".html,.htm,text/html"></label>
<p class="meta">保留网页旁的资源文件夹,方便后续查看图片。此工具会导出图片链接,不会加载或上传图片。</p>
<div id="status" class="status" role="status" aria-live="polite">等待选择文件。</div>
<div class="buttons"><button id="csv" class="primary" disabled>导出 CSV(Excel)</button><button id="json" disabled>导出 JSON</button><button id="txt" disabled>导出 TXT</button></div>
</section>
<section id="result" class="card hidden">
<h2 id="title"></h2>
<div class="stats"><div class="stat"><b id="total">0</b><span>已提取题目</span></div><div class="stat"><b id="answered">0</b><span>包含参考答案</span></div><div class="stat"><b id="expected">—</b><span>网页答题卡题数</span></div><div class="stat"><b id="resolved">0</b><span>包含文字解析</span></div></div>
<p id="types"></p><p id="source" class="meta"></p>
<h2 style="margin-top:24px">题目预览</h2><p class="meta">展示前 3 题,导出文件包含全部已提取题目。</p><div id="preview"></div>
</section>
<p class="footer">CSV 使用 UTF-8 编码并带中文 BOM;JSON 额外保留题目和解析 HTML、原始图片路径。若 HTML 本身没有参考答案,本工具会标注缺失。</p>
</main>
<script src="question-bank-core.js"></script>
<script>
(function () {
'use strict';
const byId = function (id) { return document.getElementById(id); };
const buttons = ['csv', 'json', 'txt'].map(byId);
let bank = null;
let loadToken = 0;
function status(message, level) { byId('status').textContent = message; byId('status').className = 'status ' + (level || ''); }
function paragraph(parent, text, className) { const p = document.createElement('p'); p.textContent = text; if (className) p.className = className; parent.appendChild(p); }
function render() {
byId('result').classList.remove('hidden');
byId('title').textContent = bank.title;
byId('total').textContent = bank.total;
byId('answered').textContent = bank.answered;
byId('expected').textContent = bank.expectedTotal === null ? '未知' : bank.expectedTotal;
byId('resolved').textContent = bank.questions.filter(function (q) { return q.explanation; }).length;
byId('types').textContent = Object.entries(QuestionBankCore.typeCounts(bank)).map(function (entry) { return entry[0] + ' ' + entry[1] + ' 题'; }).join(' · ');
byId('source').textContent = '原始网址:' + (bank.sourceUrl || '网页未保存原始网址;图片相对路径将保留原值');
const preview = byId('preview'); preview.replaceChildren();
bank.questions.slice(0, 3).forEach(function (q) {
const article = document.createElement('article'); article.className = 'preview';
const heading = document.createElement('h3'); heading.textContent = q.number + '. [' + q.type + '] ' + q.question; article.appendChild(heading);
q.options.forEach(function (o) { paragraph(article, o.label + '. ' + o.text + (o.images.length ? ' [图片链接已保留]' : '')); });
paragraph(article, '参考答案:' + (q.answer || '缺失') + (q.answerText ? '(' + q.answerText + ')' : ''), 'answer');
paragraph(article, '解析:' + (q.explanation || '网页未提供'));
preview.appendChild(article);
});
}
async function load(file) {
const current = ++loadToken;
bank = null; buttons.forEach(function (button) { button.disabled = true; }); byId('result').classList.add('hidden');
if (!file) { status('等待选择文件。'); return; }
if (!/\.html?$/i.test(file.name)) { status('请选择已解压的 .html 或 .htm 网页文件。', 'error'); return; }
status('正在读取 ' + file.name + ' …');
try {
const html = await file.text();
if (current !== loadToken) return;
if (!window.QuestionBankCore) throw new Error('找不到 question-bank-core.js,请将它与本工具放在同一文件夹。');
bank = QuestionBankCore.extract(QuestionBankCore.parseHTML(html));
if (!bank.total) { status('没有找到练习题目。请保存已进入“练习”的完整网页后重新导入。', 'error'); bank = null; return; }
render(); buttons.forEach(function (button) { button.disabled = false; });
const issues = [];
if (bank.expectedTotal === null) issues.push('网页没有答题卡,只能核对当前 HTML 中的题目。');
else if (bank.expectedTotal !== bank.total) issues.push('答题卡为 ' + bank.expectedTotal + ' 题,当前仅提取 ' + bank.total + ' 题。');
if (bank.missingAnswers.length) issues.push('缺失答案的题号:' + bank.missingAnswers.join('、') + '。');
if (!bank.questions.some(function (q) { return q.explanation; })) issues.push('网页中的文字解析为空,导出时会保留为空。');
status('已读取 ' + bank.total + ' 题,其中 ' + bank.answered + ' 题包含参考答案。' + (issues.length ? '\n' + issues.join('\n') : '\n题目与参考答案完整。'), bank.complete ? 'success' : 'warning');
} catch (error) { if (current === loadToken) { bank = null; status('读取失败:' + error.message, 'error'); } }
}
byId('file').addEventListener('change', function (event) { load(event.target.files[0]); });
const drop = byId('drop');
['dragenter', 'dragover'].forEach(function (name) { drop.addEventListener(name, function (event) { event.preventDefault(); drop.classList.add('drag'); }); });
['dragleave', 'drop'].forEach(function (name) { drop.addEventListener(name, function (event) { event.preventDefault(); drop.classList.remove('drag'); }); });
drop.addEventListener('drop', function (event) { load(event.dataTransfer.files[0]); });
buttons.forEach(function (button) { button.addEventListener('click', function () { if (!bank) return; try { const filename = QuestionBankCore.download(bank, button.id); status('已发起下载:' + filename + '\n如未看到文件,请检查浏览器下载列表。', 'success'); } catch (error) { status('导出失败:' + error.message, 'error'); } }); });
window.addEventListener('dragover', function (event) { event.preventDefault(); });
window.addEventListener('drop', function (event) { event.preventDefault(); });
})();
</script>
</body>
</html>
+477
View File
@@ -0,0 +1,477 @@
// ==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();
})();