feat: add manual confirmation toggle for incomplete exams

This commit is contained in:
薇星幻海
2026-10-18 01:43:23 +08:00
parent eb9c610b9f
commit 2e044655bb
14 changed files with 758 additions and 201 deletions
+43 -8
View File
@@ -54,7 +54,9 @@
const complete = bank.expectedTotal > 0 && bank.expectedTotal === bank.total && boxes.length === bank.total;
// The confirmed threshold is 80 raw points on the supplied 100-point paper.
const scoreValid = complete && validPoints && Math.abs(totalScore - 100) < 0.000001;
const fullyVerified = complete && matchedCount === bank.total && verifiedCount === bank.total;
return { title: bank.title, total: bank.total, complete, totalScore, predictedScore, matchedCount, verifiedCount,
fullyVerified, unverifiedCount: Math.max(0, bank.total - verifiedCount),
missing: details.filter(item => item.status === 'missing').length,
conflict: details.filter(item => item.status === 'conflict').length, scoreValid, details,
signature: JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map((q, index) => [q.type, q.question, q.options.map(o => [o.label, o.text]), details[index].points])]),
@@ -97,6 +99,10 @@
return /"(?:attemptid|examattemptid|answerrecordid|examrecordid|paperrecordid|recordid|data-attempt-id|data-exam-attempt-id|data-answer-record-id|data-exam-record-id)"/.test(keyFor(ctx));
}
function settings() { return options.getSettings ? options.getSettings() : {}; }
function manualIncomplete() { return settings().manualConfirmIncomplete !== false; }
function scopeValue(ctx, evidence, topic) {
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete()]);
}
function resultVisible() {
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
.some(node => visible(node) && /提交成功|交卷成功|考试结束|得分|成绩/.test(node.textContent));
@@ -152,20 +158,29 @@
enabled = false;
await observe();
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) {
stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷';
emit({ requiresManualSubmission: true, message: error }); return false;
}
const ctx = context(), topic = options.getTopic();
if (!topic || topic.status !== 'matched') { error = topic?.reason || '请先确认考试主题与题库关联'; emit({ message: error }); return false; }
const evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
if (!evidence.complete || !evidence.scoreValid || !ctx.ready || !ctx.exam || ctx.submitted) { emit({ message: evidence.reason || '等待整卷加载完成后启动考试任务' }); return false; }
if (Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).some(visible)) {
emit({ message: '请先在网页取消或关闭当前交卷对话框,再继续任务' }); return false;
}
if (stage === 'awaitingAttempt') {
const timer = timerEvidence(ctx);
entry = { startedAt: timer.startedAt, initialRemaining: timer.remain, lastRemaining: timer.remain, lastObservedAt: timer.stamp, stage: 'idle' };
}
scope = JSON.stringify([currentKey, ctx.revision, evidence.signature, topic.courses]);
if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; }
scope = scopeValue(ctx, evidence, topic);
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
}
log('当前考试已授权:全文核验后严格大于 80 分且达到最早交卷时间才交卷');
log(manualIncomplete() ? '当前考试已授权:题目不全时由用户在网页确认;整卷核验后按得分和时长交卷'
: '题目不全时手动确认已关闭:全部可匹配题核验后,严格大于 80 分且达到最早时长才交卷');
return true;
}
function disable(reason) {
@@ -177,7 +192,7 @@
}
function scopeFor(ctx) {
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses]);
return scopeValue(ctx, evidence, topic);
}
function canClick(token, ctx, confirmation) {
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
@@ -185,8 +200,9 @@
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
return current.mode === 'test' && current.root?.isConnected && current.ready && topic?.status === 'matched'
&& scope === JSON.stringify([keyFor(current), current.revision, evidence.signature, topic.courses])
&& evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount && evidence.predictedScore > 80
&& scope === scopeValue(current, evidence, topic)
&& evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount
&& (!manualIncomplete() || evidence.fullyVerified && !entry.manualRequired) && evidence.predictedScore > 80
&& options.getRunnerState() === 'complete' && (now() - entry.startedAt) / 1000 >= minimum
&& remainingSeconds(doc) !== 0 && !pendingTooLong && (confirmation || current.exam && !current.submitted);
}
@@ -194,6 +210,9 @@
const dialogs = Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).filter(visible);
const containers = dialogs.filter(node => !dialogs.some(other => other !== node && other.contains(node)));
if (!containers.length) return { pending: true };
if (manualIncomplete() && containers.some(node => /未答|未作答|没有作答|尚未作答|漏答|未完成.*题|题.*未完成/.test(clean(node.textContent)))) {
return { manualRequired: true, reason: '网站提示存在未作答题,请在原网页手动确认交卷' };
}
const matches = containers.filter(node => /(?:确认|确定|是否).*交卷|交卷.*(?:确认|确定)|未答.*(?:提交|交卷)|(?:提交|交卷).*未答/.test(clean(node.textContent)));
if (matches.length !== 1 || /验证|人脸|异常|失败|重新考试/.test(matches[0].textContent)) return { reason: '交卷对话框无法唯一确认,请人工检查' };
const buttons = Array.from(matches[0].querySelectorAll('button,[role="button"],.btn,.submited_btn'))
@@ -212,7 +231,7 @@
if (resultVisible()) {
stage = 'submitted'; entry.stage = stage; enabled = false; await persist();
if (token !== generation || destroyed || !sameContext(ctx, context())) return last;
return emit({ message: '网站已显示考试结果', submitted: true });
return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false });
}
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
@@ -220,11 +239,14 @@
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
const waitSeconds = Math.max(0, Math.ceil(minimum - elapsedSeconds));
const remaining = remainingSeconds(doc);
const runner = options.getRunnerState();
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
manualConfirmIncomplete: manualIncomplete(),
requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified),
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
if (!enabled) return last;
if (scope !== JSON.stringify([currentKey, ctx.revision, evidence.signature, topic?.courses]) || topic?.status !== 'matched') {
if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') {
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
}
if (remaining === 0 || ctx.submitted && !['submitting', 'confirming'].includes(stage)) { disable('网站考试已经结束或提交'); return last; }
@@ -233,7 +255,6 @@
if (token !== generation || destroyed || !sameContext(ctx, context())) return last;
return emit({ message: '提交结果未确认,已停止重复提交,请检查网站状态' });
}
const runner = options.getRunnerState();
const allVerified = evidence.matchedCount === evidence.verifiedCount;
if (!evidence.scoreValid || !allVerified || runner !== 'complete') {
if (stage === 'confirming' || stage === 'submitting') {
@@ -241,14 +262,28 @@
if (token !== generation || destroyed || !sameContext(ctx, context())) return last;
return emit({ message: '交卷过程中核验条件变化,等待人工检查' });
}
if (runner === 'complete') {
enabled = false; stage = 'waiting'; error = evidence.reason || '题目未全部核验,请在网页检查未核验题并手动确认交卷';
return emit({ requiresManualSubmission: true, message: error });
}
stage = 'answering'; return emit({ message: evidence.reason || '等待所有可匹配题处理并回读核验' });
}
if (manualIncomplete() && !evidence.fullyVerified) {
enabled = false; stage = 'waiting';
error = '题目未全部核验(' + evidence.verifiedCount + '/' + evidence.total + '),请在网页检查未核验题并手动确认交卷';
log(error); return emit({ requiresManualSubmission: true, message: error });
}
if (!(evidence.predictedScore > 80)) { stage = 'waiting'; return emit({ message: '预计得分小于或等于 80 分,等待人工操作' }); }
if (remaining !== null && remaining <= waitSeconds) { stage = 'waiting'; return emit({ message: '网站截止时间早于最早交卷时间,等待人工操作' }); }
if (waitSeconds) { stage = 'waitingSubmit'; return emit({ message: '核验完成,等待最早交卷时间' }); }
if (stage === 'confirming' || stage === 'submitting') {
const target = confirmationButton();
if (target.pending) return last;
if (target.manualRequired) {
generation++; enabled = false; stage = 'waiting'; entry.stage = 'manualRequired'; entry.manualRequired = true;
error = target.reason; await persist(); log(error);
return emit({ requiresManualSubmission: true, message: error });
}
if (target.reason) { disable(target.reason); return last; }
if (!entry.confirmed) {
entry.confirmed = true; entry.stage = stage = 'confirming'; await persist();