fix: require evidence for judgment answers and correct legacy banks
This commit is contained in:
Vendored
+203
-32
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user