feat: add opt-in answer selection and global hidden panel

This commit is contained in:
suanyuit-cyber
2026-10-29 18:29:58 +08:00
parent 6b04243428
commit 83ca85cc17
10 changed files with 1171 additions and 89 deletions
+245
View File
@@ -0,0 +1,245 @@
/* 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);
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', '当前题型暂不支持自动选择。');
const contents = question.options.map(option => optionKey(option.text, question.type));
if (new Set(contents).size !== contents.length || new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项文字或标记重复,无法唯一对应,已跳过。');
const relevant = [];
for (const input of records) {
let record;
try { record = core.normalizeRecord(input); } catch (_) { continue; }
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 tidy(rich.textContent);
const clone = (label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label') || label).cloneNode(true);
clone.querySelectorAll('input,button,script,style').forEach(node => node.remove());
return tidy(clone.textContent).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 => node.textContent).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 };
}
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 = tidy(titles[0].textContent);
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: '', 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 || '', question.type, stemKey(question.stem), question.options.map(option => [option.label, optionKey(option.text, question.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]); }
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 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();
await Promise.resolve();
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
return { status: 'cancelled', message: '自动选择已暂停。' };
}
if (!stillSame(candidate, mark)) 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'] });
}
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;