142 lines
10 KiB
JavaScript
142 lines
10 KiB
JavaScript
/* 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; });
|