Files
yianxue-assistant/scripts/merge-question-banks.cjs
T
2026-10-10 04:25:51 +08:00

112 lines
7.7 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.
/* Merge each role's CSV files without losing course-specific question records. */
'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) {
// sourceFile changes to the merged filename on CSV import; all answer data must remain identical.
return Array.from(records, ({ sourceFile, ...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 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) + ' 条记录列数不匹配');
group.rows.push({ 岗位: role, 来源文件: entry.name, ...Object.fromEntries(sourceHeaders.map((name, column) => [name, row[column]])) });
}
}
// Use the assistant's actual import validator to compare the source ZIP with all three results.
const original = await runtime().importFiles([file(path.basename(inputPath), input)]);
const outputs = 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 merged = await runtime().importFiles(outputs.map(output => file(output.name, output.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(comparable(merged.records).every((record, index) => record === originalRecords[index]), '合并前后题干、选项、答案或课程来源发生变化');
const report = [
'# 按岗位合并题库', '',
'由原始 `题库.zip` 中各岗位的 CSV 合并生成。三个文件均可在“谊安学助手 2.0”的“导入题库”中直接选择,也可同时导入;文件内已有岗位字段,无需靠当前岗位选择归类。', '',
'| 文件 | 原文件数 | 记录数 | 单选 | 多选 | 判断 |',
'|---|---:|---:|---:|---:|---:|'
];
for (const output of outputs) {
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('', '合计 ' + merged.total + ' 条记录。统一保留选项 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 + ':' + merged.counts[output.role] + ' 条 → ' + path.join(outputDir, output.name)));
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });