fix: resume video playback and advance linked exams with attempt controls

This commit is contained in:
薇星幻海
2026-10-18 02:12:06 +08:00
parent 2e044655bb
commit 1b497286dc
20 changed files with 1629 additions and 155 deletions
+23 -5
View File
@@ -64,11 +64,16 @@
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;
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;
@@ -77,6 +82,17 @@
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;
@@ -101,16 +117,18 @@
}
return null;
};
const click = node => { if (!active() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
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 = () => {
@@ -128,7 +146,7 @@
}
for (const step of plan) {
const latest = current();
if (!latest || !click(latest.nodes[step.i])) return result('waiting', '题目或任务状态已变化,停止课中答题');
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', '课中答题已取消');
@@ -136,7 +154,7 @@
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', '课中题提交入口无法唯一确认');
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(() => {
@@ -155,7 +173,7 @@
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', '课中题已判定正确,但下一题或关闭按钮无法唯一确认');
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.