Files
yianxue-assistant/answer-runner.js
T
2026-10-10 03:39:43 +08:00

380 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* YianAnswerRunner: 只在已识别的练习页面串行点击真实选项。 */
(function (root) {
'use strict';
const CHECKED = 'examtest_content_list_des_checked';
const WAIT_STEP = 100;
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'];
function normalize(value) {
return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim();
}
function questionSignature(question) {
// 答案、checked class 和展开解析不属于题目身份,判分后它们会正常变化。
return JSON.stringify([question.type, question.question, (question.questionImages || []), question.options.map(function (option) {
return [option.label, option.text, option.images || []];
})]);
}
function bankSignature(bank) {
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(questionSignature)]);
}
function isLocked(box, selectors) {
if (box.querySelector('.pc_examtest_content_form_disabled')) return true;
const component = box.closest('[app-exam-question],app-exam-question');
const flags = [box, component].filter(Boolean);
if (flags.some(function (element) {
return element._showanswer === true || element.getAttribute('_showanswer') === 'true' || element.getAttribute('data-showanswer') === 'true';
})) return true;
if (!box.querySelector('.pc_examtest_content_question_showanswer')) return true;
// 原页面已判分时 option 的直接父容器由 child1 变为 child2;隐藏参考答案不表示锁定。
return Array.from(box.querySelectorAll(selectors.option)).some(function (node) {
const parent = node.parentElement;
return parent && parent.matches('.pc_examtest_content_list_child2,.pc_examtest_content_list_parent_child2,.parent_child2');
});
}
function optionState(box, selectors) {
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)
};
}
function sameSelection(actual, wanted) {
return actual.length === wanted.length && actual.every(function (selected, index) { return selected === wanted[index]; });
}
function create(options) {
options = options || {};
if (typeof options.getContext !== 'function' || typeof options.match !== 'function') throw new TypeError('自动选择需要 getContext 和 match。');
const doc = root.document;
const core = root.QuestionBankCore;
if (!doc || !core || !core.selectors || typeof core.extract !== 'function') throw new Error('自动选择需要 QuestionBankCore 和当前网页。');
const selectors = core.selectors;
let active = null;
let scope = null;
let records = new Map();
let total = 0;
let destroyed = false;
let generation = 0;
function notifyState(state, reason) {
if (typeof options.onState === 'function') options.onState(state, reason || '');
}
function progress() {
const counts = { selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: total };
const details = [];
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 || '' });
});
counts.details = details;
return counts;
}
function notifyProgress() {
const value = progress();
if (typeof options.onProgress === 'function') options.onProgress(value);
return value;
}
function setRecord(item, status, reason, pending) {
records.set(item.key, { number: item.question.number, status: status, reason: reason || '', pending: Boolean(pending) });
notifyProgress();
}
function captureContext(context) {
const captured = { root: context.root, pageURL: String(root.location && root.location.href || '') };
CONTEXT_KEYS.forEach(function (key) { captured[key] = context[key]; });
return captured;
}
function sameContext(left, right) {
return left && right && left.root === right.root && left.pageURL === right.pageURL && CONTEXT_KEYS.every(function (key) { return left[key] === right[key]; });
}
function initialReason(context) {
if (!context || context.ready !== true) return '等待练习页面就绪。';
if (context.local === true || root.location && root.location.protocol === 'file:') return '保存的静态网页可搜索和导出,不能记录自动选择。';
if (context.practice !== true) return '当前页面未识别为练习,自动选择已停止。';
if (!context.role) return '请先选择主操、班长或副操。';
if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '练习内容已切换,等待新页面。';
return '';
}
function cancel(run) {
if (!run || run.cancelled) return;
run.cancelled = true;
run.waiters.forEach(function (finish) { finish(false); });
run.waiters.clear();
}
function endActive(state, reason) {
const old = active;
generation++;
active = null;
cancel(old);
if (!destroyed) notifyState(state, reason);
return progress();
}
function pause(reason) { return endActive('paused', reason || '已暂停自动选择。'); }
function stop(reason) { return endActive('stopped', reason || '自动选择已关闭。'); }
function wait(run, milliseconds) {
if (run.cancelled || active !== run) return Promise.resolve(false);
return new Promise(function (resolve) {
let timer;
let settled = false;
const finish = function (ok) {
if (settled) return;
settled = true;
root.clearTimeout(timer);
run.waiters.delete(finish);
resolve(ok && !run.cancelled && active === run);
};
run.waiters.add(finish);
timer = root.setTimeout(function () { finish(true); }, milliseconds);
});
}
function contextReason(run) {
if (destroyed || run.cancelled || active !== run || run.id !== generation) return '自动选择已取消。';
const context = options.getContext();
const reason = initialReason(context);
if (reason) return reason;
if (!sameContext(run.context, captureContext(context))) return '页面、岗位或题库已切换,已停止本轮自动选择。';
return '';
}
function extractItems(context) {
const boxes = Array.from(doc.querySelectorAll(selectors.question));
const bank = core.extract(doc);
if (bank.questions.length !== boxes.length) throw new Error('题目与选项尚未同步完成。');
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 };
}).filter(function (item) { return context.root === item.box || context.root.contains(item.box); });
return { items: items, signature: bankSignature(bank) };
}
function readCurrent(run, item) {
const reason = contextReason(run);
if (reason) return { reason: reason, cancelled: true };
if (!item.box.isConnected || !run.context.root.contains(item.box) && run.context.root !== item.box) return { reason: '题目节点已更新,已停止本轮自动选择。', cancelled: true };
const boxes = Array.from(doc.querySelectorAll(selectors.question));
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 };
const current = bank.questions[item.index];
if (!current || questionSignature(current) !== item.signature) return { reason: '题干或选项已变化,已停止本轮自动选择。', cancelled: true };
const state = optionState(item.box, selectors);
if (state.nodes.length !== current.options.length) return { reason: '选项尚未完整加载。' };
return { question: current, state: state };
}
function clickable(node, item) {
if (!node || !node.isConnected || !item.box.contains(node) || node.ownerDocument !== doc) return false;
if (node.closest('[hidden],[inert]') || node.getAttribute('aria-disabled') === 'true' || node.hasAttribute('disabled')) return false;
const style = root.getComputedStyle(node);
return node.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.visibility !== 'collapse' && style.pointerEvents !== 'none';
}
function abortForChange(run, reason) {
if (active === run && !run.cancelled) stop(reason);
return false;
}
async function clickOption(run, item, optionIndex, wantedChecked) {
const before = readCurrent(run, item);
if (before.cancelled) return { cancelled: true, reason: before.reason };
if (before.reason) return { reason: before.reason };
if (before.state.locked) return { locked: true, reason: '该题已判分或锁定,不能修改。' };
if (before.state.selected[optionIndex] === wantedChecked) return { ok: true };
const node = before.state.nodes[optionIndex];
if (!clickable(node, item)) return { reason: '选项不可点击,已跳过。' };
// 最后一次即时检查,避免异步匹配期间已经换路由或岗位。
const reason = contextReason(run);
if (reason) return { cancelled: true, reason: reason };
node.click();
for (let elapsed = 0; elapsed <= WAIT_LIMIT; elapsed += WAIT_STEP) {
const current = readCurrent(run, item);
if (current.cancelled) return { cancelled: true, reason: current.reason };
if (current.reason) return { reason: current.reason };
if (current.state.selected[optionIndex] === wantedChecked) {
if (!await wait(run, CLICK_GAP)) return { cancelled: true, reason: '自动选择已取消。' };
const settled = readCurrent(run, item);
if (settled.cancelled) return { cancelled: true, reason: settled.reason };
if (settled.reason) return { reason: settled.reason };
if (settled.state.selected[optionIndex] !== wantedChecked) return { reason: '网页未保留选项状态,已停止修改该题。' };
return { ok: true };
}
if (current.state.locked) return { locked: true, reason: '选项点击后题目锁定,但未确认目标选项,已停止修改该题。' };
if (elapsed < WAIT_LIMIT && !await wait(run, WAIT_STEP)) return { cancelled: true, reason: '自动选择已取消。' };
}
return { reason: '网页在限定时间内未更新选项状态,已跳过。' };
}
function resolveTargets(question, matched) {
const labels = Array.isArray(matched.correctLabels) ? matched.correctLabels.map(normalize) : String(matched.correctLabels || '').split(/[\s,,、;;]+/).filter(Boolean);
if (!labels.length || new Set(labels).size !== labels.length) return { reason: '正确选项为空或重复,已跳过。' };
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); }) };
}
async function processQuestion(run, item) {
run.currentBox = item.box;
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; }
if (!['单选', '多选', '判断'].includes(current.question.type)) { setRecord(item, 'unsupported', '暂不支持该题型自动选择。'); return true; }
if (current.state.locked) { 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; }
if (!matched || matched.status !== 'matched') {
const status = matched && ['missing', 'conflict', 'unsupported'].includes(matched.status) ? matched.status : 'unsupported';
setRecord(item, status, matched && matched.reason || '未获得可自动选择的唯一答案。');
return true;
}
const target = resolveTargets(current.question, matched);
if (target.reason) { setRecord(item, 'unsupported', target.reason); return true; }
let changed = false;
if (current.question.type === '多选') {
// 每次按当前集合核对;先去掉错误选项,随后补选目标选项,不重复 toggle。
const planned = [];
target.wanted.forEach(function (wanted, index) { if (!wanted && current.state.selected[index]) planned.push({ index: index, checked: false }); });
target.wanted.forEach(function (wanted, index) { if (wanted && !current.state.selected[index]) planned.push({ index: index, checked: true }); });
for (const step of planned) {
const result = await clickOption(run, item, step.index, step.checked);
if (result.cancelled) return abortForChange(run, result.reason);
if (!result.ok) { setRecord(item, result.locked && !changed ? 'locked' : 'failed', result.reason); return true; }
changed = true;
}
} else {
const wantedIndex = target.wanted.indexOf(true);
if (!sameSelection(current.state.selected, target.wanted)) {
if (current.state.selected[wantedIndex]) { setRecord(item, 'failed', '单选题有多个选中状态,已跳过。'); return true; }
const result = await clickOption(run, item, wantedIndex, true);
if (result.cancelled) return abortForChange(run, result.reason);
if (!result.ok) { setRecord(item, result.locked ? 'locked' : 'failed', result.reason); return true; }
changed = true;
}
}
current = readCurrent(run, item);
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;
setRecord(item, 'selected', pending ? '已选择,待网页确认。' : changed ? '已选择匹配答案。' : '当前已选中匹配答案。', pending);
return true;
}
async function execute(run, items) {
try {
for (const item of items) {
if (!await processQuestion(run, item)) return progress();
if (run.cancelled || active !== run) return progress();
}
if (active === run && !run.cancelled) {
active = null;
notifyState('complete', '本页自动选择已完成。');
}
} catch (error) {
if (active === run && !run.cancelled) {
if (run.currentBox) {
const item = items.find(function (candidate) { return candidate.box === run.currentBox; });
if (item) setRecord(item, 'failed', error && error.message || '读取或点击选项失败。');
}
pause(error && error.message || '自动选择遇到错误,已暂停。');
}
} finally {
cancel(run);
}
return progress();
}
function start() {
if (destroyed) return Promise.resolve(progress());
if (active && !active.cancelled) return active.promise;
const context = options.getContext();
const reason = initialReason(context);
if (reason) { notifyState('idle', reason); return Promise.resolve(progress()); }
const captured = captureContext(context);
if (!sameContext(scope, captured)) { scope = captured; records = new Map(); total = 0; }
let extracted;
try { extracted = extractItems(context); }
catch (error) { notifyState('idle', error.message || '等待题目加载。'); return Promise.resolve(progress()); }
const items = extracted.items;
total = items.length;
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()); }
const run = { id: ++generation, context: captured, bankSignature: extracted.signature, cancelled: false, waiters: new Set(), currentBox: null, promise: null };
active = run;
// 先写入 promise,再通知或执行,使同步 UI 回调中的 start 也不会并发运行。
run.promise = Promise.resolve().then(function () {
if (active !== run || run.cancelled) return progress();
notifyState('running', '正在选择当前岗位题库中的匹配答案。');
return execute(run, items);
});
return run.promise;
}
function manualClick(event) {
if (!event.isTrusted || !active || active.cancelled) return;
const target = event.target && event.target.nodeType === 1 ? event.target : event.target && event.target.parentElement;
if (!target) return;
const box = target.closest(selectors.question);
if (box && (active.context.root === box || active.context.root.contains(box))) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
}
doc.addEventListener('click', manualClick, true);
function reset() {
stop('自动选择状态已重置。');
records = new Map();
scope = null;
total = 0;
return notifyProgress();
}
function destroy() {
if (destroyed) return;
stop('自动选择已卸载。');
destroyed = true;
doc.removeEventListener('click', manualClick, true);
records.clear();
}
return { start: start, stop: stop, reset: reset, destroy: destroy, get running() { return Boolean(active && !active.cancelled); } };
}
root.YianAnswerRunner = { version: '1.0.0', create: create };
})(typeof window !== 'undefined' ? window : globalThis);