data: add merged question banks for all three roles
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/* 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; });
|
||||
Reference in New Issue
Block a user