fix: start course covers and advance verified learning tasks
This commit is contained in:
+194
-21
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user