fix: require evidence for judgment answers and correct legacy banks

This commit is contained in:
suanyuit-cyber
2026-10-30 03:34:38 +08:00
parent 4bf2de1bad
commit 31a2640adb
21 changed files with 4875 additions and 11110 deletions
+203 -32
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.5
// @version 1.2.6
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/*
// @run-at document-start
@@ -23,6 +23,7 @@ const QBCore = (() => {
const TYPES = new Set(['single', 'multiple', 'judge', 'unknown']);
const STATUSES = new Set(['confirmed', 'missing', 'conflict']);
const ANSWER_EVIDENCE = new Set(['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']);
const TYPE_NAMES = { single: '单选题', multiple: '多选题', judge: '判断题', unknown: '未知' };
const STATUS_NAMES = { confirmed: '已确认', missing: '待补', conflict: '冲突' };
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
@@ -84,6 +85,7 @@ const QBCore = (() => {
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.answerEvidence != null && !ANSWER_EVIDENCE.has(input.answerEvidence)) throw new Error(`无效答案证据:${text(input.answerEvidence)}`);
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 };
@@ -133,6 +135,7 @@ const QBCore = (() => {
}
}
if (own(input, 'questionIdAliases')) record.questionIdAliases = unique([...stringArray(input.questionIdAliases, '题目ID别名'), record.questionId].filter(Boolean));
if (input.answerEvidence != null) record.answerEvidence = input.answerEvidence;
if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source);
record.key = recordKey(record);
return record;
@@ -148,6 +151,80 @@ const QBCore = (() => {
return JSON.stringify(sorted(record.correctOptionIds.map((value) => `id:${value}`)));
}
const answerProvenance = record => record?.answerSource || record?.source;
const legacyJudgeListSource = source => source?.kind === 'network' && /\/upm\/(?:wrong\/)?start(?:[?#]|$)/u.test(source.url || '');
const judgeAnswerResponseSource = source => source?.kind === 'network' && /\/upm\/[^/]+\/users\/[^/]+\/(?:wrong\/)?answer(?:[?#]|$)/u.test(source.url || '');
const trustedAnswerProvenance = record => ANSWER_EVIDENCE.has(record?.answerEvidence) || judgeAnswerResponseSource(answerProvenance(record)) ||
/^(?:csv-import|json-import|manual|manual-import|import)$/u.test(answerProvenance(record)?.kind || '');
// Old practice-list judgeAnswer values contained unsubmitted placeholders.
// This explicit migration preserves the raw input for a durable backup and
// touches only judge records with that evidenced legacy provenance or an
// unverified old DOM answer that could have inherited the same placeholder.
function repairLegacyJudgeAnswers(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const changed = [];
const unverifiedLegacySource = record => (legacyJudgeListSource(answerProvenance(record)) || answerProvenance(record)?.kind === 'dom') && !trustedAnswerProvenance(record);
let records = input;
for (let index = 0; index < input.length; index++) {
const original = input[index];
if (original?.type !== 'judge') continue;
const snapshots = Array.isArray(original.conflicts) ? original.conflicts : [];
const contaminated = unverifiedLegacySource(original) && original.answerStatus !== 'missing' || snapshots.some(unverifiedLegacySource);
if (!contaminated) continue;
const record = normalizeRecord(original);
const candidates = [], unresolved = [];
const addCandidate = (candidate, snapshot) => {
if (unverifiedLegacySource(candidate)) return;
// An old DOM observation may merely have inherited the list value.
// Its presence in a contaminated record is not independent evidence.
if (!trustedAnswerProvenance(candidate)) {
const kind = answerProvenance(candidate)?.kind || 'unknown';
if (!['dom', 'unknown'].includes(kind)) unresolved.push(snapshot || conflictSnapshot(record, '未确认的判断题答案来源'));
return;
}
if (!candidate.answerTexts?.length && !candidate.correctOptionIds?.length) {
if (snapshot) unresolved.push(snapshot);
return;
}
try {
const source = answerProvenance(candidate);
const restored = normalizeRecord({ ...record, ...candidate, bankId: record.bankId, bankName: record.bankName,
questionId: record.questionId, source: record.source, answerSource: source,
answerEvidence: candidate.answerEvidence || (judgeAnswerResponseSource(source) ? 'answer-response' : undefined),
answerStatus: 'confirmed', conflicts: [] });
if (restored.answerStatus !== 'confirmed') {
if (snapshot) unresolved.push(snapshot);
else unresolved.push(conflictSnapshot(restored, '标准答案证据与选项不一致'));
} else candidates.push(restored);
} catch (_) { if (snapshot) unresolved.push(snapshot); }
};
for (const snapshot of snapshots) {
if (!snapshot || typeof snapshot !== 'object') continue;
addCandidate({ ...snapshot, answerEvidence: snapshot.answerEvidence }, snapshot);
}
if (record.answerStatus !== 'missing') addCandidate(record);
let repaired;
for (const candidate of candidates) repaired = repaired ? mergeNormalizedRecords(repaired, candidate) : candidate;
if (!repaired) repaired = normalizeRecord({ ...record, answerStatus: unresolved.length ? 'conflict' : 'missing',
answerTexts: [], correctOptionIds: [], answerSource: undefined, answerEvidence: undefined, conflicts: [] });
if (unresolved.length) repaired = normalizeRecord({ ...repaired, answerStatus: 'conflict',
conflicts: distinctConflicts([...repaired.conflicts, ...unresolved]) });
// Restore collection/identity metadata even when an older answer snapshot
// supplied the trustworthy stem, options and answer IDs atomically.
repaired = normalizeRecord({ ...record, ...repaired, source: record.source, updatedAt: record.updatedAt,
answerSource: repaired.answerSource, answerEvidence: repaired.answerEvidence,
questionIdAliases: record.questionIdAliases, questionIndex: record.questionIndex, contentOccurrence: record.contentOccurrence });
if (!own(record, 'questionIdAliases')) delete repaired.questionIdAliases;
if (JSON.stringify(repaired) === JSON.stringify(record)) continue;
if (records === input) records = input.slice();
records[index] = repaired;
changed.push({ index, key: record.key, reason: repaired.answerStatus === 'confirmed' ? 'recovered-proven-judge-answer' :
repaired.answerStatus === 'conflict' ? 'retained-proven-judge-conflict' : 'unverified-legacy-judge-answer', original });
}
return { records, changed };
}
// 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
@@ -341,13 +418,16 @@ const QBCore = (() => {
}
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 };
const snapshot = { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source };
if (record.answerEvidence) snapshot.answerEvidence = record.answerEvidence;
return snapshot;
}
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]);
const key = JSON.stringify([item.reason, item.stem, item.options, item.answerTexts, item.correctOptionIds,
item.answerEvidence || '', item.source?.kind || '', item.source?.url || '']);
if (!byContent.has(key)) byContent.set(key, item);
}
return [...byContent.values()].slice(-40);
@@ -388,7 +468,7 @@ const QBCore = (() => {
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',
answerSource: answerRecord.answerSource || answerRecord.source, answerEvidence: answerRecord.answerEvidence, answerStatus: 'conflict',
conflicts: distinctConflicts(conflicts) });
}
if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') {
@@ -396,6 +476,13 @@ const QBCore = (() => {
return normalizeRecord({ ...oldRecord, ...metadata, bankName: newRecord.bankName || oldRecord.bankName, explanation,
updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source });
}
if (oldRecord.type === 'judge' && oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' &&
trustedAnswerProvenance(oldRecord) && !trustedAnswerProvenance(newRecord)) {
// Re-observing the same answer without evidence cannot replace the
// independently verified answer's provenance or its own option IDs.
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 });
}
@@ -864,7 +951,7 @@ const QBCore = (() => {
return records;
}
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
return { normalizeRecord, repairLegacyJudgeAnswers, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
@@ -939,14 +1026,24 @@ const QBAdapter = (() => {
function standardAnswer(node, type, options, context) {
const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null;
const answerNode = result || node;
if ([node, answerNode].some(value => value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0)) {
if (context.hideStandardAnswers || [node, answerNode].some(value => value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0)) {
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
}
const hasSubmitted = Array.isArray(node.submitQuesAnswers) && node.submitQuesAnswers.length > 0;
const verifiedResponse = context.answerResponse && context.questionId && context.knownQuestion;
const evidence = verifiedResponse ? 'answer-response' : hasSubmitted || result ? 'submitted-list' : 'explicit-standard';
if (context.answerResponse && !verifiedResponse) return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
if (type === 'judge') {
// /upm/start sends judgeAnswer=0 for unanswered listQues as well. The
// real client creates resultInfo only for non-empty submitQuesAnswers;
// reading that placeholder as an answer silently labels every new item false.
if ((context.practiceList || context.collection === 'listQues') && !hasSubmitted && !result && !verifiedResponse) {
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
}
const raw = answerNode.judgeAnswer;
if (raw === 0 || raw === '0' || raw === 1 || raw === '1') {
const value = String(raw);
return { correctOptionIds: [value], answerTexts: [value === '1' ? '正确' : '错误'], answerStatus: 'confirmed' };
return { correctOptionIds: [value], answerTexts: [value === '1' ? '正确' : '错误'], answerStatus: 'confirmed', answerEvidence: evidence };
}
} else {
let ids = [];
@@ -957,7 +1054,7 @@ const QBAdapter = (() => {
ids = [...new Set(ids)].filter(Boolean);
const resolved = ids.map(id => options.filter(o => o.id === id));
if (ids.length && resolved.every(matches => matches.length === 1) && (type !== 'single' || ids.length === 1)) {
return { correctOptionIds: ids, answerTexts: resolved.map(matches => matches[0].text), answerStatus: 'confirmed' };
return { correctOptionIds: ids, answerTexts: resolved.map(matches => matches[0].text), answerStatus: 'confirmed', answerEvidence: evidence };
}
}
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
@@ -998,7 +1095,7 @@ const QBAdapter = (() => {
if (options.length < 2 || options.some(o => !o.text)) return null;
} else if (previous) options = previous.options.map(o => ({ ...o }));
else return null;
const answers = standardAnswer(node, type, options, context);
const answers = standardAnswer(node, type, options, { ...context, knownQuestion: previous });
const record = {
bankId: idString(node.praId || context.bankId || (previous && previous.bankId)),
bankName: text(context.bankName || (previous && previous.bankName)), questionId: id,
@@ -1029,7 +1126,9 @@ const QBAdapter = (() => {
for (const item of node.slice(0, 10000)) visit(item, arrayContext, depth + 1);
return;
}
const next = { ...inherited, bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) };
const hidden = inherited.hideStandardAnswers || [node, node.info].some(value => value &&
(value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0));
const next = { ...inherited, hideStandardAnswers: Boolean(hidden), bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) };
const isListContainer = ['listQues', 'questionList', 'questions'].some(key => Array.isArray(node[key]));
const candidate = isListContainer ? null : recordFromNode(node, next);
if (candidate) output.push(candidate);
@@ -1073,9 +1172,10 @@ const QBAdapter = (() => {
const praMatch = path.match(/\/pra\/([^/]+)\/questions/);
return {
// Retain no token-bearing query parameters in saved source URLs.
url: url.origin + url.pathname,
url: url.origin + url.pathname.replace(/\/users\/[^/]+(?=\/)/, '/users/_'),
bankId: url.searchParams.get('praId') || (answerMatch && answerMatch[1]) || (praMatch && praMatch[1]) || '',
answerResponse: Boolean(answerMatch)
answerResponse: Boolean(answerMatch),
practiceList: /\/upm\/(?:wrong\/)?start\/?$/.test(path)
};
} catch (_) { return null; }
}
@@ -1442,7 +1542,8 @@ const QBAdapter = (() => {
if (selected.length && (type !== 'single' || selected.length === 1)) {
const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id']
.map(name => idString(row.getAttribute?.(name))).find(Boolean);
const knownAnswer = matches.length === 1 && matches[0].answerStatus === 'confirmed' ? matches[0] : null;
const knownAnswer = matches.length === 1 && matches[0].answerStatus === 'confirmed' &&
['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard'].includes(matches[0].answerEvidence) ? matches[0] : null;
const agreesWithKnown = knownAnswer && knownAnswer.answerTexts.length === selected.length &&
selected.every(option => knownAnswer.answerTexts.some(answerText => flat(answerText) === flat(option.text)));
const boundToCurrent = q.questionId && feedbackId && feedbackId === q.questionId;
@@ -1450,6 +1551,7 @@ const QBAdapter = (() => {
q.correctOptionIds = selected.map(o => o.id).filter(Boolean);
q.answerTexts = selected.map(o => o.text);
q.answerStatus = 'confirmed';
q.answerEvidence = boundToCurrent ? 'bound-dom' : knownAnswer.answerEvidence;
}
} else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') {
const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id']
@@ -1473,6 +1575,10 @@ const QBAdapter = (() => {
function observePayload(payload, context) {
if (!active) return;
// A full list can replace the meaning of even an unchanged question ID.
// An older answer receipt has no independent stem/options to bind it to
// that new version; never fill it from the newer cached question.
if (context.answerResponse && (knownSnapshotOrder.get(context.bankId) || 0) > (context.requestSequence || 0)) return;
try {
const observedAt = new Date().toISOString();
const batchId = `${observedAt}:${++batchSequence}`;
@@ -1999,6 +2105,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1';
const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const storage = {
@@ -2053,6 +2160,27 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
storage.set(OPTION_REPAIR_BACKUP_KEY, { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals });
return result;
}
function repairJudgeEvidence(original) {
const result = QBCore.repairLegacyJudgeAnswers(original);
if (!result.changed.length) return result;
const existing = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null);
if (existing !== null && (!existing || existing.schemaVersion !== 1 || !Array.isArray(existing.records))) {
throw new Error('判断题答案核验备份结构异常,已停止覆盖');
}
const originals = existing ? [...existing.records] : [];
const signatures = new Set(originals.map(record => JSON.stringify(record)));
for (const item of result.changed) {
const signature = JSON.stringify(item.original);
if (!signatures.has(signature)) { signatures.add(signature); originals.push(item.original); }
}
// Retain every previous value before cleared or restored answers reach persistence.
const backup = { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals };
storage.set(JUDGE_EVIDENCE_BACKUP_KEY, backup);
if (JSON.stringify(storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null)) !== JSON.stringify(backup)) {
throw new Error('判断题答案核验备份未能完整保存,已停止覆盖');
}
return result;
}
function imageReplacementSignature(value, includeQuestionIdentity = true) {
const record = QBCore.normalizeRecord(value);
return JSON.stringify([includeQuestionIdentity ? record.key : record.bankId, record.type, record.stem,
@@ -2118,7 +2246,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
storedRecordsText = JSON.stringify(saved);
const retained = removeBackedUpImagePredecessors(saved, saved);
const repaired = repairAnswerConflicts(retained);
const judgeRepair = repairJudgeEvidence(retained);
const repaired = repairAnswerConflicts(judgeRepair.records);
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
records = compacted.records;
const removed = retained.length - records.length;
@@ -2132,6 +2261,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
recordsDirty = true;
cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.length} 道重复选项文字造成的答案误报,修复前记录已备份。`;
}
if (judgeRepair.changed.length) {
recordsDirty = true;
cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`;
}
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
let root, shadow, panel, launcher, resultBox, statusBox;
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname);
@@ -2196,9 +2329,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
// reconcile the whole library when another tab actually changed storage.
if (currentStoredText !== storedRecordsText) {
const retained = removeBackedUpImagePredecessors(stored, records);
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, records));
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(repairJudgeEvidence(retained).records).records, records));
if (compacted.removed > 0) ensureCleanupBackup(stored);
records = repairAnswerConflicts(compacted.records).records;
records = repairAnswerConflicts(repairJudgeEvidence(compacted.records).records).records;
}
const nextStoredText = JSON.stringify(records);
if (nextStoredText !== currentStoredText) storage.set(RECORDS_KEY, records);
@@ -2243,7 +2376,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
try {
incoming = incoming.filter(record => !redundantDOMObservation(record));
if (!incoming.length) return;
incoming = repairAnswerConflicts(incoming).records;
incoming = repairAnswerConflicts(repairJudgeEvidence(incoming).records).records;
const bank = currentBank();
const upgradeContext = {
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
@@ -2271,7 +2404,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
records = repairAnswerConflicts(compacted.records).records;
records = repairAnswerConflicts(repairJudgeEvidence(compacted.records).records).records;
recordsDirty = true;
schedulePersist();
renderVisible();
@@ -2449,11 +2582,38 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
shadow.getElementById('qb-auto-count').textContent = `本页已选择 ${state.answeredCount || 0} 题 · 已跳过 ${state.skippedCount || 0} 题`;
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
}
function matchesCurrentQuestion(record, current) {
if (!current || record.type !== current.type) return false;
const flat = value => String(value || '').trim().replace(/\s+/gu, '');
const stem = value => flat(String(value || '').replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''));
if (record.bankId && current.bankId && record.bankId !== current.bankId) return false;
if (!(record.bankId && record.bankId === current.bankId) && record.bankName && current.bankName &&
record.bankName !== '未命名题库' && current.bankName !== '未命名题库' && bankNameKey(record.bankName) !== bankNameKey(current.bankName)) return false;
if (Number.isSafeInteger(record.questionIndex) && Number.isSafeInteger(current.questionIndex) && record.questionIndex !== current.questionIndex) return false;
return stem(record.stem) === stem(current.stem) && JSON.stringify((record.options || []).map(option => flat(option.text)).sort()) ===
JSON.stringify((current.options || []).map(option => flat(option.text)).sort());
}
function renderResults() {
if (!resultBox || config.mode !== 'open' || config.tab !== 'search') return;
resultBox.replaceChildren();
const pool = scopedRecords(searchScope);
const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : [];
const current = currentQuestion();
const currentRecords = current ? pool.filter(record => matchesCurrentQuestion(record, current)) : [];
const matches = [];
if (query.trim()) {
const seen = new Set();
// A complete match to the displayed question precedes other keyword candidates.
for (const item of [...QBCore.search(currentRecords, query, { limit: 50, field: searchField }), ...QBCore.search(pool, query, { limit: 50, field: searchField })]) {
if (seen.has(item.record.key)) continue;
seen.add(item.record.key); matches.push(item);
if (matches.length === 50) break;
}
}
const confirmedCurrent = currentRecords.filter(record => record.answerStatus === 'confirmed')
.map(record => QBCore.mapAnswers(record, current));
const currentAnswerAgrees = confirmedCurrent.length > 0 && !currentRecords.some(record => record.answerStatus === 'conflict') &&
confirmedCurrent.every(mapping => mapping.status === 'matched') &&
new Set(confirmedCurrent.map(mapping => JSON.stringify([...mapping.labels].sort()))).size === 1;
const searchName = scopeName(searchScope);
shadow.getElementById('qb-query-state').textContent = query.trim() ? `${searchName} · ${pool.length} 题 · 显示 ${matches.length} 个结果${matches.length === 50 ? '(最多50条,请细化关键词)' : ''}` : `${searchName} · 可输入题干、选项或标准答案关键词`;
if (!matches.length) {
@@ -2462,24 +2622,25 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
resultBox.append(empty);
return;
}
const current = currentQuestion();
matches.forEach(({ record, match, matchedField }) => {
const card = node('article', 'result');
const meta = node('div', 'meta');
const statusText = record.answerStatus === 'confirmed' ? '已确认' : record.answerStatus === 'conflict' ? '答案冲突' : '待补答案';
const matchLabel = matchedField === 'options' ? '选项匹配' : matchedField === 'answer' ? '答案匹配' : ({ exact: '题干一致', partial: '文字匹配', similar: '相似候选' })[match] || '候选';
meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${matchLabel}`));
const currentMatches = matchesCurrentQuestion(record, current);
const mapped = currentMatches && record.answerStatus === 'confirmed' ? QBCore.mapAnswers(record, current) : null;
const verifiedCurrent = currentMatches && currentAnswerAgrees && mapped?.status === 'matched';
meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${currentMatches ? '当前题完整匹配' : matchLabel}`));
card.append(meta, node('p', 'stem', record.stem));
const answer = node('div', `answer ${record.answerStatus !== 'confirmed' ? 'pending' : ''}`);
answer.append(node('span', 'answer-label', '正确答案'));
answer.append(node('span', 'answer-label', verifiedCurrent ? '本题正确答案' : record.answerStatus === 'missing' ? '标准答案' : '候选题答案'));
if (record.answerStatus === 'confirmed') {
const texts = record.answerTexts || [];
answer.append(node('strong', '', texts.length ? texts.join(';') : '标准答案缺少文字,需核验选项'));
const mapped = current ? QBCore.mapAnswers(record, current) : null;
if (mapped?.status === 'matched' && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`));
else answer.append(node('span', 'mapping', '当前页面选项未确认,请按答案内容核对。'));
if (verifiedCurrent && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`));
else answer.append(node('span', 'mapping', currentMatches ? '答案尚不能唯一对应当前题,请核对题干和来源。' : current ? '与当前题的题干或完整选项不同,请核对题干。' : '尚未识别当前完整题目,请核对题干。'));
} else {
answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : '尚未取得标准答案'));
answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : record.type === 'judge' ? '标准答案未确认' : '尚未取得标准答案'));
if (record.answerStatus === 'conflict' && record.conflicts?.length) answer.append(node('span', 'mapping', '冲突记录已保留在 JSON / CSV 备份中。'));
}
card.append(answer);
@@ -2551,6 +2712,12 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`);
}
function exportJudgeEvidenceBackup() {
const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null);
if (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records)) return tell('没有判断题答案核验前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('判断题答案核验前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出判断题答案核验前的 ${backup.records.length} 条原始记录。`);
}
async function parseImportFile(file) {
if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入');
const text = (await file.text()).replace(/^\uFEFF/, '');
@@ -2583,10 +2750,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
}
const before = records.length;
const expanded = QBCore.upsert(records, repairAnswerConflicts(removeBackedUpImagePredecessors(incoming, records)).records);
const expanded = QBCore.upsert(records, repairAnswerConflicts(repairJudgeEvidence(removeBackedUpImagePredecessors(incoming, records)).records).records);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
const merged = repairAnswerConflicts(compacted.records).records;
const merged = repairAnswerConflicts(repairJudgeEvidence(compacted.records).records).records;
records = merged;
recordsDirty = true;
persistRecords();
@@ -2641,6 +2808,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
<div class="section"><h3>重复记录整理</h3><p>重复加载的题目按批次对应合并,保留原题库中的同文题和全部来源 ID。首次整理前会保留原始备份。</p><button type="button" class="btn small" data-action="export-dedup-backup">导出整理前备份</button></div>
<div class="section"><h3>答案误报恢复</h3><p>只恢复旧版因多个正确选项文字相同产生、且全部冲突证据一致的误报。修复前记录独立备份,真实答案冲突继续保留。</p><button type="button" class="btn small" data-action="export-option-repair-backup">导出误报修复前备份</button></div>
<div class="section"><h3>旧题内容补全</h3><p>新完整题库批次与页面题数一致、位置和文字答案均能唯一核对时,补全旧版遗漏的图片、乘号或正负号实体,并保留原始题目 ID 与独立备份。</p><button type="button" class="btn small" data-action="export-image-upgrade-backup">导出内容补全前备份</button></div>
<div class="section"><h3>判断题答案核验</h3><p>缺少网页标准答案依据的旧判断题改为待补;重新取得标准答案后补全,核验前原值单独保留。</p><button type="button" class="btn small" data-action="export-judge-evidence-backup">导出判断题核验前备份</button></div>
</div>
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
</div>
@@ -2681,6 +2849,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
case 'export-dedup-backup': exportCleanupBackup(); break;
case 'export-option-repair-backup': exportOptionRepairBackup(); break;
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break;
}
} catch (error) { tell(`操作失败:${error.message}`, true); }
@@ -2743,6 +2912,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup);
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
@@ -2750,13 +2920,14 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const valueText = JSON.stringify(value);
if (valueText === storedRecordsText) return;
const retained = removeBackedUpImagePredecessors(value, records);
const incoming = repairAnswerConflicts(retained);
records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records;
const judgeRepair = repairJudgeEvidence(retained);
const incoming = repairAnswerConflicts(judgeRepair.records);
records = repairAnswerConflicts(repairJudgeEvidence(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records).records;
storedRecordsText = valueText;
if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; schedulePersist(); }
if (judgeRepair.changed.length || incoming.repaired.length || retained.length < value.length) { recordsDirty = true; schedulePersist(); }
renderVisible();
}
catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
catch (error) { tell(`跨标签题库未合并:${error.message}。当前内存记录仍保留。`, true); }
});
if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled));