Add overall progress and completion estimates with autoplay recovery

This commit is contained in:
2026-10-06 03:01:32 +08:00
parent d80c490f48
commit 8023a802f1
8 changed files with 493 additions and 78 deletions
+1
View File
@@ -8,6 +8,7 @@
*.md
!README.md
!2倍速脚本使用说明.md
!CHANGELOG.md
icampus-network-diagnostic.user.js
diagnostic.test.cjs
speed-test.test.cjs
+186 -37
View File
@@ -1,47 +1,196 @@
# 学员中心 · 2倍速学习助手 v1.3.1
# 学员中心 · 2倍速学习助手使用说明
适用网页:https://icampus.yunyinsh.com/course/
适用版本:**1.4.0**。浏览器设置核对日期:**2026-10-06**。
## 安装与开始
[仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md)
1. 在篡改猴中停用旧的“学员中心 · 本地网络诊断”脚本,避免误触倍速测试。
2. 打开篡改猴管理面板 → 实用工具 → 从文件导入,选择 `icampus-study-helper.user.js`,按提示安装/更新。同名、同 namespace 的1.0版本应被更新,不要同时启用两份。也可以在原脚本编辑器中用该文件的全部内容替换并保存。
3. 刷新课程网页,让助手能从页面加载时读取平台返回的课程信息。
4. 左下角显示“2倍速学习助手 · 1.3.1”。已启动的标签页刷新后会接续;首次使用点击“开始”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
5. 可选择“仅当前课程”。开始后始终保持启用,等待评分或处理异常不要求再次点开始;刷新同一标签页也会接续。只有按“人工取消”才关闭助手并暂停当前控制的视频。
6. 默认勾选“自动测验,按平台合格线通过”。自动作答、交卷,未合格才重考;合格后继续视频。学习进度100%且测验合格后自动下一门,不要求测验得分100%。也可点击“只做本门测验”。
7. 默认勾选“切标签页继续播放”。先启动助手,再切到其他标签页;无需一直把课程页放在前台。关闭开关会恢复网站原有的隐藏页面处理。
## 1. 安装篡改猴和配置Chrome
## 已实现
本项目用于桌面Chrome的 [学员中心课程页](https://icampus.yunyinsh.com/course/),匹配 `https://icampus.yunyinsh.com/course/*`。其他学习平台、手机浏览器和其他脚本管理器未做兼容验证。
- 视频以2倍速正常播放,不跳转时间轴。
- 自然播完且平台确认该视频完成后,切换下一段。
- 本门视频和测验条件满足后,返回列表核对课程完成状态,然后按列表显示顺序继续。
- 显示实际倍速、播放位置、平台课程进度和是否收到保存确认。
- 视频暂停、倍速变化、加载失败、前置未完成或保存超时等情况转入“已启动 · 等待处理”,条件恢复后继续;人工取消才清除启用状态。
- 同一网站只允许一个标签页的助手运行。
- 单选、判断和多选题自动选择选项、交卷、确认交卷,等待平台评分。
- 未合格时,通过页面的“重新测验”流程观察答题卡反馈;对已确认正确的答案保持不变,答错的题尝试其他选项或组合。
- 答案按题干与选项文字记忆,选项字母换序不会直接套用旧字母。只在平台明确判定该题正确,或本次整卷100%正确时,将答案记为正确。
- 答案记忆与最近一份提交快照保存在本机浏览器,刷新后继续使用;内置第一门已验证的7道题答案。
篡改猴(Tampermonkey/油猴)是运行脚本的浏览器扩展。先安装扩展,再导入本仓库脚本;普通用户无需Node.js或Git。
## 当前边界
1. 用Chrome普通窗口访问 [官方商店页面](https://chromewebstore.google.com/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo),点击“添加至Chrome”。也可通过 [篡改猴官网](https://www.tampermonkey.net/index.php?browser=chrome) 的正式版商店入口安装。
2. 点击工具栏拼图图标,找到Tampermonkey,可固定到工具栏方便使用。
3. 地址栏输入 `chrome://extensions`,进入Tampermonkey“详情”,按下表配置。
4. 已安装用户无需重装;检查开关后继续安装脚本。
- 当前现场试卷显示不限次数,新版不设置脚本的重考次数上限。测验按平台判定是否合格;学习进度100%且测验合格就换课,例如75%达到60%的合格线也会继续。
- 如果平台返回次数耗尽、入口不可用或没有逐题反馈,保持启用并等待处理,不反复提交完全相同的答案。
- 图片题、材料题、填空、问答,以及无法唯一识别选项的题需要手动作答。单选/判断/多选支持每题最多8个不同文字选项。
- 现场已确认电脑端交卷在侧栏、手机端底部按钮被隐藏;新版本已经修正选择器,但尚未安装新版完成真实自动重考到100%的端到端验证。
- 平台自己发送学习记录;助手只观察必要的课程详情和保存结果,不发送或修改进度请求。
- 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。
- 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。
- 自动恢复刷新及普通刷新都会接续已启用的任务,人工取消后刷新不会启动。登录失效、前置限制或浏览器拒绝自动播放时保持启用、等待人工处理。
- 保持电脑唤醒。后台模式已实现并通过模拟测试,但尚未完成真实标签页的长时间实测。Chrome 降低后台定时器频率时切视频、答题可能变慢;后台超时窗口延长至至少180秒,仍无保存响应则暂停。
- 电脑休眠、浏览器关闭或标签页被冻结/丢弃时不能保证继续运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”。
- 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。
- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆、最近提交快照和后台模式偏好。标签页会话存储保存运行开关与刷新恢复记录。不保存登录凭据或视频链接。
| 项目 | 设置 |
| --- | --- |
| 扩展启用 | Tampermonkey扩展开关保持开启 |
| Chrome 138及以上 | 详情页开启“允许用户脚本 / Allow User Scripts” |
| Chrome 138以前的适用版本 | 扩展列表右上角开启“开发者模式” |
| 网站访问权限 | 允许 `https://icampus.yunyinsh.com` 自动运行,可选择“在特定网站上”添加 |
| 在无痕模式下启用 | 需要无痕使用时开启“Allow in incognito” |
| 篡改猴开关 | 篡改猴总开关、已安装的本脚本均启用 |
| 允许访问文件网址 | 本脚本的文件导入、复制安装和HTTPS页面运行不需要 |
## 验证情况
Chrome版本可在 `chrome://settings/help` 查看。新版Chrome不要仅打开开发者模式便认为权限已经齐全,应检查“允许用户脚本”。版本对应规则见 [Chrome用户脚本文档](https://developer.chrome.com/docs/extensions/reference/api/userScripts)、[篡改猴Q209](https://www.tampermonkey.net/faq.php?locale=zh&q=Q209)。
本地语法检查及64项行为测试通过(16项播放/进度测试、13项测验测试、13项媒体恢复测试、12项后台播放测试、10项持续运行与完成条件测试)。新增“学习100%+测验75%合格后自动下一课程”、学习未完成继续视频、学习进度不能替代测验合格等回归。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许无痕。网站权限应自动允许课程站点,而不是每次点击扩展才临时授权。相关设置见 [Chrome扩展管理帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
修改权限后刷新课程页。本脚本需要在页面加载时观察课程数据;保持原始脚本匹配规则、运行时机及沙盒设置,不需要额外修改高级选项。浏览器内部地址需复制到地址栏,通常不能从仓库直接点击打开。
## 2. 下载文件导入
1. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js)。
2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。
3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。
4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。
5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.0**,按提示确认安装或更新。仅选择文件不等于安装完成。
6. 回到“已安装脚本”,确认开关开启。
7. 刷新课程页,确认左下角版本为 **1.4.0**。
篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。
点击仓库文件后只显示源码也是正常的,可继续下载或复制。不要将SSH仓库地址粘贴到“从URL安装”中。
## 3. 复制代码安装
1. 打开脚本的“原始文件 / Raw”,或用文本编辑器打开下载的 `.user.js`。
2. 全选复制全部代码,包括最前面的 `// ==UserScript==` 配置块和文件末尾。
3. 篡改猴图标 →“添加新脚本 / Create a new script”。
4. 点击脚本编辑区域,`Ctrl+A`全选并删除默认模板,再粘贴全部代码。
5. 按 `Ctrl+S`,或“文件 → 保存”。确认名称、版本和启用开关。
6. 刷新课程页,检查助手面板版本。
不要复制行号、网页其他内容或Markdown代码围栏;不要粘贴到地址栏、浏览器控制台或URL导入框。
**更新旧版本:** 在管理面板点击原脚本进入编辑器,替换全部代码并保存即可,不必新建。文件导入时选择更新原脚本,保留原始名称与namespace,避免两份同时运行。
## 4. 开始、继续和停止
1. 停用旧诊断脚本、倍速实验脚本和重复助手。
2. 登录网站,打开“我的课程”,展开要学习的分组,清除不需要的筛选,确认目标课程已显示。
3. 选择“按列表顺序继续课程”,或进入具体课程选择“仅当前课程”。
4. 默认保留自动测验、后台播放和受限时尝试静音三个勾选,点击“开始”。
5. 需要停止时点击“人工取消”,之后刷新不会自动开始。
| 控件/状态 | 含义 |
| --- | --- |
| 按列表顺序继续课程 | 从当前显示列表首个未完成课程开始;已在课程内时先处理当前课程 |
| 仅当前课程 | 处理当前课程后等待,不继续其他课程 |
| 只做本门测验 | 先进入课程再点击,只处理该课程测验 |
| 自动测验,按平台合格线通过 | 自动答题、交卷和未合格重试;关闭后未合格测验需手动处理 |
| 切标签页继续播放 | 运行期间保留网站正常后台播放与保存流程 |
| 播放受限时尝试静音 | 有声自动播放被拒绝时尝试一次静音;成功后面板显示“静音” |
| 已启动 · 等待恢复 | 仍启用,查看提示;普通等待会定期检查或随页面变化接续 |
| 人工取消 | 关闭自动运行并暂停助手控制的视频 |
| 运行记录 → 更新记录 | 显示当前页的近期运行记录 |
更换运行范围或“只做本门测验”前,先人工取消。启动状态保存在当前标签页会话中,同一页普通刷新和自动恢复刷新都可接续。新标签页、浏览器重启、结束无痕会话不保证保留。手动暂停视频时,助手等待手动播放,不抢占暂停操作。
## 5. 完成条件与检查范围
换课条件为 **学习进度完成,且需要测验的课程已由平台判定合格**。有明确学习进度时检查100%;没有该字段才参考平台课程完成标记。合格线由平台判断,脚本不根据分数猜测或写死阈值。
| 状态 | 行为 |
| --- | --- |
| 学习100%,测验75%,平台已合格 | 返回列表核对后下一课程 |
| 学习100%,测验75%,平台未合格 | 继续处理测验 |
| 学习75%,测验已合格 | 继续未完成视频 |
| 学习100%,不要求测验 | 返回列表核对后下一课程 |
| 页面显示完成但尚无平台确认 | 等待,不直接切换 |
视频自然播完且平台确认该资源完成才切换。课程完成后返回列表读取新状态,核对后继续;仅当前课程模式不换课。
自动队列基于当前列表已加载、已显示的课程卡片,按页面顺序处理。不会自动展开所有分组、翻页或扫描账号全部课程。显示范围完成后仍保持启用并等待;不能将提示理解为账号全部课程已检查。
## 6. 整体进度和预计完成时间
面板显示:
- **整体完成:** 已完成课程数÷统计范围课程数。完成必须同时满足学习和测验要求。
- **平均学习进度:** 各课程平台学习百分比的算术平均,按门数而非时长加权,与整体完成分开。
- **待合格测验:** 需要测验但平台还未判定合格的课程数。
- **预计剩余:** 尚需播放的时长按2倍速换算,再预留测验和切换时间。
- **预计完成:** 当前时间加预计剩余,使用浏览器本地日期与时间;停止/等待时显示“等待开始或恢复后计算”。
### 统计范围
默认统计本页面已经收到的课程列表数据,显示“已读取课程列表”。仅当前课程模式只统计当前课;只做测验模式不预测结束时间。直接进入课程、尚未读取列表时显示“未读取列表,暂统计当前课程”,不要把这个百分比当成账号总体。
列表数据中有但页面未展开的课程可能计入统计,却不在自动队列可点击的范围。请展开目标分组以对应运行范围。刷新后需重新读取列表与时长样本;要看整体,先打开“我的课程”再继续运行。
### 估算方法和局限
已读到详情的课程使用各视频时长减去平台返回的已保存位置;完成资源记为零。未读取时长的课程按已知完整视频课程的平均时长,结合其学习百分比粗估,面板明确标记“粗估”和涉及门数。
时间预算另包含每门未合格测验 **2分钟**、每门未完成课程切换 **15秒**。这些是规划预留,不是平台保证。反复重考、网络加载、后台节流、等待人工或保存确认会延后;不同课程长短差异大时粗估误差也会变大。
没有可用时长样本时显示暂无法估算;已知存在未完成的非视频资源时提示需人工处理。暂停时不提供继续倒数的完成时刻。统计范围均完成时显示剩余0分钟。**所有统计和估算仅用于展示,不参与平台完成判定或自动跳课。**
## 7. 自动播放卡住,刷新是否有用
本次实际无痕页面提示“浏览器阻止自动播放,请手动点击网页‘点击播放’”。日志中反复出现播放拒绝;通过网页真实播放按钮恢复后,已观察到2倍速和平台保存确认。
**刷新可以重新加载页面并接续已启动状态,但不能保证解除浏览器的自动播放限制。** 页面加载异常、未捕获课程详情时可以刷新;明确提示自动播放受限时,优先点击网页“点击播放”。
1.4.0默认开启“播放受限时尝试静音”:有声播放被拒绝后尝试一次静音播放,使用Chrome正常允许的播放方式;不修改浏览器安全设置。成功后可在播放器恢复声音;不需要此行为时取消勾选。若静音也被拒绝,则保持启用、等待点击,不再仅靠定时器反复重试或自动刷新。浏览器机制见 [Chrome自动播放说明](https://developer.chrome.com/blog/autoplay/)。
不要在正在手动填写尚未保存答案时直接刷新。普通刷新不会清除本地答案记忆和启动意图,但页面尚未提交的输入可能丢失。
## 8. 自动测验与答案记忆
当前流程可能先处理本门未合格测验,再继续视频。支持每题最多8个不同文字选项的单选、判断、多选。
自动选择 → 点击当前可见“交卷” → 确认“确定交卷” → 等待评分。未合格时使用网页“重新测验”入口,根据逐题反馈保留正确答案、排除错误选项或组合,继续重试。没有脚本自设次数上限;已合格不再追求100分。
按题型、题干和选项文字匹配,选项字母换序不会直接照搬字母。仅平台明确反馈该题正确,或本次提交整卷确实100%正确时记为正确;75分合格的试卷不会整卷误记全对。内置第一门课程已验证的7道题答案,其余从反馈中逐步积累。
图片、材料、填空、问答、重复或不能识别的选项需人工处理。平台次数耗尽、入口不可用、缺少必要反馈或无新答案可试时等待,不反复提交相同答案,也不保证每种试卷自动通过。
## 9. 后台和无痕
先开始,再切其他标签页或将浏览器放后台,保持后台开关勾选。电脑保持唤醒、联网,浏览器和课程页保持打开。
Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活跃的网站”添加 `icampus.yunyinsh.com`。笔记本节能也可能减少后台活动,可根据电源情况调整。参考 [Chrome性能帮助](https://support.google.com/chrome/answer/12929150?hl=en)。
后台模式保留网站原有的正常播放和进度保存定时器,不改写真实页面可见状态、视频位置,不伪造或重放进度请求。关闭后台模式、停止助手或离开页面后恢复正常处理。后台等待和运行锁窗口至少180秒,回前台后给保存20秒恢复时间;仍无保存确认会等待处理。休眠、关闭浏览器、冻结/丢弃标签页、断网仍无法保证继续。
无痕使用需允许扩展无痕运行并刷新实际课程页。普通和无痕站点数据隔离,答案记忆、开关及状态不自动共用;所有无痕窗口关闭后站点数据通常清除。运行锁仅在同一站点存储环境内生效,不跨普通/无痕或不同浏览器,同账号请只运行一个页面。
## 10. 更新、停用和数据
仓库推送不会自动更新已安装代码,当前未配置仓库自动更新地址。重新导入最新脚本或在原编辑器替换并保存,然后刷新普通/无痕课程页检查面板版本。原本已启动的页刷新会接续,想先检查设置可提前人工取消。
停用/卸载前先人工取消,再在篡改猴关闭或删除脚本并刷新课程页,才能卸掉已加载代码。
| 保存位置 | 内容 |
| --- | --- |
| 网站本地存储 | 答案记忆、最近提交快照、后台和静音回退偏好、标签页运行锁 |
| 当前标签页会话存储 | 启用状态、运行范围和刷新恢复记录 |
| 当前页内存 | 运行记录、已读取课程列表和时长样本 |
本脚本不额外上传日志或账户信息,不保存登录凭据或视频链接;网站自身的请求照常运行。篡改猴导出脚本**不包含**本助手保存在课程网站中的答案记忆。更新脚本不会主动清空记忆;清理网站数据则可能清除答案和网站登录状态。
## 11. 常见问题
| 现象 | 处理 |
| --- | --- |
| 无助手面板 | 检查网址、扩展/脚本开关、用户脚本权限和网站权限;无痕另查无痕权限,最后刷新 |
| 无痕不生效 | 检查扩展无痕权限、实际使用的Chrome用户配置,再刷新无痕页 |
| 显示旧版本 | 确认保存/导入完成,停用重复脚本,刷新当前实际使用的课程页 |
| 已启动按钮不能再点 | 正常;查看下方运行/等待原因。换模式先人工取消 |
| 切换视频不播 | 等待新媒体就绪;自动播放拒绝按上一节处理;检查加载、网络和运行记录 |
| 答完不交卷 | 确认最新版本、是否有未答或不支持的题;1.3.0起已修正电脑侧栏交卷按钮识别 |
| 学习100%仍不换课 | 检查是否已合格、是否仅当前课程、列表是否返回新确认 |
| 75%已合格仍重考 | 确认当前页至少1.3.1,停用旧版并刷新 |
| 整体只显示1门 | 尚未读取课程列表或选择了仅当前课程;先打开“我的课程” |
| 预计时间暂不可用 | 等待视频时长数据;暂停、仅测验或需人工处理时不会显示可靠完成时刻 |
| 估计时间不断变化 | 数据逐步补齐、测验重试、停顿或平台保存更新会改变估计 |
| 当前显示全完成但其他分组还有课 | 展开目标分组并加载列表,必要时刷新获取新状态 |
| 另一个学习页运行 | 在另一个页面人工取消或关闭,等待运行锁释放 |
| 课程重名 | 手动进入目标课程,选仅当前课程 |
| 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 |
| 视频没有声音 | 查看是否标记静音,可在播放器恢复声音或关闭静音回退选项 |
反馈时展开“运行记录”并点“更新记录”,提供脚本版本、普通/无痕模式、运行范围、面板文字和必要记录,不要附凭据或完整敏感抓包。
## 12. 验证与恢复边界
81项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音成功和仍被拒绝等情况。
现场已观察到网页原生2倍速、平台保存响应,以及本次点击播放后恢复。新版整体统计、估算和静音回退经过模拟测试,尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
前台视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频在一次运行内共享该额度。临时播放中断有界重试;播放权限拒绝不进入刷新循环。前台视频结束25秒仍无平台完成确认会等待,后台窗口更长。这些恢复不会伪造完成状态。
+39
View File
@@ -0,0 +1,39 @@
# 更新记录
[仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md)
## 1.4.0(2026-10-06)
- 新增整体完成进度条、已完成/总课程数、平均学习进度及待合格测验数量。
- 新增剩余时长和预计完成日期时间;未知课程时长按已知均值粗估并标注,暂停或数据不足时明确提示。
- 有声自动播放受限时可尝试一次静音,提供开关;仍受限则等待真实点击,不再定时重复空转或无效刷新。
- 同步平台保存位置供估算使用;新列表完成状态覆盖旧详情,预测不参与课程完成判断。
- 补齐官方篡改猴安装入口、Chrome权限、无痕、文件导入、复制安装、更新与常见问题文档。
- 新增17项行为测试,总计81项。
## 1.3.1
- 按学习完成且平台判定测验合格换课,不再强制测验100分。
- 分别核对学习与测验,支持“学习100%+测验75%已合格”继续下一课程。
- 已合格不重考,低于100分的合格试卷不会误记整卷全对。
- 本地行为测试64项。
## 1.3.0
- 修复电脑端交卷按钮在侧栏、手机版底部按钮隐藏时无法自动提交的问题。
- 自动交卷并确认;启用状态与执行状态分离,开始后保持启用,人工取消才关闭。
- 同一标签页普通刷新接续;未合格重试不再设置脚本自己的次数上限。
## 1.2.0
- 增加切标签页继续播放,保留网站原有的进度保存流程。
- 保存后台偏好,延长后台等待与运行锁,增加回前台后的保存恢复时间。
## 1.1.1
- 修复切换视频后不自动播放。
- 等待新媒体源就绪、播放器重建后重新绑定、临时中断有界重试。
- 每段视频可进行一次加载/启动/停滞的刷新恢复。
- 保留2倍速、自动测验、错题记忆、进度确认和按列表继续等功能。
真实验证范围和已知限制以使用说明为准。
+78 -31
View File
@@ -1,59 +1,106 @@
# 学员中心 · 2倍速学习助手
适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.3.1**。
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
## 安装
当前版本:**1.4.0**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
下载 `icampus-study-helper.user.js`,在篡改猴管理面板的“实用工具 → 从文件导入”中安装或更新原脚本。刷新课程页,确认面板版本为1.3.1。已启动的标签页会自动接续;首次使用点击“开始”。不要同时启用旧诊断脚本。
**[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)**
默认按课程列表顺序处理,支持仅当前课程,以及只做本门测验。首次安装等待点击开始;开始后保持启用,刷新同一标签页也会接续,只有“人工取消”才清除运行状态。
## 快速安装
## 1.3.1 完成条件、自动交卷与持续运行
1. 在 Chrome 普通窗口打开 [篡改猴官方商店页面](https://chromewebstore.google.com/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo),点击“添加至 Chrome”。也可从 [篡改猴官网 Chrome 下载页](https://www.tampermonkey.net/index.php?browser=chrome) 进入。
2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。
3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。
4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。
5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.0”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
电脑端的“交卷”在侧栏,手机端的底部交卷按钮在电脑上隐藏。旧版只查找底部按钮,导致已答完却无法提交。新版选择当前实际可见的交卷按钮,并自动确认交卷。
扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
继续下一课程的条件是 **学习进度100%+平台判定测验合格**,不要求测验得分100%。例如学习进度100%、测验75%、合格线60%,应继续下一门;学习进度75%而测验已合格,则继续学习视频。测验是否合格以平台的明确判定为准,不硬编码合格线。
### 必要设置
未合格时根据逐题反馈保存正确答案、排除错误答案并重新测验,不设置脚本自己的重考次数上限。已合格不再重考;得分不足100%的整卷不会误记为全部答案正确。
| 设置 | 操作 |
| --- | --- |
| 扩展及脚本启用 | Tampermonkey扩展、篡改猴总开关、本脚本均保持启用 |
| Chrome 138及以上 | Tampermonkey“详情”→ 开启 **允许用户脚本 / Allow User Scripts** |
| Chrome 138以前的适用版本 | 扩展列表页右上角开启 **开发者模式** |
| 网站访问权限 | 允许在 `https://icampus.yunyinsh.com` 自动运行,可用“在特定网站上”添加 |
| 无痕窗口使用 | Tampermonkey“详情”→ 开启 **在无痕模式下启用 / Allow in incognito** |
| 允许访问文件网址 | 文件导入、复制安装及本脚本的HTTPS页面运行不需要开启 |
“运行中”和“等待恢复”都属于已启动。网络异常、等待评分、当前范围完成或需要人工处理时保持启用;自动定期检查或在页面变化后接续。需要停止时点“人工取消”。手动暂停视频后会等待用户恢复视频,不主动抢占暂停操作。
按 Chrome 版本选择对应的用户脚本权限,不必一律同时开启开发者模式和“允许用户脚本”。修改后刷新课程页。依据:[Chrome 用户脚本文档](https://developer.chrome.com/docs/extensions/reference/api/userScripts)、[篡改猴权限说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q209)。
## 功能
### 也可以复制安装
- 2倍速正常播放,平台确认完成后切换视频。
- 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。
- 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。
- 持续启用、等待恢复、异常提示、单标签页运行锁和本机答案记忆。
- 默认开启“切标签页继续播放”,助手运行时保留网站原有的后台进度保存定时器。
打开脚本的“原始文件 / Raw”,复制**全部代码** → 篡改猴图标 →“添加新脚本”→ 在编辑器内全选删除默认模板 → 粘贴 → `Ctrl+S`保存 → 刷新课程页。
## 1.2.0 后台播放
必须包含开头的 `// ==UserScript==` 配置块,不要复制行号、仓库网页文字或Markdown代码围栏。更新已有脚本时,在原脚本编辑器内替换全部内容,避免同时启用多份。
安装后刷新课程页,点击“开始”,保持“切标签页继续播放”勾选,然后切换其他标签页或把浏览器放到后台。开关偏好会保存在本机。
## 当前版本特性
已加载的课程组件在页面隐藏时主动停止心跳。后台模式在课程页的助手运行期间拦截这个隐藏事件,保留正常播放和网页原本的心跳;不会改写 `document.visibilityState`、播放位置或服务器请求。回到前台仍执行网站原来的恢复逻辑。暂停助手、关闭后台模式或离开页面时恢复正常处理。
| 功能 | 当前行为 |
| --- | --- |
| 2倍速播放 | 正常播放,观察网站原有进度保存结果,不跳时间轴 |
| 自动续播 | 视频自然结束且平台确认完成后切换下一段;等待新媒体加载并尝试恢复 |
| 自动测验 | 支持文字单选、判断、多选,自动选择、交卷、确认交卷 |
| 错题记忆 | 根据平台逐题反馈保留正确答案、排除错误选项或组合,未合格时重试 |
| 动态合格判断 | 读取平台“已合格”结果,不写死合格分数,已合格不再重考 |
| 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 |
| 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 |
| 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 |
| 自动播放恢复(新增) | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
| 整体进度(新增) | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
| 时间预测(新增) | 显示预计剩余时长和完成日期时间;缺失时长用已知课程均值粗估并标明 |
| 范围与排错 | 按可见列表顺序继续、仅当前课程、只做本门测验;提供状态和运行记录 |
考虑到 Chrome 可能降低后台定时器频率,后台等待窗口及标签页运行锁延长至至少180秒,返回前台后给保存请求20秒恢复时间。连续180秒没有保存确认仍会暂停,后台切换视频和答题可能比前台慢。
**学习100%、测验75%、平台显示已合格 → 继续下一课程。学习75%、测验已合格 → 继续视频。**
电脑休眠、浏览器关闭、标签页冻结/丢弃、断网或媒体自动播放被浏览器拒绝,无法靠网页脚本保证运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”;脚本不会自动修改浏览器设置。
整体完成比例按课程门数计算,学习进度另行显示。时间预测按2倍速计算,另预留每门未合格测验2分钟、每门未完成课程切换15秒;重考、网络和人工处理会使实际时间延后。暂停时保留条件估算,完成时刻显示等待恢复。
## 1.1.1 修复
## 自动检查范围
修复视频切换后不继续播放的问题:为每段视频单独计时,等待新媒体数据就绪和网站恢复播放位置;播放器重建时重新绑定;临时 `AbortError` 最多重试三次。加载卡住时尝试网页播放按钮,90秒仍未就绪则最多刷新恢复一次。播放停滞或启动超时也共享这一次恢复额度。
**统计范围与自动运行范围要分清:**
自动恢复会临时保存当前课程与恢复额度,刷新后接续。1.3.0起,运行开关另外保存在标签页会话中,因此普通刷新也接续;人工取消后刷新不会启动。
- 整体面板统计本页已收到的课程列表数据;仅当前课程模式统计当前课程。尚未读取列表时明确显示“暂统计当前课程”。
- 自动运行按当前列表已加载、已显示的课程卡片顺序选择未完成项目。不会自动展开全部分组、翻页或扫描账号所有课程。
- 列表数据中有但页面未显示的课程可能计入统计,却尚未进入自动运行范围。开始前请展开目标分组、清除不需要的筛选、确认目标课程已显示。
- 刷新后列表数据和时长样本需重新读取;可先打开“我的课程”再运行。统计和预测不是平台学分保证。
浏览器明确拒绝自动播放时提示手动点击播放,不会无限刷新。正常播放后的手动暂停仍会停止助手。
## 使用与更新
## 验证
默认勾选“自动测验,按平台合格线通过”“切标签页继续播放”和“播放受限时尝试静音”。需要有声播放时可关闭静音尝试;浏览器拒绝自动播放时手动点击网页播放按钮。静音成功后面板标注“静音”,可在播放器恢复声音。
运行 `npm test`,无需安装依赖。当前64项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复、后台模式、学习进度与测验合格的独立判断、刷新接续。包含“学习100%+测验75%合格→返回列表→打开下一课程”的完整模拟回归。
“已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。
现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。
**仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。
后台模式测试模拟了网页真实的可见性处理和心跳响应,验证拦截、恢复、暂停、后台超时和延迟跳转等情况。尚未在用户浏览器中完成新版的长时间真实后台运行验证。
## 后台运行与边界
## 限制
保持电脑唤醒、联网,浏览器及课程页保持打开。Chrome若经常回收该页,可在“设置 → 性能 → 始终保持活跃的网站”添加 `icampus.yunyinsh.com`。设置依据:[Chrome 性能帮助](https://support.google.com/chrome/answer/12929150?hl=en)。
计入进度、课程完成和重试能力均以平台返回结果为准。未合格且重考入口暂不可用时会等待;已合格则继续核对学习进度。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
休眠、断网、标签页冻结/丢弃和自动播放限制仍可能中断;后台答题与切换也可能变慢。图片、材料、填空、问答及无法唯一识别选项的题需要人工处理。脚本不自行限制重考次数,但会尊重平台返回的次数限制、入口和反馈情况。
更多说明见 [使用说明](2倍速脚本使用说明.md)。
本脚本无外部依赖,不额外上传日志或账户信息;学习记录仍由网站正常发送。答案记忆等保存在该站点的浏览器存储,启动状态存于当前标签页会话。普通和无痕数据隔离,运行锁也不跨这两种环境;同账号只运行一个学习页面。
## 验证与开发
目前 **81项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断,以及新增统计和自动播放恢复。实际卡住页面已通过点击播放恢复2倍速,并重新收到平台保存确认。新版本的统计、预测和静音回退通过本地模拟测试,尚未完成所有课程及长期后台的真实端到端验证。
开发验证需要 Node.js 18+,无需安装第三方依赖:
```sh
git clone ssh://git@192.168.10.31:2222/qishizhe/e-learning-assistant.git
cd e-learning-assistant
npm test
```
该SSH地址用于Git,需要相应内网连接和仓库权限,不是浏览器下载地址。
| 文件 | 用途 |
| --- | --- |
| [icampus-study-helper.user.js](icampus-study-helper.user.js) | 唯一需要安装的用户脚本 |
| [2倍速脚本使用说明.md](2倍速脚本使用说明.md) | 设置、导入、复制、后台、预测说明和排错 |
| [CHANGELOG.md](CHANGELOG.md) | 各版本变化 |
| `*.test.cjs` | 本地行为测试 |
| [package.json](package.json) | 测试入口与版本信息 |
反馈时附脚本版本、普通/无痕模式、面板提示和必要运行记录;不要提交登录凭据、完整敏感抓包或带签名的视频链接。
+94 -6
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
// @version 1.3.1
// @version 1.4.0
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
@@ -35,6 +35,44 @@
const learningDone=hasProgress ? Number(c.progressPercent)>=100 : c.status==='COMPLETED' || !!c.completedAt;
return learningDone && (!c.examRequired || examPassed(c));
}
function learningPercent(c) {
const n=c?.progressPercent;
if(n!==null && n!==undefined && n!=='' && Number.isFinite(Number(n))) return Math.max(0,Math.min(100,Number(n)));
return c?.status==='COMPLETED' || c?.completedAt ? 100 : 0;
}
// Estimates never participate in course completion or navigation decisions.
function overallProgress(courses, detailMap, speed=2) {
const unique=[...new Map(courses.map(c=>[c.id,c])).values()];
const items=unique.map(c=>({course:detailMap.get(c.id)?.course || c,detail:detailMap.get(c.id)}));
const durations=items.map(({detail})=>{
const resources=detail?.resources;
if(!resources?.length || resources.some(r=>r.type!=='VIDEO' || !Number.isFinite(Number(r.durationSeconds)) || Number(r.durationSeconds)<=0)) return null;
return resources.reduce((sum,r)=>sum+Number(r.durationSeconds),0);
});
const samples=durations.filter(n=>n!==null);
const average=samples.length?samples.reduce((a,b)=>a+b,0)/samples.length:null;
let done=0,learning=0,quizzes=0,remaining=0,estimated=0,unknown=0,manual=0;
items.forEach(({course:c,detail},i)=>{
learning+=learningPercent(c);
if(courseDone(c)) {done++;return;}
if(c.examRequired && !examPassed(c)) quizzes++;
if(detail?.resources.some(r=>r.type!=='VIDEO' && r.completed!==true)) manual++;
if(learningPercent(c)>=100) return;
if(durations[i]!==null) {
remaining+=detail.resources.reduce((sum,r)=>sum+(r.completed===true?0:Math.max(0,Number(r.durationSeconds)-Math.max(0,Number(r.currentPositionSeconds)||0))),0);
} else if(average!==null) {remaining+=average*(1-learningPercent(c)/100);estimated++;}
else unknown++;
});
const total=items.length, pending=total-done;
// Two minutes per pending exam and 15 seconds per course are explicit planning allowances.
const seconds=total && !unknown && !manual ? Math.ceil(remaining/(speed>0?speed:2)+quizzes*120+pending*15) : null;
return {total,done,percent:total?done/total*100:0,learningPercent:total?learning/total:0,
quizzes,seconds,estimated,unknown,manual,samples:samples.length};
}
function durationText(seconds) {
const minutes=Math.ceil(Math.max(0,seconds)/60);
return minutes>=60?`${Math.floor(minutes/60)}小时${minutes%60}分`:`${minutes}分钟`;
}
function videoPlan(detail) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
@@ -98,7 +136,7 @@
} catch { return null; }
}
const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, videoPlan, resolveCards, leaseAvailable,
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint};
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText};
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
@@ -108,6 +146,9 @@
const RESUME_KEY = 'icampus.study.v1.recovery';
const BACKGROUND_KEY = 'icampus.study.v1.background';
const INTENT_KEY = 'icampus.study.v1.enabled';
const MUTED_KEY = 'icampus.study.v1.mutedFallback';
let mutedFallbackEnabled = true;
try { mutedFallbackEnabled = localStorage.getItem(MUTED_KEY) !== 'off'; } catch {}
let armed = false;
let waiting = null;
let backgroundEnabled = true;
@@ -237,7 +278,10 @@
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;
packagesAt = Date.now();
// A fresh list is authoritative; old details must not override newer list flags.
for(const c of courses) { const d=details.get(c.id); if(d) d.course={...d.course,...c}; }
render(); return;
}
if (!meta.tail && data?.course && Array.isArray(data.resources)) {
const course = safeCourse(data.course);
@@ -250,6 +294,7 @@
const detail = details.get(meta.id);
const resource = detail?.resources.find(r => r.id === resourceId);
if (resource) {
if (Number.isFinite(data.currentPositionSeconds)) resource.currentPositionSeconds=data.currentPositionSeconds;
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;
@@ -644,7 +689,15 @@
const failed=error => {
if(!running || run!==runNumber || active!==a) return;
a.playPending=false;
if(error?.name==='NotAllowedError') return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续。');
if(error?.name==='NotAllowedError') {
if(mutedFallbackEnabled && !a.mutedFallbackTried && !a.video.muted) {
a.mutedFallbackTried=true; a.video.muted=true; a.nextPlayAt=0; a.attempts=Math.min(a.attempts,2);
log('浏览器限制有声自动播放,尝试静音播放;可在播放器中恢复声音。');
return attemptPlay(a,run);
}
if(a.mutedFallbackTried) a.video.muted=false;
return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续;刷新不能保证解除此限制。',{passive:true});
}
if(error?.name!=='AbortError') recoverMedia(a.courseId,a.id,'播放启动失败');
// AbortError commonly means the website is replacing the media source.
};
@@ -716,8 +769,9 @@
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 ? '已收到保存确认' : '等待保存确认'}`
? `实际 ${v.playbackRate.toFixed(1)}倍${v.muted?' · 静音':''} · ${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 ? '已收到保存确认' : '等待保存确认'}`
: '以平台保存结果为准;电脑需保持唤醒。';
renderOverall();
panel.querySelector('[data-start]').disabled = armed;
panel.querySelector('[data-start]').textContent = armed ? (running?'已启动 · 运行中':'已启动 · 等待恢复') : '开始';
panel.querySelector('[data-quiz-only]').disabled = armed;
@@ -725,17 +779,42 @@
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
mode.disabled = armed;
}
function renderOverall() {
const current=details.get(route(location.hash).id)?.course;
const single=mode?.value==='current' || quizOnly;
const list=single?(current?[current]:[]):courses.length?courses:current?[current]:[];
const summary=overallProgress(list,details);
const scope=single?'当前课程':courses.length?'已读取课程列表':'未读取列表,暂统计当前课程';
panel.querySelector('[data-overall]').textContent=summary.total
? `${scope}\n整体完成 ${summary.percent.toFixed(1)}% · ${summary.done}/${summary.total}门\n平均学习进度 ${summary.learningPercent.toFixed(1)}% · 待合格测验 ${summary.quizzes}门`
: '整体进度:等待课程数据';
panel.querySelector('[data-overall-bar]').value=summary.percent;
let estimate='预计剩余:等待视频时长数据\n预计完成:暂无法估算';
if(summary.manual) estimate='预计剩余/完成:存在需人工处理的学习资源';
else if(quizOnly) estimate='仅测验模式:重考次数不确定,暂不预测完成时间';
else if(summary.total && summary.done===summary.total) estimate='统计范围内已完成\n预计剩余:0分钟';
else if(summary.seconds!==null) {
const approximate=summary.estimated?`粗估(${summary.estimated}门时长按已知课程均值)`:'估算';
const finish=running?new Date(Date.now()+summary.seconds*1000).toLocaleString('zh-CN',{hour12:false}):'等待开始或恢复后计算';
estimate=`预计剩余:约${durationText(summary.seconds)} · ${approximate}\n预计完成:${finish}\n按2倍速,另预留每门测验2分钟、换课15秒;重考和停顿会延后。`;
}
panel.querySelector('[data-estimate]').textContent=estimate;
}
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';
host.style.maxHeight='calc(100vh - 32px)'; host.style.overflowY='auto';
panel = host.attachShadow({mode: 'open'});
panel.innerHTML = `<style>:host{font:14px/1.6 system-ui;color:#eaf0fa}section{width:290px;background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:16px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}</style>
<section><strong>2倍速学习助手 · 1.3.1</strong><p data-status></p><small data-progress></small>
<section><strong>2倍速学习助手 · 1.4.0</strong><p data-status></p><small data-progress></small>
<p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress>
<p data-estimate style="font-size:12px;color:#b7c7df"></p>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
<div><button data-start>开始</button><button data-stop>人工取消</button></div>
<label><input type="checkbox" data-quiz checked> 自动测验,按平台合格线通过</label>
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
<div><label><input type="checkbox" data-muted> 播放受限时尝试静音</label></div>
<small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div>
<small>学习进度100%且测验合格后换课。持续运行,人工取消才关闭。</small>
@@ -745,6 +824,15 @@
const backgroundToggle=panel.querySelector('[data-background]');
backgroundToggle.checked=backgroundEnabled;
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
const mutedToggle=panel.querySelector('[data-muted]');
mutedToggle.checked=mutedFallbackEnabled;
mutedToggle.onchange=()=>{
mutedFallbackEnabled=mutedToggle.checked;
try { localStorage.setItem(MUTED_KEY,mutedFallbackEnabled?'on':'off'); } catch {}
if(waiting && mutedFallbackEnabled) {waiting.passive=false;waiting.nextAt=0;}
render();
};
mode.onchange=render;
function beginRun(only, resume) {
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
if (only) currentVideo()?.pause();
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const assert=require('node:assert/strict');
const {overallProgress,learningPercent}=require('./icampus-study-helper.user.js');
const {harness,ID,RID}=require('./study-helper.test.cjs');
let passed=0;
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
const course=(id,progress=0,passed=false)=>({id,courseName:id,progressPercent:progress,examRequired:true,examPassed:passed});
const detail=(c,duration=1200,position=0)=>({course:c,resources:[{id:'r',type:'VIDEO',durationSeconds:duration,currentPositionSeconds:position,completed:false}]});
function mount(h){const nodes=new Map();const panel={querySelector(s){if(!nodes.has(s))nodes.set(s,{textContent:'',value:'all',checked:true});return nodes.get(s);}};
h.context.document.body={append(){}};h.context.document.createElement=()=>({style:{},attachShadow:()=>panel});h.api.mount();return nodes;}
async function start(h){h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();await Promise.resolve();}
(async()=>{
await test('overall completion includes passing exam and differs from average learning',()=>{
const cs=[course('a',100,true),course('b',100,false),course('c',50,true)];
const s=overallProgress(cs,new Map());assert.equal(s.total,3);assert.equal(s.done,1);assert.ok(Math.abs(s.percent-100/3)<1e-9);assert.equal(s.learningPercent,250/3);assert.equal(s.quizzes,1);
});
await test('known remaining video durations use saved position and 2x rate',()=>{
const c=course('a',50,true),s=overallProgress([c],new Map([['a',detail(c,1200,600)]]));
assert.equal(s.seconds,315);assert.equal(s.estimated,0);
});
await test('unseen durations are labelled extrapolations and include exam allowances',()=>{
const a=course('a',50,true),b=course('b',0,false);
const s=overallProgress([a,b],new Map([['a',detail(a,1200,600)]]));
assert.equal(s.seconds,1050);assert.equal(s.estimated,1);assert.equal(s.samples,1);
});
await test('missing duration never becomes a zero-minute promise',()=>{
const s=overallProgress([course('a')],new Map());assert.equal(s.seconds,null);assert.equal(s.unknown,1);
});
await test('pending exam after learning completion still reserves time',()=>{
const s=overallProgress([course('a',100,false)],new Map());assert.equal(s.seconds,135);assert.equal(s.done,0);
});
await test('completed courses take no remaining time despite old resource positions',()=>{
const c=course('a',100,true),s=overallProgress([c],new Map([['a',detail(c)]]));
assert.equal(s.seconds,0);assert.equal(s.percent,100);
});
await test('duplicate course ids are counted once',()=>{
assert.equal(overallProgress([course('a'),course('a')],new Map()).total,1);
});
await test('invalid durations cannot produce NaN or Infinity',()=>{
for(const value of [undefined,null,'',0,-1,Infinity,'bad']){
const c=course('a'),d=detail(c);d.resources[0].durationSeconds=value;
const s=overallProgress([c],new Map([['a',d]]));assert.equal(s.seconds,null);
}
});
await test('progress is bounded and empty scope has no finish estimate',()=>{
assert.equal(learningPercent(course('a',-1)),0);assert.equal(learningPercent(course('a',200)),100);
assert.equal(overallProgress([],new Map()).seconds,null);
});
await test('unfinished non-video work suppresses the finish prediction',()=>{
const c=course('a'),d=detail(c);d.resources.push({id:'manual',type:'DOCUMENT',completed:false});
const s=overallProgress([c],new Map([['a',d]]));assert.equal(s.manual,1);assert.equal(s.seconds,null);
});
await test('panel labels a current-course-only fallback when list was never loaded',()=>{
const h=harness(),ui=mount(h);assert.match(ui.get('[data-overall]').textContent,/未读取列表/);
assert.match(ui.get('[data-estimate]').textContent,/等待视频时长/);
});
await test('fresh list updates summary without starting playback and overrides stale flags',()=>{
const h=harness(),ui=mount(h);h.api.details.get(ID).course.progressPercent=0;
h.api.receive({id:'packages'},{bizCode:0,data:[{courses:[{...course(ID,100,true),courseName:'课程一'},course('b')]}]},200);
assert.match(ui.get('[data-overall]').textContent,/50.0% · 1\/2门/);assert.equal(h.video.playCount,0);
assert.equal(h.api.details.get(ID).course.progressPercent,100);
});
await test('pause removes clock finish prediction but retains conditional remaining time',()=>{
const h=harness();const d=h.api.details.get(ID);d.resources.forEach(r=>{r.durationSeconds=600;r.currentPositionSeconds=0;});
const ui=mount(h);assert.match(ui.get('[data-estimate]').textContent,/等待开始或恢复/);
ui.get('[data-start]').onclick();assert.doesNotMatch(ui.get('[data-estimate]').textContent,/等待开始或恢复/);
h.api.park('暂停');assert.match(ui.get('[data-estimate]').textContent,/等待开始或恢复/);
});
await test('real heartbeat updates remaining time without extrapolating server completion',()=>{
const h=harness(),d=h.api.details.get(ID);d.resources[0].durationSeconds=1200;d.resources[1].completed=true;d.resources[1].durationSeconds=600;
const before=overallProgress([d.course],h.api.details).seconds;
h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId:RID}},{bizCode:0,data:{currentPositionSeconds:600,courseProgressPercent:50}},200);
assert.equal(overallProgress([d.course],h.api.details).seconds,before-300);assert.equal(d.resources[0].completed,false);
});
await test('autoplay rejection retries once muted and succeeds without reload',async()=>{
const h=harness();let calls=0;h.video.play=function(){calls++;return this.muted?Promise.resolve():Promise.reject({name:'NotAllowedError'});};
await start(h);assert.equal(calls,2);assert.equal(h.video.muted,true);assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
await test('blocked muted playback waits passively without timed permission retry loops',async()=>{
const h=harness();let calls=0;h.video.play=()=>{calls++;return Promise.reject({name:'NotAllowedError'});};
await start(h);assert.equal(calls,2);assert.equal(h.api.state.waiting.passive,true);assert.equal(h.video.muted,false);
h.advance(60000);await h.api.tick();assert.equal(calls,2);assert.equal(h.context.location.reloadCount,undefined);
h.video.paused=false;await h.api.tick();assert.equal(h.api.state.running,true);
});
await test('muted fallback can be disabled and preference is stored',async()=>{
const h=harness(),ui=mount(h);ui.get('[data-muted]').checked=false;ui.get('[data-muted]').onchange();
let calls=0;h.video.play=()=>{calls++;return Promise.reject({name:'NotAllowedError'});};
await start(h);assert.equal(calls,1);assert.equal(h.storage.get('icampus.study.v1.mutedFallback'),'off');
});
console.log(`${passed} overall progress and autoplay tests passed.`);
})().catch(e=>{console.error(e);process.exitCode=1;});
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "e-learning-assistant",
"version": "1.3.1",
"version": "1.4.0",
"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 && node background-playback.test.cjs && node persistent-run.test.cjs"
"test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs && node background-playback.test.cjs && node persistent-run.test.cjs && node overall-progress.test.cjs"
},
"engines": { "node": ">=18" }
}
+2 -2
View File
@@ -3,7 +3,7 @@ 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();}
async function start(h){h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();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);}};
@@ -41,7 +41,7 @@ function mountPanel(h){
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()=>{
await test('autoplay rejection including muted fallback 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);
});