380 lines
20 KiB
JavaScript
380 lines
20 KiB
JavaScript
/* 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);
|