Files
huayi-question-bank/src/autofill.js
T

310 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;