1108 lines
92 KiB
JavaScript
1108 lines
92 KiB
JavaScript
/* 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 };
|
||
});
|