Files
icampus-learning-assistant/icampus-study-helper.user.js
T

728 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
// @version 1.2.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 courseDone(c) { return !!c && (c.status === 'COMPLETED' || !!c.completedAt); }
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 && detail.course.examPassed !== true) 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, 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';
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()) { stop('其他学习页接管了助手,本页已暂停。'); 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 stop(message = '已暂停;点击开始可继续。') {
running = false; ++runNumber;
const video = active?.video;
active = null;
mediaLoad = null;
try { sessionStorage.removeItem(RESUME_KEY); } catch {}
if (video && !video.paused) video.pause();
restoreVisibilityHandling();
releaseLease(); show(message);
}
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 stop('登录状态失效或无权限,请重新登录后刷新。');
if (!response || typeof response !== 'object' || response.bizCode === undefined) return;
if (response.bizCode !== 0) {
if (running && relevant) stop(`平台未接受操作(${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) stop('答案记忆保存失败,请检查浏览器本地存储空间。'); 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) stop(`测验操作失败:${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)) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
}
function tickQuiz(id) {
if (autoQuiz && !autoQuiz.checked && !quizOnly) return stop('自动测验已关闭,请开启后继续。');
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();
log(`测验已合格(${percent || '平台确认'})。`);
if (quizOnly) return stop(`测验已合格(${percent || '平台确认'}),答案已按可用反馈保存。`);
quiz = null; return;
}
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 stop('平台答题次数已用完,已保留答案记忆。');
const retry = quizButton(root, '重新测验');
if (!retry) return stop('本次未合格,但页面没有可用的重新测验按钮。');
if (++quiz.round > 256) return stop('本轮重试达到256次,已暂停,请检查是否存在无法判断的题目。');
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)) stop('选项点击后没有生效,已暂停,请检查页面。');
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 stop('本卷包含图片、材料题、填空或问答题,需要手动作答;已保留其他答案记忆。');
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 stop('某题的可用选项已全部尝试,或选项无法唯一识别,请人工核对。');
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 stop('未取得能改变答案的逐题反馈,已暂停,避免反复提交同一份答案。');
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 stop('作答过程中题目发生变化,已暂停。');
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 stop('页面选项状态与计划不一致,已暂停。');
}
const submit = root.querySelector('button.quiz-footer__submit');
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 stop('找不到返回列表按钮,请手动返回课程列表后继续。');
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)) stop('未收到新的课程列表数据,请刷新页面后开始。');
return;
}
if (verifyAfterBack) {
const previous = courses.find(c => c.id === verifyAfterBack);
if (!courseDone(previous)) return stop('视频已播完,但平台尚未确认整门课程完成。请查看课程状态后继续。');
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)) stop('没有找到课程列表,请检查是否已登录。');
return;
}
const mapped = resolveCards(cards, courses);
if (mapped.some(x => !x.course)) return stop('课程名称重复或列表结构发生变化,请进入具体课程后选择“仅当前课程”。');
const next = mapped.find(x => !courseDone(x.course));
if (!next) return stop('当前显示的课程均已由平台确认完成。');
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 stop(`${reason},自动恢复一次后仍失败,请检查网络并手动刷新。`);
recoveryUsed.add(key);
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 stop(`${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 stop('打开的课程与选定课程不一致,已暂停。');
expectedCourse = null;
const detail = details.get(id);
if (!detail) {
if (Date.now() - routeSince > waitLimit(20000)) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
return;
}
const plan = videoPlan(detail);
// 正在播放的资源自然结束后再切换,即便平台提前返回 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)) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
return;
}
log('平台已确认本段视频完成。'); active = null;
}
if (plan.kind === 'manual') return stop('下一项不是视频,请在页面中完成该学习资源后继续。');
if (plan.kind === 'quiz' || quiz?.courseId === id) return tickQuiz(id);
if (plan.kind === 'finish') {
if (mode.value === 'current') return stop('本门视频及测验条件已满足;请返回列表核对最终完成状态。');
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 stop('未能唯一识别下一段视频,请手动选择后检查页面。');
const button = matches[0];
if (!button.classList.contains('is-active')) {
if (button.disabled || resource.playable === false) return stop('下一段视频尚未解锁,请按平台提示完成前置学习。');
if (resourceSwitch?.id === resource.id) {
if (Date.now() - resourceSwitch.at > waitLimit(20000)) stop('切换视频没有成功,请手动选择该视频后继续。');
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 stop('播放器未接受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 stop('浏览器阻止自动播放,请手动点击网页“点击播放”,然后点助手继续。');
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 stop('视频已被手动切换,点击开始可继续。');
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 stop('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
if (v.paused && !v.ended) return stop('视频已暂停;点击助手开始可继续。');
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 stop(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
}
async function tick() {
if (!running || busy) return;
busy = true;
try {
if (!holdLease()) return stop('另一个学习页正在使用助手,或浏览器禁止本地存储。');
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) return stop('页面已被手动切换;点击开始可继续。');
}
if (navigatingUntil > Date.now()) return;
if (courseNavigationPending) return stop('课程页面跳转没有成功,请手动打开课程后继续。');
const here = route(location.hash);
if (here.list) tickList();
else if (here.id) { if (quizOnly) tickQuiz(here.id); else await tickCourse(here.id); }
else stop('请打开“我的课程”列表或课程学习页。');
render();
} catch { stop('页面结构或播放操作出现异常,请刷新页面后重试。'); }
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 = running;
panel.querySelector('[data-quiz-only]').disabled = running;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
mode.disabled = running;
}
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.2.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> 视频完成后自动做测验</label>
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
<small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div>
<small>错题根据平台反馈重试;合格后平台可能不允许重考。</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 (!holdLease()) 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;
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();
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);
}
} catch { /* Ordinary reload or invalid checkpoint: wait for the user. */ }
}
window.addEventListener('storage', e => { if (e.key === LEASE_KEY && running && readLease()?.owner !== owner) stop('其他学习页接管了助手,本页已暂停。'); });
window.addEventListener('pagehide', () => { running = false; releaseLease(); });
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, {once: true});
setInterval(() => { void tick(); }, 1000);
})();