/* Exam evidence, original entry time and bounded real submit interaction. */ (function (root) { 'use strict'; const clean = value => String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim(); const same = (a, b) => JSON.stringify(a.map(clean).sort()) === JSON.stringify(b.map(clean).sort()); function visible(node) { if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],.hidden,[inert]')) return false; const style = root.getComputedStyle(node); return node.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden'; } function remainingSeconds(doc) { const text = clean(doc.querySelector('.pc_examtest_countdown .time')?.textContent); const parts = text.match(/^(\d{1,3}):(\d{2}):(\d{2})$/); if (!parts || Number(parts[2]) > 59 || Number(parts[3]) > 59) return null; return Number(parts[1]) * 3600 + Number(parts[2]) * 60 + Number(parts[3]); } function paperKey(url, container) { const parsed = new URL(url); const route = parsed.hash.startsWith('#/') ? new URL(parsed.hash.slice(1), parsed.origin) : parsed; const identities = new Map(); for (const params of [parsed.searchParams, route.searchParams]) for (const [name, value] of params) { const key = name.toLowerCase().replace(/_/g, ''); if (/^(?:attemptid|examattemptid|answerrecordid|examrecordid|paperrecordid|recordid|examid|paperid|examinationid)$/.test(key) && value) identities.set(key, value); } if (container) for (const attribute of ['data-attempt-id', 'data-exam-attempt-id', 'data-answer-record-id', 'data-exam-record-id']) { const node = container.matches?.('[' + attribute + ']') ? container : container.querySelector?.('[' + attribute + ']'); if (node && clean(node.getAttribute(attribute))) identities.set(attribute, clean(node.getAttribute(attribute))); } return route.origin + route.pathname + '|test' + (identities.size ? '|' + JSON.stringify(Array.from(identities).sort()) : ''); } function readPaper(doc, context, snapshot, topic) { const core = root.QuestionBankCore, matcher = root.YianAnswerMatcher; const bank = core.extract(doc, context.sourceUrl); const boxes = Array.from(doc.querySelectorAll(core.selectors.question)); const details = []; let totalScore = 0, predictedScore = 0, matchedCount = 0, verifiedCount = 0, validPoints = true; bank.questions.forEach((question, index) => { const box = boxes[index]; const pointNodes = box ? Array.from(box.querySelectorAll('.pc_examtest_content_question_value')) : []; const marks = pointNodes.length === 1 ? clean(pointNodes[0].textContent).match(/^[((]?\s*(\d+(?:\.\d+)?)\s*分\s*[))]?$/) : null; const points = marks ? Number(marks[1]) : null; if (!(points > 0 && points <= 100)) validPoints = false; else totalScore += points; const match = matcher.match(question, Object.assign({}, context, { courses: topic?.courses || [] }), snapshot); const nodes = box ? Array.from(box.querySelectorAll(core.selectors.option)) : []; const currentTexts = question.options.filter((_, i) => nodes[i]?.classList.contains('examtest_content_list_des_checked')).map(option => option.text); const verified = match.status === 'matched' && same(currentTexts, match.correctTexts); if (match.status === 'matched') matchedCount++; if (verified) { verifiedCount++; if (points > 0) predictedScore += points; } details.push({ number: question.number, question: question.question, points, verified, status: match.status, reason: !marks ? '分值不明' : verified ? '当前答案全文核验通过' : match.status === 'matched' ? '选中内容尚未核验一致' : match.reason, correctTexts: match.correctTexts || [], currentTexts }); }); 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])]), reason: !complete ? '等待完整试卷及答题卡' : !scoreValid ? '题目分值或 100 分分制未完整确认,等待人工检查' : '' }; } function create(options) { options = options || {}; const doc = options.document || root.document; const now = options.now || Date.now; const storage = options.storage || { get: async (_key, fallback) => fallback, set: async () => {} }; let enabled = false, destroyed = false, busy = false, entry = null, currentKey = '', stage = 'idle'; let error = '', last = {}, scope = '', ownSubmit = false, generation = 0, observing = null, observationVersion = 0; const emit = patch => { last = Object.assign({}, last, patch, { enabled, stage }); if (options.onState) options.onState(Object.assign({}, last)); return last; }; const log = message => options.onLog && options.onLog('info', message); const persist = () => entry ? storage.set('exam:' + currentKey, Object.assign({}, entry)) : Promise.resolve(); function context() { return options.getContext(); } function keyFor(ctx) { return paperKey(ctx.sourceUrl || root.location.href, ctx.root); } function sameContext(left, right) { return left && right && left.root === right.root && left.mode === right.mode && left.revision === right.revision && keyFor(left) === keyFor(right); } function timerEvidence(ctx) { const stamp = now(), remain = remainingSeconds(doc); const parsed = new URL(ctx.sourceUrl || root.location.href); const route = parsed.hash.startsWith('#/') ? new URL(parsed.hash.slice(1), parsed.origin) : parsed; const minutes = Number(route.searchParams.get('totaltime')); const duration = minutes > 0 && minutes <= 1440 ? minutes * 60 : null; const strong = Boolean(duration && remain !== null && remain <= duration); return { stamp, remain, strong, startedAt: strong ? stamp - (duration - remain) * 1000 : stamp }; } function timerReset(saved, timer) { if (!Number.isFinite(saved.lastRemaining) || !Number.isFinite(saved.lastObservedAt) || timer.remain === null) return false; const elapsed = Math.max(0, (timer.stamp - saved.lastObservedAt) / 1000); return timer.remain - Math.max(0, saved.lastRemaining - elapsed) > 5; } function identifiedAttempt(ctx) { 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)); } function observe() { const ctx = context(); if (!ctx || ctx.mode !== 'test' || !ctx.root || destroyed) return Promise.resolve(null); const key = keyFor(ctx); if (observing && observing.key === key) return observing.promise; if (key === currentKey && entry) { const timer = timerEvidence(ctx); const resetTimer = timerReset(entry, timer); const changedPaper = entry.paperSignature && ctx.ready && readPaper(doc, ctx, options.getSnapshot(), options.getTopic()).signature !== entry.paperSignature; const pending = ['submitting', 'confirming', 'unknown'].includes(entry.stage); if ((resetTimer || changedPaper) && !pending) { enabled = false; stage = 'awaitingAttempt'; scope = ''; error = '检测到新试卷或重置的倒计时,请明确启动新场次'; last = { submitted: false }; } // A stored result belongs to the earlier attempt until the current website // actually shows that result. Explicit enable may start a fresh visible paper. if (stage === 'submitted' && !resultVisible() && ctx.exam && !ctx.submitted) { enabled = false; stage = 'awaitingAttempt'; scope = ''; error = '检测到可作答试卷,请明确启动新场次'; last = { submitted: false }; } return Promise.resolve(entry); } enabled = false; stage = 'idle'; scope = ''; error = ''; currentKey = key; entry = null; last = { submitted: false }; const version = ++observationVersion; const promise = (async () => { const timer = timerEvidence(ctx); const saved = await storage.get('exam:' + key, null); if (destroyed || version !== observationVersion || key !== currentKey || !sameContext(ctx, context())) return null; entry = saved && Number.isFinite(saved.startedAt) ? Object.assign({}, saved) : { startedAt: timer.startedAt, initialRemaining: timer.remain, stage: 'idle' }; const pending = ['submitting', 'confirming', 'unknown'].includes(entry.stage); const expiredCache = Number.isFinite(entry.lastObservedAt) && Number.isFinite(entry.lastRemaining) && timer.remain > 0 && timer.stamp - entry.lastObservedAt > (entry.lastRemaining + 30) * 1000; const resetTimer = timerReset(entry, timer); const changedPaper = entry.paperSignature && ctx.ready && readPaper(doc, ctx, options.getSnapshot(), options.getTopic()).signature !== entry.paperSignature; const unconfirmedClock = saved && timer.remain === null && !identifiedAttempt(ctx); // Current explicit duration/countdown evidence wins over an older attempt's // timestamp; taking Math.min here would make a retake immediately eligible. if (timer.strong && !pending) entry.startedAt = timer.startedAt; else entry.startedAt = Math.min(timer.stamp, entry.startedAt); if (pending) { stage = 'unknown'; error = '上次交卷结果尚未确认,请检查网站结果后人工处理'; } else if (entry.stage === 'submitted' || expiredCache || resetTimer || changedPaper || unconfirmedClock) { stage = 'awaitingAttempt'; error = '旧场次或计时连续性未确认,请明确启动新场次,重新计算最早交卷时间'; } entry.lastObservedAt = timer.stamp; entry.lastRemaining = timer.remain; // Preserve a submitted/pending record until explicit new-attempt authorization. if (stage !== 'awaitingAttempt') await persist(); return !destroyed && version === observationVersion && key === currentKey && sameContext(ctx, context()) ? entry : null; })().finally(() => { if (observing?.promise === promise) observing = null; }); observing = { key, promise }; return promise; } async function enable() { const token = ++generation, requested = context(); 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' }; } 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(manualIncomplete() ? '当前考试已授权:题目不全时由用户在网页确认;整卷核验后按得分和时长交卷' : '题目不全时手动确认已关闭:全部可匹配题核验后,严格大于 80 分且达到最早时长才交卷'); return true; } function disable(reason) { generation++; enabled = false; if (!['submitting', 'confirming', 'submitted', 'unknown'].includes(stage)) stage = 'paused'; error = reason || '考试自动交卷已暂停'; emit({ message: error }); } function scopeFor(ctx) { const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); return scopeValue(ctx, evidence, topic); } function canClick(token, ctx, confirmation) { if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false; const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic); 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 === 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); } function confirmationButton() { 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')) .filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && /^(确定|确认|确认交卷|确定交卷|提交|交卷|继续交卷|仍要交卷)$/.test(clean(node.textContent))); return buttons.length === 1 ? { button: buttons[0], container: matches[0] } : { reason: '交卷确认按钮无法唯一识别,请人工检查' }; } async function tick() { if (destroyed || busy) return last; busy = true; const token = generation; try { const ctx = context(); if (!ctx || ctx.mode !== 'test' || !ctx.root) { if (enabled) disable('考试页面已切换'); return emit({}); } await observe(); if (token !== generation || destroyed || !sameContext(ctx, context()) || !entry) return last; 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, requiresManualSubmission: false }); } const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000); const configured = Number(settings().minSubmitMinutes); 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 !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') { disable('试卷、题库或主题关联已变更,请重新启动核验'); return last; } if (remaining === 0 || ctx.submitted && !['submitting', 'confirming'].includes(stage)) { disable('网站考试已经结束或提交'); return last; } if (['submitting', 'confirming'].includes(stage) && now() - entry.submitAt > 12000) { stage = 'unknown'; entry.stage = stage; enabled = false; await persist(); if (token !== generation || destroyed || !sameContext(ctx, context())) return last; return emit({ message: '提交结果未确认,已停止重复提交,请检查网站状态' }); } const allVerified = evidence.matchedCount === evidence.verifiedCount; if (!evidence.scoreValid || !allVerified || runner !== 'complete') { if (stage === 'confirming' || stage === 'submitting') { enabled = false; stage = 'unknown'; entry.stage = stage; await persist(); 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(); if (!canClick(token, ctx, true) || confirmationButton().button !== target.button) { if (token === generation && enabled) disable('交卷确认前页面或核验条件已变化,请人工检查'); return last; } ownSubmit = true; try { target.button.click(); } finally { ownSubmit = false; } log('已点击当前场次的交卷确认,等待网站结果'); } return last; } if (!ctx.exam || !ctx.ready || runner !== 'complete') return last; const findCommits = () => Array.from(ctx.root.querySelectorAll('.pc_examtest_commit')).filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && clean(node.textContent) === '交卷'); const commits = findCommits(); if (commits.length !== 1) { disable('当前交卷入口无法唯一识别'); return last; } stage = entry.stage = 'submitting'; entry.submitAt = now(); entry.confirmed = false; await persist(); const currentCommits = findCommits(); if (!canClick(token, ctx, false) || currentCommits.length !== 1 || currentCommits[0] !== commits[0]) { if (token === generation && enabled) disable('交卷前页面或核验条件已变化,请人工检查'); return last; } ownSubmit = true; try { commits[0].click(); } finally { ownSubmit = false; } log('已点击交卷,等待当前场次的确认或结果'); return emit({ message: '交卷处理中,等待网站确认' }); } catch (failure) { disable('考试核验失败:' + failure.message); return last; } finally { busy = false; } } function manual(event) { if (!event.isTrusted || ownSubmit || !enabled) return; const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement; if (node?.closest('.pc_examtest_content_list_des,.pc_examtest_commit,.dialog_box button,[role="dialog"] button,.examine_box button')) disable('检测到人工处理,已取消自动交卷计划'); } doc.addEventListener('click', manual, true); return { observe, enable, disable, tick, getState: () => Object.assign({}, last, { enabled, stage }), destroy() { generation++; observationVersion++; destroyed = true; enabled = false; doc.removeEventListener('click', manual, true); } }; } const api = { create, readPaper, remainingSeconds, paperKey }; root.YianExamController = api; if (typeof module !== 'undefined' && module.exports) module.exports = api; })(typeof window !== 'undefined' ? window : globalThis);