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
+7 -1
View File
@@ -1,6 +1,10 @@
# 华谊题库助手 # 华谊题库助手
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.4**。 支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.5**。
1.2.5 修复运行卡顿:题目与别名合并改为索引查找;无关计时器、答题卡选中样式和助手自身变化不再触发题目扫描;隐藏面板停止重建搜索及统计;重复浏览已保存题目跳过无效合并。题库保存按800ms合批并在浏览器空闲时执行,离开页面时立即写回;存储没有被其他标签页改变时直接保存已验证的内存记录,保留跨标签冲突、备份与迁移检查。
本版同时保留已复现的切题保护:题目ID、选项ID和输入控件实际值均参与身份核对,点击前后等待异步渲染稳定;上一题残留的答案反馈不再套用到新题。自动选择仍默认关闭,题库文件及标准答案未修改。
1.2.4 在完整选项ID与正文映射一致时,可准确对应文字相同的不同选项;ID变化且文字有歧义时继续跳过。新增对旧版乘号、正负号实体记录的有证据补全,保留原始备份。缺少题目位置等依据的旧记录继续保留,仓库中的五库全集使用各库最新、题数与索引均已核验的完整网络批次。 1.2.4 在完整选项ID与正文映射一致时,可准确对应文字相同的不同选项;ID变化且文字有歧义时继续跳过。新增对旧版乘号、正负号实体记录的有证据补全,保留原始备份。缺少题目位置等依据的旧记录继续保留,仓库中的五库全集使用各库最新、题数与索引均已核验的完整网络批次。
@@ -153,4 +157,6 @@ npm run check
真实浏览器测试使用 `npm run test:browser`,需要现有 Playwright 和 Chromium;可通过 `QB_PLAYWRIGHT_MODULE`、`QB_CHROMIUM_EXECUTABLE` 指定环境路径。测试固定读取 `tests/fixtures/question.html`,使用本地模拟接口,不需要原始保存网页或平台账号。构建、语法检查和核心单元测试只需 Node.js,无第三方依赖。 真实浏览器测试使用 `npm run test:browser`,需要现有 Playwright 和 Chromium;可通过 `QB_PLAYWRIGHT_MODULE`、`QB_CHROMIUM_EXECUTABLE` 指定环境路径。测试固定读取 `tests/fixtures/question.html`,使用本地模拟接口,不需要原始保存网页或平台账号。构建、语法检查和核心单元测试只需 Node.js,无第三方依赖。
`npm run test:performance` 使用仓库真实1884题和本地最小练习页,对比固定的1.2.4提交与当前源码,不连接学习账号。实测助手根节点挂载约858→152ms,单次新题合并约916→32ms;无关计时器及答题卡变化造成的题目扫描76→0次,隐藏结果重建1→0次。以上为同机本地回归指标,不是平台整页加载时间。测试同时约束无关变化不写库、一次新题仅合并一次、隐藏面板不重建结果;详细结果与截图写入系统临时目录。
修改源码后执行 `npm run build`,再把新的 `dist/huayi-question-bank.user.js` 更新到油猴。 修改源码后执行 `npm run build`,再把新的 `dist/huayi-question-bank.user.js` 更新到油猴。
+1 -1
View File
@@ -4,7 +4,7 @@ const root = __dirname;
const header = `// ==UserScript== const header = `// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索 // @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank // @namespace local.huayi.question-bank
// @version 1.2.4 // @version 1.2.5
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/* // @match *://*/*
// @run-at document-start // @run-at document-start
+361 -83
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索 // @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank // @namespace local.huayi.question-bank
// @version 1.2.4 // @version 1.2.5
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/* // @match *://*/*
// @run-at document-start // @run-at document-start
@@ -232,6 +232,13 @@ const QBCore = (() => {
return map; return map;
}; };
const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming); 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 => { const correctContents = record => {
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length || 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; 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) || 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 || 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; 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 || if (!previous.questionId || imageCount(previous) || previous.type !== current.type ||
!Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) || !Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) ||
(previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) || (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); const oldAnswers = correctContents(previous), newAnswers = correctContents(current);
if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) || if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) ||
!sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue; !sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement; incoming[index] = replacement;
removed.add(previous.key); 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 }); bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
} }
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; 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 && 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); 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 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++) { for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current); const current = incoming[index], key = signature(current);
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue; if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue;
const candidates = bank.filter(record => hasEntity(record) && signature(record) === key); const candidates = legacyBySignature.get(key) || [];
if (candidates.length !== 1) continue; if (candidates.length !== 1) continue;
const previous = candidates[0]; const previous = candidates[0];
if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) || 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 // A previous failed export may already contain both spellings. Permit only
// fully agreeing modern copies at that very same source position; distinct // fully agreeing modern copies at that very same source position; distinct
// literal-entity questions and duplicate occurrences remain untouched. // 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; record.key !== previous.key && !sameModern(record, current))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement; incoming[index] = replacement;
removed.add(previous.key); 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 }); kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
} }
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
@@ -331,8 +354,12 @@ const QBCore = (() => {
} }
function mergeRecord(existing, incoming) { function mergeRecord(existing, incoming) {
const oldRecord = normalizeRecord(existing); return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming));
const newRecord = 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('不能合并不同题目的记录'); if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
const contentMatches = sameContent(oldRecord, newRecord); const contentMatches = sameContent(oldRecord, newRecord);
const explanation = newRecord.explanation || oldRecord.explanation; const explanation = newRecord.explanation || oldRecord.explanation;
@@ -378,27 +405,69 @@ const QBCore = (() => {
const lastSeen = new Map(); const lastSeen = new Map();
for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) {
const record = normalizeRecord(item); 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); lastSeen.set(record.key, index);
} }
// Explicit aliases are produced only by content/batch repair. Ordinary // Explicit aliases are produced only by content/batch repair. Ordinary
// records with different IDs are still kept separate until that repair. // records with different IDs are still kept separate until that repair.
const bankIdentity = bankResolver([...records.values()]); 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)) { for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue; if (!records.has(target.key)) continue;
for (const other of [...records.values()]) { const queued = new Set(), knownAliases = new Set(), candidates = [];
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue; 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 current = records.get(target.key);
const aliases = new Set(current.questionIdAliases || []); if (signature(current) !== signature(other)) continue;
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) 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); 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()]) { for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue; 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 // 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. // 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; if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false;
return normalized(a.bankName) === normalized(b.bankName); 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); (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) { for (const record of anonymous) {
const matches = candidates.get(record.key); const matches = candidates.get(record.key);
if (matches.length !== 1) continue; if (matches.length !== 1) continue;
const target = matches[0]; 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 promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId });
const currentTarget = records.get(target.key); 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.set(target.key, merged);
records.delete(record.key); records.delete(record.key);
} }
@@ -460,10 +540,13 @@ const QBCore = (() => {
// Process missing observations first so the selected options and the answer // Process missing observations first so the selected options and the answer
// IDs always come together from an answer-bearing record. // IDs always come together from an answer-bearing record.
const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing')); 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; let result;
for (const record of ordered) { for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta }); const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeRecord(result, rebased) : rebased; result = result ? mergeNormalizedRecords(result, rebased) : rebased;
} }
return result; return result;
} }
@@ -477,13 +560,15 @@ const QBCore = (() => {
const legacySources = new Map(); const legacySources = new Map();
for (const entry of entries) { for (const entry of entries) {
const record = entry.record; const record = entry.record;
entry.signature = contentSignature(record);
entry.bank = bankIdentity(record);
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence; if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId; 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) { else if (entry.occurrence == null) {
const source = listSource(record); const source = listSource(record);
if (!source) continue; 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, []); if (!legacySources.has(key)) legacySources.set(key, []);
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) }); legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
} }
@@ -503,35 +588,47 @@ const QBCore = (() => {
const counters = new Map(); const counters = new Map();
for (const entry of entries) { for (const entry of entries) {
if (!entry.batch || entry.occurrence != null) continue; 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; entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1); counters.set(key, entry.occurrence + 1);
} }
const groups = new Map(); const groups = new Map();
const byContent = 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) => { const add = (key, entry) => {
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(entry); groups.get(key).push(entry);
}; };
for (const entry of entries) { 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; entry.content = content;
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue; if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
const key = JSON.stringify([content, entry.occurrence]); const key = JSON.stringify([content, entry.occurrence]);
add(key, entry); add(key, entry);
if (!byContent.has(content)) byContent.set(content, new Set()); if (!byContent.has(content)) byContent.set(content, new Set());
byContent.get(content).add(key); byContent.get(content).add(key);
indexAliases(key, entry);
} }
for (const entry of entries) { for (const entry of entries) {
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue; 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; 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))); const matchingAlias = byContentAlias.get(entry.content)?.get(entry.record.questionId);
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0]; if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value;
else if (matches.length === 1) key = matches[0]; else if (matches?.size === 1) key = matches.values().next().value;
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`; else key = `unresolved:${entry.record.key}`;
add(key, entry); add(key, entry);
if (matches?.has(key)) indexAliases(key, entry);
} }
const records = []; const records = [];
let duplicateGroups = 0; let duplicateGroups = 0;
@@ -842,7 +939,7 @@ const QBAdapter = (() => {
function standardAnswer(node, type, options, context) { function standardAnswer(node, type, options, context) {
const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null; const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null;
const answerNode = result || node; 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' }; return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
} }
if (type === 'judge') { if (type === 'judge') {
@@ -1037,6 +1134,7 @@ const QBAdapter = (() => {
let requestSequence = 0; let requestSequence = 0;
let batchSequence = 0; let batchSequence = 0;
const known = new Map(); const known = new Map();
let knownContentIndex = null;
const xhrContext = new WeakMap(); const xhrContext = new WeakMap();
const pendingXHR = new Set(); const pendingXHR = new Set();
const banksById = new Map(); const banksById = new Map();
@@ -1237,6 +1335,7 @@ const QBAdapter = (() => {
for (const q of records) { for (const q of records) {
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q); if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
} }
knownContentIndex = null;
const current = syncBank(); const current = syncBank();
const metadata = emissionContext(context, current, staleBanks.has(context.bankId)); 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))); 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; 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() { function getCurrentQuestion() {
if (!isSupportedPage()) return null; if (!isSupportedPage()) return null;
const bank = syncBank(); const bank = syncBank();
@@ -1289,8 +1404,7 @@ const QBAdapter = (() => {
const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/); 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; const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null;
if (questionIndex !== null) q.questionIndex = questionIndex; if (questionIndex !== null) q.questionIndex = questionIndex;
let matches = [...known.values()].filter(item => signature(item) === signature(q) && let matches = matchingKnown(q);
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
if (questionIndex !== null) { if (questionIndex !== null) {
const indexed = matches.filter(item => item.questionIndex === questionIndex); const indexed = matches.filter(item => item.questionIndex === questionIndex);
if (indexed.length) matches = indexed; if (indexed.length) matches = indexed;
@@ -1298,6 +1412,11 @@ const QBAdapter = (() => {
} }
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null; if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
if (matches.length === 1) { 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.questionId = matches[0].questionId;
q.bankId = matches[0].bankId; q.bankId = matches[0].bankId;
if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex; 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 (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId;
if (blockedForm) { blockedForm = null; syncBank(); } 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 main = form.closest(ROOT_SELECTOR);
const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible); const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible);
for (const row of rows) { for (const row of rows) {
@@ -1320,11 +1440,22 @@ const QBAdapter = (() => {
if (selected.length !== requested.length) selected = []; if (selected.length !== requested.length) selected = [];
} }
if (selected.length && (type !== 'single' || selected.length === 1)) { 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.correctOptionIds = selected.map(o => o.id).filter(Boolean);
q.answerTexts = selected.map(o => o.text); q.answerTexts = selected.map(o => o.text);
q.answerStatus = 'confirmed'; 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; return q;
} }
@@ -1459,22 +1590,44 @@ const QBAdapter = (() => {
function ensureObserver() { function ensureObserver() {
if (observer || !win.MutationObserver || !isSupportedPage()) return; 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 => { observer = new win.MutationObserver(mutations => {
const relevant = mutations.some(m => { if (!mutations.some(relevantMutation) || timer !== null) return;
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);
timer = win.setTimeout(() => { timer = win.setTimeout(() => {
timer = null;
syncBank();
if (copyEnabled) ensureCopyStyle();
if (!active) return;
const q = getCurrentQuestion(); const q = getCurrentQuestion();
if (!q) return; if (!q) return;
const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]); const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]);
if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); } 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'] }); 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; active = false;
deferredSnapshots.clear(); deferredSnapshots.clear();
win.clearTimeout(timer); win.clearTimeout(timer);
timer = null;
uninstallNetwork(); uninstallNetwork();
status('采集已暂停,已保存题库和选择复制功能仍可使用。'); status('采集已暂停,已保存题库和选择复制功能仍可使用。');
} }
@@ -1541,6 +1695,50 @@ const QBAuto = (() => {
const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null; 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); 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) { function planAnswer(records, current, suppliedCore) {
const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more }); 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. // 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', '当前选项标记重复,无法唯一对应,已跳过。'); if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。');
const relevant = []; const relevant = [];
for (const input of records) { for (const record of candidateRecords(records, question, core)) {
let record;
try { record = core.normalizeRecord(input); } catch (_) { continue; }
if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue; 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 (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; 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; if (!current || bank?.loading) return null;
const set = controls(container, true); 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; 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) { function readGeneric(container) {
if (!visible(container)) return null; if (!visible(container)) return null;
@@ -1651,7 +1847,7 @@ const QBAuto = (() => {
const type = judged ? 'judge' : set.type; const type = judged ? 'judge' : set.type;
const question = { const question = {
bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')), 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: [] answerStatus: 'missing', answerTexts: [], correctOptionIds: []
}; };
return { container, inputs: set.inputs, question, site: false }; return { container, inputs: set.inputs, question, site: false };
@@ -1663,7 +1859,10 @@ const QBAuto = (() => {
} }
function fingerprint(candidate) { function fingerprint(candidate) {
const question = candidate.question; 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) { function stillSame(candidate, mark) {
if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false; 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 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 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]); } 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) { function skipped(message, stem = '', count = true) {
emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) }); emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) });
return { status: 'skipped', message }; return { status: 'skipped', message };
@@ -1706,6 +1916,9 @@ const QBAuto = (() => {
const runGeneration = generation; const runGeneration = generation;
emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem }); emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem });
try { 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' 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(({ input, index }) => Boolean(input.checked) !== flags[index])
: candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => 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); if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem);
input.click(); input.click();
await Promise.resolve(); const next = await settled(candidate, mark, runGeneration, continuous);
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) { if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
return { status: 'cancelled', 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); if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem);
emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: 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'); return element && !element.closest('#huayi-qb-root');
})) schedule(); })) 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); interval = win.setInterval(schedule, 1500);
schedule(); schedule();
@@ -1811,12 +2025,17 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
let recordsDirty = false; let recordsDirty = false;
let loadError = ''; let loadError = '';
let cleanupNotice = ''; let cleanupNotice = '';
let storedRecordsText = '';
let saveIdle = null;
const SAVE_DELAY = 800;
function ensureCleanupBackup(original) { function ensureCleanupBackup(original) {
const existing = storage.get(BACKUP_KEY, null); const existing = storage.get(BACKUP_KEY, null);
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original }); 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('去重备份结构异常,已保留原题库并停止覆盖'); else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
} }
function repairAnswerConflicts(original) { 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); const result = QBCore.repairDuplicateOptionConflicts(original);
if (!result.repaired.length) return result; if (!result.repaired.length) return result;
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null); const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
@@ -1897,6 +2116,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
try { try {
const saved = storage.get(RECORDS_KEY, []); const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
storedRecordsText = JSON.stringify(saved);
const retained = removeBackedUpImagePredecessors(saved, saved); const retained = removeBackedUpImagePredecessors(saved, saved);
const repaired = repairAnswerConflicts(retained); const repaired = repairAnswerConflicts(retained);
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records)); 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 (!bank || (!bank.bankName && !bank.bankId)) {
if (activeBank) { if (activeBank) {
activeBank = null; scope = ''; searchScope = ''; activeBank = null; scope = ''; searchScope = '';
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } renderVisible();
} }
return; return;
} }
@@ -1951,7 +2171,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const changed = JSON.stringify(previous) !== JSON.stringify(bank); const changed = JSON.stringify(previous) !== JSON.stringify(bank);
activeBank = { ...bank }; activeBank = { ...bank };
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; } if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } if (changed) renderVisible();
} }
function adapterStatus(value) { function adapterStatus(value) {
@@ -1964,16 +2184,25 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
} }
function persistRecords() { function persistRecords() {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = null;
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
if (loadError || !recordsDirty) return; if (loadError || !recordsDirty) return;
try { try {
// Merge other tabs before writing; GM change events also refresh an open panel. // Merge other tabs before writing; GM change events also refresh an open panel.
const stored = storage.get(RECORDS_KEY, []); const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖'); if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
const retained = removeBackedUpImagePredecessors(stored, records); const currentStoredText = JSON.stringify(stored);
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records)); // Local records have already been validated when received/imported. Only
if (compacted.removed > 0) ensureCleanupBackup(stored); // reconcile the whole library when another tab actually changed storage.
records = repairAnswerConflicts(compacted.records).records; if (currentStoredText !== storedRecordsText) {
storage.set(RECORDS_KEY, records); 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; recordsDirty = false;
storageError = false; storageError = false;
} catch (error) { } catch (error) {
@@ -1981,20 +2210,52 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true); 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 = {}) { function receive(incoming, context = {}) {
if (!Array.isArray(incoming) || !incoming.length || loadError) return; if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try { try {
incoming = incoming.filter(record => !redundantDOMObservation(record));
if (!incoming.length) return;
incoming = repairAnswerConflicts(incoming).records; incoming = repairAnswerConflicts(incoming).records;
const bank = currentBank(); const bank = currentBank();
const upgradeContext = { const upgradeContext = {
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '', bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
}; };
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext); if (upgradeContext.fullSnapshot && Number.isSafeInteger(upgradeContext.expectedCount)) {
const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext); const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext);
ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]); const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext);
records = contentUpgrade.existing; ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]);
incoming = contentUpgrade.incoming; records = contentUpgrade.existing;
incoming = contentUpgrade.incoming;
}
const namesById = new Map(); const namesById = new Map();
incoming.forEach(record => { incoming.forEach(record => {
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return; 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 // 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. // 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); 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 expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded); const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded); if (compacted.removed > 0) ensureCleanupBackup(expanded);
records = repairAnswerConflicts(compacted.records).records; records = repairAnswerConflicts(compacted.records).records;
recordsDirty = true; recordsDirty = true;
clearTimeout(saveTimer); schedulePersist();
saveTimer = setTimeout(persistRecords, 250); renderVisible();
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
} catch (error) { tell(`这批题目未保存:${error.message}`, true); } } catch (error) { tell(`这批题目未保存:${error.message}`, true); }
} }
function tell(message, error = false) { 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"]'); const collapse = shadow.querySelector('[data-action="collapse"]');
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起'; collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed')); collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
if (config.mode === 'open') renderVisible();
} }
function selectTab(tab) { function selectTab(tab) {
config.tab = tab; config.tab = tab;
@@ -2055,10 +2316,20 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
button.classList.toggle('active', selected); button.classList.toggle('active', selected);
}); });
shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; }); 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 bankKey(record) { return String(record.bankId || record.bankName || ''); }
function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); } function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); }
let catalogSource = null, catalogCache;
function bankCatalog() { function bankCatalog() {
if (catalogSource === records) return catalogCache;
const namedIds = new Map(); const namedIds = new Map();
records.forEach(record => { records.forEach(record => {
if (!record.bankId) return; 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 }); if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 });
catalog.get(key).count++; catalog.get(key).count++;
}); });
return [...catalog.values()]; catalogSource = records;
catalogCache = [...catalog.values()];
return catalogCache;
} }
function currentQuestion() { function currentQuestion() {
try { return adapter.getCurrentQuestion(); } catch (_) { return null; } try { return adapter.getCurrentQuestion(); } catch (_) { return null; }
@@ -2108,7 +2381,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
return anchor?.bankName || '未命名题库'; return anchor?.bankName || '未命名题库';
} }
function refreshScope() { function refreshScope() {
if (!shadow) return; if (!shadow || config.mode !== 'open') return;
const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name])); const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name]));
if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__'; if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__';
if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__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() { function renderStats() {
if (!shadow) return; if (!shadow || config.mode !== 'open') return;
const selected = scopedRecords(); const selected = scopedRecords();
const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length; const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length;
const conflicts = selected.filter(record => record.answerStatus === 'conflict').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); indicator.classList.toggle('paused', !config.collecting);
} }
function renderCurrent() { function renderCurrent() {
if (!shadow) return; if (!shadow || config.mode !== 'open') return;
const bank = currentBank(); const bank = currentBank();
const current = currentQuestion(); const current = currentQuestion();
const known = Boolean(bank?.bankName || bank?.bankId); 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; shadow.getElementById('qb-current-bank').textContent = name;
} }
function renderAuto() { function renderAuto() {
if (!shadow) return; if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return;
const state = autoAnswer.getState(); const state = autoAnswer.getState();
shadow.getElementById('qb-auto-enabled').checked = state.enabled; shadow.getElementById('qb-auto-enabled').checked = state.enabled;
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。'; 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); shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
} }
function renderResults() { function renderResults() {
if (!resultBox) return; if (!resultBox || config.mode !== 'open' || config.tab !== 'search') return;
resultBox.replaceChildren(); resultBox.replaceChildren();
const pool = scopedRecords(searchScope); const pool = scopedRecords(searchScope);
const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : []; 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; records = merged;
recordsDirty = true; recordsDirty = true;
persistRecords(); persistRecords();
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderVisible();
const bankCount = new Set(incoming.map(bankKey)).size; const bankCount = new Set(incoming.map(bankKey)).size;
if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`); 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; if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode(); 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 => { 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 (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return; if (!remote || !Array.isArray(value)) return;
try { try {
const valueText = JSON.stringify(value);
if (valueText === storedRecordsText) return;
const retained = removeBackedUpImagePredecessors(value, records); const retained = removeBackedUpImagePredecessors(value, records);
const incoming = repairAnswerConflicts(retained); const incoming = repairAnswerConflicts(retained);
records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records; 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); } storedRecordsText = valueText;
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } 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. */ } catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
}); });
if (config.collecting) adapter.start(); if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled)); 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 }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount(); else mount();
})(); })();
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "huayi-question-bank", "name": "huayi-question-bank",
"version": "1.2.4", "version": "1.2.5",
"private": true, "private": true,
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本", "description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
"scripts": { "scripts": {
@@ -9,6 +9,7 @@
"select:snapshots": "node scripts/select-bank-snapshots.cjs", "select:snapshots": "node scripts/select-bank-snapshots.cjs",
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/select-bank-snapshots.test.cjs", "test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/select-bank-snapshots.test.cjs",
"test:browser": "node --test tests/integration.test.cjs", "test:browser": "node --test tests/integration.test.cjs",
"test:performance": "node --test tests/performance-browser.test.cjs",
"check": "node build.cjs && node --check dist/huayi-question-bank.user.js" "check": "node build.cjs && node --check dist/huayi-question-bank.user.js"
} }
} }
+72 -15
View File
@@ -66,7 +66,7 @@ const QBAdapter = (() => {
function standardAnswer(node, type, options, context) { function standardAnswer(node, type, options, context) {
const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null; const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null;
const answerNode = result || node; 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' }; return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
} }
if (type === 'judge') { if (type === 'judge') {
@@ -261,6 +261,7 @@ const QBAdapter = (() => {
let requestSequence = 0; let requestSequence = 0;
let batchSequence = 0; let batchSequence = 0;
const known = new Map(); const known = new Map();
let knownContentIndex = null;
const xhrContext = new WeakMap(); const xhrContext = new WeakMap();
const pendingXHR = new Set(); const pendingXHR = new Set();
const banksById = new Map(); const banksById = new Map();
@@ -461,6 +462,7 @@ const QBAdapter = (() => {
for (const q of records) { for (const q of records) {
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q); if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
} }
knownContentIndex = null;
const current = syncBank(); const current = syncBank();
const metadata = emissionContext(context, current, staleBanks.has(context.bankId)); 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))); const containsImages = records.some(record => /\[图片:https?:\/\//.test(record.stem) || record.options.some(option => /\[图片:https?:\/\//.test(option.text)));
@@ -479,6 +481,22 @@ const QBAdapter = (() => {
return true; 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() { function getCurrentQuestion() {
if (!isSupportedPage()) return null; if (!isSupportedPage()) return null;
const bank = syncBank(); const bank = syncBank();
@@ -513,8 +531,7 @@ const QBAdapter = (() => {
const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/); 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; const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null;
if (questionIndex !== null) q.questionIndex = questionIndex; if (questionIndex !== null) q.questionIndex = questionIndex;
let matches = [...known.values()].filter(item => signature(item) === signature(q) && let matches = matchingKnown(q);
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
if (questionIndex !== null) { if (questionIndex !== null) {
const indexed = matches.filter(item => item.questionIndex === questionIndex); const indexed = matches.filter(item => item.questionIndex === questionIndex);
if (indexed.length) matches = indexed; if (indexed.length) matches = indexed;
@@ -522,6 +539,11 @@ const QBAdapter = (() => {
} }
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null; if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
if (matches.length === 1) { 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.questionId = matches[0].questionId;
q.bankId = matches[0].bankId; q.bankId = matches[0].bankId;
if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex; if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex;
@@ -529,7 +551,8 @@ const QBAdapter = (() => {
if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId; if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId;
if (blockedForm) { blockedForm = null; syncBank(); } 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 main = form.closest(ROOT_SELECTOR);
const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible); const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible);
for (const row of rows) { for (const row of rows) {
@@ -544,11 +567,22 @@ const QBAdapter = (() => {
if (selected.length !== requested.length) selected = []; if (selected.length !== requested.length) selected = [];
} }
if (selected.length && (type !== 'single' || selected.length === 1)) { 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.correctOptionIds = selected.map(o => o.id).filter(Boolean);
q.answerTexts = selected.map(o => o.text); q.answerTexts = selected.map(o => o.text);
q.answerStatus = 'confirmed'; 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; return q;
} }
@@ -683,22 +717,44 @@ const QBAdapter = (() => {
function ensureObserver() { function ensureObserver() {
if (observer || !win.MutationObserver || !isSupportedPage()) return; 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 => { observer = new win.MutationObserver(mutations => {
const relevant = mutations.some(m => { if (!mutations.some(relevantMutation) || timer !== null) return;
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);
timer = win.setTimeout(() => { timer = win.setTimeout(() => {
timer = null;
syncBank();
if (copyEnabled) ensureCopyStyle();
if (!active) return;
const q = getCurrentQuestion(); const q = getCurrentQuestion();
if (!q) return; if (!q) return;
const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]); const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]);
if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); } 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'] }); observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] });
} }
@@ -737,6 +793,7 @@ const QBAdapter = (() => {
active = false; active = false;
deferredSnapshots.clear(); deferredSnapshots.clear();
win.clearTimeout(timer); win.clearTimeout(timer);
timer = null;
uninstallNetwork(); uninstallNetwork();
status('采集已暂停,已保存题库和选择复制功能仍可使用。'); status('采集已暂停,已保存题库和选择复制功能仍可使用。');
} }
+92 -28
View File
@@ -31,12 +31,17 @@
let recordsDirty = false; let recordsDirty = false;
let loadError = ''; let loadError = '';
let cleanupNotice = ''; let cleanupNotice = '';
let storedRecordsText = '';
let saveIdle = null;
const SAVE_DELAY = 800;
function ensureCleanupBackup(original) { function ensureCleanupBackup(original) {
const existing = storage.get(BACKUP_KEY, null); const existing = storage.get(BACKUP_KEY, null);
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original }); 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('去重备份结构异常,已保留原题库并停止覆盖'); else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
} }
function repairAnswerConflicts(original) { 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); const result = QBCore.repairDuplicateOptionConflicts(original);
if (!result.repaired.length) return result; if (!result.repaired.length) return result;
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null); const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
@@ -117,6 +122,7 @@
try { try {
const saved = storage.get(RECORDS_KEY, []); const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
storedRecordsText = JSON.stringify(saved);
const retained = removeBackedUpImagePredecessors(saved, saved); const retained = removeBackedUpImagePredecessors(saved, saved);
const repaired = repairAnswerConflicts(retained); const repaired = repairAnswerConflicts(retained);
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records)); const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
@@ -161,7 +167,7 @@
if (!bank || (!bank.bankName && !bank.bankId)) { if (!bank || (!bank.bankName && !bank.bankId)) {
if (activeBank) { if (activeBank) {
activeBank = null; scope = ''; searchScope = ''; activeBank = null; scope = ''; searchScope = '';
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } renderVisible();
} }
return; return;
} }
@@ -171,7 +177,7 @@
const changed = JSON.stringify(previous) !== JSON.stringify(bank); const changed = JSON.stringify(previous) !== JSON.stringify(bank);
activeBank = { ...bank }; activeBank = { ...bank };
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; } if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } if (changed) renderVisible();
} }
function adapterStatus(value) { function adapterStatus(value) {
@@ -184,16 +190,25 @@
} }
function persistRecords() { function persistRecords() {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = null;
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
if (loadError || !recordsDirty) return; if (loadError || !recordsDirty) return;
try { try {
// Merge other tabs before writing; GM change events also refresh an open panel. // Merge other tabs before writing; GM change events also refresh an open panel.
const stored = storage.get(RECORDS_KEY, []); const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖'); if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
const retained = removeBackedUpImagePredecessors(stored, records); const currentStoredText = JSON.stringify(stored);
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records)); // Local records have already been validated when received/imported. Only
if (compacted.removed > 0) ensureCleanupBackup(stored); // reconcile the whole library when another tab actually changed storage.
records = repairAnswerConflicts(compacted.records).records; if (currentStoredText !== storedRecordsText) {
storage.set(RECORDS_KEY, records); 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; recordsDirty = false;
storageError = false; storageError = false;
} catch (error) { } catch (error) {
@@ -201,20 +216,52 @@
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true); 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 = {}) { function receive(incoming, context = {}) {
if (!Array.isArray(incoming) || !incoming.length || loadError) return; if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try { try {
incoming = incoming.filter(record => !redundantDOMObservation(record));
if (!incoming.length) return;
incoming = repairAnswerConflicts(incoming).records; incoming = repairAnswerConflicts(incoming).records;
const bank = currentBank(); const bank = currentBank();
const upgradeContext = { const upgradeContext = {
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '', bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
}; };
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext); if (upgradeContext.fullSnapshot && Number.isSafeInteger(upgradeContext.expectedCount)) {
const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext); const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext);
ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]); const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext);
records = contentUpgrade.existing; ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]);
incoming = contentUpgrade.incoming; records = contentUpgrade.existing;
incoming = contentUpgrade.incoming;
}
const namesById = new Map(); const namesById = new Map();
incoming.forEach(record => { incoming.forEach(record => {
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return; if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return;
@@ -223,18 +270,17 @@
}); });
// A newly observed, explicit bank ID/name pair can repair an old stale // 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. // 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); 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 expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded); const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded); if (compacted.removed > 0) ensureCleanupBackup(expanded);
records = repairAnswerConflicts(compacted.records).records; records = repairAnswerConflicts(compacted.records).records;
recordsDirty = true; recordsDirty = true;
clearTimeout(saveTimer); schedulePersist();
saveTimer = setTimeout(persistRecords, 250); renderVisible();
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
} catch (error) { tell(`这批题目未保存:${error.message}`, true); } } catch (error) { tell(`这批题目未保存:${error.message}`, true); }
} }
function tell(message, error = false) { function tell(message, error = false) {
@@ -265,6 +311,7 @@
const collapse = shadow.querySelector('[data-action="collapse"]'); const collapse = shadow.querySelector('[data-action="collapse"]');
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起'; collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed')); collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
if (config.mode === 'open') renderVisible();
} }
function selectTab(tab) { function selectTab(tab) {
config.tab = tab; config.tab = tab;
@@ -275,10 +322,20 @@
button.classList.toggle('active', selected); button.classList.toggle('active', selected);
}); });
shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; }); 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 bankKey(record) { return String(record.bankId || record.bankName || ''); }
function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); } function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); }
let catalogSource = null, catalogCache;
function bankCatalog() { function bankCatalog() {
if (catalogSource === records) return catalogCache;
const namedIds = new Map(); const namedIds = new Map();
records.forEach(record => { records.forEach(record => {
if (!record.bankId) return; if (!record.bankId) return;
@@ -293,7 +350,9 @@
if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 }); if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 });
catalog.get(key).count++; catalog.get(key).count++;
}); });
return [...catalog.values()]; catalogSource = records;
catalogCache = [...catalog.values()];
return catalogCache;
} }
function currentQuestion() { function currentQuestion() {
try { return adapter.getCurrentQuestion(); } catch (_) { return null; } try { return adapter.getCurrentQuestion(); } catch (_) { return null; }
@@ -328,7 +387,7 @@
return anchor?.bankName || '未命名题库'; return anchor?.bankName || '未命名题库';
} }
function refreshScope() { function refreshScope() {
if (!shadow) return; if (!shadow || config.mode !== 'open') return;
const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name])); const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name]));
if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__'; if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__';
if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__'; if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__';
@@ -348,7 +407,7 @@
}); });
} }
function renderStats() { function renderStats() {
if (!shadow) return; if (!shadow || config.mode !== 'open') return;
const selected = scopedRecords(); const selected = scopedRecords();
const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length; const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length;
const conflicts = selected.filter(record => record.answerStatus === 'conflict').length; const conflicts = selected.filter(record => record.answerStatus === 'conflict').length;
@@ -375,7 +434,7 @@
indicator.classList.toggle('paused', !config.collecting); indicator.classList.toggle('paused', !config.collecting);
} }
function renderCurrent() { function renderCurrent() {
if (!shadow) return; if (!shadow || config.mode !== 'open') return;
const bank = currentBank(); const bank = currentBank();
const current = currentQuestion(); const current = currentQuestion();
const known = Boolean(bank?.bankName || bank?.bankId); const known = Boolean(bank?.bankName || bank?.bankId);
@@ -389,7 +448,7 @@
shadow.getElementById('qb-current-bank').textContent = name; shadow.getElementById('qb-current-bank').textContent = name;
} }
function renderAuto() { function renderAuto() {
if (!shadow) return; if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return;
const state = autoAnswer.getState(); const state = autoAnswer.getState();
shadow.getElementById('qb-auto-enabled').checked = state.enabled; shadow.getElementById('qb-auto-enabled').checked = state.enabled;
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。'; shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。';
@@ -397,7 +456,7 @@
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy); shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
} }
function renderResults() { function renderResults() {
if (!resultBox) return; if (!resultBox || config.mode !== 'open' || config.tab !== 'search') return;
resultBox.replaceChildren(); resultBox.replaceChildren();
const pool = scopedRecords(searchScope); const pool = scopedRecords(searchScope);
const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : []; const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : [];
@@ -537,7 +596,7 @@
records = merged; records = merged;
recordsDirty = true; recordsDirty = true;
persistRecords(); persistRecords();
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderVisible();
const bankCount = new Set(incoming.map(bankKey)).size; const bankCount = new Set(incoming.map(bankKey)).size;
if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`); if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`);
} }
@@ -657,7 +716,9 @@
}); });
if (cleanupNotice) status = cleanupNotice; if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode(); 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 => { document.addEventListener('mouseup', event => {
@@ -692,17 +753,20 @@
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return; if (!remote || !Array.isArray(value)) return;
try { try {
const valueText = JSON.stringify(value);
if (valueText === storedRecordsText) return;
const retained = removeBackedUpImagePredecessors(value, records); const retained = removeBackedUpImagePredecessors(value, records);
const incoming = repairAnswerConflicts(retained); const incoming = repairAnswerConflicts(retained);
records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records; 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); } storedRecordsText = valueText;
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } 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. */ } catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
}); });
if (config.collecting) adapter.start(); if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled)); 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 }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount(); else mount();
})(); })();
+69 -9
View File
@@ -13,6 +13,50 @@ const QBAuto = (() => {
const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null; 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); 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) { function planAnswer(records, current, suppliedCore) {
const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more }); const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more });
@@ -25,9 +69,7 @@ const QBAuto = (() => {
// Repeated text requires mapAnswers to prove the complete option identity or correct set. // 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', '当前选项标记重复,无法唯一对应,已跳过。'); if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。');
const relevant = []; const relevant = [];
for (const input of records) { for (const record of candidateRecords(records, question, core)) {
let record;
try { record = core.normalizeRecord(input); } catch (_) { continue; }
if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue; 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 (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; if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue;
@@ -109,7 +151,7 @@ const QBAuto = (() => {
if (!current || bank?.loading) return null; if (!current || bank?.loading) return null;
const set = controls(container, true); 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; 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) { function readGeneric(container) {
if (!visible(container)) return null; if (!visible(container)) return null;
@@ -123,7 +165,7 @@ const QBAuto = (() => {
const type = judged ? 'judge' : set.type; const type = judged ? 'judge' : set.type;
const question = { const question = {
bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')), 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: [] answerStatus: 'missing', answerTexts: [], correctOptionIds: []
}; };
return { container, inputs: set.inputs, question, site: false }; return { container, inputs: set.inputs, question, site: false };
@@ -135,7 +177,10 @@ const QBAuto = (() => {
} }
function fingerprint(candidate) { function fingerprint(candidate) {
const question = candidate.question; 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) { function stillSame(candidate, mark) {
if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false; if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false;
@@ -145,6 +190,17 @@ const QBAuto = (() => {
function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; } 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 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]); } 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) { function skipped(message, stem = '', count = true) {
emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) }); emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) });
return { status: 'skipped', message }; return { status: 'skipped', message };
@@ -178,6 +234,9 @@ const QBAuto = (() => {
const runGeneration = generation; const runGeneration = generation;
emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem }); emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem });
try { 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' 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(({ input, index }) => Boolean(input.checked) !== flags[index])
: candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]); : candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]);
@@ -188,12 +247,12 @@ const QBAuto = (() => {
} }
if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem); if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem);
input.click(); input.click();
await Promise.resolve(); const next = await settled(candidate, mark, runGeneration, continuous);
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) { if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
return { status: 'cancelled', 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); if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem);
emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem }); emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem });
@@ -236,7 +295,8 @@ const QBAuto = (() => {
return element && !element.closest('#huayi-qb-root'); return element && !element.closest('#huayi-qb-root');
})) schedule(); })) 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); interval = win.setInterval(schedule, 1500);
schedule(); schedule();
+127 -30
View File
@@ -213,6 +213,13 @@ const QBCore = (() => {
return map; return map;
}; };
const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming); 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 => { const correctContents = record => {
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length || 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; 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) || 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 || 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; 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 || if (!previous.questionId || imageCount(previous) || previous.type !== current.type ||
!Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) || !Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) ||
(previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) || (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); const oldAnswers = correctContents(previous), newAnswers = correctContents(current);
if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) || if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) ||
!sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue; !sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement; incoming[index] = replacement;
removed.add(previous.key); 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 }); bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
} }
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; 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 && 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); 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 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++) { for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current); const current = incoming[index], key = signature(current);
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue; if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue;
const candidates = bank.filter(record => hasEntity(record) && signature(record) === key); const candidates = legacyBySignature.get(key) || [];
if (candidates.length !== 1) continue; if (candidates.length !== 1) continue;
const previous = candidates[0]; const previous = candidates[0];
if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) || 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 // A previous failed export may already contain both spellings. Permit only
// fully agreeing modern copies at that very same source position; distinct // fully agreeing modern copies at that very same source position; distinct
// literal-entity questions and duplicate occurrences remain untouched. // 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; record.key !== previous.key && !sameModern(record, current))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement; incoming[index] = replacement;
removed.add(previous.key); 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 }); kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
} }
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
@@ -312,8 +335,12 @@ const QBCore = (() => {
} }
function mergeRecord(existing, incoming) { function mergeRecord(existing, incoming) {
const oldRecord = normalizeRecord(existing); return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming));
const newRecord = 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('不能合并不同题目的记录'); if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
const contentMatches = sameContent(oldRecord, newRecord); const contentMatches = sameContent(oldRecord, newRecord);
const explanation = newRecord.explanation || oldRecord.explanation; const explanation = newRecord.explanation || oldRecord.explanation;
@@ -359,27 +386,69 @@ const QBCore = (() => {
const lastSeen = new Map(); const lastSeen = new Map();
for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) {
const record = normalizeRecord(item); 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); lastSeen.set(record.key, index);
} }
// Explicit aliases are produced only by content/batch repair. Ordinary // Explicit aliases are produced only by content/batch repair. Ordinary
// records with different IDs are still kept separate until that repair. // records with different IDs are still kept separate until that repair.
const bankIdentity = bankResolver([...records.values()]); 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)) { for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue; if (!records.has(target.key)) continue;
for (const other of [...records.values()]) { const queued = new Set(), knownAliases = new Set(), candidates = [];
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue; 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 current = records.get(target.key);
const aliases = new Set(current.questionIdAliases || []); if (signature(current) !== signature(other)) continue;
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) 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); 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()]) { for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue; 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 // 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. // 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; if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false;
return normalized(a.bankName) === normalized(b.bankName); 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); (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) { for (const record of anonymous) {
const matches = candidates.get(record.key); const matches = candidates.get(record.key);
if (matches.length !== 1) continue; if (matches.length !== 1) continue;
const target = matches[0]; 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 promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId });
const currentTarget = records.get(target.key); 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.set(target.key, merged);
records.delete(record.key); records.delete(record.key);
} }
@@ -441,10 +521,13 @@ const QBCore = (() => {
// Process missing observations first so the selected options and the answer // Process missing observations first so the selected options and the answer
// IDs always come together from an answer-bearing record. // IDs always come together from an answer-bearing record.
const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing')); 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; let result;
for (const record of ordered) { for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta }); const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeRecord(result, rebased) : rebased; result = result ? mergeNormalizedRecords(result, rebased) : rebased;
} }
return result; return result;
} }
@@ -458,13 +541,15 @@ const QBCore = (() => {
const legacySources = new Map(); const legacySources = new Map();
for (const entry of entries) { for (const entry of entries) {
const record = entry.record; const record = entry.record;
entry.signature = contentSignature(record);
entry.bank = bankIdentity(record);
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence; if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId; 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) { else if (entry.occurrence == null) {
const source = listSource(record); const source = listSource(record);
if (!source) continue; 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, []); if (!legacySources.has(key)) legacySources.set(key, []);
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) }); legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
} }
@@ -484,35 +569,47 @@ const QBCore = (() => {
const counters = new Map(); const counters = new Map();
for (const entry of entries) { for (const entry of entries) {
if (!entry.batch || entry.occurrence != null) continue; 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; entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1); counters.set(key, entry.occurrence + 1);
} }
const groups = new Map(); const groups = new Map();
const byContent = 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) => { const add = (key, entry) => {
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(entry); groups.get(key).push(entry);
}; };
for (const entry of entries) { 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; entry.content = content;
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue; if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
const key = JSON.stringify([content, entry.occurrence]); const key = JSON.stringify([content, entry.occurrence]);
add(key, entry); add(key, entry);
if (!byContent.has(content)) byContent.set(content, new Set()); if (!byContent.has(content)) byContent.set(content, new Set());
byContent.get(content).add(key); byContent.get(content).add(key);
indexAliases(key, entry);
} }
for (const entry of entries) { for (const entry of entries) {
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue; 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; 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))); const matchingAlias = byContentAlias.get(entry.content)?.get(entry.record.questionId);
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0]; if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value;
else if (matches.length === 1) key = matches[0]; else if (matches?.size === 1) key = matches.values().next().value;
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`; else key = `unresolved:${entry.record.key}`;
add(key, entry); add(key, entry);
if (matches?.has(key)) indexAliases(key, entry);
} }
const records = []; const records = [];
let duplicateGroups = 0; let duplicateGroups = 0;
+90 -2
View File
@@ -275,10 +275,14 @@ function bankPage(fetchImpl) {
const player = { get textContent() { return state.player; } }; const player = { get textContent() { return state.player; } };
const exam = { get textContent() { return state.exam; } }; const exam = { get textContent() { return state.exam; } };
const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null }; const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null };
const main = { querySelectorAll: () => [] }; const main = { querySelectorAll: selector => selector === '.yxtulcdsdk-examing__result-line' ? (state.feedback || []).map(value => ({
getAttribute: name => name === 'data-question-id' ? value.questionId || null : null,
querySelector: selector => selector === '.yxtulcdsdk-examing__result-line-left' ? { textContent: value.heading } : { textContent: value.answer },
closest: () => null
})) : [] };
const stem = { get textContent() { return state.stem; }, get innerHTML() { return state.stemHTML; }, matches: selector => selector === '[data-rich-text]' }; const stem = { get textContent() { return state.stem; }, get innerHTML() { return state.stemHTML; }, matches: selector => selector === '[data-rich-text]' };
const options = ['降低温度', '提高温度'].map((value, index) => ({ const options = ['降低温度', '提高温度'].map((value, index) => ({
querySelector: selector => selector === 'input' ? { value: `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null, querySelector: selector => selector === 'input' ? { value: state.optionIds?.[index] || `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null,
textContent: value textContent: value
})); }));
const form = { const form = {
@@ -650,3 +654,87 @@ test('DOM question number distinguishes legitimate identical items in the latest
assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步'); assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步');
page.adapter.stop(); page.adapter.stop();
}); });
test('hidden parent answer permission cannot be bypassed by nested resultInfo', () => {
const raw = single();
raw.allowViewSingleQuesAnswer = false;
raw.resultInfo = { answers: ['a2'], judgeAnswer: 1 };
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
});
test('old unbound feedback letters cannot become the next question standard answer', async () => {
const next = { ...single(), id: 'q2', content: '另一道题目?' };
next.choiceItems.forEach(option => delete option.answer);
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single(), next] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
page.state.stem = '另一道题目?'; page.state.questionNumber = 2;
page.state.feedback = [{ heading: '正确答案', answer: 'A' }, { heading: '解析', answer: '上一题遗留解析' }];
const unbound = page.adapter.getCurrentQuestion();
assert.equal(unbound.questionId, 'q2');
assert.equal(unbound.answerStatus, 'missing');
assert.equal(unbound.explanation, '');
page.state.feedback = [{ heading: '正确答案', answer: 'A', questionId: 'q1' }];
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '明确属于上一题的反馈也不能套在新题');
page.state.feedback = [{ heading: '正确答案', answer: 'B', questionId: 'q2' }];
assert.deepEqual(page.adapter.getCurrentQuestion().answerTexts, ['提高温度']);
page.adapter.stop();
});
test('new stem with stale radio option ids waits until DOM values match its network identity', async () => {
const raw = { ...single(), id: 'q-new', choiceItems: [
{ id: 'new-a', content: '降低温度', answer: 0 }, { id: 'new-b', content: '提高温度', answer: 1 }
] };
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
assert.equal(page.adapter.getCurrentQuestion(), null, '显示文字一致也不能给旧radio值绑定新题ID');
page.state.optionIds = ['new-a', 'new-b'];
const question = page.adapter.getCurrentQuestion();
assert.equal(question.questionId, 'q-new');
assert.deepEqual(question.options.map(option => option.id), ['new-a', 'new-b']);
page.adapter.stop();
});
test('observer ignores unrelated and selection mutations and batches relevant work before scanning', () => {
const page = bankPage();
let callback, reads = 0, sequence = 0;
const pending = new Map();
page.win.MutationObserver = class { constructor(value) { callback = value; } observe() {} };
page.win.setTimeout = fn => { const key = ++sequence; pending.set(key, fn); return key; };
page.win.clearTimeout = key => pending.delete(key);
const originalOne = page.doc.querySelector;
const originalAll = page.doc.querySelectorAll;
page.doc.querySelector = selector => { reads++; return originalOne(selector); };
page.doc.querySelectorAll = selector => { reads++; return originalAll(selector); };
function element(selectors, parentElement = null, id = '') {
const result = { nodeType: 1, id, parentElement,
matches: selector => selector.split(',').some(part => selectors.includes(part.trim()) || part.trim() === `#${id}`),
querySelector: () => null,
closest(selector) { return this.matches(selector) ? this : parentElement?.closest(selector) || null; }
};
return result;
}
page.adapter.start();
const baseline = reads;
const plain = element(['.unrelated-timer']);
const own = element([], null, 'huayi-qb-root');
const main = element([ROOT_SELECTOR_FOR_TEST]);
const radio = element(['.yxtulcdsdk-ques-radio'], main);
const card = element(['.yxtulcdsdk-answer-card__item']);
for (let i = 0; i < 1000; i++) callback([
{ type: 'characterData', target: { nodeType: 3, parentElement: plain } },
{ type: 'childList', target: own, addedNodes: [], removedNodes: [] },
{ type: 'attributes', attributeName: 'class', target: radio },
{ type: 'attributes', attributeName: 'class', target: card }
]);
assert.equal(reads, baseline, '无关页面变化不进行任何全页查询');
assert.equal(pending.size, 0, '选中/答题卡/助手自己的变化不调度扫描');
const stem = element(['.yxtulcdsdk-form__content'], main);
for (let i = 0; i < 100; i++) callback([{ type: 'characterData', target: { nodeType: 3, parentElement: stem } }]);
assert.equal(reads, baseline, '相关变化也不在observer回调里同步重扫');
assert.equal(pending.size, 1, '连续100次题目变化只调度一次检查');
const task = [...pending.values()][0]; pending.clear(); task();
assert.ok(reads > baseline, '合批后仍检查真实题目更新');
page.adapter.stop();
});
+49
View File
@@ -256,3 +256,52 @@ test('a question changing during the first click stops the rest of a multiple se
assert.equal(page.inputs[1].checked, false); assert.equal(page.inputs[1].checked, false);
engine.destroy(); engine.destroy();
}); });
test('reusing the same input nodes with different option values stops the remaining clicks', async () => {
let page;
page = checkboxPage({ duringClick: index => {
if (index === 0) [page.inputs[1].value, page.inputs[2].value] = [page.inputs[2].value, page.inputs[1].value];
} });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
await engine.answerCurrent();
assert.deepEqual(page.clicks, [0], 'an old positional plan must not click a reused node now carrying another option ID');
engine.destroy();
});
test('a nested framework microtask that changes the question is observed before another click', async () => {
let page;
page = checkboxPage({ duringClick: index => {
if (index === 0) queueMicrotask(() => queueMicrotask(() => { page.title.textContent = '框架异步渲染的新题目。'; }));
} });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
await engine.answerCurrent();
assert.deepEqual(page.clicks, [0], 'a single awaited Promise is too early to safely click the next option');
engine.destroy();
});
test('an 1884-record bank only normalizes exact-stem candidates, not every unrelated question per poll', () => {
const records = Array.from({ length: 1884 }, (_, index) => single({ questionId: `question-${index}`, stem: `无关的题库练习 ${index}。` }));
records[1000] = single();
let normalizedCount = 0;
const observed = { ...core, normalizeRecord(value) { normalizedCount++; return core.normalizeRecord(value); } };
assert.equal(planAnswer(records, current(), observed).status, 'ready');
const initial = normalizedCount;
for (let attempt = 0; attempt < 20; attempt++) assert.equal(planAnswer(records, current(), observed).status, 'ready');
assert.ok(initial <= 3, `first lookup should not normalize all 1884 records (observed ${initial})`);
assert.ok(normalizedCount - initial <= 20, 'repeat lookups only need to validate the current question again');
});
test('the answer index detects in-place option/answer mutations and changed raw records', () => {
const records = [single()];
assert.equal(planAnswer(records, current(), core).status, 'ready');
records[0].correctOptionIds = ['a']; records[0].answerTexts = ['降低温度'];
assert.deepEqual(planAnswer(records, current(), core).labels, ['B']);
records[0].answerStatus = 'conflict';
assert.equal(planAnswer(records, current(), core).status, 'ambiguous');
records[0] = single();
assert.deepEqual(planAnswer(records, current(), core).labels, ['C']);
records.push(single({ questionId: 'changed-stem', stem: '此前与目标无关。', correctOptionIds: ['a'], answerTexts: ['降低温度'] }));
assert.equal(planAnswer(records, current(), core).status, 'ready');
records[1].stem = single().stem;
assert.equal(planAnswer(records, current(), core).status, 'ambiguous', 'a record changing into an exact match must invalidate the question index');
});
+71
View File
@@ -709,3 +709,74 @@ test('metadata validation refuses corrupt occurrences and preserves explicit ali
assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record); assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record);
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]); assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
}); });
test('indexed aliases retain traversal order when a later match reveals more aliases', () => {
const rows = [
fixture({ questionId: 'root', questionIdAliases: ['root', 'bridge'], explanation: '根记录' }),
fixture({ questionId: 'earlier', explanation: '较早记录' }),
fixture({ questionId: 'bridge', questionIdAliases: ['bridge', 'earlier', 'later', 'after'], explanation: '桥接记录' }),
fixture({ questionId: 'later', explanation: '最后记录' }),
fixture({ questionId: 'bridge', bankId: 'other-bank', explanation: '另一题库' }),
fixture({ questionId: 'later', stem: '同ID别名但内容不同' }),
fixture({ questionId: 'after', explanation: '后续新增别名记录' }),
];
const result = core.upsert([], rows);
assert.deepEqual(result.map(record => [record.bankId, record.questionId]), [
['bank-1', 'root'], ['bank-1', 'earlier'], ['bank-1', 'later'], ['other-bank', 'bridge']
]);
// The late alias was not known while visiting the earlier position. Preserve
// that existing one-pass behavior rather than changing the answer's history.
assert.deepEqual(result[0].questionIdAliases, ['root', 'bridge', 'earlier', 'later', 'after']);
assert.equal(result[0].explanation, '后续新增别名记录');
assert.equal(result[2].answerStatus, 'conflict');
assert.deepEqual(core.fromCSV(core.toCSV(result)), result);
});
test('indexed anonymous promotion remains unique in both directions and preserves record order', () => {
const rows = [
fixture({ questionId: '', contentOccurrence: 0 }),
fixture({ questionId: 'unrelated', stem: '另一个题干' }),
fixture({ questionId: '', contentOccurrence: 1 }),
fixture({ questionId: 'ambiguous' }),
fixture({ questionId: 'unique', stem: '唯一可升级题干' }),
fixture({ questionId: '', stem: '唯一可升级题干', explanation: '后出现的说明' }),
];
const result = core.upsert([], rows);
assert.deepEqual(result.map(record => record.questionId), ['', 'unrelated', '', 'ambiguous', 'unique']);
assert.deepEqual(result.filter(record => !record.questionId).map(record => record.contentOccurrence), [0, 1]);
assert.equal(result.at(-1).explanation, '后出现的说明');
});
test('fast internal merges preserve fresh DOM metadata and revalidate externally mutated inputs', () => {
const original = core.normalizeRecord(fixture({ questionIndex: 3, contentOccurrence: 0, questionIdAliases: ['old'] }));
const dom = { ...original, answerStatus: 'missing', correctOptionIds: [], answerTexts: [], explanation: '新增解析',
questionIdAliases: ['another-id'], updatedAt: '2026-10-30T01:00:00Z',
source: { kind: 'dom', url: 'https://practice.example.test/new', observedAt: '2026-10-30T01:00:00Z' } };
const merged = core.upsert([original], [dom])[0];
assert.deepEqual(merged.answerTexts, original.answerTexts);
assert.deepEqual(merged.correctOptionIds, original.correctOptionIds);
assert.deepEqual(merged.answerSource, original.answerSource);
assert.deepEqual(merged.source, dom.source);
assert.equal(merged.updatedAt, dom.updatedAt);
assert.equal(merged.explanation, dom.explanation);
assert.deepEqual(merged.questionIdAliases, ['old', original.questionId, 'another-id']);
assert.equal(merged.questionIndex, 3);
assert.equal(merged.contentOccurrence, 0);
dom.options = dom.options.map(option => option.id === 'y' ? { ...option, text: '改变选项内容' } : option);
assert.equal(core.upsert([original], [dom])[0].answerStatus, 'conflict');
assert.equal(original.options.find(option => option.id === 'y').text, '提高温度');
});
test('large repeated alias banks preserve every canonical record and complete CSV content', () => {
const records = Array.from({ length: 600 }, (_, index) => core.normalizeRecord(fixture({
questionId: `canonical-${index}`, questionIdAliases: [`canonical-${index}`, `old-${index}`],
stem: `设备检查${index}:P水 > P气,面积2m²`, questionIndex: index, contentOccurrence: 0,
source: { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'complete-list' }
})));
const result = core.upsert(records, records);
assert.equal(JSON.stringify(result), JSON.stringify(records));
assert.equal(core.toCSV(result), core.toCSV(records));
const compacted = core.compactRecords(result);
assert.equal(compacted.removed, 0);
assert.equal(JSON.stringify(compacted.records), JSON.stringify(records));
});
+187
View File
@@ -0,0 +1,187 @@
'use strict';
// Local-only performance regression probe. It compares the committed 1.2.4
// sources with the working tree without rewriting dist or visiting an account.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { execFileSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const DATASET = path.join(ROOT, '题库/华谊集团化工总控工(高级)-合并题库.json');
const OUTPUT = path.join(os.tmpdir(), 'huayi-qb-performance');
const RECORDS_KEY = 'huayi-qb.records.v1';
const SOURCES = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/app.js'];
// This was HEAD/package version 1.2.4 when the reported regression was captured.
// Pin it so committing the fix does not silently turn the baseline into itself.
const BASELINE_REF = process.env.QB_PERF_BASE_REF || '26e0a920b6d56603bfee1061014c5052a7366ec2';
function loadPlaywright() {
if (process.env.QB_PLAYWRIGHT_MODULE) return require(process.env.QB_PLAYWRIGHT_MODULE);
try { return require('playwright'); }
catch (_) { return require(path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); }
}
function browserOptions() {
const requested = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
return { headless: true, ...(requested || fs.existsSync(cached) ? { executablePath: requested || cached } : {}) };
}
function html(value) { return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function fixture(record) {
const title = html(record.bankName);
const inputs = record.options.map(option => `<label class="yxtulcdsdk-ques-radio"><span class="yxtf-radio__input"><input type="radio" name="probe-answer" value="${html(option.id)}"></span><span class="yxtf-radio__label"><span data-rich-text="1">${html(option.text)}</span></span></label>`).join('');
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title><style>
body{font:16px/1.7 system-ui;margin:28px;background:#f2f5f8;color:#304455}.task-content-active{padding:10px;border:1px solid #cbd9e2;background:white}.yxtulcdsdk-exam-container{padding:24px;background:white;margin-top:20px;max-width:800px}.yxtulcdsdk-ques-radio{display:block;padding:10px;border:1px solid #dce4ec;margin:12px 0}#probe-clock{font-variant-numeric:tabular-nums}#probe-cards{display:flex;gap:5px}#probe-cards i{font-style:normal;padding:3px 8px;background:#edf1f5}.current{color:#008d78}
</style></head><body><div class="task-content-active" data-task-id="perf-task"><span class="group-title">${title}</span></div><header class="o2oPlayFrame-right"><div class="title"><span class="yxtf-tooltip">${title}</span></div></header>
<p>本地性能回归:1884 道题库记录,面板保持隐藏。</p><p>页面计时器:<span id="probe-clock">0</span></p><div id="probe-cards">${Array.from({ length: 20 }, (_, i) => `<i>${i + 1}</i>`).join('')}</div>
<div class="yxtulcdsdk-exam-container" task-id="perf-task"><div class="yxtulcdsdk-exam-header"><span class="yxtf-tooltip">${title}</span></div><div class="yxtulcdsdk-pc-marking-main"><div class="standard-size-14"><span>判断题</span></div><div class="yxtulcdsdk-form"><div class="yxtulcdsdk-form__sub">${(record.questionIndex || 0) + 1}.</div><div class="yxtulcdsdk-form__content"><div data-rich-text="1">${html(record.stem)}</div><div class="yxtulcdsdk-ques-radio-group">${inputs}</div></div></div></div></div></body></html>`;
}
function installProbe(seed) {
const metrics = { calls: {}, times: {}, recordsWrites: 0, storageWrites: 0, storageReads: 0, questionScans: 0, hiddenResultRebuilds: 0, hiddenBankRebuilds: 0, hiddenTextWrites: 0 };
const values = new Map([['huayi-qb.records.v1', seed], ['huayi-qb.config.v1', { mode: 'hidden', keepLauncher: false, collecting: true, copyEnabled: true, tab: 'search' }]]);
window.__perf = { metrics, started: performance.now(), rootReadyAt: null, errors: [] };
window.unsafeWindow = window;
window.GM_getValue = (key, fallback) => { metrics.storageReads++; return structuredClone(values.has(key) ? values.get(key) : fallback); };
window.GM_setValue = (key, value) => { metrics.storageWrites++; if (key === 'huayi-qb.records.v1') metrics.recordsWrites++; values.set(key, structuredClone(value)); };
window.GM_registerMenuCommand = () => 1;
window.GM_addValueChangeListener = () => 1;
window.GM_removeValueChangeListener = () => {};
const questionQuery = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function (selector) {
if (String(selector).includes('.yxtulcdsdk-pc-marking-main') && String(selector).includes('.yxtulcdsdk-form')) metrics.questionScans++;
return questionQuery.call(this, selector);
};
const replace = Element.prototype.replaceChildren;
Element.prototype.replaceChildren = function (...items) {
const root = this.getRootNode();
if (root.host?.id === 'huayi-qb-root' && root.querySelector('#qb-panel')?.hidden) {
if (this.id === 'qb-results') metrics.hiddenResultRebuilds++;
if (this.id === 'qb-bank-list') metrics.hiddenBankRebuilds++;
}
return replace.apply(this, items);
};
const content = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
Object.defineProperty(Node.prototype, 'textContent', { ...content, set(value) {
const root = this.getRootNode();
if (root.host?.id === 'huayi-qb-root' && root.querySelector('#qb-panel')?.hidden) metrics.hiddenTextWrites++;
content.set.call(this, value);
} });
const appearance = new MutationObserver(() => {
if (window.__perf.rootReadyAt == null && document.getElementById('huayi-qb-root')) window.__perf.rootReadyAt = performance.now();
});
appearance.observe(document, { childList: true, subtree: true });
window.__perf.cloneMetrics = () => JSON.parse(JSON.stringify(metrics));
window.__perf.recordCount = () => values.get('huayi-qb.records.v1').length;
}
const CORE_PROBE = `
for (const name of Object.keys(QBCore)) {
if (typeof QBCore[name] !== 'function') continue;
const original = QBCore[name];
QBCore[name] = function (...args) {
const start = performance.now(), m = window.__perf.metrics;
m.calls[name] = (m.calls[name] || 0) + 1;
try { return original.apply(this, args); }
finally { m.times[name] = (m.times[name] || 0) + performance.now() - start; }
};
}
`;
function sourceFor(committed) {
return `(function(){'use strict';\n${SOURCES.map((file, index) => {
const code = committed ? execFileSync('git', ['show', `${BASELINE_REF}:${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }) : fs.readFileSync(path.join(ROOT, file), 'utf8');
return code + (index === 0 ? '\n' + CORE_PROBE : '');
}).join('\n')}\n})();`;
}
function delta(end, start) {
const result = {};
for (const key of Object.keys(end)) {
if (end[key] && typeof end[key] === 'object') {
result[key] = {};
for (const name of new Set([...Object.keys(end[key]), ...Object.keys(start[key] || {})])) {
const value = (end[key][name] || 0) - (start[key]?.[name] || 0);
if (value) result[key][name] = key === 'times' ? +value.toFixed(2) : value;
}
} else result[key] = end[key] - (start[key] || 0);
}
return result;
}
test('1884题真实浏览器性能:启动、无关DOM变化和一次语义换题', { timeout: 180000, skip: !fs.existsSync(DATASET) }, async t => {
const payload = JSON.parse(fs.readFileSync(DATASET, 'utf8').replace(/^\uFEFF/, ''));
const records = Array.isArray(payload) ? payload : payload.records;
assert.equal(records.length, 1884, '性能样本应为用户当前合并的1884条题库');
const initial = records.find(record => record.type === 'judge' && !record.stem.includes('[图片:'));
assert.ok(initial);
fs.mkdirSync(OUTPUT, { recursive: true });
const server = http.createServer((request, response) => {
if (request.url === '/favicon.ico') { response.writeHead(204); response.end(); return; }
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); response.end(fixture(initial));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const { chromium } = loadPlaywright();
const browser = await chromium.launch(browserOptions());
const mode = process.env.QB_PERF_MODE || 'compare';
const results = {};
try {
for (const [name, committed] of [['before', true], ['after', false]]) {
if ((mode === 'baseline' && !committed) || (mode === 'after' && committed)) continue;
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' }));
await context.addInitScript({ content: `(${installProbe.toString()})(${JSON.stringify(records)});\n${sourceFor(committed)}` });
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
try {
await page.goto(`${origin}/o2o/#/playinfo?taskId=perf-task`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await page.waitForTimeout(1000);
const startup = await page.evaluate(() => ({ ...window.__perf.cloneMetrics(), readyMs: +(window.__perf.rootReadyAt - window.__perf.started).toFixed(2), storedCount: window.__perf.recordCount() }));
assert.equal(await page.locator('#qb-panel').isVisible(), false);
const beforeNoise = await page.evaluate(() => window.__perf.cloneMetrics());
await page.evaluate(() => {
let tick = 0;
window.__noiseTimer = setInterval(() => {
document.getElementById('probe-clock').textContent = String(++tick);
document.querySelectorAll('#probe-cards i').forEach((card, index) => card.classList.toggle('current', index === tick % 20));
}, 50);
});
await page.waitForTimeout(1800);
await page.evaluate(() => clearInterval(window.__noiseTimer));
await page.waitForTimeout(500);
const afterNoise = await page.evaluate(() => window.__perf.cloneMetrics());
const noise = delta(afterNoise, beforeNoise);
const beforeChange = await page.evaluate(() => window.__perf.cloneMetrics());
await page.evaluate(() => {
document.querySelector('.yxtulcdsdk-form__sub').textContent = '2.';
document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = '本地性能探针:新增一题用于检测合批持久化。';
});
await page.waitForTimeout(1700);
const afterChange = await page.evaluate(() => window.__perf.cloneMetrics());
const change = delta(afterChange, beforeChange);
results[name] = { startup, unrelatedChanges: noise, semanticChange: change, finalStoredCount: await page.evaluate(() => window.__perf.recordCount()), errors };
await page.screenshot({ path: path.join(OUTPUT, `${name}-hidden.png`) });
assert.deepEqual(errors, [], '本地性能夹具不能出现脚本异常');
if (!committed) {
assert.equal(noise.calls.upsert || 0, 0, '无关计时器/答题卡class变化不能合并全题库');
assert.equal(noise.calls.compactRecords || 0, 0, '无关变化不能重新整理全题库');
assert.equal(noise.recordsWrites, 0, '无关变化不能写回题库');
assert.equal(noise.hiddenResultRebuilds, 0, '隐藏时不能重建检索结果');
assert.equal(change.hiddenResultRebuilds, 0, '语义换题也不应重建隐藏检索结果');
assert.equal(change.recordsWrites, 1, '一次新题语义变化应合批为一次题库写入');
assert.ok((change.calls.upsert || 0) <= 1, '一次变化不得在保存时再次全量upsert');
}
} finally { await context.close(); }
}
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
fs.writeFileSync(path.join(OUTPUT, `performance-${mode}.json`), JSON.stringify(results, null, 2));
}
t.diagnostic(`本地真实1884题;Browser plugin not available,使用已有Playwright/Chromium;结果 ${path.join(OUTPUT, `performance-${mode}.json`)}`);
t.diagnostic(JSON.stringify(results));
});