fix: reduce userscript page blocking and stale option clicks

This commit is contained in:
suanyuit-cyber
2026-10-30 01:47:03 +08:00
parent 26e0a920b6
commit 4bf2de1bad
12 changed files with 1128 additions and 170 deletions
+127 -30
View File
@@ -213,6 +213,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;
@@ -225,18 +232,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 };
@@ -275,10 +282,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) ||
@@ -287,12 +310,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 };
@@ -312,8 +335,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;
@@ -359,27 +386,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.
@@ -390,17 +459,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);
}
@@ -441,10 +521,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;
}
@@ -458,13 +541,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) });
}
@@ -484,35 +569,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;