Files
yianxue-assistant/quiz-adapter.js
T

187 lines
13 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.
/* Courseware quiz DOM adapter. Templates verified in saved 5-es2015 bundle:
* app-question-modal -> CDK .question-modal-pc, .question_box/.answer_box.
* doCheck changes checked class; 查看答案 grades; 下一题/关闭 emits continuePlay.
* The saved site's scripts are never evaluated by this module. */
(function (root) {
'use strict';
const jobs = new WeakMap();
const CHECKED = 'examtest_content_list_des_checked';
const OWNER = '.question-modal-pc,.question-modal-mobile,app-question-modal,[data-learning-quiz]';
const clean = value => String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
const same = (left, right) => JSON.stringify(left.map(clean).sort()) === JSON.stringify(right.map(clean).sort());
function visible(node) {
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],.hidden,[inert]')) return false;
const view = node.ownerDocument.defaultView;
for (let parent = node; parent && parent.nodeType === 1; parent = parent.parentElement) {
const style = view.getComputedStyle(parent);
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0') return false;
}
return node.getClientRects().length > 0;
}
function known(node) { return Boolean(node.closest(OWNER)); }
function detect(doc) {
const question = Array.from(doc.querySelectorAll('.question_box')).filter(node => known(node) && visible(node));
const answer = Array.from(doc.querySelectorAll('.answer_box')).filter(node => known(node) && visible(node));
if (question.length > 1 || answer.length > 1 || question.length && answer.length) return { ambiguous: true };
if (answer.length) return { kind: 'portalAnswer', container: answer[0] };
if (question.length) return { kind: 'portal', container: question[0] };
const legacy = Array.from(doc.querySelectorAll('.pc_examtest_content')).filter(node => known(node) && visible(node));
if (legacy.length > 1) return { ambiguous: true };
if (legacy.length) return { kind: 'legacy', container: legacy[0] };
return null;
}
function read(view, context) {
const box = view.container;
if (box.querySelector('img,svg,canvas,video,audio,iframe,math')) return { error: '课中题含图片或非文字内容,需要人工核对' };
let question, nodes;
if (view.kind === 'portal') {
const stems = Array.from(box.querySelectorAll('.examtest_content_question > p')).filter(visible);
if (stems.length !== 1) return { error: '课中题题干无法唯一识别' };
const stem = clean(stems[0].textContent);
const type = stem.match(/^[\[【](单选|多选|判断)(?:题)?[\]】]\s*/);
if (!type) return { error: '课中题缺少明确支持的题型' };
nodes = Array.from(box.querySelectorAll('.examtest_content_list_des')).filter(visible);
const options = nodes.map(node => {
const labels = node.querySelectorAll('.examtest_content_list_des_optionsort');
if (labels.length !== 1) return null;
const label = clean(labels[0].textContent).toUpperCase().replace(/[..、::))]$/, '');
const copy = node.cloneNode(true); copy.querySelector('.examtest_content_list_des_optionsort').remove();
return { label, text: clean(copy.textContent) };
});
if (options.some(option => !option || !/^[A-H]$/.test(option.label) || !option.text)) return { error: '课中题选项文字或字母无法完整识别' };
question = { type: type[1], question: stem.slice(type[0].length), options, number: 1 };
} else {
if (!root.QuestionBankCore) return { error: '课中题提取组件未加载' };
const wrapper = box.ownerDocument.createElement('div'); wrapper.append(box.cloneNode(true));
const bank = root.QuestionBankCore.extract(wrapper, context.sourceUrl);
if (bank.questions.length !== 1) return { error: '课中题结构尚未完整加载' };
question = bank.questions[0]; nodes = Array.from(box.querySelectorAll(root.QuestionBankCore.selectors.option));
}
if (!question.question || nodes.length !== question.options.length || nodes.length < 2 || nodes.length > 8) return { error: '课中题题干及选项尚未完整加载' };
return { question, nodes, signature: JSON.stringify([question.type, question.question, question.options.map(option => [option.label, option.text])]) };
}
function buttons(container, expression) {
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
}
async function run(options) {
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
const context = options.context || {};
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
let cancelled = false, verifiedCount = 0;
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
const configured = Number(context.answerInterval);
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
const result = (status, reason, extra) => Object.assign({ status, verified: status === 'complete', verifiedCount, reason }, extra || {});
const active = () => {
if (cancelled || options.signal?.aborted) return false;
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
};
const manual = event => {
if (!event.isTrusted) return;
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
if (node && known(node) && node.closest('.question_box,.answer_box,.pc_examtest_content')) cancelled = true;
};
doc.addEventListener('click', manual, true);
const pause = milliseconds => new Promise(resolve => {
const started = Date.now();
const step = () => {
if (!active()) return resolve(false);
const remaining = milliseconds - (Date.now() - started);
if (remaining <= 0) return resolve(true);
root.setTimeout(step, Math.min(100, remaining));
};
step();
});
const waitFor = async predicate => {
for (let waited = 0; waited <= timeout; waited += 100) {
if (!active()) return { cancelled: true };
const value = predicate(); if (value) return value;
if (waited < timeout && !await pause(100)) return { cancelled: true };
}
return null;
};
const click = node => { if (!active() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
try {
let view = detect(doc);
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
const original = read(view, context);
if (original.error) return result('waiting', original.error);
const matched = await matcher.match(original.question, context, options.snapshot);
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
const current = () => {
const seen = detect(doc);
if (!seen || seen.ambiguous || seen.container !== view.container || seen.kind !== view.kind) return null;
const latest = read(seen, context); return !latest.error && latest.signature === original.signature ? latest : null;
};
const wanted = original.question.options.map(option => matched.correctTexts.some(text => clean(text) === clean(option.text)));
const plan = [];
original.nodes.forEach((node, i) => { if (!wanted[i] && node.classList.contains(CHECKED)) plan.push({ i, checked: false }); });
original.nodes.forEach((node, i) => { if (wanted[i] && !node.classList.contains(CHECKED)) plan.push({ i, checked: true }); });
if (original.question.type !== '多选') {
if (wanted.filter(Boolean).length !== 1) return result('waiting', '单选或判断题正确答案数量不明');
plan.splice(0, plan.length, ...(original.nodes.every((node, i) => node.classList.contains(CHECKED) === wanted[i]) ? [] : [{ i: wanted.indexOf(true), checked: true }]));
}
for (const step of plan) {
const latest = current();
if (!latest || !click(latest.nodes[step.i])) return result('waiting', '题目或任务状态已变化,停止课中答题');
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
if (!await pause(150)) return result('waiting', '课中答题已取消');
}
const evidence = current();
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', '课中题提交入口无法唯一确认');
// The real template replaces the question with an answer overlay immediately;
// absence of the question alone does not imply that the grade request succeeded.
const graded = await waitFor(() => {
const next = detect(doc);
if (next?.ambiguous) return { ambiguous: true };
if (next?.kind === 'portalAnswer') {
const grade = clean(next.container.querySelector('.model_dec')?.textContent);
if (/回答错误/.test(grade)) return { wrong: true };
if (/回答正确/.test(grade)) return next;
}
if (view.kind === 'legacy' && !next && !view.container.isConnected) return { closed: true };
return null;
});
if (!graded || graded.cancelled || graded.ambiguous || graded.wrong) return result('waiting', graded?.wrong ? '网站反馈课中题回答错误,等待人工核对' : '网站尚未确认课中题结果,等待人工核对');
verifiedCount++;
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
const advances = buttons(graded.container, /^(下一题|关闭)$/);
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
if (advances.length !== 1 || !click(advances[0])) return result('waiting', '课中题已判定正确,但下一题或关闭按钮无法唯一确认');
const next = await waitFor(() => {
const seen = detect(doc);
// CDK detach/attach can briefly render neither overlay between questions.
// Only the actual final 关闭 control authorizes completion by disappearance.
if (!seen) return isLast ? { closed: true } : null;
if (seen.ambiguous) return { ambiguous: true };
if (seen.kind !== 'portalAnswer' && seen.container !== graded.container) return seen;
return null;
});
if (!next || next.cancelled || next.ambiguous) return result('waiting', '课中题已判定正确,等待网页关闭弹题或切到下一题');
if (next.closed) return result('complete', '课中题均核验正确,网站已关闭弹题');
if (!await pause(interval)) return result('waiting', '课中答题已取消');
view = next;
}
return result('waiting', '本组课中题数量超过适配上限,请人工核对');
} catch (error) { return result('waiting', '课中题处理失败:' + String(error.message || error)); }
finally { doc.removeEventListener('click', manual, true); }
}
function solve(options) {
options = options || {}; const doc = options.document || root.document;
if (!doc) return Promise.resolve({ status: 'waiting', verified: false, reason: '当前页面未就绪' });
if (jobs.has(doc)) return jobs.get(doc);
const job = run(options).finally(() => { if (jobs.get(doc) === job) jobs.delete(doc); });
jobs.set(doc, job); return job;
}
root.YianQuizAdapter = { solve };
if (typeof module !== 'undefined' && module.exports) module.exports = root.YianQuizAdapter;
})(typeof window !== 'undefined' ? window : globalThis);