/* 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; });