fix: retain question images and repair evidenced legacy records

This commit is contained in:
suanyuit-cyber
2026-10-29 22:40:38 +08:00
parent 15fa009f9e
commit c3edf3975e
45 changed files with 2778 additions and 103 deletions
+380 -42
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.2
// @version 1.2.3
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/*
// @run-at document-start
@@ -103,7 +103,7 @@ const QBCore = (() => {
if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项';
else derivedTexts.push(answerText(matches[0].text, type));
}
if (!inconsistency && answerTexts.length && !sameArray(answerTexts.map((value) => comparableAnswer(value, type)), derivedTexts.map((value) => comparableAnswer(value, type)))) {
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);
@@ -139,10 +139,128 @@ const QBCore = (() => {
}
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 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 = existingBank.find(record => signature(record) === 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) ||
existingBank.filter(record => record.questionIndex === current.questionIndex).length !== 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: existingInput[existing.findIndex(record => record.key === previous.key)], replacement,
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 };
}
@@ -172,7 +290,14 @@ const QBCore = (() => {
const base = { ...newRecord, ...metadata, explanation };
const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts];
const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict';
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
const 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的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案';
@@ -447,7 +572,6 @@ const QBCore = (() => {
for (const value of answerTexts) {
const expected = comparableAnswer(value, record.type);
const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected);
if (candidates.length > 1) return result('ambiguous');
if (!candidates.length) return result('unmapped');
const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected);
for (const original of originalCorrect) {
@@ -455,7 +579,12 @@ const QBCore = (() => {
if (idCandidates.length > 1) return result('ambiguous');
if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped');
}
labels.push(candidates[0].label);
if (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);
@@ -569,7 +698,7 @@ const QBCore = (() => {
return records;
}
return { normalizeRecord, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
@@ -583,16 +712,60 @@ const QBAdapter = (() => {
const ROOT_SELECTOR = '.yxtulcdsdk-pc-marking-main';
const MAX_RESPONSE = 8 * 1024 * 1024;
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
const text = value => String(value == null ? '' : value)
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
.replace(/<br\s*\/?\s*>/gi, '\n').replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '';
}).replace(/&(nbsp|lt|gt|amp|quot|apos);/g, (_, name) =>
({ nbsp: ' ', lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" })[name])
.replace(/\u00a0/g, ' ').trim();
const flat = value => text(value).replace(/\s+/g, '');
const ENTITIES = { nbsp: ' ', lt: '<', LT: '<', gt: '>', GT: '>', amp: '&', AMP: '&', quot: '"', QUOT: '"', apos: "'",
colon: ':', sol: '/', copy: '©', reg: '®', times: '×', divide: '÷', plusmn: '±', le: '≤', ge: '≥', ne: '≠', deg: '°', micro: 'µ' };
let entityDecoder = null;
const decodeEntities = value => value.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (entity, name) => {
if (name[0] !== '#') {
if (has(ENTITIES, name)) return ENTITIES[name];
// Only an entity token reaches this detached textarea; no markup is inserted.
try {
if (typeof document !== 'undefined' && document.createElement) {
entityDecoder ||= document.createElement('textarea');
entityDecoder.innerHTML = entity;
return entityDecoder.value;
}
} catch (_) { /* A restrictive page may disable DOM entity decoding. */ }
return entity;
}
const code = name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1));
return code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff) ? String.fromCodePoint(code) : '';
});
function imageMarker(tag) {
const attributes = tag.replace(/^<img(?=[\s/>])/i, '').replace(/>$/, '');
const attribute = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
let match;
while ((match = attribute.exec(attributes))) {
if (match[1].toLowerCase() !== 'src') continue;
const value = decodeEntities(match[2] ?? match[3] ?? match[4] ?? '').trim();
if (!/^https?:\/\//i.test(value)) return '';
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return '';
return `[图片:${url.href.replace(/\[/g, '%5B').replace(/\]/g, '%5D')}]`;
} catch (_) { return ''; }
}
return '';
}
function richText(value) {
// DOM and response HTML must retain the same src, never a responsive currentSrc.
if (value && typeof value === 'object') value = typeof value.innerHTML === 'string' ? value.innerHTML : value.textContent;
const html = String(value == null ? '' : value)
.replace(/<(script|style)(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>/gi, '');
const tags = /<!--[\s\S]*?-->|<\/?[a-z][\w:-]*(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
let result = '', end = 0, match;
while ((match = tags.exec(html))) {
result += decodeEntities(html.slice(end, match.index));
if (/^<img(?=[\s/>])/i.test(match[0])) result += imageMarker(match[0]);
else if (/^<br(?=[\s/>])/i.test(match[0])) result += '\n';
end = tags.lastIndex;
}
return (result + decodeEntities(html.slice(end))).replace(/\u00a0/g, ' ').trim();
}
const text = richText;
const flat = value => String(value == null ? '' : value).replace(/\s+/g, '');
const idString = value => typeof value === 'string' || Number.isSafeInteger(value) ? String(value) : '';
const signature = q => [q.type, flat(q.stem), (q.options || []).map(o => flat(o.text)).sort().join('\u001f')].join('\u001e');
const labelFor = index => String.fromCharCode(65 + index);
@@ -800,6 +973,7 @@ const QBAdapter = (() => {
const banksById = new Map();
const banksByTask = new Map();
const knownSnapshotOrder = new Map();
const deferredSnapshots = new Map();
let currentBank = null;
let pendingSelection = '';
let blockedForm = null;
@@ -818,7 +992,7 @@ const QBAdapter = (() => {
function formSnapshot() {
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
return element ? { element, text: flat(element.textContent) } : null;
return element ? { element, text: flat(text(element)) } : null;
}
function taskFromURL() {
@@ -831,10 +1005,16 @@ const QBAdapter = (() => {
function displayedQuestionCount() {
const progress = doc.querySelector('.yxtulcdsdk-answer-card__top');
const match = text(progress?.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
if (!match) return null;
const done = Number(match[1]), total = Number(match[2]);
return total >= 0 && total <= 100000 && done <= total ? total : null;
const match = progress && visible(progress) && text(progress.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
if (match) {
const done = Number(match[1]), total = Number(match[2]);
if (total >= 0 && total <= 100000 && done <= total) return total;
}
const preview = [...doc.querySelectorAll('.yxtulcdsdk-uexam-preview-content__base-row-flex--practice .info-left .info-text')]
.filter(visible).map(node => text(node.textContent).match(/^试题总数\s*[::]\s*(\d+)\s*题$/)).filter(Boolean);
if (preview.length !== 1) return null;
const total = Number(preview[0][1]);
return total >= 0 && total <= 100000 ? total : null;
}
function bankDOM() {
@@ -904,6 +1084,7 @@ const QBAdapter = (() => {
if (changed) {
try { onBankChange({ ...next }); } catch (_) { /* Consumers must not affect site navigation. */ }
}
flushDeferredSnapshots(next);
return { ...next };
}
@@ -948,8 +1129,34 @@ const QBAdapter = (() => {
}
}
function emissionContext(context, current, stale = false) {
const samePage = !current.loading && context.bankId && context.bankId === current.bankId &&
(!context.taskId || context.taskId === current.taskId) && flat(context.bankName) === flat(current.bankName) && !stale;
return { ...context, currentBankId: samePage ? current.bankId : '',
pageQuestionCount: samePage ? displayedQuestionCount() : null };
}
function deliver(records, context) {
try { onQuestions(records, context); } catch (error) { status(`保存题目失败:${error.message || error}`); }
}
function flushDeferredSnapshots(current) {
if (!active || !current.bankId || current.loading) return;
const pending = deferredSnapshots.get(current.bankId);
if (!pending) return;
if ((knownSnapshotOrder.get(current.bankId) || 0) > (pending.context.requestSequence || 0)) {
deferredSnapshots.delete(current.bankId);
return;
}
const context = emissionContext(pending.context, current);
if (context.pageQuestionCount === null) return;
deferredSnapshots.delete(current.bankId);
deliver(pending.records, context);
}
function emit(records, context = {}) {
if (!records.length) return;
if (!context.fullSnapshot && records.some(record => deferredSnapshots.has(record.bankId))) return;
const staleBanks = new Set();
if (context.fullSnapshot) {
for (const bankId of new Set(records.map(record => record.bankId))) {
@@ -961,11 +1168,18 @@ const QBAdapter = (() => {
for (const q of records) {
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
}
try { onQuestions(records); } catch (error) { status(`保存题目失败:${error.message || error}`); }
const current = syncBank();
const metadata = emissionContext(context, current, staleBanks.has(context.bankId));
const containsImages = records.some(record => /\[图片:https?:\/\//.test(record.stem) || record.options.some(option => /\[图片:https?:\/\//.test(option.text)));
if (context.fullSnapshot && context.bankId && !staleBanks.has(context.bankId) && containsImages && metadata.pageQuestionCount === null) {
deferredSnapshots.set(context.bankId, { records, context });
return;
}
deliver(records, metadata);
}
function visible(node) {
if (!node || node.closest('#huayi-qb-root')) return false;
if (!node || node.closest?.('#huayi-qb-root')) return false;
for (let at = node; at && at.nodeType === 1; at = at.parentElement) {
if (at.hidden || at.getAttribute('aria-hidden') === 'true' || at.style?.display === 'none') return false;
}
@@ -981,7 +1195,7 @@ const QBAdapter = (() => {
const form = forms[0];
const content = form.querySelector('.yxtulcdsdk-form__content');
const stemNode = content && [...content.children].find(n => n.matches('[data-rich-text]'));
const stem = stemNode && text(stemNode.textContent);
const stem = stemNode && text(stemNode);
if (!stem) return null;
const labels = [...form.querySelectorAll('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')];
if (labels.length < 2) return null;
@@ -989,9 +1203,10 @@ const QBAdapter = (() => {
const input = label.querySelector('input');
const labelNode = label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label');
const rich = labelNode && labelNode.querySelector('[data-rich-text]');
const full = text((labelNode || label).textContent);
return { id: input ? idString(input.value) : '', text: text(rich ? rich.textContent : full.replace(/^[A-Z][.、.]\s*/, '')), label: labelFor(index) };
const full = text(labelNode || label);
return { id: input ? idString(input.value) : '', text: rich ? text(rich) : full.replace(/^[A-Z][.、.]\s*/, ''), label: labelFor(index) };
});
if (options.some(option => !option.text)) return null;
let type = form.querySelector('input[type=checkbox], [role=checkbox]') ? 'multiple' : 'single';
if (options.length === 2 && options.some(o => o.text === '正确') && options.some(o => o.text === '错误')) {
type = 'judge';
@@ -1026,7 +1241,7 @@ const QBAdapter = (() => {
const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible);
for (const row of rows) {
const heading = text(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent);
const answer = text(row.querySelector('.yxtulcdsdk-examing__result-line-right')?.textContent);
const answer = text(row.querySelector('.yxtulcdsdk-examing__result-line-right'));
if (heading === '正确答案' || heading === '标准答案') {
let selected = [];
if (type === 'judge') selected = options.filter(o => o.text === answer);
@@ -1227,6 +1442,7 @@ const QBAdapter = (() => {
function stop() {
active = false;
deferredSnapshots.clear();
win.clearTimeout(timer);
uninstallNetwork();
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
@@ -1235,7 +1451,7 @@ const QBAdapter = (() => {
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
}
return { create, extractQuestions };
return { create, extractQuestions, richText };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
@@ -1252,6 +1468,10 @@ const QBAuto = (() => {
const sorted = values => [...values].sort();
const optionSet = question => JSON.stringify(sorted(question.options.map(option => optionKey(option.text, question.type))));
const localCore = supplied => supplied || (typeof QBCore !== 'undefined' ? QBCore : typeof require === 'function' ? require('./core.js') : null);
const richText = value => {
const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null;
return adapter ? adapter.richText(value) : tidy(value && typeof value === 'object' ? value.textContent : value);
};
function planAnswer(records, current, suppliedCore) {
const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more });
@@ -1262,7 +1482,8 @@ const QBAuto = (() => {
catch (_) { return result('unsupported', '当前题目结构不完整,已跳过。'); }
if (!['single', 'multiple', 'judge'].includes(question.type) || question.options.length < 2 || question.options.length > 100) return result('unsupported', '当前题型暂不支持自动选择。');
const contents = question.options.map(option => optionKey(option.text, question.type));
if (new Set(contents).size !== contents.length || new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项文字或标记重复,无法唯一对应,已跳过。');
// Multiple-choice repeated text is checked against explicit correct IDs by mapAnswers.
if ((question.type !== 'multiple' && new Set(contents).size !== contents.length) || new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项文字或标记重复,无法唯一对应,已跳过。');
const relevant = [];
for (const input of records) {
let record;
@@ -1321,13 +1542,13 @@ const QBAuto = (() => {
if (!label) label = input.closest('label');
if (label) {
const rich = label.querySelector('.yxtf-radio__label [data-rich-text], .yxtf-checkbox__label [data-rich-text]');
if (rich) return tidy(rich.textContent);
if (rich) return richText(rich);
const clone = (label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label') || label).cloneNode(true);
clone.querySelectorAll('input,button,script,style').forEach(node => node.remove());
return tidy(clone.textContent).replace(/^(?:[A-Z][.、.)::]|\[[A-Z]\])\s*/u, '');
return richText(clone).replace(/^(?:[A-Z][.、.)::]|\[[A-Z]\])\s*/u, '');
}
const labelled = tidy(input.getAttribute('aria-labelledby')).split(/\s+/u).filter(Boolean).map(id => doc.getElementById(id)).filter(node => node && container.contains(node));
return tidy(labelled.length ? labelled.map(node => node.textContent).join(' ') : input.getAttribute('aria-label'));
return tidy(labelled.length ? labelled.map(node => richText(node)).join(' ') : input.getAttribute('aria-label'));
}
function controls(container, site) {
const selector = site ? '.yxtulcdsdk-ques-radio input[type="radio"],.yxtulcdsdk-ques-checkbox input[type="checkbox"]' : 'input[type="radio"],input[type="checkbox"]';
@@ -1356,7 +1577,7 @@ const QBAuto = (() => {
if (!set) return null;
const titles = [...container.querySelectorAll(':scope > legend, :scope > .question-title, :scope > [data-question-title], :scope > .question-stem, :scope > .stem, :scope > h2, :scope > h3, :scope > p')].filter(node => visible(node) && !node.querySelector('input'));
if (titles.length !== 1) return null;
const stem = tidy(titles[0].textContent);
const stem = richText(titles[0]);
if (!stem || stem.length > 100000) return null;
const judged = set.type === 'single' && set.texts.length === 2 && set.texts.map(judgeText).includes('正确') && set.texts.map(judgeText).includes('错误');
const type = judged ? 'judge' : set.type;
@@ -1494,6 +1715,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const CONFIG_KEY = 'huayi-qb.config.v1';
const RECORDS_KEY = 'huayi-qb.records.v1';
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 DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const storage = {
@@ -1525,17 +1748,98 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original });
else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
}
function repairAnswerConflicts(original) {
const result = QBCore.repairDuplicateOptionConflicts(original);
if (!result.repaired.length) return result;
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (existing !== null && (!existing || existing.schemaVersion !== 1 || !Array.isArray(existing.records))) {
throw new Error('答案误报修复备份结构异常,已停止修复');
}
// Preserve every original version, including later imports and other tabs.
// Back up before returning repaired data to any caller that may persist it.
const originals = existing ? [...existing.records] : [];
const signatures = new Set(originals.map(record => JSON.stringify(record)));
for (const item of result.repaired) {
const signature = JSON.stringify(item.original);
if (!signatures.has(signature)) { signatures.add(signature); originals.push(item.original); }
}
storage.set(OPTION_REPAIR_BACKUP_KEY, { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals });
return result;
}
function imageReplacementSignature(value) {
const record = QBCore.normalizeRecord(value);
return JSON.stringify([record.key, record.type, record.stem,
record.options.map(option => JSON.stringify([option.id, option.text])).sort(),
[...record.correctOptionIds].sort(), [...record.answerTexts].sort(), record.answerStatus]);
}
function imageUpgradeBackup() {
const backup = storage.get(IMAGE_UPGRADE_BACKUP_KEY, null);
if (backup !== null && (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records) || !Array.isArray(backup.migrations))) {
throw new Error('图片补全备份结构异常,已停止覆盖');
}
return backup;
}
function ensureImageUpgradeBackup(upgraded) {
if (!upgraded.length) return;
const previous = imageUpgradeBackup();
const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止图片补全');
const originals = previous ? [...previous.records] : [], migrations = previous ? [...previous.migrations] : [];
const signatures = new Set(originals.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
for (const item of upgraded) {
const canonical = QBCore.normalizeRecord(item.original);
// Compaction may just have assigned occurrence=0 in memory, without a
// previous storage write. Capture that exact stored predecessor as well;
// only this generated metadata may be absent, never content or answers.
const versions = [item.original, ...stored.filter(value => {
const record = QBCore.normalizeRecord(value);
if (record.key !== canonical.key) return false;
if (record.contentOccurrence == null && canonical.contentOccurrence != null) record.contentOccurrence = canonical.contentOccurrence;
return JSON.stringify(QBCore.normalizeRecord(record)) === JSON.stringify(canonical);
})];
for (const value of versions) {
const original = QBCore.normalizeRecord(value), originalSignature = JSON.stringify(original);
if (!signatures.has(originalSignature)) { signatures.add(originalSignature); originals.push(value); }
const migration = { originalKey: original.key, originalSignature, replacementKey: item.replacement.key,
replacementSignature: imageReplacementSignature(item.replacement), bankId: item.bankId,
batchId: item.batchId, expectedCount: item.expectedCount, questionIndex: item.questionIndex };
if (!migrations.some(value => JSON.stringify(value) === JSON.stringify(migration))) migrations.push(migration);
}
}
storage.set(IMAGE_UPGRADE_BACKUP_KEY, { schemaVersion: 1, savedAt: previous?.savedAt || new Date().toISOString(), records: originals, migrations });
}
function removeBackedUpImagePredecessors(original, replacements) {
const backup = imageUpgradeBackup();
if (!backup) return original;
// A stale tab can reintroduce the exact pre-upgrade record while saving.
// Remove only a durably backed-up version with its unchanged, confirmed
// replacement present. Different answers or newly changed old data survive.
const backups = new Set(backup.records.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
return original.filter(value => {
const record = QBCore.normalizeRecord(value), signature = JSON.stringify(record);
return !backup.migrations.some(item => item.originalKey === record.key && item.originalSignature === signature && backups.has(signature) &&
replacements.some(replacement => replacement.answerStatus === 'confirmed' && replacement.key === item.replacementKey &&
replacement.questionIdAliases?.includes(record.questionId) && imageReplacementSignature(replacement) === item.replacementSignature));
});
}
try {
const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
const compacted = QBCore.compactRecords(QBCore.upsert([], saved));
const retained = removeBackedUpImagePredecessors(saved, saved);
const repaired = repairAnswerConflicts(retained);
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
records = compacted.records;
const removed = saved.length - records.length;
const removed = retained.length - records.length;
if (retained.length < saved.length) recordsDirty = true;
if (removed > 0) {
ensureCleanupBackup(saved);
recordsDirty = true;
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
}
if (repaired.repaired.length) {
recordsDirty = true;
cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.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);
@@ -1593,9 +1897,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
// Merge other tabs before writing; GM change events also refresh an open panel.
const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
const compacted = QBCore.compactRecords(QBCore.upsert(stored, records));
const retained = removeBackedUpImagePredecessors(stored, records);
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records));
if (compacted.removed > 0) ensureCleanupBackup(stored);
records = compacted.records;
records = repairAnswerConflicts(compacted.records).records;
storage.set(RECORDS_KEY, records);
recordsDirty = false;
storageError = false;
@@ -1604,9 +1909,18 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
function receive(incoming) {
function receive(incoming, context = {}) {
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try {
incoming = repairAnswerConflicts(incoming).records;
const bank = currentBank();
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, {
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
});
ensureImageUpgradeBackup(imageUpgrade.upgraded);
records = imageUpgrade.existing;
incoming = imageUpgrade.incoming;
const namesById = new Map();
incoming.forEach(record => {
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return;
@@ -1622,7 +1936,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 = compacted.records;
records = repairAnswerConflicts(compacted.records).records;
recordsDirty = true;
clearTimeout(saveTimer);
saveTimer = setTimeout(persistRecords, 250);
@@ -1878,6 +2192,18 @@ 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 exportOptionRepairBackup() {
const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (!backup || !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} 条原始记录。`);
}
function exportImageUpgradeBackup() {
const backup = imageUpgradeBackup();
if (!backup) 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/, '');
@@ -1910,10 +2236,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, incoming);
const expanded = QBCore.upsert(records, repairAnswerConflicts(removeBackedUpImagePredecessors(incoming, records)).records);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
const merged = compacted.records;
const merged = repairAnswerConflicts(compacted.records).records;
records = merged;
recordsDirty = true;
persistRecords();
@@ -1966,6 +2292,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
<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>
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
</div>
@@ -2004,6 +2332,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
case 'export-csv': exportData('csv'); break;
case 'export-json': exportData('json'); break;
case 'export-dedup-backup': exportCleanupBackup(); break;
case 'export-option-repair-backup': exportOptionRepairBackup(); break;
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break;
}
} catch (error) { tell(`操作失败:${error.message}`, true); }
@@ -2062,10 +2392,18 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
GM_registerMenuCommand('导出图片补全前原始备份', exportImageUpgradeBackup);
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
try { records = QBCore.compactRecords(QBCore.upsert(records, value)).records; if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } }
try {
const retained = removeBackedUpImagePredecessors(value, records);
const incoming = repairAnswerConflicts(retained);
records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records;
if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(persistRecords, 250); }
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
}
catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
});
if (config.collecting) adapter.start();