fix: start course covers and advance verified learning tasks
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# 原始下载网页、压缩包和临时导出不提交;题库/ 中的统一及兼容 CSV 提交到仓库。
|
# 原始下载网页、压缩包和临时导出不提交;题库/ 中的统一及兼容 CSV 提交到仓库。
|
||||||
网页样本/
|
网页样本/
|
||||||
|
谊安学网页/
|
||||||
导出结果/
|
导出结果/
|
||||||
*.zip
|
*.zip
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 谊安学助手 2.0
|
# 谊安学助手 2.0
|
||||||
|
|
||||||
当前发布版本 **2.1.3**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。
|
当前发布版本 **2.1.4**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。
|
||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
@@ -40,10 +40,14 @@
|
|||||||
|
|
||||||
## 视频、任务与后台
|
## 视频、任务与后台
|
||||||
|
|
||||||
任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,新安装默认“视频+考试”,已有保存的模式继续沿用,执行方式默认顺序,并行最多一个视频和一场考试。“仅视频”和“视频+课中答题”均不会进入独立或关联考试;选择“视频+考试”后,平台确认视频完成,才进入已经开放的关联考试,仍需通过单次机会保护及题库预检。视频栏根据当前模式显示后续流程。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。
|
任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,新安装默认“视频+考试”,已有保存的模式继续沿用,执行方式默认顺序,并行最多一个视频和一场考试。“仅视频”和“视频+课中答题”均不会进入独立或关联考试;选择“视频+考试”后,按目录的完成条件处理视频与已开放的关联考试,仍需通过单次机会保护及题库预检。视频栏根据当前模式显示后续流程。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。
|
||||||
|
|
||||||
视频支持 1X、1.25X、1.5X、2X,显示实际播放器倍率。播放到末尾后继续等待平台学习完成标记;平台记录时长与播放器位置分别展示。没有明确学习时长要求或任务耗时依据时,预计时间显示待确认,2X 不代表整轮耗时必然减半。
|
视频支持 1X、1.25X、1.5X、2X,显示实际播放器倍率。播放到末尾后继续等待平台学习完成标记;平台记录时长与播放器位置分别展示。没有明确学习时长要求或任务耗时依据时,预计时间显示待确认,2X 不代表整轮耗时必然减半。
|
||||||
|
|
||||||
|
封面页自动点击实际绑定事件的“开始学习”文字按钮;已完成首项会跳过,从对应未完成的目录行进入,避免封面默认重播第一项。点击一次后等待播放器异步出现;人工继续或受控刷新均重新获得本次 15 秒加载窗口,不沿用旧超时,也不清零自动恢复次数。
|
||||||
|
|
||||||
|
橙色图标不代表完成。脚本读取它的网页提示,在视频栏和日志显示观看时长、观看进度、是否还需考试或及格等条件。明确按时长要求时,只使用同一课件的后台累计学习时长判断观看阶段;按百分比要求时,不用累计秒数推断进度。若视频已到末尾且提示仍要求考试,可先进入已识别的关联考试,随后返回原课程核对平台完成标记;不会因此把视频或整课记为完成。未处理的课中题、续播弹窗和用户暂停优先。已完成课件自动跳过;从学习地图启动时,当前可执行任务结束后通过已访问的原地图及真实课程入口进入下一任务。直接进入课程而没有已知地图地址时,不猜测其他课程地址。
|
||||||
|
|
||||||
课中答题适配保存 JS 模板中的 `app-question-modal` 与 PC CDK portal:按完整题干及选项匹配,回读选中集合,走网站“查看答案”判分,只有明确回答正确并关闭/进入下一题后才继续。仅视频模式遇强制题、无库、冲突或未知结构时等待人工,不刷新弹题。课中题不套用考试交卷规则。
|
课中答题适配保存 JS 模板中的 `app-question-modal` 与 PC CDK portal:按完整题干及选项匹配,回读选中集合,走网站“查看答案”判分,只有明确回答正确并关闭/进入下一题后才继续。仅视频模式遇强制题、无库、冲突或未知结构时等待人工,不刷新弹题。课中题不套用考试交卷规则。
|
||||||
|
|
||||||
“视频学习”栏新增三个独立开关,默认开启并保存设置:
|
“视频学习”栏新增三个独立开关,默认开启并保存设置:
|
||||||
@@ -97,6 +101,12 @@ npm run merge:banks
|
|||||||
|
|
||||||
源代码按职责拆分:`bank-store.js`、`answer-matcher.js`、`answer-runner.js`、`attempt-policy.js`、`diagnostic-logger.js`、`exam-controller.js`、`quiz-adapter.js`、`learning-engine.js`、`assistant-panel.js`、`practice-exporter.js`。发布仅需构建后的单个 `.user.js`。
|
源代码按职责拆分:`bank-store.js`、`answer-matcher.js`、`answer-runner.js`、`attempt-policy.js`、`diagnostic-logger.js`、`exam-controller.js`、`quiz-adapter.js`、`learning-engine.js`、`assistant-panel.js`、`practice-exporter.js`。发布仅需构建后的单个 `.user.js`。
|
||||||
|
|
||||||
|
## 2.1.4 更新记录
|
||||||
|
|
||||||
|
修复封面“开始学习”未点击、播放器异步加载期间重复点目录、继续任务立即沿用旧加载超时,以及返回地图后不再点击下一课程入口的问题。新增读取完成条件、观看与关联考试分阶段调度和完成后重新核对平台记录;同名课程与异步导航分别核对入口身份,避免把旧课程目录挂到下一课程。
|
||||||
|
|
||||||
|
`npm test` 的 20 套回归全部通过。新增本地课程入口与 7 项跨页导航检查,覆盖封面真实子节点、跳过已完成课件、多课件、多关联考试、暂停及迟到存储、时长条件、课中题保护、模式边界、返回地图及恢复超时;完整链验证考试确认提交后返回课程,等待真实完成标记,再进入下一课程。桌面与 390px 窄屏已检查。所有业务请求本地拦截;仍需实际账号验收平台学习条件及解锁行为。
|
||||||
|
|
||||||
## 2.1.3 更新记录
|
## 2.1.3 更新记录
|
||||||
|
|
||||||
已在本地复现并修复两项缺陷:租约临时冲突被记作永久人工等待,以及成功恢复后仍保留旧等待提示。另一执行页面仍有效时保持互斥,旧占用失效后自动重试。学习地图归档中的课程与考试入口使用 Angular 当前页路由,不能仅凭租约提示认定用户打开了重复标签。
|
已在本地复现并修复两项缺陷:租约临时冲突被记作永久人工等待,以及成功恢复后仍保留旧等待提示。另一执行页面仍有效时保持互斥,旧占用失效后自动重试。学习地图归档中的课程与考试入口使用 Angular 当前页路由,不能仅凭租约提示认定用户打开了重复标签。
|
||||||
|
|||||||
+3
-3
@@ -675,7 +675,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
|
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', waitingExam: '待关联考试与平台核对', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
|
||||||
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
|
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
|
||||||
function firstValue(object, keys, fallback) {
|
function firstValue(object, keys, fallback) {
|
||||||
object = object || {};
|
object = object || {};
|
||||||
@@ -745,13 +745,13 @@
|
|||||||
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
|
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
|
||||||
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
: '当前为“视频+考试”:平台确认视频完成后,进入已开放的关联考试。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
|
: '当前为“视频+考试”:按目录完成条件处理视频与已开放的关联考试,最后核对平台完成标记。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
|
||||||
videoModeNote.hidden = false;
|
videoModeNote.hidden = false;
|
||||||
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
|
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
|
||||||
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
|
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
|
||||||
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
|
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
|
||||||
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
|
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
|
||||||
infoRows(videoInfo, [['当前课程', videoTitle], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
|
infoRows(videoInfo, [['当前课程', videoTitle], ['完成要求', video.requirements || '等待网页提示;以平台完成标记为准'], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
|
||||||
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
|
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
|
||||||
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
|
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
|
||||||
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
|
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|||||||
const metadata = `// ==UserScript==
|
const metadata = `// ==UserScript==
|
||||||
// @name 谊安学助手 2.0
|
// @name 谊安学助手 2.0
|
||||||
// @namespace yianxue-question-bank
|
// @namespace yianxue-question-bank
|
||||||
// @version 2.1.3
|
// @version 2.1.4
|
||||||
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
||||||
// @match http://10.119.67.7/clientcn/*
|
// @match http://10.119.67.7/clientcn/*
|
||||||
// @run-at document-idle
|
// @run-at document-idle
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
const KEY = 'diagnosticLog.v1';
|
const KEY = 'diagnosticLog.v1';
|
||||||
const ENABLED_KEY = 'diagnosticLog.enabled';
|
const ENABLED_KEY = 'diagnosticLog.enabled';
|
||||||
const LIMIT = 2000;
|
const LIMIT = 2000;
|
||||||
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
|
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requiredSeconds requiredProgress requiresExam requiresPass requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
|
||||||
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
|
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
|
||||||
function safeURL(value) {
|
function safeURL(value) {
|
||||||
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
|
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
|
||||||
|
|||||||
+194
-21
@@ -97,6 +97,20 @@
|
|||||||
}
|
}
|
||||||
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
|
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
|
||||||
}
|
}
|
||||||
|
function courseCoverOf(doc) {
|
||||||
|
const buttons = Array.from(doc.querySelectorAll('app-course .course-courseware-playdefult > span'))
|
||||||
|
.filter(node => visible(node) && clean(node.textContent) === '开始学习' && !node.matches('[disabled],[aria-disabled="true"]'));
|
||||||
|
return buttons.length === 1 ? buttons[0] : null;
|
||||||
|
}
|
||||||
|
function completionRequirements(text) {
|
||||||
|
text = clean(text);
|
||||||
|
const time = text.match(/观看课件时长达到\s*([\d.\s小时分钟秒]+)/);
|
||||||
|
const progress = text.match(/观看视频进度\s*(\d+(?:\.\d+)?)\s*%/);
|
||||||
|
if (!time && !progress) return null;
|
||||||
|
return { text: text.slice(0, 500), requiredSeconds: time ? seconds(time[1]) : null,
|
||||||
|
requiredProgress: progress && Number(progress[1]) <= 100 ? Number(progress[1]) : null,
|
||||||
|
requiresExam: /完成考试答题/.test(text), requiresPass: /及格通过/.test(text) };
|
||||||
|
}
|
||||||
function describePage(doc, url) {
|
function describePage(doc, url) {
|
||||||
const current = canonical(url || doc.URL);
|
const current = canonical(url || doc.URL);
|
||||||
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
|
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
|
||||||
@@ -107,17 +121,17 @@
|
|||||||
coursewareTitle: active ? clean((active.querySelector('p') || active).textContent) : '',
|
coursewareTitle: active ? clean((active.querySelector('p') || active).textContent) : '',
|
||||||
video, activeCourseware: active, quiz: quizOf(doc), platformComplete: isComplete(active) || Boolean(doc.querySelector('app-course-courseware-video[data-is-learned="true"]')),
|
video, activeCourseware: active, quiz: quizOf(doc), platformComplete: isComplete(active) || Boolean(doc.querySelector('app-course-courseware-video[data-is-learned="true"]')),
|
||||||
learnedSeconds: active ? seconds(clean((active.querySelector('.menu_courseware_left') || active).textContent)) : null,
|
learnedSeconds: active ? seconds(clean((active.querySelector('.menu_courseware_left') || active).textContent)) : null,
|
||||||
requiredSeconds: number(active && active.getAttribute('data-required-seconds')),
|
requiredSeconds: active && active.hasAttribute('data-required-seconds') ? number(active.getAttribute('data-required-seconds')) : null,
|
||||||
hidden: doc.visibilityState === 'hidden' };
|
hidden: doc.visibilityState === 'hidden' };
|
||||||
}
|
}
|
||||||
function discoverTasks(doc, url) {
|
function discoverTasks(doc, url) {
|
||||||
const page = describePage(doc, url); const tasks = []; const seen = new Set();
|
const page = describePage(doc, url); const tasks = []; const seen = new Set();
|
||||||
function add(node, kind, selector, index, parentId) {
|
function add(node, kind, selector, index, parentId, subIndex) {
|
||||||
const explicitId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
|
const explicitId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
|
||||||
const targetURL = nodeURL(node, page.url);
|
const targetURL = nodeURL(node, page.url);
|
||||||
let title = clean((node.querySelector('.activity_name,p,[data-task-title]') || node).textContent);
|
let title = clean((node.querySelector('.activity_name,p,[data-task-title]') || node).textContent);
|
||||||
if (node.matches('.cn_courseware_img')) title = clean((node.querySelector(':scope > p') || node.querySelector('p') || node).textContent);
|
if (node.matches('.cn_courseware_img')) title = clean((node.querySelector(':scope > p') || node.querySelector('p') || node).textContent);
|
||||||
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index;
|
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index + (subIndex > 0 ? ':exam:' + subIndex : '');
|
||||||
const id = kind + ':' + hash(identity); if (seen.has(id)) return;
|
const id = kind + ':' + hash(identity); if (seen.has(id)) return;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
const locked = node.matches('[disabled],[aria-disabled="true"],[data-locked="true"],.locked') || Boolean(node.querySelector('[data-locked="true"]'));
|
const locked = node.matches('[disabled],[aria-disabled="true"],[data-locked="true"],.locked') || Boolean(node.querySelector('[data-locked="true"]'));
|
||||||
@@ -137,7 +151,7 @@
|
|||||||
const task = add(node, /视频/.test(icon) || node.getAttribute('data-kind') === 'video' ? 'video' : 'unsupported', 'app-course .cn_courseware_img,[data-courseware-id]', i);
|
const task = add(node, /视频/.test(icon) || node.getAttribute('data-kind') === 'video' ? 'video' : 'unsupported', 'app-course .cn_courseware_img,[data-courseware-id]', i);
|
||||||
if (task && task.kind === 'unsupported' && task.status !== 'completed') { task.status = 'skipped'; task.reason = '暂未适配此非视频课件'; }
|
if (task && task.kind === 'unsupported' && task.status !== 'completed') { task.status = 'skipped'; task.reason = '暂未适配此非视频课件'; }
|
||||||
node.querySelectorAll('.related_test,[data-related-exam]').forEach((exam, j) => {
|
node.querySelectorAll('.related_test,[data-related-exam]').forEach((exam, j) => {
|
||||||
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id);
|
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id, j);
|
||||||
if (examTask) { examTask.selector = 'app-course .cn_courseware_img,[data-courseware-id]'; examTask.subSelector = '.related_test,[data-related-exam]'; examTask.subIndex = j; examTask.id += ':exam:' + j; examTask.requiresParent = exam.getAttribute('data-requires-complete') === 'true'; }
|
if (examTask) { examTask.selector = 'app-course .cn_courseware_img,[data-courseware-id]'; examTask.subSelector = '.related_test,[data-related-exam]'; examTask.subIndex = j; examTask.id += ':exam:' + j; examTask.requiresParent = exam.getAttribute('data-requires-complete') === 'true'; }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -188,6 +202,7 @@
|
|||||||
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
|
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
|
||||||
let visibilityPlayback = null; let returnResumeRequested = false; let pageAway = false; let playbackPromptClick = null;
|
let visibilityPlayback = null; let returnResumeRequested = false; let pageAway = false; let playbackPromptClick = null;
|
||||||
let sharedPaused = false;
|
let sharedPaused = false;
|
||||||
|
let requirementProbe = null;
|
||||||
let logSequence = 0; let leaseBanner = null;
|
let logSequence = 0; let leaseBanner = null;
|
||||||
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
||||||
let policySessionId = '', policyReadSerial = 0;
|
let policySessionId = '', policyReadSerial = 0;
|
||||||
@@ -202,6 +217,10 @@
|
|||||||
function onInteraction(event) {
|
function onInteraction(event) {
|
||||||
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
|
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
|
||||||
}
|
}
|
||||||
|
function onRequirementHover(event) {
|
||||||
|
// 用户移动鼠标后,共用提示框可能已显示另一行的条件,不能归到旧请求。
|
||||||
|
if (event.isTrusted && requirementProbe && !requirementProbe.icon.contains(event.target)) requirementProbe = null;
|
||||||
|
}
|
||||||
function onVideoPause(event) {
|
function onVideoPause(event) {
|
||||||
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
|
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
|
||||||
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
|
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
@@ -261,6 +280,7 @@
|
|||||||
}
|
}
|
||||||
function taskState(task, status, reason) {
|
function taskState(task, status, reason) {
|
||||||
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
|
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
|
||||||
|
if (task.kind === 'video' && state.video?.taskId === task.id) state.video.message = task.reason;
|
||||||
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state');
|
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state');
|
||||||
}
|
}
|
||||||
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
|
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
|
||||||
@@ -390,7 +410,7 @@
|
|||||||
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
|
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
|
||||||
if (task.status === 'completed') {
|
if (task.status === 'completed') {
|
||||||
old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成';
|
old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成';
|
||||||
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed' });
|
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed', message: old.reason });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -406,6 +426,74 @@
|
|||||||
if (!task.identity && title !== task.title) return null;
|
if (!task.identity && title !== task.title) return null;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
function readRequirements(task, node, doc) {
|
||||||
|
if (!node || task.requirements) return task.requirements || null;
|
||||||
|
const icon = node.querySelector('.courseware_finished_status .en_courseware_noFinished');
|
||||||
|
if (!icon || !visible(icon)) return null;
|
||||||
|
if (requirementProbe && (requirementProbe.node !== node || requirementProbe.doc !== doc)) {
|
||||||
|
requirementProbe.icon.dispatchEvent(new root.MouseEvent('mouseleave', { bubbles: false }));
|
||||||
|
requirementProbe = null;
|
||||||
|
}
|
||||||
|
if (requirementProbe) {
|
||||||
|
const tips = Array.from(doc.querySelectorAll('.completion-tooltip')).filter(t => visible(t) && clean(t.textContent));
|
||||||
|
const requirements = tips.length === 1 ? completionRequirements(tips[0].textContent) : null;
|
||||||
|
if (requirements || now() - requirementProbe.at > 1500) {
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseleave', { bubbles: false }));
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseout', { bubbles: true }));
|
||||||
|
requirementProbe = null; task.requirementsRead = true;
|
||||||
|
if (requirements) {
|
||||||
|
task.requirements = requirements;
|
||||||
|
log('info', '课件完成条件:' + requirements.text, task, requirements, 'course.requirements');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!task.requirementsRead) {
|
||||||
|
// 橙色图标只是未完成;实际观看/考试条件由网页 hover 提示给出。
|
||||||
|
const existing = Array.from(doc.querySelectorAll('.completion-tooltip')).some(t => visible(t) && clean(t.textContent));
|
||||||
|
if (!existing) {
|
||||||
|
requirementProbe = { node, icon, doc, at: now() };
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseenter', { bubbles: false }));
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseover', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task.requirements || null;
|
||||||
|
}
|
||||||
|
function linkedExams(task) { return state.tasks.filter(t => t.kind === 'exam' && t.parentId === task.id); }
|
||||||
|
async function canAct(task, snap, epoch) {
|
||||||
|
const locallyPaused = () => task.kind === 'exam' ? config.examPaused : config.videoPaused;
|
||||||
|
if (epoch !== generation || state.status !== 'running' || locallyPaused()) return false;
|
||||||
|
if (!await heartbeat(task)) return false;
|
||||||
|
const latest = snapshot();
|
||||||
|
const livePage = describePage(latest.document, latest.url);
|
||||||
|
const samePage = latest.document === snap.document && canonical(latest.url) === canonical(snap.url)
|
||||||
|
&& (!snap.guardTitle || livePage.title === snap.guardTitle)
|
||||||
|
&& livePage.activeCourseware === snap.guardActive && livePage.video === snap.guardVideo
|
||||||
|
&& latest.document.querySelector('app-course,app-learning-map-activity-list') === snap.guardRoot
|
||||||
|
&& (!snap.guardTaskNode || resolveNode(task, latest) === snap.guardTaskNode);
|
||||||
|
if (!samePage && epoch === generation && state.status === 'running' && !locallyPaused()) taskState(task, 'waiting', '点击前课程或目录已变化,请核对当前任务后继续');
|
||||||
|
return epoch === generation && state.status === 'running' && !locallyPaused() && samePage;
|
||||||
|
}
|
||||||
|
function guardedSnapshot(snap, page) {
|
||||||
|
return Object.assign({}, snap, { guardTitle: page.title, guardActive: page.activeCourseware,
|
||||||
|
guardVideo: page.video, guardRoot: snap.document.querySelector('app-course,app-learning-map-activity-list') });
|
||||||
|
}
|
||||||
|
async function courseTick(task, snap, page, epoch) {
|
||||||
|
snap = guardedSnapshot(snap, page);
|
||||||
|
if (config.videoPaused || task.status === 'waiting') return;
|
||||||
|
if (page.kind === 'map' && canonical(task.pageUrl) === page.url && task.courseEntrySourceURL !== page.url) {
|
||||||
|
const node = resolveNode(task, snap);
|
||||||
|
snap.guardTaskNode = node;
|
||||||
|
const button = node && (node.querySelector('.map_activity_content_info_top') || node);
|
||||||
|
if (!button) { taskState(task, 'waiting', '返回学习地图后找不到原课程入口,请核对目录后继续'); return; }
|
||||||
|
if (isComplete(node)) { task.progress = 1; taskState(task, 'completed', '平台已确认该课程完成,跳过此入口'); activeTaskId = ''; await release(); return; }
|
||||||
|
await persist();
|
||||||
|
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, sourceURL: page.url, targetURL: task.url || '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
|
||||||
|
if (!await canAct(task, snap, epoch) || !snap.document.documentElement.contains(button) || !visible(button)) return;
|
||||||
|
task.courseEntrySourceURL = page.url; task.courseEntryClickedAt = now(); task.navigationRequestedAt = now();
|
||||||
|
log('info', '已返回学习地图,通过原课程入口进入下一任务', task, { pageUrl: page.url }, 'course.next');
|
||||||
|
button.click(); return;
|
||||||
|
}
|
||||||
|
if (now() - task.navigationRequestedAt > 15000) taskState(task, 'waiting', '课程入口尚未进入目标课程,请检查网站前置条件后继续');
|
||||||
|
}
|
||||||
function initialize() {
|
function initialize() {
|
||||||
if (initialized) return Promise.resolve();
|
if (initialized) return Promise.resolve();
|
||||||
if (initializing) return initializing;
|
if (initializing) return initializing;
|
||||||
@@ -416,6 +504,7 @@
|
|||||||
const snap = snapshot(); if (!snap.document) return;
|
const snap = snapshot(); if (!snap.document) return;
|
||||||
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
|
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
|
||||||
watchedDocument.addEventListener('visibilitychange', onVisibilityChange, true);
|
watchedDocument.addEventListener('visibilitychange', onVisibilityChange, true);
|
||||||
|
watchedDocument.addEventListener('mouseover', onRequirementHover, true);
|
||||||
if (watchedDocument.defaultView) { watchedDocument.defaultView.addEventListener('blur', onPageHidden); watchedDocument.defaultView.addEventListener('focus', onPageReturn); }
|
if (watchedDocument.defaultView) { watchedDocument.defaultView.addEventListener('blur', onPageHidden); watchedDocument.defaultView.addEventListener('focus', onPageReturn); }
|
||||||
const page = describePage(snap.document, snap.url);
|
const page = describePage(snap.document, snap.url);
|
||||||
const saved = await storage.get(SESSION_KEY);
|
const saved = await storage.get(SESSION_KEY);
|
||||||
@@ -443,6 +532,10 @@
|
|||||||
const controlled = sharedControl && sharedControl.status === 'running' && (recover && recover.sessionId === session.id && recover.taskId === activeTaskId && recover.expires > now() && recover.accountId === account(snap, page) || targetOK);
|
const controlled = sharedControl && sharedControl.status === 'running' && (recover && recover.sessionId === session.id && recover.taskId === activeTaskId && recover.expires > now() && recover.accountId === account(snap, page) || targetOK);
|
||||||
if (targetOK) {
|
if (targetOK) {
|
||||||
activeTaskId = transition.taskId;
|
activeTaskId = transition.taskId;
|
||||||
|
const arriving = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
if (arriving?.kind === 'course' && canonical(arriving.pageUrl) === transition.sourceURL) {
|
||||||
|
arriving.courseEntrySourceURL = transition.sourceURL; arriving.courseEntryClickedAt = now();
|
||||||
|
}
|
||||||
if (transition.workerNonce && sharedControl && sharedControl.status === 'running') {
|
if (transition.workerNonce && sharedControl && sharedControl.status === 'running') {
|
||||||
const request = await storage.get('worker:' + transition.workerNonce);
|
const request = await storage.get('worker:' + transition.workerNonce);
|
||||||
if (request && request.sessionId === session.id && request.taskId === activeTaskId) { worker = request; worker.owner = owner; worker.confirmedAt = now(); await storage.set('worker:' + worker.nonce, worker); }
|
if (request && request.sessionId === session.id && request.taskId === activeTaskId) { worker = request; worker.owner = owner; worker.confirmedAt = now(); await storage.set('worker:' + worker.nonce, worker); }
|
||||||
@@ -455,6 +548,11 @@
|
|||||||
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
|
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
|
||||||
}
|
}
|
||||||
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
|
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
|
||||||
|
// 完整导航/受控刷新后的播放器需要新的加载窗口,不能沿用旧页的超时。
|
||||||
|
if (state.status === 'running') {
|
||||||
|
const restored = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
if (restored) { restored.openedAt = now(); delete restored.coverClickedAt; }
|
||||||
|
}
|
||||||
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
|
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
|
||||||
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
|
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
|
||||||
}
|
}
|
||||||
@@ -530,6 +628,7 @@
|
|||||||
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
|
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
|
||||||
}
|
}
|
||||||
const wasRunning = state.status === 'running';
|
const wasRunning = state.status === 'running';
|
||||||
|
const previousVideoPaused = config.videoPaused, previousExamPaused = config.examPaused;
|
||||||
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
|
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
|
||||||
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
|
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
|
||||||
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
|
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
|
||||||
@@ -542,6 +641,15 @@
|
|||||||
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
|
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
|
||||||
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
|
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
|
||||||
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
|
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
|
||||||
|
if (!previousVideoPaused && config.videoPaused || !previousExamPaused && config.examPaused) generation++;
|
||||||
|
const localResumed = previousVideoPaused && patch.videoPaused === false || previousExamPaused && patch.examPaused === false;
|
||||||
|
if (localResumed && state.status === 'running') {
|
||||||
|
const active = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
if (active && (active.kind === 'exam' ? previousExamPaused && !config.examPaused : previousVideoPaused && !config.videoPaused)) {
|
||||||
|
active.status = 'pending'; active.reason = ''; activeTaskId = '';
|
||||||
|
if (active.kind === 'exam' && active.examRequested) active.examNeedsRecheck = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
|
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
|
||||||
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
|
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
|
||||||
if (manualPatch) {
|
if (manualPatch) {
|
||||||
@@ -586,7 +694,7 @@
|
|||||||
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) {
|
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) {
|
||||||
state.message = '账号或题库版本已变化,请开始新一轮任务'; state.status = 'paused'; notify(); return getState();
|
state.message = '账号或题库版本已变化,请开始新一轮任务'; state.status = 'paused'; notify(); return getState();
|
||||||
}
|
}
|
||||||
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || t.status === 'waitingPlatform') && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
|
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || ['waitingPlatform', 'waitingExam'].includes(t.status)) && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
|
||||||
config.videoPaused = false; config.examPaused = false;
|
config.videoPaused = false; config.examPaused = false;
|
||||||
generation++; visibilityPlayback = null; returnResumeRequested = false; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
generation++; visibilityPlayback = null; returnResumeRequested = false; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
||||||
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
|
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
|
||||||
@@ -595,6 +703,9 @@
|
|||||||
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
|
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
|
||||||
}
|
}
|
||||||
async function activate(task, snap, page) {
|
async function activate(task, snap, page) {
|
||||||
|
const epoch = generation;
|
||||||
|
snap = guardedSnapshot(snap, page);
|
||||||
|
if (task.kind === 'exam' ? config.examPaused : config.videoPaused) return false;
|
||||||
if (task.locked) { taskState(task, 'waiting', '网站尚未开放此任务'); return false; }
|
if (task.locked) { taskState(task, 'waiting', '网站尚未开放此任务'); return false; }
|
||||||
if (task.requiresParent && task.parentId) { const parent = state.tasks.find(t => t.id === task.parentId); if (parent && parent.status !== 'completed') return false; }
|
if (task.requiresParent && task.parentId) { const parent = state.tasks.find(t => t.id === task.parentId); if (parent && parent.status !== 'completed') return false; }
|
||||||
if (task.kind === 'exam') {
|
if (task.kind === 'exam') {
|
||||||
@@ -602,37 +713,92 @@
|
|||||||
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
|
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
|
||||||
}
|
}
|
||||||
if (!(await claim(task))) return false;
|
if (!(await claim(task))) return false;
|
||||||
|
if (epoch !== generation || state.status !== 'running') return false;
|
||||||
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
|
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
|
task.openedAt = now(); delete task.coverClickedAt; delete task.courseEntrySourceURL; delete task.courseEntryClickedAt;
|
||||||
const onPage = canonical(task.url || task.pageUrl) === page.url;
|
const onPage = canonical(task.url || task.pageUrl) === page.url;
|
||||||
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
|
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
|
||||||
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
|
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
|
||||||
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
|
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
|
||||||
const node = resolveNode(task, snap);
|
const node = resolveNode(task, snap);
|
||||||
|
snap.guardTaskNode = node;
|
||||||
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
||||||
task.navigationRequestedAt = now();
|
task.navigationRequestedAt = now();
|
||||||
if (node && task.kind === 'exam') task.examLinkClickedAt = now();
|
|
||||||
await persist();
|
await persist();
|
||||||
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
|
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
|
||||||
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
|
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
|
||||||
if (node) {
|
if (node) {
|
||||||
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
|
let button = node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node;
|
||||||
|
// 默认封面会打开首个课件;后续课件必须点击自己的目录行,防止重播已完成首项。
|
||||||
|
const first = snap.document.querySelector('app-course .cn_courseware_img,[data-courseware-id]');
|
||||||
|
const cover = task.kind === 'video' && !page.video && !page.activeCourseware && node === first ? courseCoverOf(snap.document) : null;
|
||||||
|
if (cover) button = cover;
|
||||||
|
if (!await canAct(task, snap, epoch)) return 'deferred';
|
||||||
|
if (!snap.document.documentElement.contains(button) || !visible(button)) { taskState(task, 'waiting', '原任务按钮已变化,请核对页面后继续'); return 'deferred'; }
|
||||||
|
if (cover) task.coverClickedAt = now();
|
||||||
|
if (task.kind === 'course') { task.courseEntrySourceURL = page.url; task.courseEntryClickedAt = now(); }
|
||||||
|
if (task.kind === 'exam') task.examLinkClickedAt = now();
|
||||||
|
if (cover) log('info', '点击封面“开始学习”,等待网页创建播放器', task, {}, 'video.coverStart');
|
||||||
|
button.click(); return true;
|
||||||
}
|
}
|
||||||
|
if (!await canAct(task, snap, epoch)) return 'deferred';
|
||||||
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
|
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
|
||||||
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
|
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
|
||||||
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
|
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
|
||||||
}
|
}
|
||||||
async function videoTick(task, snap, page, epoch) {
|
async function videoTick(task, snap, page, epoch) {
|
||||||
|
snap = guardedSnapshot(snap, page);
|
||||||
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); return; }
|
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); return; }
|
||||||
|
const node = resolveNode(task, snap) || (task.current ? page.activeCourseware : null);
|
||||||
|
if (!task.current) snap.guardTaskNode = node;
|
||||||
|
if (!task.current && canonical(task.pageUrl) !== page.url && !node) {
|
||||||
|
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '尚未返回课件所在课程,请核对网站跳转后继续');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page.activeCourseware && !task.current && (node ? node !== page.activeCourseware : task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent))) {
|
||||||
|
if (now() - task.openedAt <= 15000) return;
|
||||||
|
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
|
||||||
|
}
|
||||||
|
const requirements = readRequirements(task, node, snap.document);
|
||||||
|
const learnedSeconds = node ? seconds(clean((node.querySelector('.menu_courseware_left') || node).textContent)) : page.learnedSeconds;
|
||||||
|
const requiredSeconds = requirements?.requiredSeconds ?? page.requiredSeconds;
|
||||||
|
const exams = linkedExams(task);
|
||||||
|
const ended = page.video && (page.video.ended || Number.isFinite(page.video.duration) && page.video.duration > 0 && page.video.currentTime >= page.video.duration - 0.05);
|
||||||
|
const watchedTime = requirements?.requiredSeconds != null && learnedSeconds != null && learnedSeconds >= requirements.requiredSeconds;
|
||||||
|
const examsDone = exams.length && exams.every(t => t.status === 'completed');
|
||||||
|
const canHandoff = watchedTime || requirements?.requiredSeconds == null && ended;
|
||||||
|
if (!page.platformComplete && !page.quiz && !playbackPromptOf(snap.document) && !manualVideoPause
|
||||||
|
&& requirements?.requiresExam && exams.length && (canHandoff || task.examHandoff)) {
|
||||||
|
task.examHandoff = true;
|
||||||
|
state.video = { taskId: task.id, title: task.title, currentTime: page.video?.currentTime || task.currentTime || 0,
|
||||||
|
duration: page.video && Number.isFinite(page.video.duration) ? page.video.duration : task.duration,
|
||||||
|
learnedSeconds, requiredSeconds, requirements: requirements.text, platformComplete: false,
|
||||||
|
status: examsDone ? 'waitingPlatform' : 'waitingExam', retryCount: task.retryCount || 0 };
|
||||||
|
if (!allows('exam')) {
|
||||||
|
taskState(task, 'waiting', '课件还要求关联考试;当前仅处理视频/课中题,请切换“视频+考试”或人工完成关联考试'); return;
|
||||||
|
}
|
||||||
|
if (!examsDone) {
|
||||||
|
if (page.video) page.video.pause();
|
||||||
|
taskState(task, 'waitingExam', watchedTime ? '平台记录已达到观看时长要求,转入关联考试后再核对整体完成' : '视频已到末尾,转入关联考试;整体完成仍等待平台确认');
|
||||||
|
activeTaskId = ''; state.message = task.reason; await release(); return;
|
||||||
|
}
|
||||||
|
taskState(task, 'waitingPlatform', '关联考试已确认提交,等待课程目录的真实完成标记'); return;
|
||||||
|
}
|
||||||
if (!page.video) {
|
if (!page.video) {
|
||||||
if (task.selector) { const node = resolveNode(task, snap); if (node && task.status === 'loading' && !task.openedAt) { task.openedAt = now(); node.click(); return; } }
|
if (page.quiz) { taskState(task, 'waiting', '当前课中题尚未处理,等待网页恢复播放器后继续'); return; }
|
||||||
if (!task.openedAt) task.openedAt = now();
|
const first = snap.document.querySelector('app-course .cn_courseware_img,[data-courseware-id]');
|
||||||
|
const cover = courseCoverOf(snap.document);
|
||||||
|
if (cover && !task.coverClickedAt && (page.activeCourseware ? page.activeCourseware === node : node && node === first)) {
|
||||||
|
if (!await canAct(task, snap, epoch) || !snap.document.documentElement.contains(cover) || !visible(cover)) return;
|
||||||
|
task.coverClickedAt = now(); task.openedAt = now();
|
||||||
|
log('info', '点击封面“开始学习”,等待网页创建播放器', task, {}, 'video.coverStart');
|
||||||
|
cover.click(); return;
|
||||||
|
}
|
||||||
|
if (task.openedAt == null) task.openedAt = now();
|
||||||
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '播放器未加载或自动播放需要用户操作');
|
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '播放器未加载或自动播放需要用户操作');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const video = page.video;
|
const video = page.video;
|
||||||
if (page.activeCourseware && !task.current && task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent)) {
|
|
||||||
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
|
|
||||||
}
|
|
||||||
if (page.platformComplete) {
|
if (page.platformComplete) {
|
||||||
state.video = { taskId: task.id, title: task.title, currentTime: Number.isFinite(video.currentTime) ? video.currentTime : 0,
|
state.video = { taskId: task.id, title: task.title, currentTime: Number.isFinite(video.currentTime) ? video.currentTime : 0,
|
||||||
duration: Number.isFinite(video.duration) ? video.duration : null, actualRate: video.playbackRate,
|
duration: Number.isFinite(video.duration) ? video.duration : null, actualRate: video.playbackRate,
|
||||||
@@ -642,8 +808,8 @@
|
|||||||
}
|
}
|
||||||
const popup = page.quiz;
|
const popup = page.quiz;
|
||||||
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
|
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
|
||||||
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
|
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds, requiredSeconds, requirements: requirements?.text || '', platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
|
||||||
Object.assign(task, { currentTime, duration, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
|
Object.assign(task, { currentTime, duration, learnedSeconds, requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
|
||||||
const playbackPrompt = playbackPromptOf(snap.document);
|
const playbackPrompt = playbackPromptOf(snap.document);
|
||||||
if (playbackPrompt && !popup) {
|
if (playbackPrompt && !popup) {
|
||||||
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
|
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
|
||||||
@@ -861,23 +1027,28 @@
|
|||||||
}
|
}
|
||||||
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
|
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
|
||||||
}
|
}
|
||||||
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; }
|
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; requirementProbe = null; }
|
||||||
let task = state.tasks.find(t => t.id === activeTaskId);
|
let task = state.tasks.find(t => t.id === activeTaskId);
|
||||||
if (task && task.kind === 'course' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
|
if (task && task.kind === 'course' && ['course', 'video'].includes(page.kind)) {
|
||||||
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
|
const arrived = task.url ? canonical(task.url) === page.url
|
||||||
else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
|
: task.courseEntryClickedAt != null && task.title === page.title && page.url !== canonical(task.pageUrl) && page.url !== task.courseEntrySourceURL;
|
||||||
|
if (arrived) { task.visitedCourseURL = page.url; mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
|
||||||
|
// 跳转是异步的,仍在原课程时等待,不把原目录挂到下一课程名下。
|
||||||
} else mergeTasks(discoverTasks(snap.document, snap.url));
|
} else mergeTasks(discoverTasks(snap.document, snap.url));
|
||||||
if (state.status !== 'running') { await persist(); notify(); return getState(); }
|
if (state.status !== 'running') { await persist(); notify(); return getState(); }
|
||||||
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
|
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
|
||||||
if (!task) {
|
if (!task) {
|
||||||
const candidates = state.tasks.filter(t => ['pending', 'waitingLease'].includes(t.status) && allows(t.kind) && (!worker || t.id === worker.taskId));
|
const candidates = state.tasks.filter(t => (['pending', 'waitingLease'].includes(t.status)
|
||||||
|
|| t.status === 'waitingExam' && linkedExams(t).length && linkedExams(t).every(e => e.status === 'completed'))
|
||||||
|
&& allows(t.kind) && (!worker || t.id === worker.taskId));
|
||||||
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
|
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
|
||||||
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
|
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
|
||||||
candidates.sort((a, b) => rank(a) - rank(b));
|
candidates.sort((a, b) => rank(a) - rank(b));
|
||||||
const blockedKinds = new Set();
|
const blockedKinds = new Set();
|
||||||
for (const next of candidates) {
|
for (const next of candidates) {
|
||||||
const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue;
|
const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue;
|
||||||
if (await activate(next, snap, page)) { task = next; break; }
|
const activated = await activate(next, snap, page);
|
||||||
|
if (activated) { task = next; if (activated === 'deferred') task = null; break; }
|
||||||
if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; }
|
if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,6 +1058,7 @@
|
|||||||
acquiredLease(task);
|
acquiredLease(task);
|
||||||
if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
|
if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
|
||||||
else if (task.kind === 'exam') await examTick(task, snap, page);
|
else if (task.kind === 'exam') await examTick(task, snap, page);
|
||||||
|
else if (task.kind === 'course') await courseTick(task, snap, page, epoch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (epoch === generation) await parallelTick(snap, page);
|
if (epoch === generation) await parallelTick(snap, page);
|
||||||
@@ -924,6 +1096,7 @@
|
|||||||
if (watchedDocument) {
|
if (watchedDocument) {
|
||||||
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
|
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
|
||||||
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
|
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
|
||||||
|
watchedDocument.removeEventListener('mouseover', onRequirementHover, true);
|
||||||
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
|
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
|
||||||
}
|
}
|
||||||
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
|
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yianxue-question-bank-exporter",
|
"name": "yianxue-question-bank-exporter",
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验",
|
"description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
|
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
|
||||||
let actionEpoch = 0;
|
let actionEpoch = 0;
|
||||||
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
|
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
|
||||||
const version = '2.1.3';
|
const version = '2.1.4';
|
||||||
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||||
const pendingLearningSettings = new Map();
|
const pendingLearningSettings = new Map();
|
||||||
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
|
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/* Browser plugin not available. Complete bundle and independently modelled site DOM.
|
||||||
|
* Saved Angular templates supplied the selectors; no archived site scripts run here.
|
||||||
|
* All business routes are fulfilled locally and no learning record reaches the site. */
|
||||||
|
'use strict';
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const workspace = path.resolve(__dirname, '..');
|
||||||
|
const source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
|
||||||
|
const qaDir = path.join(os.tmpdir(), 'yianxue-course-entry-qa');
|
||||||
|
fs.mkdirSync(qaDir, { recursive: true });
|
||||||
|
const base = 'http://10.119.67.7/clientcn/';
|
||||||
|
const courseURL = base + 'course/course-entry-workflow';
|
||||||
|
const style = `<style>body{margin:24px;background:#eef2f7;color:#21344d;font:15px/1.5 system-ui}main{max-width:760px}h1{font-size:24px}.pc_course_left_top_header{font-size:23px;font-weight:650}.course-courseware-playdefult{height:260px;margin-top:18px;display:grid;place-items:center;background:#dae5ef;border-radius:10px}.course-courseware-playdefult>span{padding:8px 22px;background:#1697ff;color:white;border-radius:24px;cursor:pointer}li{list-style:none;background:white;padding:14px;border-radius:8px;margin-top:12px}li.active{outline:2px solid #649df0}.cn_courseware_img>p{font-weight:600;margin:0 0 8px}.menu_courseware{display:flex;justify-content:space-between}.menu_courseware_left{display:flex;gap:12px;color:#718093}.en_courseware_finished{display:inline-block;width:15px;height:15px;border-radius:50%;background:#29ab6a}.en_courseware_noFinished{background:#f49821}.related_test{color:#2475d3;cursor:pointer}.completion-tooltip{padding:6px;background:#20324b;color:white;position:absolute;z-index:100}video{width:100%;height:260px;background:#172a49}a{color:#2475d3}[hidden]{display:none!important}</style>`;
|
||||||
|
const cover = '<div class="course-courseware-playdefult"><span>开始学习</span></div>';
|
||||||
|
|
||||||
|
function row(index, { complete = false, active = false, exam = false } = {}) {
|
||||||
|
return '<li class="cn_courseware_img' + (active ? ' active' : '') + '" data-courseware-id="entry-' + index + '"><p><span>' + index + '.</span><span>安全培训视频' + index + '</span></p><div class="menu_courseware"><div class="menu_courseware_left"><div class="cn_courseware_img_icon">视频</div><div>27秒</div></div><div class="menu_courseware_right"><div class="courseware_finished_status"><img class="en_courseware_finished' + (complete ? '' : ' en_courseware_noFinished') + '" alt="' + (complete ? '已完成' : '尚未完成') + '"></div></div></div>' + (exam ? '<div class="related_test_outer"><p class="related_test">入口联动安全课程</p></div>' : '') + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentHTML(body) {
|
||||||
|
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>谊安学课程入口回归</title>' + style + '<body><h1>课程入口与完成状态</h1><main id="site-view">' + body + '</main><div class="completion-tooltip" hidden></div></body></html>';
|
||||||
|
}
|
||||||
|
function courseHTML(rows, initialCover = cover) {
|
||||||
|
return documentHTML('<app-course><div class="pc_course_left_top_header"><span data-course-title>入口联动安全课程</span><div class="pc_course_left_top_header_info">学习时长:10分钟33秒</div></div><div class="qa-player">' + initialCover + '</div><ol>' + rows + '</ol></app-course>');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(browser, html, options = {}) {
|
||||||
|
const memory = new Map(), requests = [], blocked = [], errors = [];
|
||||||
|
let holdLoadingWrite = false, releaseWrite = null, writeHeld = false;
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
|
||||||
|
await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
|
||||||
|
await context.exposeBinding('__gmWrite', async (_, key, value) => {
|
||||||
|
if (holdLoadingWrite && /learning:session\.v1$/.test(key) && value?.tasks?.some(task => task.status === 'loading')) {
|
||||||
|
holdLoadingWrite = false; writeHeld = true;
|
||||||
|
await new Promise(resolve => { releaseWrite = resolve; });
|
||||||
|
}
|
||||||
|
memory.set(key, structuredClone(value));
|
||||||
|
});
|
||||||
|
await context.route('**/*', route => {
|
||||||
|
requests.push(route.request().url());
|
||||||
|
if (route.request().isNavigationRequest() && route.request().url() === courseURL) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: html });
|
||||||
|
blocked.push(route.request().url()); return route.abort();
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
|
||||||
|
await page.goto(courseURL);
|
||||||
|
assert.equal(page.url(), courseURL); assert.equal(await page.title(), '谊安学课程入口回归');
|
||||||
|
assert(await page.locator('main').innerText(), '页面有真实课程内容');
|
||||||
|
await page.evaluate(({ delay, persistentCover, requirement }) => {
|
||||||
|
window.qaEntry = { coverClicks: 0, wrongClicks: 0, rowClicks: [], playCalls: 0, media: null, exams: 0 };
|
||||||
|
window.qaInstallVideo = () => {
|
||||||
|
document.querySelector('.qa-player').innerHTML = '<app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video>';
|
||||||
|
const media = { currentTime: 27, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
||||||
|
qaEntry.media = media;
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } });
|
||||||
|
video.play = async () => { qaEntry.playCalls++; media.paused = false; };
|
||||||
|
video.pause = () => { media.paused = true; };
|
||||||
|
};
|
||||||
|
window.qaWireCover = () => {
|
||||||
|
document.querySelectorAll('.course-courseware-playdefult > span').forEach(button => button.addEventListener('click', () => {
|
||||||
|
qaEntry.coverClicks++;
|
||||||
|
// The site's cover always defaults to the first item, even if already learned.
|
||||||
|
document.querySelectorAll('.cn_courseware_img').forEach((row, index) => row.classList.toggle('active', index === 0));
|
||||||
|
if (!persistentCover) setTimeout(qaInstallVideo, delay);
|
||||||
|
}));
|
||||||
|
document.querySelectorAll('[data-unknown-start]').forEach(button => button.addEventListener('click', () => { qaEntry.wrongClicks++; }));
|
||||||
|
};
|
||||||
|
qaWireCover();
|
||||||
|
document.querySelectorAll('.cn_courseware_img').forEach((node, index) => {
|
||||||
|
node.addEventListener('click', event => {
|
||||||
|
if (event.target.closest('.related_test')) return;
|
||||||
|
qaEntry.rowClicks.push(index + 1);
|
||||||
|
document.querySelectorAll('.cn_courseware_img').forEach(row => row.classList.toggle('active', row === node));
|
||||||
|
// Real li onSelect(item) calls toCourseware directly; it does not show a new cover.
|
||||||
|
qaEntry.media = null;
|
||||||
|
document.querySelector('.qa-player').innerHTML = '<p class="qa-loading">正在打开播放器</p>';
|
||||||
|
if (!persistentCover) setTimeout(qaInstallVideo, delay);
|
||||||
|
});
|
||||||
|
node.querySelector('.en_courseware_finished').addEventListener('mouseover', () => {
|
||||||
|
const tip = document.querySelector('.completion-tooltip');
|
||||||
|
tip.hidden = false; tip.textContent = requirement || '1.观看视频进度100%' + (node.querySelector('.related_test') ? ' 2.完成考试答题,并且及格通过' : '');
|
||||||
|
});
|
||||||
|
node.querySelector('.en_courseware_finished').addEventListener('mouseout', () => { document.querySelector('.completion-tooltip').hidden = true; });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.related_test').forEach(button => button.addEventListener('click', event => {
|
||||||
|
event.stopPropagation(); qaEntry.exams++;
|
||||||
|
history.pushState({}, '', '/clientcn/exam/entry-linked');
|
||||||
|
document.querySelector('#site-view').innerHTML = '<app-examdetail><div class="examdetail_header">入口联动安全课程 已答0/1</div><div>总考试次数:1 次</div><div class="examdetail_button">开始答题</div></app-examdetail>';
|
||||||
|
document.querySelector('.examdetail_button').onclick = () => { qaEntry.wrongClicks++; };
|
||||||
|
}));
|
||||||
|
window.qaComplete = index => {
|
||||||
|
const node = document.querySelectorAll('.cn_courseware_img')[index - 1];
|
||||||
|
node.querySelector('.en_courseware_finished').classList.remove('en_courseware_noFinished');
|
||||||
|
node.querySelector('.en_courseware_finished').alt = '已完成';
|
||||||
|
};
|
||||||
|
}, { delay: options.delay == null ? 1100 : options.delay, persistentCover: options.persistentCover === true, requirement: options.requirement || '' });
|
||||||
|
const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + source + '\n})();';
|
||||||
|
await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready);
|
||||||
|
return { page, context, memory, requests, blocked, errors,
|
||||||
|
holdLoadingWrite() { holdLoadingWrite = true; },
|
||||||
|
async waitForLoadingWrite() {
|
||||||
|
for (let i = 0; i < 100 && !writeHeld; i++) await page.waitForTimeout(50);
|
||||||
|
assert(writeHeld, '应捕获入口点击前等待中的真实GM存储写入');
|
||||||
|
},
|
||||||
|
releaseWrite() { releaseWrite?.(); },
|
||||||
|
async close() {
|
||||||
|
assert.deepEqual(blocked, [], '测试不能访问真实业务接口或归档资源');
|
||||||
|
assert.deepEqual(errors, [], '浏览器无相关错误或警告');
|
||||||
|
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
|
||||||
|
await context.close();
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configure(page, mode = 'videoQuiz') {
|
||||||
|
await page.evaluate(async mode => { await YianAssistant.setLearningSetting('mode', mode); await YianAssistant.setLearningSetting('answerInterval', 0); }, mode);
|
||||||
|
}
|
||||||
|
async function waitFor(page, predicate, label, timeout = 12000) {
|
||||||
|
await page.waitForFunction(predicate, null, { timeout }).catch(async error => {
|
||||||
|
console.error(label, await page.evaluate(() => ({ entry: qaEntry, learning: YianAssistant.getLearningState() }))); throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||||
|
try {
|
||||||
|
// The cover receives its click on the child span, exactly as onSelectDefult in the saved template.
|
||||||
|
const entry = await setup(browser, courseHTML(row(1)));
|
||||||
|
await configure(entry.page);
|
||||||
|
await entry.page.waitForTimeout(800);
|
||||||
|
assert.equal(await entry.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '未授权开始前不点击封面/课程');
|
||||||
|
await entry.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||||
|
await waitFor(entry.page, () => qaEntry.coverClicks === 1 && qaEntry.media && !qaEntry.media.paused, '唯一开始学习入口应被正确点击');
|
||||||
|
await entry.page.waitForTimeout(2200);
|
||||||
|
assert.equal(await entry.page.evaluate(() => qaEntry.coverClicks), 1, '播放器异步出现与重复tick不得重复点封面');
|
||||||
|
assert.deepEqual(await entry.page.evaluate(() => qaEntry.rowClicks), [], '可用的默认封面入口不重复点目录项');
|
||||||
|
await entry.close();
|
||||||
|
console.log('PASS 完整bundle:初始无active时唯一真实封面span点击一次,异步播放器出现并正常播放');
|
||||||
|
|
||||||
|
const flow = await setup(browser, courseHTML(row(1, { complete: true }) + row(2) + row(3)));
|
||||||
|
const { page } = flow;
|
||||||
|
await configure(page);
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
assert.equal(await page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '未授权开始前不点击封面/课程');
|
||||||
|
await page.evaluate(() => YianAssistant.taskAction('start'));
|
||||||
|
await waitFor(page, () => qaEntry.rowClicks.includes(2) && qaEntry.media && !qaEntry.media.paused, '首课件完成时应选第二目录项');
|
||||||
|
assert(!await page.evaluate(() => qaEntry.rowClicks.includes(1)), '平台已完成课件不能重开');
|
||||||
|
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 1);
|
||||||
|
await page.waitForTimeout(2200);
|
||||||
|
assert.equal(await page.evaluate(() => qaEntry.coverClicks), 0, '默认封面会打开已完成的第一项,必须直接点击第二目录项');
|
||||||
|
assert.deepEqual(await page.evaluate(() => qaEntry.rowClicks), [2], '播放器异步出现时不得重复点击目录');
|
||||||
|
const host = page.locator('#yian-question-assistant');
|
||||||
|
await host.getByRole('button', { name: '展开题库助手', exact: true }).click();
|
||||||
|
await host.getByTestId('tab-overview').click();
|
||||||
|
await page.screenshot({ path: path.join(qaDir, '01-cover-started-desktop.png') });
|
||||||
|
await page.evaluate(() => qaComplete(2));
|
||||||
|
await waitFor(page, () => qaEntry.rowClicks.includes(3) && qaEntry.media && !qaEntry.media.paused && document.querySelectorAll('.cn_courseware_img')[2].classList.contains('active'), '完成视频二后应自动到视频三');
|
||||||
|
assert.equal(await page.evaluate(() => qaEntry.rowClicks.filter(index => index === 3).length), 1, '下一课件只点击一次');
|
||||||
|
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 2);
|
||||||
|
await page.evaluate(() => qaComplete(3));
|
||||||
|
await waitFor(page, () => YianAssistant.getLearningState().status === 'finished' && YianAssistant.getLearningState().completed === 3, '全部课件完成应结束当前课件清单');
|
||||||
|
assert.equal(await page.evaluate(() => qaEntry.coverClicks), 0);
|
||||||
|
assert.deepEqual(await page.evaluate(() => qaEntry.rowClicks.filter(index => index === 1)), []);
|
||||||
|
assert.equal(flow.requests.length, 1, '没有已知下课程入口时不能猜测新网址');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.screenshot({ path: path.join(qaDir, '02-finished-mobile.png') });
|
||||||
|
await flow.close();
|
||||||
|
console.log('PASS 完整bundle:首项平台完成时直接选择未完成目录,播放器异步只开一次→完成后二到三→全部完成跳过;390px/桌面截图与零业务网络');
|
||||||
|
|
||||||
|
const paused = await setup(browser, courseHTML(row(1)), { persistentCover: true });
|
||||||
|
await configure(paused.page);
|
||||||
|
await paused.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||||
|
await waitFor(paused.page, () => qaEntry.coverClicks === 1, '封面首次点击');
|
||||||
|
await paused.page.evaluate(() => YianAssistant.taskAction('pause'));
|
||||||
|
await paused.page.waitForTimeout(1800);
|
||||||
|
assert.equal(await paused.page.evaluate(() => qaEntry.coverClicks), 1, '全局暂停不能再点开始学习');
|
||||||
|
await paused.page.evaluate(() => { qaInstallVideo(); });
|
||||||
|
await paused.page.waitForTimeout(1400);
|
||||||
|
assert.equal(await paused.page.evaluate(() => qaEntry.playCalls), 0, '暂停后迟到播放器不应启动');
|
||||||
|
await paused.page.evaluate(() => YianAssistant.taskAction('resume'));
|
||||||
|
await waitFor(paused.page, () => qaEntry.playCalls === 1, '明确继续后才播放迟到播放器');
|
||||||
|
await paused.close();
|
||||||
|
console.log('PASS 全局暂停使封面流程停止,异步迟到播放器不播放,明确继续后核验再播放');
|
||||||
|
|
||||||
|
const partial = await setup(browser, courseHTML(row(1)), { persistentCover: true });
|
||||||
|
await configure(partial.page);
|
||||||
|
await partial.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||||
|
await waitFor(partial.page, () => qaEntry.coverClicks === 1, '局部暂停前封面首次点击');
|
||||||
|
await partial.page.evaluate(() => YianAssistant.taskAction('videoPause'));
|
||||||
|
await partial.page.evaluate(() => qaInstallVideo());
|
||||||
|
await partial.page.waitForTimeout(1700);
|
||||||
|
assert.equal(await partial.page.evaluate(() => qaEntry.playCalls), 0, '视频局部暂停期间不启动迟到播放器');
|
||||||
|
assert.equal(await partial.page.evaluate(() => qaEntry.coverClicks), 1);
|
||||||
|
await partial.close();
|
||||||
|
console.log('PASS 视频局部暂停也阻止异步播放器启动和封面重试');
|
||||||
|
|
||||||
|
const localPending = await setup(browser, courseHTML(row(1)));
|
||||||
|
await configure(localPending.page); localPending.holdLoadingWrite();
|
||||||
|
await localPending.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); });
|
||||||
|
await localPending.waitForLoadingWrite();
|
||||||
|
await localPending.page.evaluate(() => YianAssistant.taskAction('videoPause'));
|
||||||
|
localPending.releaseWrite();
|
||||||
|
await localPending.page.evaluate(() => qaPendingStart);
|
||||||
|
await localPending.page.waitForTimeout(700);
|
||||||
|
assert.equal(await localPending.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '入口存储写入期间局部暂停,迟到的旧动作不得点击封面或目录');
|
||||||
|
assert.equal(await localPending.page.evaluate(() => YianAssistant.getLearningState().videoPaused), true);
|
||||||
|
await localPending.page.evaluate(() => YianAssistant.taskAction('videoResume'));
|
||||||
|
await waitFor(localPending.page, () => qaEntry.coverClicks === 1 && qaEntry.media && !qaEntry.media.paused, '局部继续须重新领取任务后正常点击真实封面');
|
||||||
|
await localPending.page.waitForTimeout(1200);
|
||||||
|
assert.equal(await localPending.page.evaluate(() => qaEntry.coverClicks), 1, '局部继续后仅点击封面一次');
|
||||||
|
assert.deepEqual(await localPending.page.evaluate(() => qaEntry.rowClicks), []);
|
||||||
|
await localPending.close();
|
||||||
|
console.log('PASS GM加载写入等待时局部暂停取消旧点击,局部继续重新领取后真实封面只点一次并播放');
|
||||||
|
|
||||||
|
for (const [name, badCover] of [['未知按钮', '<button data-unknown-start>开始学习</button>'], ['多个封面按钮', cover + cover]]) {
|
||||||
|
const bad = await setup(browser, courseHTML(row(1), badCover));
|
||||||
|
await configure(bad.page); await bad.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||||
|
await bad.page.waitForTimeout(2200);
|
||||||
|
assert.equal(await bad.page.evaluate(() => qaEntry.coverClicks + qaEntry.wrongClicks), 0, name + '必须等待人工确认,不能按文案泛点');
|
||||||
|
await bad.close();
|
||||||
|
}
|
||||||
|
console.log('PASS 未知开始学习按钮与多个真实候选都不猜点');
|
||||||
|
|
||||||
|
const multiRows = row(1, { exam: true }).replace('</p></div></li>', '</p><p class="related_test">另一份安全考核</p></div></li>');
|
||||||
|
const multi = await setup(browser, courseHTML(multiRows));
|
||||||
|
const related = await multi.page.evaluate(() => YianAssistant.getLearningState().tasks.filter(task => task.kind === 'exam'));
|
||||||
|
assert.deepEqual(related.map(task => task.title), ['入口联动安全课程', '另一份安全考核'], '同一课件两个实际关联考试都需保留,不能在子序号加入前被基础id去重');
|
||||||
|
assert.equal(new Set(related.map(task => task.id)).size, 2, '每个关联考试入口有独立任务身份');
|
||||||
|
assert.equal(await multi.page.evaluate(() => qaEntry.exams + qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '只识别多个入口不自动启动');
|
||||||
|
await multi.close();
|
||||||
|
console.log('PASS 同一课件多个实际关联考试分别识别,不遗漏、不重复、不擅自开考');
|
||||||
|
|
||||||
|
const stale = await setup(browser, courseHTML(row(1)), { persistentCover: true });
|
||||||
|
await configure(stale.page); stale.holdLoadingWrite();
|
||||||
|
await stale.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); });
|
||||||
|
await stale.waitForLoadingWrite();
|
||||||
|
await stale.page.evaluate(() => {
|
||||||
|
const old = document.querySelector('app-course');
|
||||||
|
const replacement = old.cloneNode(true);
|
||||||
|
replacement.querySelector('.course-courseware-playdefult > span').onclick = () => { qaEntry.wrongClicks++; };
|
||||||
|
old.replaceWith(replacement);
|
||||||
|
});
|
||||||
|
stale.releaseWrite();
|
||||||
|
await stale.page.evaluate(() => qaPendingStart);
|
||||||
|
await stale.page.evaluate(() => YianAssistant.taskAction('pause'));
|
||||||
|
await stale.page.waitForTimeout(700);
|
||||||
|
assert.equal(await stale.page.evaluate(() => qaEntry.coverClicks + qaEntry.wrongClicks), 0, '同URL同标题的课程根已替换,旧动作不能点击已分离按钮或新封面');
|
||||||
|
await stale.close();
|
||||||
|
console.log('PASS 异步存储写入期间同URL同标题的课程根被替换,旧计划不点击迟到封面');
|
||||||
|
|
||||||
|
const reordered = await setup(browser, courseHTML(row(1) + row(2)), { persistentCover: true });
|
||||||
|
await configure(reordered.page); reordered.holdLoadingWrite();
|
||||||
|
await reordered.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); });
|
||||||
|
await reordered.waitForLoadingWrite();
|
||||||
|
await reordered.page.evaluate(() => {
|
||||||
|
const list = document.querySelector('ol'); list.append(list.firstElementChild);
|
||||||
|
});
|
||||||
|
reordered.releaseWrite();
|
||||||
|
await reordered.page.evaluate(() => qaPendingStart);
|
||||||
|
await reordered.page.evaluate(() => YianAssistant.taskAction('pause'));
|
||||||
|
assert.equal(await reordered.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '同URL同标题同根的目录顺序已变化,旧索引不能点击封面默认项或错行');
|
||||||
|
await reordered.close();
|
||||||
|
console.log('PASS 异步加载期间目录重排,仍在文档中的旧按钮也不按过期索引点击');
|
||||||
|
|
||||||
|
for (const mode of ['all', 'videoQuiz']) {
|
||||||
|
const combined = await setup(browser, courseHTML(row(1, { exam: true })));
|
||||||
|
await configure(combined.page, mode);
|
||||||
|
await combined.page.evaluate(async () => {
|
||||||
|
const records = [{ course: '入口联动安全课程', type: '单选', question: '课后核验题', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }];
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||||
|
await YianAssistant.importFiles([{ name: '入口联动题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||||
|
await YianAssistant.taskAction('start');
|
||||||
|
});
|
||||||
|
await waitFor(combined.page, () => qaEntry.media && !qaEntry.media.paused, '组合完成条件前视频应正常播放');
|
||||||
|
assert.equal(await combined.page.evaluate(() => qaEntry.exams), 0, '未达到视频进度要求前不进入考试');
|
||||||
|
await combined.page.evaluate(() => { qaEntry.media.currentTime = 100; qaEntry.media.ended = true; });
|
||||||
|
if (mode === 'all') {
|
||||||
|
await waitFor(combined.page, () => qaEntry.exams === 1 && location.pathname === '/clientcn/exam/entry-linked', '视频达到要求后应转同课件实际关联考试');
|
||||||
|
await waitFor(combined.page, () => YianAssistant.getLearningState().tasks.some(task => task.kind === 'exam' && task.status === 'waiting'), '单次机会保护应停在真实考试详情');
|
||||||
|
const state = await combined.page.evaluate(() => YianAssistant.getLearningState());
|
||||||
|
assert.equal(state.tasks.find(task => task.kind === 'video').status, 'waitingExam', '橙色图标仍需考试,不能把整个课件当作平台已完成');
|
||||||
|
assert.equal(state.tasks.find(task => task.kind === 'video').progress < 1 || state.video.platformComplete === false, true, '进度满只表示视频阶段,不能伪报平台完成');
|
||||||
|
assert.notEqual(state.status, 'finished');
|
||||||
|
assert.equal(await combined.page.evaluate(() => qaEntry.wrongClicks), 0, '仅一次机会交给人工,不点击开始答题');
|
||||||
|
} else {
|
||||||
|
await combined.page.waitForTimeout(2400);
|
||||||
|
assert.equal(await combined.page.evaluate(() => qaEntry.exams), 0, 'videoQuiz模式不能扩大授权自动进入课程考试');
|
||||||
|
const state = await combined.page.evaluate(() => YianAssistant.getLearningState());
|
||||||
|
assert.notEqual(state.status, 'finished', '橙图标不能误记完成');
|
||||||
|
assert.equal(state.video.platformComplete, false);
|
||||||
|
assert.match(state.tasks.find(task => task.kind === 'video').reason, /考试|模式|完成条件/);
|
||||||
|
}
|
||||||
|
await combined.close();
|
||||||
|
}
|
||||||
|
console.log('PASS 真实悬浮完成条件:进度满但仍需考试时all让出至唯一关联考试且等待绿图标;videoQuiz保持人工等待,不误报已完成');
|
||||||
|
|
||||||
|
const timed = await setup(browser, courseHTML(row(1, { exam: true })), { requirement: '1.观看课件时长达到1分钟 2.完成考试答题' });
|
||||||
|
await configure(timed.page, 'all');
|
||||||
|
await timed.page.evaluate(async () => {
|
||||||
|
const records = [{ course: '入口联动安全课程', type: '单选', question: '时长要求核验', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }];
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||||
|
await YianAssistant.importFiles([{ name: '时长要求题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||||
|
await YianAssistant.taskAction('start');
|
||||||
|
});
|
||||||
|
await waitFor(timed.page, () => qaEntry.media && !qaEntry.media.paused, '时长要求下正常启动视频');
|
||||||
|
await timed.page.evaluate(() => { qaEntry.media.currentTime = 100; qaEntry.media.ended = true; });
|
||||||
|
await timed.page.waitForTimeout(2200);
|
||||||
|
assert.equal(await timed.page.evaluate(() => qaEntry.exams), 0, '明确要求平台累计1分钟,视频ended不能取代平台27秒记录');
|
||||||
|
assert.equal(await timed.page.evaluate(() => YianAssistant.getLearningState().video.platformComplete), false);
|
||||||
|
await timed.page.evaluate(() => { document.querySelector('.menu_courseware_left > div:last-child').textContent = '60秒'; });
|
||||||
|
await waitFor(timed.page, () => qaEntry.exams === 1, '平台累计达到明确时长后才进入实际关联考试');
|
||||||
|
assert.notEqual(await timed.page.evaluate(() => YianAssistant.getLearningState().status), 'finished', '时长达标仍等待考试及绿色整体完成标记');
|
||||||
|
await timed.close();
|
||||||
|
console.log('PASS 明确观看时长要求使用平台累计记录;仅ended不越过门槛,累计达到1分钟才转关联考试');
|
||||||
|
|
||||||
|
const guarded = await setup(browser, courseHTML(row(1, { exam: true })), { requirement: '1.观看课件时长达到1分钟 2.完成考试答题' });
|
||||||
|
await configure(guarded.page, 'all');
|
||||||
|
await guarded.page.evaluate(async () => {
|
||||||
|
const records = [{ course: '入口联动安全课程', type: '单选', question: '课中核验题', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }];
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||||
|
await YianAssistant.importFiles([{ name: '课中保护题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||||
|
await YianAssistant.taskAction('start');
|
||||||
|
});
|
||||||
|
await waitFor(guarded.page, () => qaEntry.media && !qaEntry.media.paused, '保护场景视频先正常播放');
|
||||||
|
await guarded.page.evaluate(() => {
|
||||||
|
const popup = document.createElement('div'); popup.className = 'question-modal-pc';
|
||||||
|
popup.innerHTML = '<p>剩余答题次数:1次</p><div class="question_box"><div class="examtest_content_question"><p class="show"><span>[单选]</span>课中核验题</p></div><div class="examtest_content_list"><div class="examtest_content_list_des"><span class="examtest_content_list_des_optionsort">A</span>准确措施</div><div class="examtest_content_list_des"><span class="examtest_content_list_des_optionsort">B</span>错误措施</div></div></div>';
|
||||||
|
popup.querySelectorAll('.examtest_content_list_des').forEach(option => { option.onclick = () => { qaEntry.wrongClicks++; }; });
|
||||||
|
document.body.append(popup);
|
||||||
|
qaEntry.media.currentTime = 100; qaEntry.media.ended = true;
|
||||||
|
document.querySelector('.menu_courseware_left > div:last-child').textContent = '60秒';
|
||||||
|
});
|
||||||
|
await waitFor(guarded.page, () => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'waiting'), '仅一次课中题先交给人工');
|
||||||
|
await guarded.page.waitForTimeout(1200);
|
||||||
|
assert.equal(await guarded.page.evaluate(() => qaEntry.exams), 0, '课中弹题存在时即使观看时长达标也不越过进入关联考试');
|
||||||
|
assert.equal(await guarded.page.evaluate(() => qaEntry.wrongClicks), 0, '一次机会保护无选项点击');
|
||||||
|
await guarded.page.evaluate(async () => { await YianAssistant.taskAction('pause'); document.querySelector('.question-modal-pc').remove(); });
|
||||||
|
await guarded.page.waitForTimeout(1200);
|
||||||
|
assert.equal(await guarded.page.evaluate(() => qaEntry.exams), 0, '手动暂停优先,弹题消失和时长达标也不跳任务');
|
||||||
|
await guarded.page.evaluate(() => YianAssistant.taskAction('resume'));
|
||||||
|
await waitFor(guarded.page, () => qaEntry.exams === 1, '人工处理课中题并明确继续后才能转实际关联考试');
|
||||||
|
await guarded.close();
|
||||||
|
console.log('PASS 观看达标时课中弹题和暂停优先;一次机会不操作,弹题处理后明确继续才转关联考试');
|
||||||
|
console.log('截图:' + qaDir);
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/* Isolated multi-page navigation and retry regression. All site requests are
|
||||||
|
* fulfilled locally; the archived site's scripts and business APIs never run. */
|
||||||
|
'use strict';
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'learning-engine.js'), 'utf8');
|
||||||
|
const origin = 'http://10.119.67.7/clientcn/';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const blocked = [], errors = [];
|
||||||
|
await context.route('**/*', route => {
|
||||||
|
if (route.request().isNavigationRequest() && route.request().url().startsWith(origin)) return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8"><body></body>' });
|
||||||
|
blocked.push(route.request().url()); return route.abort();
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.goto(origin + 'learningmap/navigation-fixture');
|
||||||
|
await page.addScriptTag({ content: source });
|
||||||
|
let checks = 0;
|
||||||
|
try {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.qaClock = 100000; window.qaMemory = new Map(); window.qaRequests = []; window.qaPendingNavigation = '';
|
||||||
|
window.qaCourseClicks = [0, 0]; window.qaPlayCalls = 0;
|
||||||
|
window.qaMapURL = location.href;
|
||||||
|
window.qaStorage = { get: async key => qaMemory.has(key) ? structuredClone(qaMemory.get(key)) : null,
|
||||||
|
set: async (key, value) => { qaMemory.set(key, structuredClone(value)); } };
|
||||||
|
window.qaRenderMap = () => {
|
||||||
|
document.body.innerHTML = '<app-learning-map-activity-list>' + ['一', '二'].map((name, index) =>
|
||||||
|
'<div class="activity_content" data-course-id="course-' + index + '"><div class="map_activity_content_info_top"><div class="activity_name">课程' + name + '</div><div class="acitvity_other_info">必修课程</div></div></div>').join('') + '</app-learning-map-activity-list>';
|
||||||
|
document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => {
|
||||||
|
node.onclick = () => { qaCourseClicks[index]++; history.pushState({}, '', '/clientcn/course/map-course-' + index); qaRenderCourse(index); };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.qaRenderCourse = index => {
|
||||||
|
document.body.innerHTML = '<app-course><div data-course-title>课程' + (index ? '二' : '一') + '</div><li class="cn_courseware_img active" data-courseware-id="map-video-' + index + '" data-kind="video"' + (index ? '' : ' data-is-learned="true"') + '><p>视频' + (index ? '二' : '一') + '</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div></li><app-course-courseware-video><video></video></app-course-courseware-video></app-course>';
|
||||||
|
const media = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
for (const key of Object.keys(media)) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } });
|
||||||
|
video.play = async () => { qaPlayCalls++; media.paused = false; };
|
||||||
|
video.pause = () => { media.paused = true; };
|
||||||
|
};
|
||||||
|
window.qaFlushNavigation = () => {
|
||||||
|
if (!qaPendingNavigation) return false;
|
||||||
|
history.pushState({}, '', qaPendingNavigation); qaPendingNavigation = ''; qaRenderMap(); return true;
|
||||||
|
};
|
||||||
|
qaRenderMap();
|
||||||
|
window.qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||||
|
getSnapshot: () => ({ document, url: location.href, planId: 'navigation-fixture', accountId: 'fixture-account' }),
|
||||||
|
navigate: async url => { qaRequests.push(url); qaPendingNavigation = url; } });
|
||||||
|
await qaEngine.configure({ mode: 'video', execution: 'sequential' }); await qaEngine.start();
|
||||||
|
});
|
||||||
|
assert.equal(await page.evaluate(() => qaCourseClicks[0]), 1, '地图原始课程入口被点击一次');
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
const beforeReturn = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, requests: qaRequests, pending: qaPendingNavigation }));
|
||||||
|
assert.equal(beforeReturn.requests[0], origin + 'learningmap/navigation-fixture', '下一课程无 href 时返回访问过的原地图地址');
|
||||||
|
assert.equal(beforeReturn.pending, beforeReturn.requests[0]);
|
||||||
|
assert.match(beforeReturn.url, /\/course\/map-course-0$/, '异步导航生效前仍显示旧课程');
|
||||||
|
assert.equal(beforeReturn.state.tasks.find(task => task.title === '视频一').status, 'completed', '课程一的实际平台完成状态被识别');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) await page.evaluate(() => qaEngine.tick());
|
||||||
|
const stale = await page.evaluate(() => ({ tasks: qaEngine.getState().tasks, clicks: qaCourseClicks }));
|
||||||
|
const secondCourse = stale.tasks.find(task => task.kind === 'course' && task.title === '课程二');
|
||||||
|
assert(secondCourse, '保留下一课程任务');
|
||||||
|
assert(!['expanded', 'completed'].includes(secondCourse.status), '旧课程仍显示时不能误展开或完成下一课程');
|
||||||
|
assert(!stale.tasks.some(task => task.parentCourse === secondCourse.id), '旧课程课件不能归到下一课程');
|
||||||
|
assert.deepEqual(stale.clicks, [1, 0], '未到地图前不点击错误课程');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
assert.equal(await page.evaluate(() => qaFlushNavigation()), true);
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaEngine.getState().video?.title === '视频二')) break;
|
||||||
|
}
|
||||||
|
const next = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaCourseClicks, playCalls: qaPlayCalls }));
|
||||||
|
assert.deepEqual(next.clicks, [1, 1], '回地图后自动点击第二课程一次');
|
||||||
|
assert.equal(next.url, origin + 'course/map-course-1');
|
||||||
|
assert.equal(next.state.video?.title, '视频二', '第二课程的实际视频自动进入调度');
|
||||||
|
assert.equal(next.state.video?.status, 'playing');
|
||||||
|
assert(next.playCalls >= 1);
|
||||||
|
assert.equal(next.state.tasks.filter(task => task.title === '视频一').length, 1, '回地图不会重复添加旧课件');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
// A timed-out task can be retried explicitly. The retry must click the real
|
||||||
|
// cover span again and receive a fresh loading window, without resetting
|
||||||
|
// platform progress or the bounded automatic-recovery counter.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear();
|
||||||
|
history.replaceState({}, '', '/clientcn/course/retry-loading-fixture');
|
||||||
|
document.body.innerHTML = '<app-course><div data-course-title>重试课程</div><div class="course-courseware-playdefult"><span>开始学习</span></div><li class="cn_courseware_img active" data-courseware-id="retry-video" data-kind="video"><p>重试视频</p><div class="cn_courseware_img_icon">视频</div></li></app-course>';
|
||||||
|
window.qaCoverClicks = 0; document.querySelector('.course-courseware-playdefult > span').onclick = () => { qaCoverClicks++; };
|
||||||
|
qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||||
|
getSnapshot: () => ({ document, url: location.href, accountId: 'fixture-account' }) });
|
||||||
|
await qaEngine.configure({ mode: 'video' }); await qaEngine.start();
|
||||||
|
});
|
||||||
|
assert.equal(await page.evaluate(() => qaCoverClicks), 1, '首次正常点击实际封面 span');
|
||||||
|
await page.evaluate(async () => { qaClock += 16001; await qaEngine.tick(); });
|
||||||
|
assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting');
|
||||||
|
const retry = await page.evaluate(async () => { await qaEngine.resume(); return { clicks: qaCoverClicks, state: qaEngine.getState() }; });
|
||||||
|
assert.equal(retry.clicks, 2, '明确继续后再次点击真实封面入口');
|
||||||
|
assert.notEqual(retry.state.tasks.find(task => task.kind === 'video').status, 'waiting', '旧 openedAt 不能让新重试立即超时');
|
||||||
|
await page.evaluate(async () => { qaClock += 14999; await qaEngine.tick(); });
|
||||||
|
assert.notEqual(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting', '本次加载有独立的 15 秒等待窗口');
|
||||||
|
await page.evaluate(async () => { qaClock += 1002; await qaEngine.tick(); });
|
||||||
|
assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting');
|
||||||
|
assert.equal(await page.evaluate(() => qaCoverClicks), 2, '超时期间不循环点击封面');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
// Display names are not resource identity. While navigation back to the
|
||||||
|
// map is pending, the old course has exactly the same name as the next.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear();
|
||||||
|
history.replaceState({}, '', '/clientcn/learningmap/same-title-fixture');
|
||||||
|
window.qaSameMapURL = location.href; window.qaSamePending = ''; window.qaSameClicks = [0, 0];
|
||||||
|
window.qaRenderSameMap = () => {
|
||||||
|
document.body.innerHTML = '<app-learning-map-activity-list>' + ['resource-first', 'resource-second'].map(resource =>
|
||||||
|
'<div class="activity_content" data-course-id="' + resource + '"><div class="map_activity_content_info_top"><div class="activity_name">同名课程</div><div class="acitvity_other_info">必修课程</div></div></div>').join('') + '</app-learning-map-activity-list>';
|
||||||
|
document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => {
|
||||||
|
node.onclick = () => {
|
||||||
|
qaSameClicks[index]++; history.pushState({}, '', '/clientcn/course/resource?resourceId=' + (index ? 'resource-second' : 'resource-first'));
|
||||||
|
qaRenderCourse(index); document.querySelector('[data-course-title]').textContent = '同名课程';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
qaRenderSameMap();
|
||||||
|
qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||||
|
getSnapshot: () => ({ document, url: location.href, planId: 'same-title-fixture', accountId: 'fixture-account' }),
|
||||||
|
navigate: async url => { qaSamePending = url; } });
|
||||||
|
await qaEngine.configure({ mode: 'video', execution: 'sequential' }); await qaEngine.start(); await qaEngine.tick();
|
||||||
|
});
|
||||||
|
assert.equal(await page.evaluate(() => qaSamePending), origin + 'learningmap/same-title-fixture');
|
||||||
|
for (let i = 0; i < 3; i++) await page.evaluate(() => qaEngine.tick());
|
||||||
|
const sameTitleStale = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaSameClicks }));
|
||||||
|
const sameTitleSecond = sameTitleStale.state.tasks.find(task => task.kind === 'course' && task.identity === 'resource-second');
|
||||||
|
assert(sameTitleSecond, '保留相同名称但资源不同的第二课程');
|
||||||
|
assert(!['expanded', 'completed'].includes(sameTitleSecond.status), '同名旧课程不能冒充尚未点击入口的下一资源');
|
||||||
|
assert(!sameTitleStale.state.tasks.some(task => task.parentCourse === sameTitleSecond.id), '同名旧课件不能挂到第二资源');
|
||||||
|
assert.equal(new URL(sameTitleStale.url).searchParams.get('resourceId'), 'resource-first');
|
||||||
|
assert.deepEqual(sameTitleStale.clicks, [1, 0]);
|
||||||
|
await page.evaluate(() => { history.pushState({}, '', qaSamePending); qaSamePending = ''; qaRenderSameMap(); });
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaEngine.getState().video?.title === '视频二')) break;
|
||||||
|
}
|
||||||
|
const sameTitleNext = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaSameClicks }));
|
||||||
|
assert.deepEqual(sameTitleNext.clicks, [1, 1]);
|
||||||
|
assert.equal(new URL(sameTitleNext.url).searchParams.get('resourceId'), 'resource-second', '第二资源只能在原地图入口点击后进入');
|
||||||
|
assert.equal(sameTitleNext.state.video?.title, '视频二');
|
||||||
|
assert.equal(sameTitleNext.state.video?.status, 'playing');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
// A worker reads its task checkpoint after restoring the shared session.
|
||||||
|
// Its old checkpoint must not overwrite the fresh page's loading window.
|
||||||
|
const workerRestore = await page.evaluate(async () => {
|
||||||
|
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
const saved = await qaStorage.get('session.v1'); qaMemory.clear();
|
||||||
|
const workerURL = 'http://10.119.67.7/clientcn/course/worker-loading-fixture';
|
||||||
|
const nonce = 'checkpoint-window-fixture'; history.replaceState({}, '', workerURL + '#__yian_worker=' + nonce);
|
||||||
|
document.body.innerHTML = '<app-course><div data-course-title>工作页课程</div><div class="course-courseware-playdefult"><span>开始学习</span></div><li class="cn_courseware_img active" data-courseware-id="worker-video" data-kind="video"><p>工作页视频</p><div class="cn_courseware_img_icon">视频</div></li></app-course>';
|
||||||
|
window.qaWorkerCoverClicks = 0; document.querySelector('.course-courseware-playdefult > span').onclick = () => { qaWorkerCoverClicks++; };
|
||||||
|
const task = YianLearningEngine.discoverTasks(document, workerURL).find(item => item.kind === 'video');
|
||||||
|
Object.assign(task, { status: 'loading', openedAt: qaClock, retryCount: 2, lastRefreshAt: qaClock - 60000 });
|
||||||
|
Object.assign(saved, { status: 'running', tasks: [task], activeTaskId: task.id, accountId: 'fixture-account', planId: '', bankVersion: null, config: Object.assign({}, saved.config, { mode: 'video', execution: 'sequential' }) });
|
||||||
|
await qaStorage.set('session.v1', saved);
|
||||||
|
await qaStorage.set('control.v1', { sessionId: saved.id, status: 'running', config: saved.config });
|
||||||
|
await qaStorage.set('worker:' + nonce, { nonce, sessionId: saved.id, taskId: task.id, url: workerURL, expires: qaClock + 30000, owner: 'old-worker' });
|
||||||
|
await qaStorage.set('task:' + saved.id + ':' + task.id, Object.assign({}, task, { openedAt: qaClock - 90000, coverClickedAt: qaClock - 90000 }));
|
||||||
|
qaEngine = YianLearningEngine.create({ ownerId: 'checkpoint-worker', storage: qaStorage, now: () => qaClock,
|
||||||
|
getSnapshot: () => ({ document, url: location.href, accountId: 'fixture-account' }) });
|
||||||
|
await qaEngine.tick();
|
||||||
|
return { state: qaEngine.getState(), clicks: qaWorkerCoverClicks, worker: await qaStorage.get('worker:' + nonce) };
|
||||||
|
});
|
||||||
|
assert.equal(workerRestore.worker.owner, 'checkpoint-worker', '本例确实恢复受控工作页');
|
||||||
|
assert.equal(workerRestore.state.status, 'running');
|
||||||
|
assert.equal(workerRestore.clicks, 1, 'checkpoint里的旧coverClickedAt不能阻止新工作页点击真实span');
|
||||||
|
assert.notEqual(workerRestore.state.tasks[0].status, 'waiting', '工作页checkpoint读取后仍有新加载窗口');
|
||||||
|
assert.equal(workerRestore.state.tasks[0].retryCount, 2, '页面恢复不清空自动恢复次数');
|
||||||
|
await page.evaluate(async () => { qaClock += 14999; await qaEngine.tick(); });
|
||||||
|
assert.notEqual(await page.evaluate(() => qaEngine.getState().tasks[0].status), 'waiting');
|
||||||
|
await page.evaluate(async () => { qaClock += 1002; await qaEngine.tick(); });
|
||||||
|
assert.equal(await page.evaluate(() => qaEngine.getState().tasks[0].status), 'waiting');
|
||||||
|
assert.equal(await page.evaluate(() => qaWorkerCoverClicks), 1, '工作页新加载窗口内只点一次封面');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
// Full coordinator chain: video requirements include passing its related
|
||||||
|
// exam, so an ended video is handed off and only the later green website
|
||||||
|
// icon completes the course. Navigation remains asynchronous throughout.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear();
|
||||||
|
history.replaceState({}, '', '/clientcn/learningmap/exam-handoff-fixture');
|
||||||
|
window.qaFull = { courseClicks: [0, 0], relatedClicks: 0, enterClicks: 0, coverClicks: 0, firstPlayCalls: 0, examStarts: 0, returns: 0, pending: '', navigations: [], examState: { stage: 'answering', total: 1, verifiedCount: 0 } };
|
||||||
|
window.qaFullURLs = { map: location.href, first: 'http://10.119.67.7/clientcn/course/handoff-first', second: 'http://10.119.67.7/clientcn/course/handoff-second', detail: 'http://10.119.67.7/clientcn/exam/handoff-exam', paper: 'http://10.119.67.7/clientcn/exam/handoff-exam/questions?flag=test' };
|
||||||
|
window.qaFullMap = () => {
|
||||||
|
document.body.innerHTML = '<app-learning-map-activity-list>' + ['联动课程', '后续课程'].map((title, index) => '<div class="activity_content" data-course-id="handoff-course-' + index + '"><div class="map_activity_content_info_top"><div class="activity_name">' + title + '</div><div class="acitvity_other_info">必修课程</div></div></div>').join('') + '</app-learning-map-activity-list>';
|
||||||
|
document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => { node.onclick = () => {
|
||||||
|
qaFull.courseClicks[index]++; history.pushState({}, '', index ? qaFullURLs.second : qaFullURLs.first); qaFullCourse(index, false);
|
||||||
|
}; });
|
||||||
|
};
|
||||||
|
window.qaFullCourse = (index, returning) => {
|
||||||
|
const title = index ? '后续课程' : '联动课程';
|
||||||
|
document.body.innerHTML = '<app-course><div data-course-title>' + title + '</div><div class="qa-full-player">' + (returning ? '<div class="course-courseware-playdefult"><span>开始学习</span></div>' : '<app-course-courseware-video><video></video></app-course-courseware-video>') + '</div><li class="cn_courseware_img active" data-courseware-id="handoff-video-' + index + '" data-kind="video"><p>' + (index ? '后续视频' : '联动视频') + '</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习' + (index ? '20' : '100') + '秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div>' + (index ? '' : '<p class="related_test">联动课程</p>') + '</li></app-course><div class="completion-tooltip" hidden></div>';
|
||||||
|
const icon = document.querySelector('.en_courseware_noFinished'), tip = document.querySelector('.completion-tooltip');
|
||||||
|
icon.onmouseover = () => { tip.textContent = index ? '1.观看视频进度100%' : '1.观看视频进度100% 2.完成考试答题,并且及格通过'; tip.hidden = false; };
|
||||||
|
icon.onmouseout = () => { tip.hidden = true; };
|
||||||
|
const cover = document.querySelector('.course-courseware-playdefult > span'); if (cover) cover.onclick = () => { qaFull.coverClicks++; };
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
const media = { currentTime: index ? 20 : 100, duration: 100, ended: !index, paused: true, playbackRate: 1 };
|
||||||
|
for (const key of Object.keys(media)) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } });
|
||||||
|
video.play = async () => { if (!index) qaFull.firstPlayCalls++; media.paused = false; }; video.pause = () => { media.paused = true; };
|
||||||
|
}
|
||||||
|
const related = document.querySelector('.related_test'); if (related) related.onclick = event => {
|
||||||
|
event.stopPropagation(); qaFull.relatedClicks++; history.pushState({}, '', qaFullURLs.detail);
|
||||||
|
document.body.innerHTML = '<app-examdetail><div class="examdetail_header">联动课程 已答0/1</div><p>总考试次数:3 次</p><div class="examdetail_button">开始答题</div></app-examdetail>';
|
||||||
|
document.querySelector('.examdetail_button').onclick = () => { qaFull.enterClicks++; history.pushState({}, '', qaFullURLs.paper); document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">联动课程</div></app-examtest>'; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
window.qaFullFlush = () => {
|
||||||
|
if (!qaFull.pending) return false;
|
||||||
|
const url = qaFull.pending; qaFull.pending = ''; history.pushState({}, '', url);
|
||||||
|
if (url === qaFullURLs.map) qaFullMap();
|
||||||
|
else if (url === qaFullURLs.first) { qaFull.returns++; qaFullCourse(0, true); }
|
||||||
|
else throw new Error('Unexpected fixture destination: ' + url);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
qaFullMap();
|
||||||
|
qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||||
|
getSnapshot: () => ({ document, url: location.href, planId: 'exam-handoff-fixture', accountId: 'fixture-account', courses: ['联动课程'] }),
|
||||||
|
getExamState: () => qaFull.examState, onExamStart: async () => { qaFull.examStarts++; return true; },
|
||||||
|
navigate: async url => { qaFull.navigations.push(url); qaFull.pending = url; } });
|
||||||
|
await qaEngine.configure({ mode: 'all', execution: 'sequential' }); await qaEngine.start();
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaEngine.getState().tasks.some(task => task.title === '联动视频' && task.status === 'waitingExam'))) break;
|
||||||
|
}
|
||||||
|
const handoff = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull }));
|
||||||
|
assert.equal(handoff.state.tasks.find(task => task.title === '联动视频').status, 'waitingExam', '结束视频按网页提示转考试,不能提前计完成');
|
||||||
|
assert.equal(handoff.state.video?.platformComplete, false);
|
||||||
|
assert.equal(handoff.flow.relatedClicks, 0, '交接阶段保留视频与考试独立状态');
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaFull.examStarts === 1)) break;
|
||||||
|
}
|
||||||
|
const paper = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull, url: location.href }));
|
||||||
|
assert.equal(paper.flow.relatedClicks, 1, '仅点击一次真实关联考试入口');
|
||||||
|
assert.equal(paper.flow.enterClicks, 1, '仅通过正常详情开考按钮进入试卷');
|
||||||
|
assert.equal(paper.flow.examStarts, 1);
|
||||||
|
assert.equal(paper.url, origin + 'exam/handoff-exam/questions?flag=test');
|
||||||
|
assert.equal(paper.state.tasks.filter(task => task.kind === 'exam').length, 1, '目录、详情和试卷为同一场考试');
|
||||||
|
await page.evaluate(() => { qaFull.examState = { stage: 'completed', total: 1, verifiedCount: 1, submitted: true, confirmed: true }; });
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaFull.pending === qaFullURLs.first)) break;
|
||||||
|
}
|
||||||
|
assert.equal(await page.evaluate(() => qaFull.pending), origin + 'course/handoff-first', '考试完成后返回实际原课程核对网站完成标记');
|
||||||
|
for (let i = 0; i < 2; i++) await page.evaluate(() => qaEngine.tick());
|
||||||
|
const stillPaper = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, flow: qaFull }));
|
||||||
|
assert.equal(stillPaper.url, origin + 'exam/handoff-exam/questions?flag=test');
|
||||||
|
assert.notEqual(stillPaper.state.tasks.find(task => task.title === '联动视频').status, 'waitingPlatform', '异步返回尚未生效时不能在考试页假装等待课程完成');
|
||||||
|
assert.equal(stillPaper.flow.coverClicks, 0);
|
||||||
|
assert.equal(await page.evaluate(() => qaFullFlush()), true);
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
const returned = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull }));
|
||||||
|
assert.equal(returned.state.tasks.find(task => task.title === '联动视频').status, 'waitingPlatform');
|
||||||
|
assert.equal(returned.flow.returns, 1);
|
||||||
|
assert.equal(returned.flow.coverClicks, 0, '原课程封面出现时不重播已经到末尾的视频');
|
||||||
|
await page.evaluate(() => { const icon = document.querySelector('.en_courseware_noFinished'); icon.classList.remove('en_courseware_noFinished'); icon.textContent = '已完成'; });
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaFull.pending === qaFullURLs.map)) break;
|
||||||
|
}
|
||||||
|
assert.equal(await page.evaluate(() => qaFull.pending), origin + 'learningmap/exam-handoff-fixture');
|
||||||
|
assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.title === '联动视频').status), 'completed', '仅真实绿色完成图标才完成原视频任务');
|
||||||
|
assert.equal(await page.evaluate(() => qaFullFlush()), true);
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.evaluate(() => qaEngine.tick());
|
||||||
|
if (await page.evaluate(() => qaEngine.getState().video?.title === '后续视频')) break;
|
||||||
|
}
|
||||||
|
const full = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull, url: location.href }));
|
||||||
|
assert.deepEqual(full.flow.courseClicks, [1, 1]); assert.equal(full.flow.coverClicks, 0);
|
||||||
|
assert.equal(full.flow.relatedClicks, 1); assert.equal(full.flow.enterClicks, 1); assert.equal(full.flow.examStarts, 1);
|
||||||
|
assert.equal(full.state.tasks.filter(task => task.kind === 'exam').length, 1);
|
||||||
|
assert.equal(full.state.tasks.find(task => task.kind === 'exam').status, 'completed');
|
||||||
|
assert.equal(full.state.tasks.find(task => task.title === '联动视频').status, 'completed');
|
||||||
|
assert.equal(full.url, origin + 'course/handoff-second');
|
||||||
|
assert.equal(full.state.video?.title, '后续视频'); assert.equal(full.state.video?.status, 'playing');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
|
||||||
|
console.log('course-map-navigation: ' + checks + ' checks passed (map return, resource identity, fresh retry and worker checkpoint windows, video/exam/green-icon/next-course chain; no business requests)');
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(() => window.qaEngine?.destroy()).catch(() => {});
|
||||||
|
await context.close(); await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -165,7 +165,7 @@ async function main() {
|
|||||||
console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state');
|
console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state');
|
||||||
|
|
||||||
await tab('video').click();
|
await tab('video').click();
|
||||||
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('平台确认视频完成后') && text.includes('已开放的关联考试') && text.includes('人工保护仍生效')));
|
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('目录完成条件') && text.includes('已开放的关联考试') && text.includes('平台完成标记') && text.includes('人工保护仍生效')));
|
||||||
const callsBeforeModeHints = await page.evaluate(() => JSON.stringify(qaCalls));
|
const callsBeforeModeHints = await page.evaluate(() => JSON.stringify(qaCalls));
|
||||||
for (const mode of ['video', 'videoQuiz']) {
|
for (const mode of ['video', 'videoQuiz']) {
|
||||||
await page.evaluate(mode => qaPanel.setState({ learning: { mode } }), mode);
|
await page.evaluate(mode => qaPanel.setState({ learning: { mode } }), mode);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
const { spawnSync } = require('node:child_process');
|
const { spawnSync } = require('node:child_process');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'diagnostic-logger.cjs', 'learning-engine.cjs', 'lease-recovery.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs', 'video-exam-workflow.cjs', 'diagnostics-workflow.cjs']) {
|
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'diagnostic-logger.cjs', 'learning-engine.cjs', 'lease-recovery.cjs', 'course-map-navigation.cjs', 'course-entry-workflow.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs', 'video-exam-workflow.cjs', 'diagnostics-workflow.cjs']) {
|
||||||
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
||||||
if (result.status !== 0) process.exit(result.status || 1);
|
if (result.status !== 0) process.exit(result.status || 1);
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-27
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 谊安学助手 2.0
|
// @name 谊安学助手 2.0
|
||||||
// @namespace yianxue-question-bank
|
// @namespace yianxue-question-bank
|
||||||
// @version 2.1.3
|
// @version 2.1.4
|
||||||
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
||||||
// @match http://10.119.67.7/clientcn/*
|
// @match http://10.119.67.7/clientcn/*
|
||||||
// @run-at document-idle
|
// @run-at document-idle
|
||||||
@@ -1114,7 +1114,7 @@ window.JSZip = module.exports;
|
|||||||
const KEY = 'diagnosticLog.v1';
|
const KEY = 'diagnosticLog.v1';
|
||||||
const ENABLED_KEY = 'diagnosticLog.enabled';
|
const ENABLED_KEY = 'diagnosticLog.enabled';
|
||||||
const LIMIT = 2000;
|
const LIMIT = 2000;
|
||||||
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
|
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requiredSeconds requiredProgress requiresExam requiresPass requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
|
||||||
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
|
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
|
||||||
function safeURL(value) {
|
function safeURL(value) {
|
||||||
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
|
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
|
||||||
@@ -1966,7 +1966,7 @@ window.JSZip = module.exports;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
|
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', waitingExam: '待关联考试与平台核对', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
|
||||||
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
|
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
|
||||||
function firstValue(object, keys, fallback) {
|
function firstValue(object, keys, fallback) {
|
||||||
object = object || {};
|
object = object || {};
|
||||||
@@ -2036,13 +2036,13 @@ window.JSZip = module.exports;
|
|||||||
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
|
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
|
||||||
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
: '当前为“视频+考试”:平台确认视频完成后,进入已开放的关联考试。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
|
: '当前为“视频+考试”:按目录完成条件处理视频与已开放的关联考试,最后核对平台完成标记。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
|
||||||
videoModeNote.hidden = false;
|
videoModeNote.hidden = false;
|
||||||
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
|
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
|
||||||
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
|
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
|
||||||
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
|
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
|
||||||
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
|
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
|
||||||
infoRows(videoInfo, [['当前课程', videoTitle], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
|
infoRows(videoInfo, [['当前课程', videoTitle], ['完成要求', video.requirements || '等待网页提示;以平台完成标记为准'], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
|
||||||
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
|
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
|
||||||
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
|
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
|
||||||
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
|
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
|
||||||
@@ -2966,6 +2966,20 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
|
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
|
||||||
}
|
}
|
||||||
|
function courseCoverOf(doc) {
|
||||||
|
const buttons = Array.from(doc.querySelectorAll('app-course .course-courseware-playdefult > span'))
|
||||||
|
.filter(node => visible(node) && clean(node.textContent) === '开始学习' && !node.matches('[disabled],[aria-disabled="true"]'));
|
||||||
|
return buttons.length === 1 ? buttons[0] : null;
|
||||||
|
}
|
||||||
|
function completionRequirements(text) {
|
||||||
|
text = clean(text);
|
||||||
|
const time = text.match(/观看课件时长达到\s*([\d.\s小时分钟秒]+)/);
|
||||||
|
const progress = text.match(/观看视频进度\s*(\d+(?:\.\d+)?)\s*%/);
|
||||||
|
if (!time && !progress) return null;
|
||||||
|
return { text: text.slice(0, 500), requiredSeconds: time ? seconds(time[1]) : null,
|
||||||
|
requiredProgress: progress && Number(progress[1]) <= 100 ? Number(progress[1]) : null,
|
||||||
|
requiresExam: /完成考试答题/.test(text), requiresPass: /及格通过/.test(text) };
|
||||||
|
}
|
||||||
function describePage(doc, url) {
|
function describePage(doc, url) {
|
||||||
const current = canonical(url || doc.URL);
|
const current = canonical(url || doc.URL);
|
||||||
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
|
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
|
||||||
@@ -2976,17 +2990,17 @@ window.JSZip = module.exports;
|
|||||||
coursewareTitle: active ? clean((active.querySelector('p') || active).textContent) : '',
|
coursewareTitle: active ? clean((active.querySelector('p') || active).textContent) : '',
|
||||||
video, activeCourseware: active, quiz: quizOf(doc), platformComplete: isComplete(active) || Boolean(doc.querySelector('app-course-courseware-video[data-is-learned="true"]')),
|
video, activeCourseware: active, quiz: quizOf(doc), platformComplete: isComplete(active) || Boolean(doc.querySelector('app-course-courseware-video[data-is-learned="true"]')),
|
||||||
learnedSeconds: active ? seconds(clean((active.querySelector('.menu_courseware_left') || active).textContent)) : null,
|
learnedSeconds: active ? seconds(clean((active.querySelector('.menu_courseware_left') || active).textContent)) : null,
|
||||||
requiredSeconds: number(active && active.getAttribute('data-required-seconds')),
|
requiredSeconds: active && active.hasAttribute('data-required-seconds') ? number(active.getAttribute('data-required-seconds')) : null,
|
||||||
hidden: doc.visibilityState === 'hidden' };
|
hidden: doc.visibilityState === 'hidden' };
|
||||||
}
|
}
|
||||||
function discoverTasks(doc, url) {
|
function discoverTasks(doc, url) {
|
||||||
const page = describePage(doc, url); const tasks = []; const seen = new Set();
|
const page = describePage(doc, url); const tasks = []; const seen = new Set();
|
||||||
function add(node, kind, selector, index, parentId) {
|
function add(node, kind, selector, index, parentId, subIndex) {
|
||||||
const explicitId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
|
const explicitId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
|
||||||
const targetURL = nodeURL(node, page.url);
|
const targetURL = nodeURL(node, page.url);
|
||||||
let title = clean((node.querySelector('.activity_name,p,[data-task-title]') || node).textContent);
|
let title = clean((node.querySelector('.activity_name,p,[data-task-title]') || node).textContent);
|
||||||
if (node.matches('.cn_courseware_img')) title = clean((node.querySelector(':scope > p') || node.querySelector('p') || node).textContent);
|
if (node.matches('.cn_courseware_img')) title = clean((node.querySelector(':scope > p') || node.querySelector('p') || node).textContent);
|
||||||
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index;
|
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index + (subIndex > 0 ? ':exam:' + subIndex : '');
|
||||||
const id = kind + ':' + hash(identity); if (seen.has(id)) return;
|
const id = kind + ':' + hash(identity); if (seen.has(id)) return;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
const locked = node.matches('[disabled],[aria-disabled="true"],[data-locked="true"],.locked') || Boolean(node.querySelector('[data-locked="true"]'));
|
const locked = node.matches('[disabled],[aria-disabled="true"],[data-locked="true"],.locked') || Boolean(node.querySelector('[data-locked="true"]'));
|
||||||
@@ -3006,7 +3020,7 @@ window.JSZip = module.exports;
|
|||||||
const task = add(node, /视频/.test(icon) || node.getAttribute('data-kind') === 'video' ? 'video' : 'unsupported', 'app-course .cn_courseware_img,[data-courseware-id]', i);
|
const task = add(node, /视频/.test(icon) || node.getAttribute('data-kind') === 'video' ? 'video' : 'unsupported', 'app-course .cn_courseware_img,[data-courseware-id]', i);
|
||||||
if (task && task.kind === 'unsupported' && task.status !== 'completed') { task.status = 'skipped'; task.reason = '暂未适配此非视频课件'; }
|
if (task && task.kind === 'unsupported' && task.status !== 'completed') { task.status = 'skipped'; task.reason = '暂未适配此非视频课件'; }
|
||||||
node.querySelectorAll('.related_test,[data-related-exam]').forEach((exam, j) => {
|
node.querySelectorAll('.related_test,[data-related-exam]').forEach((exam, j) => {
|
||||||
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id);
|
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id, j);
|
||||||
if (examTask) { examTask.selector = 'app-course .cn_courseware_img,[data-courseware-id]'; examTask.subSelector = '.related_test,[data-related-exam]'; examTask.subIndex = j; examTask.id += ':exam:' + j; examTask.requiresParent = exam.getAttribute('data-requires-complete') === 'true'; }
|
if (examTask) { examTask.selector = 'app-course .cn_courseware_img,[data-courseware-id]'; examTask.subSelector = '.related_test,[data-related-exam]'; examTask.subIndex = j; examTask.id += ':exam:' + j; examTask.requiresParent = exam.getAttribute('data-requires-complete') === 'true'; }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3057,6 +3071,7 @@ window.JSZip = module.exports;
|
|||||||
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
|
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
|
||||||
let visibilityPlayback = null; let returnResumeRequested = false; let pageAway = false; let playbackPromptClick = null;
|
let visibilityPlayback = null; let returnResumeRequested = false; let pageAway = false; let playbackPromptClick = null;
|
||||||
let sharedPaused = false;
|
let sharedPaused = false;
|
||||||
|
let requirementProbe = null;
|
||||||
let logSequence = 0; let leaseBanner = null;
|
let logSequence = 0; let leaseBanner = null;
|
||||||
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
||||||
let policySessionId = '', policyReadSerial = 0;
|
let policySessionId = '', policyReadSerial = 0;
|
||||||
@@ -3071,6 +3086,10 @@ window.JSZip = module.exports;
|
|||||||
function onInteraction(event) {
|
function onInteraction(event) {
|
||||||
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
|
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
|
||||||
}
|
}
|
||||||
|
function onRequirementHover(event) {
|
||||||
|
// 用户移动鼠标后,共用提示框可能已显示另一行的条件,不能归到旧请求。
|
||||||
|
if (event.isTrusted && requirementProbe && !requirementProbe.icon.contains(event.target)) requirementProbe = null;
|
||||||
|
}
|
||||||
function onVideoPause(event) {
|
function onVideoPause(event) {
|
||||||
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
|
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
|
||||||
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
|
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
@@ -3130,6 +3149,7 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
function taskState(task, status, reason) {
|
function taskState(task, status, reason) {
|
||||||
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
|
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
|
||||||
|
if (task.kind === 'video' && state.video?.taskId === task.id) state.video.message = task.reason;
|
||||||
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state');
|
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state');
|
||||||
}
|
}
|
||||||
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
|
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
|
||||||
@@ -3259,7 +3279,7 @@ window.JSZip = module.exports;
|
|||||||
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
|
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
|
||||||
if (task.status === 'completed') {
|
if (task.status === 'completed') {
|
||||||
old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成';
|
old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成';
|
||||||
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed' });
|
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed', message: old.reason });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3275,6 +3295,74 @@ window.JSZip = module.exports;
|
|||||||
if (!task.identity && title !== task.title) return null;
|
if (!task.identity && title !== task.title) return null;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
function readRequirements(task, node, doc) {
|
||||||
|
if (!node || task.requirements) return task.requirements || null;
|
||||||
|
const icon = node.querySelector('.courseware_finished_status .en_courseware_noFinished');
|
||||||
|
if (!icon || !visible(icon)) return null;
|
||||||
|
if (requirementProbe && (requirementProbe.node !== node || requirementProbe.doc !== doc)) {
|
||||||
|
requirementProbe.icon.dispatchEvent(new root.MouseEvent('mouseleave', { bubbles: false }));
|
||||||
|
requirementProbe = null;
|
||||||
|
}
|
||||||
|
if (requirementProbe) {
|
||||||
|
const tips = Array.from(doc.querySelectorAll('.completion-tooltip')).filter(t => visible(t) && clean(t.textContent));
|
||||||
|
const requirements = tips.length === 1 ? completionRequirements(tips[0].textContent) : null;
|
||||||
|
if (requirements || now() - requirementProbe.at > 1500) {
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseleave', { bubbles: false }));
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseout', { bubbles: true }));
|
||||||
|
requirementProbe = null; task.requirementsRead = true;
|
||||||
|
if (requirements) {
|
||||||
|
task.requirements = requirements;
|
||||||
|
log('info', '课件完成条件:' + requirements.text, task, requirements, 'course.requirements');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!task.requirementsRead) {
|
||||||
|
// 橙色图标只是未完成;实际观看/考试条件由网页 hover 提示给出。
|
||||||
|
const existing = Array.from(doc.querySelectorAll('.completion-tooltip')).some(t => visible(t) && clean(t.textContent));
|
||||||
|
if (!existing) {
|
||||||
|
requirementProbe = { node, icon, doc, at: now() };
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseenter', { bubbles: false }));
|
||||||
|
icon.dispatchEvent(new root.MouseEvent('mouseover', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task.requirements || null;
|
||||||
|
}
|
||||||
|
function linkedExams(task) { return state.tasks.filter(t => t.kind === 'exam' && t.parentId === task.id); }
|
||||||
|
async function canAct(task, snap, epoch) {
|
||||||
|
const locallyPaused = () => task.kind === 'exam' ? config.examPaused : config.videoPaused;
|
||||||
|
if (epoch !== generation || state.status !== 'running' || locallyPaused()) return false;
|
||||||
|
if (!await heartbeat(task)) return false;
|
||||||
|
const latest = snapshot();
|
||||||
|
const livePage = describePage(latest.document, latest.url);
|
||||||
|
const samePage = latest.document === snap.document && canonical(latest.url) === canonical(snap.url)
|
||||||
|
&& (!snap.guardTitle || livePage.title === snap.guardTitle)
|
||||||
|
&& livePage.activeCourseware === snap.guardActive && livePage.video === snap.guardVideo
|
||||||
|
&& latest.document.querySelector('app-course,app-learning-map-activity-list') === snap.guardRoot
|
||||||
|
&& (!snap.guardTaskNode || resolveNode(task, latest) === snap.guardTaskNode);
|
||||||
|
if (!samePage && epoch === generation && state.status === 'running' && !locallyPaused()) taskState(task, 'waiting', '点击前课程或目录已变化,请核对当前任务后继续');
|
||||||
|
return epoch === generation && state.status === 'running' && !locallyPaused() && samePage;
|
||||||
|
}
|
||||||
|
function guardedSnapshot(snap, page) {
|
||||||
|
return Object.assign({}, snap, { guardTitle: page.title, guardActive: page.activeCourseware,
|
||||||
|
guardVideo: page.video, guardRoot: snap.document.querySelector('app-course,app-learning-map-activity-list') });
|
||||||
|
}
|
||||||
|
async function courseTick(task, snap, page, epoch) {
|
||||||
|
snap = guardedSnapshot(snap, page);
|
||||||
|
if (config.videoPaused || task.status === 'waiting') return;
|
||||||
|
if (page.kind === 'map' && canonical(task.pageUrl) === page.url && task.courseEntrySourceURL !== page.url) {
|
||||||
|
const node = resolveNode(task, snap);
|
||||||
|
snap.guardTaskNode = node;
|
||||||
|
const button = node && (node.querySelector('.map_activity_content_info_top') || node);
|
||||||
|
if (!button) { taskState(task, 'waiting', '返回学习地图后找不到原课程入口,请核对目录后继续'); return; }
|
||||||
|
if (isComplete(node)) { task.progress = 1; taskState(task, 'completed', '平台已确认该课程完成,跳过此入口'); activeTaskId = ''; await release(); return; }
|
||||||
|
await persist();
|
||||||
|
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, sourceURL: page.url, targetURL: task.url || '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
|
||||||
|
if (!await canAct(task, snap, epoch) || !snap.document.documentElement.contains(button) || !visible(button)) return;
|
||||||
|
task.courseEntrySourceURL = page.url; task.courseEntryClickedAt = now(); task.navigationRequestedAt = now();
|
||||||
|
log('info', '已返回学习地图,通过原课程入口进入下一任务', task, { pageUrl: page.url }, 'course.next');
|
||||||
|
button.click(); return;
|
||||||
|
}
|
||||||
|
if (now() - task.navigationRequestedAt > 15000) taskState(task, 'waiting', '课程入口尚未进入目标课程,请检查网站前置条件后继续');
|
||||||
|
}
|
||||||
function initialize() {
|
function initialize() {
|
||||||
if (initialized) return Promise.resolve();
|
if (initialized) return Promise.resolve();
|
||||||
if (initializing) return initializing;
|
if (initializing) return initializing;
|
||||||
@@ -3285,6 +3373,7 @@ window.JSZip = module.exports;
|
|||||||
const snap = snapshot(); if (!snap.document) return;
|
const snap = snapshot(); if (!snap.document) return;
|
||||||
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
|
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
|
||||||
watchedDocument.addEventListener('visibilitychange', onVisibilityChange, true);
|
watchedDocument.addEventListener('visibilitychange', onVisibilityChange, true);
|
||||||
|
watchedDocument.addEventListener('mouseover', onRequirementHover, true);
|
||||||
if (watchedDocument.defaultView) { watchedDocument.defaultView.addEventListener('blur', onPageHidden); watchedDocument.defaultView.addEventListener('focus', onPageReturn); }
|
if (watchedDocument.defaultView) { watchedDocument.defaultView.addEventListener('blur', onPageHidden); watchedDocument.defaultView.addEventListener('focus', onPageReturn); }
|
||||||
const page = describePage(snap.document, snap.url);
|
const page = describePage(snap.document, snap.url);
|
||||||
const saved = await storage.get(SESSION_KEY);
|
const saved = await storage.get(SESSION_KEY);
|
||||||
@@ -3312,6 +3401,10 @@ window.JSZip = module.exports;
|
|||||||
const controlled = sharedControl && sharedControl.status === 'running' && (recover && recover.sessionId === session.id && recover.taskId === activeTaskId && recover.expires > now() && recover.accountId === account(snap, page) || targetOK);
|
const controlled = sharedControl && sharedControl.status === 'running' && (recover && recover.sessionId === session.id && recover.taskId === activeTaskId && recover.expires > now() && recover.accountId === account(snap, page) || targetOK);
|
||||||
if (targetOK) {
|
if (targetOK) {
|
||||||
activeTaskId = transition.taskId;
|
activeTaskId = transition.taskId;
|
||||||
|
const arriving = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
if (arriving?.kind === 'course' && canonical(arriving.pageUrl) === transition.sourceURL) {
|
||||||
|
arriving.courseEntrySourceURL = transition.sourceURL; arriving.courseEntryClickedAt = now();
|
||||||
|
}
|
||||||
if (transition.workerNonce && sharedControl && sharedControl.status === 'running') {
|
if (transition.workerNonce && sharedControl && sharedControl.status === 'running') {
|
||||||
const request = await storage.get('worker:' + transition.workerNonce);
|
const request = await storage.get('worker:' + transition.workerNonce);
|
||||||
if (request && request.sessionId === session.id && request.taskId === activeTaskId) { worker = request; worker.owner = owner; worker.confirmedAt = now(); await storage.set('worker:' + worker.nonce, worker); }
|
if (request && request.sessionId === session.id && request.taskId === activeTaskId) { worker = request; worker.owner = owner; worker.confirmedAt = now(); await storage.set('worker:' + worker.nonce, worker); }
|
||||||
@@ -3324,6 +3417,11 @@ window.JSZip = module.exports;
|
|||||||
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
|
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
|
||||||
}
|
}
|
||||||
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
|
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
|
||||||
|
// 完整导航/受控刷新后的播放器需要新的加载窗口,不能沿用旧页的超时。
|
||||||
|
if (state.status === 'running') {
|
||||||
|
const restored = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
if (restored) { restored.openedAt = now(); delete restored.coverClickedAt; }
|
||||||
|
}
|
||||||
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
|
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
|
||||||
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
|
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
|
||||||
}
|
}
|
||||||
@@ -3399,6 +3497,7 @@ window.JSZip = module.exports;
|
|||||||
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
|
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
|
||||||
}
|
}
|
||||||
const wasRunning = state.status === 'running';
|
const wasRunning = state.status === 'running';
|
||||||
|
const previousVideoPaused = config.videoPaused, previousExamPaused = config.examPaused;
|
||||||
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
|
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
|
||||||
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
|
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
|
||||||
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
|
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
|
||||||
@@ -3411,6 +3510,15 @@ window.JSZip = module.exports;
|
|||||||
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
|
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
|
||||||
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
|
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
|
||||||
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
|
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
|
||||||
|
if (!previousVideoPaused && config.videoPaused || !previousExamPaused && config.examPaused) generation++;
|
||||||
|
const localResumed = previousVideoPaused && patch.videoPaused === false || previousExamPaused && patch.examPaused === false;
|
||||||
|
if (localResumed && state.status === 'running') {
|
||||||
|
const active = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
if (active && (active.kind === 'exam' ? previousExamPaused && !config.examPaused : previousVideoPaused && !config.videoPaused)) {
|
||||||
|
active.status = 'pending'; active.reason = ''; activeTaskId = '';
|
||||||
|
if (active.kind === 'exam' && active.examRequested) active.examNeedsRecheck = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
|
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
|
||||||
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
|
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
|
||||||
if (manualPatch) {
|
if (manualPatch) {
|
||||||
@@ -3455,7 +3563,7 @@ window.JSZip = module.exports;
|
|||||||
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) {
|
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) {
|
||||||
state.message = '账号或题库版本已变化,请开始新一轮任务'; state.status = 'paused'; notify(); return getState();
|
state.message = '账号或题库版本已变化,请开始新一轮任务'; state.status = 'paused'; notify(); return getState();
|
||||||
}
|
}
|
||||||
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || t.status === 'waitingPlatform') && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
|
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || ['waitingPlatform', 'waitingExam'].includes(t.status)) && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
|
||||||
config.videoPaused = false; config.examPaused = false;
|
config.videoPaused = false; config.examPaused = false;
|
||||||
generation++; visibilityPlayback = null; returnResumeRequested = false; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
generation++; visibilityPlayback = null; returnResumeRequested = false; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
||||||
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
|
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
|
||||||
@@ -3464,6 +3572,9 @@ window.JSZip = module.exports;
|
|||||||
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
|
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
|
||||||
}
|
}
|
||||||
async function activate(task, snap, page) {
|
async function activate(task, snap, page) {
|
||||||
|
const epoch = generation;
|
||||||
|
snap = guardedSnapshot(snap, page);
|
||||||
|
if (task.kind === 'exam' ? config.examPaused : config.videoPaused) return false;
|
||||||
if (task.locked) { taskState(task, 'waiting', '网站尚未开放此任务'); return false; }
|
if (task.locked) { taskState(task, 'waiting', '网站尚未开放此任务'); return false; }
|
||||||
if (task.requiresParent && task.parentId) { const parent = state.tasks.find(t => t.id === task.parentId); if (parent && parent.status !== 'completed') return false; }
|
if (task.requiresParent && task.parentId) { const parent = state.tasks.find(t => t.id === task.parentId); if (parent && parent.status !== 'completed') return false; }
|
||||||
if (task.kind === 'exam') {
|
if (task.kind === 'exam') {
|
||||||
@@ -3471,37 +3582,92 @@ window.JSZip = module.exports;
|
|||||||
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
|
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
|
||||||
}
|
}
|
||||||
if (!(await claim(task))) return false;
|
if (!(await claim(task))) return false;
|
||||||
|
if (epoch !== generation || state.status !== 'running') return false;
|
||||||
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
|
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
|
task.openedAt = now(); delete task.coverClickedAt; delete task.courseEntrySourceURL; delete task.courseEntryClickedAt;
|
||||||
const onPage = canonical(task.url || task.pageUrl) === page.url;
|
const onPage = canonical(task.url || task.pageUrl) === page.url;
|
||||||
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
|
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
|
||||||
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
|
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
|
||||||
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
|
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
|
||||||
const node = resolveNode(task, snap);
|
const node = resolveNode(task, snap);
|
||||||
|
snap.guardTaskNode = node;
|
||||||
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
||||||
task.navigationRequestedAt = now();
|
task.navigationRequestedAt = now();
|
||||||
if (node && task.kind === 'exam') task.examLinkClickedAt = now();
|
|
||||||
await persist();
|
await persist();
|
||||||
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
|
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
|
||||||
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
|
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
|
||||||
if (node) {
|
if (node) {
|
||||||
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
|
let button = node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node;
|
||||||
|
// 默认封面会打开首个课件;后续课件必须点击自己的目录行,防止重播已完成首项。
|
||||||
|
const first = snap.document.querySelector('app-course .cn_courseware_img,[data-courseware-id]');
|
||||||
|
const cover = task.kind === 'video' && !page.video && !page.activeCourseware && node === first ? courseCoverOf(snap.document) : null;
|
||||||
|
if (cover) button = cover;
|
||||||
|
if (!await canAct(task, snap, epoch)) return 'deferred';
|
||||||
|
if (!snap.document.documentElement.contains(button) || !visible(button)) { taskState(task, 'waiting', '原任务按钮已变化,请核对页面后继续'); return 'deferred'; }
|
||||||
|
if (cover) task.coverClickedAt = now();
|
||||||
|
if (task.kind === 'course') { task.courseEntrySourceURL = page.url; task.courseEntryClickedAt = now(); }
|
||||||
|
if (task.kind === 'exam') task.examLinkClickedAt = now();
|
||||||
|
if (cover) log('info', '点击封面“开始学习”,等待网页创建播放器', task, {}, 'video.coverStart');
|
||||||
|
button.click(); return true;
|
||||||
}
|
}
|
||||||
|
if (!await canAct(task, snap, epoch)) return 'deferred';
|
||||||
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
|
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
|
||||||
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
|
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
|
||||||
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
|
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
|
||||||
}
|
}
|
||||||
async function videoTick(task, snap, page, epoch) {
|
async function videoTick(task, snap, page, epoch) {
|
||||||
|
snap = guardedSnapshot(snap, page);
|
||||||
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); return; }
|
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); return; }
|
||||||
|
const node = resolveNode(task, snap) || (task.current ? page.activeCourseware : null);
|
||||||
|
if (!task.current) snap.guardTaskNode = node;
|
||||||
|
if (!task.current && canonical(task.pageUrl) !== page.url && !node) {
|
||||||
|
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '尚未返回课件所在课程,请核对网站跳转后继续');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page.activeCourseware && !task.current && (node ? node !== page.activeCourseware : task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent))) {
|
||||||
|
if (now() - task.openedAt <= 15000) return;
|
||||||
|
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
|
||||||
|
}
|
||||||
|
const requirements = readRequirements(task, node, snap.document);
|
||||||
|
const learnedSeconds = node ? seconds(clean((node.querySelector('.menu_courseware_left') || node).textContent)) : page.learnedSeconds;
|
||||||
|
const requiredSeconds = requirements?.requiredSeconds ?? page.requiredSeconds;
|
||||||
|
const exams = linkedExams(task);
|
||||||
|
const ended = page.video && (page.video.ended || Number.isFinite(page.video.duration) && page.video.duration > 0 && page.video.currentTime >= page.video.duration - 0.05);
|
||||||
|
const watchedTime = requirements?.requiredSeconds != null && learnedSeconds != null && learnedSeconds >= requirements.requiredSeconds;
|
||||||
|
const examsDone = exams.length && exams.every(t => t.status === 'completed');
|
||||||
|
const canHandoff = watchedTime || requirements?.requiredSeconds == null && ended;
|
||||||
|
if (!page.platformComplete && !page.quiz && !playbackPromptOf(snap.document) && !manualVideoPause
|
||||||
|
&& requirements?.requiresExam && exams.length && (canHandoff || task.examHandoff)) {
|
||||||
|
task.examHandoff = true;
|
||||||
|
state.video = { taskId: task.id, title: task.title, currentTime: page.video?.currentTime || task.currentTime || 0,
|
||||||
|
duration: page.video && Number.isFinite(page.video.duration) ? page.video.duration : task.duration,
|
||||||
|
learnedSeconds, requiredSeconds, requirements: requirements.text, platformComplete: false,
|
||||||
|
status: examsDone ? 'waitingPlatform' : 'waitingExam', retryCount: task.retryCount || 0 };
|
||||||
|
if (!allows('exam')) {
|
||||||
|
taskState(task, 'waiting', '课件还要求关联考试;当前仅处理视频/课中题,请切换“视频+考试”或人工完成关联考试'); return;
|
||||||
|
}
|
||||||
|
if (!examsDone) {
|
||||||
|
if (page.video) page.video.pause();
|
||||||
|
taskState(task, 'waitingExam', watchedTime ? '平台记录已达到观看时长要求,转入关联考试后再核对整体完成' : '视频已到末尾,转入关联考试;整体完成仍等待平台确认');
|
||||||
|
activeTaskId = ''; state.message = task.reason; await release(); return;
|
||||||
|
}
|
||||||
|
taskState(task, 'waitingPlatform', '关联考试已确认提交,等待课程目录的真实完成标记'); return;
|
||||||
|
}
|
||||||
if (!page.video) {
|
if (!page.video) {
|
||||||
if (task.selector) { const node = resolveNode(task, snap); if (node && task.status === 'loading' && !task.openedAt) { task.openedAt = now(); node.click(); return; } }
|
if (page.quiz) { taskState(task, 'waiting', '当前课中题尚未处理,等待网页恢复播放器后继续'); return; }
|
||||||
if (!task.openedAt) task.openedAt = now();
|
const first = snap.document.querySelector('app-course .cn_courseware_img,[data-courseware-id]');
|
||||||
|
const cover = courseCoverOf(snap.document);
|
||||||
|
if (cover && !task.coverClickedAt && (page.activeCourseware ? page.activeCourseware === node : node && node === first)) {
|
||||||
|
if (!await canAct(task, snap, epoch) || !snap.document.documentElement.contains(cover) || !visible(cover)) return;
|
||||||
|
task.coverClickedAt = now(); task.openedAt = now();
|
||||||
|
log('info', '点击封面“开始学习”,等待网页创建播放器', task, {}, 'video.coverStart');
|
||||||
|
cover.click(); return;
|
||||||
|
}
|
||||||
|
if (task.openedAt == null) task.openedAt = now();
|
||||||
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '播放器未加载或自动播放需要用户操作');
|
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '播放器未加载或自动播放需要用户操作');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const video = page.video;
|
const video = page.video;
|
||||||
if (page.activeCourseware && !task.current && task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent)) {
|
|
||||||
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
|
|
||||||
}
|
|
||||||
if (page.platformComplete) {
|
if (page.platformComplete) {
|
||||||
state.video = { taskId: task.id, title: task.title, currentTime: Number.isFinite(video.currentTime) ? video.currentTime : 0,
|
state.video = { taskId: task.id, title: task.title, currentTime: Number.isFinite(video.currentTime) ? video.currentTime : 0,
|
||||||
duration: Number.isFinite(video.duration) ? video.duration : null, actualRate: video.playbackRate,
|
duration: Number.isFinite(video.duration) ? video.duration : null, actualRate: video.playbackRate,
|
||||||
@@ -3511,8 +3677,8 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
const popup = page.quiz;
|
const popup = page.quiz;
|
||||||
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
|
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
|
||||||
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
|
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds, requiredSeconds, requirements: requirements?.text || '', platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
|
||||||
Object.assign(task, { currentTime, duration, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
|
Object.assign(task, { currentTime, duration, learnedSeconds, requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
|
||||||
const playbackPrompt = playbackPromptOf(snap.document);
|
const playbackPrompt = playbackPromptOf(snap.document);
|
||||||
if (playbackPrompt && !popup) {
|
if (playbackPrompt && !popup) {
|
||||||
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
|
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
|
||||||
@@ -3730,23 +3896,28 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
|
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
|
||||||
}
|
}
|
||||||
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; }
|
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; requirementProbe = null; }
|
||||||
let task = state.tasks.find(t => t.id === activeTaskId);
|
let task = state.tasks.find(t => t.id === activeTaskId);
|
||||||
if (task && task.kind === 'course' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
|
if (task && task.kind === 'course' && ['course', 'video'].includes(page.kind)) {
|
||||||
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
|
const arrived = task.url ? canonical(task.url) === page.url
|
||||||
else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
|
: task.courseEntryClickedAt != null && task.title === page.title && page.url !== canonical(task.pageUrl) && page.url !== task.courseEntrySourceURL;
|
||||||
|
if (arrived) { task.visitedCourseURL = page.url; mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
|
||||||
|
// 跳转是异步的,仍在原课程时等待,不把原目录挂到下一课程名下。
|
||||||
} else mergeTasks(discoverTasks(snap.document, snap.url));
|
} else mergeTasks(discoverTasks(snap.document, snap.url));
|
||||||
if (state.status !== 'running') { await persist(); notify(); return getState(); }
|
if (state.status !== 'running') { await persist(); notify(); return getState(); }
|
||||||
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
|
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
|
||||||
if (!task) {
|
if (!task) {
|
||||||
const candidates = state.tasks.filter(t => ['pending', 'waitingLease'].includes(t.status) && allows(t.kind) && (!worker || t.id === worker.taskId));
|
const candidates = state.tasks.filter(t => (['pending', 'waitingLease'].includes(t.status)
|
||||||
|
|| t.status === 'waitingExam' && linkedExams(t).length && linkedExams(t).every(e => e.status === 'completed'))
|
||||||
|
&& allows(t.kind) && (!worker || t.id === worker.taskId));
|
||||||
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
|
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
|
||||||
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
|
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
|
||||||
candidates.sort((a, b) => rank(a) - rank(b));
|
candidates.sort((a, b) => rank(a) - rank(b));
|
||||||
const blockedKinds = new Set();
|
const blockedKinds = new Set();
|
||||||
for (const next of candidates) {
|
for (const next of candidates) {
|
||||||
const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue;
|
const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue;
|
||||||
if (await activate(next, snap, page)) { task = next; break; }
|
const activated = await activate(next, snap, page);
|
||||||
|
if (activated) { task = next; if (activated === 'deferred') task = null; break; }
|
||||||
if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; }
|
if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3756,6 +3927,7 @@ window.JSZip = module.exports;
|
|||||||
acquiredLease(task);
|
acquiredLease(task);
|
||||||
if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
|
if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
|
||||||
else if (task.kind === 'exam') await examTick(task, snap, page);
|
else if (task.kind === 'exam') await examTick(task, snap, page);
|
||||||
|
else if (task.kind === 'course') await courseTick(task, snap, page, epoch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (epoch === generation) await parallelTick(snap, page);
|
if (epoch === generation) await parallelTick(snap, page);
|
||||||
@@ -3793,6 +3965,7 @@ window.JSZip = module.exports;
|
|||||||
if (watchedDocument) {
|
if (watchedDocument) {
|
||||||
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
|
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
|
||||||
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
|
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
|
||||||
|
watchedDocument.removeEventListener('mouseover', onRequirementHover, true);
|
||||||
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
|
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
|
||||||
}
|
}
|
||||||
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
|
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
|
||||||
@@ -4379,7 +4552,7 @@ window.JSZip = module.exports;
|
|||||||
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
|
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
|
||||||
let actionEpoch = 0;
|
let actionEpoch = 0;
|
||||||
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
|
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
|
||||||
const version = '2.1.3';
|
const version = '2.1.4';
|
||||||
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||||
const pendingLearningSettings = new Map();
|
const pendingLearningSettings = new Map();
|
||||||
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
|
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
|
||||||
|
|||||||
Reference in New Issue
Block a user