Files
yianxue-assistant/scripts/merge-question-banks.cjs

142 lines
10 KiB
JavaScript
Raw Permalink 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.
/* Build one bank with every original source cell and validate its import identity. */
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const rootDir = path.resolve(__dirname, '..');
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
const bankSource = fs.readFileSync(path.join(rootDir, 'bank-store.js'), 'utf8');
const roles = ['副操', '主操', '班长'];
const originalHeaders = ['题号', '题型', '题目', ...'ABCDEFGH'.split('').map(label => '选项' + label), '全部选项', '参考答案', '答案对应选项', '题目解析', '图片链接', '来源网址', '题库名称', '完整性', '网页题数', '缺失答案题号'];
function runtime() {
const environment = { TextDecoder, JSZip, GM_getValue: (_key, fallback) => fallback, GM_setValue: () => {} };
vm.createContext(environment);
vm.runInContext(bankSource, environment);
return environment.YianBankStore;
}
function file(name, bytes) {
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
}
function decode(bytes) {
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
catch (_) { return new TextDecoder('gb18030', { fatal: true }).decode(bytes); }
}
function csv(rows) {
const cell = value => '"' + String(value ?? '').replace(/"/g, '""') + '"';
return '\uFEFF' + rows.map(row => row.map(cell).join(',')).join('\r\n') + '\r\n';
}
function comparable(records) { return Array.from(records, record => JSON.stringify(record)).sort(); }
async function main() {
const inputPath = path.resolve(process.argv[2] || path.join(rootDir, '题库.zip'));
const outputDir = path.resolve(process.argv[3] || path.join(rootDir, '题库'));
const input = fs.readFileSync(inputPath);
const zip = await JSZip.loadAsync(input);
const entries = Object.values(zip.files).filter(entry => !entry.dir && /\.csv$/i.test(entry.name)
&& !entry.name.replace(/\\/g, '/').split('/').some(part => /^(?:__MACOSX|\.DS_Store|\.~lock\.|~\$|\._)/i.test(part)))
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
if (!entries.length) throw new Error('ZIP 内没有 CSV 题库');
const parser = runtime();
const groups = new Map(roles.map(role => [role, { files: [], rows: [] }]));
const originalCells = [];
const headers = ['岗位', '来源文件', ...originalHeaders];
for (const entry of entries) {
const role = entry.name.replace(/\\/g, '/').split('/').find(part => roles.includes(part));
if (!role) throw new Error('题库文件没有岗位目录:' + entry.name);
const rows = parser.parseCSV(decode(await entry.async('uint8array')));
if (rows.length < 2) throw new Error('CSV 没有题目:' + entry.name);
const sourceHeaders = rows[0].map(value => value.replace(/^\uFEFF/, '').trim());
if (new Set(sourceHeaders).size !== sourceHeaders.length) throw new Error('CSV 列名重复:' + entry.name);
if (sourceHeaders.includes('岗位') || sourceHeaders.includes('来源文件')) throw new Error('请使用原始导出 CSV;文件已有合并列:' + entry.name);
sourceHeaders.forEach(name => { if (!headers.includes(name)) headers.push(name); });
const group = groups.get(role);
group.files.push({ name: entry.name, total: rows.length - 1 });
for (const [index, row] of rows.slice(1).entries()) {
if (row.length !== sourceHeaders.length) throw new Error(entry.name + ' 第 ' + (index + 2) + ' 条记录列数不匹配');
const cells = { 岗位: role, 来源文件: entry.name, ...Object.fromEntries(sourceHeaders.map((name, column) => [name, row[column]])) };
group.rows.push(cells);
originalCells.push({ role, sourceFile: entry.name, sourceHeaders, cells: row.slice() });
}
}
// Keep the previous three files compatible, but recommend the single unified output.
const original = await runtime().importFiles([file(path.basename(inputPath), input)]);
const compatibilityOutputs = roles.map(role => {
const group = groups.get(role);
if (!group.rows.length) throw new Error('缺少岗位题库:' + role);
return { role, name: role + '_合并题库.csv', bytes: Buffer.from(csv([headers, ...group.rows.map(row => headers.map(name => row[name] ?? ''))])) };
});
const unifiedRows = roles.flatMap(role => groups.get(role).rows);
const unified = { role: '', name: '统一题库.csv', bytes: Buffer.from(csv([headers, ...unifiedRows.map(row => headers.map(name => row[name] ?? ''))])) };
const outputs = [unified, ...compatibilityOutputs];
const generatedRows = parser.parseCSV(decode(unified.bytes));
const generatedHeaders = generatedRows[0];
const generatedBySource = new Map();
generatedRows.slice(1).forEach(row => {
assert.equal(row.length, generatedHeaders.length, '生成 CSV 的列数不一致');
const role = row[generatedHeaders.indexOf('岗位')], sourceFile = row[generatedHeaders.indexOf('来源文件')];
const key = JSON.stringify([role, sourceFile]);
if (!generatedBySource.has(key)) generatedBySource.set(key, []);
generatedBySource.get(key).push(row);
});
const sourceOffsets = new Map();
for (const originalRow of originalCells) {
const key = JSON.stringify([originalRow.role, originalRow.sourceFile]);
const offset = sourceOffsets.get(key) || 0;
const generatedRow = generatedBySource.get(key)?.[offset];
assert(generatedRow, '生成 CSV 缺少来源行:' + originalRow.sourceFile);
originalRow.sourceHeaders.forEach((name, column) => {
assert.equal(generatedRow[generatedHeaders.indexOf(name)], originalRow.cells[column], originalRow.sourceFile + ' 原字段改变:' + name + ' 第 ' + (offset + 1) + ' 行');
});
sourceOffsets.set(key, offset + 1);
}
const merged = await runtime().importFiles([file(unified.name, unified.bytes)]);
const sourceRowCount = roles.reduce((total, role) => total + groups.get(role).rows.length, 0);
assert.equal(merged.total, sourceRowCount, '合并后导入丢失了题目记录');
const originalRecords = comparable(original.records);
assert.deepEqual(comparable(merged.records), originalRecords, '合并前后题干、选项、答案、原岗位或课程来源发生变化');
const compatible = await runtime().importFiles(compatibilityOutputs.map(output => file(output.name, output.bytes)));
assert.deepEqual(comparable(compatible.records), originalRecords, '兼容文件与统一文件的数据不一致');
const report = [
'# 统一题库', '',
'推荐下载并导入 [统一题库.csv](统一题库.csv)。由原始 `题库.zip` 的 28 个 CSV 合成,共 5520 条来源记录,主操、副操、班长共用检索和答案匹配,不再需要选择岗位。原岗位仅作为来源信息保留。', '',
'导入后的相同题型、题干、完整选项及答案会聚合展示,并保留全部来源;不同答案分别展示并标记冲突,自动答题不会多数决。可以通过明确的课程/来源信息消歧。', '',
'统一题库共有 ' + merged.uniqueTotal + ' 个题目答案组合、' + merged.questionTotal + ' 道唯一题目,其中 ' + merged.conflictTotal + ' 道存在跨来源答案冲突。', '',
'## 兼容文件', '',
'下面三个旧文件继续保留,内容均已包含在统一题库中,无需另外导入。', '',
'| 文件 | 原文件数 | 记录数 | 单选 | 多选 | 判断 |',
'|---|---:|---:|---:|---:|---:|'
];
for (const output of compatibilityOutputs) {
const records = merged.records.filter(record => record.role === output.role);
const count = type => records.filter(record => record.type === type).length;
report.push('| [' + output.name + '](' + output.name + ') | ' + groups.get(output.role).files.length + ' | ' + records.length + ' | ' + count('单选') + ' | ' + count('多选') + ' | ' + count('判断') + ' |');
}
report.push('', '统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。', '',
'合并保留全部记录,不按题干去重,避免丢失课程来源或不同选项。同题在不同课程出现答案差异时也原样保留;搜索时可按课程核对。“常压罐组收料作业中,以下哪些属于工程控制措施?”在风险管理题库中为 ABC,在重大危险源题库中为 ABCD。', '',
'负数等原有防公式单引号转义保持不变,助手导入时会按原规则恢复选项文字。', '',
'## 来源文件', '', '| 岗位 | 原 CSV | 记录数 |', '|---|---|---:|');
for (const role of roles) {
for (const source of groups.get(role).files) report.push('| ' + role + ' | ' + source.name.replace(/\|/g, '\\|') + ' | ' + source.total + ' |');
}
report.push('', '## 重新生成', '', '在仓库根目录运行(原始 ZIP 不提交到 Git):', '', '```powershell', 'npm run merge:banks', '```', '',
'也可指定输入和输出目录:', '', '```powershell', 'node scripts/merge-question-banks.cjs "题库.zip" "题库"', '```', '',
'脚本在写入前独立逐格核对全部原始字段,并调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、原岗位、题号、来源文件及课程来源。重复导入同一题库不会增加来源记录。无需安装额外依赖。', '',
'原始 ZIP SHA-256:`' + crypto.createHash('sha256').update(input).digest('hex') + '`。', '');
fs.mkdirSync(outputDir, { recursive: true });
outputs.forEach(output => fs.writeFileSync(path.join(outputDir, output.name), output.bytes));
fs.writeFileSync(path.join(outputDir, 'README.md'), report.join('\n'));
console.log('已验证并合并 ' + entries.length + ' 个原始 CSV,共 ' + merged.total + ' 条记录:');
outputs.forEach(output => console.log((output.role || '统一题库') + ':' + (output.role ? merged.counts[output.role] : merged.total) + ' 条 → ' + path.join(outputDir, output.name)));
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });