fix: start course covers and advance verified learning tasks

This commit is contained in:
薇星幻海
2026-10-18 03:31:46 +08:00
parent ef35b461f7
commit 865a21d714
13 changed files with 1070 additions and 59 deletions
+200 -27
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
// @version 2.1.3
// @version 2.1.4
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
// @match http://10.119.67.7/clientcn/*
// @run-at document-idle
@@ -1114,7 +1114,7 @@ window.JSZip = module.exports;
const KEY = 'diagnosticLog.v1';
const ENABLED_KEY = 'diagnosticLog.enabled';
const LIMIT = 2000;
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requiredSeconds requiredProgress requiresExam requiresPass requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
function safeURL(value) {
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
@@ -1966,7 +1966,7 @@ window.JSZip = module.exports;
});
}
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', waitingExam: '待关联考试与平台核对', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
function firstValue(object, keys, fallback) {
object = object || {};
@@ -2036,13 +2036,13 @@ window.JSZip = module.exports;
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
: '当前为“视频+考试”:平台确认视频完成后,进入已开放的关联考试。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
: '当前为“视频+考试”:按目录完成条件处理视频与已开放的关联考试,最后核对平台完成标记。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
videoModeNote.hidden = false;
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
infoRows(videoInfo, [['当前课程', videoTitle], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
infoRows(videoInfo, [['当前课程', videoTitle], ['完成要求', video.requirements || '等待网页提示;以平台完成标记为准'], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
@@ -2966,6 +2966,20 @@ window.JSZip = module.exports;
}
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
}
function courseCoverOf(doc) {
const buttons = Array.from(doc.querySelectorAll('app-course .course-courseware-playdefult > span'))
.filter(node => visible(node) && clean(node.textContent) === '开始学习' && !node.matches('[disabled],[aria-disabled="true"]'));
return buttons.length === 1 ? buttons[0] : null;
}
function completionRequirements(text) {
text = clean(text);
const time = text.match(/观看课件时长达到\s*([\d.\s小时分钟秒]+)/);
const progress = text.match(/观看视频进度\s*(\d+(?:\.\d+)?)\s*%/);
if (!time && !progress) return null;
return { text: text.slice(0, 500), requiredSeconds: time ? seconds(time[1]) : null,
requiredProgress: progress && Number(progress[1]) <= 100 ? Number(progress[1]) : null,
requiresExam: /完成考试答题/.test(text), requiresPass: /及格通过/.test(text) };
}
function describePage(doc, url) {
const current = canonical(url || doc.URL);
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
@@ -2976,17 +2990,17 @@ window.JSZip = module.exports;
coursewareTitle: active ? clean((active.querySelector('p') || active).textContent) : '',
video, activeCourseware: active, quiz: quizOf(doc), platformComplete: isComplete(active) || Boolean(doc.querySelector('app-course-courseware-video[data-is-learned="true"]')),
learnedSeconds: active ? seconds(clean((active.querySelector('.menu_courseware_left') || active).textContent)) : null,
requiredSeconds: number(active && active.getAttribute('data-required-seconds')),
requiredSeconds: active && active.hasAttribute('data-required-seconds') ? number(active.getAttribute('data-required-seconds')) : null,
hidden: doc.visibilityState === 'hidden' };
}
function discoverTasks(doc, url) {
const page = describePage(doc, url); const tasks = []; const seen = new Set();
function add(node, kind, selector, index, parentId) {
function add(node, kind, selector, index, parentId, subIndex) {
const explicitId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
const targetURL = nodeURL(node, page.url);
let title = clean((node.querySelector('.activity_name,p,[data-task-title]') || node).textContent);
if (node.matches('.cn_courseware_img')) title = clean((node.querySelector(':scope > p') || node.querySelector('p') || node).textContent);
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index;
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index + (subIndex > 0 ? ':exam:' + subIndex : '');
const id = kind + ':' + hash(identity); if (seen.has(id)) return;
seen.add(id);
const locked = node.matches('[disabled],[aria-disabled="true"],[data-locked="true"],.locked') || Boolean(node.querySelector('[data-locked="true"]'));
@@ -3006,7 +3020,7 @@ window.JSZip = module.exports;
const task = add(node, /视频/.test(icon) || node.getAttribute('data-kind') === 'video' ? 'video' : 'unsupported', 'app-course .cn_courseware_img,[data-courseware-id]', i);
if (task && task.kind === 'unsupported' && task.status !== 'completed') { task.status = 'skipped'; task.reason = '暂未适配此非视频课件'; }
node.querySelectorAll('.related_test,[data-related-exam]').forEach((exam, j) => {
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id);
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id, j);
if (examTask) { examTask.selector = 'app-course .cn_courseware_img,[data-courseware-id]'; examTask.subSelector = '.related_test,[data-related-exam]'; examTask.subIndex = j; examTask.id += ':exam:' + j; examTask.requiresParent = exam.getAttribute('data-requires-complete') === 'true'; }
});
});
@@ -3057,6 +3071,7 @@ window.JSZip = module.exports;
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
let visibilityPlayback = null; let returnResumeRequested = false; let pageAway = false; let playbackPromptClick = null;
let sharedPaused = false;
let requirementProbe = null;
let logSequence = 0; let leaseBanner = null;
let manualConfirmRevision = 0; let pendingManualConfirm = null;
let policySessionId = '', policyReadSerial = 0;
@@ -3071,6 +3086,10 @@ window.JSZip = module.exports;
function onInteraction(event) {
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
}
function onRequirementHover(event) {
// 用户移动鼠标后,共用提示框可能已显示另一行的条件,不能归到旧请求。
if (event.isTrusted && requirementProbe && !requirementProbe.icon.contains(event.target)) requirementProbe = null;
}
function onVideoPause(event) {
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
@@ -3130,6 +3149,7 @@ window.JSZip = module.exports;
}
function taskState(task, status, reason) {
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
if (task.kind === 'video' && state.video?.taskId === task.id) state.video.message = task.reason;
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state');
}
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
@@ -3259,7 +3279,7 @@ window.JSZip = module.exports;
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
if (task.status === 'completed') {
old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成';
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed' });
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed', message: old.reason });
}
}
});
@@ -3275,6 +3295,74 @@ window.JSZip = module.exports;
if (!task.identity && title !== task.title) return null;
return node;
}
function readRequirements(task, node, doc) {
if (!node || task.requirements) return task.requirements || null;
const icon = node.querySelector('.courseware_finished_status .en_courseware_noFinished');
if (!icon || !visible(icon)) return null;
if (requirementProbe && (requirementProbe.node !== node || requirementProbe.doc !== doc)) {
requirementProbe.icon.dispatchEvent(new root.MouseEvent('mouseleave', { bubbles: false }));
requirementProbe = null;
}
if (requirementProbe) {
const tips = Array.from(doc.querySelectorAll('.completion-tooltip')).filter(t => visible(t) && clean(t.textContent));
const requirements = tips.length === 1 ? completionRequirements(tips[0].textContent) : null;
if (requirements || now() - requirementProbe.at > 1500) {
icon.dispatchEvent(new root.MouseEvent('mouseleave', { bubbles: false }));
icon.dispatchEvent(new root.MouseEvent('mouseout', { bubbles: true }));
requirementProbe = null; task.requirementsRead = true;
if (requirements) {
task.requirements = requirements;
log('info', '课件完成条件:' + requirements.text, task, requirements, 'course.requirements');
}
}
} else if (!task.requirementsRead) {
// 橙色图标只是未完成;实际观看/考试条件由网页 hover 提示给出。
const existing = Array.from(doc.querySelectorAll('.completion-tooltip')).some(t => visible(t) && clean(t.textContent));
if (!existing) {
requirementProbe = { node, icon, doc, at: now() };
icon.dispatchEvent(new root.MouseEvent('mouseenter', { bubbles: false }));
icon.dispatchEvent(new root.MouseEvent('mouseover', { bubbles: true }));
}
}
return task.requirements || null;
}
function linkedExams(task) { return state.tasks.filter(t => t.kind === 'exam' && t.parentId === task.id); }
async function canAct(task, snap, epoch) {
const locallyPaused = () => task.kind === 'exam' ? config.examPaused : config.videoPaused;
if (epoch !== generation || state.status !== 'running' || locallyPaused()) return false;
if (!await heartbeat(task)) return false;
const latest = snapshot();
const livePage = describePage(latest.document, latest.url);
const samePage = latest.document === snap.document && canonical(latest.url) === canonical(snap.url)
&& (!snap.guardTitle || livePage.title === snap.guardTitle)
&& livePage.activeCourseware === snap.guardActive && livePage.video === snap.guardVideo
&& latest.document.querySelector('app-course,app-learning-map-activity-list') === snap.guardRoot
&& (!snap.guardTaskNode || resolveNode(task, latest) === snap.guardTaskNode);
if (!samePage && epoch === generation && state.status === 'running' && !locallyPaused()) taskState(task, 'waiting', '点击前课程或目录已变化,请核对当前任务后继续');
return epoch === generation && state.status === 'running' && !locallyPaused() && samePage;
}
function guardedSnapshot(snap, page) {
return Object.assign({}, snap, { guardTitle: page.title, guardActive: page.activeCourseware,
guardVideo: page.video, guardRoot: snap.document.querySelector('app-course,app-learning-map-activity-list') });
}
async function courseTick(task, snap, page, epoch) {
snap = guardedSnapshot(snap, page);
if (config.videoPaused || task.status === 'waiting') return;
if (page.kind === 'map' && canonical(task.pageUrl) === page.url && task.courseEntrySourceURL !== page.url) {
const node = resolveNode(task, snap);
snap.guardTaskNode = node;
const button = node && (node.querySelector('.map_activity_content_info_top') || node);
if (!button) { taskState(task, 'waiting', '返回学习地图后找不到原课程入口,请核对目录后继续'); return; }
if (isComplete(node)) { task.progress = 1; taskState(task, 'completed', '平台已确认该课程完成,跳过此入口'); activeTaskId = ''; await release(); return; }
await persist();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, sourceURL: page.url, targetURL: task.url || '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
if (!await canAct(task, snap, epoch) || !snap.document.documentElement.contains(button) || !visible(button)) return;
task.courseEntrySourceURL = page.url; task.courseEntryClickedAt = now(); task.navigationRequestedAt = now();
log('info', '已返回学习地图,通过原课程入口进入下一任务', task, { pageUrl: page.url }, 'course.next');
button.click(); return;
}
if (now() - task.navigationRequestedAt > 15000) taskState(task, 'waiting', '课程入口尚未进入目标课程,请检查网站前置条件后继续');
}
function initialize() {
if (initialized) return Promise.resolve();
if (initializing) return initializing;
@@ -3285,6 +3373,7 @@ window.JSZip = module.exports;
const snap = snapshot(); if (!snap.document) return;
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
watchedDocument.addEventListener('visibilitychange', onVisibilityChange, true);
watchedDocument.addEventListener('mouseover', onRequirementHover, true);
if (watchedDocument.defaultView) { watchedDocument.defaultView.addEventListener('blur', onPageHidden); watchedDocument.defaultView.addEventListener('focus', onPageReturn); }
const page = describePage(snap.document, snap.url);
const saved = await storage.get(SESSION_KEY);
@@ -3312,6 +3401,10 @@ window.JSZip = module.exports;
const controlled = sharedControl && sharedControl.status === 'running' && (recover && recover.sessionId === session.id && recover.taskId === activeTaskId && recover.expires > now() && recover.accountId === account(snap, page) || targetOK);
if (targetOK) {
activeTaskId = transition.taskId;
const arriving = state.tasks.find(t => t.id === activeTaskId);
if (arriving?.kind === 'course' && canonical(arriving.pageUrl) === transition.sourceURL) {
arriving.courseEntrySourceURL = transition.sourceURL; arriving.courseEntryClickedAt = now();
}
if (transition.workerNonce && sharedControl && sharedControl.status === 'running') {
const request = await storage.get('worker:' + transition.workerNonce);
if (request && request.sessionId === session.id && request.taskId === activeTaskId) { worker = request; worker.owner = owner; worker.confirmedAt = now(); await storage.set('worker:' + worker.nonce, worker); }
@@ -3324,6 +3417,11 @@ 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 (state.status === 'running') {
const restored = state.tasks.find(t => t.id === activeTaskId);
if (restored) { restored.openedAt = now(); delete restored.coverClickedAt; }
}
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
@@ -3399,6 +3497,7 @@ window.JSZip = module.exports;
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
}
const wasRunning = state.status === 'running';
const previousVideoPaused = config.videoPaused, previousExamPaused = config.examPaused;
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
@@ -3411,6 +3510,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;
if (!previousVideoPaused && config.videoPaused || !previousExamPaused && config.examPaused) generation++;
const localResumed = previousVideoPaused && patch.videoPaused === false || previousExamPaused && patch.examPaused === false;
if (localResumed && state.status === 'running') {
const active = state.tasks.find(t => t.id === activeTaskId);
if (active && (active.kind === 'exam' ? previousExamPaused && !config.examPaused : previousVideoPaused && !config.videoPaused)) {
active.status = 'pending'; active.reason = ''; activeTaskId = '';
if (active.kind === 'exam' && active.examRequested) active.examNeedsRecheck = true;
}
}
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
if (manualPatch) {
@@ -3455,7 +3563,7 @@ window.JSZip = module.exports;
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) {
state.message = '账号或题库版本已变化,请开始新一轮任务'; state.status = 'paused'; notify(); return getState();
}
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || t.status === 'waitingPlatform') && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || ['waitingPlatform', 'waitingExam'].includes(t.status)) && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
config.videoPaused = false; config.examPaused = false;
generation++; visibilityPlayback = null; returnResumeRequested = false; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
@@ -3464,6 +3572,9 @@ window.JSZip = module.exports;
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
}
async function activate(task, snap, page) {
const epoch = generation;
snap = guardedSnapshot(snap, page);
if (task.kind === 'exam' ? config.examPaused : config.videoPaused) return false;
if (task.locked) { taskState(task, 'waiting', '网站尚未开放此任务'); return false; }
if (task.requiresParent && task.parentId) { const parent = state.tasks.find(t => t.id === task.parentId); if (parent && parent.status !== 'completed') return false; }
if (task.kind === 'exam') {
@@ -3471,37 +3582,92 @@ window.JSZip = module.exports;
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
}
if (!(await claim(task))) return false;
if (epoch !== generation || state.status !== 'running') return false;
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
task.openedAt = now(); delete task.coverClickedAt; delete task.courseEntrySourceURL; delete task.courseEntryClickedAt;
const onPage = canonical(task.url || task.pageUrl) === page.url;
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
const node = resolveNode(task, snap);
snap.guardTaskNode = node;
taskState(task, 'loading', '正在通过网页现有入口打开任务');
task.navigationRequestedAt = now();
if (node && task.kind === 'exam') task.examLinkClickedAt = now();
await persist();
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
if (node) {
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
let button = node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node;
// 默认封面会打开首个课件;后续课件必须点击自己的目录行,防止重播已完成首项。
const first = snap.document.querySelector('app-course .cn_courseware_img,[data-courseware-id]');
const cover = task.kind === 'video' && !page.video && !page.activeCourseware && node === first ? courseCoverOf(snap.document) : null;
if (cover) button = cover;
if (!await canAct(task, snap, epoch)) return 'deferred';
if (!snap.document.documentElement.contains(button) || !visible(button)) { taskState(task, 'waiting', '原任务按钮已变化,请核对页面后继续'); return 'deferred'; }
if (cover) task.coverClickedAt = now();
if (task.kind === 'course') { task.courseEntrySourceURL = page.url; task.courseEntryClickedAt = now(); }
if (task.kind === 'exam') task.examLinkClickedAt = now();
if (cover) log('info', '点击封面“开始学习”,等待网页创建播放器', task, {}, 'video.coverStart');
button.click(); return true;
}
if (!await canAct(task, snap, epoch)) return 'deferred';
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
}
async function videoTick(task, snap, page, epoch) {
snap = guardedSnapshot(snap, page);
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); return; }
const node = resolveNode(task, snap) || (task.current ? page.activeCourseware : null);
if (!task.current) snap.guardTaskNode = node;
if (!task.current && canonical(task.pageUrl) !== page.url && !node) {
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '尚未返回课件所在课程,请核对网站跳转后继续');
return;
}
if (page.activeCourseware && !task.current && (node ? node !== page.activeCourseware : task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent))) {
if (now() - task.openedAt <= 15000) return;
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
}
const requirements = readRequirements(task, node, snap.document);
const learnedSeconds = node ? seconds(clean((node.querySelector('.menu_courseware_left') || node).textContent)) : page.learnedSeconds;
const requiredSeconds = requirements?.requiredSeconds ?? page.requiredSeconds;
const exams = linkedExams(task);
const ended = page.video && (page.video.ended || Number.isFinite(page.video.duration) && page.video.duration > 0 && page.video.currentTime >= page.video.duration - 0.05);
const watchedTime = requirements?.requiredSeconds != null && learnedSeconds != null && learnedSeconds >= requirements.requiredSeconds;
const examsDone = exams.length && exams.every(t => t.status === 'completed');
const canHandoff = watchedTime || requirements?.requiredSeconds == null && ended;
if (!page.platformComplete && !page.quiz && !playbackPromptOf(snap.document) && !manualVideoPause
&& requirements?.requiresExam && exams.length && (canHandoff || task.examHandoff)) {
task.examHandoff = true;
state.video = { taskId: task.id, title: task.title, currentTime: page.video?.currentTime || task.currentTime || 0,
duration: page.video && Number.isFinite(page.video.duration) ? page.video.duration : task.duration,
learnedSeconds, requiredSeconds, requirements: requirements.text, platformComplete: false,
status: examsDone ? 'waitingPlatform' : 'waitingExam', retryCount: task.retryCount || 0 };
if (!allows('exam')) {
taskState(task, 'waiting', '课件还要求关联考试;当前仅处理视频/课中题,请切换“视频+考试”或人工完成关联考试'); return;
}
if (!examsDone) {
if (page.video) page.video.pause();
taskState(task, 'waitingExam', watchedTime ? '平台记录已达到观看时长要求,转入关联考试后再核对整体完成' : '视频已到末尾,转入关联考试;整体完成仍等待平台确认');
activeTaskId = ''; state.message = task.reason; await release(); return;
}
taskState(task, 'waitingPlatform', '关联考试已确认提交,等待课程目录的真实完成标记'); return;
}
if (!page.video) {
if (task.selector) { const node = resolveNode(task, snap); if (node && task.status === 'loading' && !task.openedAt) { task.openedAt = now(); node.click(); return; } }
if (!task.openedAt) task.openedAt = now();
if (page.quiz) { taskState(task, 'waiting', '当前课中题尚未处理,等待网页恢复播放器后继续'); return; }
const first = snap.document.querySelector('app-course .cn_courseware_img,[data-courseware-id]');
const cover = courseCoverOf(snap.document);
if (cover && !task.coverClickedAt && (page.activeCourseware ? page.activeCourseware === node : node && node === first)) {
if (!await canAct(task, snap, epoch) || !snap.document.documentElement.contains(cover) || !visible(cover)) return;
task.coverClickedAt = now(); task.openedAt = now();
log('info', '点击封面“开始学习”,等待网页创建播放器', task, {}, 'video.coverStart');
cover.click(); return;
}
if (task.openedAt == null) task.openedAt = now();
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '播放器未加载或自动播放需要用户操作');
return;
}
const video = page.video;
if (page.activeCourseware && !task.current && task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent)) {
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
}
if (page.platformComplete) {
state.video = { taskId: task.id, title: task.title, currentTime: Number.isFinite(video.currentTime) ? video.currentTime : 0,
duration: Number.isFinite(video.duration) ? video.duration : null, actualRate: video.playbackRate,
@@ -3511,8 +3677,8 @@ window.JSZip = module.exports;
}
const popup = page.quiz;
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
Object.assign(task, { currentTime, duration, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds, requiredSeconds, requirements: requirements?.text || '', platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
Object.assign(task, { currentTime, duration, learnedSeconds, requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
const playbackPrompt = playbackPromptOf(snap.document);
if (playbackPrompt && !popup) {
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
@@ -3730,23 +3896,28 @@ window.JSZip = module.exports;
}
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(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; }
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; requirementProbe = null; }
let task = state.tasks.find(t => t.id === activeTaskId);
if (task && task.kind === 'course' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
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; }
if (task && task.kind === 'course' && ['course', 'video'].includes(page.kind)) {
const arrived = task.url ? canonical(task.url) === page.url
: task.courseEntryClickedAt != null && task.title === page.title && page.url !== canonical(task.pageUrl) && page.url !== task.courseEntrySourceURL;
if (arrived) { task.visitedCourseURL = page.url; 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') { await persist(); notify(); return getState(); }
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
if (!task) {
const candidates = state.tasks.filter(t => ['pending', 'waitingLease'].includes(t.status) && allows(t.kind) && (!worker || t.id === worker.taskId));
const candidates = state.tasks.filter(t => (['pending', 'waitingLease'].includes(t.status)
|| t.status === 'waitingExam' && linkedExams(t).length && linkedExams(t).every(e => e.status === 'completed'))
&& allows(t.kind) && (!worker || t.id === worker.taskId));
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
candidates.sort((a, b) => rank(a) - rank(b));
const blockedKinds = new Set();
for (const next of candidates) {
const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue;
if (await activate(next, snap, page)) { task = next; break; }
const activated = await activate(next, snap, page);
if (activated) { task = next; if (activated === 'deferred') task = null; break; }
if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; }
}
}
@@ -3756,6 +3927,7 @@ window.JSZip = module.exports;
acquiredLease(task);
if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.kind === 'exam') await examTick(task, snap, page);
else if (task.kind === 'course') await courseTick(task, snap, page, epoch);
}
}
if (epoch === generation) await parallelTick(snap, page);
@@ -3793,6 +3965,7 @@ window.JSZip = module.exports;
if (watchedDocument) {
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
watchedDocument.removeEventListener('mouseover', onRequirementHover, true);
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
}
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
@@ -4379,7 +4552,7 @@ window.JSZip = module.exports;
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
let actionEpoch = 0;
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
const version = '2.1.3';
const version = '2.1.4';
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
const pendingLearningSettings = new Map();
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };