feat: add unified learning workflows and six-tab assistant
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
/* 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;
|
||||
return { title: bank.title, total: bank.total, complete, totalScore, predictedScore, matchedCount, 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 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;
|
||||
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 (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]);
|
||||
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 分且达到最早交卷时间才交卷');
|
||||
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 JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses]);
|
||||
}
|
||||
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 === JSON.stringify([keyFor(current), current.revision, evidence.signature, topic.courses])
|
||||
&& evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount && 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 };
|
||||
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 });
|
||||
}
|
||||
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);
|
||||
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
|
||||
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
|
||||
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') {
|
||||
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 runner = options.getRunnerState();
|
||||
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: '交卷过程中核验条件变化,等待人工检查' });
|
||||
}
|
||||
stage = 'answering'; return emit({ message: evidence.reason || '等待所有可匹配题处理并回读核验' });
|
||||
}
|
||||
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.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);
|
||||
Reference in New Issue
Block a user