Files
yianxue-assistant/learning-engine.js
T

651 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* YianLearningEngine: 读取网页实际目录、控制原生播放器并协调已授权的学习任务。 */
(function (root, factory) {
'use strict';
const api = factory(root);
if (typeof module === 'object' && module.exports) module.exports = api;
root.YianLearningEngine = api;
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
const TERMINAL = ['completed', 'skipped', 'failed', 'expanded'];
const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'retrying'];
const MODES = ['video', 'videoQuiz', 'exam', 'all'];
const LEASE_MS = 18000;
const SESSION_KEY = 'session.v1';
const CONTROL_KEY = 'control.v1';
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)); }
function hash(value) { let h = 2166136261; for (const c of String(value)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36); }
function token() { return root.crypto && root.crypto.randomUUID ? root.crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); }
function safeURL(value, base) {
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 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) {
if (n.style && (n.style.display === 'none' || n.style.visibility === 'hidden')) return false;
if (n.isConnected && n.ownerDocument.defaultView && n.ownerDocument.defaultView.getComputedStyle) {
const style = n.ownerDocument.defaultView.getComputedStyle(n);
if (style.display === 'none' || style.visibility === 'hidden') return false;
}
}
return true;
}
function textAt(doc, selectors) { for (const selector of selectors.split(',')) { const node = doc.querySelector(selector); if (node && clean(node.textContent)) return clean(node.textContent); } return ''; }
function number(value) { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : null; }
function seconds(value) {
const text = clean(value); let total = 0; let matched = false;
const clock = text.match(/(?:^|\s)(\d{1,3}):(\d{2})(?::(\d{2}))?(?:$|\s)/);
if (clock) return clock[3] == null ? Number(clock[1]) * 60 + Number(clock[2]) : Number(clock[1]) * 3600 + Number(clock[2]) * 60 + Number(clock[3]);
for (const entry of [[/(\d+(?:\.\d+)?)小时/, 3600], [/(\d+(?:\.\d+)?)分钟/, 60], [/(\d+(?:\.\d+)?)分(?!钟)/, 60], [/(\d+(?:\.\d+)?)秒/, 1]]) {
const m = text.match(entry[0]); if (m) { total += Number(m[1]) * entry[1]; matched = true; }
}
return matched ? total : null;
}
function nodeURL(node, url) {
const own = node.matches('[href],[data-task-url],[routerlink]') ? node : node.querySelector('a[href],[data-task-url],[routerlink]');
if (!own) return '';
const raw = own.getAttribute('href') || own.getAttribute('data-task-url') || own.getAttribute('routerlink');
if (!raw || /^(?:javascript:|#|\[|\{)/i.test(raw)) return '';
const resolved = safeURL(raw, url); return resolved ? resolved.href : '';
}
function isComplete(node) {
if (!node) return false;
if (node.matches('[data-is-learned="true"],[data-learned="true"],[data-completed="true"]')) return true;
if (node.querySelector('[data-is-learned="true"],[data-completed="true"]')) return true;
if (node.matches('.map_activity_content_info_completed') || node.querySelector('.map_activity_content_info_completed')) return true;
// 原站未完成图标也含 en_courseware_finished,必须排除 noFinished。
if (Array.from(node.querySelectorAll('.en_courseware_finished')).some(n => !n.classList.contains('en_courseware_noFinished'))) return true;
const explicit = node.querySelector('[data-learning-status],.courseware_finished_status,.learning-status');
return Boolean(explicit && /^(?:已完成|学习完成|已学完|完成)$/.test(clean(explicit.textContent)));
}
function titleOf(doc) {
const exact = textAt(doc, '[data-course-title],.course-courseware-other-top-left');
if (exact) return exact;
const header = doc.querySelector('.pc_course_left_top_header');
if (header) {
const copy = header.cloneNode(true); copy.querySelectorAll('.pc_course_left_top_header_info,.course-courseware,.course-courseware-playdefult').forEach(n => n.remove());
const title = clean(copy.textContent).replace(/(?:学习时长|发布时间|学习计划)[\s\S]*$/, '').trim(); if (title) return title;
}
const exam = doc.querySelector('.examdetail_header');
if (exam) return clean(exam.textContent).split(/已答|正确率|得分[::]/)[0].trim();
return textAt(doc, '[data-course-title],.pc_examtest_title,.top_map_title,h1') || clean(doc.title) || '未命名任务';
}
function quizOf(doc) {
const portal = Array.from(doc.querySelectorAll('.question-modal-pc .question_box,.question-modal-pc .answer_box')).find(visible);
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 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;
const kind = doc.querySelector('app-examtest') ? 'paper' : video ? 'video' : doc.querySelector('app-examdetail') ? 'exam' : doc.querySelector('app-learning-map-activity-list') ? 'map' : doc.querySelector('app-course') ? 'course' : 'unknown';
const active = doc.querySelector('.cn_courseware_img.active,[data-courseware-id][aria-current="true"]');
const explicit = doc.querySelector('[data-account-id]');
return { kind, url: current, title: titleOf(doc), accountId: explicit ? clean(explicit.getAttribute('data-account-id')) : '',
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')),
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) {
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 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"]'));
tasks.push({ id, kind, title: title || kind, url: targetURL, pageUrl: page.url, selector, index, identity: explicitId || '', parentId: parentId || '',
status: isComplete(node) ? 'completed' : locked ? 'waiting' : 'pending', reason: locked ? '网站尚未开放此任务,等待前置条件' : '',
progress: isComplete(node) ? 1 : 0, duration: number(node.getAttribute('data-duration')), locked, retryCount: 0, lastRefreshAt: 0 });
return tasks[tasks.length - 1];
}
const mapNodes = doc.querySelectorAll('app-learning-map-activity-list .activity_content');
mapNodes.forEach((node, i) => {
const label = clean((node.querySelector('.acitvity_other_info') || node).textContent);
add(node, /考试/.test(label) ? 'exam' : 'course', 'app-learning-map-activity-list .activity_content', i);
});
const chapterNodes = doc.querySelectorAll('app-course .cn_courseware_img,[data-courseware-id]');
chapterNodes.forEach((node, i) => {
const icon = clean((node.querySelector('.cn_courseware_img_icon') || node).textContent);
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);
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'; }
});
});
doc.querySelectorAll('a[href],[data-task-url]').forEach((node, i) => {
const kind = node.getAttribute('data-kind'); const u = nodeURL(node, page.url);
if (!u || node.closest('.activity_content,.cn_courseware_img,[data-courseware-id]')) return;
let inferred = /\/exam\//.test(u) ? 'exam' : /\/(?:course|courseware)\//.test(u) ? 'course' : '';
if (['video', 'exam', 'course'].includes(kind)) inferred = kind;
if (inferred) add(node, inferred, 'a[href],[data-task-url]', i);
});
if ((page.kind === 'exam' || page.kind === 'paper') && !tasks.some(t => t.kind === 'exam' && canonical(t.url || t.pageUrl) === page.url)) {
tasks.unshift({ id: 'exam:' + hash(page.url), kind: 'exam', title: page.title, url: page.url, pageUrl: page.url, current: true, status: 'pending', progress: 0, retryCount: 0 });
}
if (page.video && !tasks.some(t => t.kind === 'video' && doc.querySelectorAll(t.selector || ':not(*)')[t.index] === page.activeCourseware)) {
tasks.unshift({ id: 'video:' + hash(page.url + ':' + (page.video.id || 'player')), kind: 'video', title: page.coursewareTitle || page.title, url: page.url, pageUrl: page.url, current: true,
status: page.platformComplete ? 'completed' : 'pending', progress: page.platformComplete ? 1 : 0, retryCount: 0, lastRefreshAt: 0 });
}
return tasks;
}
function preflightTopic(doc, snapshot, associations, task, explicitTask) {
// 兼容控制器公开调用 (document, url, snapshot, associations, task)。
if (typeof snapshot === 'string') { snapshot = associations; associations = task; task = explicitTask; }
snapshot = snapshot || {}; associations = associations || {};
const title = clean(task && task.title || titleOf(doc));
const records = snapshot.records || snapshot.bank && snapshot.bank.records || [];
const declared = snapshot.courses || snapshot.themes || snapshot.bank && snapshot.bank.courses || [];
const available = Array.from(new Set((declared.length ? declared : records).map(c => clean(typeof c === 'string' ? c : c.course || c.name)).filter(Boolean)));
const explicit = associations[task && task.id] || associations[title];
const list = (Array.isArray(explicit) ? explicit : explicit ? [explicit] : []).map(clean).filter(Boolean);
if (list.length) {
const missing = list.filter(c => !available.includes(c));
return missing.length ? { status: 'missing', title, courses: [], candidates: available, reason: '明确关联的题库主题不存在:' + missing.join('、') } : { status: 'matched', title, courses: list, candidates: list, reason: '使用用户明确确认的主题关联' };
}
if (!available.length) return { status: 'missing', title, courses: [], candidates: [], reason: '统一题库为空或没有可用课程/主题' };
const exact = available.filter(c => c === title || c === clean(snapshot.course));
if (exact.length) return { status: 'matched', title, courses: exact, candidates: exact, reason: '考试主题与题库课程完全对应' };
const candidates = available.filter(c => c.length >= 2 && (title.includes(c) || c.includes(title))).slice(0, 20);
return { status: candidates.length ? 'waiting' : 'missing', title, courses: [], candidates, reason: candidates.length ? '仅发现相似主题,请先明确关联,暂不自动开考' : '未发现对应题库主题,已跳过自动开考;可在题库栏建立明确关联' };
}
function create(options) {
options = options || {};
const now = options.now || Date.now;
const storage = options.storage || { get: async () => null, set: async () => {} };
const owner = options.ownerId || token(); let initialized = false; let initializing = null; let destroyed = false; let busy = false; let generation = 0;
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 sharedPaused = false;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, 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, 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() || {} : {};
return Object.assign({ document: root.document, url: root.location ? root.location.href : '', courses: [] }, given);
}
function onInteraction(event) {
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;
}
function notify() {
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
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;
const todo = counted.filter(t => !TERMINAL.includes(t.status));
const estimates = todo.map(t => {
if (t.status === 'waiting' || t.kind === 'course' || t.kind === 'unsupported') return null;
if (t.kind === 'video') {
const v = state.video && state.video.taskId === t.id ? state.video : t;
if (!(v.duration > 0)) return null;
const playback = Math.max(0, v.duration - (v.currentTime || 0)) / (v.actualRate || config.rate);
// 播放时长已知仍不能推定平台学习条件,未知条件须明确显示未知。
if (v.requiredSeconds == null || v.learnedSeconds == null) return null;
return Math.max(playback, Math.max(0, v.requiredSeconds - v.learnedSeconds));
}
const e = state.exam && state.exam.taskId === t.id ? state.exam : null;
if (!e || e.total == null || ['waiting', 'paused', 'unknown'].includes(e.stage || e.status)) return null;
const elapsed = e.elapsedSeconds != null ? e.elapsedSeconds : e.enteredAt != null ? (now() - e.enteredAt) / 1000 : null;
if (elapsed == null && e.waitSeconds == null) return null;
return Math.max(0, (e.total - (e.verified || 0)) * config.answerInterval, e.waitSeconds != null ? e.waitSeconds : config.minSubmitMinutes * 60 - elapsed);
});
state.etaSeconds = estimates.some(e => e == null) ? null : config.execution === 'parallel' ? Math.max(0, ...['video', 'exam'].map(kind => estimates.reduce((n, e, i) => n + (todo[i].kind === kind ? e : 0), 0))) : estimates.reduce((n, e) => n + e, 0);
if (typeof options.onState === 'function') options.onState(clone(state));
}
function log(level, message, task) {
state.logs.push({ time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', message: String(message), task: task && task.title || '', taskId: task && task.id || '' });
state.logs = state.logs.slice(-500); notify();
}
function taskState(task, status, reason) {
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task);
}
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 || ''); }
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();
// 执行器只写自己领取的任务检查点,主协调器合并,避免并行页面覆盖整份清单。
if (worker) {
const own = state.tasks.find(t => t.id === (activeTaskId || worker.taskId));
if (own) await storage.set('task:' + session.id + ':' + own.id, clone(own));
return;
}
const currentControl = await storage.get(CONTROL_KEY);
if (currentControl && currentControl.sessionId !== session.id) return;
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; }
}
async function release() {
for (const kind of ['video', 'exam']) { const key = 'lease:' + kind; const lease = await storage.get(key); if (lease && lease.owner === owner) await storage.set(key, null); }
}
async function claim(task) {
const kind = task.kind === 'course' ? 'video' : task.kind;
if (kind !== 'video' && kind !== 'exam') return true;
async function acquire() {
const key = 'lease:' + kind; const lease = await storage.get(key);
const shared = await storage.get(CONTROL_KEY);
if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false;
if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) return false;
const wanted = { owner, taskId: task.id, sessionId: session.id, expires: now() + LEASE_MS };
await storage.set(key, wanted);
// GM存储无CAS;延后回读并在每次动作前复核。支持Web Locks时同源标签领取是互斥的。
if (!(root.navigator && root.navigator.locks)) await new Promise(resolve => root.setTimeout(resolve, 25));
const saved = await storage.get(key); return Boolean(saved && saved.owner === owner && saved.taskId === task.id);
}
return root.navigator && root.navigator.locks ? root.navigator.locks.request('yian-learning-' + kind, acquire) : acquire();
}
async function heartbeat(task) {
const key = 'lease:' + (task.kind === 'course' ? 'video' : task.kind); const lease = await storage.get(key);
if (!lease || lease.owner !== owner || lease.taskId !== task.id || lease.sessionId !== session.id) return false;
const shared = await storage.get(CONTROL_KEY); if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false;
lease.expires = now() + LEASE_MS; await storage.set(key, lease); return true;
}
function mergeTasks(tasks, parent) {
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 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);
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 = '平台确认已完成'; }
}
});
}
function resolveNode(task, snap) {
if (canonical(snap.url) !== canonical(task.pageUrl) || !task.selector) return null;
let node = snap.document.querySelectorAll(task.selector)[task.index];
if (node && task.subSelector) node = node.querySelectorAll(task.subSelector)[task.subIndex];
if (!node || !visible(node)) return null;
const actualId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
if (task.identity && actualId !== task.identity) return null;
const title = clean((node.querySelector('.activity_name,:scope > p,[data-task-title]') || node).textContent);
if (!task.identity && title !== task.title) return null;
return node;
}
function initialize() {
if (initialized) return Promise.resolve();
if (initializing) return initializing;
initializing = initializeWork().then(() => { initialized = true; }).finally(() => { initializing = null; });
return initializing;
}
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);
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=([^&]+)/);
const recover = await storage.get('recover:' + canonical(snap.url));
const transition = await storage.get('transition.v1');
if (saved && Array.isArray(saved.tasks)) {
const identityOK = (!saved.accountId || saved.accountId === account(snap, page)) && (!saved.planId || !snap.planId || saved.planId === snap.planId) && (!saved.bankVersion || !snap.bankVersion || saved.bankVersion === snap.bankVersion);
if (identityOK) {
session = saved; Object.assign(config, saved.config || {}); state.tasks = saved.tasks; state.logs = Array.isArray(saved.logs) ? saved.logs.slice(-500) : []; activeTaskId = saved.activeTaskId || '';
if (marker) {
const request = await storage.get('worker:' + marker[1]);
const control = await storage.get(CONTROL_KEY);
if (request && control && request.sessionId === session.id && request.url === canonical(snap.url) && request.expires > now() && ['running', 'paused'].includes(control.status)) {
worker = request; activeTaskId = request.taskId; state.status = control.status; sharedPaused = control.status === 'paused'; request.owner = owner; request.confirmedAt = now(); await storage.set('worker:' + marker[1], request);
}
}
if (!worker) {
const sharedControl = await storage.get(CONTROL_KEY);
const targetOK = transition && transition.sessionId === session.id && transition.expires > now() && transition.accountId === account(snap, page) &&
(transition.targetURL ? transition.targetURL === page.url : transition.sourceURL !== page.url && transition.title === page.title && ['course', 'video', 'exam', 'paper'].includes(page.kind));
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;
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); }
}
await storage.set('transition.v1', null);
}
if (controlled) { state.status = 'running'; session.status = 'running'; await storage.set('recover:' + canonical(snap.url), null); log('info', '已核对脚本恢复标记,继续同一视频任务'); }
else { state.status = 'paused'; session.status = 'paused'; state.message = '已恢复上次任务清单,人工刷新后默认暂停;点击继续重新核对'; }
}
} 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); }
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
async function control(status, force, explicitWorkerAction) {
if (!session || worker && !explicitWorkerAction) return;
const current = await storage.get(CONTROL_KEY);
if (!force && current && current.sessionId !== session.id) return;
await storage.set(CONTROL_KEY, { sessionId: session.id, status, config: clone(worker && current ? current.config : config), updatedAt: now(), owner: worker && current ? current.owner : owner });
}
async function configure(patch) {
await initialize(); patch = patch || {}; 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;
if (MODES.includes(patch.mode)) config.mode = patch.mode;
if (['sequential', 'parallel'].includes(patch.execution)) config.execution = patch.execution;
for (const key of ['rate', 'answerInterval', 'minSubmitMinutes', 'stallSeconds', 'recoverySeconds', 'maxRefreshes', 'minRefreshSeconds']) {
if (Number.isFinite(Number(patch[key]))) config[key] = Math.max(key === 'rate' ? 0.25 : key === 'answerInterval' || key === 'minSubmitMinutes' ? 0 : 1, Number(patch[key]));
}
config.rate = Math.min(2, config.rate); config.maxRefreshes = Math.min(3, Math.floor(config.maxRefreshes));
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 (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(); }
if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停');
await control(state.status); await persist(); notify(); return getState();
}
async function start() {
await initialize(); if (destroyed) return getState();
const snap = snapshot(); const page = describePage(snap.document, snap.url);
if (!safeURL(snap.url, snap.url)) { state.status = 'paused'; state.message = '当前页面不在已授权站点目录'; notify(); return getState(); }
const previous = session;
config.videoPaused = false; config.examPaused = false;
session = { id: token(), accountId: account(snap, page), planId: snap.planId || page.url, bankVersion: snap.bankVersion || '', status: 'running', createdAt: now(), tasks: [], config: clone(config), logs: clone(state.logs) };
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
if (previous) await release();
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 || '已暂停全部任务;网站考试倒计时仍会继续';
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();
}
async function resume() {
await initialize(); if (!session) return start();
const snap = snapshot(); const page = describePage(snap.document, snap.url);
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; });
config.videoPaused = false; config.examPaused = false;
generation++; 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() {
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
}
async function activate(task, snap, page) {
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') {
task.preflight = preflightTopic(snap.document, snap, config.associations, task);
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;
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', '正在通过网页现有入口打开任务');
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 (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) {
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); 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 (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,
learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: true,
background: page.hidden, status: 'completed', retryCount: task.retryCount || 0 };
task.progress = 1; taskState(task, 'completed', '平台已确认课件学习完成'); activeTaskId = ''; await release(); return;
}
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 });
if (popup) {
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';
if (typeof options.solveQuiz !== 'function') { taskState(task, 'waiting', '缺少此课中题结构适配,请人工处理;不会猜选或刷新'); return; }
if (pendingQuiz) return;
pendingQuiz = popup;
try {
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, 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(); }
} catch (error) { if (epoch === generation) taskState(task, 'waiting', '课中题处理失败:' + error.message); }
finally { pendingQuiz = null; }
return;
}
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 (!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';
if (!playStarted) {
playStarted = true;
lastAutoPlayAt = now();
try { const promise = video.play(); if (promise && promise.catch) await promise; }
catch (_) { taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; }
if (epoch !== generation || state.status !== 'running') { video.pause(); return; }
lastAdvancedAt = now();
}
if (lastTime === null || Math.abs(currentTime - lastTime) > 0.01) { lastTime = currentTime; lastAdvancedAt = now(); attemptedAt = 0; taskState(task, 'playing', page.hidden ? '页面在后台,按真实播放与平台记录跟踪' : '视频播放中'); return; }
if (!attemptedAt && now() - lastAdvancedAt >= config.stallSeconds * 1000) {
attemptedAt = now(); taskState(task, 'retrying', '播放进度长时间未变化,先尝试续播');
try { await video.play(); } catch (_) { taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); }
return;
}
if (attemptedAt && now() - attemptedAt >= config.recoverySeconds * 1000) {
if ((task.retryCount || 0) >= config.maxRefreshes) { taskState(task, 'failed', '已达到本课件最多 ' + config.maxRefreshes + ' 次恢复上限,请人工处理'); state.video.status = 'failed'; return; }
if (task.lastRefreshAt && now() - task.lastRefreshAt < config.minRefreshSeconds * 1000) return;
if (!await heartbeat(task) || epoch !== generation || state.status !== 'running') return;
task.retryCount = (task.retryCount || 0) + 1; task.lastRefreshAt = now(); taskState(task, 'retrying', '视频续播失败,保存检查点后刷新当前视频页(' + task.retryCount + '/' + config.maxRefreshes + ')');
await persist();
await storage.set('recover:' + page.url, { sessionId: session.id, taskId: task.id, accountId: account(snap, page), expires: now() + 60000 });
if (typeof options.reload === 'function') await options.reload(); else if (root.location && canonical(root.location.href) === page.url) root.location.reload();
attemptedAt = 0; lastAdvancedAt = now();
}
}
async function examTick(task, snap, page) {
if (config.examPaused) { taskState(task, 'waiting', '考试任务已局部暂停'); return; }
if (!['exam', 'paper'].includes(page.kind)) {
const node = resolveNode(task, snap);
if (node && !task.examLinkClickedAt) {
task.examLinkClickedAt = now(); taskState(task, 'loading', '通过课程目录的原始关联考试入口打开考试'); await persist();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
node.click();
} else if (task.examLinkClickedAt && now() - task.examLinkClickedAt > 15000) taskState(task, 'waiting', '关联考试入口未进入考试,请核对网站限制');
return;
}
task.preflight = preflightTopic(snap.document, snap, config.associations, task);
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return; }
if (page.kind === 'exam') {
const blockers = Array.from(snap.document.querySelectorAll('.exam_qrcode_box,[data-auth-required],.ant-modal-wrap')).filter(node => visible(node) && /人脸|身份验证|考前须知|登录/.test(clean(node.textContent)));
if (blockers.length) { taskState(task, 'waiting', '考试需要身份验证或阅读须知,请人工完成后继续'); return; }
if (task.enterClickedAt) { if (now() - task.enterClickedAt > 15000) taskState(task, 'waiting', '开考页面未进入试卷,请人工检查限制或提示'); return; }
const buttons = Array.from(snap.document.querySelectorAll('.examdetail_button')).filter(node => visible(node) && !node.matches('[disabled],[aria-disabled="true"]') && /^(?:开始答题|继续答题|开始考试|继续考试)$/.test(clean(node.textContent)));
if (buttons.length !== 1) { taskState(task, 'waiting', '找不到唯一有效的正常开考入口,请人工进入试卷'); return; }
task.enterClickedAt = now(); taskState(task, 'loading', '主题预检通过,通过网页正常按钮进入考试'); await persist();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
buttons[0].click(); return;
}
if (!task.examRequested || task.examPage !== page.kind) {
task.examPage = page.kind; taskState(task, 'answering', '考试主题已确认,交给答题与交卷控制器');
if (typeof options.onExamStart === 'function') {
const accepted = await options.onExamStart({ task: clone(task), preflight: clone(task.preflight), document: snap.document, page: page.kind, config: clone(config) });
task.examRequested = accepted !== false;
if (accepted === false) { if (!task.readyWaitAt) task.readyWaitAt = now(); if (now() - task.readyWaitAt > 15000) taskState(task, 'waiting', '试卷未完整加载,请人工核对后继续'); }
}
else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理');
}
const exam = typeof options.getExamState === 'function' ? options.getExamState() : null;
if (exam) {
state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam));
state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total;
state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount;
const stage = exam.stage || exam.status;
if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); }
else if (['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理');
else if (stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
}
async function parallelTick(snap, page) {
if (config.execution !== 'parallel' || worker || !session) return;
const local = state.tasks.find(t => t.id === activeTaskId); const opposite = local && local.kind === 'exam' ? 'video' : 'exam';
for (const t of state.tasks) {
const checkpoint = await storage.get('task:' + session.id + ':' + t.id);
if (checkpoint) Object.assign(t, checkpoint);
if (t.workerNonce) {
const status = await storage.get('worker:' + t.workerNonce);
if (status && status.confirmedAt && status.owner && (!status.heartbeatAt || now() - status.heartbeatAt < LEASE_MS)) {
if (status.video && (!local || local.kind !== 'video')) state.video = clone(status.video);
if (status.exam && (!local || local.kind !== 'exam')) state.exam = clone(status.exam);
}
}
}
const requested = state.tasks.find(t => t.workerNonce && !TERMINAL.includes(t.status));
if (requested) {
const request = await storage.get('worker:' + requested.workerNonce);
if (request && request.confirmedAt && request.owner && (!request.heartbeatAt || now() - request.heartbeatAt < LEASE_MS)) {
requested.status = request.status || 'loading'; state.message = '并行执行器已确认启动:最多一个视频和一场考试';
if (request.video && (!local || local.kind !== 'video')) state.video = clone(request.video);
if (request.exam && (!local || local.kind !== 'exam')) state.exam = clone(request.exam);
}
else if (!request || request.expires <= now()) { taskState(requested, 'waiting', '另一个页面未确认启动,可能被浏览器拦截;请打开任务后继续'); }
else state.message = '正在等待另一执行页面确认,尚未开始并行';
return;
}
if (!local || !['video', 'exam'].includes(local.kind)) return;
const next = state.tasks.find(t => t.kind === opposite && t.status === 'pending' && allows(t.kind) && !t.locked && (!t.requiresParent || !t.parentId || state.tasks.find(p => p.id === t.parentId && p.status === 'completed')));
if (!next) return;
if (next.kind === 'exam') { next.preflight = preflightTopic(snap.document, snap, config.associations, next); if (next.preflight.status !== 'matched') { taskState(next, next.preflight.status === 'missing' ? 'skipped' : 'waiting', next.preflight.reason); return; } }
// Angular目录没有href时,先打开已经访问过的真实课程URL,再在工作页点击原目录节点。
const targetURL = next.url || next.selector && next.pageUrl || '';
if (!targetURL || !safeURL(targetURL, snap.url)) { taskState(next, 'waiting', '并行任务缺少网页真实链接或可恢复目录,请在另一页面打开对应任务'); return; }
if (typeof options.openWorker !== 'function') { taskState(next, 'waiting', '当前环境不能创建受控执行页面,请人工打开任务'); return; }
const nonce = token(); const url = new URL(targetURL); url.hash = (url.hash ? url.hash.slice(1) + '&' : '') + '__yian_worker=' + nonce;
next.workerNonce = nonce; next.status = 'loading';
await storage.set('worker:' + nonce, { nonce, sessionId: session.id, taskId: next.id, url: canonical(targetURL), expires: now() + 30000 }); await persist();
try { const opened = await options.openWorker(url.href); if (opened === false || opened === null) taskState(next, 'waiting', '并行页面被浏览器拦截,请人工打开任务'); else log('info', '已请求打开执行页面,等待其心跳确认', next); }
catch (_) { taskState(next, 'waiting', '并行页面打开失败,请人工处理'); }
}
async function tick() {
if (destroyed) return getState();
// 课中题异步处理期间仍响应另一标签页的全局暂停,不能等整段答题结束。
if (busy) {
if (await observeControl()) return getState();
const task = state.tasks.find(t => t.id === activeTaskId);
if (state.status === 'running' && task && !await heartbeat(task)) {
// 弹题等待中的旧异步流程必须立即失效,getActive 随状态变更阻止后续提交。
generation++; state.status = 'paused'; sharedPaused = false;
state.message = '当前任务租约已失效或被另一执行器领取,旧答题流程已暂停';
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 (state.video) state.video.status = 'waiting';
if (options.onExamPause) options.onExamPause(state.message);
taskState(task, 'waiting', state.message); await release(); notify();
}
return getState();
}
busy = true; const epoch = generation;
try {
await initialize(); const snap = snapshot(); if (!snap.document) return getState();
const page = describePage(snap.document, snap.url);
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
if (session && (state.status === 'running' || sharedPaused)) {
const controlValue = await storage.get(CONTROL_KEY);
if (await observeControl(controlValue)) return getState();
if (worker && controlValue && controlValue.config) Object.assign(config, controlValue.config);
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(); }
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; }
} else mergeTasks(discoverTasks(snap.document, snap.url));
if (state.status !== 'running') { notify(); return getState(); }
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
if (!task) {
const candidates = state.tasks.filter(t => t.status === 'pending' && 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));
for (const next of candidates) { if (await activate(next, snap, page)) { task = next; break; } }
}
if (task && epoch === generation) {
const held = await heartbeat(task) || await claim(task);
if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面';
else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.status !== 'waiting' && task.kind === 'exam') await examTick(task, snap, page);
}
if (epoch === generation) await parallelTick(snap, page);
if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } }
const remaining = state.tasks.filter(t => allows(t.kind) && !TERMINAL.includes(t.status));
if (!task && !remaining.length) { state.status = state.tasks.length ? 'finished' : 'waiting'; state.message = state.tasks.length ? '本轮调度结束;请检查完成、跳过及失败清单' : '当前页面没有可识别的任务,请打开学习地图、课程或试卷'; if (session) session.status = state.status; await control(state.status); }
else if (!task && remaining.every(t => t.status === 'waiting')) { state.message = '剩余任务等待人工处理;确认后点击继续'; }
await persist(); notify(); return getState();
} catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message); return getState(); }
finally { busy = false; }
}
async function observeControl(value) {
if (!session || state.status !== 'running' && !sharedPaused) return false;
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
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();
const active = state.tasks.find(t => t.id === activeTaskId); if (active) { active.examRequested = false; if (active.status === 'waiting') active.status = 'pending'; }
notify(); return false;
}
if (!shared || shared.sessionId === session.id && shared.status === 'running') return false;
if (state.status !== 'running') return false;
generation++; state.status = shared.status === 'stopped' && shared.sessionId === session.id ? 'stopped' : 'paused'; sharedPaused = shared.sessionId === session.id && shared.status === 'paused';
state.message = shared.sessionId !== session.id ? '另一页面已开始新一轮任务,旧执行器已停止' : '全局任务已暂停;网站考试倒计时仍会继续';
const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
if (options.onExamPause) options.onExamPause(state.message); await release(); notify(); return true;
}
function getState() { return clone(state); }
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 (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
}
return { configure, start, pause, resume, stop, tick, getState, destroy, log, clearLogs };
}
return { create, describePage, discoverTasks, preflightTopic };
});