Files
yianxue-assistant/question-bank-core.js
T
2026-10-10 02:03:40 +08:00

291 lines
16 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.
/* 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);