Advance when learning is complete and the platform exam is passed

This commit is contained in:
2026-10-06 02:38:21 +08:00
parent 606d264917
commit d80c490f48
5 changed files with 63 additions and 38 deletions
+5 -5
View File
@@ -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门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
+8 -6
View File
@@ -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)。
+17 -16
View File
@@ -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 = `<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.0</strong><p data-status></p><small data-progress></small>
<section><strong>2倍速学习助手 · 1.3.1</strong><p data-status></p><small data-progress></small>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
<div><button data-start>开始</button><button data-stop>人工取消</button></div>
<label><input type="checkbox" data-quiz checked> 自动测验,达到100%才继续</label>
<label><input type="checkbox" data-quiz checked> 自动测验,按平台合格线通过</label>
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
<small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div>
<small>启动后持续运行与等待;只有人工取消才关闭。测验100%后继续视频。</small>
<small>学习进度100%且测验合格后换课。持续运行,人工取消才关闭。</small>
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
mode = panel.querySelector('select');
autoQuiz = panel.querySelector('[data-quiz]');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "e-learning-assistant",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "学员中心2倍速播放和测验辅助脚本",
"scripts": {
+32 -10
View File
@@ -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('网络等待');