/* 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(unique(answerTexts.map((value) => comparableAnswer(value, type))), unique(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) { // IDs retain how many identical-text options are correct; answerTexts is a set. if (record.correctOptionIds.length && record.options.length) { const correct = record.options.filter((option) => record.correctOptionIds.includes(option.id)); return JSON.stringify(sorted(correct.map((option) => comparableAnswer(option.text, record.type)))); } 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}`))); } // Old versions compared the list of ID-derived answers with a text set. When // two correct options shared text, that comparison produced a false conflict. // Repair only that evidenced shape; normalizeRecord keeps all other explicit // conflicts conservative. The original inputs are returned for durable backup. function repairDuplicateOptionConflicts(input) { if (!Array.isArray(input)) throw new Error('题库必须是数组'); const repaired = []; const legacyReason = '正确选项ID与正确答案内容不一致'; const mergeReason = '同一题目出现不同或不一致的标准答案'; const ids = values => Array.isArray(values) && values.length > 0 && values.every(value => typeof value === 'string' && clean(value)) && new Set(values.map(clean)).size === values.length ? values.map(clean) : null; const answers = values => Array.isArray(values) && values.length > 0 && values.every(value => typeof value === 'string' && normalized(value)) ? unique(values.map(normalized)) : null; const optionSignature = options => { if (!Array.isArray(options) || !options.length || options.some(option => !option || typeof option !== 'object' || typeof option.id !== 'string' || !clean(option.id) || typeof option.text !== 'string' || !normalized(option.text))) return null; if (new Set(options.map(option => clean(option.id))).size !== options.length) return null; return options.map(option => JSON.stringify([clean(option.id), normalized(option.text)])); }; const records = input.map((original, index) => { const record = normalizeRecord(original); if (record.type !== 'multiple' || record.answerStatus !== 'conflict' || !Array.isArray(original.conflicts) || !original.conflicts.length) return record; const optionIds = optionSignature(original.options); const correctIds = ids(original.correctOptionIds); const answerSet = answers(original.answerTexts); if (!optionIds || !correctIds || !answerSet) return record; const derived = correctIds.map(id => record.options.find(option => option.id === id)); if (derived.some(option => !option)) return record; const derivedAnswers = derived.map(option => normalized(option.text)); if (new Set(derivedAnswers).size === derivedAnswers.length || !sameArray(unique(derivedAnswers), answerSet)) return record; const source = original.answerSource || original.source; const sameSource = candidate => candidate && typeof candidate === 'object' && !Array.isArray(candidate) && source && typeof source === 'object' && !Array.isArray(source) && (clean(source.batchId) || (clean(source.url) && clean(source.observedAt))) && ['kind', 'url', 'observedAt', 'batchId'].every(field => clean(candidate[field]) === clean(source[field])); const valid = original.conflicts.every(snapshot => { if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) || ![legacyReason, mergeReason].includes(snapshot.reason)) return false; const snapshotIds = ids(snapshot.correctOptionIds), snapshotAnswers = answers(snapshot.answerTexts); if (!snapshotIds || !snapshotAnswers || !sameArray(snapshotIds, correctIds) || !sameArray(snapshotAnswers, answerSet)) return false; if ((own(snapshot, 'stem') || own(snapshot, 'options')) && (!own(snapshot, 'stem') || !own(snapshot, 'options'))) return false; if (own(snapshot, 'stem') && (typeof snapshot.stem !== 'string' || normalized(snapshot.stem) !== normalized(record.stem))) return false; if (own(snapshot, 'options')) { const signature = optionSignature(snapshot.options); if (!signature || !sameArray(signature, optionIds)) return false; } for (const field of ['type', 'bankId', 'bankName', 'questionId']) if (own(snapshot, field) && snapshot[field] !== record[field]) return false; if (own(snapshot, 'answerStatus') && !['confirmed', 'conflict'].includes(snapshot.answerStatus)) return false; for (const field of ['source', 'answerSource']) if (own(snapshot, field) && !sameSource(snapshot[field])) return false; for (const field of ['batchId', 'batch']) if (own(snapshot, field) && (typeof snapshot[field] !== 'string' || !clean(snapshot[field]) || clean(snapshot[field]) !== clean(source?.batchId))) return false; return true; }); if (!valid || !original.conflicts.some(snapshot => snapshot.reason === legacyReason)) return record; const restored = normalizeRecord({ ...record, answerStatus: 'confirmed', conflicts: [] }); if (restored.answerStatus !== 'confirmed') return record; repaired.push({ index, key: record.key, bankId: record.bankId, questionId: record.questionId, reason: 'duplicate-correct-option-text', original }); return restored; }); return { records, repaired }; } // Upgrade the old text-only representation only against an evidenced complete // current network snapshot. A matching text fragment alone cannot prove that // two image questions are equivalent. function upgradeLegacyImageRecords(existingInput, incomingInput, { bankId = '', expectedCount, fullSnapshot = false } = {}) { if (!Array.isArray(existingInput) || !Array.isArray(incomingInput)) throw new Error('题库必须是数组'); const existing = existingInput.map(normalizeRecord), incoming = incomingInput.map(normalizeRecord); const unchanged = () => ({ existing, incoming, upgraded: [] }); if (!fullSnapshot || !bankId || !Number.isSafeInteger(expectedCount) || expectedCount < 1 || incoming.length !== expectedCount) return unchanged(); const isList = source => source?.kind === 'network' && /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt)); const batch = incoming[0]?.source.batchId; if (!batch || incoming.some(record => record.bankId !== bankId || !isList(record.source) || record.source.batchId !== batch || !Number.isSafeInteger(record.questionIndex) || record.questionIndex < 0 || record.questionIndex >= expectedCount) || new Set(incoming.map(record => record.questionIndex)).size !== expectedCount || new Set(incoming.map(record => record.questionId)).size !== expectedCount || incoming.some(record => !record.questionId)) return unchanged(); const marker = /\[图片:https?:\/\/[^\]\s]+\]/gu; const strip = value => normalized(value.replace(marker, '')); const imageCount = record => [record.stem, ...record.options.map(option => option.text)].reduce((count, value) => count + (value.match(marker)?.length || 0), 0); const signature = record => JSON.stringify([record.type, strip(record.stem), sorted(record.options.map(option => strip(option.text)))]); const existingBank = existing.filter(record => record.bankId === bankId); const countSignatures = records => { const map = new Map(); for (const record of records) { const key = signature(record); map.set(key, (map.get(key) || 0) + 1); } return map; }; const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming); const previousBySignature = new Map(), indexCounts = new Map(), originalByKey = new Map(); existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); }); for (const record of existingBank) { const key = signature(record); if (!previousBySignature.has(key)) previousBySignature.set(key, record); indexCounts.set(record.questionIndex, (indexCounts.get(record.questionIndex) || 0) + 1); } const correctContents = record => { if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length || record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length) return null; const options = record.correctOptionIds.map(id => record.options.find(option => option.id === id)); return options.some(option => !option) ? null : options.map(option => strip(option.text)); }; const upgraded = [], removed = new Set(); for (let index = 0; index < incoming.length; index++) { const current = incoming[index], key = signature(current); if (!imageCount(current) || current.type === 'unknown' || current.options.length < 2 || !strip(current.stem) || oldCounts.get(key) !== 1 || newCounts.get(key) !== 1 || new Set(current.options.map(option => strip(option.text))).size !== current.options.length || current.options.some(option => !strip(option.text))) continue; const previous = previousBySignature.get(key); if (!previous.questionId || imageCount(previous) || previous.type !== current.type || !Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) || (previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) || indexCounts.get(current.questionIndex) !== 1) continue; const oldAnswers = correctContents(previous), newAnswers = correctContents(current); if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) || !sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue; const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); incoming[index] = replacement; removed.add(previous.key); upgraded.push({ original: originalByKey.get(previous.key), replacement, bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex }); } return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; } // These are deliberately evidenced legacy spellings, not an HTML decoder. // Literal entities elsewhere stay distinct in normalization/search/compaction. function upgradeLegacyMathEntities(existingInput, incomingInput, { bankId = '', expectedCount, fullSnapshot = false } = {}) { if (!Array.isArray(existingInput) || !Array.isArray(incomingInput)) throw new Error('题库必须是数组'); const existing = existingInput.map(normalizeRecord), incoming = incomingInput.map(normalizeRecord); const unchanged = () => ({ existing, incoming, upgraded: [] }); if (!fullSnapshot || !bankId || !Number.isSafeInteger(expectedCount) || expectedCount < 1 || incoming.length !== expectedCount) return unchanged(); const isList = source => source?.kind === 'network' && /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt)); const batch = incoming[0]?.source.batchId; if (!batch || incoming.some(record => record.bankId !== bankId || !isList(record.source) || record.source.batchId !== batch || !Number.isSafeInteger(record.questionIndex) || record.questionIndex < 0 || record.questionIndex >= expectedCount || !record.questionId) || new Set(incoming.map(record => record.questionIndex)).size !== expectedCount || new Set(incoming.map(record => record.questionId)).size !== expectedCount) return unchanged(); const content = record => [record.stem, ...record.options.map(option => option.text)]; const entities = { '×': '×', '±': '±' }; const hasEntity = record => content(record).some(value => /&(?:times|plusmn);/.test(value)); const canonical = value => normalized(value.replace(/&(?:times|plusmn);/g, entity => entities[entity])); const signature = record => JSON.stringify([record.type, canonical(record.stem), sorted(record.options.map(option => canonical(option.text)))]); const optionPairs = record => record.options.map(option => JSON.stringify([option.id, canonical(option.text)])); const correctContents = record => { if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || record.options.length < 2 || record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length || new Set(record.options.map(option => canonical(option.text))).size !== record.options.length) return null; const selected = record.correctOptionIds.map(id => record.options.find(option => option.id === id)); return selected.some(option => !option) ? null : selected.map(option => canonical(option.text)); }; const sameAnswers = (a, b) => { const left = correctContents(a), right = correctContents(b); return left && right && sameArray(left, right) && sameArray(a.correctOptionIds, b.correctOptionIds) && sameArray(unique(a.answerTexts.map(canonical)), unique(b.answerTexts.map(canonical))); }; const sameModern = (record, current) => !hasEntity(record) && record.type === current.type && record.questionIndex === current.questionIndex && normalized(record.stem) === normalized(current.stem) && sameArray(optionPairs(record), optionPairs(current)) && isList(record.source) && sameAnswers(record, current); const bank = existing.filter(record => record.bankId === bankId), upgraded = [], removed = new Set(); const incomingCounts = new Map(), bankBySignature = new Map(), legacyBySignature = new Map(), bankByIndex = new Map(), originalByKey = new Map(); existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); }); const add = (map, key, record) => { if (!map.has(key)) map.set(key, []); map.get(key).push(record); }; for (const record of incoming) { const key = signature(record); incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1); } for (const record of bank) { const key = signature(record); add(bankBySignature, key, record); add(bankByIndex, record.questionIndex, record); if (hasEntity(record)) add(legacyBySignature, key, record); } for (let index = 0; index < incoming.length; index++) { const current = incoming[index], key = signature(current); if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue; const candidates = legacyBySignature.get(key) || []; if (candidates.length !== 1) continue; const previous = candidates[0]; if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) || !isList(previous.source) || (previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) || !sameArray(optionPairs(previous), optionPairs(current)) || !sameAnswers(previous, current)) continue; // A previous failed export may already contain both spellings. Permit only // fully agreeing modern copies at that very same source position; distinct // literal-entity questions and duplicate occurrences remain untouched. if ([...(bankBySignature.get(key) || []), ...(bankByIndex.get(current.questionIndex) || [])].some(record => record.key !== previous.key && !sameModern(record, current))) continue; const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); incoming[index] = replacement; removed.add(previous.key); upgraded.push({ original: originalByKey.get(previous.key), replacement, kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex }); } return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; } 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) { return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming)); } // Internal callers already validated both records. Keep validation at the // public boundary and on the merged result without cloning both inputs again. function mergeNormalizedRecords(oldRecord, newRecord) { 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 sameOptionIdentities = oldRecord.options.length > 0 && oldRecord.options.every((option) => option.id) && unique(oldRecord.options.map((option) => option.id)).length === oldRecord.options.length && sameArray(oldRecord.options.map((option) => JSON.stringify([option.id, comparableAnswer(option.text, oldRecord.type)])), newRecord.options.map((option) => JSON.stringify([option.id, comparableAnswer(option.text, newRecord.type)]))); const exactIdsDiffer = sameOptionIdentities && oldRecord.correctOptionIds.length && newRecord.correctOptionIds.length && !sameArray(oldRecord.correctOptionIds, newRecord.correctOptionIds); const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && (answerSignature(oldRecord) !== answerSignature(newRecord) || exactIdsDiffer); 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) ? mergeNormalizedRecords(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()]); const signatures = new WeakMap(); const signature = record => { if (!signatures.has(record)) signatures.set(record, contentSignature(record)); return signatures.get(record); }; const byBankAndId = new Map(); const positions = new Map(); for (const [index, record] of [...records.values()].entries()) { positions.set(record.key, index); if (!record.questionId) continue; const key = JSON.stringify([bankIdentity(record), record.questionId]); if (!byBankAndId.has(key)) byBankAndId.set(key, []); byBankAndId.get(key).push(record.key); } for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) { if (!records.has(target.key)) continue; const queued = new Set(), knownAliases = new Set(), candidates = []; let cursor = -1; const enqueue = aliases => { for (const alias of aliases) { if (knownAliases.has(alias)) continue; knownAliases.add(alias); for (const key of byBankAndId.get(JSON.stringify([bankIdentity(target), alias])) || []) { if (key === target.key || queued.has(key) || positions.get(key) <= cursor) continue; queued.add(key); // Retain the old Map traversal order, including when a merged // record introduces further aliases during this pass. let low = 0, high = candidates.length; while (low < high) { const middle = (low + high) >>> 1; if (positions.get(candidates[middle]) < positions.get(key)) low = middle + 1; else high = middle; } candidates.splice(low, 0, key); } } }; enqueue(records.get(target.key).questionIdAliases || []); while (candidates.length) { const key = candidates.shift(); cursor = positions.get(key); const other = records.get(key); if (!other) continue; const current = records.get(target.key); if (signature(current) !== signature(other)) continue; if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue; const merged = foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }); records.set(target.key, merged); records.delete(other.key); enqueue(merged.questionIdAliases || []); } } const repairedContents = new Set([...records.values()].filter(record => record.questionIdAliases?.length > 1) .map(record => JSON.stringify([bankIdentity(record), signature(record)]))); for (const record of [...records.values()]) { if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue; if (repairedContents.has(JSON.stringify([bankIdentity(record), signature(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 && sameBank(a, b) && (a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence); const identifiedByContent = new Map(); for (const record of identified) { const key = signature(record); if (!identifiedByContent.has(key)) identifiedByContent.set(key, []); identifiedByContent.get(key).push(record); } const candidates = new Map(), candidateCounts = new Map(); for (const record of anonymous) { const matches = (identifiedByContent.get(signature(record)) || []).filter(other => possiblePair(record, other)); candidates.set(record.key, matches); for (const match of matches) candidateCounts.set(match.key, (candidateCounts.get(match.key) || 0) + 1); } for (const record of anonymous) { const matches = candidates.get(record.key); if (matches.length !== 1) continue; const target = matches[0]; if (candidateCounts.get(target.key) !== 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) ? mergeNormalizedRecords(promoted, currentTarget) : mergeNormalizedRecords(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')); if (group.length === 1 && first.bankId === meta.bankId && first.bankName === meta.bankName && first.questionId === meta.questionId && (meta.contentOccurrence == null || meta.contentOccurrence === first.contentOccurrence) && (!own(meta, 'questionIdAliases') || JSON.stringify(meta.questionIdAliases) === JSON.stringify(first.questionIdAliases))) return first; let result; for (const record of ordered) { const rebased = normalizeRecord({ ...record, ...meta }); result = result ? mergeNormalizedRecords(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; entry.signature = contentSignature(record); entry.bank = bankIdentity(record); if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence; const explicit = record.source.batchId || record.answerSource?.batchId; if (explicit) entry.batch = `${entry.bank}:${explicit}`; else if (entry.occurrence == null) { const source = listSource(record); if (!source) continue; const key = JSON.stringify([entry.bank, 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, entry.signature]); entry.occurrence = counters.get(key) || 0; counters.set(key, entry.occurrence + 1); } const groups = new Map(); const byContent = new Map(); const byContentAlias = new Map(); const indexAliases = (key, entry) => { if (!byContentAlias.has(entry.content)) byContentAlias.set(entry.content, new Map()); const aliases = byContentAlias.get(entry.content); for (const id of [entry.record.questionId, ...(entry.record.questionIdAliases || [])]) { if (!id) continue; if (!aliases.has(id)) aliases.set(id, new Set()); aliases.get(id).add(key); } }; 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([entry.bank, entry.signature]); 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); indexAliases(key, entry); } 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 = byContentAlias.get(entry.content)?.get(entry.record.questionId); if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value; else if (matches?.size === 1) key = matches.values().next().value; else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; else key = `unresolved:${entry.record.key}`; add(key, entry); if (matches?.has(key)) indexAliases(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 completeIdentity = record.type === current.type && record.type !== 'unknown' && record.options.length > 0 && record.options.length === current.options.length && record.correctOptionIds.length > 0 && record.options.every(option => option.id) && current.options.every(option => option.id) && new Set(record.options.map(option => option.id)).size === record.options.length && new Set(current.options.map(option => option.id)).size === current.options.length && sameArray(record.options.map(option => JSON.stringify([option.id, comparableAnswer(option.text, record.type)])), current.options.map(option => JSON.stringify([option.id, comparableAnswer(option.text, record.type)]))); if (completeIdentity) { const selected = record.correctOptionIds.map(id => current.options.find(option => option.id === id)); if (selected.some(option => !option)) return result('unmapped'); if (unique(current.options.map(option => option.label)).length !== current.options.length) return result('ambiguous'); return result('matched', selected.map(option => option.label)); } 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) 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'); } if (candidates.length > 1) { const originalGroup = record.options.filter((option) => comparableAnswer(option.text, record.type) === expected); // Repeated text is safe only when every occurrence is explicitly correct. if (record.type !== 'multiple' || originalCorrect.length !== originalGroup.length || originalCorrect.length !== candidates.length) return result('ambiguous'); labels.push(...candidates.map((option) => option.label)); } else 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, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV }; })(); if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;