/* 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);