/* Pure question-bank operations. No page access or network requests. */ const QBCore = (() => { 'use strict'; const TYPES = new Set(['single', 'multiple', 'judge', 'unknown']); const STATUSES = new Set(['confirmed', 'missing', 'conflict']); const TYPE_NAMES = { single: '单选题', multiple: '多选题', judge: '判断题', unknown: '未知' }; const STATUS_NAMES = { confirmed: '已确认', missing: '待补', conflict: '冲突' }; const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key); const text = (value) => value == null ? '' : String(value); const unique = (values) => [...new Set(values)]; const clean = (value) => text(value).replace(/\u00a0/g, ' ').trim(); // Do not use NFKC: it changes superscripts, units and some mathematical symbols. const normalized = (value) => clean(value).replace(/\s+/gu, ''); const normalizedStem = (value) => normalized(clean(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, '')); const sorted = (values) => [...values].sort(); const sameArray = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b)); function idText(value, name) { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error(`${name} 是不安全的数字,请以字符串提供`); if (value != null && !['string', 'number'].includes(typeof value)) throw new Error(`${name} 必须是字符串`); return clean(value); } function stringArray(value, name) { if (value == null) return []; if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' && typeof item !== 'number')) { throw new Error(`${name} 必须是字符串数组`); } return unique(value.map((item) => idText(item, name)).filter(Boolean)); } function judgeText(value) { const result = clean(value); if (/^(正确|对|是|√|true)$/i.test(result)) return '正确'; if (/^(错误|错|否|×|false)$/i.test(result)) return '错误'; return result; } const answerText = (value, type) => type === 'judge' ? judgeText(value) : clean(value); const comparableAnswer = (value, type) => normalized(answerText(value, type)); const optionContents = (record) => record.options.map((option) => comparableAnswer(option.text, record.type)); const sameContent = (a, b) => normalizedStem(a.stem) === normalizedStem(b.stem) && (a.type === b.type || a.type === 'unknown' || b.type === 'unknown') && sameArray(optionContents(a), optionContents(b)); function sourceInfo(value) { const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const result = { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() }; if (clean(source.batchId)) result.batchId = idText(source.batchId, '采集批次ID'); return result; } function recordKey(record) { const bank = record.bankId || record.bankName; if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`; // Keep the complete signature, rather than a short hash whose collisions could merge answers. const fingerprint = [bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))]; if (record.contentOccurrence != null) fingerprint.push(record.contentOccurrence); return `fp:${JSON.stringify(fingerprint)}`; } function normalizeRecord(input) { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('题目记录必须是对象'); if (typeof input.stem !== 'string' || !input.stem.trim()) throw new Error('题干不能为空'); const type = input.type == null ? 'unknown' : input.type; if (!TYPES.has(type)) throw new Error(`不支持的题型:${text(type)}`); if (input.answerStatus != null && !STATUSES.has(input.answerStatus)) throw new Error(`无效答案状态:${text(input.answerStatus)}`); if (input.options != null && !Array.isArray(input.options)) throw new Error('选项必须是数组'); const options = (input.options || []).map((option, index) => { if (typeof option === 'string') option = { text: option }; if (!option || typeof option !== 'object' || typeof option.text !== 'string' || !option.text.trim()) throw new Error('选项内容不能为空'); return { id: idText(option.id, '选项ID'), text: clean(option.text), label: clean(option.label) || (index < 26 ? String.fromCharCode(65 + index) : String(index + 1)) }; }); const correctOptionIds = stringArray(input.correctOptionIds, '正确选项ID'); let answerTexts = stringArray(input.answerTexts, '正确答案内容').map((value) => answerText(value, type)); answerTexts = unique(answerTexts); let answerStatus = input.answerStatus || (correctOptionIds.length || answerTexts.length ? 'confirmed' : 'missing'); const conflicts = Array.isArray(input.conflicts) ? input.conflicts.filter((item) => item && typeof item === 'object').map((item) => JSON.parse(JSON.stringify(item))) : []; let inconsistency = ''; const derivedTexts = []; if (correctOptionIds.length && options.length) { for (const id of correctOptionIds) { const matches = options.filter((option) => option.id === id); if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项'; else derivedTexts.push(answerText(matches[0].text, type)); } if (!inconsistency && answerTexts.length && !sameArray(answerTexts.map((value) => comparableAnswer(value, type)), derivedTexts.map((value) => comparableAnswer(value, type)))) { inconsistency = '正确选项ID与正确答案内容不一致'; } if (!answerTexts.length && !inconsistency) answerTexts = unique(derivedTexts); } if (['single', 'judge'].includes(type) && (answerTexts.length > 1 || correctOptionIds.length > 1)) inconsistency = '单选或判断题存在多个正确答案'; if (options.length && ['single', 'multiple', 'judge'].includes(type) && answerTexts.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) { inconsistency = '正确答案内容不属于已保存选项'; } if (answerStatus === 'confirmed' && !answerTexts.length && !correctOptionIds.length) answerStatus = 'missing'; if (answerStatus !== 'missing' && inconsistency) { answerStatus = 'conflict'; if (!conflicts.some((item) => item.reason === inconsistency)) conflicts.push({ reason: inconsistency, answerTexts: [...answerTexts], correctOptionIds: [...correctOptionIds] }); } // Explicitly missing data must never carry an apparently confirmed answer forward. if (answerStatus === 'missing') answerTexts = []; const source = sourceInfo(input.source); const record = { bankId: idText(input.bankId, '题库ID'), bankName: clean(input.bankName) || '未命名题库', questionId: idText(input.questionId, '题目ID'), type, stem: clean(input.stem), options, correctOptionIds: answerStatus === 'missing' ? [] : correctOptionIds, answerTexts, answerStatus, explanation: clean(input.explanation), source, updatedAt: clean(input.updatedAt) || source.observedAt, conflicts, }; for (const field of ['questionIndex', 'contentOccurrence']) { if (input[field] != null) { if (!Number.isSafeInteger(input[field]) || input[field] < 0) throw new Error(`${field} 必须为非负整数`); record[field] = input[field]; } } if (own(input, 'questionIdAliases')) record.questionIdAliases = unique([...stringArray(input.questionIdAliases, '题目ID别名'), record.questionId].filter(Boolean)); if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source); record.key = recordKey(record); return record; } function answerSignature(record) { if (record.answerTexts.length) return JSON.stringify(sorted(record.answerTexts.map((value) => comparableAnswer(value, record.type)))); return JSON.stringify(sorted(record.correctOptionIds.map((value) => `id:${value}`))); } function conflictSnapshot(record, reason) { return { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source }; } function distinctConflicts(conflicts) { const byContent = new Map(); for (const item of conflicts) { const key = JSON.stringify([item.reason, item.stem, item.options, item.answerTexts, item.correctOptionIds]); if (!byContent.has(key)) byContent.set(key, item); } return [...byContent.values()].slice(-40); } function mergeRecord(existing, incoming) { const oldRecord = normalizeRecord(existing); const newRecord = normalizeRecord(incoming); if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录'); const contentMatches = sameContent(oldRecord, newRecord); const explanation = newRecord.explanation || oldRecord.explanation; const metadata = {}; for (const field of ['questionIndex', 'contentOccurrence']) { if (newRecord[field] != null || oldRecord[field] != null) metadata[field] = newRecord[field] ?? oldRecord[field]; } if (own(oldRecord, 'questionIdAliases') || own(newRecord, 'questionIdAliases')) metadata.questionIdAliases = unique([ ...(oldRecord.questionIdAliases || []), ...(newRecord.questionIdAliases || []), oldRecord.questionId, newRecord.questionId ].filter(Boolean)); const base = { ...newRecord, ...metadata, explanation }; const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts]; const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict'; const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord); const contentChanged = !contentMatches && oldRecord.answerStatus !== 'missing'; if (conflictAlready || answersDiffer || contentChanged) { const reason = contentChanged ? '同一题目ID的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案'; if (oldRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(oldRecord, reason)); if (newRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(newRecord, reason)); const answerRecord = newRecord.answerStatus !== 'missing' ? newRecord : oldRecord; return normalizeRecord({ ...base, answerTexts: answerRecord.answerTexts, correctOptionIds: answerRecord.correctOptionIds, answerSource: answerRecord.answerSource || answerRecord.source, answerStatus: 'conflict', conflicts: distinctConflicts(conflicts) }); } if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') { // The incoming DOM may use different IDs/order. Keep the original answer's own options and IDs together. return normalizeRecord({ ...oldRecord, ...metadata, bankName: newRecord.bankName || oldRecord.bankName, explanation, updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source }); } return normalizeRecord({ ...base, conflicts }); } function upsert(recordsArray, incomingRecords) { if (!Array.isArray(recordsArray) || !Array.isArray(incomingRecords)) throw new Error('题库必须是数组'); const records = new Map(); const lastSeen = new Map(); for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { const record = normalizeRecord(item); records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record); lastSeen.set(record.key, index); } // Explicit aliases are produced only by content/batch repair. Ordinary // records with different IDs are still kept separate until that repair. const bankIdentity = bankResolver([...records.values()]); for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) { if (!records.has(target.key)) continue; for (const other of [...records.values()]) { if (other.key === target.key || !records.has(other.key) || !other.questionId) continue; const current = records.get(target.key); const aliases = new Set(current.questionIdAliases || []); if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue; if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue; records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence })); records.delete(other.key); } } for (const record of [...records.values()]) { if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue; if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key); } // The saved DOM has no stable question ID. Upgrade it only when the complete // content and bank identify one unique server record in both directions. const anonymous = [...records.values()].filter((record) => !record.questionId); const identified = [...records.values()].filter((record) => record.questionId); const sameBank = (a, b) => { if (a.bankId && b.bankId) return a.bankId === b.bankId; if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false; return normalized(a.bankName) === normalized(b.bankName); }; const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b) && (a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence); const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))])); for (const record of anonymous) { const matches = candidates.get(record.key); if (matches.length !== 1) continue; const target = matches[0]; if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue; const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId }); const currentTarget = records.get(target.key); const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted); records.set(target.key, merged); records.delete(record.key); } return [...records.values()]; } function contentSignature(record) { return JSON.stringify([record.type, normalizedStem(record.stem), sorted(optionContents(record))]); } function bankResolver(records) { const namedIds = new Map(); for (const record of records) if (record.bankId && record.bankName !== '未命名题库') { const name = normalized(record.bankName); if (!namedIds.has(name)) namedIds.set(name, new Set()); namedIds.get(name).add(record.bankId); } return record => { if (record.bankId) return `id:${record.bankId}`; const name = normalized(record.bankName); if (!name || record.bankName === '未命名题库') return `unresolved:${record.key}`; const ids = namedIds.get(name); return ids?.size === 1 ? `id:${[...ids][0]}` : `name:${name}`; }; } function foldEquivalent(group, identity = {}) { const existing = group.find(record => record.questionIdAliases?.length > 1); const first = existing || group.find(record => record.questionId) || group[0]; const questionId = identity.questionId ?? first.questionId; const bankId = identity.bankId ?? (group.find(record => record.bankId)?.bankId || first.bankId); const bankName = identity.bankName || first.bankName; const ids = unique(group.flatMap(record => [record.questionId, ...(record.questionIdAliases || [])]).filter(Boolean)); const meta = { bankId, bankName, questionId }; if (ids.length > 1 || group.some(record => own(record, 'questionIdAliases'))) meta.questionIdAliases = ids; const occurrence = identity.contentOccurrence ?? first.contentOccurrence ?? group.find(record => record.contentOccurrence != null)?.contentOccurrence; if (occurrence != null) meta.contentOccurrence = occurrence; // Process missing observations first so the selected options and the answer // IDs always come together from an answer-bearing record. const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing')); let result; for (const record of ordered) { const rebased = normalizeRecord({ ...record, ...meta }); result = result ? mergeRecord(result, rebased) : rebased; } return result; } function compactRecords(input) { if (!Array.isArray(input)) throw new Error('题库必须是数组'); const entries = input.map((value, index) => ({ record: normalizeRecord(value), index, batch: '', occurrence: null })); const bankIdentity = bankResolver(entries.map(entry => entry.record)); const listSource = record => [record.answerSource, record.source].find(source => source?.kind === 'network' && /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt))); const legacySources = new Map(); for (const entry of entries) { const record = entry.record; if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence; const explicit = record.source.batchId || record.answerSource?.batchId; if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`; else if (entry.occurrence == null) { const source = listSource(record); if (!source) continue; const key = JSON.stringify([bankIdentity(record), source.url]); if (!legacySources.has(key)) legacySources.set(key, []); legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) }); } } // Older versions timestamped individual rows inside each list response. // Infer only network list-response clusters; DOM/answer observations are // never assumed to be full snapshots. Their original order gives duplicate // occurrence numbers and keeps legitimate repeated questions in each list. for (const [key, items] of legacySources) { items.sort((a, b) => a.time - b.time || a.entry.index - b.entry.index); let previous = -Infinity, batch = ''; for (const item of items) { if (item.time - previous > 1000) batch = `legacy:${key}:${item.time}`; item.entry.batch = batch; previous = item.time; } } const counters = new Map(); for (const entry of entries) { if (!entry.batch || entry.occurrence != null) continue; const key = JSON.stringify([entry.batch, contentSignature(entry.record)]); entry.occurrence = counters.get(key) || 0; counters.set(key, entry.occurrence + 1); } const groups = new Map(); const byContent = new Map(); const add = (key, entry) => { if (!groups.has(key)) groups.set(key, []); groups.get(key).push(entry); }; for (const entry of entries) { const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]); entry.content = content; if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue; const key = JSON.stringify([content, entry.occurrence]); add(key, entry); if (!byContent.has(content)) byContent.set(content, new Set()); byContent.get(content).add(key); } for (const entry of entries) { if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue; const matches = [...(byContent.get(entry.content) || [])]; let key; const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId))); if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0]; else if (matches.length === 1) key = matches[0]; else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; else key = `unresolved:${entry.record.key}`; add(key, entry); } const records = []; let duplicateGroups = 0; for (const group of groups.values()) { if (group.length > 1) duplicateGroups++; const identity = {}; const occurrence = group.find(entry => entry.occurrence != null)?.occurrence; if (occurrence != null) identity.contentOccurrence = occurrence; records.push(foldEquivalent(group.map(entry => entry.record), identity)); } return { records, removed: input.length - records.length, groups: duplicateGroups, conflictCount: records.filter(record => record.answerStatus === 'conflict').length }; } function bigrams(value) { const chars = Array.from(value); if (chars.length < 2) return new Set(chars); return new Set(chars.slice(0, -1).map((char, index) => char + chars[index + 1])); } function search(records, query, { limit = 8, field = 'all', offset = 0 } = {}) { const queryText = clean(query).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''); const needle = normalized(queryText); if (needle.length < 2) return []; const selectedField = ['all', 'stem', 'options', 'answer'].includes(field) ? field : 'all'; const terms = unique(queryText.split(/\s+/u).map(normalized).filter(Boolean)); const queryGrams = bigrams(needle); const result = []; for (const record of records) { const haystack = normalizedStem(record.stem); // Each option/answer stays a separate searchable segment. Joining option A // and option B would create phrases that never existed in either option. const fields = { stem: [haystack], options: (record.options || []).map((option) => normalized(option.text)), answer: record.answerStatus === 'confirmed' ? (record.answerTexts || []).map(normalized) : [], }; const allowed = selectedField === 'all' ? ['stem', 'options', 'answer'] : [selectedField]; let match, score = 0, matchedField; const prefer = (candidateField, candidateScore, candidateMatch = 'partial') => { if (candidateScore > score) { matchedField = candidateField; score = candidateScore; match = candidateMatch; } }; if (allowed.includes('stem')) { if (haystack === needle) prefer('stem', 1, 'exact'); else if (haystack.includes(needle)) prefer('stem', 0.86 + 0.08 * needle.length / haystack.length); } for (const candidateField of ['options', 'answer']) { if (!allowed.includes(candidateField)) continue; const matching = fields[candidateField].filter((segment) => segment.includes(needle)); if (matching.length) { const coverage = Math.max(...matching.map((segment) => needle.length / segment.length)); prefer(candidateField, (candidateField === 'options' ? 0.72 : 0.60) + 0.06 * coverage); } } if (terms.length > 1) { const allIn = (names) => terms.every((term) => names.some((name) => fields[name].some((segment) => segment.includes(term)))); if (allIn(allowed)) { if (allowed.includes('stem') && allIn(['stem'])) prefer('stem', 0.82); else if (allowed.includes('options') && allIn(['options'])) prefer('options', 0.70); else if (allowed.includes('answer') && allIn(['answer'])) prefer('answer', 0.58); else { // Cross-field AND is available only with the explicit all-fields scope. const firstField = allowed.find((name) => terms.some((term) => fields[name].some((segment) => segment.includes(term)))); prefer(firstField, firstField === 'stem' ? 0.80 : firstField === 'options' ? 0.68 : 0.56); } } } if (!match && allowed.includes('stem') && needle.length >= 4 && terms.length === 1) { const grams = bigrams(haystack); const intersection = [...queryGrams].filter((gram) => grams.has(gram)).length; const similarity = 2 * intersection / (queryGrams.size + grams.size || 1); if (similarity >= 0.45) prefer('stem', similarity * 0.50, 'similar'); } if (match) result.push({ record, match, score, matchedField }); } const stableKey = (record) => JSON.stringify([record.bankId || '', record.bankName || '', record.questionId || '', record.key || '']); result.sort((a, b) => b.score - a.score || a.record.stem.localeCompare(b.record.stem, 'zh-CN') || stableKey(a.record).localeCompare(stableKey(b.record), 'zh-CN')); const start = Math.max(0, Math.floor(Number(offset) || 0)); const count = Math.max(0, Math.min(100, Math.floor(Number(limit) || 0))); return result.slice(start, start + count); } function mapAnswers(recordInput, currentInput) { let record, current; try { record = normalizeRecord(recordInput); current = normalizeRecord(currentInput); } catch (_) { return { status: 'unmapped', labels: [], answerTexts: [] }; } const answerTexts = [...record.answerTexts]; const result = (status, labels = []) => ({ status, labels, answerTexts }); if (record.answerStatus === 'conflict') return result('ambiguous'); if (record.answerStatus !== 'confirmed' || !answerTexts.length) return result('unmapped'); if (normalizedStem(record.stem) !== normalizedStem(current.stem) || (record.type !== 'unknown' && current.type !== 'unknown' && record.type !== current.type)) return result('unmapped'); if (record.options.length && !sameArray(optionContents(record), current.options.map((option) => comparableAnswer(option.text, record.type)))) return result('unmapped'); const labels = []; for (const value of answerTexts) { const expected = comparableAnswer(value, record.type); const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected); if (candidates.length > 1) return result('ambiguous'); if (!candidates.length) return result('unmapped'); const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected); for (const original of originalCorrect) { const idCandidates = original.id ? current.options.filter((option) => option.id === original.id) : []; if (idCandidates.length > 1) return result('ambiguous'); if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped'); } labels.push(candidates[0].label); } if (unique(labels).length !== labels.length) return result('ambiguous'); return result('matched', labels); } function csvSafe(value) { const result = text(value); // CSV quoting alone does not stop spreadsheet formula evaluation or long-ID rounding. return /^(?:\s*[=+\-@]|[\t\r]|\d{16,}$)/u.test(result) ? `'${result}` : result; } function csvRestore(value) { return value.startsWith("'") && csvSafe(value.slice(1)) === value ? value.slice(1) : value; } function csvCell(value) { return `"${csvSafe(value).replace(/"/g, '""')}"`; } function toCSV(records) { const canonical = records.map(normalizeRecord); const optionCount = Math.max(0, ...canonical.map((record) => record.options.length)); const optionNames = Array.from({ length: optionCount }, (_, index) => `选项${index < 26 ? String.fromCharCode(65 + index) : index + 1}`); const headers = ['来源题库', '题库ID', '题目ID', '题型', '题干', ...optionNames, '正确答案内容', '采集时答案字母', '解析', '答案状态', '答案来源', '采集时间', '完整记录JSON']; const rows = [headers]; for (const record of canonical) { const mapped = mapAnswers(record, record); rows.push([record.bankName, record.bankId, record.questionId, TYPE_NAMES[record.type], record.stem, ...Array.from({ length: optionCount }, (_, index) => record.options[index]?.text || ''), record.answerTexts.join('\n'), mapped.status === 'matched' ? mapped.labels.join(',') : '', record.explanation, STATUS_NAMES[record.answerStatus], record.answerSource?.kind || record.source.kind, record.answerSource?.observedAt || record.source.observedAt, JSON.stringify(record)]); } return '\uFEFF' + rows.map((row) => row.map(csvCell).join(',')).join('\r\n') + '\r\n'; } function parseCSV(input) { if (typeof input !== 'string') throw new Error('CSV内容必须是文字'); const value = input.replace(/^\uFEFF/, ''); const rows = []; let row = [], cell = '', quoted = false, closed = false; const finishCell = () => { row.push(cell); cell = ''; closed = false; }; const finishRow = () => { finishCell(); if (row.some((item) => item !== '')) rows.push(row); row = []; }; for (let index = 0; index < value.length; index += 1) { const char = value[index]; if (quoted) { if (char === '"') { if (value[index + 1] === '"') { cell += '"'; index += 1; } else { quoted = false; closed = true; } } else cell += char; } else if (char === ',') finishCell(); else if (char === '\n' || char === '\r') { finishRow(); if (char === '\r' && value[index + 1] === '\n') index += 1; } else if (char === '"') { if (cell || closed) throw new Error('CSV引号位置错误'); quoted = true; } else { if (closed) throw new Error('CSV引号后包含非法字符'); cell += char; } } if (quoted) throw new Error('CSV存在未闭合引号'); if (cell || row.length || closed) finishRow(); return rows; } function importedArray(value, type, options) { const trimmed = clean(value); if (!trimmed) return []; if (trimmed.startsWith('[')) { let result; try { result = JSON.parse(trimmed); } catch (_) { throw new Error('正确答案内容JSON格式错误'); } return stringArray(result, '正确答案内容'); } if (type !== 'multiple' || options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(trimmed, type))) return [trimmed]; const answers = trimmed.split(/\r?\n/).map(clean).filter(Boolean); if (answers.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) throw new Error('多选答案请使用JSON数组,或每行填写一个完整选项内容'); return answers; } function fromCSV(value) { const rows = parseCSV(value); if (!rows.length) throw new Error('CSV为空'); const headers = rows.shift().map((item) => clean(csvRestore(item))); if (unique(headers).length !== headers.length) throw new Error('CSV存在重复列名'); if (!headers.includes('完整记录JSON') && (!headers.includes('题干') || !headers.includes('题型'))) throw new Error('CSV需要“题干、题型”列或“完整记录JSON”列'); const typeAliases = { ...Object.fromEntries(Object.entries(TYPE_NAMES).map(([key, name]) => [name, key])), 单选: 'single', 多选: 'multiple', 判断: 'judge', single: 'single', multiple: 'multiple', judge: 'judge', unknown: 'unknown' }; const statusAliases = { ...Object.fromEntries(Object.entries(STATUS_NAMES).map(([key, name]) => [name, key])), confirmed: 'confirmed', missing: 'missing', conflict: 'conflict', 无答案: 'missing', 待核验: 'missing' }; const records = []; for (let index = 0; index < rows.length; index += 1) { const cells = rows[index]; try { if (cells.length !== headers.length) throw new Error(`列数应为${headers.length},实际为${cells.length}`); const row = Object.fromEntries(headers.map((header, column) => [header, csvRestore(cells[column])])); if (clean(row['完整记录JSON'])) { let raw; try { raw = JSON.parse(row['完整记录JSON']); } catch (_) { throw new Error('完整记录JSON格式错误'); } records.push(normalizeRecord(raw)); continue; } const type = typeAliases[clean(row['题型'])]; if (!type) throw new Error('题型必须为单选题、多选题、判断题或未知'); const options = headers.filter((header) => /^选项(?:[A-Z]+|\d+)$/u.test(header) && clean(row[header])).map((header) => ({ id: '', label: header.slice(2), text: row[header] })); const answers = importedArray(row['正确答案内容'], type, options); const statusText = clean(row['答案状态']); if (statusText && !own(statusAliases, statusText)) throw new Error('无效答案状态'); const answerStatus = statusText ? statusAliases[statusText] : answers.length ? 'confirmed' : 'missing'; const record = normalizeRecord({ bankId: row['题库ID'], bankName: row['来源题库'], questionId: row['题目ID'], type, stem: row['题干'], options, answerTexts: answers, answerStatus, explanation: row['解析'], source: { kind: clean(row['答案来源']) || 'csv-import', observedAt: row['采集时间'] } }); if (record.answerStatus === 'confirmed' && record.options.length && mapAnswers(record, record).status !== 'matched') throw new Error('正确答案内容不能唯一对应选项'); records.push(record); } catch (error) { throw new Error(`CSV第${index + 2}行:${error.message}`); } } return records; } return { normalizeRecord, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV }; })(); if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;