fix: reduce userscript page blocking and stale option clicks
This commit is contained in:
@@ -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` 更新到油猴。
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+351
-73
@@ -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;
|
||||
if (!mutations.some(relevantMutation) || timer !== null) return;
|
||||
timer = win.setTimeout(() => {
|
||||
timer = null;
|
||||
syncBank();
|
||||
if (copyEnabled) ensureCopyStyle();
|
||||
if (!active) return;
|
||||
win.clearTimeout(timer);
|
||||
timer = win.setTimeout(() => {
|
||||
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 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, repairAnswerConflicts(records).records));
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, records));
|
||||
if (compacted.removed > 0) ensureCleanupBackup(stored);
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
storage.set(RECORDS_KEY, 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
|
||||
};
|
||||
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();
|
||||
})();
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+70
-13
@@ -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;
|
||||
if (!mutations.some(relevantMutation) || timer !== null) return;
|
||||
timer = win.setTimeout(() => {
|
||||
timer = null;
|
||||
syncBank();
|
||||
if (copyEnabled) ensureCopyStyle();
|
||||
if (!active) return;
|
||||
win.clearTimeout(timer);
|
||||
timer = win.setTimeout(() => {
|
||||
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('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
||||
}
|
||||
|
||||
+84
-20
@@ -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 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, repairAnswerConflicts(records).records));
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, records));
|
||||
if (compacted.removed > 0) ensureCleanupBackup(stored);
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
storage.set(RECORDS_KEY, 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
|
||||
};
|
||||
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();
|
||||
})();
|
||||
|
||||
+69
-9
@@ -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();
|
||||
|
||||
+127
-30
@@ -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;
|
||||
|
||||
+90
-2
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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, '>').replace(/"/g, '"'); }
|
||||
function fixture(record) {
|
||||
const title = html(record.bankName);
|
||||
const inputs = record.options.map(option => `<label class="yxtulcdsdk-ques-radio"><span class="yxtf-radio__input"><input type="radio" name="probe-answer" value="${html(option.id)}"></span><span class="yxtf-radio__label"><span data-rich-text="1">${html(option.text)}</span></span></label>`).join('');
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title><style>
|
||||
body{font:16px/1.7 system-ui;margin:28px;background:#f2f5f8;color:#304455}.task-content-active{padding:10px;border:1px solid #cbd9e2;background:white}.yxtulcdsdk-exam-container{padding:24px;background:white;margin-top:20px;max-width:800px}.yxtulcdsdk-ques-radio{display:block;padding:10px;border:1px solid #dce4ec;margin:12px 0}#probe-clock{font-variant-numeric:tabular-nums}#probe-cards{display:flex;gap:5px}#probe-cards i{font-style:normal;padding:3px 8px;background:#edf1f5}.current{color:#008d78}
|
||||
</style></head><body><div class="task-content-active" data-task-id="perf-task"><span class="group-title">${title}</span></div><header class="o2oPlayFrame-right"><div class="title"><span class="yxtf-tooltip">${title}</span></div></header>
|
||||
<p>本地性能回归:1884 道题库记录,面板保持隐藏。</p><p>页面计时器:<span id="probe-clock">0</span></p><div id="probe-cards">${Array.from({ length: 20 }, (_, i) => `<i>${i + 1}</i>`).join('')}</div>
|
||||
<div class="yxtulcdsdk-exam-container" task-id="perf-task"><div class="yxtulcdsdk-exam-header"><span class="yxtf-tooltip">${title}</span></div><div class="yxtulcdsdk-pc-marking-main"><div class="standard-size-14"><span>判断题</span></div><div class="yxtulcdsdk-form"><div class="yxtulcdsdk-form__sub">${(record.questionIndex || 0) + 1}.</div><div class="yxtulcdsdk-form__content"><div data-rich-text="1">${html(record.stem)}</div><div class="yxtulcdsdk-ques-radio-group">${inputs}</div></div></div></div></div></body></html>`;
|
||||
}
|
||||
|
||||
function installProbe(seed) {
|
||||
const metrics = { calls: {}, times: {}, recordsWrites: 0, storageWrites: 0, storageReads: 0, questionScans: 0, hiddenResultRebuilds: 0, hiddenBankRebuilds: 0, hiddenTextWrites: 0 };
|
||||
const values = new Map([['huayi-qb.records.v1', seed], ['huayi-qb.config.v1', { mode: 'hidden', keepLauncher: false, collecting: true, copyEnabled: true, tab: 'search' }]]);
|
||||
window.__perf = { metrics, started: performance.now(), rootReadyAt: null, errors: [] };
|
||||
window.unsafeWindow = window;
|
||||
window.GM_getValue = (key, fallback) => { metrics.storageReads++; return structuredClone(values.has(key) ? values.get(key) : fallback); };
|
||||
window.GM_setValue = (key, value) => { metrics.storageWrites++; if (key === 'huayi-qb.records.v1') metrics.recordsWrites++; values.set(key, structuredClone(value)); };
|
||||
window.GM_registerMenuCommand = () => 1;
|
||||
window.GM_addValueChangeListener = () => 1;
|
||||
window.GM_removeValueChangeListener = () => {};
|
||||
const questionQuery = Document.prototype.querySelectorAll;
|
||||
Document.prototype.querySelectorAll = function (selector) {
|
||||
if (String(selector).includes('.yxtulcdsdk-pc-marking-main') && String(selector).includes('.yxtulcdsdk-form')) metrics.questionScans++;
|
||||
return questionQuery.call(this, selector);
|
||||
};
|
||||
const replace = Element.prototype.replaceChildren;
|
||||
Element.prototype.replaceChildren = function (...items) {
|
||||
const root = this.getRootNode();
|
||||
if (root.host?.id === 'huayi-qb-root' && root.querySelector('#qb-panel')?.hidden) {
|
||||
if (this.id === 'qb-results') metrics.hiddenResultRebuilds++;
|
||||
if (this.id === 'qb-bank-list') metrics.hiddenBankRebuilds++;
|
||||
}
|
||||
return replace.apply(this, items);
|
||||
};
|
||||
const content = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
|
||||
Object.defineProperty(Node.prototype, 'textContent', { ...content, set(value) {
|
||||
const root = this.getRootNode();
|
||||
if (root.host?.id === 'huayi-qb-root' && root.querySelector('#qb-panel')?.hidden) metrics.hiddenTextWrites++;
|
||||
content.set.call(this, value);
|
||||
} });
|
||||
const appearance = new MutationObserver(() => {
|
||||
if (window.__perf.rootReadyAt == null && document.getElementById('huayi-qb-root')) window.__perf.rootReadyAt = performance.now();
|
||||
});
|
||||
appearance.observe(document, { childList: true, subtree: true });
|
||||
window.__perf.cloneMetrics = () => JSON.parse(JSON.stringify(metrics));
|
||||
window.__perf.recordCount = () => values.get('huayi-qb.records.v1').length;
|
||||
}
|
||||
|
||||
const CORE_PROBE = `
|
||||
for (const name of Object.keys(QBCore)) {
|
||||
if (typeof QBCore[name] !== 'function') continue;
|
||||
const original = QBCore[name];
|
||||
QBCore[name] = function (...args) {
|
||||
const start = performance.now(), m = window.__perf.metrics;
|
||||
m.calls[name] = (m.calls[name] || 0) + 1;
|
||||
try { return original.apply(this, args); }
|
||||
finally { m.times[name] = (m.times[name] || 0) + performance.now() - start; }
|
||||
};
|
||||
}
|
||||
`;
|
||||
function sourceFor(committed) {
|
||||
return `(function(){'use strict';\n${SOURCES.map((file, index) => {
|
||||
const code = committed ? execFileSync('git', ['show', `${BASELINE_REF}:${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }) : fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
return code + (index === 0 ? '\n' + CORE_PROBE : '');
|
||||
}).join('\n')}\n})();`;
|
||||
}
|
||||
function delta(end, start) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(end)) {
|
||||
if (end[key] && typeof end[key] === 'object') {
|
||||
result[key] = {};
|
||||
for (const name of new Set([...Object.keys(end[key]), ...Object.keys(start[key] || {})])) {
|
||||
const value = (end[key][name] || 0) - (start[key]?.[name] || 0);
|
||||
if (value) result[key][name] = key === 'times' ? +value.toFixed(2) : value;
|
||||
}
|
||||
} else result[key] = end[key] - (start[key] || 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test('1884题真实浏览器性能:启动、无关DOM变化和一次语义换题', { timeout: 180000, skip: !fs.existsSync(DATASET) }, async t => {
|
||||
const payload = JSON.parse(fs.readFileSync(DATASET, 'utf8').replace(/^\uFEFF/, ''));
|
||||
const records = Array.isArray(payload) ? payload : payload.records;
|
||||
assert.equal(records.length, 1884, '性能样本应为用户当前合并的1884条题库');
|
||||
const initial = records.find(record => record.type === 'judge' && !record.stem.includes('[图片:'));
|
||||
assert.ok(initial);
|
||||
fs.mkdirSync(OUTPUT, { recursive: true });
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/favicon.ico') { response.writeHead(204); response.end(); return; }
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); response.end(fixture(initial));
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const { chromium } = loadPlaywright();
|
||||
const browser = await chromium.launch(browserOptions());
|
||||
const mode = process.env.QB_PERF_MODE || 'compare';
|
||||
const results = {};
|
||||
try {
|
||||
for (const [name, committed] of [['before', true], ['after', false]]) {
|
||||
if ((mode === 'baseline' && !committed) || (mode === 'after' && committed)) continue;
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' }));
|
||||
await context.addInitScript({ content: `(${installProbe.toString()})(${JSON.stringify(records)});\n${sourceFor(committed)}` });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
await page.goto(`${origin}/o2o/#/playinfo?taskId=perf-task`, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||
await page.waitForTimeout(1000);
|
||||
const startup = await page.evaluate(() => ({ ...window.__perf.cloneMetrics(), readyMs: +(window.__perf.rootReadyAt - window.__perf.started).toFixed(2), storedCount: window.__perf.recordCount() }));
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
const beforeNoise = await page.evaluate(() => window.__perf.cloneMetrics());
|
||||
await page.evaluate(() => {
|
||||
let tick = 0;
|
||||
window.__noiseTimer = setInterval(() => {
|
||||
document.getElementById('probe-clock').textContent = String(++tick);
|
||||
document.querySelectorAll('#probe-cards i').forEach((card, index) => card.classList.toggle('current', index === tick % 20));
|
||||
}, 50);
|
||||
});
|
||||
await page.waitForTimeout(1800);
|
||||
await page.evaluate(() => clearInterval(window.__noiseTimer));
|
||||
await page.waitForTimeout(500);
|
||||
const afterNoise = await page.evaluate(() => window.__perf.cloneMetrics());
|
||||
const noise = delta(afterNoise, beforeNoise);
|
||||
const beforeChange = await page.evaluate(() => window.__perf.cloneMetrics());
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.yxtulcdsdk-form__sub').textContent = '2.';
|
||||
document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = '本地性能探针:新增一题用于检测合批持久化。';
|
||||
});
|
||||
await page.waitForTimeout(1700);
|
||||
const afterChange = await page.evaluate(() => window.__perf.cloneMetrics());
|
||||
const change = delta(afterChange, beforeChange);
|
||||
results[name] = { startup, unrelatedChanges: noise, semanticChange: change, finalStoredCount: await page.evaluate(() => window.__perf.recordCount()), errors };
|
||||
await page.screenshot({ path: path.join(OUTPUT, `${name}-hidden.png`) });
|
||||
assert.deepEqual(errors, [], '本地性能夹具不能出现脚本异常');
|
||||
if (!committed) {
|
||||
assert.equal(noise.calls.upsert || 0, 0, '无关计时器/答题卡class变化不能合并全题库');
|
||||
assert.equal(noise.calls.compactRecords || 0, 0, '无关变化不能重新整理全题库');
|
||||
assert.equal(noise.recordsWrites, 0, '无关变化不能写回题库');
|
||||
assert.equal(noise.hiddenResultRebuilds, 0, '隐藏时不能重建检索结果');
|
||||
assert.equal(change.hiddenResultRebuilds, 0, '语义换题也不应重建隐藏检索结果');
|
||||
assert.equal(change.recordsWrites, 1, '一次新题语义变化应合批为一次题库写入');
|
||||
assert.ok((change.calls.upsert || 0) <= 1, '一次变化不得在保存时再次全量upsert');
|
||||
}
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
fs.writeFileSync(path.join(OUTPUT, `performance-${mode}.json`), JSON.stringify(results, null, 2));
|
||||
}
|
||||
t.diagnostic(`本地真实1884题;Browser plugin not available,使用已有Playwright/Chromium;结果 ${path.join(OUTPUT, `performance-${mode}.json`)}`);
|
||||
t.diagnostic(JSON.stringify(results));
|
||||
});
|
||||
Reference in New Issue
Block a user