From 4bf2de1bad5c76ef8ee81fd003f743adec1abfa2 Mon Sep 17 00:00:00 2001 From: suanyuit-cyber <346708943+suanyuit-cyber@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:47:03 +0800 Subject: [PATCH] fix: reduce userscript page blocking and stale option clicks --- README.md | 8 +- build.cjs | 2 +- dist/huayi-question-bank.user.js | 444 +++++++++++++++++++++++------ package.json | 3 +- src/adapter.js | 87 +++++- src/app.js | 120 ++++++-- src/autofill.js | 78 ++++- src/core.js | 157 ++++++++-- tests/adapter.test.cjs | 92 +++++- tests/autofill.test.cjs | 49 ++++ tests/core.test.cjs | 71 +++++ tests/performance-browser.test.cjs | 187 ++++++++++++ 12 files changed, 1128 insertions(+), 170 deletions(-) create mode 100644 tests/performance-browser.test.cjs diff --git a/README.md b/README.md index f616982..4c76fd5 100644 --- a/README.md +++ b/README.md @@ -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变化且文字有歧义时继续跳过。新增对旧版乘号、正负号实体记录的有证据补全,保留原始备份。缺少题目位置等依据的旧记录继续保留,仓库中的五库全集使用各库最新、题数与索引均已核验的完整网络批次。 @@ -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:performance` 使用仓库真实1884题和本地最小练习页,对比固定的1.2.4提交与当前源码,不连接学习账号。实测助手根节点挂载约858→152ms,单次新题合并约916→32ms;无关计时器及答题卡变化造成的题目扫描76→0次,隐藏结果重建1→0次。以上为同机本地回归指标,不是平台整页加载时间。测试同时约束无关变化不写库、一次新题仅合并一次、隐藏面板不重建结果;详细结果与截图写入系统临时目录。 + 修改源码后执行 `npm run build`,再把新的 `dist/huayi-question-bank.user.js` 更新到油猴。 diff --git a/build.cjs b/build.cjs index 560bae4..aa2373e 100644 --- a/build.cjs +++ b/build.cjs @@ -4,7 +4,7 @@ const root = __dirname; const header = `// ==UserScript== // @name 华谊题库助手 · CSV导出与划词检索 // @namespace local.huayi.question-bank -// @version 1.2.4 +// @version 1.2.5 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @match *://*/* // @run-at document-start diff --git a/dist/huayi-question-bank.user.js b/dist/huayi-question-bank.user.js index 2c80093..22b1708 100644 --- a/dist/huayi-question-bank.user.js +++ b/dist/huayi-question-bank.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 华谊题库助手 · CSV导出与划词检索 // @namespace local.huayi.question-bank -// @version 1.2.4 +// @version 1.2.5 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @match *://*/* // @run-at document-start @@ -232,6 +232,13 @@ const QBCore = (() => { return map; }; const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming); + const previousBySignature = new Map(), indexCounts = new Map(), originalByKey = new Map(); + existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); }); + for (const record of existingBank) { + const key = signature(record); + if (!previousBySignature.has(key)) previousBySignature.set(key, record); + indexCounts.set(record.questionIndex, (indexCounts.get(record.questionIndex) || 0) + 1); + } const correctContents = record => { if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length || record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length) return null; @@ -244,18 +251,18 @@ const QBCore = (() => { if (!imageCount(current) || current.type === 'unknown' || current.options.length < 2 || !strip(current.stem) || oldCounts.get(key) !== 1 || newCounts.get(key) !== 1 || new Set(current.options.map(option => strip(option.text))).size !== current.options.length || current.options.some(option => !strip(option.text))) continue; - const previous = existingBank.find(record => signature(record) === key); + const previous = previousBySignature.get(key); if (!previous.questionId || imageCount(previous) || previous.type !== current.type || !Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) || (previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) || - existingBank.filter(record => record.questionIndex === current.questionIndex).length !== 1) continue; + indexCounts.get(current.questionIndex) !== 1) continue; const oldAnswers = correctContents(previous), newAnswers = correctContents(current); if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) || !sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue; const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); incoming[index] = replacement; removed.add(previous.key); - upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement, + upgraded.push({ original: originalByKey.get(previous.key), replacement, bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex }); } return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; @@ -294,10 +301,26 @@ const QBCore = (() => { const sameModern = (record, current) => !hasEntity(record) && record.type === current.type && record.questionIndex === current.questionIndex && normalized(record.stem) === normalized(current.stem) && sameArray(optionPairs(record), optionPairs(current)) && isList(record.source) && sameAnswers(record, current); const bank = existing.filter(record => record.bankId === bankId), upgraded = [], removed = new Set(); + const incomingCounts = new Map(), bankBySignature = new Map(), legacyBySignature = new Map(), bankByIndex = new Map(), originalByKey = new Map(); + existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); }); + const add = (map, key, record) => { + if (!map.has(key)) map.set(key, []); + map.get(key).push(record); + }; + for (const record of incoming) { + const key = signature(record); + incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1); + } + for (const record of bank) { + const key = signature(record); + add(bankBySignature, key, record); + add(bankByIndex, record.questionIndex, record); + if (hasEntity(record)) add(legacyBySignature, key, record); + } for (let index = 0; index < incoming.length; index++) { const current = incoming[index], key = signature(current); - if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue; - const candidates = bank.filter(record => hasEntity(record) && signature(record) === key); + if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue; + const candidates = legacyBySignature.get(key) || []; if (candidates.length !== 1) continue; const previous = candidates[0]; if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) || @@ -306,12 +329,12 @@ const QBCore = (() => { // A previous failed export may already contain both spellings. Permit only // fully agreeing modern copies at that very same source position; distinct // literal-entity questions and duplicate occurrences remain untouched. - if (bank.some(record => (signature(record) === key || record.questionIndex === current.questionIndex) && + if ([...(bankBySignature.get(key) || []), ...(bankByIndex.get(current.questionIndex) || [])].some(record => record.key !== previous.key && !sameModern(record, current))) continue; const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); incoming[index] = replacement; removed.add(previous.key); - upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement, + upgraded.push({ original: originalByKey.get(previous.key), replacement, kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex }); } return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; @@ -331,8 +354,12 @@ const QBCore = (() => { } function mergeRecord(existing, incoming) { - const oldRecord = normalizeRecord(existing); - const newRecord = normalizeRecord(incoming); + return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming)); + } + + // Internal callers already validated both records. Keep validation at the + // public boundary and on the merged result without cloning both inputs again. + function mergeNormalizedRecords(oldRecord, newRecord) { if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录'); const contentMatches = sameContent(oldRecord, newRecord); const explanation = newRecord.explanation || oldRecord.explanation; @@ -378,27 +405,69 @@ const QBCore = (() => { const lastSeen = new Map(); for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { const record = normalizeRecord(item); - records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record); + records.set(record.key, records.has(record.key) ? mergeNormalizedRecords(records.get(record.key), record) : record); lastSeen.set(record.key, index); } // Explicit aliases are produced only by content/batch repair. Ordinary // records with different IDs are still kept separate until that repair. const bankIdentity = bankResolver([...records.values()]); + const signatures = new WeakMap(); + const signature = record => { + if (!signatures.has(record)) signatures.set(record, contentSignature(record)); + return signatures.get(record); + }; + const byBankAndId = new Map(); + const positions = new Map(); + for (const [index, record] of [...records.values()].entries()) { + positions.set(record.key, index); + if (!record.questionId) continue; + const key = JSON.stringify([bankIdentity(record), record.questionId]); + if (!byBankAndId.has(key)) byBankAndId.set(key, []); + byBankAndId.get(key).push(record.key); + } for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) { if (!records.has(target.key)) continue; - for (const other of [...records.values()]) { - if (other.key === target.key || !records.has(other.key) || !other.questionId) continue; + const queued = new Set(), knownAliases = new Set(), candidates = []; + let cursor = -1; + const enqueue = aliases => { + for (const alias of aliases) { + if (knownAliases.has(alias)) continue; + knownAliases.add(alias); + for (const key of byBankAndId.get(JSON.stringify([bankIdentity(target), alias])) || []) { + if (key === target.key || queued.has(key) || positions.get(key) <= cursor) continue; + queued.add(key); + // Retain the old Map traversal order, including when a merged + // record introduces further aliases during this pass. + let low = 0, high = candidates.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (positions.get(candidates[middle]) < positions.get(key)) low = middle + 1; + else high = middle; + } + candidates.splice(low, 0, key); + } + } + }; + enqueue(records.get(target.key).questionIdAliases || []); + while (candidates.length) { + const key = candidates.shift(); + cursor = positions.get(key); + const other = records.get(key); + if (!other) continue; const current = records.get(target.key); - const aliases = new Set(current.questionIdAliases || []); - if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue; + if (signature(current) !== signature(other)) continue; if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue; - records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence })); + const merged = foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }); + records.set(target.key, merged); records.delete(other.key); + enqueue(merged.questionIdAliases || []); } } + const repairedContents = new Set([...records.values()].filter(record => record.questionIdAliases?.length > 1) + .map(record => JSON.stringify([bankIdentity(record), signature(record)]))); for (const record of [...records.values()]) { if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue; - if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key); + if (repairedContents.has(JSON.stringify([bankIdentity(record), signature(record)]))) records.delete(record.key); } // The saved DOM has no stable question ID. Upgrade it only when the complete // content and bank identify one unique server record in both directions. @@ -409,17 +478,28 @@ const QBCore = (() => { if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false; return normalized(a.bankName) === normalized(b.bankName); }; - const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b) && + const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && sameBank(a, b) && (a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence); - const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))])); + const identifiedByContent = new Map(); + for (const record of identified) { + const key = signature(record); + if (!identifiedByContent.has(key)) identifiedByContent.set(key, []); + identifiedByContent.get(key).push(record); + } + const candidates = new Map(), candidateCounts = new Map(); + for (const record of anonymous) { + const matches = (identifiedByContent.get(signature(record)) || []).filter(other => possiblePair(record, other)); + candidates.set(record.key, matches); + for (const match of matches) candidateCounts.set(match.key, (candidateCounts.get(match.key) || 0) + 1); + } for (const record of anonymous) { const matches = candidates.get(record.key); if (matches.length !== 1) continue; const target = matches[0]; - if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue; + if (candidateCounts.get(target.key) !== 1) continue; const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId }); const currentTarget = records.get(target.key); - const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted); + const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeNormalizedRecords(promoted, currentTarget) : mergeNormalizedRecords(currentTarget, promoted); records.set(target.key, merged); records.delete(record.key); } @@ -460,10 +540,13 @@ const QBCore = (() => { // Process missing observations first so the selected options and the answer // IDs always come together from an answer-bearing record. const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing')); + if (group.length === 1 && first.bankId === meta.bankId && first.bankName === meta.bankName && first.questionId === meta.questionId && + (meta.contentOccurrence == null || meta.contentOccurrence === first.contentOccurrence) && + (!own(meta, 'questionIdAliases') || JSON.stringify(meta.questionIdAliases) === JSON.stringify(first.questionIdAliases))) return first; let result; for (const record of ordered) { const rebased = normalizeRecord({ ...record, ...meta }); - result = result ? mergeRecord(result, rebased) : rebased; + result = result ? mergeNormalizedRecords(result, rebased) : rebased; } return result; } @@ -477,13 +560,15 @@ const QBCore = (() => { const legacySources = new Map(); for (const entry of entries) { const record = entry.record; + entry.signature = contentSignature(record); + entry.bank = bankIdentity(record); if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence; const explicit = record.source.batchId || record.answerSource?.batchId; - if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`; + if (explicit) entry.batch = `${entry.bank}:${explicit}`; else if (entry.occurrence == null) { const source = listSource(record); if (!source) continue; - const key = JSON.stringify([bankIdentity(record), source.url]); + const key = JSON.stringify([entry.bank, source.url]); if (!legacySources.has(key)) legacySources.set(key, []); legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) }); } @@ -503,35 +588,47 @@ const QBCore = (() => { const counters = new Map(); for (const entry of entries) { if (!entry.batch || entry.occurrence != null) continue; - const key = JSON.stringify([entry.batch, contentSignature(entry.record)]); + const key = JSON.stringify([entry.batch, entry.signature]); entry.occurrence = counters.get(key) || 0; counters.set(key, entry.occurrence + 1); } const groups = new Map(); const byContent = new Map(); + const byContentAlias = new Map(); + const indexAliases = (key, entry) => { + if (!byContentAlias.has(entry.content)) byContentAlias.set(entry.content, new Map()); + const aliases = byContentAlias.get(entry.content); + for (const id of [entry.record.questionId, ...(entry.record.questionIdAliases || [])]) { + if (!id) continue; + if (!aliases.has(id)) aliases.set(id, new Set()); + aliases.get(id).add(key); + } + }; const add = (key, entry) => { if (!groups.has(key)) groups.set(key, []); groups.get(key).push(entry); }; for (const entry of entries) { - const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]); + const content = JSON.stringify([entry.bank, entry.signature]); entry.content = content; if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue; const key = JSON.stringify([content, entry.occurrence]); add(key, entry); if (!byContent.has(content)) byContent.set(content, new Set()); byContent.get(content).add(key); + indexAliases(key, entry); } for (const entry of entries) { if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue; - const matches = [...(byContent.get(entry.content) || [])]; + const matches = byContent.get(entry.content); let key; - const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId))); - if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0]; - else if (matches.length === 1) key = matches[0]; - else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; + const matchingAlias = byContentAlias.get(entry.content)?.get(entry.record.questionId); + if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value; + else if (matches?.size === 1) key = matches.values().next().value; + else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; else key = `unresolved:${entry.record.key}`; add(key, entry); + if (matches?.has(key)) indexAliases(key, entry); } const records = []; let duplicateGroups = 0; @@ -842,7 +939,7 @@ const QBAdapter = (() => { function standardAnswer(node, type, options, context) { const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null; const answerNode = result || node; - if (answerNode.allowViewSingleQuesAnswer === false || answerNode.allowViewSingleQuesAnswer === 0) { + if ([node, answerNode].some(value => value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0)) { return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }; } if (type === 'judge') { @@ -1037,6 +1134,7 @@ const QBAdapter = (() => { let requestSequence = 0; let batchSequence = 0; const known = new Map(); + let knownContentIndex = null; const xhrContext = new WeakMap(); const pendingXHR = new Set(); const banksById = new Map(); @@ -1237,6 +1335,7 @@ const QBAdapter = (() => { for (const q of records) { if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q); } + knownContentIndex = null; const current = syncBank(); const metadata = emissionContext(context, current, staleBanks.has(context.bankId)); const containsImages = records.some(record => /\[图片:https?:\/\//.test(record.stem) || record.options.some(option => /\[图片:https?:\/\//.test(option.text))); @@ -1255,6 +1354,22 @@ const QBAdapter = (() => { return true; } + function matchingKnown(question) { + if (!knownContentIndex) { + knownContentIndex = new Map(); + for (const record of known.values()) { + const content = signature(record); + const keys = [`id:${record.bankId}\u001d${content}`, `name:${flat(record.bankName)}\u001d${content}`]; + for (const key of keys) { + if (!knownContentIndex.has(key)) knownContentIndex.set(key, []); + knownContentIndex.get(key).push(record); + } + } + } + const key = question.bankId ? `id:${question.bankId}` : `name:${flat(question.bankName)}`; + return knownContentIndex.get(`${key}\u001d${signature(question)}`) || []; + } + function getCurrentQuestion() { if (!isSupportedPage()) return null; const bank = syncBank(); @@ -1289,8 +1404,7 @@ const QBAdapter = (() => { const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/); const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null; if (questionIndex !== null) q.questionIndex = questionIndex; - let matches = [...known.values()].filter(item => signature(item) === signature(q) && - (q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName))); + let matches = matchingKnown(q); if (questionIndex !== null) { const indexed = matches.filter(item => item.questionIndex === questionIndex); if (indexed.length) matches = indexed; @@ -1298,6 +1412,11 @@ const QBAdapter = (() => { } if (blockedForm && (!bank.bankId || matches.length !== 1)) return null; if (matches.length === 1) { + // Vue may update the question text before the old radio values. Those + // transitional controls must not be attached to the new server identity. + const expectedOptions = matches[0].options; + if (expectedOptions.every(option => option.id) && options.some(option => option.id && + !expectedOptions.some(expected => expected.id === option.id && flat(expected.text) === flat(option.text)))) return null; q.questionId = matches[0].questionId; q.bankId = matches[0].bankId; if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex; @@ -1305,7 +1424,8 @@ const QBAdapter = (() => { if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId; if (blockedForm) { blockedForm = null; syncBank(); } } - // Only a row explicitly labelled standard answer can establish correctness. + // A letter-only result row can survive the previous question during a + // render. Its heading alone does not prove it belongs to this question. const main = form.closest(ROOT_SELECTOR); const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible); for (const row of rows) { @@ -1320,11 +1440,22 @@ const QBAdapter = (() => { if (selected.length !== requested.length) selected = []; } if (selected.length && (type !== 'single' || selected.length === 1)) { + const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'] + .map(name => idString(row.getAttribute?.(name))).find(Boolean); + const knownAnswer = matches.length === 1 && matches[0].answerStatus === 'confirmed' ? matches[0] : null; + const agreesWithKnown = knownAnswer && knownAnswer.answerTexts.length === selected.length && + selected.every(option => knownAnswer.answerTexts.some(answerText => flat(answerText) === flat(option.text))); + const boundToCurrent = q.questionId && feedbackId && feedbackId === q.questionId; + if ((feedbackId && feedbackId !== q.questionId) || (!boundToCurrent && !agreesWithKnown)) continue; q.correctOptionIds = selected.map(o => o.id).filter(Boolean); q.answerTexts = selected.map(o => o.text); q.answerStatus = 'confirmed'; } - } else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') q.explanation = answer; + } else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') { + const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'] + .map(name => idString(row.getAttribute?.(name))).find(Boolean); + if (q.answerStatus === 'confirmed' && (!feedbackId || feedbackId === q.questionId)) q.explanation = answer; + } } return q; } @@ -1459,22 +1590,44 @@ const QBAdapter = (() => { function ensureObserver() { if (observer || !win.MutationObserver || !isSupportedPage()) return; + const contextSelectors = '.task-content, .task-content-active, header.o2oPlayFrame-right, .yxtulcdsdk-exam-header, .yxtulcdsdk-answer-card__top, .yxtulcdsdk-uexam-preview-content__base-row-flex--practice, title'; + const questionSelectors = '.yxtulcdsdk-form__content, .yxtulcdsdk-form__sub, .yxtulcdsdk-examing__result-line'; + const insertedSelectors = `${ROOT_SELECTOR}, .yxtulcdsdk-form, .yxtulcdsdk-exam-container, ${contextSelectors}, ${questionSelectors}`; + function relevantMutation(mutation) { + const node = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement; + if (!node || node.closest?.('#huayi-qb-root') || node.id === 'huayi-qb-copy-style') return false; + const inQuestion = node.closest?.(ROOT_SELECTOR); + if (inQuestion) { + if (mutation.type === 'attributes') { + // checked/disabled/selected and feedback classes change on every + // answer. They do not change the question or its standard answer. + if (node.closest?.('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')) return false; + return Boolean(node.matches?.(`${ROOT_SELECTOR}, .yxtulcdsdk-form`) || + node.closest?.('.yxtulcdsdk-examing__result-line')); + } + if (node.closest?.(questionSelectors)) return true; + } + if (node.closest?.(contextSelectors)) return true; + if (mutation.type === 'attributes' && mutation.attributeName === 'task-id' && node.matches?.('.yxtulcdsdk-exam-container')) return true; + if (mutation.type !== 'childList') return false; + // Structural additions/removals can introduce an exercise root, but + // unrelated timers, chat widgets, answer-card cells and our UI cannot. + return [...(mutation.addedNodes || []), ...(mutation.removedNodes || [])].some(changed => changed.nodeType === 1 && + changed.id !== 'huayi-qb-root' && changed.id !== 'huayi-qb-copy-style' && + (changed.matches?.(insertedSelectors) || changed.querySelector?.(insertedSelectors))); + } observer = new win.MutationObserver(mutations => { - const relevant = mutations.some(m => { - const node = m.target.nodeType === 1 ? m.target : m.target.parentElement; - return node && !node.closest('#huayi-qb-root') && node.id !== 'huayi-qb-copy-style'; - }); - if (!relevant) return; - syncBank(); - if (copyEnabled) ensureCopyStyle(); - if (!active) return; - win.clearTimeout(timer); + if (!mutations.some(relevantMutation) || timer !== null) return; timer = win.setTimeout(() => { + timer = null; + syncBank(); + if (copyEnabled) ensureCopyStyle(); + if (!active) return; const q = getCurrentQuestion(); if (!q) return; const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]); if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); } - }, 180); + }, 120); }); observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] }); } @@ -1513,6 +1666,7 @@ const QBAdapter = (() => { active = false; deferredSnapshots.clear(); win.clearTimeout(timer); + timer = null; uninstallNetwork(); status('采集已暂停,已保存题库和选择复制功能仍可使用。'); } @@ -1541,6 +1695,50 @@ const QBAuto = (() => { const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null; return adapter ? adapter.richText(value) : tidy(value && typeof value === 'object' ? value.textContent : value); }; + const recordIndexes = new WeakMap(); + function candidateRecords(records, question, core) { + let index = recordIndexes.get(records); + if (!index || index.core !== core) { + index = { core, entries: [], byQuestion: new Map(), normalized: new WeakMap() }; + recordIndexes.set(records, index); + } + let changed = index.entries.length !== records.length; + const entries = records.map((record, position) => { + const rawStem = record && typeof record.stem === 'string' ? record.stem : ''; + const type = record && typeof record.type === 'string' ? record.type : ''; + const old = index.entries[position]; + if (old?.record === record && old.rawStem === rawStem && old.type === type) return old; + changed = true; + return { record, rawStem, type, key: rawStem ? JSON.stringify([type, stemKey(rawStem)]) : '' }; + }); + if (changed) { + index.entries = entries; + index.byQuestion = new Map(); + for (const entry of entries) { + if (!entry.key || !entry.record || typeof entry.record !== 'object') continue; + if (!index.byQuestion.has(entry.key)) index.byQuestion.set(entry.key, []); + index.byQuestion.get(entry.key).push(entry.record); + } + } + const matches = index.byQuestion.get(JSON.stringify([question.type, stemKey(question.stem)])) || []; + return matches.map(input => { + // Only exact-stem candidates need full answer validation. This signature + // also detects in-place edits to answers/options, not just array replacement. + let signature; + try { + signature = JSON.stringify([input.bankId, input.bankName, input.questionId, input.type, input.stem, + input.options, input.correctOptionIds, input.answerTexts, input.answerStatus, input.conflicts, + input.questionIndex, input.contentOccurrence, input.questionIdAliases]); + } catch (_) { return null; } + const saved = index.normalized.get(input); + if (saved && saved.signature === signature) return saved.record; + try { + const record = core.normalizeRecord(input); + index.normalized.set(input, { signature, record }); + return record; + } catch (_) { return null; } + }).filter(Boolean); + } function planAnswer(records, current, suppliedCore) { const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more }); @@ -1553,9 +1751,7 @@ const QBAuto = (() => { // Repeated text requires mapAnswers to prove the complete option identity or correct set. if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。'); const relevant = []; - for (const input of records) { - let record; - try { record = core.normalizeRecord(input); } catch (_) { continue; } + for (const record of candidateRecords(records, question, core)) { if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue; if (question.bankId && record.bankId && question.bankId !== record.bankId) continue; if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue; @@ -1637,7 +1833,7 @@ const QBAuto = (() => { if (!current || bank?.loading) return null; const set = controls(container, true); if (!set || set.inputs.length !== current.options?.length || set.texts.some((value, index) => optionKey(value, current.type) !== optionKey(current.options[index].text, current.type))) return null; - return { container, inputs: set.inputs, question: current, site: true }; + return { container, inputs: set.inputs, question: current, site: true, bankIdentity: JSON.stringify([bank?.taskId || '', bank?.bankId || '']) }; } function readGeneric(container) { if (!visible(container)) return null; @@ -1651,7 +1847,7 @@ const QBAuto = (() => { const type = judged ? 'judge' : set.type; const question = { bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')), - questionId: '', stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })), + questionId: tidy(container.getAttribute('data-question-id') || container.getAttribute('data-ques-id')), stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })), answerStatus: 'missing', answerTexts: [], correctOptionIds: [] }; return { container, inputs: set.inputs, question, site: false }; @@ -1663,7 +1859,10 @@ const QBAuto = (() => { } function fingerprint(candidate) { const question = candidate.question; - return JSON.stringify([question.bankId || '', question.bankName || '', question.type, stemKey(question.stem), question.options.map(option => [option.label, optionKey(option.text, question.type)])]); + return JSON.stringify([question.bankId || '', question.bankName || '', candidate.bankIdentity || '', question.questionId || '', + question.questionIndex ?? null, question.contentOccurrence ?? null, question.type, stemKey(question.stem), + question.options.map(option => [option.id || '', option.label, optionKey(option.text, question.type)]), + candidate.inputs.map(input => [input.value, input.id, input.name, input.type])]); } function stillSame(candidate, mark) { if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false; @@ -1673,6 +1872,17 @@ const QBAuto = (() => { function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; } function desired(candidate, plan) { return candidate.question.options.map(option => plan.labels.includes(option.label)); } function alreadyChosen(candidate, flags) { return candidate.inputs.every((input, index) => Boolean(input.checked) === flags[index]); } + async function settled(candidate, mark, runGeneration, continuous) { + // A single Promise only waits one microtask. Vue and native event handlers + // can schedule further microtasks or a later render before changing values. + // Observe two distinct task turns before selecting any next option. + for (let check = 0; check < 2; check++) { + await new Promise(resolve => globalThis.setTimeout(resolve, 50)); + if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) return 'cancelled'; + if (!stillSame(candidate, mark)) return 'changed'; + } + return 'stable'; + } function skipped(message, stem = '', count = true) { emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) }); return { status: 'skipped', message }; @@ -1706,6 +1916,9 @@ const QBAuto = (() => { const runGeneration = generation; emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem }); try { + const initial = await settled(candidate, mark, runGeneration, continuous); + if (initial === 'cancelled') return { status: 'cancelled', message: '自动选择已暂停。' }; + if (initial !== 'stable') return skipped('题目仍在切换或选项身份变化,已停止本次选择。', questionStem); const actions = candidate.question.type === 'multiple' ? candidate.inputs.map((input, index) => ({ input, index })).filter(({ input, index }) => Boolean(input.checked) !== flags[index]) : candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]); @@ -1716,12 +1929,12 @@ const QBAuto = (() => { } if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem); input.click(); - await Promise.resolve(); + const next = await settled(candidate, mark, runGeneration, continuous); if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) { emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); return { status: 'cancelled', message: '自动选择已暂停。' }; } - if (!stillSame(candidate, mark)) return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem); + if (next !== 'stable') return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem); } if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem); emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem }); @@ -1764,7 +1977,8 @@ const QBAuto = (() => { return element && !element.closest('#huayi-qb-root'); })) schedule(); }); - observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled'] }); + observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, + attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled', 'value', 'data-question-id', 'data-ques-id', 'data-bank-id'] }); } interval = win.setInterval(schedule, 1500); schedule(); @@ -1811,12 +2025,17 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; let recordsDirty = false; let loadError = ''; let cleanupNotice = ''; + let storedRecordsText = ''; + let saveIdle = null; + const SAVE_DELAY = 800; function ensureCleanupBackup(original) { const existing = storage.get(BACKUP_KEY, null); if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original }); else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖'); } function repairAnswerConflicts(original) { + if (!original.some(record => record?.answerStatus === 'conflict' && Array.isArray(record.conflicts) && record.conflicts.some(item => + item?.reason === '正确选项ID与正确答案内容不一致'))) return { records: original, repaired: [] }; const result = QBCore.repairDuplicateOptionConflicts(original); if (!result.repaired.length) return result; const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null); @@ -1897,6 +2116,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; try { const saved = storage.get(RECORDS_KEY, []); if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); + storedRecordsText = JSON.stringify(saved); const retained = removeBackedUpImagePredecessors(saved, saved); const repaired = repairAnswerConflicts(retained); const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records)); @@ -1941,7 +2161,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; if (!bank || (!bank.bankName && !bank.bankId)) { if (activeBank) { activeBank = null; scope = ''; searchScope = ''; - if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + renderVisible(); } return; } @@ -1951,7 +2171,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; const changed = JSON.stringify(previous) !== JSON.stringify(bank); activeBank = { ...bank }; if (identityChanged) { scope = '__current__'; searchScope = '__current__'; } - if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + if (changed) renderVisible(); } function adapterStatus(value) { @@ -1964,16 +2184,25 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; } function persistRecords() { clearTimeout(saveTimer); + saveTimer = null; + if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; } if (loadError || !recordsDirty) return; try { // Merge other tabs before writing; GM change events also refresh an open panel. const stored = storage.get(RECORDS_KEY, []); if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖'); - const retained = removeBackedUpImagePredecessors(stored, records); - const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records)); - if (compacted.removed > 0) ensureCleanupBackup(stored); - records = repairAnswerConflicts(compacted.records).records; - storage.set(RECORDS_KEY, records); + const currentStoredText = JSON.stringify(stored); + // Local records have already been validated when received/imported. Only + // reconcile the whole library when another tab actually changed storage. + if (currentStoredText !== storedRecordsText) { + const retained = removeBackedUpImagePredecessors(stored, records); + const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, records)); + if (compacted.removed > 0) ensureCleanupBackup(stored); + records = repairAnswerConflicts(compacted.records).records; + } + const nextStoredText = JSON.stringify(records); + if (nextStoredText !== currentStoredText) storage.set(RECORDS_KEY, records); + storedRecordsText = nextStoredText; recordsDirty = false; storageError = false; } catch (error) { @@ -1981,20 +2210,52 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true); } } + function schedulePersist() { + if (saveTimer != null || saveIdle !== null) return; + saveTimer = setTimeout(() => { + saveTimer = null; + if (typeof window.requestIdleCallback === 'function') { + saveIdle = window.requestIdleCallback(() => { saveIdle = null; persistRecords(); }, { timeout: 1000 }); + } else persistRecords(); + }, SAVE_DELAY); + } + let observationIndexSource = null, observationIndex; + function redundantDOMObservation(record) { + if (record.source?.kind !== 'dom' || record.answerStatus !== 'missing' || record.explanation || !record.bankId || !record.questionId) return false; + if (observationIndexSource !== records) { + observationIndexSource = records; + observationIndex = new Map(); + for (const saved of records) for (const id of new Set([saved.questionId, ...(saved.questionIdAliases || [])].filter(Boolean))) { + const key = JSON.stringify([saved.bankId, id]); + observationIndex.set(key, observationIndex.has(key) && observationIndex.get(key) !== saved ? null : saved); + } + } + const saved = observationIndex.get(JSON.stringify([record.bankId, record.questionId])); + const flat = value => String(value || '').replace(/\s+/gu, ''); + if (!saved || saved.answerStatus !== 'confirmed' || saved.type !== record.type || flat(saved.stem) !== flat(record.stem) || + (record.questionIndex != null && record.questionIndex !== saved.questionIndex) || + (record.contentOccurrence != null && record.contentOccurrence !== saved.contentOccurrence)) return false; + return JSON.stringify(saved.options.map(option => flat(option.text)).sort()) === + JSON.stringify(record.options.map(option => flat(option.text)).sort()); + } function receive(incoming, context = {}) { if (!Array.isArray(incoming) || !incoming.length || loadError) return; try { + incoming = incoming.filter(record => !redundantDOMObservation(record)); + if (!incoming.length) return; incoming = repairAnswerConflicts(incoming).records; const bank = currentBank(); const upgradeContext = { bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '', expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true }; - const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext); - const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext); - ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]); - records = contentUpgrade.existing; - incoming = contentUpgrade.incoming; + if (upgradeContext.fullSnapshot && Number.isSafeInteger(upgradeContext.expectedCount)) { + const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext); + const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext); + ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]); + records = contentUpgrade.existing; + incoming = contentUpgrade.incoming; + } const namesById = new Map(); incoming.forEach(record => { if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return; @@ -2003,18 +2264,17 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; }); // A newly observed, explicit bank ID/name pair can repair an old stale // title for that same ID. Never infer a rename from question type or text. - records = records.map(record => { + if (namesById.size) records = records.map(record => { const names = record.bankId && namesById.get(record.bankId); - return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record; + return names?.size === 1 && record.bankName !== [...names][0] ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record; }); const expanded = QBCore.upsert(records, incoming); const compacted = QBCore.compactRecords(expanded); if (compacted.removed > 0) ensureCleanupBackup(expanded); records = repairAnswerConflicts(compacted.records).records; recordsDirty = true; - clearTimeout(saveTimer); - saveTimer = setTimeout(persistRecords, 250); - if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); } + schedulePersist(); + renderVisible(); } catch (error) { tell(`这批题目未保存:${error.message}`, true); } } function tell(message, error = false) { @@ -2045,6 +2305,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; const collapse = shadow.querySelector('[data-action="collapse"]'); collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起'; collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed')); + if (config.mode === 'open') renderVisible(); } function selectTab(tab) { config.tab = tab; @@ -2055,10 +2316,20 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; button.classList.toggle('active', selected); }); shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; }); + renderVisible(); + } + function renderVisible() { + if (!mounted || config.mode !== 'open') return; + refreshScope(); renderStats(); renderCurrent(); + if (config.tab === 'search') renderResults(); + if (config.tab === 'auto') renderAuto(); + renderStatus(); } function bankKey(record) { return String(record.bankId || record.bankName || ''); } function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); } + let catalogSource = null, catalogCache; function bankCatalog() { + if (catalogSource === records) return catalogCache; const namedIds = new Map(); records.forEach(record => { if (!record.bankId) return; @@ -2073,7 +2344,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 }); catalog.get(key).count++; }); - return [...catalog.values()]; + catalogSource = records; + catalogCache = [...catalog.values()]; + return catalogCache; } function currentQuestion() { try { return adapter.getCurrentQuestion(); } catch (_) { return null; } @@ -2108,7 +2381,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; return anchor?.bankName || '未命名题库'; } function refreshScope() { - if (!shadow) return; + if (!shadow || config.mode !== 'open') return; const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name])); if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__'; if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__'; @@ -2128,7 +2401,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; }); } function renderStats() { - if (!shadow) return; + if (!shadow || config.mode !== 'open') return; const selected = scopedRecords(); const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length; const conflicts = selected.filter(record => record.answerStatus === 'conflict').length; @@ -2155,7 +2428,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; indicator.classList.toggle('paused', !config.collecting); } function renderCurrent() { - if (!shadow) return; + if (!shadow || config.mode !== 'open') return; const bank = currentBank(); const current = currentQuestion(); const known = Boolean(bank?.bankName || bank?.bankId); @@ -2169,7 +2442,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; shadow.getElementById('qb-current-bank').textContent = name; } function renderAuto() { - if (!shadow) return; + if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return; const state = autoAnswer.getState(); shadow.getElementById('qb-auto-enabled').checked = state.enabled; shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。'; @@ -2177,7 +2450,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy); } function renderResults() { - if (!resultBox) return; + if (!resultBox || config.mode !== 'open' || config.tab !== 'search') return; resultBox.replaceChildren(); const pool = scopedRecords(searchScope); const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : []; @@ -2317,7 +2590,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; records = merged; recordsDirty = true; persistRecords(); - refreshScope(); renderStats(); renderCurrent(); renderResults(); + renderVisible(); const bankCount = new Set(incoming.map(bankKey)).size; if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`); } @@ -2437,7 +2710,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; }); if (cleanupNotice) status = cleanupNotice; refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode(); - setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000); + // Navigation/collection is driven by the adapter. A hidden panel has no + // polling/render loop; only an open, foreground panel refreshes its header. + setInterval(() => { if (config.mode === 'open' && !document.hidden) renderCurrent(); }, 2000); } document.addEventListener('mouseup', event => { @@ -2472,17 +2747,20 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto; if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { if (!remote || !Array.isArray(value)) return; try { + const valueText = JSON.stringify(value); + if (valueText === storedRecordsText) return; const retained = removeBackedUpImagePredecessors(value, records); const incoming = repairAnswerConflicts(retained); records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records; - if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(persistRecords, 250); } - if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + storedRecordsText = valueText; + if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; schedulePersist(); } + renderVisible(); } catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ } }); if (config.collecting) adapter.start(); adapter.setCopyEnabled(Boolean(config.copyEnabled)); - if (recordsDirty && !loadError) saveTimer = setTimeout(persistRecords, 250); + if (recordsDirty && !loadError) schedulePersist(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true }); else mount(); })(); diff --git a/package.json b/package.json index 74165fc..ab5d1cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "huayi-question-bank", - "version": "1.2.4", + "version": "1.2.5", "private": true, "description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本", "scripts": { @@ -9,6 +9,7 @@ "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: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" } } diff --git a/src/adapter.js b/src/adapter.js index bc2fab4..a23c79a 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -66,7 +66,7 @@ const QBAdapter = (() => { function standardAnswer(node, type, options, context) { const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null; const answerNode = result || node; - if (answerNode.allowViewSingleQuesAnswer === false || answerNode.allowViewSingleQuesAnswer === 0) { + if ([node, answerNode].some(value => value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0)) { return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }; } if (type === 'judge') { @@ -261,6 +261,7 @@ const QBAdapter = (() => { let requestSequence = 0; let batchSequence = 0; const known = new Map(); + let knownContentIndex = null; const xhrContext = new WeakMap(); const pendingXHR = new Set(); const banksById = new Map(); @@ -461,6 +462,7 @@ const QBAdapter = (() => { for (const q of records) { if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q); } + knownContentIndex = null; const current = syncBank(); const metadata = emissionContext(context, current, staleBanks.has(context.bankId)); const containsImages = records.some(record => /\[图片:https?:\/\//.test(record.stem) || record.options.some(option => /\[图片:https?:\/\//.test(option.text))); @@ -479,6 +481,22 @@ const QBAdapter = (() => { return true; } + function matchingKnown(question) { + if (!knownContentIndex) { + knownContentIndex = new Map(); + for (const record of known.values()) { + const content = signature(record); + const keys = [`id:${record.bankId}\u001d${content}`, `name:${flat(record.bankName)}\u001d${content}`]; + for (const key of keys) { + if (!knownContentIndex.has(key)) knownContentIndex.set(key, []); + knownContentIndex.get(key).push(record); + } + } + } + const key = question.bankId ? `id:${question.bankId}` : `name:${flat(question.bankName)}`; + return knownContentIndex.get(`${key}\u001d${signature(question)}`) || []; + } + function getCurrentQuestion() { if (!isSupportedPage()) return null; const bank = syncBank(); @@ -513,8 +531,7 @@ const QBAdapter = (() => { const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/); const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null; if (questionIndex !== null) q.questionIndex = questionIndex; - let matches = [...known.values()].filter(item => signature(item) === signature(q) && - (q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName))); + let matches = matchingKnown(q); if (questionIndex !== null) { const indexed = matches.filter(item => item.questionIndex === questionIndex); if (indexed.length) matches = indexed; @@ -522,6 +539,11 @@ const QBAdapter = (() => { } if (blockedForm && (!bank.bankId || matches.length !== 1)) return null; if (matches.length === 1) { + // Vue may update the question text before the old radio values. Those + // transitional controls must not be attached to the new server identity. + const expectedOptions = matches[0].options; + if (expectedOptions.every(option => option.id) && options.some(option => option.id && + !expectedOptions.some(expected => expected.id === option.id && flat(expected.text) === flat(option.text)))) return null; q.questionId = matches[0].questionId; q.bankId = matches[0].bankId; if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex; @@ -529,7 +551,8 @@ const QBAdapter = (() => { if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId; if (blockedForm) { blockedForm = null; syncBank(); } } - // Only a row explicitly labelled standard answer can establish correctness. + // A letter-only result row can survive the previous question during a + // render. Its heading alone does not prove it belongs to this question. const main = form.closest(ROOT_SELECTOR); const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible); for (const row of rows) { @@ -544,11 +567,22 @@ const QBAdapter = (() => { if (selected.length !== requested.length) selected = []; } if (selected.length && (type !== 'single' || selected.length === 1)) { + const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'] + .map(name => idString(row.getAttribute?.(name))).find(Boolean); + const knownAnswer = matches.length === 1 && matches[0].answerStatus === 'confirmed' ? matches[0] : null; + const agreesWithKnown = knownAnswer && knownAnswer.answerTexts.length === selected.length && + selected.every(option => knownAnswer.answerTexts.some(answerText => flat(answerText) === flat(option.text))); + const boundToCurrent = q.questionId && feedbackId && feedbackId === q.questionId; + if ((feedbackId && feedbackId !== q.questionId) || (!boundToCurrent && !agreesWithKnown)) continue; q.correctOptionIds = selected.map(o => o.id).filter(Boolean); q.answerTexts = selected.map(o => o.text); q.answerStatus = 'confirmed'; } - } else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') q.explanation = answer; + } else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') { + const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'] + .map(name => idString(row.getAttribute?.(name))).find(Boolean); + if (q.answerStatus === 'confirmed' && (!feedbackId || feedbackId === q.questionId)) q.explanation = answer; + } } return q; } @@ -683,22 +717,44 @@ const QBAdapter = (() => { function ensureObserver() { if (observer || !win.MutationObserver || !isSupportedPage()) return; + const contextSelectors = '.task-content, .task-content-active, header.o2oPlayFrame-right, .yxtulcdsdk-exam-header, .yxtulcdsdk-answer-card__top, .yxtulcdsdk-uexam-preview-content__base-row-flex--practice, title'; + const questionSelectors = '.yxtulcdsdk-form__content, .yxtulcdsdk-form__sub, .yxtulcdsdk-examing__result-line'; + const insertedSelectors = `${ROOT_SELECTOR}, .yxtulcdsdk-form, .yxtulcdsdk-exam-container, ${contextSelectors}, ${questionSelectors}`; + function relevantMutation(mutation) { + const node = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement; + if (!node || node.closest?.('#huayi-qb-root') || node.id === 'huayi-qb-copy-style') return false; + const inQuestion = node.closest?.(ROOT_SELECTOR); + if (inQuestion) { + if (mutation.type === 'attributes') { + // checked/disabled/selected and feedback classes change on every + // answer. They do not change the question or its standard answer. + if (node.closest?.('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')) return false; + return Boolean(node.matches?.(`${ROOT_SELECTOR}, .yxtulcdsdk-form`) || + node.closest?.('.yxtulcdsdk-examing__result-line')); + } + if (node.closest?.(questionSelectors)) return true; + } + if (node.closest?.(contextSelectors)) return true; + if (mutation.type === 'attributes' && mutation.attributeName === 'task-id' && node.matches?.('.yxtulcdsdk-exam-container')) return true; + if (mutation.type !== 'childList') return false; + // Structural additions/removals can introduce an exercise root, but + // unrelated timers, chat widgets, answer-card cells and our UI cannot. + return [...(mutation.addedNodes || []), ...(mutation.removedNodes || [])].some(changed => changed.nodeType === 1 && + changed.id !== 'huayi-qb-root' && changed.id !== 'huayi-qb-copy-style' && + (changed.matches?.(insertedSelectors) || changed.querySelector?.(insertedSelectors))); + } observer = new win.MutationObserver(mutations => { - const relevant = mutations.some(m => { - const node = m.target.nodeType === 1 ? m.target : m.target.parentElement; - return node && !node.closest('#huayi-qb-root') && node.id !== 'huayi-qb-copy-style'; - }); - if (!relevant) return; - syncBank(); - if (copyEnabled) ensureCopyStyle(); - if (!active) return; - win.clearTimeout(timer); + if (!mutations.some(relevantMutation) || timer !== null) return; timer = win.setTimeout(() => { + timer = null; + syncBank(); + if (copyEnabled) ensureCopyStyle(); + if (!active) return; const q = getCurrentQuestion(); if (!q) return; const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]); if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); } - }, 180); + }, 120); }); observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] }); } @@ -737,6 +793,7 @@ const QBAdapter = (() => { active = false; deferredSnapshots.clear(); win.clearTimeout(timer); + timer = null; uninstallNetwork(); status('采集已暂停,已保存题库和选择复制功能仍可使用。'); } diff --git a/src/app.js b/src/app.js index 5036fb0..42d8a18 100644 --- a/src/app.js +++ b/src/app.js @@ -31,12 +31,17 @@ let recordsDirty = false; let loadError = ''; let cleanupNotice = ''; + let storedRecordsText = ''; + let saveIdle = null; + const SAVE_DELAY = 800; function ensureCleanupBackup(original) { const existing = storage.get(BACKUP_KEY, null); if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original }); else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖'); } function repairAnswerConflicts(original) { + if (!original.some(record => record?.answerStatus === 'conflict' && Array.isArray(record.conflicts) && record.conflicts.some(item => + item?.reason === '正确选项ID与正确答案内容不一致'))) return { records: original, repaired: [] }; const result = QBCore.repairDuplicateOptionConflicts(original); if (!result.repaired.length) return result; const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null); @@ -117,6 +122,7 @@ try { const saved = storage.get(RECORDS_KEY, []); if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); + storedRecordsText = JSON.stringify(saved); const retained = removeBackedUpImagePredecessors(saved, saved); const repaired = repairAnswerConflicts(retained); const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records)); @@ -161,7 +167,7 @@ if (!bank || (!bank.bankName && !bank.bankId)) { if (activeBank) { activeBank = null; scope = ''; searchScope = ''; - if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + renderVisible(); } return; } @@ -171,7 +177,7 @@ const changed = JSON.stringify(previous) !== JSON.stringify(bank); activeBank = { ...bank }; if (identityChanged) { scope = '__current__'; searchScope = '__current__'; } - if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + if (changed) renderVisible(); } function adapterStatus(value) { @@ -184,16 +190,25 @@ } function persistRecords() { clearTimeout(saveTimer); + saveTimer = null; + if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; } if (loadError || !recordsDirty) return; try { // Merge other tabs before writing; GM change events also refresh an open panel. const stored = storage.get(RECORDS_KEY, []); if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖'); - const retained = removeBackedUpImagePredecessors(stored, records); - const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records)); - if (compacted.removed > 0) ensureCleanupBackup(stored); - records = repairAnswerConflicts(compacted.records).records; - storage.set(RECORDS_KEY, records); + const currentStoredText = JSON.stringify(stored); + // Local records have already been validated when received/imported. Only + // reconcile the whole library when another tab actually changed storage. + if (currentStoredText !== storedRecordsText) { + const retained = removeBackedUpImagePredecessors(stored, records); + const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, records)); + if (compacted.removed > 0) ensureCleanupBackup(stored); + records = repairAnswerConflicts(compacted.records).records; + } + const nextStoredText = JSON.stringify(records); + if (nextStoredText !== currentStoredText) storage.set(RECORDS_KEY, records); + storedRecordsText = nextStoredText; recordsDirty = false; storageError = false; } catch (error) { @@ -201,20 +216,52 @@ tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true); } } + function schedulePersist() { + if (saveTimer != null || saveIdle !== null) return; + saveTimer = setTimeout(() => { + saveTimer = null; + if (typeof window.requestIdleCallback === 'function') { + saveIdle = window.requestIdleCallback(() => { saveIdle = null; persistRecords(); }, { timeout: 1000 }); + } else persistRecords(); + }, SAVE_DELAY); + } + let observationIndexSource = null, observationIndex; + function redundantDOMObservation(record) { + if (record.source?.kind !== 'dom' || record.answerStatus !== 'missing' || record.explanation || !record.bankId || !record.questionId) return false; + if (observationIndexSource !== records) { + observationIndexSource = records; + observationIndex = new Map(); + for (const saved of records) for (const id of new Set([saved.questionId, ...(saved.questionIdAliases || [])].filter(Boolean))) { + const key = JSON.stringify([saved.bankId, id]); + observationIndex.set(key, observationIndex.has(key) && observationIndex.get(key) !== saved ? null : saved); + } + } + const saved = observationIndex.get(JSON.stringify([record.bankId, record.questionId])); + const flat = value => String(value || '').replace(/\s+/gu, ''); + if (!saved || saved.answerStatus !== 'confirmed' || saved.type !== record.type || flat(saved.stem) !== flat(record.stem) || + (record.questionIndex != null && record.questionIndex !== saved.questionIndex) || + (record.contentOccurrence != null && record.contentOccurrence !== saved.contentOccurrence)) return false; + return JSON.stringify(saved.options.map(option => flat(option.text)).sort()) === + JSON.stringify(record.options.map(option => flat(option.text)).sort()); + } function receive(incoming, context = {}) { if (!Array.isArray(incoming) || !incoming.length || loadError) return; try { + incoming = incoming.filter(record => !redundantDOMObservation(record)); + if (!incoming.length) return; incoming = repairAnswerConflicts(incoming).records; const bank = currentBank(); const upgradeContext = { bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '', expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true }; - const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext); - const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext); - ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]); - records = contentUpgrade.existing; - incoming = contentUpgrade.incoming; + if (upgradeContext.fullSnapshot && Number.isSafeInteger(upgradeContext.expectedCount)) { + const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext); + const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext); + ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]); + records = contentUpgrade.existing; + incoming = contentUpgrade.incoming; + } const namesById = new Map(); incoming.forEach(record => { if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return; @@ -223,18 +270,17 @@ }); // A newly observed, explicit bank ID/name pair can repair an old stale // title for that same ID. Never infer a rename from question type or text. - records = records.map(record => { + if (namesById.size) records = records.map(record => { const names = record.bankId && namesById.get(record.bankId); - return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record; + return names?.size === 1 && record.bankName !== [...names][0] ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record; }); const expanded = QBCore.upsert(records, incoming); const compacted = QBCore.compactRecords(expanded); if (compacted.removed > 0) ensureCleanupBackup(expanded); records = repairAnswerConflicts(compacted.records).records; recordsDirty = true; - clearTimeout(saveTimer); - saveTimer = setTimeout(persistRecords, 250); - if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); } + schedulePersist(); + renderVisible(); } catch (error) { tell(`这批题目未保存:${error.message}`, true); } } function tell(message, error = false) { @@ -265,6 +311,7 @@ const collapse = shadow.querySelector('[data-action="collapse"]'); collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起'; collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed')); + if (config.mode === 'open') renderVisible(); } function selectTab(tab) { config.tab = tab; @@ -275,10 +322,20 @@ button.classList.toggle('active', selected); }); shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; }); + renderVisible(); + } + function renderVisible() { + if (!mounted || config.mode !== 'open') return; + refreshScope(); renderStats(); renderCurrent(); + if (config.tab === 'search') renderResults(); + if (config.tab === 'auto') renderAuto(); + renderStatus(); } function bankKey(record) { return String(record.bankId || record.bankName || ''); } function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); } + let catalogSource = null, catalogCache; function bankCatalog() { + if (catalogSource === records) return catalogCache; const namedIds = new Map(); records.forEach(record => { if (!record.bankId) return; @@ -293,7 +350,9 @@ if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 }); catalog.get(key).count++; }); - return [...catalog.values()]; + catalogSource = records; + catalogCache = [...catalog.values()]; + return catalogCache; } function currentQuestion() { try { return adapter.getCurrentQuestion(); } catch (_) { return null; } @@ -328,7 +387,7 @@ return anchor?.bankName || '未命名题库'; } function refreshScope() { - if (!shadow) return; + if (!shadow || config.mode !== 'open') return; const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name])); if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__'; if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__'; @@ -348,7 +407,7 @@ }); } function renderStats() { - if (!shadow) return; + if (!shadow || config.mode !== 'open') return; const selected = scopedRecords(); const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length; const conflicts = selected.filter(record => record.answerStatus === 'conflict').length; @@ -375,7 +434,7 @@ indicator.classList.toggle('paused', !config.collecting); } function renderCurrent() { - if (!shadow) return; + if (!shadow || config.mode !== 'open') return; const bank = currentBank(); const current = currentQuestion(); const known = Boolean(bank?.bankName || bank?.bankId); @@ -389,7 +448,7 @@ shadow.getElementById('qb-current-bank').textContent = name; } function renderAuto() { - if (!shadow) return; + if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return; const state = autoAnswer.getState(); shadow.getElementById('qb-auto-enabled').checked = state.enabled; shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。'; @@ -397,7 +456,7 @@ shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy); } function renderResults() { - if (!resultBox) return; + if (!resultBox || config.mode !== 'open' || config.tab !== 'search') return; resultBox.replaceChildren(); const pool = scopedRecords(searchScope); const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : []; @@ -537,7 +596,7 @@ records = merged; recordsDirty = true; persistRecords(); - refreshScope(); renderStats(); renderCurrent(); renderResults(); + renderVisible(); const bankCount = new Set(incoming.map(bankKey)).size; if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`); } @@ -657,7 +716,9 @@ }); if (cleanupNotice) status = cleanupNotice; refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode(); - setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000); + // Navigation/collection is driven by the adapter. A hidden panel has no + // polling/render loop; only an open, foreground panel refreshes its header. + setInterval(() => { if (config.mode === 'open' && !document.hidden) renderCurrent(); }, 2000); } document.addEventListener('mouseup', event => { @@ -692,17 +753,20 @@ if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { if (!remote || !Array.isArray(value)) return; try { + const valueText = JSON.stringify(value); + if (valueText === storedRecordsText) return; const retained = removeBackedUpImagePredecessors(value, records); const incoming = repairAnswerConflicts(retained); records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records; - if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(persistRecords, 250); } - if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + storedRecordsText = valueText; + if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; schedulePersist(); } + renderVisible(); } catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ } }); if (config.collecting) adapter.start(); adapter.setCopyEnabled(Boolean(config.copyEnabled)); - if (recordsDirty && !loadError) saveTimer = setTimeout(persistRecords, 250); + if (recordsDirty && !loadError) schedulePersist(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true }); else mount(); })(); diff --git a/src/autofill.js b/src/autofill.js index c103205..5005587 100644 --- a/src/autofill.js +++ b/src/autofill.js @@ -13,6 +13,50 @@ const QBAuto = (() => { const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null; return adapter ? adapter.richText(value) : tidy(value && typeof value === 'object' ? value.textContent : value); }; + const recordIndexes = new WeakMap(); + function candidateRecords(records, question, core) { + let index = recordIndexes.get(records); + if (!index || index.core !== core) { + index = { core, entries: [], byQuestion: new Map(), normalized: new WeakMap() }; + recordIndexes.set(records, index); + } + let changed = index.entries.length !== records.length; + const entries = records.map((record, position) => { + const rawStem = record && typeof record.stem === 'string' ? record.stem : ''; + const type = record && typeof record.type === 'string' ? record.type : ''; + const old = index.entries[position]; + if (old?.record === record && old.rawStem === rawStem && old.type === type) return old; + changed = true; + return { record, rawStem, type, key: rawStem ? JSON.stringify([type, stemKey(rawStem)]) : '' }; + }); + if (changed) { + index.entries = entries; + index.byQuestion = new Map(); + for (const entry of entries) { + if (!entry.key || !entry.record || typeof entry.record !== 'object') continue; + if (!index.byQuestion.has(entry.key)) index.byQuestion.set(entry.key, []); + index.byQuestion.get(entry.key).push(entry.record); + } + } + const matches = index.byQuestion.get(JSON.stringify([question.type, stemKey(question.stem)])) || []; + return matches.map(input => { + // Only exact-stem candidates need full answer validation. This signature + // also detects in-place edits to answers/options, not just array replacement. + let signature; + try { + signature = JSON.stringify([input.bankId, input.bankName, input.questionId, input.type, input.stem, + input.options, input.correctOptionIds, input.answerTexts, input.answerStatus, input.conflicts, + input.questionIndex, input.contentOccurrence, input.questionIdAliases]); + } catch (_) { return null; } + const saved = index.normalized.get(input); + if (saved && saved.signature === signature) return saved.record; + try { + const record = core.normalizeRecord(input); + index.normalized.set(input, { signature, record }); + return record; + } catch (_) { return null; } + }).filter(Boolean); + } function planAnswer(records, current, suppliedCore) { const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more }); @@ -25,9 +69,7 @@ const QBAuto = (() => { // Repeated text requires mapAnswers to prove the complete option identity or correct set. if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。'); const relevant = []; - for (const input of records) { - let record; - try { record = core.normalizeRecord(input); } catch (_) { continue; } + for (const record of candidateRecords(records, question, core)) { if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue; if (question.bankId && record.bankId && question.bankId !== record.bankId) continue; if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue; @@ -109,7 +151,7 @@ const QBAuto = (() => { if (!current || bank?.loading) return null; const set = controls(container, true); if (!set || set.inputs.length !== current.options?.length || set.texts.some((value, index) => optionKey(value, current.type) !== optionKey(current.options[index].text, current.type))) return null; - return { container, inputs: set.inputs, question: current, site: true }; + return { container, inputs: set.inputs, question: current, site: true, bankIdentity: JSON.stringify([bank?.taskId || '', bank?.bankId || '']) }; } function readGeneric(container) { if (!visible(container)) return null; @@ -123,7 +165,7 @@ const QBAuto = (() => { const type = judged ? 'judge' : set.type; const question = { bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')), - questionId: '', stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })), + questionId: tidy(container.getAttribute('data-question-id') || container.getAttribute('data-ques-id')), stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })), answerStatus: 'missing', answerTexts: [], correctOptionIds: [] }; return { container, inputs: set.inputs, question, site: false }; @@ -135,7 +177,10 @@ const QBAuto = (() => { } function fingerprint(candidate) { const question = candidate.question; - return JSON.stringify([question.bankId || '', question.bankName || '', question.type, stemKey(question.stem), question.options.map(option => [option.label, optionKey(option.text, question.type)])]); + return JSON.stringify([question.bankId || '', question.bankName || '', candidate.bankIdentity || '', question.questionId || '', + question.questionIndex ?? null, question.contentOccurrence ?? null, question.type, stemKey(question.stem), + question.options.map(option => [option.id || '', option.label, optionKey(option.text, question.type)]), + candidate.inputs.map(input => [input.value, input.id, input.name, input.type])]); } function stillSame(candidate, mark) { if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false; @@ -145,6 +190,17 @@ const QBAuto = (() => { function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; } function desired(candidate, plan) { return candidate.question.options.map(option => plan.labels.includes(option.label)); } function alreadyChosen(candidate, flags) { return candidate.inputs.every((input, index) => Boolean(input.checked) === flags[index]); } + async function settled(candidate, mark, runGeneration, continuous) { + // A single Promise only waits one microtask. Vue and native event handlers + // can schedule further microtasks or a later render before changing values. + // Observe two distinct task turns before selecting any next option. + for (let check = 0; check < 2; check++) { + await new Promise(resolve => globalThis.setTimeout(resolve, 50)); + if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) return 'cancelled'; + if (!stillSame(candidate, mark)) return 'changed'; + } + return 'stable'; + } function skipped(message, stem = '', count = true) { emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) }); return { status: 'skipped', message }; @@ -178,6 +234,9 @@ const QBAuto = (() => { const runGeneration = generation; emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem }); try { + const initial = await settled(candidate, mark, runGeneration, continuous); + if (initial === 'cancelled') return { status: 'cancelled', message: '自动选择已暂停。' }; + if (initial !== 'stable') return skipped('题目仍在切换或选项身份变化,已停止本次选择。', questionStem); const actions = candidate.question.type === 'multiple' ? candidate.inputs.map((input, index) => ({ input, index })).filter(({ input, index }) => Boolean(input.checked) !== flags[index]) : candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]); @@ -188,12 +247,12 @@ const QBAuto = (() => { } if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem); input.click(); - await Promise.resolve(); + const next = await settled(candidate, mark, runGeneration, continuous); if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) { emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); return { status: 'cancelled', message: '自动选择已暂停。' }; } - if (!stillSame(candidate, mark)) return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem); + if (next !== 'stable') return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem); } if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem); emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem }); @@ -236,7 +295,8 @@ const QBAuto = (() => { return element && !element.closest('#huayi-qb-root'); })) schedule(); }); - observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled'] }); + observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, + attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled', 'value', 'data-question-id', 'data-ques-id', 'data-bank-id'] }); } interval = win.setInterval(schedule, 1500); schedule(); diff --git a/src/core.js b/src/core.js index f663cb5..69480fb 100644 --- a/src/core.js +++ b/src/core.js @@ -213,6 +213,13 @@ const QBCore = (() => { return map; }; const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming); + const previousBySignature = new Map(), indexCounts = new Map(), originalByKey = new Map(); + existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); }); + for (const record of existingBank) { + const key = signature(record); + if (!previousBySignature.has(key)) previousBySignature.set(key, record); + indexCounts.set(record.questionIndex, (indexCounts.get(record.questionIndex) || 0) + 1); + } const correctContents = record => { if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length || record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length) return null; @@ -225,18 +232,18 @@ const QBCore = (() => { if (!imageCount(current) || current.type === 'unknown' || current.options.length < 2 || !strip(current.stem) || oldCounts.get(key) !== 1 || newCounts.get(key) !== 1 || new Set(current.options.map(option => strip(option.text))).size !== current.options.length || current.options.some(option => !strip(option.text))) continue; - const previous = existingBank.find(record => signature(record) === key); + const previous = previousBySignature.get(key); if (!previous.questionId || imageCount(previous) || previous.type !== current.type || !Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) || (previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) || - existingBank.filter(record => record.questionIndex === current.questionIndex).length !== 1) continue; + indexCounts.get(current.questionIndex) !== 1) continue; const oldAnswers = correctContents(previous), newAnswers = correctContents(current); if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) || !sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue; const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); incoming[index] = replacement; removed.add(previous.key); - upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement, + upgraded.push({ original: originalByKey.get(previous.key), replacement, bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex }); } return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; @@ -275,10 +282,26 @@ const QBCore = (() => { const sameModern = (record, current) => !hasEntity(record) && record.type === current.type && record.questionIndex === current.questionIndex && normalized(record.stem) === normalized(current.stem) && sameArray(optionPairs(record), optionPairs(current)) && isList(record.source) && sameAnswers(record, current); const bank = existing.filter(record => record.bankId === bankId), upgraded = [], removed = new Set(); + const incomingCounts = new Map(), bankBySignature = new Map(), legacyBySignature = new Map(), bankByIndex = new Map(), originalByKey = new Map(); + existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); }); + const add = (map, key, record) => { + if (!map.has(key)) map.set(key, []); + map.get(key).push(record); + }; + for (const record of incoming) { + const key = signature(record); + incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1); + } + for (const record of bank) { + const key = signature(record); + add(bankBySignature, key, record); + add(bankByIndex, record.questionIndex, record); + if (hasEntity(record)) add(legacyBySignature, key, record); + } for (let index = 0; index < incoming.length; index++) { const current = incoming[index], key = signature(current); - if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue; - const candidates = bank.filter(record => hasEntity(record) && signature(record) === key); + if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue; + const candidates = legacyBySignature.get(key) || []; if (candidates.length !== 1) continue; const previous = candidates[0]; if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) || @@ -287,12 +310,12 @@ const QBCore = (() => { // A previous failed export may already contain both spellings. Permit only // fully agreeing modern copies at that very same source position; distinct // literal-entity questions and duplicate occurrences remain untouched. - if (bank.some(record => (signature(record) === key || record.questionIndex === current.questionIndex) && + if ([...(bankBySignature.get(key) || []), ...(bankByIndex.get(current.questionIndex) || [])].some(record => record.key !== previous.key && !sameModern(record, current))) continue; const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) }); incoming[index] = replacement; removed.add(previous.key); - upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement, + upgraded.push({ original: originalByKey.get(previous.key), replacement, kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex }); } return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded }; @@ -312,8 +335,12 @@ const QBCore = (() => { } function mergeRecord(existing, incoming) { - const oldRecord = normalizeRecord(existing); - const newRecord = normalizeRecord(incoming); + return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming)); + } + + // Internal callers already validated both records. Keep validation at the + // public boundary and on the merged result without cloning both inputs again. + function mergeNormalizedRecords(oldRecord, newRecord) { if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录'); const contentMatches = sameContent(oldRecord, newRecord); const explanation = newRecord.explanation || oldRecord.explanation; @@ -359,27 +386,69 @@ const QBCore = (() => { const lastSeen = new Map(); for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { const record = normalizeRecord(item); - records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record); + records.set(record.key, records.has(record.key) ? mergeNormalizedRecords(records.get(record.key), record) : record); lastSeen.set(record.key, index); } // Explicit aliases are produced only by content/batch repair. Ordinary // records with different IDs are still kept separate until that repair. const bankIdentity = bankResolver([...records.values()]); + const signatures = new WeakMap(); + const signature = record => { + if (!signatures.has(record)) signatures.set(record, contentSignature(record)); + return signatures.get(record); + }; + const byBankAndId = new Map(); + const positions = new Map(); + for (const [index, record] of [...records.values()].entries()) { + positions.set(record.key, index); + if (!record.questionId) continue; + const key = JSON.stringify([bankIdentity(record), record.questionId]); + if (!byBankAndId.has(key)) byBankAndId.set(key, []); + byBankAndId.get(key).push(record.key); + } for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) { if (!records.has(target.key)) continue; - for (const other of [...records.values()]) { - if (other.key === target.key || !records.has(other.key) || !other.questionId) continue; + const queued = new Set(), knownAliases = new Set(), candidates = []; + let cursor = -1; + const enqueue = aliases => { + for (const alias of aliases) { + if (knownAliases.has(alias)) continue; + knownAliases.add(alias); + for (const key of byBankAndId.get(JSON.stringify([bankIdentity(target), alias])) || []) { + if (key === target.key || queued.has(key) || positions.get(key) <= cursor) continue; + queued.add(key); + // Retain the old Map traversal order, including when a merged + // record introduces further aliases during this pass. + let low = 0, high = candidates.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (positions.get(candidates[middle]) < positions.get(key)) low = middle + 1; + else high = middle; + } + candidates.splice(low, 0, key); + } + } + }; + enqueue(records.get(target.key).questionIdAliases || []); + while (candidates.length) { + const key = candidates.shift(); + cursor = positions.get(key); + const other = records.get(key); + if (!other) continue; const current = records.get(target.key); - const aliases = new Set(current.questionIdAliases || []); - if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue; + if (signature(current) !== signature(other)) continue; if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue; - records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence })); + const merged = foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }); + records.set(target.key, merged); records.delete(other.key); + enqueue(merged.questionIdAliases || []); } } + const repairedContents = new Set([...records.values()].filter(record => record.questionIdAliases?.length > 1) + .map(record => JSON.stringify([bankIdentity(record), signature(record)]))); for (const record of [...records.values()]) { if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue; - if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key); + if (repairedContents.has(JSON.stringify([bankIdentity(record), signature(record)]))) records.delete(record.key); } // The saved DOM has no stable question ID. Upgrade it only when the complete // content and bank identify one unique server record in both directions. @@ -390,17 +459,28 @@ const QBCore = (() => { if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false; return normalized(a.bankName) === normalized(b.bankName); }; - const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b) && + const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && sameBank(a, b) && (a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence); - const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))])); + const identifiedByContent = new Map(); + for (const record of identified) { + const key = signature(record); + if (!identifiedByContent.has(key)) identifiedByContent.set(key, []); + identifiedByContent.get(key).push(record); + } + const candidates = new Map(), candidateCounts = new Map(); + for (const record of anonymous) { + const matches = (identifiedByContent.get(signature(record)) || []).filter(other => possiblePair(record, other)); + candidates.set(record.key, matches); + for (const match of matches) candidateCounts.set(match.key, (candidateCounts.get(match.key) || 0) + 1); + } for (const record of anonymous) { const matches = candidates.get(record.key); if (matches.length !== 1) continue; const target = matches[0]; - if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue; + if (candidateCounts.get(target.key) !== 1) continue; const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId }); const currentTarget = records.get(target.key); - const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted); + const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeNormalizedRecords(promoted, currentTarget) : mergeNormalizedRecords(currentTarget, promoted); records.set(target.key, merged); records.delete(record.key); } @@ -441,10 +521,13 @@ const QBCore = (() => { // Process missing observations first so the selected options and the answer // IDs always come together from an answer-bearing record. const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing')); + if (group.length === 1 && first.bankId === meta.bankId && first.bankName === meta.bankName && first.questionId === meta.questionId && + (meta.contentOccurrence == null || meta.contentOccurrence === first.contentOccurrence) && + (!own(meta, 'questionIdAliases') || JSON.stringify(meta.questionIdAliases) === JSON.stringify(first.questionIdAliases))) return first; let result; for (const record of ordered) { const rebased = normalizeRecord({ ...record, ...meta }); - result = result ? mergeRecord(result, rebased) : rebased; + result = result ? mergeNormalizedRecords(result, rebased) : rebased; } return result; } @@ -458,13 +541,15 @@ const QBCore = (() => { const legacySources = new Map(); for (const entry of entries) { const record = entry.record; + entry.signature = contentSignature(record); + entry.bank = bankIdentity(record); if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence; const explicit = record.source.batchId || record.answerSource?.batchId; - if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`; + if (explicit) entry.batch = `${entry.bank}:${explicit}`; else if (entry.occurrence == null) { const source = listSource(record); if (!source) continue; - const key = JSON.stringify([bankIdentity(record), source.url]); + const key = JSON.stringify([entry.bank, source.url]); if (!legacySources.has(key)) legacySources.set(key, []); legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) }); } @@ -484,35 +569,47 @@ const QBCore = (() => { const counters = new Map(); for (const entry of entries) { if (!entry.batch || entry.occurrence != null) continue; - const key = JSON.stringify([entry.batch, contentSignature(entry.record)]); + const key = JSON.stringify([entry.batch, entry.signature]); entry.occurrence = counters.get(key) || 0; counters.set(key, entry.occurrence + 1); } const groups = new Map(); const byContent = new Map(); + const byContentAlias = new Map(); + const indexAliases = (key, entry) => { + if (!byContentAlias.has(entry.content)) byContentAlias.set(entry.content, new Map()); + const aliases = byContentAlias.get(entry.content); + for (const id of [entry.record.questionId, ...(entry.record.questionIdAliases || [])]) { + if (!id) continue; + if (!aliases.has(id)) aliases.set(id, new Set()); + aliases.get(id).add(key); + } + }; const add = (key, entry) => { if (!groups.has(key)) groups.set(key, []); groups.get(key).push(entry); }; for (const entry of entries) { - const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]); + const content = JSON.stringify([entry.bank, entry.signature]); entry.content = content; if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue; const key = JSON.stringify([content, entry.occurrence]); add(key, entry); if (!byContent.has(content)) byContent.set(content, new Set()); byContent.get(content).add(key); + indexAliases(key, entry); } for (const entry of entries) { if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue; - const matches = [...(byContent.get(entry.content) || [])]; + const matches = byContent.get(entry.content); let key; - const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId))); - if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0]; - else if (matches.length === 1) key = matches[0]; - else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; + const matchingAlias = byContentAlias.get(entry.content)?.get(entry.record.questionId); + if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value; + else if (matches?.size === 1) key = matches.values().next().value; + else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue; else key = `unresolved:${entry.record.key}`; add(key, entry); + if (matches?.has(key)) indexAliases(key, entry); } const records = []; let duplicateGroups = 0; diff --git a/tests/adapter.test.cjs b/tests/adapter.test.cjs index 8f5e418..5c153eb 100644 --- a/tests/adapter.test.cjs +++ b/tests/adapter.test.cjs @@ -275,10 +275,14 @@ function bankPage(fetchImpl) { const player = { get textContent() { return state.player; } }; const exam = { get textContent() { return state.exam; } }; 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 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 })); const form = { @@ -650,3 +654,87 @@ test('DOM question number distinguishes legitimate identical items in the latest assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步'); 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(); +}); diff --git a/tests/autofill.test.cjs b/tests/autofill.test.cjs index 78a1ca7..a67526c 100644 --- a/tests/autofill.test.cjs +++ b/tests/autofill.test.cjs @@ -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); 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'); +}); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 0c5c32b..f9a399d 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -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.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)); +}); diff --git a/tests/performance-browser.test.cjs b/tests/performance-browser.test.cjs new file mode 100644 index 0000000..81555ce --- /dev/null +++ b/tests/performance-browser.test.cjs @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); } +function fixture(record) { + const title = html(record.bankName); + const inputs = record.options.map(option => ``).join(''); + return `
本地性能回归:1884 道题库记录,面板保持隐藏。
页面计时器:0