Files
yianxue-assistant/exam-controller.js
T

326 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);