fix: reduce userscript page blocking and stale option clicks
This commit is contained in:
Vendored
+361
-83
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.4
|
||||
// @version 1.2.5
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
@@ -232,6 +232,13 @@ const QBCore = (() => {
|
||||
return map;
|
||||
};
|
||||
const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming);
|
||||
const previousBySignature = new Map(), indexCounts = new Map(), originalByKey = new Map();
|
||||
existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); });
|
||||
for (const record of existingBank) {
|
||||
const key = signature(record);
|
||||
if (!previousBySignature.has(key)) previousBySignature.set(key, record);
|
||||
indexCounts.set(record.questionIndex, (indexCounts.get(record.questionIndex) || 0) + 1);
|
||||
}
|
||||
const correctContents = record => {
|
||||
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length ||
|
||||
record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length) return null;
|
||||
@@ -244,18 +251,18 @@ const QBCore = (() => {
|
||||
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);
|
||||
const previous = previousBySignature.get(key);
|
||||
if (!previous.questionId || imageCount(previous) || previous.type !== current.type ||
|
||||
!Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) ||
|
||||
(previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) ||
|
||||
existingBank.filter(record => record.questionIndex === current.questionIndex).length !== 1) continue;
|
||||
indexCounts.get(current.questionIndex) !== 1) continue;
|
||||
const oldAnswers = correctContents(previous), newAnswers = correctContents(current);
|
||||
if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) ||
|
||||
!sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue;
|
||||
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
|
||||
incoming[index] = replacement;
|
||||
removed.add(previous.key);
|
||||
upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement,
|
||||
upgraded.push({ original: originalByKey.get(previous.key), replacement,
|
||||
bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
|
||||
}
|
||||
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
|
||||
@@ -294,10 +301,26 @@ const QBCore = (() => {
|
||||
const sameModern = (record, current) => !hasEntity(record) && record.type === current.type && record.questionIndex === current.questionIndex &&
|
||||
normalized(record.stem) === normalized(current.stem) && sameArray(optionPairs(record), optionPairs(current)) && isList(record.source) && sameAnswers(record, current);
|
||||
const bank = existing.filter(record => record.bankId === bankId), upgraded = [], removed = new Set();
|
||||
const incomingCounts = new Map(), bankBySignature = new Map(), legacyBySignature = new Map(), bankByIndex = new Map(), originalByKey = new Map();
|
||||
existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); });
|
||||
const add = (map, key, record) => {
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(record);
|
||||
};
|
||||
for (const record of incoming) {
|
||||
const key = signature(record);
|
||||
incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1);
|
||||
}
|
||||
for (const record of bank) {
|
||||
const key = signature(record);
|
||||
add(bankBySignature, key, record);
|
||||
add(bankByIndex, record.questionIndex, record);
|
||||
if (hasEntity(record)) add(legacyBySignature, key, record);
|
||||
}
|
||||
for (let index = 0; index < incoming.length; index++) {
|
||||
const current = incoming[index], key = signature(current);
|
||||
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue;
|
||||
const candidates = bank.filter(record => hasEntity(record) && signature(record) === key);
|
||||
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue;
|
||||
const candidates = legacyBySignature.get(key) || [];
|
||||
if (candidates.length !== 1) continue;
|
||||
const previous = candidates[0];
|
||||
if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) ||
|
||||
@@ -306,12 +329,12 @@ const QBCore = (() => {
|
||||
// A previous failed export may already contain both spellings. Permit only
|
||||
// fully agreeing modern copies at that very same source position; distinct
|
||||
// literal-entity questions and duplicate occurrences remain untouched.
|
||||
if (bank.some(record => (signature(record) === key || record.questionIndex === current.questionIndex) &&
|
||||
if ([...(bankBySignature.get(key) || []), ...(bankByIndex.get(current.questionIndex) || [])].some(record =>
|
||||
record.key !== previous.key && !sameModern(record, current))) continue;
|
||||
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
|
||||
incoming[index] = replacement;
|
||||
removed.add(previous.key);
|
||||
upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement,
|
||||
upgraded.push({ original: originalByKey.get(previous.key), replacement,
|
||||
kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
|
||||
}
|
||||
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
|
||||
@@ -331,8 +354,12 @@ const QBCore = (() => {
|
||||
}
|
||||
|
||||
function mergeRecord(existing, incoming) {
|
||||
const oldRecord = normalizeRecord(existing);
|
||||
const newRecord = normalizeRecord(incoming);
|
||||
return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming));
|
||||
}
|
||||
|
||||
// Internal callers already validated both records. Keep validation at the
|
||||
// public boundary and on the merged result without cloning both inputs again.
|
||||
function mergeNormalizedRecords(oldRecord, newRecord) {
|
||||
if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
|
||||
const contentMatches = sameContent(oldRecord, newRecord);
|
||||
const explanation = newRecord.explanation || oldRecord.explanation;
|
||||
@@ -378,27 +405,69 @@ const QBCore = (() => {
|
||||
const lastSeen = new Map();
|
||||
for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) {
|
||||
const record = normalizeRecord(item);
|
||||
records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record);
|
||||
records.set(record.key, records.has(record.key) ? mergeNormalizedRecords(records.get(record.key), record) : record);
|
||||
lastSeen.set(record.key, index);
|
||||
}
|
||||
// Explicit aliases are produced only by content/batch repair. Ordinary
|
||||
// records with different IDs are still kept separate until that repair.
|
||||
const bankIdentity = bankResolver([...records.values()]);
|
||||
const signatures = new WeakMap();
|
||||
const signature = record => {
|
||||
if (!signatures.has(record)) signatures.set(record, contentSignature(record));
|
||||
return signatures.get(record);
|
||||
};
|
||||
const byBankAndId = new Map();
|
||||
const positions = new Map();
|
||||
for (const [index, record] of [...records.values()].entries()) {
|
||||
positions.set(record.key, index);
|
||||
if (!record.questionId) continue;
|
||||
const key = JSON.stringify([bankIdentity(record), record.questionId]);
|
||||
if (!byBankAndId.has(key)) byBankAndId.set(key, []);
|
||||
byBankAndId.get(key).push(record.key);
|
||||
}
|
||||
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
|
||||
if (!records.has(target.key)) continue;
|
||||
for (const other of [...records.values()]) {
|
||||
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue;
|
||||
const queued = new Set(), knownAliases = new Set(), candidates = [];
|
||||
let cursor = -1;
|
||||
const enqueue = aliases => {
|
||||
for (const alias of aliases) {
|
||||
if (knownAliases.has(alias)) continue;
|
||||
knownAliases.add(alias);
|
||||
for (const key of byBankAndId.get(JSON.stringify([bankIdentity(target), alias])) || []) {
|
||||
if (key === target.key || queued.has(key) || positions.get(key) <= cursor) continue;
|
||||
queued.add(key);
|
||||
// Retain the old Map traversal order, including when a merged
|
||||
// record introduces further aliases during this pass.
|
||||
let low = 0, high = candidates.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (positions.get(candidates[middle]) < positions.get(key)) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
candidates.splice(low, 0, key);
|
||||
}
|
||||
}
|
||||
};
|
||||
enqueue(records.get(target.key).questionIdAliases || []);
|
||||
while (candidates.length) {
|
||||
const key = candidates.shift();
|
||||
cursor = positions.get(key);
|
||||
const other = records.get(key);
|
||||
if (!other) continue;
|
||||
const current = records.get(target.key);
|
||||
const aliases = new Set(current.questionIdAliases || []);
|
||||
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
|
||||
if (signature(current) !== signature(other)) continue;
|
||||
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
|
||||
records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }));
|
||||
const merged = foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence });
|
||||
records.set(target.key, merged);
|
||||
records.delete(other.key);
|
||||
enqueue(merged.questionIdAliases || []);
|
||||
}
|
||||
}
|
||||
const repairedContents = new Set([...records.values()].filter(record => record.questionIdAliases?.length > 1)
|
||||
.map(record => JSON.stringify([bankIdentity(record), signature(record)])));
|
||||
for (const record of [...records.values()]) {
|
||||
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
|
||||
if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key);
|
||||
if (repairedContents.has(JSON.stringify([bankIdentity(record), signature(record)]))) records.delete(record.key);
|
||||
}
|
||||
// The saved DOM has no stable question ID. Upgrade it only when the complete
|
||||
// content and bank identify one unique server record in both directions.
|
||||
@@ -409,17 +478,28 @@ const QBCore = (() => {
|
||||
if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false;
|
||||
return normalized(a.bankName) === normalized(b.bankName);
|
||||
};
|
||||
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b) &&
|
||||
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && sameBank(a, b) &&
|
||||
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
|
||||
const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))]));
|
||||
const identifiedByContent = new Map();
|
||||
for (const record of identified) {
|
||||
const key = signature(record);
|
||||
if (!identifiedByContent.has(key)) identifiedByContent.set(key, []);
|
||||
identifiedByContent.get(key).push(record);
|
||||
}
|
||||
const candidates = new Map(), candidateCounts = new Map();
|
||||
for (const record of anonymous) {
|
||||
const matches = (identifiedByContent.get(signature(record)) || []).filter(other => possiblePair(record, other));
|
||||
candidates.set(record.key, matches);
|
||||
for (const match of matches) candidateCounts.set(match.key, (candidateCounts.get(match.key) || 0) + 1);
|
||||
}
|
||||
for (const record of anonymous) {
|
||||
const matches = candidates.get(record.key);
|
||||
if (matches.length !== 1) continue;
|
||||
const target = matches[0];
|
||||
if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue;
|
||||
if (candidateCounts.get(target.key) !== 1) continue;
|
||||
const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId });
|
||||
const currentTarget = records.get(target.key);
|
||||
const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted);
|
||||
const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeNormalizedRecords(promoted, currentTarget) : mergeNormalizedRecords(currentTarget, promoted);
|
||||
records.set(target.key, merged);
|
||||
records.delete(record.key);
|
||||
}
|
||||
@@ -460,10 +540,13 @@ const QBCore = (() => {
|
||||
// Process missing observations first so the selected options and the answer
|
||||
// IDs always come together from an answer-bearing record.
|
||||
const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing'));
|
||||
if (group.length === 1 && first.bankId === meta.bankId && first.bankName === meta.bankName && first.questionId === meta.questionId &&
|
||||
(meta.contentOccurrence == null || meta.contentOccurrence === first.contentOccurrence) &&
|
||||
(!own(meta, 'questionIdAliases') || JSON.stringify(meta.questionIdAliases) === JSON.stringify(first.questionIdAliases))) return first;
|
||||
let result;
|
||||
for (const record of ordered) {
|
||||
const rebased = normalizeRecord({ ...record, ...meta });
|
||||
result = result ? mergeRecord(result, rebased) : rebased;
|
||||
result = result ? mergeNormalizedRecords(result, rebased) : rebased;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -477,13 +560,15 @@ const QBCore = (() => {
|
||||
const legacySources = new Map();
|
||||
for (const entry of entries) {
|
||||
const record = entry.record;
|
||||
entry.signature = contentSignature(record);
|
||||
entry.bank = bankIdentity(record);
|
||||
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
|
||||
const explicit = record.source.batchId || record.answerSource?.batchId;
|
||||
if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`;
|
||||
if (explicit) entry.batch = `${entry.bank}:${explicit}`;
|
||||
else if (entry.occurrence == null) {
|
||||
const source = listSource(record);
|
||||
if (!source) continue;
|
||||
const key = JSON.stringify([bankIdentity(record), source.url]);
|
||||
const key = JSON.stringify([entry.bank, source.url]);
|
||||
if (!legacySources.has(key)) legacySources.set(key, []);
|
||||
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
|
||||
}
|
||||
@@ -503,35 +588,47 @@ const QBCore = (() => {
|
||||
const counters = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!entry.batch || entry.occurrence != null) continue;
|
||||
const key = JSON.stringify([entry.batch, contentSignature(entry.record)]);
|
||||
const key = JSON.stringify([entry.batch, entry.signature]);
|
||||
entry.occurrence = counters.get(key) || 0;
|
||||
counters.set(key, entry.occurrence + 1);
|
||||
}
|
||||
const groups = new Map();
|
||||
const byContent = new Map();
|
||||
const byContentAlias = new Map();
|
||||
const indexAliases = (key, entry) => {
|
||||
if (!byContentAlias.has(entry.content)) byContentAlias.set(entry.content, new Map());
|
||||
const aliases = byContentAlias.get(entry.content);
|
||||
for (const id of [entry.record.questionId, ...(entry.record.questionIdAliases || [])]) {
|
||||
if (!id) continue;
|
||||
if (!aliases.has(id)) aliases.set(id, new Set());
|
||||
aliases.get(id).add(key);
|
||||
}
|
||||
};
|
||||
const add = (key, entry) => {
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(entry);
|
||||
};
|
||||
for (const entry of entries) {
|
||||
const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]);
|
||||
const content = JSON.stringify([entry.bank, entry.signature]);
|
||||
entry.content = content;
|
||||
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
|
||||
const key = JSON.stringify([content, entry.occurrence]);
|
||||
add(key, entry);
|
||||
if (!byContent.has(content)) byContent.set(content, new Set());
|
||||
byContent.get(content).add(key);
|
||||
indexAliases(key, entry);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue;
|
||||
const matches = [...(byContent.get(entry.content) || [])];
|
||||
const matches = byContent.get(entry.content);
|
||||
let key;
|
||||
const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId)));
|
||||
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0];
|
||||
else if (matches.length === 1) key = matches[0];
|
||||
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
|
||||
const matchingAlias = byContentAlias.get(entry.content)?.get(entry.record.questionId);
|
||||
if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value;
|
||||
else if (matches?.size === 1) key = matches.values().next().value;
|
||||
else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
|
||||
else key = `unresolved:${entry.record.key}`;
|
||||
add(key, entry);
|
||||
if (matches?.has(key)) indexAliases(key, entry);
|
||||
}
|
||||
const records = [];
|
||||
let duplicateGroups = 0;
|
||||
@@ -842,7 +939,7 @@ const QBAdapter = (() => {
|
||||
function standardAnswer(node, type, options, context) {
|
||||
const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null;
|
||||
const answerNode = result || node;
|
||||
if (answerNode.allowViewSingleQuesAnswer === false || answerNode.allowViewSingleQuesAnswer === 0) {
|
||||
if ([node, answerNode].some(value => value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0)) {
|
||||
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
|
||||
}
|
||||
if (type === 'judge') {
|
||||
@@ -1037,6 +1134,7 @@ const QBAdapter = (() => {
|
||||
let requestSequence = 0;
|
||||
let batchSequence = 0;
|
||||
const known = new Map();
|
||||
let knownContentIndex = null;
|
||||
const xhrContext = new WeakMap();
|
||||
const pendingXHR = new Set();
|
||||
const banksById = new Map();
|
||||
@@ -1237,6 +1335,7 @@ const QBAdapter = (() => {
|
||||
for (const q of records) {
|
||||
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
|
||||
}
|
||||
knownContentIndex = null;
|
||||
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)));
|
||||
@@ -1255,6 +1354,22 @@ const QBAdapter = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchingKnown(question) {
|
||||
if (!knownContentIndex) {
|
||||
knownContentIndex = new Map();
|
||||
for (const record of known.values()) {
|
||||
const content = signature(record);
|
||||
const keys = [`id:${record.bankId}\u001d${content}`, `name:${flat(record.bankName)}\u001d${content}`];
|
||||
for (const key of keys) {
|
||||
if (!knownContentIndex.has(key)) knownContentIndex.set(key, []);
|
||||
knownContentIndex.get(key).push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = question.bankId ? `id:${question.bankId}` : `name:${flat(question.bankName)}`;
|
||||
return knownContentIndex.get(`${key}\u001d${signature(question)}`) || [];
|
||||
}
|
||||
|
||||
function getCurrentQuestion() {
|
||||
if (!isSupportedPage()) return null;
|
||||
const bank = syncBank();
|
||||
@@ -1289,8 +1404,7 @@ const QBAdapter = (() => {
|
||||
const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/);
|
||||
const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null;
|
||||
if (questionIndex !== null) q.questionIndex = questionIndex;
|
||||
let matches = [...known.values()].filter(item => signature(item) === signature(q) &&
|
||||
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
|
||||
let matches = matchingKnown(q);
|
||||
if (questionIndex !== null) {
|
||||
const indexed = matches.filter(item => item.questionIndex === questionIndex);
|
||||
if (indexed.length) matches = indexed;
|
||||
@@ -1298,6 +1412,11 @@ const QBAdapter = (() => {
|
||||
}
|
||||
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
|
||||
if (matches.length === 1) {
|
||||
// Vue may update the question text before the old radio values. Those
|
||||
// transitional controls must not be attached to the new server identity.
|
||||
const expectedOptions = matches[0].options;
|
||||
if (expectedOptions.every(option => option.id) && options.some(option => option.id &&
|
||||
!expectedOptions.some(expected => expected.id === option.id && flat(expected.text) === flat(option.text)))) return null;
|
||||
q.questionId = matches[0].questionId;
|
||||
q.bankId = matches[0].bankId;
|
||||
if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex;
|
||||
@@ -1305,7 +1424,8 @@ const QBAdapter = (() => {
|
||||
if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId;
|
||||
if (blockedForm) { blockedForm = null; syncBank(); }
|
||||
}
|
||||
// Only a row explicitly labelled standard answer can establish correctness.
|
||||
// A letter-only result row can survive the previous question during a
|
||||
// render. Its heading alone does not prove it belongs to this question.
|
||||
const main = form.closest(ROOT_SELECTOR);
|
||||
const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible);
|
||||
for (const row of rows) {
|
||||
@@ -1320,11 +1440,22 @@ const QBAdapter = (() => {
|
||||
if (selected.length !== requested.length) selected = [];
|
||||
}
|
||||
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 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;
|
||||
if ((feedbackId && feedbackId !== q.questionId) || (!boundToCurrent && !agreesWithKnown)) continue;
|
||||
q.correctOptionIds = selected.map(o => o.id).filter(Boolean);
|
||||
q.answerTexts = selected.map(o => o.text);
|
||||
q.answerStatus = 'confirmed';
|
||||
}
|
||||
} else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') q.explanation = answer;
|
||||
} else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') {
|
||||
const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id']
|
||||
.map(name => idString(row.getAttribute?.(name))).find(Boolean);
|
||||
if (q.answerStatus === 'confirmed' && (!feedbackId || feedbackId === q.questionId)) q.explanation = answer;
|
||||
}
|
||||
}
|
||||
return q;
|
||||
}
|
||||
@@ -1459,22 +1590,44 @@ const QBAdapter = (() => {
|
||||
|
||||
function ensureObserver() {
|
||||
if (observer || !win.MutationObserver || !isSupportedPage()) return;
|
||||
const contextSelectors = '.task-content, .task-content-active, header.o2oPlayFrame-right, .yxtulcdsdk-exam-header, .yxtulcdsdk-answer-card__top, .yxtulcdsdk-uexam-preview-content__base-row-flex--practice, title';
|
||||
const questionSelectors = '.yxtulcdsdk-form__content, .yxtulcdsdk-form__sub, .yxtulcdsdk-examing__result-line';
|
||||
const insertedSelectors = `${ROOT_SELECTOR}, .yxtulcdsdk-form, .yxtulcdsdk-exam-container, ${contextSelectors}, ${questionSelectors}`;
|
||||
function relevantMutation(mutation) {
|
||||
const node = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement;
|
||||
if (!node || node.closest?.('#huayi-qb-root') || node.id === 'huayi-qb-copy-style') return false;
|
||||
const inQuestion = node.closest?.(ROOT_SELECTOR);
|
||||
if (inQuestion) {
|
||||
if (mutation.type === 'attributes') {
|
||||
// checked/disabled/selected and feedback classes change on every
|
||||
// answer. They do not change the question or its standard answer.
|
||||
if (node.closest?.('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')) return false;
|
||||
return Boolean(node.matches?.(`${ROOT_SELECTOR}, .yxtulcdsdk-form`) ||
|
||||
node.closest?.('.yxtulcdsdk-examing__result-line'));
|
||||
}
|
||||
if (node.closest?.(questionSelectors)) return true;
|
||||
}
|
||||
if (node.closest?.(contextSelectors)) return true;
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'task-id' && node.matches?.('.yxtulcdsdk-exam-container')) return true;
|
||||
if (mutation.type !== 'childList') return false;
|
||||
// Structural additions/removals can introduce an exercise root, but
|
||||
// unrelated timers, chat widgets, answer-card cells and our UI cannot.
|
||||
return [...(mutation.addedNodes || []), ...(mutation.removedNodes || [])].some(changed => changed.nodeType === 1 &&
|
||||
changed.id !== 'huayi-qb-root' && changed.id !== 'huayi-qb-copy-style' &&
|
||||
(changed.matches?.(insertedSelectors) || changed.querySelector?.(insertedSelectors)));
|
||||
}
|
||||
observer = new win.MutationObserver(mutations => {
|
||||
const relevant = mutations.some(m => {
|
||||
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
|
||||
return node && !node.closest('#huayi-qb-root') && node.id !== 'huayi-qb-copy-style';
|
||||
});
|
||||
if (!relevant) return;
|
||||
syncBank();
|
||||
if (copyEnabled) ensureCopyStyle();
|
||||
if (!active) return;
|
||||
win.clearTimeout(timer);
|
||||
if (!mutations.some(relevantMutation) || timer !== null) return;
|
||||
timer = win.setTimeout(() => {
|
||||
timer = null;
|
||||
syncBank();
|
||||
if (copyEnabled) ensureCopyStyle();
|
||||
if (!active) return;
|
||||
const q = getCurrentQuestion();
|
||||
if (!q) return;
|
||||
const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]);
|
||||
if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); }
|
||||
}, 180);
|
||||
}, 120);
|
||||
});
|
||||
observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] });
|
||||
}
|
||||
@@ -1513,6 +1666,7 @@ const QBAdapter = (() => {
|
||||
active = false;
|
||||
deferredSnapshots.clear();
|
||||
win.clearTimeout(timer);
|
||||
timer = null;
|
||||
uninstallNetwork();
|
||||
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
||||
}
|
||||
@@ -1541,6 +1695,50 @@ const QBAuto = (() => {
|
||||
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);
|
||||
};
|
||||
const recordIndexes = new WeakMap();
|
||||
function candidateRecords(records, question, core) {
|
||||
let index = recordIndexes.get(records);
|
||||
if (!index || index.core !== core) {
|
||||
index = { core, entries: [], byQuestion: new Map(), normalized: new WeakMap() };
|
||||
recordIndexes.set(records, index);
|
||||
}
|
||||
let changed = index.entries.length !== records.length;
|
||||
const entries = records.map((record, position) => {
|
||||
const rawStem = record && typeof record.stem === 'string' ? record.stem : '';
|
||||
const type = record && typeof record.type === 'string' ? record.type : '';
|
||||
const old = index.entries[position];
|
||||
if (old?.record === record && old.rawStem === rawStem && old.type === type) return old;
|
||||
changed = true;
|
||||
return { record, rawStem, type, key: rawStem ? JSON.stringify([type, stemKey(rawStem)]) : '' };
|
||||
});
|
||||
if (changed) {
|
||||
index.entries = entries;
|
||||
index.byQuestion = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!entry.key || !entry.record || typeof entry.record !== 'object') continue;
|
||||
if (!index.byQuestion.has(entry.key)) index.byQuestion.set(entry.key, []);
|
||||
index.byQuestion.get(entry.key).push(entry.record);
|
||||
}
|
||||
}
|
||||
const matches = index.byQuestion.get(JSON.stringify([question.type, stemKey(question.stem)])) || [];
|
||||
return matches.map(input => {
|
||||
// Only exact-stem candidates need full answer validation. This signature
|
||||
// also detects in-place edits to answers/options, not just array replacement.
|
||||
let signature;
|
||||
try {
|
||||
signature = JSON.stringify([input.bankId, input.bankName, input.questionId, input.type, input.stem,
|
||||
input.options, input.correctOptionIds, input.answerTexts, input.answerStatus, input.conflicts,
|
||||
input.questionIndex, input.contentOccurrence, input.questionIdAliases]);
|
||||
} catch (_) { return null; }
|
||||
const saved = index.normalized.get(input);
|
||||
if (saved && saved.signature === signature) return saved.record;
|
||||
try {
|
||||
const record = core.normalizeRecord(input);
|
||||
index.normalized.set(input, { signature, record });
|
||||
return record;
|
||||
} catch (_) { return null; }
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function planAnswer(records, current, suppliedCore) {
|
||||
const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more });
|
||||
@@ -1553,9 +1751,7 @@ const QBAuto = (() => {
|
||||
// Repeated text requires mapAnswers to prove the complete option identity or correct set.
|
||||
if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。');
|
||||
const relevant = [];
|
||||
for (const input of records) {
|
||||
let record;
|
||||
try { record = core.normalizeRecord(input); } catch (_) { continue; }
|
||||
for (const record of candidateRecords(records, question, core)) {
|
||||
if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue;
|
||||
if (question.bankId && record.bankId && question.bankId !== record.bankId) continue;
|
||||
if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue;
|
||||
@@ -1637,7 +1833,7 @@ const QBAuto = (() => {
|
||||
if (!current || bank?.loading) return null;
|
||||
const set = controls(container, true);
|
||||
if (!set || set.inputs.length !== current.options?.length || set.texts.some((value, index) => optionKey(value, current.type) !== optionKey(current.options[index].text, current.type))) return null;
|
||||
return { container, inputs: set.inputs, question: current, site: true };
|
||||
return { container, inputs: set.inputs, question: current, site: true, bankIdentity: JSON.stringify([bank?.taskId || '', bank?.bankId || '']) };
|
||||
}
|
||||
function readGeneric(container) {
|
||||
if (!visible(container)) return null;
|
||||
@@ -1651,7 +1847,7 @@ const QBAuto = (() => {
|
||||
const type = judged ? 'judge' : set.type;
|
||||
const question = {
|
||||
bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')),
|
||||
questionId: '', stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })),
|
||||
questionId: tidy(container.getAttribute('data-question-id') || container.getAttribute('data-ques-id')), stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })),
|
||||
answerStatus: 'missing', answerTexts: [], correctOptionIds: []
|
||||
};
|
||||
return { container, inputs: set.inputs, question, site: false };
|
||||
@@ -1663,7 +1859,10 @@ const QBAuto = (() => {
|
||||
}
|
||||
function fingerprint(candidate) {
|
||||
const question = candidate.question;
|
||||
return JSON.stringify([question.bankId || '', question.bankName || '', question.type, stemKey(question.stem), question.options.map(option => [option.label, optionKey(option.text, question.type)])]);
|
||||
return JSON.stringify([question.bankId || '', question.bankName || '', candidate.bankIdentity || '', question.questionId || '',
|
||||
question.questionIndex ?? null, question.contentOccurrence ?? null, question.type, stemKey(question.stem),
|
||||
question.options.map(option => [option.id || '', option.label, optionKey(option.text, question.type)]),
|
||||
candidate.inputs.map(input => [input.value, input.id, input.name, input.type])]);
|
||||
}
|
||||
function stillSame(candidate, mark) {
|
||||
if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false;
|
||||
@@ -1673,6 +1872,17 @@ const QBAuto = (() => {
|
||||
function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; }
|
||||
function desired(candidate, plan) { return candidate.question.options.map(option => plan.labels.includes(option.label)); }
|
||||
function alreadyChosen(candidate, flags) { return candidate.inputs.every((input, index) => Boolean(input.checked) === flags[index]); }
|
||||
async function settled(candidate, mark, runGeneration, continuous) {
|
||||
// A single Promise only waits one microtask. Vue and native event handlers
|
||||
// can schedule further microtasks or a later render before changing values.
|
||||
// Observe two distinct task turns before selecting any next option.
|
||||
for (let check = 0; check < 2; check++) {
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, 50));
|
||||
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) return 'cancelled';
|
||||
if (!stillSame(candidate, mark)) return 'changed';
|
||||
}
|
||||
return 'stable';
|
||||
}
|
||||
function skipped(message, stem = '', count = true) {
|
||||
emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) });
|
||||
return { status: 'skipped', message };
|
||||
@@ -1706,6 +1916,9 @@ const QBAuto = (() => {
|
||||
const runGeneration = generation;
|
||||
emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem });
|
||||
try {
|
||||
const initial = await settled(candidate, mark, runGeneration, continuous);
|
||||
if (initial === 'cancelled') return { status: 'cancelled', message: '自动选择已暂停。' };
|
||||
if (initial !== 'stable') return skipped('题目仍在切换或选项身份变化,已停止本次选择。', questionStem);
|
||||
const actions = candidate.question.type === 'multiple'
|
||||
? candidate.inputs.map((input, index) => ({ input, index })).filter(({ input, index }) => Boolean(input.checked) !== flags[index])
|
||||
: candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]);
|
||||
@@ -1716,12 +1929,12 @@ const QBAuto = (() => {
|
||||
}
|
||||
if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem);
|
||||
input.click();
|
||||
await Promise.resolve();
|
||||
const next = await settled(candidate, mark, runGeneration, continuous);
|
||||
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
|
||||
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
|
||||
return { status: 'cancelled', message: '自动选择已暂停。' };
|
||||
}
|
||||
if (!stillSame(candidate, mark)) return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem);
|
||||
if (next !== 'stable') return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem);
|
||||
}
|
||||
if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem);
|
||||
emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem });
|
||||
@@ -1764,7 +1977,8 @@ const QBAuto = (() => {
|
||||
return element && !element.closest('#huayi-qb-root');
|
||||
})) schedule();
|
||||
});
|
||||
observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled'] });
|
||||
observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true,
|
||||
attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled', 'value', 'data-question-id', 'data-ques-id', 'data-bank-id'] });
|
||||
}
|
||||
interval = win.setInterval(schedule, 1500);
|
||||
schedule();
|
||||
@@ -1811,12 +2025,17 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
let recordsDirty = false;
|
||||
let loadError = '';
|
||||
let cleanupNotice = '';
|
||||
let storedRecordsText = '';
|
||||
let saveIdle = null;
|
||||
const SAVE_DELAY = 800;
|
||||
function ensureCleanupBackup(original) {
|
||||
const existing = storage.get(BACKUP_KEY, null);
|
||||
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) {
|
||||
if (!original.some(record => record?.answerStatus === 'conflict' && Array.isArray(record.conflicts) && record.conflicts.some(item =>
|
||||
item?.reason === '正确选项ID与正确答案内容不一致'))) return { records: original, repaired: [] };
|
||||
const result = QBCore.repairDuplicateOptionConflicts(original);
|
||||
if (!result.repaired.length) return result;
|
||||
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
|
||||
@@ -1897,6 +2116,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
try {
|
||||
const saved = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
|
||||
storedRecordsText = JSON.stringify(saved);
|
||||
const retained = removeBackedUpImagePredecessors(saved, saved);
|
||||
const repaired = repairAnswerConflicts(retained);
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
|
||||
@@ -1941,7 +2161,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
if (!bank || (!bank.bankName && !bank.bankId)) {
|
||||
if (activeBank) {
|
||||
activeBank = null; scope = ''; searchScope = '';
|
||||
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
|
||||
renderVisible();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1951,7 +2171,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
const changed = JSON.stringify(previous) !== JSON.stringify(bank);
|
||||
activeBank = { ...bank };
|
||||
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
|
||||
if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
|
||||
if (changed) renderVisible();
|
||||
}
|
||||
|
||||
function adapterStatus(value) {
|
||||
@@ -1964,16 +2184,25 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
}
|
||||
function persistRecords() {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
|
||||
if (loadError || !recordsDirty) return;
|
||||
try {
|
||||
// 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 retained = removeBackedUpImagePredecessors(stored, records);
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records));
|
||||
if (compacted.removed > 0) ensureCleanupBackup(stored);
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
storage.set(RECORDS_KEY, records);
|
||||
const currentStoredText = JSON.stringify(stored);
|
||||
// Local records have already been validated when received/imported. Only
|
||||
// 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));
|
||||
if (compacted.removed > 0) ensureCleanupBackup(stored);
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
}
|
||||
const nextStoredText = JSON.stringify(records);
|
||||
if (nextStoredText !== currentStoredText) storage.set(RECORDS_KEY, records);
|
||||
storedRecordsText = nextStoredText;
|
||||
recordsDirty = false;
|
||||
storageError = false;
|
||||
} catch (error) {
|
||||
@@ -1981,20 +2210,52 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
|
||||
}
|
||||
}
|
||||
function schedulePersist() {
|
||||
if (saveTimer != null || saveIdle !== null) return;
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
saveIdle = window.requestIdleCallback(() => { saveIdle = null; persistRecords(); }, { timeout: 1000 });
|
||||
} else persistRecords();
|
||||
}, SAVE_DELAY);
|
||||
}
|
||||
let observationIndexSource = null, observationIndex;
|
||||
function redundantDOMObservation(record) {
|
||||
if (record.source?.kind !== 'dom' || record.answerStatus !== 'missing' || record.explanation || !record.bankId || !record.questionId) return false;
|
||||
if (observationIndexSource !== records) {
|
||||
observationIndexSource = records;
|
||||
observationIndex = new Map();
|
||||
for (const saved of records) for (const id of new Set([saved.questionId, ...(saved.questionIdAliases || [])].filter(Boolean))) {
|
||||
const key = JSON.stringify([saved.bankId, id]);
|
||||
observationIndex.set(key, observationIndex.has(key) && observationIndex.get(key) !== saved ? null : saved);
|
||||
}
|
||||
}
|
||||
const saved = observationIndex.get(JSON.stringify([record.bankId, record.questionId]));
|
||||
const flat = value => String(value || '').replace(/\s+/gu, '');
|
||||
if (!saved || saved.answerStatus !== 'confirmed' || saved.type !== record.type || flat(saved.stem) !== flat(record.stem) ||
|
||||
(record.questionIndex != null && record.questionIndex !== saved.questionIndex) ||
|
||||
(record.contentOccurrence != null && record.contentOccurrence !== saved.contentOccurrence)) return false;
|
||||
return JSON.stringify(saved.options.map(option => flat(option.text)).sort()) ===
|
||||
JSON.stringify(record.options.map(option => flat(option.text)).sort());
|
||||
}
|
||||
function receive(incoming, context = {}) {
|
||||
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
|
||||
try {
|
||||
incoming = incoming.filter(record => !redundantDOMObservation(record));
|
||||
if (!incoming.length) return;
|
||||
incoming = repairAnswerConflicts(incoming).records;
|
||||
const bank = currentBank();
|
||||
const upgradeContext = {
|
||||
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
|
||||
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
|
||||
};
|
||||
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext);
|
||||
const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext);
|
||||
ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]);
|
||||
records = contentUpgrade.existing;
|
||||
incoming = contentUpgrade.incoming;
|
||||
if (upgradeContext.fullSnapshot && Number.isSafeInteger(upgradeContext.expectedCount)) {
|
||||
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext);
|
||||
const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext);
|
||||
ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]);
|
||||
records = contentUpgrade.existing;
|
||||
incoming = contentUpgrade.incoming;
|
||||
}
|
||||
const namesById = new Map();
|
||||
incoming.forEach(record => {
|
||||
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return;
|
||||
@@ -2003,18 +2264,17 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
});
|
||||
// A newly observed, explicit bank ID/name pair can repair an old stale
|
||||
// title for that same ID. Never infer a rename from question type or text.
|
||||
records = records.map(record => {
|
||||
if (namesById.size) records = records.map(record => {
|
||||
const names = record.bankId && namesById.get(record.bankId);
|
||||
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
|
||||
return names?.size === 1 && record.bankName !== [...names][0] ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
|
||||
});
|
||||
const expanded = QBCore.upsert(records, incoming);
|
||||
const compacted = QBCore.compactRecords(expanded);
|
||||
if (compacted.removed > 0) ensureCleanupBackup(expanded);
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
recordsDirty = true;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(persistRecords, 250);
|
||||
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
|
||||
schedulePersist();
|
||||
renderVisible();
|
||||
} catch (error) { tell(`这批题目未保存:${error.message}`, true); }
|
||||
}
|
||||
function tell(message, error = false) {
|
||||
@@ -2045,6 +2305,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
const collapse = shadow.querySelector('[data-action="collapse"]');
|
||||
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
|
||||
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
|
||||
if (config.mode === 'open') renderVisible();
|
||||
}
|
||||
function selectTab(tab) {
|
||||
config.tab = tab;
|
||||
@@ -2055,10 +2316,20 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
button.classList.toggle('active', selected);
|
||||
});
|
||||
shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; });
|
||||
renderVisible();
|
||||
}
|
||||
function renderVisible() {
|
||||
if (!mounted || config.mode !== 'open') return;
|
||||
refreshScope(); renderStats(); renderCurrent();
|
||||
if (config.tab === 'search') renderResults();
|
||||
if (config.tab === 'auto') renderAuto();
|
||||
renderStatus();
|
||||
}
|
||||
function bankKey(record) { return String(record.bankId || record.bankName || ''); }
|
||||
function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); }
|
||||
let catalogSource = null, catalogCache;
|
||||
function bankCatalog() {
|
||||
if (catalogSource === records) return catalogCache;
|
||||
const namedIds = new Map();
|
||||
records.forEach(record => {
|
||||
if (!record.bankId) return;
|
||||
@@ -2073,7 +2344,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 });
|
||||
catalog.get(key).count++;
|
||||
});
|
||||
return [...catalog.values()];
|
||||
catalogSource = records;
|
||||
catalogCache = [...catalog.values()];
|
||||
return catalogCache;
|
||||
}
|
||||
function currentQuestion() {
|
||||
try { return adapter.getCurrentQuestion(); } catch (_) { return null; }
|
||||
@@ -2108,7 +2381,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
return anchor?.bankName || '未命名题库';
|
||||
}
|
||||
function refreshScope() {
|
||||
if (!shadow) return;
|
||||
if (!shadow || config.mode !== 'open') return;
|
||||
const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name]));
|
||||
if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__';
|
||||
if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__';
|
||||
@@ -2128,7 +2401,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
});
|
||||
}
|
||||
function renderStats() {
|
||||
if (!shadow) return;
|
||||
if (!shadow || config.mode !== 'open') return;
|
||||
const selected = scopedRecords();
|
||||
const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length;
|
||||
const conflicts = selected.filter(record => record.answerStatus === 'conflict').length;
|
||||
@@ -2155,7 +2428,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
indicator.classList.toggle('paused', !config.collecting);
|
||||
}
|
||||
function renderCurrent() {
|
||||
if (!shadow) return;
|
||||
if (!shadow || config.mode !== 'open') return;
|
||||
const bank = currentBank();
|
||||
const current = currentQuestion();
|
||||
const known = Boolean(bank?.bankName || bank?.bankId);
|
||||
@@ -2169,7 +2442,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
shadow.getElementById('qb-current-bank').textContent = name;
|
||||
}
|
||||
function renderAuto() {
|
||||
if (!shadow) return;
|
||||
if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return;
|
||||
const state = autoAnswer.getState();
|
||||
shadow.getElementById('qb-auto-enabled').checked = state.enabled;
|
||||
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。';
|
||||
@@ -2177,7 +2450,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
|
||||
}
|
||||
function renderResults() {
|
||||
if (!resultBox) return;
|
||||
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 }) : [];
|
||||
@@ -2317,7 +2590,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
records = merged;
|
||||
recordsDirty = true;
|
||||
persistRecords();
|
||||
refreshScope(); renderStats(); renderCurrent(); renderResults();
|
||||
renderVisible();
|
||||
const bankCount = new Set(incoming.map(bankKey)).size;
|
||||
if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`);
|
||||
}
|
||||
@@ -2437,7 +2710,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
});
|
||||
if (cleanupNotice) status = cleanupNotice;
|
||||
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
|
||||
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
|
||||
// Navigation/collection is driven by the adapter. A hidden panel has no
|
||||
// polling/render loop; only an open, foreground panel refreshes its header.
|
||||
setInterval(() => { if (config.mode === 'open' && !document.hidden) renderCurrent(); }, 2000);
|
||||
}
|
||||
|
||||
document.addEventListener('mouseup', event => {
|
||||
@@ -2472,17 +2747,20 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||||
if (!remote || !Array.isArray(value)) return;
|
||||
try {
|
||||
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;
|
||||
if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(persistRecords, 250); }
|
||||
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
|
||||
storedRecordsText = valueText;
|
||||
if (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. */ }
|
||||
});
|
||||
if (config.collecting) adapter.start();
|
||||
adapter.setCopyEnabled(Boolean(config.copyEnabled));
|
||||
if (recordsDirty && !loadError) saveTimer = setTimeout(persistRecords, 250);
|
||||
if (recordsDirty && !loadError) schedulePersist();
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||||
else mount();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user