fix: resume video playback and advance linked exams with attempt controls

This commit is contained in:
薇星幻海
2026-10-18 02:12:06 +08:00
parent 2e044655bb
commit 1b497286dc
20 changed files with 1629 additions and 155 deletions
+212 -31
View File
@@ -13,6 +13,7 @@
const LEASE_MS = 18000;
const SESSION_KEY = 'session.v1';
const CONTROL_KEY = 'control.v1';
const POLICY_KEYS = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
function clone(value) { return JSON.parse(JSON.stringify(value)); }
@@ -22,6 +23,9 @@
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
}
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
function examIdentity(value) {
try { const url = new URL(value); const match = url.pathname.match(/^\/clientcn\/(?:exam|examtest)\/([^/]+)(?:\/questions)?\/?$/); return match ? url.origin + ':exam:' + match[1] : ''; } catch (_) { return ''; }
}
function visible(node) {
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
@@ -78,6 +82,21 @@
if (portal) return portal.closest('.question-modal-pc');
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
}
function playbackPromptOf(doc) {
// 原站 ngx-bootstrap 将模板放在 body portal,app-dialog-box 自身可能为空。
const bodies = Array.from(doc.querySelectorAll('.modal-dialog.dialog_box .modal-body,[role="dialog"] .modal-body'))
.filter(node => visible(node) && /^是否继续上次播放的位置[??]$/.test(clean(node.textContent)));
const candidates = [];
for (const body of bodies) {
const container = body.closest('.modal-content,[role="dialog"]'); if (!container) continue;
if (candidates.some(item => item.container === container)) continue;
const buttons = Array.from(container.querySelectorAll('.modal-footer button')).filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
const keep = buttons.filter(node => clean(node.textContent) === '继续播放');
const restart = buttons.filter(node => clean(node.textContent) === '重新开始');
candidates.push({ container, body, button: keep.length === 1 && restart.length === 1 ? keep[0] : null });
}
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
}
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;
@@ -167,10 +186,13 @@
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
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 manualConfirmRevision = 0; let pendingManualConfirm = null;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
let policySessionId = '', policyReadSerial = 0;
const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map();
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
function snapshot() {
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
@@ -180,10 +202,32 @@
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
}
function onVideoPause(event) {
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
}
}
function rememberPlayback() {
const snap = snapshot(); if (!snap.document || !safeURL(snap.url, snap.url)) return;
const task = state.tasks.find(t => t.id === activeTaskId); const page = describePage(snap.document, snap.url);
if (state.status !== 'running' || config.videoPaused || manualVideoPause || !task || task.kind !== 'video'
|| !['loading', 'playing', 'retrying'].includes(task.status) || !page.video || page.quiz || playbackPromptOf(snap.document) || page.video.ended || page.platformComplete) return;
if (!visibilityPlayback && (playStarted || !page.video.paused)) visibilityPlayback = { taskId: task.id, url: page.url, video: page.video, generation };
lastAdvancedAt = now(); attemptedAt = 0;
}
function onPageHidden() { pageAway = true; rememberPlayback(); }
function onPageReturn() {
if (watchedDocument && watchedDocument.visibilityState === 'hidden') return;
pageAway = false; lastAdvancedAt = now(); attemptedAt = 0;
returnResumeRequested = Boolean(visibilityPlayback && config.resumePlaybackOnReturn);
// 仍走正常调度、租约和弹题检查,事件本身不直接点击或播放。
root.setTimeout(() => { if (!destroyed) tick().catch(() => {}); }, 0);
}
function onVisibilityChange() {
if (watchedDocument && watchedDocument.visibilityState === 'hidden') onPageHidden(); else onPageReturn();
}
function notify() {
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused });
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
for (const key of POLICY_KEYS) state[key] = config[key];
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
@@ -217,6 +261,23 @@
}
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
function readExamAttempt(task, snap, page) {
const scope = snap.document.querySelector('app-examdetail,app-examtest,.pc_examtest');
const live = root.YianAttemptPolicy?.read(scope) || { known: false, blocked: false, count: null, reason: '', signature: '[]' };
if (!['exam', 'paper'].includes(page.kind)) return live;
const taskURL = task && (task.examPaperURL || task.url || task.pageUrl);
const sameExam = taskURL && examIdentity(taskURL) && examIdentity(taskURL) === examIdentity(page.url);
if (task && task.kind === 'exam' && (canonical(taskURL) === page.url || sameExam)) {
if (live.known) { task.attemptPolicy = clone(live); task.attemptPolicyURL = page.url; }
// 同一任务从详情进入试卷后,次数提示可能不再显示;保留已核对的证据。
else if (task.attemptPolicy && (task.attemptPolicyURL === page.url || sameExam)) return clone(task.attemptPolicy);
}
return live;
}
function getAttemptPolicy() {
const snap = snapshot(); if (!snap.document) return { known: false, blocked: false, count: null, reason: '', signature: '[]' };
return readExamAttempt(state.tasks.find(t => t.id === activeTaskId), snap, describePage(snap.document, snap.url));
}
async function persist() {
if (!session) return;
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
@@ -228,7 +289,7 @@
}
const currentControl = await storage.get(CONTROL_KEY);
if (currentControl && currentControl.sessionId !== session.id) return;
syncManualConfirmation(currentControl); session.config = clone(config);
await syncPolicies(currentControl); session.config = clone(config);
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
}
@@ -261,14 +322,25 @@
tasks.forEach(task => {
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
const current = state.tasks.find(t => t.id === activeTaskId);
const conflictingExam = current && task.kind === 'exam' && examIdentity(current.examPaperURL || current.url || current.pageUrl)
&& examIdentity(task.url || task.pageUrl) && examIdentity(current.examPaperURL || current.url || current.pageUrl) !== examIdentity(task.url || task.pageUrl);
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
const alias = task.current && current && current.kind === task.kind &&
(task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || task.kind === 'video' && task.title === current.title);
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity) || (alias ? current : null);
(task.kind === 'exam' && !conflictingExam && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title
&& (!current.examPaperURL || current.examPaperURL === task.url)
&& (!current.enterClickedAt || now() - current.enterClickedAt < 60000 || current.examPaperURL === task.url)
|| task.kind === 'video' && task.title === current.title);
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity
|| task.current && task.kind === t.kind && task.url && (canonical(t.examPaperURL || t.url) === canonical(task.url)
|| task.kind === 'exam' && examIdentity(t.examPaperURL || t.url || t.pageUrl) && examIdentity(t.examPaperURL || t.url || t.pageUrl) === examIdentity(task.url))) || (alias ? current : null);
if (!old) state.tasks.push(task);
else {
if (alias && task.url) old.url = task.url;
if (task.status === 'completed') { old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成'; }
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' });
}
}
});
}
@@ -292,6 +364,8 @@
async function initializeWork() {
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);
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);
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
@@ -327,33 +401,81 @@
} 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 (session) syncManualConfirmation(await storage.get(CONTROL_KEY));
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
function syncManualConfirmation(shared) {
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
const revision = Number(shared.manualConfirmRevision) || 0;
if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return;
config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision;
session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete });
if (revision < manualConfirmRevision) return;
for (const key of POLICY_KEYS) if (!authoritativePolicies.has(key) && typeof shared.config[key] === 'boolean') { config[key] = shared.config[key]; state[key] = config[key]; }
manualConfirmRevision = revision; session.config = Object.assign({}, session.config);
for (const key of POLICY_KEYS) session.config[key] = config[key];
}
function ensurePolicySession() {
const id = session?.id || '';
if (policySessionId === id) return;
policySessionId = id; authoritativePolicies.clear(); policyAppliedReads.clear(); policyLocalWrites.clear();
}
function policyKey(key, id) { return 'policy:' + (id || session.id) + ':' + key; }
async function syncPolicies(shared) {
if (!session) return;
ensurePolicySession();
syncManualConfirmation(shared);
const id = session.id, serial = ++policyReadSerial;
const writes = new Map(POLICY_KEYS.map(key => [key, policyLocalWrites.get(key) || 0]));
const records = await Promise.all(POLICY_KEYS.map(key => storage.get(policyKey(key, id))));
if (!session || session.id !== id || destroyed) return;
records.forEach((record, index) => {
const key = POLICY_KEYS[index];
if (!record || record.sessionId !== id || typeof record.value !== 'boolean'
|| pendingManualConfirm?.values && key in pendingManualConfirm.values
|| (policyLocalWrites.get(key) || 0) !== writes.get(key)
|| serial < (policyAppliedReads.get(key) || 0)) return;
// 独立键是共享策略的权威值;旧 control 全量快照不能覆盖它。
authoritativePolicies.add(key); policyAppliedReads.set(key, serial);
config[key] = record.value; state[key] = record.value;
});
session.config = Object.assign({}, session.config);
for (const key of POLICY_KEYS) session.config[key] = config[key];
}
async function control(status, force, explicitWorkerAction, manualPatch) {
if (!session || worker && !explicitWorkerAction) return;
const controlEpoch = generation, controlSessionId = session.id;
ensurePolicySession();
const current = await storage.get(CONTROL_KEY);
if (!force && current && current.sessionId !== session.id) return;
const sameSession = current && current.sessionId === session.id;
if (manualPatch) {
config.manualConfirmIncomplete = manualPatch.value;
if (sameSession && current.config) for (const key of POLICY_KEYS) {
if (!(key in manualPatch.values) && !authoritativePolicies.has(key) && typeof current.config[key] === 'boolean') config[key] = current.config[key];
}
Object.assign(config, manualPatch.values);
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
} else syncManualConfirmation(current);
const id = session.id;
await Promise.all(Object.entries(manualPatch.values).map(async ([key, value]) => {
authoritativePolicies.add(key);
await storage.set(policyKey(key, id), { sessionId: id, value, revision: manualConfirmRevision, updatedAt: now(), owner });
}));
}
await syncPolicies(current);
// 策略通过独立键发布,不将旧 control 快照写回,避免覆盖另一页刚发出的暂停。
if (manualPatch && sameSession || controlEpoch !== generation || session?.id !== controlSessionId) return;
const sharedConfig = clone(worker && sameSession ? current.config : config);
sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete;
for (const key of POLICY_KEYS) sharedConfig[key] = config[key];
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
}
async function configure(patch) {
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
async function configure(patch, restoreOptions) {
await initialize(); patch = patch || {};
if (restoreOptions?.restore === true && session) {
await syncPolicies(await storage.get(CONTROL_KEY));
// 普通设置快照可能来自另一个标签的旧全量写回;重载只恢复尚无权威记录的策略。
patch = Object.assign({}, patch);
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
}
const wasRunning = state.status === 'running';
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;
@@ -366,8 +488,12 @@
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;
const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null;
if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; }
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) {
ensurePolicySession(); pendingManualConfirm = manualPatch; Object.assign(config, policies);
for (const key of Object.keys(policies)) policyLocalWrites.set(key, (policyLocalWrites.get(key) || 0) + 1);
}
if (patch.clearLogs) state.logs = [];
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
@@ -386,14 +512,19 @@
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
if (previous) await release();
visibilityPlayback = null; returnResumeRequested = false;
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
return tick();
}
async function pause(reason) {
await initialize(); generation++; state.status = 'paused'; sharedPaused = true; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
await initialize(); const pauseEpoch = ++generation; visibilityPlayback = null; returnResumeRequested = false; state.status = 'paused'; sharedPaused = false; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
await control('paused', false, true); await release(); await persist(); log('info', state.message); return getState();
await control('paused', false, true);
if (pauseEpoch !== generation || state.status !== 'paused') return getState();
// 暂停写入完成后才接受远端“继续”;使写入期间读到旧 running 的轮询失效。
sharedPaused = true; generation++;
await release(); await persist(); log('info', state.message); return getState();
}
async function resume() {
await initialize(); if (!session) return start();
@@ -403,7 +534,7 @@
}
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; });
config.videoPaused = false; config.examPaused = false;
generation++; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
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();
}
async function stop() {
@@ -417,18 +548,21 @@
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
}
if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false;
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
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);
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 : '');
task.navigationRequestedAt = now();
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; }
if (node) {
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
}
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;
@@ -456,7 +590,23 @@
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 });
const playbackPrompt = playbackPromptOf(snap.document);
if (playbackPrompt && !popup) {
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
if (manualVideoPause || !config.autoContinuePlayback || playbackPrompt.ambiguous || !playbackPrompt.button) {
taskState(task, 'waiting', manualVideoPause ? '用户已暂停,请手动选择续播并继续任务' : !config.autoContinuePlayback ? '自动续播已关闭,请在网页选择继续播放或重新开始后继续任务' : '续播弹窗无法唯一确认,请人工选择后继续'); return;
}
if (playbackPromptClick && playbackPromptClick.container === playbackPrompt.container) {
if (now() - playbackPromptClick.at > 15000) taskState(task, 'waiting', '已选择继续播放,但网页弹窗未关闭,请人工检查');
return;
}
if (epoch !== generation || state.status !== 'running' || config.videoPaused || !config.autoContinuePlayback) return;
playbackPromptClick = { container: playbackPrompt.container, at: now() }; playStarted = false; lastTime = null;
taskState(task, 'loading', '已选择继续上次位置,等待网页恢复播放器'); playbackPrompt.button.click(); return;
}
if (!playbackPrompt) playbackPromptClick = null;
if (popup) {
visibilityPlayback = null; returnResumeRequested = false;
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
@@ -464,7 +614,7 @@
if (pendingQuiz) return;
pendingQuiz = popup;
try {
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, document: snap.document });
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz, document: snap.document });
if (epoch !== generation || state.status !== 'running') return;
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
@@ -474,6 +624,19 @@
}
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
if ((page.hidden || pageAway) && video.paused && (playStarted || visibilityPlayback)) {
rememberPlayback(); lastAdvancedAt = now(); attemptedAt = 0;
state.video.status = 'backgroundPaused'; taskState(task, 'playing', '网站在后台暂停播放,等待切回页面后恢复'); return;
}
if (returnResumeRequested) {
const previous = visibilityPlayback; returnResumeRequested = false; visibilityPlayback = null;
if (previous && previous.taskId === task.id && previous.url === page.url && previous.video === video && previous.generation === epoch && config.resumePlaybackOnReturn) {
lastAdvancedAt = now(); attemptedAt = 0; lastTime = null;
if (video.paused) { playStarted = false; taskState(task, 'playing', '已切回页面,正在继续原播放位置'); }
}
} else if (visibilityPlayback && !page.hidden && !pageAway && video.paused && !config.resumePlaybackOnReturn) {
state.video.status = 'waiting'; taskState(task, 'waiting', '切回页面自动恢复已关闭,请手动播放后继续任务'); return;
}
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
state.video.actualRate = video.playbackRate;
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
@@ -516,6 +679,17 @@
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
async function examTick(task, snap, page) {
if (['exam', 'paper'].includes(page.kind)) {
const sourceURL = task.examPaperURL || task.url || task.pageUrl;
const sameURL = canonical(sourceURL) === page.url || examIdentity(sourceURL) && examIdentity(sourceURL) === examIdentity(page.url);
if (!sameURL) { taskState(task, 'waiting', '当前考试与已领取任务的地址不同,请核对后开始新一轮'); return; }
if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url;
}
const attempts = readExamAttempt(task, snap, page);
if (config.skipSingleAttemptQuiz && attempts.blocked && !task.examRequested) {
state.exam = { taskId: task.id, title: task.title, stage: 'waiting', requiresManualSubmission: true, message: attempts.reason };
taskState(task, 'waiting', attempts.reason); return;
}
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
if (task.status === 'waiting' || config.examPaused) {
await observeExamState(task, true);
@@ -620,14 +794,15 @@
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
if (session) {
const controlValue = await storage.get(CONTROL_KEY);
if (epoch !== generation) return getState();
if (await observeControl(controlValue)) return getState();
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete;
const sharedConfig = Object.assign({}, controlValue.config); for (const key of POLICY_KEYS) delete sharedConfig[key];
Object.assign(config, sharedConfig);
}
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(); }
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = 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', '当前课程与已领取任务不同,请核对后继续');
@@ -659,8 +834,10 @@
}
async function observeControl(value) {
if (!session) return false;
const controlEpoch = generation;
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
syncManualConfirmation(shared);
await syncPolicies(shared);
if (controlEpoch !== generation) return true;
if (state.status !== 'running' && !sharedPaused) return false;
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
@@ -678,10 +855,14 @@
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
function destroy() {
destroyed = true; generation++;
if (watchedDocument) { watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true); }
if (watchedDocument) {
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
}
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
}
return { configure, start, pause, resume, stop, tick, getState, destroy, log, clearLogs };
return { configure, start, pause, resume, stop, tick, getState, getAttemptPolicy, destroy, log, clearLogs };
}
return { create, describePage, discoverTasks, preflightTopic };
});