// ==UserScript== // @name 学员中心 · 2倍速学习助手 // @namespace local.icampus.study // @version 1.1.1 // @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'; 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; } } function holdLease() { if (!leaseAvailable(readLease(), owner, Date.now())) return false; try { localStorage.setItem(LEASE_KEY, JSON.stringify({owner, until: Date.now() + 12000})); } catch { return false; } return readLease()?.owner === owner; } function releaseLease() { try { if (readLease()?.owner === owner) localStorage.removeItem(LEASE_KEY); } catch {} } 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(); 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 > 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>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 > 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 > 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>=90000) recoverMedia(id,resource.id,'视频加载超过90秒'); 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 > 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 > 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 > 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() { 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>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 > 60000) return recoverMedia(a.courseId,a.id,'视频超过60秒没有推进'); if (Date.now() - lastNetworkAt > 75000) return stop('超过75秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。'); } 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 expected = navigatingUntil > Date.now(); 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; 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 = `
2倍速学习助手 · 1.1.1

错题根据平台反馈重试;合格后平台可能不允许重考。
运行记录
`; mode = panel.querySelector('select'); autoQuiz = panel.querySelector('[data-quiz]'); 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); })();