feat: support desktop exams with strict answer text verification

This commit is contained in:
薇星幻海
2026-10-10 06:39:36 +08:00
parent 3969c233b3
commit 3d7a1ffed0
16 changed files with 1137 additions and 187 deletions
+103 -29
View File
@@ -1,4 +1,4 @@
/* YianAnswerRunner: 只在已识别的练习页面串行点击真实选项。 */
/* YianAnswerRunner: 只在已明确识别的练习或考试页面串行点击真实选项。 */
(function (root) {
'use strict';
@@ -7,10 +7,11 @@
const WAIT_LIMIT = 1200;
const CLICK_GAP = 150;
const COUNT_KEYS = ['selected', 'pending', 'missing', 'conflict', 'locked', 'failed', 'unsupported'];
const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision'];
const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision', 'exam', 'mode'];
function normalize(value) {
return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim();
// 仅中和排版空白和不可见分隔符;不折叠大小写、数字、单位或标点。
return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
}
function questionSignature(question) {
@@ -24,8 +25,13 @@
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(questionSignature)]);
}
function isLocked(box, selectors) {
function isLocked(box, selectors, context) {
if (box.querySelector('.pc_examtest_content_form_disabled')) return true;
if (box.closest('[disabled],[aria-disabled="true"]')) return true;
if (Array.from(box.querySelectorAll(selectors.option)).some(function (node) { return Boolean(node.closest('[disabled],[aria-disabled="true"]')); })) return true;
// 考试正常作答时没有查看答案控件,单选/判断也允许改选。
// 已提交、到时或出现提交对话框由 getContext 将 exam 置 false,每一步都会复核。
if (context && context.exam === true) return false;
const component = box.closest('[app-exam-question],app-exam-question');
const flags = [box, component].filter(Boolean);
if (flags.some(function (element) {
@@ -39,12 +45,12 @@
});
}
function optionState(box, selectors) {
function optionState(box, selectors, context) {
const nodes = Array.from(box.querySelectorAll(selectors.option));
return {
nodes: nodes,
selected: nodes.map(function (node) { return node.classList.contains(CHECKED); }),
locked: isLocked(box, selectors)
locked: isLocked(box, selectors, context)
};
}
@@ -76,7 +82,14 @@
records.forEach(function (record) {
if (COUNT_KEYS.includes(record.status)) counts[record.status]++;
if (record.pending) counts.pending++;
details.push({ number: record.number, status: record.status, reason: record.reason || '' });
details.push({
number: record.number, question: record.question, course: record.course,
pageCourse: record.pageCourse, sourceCourses: record.sourceCourses.slice(),
status: record.status, reason: record.reason || '',
correctTexts: record.correctTexts.slice(), correctLabels: record.correctLabels.slice(),
currentLabels: record.currentLabels.slice(), currentTexts: record.currentTexts.slice(),
currentAnswerText: record.currentAnswerText
});
});
counts.details = details;
return counts;
@@ -89,7 +102,19 @@
}
function setRecord(item, status, reason, pending) {
records.set(item.key, { number: item.question.number, status: status, reason: reason || '', pending: Boolean(pending) });
const nodes = Array.from(item.box.querySelectorAll(selectors.option));
const selected = item.question.options.filter(function (_, index) { return nodes[index] && nodes[index].classList.contains(CHECKED); });
const sourceCourses = (item.sourceCourses || []).slice();
records.set(item.key, {
index: item.index, number: item.question.number, question: item.question.question,
course: sourceCourses.length ? sourceCourses.join('、') : item.course,
pageCourse: item.course, sourceCourses: sourceCourses,
status: status, reason: reason || '', pending: Boolean(pending),
correctTexts: (item.correctTexts || []).slice(), correctLabels: (item.correctLabels || []).slice(),
currentLabels: selected.map(function (option) { return option.label; }),
currentTexts: selected.map(function (option) { return option.text; }),
currentAnswerText: selected.map(function (option) { return option.label + '. ' + option.text; }).join(';')
});
notifyProgress();
}
@@ -104,11 +129,11 @@
}
function initialReason(context) {
if (!context || context.ready !== true) return '等待练习页面就绪。';
if (!context || context.ready !== true) return '等待答题页面就绪。';
if (context.local === true || root.location && root.location.protocol === 'file:') return '保存的静态网页可搜索和导出,不能记录自动选择。';
if (context.practice !== true) return '当前页面未识别为练习,自动选择已停止。';
if (context.practice !== true && context.exam !== true) return '当前页面未识别为可作答的练习或考试,自动选择已停止。';
if (!context.role) return '请先选择主操、班长或副操。';
if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '练习内容已切换,等待新页面。';
if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '答题内容已切换,等待新页面。';
return '';
}
@@ -165,7 +190,7 @@
const items = boxes.map(function (box, index) {
const question = bank.questions[index];
const signature = questionSignature(question);
return { box: box, index: index, question: question, signature: signature, key: index + '\u0000' + signature };
return { box: box, index: index, question: question, course: context.course || '', signature: signature, key: index + '\u0000' + signature };
}).filter(function (item) { return context.root === item.box || context.root.contains(item.box); });
return { items: items, signature: bankSignature(bank) };
}
@@ -178,10 +203,10 @@
if (boxes[item.index] !== item.box) return { reason: '题目顺序已变化,已停止本轮自动选择。', cancelled: true };
const bank = core.extract(doc);
// 父面板的 MutationObserver 尚未运行时,也直接阻断标题或其他题目的变化。
if (bankSignature(bank) !== run.bankSignature) return { reason: '练习题目或课程已更新,已停止本轮自动选择。', cancelled: true };
if (bankSignature(bank) !== run.bankSignature) return { reason: '题目或课程已更新,已停止本轮自动选择。', cancelled: true };
const current = bank.questions[item.index];
if (!current || questionSignature(current) !== item.signature) return { reason: '题干或选项已变化,已停止本轮自动选择。', cancelled: true };
const state = optionState(item.box, selectors);
const state = optionState(item.box, selectors, run.context);
if (state.nodes.length !== current.options.length) return { reason: '选项尚未完整加载。' };
return { question: current, state: state };
}
@@ -234,12 +259,18 @@
const actualLabels = question.options.map(function (option) { return normalize(option.label); });
if (new Set(actualLabels).size !== actualLabels.length || labels.some(function (label) { return !actualLabels.includes(label); })) return { reason: '答案不能唯一对应网页选项,已跳过。' };
if (question.type !== '多选' && labels.length !== 1) return { reason: '单选或判断题匹配到多个答案,已跳过。' };
if (Array.isArray(matched.correctTexts) && matched.correctTexts.length) {
const expectedTexts = matched.correctTexts.map(normalize).sort();
const actualTexts = question.options.filter(function (option) { return labels.includes(normalize(option.label)); }).map(function (option) { return normalize(option.text); }).sort();
if (JSON.stringify(expectedTexts) !== JSON.stringify(actualTexts)) return { reason: '匹配答案文字与网页选项不一致,已跳过。' };
}
return { wanted: actualLabels.map(function (label) { return labels.includes(label); }) };
if (!Array.isArray(matched.correctTexts) || matched.correctTexts.length !== labels.length) return { reason: '题库缺少完整正确答案文字,不能仅凭答案字母自动选择。' };
const expectedTexts = matched.correctTexts.map(normalize);
if (expectedTexts.some(function (text) { return !text; }) || new Set(expectedTexts).size !== expectedTexts.length) return { reason: '正确答案文字为空或重复,无法唯一核验。' };
const selected = question.options.filter(function (option) { return labels.includes(normalize(option.label)); });
const actualTexts = selected.map(function (option) { return normalize(option.text); });
if (JSON.stringify(expectedTexts.slice().sort()) !== JSON.stringify(actualTexts.slice().sort())) return { reason: '题库正确答案全文与网页选项不一致,已跳过。' };
return {
wanted: actualLabels.map(function (label) { return labels.includes(label); }),
correctLabels: selected.map(function (option) { return option.label; }),
// 原始题库文本按当页选项顺序存入证据,方便与 currentLabels 逐项核对。
correctTexts: actualTexts.map(function (text) { return matched.correctTexts[expectedTexts.indexOf(text)]; })
};
}
async function processQuestion(run, item) {
@@ -247,15 +278,19 @@
let current = readCurrent(run, item);
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
if (records.has(item.key)) return true;
if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的练习组件,已跳过。'); return true; }
const previous = records.get(item.key);
if (previous && previous.status !== 'selected' && !previous.manualInvalid) return true;
if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的答题组件,已跳过。'); return true; }
if (!['单选', '多选', '判断'].includes(current.question.type)) { setRecord(item, 'unsupported', '暂不支持该题型自动选择。'); return true; }
if (current.state.locked) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
if (current.state.locked && !previous) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
const matched = await options.match(current.question, options.getContext());
current = readCurrent(run, item);
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
if (current.state.locked) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
item.correctTexts = matched && Array.isArray(matched.correctTexts) ? matched.correctTexts.slice() : [];
item.correctLabels = matched && Array.isArray(matched.correctLabels) ? matched.correctLabels.slice() : [];
item.sourceCourses = Array.from(new Set((matched && Array.isArray(matched.records) ? matched.records : []).map(function (record) { return normalize(record.course); }).filter(Boolean)));
if (current.state.locked && !previous) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
if (!matched || matched.status !== 'matched') {
const status = matched && ['missing', 'conflict', 'unsupported'].includes(matched.status) ? matched.status : 'unsupported';
setRecord(item, status, matched && matched.reason || '未获得可自动选择的唯一答案。');
@@ -263,6 +298,13 @@
}
const target = resolveTargets(current.question, matched);
if (target.reason) { setRecord(item, 'unsupported', target.reason); return true; }
item.correctTexts = target.correctTexts;
item.correctLabels = target.correctLabels;
if (current.state.locked) {
if (sameSelection(current.state.selected, target.wanted)) setRecord(item, 'selected', '已重新核验原作答与题库答案一致。');
else setRecord(item, 'locked', '该题已锁定且当前作答不同于题库答案,保留原作答。');
return true;
}
let changed = false;
if (current.question.type === '多选') {
// 每次按当前集合核对;先去掉错误选项,随后补选目标选项,不重复 toggle。
@@ -289,7 +331,7 @@
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
if (!sameSelection(current.state.selected, target.wanted)) { setRecord(item, 'failed', '网页当前选中集合与答案不一致,已停止修改该题。'); return true; }
const pending = current.question.type === '多选' && !current.state.locked;
const pending = run.context.exam !== true && current.question.type === '多选' && !current.state.locked;
setRecord(item, 'selected', pending ? '已选择,待网页确认。' : changed ? '已选择匹配答案。' : '当前已选中匹配答案。', pending);
return true;
}
@@ -334,7 +376,7 @@
const currentKeys = new Set(items.map(function (item) { return item.key; }));
records.forEach(function (_, key) { if (!currentKeys.has(key)) records.delete(key); });
notifyProgress();
if (!items.length) { notifyState('idle', '当前没有可处理的练习题目。'); return Promise.resolve(progress()); }
if (!items.length) { notifyState('idle', '当前没有可处理的答题题目。'); return Promise.resolve(progress()); }
const run = { id: ++generation, context: captured, bankSignature: extracted.signature, cancelled: false, waiters: new Set(), currentBox: null, promise: null };
active = run;
// 先写入 promise,再通知或执行,使同步 UI 回调中的 start 也不会并发运行。
@@ -347,11 +389,43 @@
}
function manualClick(event) {
if (!event.isTrusted || !active || active.cancelled) return;
if (!event.isTrusted || destroyed) return;
const target = event.target && event.target.nodeType === 1 ? event.target : event.target && event.target.parentElement;
if (!target) return;
const run = active && !active.cancelled ? active : null;
let currentScope = run && run.context;
if (!currentScope && scope && records.size) {
const context = options.getContext();
if (!initialReason(context) && sameContext(scope, captureContext(context))) currentScope = scope;
}
if (!currentScope) return;
const submit = target.closest('.pc_examtest_commit,button[type="submit"],input[type="submit"]');
if (submit && currentScope.root.contains(submit)) {
pause('检测到手动交卷,已暂停自动选择。');
return;
}
const box = target.closest(selectors.question);
if (box && (active.context.root === box || active.context.root.contains(box))) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
if (!box || currentScope.root !== box && !currentScope.root.contains(box)) return;
let invalidated = false;
if (target.closest(selectors.option)) {
const index = Array.from(doc.querySelectorAll(selectors.question)).indexOf(box);
records.forEach(function (record) {
if (record.index !== index || record.status !== 'selected') return;
// 捕获阶段网页 onclick 还未执行,不能把此前的 checked 集合当成改选后的证据。
record.status = 'unsupported';
record.reason = '手动作答后需重新核对';
record.pending = false;
record.manualInvalid = true;
record.correctTexts = [];
record.correctLabels = [];
record.currentLabels = [];
record.currentTexts = [];
record.currentAnswerText = '';
invalidated = true;
});
}
if (invalidated) notifyProgress();
if (run || invalidated) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
}
doc.addEventListener('click', manualClick, true);
@@ -375,5 +449,5 @@
return { start: start, stop: stop, reset: reset, destroy: destroy, get running() { return Boolean(active && !active.cancelled); } };
}
root.YianAnswerRunner = { version: '1.0.0', create: create };
root.YianAnswerRunner = { version: '1.1.0', create: create };
})(typeof window !== 'undefined' ? window : globalThis);