From d80c490f48b5d460f4d9b885388593da5680dd25 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=96=87=E6=98=9F=E7=8E=AF=E6=B5=B7?= <3032732426@qq.com>
Date: Tue, 6 Oct 2026 02:38:21 +0800
Subject: [PATCH] Advance when learning is complete and the platform exam is
passed
---
2倍速脚本使用说明.md | 10 ++++-----
README.md | 14 ++++++------
icampus-study-helper.user.js | 33 ++++++++++++++--------------
package.json | 2 +-
persistent-run.test.cjs | 42 +++++++++++++++++++++++++++---------
5 files changed, 63 insertions(+), 38 deletions(-)
diff --git a/2倍速脚本使用说明.md b/2倍速脚本使用说明.md
index 282e5f4..f423d42 100644
--- a/2倍速脚本使用说明.md
+++ b/2倍速脚本使用说明.md
@@ -1,4 +1,4 @@
-# 学员中心 · 2倍速学习助手 v1.3.0
+# 学员中心 · 2倍速学习助手 v1.3.1
适用网页:https://icampus.yunyinsh.com/course/
@@ -7,9 +7,9 @@
1. 在篡改猴中停用旧的“学员中心 · 本地网络诊断”脚本,避免误触倍速测试。
2. 打开篡改猴管理面板 → 实用工具 → 从文件导入,选择 `icampus-study-helper.user.js`,按提示安装/更新。同名、同 namespace 的1.0版本应被更新,不要同时启用两份。也可以在原脚本编辑器中用该文件的全部内容替换并保存。
3. 刷新课程网页,让助手能从页面加载时读取平台返回的课程信息。
-4. 左下角出现“2倍速学习助手 · 1.3.0”,点击“开始”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
+4. 左下角显示“2倍速学习助手 · 1.3.1”。已启动的标签页刷新后会接续;首次使用点击“开始”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
5. 可选择“仅当前课程”。开始后始终保持启用,等待评分或处理异常不要求再次点开始;刷新同一标签页也会接续。只有按“人工取消”才关闭助手并暂停当前控制的视频。
-6. 默认勾选“自动测验,达到100%才继续”。先自动作答、交卷并重考,测验达到100%后再继续本门视频或下一课程。也可点击“只做本门测验”。
+6. 默认勾选“自动测验,按平台合格线通过”。自动作答、交卷,未合格才重考;合格后继续视频。学习进度100%且测验合格后自动下一门,不要求测验得分100%。也可点击“只做本门测验”。
7. 默认勾选“切标签页继续播放”。先启动助手,再切到其他标签页;无需一直把课程页放在前台。关闭开关会恢复网站原有的隐藏页面处理。
## 已实现
@@ -27,7 +27,7 @@
## 当前边界
-- 当前现场试卷显示不限次数,新版不设置脚本的重考次数上限。严格达到100%才放行;即使平台已显示合格,不满100%也保持等待重考,不切换视频或课程。
+- 当前现场试卷显示不限次数,新版不设置脚本的重考次数上限。测验按平台判定是否合格;学习进度100%且测验合格就换课,例如75%达到60%的合格线也会继续。
- 如果平台返回次数耗尽、入口不可用或没有逐题反馈,保持启用并等待处理,不反复提交完全相同的答案。
- 图片题、材料题、填空、问答,以及无法唯一识别选项的题需要手动作答。单选/判断/多选支持每题最多8个不同文字选项。
- 现场已确认电脑端交卷在侧栏、手机端底部按钮被隐藏;新版本已经修正选择器,但尚未安装新版完成真实自动重考到100%的端到端验证。
@@ -42,6 +42,6 @@
## 验证情况
-本地语法检查及61项行为测试通过(16项播放/进度测试、13项测验测试、13项媒体恢复测试、12项后台播放测试、7项持续运行测试)。模拟页面完整跑通开始、选择、电脑端交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、低于100%不放行、超过旧重试上限仍可继续、刷新接续及人工取消。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
+本地语法检查及64项行为测试通过(16项播放/进度测试、13项测验测试、13项媒体恢复测试、12项后台播放测试、10项持续运行与完成条件测试)。新增“学习100%+测验75%合格后自动下一课程”、学习未完成继续视频、学习进度不能替代测验合格等回归。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
diff --git a/README.md b/README.md
index 62e49ea..ceabd45 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,20 @@
# 学员中心 · 2倍速学习助手
-适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.3.0**。
+适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.3.1**。
## 安装
-下载 `icampus-study-helper.user.js`,在篡改猴管理面板的“实用工具 → 从文件导入”中安装或更新原脚本。刷新课程页,确认面板版本为1.3.0,点击左下角“开始”。不要同时启用旧诊断脚本。
+下载 `icampus-study-helper.user.js`,在篡改猴管理面板的“实用工具 → 从文件导入”中安装或更新原脚本。刷新课程页,确认面板版本为1.3.1。已启动的标签页会自动接续;首次使用点击“开始”。不要同时启用旧诊断脚本。
默认按课程列表顺序处理,支持仅当前课程,以及只做本门测验。首次安装等待点击开始;开始后保持启用,刷新同一标签页也会接续,只有“人工取消”才清除运行状态。
-## 1.3.0 自动交卷与持续运行
+## 1.3.1 完成条件、自动交卷与持续运行
电脑端的“交卷”在侧栏,手机端的底部交卷按钮在电脑上隐藏。旧版只查找底部按钮,导致已答完却无法提交。新版选择当前实际可见的交卷按钮,并自动确认交卷。
-测验必须达到 **100%** 才允许继续本门视频或下一课程。低于100%时,根据逐题反馈保存正确答案、排除错误答案并重新测验;不设脚本自己的重考次数上限。当前现场试卷显示“答题次数:不限”。若入口暂不可用,保留已启动状态等待,不跳过该测验。
+继续下一课程的条件是 **学习进度100%+平台判定测验合格**,不要求测验得分100%。例如学习进度100%、测验75%、合格线60%,应继续下一门;学习进度75%而测验已合格,则继续学习视频。测验是否合格以平台的明确判定为准,不硬编码合格线。
+
+未合格时根据逐题反馈保存正确答案、排除错误答案并重新测验,不设置脚本自己的重考次数上限。已合格不再重考;得分不足100%的整卷不会误记为全部答案正确。
“运行中”和“等待恢复”都属于已启动。网络异常、等待评分、当前范围完成或需要人工处理时保持启用;自动定期检查或在页面变化后接续。需要停止时点“人工取消”。手动暂停视频后会等待用户恢复视频,不主动抢占暂停操作。
@@ -44,7 +46,7 @@
## 验证
-运行 `npm test`,无需安装依赖。当前61项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复、后台模式、严格100%条件和刷新接续。测验模拟包含隐藏的手机端按钮与可见电脑端按钮,验证自动选择、交卷、确认、重试直至100%。
+运行 `npm test`,无需安装依赖。当前64项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复、后台模式、学习进度与测验合格的独立判断、刷新接续。包含“学习100%+测验75%合格→返回列表→打开下一课程”的完整模拟回归。
现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。
@@ -52,6 +54,6 @@
## 限制
-计入进度、课程完成和重试能力均以平台返回结果为准。重考入口暂不可用时会等待;即使平台显示“合格”,不足100%仍不会放行。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
+计入进度、课程完成和重试能力均以平台返回结果为准。未合格且重考入口暂不可用时会等待;已合格则继续核对学习进度。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
更多说明见 [使用说明](2倍速脚本使用说明.md)。
diff --git a/icampus-study-helper.user.js b/icampus-study-helper.user.js
index 4761d5f..0cd14d6 100644
--- a/icampus-study-helper.user.js
+++ b/icampus-study-helper.user.js
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
-// @version 1.3.0
+// @version 1.3.1
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
@@ -28,14 +28,19 @@
function bodyJSON(body) {
try { return typeof body === 'string' && body.length < 65536 ? JSON.parse(body) : null; } catch { return null; }
}
- function examPerfect(c) { return !!c && Number(c.examScorePercent)===100 && (c.examPassed===true || c.examPassed==='true'); }
- function courseDone(c) { return !!c && (c.status === 'COMPLETED' || !!c.completedAt) && (!c.examRequired || examPerfect(c)); }
+ function examPassed(c) { return !!c && (c.examPassed===true || c.examPassed==='true'); }
+ function courseDone(c) {
+ if(!c) return false;
+ const hasProgress=c.progressPercent!==null && c.progressPercent!==undefined && c.progressPercent!=='';
+ const learningDone=hasProgress ? Number(c.progressPercent)>=100 : c.status==='COMPLETED' || !!c.completedAt;
+ return learningDone && (!c.examRequired || examPassed(c));
+ }
function videoPlan(detail) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
const next = resources.find(r => r.completed !== true);
if (next) return {kind: next.type === 'VIDEO' ? 'video' : 'manual', resource: next};
- if (detail.course.examRequired && !examPerfect(detail.course)) return {kind: 'quiz'};
+ if (detail.course.examRequired && !examPassed(detail.course)) return {kind: 'quiz'};
return {kind: 'finish'};
}
function resolveCards(cards, courses) {
@@ -92,7 +97,7 @@
return m ? {kind:'card', paperId:m[1]} : null;
} catch { return null; }
}
- const core = {normalize, route, endpoint, bodyJSON, courseDone, examPerfect, videoPlan, resolveCards, leaseAvailable,
+ const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, 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;
@@ -406,13 +411,9 @@
const percent = result.querySelector('.quiz-result__score strong')?.textContent.trim();
const score=percent && /^\d+(\.\d+)?%$/.test(percent) ? Number(percent.replace('%','')) : course?.examScorePercent;
if(course && score!==undefined) course.examScorePercent=score;
- if(Number(score)===100) {
- log('测验已达到100%,允许继续。');
- if(quizOnly) return park('本门测验已达到100%,保持已启动,等待后续操作。',{passive:true});
- quiz=null; return;
- }
- // A lower platform pass mark does not satisfy the user's 100% gate.
- if(!quizButton(root,'重新测验')) return park(`当前正确率${score ?? '未知'}%,等待重考入口;达到100%前不切换视频或课程。`);
+ log(`平台已确认测验合格(${score ?? '未显示'}%),继续检查学习进度。`);
+ if(quizOnly) return park('本门测验已合格,保持已启动,等待后续操作。',{passive:true});
+ quiz=null; return;
}
if (quiz.phase === 'opening' || quiz.phase === 'retrying') return quizWait('正在打开测验…');
const exam = exams.get(id);
@@ -581,7 +582,7 @@
return;
}
const plan = videoPlan(detail);
- if(detail.course.examRequired && !examPerfect(detail.course)) {
+ if(detail.course.examRequired && !examPassed(detail.course)) {
const gatedVideo=active?.video || currentVideo();
if(gatedVideo && !gatedVideo.paused) gatedVideo.pause();
active=null;
@@ -730,14 +731,14 @@
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
panel = host.attachShadow({mode: 'open'});
panel.innerHTML = `
- 2倍速学习助手 · 1.3.0
+ `;
mode = panel.querySelector('select');
autoQuiz = panel.querySelector('[data-quiz]');
diff --git a/package.json b/package.json
index d31e343..8a0de71 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "e-learning-assistant",
- "version": "1.3.0",
+ "version": "1.3.1",
"private": true,
"description": "学员中心2倍速播放和测验辅助脚本",
"scripts": {
diff --git a/persistent-run.test.cjs b/persistent-run.test.cjs
index 21a037c..91cc799 100644
--- a/persistent-run.test.cjs
+++ b/persistent-run.test.cjs
@@ -7,23 +7,45 @@ async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
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()=>{
- await test('a platform pass below 100 never counts as a completed course',()=>{
- const d=detail();d.course.status='COMPLETED';d.course.examScorePercent=60;d.resources.forEach(r=>r.completed=true);
- assert.equal(core.courseDone(d.course),false);assert.equal(core.videoPlan(d).kind,'quiz');
- d.course.examScorePercent=100;assert.equal(core.courseDone(d.course),true);
+ await test('100 percent learning with a 75 percent passing exam is complete',()=>{
+ const d=detail();d.course.status='COMPLETED';d.course.progressPercent=100;d.course.examScorePercent=75;d.resources.forEach(r=>r.completed=true);
+ assert.equal(core.courseDone(d.course),true);assert.equal(core.videoPlan(d).kind,'finish');
+ d.course.examPassed=false;assert.equal(core.courseDone(d.course),false);assert.equal(core.videoPlan(d).kind,'quiz');
});
- await test('a course below 100 starts its quiz before playing or switching video',async()=>{
+ await test('an unpassed exam starts its quiz before playing or switching video',async()=>{
const h=harness();let starts=0;const c=h.api.details.get(ID).course;c.examPassed=false;c.examScorePercent=0;
const button={textContent:'开始测验',disabled:false,getClientRects:()=>[{}],click(){starts++;}};
h.selectors.set('.course-quiz',{querySelector:()=>null,querySelectorAll:s=>s==='button'?[button]:[]});
h.api.begin();await h.api.tickCourse(ID);assert.equal(starts,1);assert.equal(h.video.playCount,0);assert.equal(h.buttons[1].clicks,0);assert.equal(h.video.paused,true);
});
- await test('a low passing score with a missing retake entry stays armed and does not advance',async()=>{
- const h=harness();const c=h.api.details.get(ID).course;c.examScorePercent=80;
- const result={getClientRects:()=>[{}],querySelector:s=>s==='.quiz-result__status.is-pass'?{}:s==='.quiz-result__score strong'?{textContent:'80%'}:null};
+ await test('a low passing score needs no retake entry and allows return to the list',async()=>{
+ const h=harness();const d=h.api.details.get(ID);const c=d.course;c.examScorePercent=75;c.progressPercent=100;d.resources.forEach(r=>r.completed=true);
+ let backs=0;h.selectors.set('.van-nav-bar__left',{click(){backs++;}});
+ const result={getClientRects:()=>[{}],querySelector:s=>s==='.quiz-result__status.is-pass'?{}:s==='.quiz-result__score strong'?{textContent:'75%'}:null};
h.selectors.set('.course-quiz',{querySelector:s=>s==='.quiz-result'?result:null,querySelectorAll:()=>[]});
- h.api.begin();await h.api.tickCourse(ID);assert.equal(h.api.state.armed,true);assert.equal(h.api.state.running,false);
- assert.match(h.api.state.status,/100%/);assert.equal(h.video.playCount,0);assert.equal(h.buttons[1].clicks,0);
+ h.api.begin();h.api.tickQuiz(ID);await h.api.tickCourse(ID);assert.equal(h.api.state.armed,true);assert.equal(h.api.state.running,true);
+ assert.equal(backs,1);assert.equal(h.video.playCount,0);assert.equal(h.buttons[1].clicks,0);
+ });
+ await test('learning progress and exam score cannot substitute for each other',()=>{
+ const c={...detail().course,progressPercent:75,examScorePercent:100};assert.equal(core.courseDone(c),false);
+ c.progressPercent=100;c.examPassed=false;assert.equal(core.courseDone(c),false);
+ c.examPassed=true;c.examScorePercent=60;assert.equal(core.courseDone(c),true);
+ c.progressPercent=99;c.status='COMPLETED';assert.equal(core.courseDone(c),false);
+ });
+ await test('a passed 75 percent exam with unfinished learning continues its videos',async()=>{
+ const h=harness();const c=h.api.details.get(ID).course;c.progressPercent=75;c.examScorePercent=75;
+ h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);
+ assert.equal(h.video.playCount,1);assert.equal(core.courseDone(c),false);
+ });
+ await test('100 percent learning and a 75 percent pass advance from the result to the next course',async()=>{
+ const h=harness(),d=h.api.details.get(ID);d.course.progressPercent=100;d.course.examScorePercent=75;d.course.status='COMPLETED';
+ d.resources.forEach(r=>r.completed=true);
+ h.selectors.set('.van-nav-bar__left',{click(){h.context.location.hash='#/my-course';}});
+ const clicks=[0,0];const names=['课程一','课程二'];
+ h.selectors.set('article.course-row',names.map((name,index)=>({getClientRects:()=>[{}],querySelector:()=>({textContent:name}),click(){clicks[index]++;}})));
+ h.api.begin();await h.api.tickCourse(ID);h.advance(1000);
+ h.api.receive({id:'packages',tail:''},{bizCode:0,data:[{courses:[d.course,{id:'123',courseName:'课程二',progressPercent:0,examRequired:true,examPassed:false,status:'NOT_STARTED'}]}]},200);
+ await h.api.tick();assert.deepEqual(clicks,[0,1]);assert.equal(h.api.state.running,true);assert.equal(h.api.state.armed,true);
});
await test('temporary waits retain the started button state and retry automatically',async()=>{
const h=harness();const ui=mount(h);ui.get('[data-start]').onclick();h.api.park('网络等待');