commit b8d830d231152099b1e9508bc310b1ca06cd98bf Author: 薇星环海 <3032732426@qq.com> Date: Tue Oct 6 01:54:54 2026 +0800 Fix playback recovery after switching course videos diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..177e0bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.js text eol=lf +*.cjs text eol=lf +*.md text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1716ffa --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Keep local captures, network traces and downloaded website bundles local. +*.jpg +*.png +*.json +!package.json +*.reference +*.reference.js +*.md +!README.md +!2倍速脚本使用说明.md +icampus-network-diagnostic.user.js +diagnostic.test.cjs +speed-test.test.cjs +node_modules/ +*.log diff --git a/2倍速脚本使用说明.md b/2倍速脚本使用说明.md new file mode 100644 index 0000000..244216d --- /dev/null +++ b/2倍速脚本使用说明.md @@ -0,0 +1,45 @@ +# 学员中心 · 2倍速学习助手 v1.1.1 + +适用网页:https://icampus.yunyinsh.com/course/ + +## 安装与开始 + +1. 在篡改猴中停用旧的“学员中心 · 本地网络诊断”脚本,避免误触倍速测试。 +2. 打开篡改猴管理面板 → 实用工具 → 从文件导入,选择 `icampus-study-helper.user.js`,按提示安装/更新。同名、同 namespace 的1.0版本应被更新,不要同时启用两份。也可以在原脚本编辑器中用该文件的全部内容替换并保存。 +3. 刷新课程网页,让助手能从页面加载时读取平台返回的课程信息。 +4. 左下角出现“2倍速学习助手”,点击“开始 / 继续”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。 +5. 可选择“仅当前课程”。按“暂停”会暂停助手当前控制的视频;刷新后不会自行启动。 +6. 默认勾选“视频完成后自动做测验”。也可在学习页点击“只做本门测验”,按网页提供的入口直接进入作答流程。 + +## 已实现 + +- 视频以2倍速正常播放,不跳转时间轴。 +- 自然播完且平台确认该视频完成后,切换下一段。 +- 本门视频和测验条件满足后,返回列表核对课程完成状态,然后按列表显示顺序继续。 +- 显示实际倍速、播放位置、平台课程进度和是否收到保存确认。 +- 用户暂停、倍速被改变、视频加载失败、前置课程未完成、平台保存超时等情况自动暂停并提示。 +- 同一网站只允许一个标签页的助手运行。 +- 单选、判断和多选题自动选择选项、交卷、确认交卷,等待平台评分。 +- 未合格时,通过页面的“重新测验”流程观察答题卡反馈;对已确认正确的答案保持不变,答错的题尝试其他选项或组合。 +- 答案按题干与选项文字记忆,选项字母换序不会直接套用旧字母。只在平台明确判定该题正确,或本次整卷100%正确时,将答案记为正确。 +- 答案记忆与最近一份提交快照保存在本机浏览器,刷新后继续使用;内置第一门已验证的7道题答案。 + +## 当前边界 + +- 平台未合格且允许重考时会继续尝试,目标是全部答对。但该网页在达到合格线后隐藏重考按钮;合格线低于100%的课程可能提前合格,无法保证继续重考到100%。 +- 有限次数以平台为准;次数耗尽会暂停。无逐题反馈时不会猜测哪些题正确,也不会反复提交完全相同的答案。 +- 图片题、材料题、填空、问答,以及无法唯一识别选项的题需要手动作答。单选/判断/多选支持每题最多8个不同文字选项;一轮最多自动重试256次。 +- 更新版未在真实的未通过试卷上完成端到端验证。当前第一门已通过且没有重考入口,因此验证使用已加载网页源码与模拟页面/响应,不能把模拟结果当成真实平台成绩。 +- 平台自己发送学习记录;助手只观察必要的课程详情和保存结果,不发送或修改进度请求。 +- 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。 +- 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。 +- 自动恢复刷新会接续当前课程;普通刷新仍默认暂停。临时恢复记录两分钟过期、读取后删除;若手动刷新恰好遇到尚未消费的恢复记录,也可能接续运行。登录失效、前置限制或浏览器拒绝自动播放时,需要手动处理。 +- 保持电脑唤醒,建议让课程页在前台。后台计时尚未验证。 +- 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。 +- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆及最近提交快照。标签页会话存储保存短期刷新恢复记录。不保存登录凭据或视频链接。 + +## 验证情况 + +本地语法检查及41项行为测试通过(16项播放/进度测试、12项测验测试、13项媒体恢复测试)。模拟页面完整跑通开始、选择、交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、无反馈时不重复提交、次数耗尽停止、低于100%合格时不误记整卷全对。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。 + +新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ed478a --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# 学员中心 · 2倍速学习助手 + +适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.1.1**。 + +## 安装 + +下载 `icampus-study-helper.user.js`,在篡改猴管理面板的“实用工具 → 从文件导入”中安装或更新原脚本。刷新课程页,点击左下角“开始 / 继续”。不要同时启用旧诊断脚本。 + +默认按课程列表顺序播放,支持仅当前课程,以及只做本门测验。安装后不会自行开始播放。 + +## 功能 + +- 2倍速正常播放,平台确认完成后切换视频。 +- 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。 +- 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。 +- 自动暂停、异常提示、单标签页运行锁,本机答案记忆。 + +## 1.1.1 修复 + +修复视频切换后不继续播放的问题:为每段视频单独计时,等待新媒体数据就绪和网站恢复播放位置;播放器重建时重新绑定;临时 `AbortError` 最多重试三次。加载卡住时尝试网页播放按钮,90秒仍未就绪则最多刷新恢复一次。播放停滞或启动超时也共享这一次恢复额度。 + +自动恢复会临时保存当前课程与恢复额度,刷新后接续。普通手动刷新仍默认暂停;若恰好发生在待执行的恢复刷新期间,则短期恢复记录可能被该刷新使用。记录两分钟过期,读取后即删除。 + +浏览器明确拒绝自动播放时提示手动点击播放,不会无限刷新。正常播放后的手动暂停仍会停止助手。 + +## 验证 + +运行 `npm test`,无需安装依赖。当前41项本地行为测试覆盖播放、进度保存、测验循环及媒体加载恢复。 + +现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。 + +## 限制 + +计入进度、课程完成和重试次数均以平台返回结果为准。合格后平台若关闭重考入口,无法继续重考到100%。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。 + +更多说明见 [使用说明](2倍速脚本使用说明.md)。 diff --git a/icampus-study-helper.user.js b/icampus-study-helper.user.js new file mode 100644 index 0000000..da2165a --- /dev/null +++ b/icampus-study-helper.user.js @@ -0,0 +1,686 @@ +// ==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); +})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..8126874 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "e-learning-assistant", + "version": "1.1.1", + "private": true, + "description": "学员中心2倍速播放和测验辅助脚本", + "scripts": { + "test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs" + }, + "engines": { "node": ">=18" } +} diff --git a/playback-recovery.test.cjs b/playback-recovery.test.cjs new file mode 100644 index 0000000..ae6cfa3 --- /dev/null +++ b/playback-recovery.test.cjs @@ -0,0 +1,83 @@ +'use strict'; +const assert=require('node:assert/strict'); +const {harness,ID,RID}=require('./study-helper.test.cjs'); +let passed=0; +async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);} +async function start(h){h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();} +function mountPanel(h){ + const elements=new Map(); + const panel={querySelector(s){if(!elements.has(s))elements.set(s,{textContent:'',value:'all',checked:true});return elements.get(s);}}; + h.context.document.body={append(){}}; + h.context.document.createElement=()=>({style:{},attachShadow:()=>panel}); + h.api.mount();return elements; +} +(async()=>{ + await test('new video gets its own load deadline even after a long course',async()=>{ + const h=harness();h.advance(3600000);h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID); + assert.equal(h.api.state.running,true);assert.equal(h.video.playCount,0); + }); + await test('metadata alone does not start playback; loaded data must settle',async()=>{ + const h=harness();h.video.readyState=1;h.api.begin();await h.api.tickCourse(ID);assert.equal(h.video.playCount,0); + h.video.readyState=2;await h.api.tickCourse(ID);assert.equal(h.video.playCount,0); + h.advance(1000);await h.api.tickCourse(ID);assert.equal(h.video.playCount,1);assert.equal(h.video.playbackRate,2); + }); + await test('stuck loading tries the website play overlay once',async()=>{ + const h=harness();let clicks=0;h.selectors.set('.player__play-overlay',{getClientRects:()=>[{}],click(){clicks++;}}); + h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);h.advance(10000);await h.api.tickCourse(ID); + h.advance(10000);await h.api.tickCourse(ID);assert.equal(clicks,1);assert.equal(h.api.state.running,true); + }); + await test('90-second stuck load saves a bounded recovery checkpoint and reloads',async()=>{ + const h=harness();h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);h.advance(90000);await h.api.tickCourse(ID); + assert.equal(h.context.location.reloadCount,1);const saved=JSON.parse(h.session.get('icampus.study.v1.recovery')); + assert.deepEqual(saved.used,[`${ID}/${RID}`]);assert.equal(saved.hash,h.context.location.hash);assert.equal(h.api.state.running,false); + }); + await test('a recovered video cannot enter an infinite reload loop',async()=>{ + const h=harness();h.api.recovered(RID);h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);h.advance(90000);await h.api.tickCourse(ID); + assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/恢复一次/); + }); + await test('source-switch AbortError retries then confirms advancing playback',async()=>{ + const h=harness();let calls=0;h.video.paused=true; + h.video.play=function(){calls++;if(calls===1)return Promise.reject(Object.assign(new Error('loading'),{name:'AbortError'}));this.paused=false;return Promise.resolve();}; + await start(h);assert.equal(h.api.state.running,true);h.advance(2000);h.api.monitor();await Promise.resolve(); + h.video.currentTime+=2;h.api.monitor();assert.equal(calls,2);assert.equal(h.api.state.active.phase,'playing'); + }); + await test('real autoplay permission rejection asks for a click without reloading',async()=>{ + const h=harness();h.video.play=()=>Promise.reject(Object.assign(new Error('blocked'),{name:'NotAllowedError'})); + await start(h);assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/浏览器阻止/);assert.equal(h.context.location.reloadCount,undefined); + }); + await test('rebuilt player returns to readiness checking instead of stopping the helper',async()=>{ + const h=harness();await start(h);const old=h.video;const replacement={...old,currentSrc:'replacement',readyState:0}; + h.selectors.set('video.player__video',replacement);old.isConnected=false;h.api.monitor(); + assert.equal(h.api.state.running,true);assert.equal(h.api.state.active,null); + await h.api.tickCourse(ID);assert.equal(h.api.state.active,null); + replacement.readyState=4;await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID); + assert.equal(h.api.state.active.video,replacement); + }); + await test('ordinary pause after confirmed playback remains respected',async()=>{ + const h=harness();await start(h);h.video.currentTime+=2;h.api.monitor();h.video.paused=true;h.api.monitor(); + assert.equal(h.api.state.running,false);assert.equal(h.video.playCount,1); + }); + await test('switching resource does not play the previous video source',async()=>{ + const h=harness();h.api.details.get(ID).resources[0].completed=true; + let secondActive=false;h.buttons[1].classList.contains=()=>secondActive;h.buttons[1].click=()=>{secondActive=true;}; + h.api.begin();await h.api.tickCourse(ID);h.advance(5000);await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID); + assert.equal(h.video.playCount,0); + h.video.currentSrc='second-video';await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID); + assert.equal(h.video.playCount,1);assert.equal(h.api.state.active.id,'2093583972926885888'); + }); + await test('stop clears pending recovery checkpoint',()=>{ + const h=harness();h.session.set('icampus.study.v1.recovery','{}');h.api.stop();assert.equal(h.session.has('icampus.study.v1.recovery'),false); + }); + await test('recovery reload resumes the same course and consumes its checkpoint',()=>{ + const h=harness();h.session.set('icampus.study.v1.recovery',JSON.stringify({hash:h.context.location.hash,expires:200000,mode:'current',autoQuiz:false,used:[`${ID}/${RID}`]})); + const ui=mountPanel(h);assert.equal(h.api.state.running,true);assert.equal(ui.get('select').value,'current');assert.equal(ui.get('[data-quiz]').checked,false); + assert.equal(h.session.has('icampus.study.v1.recovery'),false); + h.api.recoverMedia(ID,RID,'再次失败');assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.api.state.running,false); + }); + await test('ordinary refresh and mismatched recovery routes remain paused',()=>{ + const h=harness();mountPanel(h);assert.equal(h.api.state.running,false); + const other=harness();other.session.set('icampus.study.v1.recovery',JSON.stringify({hash:'#/other',expires:200000,used:[]})); + mountPanel(other);assert.equal(other.api.state.running,false);assert.equal(other.session.has('icampus.study.v1.recovery'),false); + }); + console.log(`${passed} playback recovery tests passed.`); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/quiz-helper.test.cjs b/quiz-helper.test.cjs new file mode 100644 index 0000000..5ff4bb0 --- /dev/null +++ b/quiz-helper.test.cjs @@ -0,0 +1,127 @@ +'use strict'; +const assert = require('node:assert/strict'); +const core = require('./icampus-study-helper.user.js'); +const {harness, ID} = require('./study-helper.test.cjs'); +let passed=0; +function test(name, fn) { fn(); ++passed; console.log(`PASS ${name}`); } +const q = (id='q1',type='single') => ({id,title:`题目 ${id}`,type,group:'',options:[{key:'A',text:'Alpha'},{key:'B',text:'Beta'}],chosen:['Alpha']}); +test('question identity and remembered choices survive option reorder', () => { + const a=q(), b={...a,options:[{key:'A',text:'Beta'},{key:'B',text:'Alpha'}]}; + assert.equal(core.questionKey(a),core.questionKey(b)); + assert.deepEqual(core.chooseAnswer(b,{correct:['Alpha'],wrong:[]}),['Alpha']); +}); +test('different options or grouped context do not share a remembered answer', () => { + const a=q(), b={...a,group:'另一段材料'}; assert.notEqual(core.questionKey(a),core.questionKey(b)); + assert.notEqual(core.questionKey(a),core.questionKey({...a,options:[{key:'A',text:'different'}]})); +}); +test('single choice excludes confirmed incorrect answers and stops at exhaustion', () => { + assert.deepEqual(core.chooseAnswer(q(),{wrong:['["Alpha"]']}),['Beta']); + assert.equal(core.chooseAnswer(q(),{wrong:['["Alpha"]','["Beta"]']}),null); +}); +test('multi choice explores combinations, including all choices', () => { + const a=q('multi','multi'), wrong=[]; + for(let i=0;i<3;i++) { const choice=core.chooseAnswer(a,{wrong}); assert.ok(choice); wrong.push(core.selectionKey(choice)); } + assert.equal(new Set(wrong).size,3); assert.ok(wrong.includes('["Alpha","Beta"]')); + assert.equal(core.chooseAnswer(a,{wrong}),null); +}); +test('ambiguous options and non-option questions require manual handling', () => { + assert.equal(core.chooseAnswer({...q(),options:[{text:'same'},{text:'same'}]},{}),null); + assert.equal(core.chooseAnswer({...q(),type:'essay'},{}),null); +}); +test('only explicit, matching per-question feedback teaches the bank', () => { + const bank={}, snapshot={questions:[q()]}; + assert.equal(core.learnCard(bank,snapshot,[{questionId:'q1',selectedOptionKey:'A'}]),0); + assert.equal(core.learnCard(bank,snapshot,[{questionId:'q1',selectedOptionKey:'B',correct:true}]),0); + assert.equal(core.learnCard(bank,snapshot,[{questionId:'different',selectedOptionKey:'A',correct:true}]),0); + assert.equal(core.learnCard(bank,snapshot,[{questionId:'q1',selectedOptionKey:'A',correct:true}]),1); + assert.deepEqual(bank[core.questionKey(q())].correct,['Alpha']); +}); +test('quiz observer scopes exact endpoints and origins', () => { + const base='https://icampus.yunyinsh.com/'; + assert.equal(core.quizEndpoint(`/smtp/api/my-courses/${ID}/exam`,base).kind,'exam'); + assert.equal(core.quizEndpoint('/smtp/api/my-courses/exam-papers/999/answer-card',base).kind,'card'); + assert.equal(core.quizEndpoint('/smtp/api/my-courses/exams/999/submit',base).kind,'submit'); + assert.equal(core.quizEndpoint('https://foreign.test/smtp/api/my-courses/exams/999/submit',base),null); +}); + +// Simulates the observed Vue DOM and its normal request/response sequence. +// The userscript must click choices, submit, confirm, read the answer card and retry. +function simulator({feedback=true,exhaust=false,threshold=100}={}) { + const h=harness(); let view='idle', dialog=false, attempt=0, submissions=0; + let answers={}, selected={}, lastCard=[]; + const definitions=[q('q1'),q('q2'),q('q3','multi')]; + const expected={q1:['A'],q2:['B'],q3:['A','B']}; + const clicks=[]; + const element = extra => ({getClientRects:()=>[{}],...extra}); + const button = (text,click) => element({textContent:text,disabled:false,click}); + const begin = () => { + ++attempt; view='form'; selected={}; + for(const d of definitions) selected[d.id]=attempt===1?[]:[...(answers[d.id]||[])]; + h.api.receive({kind:'exam',id:ID},{bizCode:0,data:{examId:String(900+attempt),paperId:'800',status:'IN_PROGRESS',maxAttempts:null, + questions:definitions.map(d=>({questionId:d.id}))}},200); + }; + const retry = button('重新测验',() => { + if(feedback) h.api.receive({kind:'card',paperId:'800'},{bizCode:0,data:lastCard},200); + begin(); + }); + const start=button('开始测验',begin); + const submit=button('交卷',()=>{dialog=true;}); + const confirm=button('确定交卷',()=>{ + dialog=false; ++submissions; answers=structuredClone(selected); + const body={answers:definitions.map(d=>d.type==='multi'?{questionId:d.id,selectedOptionKeys:answers[d.id]}:{questionId:d.id,selectedOptionKey:answers[d.id][0]})}; + lastCard=body.answers.map(a=>({...a,correct:JSON.stringify([...(answers[a.questionId]||[])].sort())===JSON.stringify(expected[a.questionId])})); + const score=lastCard.filter(a=>a.correct).length/definitions.length*100; + const examId=String(900+attempt); + h.api.receive({kind:'answers',examId,body},{bizCode:0,data:{}},200); + h.api.receive({kind:'submit',examId},{bizCode:0,data:{scorePercent:score,passed:score>=threshold,canRetry:!exhaust,attemptsExhausted:exhaust}},200); + view=score>=threshold?'pass':'fail'; + }); + const dialogNode=element({querySelector:s=>s==='.van-dialog__header'?{textContent:'确认交卷'}:null,querySelectorAll:s=>s==='button'?[confirm]:[]}); + const nodes=definitions.map(d=>element({getAttribute:()=>d.id, + querySelector:s=>s==='.quiz-question__title'?{textContent:d.title}:s==='.quiz-options--multi'&&d.type==='multi'?{}:null, + querySelectorAll:s=>s==='button.quiz-option'?d.options.map(o=>element({ + classList:{contains:()=>selected[d.id]?.includes(o.key)}, + querySelector:s=>s==='.quiz-option__key'?{textContent:o.key}:s==='.quiz-option__text'?{textContent:o.text}:null, + click(){clicks.push({attempt,id:d.id,key:o.key});if(d.type==='single')selected[d.id]=[o.key];else selected[d.id]=selected[d.id].includes(o.key)?selected[d.id].filter(k=>k!==o.key):[...selected[d.id],o.key];} + })):[]})); + const root=element({ + querySelector(s){ + if(s==='.quiz-result'&&['pass','fail'].includes(view)) return element({querySelector:s=>s==='.quiz-result__status.is-pass'&&view==='pass'?{}:s==='.quiz-result__score strong'?{textContent:view==='pass'?'100%':'33%'}:null}); + if(s==='.quiz-result__exhausted'&&view==='fail'&&exhaust)return {}; + if(s==='button.quiz-footer__submit'&&view==='form') return submit; + return null; + }, + querySelectorAll(s){if(s==='article.quiz-question')return view==='form'?nodes:[];if(s==='button')return view==='idle'?[start]:view==='fail'?[retry]:[];return [];} + }); + h.selectors.set('.course-quiz',root); + const original=h.context.document.querySelectorAll; + h.context.document.querySelectorAll=s=>s==='.van-dialog'?(dialog?[dialogNode]:[]):original(s); + h.api.begin(); + return {h,clicks,get submissions(){return submissions;},get view(){return view;}, + run(){for(let i=0;i<150&&h.api.state.running&&view!=='pass';i++){h.advance(1000);h.api.tickQuiz(ID);}if(view==='pass')h.api.tickQuiz(ID);}}; +} +test('full DOM workflow reaches 100%, learns correct questions and changes only wrong ones', () => { + const s=simulator();s.run();assert.equal(s.view,'pass');assert.equal(s.submissions,3); + assert.equal(s.clicks.filter(c=>c.id==='q1').length,1); + assert.ok(Object.values(s.h.api.state.bank).every(e=>e.correct?.length)); + assert.equal(Object.keys(s.h.api.state.bank).length,3); + assert.ok(s.h.storage.has('icampus.study.v1.answers')); +}); +test('missing per-question feedback stops before duplicate submission', () => { + const s=simulator({feedback:false});s.run();assert.equal(s.submissions,1);assert.equal(s.h.api.state.running,false); + assert.match(s.h.api.state.status,/逐题反馈/); +}); +test('attempt exhaustion stops without clicking retry', () => { + const s=simulator({exhaust:true});s.run();assert.equal(s.submissions,1);assert.equal(s.h.api.state.running,false); + assert.match(s.h.api.state.status,/次数已用完/); +}); +test('passing below 100 does not falsely teach the whole paper', () => { + const s=simulator({threshold:30});s.run();assert.equal(s.submissions,1);assert.equal(s.view,'pass'); + assert.equal(Object.keys(s.h.api.state.bank).length,0); +}); +test('foreign exam submission cannot teach this attempt', () => { + const s=simulator({feedback:false});s.run(); + s.h.api.receive({kind:'submit',examId:'123456'},{bizCode:0,data:{scorePercent:100,passed:true}},200); + assert.equal(Object.keys(s.h.api.state.bank).length,0); +}); +console.log(`${passed} quiz tests passed.`); diff --git a/study-helper.test.cjs b/study-helper.test.cjs new file mode 100644 index 0000000..f2ebe7e --- /dev/null +++ b/study-helper.test.cjs @@ -0,0 +1,143 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const core = require('./icampus-study-helper.user.js'); +const ID = '2107129233351512064'; +const RID = '2093583772296548352'; +let passed = 0; +async function test(name, fn) { await fn(); ++passed; console.log(`PASS ${name}`); } +function detail(overrides = {}) { + return {course: {id: ID, courseName: '课程一', status: 'LEARNING', completedAt: null, + examRequired: true, examPassed: true}, resources: [ + {id: RID, name: '视频一', type: 'VIDEO', sortNo: 1, completed: false, playable: true}, + {id: '2093583972926885888', name: '视频二', type: 'VIDEO', sortNo: 2, completed: false, playable: true}], ...overrides}; +} +function harness() { + let now = 100000; + class TestDate extends Date { static now() { return now; } } + const storage = new Map(), session = new Map(), selectors = new Map(); + const document = {body: null, getElementById: () => null, addEventListener() {}, + querySelector: s => selectors.get(s) || null, querySelectorAll: s => selectors.get(s) || []}; + class XHR { + open(...args) { this.openArgs = args; } + send(...args) { this.sendArgs = args; } + addEventListener(name, fn) { this[name] = fn; } + } + const pending = new Promise(() => {}); + const window = {fetch() { return pending; }, addEventListener() {}}; + window.self = window.top = window; + const context = {window, document, XMLHttpRequest: XHR, location: { + href: `https://icampus.yunyinsh.com/course/#/my-course/learn?id=${ID}`, hash: `#/my-course/learn?id=${ID}`, reload(){this.reloadCount=(this.reloadCount||0)+1;}}, + URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder, + localStorage: {getItem: k => storage.get(k) ?? null, setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)}, + sessionStorage: {getItem:k=>session.get(k)??null,setItem:(k,v)=>session.set(k,v),removeItem:k=>session.delete(k)}, + setInterval() {}}; + const source = fs.readFileSync('./icampus-study-helper.user.js', 'utf8').replace( + 'setInterval(() => { void tick(); }, 1000);', + `window.testing = {receive, stop, tick, tickCourse, tickQuiz, monitor, details, holdLease, waitMedia, recoverMedia, mount, + recovered(id){recoveryUsed.add('${ID}/'+id);}, + begin(){running=true;runNumber++;mode={value:'all'};startAt=Date.now()-1000;}, + track(v){active={video:v,id:'${RID}',courseId:'${ID}',src:v.currentSrc,lastPosition:v.currentTime,progressedAt:Date.now(),started:Date.now()};lastNetworkAt=Date.now();}, + pending(){courseNavigationPending=true;navigatingUntil=Date.now()-1;}, + get state(){return {running,status,active,courses,packagesAt,busy,bank,lastExam,quiz};}};`); + vm.runInNewContext(source, context); + const api = window.testing; + const video = {currentTime: 120, currentSrc: 'media', readyState: 4, isConnected: true, playbackRate: 2, + paused: false, ended: false, error: null, pauseCount: 0, playCount: 0, + pause() { this.paused = true; ++this.pauseCount; }, + play() { this.paused = false; ++this.playCount; return Promise.resolve(); }}; + selectors.set('video.player__video', video); + const buttons = ['视频一','视频二'].map((name,i) => ({disabled: false, clicks: 0, + classList: {contains: () => i === 0}, querySelector: () => ({textContent: name}), click() { ++this.clicks; }})); + selectors.set('button.resource-item', buttons); + api.receive({id: ID, tail: ''}, {bizCode: 0, data: detail()}, 200); + return {api, video, buttons, context, window, pending, selectors, storage, session, advance(ms) { now += ms; }}; +} +module.exports = {harness, detail, ID, RID}; +if (require.main === module) (async () => { + await test('route preserves identifiers beyond Number.MAX_SAFE_INTEGER', () => { + assert.equal(core.route(`#/my-course/learn?id=${ID}`).id, ID); + }); + await test('observer excludes foreign origins and signed media URLs', () => { + const base = 'https://icampus.yunyinsh.com/course/'; + assert.equal(core.endpoint('https://foreign.test/smtp/api/my-courses/packages',base), null); + assert.equal(core.endpoint('/video.mp4?signature=secret',base), null); + assert.equal(core.endpoint(`/smtp/api/my-courses/${ID}/sessions/s/heartbeat`,base).id, ID); + }); + await test('exam passed alone never marks unfinished videos complete', () => { + assert.equal(core.videoPlan(detail()).kind, 'video'); + assert.equal(core.courseDone(detail().course), false); + }); + await test('resource order, completion and quiz decisions', () => { + const d = detail(); d.resources.reverse(); + assert.equal(core.videoPlan(d).resource.id, RID); + d.resources.forEach(r => r.completed = true); d.course.examPassed = false; + assert.equal(core.videoPlan(d).kind, 'quiz'); + d.course.examPassed = true; assert.equal(core.videoPlan(d).kind, 'finish'); + d.resources[0].completed = false; d.resources[0].type = 'DOCUMENT'; + assert.equal(core.videoPlan(d).kind, 'manual'); + }); + await test('queue follows visible cards, ambiguous titles are rejected', () => { + const courses = [{id:'2',courseName:'B'}, {id:'1',courseName:'A'}]; + assert.deepEqual(core.resolveCards([{title:'A'},{title:'B'}],courses).map(x=>x.course.id), ['1','2']); + assert.equal(core.resolveCards([{title:'A'}],[...courses,{id:'3',courseName:'A'}])[0].course,null); + }); + await test('startup is paused, wrappers preserve original fetch promise and XHR arguments', () => { + const h = harness(); assert.equal(h.api.state.running,false); + assert.equal(h.window.fetch('/smtp/api/my-courses/packages'),h.pending); + const x = new h.context.XMLHttpRequest(); x.open('POST',`/smtp/api/my-courses/${ID}/sessions`,true); x.send(null); + assert.deepEqual(x.openArgs,['POST',`/smtp/api/my-courses/${ID}/sessions`,true]); + assert.deepEqual(x.sendArgs,[null]); + }); + await test('2x applied once and ordinary play called', async () => { + const h=harness(); h.video.paused=true; h.video.playbackRate=1; h.api.begin(); + await h.api.tickCourse(ID); h.advance(1000); await h.api.tickCourse(ID); + assert.equal(h.video.playbackRate,2); assert.equal(h.video.playCount,1); + }); + await test('manual pause stops instead of restarting playback', () => { + const h=harness(); h.api.begin(); h.api.track(h.video); h.video.paused=true; h.api.monitor(); + assert.equal(h.api.state.running,false); assert.equal(h.video.playCount,0); + }); + await test('platform rate reset stops without fighting the setter', () => { + const h=harness(); h.api.begin(); h.api.track(h.video); h.video.playbackRate=1; h.api.monitor(); + assert.equal(h.api.state.running,false); assert.equal(h.video.playbackRate,1); assert.equal(h.video.paused,true); + }); + await test('ended without server completion never clicks next, then stops on timeout', async () => { + const h=harness(); h.api.begin(); h.api.track(h.video); h.video.ended=true; + await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,0); + h.advance(26000); await h.api.tickCourse(ID); + assert.equal(h.api.state.running,false); assert.equal(h.buttons[1].clicks,0); + }); + await test('matching genuine heartbeat plus natural end allows next video', async () => { + const h=harness(); h.api.begin(); h.api.track(h.video); + h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId:RID}}, + {bizCode:0,data:{resourceCompleted:true,currentPositionSeconds:1161,courseProgressPercent:50,courseStatus:'LEARNING'}},200); + await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,0); + h.video.ended=true; await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,1); + }); + await test('heartbeat for another resource cannot confirm this video', () => { + const h=harness(); h.api.begin(); h.api.track(h.video); + h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId:'other'}},{bizCode:0,data:{resourceCompleted:true}},200); + assert.equal(h.api.details.get(ID).resources[0].completed,false); + }); + await test('prerequisite failure stops without retrying', () => { + const h=harness(); h.api.begin(); h.api.track(h.video); + h.api.receive({id:ID,tail:'sessions'},{bizCode:47235,message:'上一个视频尚未完成'},200); + assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/47235/); + }); + await test('missing heartbeat stops even if media continues advancing', () => { + const h=harness(); h.api.begin(); h.api.track(h.video); h.advance(76000); h.video.currentTime+=100; h.api.monitor(); + assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/75秒/); + }); + await test('failed course navigation is bounded', async () => { + const h=harness(); h.api.begin(); h.api.pending(); await h.api.tick(); + assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/跳转/); + }); + await test('another tab lease prevents concurrent controller', () => { + const h=harness(); h.storage.set('icampus.study.v1.lease',JSON.stringify({owner:'other',until:200000})); + assert.equal(h.api.holdLease(),false); + h.advance(110000); assert.equal(h.api.holdLease(),true); + }); + console.log(`${passed} tests passed.`); +})().catch(error => { console.error(error); process.exitCode=1; });