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
+194 -21
View File
@@ -97,6 +97,20 @@
}
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;
@@ -107,17 +121,17 @@
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"]'));
@@ -137,7 +151,7 @@
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'; }
});
});
@@ -188,6 +202,7 @@
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;
@@ -202,6 +217,10 @@
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;
@@ -261,6 +280,7 @@
}
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'); }
@@ -390,7 +410,7 @@
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 });
}
}
});
@@ -406,6 +426,74 @@
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;
@@ -416,6 +504,7 @@
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);
@@ -443,6 +532,10 @@
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); }
@@ -455,6 +548,11 @@
} 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();
}
@@ -530,6 +628,7 @@
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;
@@ -542,6 +641,15 @@
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) {
@@ -586,7 +694,7 @@
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();
@@ -595,6 +703,9 @@
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') {
@@ -602,37 +713,92 @@
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,
@@ -642,8 +808,8 @@
}
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';
@@ -861,23 +1027,28 @@
}
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; }
}
}
@@ -887,6 +1058,7 @@
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);
@@ -924,6 +1096,7 @@
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(() => {});