/* Select confirmed local answers. This module never submits forms or changes pages. */ const QBAuto = (() => { 'use strict'; const tidy = value => String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim(); const flat = value => tidy(value).replace(/\s+/gu, ''); const stemKey = value => flat(tidy(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, '')); const judgeText = value => /^(正确|对|是|√|true)$/i.test(tidy(value)) ? '正确' : /^(错误|错|否|×|false)$/i.test(tidy(value)) ? '错误' : tidy(value); const optionKey = (value, type) => flat(type === 'judge' ? judgeText(value) : value); const sorted = values => [...values].sort(); const optionSet = question => JSON.stringify(sorted(question.options.map(option => optionKey(option.text, question.type)))); const localCore = supplied => supplied || (typeof QBCore !== 'undefined' ? QBCore : typeof require === 'function' ? require('./core.js') : null); const richText = value => { const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null; return adapter ? adapter.richText(value) : tidy(value && typeof value === 'object' ? value.textContent : value); }; const recordIndexes = new WeakMap(); function candidateRecords(records, question, core) { let index = recordIndexes.get(records); if (!index || index.core !== core) { index = { core, entries: [], byQuestion: new Map(), normalized: new WeakMap() }; recordIndexes.set(records, index); } let changed = index.entries.length !== records.length; const entries = records.map((record, position) => { const rawStem = record && typeof record.stem === 'string' ? record.stem : ''; const type = record && typeof record.type === 'string' ? record.type : ''; const old = index.entries[position]; if (old?.record === record && old.rawStem === rawStem && old.type === type) return old; changed = true; return { record, rawStem, type, key: rawStem ? JSON.stringify([type, stemKey(rawStem)]) : '' }; }); if (changed) { index.entries = entries; index.byQuestion = new Map(); for (const entry of entries) { if (!entry.key || !entry.record || typeof entry.record !== 'object') continue; if (!index.byQuestion.has(entry.key)) index.byQuestion.set(entry.key, []); index.byQuestion.get(entry.key).push(entry.record); } } const matches = index.byQuestion.get(JSON.stringify([question.type, stemKey(question.stem)])) || []; return matches.map(input => { // Only exact-stem candidates need full answer validation. This signature // also detects in-place edits to answers/options, not just array replacement. let signature; try { signature = JSON.stringify([input.bankId, input.bankName, input.questionId, input.type, input.stem, input.options, input.correctOptionIds, input.answerTexts, input.answerStatus, input.conflicts, input.questionIndex, input.contentOccurrence, input.questionIdAliases]); } catch (_) { return null; } const saved = index.normalized.get(input); if (saved && saved.signature === signature) return saved.record; try { const record = core.normalizeRecord(input); index.normalized.set(input, { signature, record }); return record; } catch (_) { return null; } }).filter(Boolean); } function planAnswer(records, current, suppliedCore) { const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more }); const core = localCore(suppliedCore); if (!core || !current || !Array.isArray(records)) return result('unsupported', '当前网页未识别到可自动选择的题目。'); let question; try { question = core.normalizeRecord({ ...current, answerStatus: 'missing' }); } catch (_) { return result('unsupported', '当前题目结构不完整,已跳过。'); } if (!['single', 'multiple', 'judge'].includes(question.type) || question.options.length < 2 || question.options.length > 100) return result('unsupported', '当前题型暂不支持自动选择。'); // Repeated text requires mapAnswers to prove the complete option identity or correct set. if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。'); const relevant = []; for (const record of candidateRecords(records, question, core)) { if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue; if (question.bankId && record.bankId && question.bankId !== record.bankId) continue; if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue; relevant.push(record); } let candidates = relevant; if (question.bankId) { const exactBank = relevant.filter(record => record.bankId === question.bankId); if (exactBank.length) candidates = relevant.filter(record => record.bankId === question.bankId || !record.bankId); } else if (question.bankName && question.bankName !== '未命名题库') { const sameName = relevant.filter(record => flat(record.bankName) === flat(question.bankName)); if (sameName.length) candidates = sameName; } if (candidates.some(record => record.answerStatus === 'conflict')) return result('ambiguous', '本地题库存在该题的冲突答案,已跳过。'); const confirmed = candidates.filter(record => record.answerStatus === 'confirmed'); if (!confirmed.length) return result('missing', '未找到题干、题型和全部选项一致的已确认答案。'); const signatures = new Set(confirmed.map(record => JSON.stringify(sorted(record.answerTexts.map(value => optionKey(value, record.type)))))); if (signatures.size !== 1) return result('ambiguous', '相同题目对应多个不同标准答案,已跳过。'); const mapped = confirmed.map(record => ({ record, mapping: core.mapAnswers(record, question) })); if (mapped.some(item => item.mapping.status !== 'matched' || !item.mapping.labels.length)) return result('ambiguous', '标准答案无法安全对应当前选项,已跳过。'); const labelSets = new Set(mapped.map(item => JSON.stringify(sorted(item.mapping.labels)))); if (labelSets.size !== 1) return result('ambiguous', '标准答案对应的选项位置不一致,已跳过。'); return result('ready', '已找到唯一对应的标准答案。', { labels: [...mapped[0].mapping.labels], answerTexts: [...mapped[0].mapping.answerTexts], record: mapped[0].record }); } function create({ window: win, document: doc, core: suppliedCore, getRecords = () => [], getQuestion = () => null, getBank = () => null, onStatus = () => {}, onChange = () => {} }) { const core = localCore(suppliedCore); const SITE_FORM = '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form'; const GENERIC = 'fieldset, .question, .question-item, [data-question]'; let state = { enabled: false, busy: false, status: 'idle', message: '自动答题未开启。', answeredCount: 0, skippedCount: 0, lastStem: '' }; let observer = null, timer = null, interval = null, generation = 0, destroyed = false; let attempts = new WeakMap(); const setTimer = (fn, delay) => win.setTimeout(fn, delay); function emit(patch) { const before = state; state = { ...state, ...patch }; if (JSON.stringify(before) === JSON.stringify(state)) return; try { onChange({ ...state }); } catch (_) { /* UI failures must not affect a page. */ } if (before.message !== state.message) try { onStatus(state.message); } catch (_) { /* Ignore callback errors. */ } } function visible(element) { if (!element || !element.isConnected || element.closest('#huayi-qb-root')) return false; for (let at = element; at && at.nodeType === 1; at = at.parentElement) { if (at.hidden || at.getAttribute('aria-hidden') === 'true') return false; const style = win.getComputedStyle?.(at); if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false; } return true; } function labelText(input, container) { let label = input.labels && [...input.labels].find(item => container.contains(item)); if (!label) label = input.closest('label'); if (label) { const rich = label.querySelector('.yxtf-radio__label [data-rich-text], .yxtf-checkbox__label [data-rich-text]'); if (rich) return richText(rich); const clone = (label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label') || label).cloneNode(true); clone.querySelectorAll('input,button,script,style').forEach(node => node.remove()); return richText(clone).replace(/^(?:[A-Z][.、.)::]|\[[A-Z]\])\s*/u, ''); } const labelled = tidy(input.getAttribute('aria-labelledby')).split(/\s+/u).filter(Boolean).map(id => doc.getElementById(id)).filter(node => node && container.contains(node)); return tidy(labelled.length ? labelled.map(node => richText(node)).join(' ') : input.getAttribute('aria-label')); } function controls(container, site) { const selector = site ? '.yxtulcdsdk-ques-radio input[type="radio"],.yxtulcdsdk-ques-checkbox input[type="checkbox"]' : 'input[type="radio"],input[type="checkbox"]'; const inputs = [...container.querySelectorAll(selector)].filter(input => site || input.closest(GENERIC) === container); if (inputs.length < 2 || inputs.length > 100) return null; const types = new Set(inputs.map(input => input.type)); if (types.size !== 1 || inputs.some(input => input.disabled || input.matches(':disabled') || input.closest('[aria-disabled="true"]'))) return null; if (inputs.some(input => !visible(input) && !visible(input.closest('label')))) return null; if (!site && inputs[0].type === 'radio' && new Set(inputs.map(input => input.name)).size > 1) return null; const texts = inputs.map(input => labelText(input, container)); if (texts.some(value => !value)) return null; return { inputs, texts, type: inputs[0].type === 'checkbox' ? 'multiple' : 'single' }; } function readSite(container) { if (!visible(container)) return null; const current = getQuestion(); const bank = getBank(); if (!current || bank?.loading) return null; const set = controls(container, true); if (!set || set.inputs.length !== current.options?.length || set.texts.some((value, index) => optionKey(value, current.type) !== optionKey(current.options[index].text, current.type))) return null; return { container, inputs: set.inputs, question: current, site: true, bankIdentity: JSON.stringify([bank?.taskId || '', bank?.bankId || '']) }; } function readGeneric(container) { if (!visible(container)) return null; const set = controls(container, false); if (!set) return null; const titles = [...container.querySelectorAll(':scope > legend, :scope > .question-title, :scope > [data-question-title], :scope > .question-stem, :scope > .stem, :scope > h2, :scope > h3, :scope > p')].filter(node => visible(node) && !node.querySelector('input')); if (titles.length !== 1) return null; const stem = richText(titles[0]); if (!stem || stem.length > 100000) return null; const judged = set.type === 'single' && set.texts.length === 2 && set.texts.map(judgeText).includes('正确') && set.texts.map(judgeText).includes('错误'); const type = judged ? 'judge' : set.type; const question = { bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')), questionId: tidy(container.getAttribute('data-question-id') || container.getAttribute('data-ques-id')), stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })), answerStatus: 'missing', answerTexts: [], correctOptionIds: [] }; return { container, inputs: set.inputs, question, site: false }; } function allCandidates() { const siteForms = [...doc.querySelectorAll(SITE_FORM)].filter(visible); if (siteForms.length) return siteForms.length === 1 ? [readSite(siteForms[0])].filter(Boolean) : []; return [...doc.querySelectorAll(GENERIC)].map(readGeneric).filter(Boolean); } function fingerprint(candidate) { const question = candidate.question; return JSON.stringify([question.bankId || '', question.bankName || '', candidate.bankIdentity || '', question.questionId || '', question.questionIndex ?? null, question.contentOccurrence ?? null, question.type, stemKey(question.stem), question.options.map(option => [option.id || '', option.label, optionKey(option.text, question.type)]), candidate.inputs.map(input => [input.value, input.id, input.name, input.type])]); } function stillSame(candidate, mark) { if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false; const current = candidate.site ? readSite(candidate.container) : readGeneric(candidate.container); return current && fingerprint(current) === mark && current.inputs.length === candidate.inputs.length && current.inputs.every((input, index) => input === candidate.inputs[index]); } function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; } function desired(candidate, plan) { return candidate.question.options.map(option => plan.labels.includes(option.label)); } function alreadyChosen(candidate, flags) { return candidate.inputs.every((input, index) => Boolean(input.checked) === flags[index]); } async function settled(candidate, mark, runGeneration, continuous) { // A single Promise only waits one microtask. Vue and native event handlers // can schedule further microtasks or a later render before changing values. // Observe two distinct task turns before selecting any next option. for (let check = 0; check < 2; check++) { await new Promise(resolve => globalThis.setTimeout(resolve, 50)); if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) return 'cancelled'; if (!stillSame(candidate, mark)) return 'changed'; } return 'stable'; } function skipped(message, stem = '', count = true) { emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) }); return { status: 'skipped', message }; } async function answerCurrent(options = {}) { const continuous = options.continuous === true; if (destroyed) return { status: 'cancelled', message: '自动答题已关闭。' }; if (state.busy) return { status: 'busy', message: '正在处理当前题目。' }; if (continuous && !state.enabled) return { status: 'cancelled', message: '连续自动选择已暂停。' }; const candidates = allCandidates(); if (!candidates.length) return continuous ? { status: 'idle', message: '等待可识别的选择题。' } : skipped('当前网页未识别到明确的题干和选项,已跳过。'); const prioritized = [...candidates].sort((a, b) => Number(Boolean(b.container.matches('.current,.is-active,[aria-current="true"]'))) - Number(Boolean(a.container.matches('.current,.is-active,[aria-current="true"]')))); let candidate, plan, attempt; for (const item of prioritized) { const proposal = planAnswer(getRecords(), item.question, core); const key = attempted(item, proposal); if (continuous && attempts.get(item.container) === key) continue; candidate = item; plan = proposal; attempt = key; break; } if (!candidate) return { status: 'idle', message: '当前识别的题目已处理。' }; const questionStem = candidate.question.stem; attempts.set(candidate.container, attempt); if (plan.status !== 'ready') return skipped(plan.message, questionStem); const flags = desired(candidate, plan); if (candidate.question.type !== 'multiple' && flags.filter(Boolean).length !== 1) return skipped('单选或判断题的目标选项不唯一,已跳过。', questionStem); if (alreadyChosen(candidate, flags)) { emit({ status: 'answered', message: '当前题已选择正确选项。', lastStem: questionStem }); return { status: 'answered', alreadySelected: true, labels: plan.labels, answerTexts: plan.answerTexts }; } const mark = fingerprint(candidate); const runGeneration = generation; emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem }); try { const initial = await settled(candidate, mark, runGeneration, continuous); if (initial === 'cancelled') return { status: 'cancelled', message: '自动选择已暂停。' }; if (initial !== 'stable') return skipped('题目仍在切换或选项身份变化,已停止本次选择。', questionStem); const actions = candidate.question.type === 'multiple' ? candidate.inputs.map((input, index) => ({ input, index })).filter(({ input, index }) => Boolean(input.checked) !== flags[index]) : candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]); for (const { input } of actions) { if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) { emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); return { status: 'cancelled', message: '自动选择已暂停。' }; } if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem); input.click(); const next = await settled(candidate, mark, runGeneration, continuous); if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) { emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); return { status: 'cancelled', message: '自动选择已暂停。' }; } if (next !== 'stable') return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem); } if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem); emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem }); return { status: 'answered', labels: plan.labels, answerTexts: plan.answerTexts }; } catch (error) { return skipped(`自动选择未完成:${error.message || '控件操作失败'}`, questionStem); } finally { if (state.busy) emit({ busy: false }); } } function schedule() { if (!state.enabled || destroyed) return; win.clearTimeout(timer); timer = setTimer(() => { timer = null; void answerCurrent({ continuous: true }); }, 160); } function stopObservers() { win.clearTimeout(timer); win.clearInterval(interval); timer = null; interval = null; if (observer) observer.disconnect(); observer = null; } function setEnabled(value) { if (destroyed) return; const enabled = Boolean(value); if (state.enabled === enabled) { // A page can be hidden while a one-shot selection is awaiting a UI // microtask. Calling false must cancel that run even without continuous mode. if (!enabled) { generation++; stopObservers(); if (state.busy) emit({ busy: false, status: 'idle', message: '自动选择已暂停。' }); } return; } generation++; stopObservers(); emit({ enabled, busy: false, status: 'idle', message: enabled ? '连续自动选择已开启,仅处理明确匹配的题目。' : '连续自动选择已暂停。' }); if (!enabled) return; attempts = new WeakMap(); if (win.MutationObserver) { observer = new win.MutationObserver(mutations => { if (mutations.some(mutation => { const element = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement; return element && !element.closest('#huayi-qb-root'); })) schedule(); }); observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled', 'value', 'data-question-id', 'data-ques-id', 'data-bank-id'] }); } interval = win.setInterval(schedule, 1500); schedule(); } function destroy() { setEnabled(false); stopObservers(); destroyed = true; generation++; } return { answerCurrent, setEnabled, isEnabled: () => state.enabled, getState: () => ({ ...state }), destroy }; } return { planAnswer, create }; })(); if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;