Files
yianxue-assistant/bank-store.js

391 lines
21 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.
/* Local question-bank import and storage. No network requests are made. */
(function (root) {
'use strict';
const ROLES = Object.freeze(['主操', '班长', '副操']);
const BANK_KEY = 'yianxue.assistant.bank.v3';
const LEGACY_BANK_KEY = 'yianxue.assistant.bank.v2';
const SETTING_PREFIX = 'yianxue.assistant.setting.v2.';
const MAX_BYTES = 60 * 1024 * 1024;
let current = null;
let loading = null;
let importQueue = Promise.resolve();
function normalize(value) {
// Answer identity must retain case, unit spelling, superscripts and punctuation.
// Only presentation whitespace and invisible layout characters are cleaned.
return String(value == null ? '' : value)
.replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
.replace(/\s+/g, ' ').trim();
}
function normalizeType(value) {
const type = String(value || '').trim().replace(/^[\[【]|[\]】]$/g, '').replace(/题$/, '');
return ({ 单选: '单选', 单项选择: '单选', single: '单选', radio: '单选', 多选: '多选', 多项选择: '多选', multiple: '多选', checkbox: '多选', 判断: '判断', 判断正误: '判断', boolean: '判断' })[type.toLowerCase()] || type;
}
function optionKey(options) {
return JSON.stringify((options || []).map(option => normalize(option.text)).sort());
}
function questionKey(record) {
return JSON.stringify([normalizeType(record.type), normalize(record.question)]);
}
function hash(value) {
let result = 2166136261;
for (let i = 0; i < value.length; i++) { result ^= value.charCodeAt(i); result = Math.imul(result, 16777619); }
return (result >>> 0).toString(36);
}
function storage() {
// Userscript grants can be sandbox globals without being properties of window.
const legacyGet = typeof GM_getValue === 'function' ? GM_getValue : root.GM_getValue;
const legacySet = typeof GM_setValue === 'function' ? GM_setValue : root.GM_setValue;
const modern = typeof GM !== 'undefined' && GM ? GM : root.GM;
if (typeof legacyGet === 'function' && typeof legacySet === 'function') {
return { name: 'userscript', get: (key, fallback) => Promise.resolve(legacyGet(key, fallback)), set: (key, value) => Promise.resolve(legacySet(key, value)) };
}
if (modern && typeof modern.getValue === 'function' && typeof modern.setValue === 'function') {
return { name: 'userscript', get: (key, fallback) => modern.getValue(key, fallback), set: (key, value) => modern.setValue(key, value) };
}
return {
name: 'origin',
get: async function (key, fallback) {
try { const raw = root.localStorage.getItem(key); return raw === null ? fallback : JSON.parse(raw); }
catch (error) { throw new Error('无法读取此网址的本地存储:' + error.message); }
},
set: async function (key, value) {
try { root.localStorage.setItem(key, JSON.stringify(value)); }
catch (error) { throw new Error('题库保存失败;请检查浏览器存储空间或油猴权限:' + error.message); }
}
};
}
function snapshot(records, revision, storageName) {
const counts = { 主操: 0, 班长: 0, 副操: 0, 未标明: 0 };
const questions = new Map(), variants = new Set();
records.forEach(record => {
const role = record.role || '未标明';
counts[role] = (counts[role] || 0) + 1;
const key = record.questionKey + '\n' + record.optionKey;
const answer = JSON.stringify(record.correctTexts.map(normalize).sort());
if (!questions.has(key)) questions.set(key, new Set());
questions.get(key).add(answer);
variants.add(key + '\n' + answer);
});
return { schemaVersion: 3, records, counts, total: records.length, uniqueTotal: variants.size, questionTotal: questions.size,
conflictTotal: Array.from(questions.values()).filter(answers => answers.size > 1).length,
revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
}
// RFC 4180: quoted commas, escaped quotes, CRLF and embedded line breaks.
function parseCSV(input) {
const text = String(input).replace(/^\uFEFF/, '');
const rows = [];
let row = [], field = '', quoted = false, closed = false, line = 1;
function pushField() { row.push(field); field = ''; closed = false; }
function pushRow() { pushField(); if (row.some(value => value !== '')) rows.push(row); row = []; }
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else { quoted = false; closed = true; }
} else { field += ch; if (ch === '\n') line++; }
} else if (ch === ',') { pushField(); }
else if (ch === '\r' || ch === '\n') {
if (ch === '\r' && text[i + 1] === '\n') i++;
pushRow(); line++;
} else if (closed) {
if (ch !== ' ' && ch !== '\t') throw new Error('CSV 第 ' + line + ' 行的引号后存在非法字符');
} else if (ch === '"') {
if (field !== '') throw new Error('CSV 第 ' + line + ' 行有未转义的引号');
quoted = true;
} else { field += ch; }
}
if (quoted) throw new Error('CSV 引号未闭合(第 ' + line + ' 行附近)');
if (field !== '' || row.length || closed) pushRow();
return rows;
}
function restoreCSVCell(value) {
// toCSV prefixes one apostrophe only for a leading spreadsheet formula marker.
// A real quote such as '引用, or two leading quotes, is retained.
return String(value == null ? '' : value).replace(/^'(?=[\s\uFEFF]*[=+\-@])/, '');
}
function roleFromPath(path) {
return String(path || '').replace(/\\/g, '/').split('/').find(part => ROLES.includes(part)) || '';
}
function courseFromFilename(name) {
return String(name || '').replace(/\\/g, '/').split('/').pop().replace(/\.(csv|json)$/i, '').replace(/_题库_\d+题(?:_不完整)?$/, '');
}
function boolValue(value) {
const text = normalize(value).toLowerCase();
if (/^(正确|对|是|true|√|✓)$/.test(text)) return 'true';
if (/^(错误|错|否|false|×|✗)$/.test(text)) return 'false';
return '';
}
function labelsForAnswer(answer, options, type) {
const raw = String(answer == null ? '' : answer).trim().replace(/^(?:参考答案|正确答案|答案)\s*[::]\s*/, '');
const compact = raw.toUpperCase().replace(/[\s,,、;;|/]+/g, '');
if (/^[A-H]+$/.test(compact)) {
if (new Set(compact).size !== compact.length) throw new Error('参考答案包含重复字母');
return Array.from(compact);
}
const exact = options.filter(option => normalize(option.text) === normalize(raw));
if (exact.length === 1) return [exact[0].label];
if (type === '判断' && boolValue(raw)) {
const found = options.filter(option => boolValue(option.text) === boolValue(raw));
if (found.length === 1) return [found[0].label];
}
throw new Error('参考答案无法对应选项:' + (raw || '答案为空'));
}
function validateRecord(raw, context) {
context = context || {};
if (!raw || typeof raw !== 'object') throw new Error('题目记录格式不正确');
const role = String(raw.role || context.role || '').trim();
// Role is provenance only. Older exports and role-free banks share one index.
if (context.pathRole && raw.role && raw.role !== context.pathRole) throw new Error('题目岗位与 ZIP 目录岗位不一致');
const type = normalizeType(raw.type);
if (!['单选', '多选', '判断'].includes(type)) throw new Error('题型不支持或缺失:' + (type || '空'));
const question = String(raw.question == null ? '' : raw.question).trim();
if (!question) throw new Error('题干为空');
if (!Array.isArray(raw.options) || raw.options.length < 2 || raw.options.length > 8) throw new Error('每道题需要 2 至 8 个有效选项');
const options = raw.options.map((option, index) => {
if (!option || typeof option !== 'object') throw new Error('选项格式不正确');
const label = String(option.label || String.fromCharCode(65 + index)).trim().toUpperCase().replace(/[..、::))]$/, '');
const text = String(option.text == null ? '' : option.text).trim();
if (!/^[A-H]$/.test(label) || !text) throw new Error('选项字母或文字为空/无效');
return { label, text, ...(Array.isArray(option.images) && option.images.length ? { images: option.images.map(String) } : {}) };
});
if (new Set(options.map(option => option.label)).size !== options.length) throw new Error('选项字母重复');
let answer = String(raw.answer == null ? '' : raw.answer).trim();
let labels;
if (answer) labels = labelsForAnswer(answer, options, type);
else if (Array.isArray(raw.correctTexts) && raw.correctTexts.length) {
labels = raw.correctTexts.map(text => {
const found = options.filter(option => normalize(option.text) === normalize(text));
if (found.length !== 1) throw new Error('正确答案文字无法唯一对应选项');
return found[0].label;
});
} else throw new Error('参考答案为空');
if (labels.some(label => !options.some(option => option.label === label))) throw new Error('参考答案引用不存在的选项');
if ((type === '单选' || type === '判断') && labels.length !== 1) throw new Error('单选/判断题必须只有一个参考答案');
if (new Set(labels).size !== labels.length) throw new Error('参考答案重复');
const correctOptions = options.filter(option => labels.includes(option.label));
const correctTexts = correctOptions.map(option => option.text);
if (Array.isArray(raw.correctTexts) && raw.correctTexts.length && JSON.stringify(raw.correctTexts.map(normalize).sort()) !== JSON.stringify(correctTexts.map(normalize).sort())) {
throw new Error('正确答案文字与参考答案字母不一致');
}
answer = correctOptions.map(option => option.label).join('');
const course = String(raw.course || context.course || '').trim();
if (!course) throw new Error('题库缺少课程名称');
const sourceUrl = String(raw.sourceUrl || context.sourceUrl || '').trim();
const number = raw.number == null || raw.number === '' ? context.number || 0 : Number(raw.number);
if (!Number.isInteger(number) || number < 0) throw new Error('题号格式不正确');
const record = {
role, course, sourceUrl, number, type, question, options, correctTexts, answer,
answerText: correctOptions.map(option => option.label + '. ' + option.text).join(';'),
explanation: String(raw.explanation == null ? '' : raw.explanation).trim(),
sourceFile: String(raw.sourceFile || context.sourceFile || ''),
...(Array.isArray(raw.questionImages) && raw.questionImages.length ? { questionImages: raw.questionImages.map(String) } : {})
};
record.questionKey = questionKey(record);
record.optionKey = optionKey(options);
record.id = 'qb-' + hash(JSON.stringify([role, course, sourceUrl, number, record.questionKey, record.optionKey, correctTexts.map(normalize).sort()]));
return record;
}
function readCSV(text, context) {
const rows = parseCSV(text);
if (rows.length < 2) throw new Error('CSV 没有题目记录');
const headers = rows[0].map(value => value.replace(/^\uFEFF/, '').trim());
if (new Set(headers).size !== headers.length) throw new Error('CSV 列名重复');
const map = new Map(headers.map((name, index) => [name, index]));
if (!map.has('题目') || !map.has('参考答案')) throw new Error('CSV 缺少“题目”或“参考答案”列');
const read = (row, key) => map.has(key) ? restoreCSVCell(row[map.get(key)] || '') : '';
const optionColumns = headers.map((name, index) => ({ name, index })).filter(item => /^选项[A-H]$/.test(item.name));
return rows.slice(1).map((row, index) => {
if (row.length !== headers.length) throw new Error('CSV 第 ' + (index + 2) + ' 条记录的列数与表头不一致');
let options = optionColumns.map(item => ({ label: item.name.slice(-1), text: restoreCSVCell(row[item.index] || '') })).filter(option => option.text.trim());
if (!options.length && map.has('全部选项')) {
options = [];
read(row, '全部选项').split(/\r?\n/).forEach(line => {
const match = line.match(/^\s*([A-H])[..、::))]\s*(.*)$/);
if (match) options.push({ label: match[1], text: match[2] });
else if (options.length) options[options.length - 1].text += '\n' + line;
});
}
try {
return validateRecord({
role: read(row, '岗位') || read(row, '角色') || context.role,
course: read(row, '题库名称') || read(row, '课程') || context.course,
sourceUrl: read(row, '来源网址') || context.sourceUrl,
sourceFile: read(row, '来源文件') || context.sourceFile,
number: read(row, '题号') || index + 1,
type: read(row, '题型'), question: read(row, '题目'), options,
answer: read(row, '参考答案'), explanation: read(row, '题目解析')
}, context);
} catch (error) { throw new Error('第 ' + (index + 2) + ' 条记录:' + error.message); }
});
}
function readJSON(text, context) {
let data;
try { data = JSON.parse(String(text).replace(/^\uFEFF/, '')); }
catch (error) { throw new Error('JSON 格式错误:' + error.message); }
let rows;
if (Array.isArray(data)) rows = data;
else if (data && Array.isArray(data.records)) rows = data.records;
else if (data && Array.isArray(data.questions)) rows = data.questions;
else throw new Error('JSON 应包含 questions 或 records 数组');
if (!rows.length) throw new Error('JSON 没有题目记录');
const base = { ...context, role: data.role || context.role, course: data.title || data.course || context.course, sourceUrl: data.sourceUrl || context.sourceUrl };
return rows.map((raw, index) => {
try { return validateRecord(raw, { ...base, number: index + 1 }); }
catch (error) { throw new Error('第 ' + (index + 1) + ' 道题:' + error.message); }
});
}
function isIgnored(name) {
const parts = String(name).replace(/\\/g, '/').split('/');
return parts.some(part => /^(?:__MACOSX|\.DS_Store|\.~lock\.|~\$|\._)/i.test(part));
}
function sourceScope(record) {
return JSON.stringify([record.role, record.course, record.sourceUrl]);
}
function deduplicate(records) {
const seen = new Set();
return records.filter(record => {
const key = JSON.stringify([sourceScope(record), record.sourceFile, record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
if (seen.has(key)) return false;
seen.add(key); return true;
});
}
async function readSaved() {
const backend = storage();
let saved = await backend.get(BANK_KEY, null);
const legacy = saved == null;
if (legacy) saved = await backend.get(LEGACY_BANK_KEY, null);
if (typeof saved === 'string') {
try { saved = JSON.parse(saved); } catch (_) { throw new Error('已缓存题库格式损坏;请重新导入题库'); }
}
if (saved == null) return snapshot([], 0, backend.name);
if (!saved || !Array.isArray(saved.records) || (!legacy && saved.schemaVersion !== 3)) throw new Error('已缓存题库格式损坏;请重新导入题库');
const records = saved.records.map((record, index) => {
try { return validateRecord(record, { number: index + 1 }); }
catch (error) { throw new Error('已缓存题库的第 ' + (index + 1) + ' 道题无效;请重新导入:' + error.message); }
});
const next = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
if (saved.updatedAt) next.updatedAt = saved.updatedAt;
if (legacy) {
// Do not alter the v2 value: a failed migration always leaves a recoverable copy.
try {
await backend.set(BANK_KEY, next);
next.migration = { from: 2, status: 'complete' };
} catch (error) {
// The validated legacy bank remains usable when a storage quota is exhausted.
next.migration = { from: 2, status: 'pending', error: String(error.message || error) };
}
}
return next;
}
async function load() {
if (current) return current;
if (!loading) loading = readSaved().then(value => (current = value)).finally(() => { loading = null; });
return loading;
}
async function fileBytes(file) {
if (typeof file.arrayBuffer === 'function') return file.arrayBuffer();
if (typeof root.FileReader === 'function') return new Promise((resolve, reject) => {
const reader = new root.FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('无法读取导入文件')); reader.readAsArrayBuffer(file);
});
throw new Error('浏览器不支持文件读取');
}
function decodeCSV(bytes) {
const decoder = root.TextDecoder || (typeof TextDecoder !== 'undefined' ? TextDecoder : null);
if (!decoder) throw new Error('浏览器不支持 TextDecoder');
try { return new decoder('utf-8', { fatal: true }).decode(bytes); }
catch (_) { return new decoder('gb18030', { fatal: true }).decode(bytes); }
}
async function performImport(files, options) {
const selected = Array.from(files || []).filter(file => !isIgnored(file.webkitRelativePath || file.name));
if (!selected.length) throw new Error('请选择 ZIP、CSV 或 JSON 题库文件');
const zipFiles = selected.filter(file => /\.zip$/i.test(file.name || ''));
if (zipFiles.length && (zipFiles.length !== 1 || selected.length !== 1)) throw new Error('ZIP 全量导入时请只选择一个 ZIP 文件');
const incoming = [];
let bytesRead = 0;
function accept(text, name, pathRole) {
bytesRead += text.length * 2;
if (bytesRead > MAX_BYTES) throw new Error('题库解压后超过 60 MB,请拆分导入');
const context = { role: pathRole || options.role || '', pathRole, course: courseFromFilename(name), sourceFile: name };
try {
incoming.push(...(/\.csv$/i.test(name) ? readCSV(text, context) : readJSON(text, context)));
} catch (error) { throw new Error(name + ':' + error.message); }
}
if (zipFiles.length) {
if (!root.JSZip || typeof root.JSZip.loadAsync !== 'function') throw new Error('ZIP 解析组件未加载,请重新安装完整脚本');
const file = zipFiles[0];
if (file.size > MAX_BYTES) throw new Error('ZIP 文件超过 60 MB');
const zip = await root.JSZip.loadAsync(await fileBytes(file));
const entries = Object.values(zip.files).filter(entry => !entry.dir && !isIgnored(entry.name) && /\.(csv|json)$/i.test(entry.name));
if (!entries.length) throw new Error('ZIP 内没有 CSV 或 JSON 题库');
if (entries.length > 2000) throw new Error('ZIP 内题库文件数量过多');
let estimatedSize = 0;
for (const entry of entries) {
if (entry._data && entry._data.uncompressedSize) estimatedSize += entry._data.uncompressedSize;
if (estimatedSize > MAX_BYTES) throw new Error('题库解压后超过 60 MB,请拆分导入');
const text = decodeCSV(await entry.async('uint8array'));
accept(text, entry.name, roleFromPath(entry.name));
}
} else {
for (const file of selected) {
const name = file.webkitRelativePath || file.name || '';
if (!/\.(csv|json)$/i.test(name)) throw new Error('仅支持 ZIP、CSV、JSON 文件:' + name);
if (file.size > MAX_BYTES) throw new Error('文件超过 60 MB:' + name);
accept(decodeCSV(await fileBytes(file)), name, roleFromPath(name));
}
}
if (!incoming.length) throw new Error('题库没有有效题目');
const backend = storage();
let previous;
try { previous = await load(); }
catch (error) { if (!zipFiles.length) throw error; previous = snapshot([], 0, backend.name); }
const scopes = new Set(incoming.map(sourceScope));
const retained = zipFiles.length ? [] : previous.records.filter(record => !scopes.has(sourceScope(record)));
const next = snapshot(deduplicate(retained.concat(incoming)), previous.revision + 1, backend.name);
// Only publish after every record has passed validation and storage succeeded.
await backend.set(BANK_KEY, next);
current = next;
return next;
}
function importFiles(files, options) {
const selection = Array.from(files || []);
const settings = { ...(options || {}) };
const job = importQueue.then(() => performImport(selection, settings));
importQueue = job.catch(() => {});
return job;
}
async function getSetting(key, fallback) { return storage().get(SETTING_PREFIX + String(key), fallback); }
async function setSetting(key, value) { await storage().set(SETTING_PREFIX + String(key), value); return value; }
const api = Object.freeze({ version: '3.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
root.YianBankStore = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);