fix: reduce userscript page blocking and stale option clicks
This commit is contained in:
+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();
|
||||
|
||||
Reference in New Issue
Block a user