/* 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.v2'; const SETTING_PREFIX = 'yianxue.assistant.setting.v2.'; const MAX_BYTES = 60 * 1024 * 1024; let current = 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 }; records.forEach(record => { counts[record.role]++; }); return { schemaVersion: 2, records, counts, total: records.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(); if (!ROLES.includes(role)) throw new Error('题库未标明岗位;请先选择主操、班长或副操再导入'); 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, 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.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]); if (seen.has(key)) return false; seen.add(key); return true; }); } async function load() { if (current) return current; const backend = storage(); let saved = await backend.get(BANK_KEY, null); if (typeof saved === 'string') { try { saved = JSON.parse(saved); } catch (_) { throw new Error('已缓存题库格式损坏;请重新导入题库'); } } if (!saved) return (current = snapshot([], 0, backend.name)); if (!Array.isArray(saved.records)) 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); } }); current = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name); if (saved.updatedAt) current.updatedAt = saved.updatedAt; return current; } 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 题库文件'); if (options.role && !ROLES.includes(options.role)) throw new Error('岗位只能选择主操、班长或副操'); 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: '2.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);