310 lines
21 KiB
JavaScript
310 lines
21 KiB
JavaScript
/* 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;
|