/* 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);