/* 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', 'waitingLease', 'retrying']; const MODES = ['video', 'videoQuiz', 'exam', 'all']; 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)); } 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 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) { 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 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 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; 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: 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, 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 + (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"]')); 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, 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'; } }); }); 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 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; 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() || {} : {}; 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 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; } } 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, 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; state.leaseWaiting = counted.filter(t => t.status === 'waitingLease').length; const todo = counted.filter(t => !TERMINAL.includes(t.status)); const estimates = todo.map(t => { if (['waiting', 'waitingLease'].includes(t.status) || 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, details, event) { const record = { id: owner + ':' + (++logSequence), time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', event: event || 'learning.message', message: String(message), task: task && task.title || '', taskId: task && task.id || '', details: details || {} }; state.logs.push(record); if (typeof options.onLog === 'function') { try { options.onLog(clone(record)); } catch (_) {} } 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 (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'); } 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(); // 执行器只写自己领取的任务检查点,主协调器合并,避免并行页面覆盖整份清单。 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; 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; } } 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); log('info', '已释放' + (kind === 'video' ? '视频/课程' : '考试') + '执行权', null, lease, 'lease.released'); } } } function waitForLease(task, lease, reason) { const previous = task.leaseWait; task.leaseWait = { previousStatus: previous?.previousStatus || (task.status === 'waitingLease' ? 'pending' : task.status), previousReason: previous ? previous.previousReason : task.reason || '', lastLoggedAt: previous?.lastLoggedAt, since: previous?.since == null ? now() : previous.since, kind: task.kind === 'course' ? 'video' : task.kind, owner: lease?.owner || '', taskId: lease?.taskId || '', expires: Number(lease?.expires) || 0, pageUrl: lease?.pageUrl || '', reason }; if (!previous && activeTaskId === task.id) { // 等待执行权期间取消本页旧动作;重新领取后仍须按当前页面重验。 if (task.kind === 'video') { const snap = snapshot(); const video = snap.document && describePage(snap.document, snap.url).video; if (video) video.pause(); playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); if (state.video) state.video.status = 'waitingLease'; } if (task.kind === 'exam' && task.examRequested && task.leaseWait.previousStatus !== 'waiting') { task.examNeedsRecheck = true; if (typeof options.onExamPause === 'function') options.onExamPause('本页已失去考试执行权,停止旧自动作答并等待重新核验'); } } const secondsLeft = Math.max(0, Math.ceil((task.leaseWait.expires - now()) / 1000)); const holder = lease?.title || lease?.taskId || '另一页面任务'; const message = reason === 'control' ? '正在重新核对全局任务状态,暂不操作当前页面' : '等待' + (task.leaseWait.kind === 'exam' ? '考试' : '视频/课程') + '执行权:' + holder + '由另一个页面处理,约 ' + secondsLeft + ' 秒后检查;执行权释放后自动继续'; taskState(task, 'waitingLease', '同类任务由另一个页面处理,自动等待并重试'); state.message = message; leaseBanner = { taskId: task.id, message }; if (!previous || previous.owner !== task.leaseWait.owner || previous.taskId !== task.leaseWait.taskId || previous.reason !== reason || now() - (previous.lastLoggedAt || 0) >= 15000) { task.leaseWait.lastLoggedAt = now(); log('info', message, task, Object.assign({ ownOwner: owner }, task.leaseWait), 'lease.wait'); } } function acquiredLease(task) { if (!task.leaseWait) return; const waited = task.leaseWait; if (task.status === 'waitingLease') { task.status = waited.previousStatus || 'pending'; task.reason = waited.previousReason || ''; } delete task.leaseWait; if (leaseBanner?.taskId === task.id) { if (state.message === leaseBanner.message) state.message = '已取得' + (task.kind === 'exam' ? '考试' : '视频/课程') + '执行权,正在继续任务'; leaseBanner = null; } log('info', '等待结束,已取得执行权并重新核对任务', task, { waitedMs: Math.max(0, now() - waited.since), kind: waited.kind, previousOwner: waited.owner }, 'lease.resumed'); } 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')) { waitForLease(task, null, 'control'); return false; } if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) { waitForLease(task, lease, 'busy'); return false; } if (lease && lease.expires <= now()) log('info', '原执行权已到期,重新领取任务', task, lease, 'lease.expired'); const wanted = { owner, taskId: task.id, title: task.title, pageUrl: canonical(snapshot().url), sessionId: session.id, expires: now() + LEASE_MS, updatedAt: now() }; 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); if (!saved || saved.owner !== owner || saved.taskId !== task.id || saved.sessionId !== session.id) { waitForLease(task, saved, 'race'); return false; } acquiredLease(task); log('info', '已取得' + (kind === 'exam' ? '考试' : '视频/课程') + '执行权', task, wanted, 'lease.acquired'); return true; } 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; if (lease.expires <= now()) log('info', '本页原执行权已到期,核对所有者后恢复', task, lease, 'lease.renewed'); lease.expires = now() + LEASE_MS; lease.updatedAt = now(); lease.pageUrl = canonical(snapshot().url); lease.title = task.title; 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 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' && !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.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', message: 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 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; 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); 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); 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 || ''; for (const task of state.tasks) if (task.status === 'waiting' && task.reason === '另一个页面正在处理同类任务,避免重复领取') { task.status = 'waitingLease'; task.leaseWait = { previousStatus: 'pending', previousReason: '', since: now() }; } 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; 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); } } 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); } // 完整导航/受控刷新后的播放器需要新的加载窗口,不能沿用旧页的超时。 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(); } function syncManualConfirmation(shared) { if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return; const revision = Number(shared.manualConfirmRevision) || 0; 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) { 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; 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); 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, 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'; 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; 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 (!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) { 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(); } if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停'); try { await control(state.status, false, Boolean(manualPatch), manualPatch); } finally { if (pendingManualConfirm === manualPatch) pendingManualConfirm = null; } 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(); 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(); 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); 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(); 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) || ['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(); } 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) { 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') { 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))) 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(); 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) { 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 (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.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, 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'; 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'; 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, 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(); } } catch (error) { if (epoch === generation) { log('error', '课中题处理发生异常', task, { errorName: error.name, error: error.message, stack: error.stack }, 'quiz.error'); 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 ((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'; if (!playStarted) { playStarted = true; lastAutoPlayAt = now(); try { const promise = video.play(); if (promise && promise.catch) await promise; } catch (error) { log('warn', '播放器启动失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.playFailed'); 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 (error) { log('warn', '播放器续播失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.resumeFailed'); 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 observeExamState(task, readOnly) { // 仅观察已经交给本场控制器的考试,避免将旧场次结果附到尚未开考的任务。 if (!task.examRequested) return; const exam = typeof options.getExamState === 'function' ? options.getExamState() : null; if (!exam) return; 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 (!readOnly && ['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理'); 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 (task.examRequested && task.examNeedsRecheck) { await observeExamState(task, true); if (task.status === 'completed') return; } 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); if (task.status !== 'completed' && 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.examNeedsRecheck || 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) task.examNeedsRecheck = false; if (accepted === false) { if (!task.readyWaitAt) task.readyWaitAt = now(); if (now() - task.readyWaitAt > 15000) taskState(task, 'waiting', '试卷未完整加载,请人工核对后继续'); } } else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理'); } await observeExamState(task, false); } 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) { 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); 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(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; requirementProbe = null; } let task = state.tasks.find(t => t.id === activeTaskId); 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) || 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; 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; } } } if (task && epoch === generation) { const held = await heartbeat(task) || await claim(task); if (held) { 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); 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, null, { errorName: error.name, error: error.message, stack: error.stack }, 'learning.error'); return getState(); } finally { busy = false; } } async function observeControl(value) { if (!session) return false; const controlEpoch = generation; const shared = value === undefined ? await storage.get(CONTROL_KEY) : value; 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(); 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); 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(() => {}); } return { configure, start, pause, resume, stop, tick, getState, getAttemptPolicy, destroy, log, clearLogs }; } return { create, describePage, discoverTasks, preflightTopic }; });