785 lines
46 KiB
JavaScript
785 lines
46 KiB
JavaScript
// ==UserScript==
|
||
// @name 学员中心 · 2倍速学习助手
|
||
// @namespace local.icampus.study
|
||
// @version 1.3.0
|
||
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
|
||
// @match https://icampus.yunyinsh.com/course/*
|
||
// @run-at document-start
|
||
// @grant none
|
||
// @sandbox raw
|
||
// @noframes
|
||
// ==/UserScript==
|
||
|
||
(() => {
|
||
'use strict';
|
||
const normalize = text => String(text || '').normalize('NFKC').replace(/\s+/g, '').trim();
|
||
function route(hash) {
|
||
const [path, query = ''] = hash.replace(/^#/, '').split('?');
|
||
return {list: path === '/my-course', id: path === '/my-course/learn' ? new URLSearchParams(query).get('id') : null};
|
||
}
|
||
function endpoint(raw, base) {
|
||
try {
|
||
const u = new URL(raw, base);
|
||
if (u.origin !== new URL(base).origin) return null;
|
||
const m = u.pathname.match(/^\/smtp\/api\/my-courses\/(packages|\d+)(?:\/(.*))?$/);
|
||
return m ? {id: m[1], tail: m[2] || ''} : null;
|
||
} catch { return null; }
|
||
}
|
||
function bodyJSON(body) {
|
||
try { return typeof body === 'string' && body.length < 65536 ? JSON.parse(body) : null; } catch { return null; }
|
||
}
|
||
function examPerfect(c) { return !!c && Number(c.examScorePercent)===100 && (c.examPassed===true || c.examPassed==='true'); }
|
||
function courseDone(c) { return !!c && (c.status === 'COMPLETED' || !!c.completedAt) && (!c.examRequired || examPerfect(c)); }
|
||
function videoPlan(detail) {
|
||
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
|
||
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
|
||
const next = resources.find(r => r.completed !== true);
|
||
if (next) return {kind: next.type === 'VIDEO' ? 'video' : 'manual', resource: next};
|
||
if (detail.course.examRequired && !examPerfect(detail.course)) return {kind: 'quiz'};
|
||
return {kind: 'finish'};
|
||
}
|
||
function resolveCards(cards, courses) {
|
||
return cards.map(card => {
|
||
const matches = courses.filter(c => normalize(c.courseName) === normalize(card.title));
|
||
return {card, course: matches.length === 1 ? matches[0] : null};
|
||
});
|
||
}
|
||
function leaseAvailable(lease, owner, now) { return !lease || lease.owner === owner || lease.until <= now; }
|
||
function questionKey(q) {
|
||
return JSON.stringify([q.type, normalize(q.title), normalize(q.group), q.options.map(o => normalize(o.text)).sort()]);
|
||
}
|
||
const selectionKey = texts => JSON.stringify(texts.map(normalize).sort());
|
||
function chooseAnswer(q, entry) {
|
||
const texts = q.options.map(o => normalize(o.text));
|
||
if (!['single','multi'].includes(q.type) || !texts.length || texts.length > 8 || texts.some(t => !t) || new Set(texts).size !== texts.length) return null;
|
||
if (entry?.correct?.every(t => texts.includes(t)) && entry.correct.length) return entry.correct;
|
||
const ordered = [...texts].sort();
|
||
const candidates = q.type === 'single' ? ordered.map(t => [t]) :
|
||
Array.from({length: 2 ** ordered.length - 1}, (_, n) => ordered.filter((_, i) => ((n + 1) & (1 << i)) !== 0));
|
||
return candidates.find(c => !(entry?.wrong || []).includes(selectionKey(c))) || null;
|
||
}
|
||
function learnCard(bank, snapshot, rows) {
|
||
let learned = 0;
|
||
for (const row of rows) {
|
||
if (typeof row.correct !== 'boolean') continue;
|
||
const q = snapshot.questions.find(q => q.id === String(row.questionId));
|
||
if (!q) continue;
|
||
const keys = q.type === 'multi' ? row.selectedOptionKeys : [row.selectedOptionKey];
|
||
if (!Array.isArray(keys) || !keys.length || keys.some(k => typeof k !== 'string')) continue;
|
||
const options = keys.map(k => q.options.find(o => o.key === k));
|
||
if (options.some(o => !o)) continue;
|
||
const texts = options.map(o => normalize(o.text));
|
||
// Only learn feedback about exactly the submitted choice, never a stale answer card.
|
||
if (selectionKey(texts) !== selectionKey(q.chosen)) continue;
|
||
const key = questionKey(q), entry = bank[key] || {wrong: []};
|
||
if (row.correct) entry.correct = texts;
|
||
else {
|
||
if (entry.correct && selectionKey(entry.correct) === selectionKey(texts)) delete entry.correct;
|
||
entry.wrong = [...new Set([...(entry.wrong || []), selectionKey(texts)])];
|
||
}
|
||
bank[key] = entry; ++learned;
|
||
}
|
||
return learned;
|
||
}
|
||
function quizEndpoint(raw, base) {
|
||
try {
|
||
const u = new URL(raw, base); if (u.origin !== new URL(base).origin) return null;
|
||
let m = u.pathname.match(/^\/smtp\/api\/my-courses\/(\d+)\/exam$/);
|
||
if (m) return {kind:'exam', id:m[1]};
|
||
m = u.pathname.match(/^\/smtp\/api\/my-courses\/exams\/(\d+)\/(answers|submit)$/);
|
||
if (m) return {kind:m[2], examId:m[1]};
|
||
m = u.pathname.match(/^\/smtp\/api\/my-courses\/exam-papers\/(\d+)\/answer-card$/);
|
||
return m ? {kind:'card', paperId:m[1]} : null;
|
||
} catch { return null; }
|
||
}
|
||
const core = {normalize, route, endpoint, bodyJSON, courseDone, examPerfect, videoPlan, resolveCards, leaseAvailable,
|
||
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint};
|
||
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
|
||
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
|
||
|
||
const LEASE_KEY = 'icampus.study.v1.lease';
|
||
const BANK_KEY = 'icampus.study.v1.answers';
|
||
const SNAPSHOT_KEY = 'icampus.study.v1.lastExam';
|
||
const RESUME_KEY = 'icampus.study.v1.recovery';
|
||
const BACKGROUND_KEY = 'icampus.study.v1.background';
|
||
const INTENT_KEY = 'icampus.study.v1.enabled';
|
||
let armed = false;
|
||
let waiting = null;
|
||
let backgroundEnabled = true;
|
||
try { backgroundEnabled = localStorage.getItem(BACKGROUND_KEY) !== 'off'; } catch {}
|
||
let foregroundGraceUntil = 0;
|
||
let mediaLoad = null;
|
||
let recoveryUsed = new Set();
|
||
let bank;
|
||
let lastExam;
|
||
try { bank = JSON.parse(localStorage.getItem(BANK_KEY) || '{}'); } catch { bank = {}; }
|
||
if (!bank || typeof bank !== 'object' || Array.isArray(bank)) bank = {};
|
||
try { lastExam = JSON.parse(localStorage.getItem(SNAPSHOT_KEY) || 'null'); } catch { lastExam = null; }
|
||
if (!lastExam || !Array.isArray(lastExam.questions)) lastExam = null;
|
||
const exams = new Map();
|
||
let quiz = null;
|
||
let autoQuiz;
|
||
let quizOnly = false;
|
||
const VERIFIED_ANSWERS = [{"question":"在“2.11”辽宁先达公司较大爆炸事故中,以下哪项是导致爆炸发生的直接原因?( )","answerText":"一操未对物料复核确认,二操错误地将丙酰三酮与氯代胺同时加入储罐内反应"},{"question":"关于“4.6”成都精济药业实验室电气火灾事故,以下哪项不是该事故的间接原因?( )","answerText":"实验室内电气线路短路,引燃周围可燃物"},{"question":"“6.13”浙江温岭液化石油气槽罐车重大爆炸事故的直接原因是?( )","answerText":"驾驶员在弯道路段未及时减速导致车辆侧翻,罐体与护栏碰撞破裂解体"},{"question":"根据对黎巴嫩贝鲁特港“8.4”特大爆炸事故的分析,以下哪项不是事故的间接原因?( )","answerText":"焊接维修作业产生的火花引燃了仓库中的烟花爆竹"},{"question":"“9.19”中科(广东)炼化公司闪爆事故的直接原因是?( )","answerText":"作业人员在切割法兰时,砂轮机产生的火花引燃了污水池内的环氧乙烷爆炸性气体"},{"question":"“4.22”上海金山胜瑞电子科技公司较大火灾事故的直接原因是?( )","answerText":"作业人员黄某在车间内违章吸烟,引燃周边杂物"},{"question":"在“5.29”上海石化公司爆燃事故中,以下哪项不是事故的间接原因?( )","answerText":"作业人员打开了轻石脑油进料界区阀门,造成物料从盲板法兰处泄漏"}];
|
||
const owner = crypto.randomUUID();
|
||
const details = new Map();
|
||
let courses = [];
|
||
let packagesAt = 0;
|
||
let running = false;
|
||
let busy = false;
|
||
let runNumber = 0;
|
||
let status = '就绪:刷新后,在课程列表或学习页点击开始。';
|
||
let panel;
|
||
let mode;
|
||
let active = null;
|
||
let routeSince = Date.now();
|
||
let lastHash = location.hash;
|
||
let navigatingUntil = 0;
|
||
let courseNavigationPending = false;
|
||
let resourceSwitch = null;
|
||
let expectedCourse = null;
|
||
let verifyAfterBack = null;
|
||
let mustObservePackagesAfter = 0;
|
||
let lastNetworkAt = 0;
|
||
let startAt = 0;
|
||
const logs = [];
|
||
const visible = el => !!el && el.getClientRects().length > 0;
|
||
const currentVideo = () => document.querySelector('video.player__video');
|
||
function log(message) {
|
||
logs.push({time: new Date().toLocaleTimeString(), message: String(message).replace(/https?:\/\/\S+/g, '[链接]').slice(0, 300)});
|
||
if (logs.length > 60) logs.shift();
|
||
}
|
||
function show(message) { if (status !== message) { status = message; log(message); } render(); }
|
||
function readLease() { try { return JSON.parse(localStorage.getItem(LEASE_KEY) || 'null'); } catch { return null; } }
|
||
const isHidden = () => document.visibilityState === 'hidden';
|
||
const backgroundActive = () => backgroundEnabled && isHidden();
|
||
const waitLimit = normal => backgroundActive() ? Math.max(normal,180000) : normal;
|
||
function holdLease() {
|
||
if (!leaseAvailable(readLease(), owner, Date.now())) return false;
|
||
try { localStorage.setItem(LEASE_KEY, JSON.stringify({owner, until: Date.now() + waitLimit(12000)})); }
|
||
catch { return false; }
|
||
return readLease()?.owner === owner;
|
||
}
|
||
function releaseLease() { try { if (readLease()?.owner === owner) localStorage.removeItem(LEASE_KEY); } catch {} }
|
||
function restoreVisibilityHandling() {
|
||
if (isHidden()) document.dispatchEvent(new Event('visibilitychange'));
|
||
}
|
||
function setBackgroundEnabled(enabled) {
|
||
backgroundEnabled = !!enabled;
|
||
try { localStorage.setItem(BACKGROUND_KEY,backgroundEnabled?'on':'off'); } catch {}
|
||
if(!backgroundEnabled) restoreVisibilityHandling();
|
||
log(backgroundEnabled?'已开启切标签页继续播放。':'已关闭后台模式,恢复网页的标签页切换行为。');
|
||
render();
|
||
}
|
||
function onVisibilityChange(event) {
|
||
if(!isHidden()) {
|
||
foregroundGraceUntil=Date.now()+20000;
|
||
render(); return; // Let the website restart its normal timer on return.
|
||
}
|
||
if(!running || !backgroundEnabled || !route(location.hash).id) return;
|
||
if(!holdLease()) { park('其他学习页接管了助手,本页已暂停。'); return; }
|
||
// The course component's hidden handler stops its heartbeat timer.
|
||
// Keep the existing timer alive; leave true visibility and all requests unchanged.
|
||
event.stopImmediatePropagation();
|
||
log('已切到后台,继续正常播放并等待平台保存。'); render();
|
||
}
|
||
document.addEventListener('visibilitychange',onVisibilityChange,true);
|
||
function workSignature() {
|
||
const root=document.querySelector('.course-quiz');
|
||
return JSON.stringify([location.hash,root?.textContent,
|
||
[...(root?.querySelectorAll('.quiz-option.is-selected') || [])].map(b=>b.textContent),
|
||
[...(root?.querySelectorAll('input, textarea') || [])].map(b=>b.value),
|
||
document.querySelector('.course-head__top')?.textContent,
|
||
currentVideo()?.paused,currentVideo()?.readyState,
|
||
[...document.querySelectorAll('article.course-row')].map(b=>b.textContent)]);
|
||
}
|
||
function persistIntent() {
|
||
try {
|
||
if(armed) sessionStorage.setItem(INTENT_KEY,JSON.stringify({enabled:true,mode:mode?.value || 'all',
|
||
quizOnly,autoQuiz:autoQuiz?.checked!==false,used:[...recoveryUsed]}));
|
||
else sessionStorage.removeItem(INTENT_KEY);
|
||
} catch { /* Run remains active in this page if session storage is unavailable. */ }
|
||
}
|
||
function park(message = '等待页面恢复。', {passive=false}={}) {
|
||
running = false; ++runNumber;
|
||
const video = active?.video;
|
||
active = null;
|
||
mediaLoad = null;
|
||
try { sessionStorage.removeItem(RESUME_KEY); } catch {}
|
||
if (video && !video.paused) video.pause();
|
||
restoreVisibilityHandling();
|
||
waiting=armed?{message,passive,signature:workSignature(),nextAt:Date.now()+15000}:null;
|
||
releaseLease(); show(armed?`已启动 · 等待处理:${message}`:message);
|
||
}
|
||
function stop() {
|
||
armed=false; waiting=null; persistIntent();
|
||
park('已人工取消自动运行。');
|
||
}
|
||
function safeCourse(c) {
|
||
if (!c || typeof c.id !== 'string') return null;
|
||
return Object.fromEntries(['id', 'courseName', 'status', 'completedAt', 'examRequired', 'examPassed', 'examScorePercent', 'progressPercent'].map(k => [k, c[k]]));
|
||
}
|
||
function receive(meta, response, httpStatus) {
|
||
if (!meta) return;
|
||
if (meta.kind) return receiveQuiz(meta, response, httpStatus);
|
||
const relevant = meta.id === route(location.hash).id || meta.id === 'packages';
|
||
if (relevant && running && (httpStatus === 401 || httpStatus === 403)) return park('登录状态失效或无权限,请重新登录后刷新。');
|
||
if (!response || typeof response !== 'object' || response.bizCode === undefined) return;
|
||
if (response.bizCode !== 0) {
|
||
if (running && relevant) park(`平台未接受操作(${response.bizCode}):${response.message || '请查看页面提示'}`);
|
||
return;
|
||
}
|
||
const data = response.data;
|
||
if (meta.id === 'packages' && Array.isArray(data)) {
|
||
courses = data.flatMap(p => Array.isArray(p.courses) ? p.courses : []).map(safeCourse).filter(Boolean);
|
||
packagesAt = Date.now(); return;
|
||
}
|
||
if (!meta.tail && data?.course && Array.isArray(data.resources)) {
|
||
const course = safeCourse(data.course);
|
||
if (!course || course.id !== meta.id) return;
|
||
details.set(meta.id, {course, at: Date.now(), resources: data.resources.map(r =>
|
||
Object.fromEntries(['id','name','type','sortNo','completed','playable','currentPositionSeconds','durationSeconds'].map(k => [k, r[k]])))});
|
||
}
|
||
if (/^sessions\/[^/]+\/heartbeat$/.test(meta.tail) && data) {
|
||
const resourceId = meta.body?.resourceId;
|
||
const detail = details.get(meta.id);
|
||
const resource = detail?.resources.find(r => r.id === resourceId);
|
||
if (resource) {
|
||
if (typeof data.resourceCompleted === 'boolean') resource.completed = data.resourceCompleted;
|
||
if (typeof data.courseStatus === 'string') detail.course.status = data.courseStatus;
|
||
if (typeof data.courseProgressPercent === 'number') detail.course.progressPercent = data.courseProgressPercent;
|
||
}
|
||
if (active && active.courseId === meta.id && active.id === resourceId) {
|
||
lastNetworkAt = Date.now(); active.saved = true;
|
||
active.serverPosition = data.currentPositionSeconds;
|
||
}
|
||
}
|
||
render();
|
||
}
|
||
// 只旁观原网页的详情/心跳响应;不发送、重放或改写学习记录。
|
||
function observe(raw, body) {
|
||
const examMeta = quizEndpoint(raw, location.href);
|
||
if (examMeta) return {...examMeta, body: bodyJSON(body)};
|
||
const meta = endpoint(raw, location.href);
|
||
if (!meta || (meta.tail && !/^sessions(?:\/[^/]+\/(heartbeat|end))?$/.test(meta.tail))) return null;
|
||
return {...meta, body: bodyJSON(body)};
|
||
}
|
||
const originalFetch = window.fetch;
|
||
if (originalFetch) window.fetch = function (...args) {
|
||
const promise = Reflect.apply(originalFetch, this, args);
|
||
let meta;
|
||
try { meta = observe(typeof args[0] === 'string' || args[0] instanceof URL ? String(args[0]) : args[0]?.url, args[1]?.body); } catch {}
|
||
if (meta) promise.then(async response => {
|
||
if (Number(response.headers.get('content-length')) > 524288) return;
|
||
const copy = response.clone();
|
||
const reader = copy.body?.getReader();
|
||
if (!reader) return;
|
||
const decoder = new TextDecoder(); let text = ''; let size = 0;
|
||
try {
|
||
for (;;) {
|
||
const chunk = await reader.read(); if (chunk.done) break;
|
||
size += chunk.value.byteLength;
|
||
if (size > 524288) { void reader.cancel().catch(() => {}); return; }
|
||
text += decoder.decode(chunk.value, {stream: true});
|
||
}
|
||
text += decoder.decode(); receive(meta, JSON.parse(text), response.status);
|
||
} catch { /* 旁观失败不影响原请求。 */ }
|
||
}).catch(() => {});
|
||
return promise;
|
||
};
|
||
const xhrMeta = new WeakMap();
|
||
const originalOpen = XMLHttpRequest.prototype.open;
|
||
const originalSend = XMLHttpRequest.prototype.send;
|
||
XMLHttpRequest.prototype.open = function (...args) {
|
||
const result = Reflect.apply(originalOpen, this, args);
|
||
xhrMeta.set(this, {url: args[1]}); return result;
|
||
};
|
||
XMLHttpRequest.prototype.send = function (...args) {
|
||
const meta = observe(xhrMeta.get(this)?.url, args[0]);
|
||
if (meta) this.addEventListener('loadend', function () {
|
||
try {
|
||
if (this.responseType === 'json') receive(meta, this.response, this.status);
|
||
else if ((!this.responseType || this.responseType === 'text') && this.responseText.length <= 524288)
|
||
receive(meta, JSON.parse(this.responseText), this.status);
|
||
} catch {}
|
||
}, {once: true});
|
||
return Reflect.apply(originalSend, this, args);
|
||
};
|
||
|
||
function persistQuiz() {
|
||
// Keep a bounded local bank; never store access tokens, media URLs or account details.
|
||
const entries = Object.entries(bank).slice(-2000);
|
||
bank = Object.fromEntries(entries);
|
||
try {
|
||
localStorage.setItem(BANK_KEY, JSON.stringify(bank));
|
||
if (lastExam) localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(lastExam));
|
||
return true;
|
||
} catch { if (running) park('答案记忆保存失败,请检查浏览器本地存储空间。'); return false; }
|
||
}
|
||
function savedAnswersMatch(snapshot, body) {
|
||
if (!Array.isArray(body?.answers) || body.answers.length !== snapshot.questions.length) return false;
|
||
return snapshot.questions.every(q => {
|
||
const answer = body.answers.find(a => a.questionId === q.id);
|
||
if (!answer) return false;
|
||
const keys = q.type === 'multi' ? answer.selectedOptionKeys : [answer.selectedOptionKey];
|
||
if (!Array.isArray(keys) || !keys.length) return false;
|
||
const options = keys.map(k => q.options.find(o => o.key === k));
|
||
return options.every(Boolean) && selectionKey(options.map(o => o.text)) === selectionKey(q.chosen);
|
||
});
|
||
}
|
||
function receiveQuiz(meta, response, httpStatus) {
|
||
const id = route(location.hash).id;
|
||
const relevant = meta.kind === 'exam' ? meta.id === id :
|
||
meta.kind === 'card' ? lastExam?.courseId === id && lastExam.paperId === meta.paperId :
|
||
exams.get(id)?.examId === meta.examId;
|
||
if (!relevant) return;
|
||
if (httpStatus === 401 || httpStatus === 403 || (response?.bizCode !== undefined && response.bizCode !== 0)) {
|
||
if (running) park(`测验操作失败:${response?.message || '登录状态或权限异常'}`); return;
|
||
}
|
||
if (response?.bizCode !== 0) return;
|
||
const data = response.data;
|
||
if (meta.kind === 'exam' && data && typeof data.examId === 'string') {
|
||
exams.set(id, {examId:data.examId, paperId:String(data.paperId || data.assessmentPaperId || data.examId),
|
||
status:data.status, maxAttempts:data.maxAttempts, remainingAttempts:data.remainingAttempts,
|
||
canRetry:data.canRetry, attemptsExhausted:data.attemptsExhausted,
|
||
questionIds:Array.isArray(data.questions) ? data.questions.map(q => String(q.questionId)) : [], at:Date.now()});
|
||
} else if (meta.kind === 'card' && Array.isArray(data) && lastExam?.submitted === true) {
|
||
const count = learnCard(bank, lastExam, data);
|
||
if (count) { persistQuiz(); log(`已根据平台逐题反馈更新 ${count} 道题的答案记忆。`); }
|
||
} else if (meta.kind === 'answers' && lastExam?.examId === meta.examId && savedAnswersMatch(lastExam, meta.body)) {
|
||
lastExam.answersSaved = true; persistQuiz(); finishQuizSubmission(id);
|
||
} else if (meta.kind === 'submit' && lastExam?.examId === meta.examId && data) {
|
||
lastExam.grade = Object.fromEntries(['scorePercent','passed','canRetry','attemptsExhausted','remainingAttempts'].map(k => [k,data[k]]));
|
||
finishQuizSubmission(id);
|
||
}
|
||
}
|
||
function finishQuizSubmission(id) {
|
||
if (lastExam?.courseId === id && lastExam.answersSaved && lastExam.grade && !lastExam.submitted) {
|
||
const data = lastExam.grade;
|
||
lastExam.submitted = true;
|
||
if (Number(data.scorePercent) === 100) {
|
||
for (const q of lastExam.questions) bank[questionKey(q)] = {correct:q.chosen.map(normalize), wrong:[]};
|
||
log('本次100%正确,已保存整卷答案。');
|
||
}
|
||
const exam = exams.get(id); if (exam) Object.assign(exam, {status:'SUBMITTED', canRetry:data.canRetry,
|
||
attemptsExhausted:data.attemptsExhausted, remainingAttempts:data.remainingAttempts});
|
||
const course = details.get(id)?.course;
|
||
if (course && typeof data.passed === 'boolean') { course.examPassed=data.passed; course.examScorePercent=data.scorePercent; }
|
||
persistQuiz();
|
||
if (quiz?.courseId === id) { quiz.phase='result'; quiz.since=Date.now(); }
|
||
}
|
||
}
|
||
function quizButton(root, text) {
|
||
return [...root.querySelectorAll('button')].find(b => visible(b) && normalize(b.textContent) === text && !b.disabled);
|
||
}
|
||
function readQuestions(root) {
|
||
const nodes = [...root.querySelectorAll('article.quiz-question')];
|
||
return nodes.map(node => {
|
||
const options = [...node.querySelectorAll('button.quiz-option')].map(button => ({
|
||
key:button.querySelector('.quiz-option__key')?.textContent.trim(),
|
||
text:button.querySelector('.quiz-option__text')?.textContent.trim(),
|
||
selected:button.classList.contains('is-selected'), button}));
|
||
return {id:node.getAttribute('data-qid'), title:node.querySelector('.quiz-question__title')?.textContent,
|
||
group:'', type:node.querySelector('.quiz-options--multi') ? 'multi' : options.length ? 'single' : 'unsupported',
|
||
unsupported:!!node.querySelector('img, input, textarea') || !!root.querySelector('.quiz-material'), options};
|
||
});
|
||
}
|
||
function pureQuestion(q, chosen) {
|
||
return {id:q.id,title:q.title,type:q.type,group:q.group,
|
||
options:q.options.map(o => ({key:o.key,text:o.text})),chosen};
|
||
}
|
||
function quizWait(message, timeout = 25000) {
|
||
show(message);
|
||
if (Date.now() - quiz.since > waitLimit(timeout)) park('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
|
||
}
|
||
function tickQuiz(id) {
|
||
if (autoQuiz && !autoQuiz.checked && !quizOnly) return park('自动测验已关闭,请开启后继续。');
|
||
if (!quiz || quiz.courseId !== id) quiz = {courseId:id,phase:'idle',since:Date.now(),round:0};
|
||
const root = document.querySelector('.course-quiz');
|
||
if (!root) return quizWait('等待课程测验区域加载…');
|
||
const result = root.querySelector('.quiz-result');
|
||
if (result && visible(result)) {
|
||
// A previously passed course has no retake button; do not resubmit it.
|
||
if (result.querySelector('.quiz-result__status.is-pass')) {
|
||
const course = details.get(id)?.course;
|
||
if (course) course.examPassed = true;
|
||
const percent = result.querySelector('.quiz-result__score strong')?.textContent.trim();
|
||
const score=percent && /^\d+(\.\d+)?%$/.test(percent) ? Number(percent.replace('%','')) : course?.examScorePercent;
|
||
if(course && score!==undefined) course.examScorePercent=score;
|
||
if(Number(score)===100) {
|
||
log('测验已达到100%,允许继续。');
|
||
if(quizOnly) return park('本门测验已达到100%,保持已启动,等待后续操作。',{passive:true});
|
||
quiz=null; return;
|
||
}
|
||
// A lower platform pass mark does not satisfy the user's 100% gate.
|
||
if(!quizButton(root,'重新测验')) return park(`当前正确率${score ?? '未知'}%,等待重考入口;达到100%前不切换视频或课程。`);
|
||
}
|
||
if (quiz.phase === 'opening' || quiz.phase === 'retrying') return quizWait('正在打开测验…');
|
||
const exam = exams.get(id);
|
||
if (exam?.canRetry === false || exam?.attemptsExhausted || root.querySelector('.quiz-result__exhausted'))
|
||
return park('平台答题次数已用完,已保留答案记忆。');
|
||
const retry = quizButton(root, '重新测验');
|
||
if (!retry) return park('等待重新测验入口出现后继续重考。');
|
||
++quiz.round;
|
||
quiz.phase='retrying'; quiz.since=Date.now(); quiz.plan=null;
|
||
retry.click(); show('未合格,读取上次逐题反馈后重新作答…'); return;
|
||
}
|
||
if (quiz.phase === 'confirming') {
|
||
const dialogs = [...document.querySelectorAll('.van-dialog')].filter(visible);
|
||
const dialog = dialogs.find(d => normalize(d.querySelector('.van-dialog__header')?.textContent) === '确认交卷');
|
||
if (dialog) {
|
||
const confirm = quizButton(dialog, '确定交卷');
|
||
if (confirm) { quiz.phase='submitting'; quiz.since=Date.now(); confirm.click(); show('已确认交卷,等待平台评分…'); return; }
|
||
}
|
||
return quizWait('等待交卷确认框…');
|
||
}
|
||
if (quiz.phase === 'submitting' || quiz.phase === 'result') return quizWait('等待平台返回测验结果…', 45000);
|
||
const questions = readQuestions(root);
|
||
if (!questions.length) {
|
||
if (quiz.phase === 'opening' || quiz.phase === 'retrying') return quizWait('正在加载试卷…');
|
||
const start = quizButton(root, '开始测验');
|
||
if (!start) return quizWait('等待可用的测验入口…');
|
||
quiz.phase='opening'; quiz.since=Date.now(); start.click(); show('正在开始本门测验…'); return;
|
||
}
|
||
const exam = exams.get(id);
|
||
if (!exam || exam.status === 'SUBMITTED' || questions.length !== exam.questionIds.length ||
|
||
questions.some(q => !exam.questionIds.includes(q.id))) return quizWait('核对当前试卷数据…');
|
||
if (quiz.lastClick) {
|
||
const pending=quiz.lastClick;
|
||
const option=questions.find(q=>q.id===pending.id)?.options.find(o=>o.key===pending.key);
|
||
if (option?.selected===pending.want) quiz.lastClick=null;
|
||
else {
|
||
if(Date.now()-pending.at>waitLimit(8000)) park('选项点击后没有生效,已暂停,请检查页面。');
|
||
return;
|
||
}
|
||
}
|
||
if (!quiz.plan) {
|
||
const planned = [];
|
||
for (const q of questions) {
|
||
if (q.unsupported || !q.title || !q.id || q.options.some(o => !o.key))
|
||
return park('本卷包含图片、材料题、填空或问答题,需要手动作答;已保留其他答案记忆。');
|
||
const key = questionKey(q);
|
||
const seed = VERIFIED_ANSWERS.find(a => normalize(a.question) === normalize(q.title));
|
||
if (!bank[key] && seed && q.type === 'single' && q.options.some(o => normalize(o.text) === normalize(seed.answerText)))
|
||
bank[key] = {correct:[normalize(seed.answerText)],wrong:[]};
|
||
const choice = chooseAnswer(q, bank[key]);
|
||
if (!choice) return park('某题的可用选项已全部尝试,或选项无法唯一识别,请人工核对。');
|
||
planned.push(pureQuestion(q, choice));
|
||
}
|
||
const signature = qs => JSON.stringify(qs.map(q => [questionKey(q),selectionKey(q.chosen)]).sort());
|
||
if (lastExam?.courseId === id && lastExam.submitted && signature(lastExam.questions) === signature(planned))
|
||
return park('未取得能改变答案的逐题反馈,已暂停,避免反复提交同一份答案。');
|
||
quiz.plan = planned; quiz.phase='answering'; quiz.since=Date.now();
|
||
show(`自动作答:共 ${questions.length} 题,优先使用已确认答案。`);
|
||
}
|
||
for (const planned of quiz.plan) {
|
||
const q = questions.find(q => q.id === planned.id);
|
||
if (!q || questionKey(q) !== questionKey(planned)) return park('作答过程中题目发生变化,已暂停。');
|
||
for (const option of q.options) {
|
||
const want = planned.chosen.includes(normalize(option.text));
|
||
if (q.type === 'single' ? want && !option.selected : want !== option.selected) {
|
||
quiz.lastClick={id:q.id,key:option.key,want,at:Date.now()};
|
||
option.button.click(); quiz.since=Date.now(); return;
|
||
}
|
||
}
|
||
if (selectionKey(q.options.filter(o => o.selected).map(o => o.text)) !== selectionKey(planned.chosen))
|
||
return park('页面选项状态与计划不一致,已暂停。');
|
||
}
|
||
// Desktop uses the side answer-card button; the mobile footer is hidden there.
|
||
const submit = quizButton(root,'交卷');
|
||
if (!visible(submit) || submit.disabled) return quizWait('等待交卷按钮可用…');
|
||
lastExam = {courseId:id,examId:exam.examId,paperId:exam.paperId,questions:quiz.plan,
|
||
answersSaved:false,submitted:false};
|
||
if (!persistQuiz()) return;
|
||
quiz.phase='confirming'; quiz.since=Date.now(); submit.click(); show('作答完成,正在交卷…');
|
||
}
|
||
|
||
function goBack(forVerification) {
|
||
const back = document.querySelector('.van-nav-bar__left');
|
||
if (!back) return park('找不到返回列表按钮,请手动返回课程列表后继续。');
|
||
if (forVerification) verifyAfterBack = route(location.hash).id;
|
||
mustObservePackagesAfter = Date.now();
|
||
navigatingUntil = Date.now() + 20000;
|
||
courseNavigationPending = true;
|
||
active = null;
|
||
back.click();
|
||
show('正在返回列表,等待平台核对课程状态…');
|
||
}
|
||
function tickList() {
|
||
if (!packagesAt || packagesAt < mustObservePackagesAfter) {
|
||
if (Date.now() - routeSince > waitLimit(25000)) park('未收到新的课程列表数据,请刷新页面,助手会自动接续。');
|
||
return;
|
||
}
|
||
if (verifyAfterBack) {
|
||
const previous = courses.find(c => c.id === verifyAfterBack);
|
||
if (!courseDone(previous)) return park('视频已播完,但平台尚未确认整门课程完成。请查看课程状态后继续。');
|
||
log('平台已确认上一门课程完成。'); verifyAfterBack = null;
|
||
}
|
||
const cards = [...document.querySelectorAll('article.course-row')].filter(visible)
|
||
.map(el => ({el, title: el.querySelector('.course-row__title')?.textContent}));
|
||
if (!cards.length) {
|
||
if (Date.now() - routeSince > waitLimit(25000)) park('没有找到课程列表,请检查是否已登录。');
|
||
return;
|
||
}
|
||
const mapped = resolveCards(cards, courses);
|
||
if (mapped.some(x => !x.course)) return park('课程名称重复或列表结构发生变化,请进入具体课程后选择“仅当前课程”。');
|
||
const next = mapped.find(x => !courseDone(x.course));
|
||
if (!next) return park('当前显示的课程均已由平台确认完成。');
|
||
expectedCourse = next.course.id;
|
||
details.delete(expectedCourse);
|
||
navigatingUntil = Date.now() + 20000;
|
||
courseNavigationPending = true;
|
||
next.card.el.click();
|
||
show(`正在打开:${next.course.courseName}`);
|
||
}
|
||
function recoverMedia(id, resourceId, reason) {
|
||
const key = `${id}/${resourceId}`;
|
||
if (recoveryUsed.has(key)) return park(`${reason},自动恢复一次后仍失败,请检查网络并手动刷新。`);
|
||
recoveryUsed.add(key);
|
||
persistIntent();
|
||
try {
|
||
sessionStorage.setItem(RESUME_KEY, JSON.stringify({hash:location.hash, expires:Date.now()+120000,
|
||
mode:mode.value, autoQuiz:autoQuiz?.checked !== false, used:[...recoveryUsed]}));
|
||
} catch { return park(`${reason},无法保存恢复状态,请手动刷新后继续。`); }
|
||
// The website tears down its old session and fetches fresh media on reload.
|
||
// Only this explicit recovery reload resumes; ordinary refreshes stay paused.
|
||
running=false; ++runNumber;
|
||
const v=active?.video; active=null; mediaLoad=null;
|
||
if(v && !v.paused) v.pause();
|
||
releaseLease(); show(`${reason},正在刷新恢复(本视频最多一次)…`);
|
||
location.reload();
|
||
}
|
||
function waitMedia(id, resource, video) {
|
||
const key=`${id}/${resource.id}`;
|
||
if(!mediaLoad || mediaLoad.key!==key) mediaLoad={key,since:Date.now(),overlayTried:false,video:null,src:null,settledAt:0};
|
||
const load=mediaLoad;
|
||
if(video?.error) { recoverMedia(id,resource.id,'视频加载错误'); return false; }
|
||
const staleSource=resourceSwitch?.id===resource.id && video===resourceSwitch.previousVideo && video?.currentSrc===resourceSwitch.previousSrc;
|
||
if(!video || video.readyState<2 || !video.currentSrc || video.seeking || staleSource) {
|
||
load.settledAt=0;
|
||
const elapsed=Date.now()-load.since;
|
||
const overlay=document.querySelector('.player__play-overlay');
|
||
if(!load.overlayTried && elapsed>=10000 && visible(overlay)) {
|
||
load.overlayTried=true; overlay.click();
|
||
}
|
||
show(`正在加载:${resource.name}(${Math.floor(elapsed/1000)}秒)`);
|
||
if(elapsed>=waitLimit(90000)) recoverMedia(id,resource.id,`视频加载超过${waitLimit(90000)/1000}秒`);
|
||
return false;
|
||
}
|
||
if(load.video!==video || load.src!==video.currentSrc || !load.settledAt) {
|
||
load.video=video; load.src=video.currentSrc; load.settledAt=Date.now(); return false;
|
||
}
|
||
// Let the website finish restoring the position and its own initial rate.
|
||
return Date.now()-load.settledAt>=1000;
|
||
}
|
||
async function tickCourse(id) {
|
||
if (expectedCourse && expectedCourse !== id) return park('打开的课程与选定课程不一致,已暂停。');
|
||
expectedCourse = null;
|
||
const detail = details.get(id);
|
||
if (!detail) {
|
||
if (Date.now() - routeSince > waitLimit(20000)) park('未捕获课程详情,请刷新学习页,助手会自动接续。');
|
||
return;
|
||
}
|
||
const plan = videoPlan(detail);
|
||
if(detail.course.examRequired && !examPerfect(detail.course)) {
|
||
const gatedVideo=active?.video || currentVideo();
|
||
if(gatedVideo && !gatedVideo.paused) gatedVideo.pause();
|
||
active=null;
|
||
return tickQuiz(id);
|
||
}
|
||
// 正在播放的资源自然结束后再切换,即便平台提前返回 completed。
|
||
if (active && active.courseId === id && !active.video.ended) return monitor();
|
||
if (active?.video.ended) {
|
||
const resource = detail.resources.find(r => r.id === active.id);
|
||
active.endedAt ||= Date.now();
|
||
if (resource?.completed !== true) {
|
||
show('视频已结束,等待平台确认保存…');
|
||
if (Date.now() - active.endedAt > waitLimit(25000)) park('平台尚未确认视频完成,请返回列表核对后再继续。');
|
||
return;
|
||
}
|
||
log('平台已确认本段视频完成。'); active = null;
|
||
}
|
||
if (plan.kind === 'manual') return park('下一项不是视频,请在页面中完成该学习资源后继续。');
|
||
if (plan.kind === 'quiz' || quiz?.courseId === id) return tickQuiz(id);
|
||
if (plan.kind === 'finish') {
|
||
if (mode.value === 'current') return park('本门视频及测验条件已满足;请返回列表核对最终完成状态。');
|
||
return goBack(true);
|
||
}
|
||
if (plan.kind !== 'video') return;
|
||
const resource = plan.resource;
|
||
const buttons = [...document.querySelectorAll('button.resource-item')];
|
||
const matches = buttons.filter(b => normalize(b.querySelector('.resource-item__title')?.textContent) === normalize(resource.name));
|
||
if (matches.length !== 1) return park('未能唯一识别下一段视频,请手动选择后检查页面。');
|
||
const button = matches[0];
|
||
if (!button.classList.contains('is-active')) {
|
||
if (button.disabled || resource.playable === false) return park('下一段视频尚未解锁,请按平台提示完成前置学习。');
|
||
if (resourceSwitch?.id === resource.id) {
|
||
if (Date.now() - resourceSwitch.at > waitLimit(20000)) park('切换视频没有成功,请手动选择该视频后继续。');
|
||
return;
|
||
}
|
||
resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc};
|
||
mediaLoad = null;
|
||
navigatingUntil = Date.now() + 5000;
|
||
button.click(); show(`正在加载:${resource.name}`); routeSince = Date.now(); return;
|
||
}
|
||
const video = currentVideo();
|
||
if (!waitMedia(id, resource, video)) return;
|
||
resourceSwitch = null;
|
||
// 设置一次,平台如改变速率就停止,避免与播放器循环争抢。
|
||
const run = runNumber;
|
||
active = {video, id: resource.id, courseId: id, src: video.currentSrc,
|
||
lastPosition: video.currentTime, progressedAt: Date.now(), started: Date.now(), saved: false, serverPosition: null,
|
||
phase:'starting', attempts:0, playPending:false, nextPlayAt:0};
|
||
mediaLoad=null;
|
||
lastNetworkAt = Date.now();
|
||
video.playbackRate = 2;
|
||
if (Math.abs(video.playbackRate - 2) > 0.01) return park('播放器未接受2倍速,请检查页面倍速设置。');
|
||
attemptPlay(active,run);
|
||
if(running) show(`正在启动2倍速播放:${resource.name}`);
|
||
}
|
||
function attemptPlay(a, run=runNumber) {
|
||
if(!running || active!==a || a.playPending || a.attempts>=3 || Date.now()<a.nextPlayAt) return;
|
||
a.playPending=true; ++a.attempts; a.nextPlayAt=Date.now()+2000;
|
||
const failed=error => {
|
||
if(!running || run!==runNumber || active!==a) return;
|
||
a.playPending=false;
|
||
if(error?.name==='NotAllowedError') return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续。');
|
||
if(error?.name!=='AbortError') recoverMedia(a.courseId,a.id,'播放启动失败');
|
||
// AbortError commonly means the website is replacing the media source.
|
||
};
|
||
try { Promise.resolve(a.video.play()).then(() => {
|
||
if(!running || run!==runNumber || active!==a) return;
|
||
a.playPending=false;
|
||
},failed); } catch(error) { failed(error); }
|
||
}
|
||
function monitor() {
|
||
const a = active, v = a.video;
|
||
const resource=details.get(a.courseId)?.resources.find(r=>r.id===a.id);
|
||
const selected=document.querySelector('button.resource-item.is-active .resource-item__title');
|
||
if(selected && normalize(selected.textContent)!==normalize(resource?.name)) return park('视频已被手动切换,等待页面稳定后继续。');
|
||
if (!v.isConnected || currentVideo() !== v || v.currentSrc !== a.src) {
|
||
active=null; mediaLoad=null; show('播放器正在重建,等待新视频就绪…'); return;
|
||
}
|
||
if (v.error) return recoverMedia(a.courseId,a.id,'视频播放错误');
|
||
if(a.phase==='starting') {
|
||
if(!v.paused && v.currentTime>a.lastPosition+0.1) { a.phase='playing'; show('2倍速播放中,正在核对平台保存进度。'); }
|
||
else {
|
||
if(Date.now()-a.started>waitLimit(20000)) return recoverMedia(a.courseId,a.id,'视频未能开始播放');
|
||
if(v.paused && v.readyState>=2) attemptPlay(a);
|
||
return;
|
||
}
|
||
}
|
||
if (Math.abs(v.playbackRate - 2) > 0.01) return park('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
|
||
if (v.paused && !v.ended) return park('视频已手动暂停;点击视频播放可继续,或点人工取消关闭助手。',{passive:true});
|
||
if (v.currentTime > a.lastPosition + 0.1) { a.progressedAt = Date.now(); a.lastPosition = v.currentTime; }
|
||
if (Date.now() - a.progressedAt > waitLimit(60000)) return recoverMedia(a.courseId,a.id,'视频长时间没有推进');
|
||
const saveTimeout=waitLimit(75000);
|
||
if (Date.now()>foregroundGraceUntil && Date.now()-lastNetworkAt>saveTimeout)
|
||
return park(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
|
||
}
|
||
async function tick() {
|
||
if(busy || !armed) return;
|
||
if(!running) {
|
||
if(!waiting) return;
|
||
const changed=waiting.signature!==workSignature();
|
||
if(!changed && (waiting.passive || Date.now()<waiting.nextAt)) return;
|
||
if(!holdLease()) { waiting.nextAt=Date.now()+15000; return; }
|
||
waiting=null;running=true;routeSince=Date.now();mediaLoad=null;
|
||
if(quiz) quiz.since=Date.now();
|
||
show('已启动 · 自动检查并继续…');
|
||
}
|
||
busy = true;
|
||
try {
|
||
if (!holdLease()) return park('另一个学习页正在使用助手,或浏览器禁止本地存储。');
|
||
if (Date.now() - startAt < 600) return;
|
||
if (location.hash !== lastHash) {
|
||
const destination=route(location.hash);
|
||
const expected=courseNavigationPending && (expectedCourse ? destination.id===expectedCourse : destination.list);
|
||
lastHash = location.hash; routeSince = Date.now(); active = null; navigatingUntil = 0;
|
||
courseNavigationPending = false; resourceSwitch = null;
|
||
if (!expected) { quiz=null; expectedCourse=null; show('已启动 · 页面已切换,继续检查当前课程。'); }
|
||
}
|
||
if (navigatingUntil > Date.now()) return;
|
||
if (courseNavigationPending) return park('课程页面跳转没有成功,请手动打开课程后继续。');
|
||
const here = route(location.hash);
|
||
if (here.list) tickList();
|
||
else if (here.id) { if (quizOnly) tickQuiz(here.id); else await tickCourse(here.id); }
|
||
else park('请打开“我的课程”列表或课程学习页。');
|
||
render();
|
||
} catch { park('页面结构或播放操作出现异常,请刷新页面后重试。'); }
|
||
finally { busy = false; }
|
||
}
|
||
function render() {
|
||
if (!panel) return;
|
||
panel.querySelector('[data-status]').textContent = status;
|
||
const v = active?.video;
|
||
const course = details.get(route(location.hash).id)?.course;
|
||
panel.querySelector('[data-progress]').textContent = v
|
||
? `实际 ${v.playbackRate.toFixed(1)}倍 · ${Math.floor(v.currentTime / 60)}:${String(Math.floor(v.currentTime % 60)).padStart(2, '0')} / ${Math.floor(v.duration / 60)}:${String(Math.floor(v.duration % 60)).padStart(2, '0')}\n平台进度 ${course?.progressPercent ?? '待确认'}% · ${active.saved ? '已收到保存确认' : '等待保存确认'}`
|
||
: '以平台保存结果为准;电脑需保持唤醒。';
|
||
panel.querySelector('[data-start]').disabled = armed;
|
||
panel.querySelector('[data-start]').textContent = armed ? (running?'已启动 · 运行中':'已启动 · 等待恢复') : '开始';
|
||
panel.querySelector('[data-quiz-only]').disabled = armed;
|
||
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
|
||
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
|
||
mode.disabled = armed;
|
||
}
|
||
function mount() {
|
||
if (!document.body) return;
|
||
const host = document.createElement('div'); host.id = 'icampus-study-helper';
|
||
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
|
||
panel = host.attachShadow({mode: 'open'});
|
||
panel.innerHTML = `<style>:host{font:14px/1.6 system-ui;color:#eaf0fa}section{width:290px;background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:16px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}</style>
|
||
<section><strong>2倍速学习助手 · 1.3.0</strong><p data-status></p><small data-progress></small>
|
||
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
|
||
<div><button data-start>开始</button><button data-stop>人工取消</button></div>
|
||
<label><input type="checkbox" data-quiz checked> 自动测验,达到100%才继续</label>
|
||
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
|
||
<small data-background-state></small>
|
||
<div><button data-quiz-only>只做本门测验</button></div>
|
||
<small>启动后持续运行与等待;只有人工取消才关闭。测验100%后继续视频。</small>
|
||
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
|
||
mode = panel.querySelector('select');
|
||
autoQuiz = panel.querySelector('[data-quiz]');
|
||
const backgroundToggle=panel.querySelector('[data-background]');
|
||
backgroundToggle.checked=backgroundEnabled;
|
||
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
|
||
function beginRun(only, resume) {
|
||
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
|
||
if (only) currentVideo()?.pause();
|
||
quizOnly=only; quiz=null;
|
||
mediaLoad=null; recoveryUsed=new Set(resume?.used || []);
|
||
running = true; ++runNumber; active = null; expectedCourse = null; verifyAfterBack = null;
|
||
armed=true; waiting=null; persistIntent();
|
||
if(!holdLease()) return park('另一个学习页正在运行,等待其释放控制权。');
|
||
startAt = routeSince = Date.now(); lastHash = location.hash; navigatingUntil = 0;
|
||
courseNavigationPending = false; resourceSwitch = null;
|
||
show('已开始,正在核对课程与平台数据…'); void tick();
|
||
}
|
||
panel.querySelector('[data-start]').onclick = () => beginRun(false);
|
||
panel.querySelector('[data-quiz-only]').onclick = () => beginRun(true);
|
||
panel.querySelector('[data-stop]').onclick = () => stop();
|
||
autoQuiz.onchange=()=>{persistIntent();if(waiting)waiting.nextAt=0;};
|
||
panel.querySelector('[data-copy]').onclick = () => { panel.querySelector('pre').textContent = logs.map(x => `${x.time} ${x.message}`).join('\n'); };
|
||
document.body.append(host); render();
|
||
try {
|
||
const resume=JSON.parse(sessionStorage.getItem(RESUME_KEY) || 'null');
|
||
sessionStorage.removeItem(RESUME_KEY);
|
||
if(resume?.hash===location.hash && resume.expires>Date.now() && Array.isArray(resume.used) && resume.used.length<=100) {
|
||
mode.value=resume.mode==='current'?'current':'all'; autoQuiz.checked=resume.autoQuiz!==false;
|
||
beginRun(false,resume);
|
||
} else {
|
||
const intent=JSON.parse(sessionStorage.getItem(INTENT_KEY) || 'null');
|
||
if(intent?.enabled && Array.isArray(intent.used)) {
|
||
mode.value=intent.mode==='current'?'current':'all';autoQuiz.checked=intent.autoQuiz!==false;
|
||
beginRun(!!intent.quizOnly,intent);
|
||
}
|
||
}
|
||
} catch { /* Invalid stored settings: wait for an explicit start. */ }
|
||
}
|
||
window.addEventListener('storage', e => { if (e.key === LEASE_KEY && running && readLease()?.owner !== owner) park('其他学习页接管了助手,本页已暂停。'); });
|
||
window.addEventListener('pagehide', () => { running = false; releaseLease(); });
|
||
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, {once: true});
|
||
setInterval(() => { void tick(); }, 1000);
|
||
})();
|