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
+149 -40
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
// @version 2.1.0
// @version 2.1.1
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
// @match http://10.119.67.7/clientcn/*
// @run-at document-idle
@@ -1132,7 +1132,7 @@ window.JSZip = module.exports;
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
};
let destroyed = false;
let selectionText = '';
@@ -1550,6 +1550,16 @@ window.JSZip = module.exports;
});
return input;
}
function learningSwitch(container, key, label, fallback) {
const control = makeSwitch(label, function () {
state.learning[key] = control.input.checked;
invoke('onLearningSetting', key, control.input.checked);
refreshLearning();
}, container);
control.input.setAttribute('data-testid', 'setting-' + key);
learningControls[key] = { input: control.input, fallback: fallback, switch: true };
return control.input;
}
function taskButton(container, label, action, className) {
const button = el(doc, 'button', className || '', label); button.type = 'button';
button.setAttribute('data-testid', 'task-' + action);
@@ -1593,13 +1603,17 @@ window.JSZip = module.exports;
const examModeNote = el(doc, 'p', 'mode-note'); examIntro.appendChild(examModeNote);
learningSetting(examIntro, 'answerInterval', '答题间隔(秒)', null, 4, [0, 120, 0.5]);
learningSetting(examIntro, 'minSubmitMinutes', '最早交卷(分)', null, 6, [0.5, 180, 0.5]);
learningSwitch(examIntro, 'manualConfirmIncomplete', '题目不全时手动确认', true);
const examInfo = el(doc, 'dl', 'info-grid');
const examMessage = el(doc, 'p', 'explanation');
const examThreshold = el(doc, 'p', 'threshold');
const incompleteHint = el(doc, 'p', 'hint');
examThreshold.setAttribute('data-testid', 'exam-threshold');
incompleteHint.setAttribute('data-testid', 'incomplete-confirmation-hint');
const examActions = el(doc, 'div', 'task-actions');
const examPause = taskButton(examActions, '暂停考试任务', 'examPause');
const examResume = taskButton(examActions, '继续考试任务', 'examResume');
examIntro.append(examInfo, el(doc, 'p', 'threshold', '预计得分必须严格大于 80 分;正好 80 分等待人工。'), examMessage, examActions,
el(doc, 'p', 'hint', '仅已精确匹配、点击并核验完整答案文字的题目计分。进入原试卷满设定时长、核验完成且满足条件后才自动交卷;预计分数以本地题库为依据。练习与课中题不使用考试交卷门槛。'));
examIntro.append(examInfo, examThreshold, examMessage, examActions, incompleteHint);
views.exam.insertBefore(examIntro, switches);
const bankCounts = el(doc, 'p', 'bank-counts'); bankSection.appendChild(bankCounts);
@@ -1802,8 +1816,17 @@ window.JSZip = module.exports;
}
Object.keys(learningControls).forEach(function (key) {
const setting = learningControls[key];
if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]);
if (setting.switch) setting.input.checked = learning[key] == null ? setting.fallback : learning[key] !== false;
else if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]);
});
const manualConfirmIncomplete = learning.manualConfirmIncomplete !== false;
setText(examThreshold, manualConfirmIncomplete
? '必须整卷每题核验且预计得分严格大于 80 分;题目不全时由用户在原网页手动确认交卷。'
: '允许题库部分缺题时自动交卷:已核验预计得分严格大于 80 分,所有可匹配题均已核验,且已满最早交卷时间。');
setText(incompleteHint, (manualConfirmIncomplete
? '本开关已开启:存在未匹配、冲突或未核验题时,即使超过 80 分、已满交卷时长也不自动交卷;网页提示未答完时同样等待人工。'
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
const videoEnabled = learning.mode !== 'exam';
setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled;
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
@@ -1819,6 +1842,8 @@ window.JSZip = module.exports;
videoPause.disabled = !videoHasTask || ['paused', 'completed', 'done', 'idle'].includes(video.status || 'idle');
videoResume.disabled = !videoHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(video.status);
infoRows(examInfo, [['考试主题', examTitle], ['题库预检', plain(firstValue(exam, ['preflightMessage', 'themeStatus', 'preflight'], '尚未预检'))],
['全文核验', exam.total == null ? '待确认' : plain(exam.verifiedCount || 0) + ' / ' + plain(exam.total) + ' 题'],
['交卷确认', exam.submitted || exam.status === 'submitted' ? '网站已确认交卷' : exam.requiresManualSubmission ? '题目未全部核验或网站提示未答完,请在原网页手动确认' : manualConfirmIncomplete ? '整卷每题核验后才按条件自动交卷' : '已核验得分超过 80 分并满最早交卷时间后才按条件自动交卷'],
['预计得分', firstValue(exam, ['predictedScore', 'score'], null) == null ? '待核算' : plain(firstValue(exam, ['predictedScore', 'score'], 0)) + ' 分(题库估计)'],
['网页剩余', duration(firstValue(exam, ['remainingSeconds', 'remainingTime'], null))], ['进入试卷', duration(firstValue(exam, ['elapsedSeconds', 'elapsedTime'], null))],
['最早交卷', duration(firstValue(exam, ['submitWaitSeconds', 'waitSeconds'], null))], ['交卷状态', statusName(exam.status || 'idle')]]);
@@ -2688,8 +2713,9 @@ window.JSZip = module.exports;
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
let sharedPaused = false;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
let manualConfirmRevision = 0; let pendingManualConfirm = null;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
function snapshot() {
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
@@ -2702,7 +2728,7 @@ window.JSZip = module.exports;
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
}
function notify() {
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused });
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
@@ -2747,6 +2773,7 @@ window.JSZip = module.exports;
}
const currentControl = await storage.get(CONTROL_KEY);
if (currentControl && currentControl.sessionId !== session.id) return;
syncManualConfirmation(currentControl); session.config = clone(config);
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
}
@@ -2845,13 +2872,30 @@ window.JSZip = module.exports;
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
}
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
if (session) syncManualConfirmation(await storage.get(CONTROL_KEY));
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
async function control(status, force, explicitWorkerAction) {
function syncManualConfirmation(shared) {
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
const revision = Number(shared.manualConfirmRevision) || 0;
if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return;
config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision;
session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete });
}
async function control(status, force, explicitWorkerAction, manualPatch) {
if (!session || worker && !explicitWorkerAction) return;
const current = await storage.get(CONTROL_KEY);
if (!force && current && current.sessionId !== session.id) return;
await storage.set(CONTROL_KEY, { sessionId: session.id, status, config: clone(worker && current ? current.config : config), updatedAt: now(), owner: worker && current ? current.owner : owner });
const sameSession = current && current.sessionId === session.id;
if (manualPatch) {
config.manualConfirmIncomplete = manualPatch.value;
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
} else syncManualConfirmation(current);
const sharedConfig = clone(worker && sameSession ? current.config : config);
sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete;
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
}
async function configure(patch) {
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
@@ -2867,11 +2911,15 @@ window.JSZip = module.exports;
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null;
if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; }
if (patch.clearLogs) state.logs = [];
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停');
await control(state.status); await persist(); notify(); return getState();
try { await control(state.status, false, Boolean(manualPatch), manualPatch); }
finally { if (pendingManualConfirm === manualPatch) pendingManualConfirm = null; }
await persist(); notify(); return getState();
}
async function start() {
await initialize(); if (destroyed) return getState();
@@ -2999,8 +3047,26 @@ window.JSZip = module.exports;
attemptedAt = 0; lastAdvancedAt = now();
}
}
async function observeExamState(task, readOnly) {
// 仅观察已经交给本场控制器的考试,避免将旧场次结果附到尚未开考的任务。
if (!task.examRequested) return;
const exam = typeof options.getExamState === 'function' ? options.getExamState() : null;
if (!exam) return;
state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam));
state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total;
state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount;
const stage = exam.stage || exam.status;
if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); }
else if (!readOnly && ['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理');
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
async function examTick(task, snap, page) {
if (config.examPaused) { taskState(task, 'waiting', '考试任务已局部暂停'); return; }
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
if (task.status === 'waiting' || config.examPaused) {
await observeExamState(task, true);
if (task.status !== 'completed' && config.examPaused) taskState(task, 'waiting', '考试任务已局部暂停');
return;
}
if (!['exam', 'paper'].includes(page.kind)) {
const node = resolveNode(task, snap);
if (node && !task.examLinkClickedAt) {
@@ -3031,16 +3097,7 @@ window.JSZip = module.exports;
}
else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理');
}
const exam = typeof options.getExamState === 'function' ? options.getExamState() : null;
if (exam) {
state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam));
state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total;
state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount;
const stage = exam.stage || exam.status;
if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); }
else if (['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理');
else if (stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
await observeExamState(task, false);
}
async function parallelTick(snap, page) {
if (config.execution !== 'parallel' || worker || !session) return;
@@ -3106,10 +3163,13 @@ window.JSZip = module.exports;
await initialize(); const snap = snapshot(); if (!snap.document) return getState();
const page = describePage(snap.document, snap.url);
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
if (session && (state.status === 'running' || sharedPaused)) {
if (session) {
const controlValue = await storage.get(CONTROL_KEY);
if (await observeControl(controlValue)) return getState();
if (worker && controlValue && controlValue.config) Object.assign(config, controlValue.config);
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete;
Object.assign(config, sharedConfig);
}
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
}
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); }
@@ -3118,7 +3178,7 @@ window.JSZip = module.exports;
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
} else mergeTasks(discoverTasks(snap.document, snap.url));
if (state.status !== 'running') { notify(); return getState(); }
if (state.status !== 'running') { await persist(); notify(); return getState(); }
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
if (!task) {
const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId));
@@ -3131,7 +3191,7 @@ window.JSZip = module.exports;
const held = await heartbeat(task) || await claim(task);
if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面';
else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.status !== 'waiting' && task.kind === 'exam') await examTick(task, snap, page);
else if (task.kind === 'exam') await examTick(task, snap, page);
}
if (epoch === generation) await parallelTick(snap, page);
if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } }
@@ -3143,8 +3203,10 @@ window.JSZip = module.exports;
finally { busy = false; }
}
async function observeControl(value) {
if (!session || state.status !== 'running' && !sharedPaused) return false;
if (!session) return false;
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
syncManualConfirmation(shared);
if (state.status !== 'running' && !sharedPaused) return false;
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
const active = state.tasks.find(t => t.id === activeTaskId); if (active) { active.examRequested = false; if (active.status === 'waiting') active.status = 'pending'; }
@@ -3412,7 +3474,9 @@ window.JSZip = module.exports;
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])]),
@@ -3455,6 +3519,10 @@ window.JSZip = module.exports;
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));
@@ -3510,20 +3578,29 @@ window.JSZip = module.exports;
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) {
@@ -3535,7 +3612,7 @@ window.JSZip = module.exports;
}
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;
@@ -3543,8 +3620,9 @@ window.JSZip = module.exports;
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);
}
@@ -3552,6 +3630,9 @@ window.JSZip = module.exports;
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'))
@@ -3570,7 +3651,7 @@ window.JSZip = module.exports;
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);
@@ -3578,11 +3659,14 @@ window.JSZip = module.exports;
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; }
@@ -3591,7 +3675,6 @@ window.JSZip = module.exports;
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') {
@@ -3599,14 +3682,28 @@ window.JSZip = module.exports;
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();
@@ -3665,7 +3762,7 @@ window.JSZip = module.exports;
let positionRevision = 0;
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
let actionEpoch = 0;
let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {} };
let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {} };
const inScope = () => YianSiteScope.isAllowedURL(location.href);
function topicPreflight() {
@@ -3844,10 +3941,11 @@ window.JSZip = module.exports;
});
async function changeLearningSetting(key, value) {
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
if (!allowed.includes(key)) return;
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关');
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
if (ranges[key]) {
const range = ranges[key]; value = Number(value);
@@ -3859,7 +3957,7 @@ window.JSZip = module.exports;
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
throw new Error('主题关联须使用完整标题及题库课程名称列表');
}
if (['mode', 'execution', 'associations'].includes(key)) {
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) {
actionEpoch++;
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
await learning.pause();
@@ -3881,6 +3979,9 @@ window.JSZip = module.exports;
} else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' });
else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); }
else if (action === 'examResume') {
if (learning.getState().status !== 'running') {
panel.setNotice('当前计划已暂停,请先在任务总览点击“继续上次任务”重新核验。', 'warning'); return;
}
await learning.configure({ examPaused: false });
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true);
@@ -3889,7 +3990,9 @@ window.JSZip = module.exports;
}
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
getTopic: topicPreflight, getSettings: () => learningSettings, getRunnerState: () => runnerState,
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false
}), getRunnerState: () => runnerState,
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
onState: state => { examState = state; renderLearning(); } });
learning = window.YianLearningEngine.create({
@@ -3922,7 +4025,13 @@ window.JSZip = module.exports;
if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true });
const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child;
},
onState: state => { learningState = state; renderLearning(); }
onState: state => {
learningState = state;
if (!loading && typeof state.manualConfirmIncomplete === 'boolean') {
learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete;
}
renderLearning();
}
});
function tick() {