205 lines
15 KiB
JavaScript
205 lines
15 KiB
JavaScript
/* 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)));
|
||
}
|
||
function attemptPolicy(view) {
|
||
if (!view?.container) return null;
|
||
const scope = view.container.closest(OWNER) || view.container;
|
||
return root.YianAttemptPolicy?.read?.(scope) || null;
|
||
}
|
||
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, attemptBlocked = null;
|
||
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 attemptAllowed = () => {
|
||
if (context.skipSingleAttemptQuiz === false) return true;
|
||
if (!root.YianAttemptPolicy || typeof root.YianAttemptPolicy.read !== 'function') {
|
||
attemptBlocked = '答题次数核验组件未就绪,已停止自动操作,请人工处理';
|
||
return false;
|
||
}
|
||
const policy = attemptPolicy(detect(doc));
|
||
if (!policy?.blocked) return true;
|
||
attemptBlocked = policy.reason || '网页提示课中题答题机会受限,已停止自动操作,请人工处理';
|
||
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() || !attemptAllowed() || !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', '课中答题已取消,等待重新启动');
|
||
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
|
||
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 (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
|
||
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', attemptBlocked || '题目或任务状态已变化,停止课中答题', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||
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', attemptBlocked || '课中题提交入口无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||
// 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', attemptBlocked || '课中题已判定正确,但下一题或关闭按钮无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||
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);
|