/* 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 = ['course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision', 'exam', 'mode', 'topicKey']; function normalize(value) { // 仅中和排版空白和不可见分隔符;不折叠大小写、数字、单位或标点。 return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/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, 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) { 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, 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, context) }; } 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, 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; } function notifyProgress() { const value = progress(); if (typeof options.onProgress === 'function') options.onProgress(value); return value; } function setRecord(item, status, reason, 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(); } 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 && context.exam !== true) 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, 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) }; } 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, run.context); 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 !== 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) { 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; } 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 && !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; } 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 || '未获得可自动选择的唯一答案。'); return true; } 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。 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 = run.context.exam !== true && 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 (item !== items[0]) { const configured = typeof options.getInterval === 'function' ? Number(options.getInterval()) : 0; const gap = Number.isFinite(configured) ? Math.max(0, Math.min(120, configured)) * 1000 : 4000; if (gap && !await wait(run, gap)) return progress(); } 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 || 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 || 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); 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.1.0', create: create }; })(typeof window !== 'undefined' ? window : globalThis);