Files
yianxue-assistant/learning-engine.js

1108 lines
92 KiB
JavaScript
Raw Permalink 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', '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 };
});