fix: resume video playback and advance linked exams with attempt controls
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 谊安学助手 2.0
|
# 谊安学助手 2.0
|
||||||
|
|
||||||
当前发布版本 **2.1.1**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。
|
当前发布版本 **2.1.2**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。
|
||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
2. 打开 `http://10.119.67.7/clientcn/` 下的学习地图、课程、练习或考试页面。
|
2. 打开 `http://10.119.67.7/clientcn/` 下的学习地图、课程、练习或考试页面。
|
||||||
3. 展开右下角面板,在“题库搜索”导入 [统一题库.csv](题库/统一题库.csv)。原 ZIP、三个岗位 CSV、CSV/JSON 仍可导入,无需选择岗位。
|
3. 展开右下角面板,在“题库搜索”导入 [统一题库.csv](题库/统一题库.csv)。原 ZIP、三个岗位 CSV、CSV/JSON 仍可导入,无需选择岗位。
|
||||||
4. 若考试标题与题库课程不同,在题库栏建立明确的主题关联。关联使用完整标题和完整题库课程名;相似关键词只提供候选,不授权自动答题。
|
4. 若考试标题与题库课程不同,在题库栏建立明确的主题关联。关联使用完整标题和完整题库课程名;相似关键词只提供候选,不授权自动答题。
|
||||||
5. 在“任务总览”选择任务模式、顺序或并行,点击“开始本轮”。人工刷新或重开浏览器后默认暂停,检查清单后点击“继续上次任务”。
|
5. 在“任务总览”选择任务模式、顺序或并行,点击“开始本轮”。新安装默认“视频+考试(含课中答题)”,升级保留你已经保存的模式;若希望视频完成后进入关联考试,请确认当前模式为“视频+考试”。人工刷新或重开浏览器后默认暂停,检查清单后点击“继续上次任务”。
|
||||||
|
|
||||||
脚本仅在 `http://10.119.67.7/clientcn/*` 生效,含其下课程、练习及考试路径;其他主机、HTTPS、其他端口及站点目录外不会注入功能。名称继续为“谊安学助手 2.0”。
|
脚本仅在 `http://10.119.67.7/clientcn/*` 生效,含其下课程、练习及考试路径;其他主机、HTTPS、其他端口及站点目录外不会注入功能。名称继续为“谊安学助手 2.0”。
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
| 功能栏 | 内容 |
|
| 功能栏 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 任务总览 | 四种任务模式、顺序/并行、任务清单、当前任务、总进度及预计剩余时间 |
|
| 任务总览 | 四种任务模式、顺序/并行、任务清单、当前任务、总进度及预计剩余时间 |
|
||||||
| 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复 |
|
| 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复;续播、单次答题机会保护、切回恢复三个开关 |
|
||||||
| 考试答题 | 自动选择开关、缺题人工确认开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 |
|
| 考试答题 | 自动选择开关、缺题人工确认开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 |
|
||||||
| 题库搜索 | 统一题库导入、关键词搜索、课程筛选、选中文字搜索、来源及主题关联 |
|
| 题库搜索 | 统一题库导入、关键词搜索、课程筛选、选中文字搜索、来源及主题关联 |
|
||||||
| 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示 |
|
| 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示 |
|
||||||
@@ -40,15 +40,21 @@
|
|||||||
|
|
||||||
## 视频、任务与后台
|
## 视频、任务与后台
|
||||||
|
|
||||||
任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,执行方式默认顺序,并行最多一个视频和一场考试。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。
|
任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,新安装默认“视频+考试”,已有保存的模式继续沿用,执行方式默认顺序,并行最多一个视频和一场考试。“仅视频”和“视频+课中答题”均不会进入独立或关联考试;选择“视频+考试”后,平台确认视频完成,才进入已经开放的关联考试,仍需通过单次机会保护及题库预检。视频栏根据当前模式显示后续流程。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。
|
||||||
|
|
||||||
视频支持 1X、1.25X、1.5X、2X,显示实际播放器倍率。播放到末尾后继续等待平台学习完成标记;平台记录时长与播放器位置分别展示。没有明确学习时长要求或任务耗时依据时,预计时间显示待确认,2X 不代表整轮耗时必然减半。
|
视频支持 1X、1.25X、1.5X、2X,显示实际播放器倍率。播放到末尾后继续等待平台学习完成标记;平台记录时长与播放器位置分别展示。没有明确学习时长要求或任务耗时依据时,预计时间显示待确认,2X 不代表整轮耗时必然减半。
|
||||||
|
|
||||||
课中答题适配保存 JS 模板中的 `app-question-modal` 与 PC CDK portal:按完整题干及选项匹配,回读选中集合,走网站“查看答案”判分,只有明确回答正确并关闭/进入下一题后才继续。仅视频模式遇强制题、无库、冲突或未知结构时等待人工,不刷新弹题。课中题不套用考试交卷规则。
|
课中答题适配保存 JS 模板中的 `app-question-modal` 与 PC CDK portal:按完整题干及选项匹配,回读选中集合,走网站“查看答案”判分,只有明确回答正确并关闭/进入下一题后才继续。仅视频模式遇强制题、无库、冲突或未知结构时等待人工,不刷新弹题。课中题不套用考试交卷规则。
|
||||||
|
|
||||||
|
“视频学习”栏新增三个独立开关,默认开启并保存设置:
|
||||||
|
|
||||||
|
- **自动继续上次播放**(`autoContinuePlayback`):运行中的视频任务遇到正文为“是否继续上次播放的位置?”、同时有“重新开始”和“继续播放”的明确弹窗时,选择“继续播放”。关闭后由你处理;不会点击其他未知弹窗。
|
||||||
|
- **仅 1 次答题机会时交给我处理**(`skipSingleAttemptQuiz`):用户确认的含义是“只剩 1 次或总共仅允许 1 次答题机会”,包含课中题与课程考试。开启时停止受影响的自动进入、选项点击及提交,交给你手动操作;0 次同样不自动操作。次数只取当前题目或考试容器中明确可见的允许、总、剩余次数及“还可补考 N 次”等提示,不把本人已答 1 次、已答 1/40 题或交卷人数当成机会数。未显示明确次数时不作推断,关闭保护仍需满足原有题库和交卷条件。
|
||||||
|
- **切回页面时自动恢复播放**(`resumePlaybackOnReturn`):只对切页前正在播放、因网站切页逻辑而暂停的当前视频,在返回后尝试正常续播。手动暂停优先,暂停任务、课中题或其他待处理状态不会被自动播放覆盖。关闭后保持暂停,按网站或面板正常继续。
|
||||||
|
|
||||||
默认连续 60 秒没有播放进度时尝试续播,15 秒后仍失败再刷新当前视频页面,最多 3 次,两次刷新至少间隔 30 秒,次数跨刷新保存。主动暂停、课中题、等待平台确认、等待交卷或等待人工不算故障。脚本发起的恢复及任务跳转保存一次性续作标记;人工刷新默认暂停。并行页面通过共享租约、心跳及全局控制协调,打开失败或未确认时显示等待,不假称并行成功。
|
默认连续 60 秒没有播放进度时尝试续播,15 秒后仍失败再刷新当前视频页面,最多 3 次,两次刷新至少间隔 30 秒,次数跨刷新保存。主动暂停、课中题、等待平台确认、等待交卷或等待人工不算故障。脚本发起的恢复及任务跳转保存一次性续作标记;人工刷新默认暂停。并行页面通过共享租约、心跳及全局控制协调,打开失败或未确认时显示等待,不假称并行成功。
|
||||||
|
|
||||||
后台指正常切换标签页或最小化后尽量继续,恢复前台按真实时间和网站记录校准。浏览器关闭、设备休眠、标签冻结或网站强制暂停期间无法保证持续执行;中断显示真实状态,不伪造学习时长。真实账号的解锁、倍速、后台和并行规则仍需实站验收。
|
保存站点脚本显示:网站切到后台会主动暂停播放器,返回前台没有相应自动恢复逻辑。本版记录切页前的真实播放状态,后台暂停时显示“后台暂停,等待返回”,开启上述恢复开关后返回再尝试续播,不把后台暂停误当成长时间卡顿而刷新。不能保证后台一直播放,也不能保证浏览器关闭、设备休眠或标签冻结期间执行;恢复后按实际播放器及平台记录校准,不伪造学习时长。真实账号的解锁、倍速、后台和并行规则仍需实站验收。
|
||||||
|
|
||||||
## 考试预计得分与交卷
|
## 考试预计得分与交卷
|
||||||
|
|
||||||
@@ -87,6 +93,12 @@ npm run merge:banks
|
|||||||
|
|
||||||
测试依赖 Node.js 和 Playwright/Chrome;使用本机模拟页面及中和后的保存 DOM,所有业务 URL 由测试拦截,未访问业务服务器。验证范围包括统一题库迁移/来源/冲突、真实 40 题全文乱序、题间隔、80 分门槛与 4/6 分钟边界、缺题人工确认开关两种模式、设置保存及切换暂停、加载不全等通用门槛、整卷 100 分核验后的交卷流程、防重复、视频恢复、并行心跳、六栏及拖拽/窄屏、复制/导出/站点范围和完整任务流程;本次结果以实际测试记录为准。
|
测试依赖 Node.js 和 Playwright/Chrome;使用本机模拟页面及中和后的保存 DOM,所有业务 URL 由测试拦截,未访问业务服务器。验证范围包括统一题库迁移/来源/冲突、真实 40 题全文乱序、题间隔、80 分门槛与 4/6 分钟边界、缺题人工确认开关两种模式、设置保存及切换暂停、加载不全等通用门槛、整卷 100 分核验后的交卷流程、防重复、视频恢复、并行心跳、六栏及拖拽/窄屏、复制/导出/站点范围和完整任务流程;本次结果以实际测试记录为准。
|
||||||
|
|
||||||
源代码按职责拆分:`bank-store.js`、`answer-matcher.js`、`answer-runner.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`、`exam-controller.js`、`quiz-adapter.js`、`learning-engine.js`、`assistant-panel.js`、`practice-exporter.js`。发布仅需构建后的单个 `.user.js`。
|
||||||
|
|
||||||
|
## 2.1.2 更新记录
|
||||||
|
|
||||||
|
新增默认开启的三个视频栏开关,统一课中题及课程考试的单次机会判断,修复切页后网站暂停而返回不能及时续播的问题。新安装默认选择“视频+考试”,升级保留原模式,并显示视频完成后是否进入考试的具体说明。
|
||||||
|
|
||||||
|
本次提供的 `08.zip` 仅包含 Angular 页面外壳,没有播放器、续播弹窗或次数提示的实际 DOM,也没有业务 JS 资源。适配依据旧完整归档的静态模板和脚本片段,未执行原站代码。`npm test` 的 15 套回归全部通过,学习引擎另完成 52 项检查,覆盖共享设置、暂停保存竞争和关联考试完整导航;桌面及 390px 窄屏无相关控制台错误,所有业务请求均在本地拦截。本地模拟通过仍需结合真实账号验收。
|
||||||
|
|
||||||
开发依据与实施顺序见 [需求.md](需求.md) 和 [实施开发方案.md](实施开发方案.md)。真实课中弹题交互、课程完成条件、账号登录后的并发限制与正式提交结果需在实际环境核实;识别不明时脚本等待人工,不把本地模拟通过等同于全站验收完成。
|
开发依据与实施顺序见 [需求.md](需求.md) 和 [实施开发方案.md](实施开发方案.md)。真实课中弹题交互、课程完成条件、账号登录后的并发限制与正式提交结果需在实际环境核实;识别不明时脚本等待人工,不把本地模拟通过等同于全站验收完成。
|
||||||
|
|||||||
+18
-5
@@ -22,7 +22,7 @@
|
|||||||
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
|
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
|
||||||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
|
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
|
||||||
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
|
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
|
||||||
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
|
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
|
||||||
};
|
};
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let selectionText = '';
|
let selectionText = '';
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
container.appendChild(button); return button;
|
container.appendChild(button); return button;
|
||||||
}
|
}
|
||||||
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
|
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
|
||||||
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频、课中题与考试']], 'all');
|
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频+考试(含课中答题)']], 'all');
|
||||||
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
|
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
|
||||||
const taskActions = el(doc, 'div', 'task-actions');
|
const taskActions = el(doc, 'div', 'task-actions');
|
||||||
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
|
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
|
||||||
@@ -479,8 +479,17 @@
|
|||||||
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
|
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
|
||||||
|
|
||||||
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
|
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
|
||||||
const videoModeNote = el(doc, 'p', 'mode-note'); views.video.appendChild(videoModeNote);
|
const videoModeNote = el(doc, 'p', 'mode-note');
|
||||||
|
videoModeNote.setAttribute('data-testid', 'video-mode-note'); views.video.appendChild(videoModeNote);
|
||||||
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
|
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
|
||||||
|
learningSwitch(views.video, 'autoContinuePlayback', '自动继续上次播放', true);
|
||||||
|
views.video.appendChild(el(doc, 'p', 'hint', '运行中的视频任务出现“是否继续上次播放的位置”时,自动选择“继续播放”;关闭后由你处理。'));
|
||||||
|
learningSwitch(views.video, 'skipSingleAttemptQuiz', '仅 1 次答题机会时交给我处理', true);
|
||||||
|
views.video.appendChild(el(doc, 'p', 'hint', '含课中题和课程考试:网页明确显示仅允许或剩余 1 次机会时,不自动答题,等待你处理。次数未显示时不能推断;关闭后仍需满足原有题库核验条件。'));
|
||||||
|
learningSwitch(views.video, 'resumePlaybackOnReturn', '切回页面时自动恢复播放', true);
|
||||||
|
const playbackReturnHint = el(doc, 'p', 'hint', '只恢复切页引起的暂停。手动暂停优先;你暂停播放器、暂停任务,或有题目等待处理时,不会自动播放。');
|
||||||
|
playbackReturnHint.setAttribute('data-testid', 'playback-return-hint');
|
||||||
|
views.video.appendChild(playbackReturnHint);
|
||||||
const videoInfo = el(doc, 'dl', 'info-grid');
|
const videoInfo = el(doc, 'dl', 'info-grid');
|
||||||
const videoActions = el(doc, 'div', 'task-actions');
|
const videoActions = el(doc, 'div', 'task-actions');
|
||||||
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
|
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
|
||||||
@@ -651,7 +660,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', 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: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', 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 || {};
|
||||||
@@ -718,7 +727,11 @@
|
|||||||
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
|
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
|
||||||
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
|
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
|
||||||
const videoEnabled = learning.mode !== 'exam';
|
const videoEnabled = learning.mode !== 'exam';
|
||||||
setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled;
|
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
|
||||||
|
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
|
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
|
: '当前为“视频+考试”:平台确认视频完成后,进入已开放的关联考试。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
|
||||||
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/* Visible attempt labels shared by course quizzes and course exams. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
const IGNORE = [
|
||||||
|
'script', 'style', 'noscript', 'template', 'textarea', 'input', 'select', 'option', 'pre', 'code', 'svg', 'canvas',
|
||||||
|
'.examtest_content_question', '.examtest_content_list', '.examtest_content_list_des',
|
||||||
|
'.pc_examtest_content_question', '.pc_examtest_content_list',
|
||||||
|
'.model_answer_wrapper', '.model_answer', '.model_dec',
|
||||||
|
'[data-question-stem]', '[data-question-text]', '[data-question-options]', '[data-answer-text]',
|
||||||
|
'.question-stem', '.question-text', '.question-options', '.question-answer', '.option-text', '.answer-text', '[role="option"]'
|
||||||
|
].join(',');
|
||||||
|
const BLOCK = /^(?:ADDRESS|ARTICLE|ASIDE|BLOCKQUOTE|DD|DIV|DL|DT|FIELDSET|FIGCAPTION|FIGURE|FOOTER|FORM|H[1-6]|HEADER|HR|LI|MAIN|NAV|OL|P|SECTION|TABLE|TBODY|TD|TFOOT|TH|THEAD|TR|UL)$/;
|
||||||
|
const DIGITS = '[0-9零〇一二三四五六七八九十百两]+';
|
||||||
|
const TARGET = '(?:答题|作答|考试)';
|
||||||
|
const CAPTION = '(?:允许|允许的|可用|可|总|总共|最多|最大|剩余|还剩|还有|还可)';
|
||||||
|
const SEPARATOR = '(?:[::=]|为|是)?';
|
||||||
|
const LIMIT = '(?:仅|只有|仅有|只限)?';
|
||||||
|
const UNIT = '(?:次|[((]次[))])?';
|
||||||
|
const forms = [
|
||||||
|
new RegExp('^(?:本次|本场)?' + CAPTION + '(?:可|允许)?' + TARGET + '(?:总|剩余)?(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
|
||||||
|
new RegExp('^(?:本次|本场)?' + TARGET + '(?:允许|可用|总|最多|最大|剩余|还剩)(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
|
||||||
|
new RegExp('^(?:本次|本场)?' + TARGET + '(?:次数|机会)' + SEPARATOR + '(?:上限|最多|最大|仅剩|还剩|剩余)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|可|能|允许)?' + TARGET + '(' + DIGITS + ')次$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:最多|总共|总|允许|还可|剩余)(?:可)?' + TARGET + '(' + DIGITS + ')次$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|剩)?(' + DIGITS + ')次' + TARGET + '机会$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:剩余|还剩|还有)(' + DIGITS + ')次' + TARGET + '机会$'),
|
||||||
|
new RegExp('^(?:本次|本场)?还可(?:补考|重考)' + SEPARATOR + '(' + DIGITS + ')次$'),
|
||||||
|
// The saved site's showButton2 label is “不及格,还可补考 N 次”.
|
||||||
|
// The template may wrap it in parentheses after the normal retake button.
|
||||||
|
new RegExp('^不及格[,,]还可补考(' + DIGITS + ')次$'),
|
||||||
|
new RegExp('^(?:重新答题|重新考试)?[((]不及格[,,]还可补考(' + DIGITS + ')次[))]$')
|
||||||
|
];
|
||||||
|
function clean(value) {
|
||||||
|
return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/[0-9]/g, char => String(char.charCodeAt(0) - 0xff10)).replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
function visible(node) {
|
||||||
|
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],[inert]')) return false;
|
||||||
|
const view = node.ownerDocument && node.ownerDocument.defaultView;
|
||||||
|
if (!view) return false;
|
||||||
|
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
|
||||||
|
const style = view.getComputedStyle(current);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false;
|
||||||
|
}
|
||||||
|
return node.getClientRects().length > 0 || view.getComputedStyle(node).display === 'contents';
|
||||||
|
}
|
||||||
|
function numberValue(raw) {
|
||||||
|
if (/^[0-9]+$/.test(raw)) return Number(raw);
|
||||||
|
if (/^[零〇]+$/.test(raw)) return 0;
|
||||||
|
if (raw === '一') return 1;
|
||||||
|
// Only zero and one affect this policy. Other explicit Chinese counts still
|
||||||
|
// form part of the signature but are never mistaken for a single attempt.
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
function evidenceText(scope) {
|
||||||
|
const candidates = new Set();
|
||||||
|
function walk(node) {
|
||||||
|
if (node.nodeType === 3) return node.textContent;
|
||||||
|
if (node.nodeType !== 1 || node.matches(IGNORE) || !visible(node)) return '';
|
||||||
|
if (node.tagName === 'BR') return '\n';
|
||||||
|
const text = Array.from(node.childNodes, walk).join(' ');
|
||||||
|
// Small DOM rows permit labels and values in separate inline spans while
|
||||||
|
// whole pages and unrelated paragraphs are never treated as one label.
|
||||||
|
if (text.length <= 240) candidates.add(clean(text));
|
||||||
|
return BLOCK.test(node.tagName) ? '\n' + text + '\n' : text;
|
||||||
|
}
|
||||||
|
const lines = walk(scope).split(/[\n,,;;。!!]+/).map(clean).filter(Boolean);
|
||||||
|
lines.forEach(line => { if (line.length <= 240) candidates.add(line); });
|
||||||
|
// Definition lists commonly render a status label and its value separately.
|
||||||
|
scope.querySelectorAll('dt').forEach(label => {
|
||||||
|
const value = label.nextElementSibling;
|
||||||
|
if (value && value.tagName === 'DD' && visible(label) && visible(value) && !label.closest(IGNORE) && !value.closest(IGNORE)) {
|
||||||
|
candidates.add(clean(walk(label) + ' ' + walk(value)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
function read(scope) {
|
||||||
|
const empty = { blocked: false, known: false, count: null, reason: '', signature: '[]' };
|
||||||
|
// The caller must pass the current exam/quiz container. Reading Document or
|
||||||
|
// body could borrow another course's limits or a hidden background dialog.
|
||||||
|
if (!scope || scope.nodeType !== 1 || /^(?:HTML|BODY)$/.test(scope.tagName) || !visible(scope) || scope.closest(IGNORE)) return empty;
|
||||||
|
const evidence = new Set();
|
||||||
|
evidenceText(scope).forEach(raw => {
|
||||||
|
const value = raw.replace(/\s+/g, '').replace(/[::]$/, '');
|
||||||
|
for (const expression of forms) {
|
||||||
|
const match = value.match(expression);
|
||||||
|
if (!match) continue;
|
||||||
|
evidence.add(JSON.stringify([value, numberValue(match[1])]));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const labels = Array.from(evidence).sort();
|
||||||
|
const values = labels.map(item => JSON.parse(item)[1]);
|
||||||
|
const count = values.includes(0) ? 0 : values.includes(1) ? 1 : null;
|
||||||
|
return {
|
||||||
|
blocked: count !== null,
|
||||||
|
known: labels.length > 0,
|
||||||
|
count: count,
|
||||||
|
reason: count === 0 ? '网页明确提示已无可用答题或考试次数,等待人工处理' : count === 1 ? '网页明确提示仅允许或剩余 1 次答题或考试机会,等待人工处理' : '',
|
||||||
|
signature: JSON.stringify(labels.map(item => JSON.parse(item)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const api = { read: read };
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.YianAttemptPolicy = api;
|
||||||
|
})(typeof window === 'object' ? window : typeof globalThis === 'object' ? globalThis : this);
|
||||||
@@ -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.1
|
// @version 2.1.2
|
||||||
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
||||||
// @match http://10.119.67.7/clientcn/*
|
// @match http://10.119.67.7/clientcn/*
|
||||||
// @run-at document-idle
|
// @run-at document-idle
|
||||||
@@ -23,7 +23,7 @@ const notices = '/* Third-party notices: JSZip and pako distributed under their
|
|||||||
const zipWrapper = '(function () { const module = { exports: {} }; const exports = module.exports;\n'
|
const zipWrapper = '(function () { const module = { exports: {} }; const exports = module.exports;\n'
|
||||||
+ zip + '\nwindow.JSZip = module.exports;\n})();\n';
|
+ zip + '\nwindow.JSZip = module.exports;\n})();\n';
|
||||||
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
|
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
|
||||||
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'learning-engine.js', 'quiz-adapter.js', 'exam-controller.js', 'practice-exporter.js'];
|
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'attempt-policy.js', 'learning-engine.js', 'quiz-adapter.js', 'exam-controller.js', 'practice-exporter.js'];
|
||||||
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
|
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
|
||||||
const scope = readFileSync(new URL('./site-scope.js', import.meta.url), 'utf8');
|
const scope = readFileSync(new URL('./site-scope.js', import.meta.url), 'utf8');
|
||||||
// Also enforce the boundary when a manager injects manually or ignores metadata.
|
// Also enforce the boundary when a manager injects manually or ignores metadata.
|
||||||
|
|||||||
+28
-1
@@ -100,8 +100,24 @@
|
|||||||
}
|
}
|
||||||
function settings() { return options.getSettings ? options.getSettings() : {}; }
|
function settings() { return options.getSettings ? options.getSettings() : {}; }
|
||||||
function manualIncomplete() { return settings().manualConfirmIncomplete !== false; }
|
function manualIncomplete() { return settings().manualConfirmIncomplete !== false; }
|
||||||
|
function attemptPolicy() {
|
||||||
|
const policy = (options.getAttemptPolicy ? options.getAttemptPolicy() : root.YianAttemptPolicy?.read(doc.querySelector('app-examdetail,app-examtest,.pc_examtest'))) || {};
|
||||||
|
const active = settings().skipSingleAttemptQuiz !== false;
|
||||||
|
return { active, blocked: active && policy.blocked === true, count: policy.count == null ? null : policy.count,
|
||||||
|
reason: clean(policy.reason) || '仅有 1 次答题机会,已停止自动答题与交卷,请在原网页人工操作',
|
||||||
|
signature: active ? JSON.stringify([policy.signature || '', policy.blocked === true, policy.count == null ? null : policy.count]) : '' };
|
||||||
|
}
|
||||||
|
function blockSingleAttempt(policy) {
|
||||||
|
generation++; enabled = false;
|
||||||
|
if (['submitting', 'confirming', 'unknown'].includes(stage)) stage = 'unknown';
|
||||||
|
else stage = 'waiting';
|
||||||
|
error = policy.reason;
|
||||||
|
return emit({ skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: true, attemptCount: policy.count,
|
||||||
|
requiresManualSubmission: true, message: error });
|
||||||
|
}
|
||||||
function scopeValue(ctx, evidence, topic) {
|
function scopeValue(ctx, evidence, topic) {
|
||||||
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete()]);
|
const policy = attemptPolicy();
|
||||||
|
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete(), policy.active, policy.signature]);
|
||||||
}
|
}
|
||||||
function resultVisible() {
|
function resultVisible() {
|
||||||
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
|
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
|
||||||
@@ -156,8 +172,12 @@
|
|||||||
async function enable() {
|
async function enable() {
|
||||||
const token = ++generation, requested = context();
|
const token = ++generation, requested = context();
|
||||||
enabled = false;
|
enabled = false;
|
||||||
|
const initialPolicy = attemptPolicy();
|
||||||
|
if (initialPolicy.blocked) { blockSingleAttempt(initialPolicy); return false; }
|
||||||
await observe();
|
await observe();
|
||||||
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
|
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
|
||||||
|
const currentPolicy = attemptPolicy();
|
||||||
|
if (currentPolicy.blocked) { blockSingleAttempt(currentPolicy); return false; }
|
||||||
if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) {
|
if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) {
|
||||||
stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷';
|
stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷';
|
||||||
emit({ requiresManualSubmission: true, message: error }); return false;
|
emit({ requiresManualSubmission: true, message: error }); return false;
|
||||||
@@ -176,6 +196,8 @@
|
|||||||
if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; }
|
if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; }
|
||||||
scope = scopeValue(ctx, evidence, topic);
|
scope = scopeValue(ctx, evidence, topic);
|
||||||
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
|
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
|
||||||
|
const finalPolicy = attemptPolicy();
|
||||||
|
if (token === generation && finalPolicy.blocked) { blockSingleAttempt(finalPolicy); return false; }
|
||||||
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
|
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
|
||||||
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
|
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
|
||||||
}
|
}
|
||||||
@@ -196,6 +218,8 @@
|
|||||||
}
|
}
|
||||||
function canClick(token, ctx, confirmation) {
|
function canClick(token, ctx, confirmation) {
|
||||||
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
|
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
|
||||||
|
const policy = attemptPolicy();
|
||||||
|
if (policy.blocked) { blockSingleAttempt(policy); return false; }
|
||||||
const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic);
|
const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic);
|
||||||
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
||||||
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
|
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
|
||||||
@@ -234,6 +258,7 @@
|
|||||||
return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false });
|
return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false });
|
||||||
}
|
}
|
||||||
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
|
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
|
||||||
|
const policy = attemptPolicy();
|
||||||
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
|
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
|
||||||
const configured = Number(settings().minSubmitMinutes);
|
const configured = Number(settings().minSubmitMinutes);
|
||||||
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
||||||
@@ -243,8 +268,10 @@
|
|||||||
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
|
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
|
||||||
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
|
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
|
||||||
manualConfirmIncomplete: manualIncomplete(),
|
manualConfirmIncomplete: manualIncomplete(),
|
||||||
|
skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: policy.blocked, attemptCount: policy.count,
|
||||||
requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified),
|
requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified),
|
||||||
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
|
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
|
||||||
|
if (policy.blocked) return blockSingleAttempt(policy);
|
||||||
if (!enabled) return last;
|
if (!enabled) return last;
|
||||||
if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') {
|
if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') {
|
||||||
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
|
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
|
||||||
|
|||||||
+212
-31
@@ -13,6 +13,7 @@
|
|||||||
const LEASE_MS = 18000;
|
const LEASE_MS = 18000;
|
||||||
const SESSION_KEY = 'session.v1';
|
const SESSION_KEY = 'session.v1';
|
||||||
const CONTROL_KEY = 'control.v1';
|
const CONTROL_KEY = 'control.v1';
|
||||||
|
const POLICY_KEYS = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||||
|
|
||||||
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
|
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
|
||||||
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
||||||
@@ -22,6 +23,9 @@
|
|||||||
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
|
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
|
||||||
}
|
}
|
||||||
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
|
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
|
||||||
|
function examIdentity(value) {
|
||||||
|
try { const url = new URL(value); const match = url.pathname.match(/^\/clientcn\/(?:exam|examtest)\/([^/]+)(?:\/questions)?\/?$/); return match ? url.origin + ':exam:' + match[1] : ''; } catch (_) { return ''; }
|
||||||
|
}
|
||||||
function visible(node) {
|
function visible(node) {
|
||||||
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
|
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
|
||||||
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
|
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
|
||||||
@@ -78,6 +82,21 @@
|
|||||||
if (portal) return portal.closest('.question-modal-pc');
|
if (portal) return portal.closest('.question-modal-pc');
|
||||||
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
|
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
|
||||||
}
|
}
|
||||||
|
function playbackPromptOf(doc) {
|
||||||
|
// 原站 ngx-bootstrap 将模板放在 body portal,app-dialog-box 自身可能为空。
|
||||||
|
const bodies = Array.from(doc.querySelectorAll('.modal-dialog.dialog_box .modal-body,[role="dialog"] .modal-body'))
|
||||||
|
.filter(node => visible(node) && /^是否继续上次播放的位置[??]$/.test(clean(node.textContent)));
|
||||||
|
const candidates = [];
|
||||||
|
for (const body of bodies) {
|
||||||
|
const container = body.closest('.modal-content,[role="dialog"]'); if (!container) continue;
|
||||||
|
if (candidates.some(item => item.container === container)) continue;
|
||||||
|
const buttons = Array.from(container.querySelectorAll('.modal-footer button')).filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||||||
|
const keep = buttons.filter(node => clean(node.textContent) === '继续播放');
|
||||||
|
const restart = buttons.filter(node => clean(node.textContent) === '重新开始');
|
||||||
|
candidates.push({ container, body, button: keep.length === 1 && restart.length === 1 ? keep[0] : null });
|
||||||
|
}
|
||||||
|
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -167,10 +186,13 @@
|
|||||||
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
|
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
|
||||||
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
|
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
|
||||||
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 sharedPaused = false;
|
let sharedPaused = false;
|
||||||
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
||||||
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
|
let policySessionId = '', policyReadSerial = 0;
|
||||||
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
|
const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map();
|
||||||
|
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
|
||||||
|
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
|
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
|
||||||
@@ -180,10 +202,32 @@
|
|||||||
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 onVideoPause(event) {
|
function onVideoPause(event) {
|
||||||
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
|
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
|
||||||
|
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rememberPlayback() {
|
||||||
|
const snap = snapshot(); if (!snap.document || !safeURL(snap.url, snap.url)) return;
|
||||||
|
const task = state.tasks.find(t => t.id === activeTaskId); const page = describePage(snap.document, snap.url);
|
||||||
|
if (state.status !== 'running' || config.videoPaused || manualVideoPause || !task || task.kind !== 'video'
|
||||||
|
|| !['loading', 'playing', 'retrying'].includes(task.status) || !page.video || page.quiz || playbackPromptOf(snap.document) || page.video.ended || page.platformComplete) return;
|
||||||
|
if (!visibilityPlayback && (playStarted || !page.video.paused)) visibilityPlayback = { taskId: task.id, url: page.url, video: page.video, generation };
|
||||||
|
lastAdvancedAt = now(); attemptedAt = 0;
|
||||||
|
}
|
||||||
|
function onPageHidden() { pageAway = true; rememberPlayback(); }
|
||||||
|
function onPageReturn() {
|
||||||
|
if (watchedDocument && watchedDocument.visibilityState === 'hidden') return;
|
||||||
|
pageAway = false; lastAdvancedAt = now(); attemptedAt = 0;
|
||||||
|
returnResumeRequested = Boolean(visibilityPlayback && config.resumePlaybackOnReturn);
|
||||||
|
// 仍走正常调度、租约和弹题检查,事件本身不直接点击或播放。
|
||||||
|
root.setTimeout(() => { if (!destroyed) tick().catch(() => {}); }, 0);
|
||||||
|
}
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (watchedDocument && watchedDocument.visibilityState === 'hidden') onPageHidden(); else onPageReturn();
|
||||||
}
|
}
|
||||||
function notify() {
|
function notify() {
|
||||||
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused });
|
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
|
||||||
|
for (const key of POLICY_KEYS) state[key] = config[key];
|
||||||
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
|
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
|
||||||
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
|
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
|
||||||
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
|
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
|
||||||
@@ -217,6 +261,23 @@
|
|||||||
}
|
}
|
||||||
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'); }
|
||||||
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
|
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
|
||||||
|
function readExamAttempt(task, snap, page) {
|
||||||
|
const scope = snap.document.querySelector('app-examdetail,app-examtest,.pc_examtest');
|
||||||
|
const live = root.YianAttemptPolicy?.read(scope) || { known: false, blocked: false, count: null, reason: '', signature: '[]' };
|
||||||
|
if (!['exam', 'paper'].includes(page.kind)) return live;
|
||||||
|
const taskURL = task && (task.examPaperURL || task.url || task.pageUrl);
|
||||||
|
const sameExam = taskURL && examIdentity(taskURL) && examIdentity(taskURL) === examIdentity(page.url);
|
||||||
|
if (task && task.kind === 'exam' && (canonical(taskURL) === page.url || sameExam)) {
|
||||||
|
if (live.known) { task.attemptPolicy = clone(live); task.attemptPolicyURL = page.url; }
|
||||||
|
// 同一任务从详情进入试卷后,次数提示可能不再显示;保留已核对的证据。
|
||||||
|
else if (task.attemptPolicy && (task.attemptPolicyURL === page.url || sameExam)) return clone(task.attemptPolicy);
|
||||||
|
}
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
function getAttemptPolicy() {
|
||||||
|
const snap = snapshot(); if (!snap.document) return { known: false, blocked: false, count: null, reason: '', signature: '[]' };
|
||||||
|
return readExamAttempt(state.tasks.find(t => t.id === activeTaskId), snap, describePage(snap.document, snap.url));
|
||||||
|
}
|
||||||
async function persist() {
|
async function persist() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
|
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
|
||||||
@@ -228,7 +289,7 @@
|
|||||||
}
|
}
|
||||||
const currentControl = await storage.get(CONTROL_KEY);
|
const currentControl = await storage.get(CONTROL_KEY);
|
||||||
if (currentControl && currentControl.sessionId !== session.id) return;
|
if (currentControl && currentControl.sessionId !== session.id) return;
|
||||||
syncManualConfirmation(currentControl); session.config = clone(config);
|
await syncPolicies(currentControl); session.config = clone(config);
|
||||||
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
|
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
|
||||||
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
|
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
|
||||||
}
|
}
|
||||||
@@ -261,14 +322,25 @@
|
|||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
|
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
|
||||||
const current = state.tasks.find(t => t.id === activeTaskId);
|
const current = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
const conflictingExam = current && task.kind === 'exam' && examIdentity(current.examPaperURL || current.url || current.pageUrl)
|
||||||
|
&& examIdentity(task.url || task.pageUrl) && examIdentity(current.examPaperURL || current.url || current.pageUrl) !== examIdentity(task.url || task.pageUrl);
|
||||||
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
|
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
|
||||||
const alias = task.current && current && current.kind === task.kind &&
|
const alias = task.current && current && current.kind === task.kind &&
|
||||||
(task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || task.kind === 'video' && task.title === current.title);
|
(task.kind === 'exam' && !conflictingExam && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title
|
||||||
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity) || (alias ? current : null);
|
&& (!current.examPaperURL || current.examPaperURL === task.url)
|
||||||
|
&& (!current.enterClickedAt || now() - current.enterClickedAt < 60000 || current.examPaperURL === task.url)
|
||||||
|
|| task.kind === 'video' && task.title === current.title);
|
||||||
|
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity
|
||||||
|
|| task.current && task.kind === t.kind && task.url && (canonical(t.examPaperURL || t.url) === canonical(task.url)
|
||||||
|
|| task.kind === 'exam' && examIdentity(t.examPaperURL || t.url || t.pageUrl) && examIdentity(t.examPaperURL || t.url || t.pageUrl) === examIdentity(task.url))) || (alias ? current : null);
|
||||||
if (!old) state.tasks.push(task);
|
if (!old) state.tasks.push(task);
|
||||||
else {
|
else {
|
||||||
if (alias && task.url) old.url = task.url;
|
if (alias && task.url) old.url = task.url;
|
||||||
if (task.status === 'completed') { old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成'; }
|
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
|
||||||
|
if (task.status === 'completed') {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -292,6 +364,8 @@
|
|||||||
async function initializeWork() {
|
async function initializeWork() {
|
||||||
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);
|
||||||
|
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);
|
||||||
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
|
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
|
||||||
@@ -327,33 +401,81 @@
|
|||||||
} 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 (session) syncManualConfirmation(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();
|
||||||
}
|
}
|
||||||
function syncManualConfirmation(shared) {
|
function syncManualConfirmation(shared) {
|
||||||
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
|
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
|
||||||
const revision = Number(shared.manualConfirmRevision) || 0;
|
const revision = Number(shared.manualConfirmRevision) || 0;
|
||||||
if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return;
|
if (revision < manualConfirmRevision) return;
|
||||||
config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision;
|
for (const key of POLICY_KEYS) if (!authoritativePolicies.has(key) && typeof shared.config[key] === 'boolean') { config[key] = shared.config[key]; state[key] = config[key]; }
|
||||||
session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete });
|
manualConfirmRevision = revision; session.config = Object.assign({}, session.config);
|
||||||
|
for (const key of POLICY_KEYS) session.config[key] = config[key];
|
||||||
|
}
|
||||||
|
function ensurePolicySession() {
|
||||||
|
const id = session?.id || '';
|
||||||
|
if (policySessionId === id) return;
|
||||||
|
policySessionId = id; authoritativePolicies.clear(); policyAppliedReads.clear(); policyLocalWrites.clear();
|
||||||
|
}
|
||||||
|
function policyKey(key, id) { return 'policy:' + (id || session.id) + ':' + key; }
|
||||||
|
async function syncPolicies(shared) {
|
||||||
|
if (!session) return;
|
||||||
|
ensurePolicySession();
|
||||||
|
syncManualConfirmation(shared);
|
||||||
|
const id = session.id, serial = ++policyReadSerial;
|
||||||
|
const writes = new Map(POLICY_KEYS.map(key => [key, policyLocalWrites.get(key) || 0]));
|
||||||
|
const records = await Promise.all(POLICY_KEYS.map(key => storage.get(policyKey(key, id))));
|
||||||
|
if (!session || session.id !== id || destroyed) return;
|
||||||
|
records.forEach((record, index) => {
|
||||||
|
const key = POLICY_KEYS[index];
|
||||||
|
if (!record || record.sessionId !== id || typeof record.value !== 'boolean'
|
||||||
|
|| pendingManualConfirm?.values && key in pendingManualConfirm.values
|
||||||
|
|| (policyLocalWrites.get(key) || 0) !== writes.get(key)
|
||||||
|
|| serial < (policyAppliedReads.get(key) || 0)) return;
|
||||||
|
// 独立键是共享策略的权威值;旧 control 全量快照不能覆盖它。
|
||||||
|
authoritativePolicies.add(key); policyAppliedReads.set(key, serial);
|
||||||
|
config[key] = record.value; state[key] = record.value;
|
||||||
|
});
|
||||||
|
session.config = Object.assign({}, session.config);
|
||||||
|
for (const key of POLICY_KEYS) session.config[key] = config[key];
|
||||||
}
|
}
|
||||||
async function control(status, force, explicitWorkerAction, manualPatch) {
|
async function control(status, force, explicitWorkerAction, manualPatch) {
|
||||||
if (!session || worker && !explicitWorkerAction) return;
|
if (!session || worker && !explicitWorkerAction) return;
|
||||||
|
const controlEpoch = generation, controlSessionId = session.id;
|
||||||
|
ensurePolicySession();
|
||||||
const current = await storage.get(CONTROL_KEY);
|
const current = await storage.get(CONTROL_KEY);
|
||||||
if (!force && current && current.sessionId !== session.id) return;
|
if (!force && current && current.sessionId !== session.id) return;
|
||||||
const sameSession = current && current.sessionId === session.id;
|
const sameSession = current && current.sessionId === session.id;
|
||||||
if (manualPatch) {
|
if (manualPatch) {
|
||||||
config.manualConfirmIncomplete = manualPatch.value;
|
if (sameSession && current.config) for (const key of POLICY_KEYS) {
|
||||||
|
if (!(key in manualPatch.values) && !authoritativePolicies.has(key) && typeof current.config[key] === 'boolean') config[key] = current.config[key];
|
||||||
|
}
|
||||||
|
Object.assign(config, manualPatch.values);
|
||||||
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
|
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
|
||||||
} else syncManualConfirmation(current);
|
const id = session.id;
|
||||||
|
await Promise.all(Object.entries(manualPatch.values).map(async ([key, value]) => {
|
||||||
|
authoritativePolicies.add(key);
|
||||||
|
await storage.set(policyKey(key, id), { sessionId: id, value, revision: manualConfirmRevision, updatedAt: now(), owner });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await syncPolicies(current);
|
||||||
|
// 策略通过独立键发布,不将旧 control 快照写回,避免覆盖另一页刚发出的暂停。
|
||||||
|
if (manualPatch && sameSession || controlEpoch !== generation || session?.id !== controlSessionId) return;
|
||||||
const sharedConfig = clone(worker && sameSession ? current.config : config);
|
const sharedConfig = clone(worker && sameSession ? current.config : config);
|
||||||
sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete;
|
for (const key of POLICY_KEYS) sharedConfig[key] = config[key];
|
||||||
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
|
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
|
||||||
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
|
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
|
||||||
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
|
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
|
||||||
}
|
}
|
||||||
async function configure(patch) {
|
async function configure(patch, restoreOptions) {
|
||||||
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
|
await initialize(); patch = patch || {};
|
||||||
|
if (restoreOptions?.restore === true && session) {
|
||||||
|
await syncPolicies(await storage.get(CONTROL_KEY));
|
||||||
|
// 普通设置快照可能来自另一个标签的旧全量写回;重载只恢复尚无权威记录的策略。
|
||||||
|
patch = Object.assign({}, patch);
|
||||||
|
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
|
||||||
|
}
|
||||||
|
const wasRunning = state.status === 'running';
|
||||||
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;
|
||||||
@@ -366,8 +488,12 @@
|
|||||||
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;
|
||||||
const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null;
|
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
|
||||||
if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; }
|
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
|
||||||
|
if (manualPatch) {
|
||||||
|
ensurePolicySession(); pendingManualConfirm = manualPatch; Object.assign(config, policies);
|
||||||
|
for (const key of Object.keys(policies)) policyLocalWrites.set(key, (policyLocalWrites.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
if (patch.clearLogs) state.logs = [];
|
if (patch.clearLogs) state.logs = [];
|
||||||
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
|
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
|
||||||
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
|
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
|
||||||
@@ -386,14 +512,19 @@
|
|||||||
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
|
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
|
||||||
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
|
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
|
||||||
if (previous) await release();
|
if (previous) await release();
|
||||||
|
visibilityPlayback = null; returnResumeRequested = false;
|
||||||
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
|
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
|
||||||
return tick();
|
return tick();
|
||||||
}
|
}
|
||||||
async function pause(reason) {
|
async function pause(reason) {
|
||||||
await initialize(); generation++; state.status = 'paused'; sharedPaused = true; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
|
await initialize(); const pauseEpoch = ++generation; visibilityPlayback = null; returnResumeRequested = false; state.status = 'paused'; sharedPaused = false; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
|
||||||
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
|
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
|
||||||
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
|
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
|
||||||
await control('paused', false, true); await release(); await persist(); log('info', state.message); return getState();
|
await control('paused', false, true);
|
||||||
|
if (pauseEpoch !== generation || state.status !== 'paused') return getState();
|
||||||
|
// 暂停写入完成后才接受远端“继续”;使写入期间读到旧 running 的轮询失效。
|
||||||
|
sharedPaused = true; generation++;
|
||||||
|
await release(); await persist(); log('info', state.message); return getState();
|
||||||
}
|
}
|
||||||
async function resume() {
|
async function resume() {
|
||||||
await initialize(); if (!session) return start();
|
await initialize(); if (!session) return start();
|
||||||
@@ -403,7 +534,7 @@
|
|||||||
}
|
}
|
||||||
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) || t.status === 'waitingPlatform') && !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++; 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();
|
||||||
}
|
}
|
||||||
async function stop() {
|
async function stop() {
|
||||||
@@ -417,18 +548,21 @@
|
|||||||
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))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
|
if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
|
||||||
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false;
|
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
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);
|
||||||
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
||||||
|
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 : '');
|
||||||
task.navigationRequestedAt = now();
|
|
||||||
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) { (node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true; }
|
if (node) {
|
||||||
|
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -456,7 +590,23 @@
|
|||||||
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: page.learnedSeconds, requiredSeconds: page.requiredSeconds, 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: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
|
||||||
|
const playbackPrompt = playbackPromptOf(snap.document);
|
||||||
|
if (playbackPrompt && !popup) {
|
||||||
|
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
|
||||||
|
if (manualVideoPause || !config.autoContinuePlayback || playbackPrompt.ambiguous || !playbackPrompt.button) {
|
||||||
|
taskState(task, 'waiting', manualVideoPause ? '用户已暂停,请手动选择续播并继续任务' : !config.autoContinuePlayback ? '自动续播已关闭,请在网页选择继续播放或重新开始后继续任务' : '续播弹窗无法唯一确认,请人工选择后继续'); return;
|
||||||
|
}
|
||||||
|
if (playbackPromptClick && playbackPromptClick.container === playbackPrompt.container) {
|
||||||
|
if (now() - playbackPromptClick.at > 15000) taskState(task, 'waiting', '已选择继续播放,但网页弹窗未关闭,请人工检查');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (epoch !== generation || state.status !== 'running' || config.videoPaused || !config.autoContinuePlayback) return;
|
||||||
|
playbackPromptClick = { container: playbackPrompt.container, at: now() }; playStarted = false; lastTime = null;
|
||||||
|
taskState(task, 'loading', '已选择继续上次位置,等待网页恢复播放器'); playbackPrompt.button.click(); return;
|
||||||
|
}
|
||||||
|
if (!playbackPrompt) playbackPromptClick = null;
|
||||||
if (popup) {
|
if (popup) {
|
||||||
|
visibilityPlayback = null; returnResumeRequested = false;
|
||||||
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
|
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
|
||||||
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
|
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
|
||||||
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
|
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
|
||||||
@@ -464,7 +614,7 @@
|
|||||||
if (pendingQuiz) return;
|
if (pendingQuiz) return;
|
||||||
pendingQuiz = popup;
|
pendingQuiz = popup;
|
||||||
try {
|
try {
|
||||||
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, document: snap.document });
|
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz, document: snap.document });
|
||||||
if (epoch !== generation || state.status !== 'running') return;
|
if (epoch !== generation || state.status !== 'running') return;
|
||||||
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
|
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
|
||||||
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
|
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
|
||||||
@@ -474,6 +624,19 @@
|
|||||||
}
|
}
|
||||||
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
|
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
|
||||||
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
|
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
|
||||||
|
if ((page.hidden || pageAway) && video.paused && (playStarted || visibilityPlayback)) {
|
||||||
|
rememberPlayback(); lastAdvancedAt = now(); attemptedAt = 0;
|
||||||
|
state.video.status = 'backgroundPaused'; taskState(task, 'playing', '网站在后台暂停播放,等待切回页面后恢复'); return;
|
||||||
|
}
|
||||||
|
if (returnResumeRequested) {
|
||||||
|
const previous = visibilityPlayback; returnResumeRequested = false; visibilityPlayback = null;
|
||||||
|
if (previous && previous.taskId === task.id && previous.url === page.url && previous.video === video && previous.generation === epoch && config.resumePlaybackOnReturn) {
|
||||||
|
lastAdvancedAt = now(); attemptedAt = 0; lastTime = null;
|
||||||
|
if (video.paused) { playStarted = false; taskState(task, 'playing', '已切回页面,正在继续原播放位置'); }
|
||||||
|
}
|
||||||
|
} else if (visibilityPlayback && !page.hidden && !pageAway && video.paused && !config.resumePlaybackOnReturn) {
|
||||||
|
state.video.status = 'waiting'; taskState(task, 'waiting', '切回页面自动恢复已关闭,请手动播放后继续任务'); return;
|
||||||
|
}
|
||||||
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
|
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
|
||||||
state.video.actualRate = video.playbackRate;
|
state.video.actualRate = video.playbackRate;
|
||||||
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
|
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
|
||||||
@@ -516,6 +679,17 @@
|
|||||||
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
|
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
|
||||||
}
|
}
|
||||||
async function examTick(task, snap, page) {
|
async function examTick(task, snap, page) {
|
||||||
|
if (['exam', 'paper'].includes(page.kind)) {
|
||||||
|
const sourceURL = task.examPaperURL || task.url || task.pageUrl;
|
||||||
|
const sameURL = canonical(sourceURL) === page.url || examIdentity(sourceURL) && examIdentity(sourceURL) === examIdentity(page.url);
|
||||||
|
if (!sameURL) { taskState(task, 'waiting', '当前考试与已领取任务的地址不同,请核对后开始新一轮'); return; }
|
||||||
|
if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url;
|
||||||
|
}
|
||||||
|
const attempts = readExamAttempt(task, snap, page);
|
||||||
|
if (config.skipSingleAttemptQuiz && attempts.blocked && !task.examRequested) {
|
||||||
|
state.exam = { taskId: task.id, title: task.title, stage: 'waiting', requiresManualSubmission: true, message: attempts.reason };
|
||||||
|
taskState(task, 'waiting', attempts.reason); return;
|
||||||
|
}
|
||||||
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
|
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
|
||||||
if (task.status === 'waiting' || config.examPaused) {
|
if (task.status === 'waiting' || config.examPaused) {
|
||||||
await observeExamState(task, true);
|
await observeExamState(task, true);
|
||||||
@@ -620,14 +794,15 @@
|
|||||||
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
|
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
|
||||||
if (session) {
|
if (session) {
|
||||||
const controlValue = await storage.get(CONTROL_KEY);
|
const controlValue = await storage.get(CONTROL_KEY);
|
||||||
|
if (epoch !== generation) return getState();
|
||||||
if (await observeControl(controlValue)) return getState();
|
if (await observeControl(controlValue)) return getState();
|
||||||
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
|
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
|
||||||
const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete;
|
const sharedConfig = Object.assign({}, controlValue.config); for (const key of POLICY_KEYS) delete sharedConfig[key];
|
||||||
Object.assign(config, sharedConfig);
|
Object.assign(config, sharedConfig);
|
||||||
}
|
}
|
||||||
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(); }
|
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = 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' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
|
||||||
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
|
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
|
||||||
@@ -659,8 +834,10 @@
|
|||||||
}
|
}
|
||||||
async function observeControl(value) {
|
async function observeControl(value) {
|
||||||
if (!session) return false;
|
if (!session) return false;
|
||||||
|
const controlEpoch = generation;
|
||||||
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
|
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
|
||||||
syncManualConfirmation(shared);
|
await syncPolicies(shared);
|
||||||
|
if (controlEpoch !== generation) return true;
|
||||||
if (state.status !== 'running' && !sharedPaused) return false;
|
if (state.status !== 'running' && !sharedPaused) return false;
|
||||||
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
|
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
|
||||||
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
||||||
@@ -678,10 +855,14 @@
|
|||||||
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
|
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
|
||||||
function destroy() {
|
function destroy() {
|
||||||
destroyed = true; generation++;
|
destroyed = true; generation++;
|
||||||
if (watchedDocument) { watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true); }
|
if (watchedDocument) {
|
||||||
|
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
|
||||||
|
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
|
||||||
|
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(() => {});
|
||||||
}
|
}
|
||||||
return { configure, start, pause, resume, stop, tick, getState, destroy, log, clearLogs };
|
return { configure, start, pause, resume, stop, tick, getState, getAttemptPolicy, destroy, log, clearLogs };
|
||||||
}
|
}
|
||||||
return { create, describePage, discoverTasks, preflightTopic };
|
return { create, describePage, discoverTasks, preflightTopic };
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yianxue-question-bank-exporter",
|
"name": "yianxue-question-bank-exporter",
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验",
|
"description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+45
-22
@@ -16,9 +16,16 @@
|
|||||||
let positionRevision = 0;
|
let positionRevision = 0;
|
||||||
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 learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {} };
|
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||||
|
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: {} };
|
||||||
const inScope = () => YianSiteScope.isAllowedURL(location.href);
|
const inScope = () => YianSiteScope.isAllowedURL(location.href);
|
||||||
|
|
||||||
|
function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; }
|
||||||
|
function attemptPolicy() {
|
||||||
|
return learning?.getAttemptPolicy() || window.YianAttemptPolicy.read(document.querySelector('app-examdetail,app-examtest,.pc_examtest'));
|
||||||
|
}
|
||||||
|
|
||||||
function topicPreflight() {
|
function topicPreflight() {
|
||||||
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
|
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
|
||||||
}
|
}
|
||||||
@@ -69,13 +76,15 @@
|
|||||||
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
||||||
const signature = route.root ? structureSignature(bank) : '';
|
const signature = route.root ? structureSignature(bank) : '';
|
||||||
const topic = route.mode === 'test' ? topicPreflight() : null;
|
const topic = route.mode === 'test' ? topicPreflight() : null;
|
||||||
|
const attempt = route.mode === 'test' ? attemptPolicy() : null;
|
||||||
|
const attemptBlocked = !!attempt && skipSingleAttempt() && attempt.blocked;
|
||||||
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
|
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
|
||||||
&& page.root === route.root && page.signature === signature,
|
&& page.root === route.root && page.signature === signature && !attemptBlocked,
|
||||||
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
|
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
|
||||||
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
|
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
|
||||||
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
|
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
|
||||||
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
|
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
|
||||||
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations]) : '' };
|
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations, skipSingleAttempt(), attempt.signature]) : '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const runner = window.YianAnswerRunner.create({ getContext,
|
const runner = window.YianAnswerRunner.create({ getContext,
|
||||||
@@ -99,10 +108,12 @@
|
|||||||
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
|
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
|
||||||
const route = routeInfo();
|
const route = routeInfo();
|
||||||
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
|
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
|
||||||
panel.setState({ learning: Object.assign({}, learningSettings, learningState, {
|
const rendered = Object.assign({}, learningSettings, learningState, {
|
||||||
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
|
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
|
||||||
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
|
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
|
||||||
currentTitle: window.YianLearningEngine.describePage(document, location.href).title }) });
|
currentTitle: window.YianLearningEngine.describePage(document, location.href).title });
|
||||||
|
for (const [key, pending] of pendingLearningSettings) rendered[key] = pending.value;
|
||||||
|
panel.setState({ learning: rendered });
|
||||||
}
|
}
|
||||||
function refreshPanel(extra) {
|
function refreshPanel(extra) {
|
||||||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||||||
@@ -129,6 +140,9 @@
|
|||||||
function setAutoAnswer(enabled) {
|
function setAutoAnswer(enabled) {
|
||||||
if (!enabled) actionEpoch++;
|
if (!enabled) actionEpoch++;
|
||||||
const context = getContext();
|
const context = getContext();
|
||||||
|
if (enabled && context.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked) {
|
||||||
|
panel.setNotice(attemptPolicy().reason, 'warning'); refreshPanel(); return false;
|
||||||
|
}
|
||||||
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
|
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
|
||||||
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
|
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
|
||||||
}
|
}
|
||||||
@@ -195,11 +209,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function changeLearningSetting(key, value) {
|
async function changeLearningSetting(key, value) {
|
||||||
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
|
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
|
||||||
if (!allowed.includes(key)) return;
|
if (!allowed.includes(key)) return;
|
||||||
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
|
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
|
||||||
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
|
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
|
||||||
if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关');
|
if (policyKeys.includes(key) && typeof value !== 'boolean') throw new Error('开关设置须为开或关');
|
||||||
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
|
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
|
||||||
if (ranges[key]) {
|
if (ranges[key]) {
|
||||||
const range = ranges[key]; value = Number(value);
|
const range = ranges[key]; value = Number(value);
|
||||||
@@ -211,15 +225,20 @@
|
|||||||
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
|
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
|
||||||
throw new Error('主题关联须使用完整标题及题库课程名称列表');
|
throw new Error('主题关联须使用完整标题及题库课程名称列表');
|
||||||
}
|
}
|
||||||
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) {
|
const pending = { value }; pendingLearningSettings.set(key, pending);
|
||||||
actionEpoch++;
|
try {
|
||||||
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
|
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) {
|
||||||
await learning.pause();
|
actionEpoch++;
|
||||||
|
config.autoAnswer = false; runner.stop('执行策略或主题关联变更'); examController.disable('设置变更,须重新启动核验');
|
||||||
|
await learning.pause();
|
||||||
|
}
|
||||||
|
learningSettings = Object.assign({}, learningSettings, { [key]: value });
|
||||||
|
await learning.configure({ [key]: value });
|
||||||
|
await saveSetting('learningSettings', learningSettings);
|
||||||
|
} finally {
|
||||||
|
if (pendingLearningSettings.get(key) === pending) pendingLearningSettings.delete(key);
|
||||||
|
refreshPanel(); renderLearning();
|
||||||
}
|
}
|
||||||
learningSettings = Object.assign({}, learningSettings, { [key]: value });
|
|
||||||
await learning.configure({ [key]: value });
|
|
||||||
await saveSetting('learningSettings', learningSettings);
|
|
||||||
refreshPanel(); renderLearning();
|
|
||||||
}
|
}
|
||||||
async function taskAction(action) {
|
async function taskAction(action) {
|
||||||
actionEpoch++;
|
actionEpoch++;
|
||||||
@@ -245,8 +264,9 @@
|
|||||||
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
|
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
|
||||||
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
|
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
|
||||||
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
|
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
|
||||||
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false
|
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false,
|
||||||
}), getRunnerState: () => runnerState,
|
skipSingleAttemptQuiz: skipSingleAttempt()
|
||||||
|
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
|
||||||
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
|
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
|
||||||
onState: state => { examState = state; renderLearning(); } });
|
onState: state => { examState = state; renderLearning(); } });
|
||||||
learning = window.YianLearningEngine.create({
|
learning = window.YianLearningEngine.create({
|
||||||
@@ -266,7 +286,7 @@
|
|||||||
const epoch = actionEpoch;
|
const epoch = actionEpoch;
|
||||||
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
|
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
|
||||||
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
|
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
|
||||||
sourceUrl: location.href, answerInterval: quizContext.answerInterval },
|
sourceUrl: location.href, answerInterval: quizContext.answerInterval, skipSingleAttemptQuiz: quizContext.skipSingleAttemptQuiz },
|
||||||
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
|
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
|
||||||
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
|
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
|
||||||
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
|
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
|
||||||
@@ -281,9 +301,7 @@
|
|||||||
},
|
},
|
||||||
onState: state => {
|
onState: state => {
|
||||||
learningState = state;
|
learningState = state;
|
||||||
if (!loading && typeof state.manualConfirmIncomplete === 'boolean') {
|
if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key];
|
||||||
learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete;
|
|
||||||
}
|
|
||||||
renderLearning();
|
renderLearning();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -308,6 +326,9 @@
|
|||||||
}
|
}
|
||||||
const route = routeInfo();
|
const route = routeInfo();
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
if (route.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked && config.autoAnswer) {
|
||||||
|
config.autoAnswer = false; runKey = ''; runner.stop(attemptPolicy().reason); panel.setNotice(attemptPolicy().reason, 'warning');
|
||||||
|
}
|
||||||
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
|
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||||||
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
|
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||||||
}
|
}
|
||||||
@@ -386,7 +407,9 @@
|
|||||||
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
|
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
|
||||||
snapshot = values[0]; config.role = '';
|
snapshot = values[0]; config.role = '';
|
||||||
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
|
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
|
||||||
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings);
|
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings, { restore: true });
|
||||||
|
const restoredPolicies = learning.getState();
|
||||||
|
for (const key of policyKeys) learningSettings[key] = restoredPolicies[key];
|
||||||
config.allowCopy = values[2] !== false;
|
config.allowCopy = values[2] !== false;
|
||||||
const position = values[3];
|
const position = values[3];
|
||||||
// A drag during asynchronous storage loading wins over the older saved position.
|
// A drag during asynchronous storage loading wins over the older saved position.
|
||||||
|
|||||||
+23
-5
@@ -64,11 +64,16 @@
|
|||||||
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
|
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
|
||||||
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
|
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
|
||||||
}
|
}
|
||||||
|
function attemptPolicy(view) {
|
||||||
|
if (!view?.container) return null;
|
||||||
|
const scope = view.container.closest(OWNER) || view.container;
|
||||||
|
return root.YianAttemptPolicy?.read?.(scope) || null;
|
||||||
|
}
|
||||||
async function run(options) {
|
async function run(options) {
|
||||||
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
|
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
|
||||||
const context = options.context || {};
|
const context = options.context || {};
|
||||||
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
|
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
|
||||||
let cancelled = false, verifiedCount = 0;
|
let cancelled = false, verifiedCount = 0, attemptBlocked = null;
|
||||||
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
|
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
|
||||||
const configured = Number(context.answerInterval);
|
const configured = Number(context.answerInterval);
|
||||||
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
|
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
|
||||||
@@ -77,6 +82,17 @@
|
|||||||
if (cancelled || options.signal?.aborted) return false;
|
if (cancelled || options.signal?.aborted) return false;
|
||||||
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
|
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
|
||||||
};
|
};
|
||||||
|
const attemptAllowed = () => {
|
||||||
|
if (context.skipSingleAttemptQuiz === false) return true;
|
||||||
|
if (!root.YianAttemptPolicy || typeof root.YianAttemptPolicy.read !== 'function') {
|
||||||
|
attemptBlocked = '答题次数核验组件未就绪,已停止自动操作,请人工处理';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const policy = attemptPolicy(detect(doc));
|
||||||
|
if (!policy?.blocked) return true;
|
||||||
|
attemptBlocked = policy.reason || '网页提示课中题答题机会受限,已停止自动操作,请人工处理';
|
||||||
|
return false;
|
||||||
|
};
|
||||||
const manual = event => {
|
const manual = event => {
|
||||||
if (!event.isTrusted) return;
|
if (!event.isTrusted) return;
|
||||||
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
|
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
|
||||||
@@ -101,16 +117,18 @@
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const click = node => { if (!active() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
|
const click = node => { if (!active() || !attemptAllowed() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
|
||||||
try {
|
try {
|
||||||
let view = detect(doc);
|
let view = detect(doc);
|
||||||
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
|
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
|
||||||
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
|
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
|
||||||
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
||||||
|
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
|
||||||
const original = read(view, context);
|
const original = read(view, context);
|
||||||
if (original.error) return result('waiting', original.error);
|
if (original.error) return result('waiting', original.error);
|
||||||
const matched = await matcher.match(original.question, context, options.snapshot);
|
const matched = await matcher.match(original.question, context, options.snapshot);
|
||||||
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
||||||
|
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
|
||||||
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
|
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
|
||||||
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
|
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
|
||||||
const current = () => {
|
const current = () => {
|
||||||
@@ -128,7 +146,7 @@
|
|||||||
}
|
}
|
||||||
for (const step of plan) {
|
for (const step of plan) {
|
||||||
const latest = current();
|
const latest = current();
|
||||||
if (!latest || !click(latest.nodes[step.i])) return result('waiting', '题目或任务状态已变化,停止课中答题');
|
if (!latest || !click(latest.nodes[step.i])) return result('waiting', attemptBlocked || '题目或任务状态已变化,停止课中答题', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||||||
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
|
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
|
||||||
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
|
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
|
||||||
if (!await pause(150)) return result('waiting', '课中答题已取消');
|
if (!await pause(150)) return result('waiting', '课中答题已取消');
|
||||||
@@ -136,7 +154,7 @@
|
|||||||
const evidence = current();
|
const evidence = current();
|
||||||
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
|
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
|
||||||
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
|
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
|
||||||
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', '课中题提交入口无法唯一确认');
|
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', attemptBlocked || '课中题提交入口无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||||||
// The real template replaces the question with an answer overlay immediately;
|
// The real template replaces the question with an answer overlay immediately;
|
||||||
// absence of the question alone does not imply that the grade request succeeded.
|
// absence of the question alone does not imply that the grade request succeeded.
|
||||||
const graded = await waitFor(() => {
|
const graded = await waitFor(() => {
|
||||||
@@ -155,7 +173,7 @@
|
|||||||
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
|
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
|
||||||
const advances = buttons(graded.container, /^(下一题|关闭)$/);
|
const advances = buttons(graded.container, /^(下一题|关闭)$/);
|
||||||
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
|
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
|
||||||
if (advances.length !== 1 || !click(advances[0])) return result('waiting', '课中题已判定正确,但下一题或关闭按钮无法唯一确认');
|
if (advances.length !== 1 || !click(advances[0])) return result('waiting', attemptBlocked || '课中题已判定正确,但下一题或关闭按钮无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||||||
const next = await waitFor(() => {
|
const next = await waitFor(() => {
|
||||||
const seen = detect(doc);
|
const seen = detect(doc);
|
||||||
// CDK detach/attach can briefly render neither overlay between questions.
|
// CDK detach/attach can briefly render neither overlay between questions.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* The common attempt policy reads visible scoped DOM, never Angular/network state. */
|
||||||
|
'use strict';
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||||
|
const requests = [], errors = [];
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.addScriptTag({ content: fs.readFileSync(path.join(__dirname, '..', 'attempt-policy.js'), 'utf8') });
|
||||||
|
async function read(html, tag = 'app-examdetail') {
|
||||||
|
return page.evaluate(({ html, tag }) => {
|
||||||
|
document.body.innerHTML = '<style>app-examdetail,app-examtest,.question-modal-pc{display:block;padding:12px}.hidden{display:none}</style><' + tag + ' id="scope">' + html + '</' + tag + '>';
|
||||||
|
return YianAttemptPolicy.read(document.querySelector('#scope'));
|
||||||
|
}, { html, tag });
|
||||||
|
}
|
||||||
|
const positive = [
|
||||||
|
'允许答题次数:1次', '总考试次数:1', '最多作答次数为一次', '剩余考试次数:1 次',
|
||||||
|
'可用答题机会:1次', '考试总次数:1次', '答题次数上限:1次', '允许答题次数:仅1次', '考试机会:仅剩1次',
|
||||||
|
'本次仅可答题1次', '仅允许考试一次', '仅有1次答题机会', '本场只剩一次考试机会',
|
||||||
|
'允许作答1次', '总共可考试1次', '还可补考 1 次', '还可补考一次', '剩余答题次数:1次',
|
||||||
|
'剩余 1 次答题机会', '还有一次考试机会', '还可重考1次',
|
||||||
|
'不及格,还可补考 1 次', '(不及格,还可补考 1 次)',
|
||||||
|
'<button>重新答题(不及格,还可补考 1 次)</button>', '<button>重新考试(不及格,还可补考1次)</button>',
|
||||||
|
'<button>重新答题<span>(不及格,还可补考 <strong>1</strong> 次)</span></button>',
|
||||||
|
'<div><span>允许答题次数:</span><strong>1</strong><span>次</span></div>',
|
||||||
|
'<dl><dt>剩余考试次数</dt><dd>1 次</dd></dl>'
|
||||||
|
];
|
||||||
|
for (const html of positive) {
|
||||||
|
const result = await read(html);
|
||||||
|
assert.equal(result.blocked, true, html);
|
||||||
|
assert.equal(result.known, true, html);
|
||||||
|
assert.equal(result.count, 1, html);
|
||||||
|
assert(result.reason.includes('1 次'), html);
|
||||||
|
}
|
||||||
|
console.log('PASS explicit allowed/total/remaining counts, split inline labels, definition rows and real retake wording');
|
||||||
|
for (const html of ['剩余考试次数:0次', '还可补考零次', '允许答题次数:0', '重新答题(不及格,还可补考0次)']) {
|
||||||
|
const result = await read(html); assert(result.blocked, html); assert.equal(result.count, 0, html);
|
||||||
|
}
|
||||||
|
for (const html of ['总考试次数:2次', '剩余答题次数:12次', '还可补考十一次', '允许答题次数:无限次', '重新考试(不及格,还可补考3次)']) {
|
||||||
|
const result = await read(html); assert.equal(result.blocked, false, html); assert.equal(result.count, null, html);
|
||||||
|
}
|
||||||
|
assert.equal((await read('<p>总考试次数:3次</p><p>剩余考试次数:1次</p>')).count, 1);
|
||||||
|
assert.equal((await read('<p>总考试次数:1次</p><p>还可补考0次</p>')).count, 0);
|
||||||
|
console.log('PASS zero/single attempts stop, larger counts are not blocked, remaining zero wins over total one');
|
||||||
|
const ignored = [
|
||||||
|
'已答次数:1次', '已用答题次数:1次', '已考试次数:1次', '已答题数1/40', '已答 1/40', '157人已交卷',
|
||||||
|
'已答题次数:1次,答对 1 题', '答题次数:1', '考试次数:1次', '考试题数:1',
|
||||||
|
'剩余考试次数:1/3', '允许答题次数:1.5次', '允许答题次数:-1次', '还可补考1次是正确答案',
|
||||||
|
'<div class="examtest_content_question">仅有1次答题机会</div>',
|
||||||
|
'<div class="examtest_content_question">(不及格,还可补考1次)是这道题的答案</div>',
|
||||||
|
'<div class="model_answer">重新答题(不及格,还可补考1次)</div>',
|
||||||
|
'<div class="pc_examtest_content_question">允许答题次数1次</div>',
|
||||||
|
'<div class="examtest_content_list_des">还可补考1次</div>',
|
||||||
|
'<div class="model_answer">剩余考试次数1次</div>',
|
||||||
|
'<div data-question-stem>总考试次数1次</div>', '<div class="answer-text">允许答题次数1次</div>',
|
||||||
|
'<div hidden>剩余考试次数1次</div>', '<div aria-hidden="true">还可补考1次</div>',
|
||||||
|
'<div class="hidden">仅有1次答题机会</div>', '<div style="visibility:hidden">总考试次数1次</div>',
|
||||||
|
'<div style="opacity:0"><span>剩余考试次数1次</span></div>', '<div inert>还可补考1次</div>',
|
||||||
|
'<template>允许答题次数1次</template>', '<script type="application/json">{"totalExamCount":1}</script>',
|
||||||
|
'<div data-max-attempts="1" data-remaining-attempts="1">考试已就绪</div>', '<p>这里只剩1题要答</p>'
|
||||||
|
];
|
||||||
|
for (const html of ignored) {
|
||||||
|
const result = await read(html);
|
||||||
|
assert.equal(result.blocked, false, html); assert.equal(result.count, null, html); assert.equal(result.signature, '[]', html);
|
||||||
|
assert.equal(result.known, false, html);
|
||||||
|
}
|
||||||
|
console.log('PASS used counts, question totals, people, decimals, hidden/answer/question text and private/data attributes do not infer limits');
|
||||||
|
for (const tag of ['app-examtest', 'div']) assert.equal((await read('剩余考试次数:1次', tag)).count, 1);
|
||||||
|
const before = await read('<p>总考试次数:3次</p>');
|
||||||
|
const after = await read('<p>总考试次数:2次</p>');
|
||||||
|
assert(before.known && after.known, 'larger explicit count is known even though it does not block');
|
||||||
|
assert.notEqual(before.signature, after.signature, 'larger explicit count changes invalidate earlier evidence');
|
||||||
|
const stable = await read('<p>剩余考试次数:1次</p><p hidden>还可补考0次</p>');
|
||||||
|
assert.equal(stable.signature, (await read('<p>剩余考试次数:1次</p>')).signature);
|
||||||
|
await page.evaluate(() => { document.querySelector('#scope').insertAdjacentHTML('afterend', '<app-examtest id="other">还可补考0次</app-examtest>'); });
|
||||||
|
assert.equal(await page.evaluate(() => YianAttemptPolicy.read(document.querySelector('#scope')).count), 1, 'another exam outside scope is ignored');
|
||||||
|
for (const invalid of ['document', 'body', 'null', 'detached']) {
|
||||||
|
const result = await page.evaluate(invalid => YianAttemptPolicy.read(invalid === 'document' ? document : invalid === 'body' ? document.body : invalid === 'detached' ? document.createElement('app-examtest') : null), invalid);
|
||||||
|
assert.equal(result.blocked, false); assert.equal(result.signature, '[]');
|
||||||
|
}
|
||||||
|
assert.deepEqual(requests, []); assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS scoped containers only, stable signatures, explicit changes, no network or browser errors');
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -25,8 +25,8 @@ function paperHTML(total = 25) {
|
|||||||
page.on('pageerror', error => errors.push(error.message));
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
for (const name of modules) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
|
for (const name of modules) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
|
||||||
|
|
||||||
async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, unansweredWarning = false, manualConfirmIncomplete, total = 25 } = {}) {
|
async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, unansweredWarning = false, manualConfirmIncomplete, skipSingleAttemptQuiz, attemptPolicy = null, total = 25 } = {}) {
|
||||||
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete }) => {
|
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete, skipSingleAttemptQuiz, attemptPolicy }) => {
|
||||||
window.qaExam?.destroy(); window.qaRunner?.destroy();
|
window.qaExam?.destroy(); window.qaRunner?.destroy();
|
||||||
document.documentElement.innerHTML = html.replace(/^<!doctype html>/, '').replace(/^<html>/, '').replace(/<\/html>$/, '');
|
document.documentElement.innerHTML = html.replace(/^<!doctype html>/, '').replace(/^<html>/, '').replace(/<\/html>$/, '');
|
||||||
window.qaClock = { now: 0 };
|
window.qaClock = { now: 0 };
|
||||||
@@ -48,6 +48,8 @@ function paperHTML(total = 25) {
|
|||||||
window.qaRunnerState = 'complete';
|
window.qaRunnerState = 'complete';
|
||||||
window.qaSettings = { minSubmitMinutes: 6 };
|
window.qaSettings = { minSubmitMinutes: 6 };
|
||||||
if (manualConfirmIncomplete !== undefined) qaSettings.manualConfirmIncomplete = manualConfirmIncomplete;
|
if (manualConfirmIncomplete !== undefined) qaSettings.manualConfirmIncomplete = manualConfirmIncomplete;
|
||||||
|
if (skipSingleAttemptQuiz !== undefined) qaSettings.skipSingleAttemptQuiz = skipSingleAttemptQuiz;
|
||||||
|
window.qaAttemptPolicy = attemptPolicy || { blocked: false, count: null, signature: 'unknown' };
|
||||||
if (unknownPoint) document.querySelector('.pc_examtest_content_question_value').textContent = '分值待定';
|
if (unknownPoint) document.querySelector('.pc_examtest_content_question_value').textContent = '分值待定';
|
||||||
const boxes = Array.from(document.querySelectorAll('.pc_examtest_content'));
|
const boxes = Array.from(document.querySelectorAll('.pc_examtest_content'));
|
||||||
boxes.forEach((box, index) => box.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
|
boxes.forEach((box, index) => box.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
|
||||||
@@ -75,17 +77,59 @@ function paperHTML(total = 25) {
|
|||||||
storage: { get: async (key, fallback) => { qaStorageReads++; if (qaReadGate) await qaReadGate; return qaStorage.has(key) ? structuredClone(qaStorage.get(key)) : fallback; },
|
storage: { get: async (key, fallback) => { qaStorageReads++; if (qaReadGate) await qaReadGate; return qaStorage.has(key) ? structuredClone(qaStorage.get(key)) : fallback; },
|
||||||
set: async (key, value) => { if (qaStorageHook) await qaStorageHook(key, value); qaStorage.set(key, structuredClone(value)); } },
|
set: async (key, value) => { if (qaStorageHook) await qaStorageHook(key, value); qaStorage.set(key, structuredClone(value)); } },
|
||||||
getContext: () => qaContext, getTopic: () => qaTopic, getSnapshot: () => qaSnapshot,
|
getContext: () => qaContext, getTopic: () => qaTopic, getSnapshot: () => qaSnapshot,
|
||||||
getSettings: () => qaSettings, getRunnerState: () => qaRunnerState,
|
getSettings: () => qaSettings, getRunnerState: () => qaRunnerState, getAttemptPolicy: () => qaAttemptPolicy,
|
||||||
onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message })
|
onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message })
|
||||||
});
|
});
|
||||||
window.qaExam = qaMakeController();
|
window.qaExam = qaMakeController();
|
||||||
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete });
|
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete, skipSingleAttemptQuiz, attemptPolicy });
|
||||||
return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; });
|
return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; });
|
||||||
}
|
}
|
||||||
async function tickAt(seconds) {
|
async function tickAt(seconds) {
|
||||||
return page.evaluate(async value => { qaSetClock(value * 1000); await qaExam.tick(); return { state: qaExam.getState(), model: qaModel }; }, seconds);
|
return page.evaluate(async value => { qaSetClock(value * 1000); await qaExam.tick(); return { state: qaExam.getState(), model: qaModel }; }, seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const singleAttempt = { blocked: true, count: 1, reason: '答题机会仅 1 次,等待人工操作', signature: 'total-attempts:1' };
|
||||||
|
let guarded = await setup({ attemptPolicy: singleAttempt });
|
||||||
|
assert.equal(guarded.enabled, false, '单次机会守卫默认开启,在启动前拒绝自动计划');
|
||||||
|
assert.equal(guarded.singleAttemptBlocked, true);
|
||||||
|
assert.equal(guarded.attemptCount, 1);
|
||||||
|
assert.match(guarded.message, /1 次.*人工/);
|
||||||
|
let guardedResult = await tickAt(600);
|
||||||
|
assert.equal(guardedResult.model.commitClicks, 0, '全部题已核验且满 6 分钟也不能自动消耗唯一机会');
|
||||||
|
assert.equal(guardedResult.model.confirmClicks, 0);
|
||||||
|
assert.equal(guardedResult.state.requiresManualSubmission, true);
|
||||||
|
await page.evaluate(() => { qaSettings.skipSingleAttemptQuiz = false; });
|
||||||
|
assert.equal((await tickAt(601)).model.commitClicks, 0, '仅关闭开关不会重启此前已停止的计划');
|
||||||
|
assert.equal(await page.evaluate(() => qaExam.enable()), true, '关闭开关并明确启动可恢复原交卷条件');
|
||||||
|
assert.equal((await tickAt(602)).model.commitClicks, 1);
|
||||||
|
guarded = await setup({ skipSingleAttemptQuiz: false, attemptPolicy: singleAttempt, confirm: true, showResult: true });
|
||||||
|
assert.equal(guarded.enabled, true);
|
||||||
|
assert.equal(guarded.singleAttemptBlocked, false);
|
||||||
|
assert.equal((await tickAt(360)).model.commitClicks, 1);
|
||||||
|
assert.equal((await tickAt(361)).model.confirmClicks, 1);
|
||||||
|
assert.equal((await tickAt(362)).state.submitted, true, '关闭守卫后仍需网站结果确认成功');
|
||||||
|
guarded = await setup({ attemptPolicy: { blocked: false, count: null, signature: 'unknown' } });
|
||||||
|
assert.equal(guarded.enabled, true, '次数证据未知时不猜测为单次机会');
|
||||||
|
assert.equal((await tickAt(360)).model.commitClicks, 1);
|
||||||
|
await setup({ attemptPolicy: { blocked: false, count: 2, signature: 'total-attempts:2' } });
|
||||||
|
await page.evaluate(policy => { qaAttemptPolicy = policy; }, singleAttempt);
|
||||||
|
guardedResult = await tickAt(360);
|
||||||
|
assert.equal(guardedResult.model.commitClicks, 0, '运行中发现仅一次机会立即阻止旧自动交卷');
|
||||||
|
assert.equal(guardedResult.state.singleAttemptBlocked, true);
|
||||||
|
assert.equal(guardedResult.state.enabled, false);
|
||||||
|
await setup({ confirm: true, showResult: true });
|
||||||
|
await tickAt(360);
|
||||||
|
await page.evaluate(policy => { qaAttemptPolicy = policy; }, singleAttempt);
|
||||||
|
guardedResult = await tickAt(361);
|
||||||
|
assert.equal(guardedResult.model.confirmClicks, 0, '已弹交卷确认后出现单次机会也不能自动确认');
|
||||||
|
assert.equal(guardedResult.state.stage, 'unknown', '已发起交卷后停止自动确认保留结果待确认状态');
|
||||||
|
await page.locator('.qa-confirm').click();
|
||||||
|
guardedResult = await tickAt(362);
|
||||||
|
assert.equal(guardedResult.model.confirmClicks, 1, '用户仍可在原网页人工确认');
|
||||||
|
assert.equal(guardedResult.state.submitted, true, '守卫生效期间仍只读识别人工交卷的实际网站结果');
|
||||||
|
assert.equal(guardedResult.state.requiresManualSubmission, false);
|
||||||
|
console.log('PASS 单次答题机会守卫默认开启、关闭后明确恢复、未知次数不猜、运行变化及人工交卷结果观察');
|
||||||
|
|
||||||
let initial = await setup({ matched: 20 });
|
let initial = await setup({ matched: 20 });
|
||||||
assert.equal(initial.total, 25);
|
assert.equal(initial.total, 25);
|
||||||
assert.equal(initial.totalScore, 100, '按实际 DOM 分值计算 25×4=100');
|
assert.equal(initial.totalScore, 100, '按实际 DOM 分值计算 25×4=100');
|
||||||
@@ -339,7 +383,7 @@ function paperHTML(total = 25) {
|
|||||||
assert.equal(inferred.waitSeconds, 120);
|
assert.equal(inferred.waitSeconds, 120);
|
||||||
console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推');
|
console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推');
|
||||||
|
|
||||||
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause', 'manualConfirmIncomplete']) {
|
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause', 'manualConfirmIncomplete', 'singleAttempt', 'attemptEvidence', 'skipSingleAttemptQuiz']) {
|
||||||
await setup();
|
await setup();
|
||||||
const blocked = await page.evaluate(async mutation => {
|
const blocked = await page.evaluate(async mutation => {
|
||||||
qaSetClock(360000);
|
qaSetClock(360000);
|
||||||
@@ -356,13 +400,16 @@ function paperHTML(total = 25) {
|
|||||||
if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] };
|
if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] };
|
||||||
if (mutation === 'pause') qaExam.disable('用户已暂停');
|
if (mutation === 'pause') qaExam.disable('用户已暂停');
|
||||||
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false;
|
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false;
|
||||||
|
if (mutation === 'singleAttempt') qaAttemptPolicy = { blocked: true, count: 1, reason: '仅一次机会,等待人工', signature: 'total-attempts:1' };
|
||||||
|
if (mutation === 'attemptEvidence') qaAttemptPolicy = { blocked: false, count: 2, signature: 'total-attempts:2' };
|
||||||
|
if (mutation === 'skipSingleAttemptQuiz') qaSettings.skipSingleAttemptQuiz = false;
|
||||||
};
|
};
|
||||||
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
|
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
|
||||||
}, mutation);
|
}, mutation);
|
||||||
assert.equal(blocked.model.commitClicks, 0, '异步保存期间发生 ' + mutation + ' 时禁止旧提交点击');
|
assert.equal(blocked.model.commitClicks, 0, '异步保存期间发生 ' + mutation + ' 时禁止旧提交点击');
|
||||||
assert.equal(blocked.state.enabled, false);
|
assert.equal(blocked.state.enabled, false);
|
||||||
}
|
}
|
||||||
for (const mutation of ['revision', 'duplicateButton', 'pause', 'manualConfirmIncomplete']) {
|
for (const mutation of ['revision', 'duplicateButton', 'pause', 'manualConfirmIncomplete', 'singleAttempt', 'attemptEvidence', 'skipSingleAttemptQuiz']) {
|
||||||
await setup({ confirm: true });
|
await setup({ confirm: true });
|
||||||
await tickAt(360);
|
await tickAt(360);
|
||||||
const blocked = await page.evaluate(async mutation => {
|
const blocked = await page.evaluate(async mutation => {
|
||||||
@@ -372,6 +419,9 @@ function paperHTML(total = 25) {
|
|||||||
if (mutation === 'duplicateButton') { const button = document.createElement('button'); button.textContent = '确认交卷'; document.querySelector('.dialog_box').append(button); }
|
if (mutation === 'duplicateButton') { const button = document.createElement('button'); button.textContent = '确认交卷'; document.querySelector('.dialog_box').append(button); }
|
||||||
if (mutation === 'pause') qaExam.disable('用户已暂停');
|
if (mutation === 'pause') qaExam.disable('用户已暂停');
|
||||||
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false;
|
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false;
|
||||||
|
if (mutation === 'singleAttempt') qaAttemptPolicy = { blocked: true, count: 1, reason: '仅一次机会,等待人工', signature: 'total-attempts:1' };
|
||||||
|
if (mutation === 'attemptEvidence') qaAttemptPolicy = { blocked: false, count: 2, signature: 'total-attempts:2' };
|
||||||
|
if (mutation === 'skipSingleAttemptQuiz') qaSettings.skipSingleAttemptQuiz = false;
|
||||||
};
|
};
|
||||||
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
|
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
|
||||||
}, mutation);
|
}, mutation);
|
||||||
@@ -417,6 +467,19 @@ function paperHTML(total = 25) {
|
|||||||
});
|
});
|
||||||
assert.equal(interruptedWrite.returned, false);
|
assert.equal(interruptedWrite.returned, false);
|
||||||
assert.equal(interruptedWrite.state.enabled, false);
|
assert.equal(interruptedWrite.state.enabled, false);
|
||||||
|
await setup();
|
||||||
|
const changedWhileEnabling = await page.evaluate(async () => {
|
||||||
|
qaStorageHook = async (_key, value) => {
|
||||||
|
if (value.stage === 'answering') qaAttemptPolicy = { blocked: true, count: 1, reason: '仅一次机会,等待人工', signature: 'total-attempts:1' };
|
||||||
|
};
|
||||||
|
const returned = await qaExam.enable(); await qaExam.tick();
|
||||||
|
return { returned, state: qaExam.getState(), model: qaModel };
|
||||||
|
});
|
||||||
|
assert.equal(changedWhileEnabling.returned, false, '异步启动保存期间出现单次证据不能返回启动成功');
|
||||||
|
assert.equal(changedWhileEnabling.state.enabled, false);
|
||||||
|
assert.equal(changedWhileEnabling.state.singleAttemptBlocked, true);
|
||||||
|
assert.equal(changedWhileEnabling.model.commitClicks, 0);
|
||||||
|
assert.equal(changedWhileEnabling.model.confirmClicks, 0);
|
||||||
console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活');
|
console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活');
|
||||||
|
|
||||||
await setup();
|
await setup();
|
||||||
|
|||||||
+286
-4
@@ -6,6 +6,7 @@ const { chromium } = require('playwright');
|
|||||||
const ZIP = require('../vendor/jszip.min.js');
|
const ZIP = require('../vendor/jszip.min.js');
|
||||||
const workspace = path.resolve(__dirname, '..');
|
const workspace = path.resolve(__dirname, '..');
|
||||||
const source = fs.readFileSync(path.join(workspace, 'learning-engine.js'), 'utf8');
|
const source = fs.readFileSync(path.join(workspace, 'learning-engine.js'), 'utf8');
|
||||||
|
const attemptSource = fs.readFileSync(path.join(workspace, 'attempt-policy.js'), 'utf8');
|
||||||
const core = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
|
const core = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
|
||||||
const origin = 'http://10.119.67.7/clientcn/';
|
const origin = 'http://10.119.67.7/clientcn/';
|
||||||
const videoHTML = '<app-course><div class="course-courseware-other-top-left">测试课程</div><li class="cn_courseware_img active" data-courseware-id="v1" data-required-seconds="100"><p>视频一</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><img class="en_courseware_finished en_courseware_noFinished"></div></li><app-course-courseware-video><video id="player1"></video></app-course-courseware-video></app-course>';
|
const videoHTML = '<app-course><div class="course-courseware-other-top-left">测试课程</div><li class="cn_courseware_img active" data-courseware-id="v1" data-required-seconds="100"><p>视频一</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><img class="en_courseware_finished en_courseware_noFinished"></div></li><app-course-courseware-video><video id="player1"></video></app-course-courseware-video></app-course>';
|
||||||
@@ -20,6 +21,7 @@ async function main() {
|
|||||||
await page.goto(origin + 'course/test');
|
await page.goto(origin + 'course/test');
|
||||||
await page.addScriptTag({ content: core });
|
await page.addScriptTag({ content: core });
|
||||||
await page.addScriptTag({ content: source });
|
await page.addScriptTag({ content: source });
|
||||||
|
await page.addScriptTag({ content: attemptSource });
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
try {
|
try {
|
||||||
async function setup(html = videoHTML, config = {}, settings = {}) {
|
async function setup(html = videoHTML, config = {}, settings = {}) {
|
||||||
@@ -27,7 +29,9 @@ async function main() {
|
|||||||
window.qaEngine?.destroy();
|
window.qaEngine?.destroy();
|
||||||
history.replaceState(null, '', '/clientcn/course/test');
|
history.replaceState(null, '', '/clientcn/course/test');
|
||||||
document.body.innerHTML = html;
|
document.body.innerHTML = html;
|
||||||
window.qaClock = 100000; window.qaMemory = new Map(); window.qaReloads = 0; window.qaNavigations = []; window.qaWorkerURLs = []; window.qaPlayCalls = 0; window.qaPauseCalls = 0; window.qaExamCalls = 0; window.qaQuizCalls = 0;
|
window.qaClock = 100000; window.qaMemory = new Map(); window.qaReloads = 0; window.qaNavigations = []; window.qaWorkerURLs = []; window.qaPlayCalls = 0; window.qaPauseCalls = 0; window.qaExamCalls = 0; window.qaQuizCalls = 0; window.qaEnterCount = 0;
|
||||||
|
window.qaVisibility = 'visible';
|
||||||
|
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => qaVisibility });
|
||||||
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
||||||
window.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 };
|
window.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 };
|
||||||
window.qaSnapshot = { document, url: location.href, accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan', courses: ['测试课程'] };
|
window.qaSnapshot = { document, url: location.href, accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan', courses: ['测试课程'] };
|
||||||
@@ -160,7 +164,100 @@ async function main() {
|
|||||||
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
|
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
|
||||||
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
|
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
|
||||||
|
|
||||||
|
// 原站 hidden 分支主动暂停;后台时不进行卡顿刷新,回前台立即续播原位置。
|
||||||
|
await setup(); await page.evaluate(() => qaEngine.start());
|
||||||
|
const playsBeforeHide = await page.evaluate(() => {
|
||||||
|
qaVideo.currentTime = 37.5; qaVisibility = 'hidden'; document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
document.querySelector('video').pause(); document.querySelector('video').dispatchEvent(new Event('pause'));
|
||||||
|
return qaPlayCalls;
|
||||||
|
});
|
||||||
|
state = await tick(180000); assert.equal(state.video.status, 'backgroundPaused');
|
||||||
|
assert.equal(await page.evaluate(() => qaPlayCalls), playsBeforeHide); assert.equal(await page.evaluate(() => qaReloads), 0);
|
||||||
|
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
|
||||||
|
await page.waitForFunction(() => !qaVideo.paused);
|
||||||
|
assert.equal(await page.evaluate(() => qaPlayCalls), playsBeforeHide + 1);
|
||||||
|
assert.equal(await page.evaluate(() => qaVideo.currentTime), 37.5); assert.equal((await read()).completed, 0); checks++;
|
||||||
|
|
||||||
|
// 开关关闭、全局暂停、播放器手动暂停均不能被 focus/visible 事件重新播放。
|
||||||
|
await setup(videoHTML, { resumePlaybackOnReturn: false }); await page.evaluate(() => qaEngine.start());
|
||||||
|
await page.evaluate(() => { qaVisibility = 'hidden'; document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause(); });
|
||||||
|
await tick(180000);
|
||||||
|
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
|
||||||
|
await page.waitForTimeout(50); state = await tick();
|
||||||
|
assert.equal(await page.evaluate(() => qaPlayCalls), 1); assert.equal(state.tasks[0].status, 'waiting');
|
||||||
|
assert.match(state.tasks[0].reason, /自动恢复已关闭/); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
|
||||||
|
|
||||||
|
for (const kind of ['global', 'local', 'manual', 'quiz']) {
|
||||||
|
await setup(); await page.evaluate(() => qaEngine.start());
|
||||||
|
if (kind === 'global') await page.evaluate(() => qaEngine.pause());
|
||||||
|
if (kind === 'local') await page.evaluate(() => qaEngine.configure({ videoPaused: true }));
|
||||||
|
if (kind === 'manual') {
|
||||||
|
await page.evaluate(() => { qaClock += 1000; const v = document.querySelector('video'); v.style.cssText = 'width:200px;height:100px'; v.onclick = () => { v.pause(); v.dispatchEvent(new Event('pause')); }; });
|
||||||
|
await page.locator('video').click(); await tick();
|
||||||
|
}
|
||||||
|
await page.evaluate(kind => {
|
||||||
|
qaVisibility = 'hidden'; document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause();
|
||||||
|
if (kind === 'quiz') document.querySelector('app-course').insertAdjacentHTML('beforeend', '<div data-learning-quiz><p data-question>等待人工处理的题</p></div>');
|
||||||
|
}, kind);
|
||||||
|
const before = await page.evaluate(() => qaPlayCalls);
|
||||||
|
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
|
||||||
|
await page.waitForTimeout(50); await tick(180000);
|
||||||
|
assert.equal(await page.evaluate(() => qaPlayCalls), before, kind + ' 优先于自动恢复');
|
||||||
|
assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackPrompt = '<div class="modal-dialog dialog_box"><div class="modal-content"><div class="modal-body modal-dialog-scrollable"><div>是否继续上次播放的位置?</div></div><div class="modal-footer"><button class="btn" type="button" aria-label="Close" onclick="qaRestartClicks++"><span aria-hidden="true">重新开始</span></button><button class="btn" type="button" aria-label="Close" onclick="qaContinueClicks++;qaVideo.currentTime=37.5;this.closest(\'.modal-dialog\').remove()"><span aria-hidden="true">继续播放</span></button></div></div></div>';
|
||||||
|
for (const enabled of [true, false]) {
|
||||||
|
await setup(videoHTML + playbackPrompt, { autoContinuePlayback: enabled });
|
||||||
|
await page.evaluate(() => { window.qaContinueClicks = 0; window.qaRestartClicks = 0; });
|
||||||
|
state = await page.evaluate(() => qaEngine.start());
|
||||||
|
await tick(); await tick(180000);
|
||||||
|
assert.equal(await page.evaluate(() => qaContinueClicks), enabled ? 1 : 0);
|
||||||
|
assert.equal(await page.evaluate(() => qaRestartClicks), 0);
|
||||||
|
if (enabled) assert.equal(await page.evaluate(() => qaVideo.currentTime), 37.5);
|
||||||
|
else { assert.equal(state.tasks[0].status, 'waiting'); assert.equal(await page.evaluate(() => qaPlayCalls), 0); }
|
||||||
|
assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 唯一弹窗/按钮及全局运行状态是续播前提,不能接管普通“继续播放”按钮或考试弹窗。
|
||||||
|
for (const variant of ['duplicate', 'disabled', 'otherPrompt', 'paused']) {
|
||||||
|
const modal = variant === 'duplicate' ? playbackPrompt + playbackPrompt : variant === 'disabled' ? playbackPrompt.replace('onclick="qaContinueClicks++', 'disabled onclick="qaContinueClicks++') : variant === 'otherPrompt' ? playbackPrompt.replace('是否继续上次播放的位置?', '是否继续考试?') : playbackPrompt;
|
||||||
|
await setup(videoHTML + modal);
|
||||||
|
await page.evaluate(() => { window.qaContinueClicks = 0; window.qaRestartClicks = 0; });
|
||||||
|
if (variant === 'paused') { await page.evaluate(async () => { await qaEngine.start(); await qaEngine.pause(); document.body.insertAdjacentHTML('beforeend', '<button onclick="qaContinueClicks++">继续播放</button>'); }); }
|
||||||
|
else await page.evaluate(() => qaEngine.start());
|
||||||
|
const before = await page.evaluate(() => qaContinueClicks);
|
||||||
|
await tick(180000); assert.equal(await page.evaluate(() => qaContinueClicks), before);
|
||||||
|
if (variant !== 'paused') assert.equal(before, 0);
|
||||||
|
assert.equal(await page.evaluate(() => qaRestartClicks), 0); checks++;
|
||||||
|
}
|
||||||
|
|
||||||
const examDetail = '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div><div class="examdetail_button" onclick="window.qaEnterCount=(window.qaEnterCount||0)+1">开始答题</div></app-examdetail>';
|
const examDetail = '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div><div class="examdetail_button" onclick="window.qaEnterCount=(window.qaEnterCount||0)+1">开始答题</div></app-examdetail>';
|
||||||
|
for (const enabled of [true, false]) {
|
||||||
|
await setup(examDetail.replace('</app-examdetail>', '<p>允许考试次数:1次</p></app-examdetail>'), { mode: 'exam', skipSingleAttemptQuiz: enabled });
|
||||||
|
state = await page.evaluate(() => qaEngine.start());
|
||||||
|
assert.equal(await page.evaluate(() => window.qaEnterCount || 0), enabled ? 0 : 1);
|
||||||
|
if (enabled) { assert.equal(state.tasks[0].status, 'waiting'); assert.equal(state.exam.requiresManualSubmission, true); }
|
||||||
|
else {
|
||||||
|
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
|
||||||
|
await tick(); assert.equal(await page.evaluate(() => qaEngine.getAttemptPolicy().blocked), true, '试卷无次数标签时仍保留同一考试详情证据');
|
||||||
|
}
|
||||||
|
checks++;
|
||||||
|
}
|
||||||
|
await setup(examDetail.replace('</app-examdetail>', '<p>允许考试次数:1次</p></app-examdetail>'), { mode: 'exam', skipSingleAttemptQuiz: false });
|
||||||
|
await page.evaluate(() => { history.replaceState(null, '', '/clientcn/exam/limited'); });
|
||||||
|
await page.evaluate(() => qaEngine.start());
|
||||||
|
await page.evaluate(() => {
|
||||||
|
history.replaceState(null, '', '/clientcn/exam/unrelated/questions');
|
||||||
|
document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div><p>允许考试次数:3次</p></app-examtest>';
|
||||||
|
});
|
||||||
|
state = await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 0, '相同标题的其他考试不得接管');
|
||||||
|
assert.equal(state.tasks.find(t => t.url.includes('/exam/limited')).attemptPolicy.count, 1, '其他考试不得覆写旧考试次数');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
history.replaceState(null, '', '/clientcn/exam/limited/questions');
|
||||||
|
document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>';
|
||||||
|
});
|
||||||
|
assert.equal(await page.evaluate(() => qaEngine.getAttemptPolicy().count), 1, '同一考试ID的试卷继承详情次数,外部考试不污染'); checks++;
|
||||||
await setup(examDetail, { mode: 'exam' });
|
await setup(examDetail, { mode: 'exam' });
|
||||||
await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1);
|
await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1);
|
||||||
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
|
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
|
||||||
@@ -198,6 +295,17 @@ async function main() {
|
|||||||
checks++;
|
checks++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setup(videoHTML.replace('</li>', '<p class="related_test">测试课程</p></li>'), { mode: 'all' });
|
||||||
|
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
|
||||||
|
await page.evaluate(() => qaEngine.start());
|
||||||
|
state = await page.evaluate(async html => {
|
||||||
|
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
history.replaceState(null, '', '/clientcn/exam/linked-full-navigation'); document.body.innerHTML = html;
|
||||||
|
qaEngine = qaCreate(); return qaEngine.tick();
|
||||||
|
}, examDetail);
|
||||||
|
assert.equal(state.status, 'running', '关联考试完整导航沿用一次性转场授权');
|
||||||
|
assert.equal(state.tasks.filter(t => t.kind === 'exam').length, 1); assert.equal(await page.evaluate(() => qaEnterCount), 1); checks++;
|
||||||
|
|
||||||
await setup('<app-examdetail><div class="examdetail_header">没有题库的考试</div><div class="examdetail_button" onclick="window.qaWrongEnter=true">开始答题</div></app-examdetail>', { mode: 'exam' });
|
await setup('<app-examdetail><div class="examdetail_header">没有题库的考试</div><div class="examdetail_button" onclick="window.qaWrongEnter=true">开始答题</div></app-examdetail>', { mode: 'exam' });
|
||||||
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'skipped'); assert.equal(await page.evaluate(() => Boolean(window.qaWrongEnter)), false); checks++;
|
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'skipped'); assert.equal(await page.evaluate(() => Boolean(window.qaWrongEnter)), false); checks++;
|
||||||
|
|
||||||
@@ -231,10 +339,11 @@ async function main() {
|
|||||||
qaEngine = qaCreate(); await qaEngine.tick();
|
qaEngine = qaCreate(); await qaEngine.tick();
|
||||||
holdNext = true; const oldTick = qaEngine.tick(); await captured;
|
holdNext = true; const oldTick = qaEngine.tick(); await captured;
|
||||||
await qaEngine.configure({ manualConfirmIncomplete: false }); release(); await oldTick;
|
await qaEngine.configure({ manualConfirmIncomplete: false }); release(); await oldTick;
|
||||||
return { state: qaEngine.getState(), shared: await qaStorage.get('control.v1') };
|
const control = await qaStorage.get('control.v1');
|
||||||
|
return { state: qaEngine.getState(), policy: await qaStorage.get('policy:' + control.sessionId + ':manualConfirmIncomplete') };
|
||||||
});
|
});
|
||||||
assert.equal(stalePolicyRead.state.manualConfirmIncomplete, false, '迟到的旧共享读数不得覆盖刚发布的开关');
|
assert.equal(stalePolicyRead.state.manualConfirmIncomplete, false, '迟到的旧共享读数不得覆盖刚发布的开关');
|
||||||
assert.equal(stalePolicyRead.shared.config.manualConfirmIncomplete, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++;
|
assert.equal(stalePolicyRead.policy.value, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++;
|
||||||
|
|
||||||
// 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。
|
// 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。
|
||||||
const shared = new Map();
|
const shared = new Map();
|
||||||
@@ -274,7 +383,8 @@ async function main() {
|
|||||||
assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused', '策略同步不能自动恢复任务');
|
assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused', '策略同步不能自动恢复任务');
|
||||||
assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1, '修改共享策略期间不得重启答题');
|
assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1, '修改共享策略期间不得重启答题');
|
||||||
const synchronizedControl = shared.get('control.v1');
|
const synchronizedControl = shared.get('control.v1');
|
||||||
assert.equal(synchronizedControl.status, 'paused'); assert.equal(synchronizedControl.config.manualConfirmIncomplete, true);
|
assert.equal(synchronizedControl.status, 'paused');
|
||||||
|
assert.equal(shared.get('policy:' + synchronizedControl.sessionId + ':manualConfirmIncomplete').value, true);
|
||||||
assert.equal(shared.get('session.v1').config.manualConfirmIncomplete, true, '主页面暂停时同步保存工作页策略'); checks++;
|
assert.equal(shared.get('session.v1').config.manualConfirmIncomplete, true, '主页面暂停时同步保存工作页策略'); checks++;
|
||||||
await page.evaluate(() => qaEngine.resume()); await workerPage.evaluate(() => fixtureEngine.tick()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
|
await page.evaluate(() => qaEngine.resume()); await workerPage.evaluate(() => fixtureEngine.tick()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
|
||||||
assert.equal(workerState.status, 'running'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 2, '全局继续后重新核验考试');
|
assert.equal(workerState.status, 'running'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 2, '全局继续后重新核验考试');
|
||||||
@@ -341,6 +451,178 @@ async function main() {
|
|||||||
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
|
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
|
||||||
assert.equal(await page.evaluate(() => qaQuizCommitted), 0); assert.equal(await page.evaluate(async () => (await qaStorage.get('lease:video')).owner), 'another-worker'); checks++;
|
assert.equal(await page.evaluate(() => qaQuizCommitted), 0); assert.equal(await page.evaluate(async () => (await qaStorage.get('lease:video')).owner), 'another-worker'); checks++;
|
||||||
|
|
||||||
|
// HTTP 内网页无法依赖 Web Locks:不同标签同时改不同开关,逐键记录不能丢失任一修改。
|
||||||
|
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
|
||||||
|
const concurrentPolicies = await page.evaluate(async () => {
|
||||||
|
qaEngine.destroy();
|
||||||
|
const previous = qaStorage; let gated = false, capturedReads = 0, release, captured;
|
||||||
|
const gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const bothCaptured = new Promise(resolve => { captured = resolve; });
|
||||||
|
qaStorage = {
|
||||||
|
get: async key => {
|
||||||
|
const value = await previous.get(key);
|
||||||
|
if (gated && key === 'control.v1') { if (++capturedReads === 2) captured(); await gate; }
|
||||||
|
return value;
|
||||||
|
}, set: previous.set
|
||||||
|
};
|
||||||
|
qaEngine = qaCreate(); window.qaPolicyPeer = qaCreate();
|
||||||
|
await qaEngine.tick(); await qaPolicyPeer.tick();
|
||||||
|
gated = true;
|
||||||
|
const first = qaEngine.configure({ autoContinuePlayback: false });
|
||||||
|
const second = qaPolicyPeer.configure({ resumePlaybackOnReturn: false });
|
||||||
|
await bothCaptured; release(); await Promise.all([first, second]); gated = false;
|
||||||
|
await qaEngine.tick(); await qaPolicyPeer.tick();
|
||||||
|
const state = qaEngine.getState(), peer = qaPolicyPeer.getState();
|
||||||
|
const saved = await qaStorage.get('session.v1');
|
||||||
|
const result = { state, peer, saved: saved.config,
|
||||||
|
independent: Array.from(qaMemory.keys()).filter(key => key.startsWith('policy:' + saved.id + ':')) };
|
||||||
|
qaPolicyPeer.destroy(); return result;
|
||||||
|
});
|
||||||
|
for (const value of [concurrentPolicies.state, concurrentPolicies.peer, concurrentPolicies.saved]) {
|
||||||
|
assert.equal(value.autoContinuePlayback, false, '并发切换不能丢失第一个标签关闭自动续播');
|
||||||
|
assert.equal(value.resumePlaybackOnReturn, false, '并发切换不能丢失另一个标签关闭切回恢复');
|
||||||
|
}
|
||||||
|
assert.equal(concurrentPolicies.state.status, 'paused'); assert.equal(concurrentPolicies.peer.status, 'paused');
|
||||||
|
assert.equal(concurrentPolicies.independent.length, 2, '不同策略写入两个互不覆盖的独立存储键'); checks++;
|
||||||
|
|
||||||
|
const delayedIndependentRead = await page.evaluate(async () => {
|
||||||
|
const previous = qaStorage; let holdNext = true, release, captured;
|
||||||
|
const gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const held = new Promise(resolve => { captured = resolve; });
|
||||||
|
qaEngine.destroy();
|
||||||
|
qaStorage = {
|
||||||
|
get: async key => {
|
||||||
|
const value = await previous.get(key);
|
||||||
|
if (holdNext && key.endsWith(':autoContinuePlayback')) { holdNext = false; captured(); await gate; }
|
||||||
|
return value;
|
||||||
|
}, set: previous.set
|
||||||
|
};
|
||||||
|
// 初始化先正常读取;随后挂起的旧读取必须晚于新本地修改完成才释放。
|
||||||
|
holdNext = false; qaEngine = qaCreate(); await qaEngine.tick(); holdNext = true;
|
||||||
|
const old = qaEngine.tick(); await held;
|
||||||
|
await qaEngine.configure({ autoContinuePlayback: true }); release(); await old;
|
||||||
|
return qaEngine.getState();
|
||||||
|
});
|
||||||
|
assert.equal(delayedIndependentRead.autoContinuePlayback, true, '迟到的逐键旧读取不能覆盖刚保存的新值');
|
||||||
|
assert.equal(delayedIndependentRead.resumePlaybackOnReturn, false, '更新单键保留另一键的已同步设置');
|
||||||
|
assert.equal(delayedIndependentRead.status, 'paused'); checks++;
|
||||||
|
|
||||||
|
for (const delayedKey of ['control.v1', ':autoContinuePlayback']) {
|
||||||
|
await setup();
|
||||||
|
const pausedDuringRead = await page.evaluate(async delayedKey => {
|
||||||
|
qaEngine.destroy();
|
||||||
|
const previous = qaStorage; let holdNext = false, release, captured;
|
||||||
|
const gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const held = new Promise(resolve => { captured = resolve; });
|
||||||
|
qaStorage = {
|
||||||
|
get: async key => {
|
||||||
|
const value = await previous.get(key);
|
||||||
|
if (holdNext && (delayedKey === 'control.v1' ? key === delayedKey : key.endsWith(delayedKey))) {
|
||||||
|
holdNext = false; captured(); await gate;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}, set: previous.set
|
||||||
|
};
|
||||||
|
qaEngine = qaCreate(); await qaEngine.start();
|
||||||
|
holdNext = true; const oldTick = qaEngine.tick(); await held;
|
||||||
|
await qaEngine.pause(); const paused = qaEngine.getState(); const plays = qaPlayCalls;
|
||||||
|
release(); await oldTick;
|
||||||
|
return { paused, state: qaEngine.getState(), plays, afterPlays: qaPlayCalls, shared: await qaStorage.get('control.v1'), videoPaused: qaVideo.paused };
|
||||||
|
}, delayedKey);
|
||||||
|
assert.equal(pausedDuringRead.paused.status, 'paused');
|
||||||
|
assert.equal(pausedDuringRead.state.status, 'paused', delayedKey + ' 的旧 running 读取返回后不能恢复刚暂停的任务');
|
||||||
|
assert.equal(pausedDuringRead.shared.status, 'paused');
|
||||||
|
assert.equal(pausedDuringRead.afterPlays, pausedDuringRead.plays, '旧异步读取不能额外触发 video.play');
|
||||||
|
assert.equal(pausedDuringRead.videoPaused, true); checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置仅发布独立策略,挂起中的旧设置不得覆盖本页或另一执行器随后发布的暂停。
|
||||||
|
for (const pauseElsewhere of [false, true]) {
|
||||||
|
await setup();
|
||||||
|
const pausedDuringConfigure = await page.evaluate(async pauseElsewhere => {
|
||||||
|
qaEngine.destroy();
|
||||||
|
const previous = qaStorage; let holdNext = false, release, captured;
|
||||||
|
const gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const held = new Promise(resolve => { captured = resolve; });
|
||||||
|
qaStorage = {
|
||||||
|
get: async key => {
|
||||||
|
const value = await previous.get(key);
|
||||||
|
if (holdNext && key.endsWith(':autoContinuePlayback')) { holdNext = false; captured(); await gate; }
|
||||||
|
return value;
|
||||||
|
}, set: previous.set
|
||||||
|
};
|
||||||
|
qaEngine = qaCreate(); await qaEngine.start();
|
||||||
|
window.qaPolicyPeer = pauseElsewhere ? qaCreate() : null;
|
||||||
|
if (qaPolicyPeer) await qaPolicyPeer.tick();
|
||||||
|
holdNext = true; const changing = qaEngine.configure({ autoContinuePlayback: false }); await held;
|
||||||
|
const pausing = qaPolicyPeer || qaEngine; await pausing.pause(); const paused = pausing.getState().status; const plays = qaPlayCalls;
|
||||||
|
release(); await changing; await qaEngine.tick();
|
||||||
|
const shared = await qaStorage.get('control.v1');
|
||||||
|
const policy = await qaStorage.get('policy:' + shared.sessionId + ':autoContinuePlayback');
|
||||||
|
const result = { paused, state: qaEngine.getState(), shared, policy, plays, afterPlays: qaPlayCalls, videoPaused: qaVideo.paused };
|
||||||
|
qaPolicyPeer?.destroy(); return result;
|
||||||
|
}, pauseElsewhere);
|
||||||
|
assert.equal(pausedDuringConfigure.paused, 'paused');
|
||||||
|
assert.equal(pausedDuringConfigure.shared.status, 'paused', '旧设置写回不得覆盖' + (pauseElsewhere ? '另一执行器' : '本页') + '的新暂停');
|
||||||
|
assert.equal(pausedDuringConfigure.state.status, 'paused');
|
||||||
|
assert.equal(pausedDuringConfigure.state.autoContinuePlayback, false);
|
||||||
|
assert.equal(pausedDuringConfigure.policy.value, false, '暂停不丢失已请求关闭的独立设置');
|
||||||
|
assert.equal(pausedDuringConfigure.afterPlays, pausedDuringConfigure.plays);
|
||||||
|
assert.equal(pausedDuringConfigure.videoPaused, true); checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全量 learningSettings 可能是另一标签较早的快照;重载不能重新发布其中的旧策略。
|
||||||
|
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
|
||||||
|
const restoredPolicies = await page.evaluate(async () => {
|
||||||
|
await qaEngine.configure({ autoContinuePlayback: false, resumePlaybackOnReturn: false });
|
||||||
|
const control = await qaStorage.get('control.v1');
|
||||||
|
const keys = ['autoContinuePlayback', 'resumePlaybackOnReturn'].map(key => 'policy:' + control.sessionId + ':' + key);
|
||||||
|
const before = await Promise.all(keys.map(key => qaStorage.get(key)));
|
||||||
|
const plays = qaPlayCalls;
|
||||||
|
qaEngine.destroy(); qaEngine = qaCreate();
|
||||||
|
await qaEngine.configure({ autoContinuePlayback: true, resumePlaybackOnReturn: true, manualConfirmIncomplete: true,
|
||||||
|
skipSingleAttemptQuiz: true, rate: 2, answerInterval: 7, mode: 'video' }, { restore: true });
|
||||||
|
const after = await Promise.all(keys.map(key => qaStorage.get(key)));
|
||||||
|
return { before, after, state: qaEngine.getState(), plays, afterPlays: qaPlayCalls };
|
||||||
|
});
|
||||||
|
assert.equal(restoredPolicies.state.autoContinuePlayback, false, '重载的旧全量快照不能覆盖已发布的自动续播设置');
|
||||||
|
assert.equal(restoredPolicies.state.resumePlaybackOnReturn, false, '重载保留另一标签已发布的切回恢复设置');
|
||||||
|
assert.equal(restoredPolicies.state.status, 'paused');
|
||||||
|
assert.equal(restoredPolicies.state.rate, 2, '普通保存设置仍正常恢复');
|
||||||
|
assert.equal(restoredPolicies.state.answerInterval, 7);
|
||||||
|
assert.equal(restoredPolicies.state.mode, 'video');
|
||||||
|
assert.equal(restoredPolicies.afterPlays, restoredPolicies.plays);
|
||||||
|
assert.deepEqual(restoredPolicies.after, restoredPolicies.before, '恢复不能重新发布已存在的权威策略记录'); checks++;
|
||||||
|
|
||||||
|
// 本地暂停先生效;共享 paused 尚未写完时,新的轮询也不能把旧 running 当作远端继续。
|
||||||
|
await setup();
|
||||||
|
const pendingPauseWrite = await page.evaluate(async () => {
|
||||||
|
qaEngine.destroy();
|
||||||
|
const previous = qaStorage; let holdNext = false, release, captured;
|
||||||
|
const gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const held = new Promise(resolve => { captured = resolve; });
|
||||||
|
qaStorage = {
|
||||||
|
get: previous.get,
|
||||||
|
set: async (key, value) => {
|
||||||
|
if (holdNext && key === 'control.v1' && value.status === 'paused') { holdNext = false; captured(); await gate; }
|
||||||
|
return previous.set(key, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
qaEngine = qaCreate(); await qaEngine.start();
|
||||||
|
const plays = qaPlayCalls; holdNext = true; const pausing = qaEngine.pause(); await held;
|
||||||
|
const before = qaEngine.getState(); const oldShared = await qaStorage.get('control.v1');
|
||||||
|
await qaEngine.tick(); const during = qaEngine.getState();
|
||||||
|
release(); await pausing; await qaEngine.tick();
|
||||||
|
return { before, oldShared, during, state: qaEngine.getState(), shared: await qaStorage.get('control.v1'), plays, afterPlays: qaPlayCalls, videoPaused: qaVideo.paused };
|
||||||
|
});
|
||||||
|
assert.equal(pendingPauseWrite.before.status, 'paused');
|
||||||
|
assert.equal(pendingPauseWrite.oldShared.status, 'running', '明确覆盖本地 paused 已生效但共享写入仍挂起的窗口');
|
||||||
|
assert.equal(pendingPauseWrite.during.status, 'paused', '写入挂起期间的新 tick 不能接受旧 running 而恢复任务');
|
||||||
|
assert.equal(pendingPauseWrite.state.status, 'paused');
|
||||||
|
assert.equal(pendingPauseWrite.shared.status, 'paused');
|
||||||
|
assert.equal(pendingPauseWrite.afterPlays, pendingPauseWrite.plays);
|
||||||
|
assert.equal(pendingPauseWrite.videoPaused, true); checks++;
|
||||||
|
|
||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)');
|
console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)');
|
||||||
} finally { await context.close(); await browser.close(); }
|
} finally { await context.close(); await browser.close(); }
|
||||||
|
|||||||
@@ -165,6 +165,51 @@ 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('人工保护仍生效')));
|
||||||
|
const callsBeforeModeHints = await page.evaluate(() => JSON.stringify(qaCalls));
|
||||||
|
for (const mode of ['video', 'videoQuiz']) {
|
||||||
|
await page.evaluate(mode => qaPanel.setState({ learning: { mode } }), mode);
|
||||||
|
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('不会进入独立或关联考试') && text.includes('任务总览选择“视频+考试”')));
|
||||||
|
}
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { mode: 'exam' } }));
|
||||||
|
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('本模式未启用视频')));
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { mode: 'all', skipSingleAttemptQuiz: false } }));
|
||||||
|
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('单次机会保护已关闭')));
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { skipSingleAttemptQuiz: true } }));
|
||||||
|
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsBeforeModeHints, '模式说明更新只展示状态,不启动任务或改设置');
|
||||||
|
const playbackSwitches = [
|
||||||
|
['autoContinuePlayback', '自动继续上次播放'],
|
||||||
|
['skipSingleAttemptQuiz', '仅 1 次答题机会时交给我处理'],
|
||||||
|
['resumePlaybackOnReturn', '切回页面时自动恢复播放']
|
||||||
|
];
|
||||||
|
for (const [key, label] of playbackSwitches) {
|
||||||
|
const control = host.getByTestId('setting-' + key);
|
||||||
|
assert.equal(await control.getAttribute('role'), 'switch');
|
||||||
|
assert.equal(await control.getAttribute('aria-label'), label);
|
||||||
|
assert(await control.isChecked(), label + '默认开启');
|
||||||
|
await control.uncheck();
|
||||||
|
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), [key, false]);
|
||||||
|
}
|
||||||
|
const callsAfterPlaybackOff = await page.evaluate(() => JSON.stringify(qaCalls));
|
||||||
|
await tab('overview').click();
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 358 } }));
|
||||||
|
await tab('video').click();
|
||||||
|
for (const [key] of playbackSwitches) assert(!await host.getByTestId('setting-' + key).isChecked(), '切栏及部分更新保留视频开关状态');
|
||||||
|
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterPlaybackOff, '切栏不能启动续播或自动答题');
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true } }));
|
||||||
|
for (const [key] of playbackSwitches) assert(await host.getByTestId('setting-' + key).isChecked(), '显示外部恢复的视频开关状态');
|
||||||
|
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterPlaybackOff, '读取保存状态不触发设置或任务回调');
|
||||||
|
assert(await host.getByTestId('playback-return-hint').textContent().then(text => text.includes('手动暂停优先') && text.includes('不会自动播放')));
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { video: { title: '1-1安全生产方针和目标', status: 'backgroundPaused', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 } } }));
|
||||||
|
assert(await host.locator('.worker-status').textContent().then(text => text.includes('后台暂停,等待返回')));
|
||||||
|
await page.evaluate(() => qaPanel.setState({ learning: { video: { title: '1-1安全生产方针和目标', status: 'playing', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 } } }));
|
||||||
|
await screenshot('02b-desktop-playback-switches.png');
|
||||||
|
for (const [key] of playbackSwitches) {
|
||||||
|
await host.getByTestId('setting-' + key).uncheck();
|
||||||
|
await host.getByTestId('setting-' + key).check();
|
||||||
|
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), [key, true]);
|
||||||
|
}
|
||||||
|
console.log('PASS playback switches default on, boolean callbacks, persisted rendered state and manual-pause guidance');
|
||||||
await host.getByTestId('setting-rate').selectOption('2');
|
await host.getByTestId('setting-rate').selectOption('2');
|
||||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]);
|
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]);
|
||||||
await host.getByTestId('task-videoPause').click();
|
await host.getByTestId('task-videoPause').click();
|
||||||
@@ -213,6 +258,12 @@ async function main() {
|
|||||||
const moved = await host.boundingBox(); assert(Math.abs(moved.x - box.x - 35) <= 2);
|
const moved = await host.boundingBox(); assert(Math.abs(moved.x - box.x - 35) <= 2);
|
||||||
await page.setViewportSize({ width: 390, height: 844 }); await visible();
|
await page.setViewportSize({ width: 390, height: 844 }); await visible();
|
||||||
assert.equal(await host.locator('.navigation').evaluate(element => getComputedStyle(element).flexDirection), 'row');
|
assert.equal(await host.locator('.navigation').evaluate(element => getComputedStyle(element).flexDirection), 'row');
|
||||||
|
await tab('video').click();
|
||||||
|
for (const [key] of playbackSwitches) {
|
||||||
|
const control = host.getByTestId('setting-' + key);
|
||||||
|
await control.scrollIntoViewIfNeeded(); assert(await control.isVisible()); assert(await control.isChecked());
|
||||||
|
}
|
||||||
|
await screenshot('03b-mobile-playback-switches.png');
|
||||||
await tab('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded();
|
await tab('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded();
|
||||||
assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked());
|
assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked());
|
||||||
await screenshot('03a-mobile-incomplete-switch-on.png');
|
await screenshot('03a-mobile-incomplete-switch-on.png');
|
||||||
|
|||||||
+36
-3
@@ -25,7 +25,7 @@ const rootDir = path.resolve(__dirname, '..');
|
|||||||
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
|
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
|
||||||
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
|
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
|
||||||
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
|
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
|
||||||
for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
|
for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'attempt-policy.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
|
||||||
async function setup(config = {}) {
|
async function setup(config = {}) {
|
||||||
await page.evaluate(config => {
|
await page.evaluate(config => {
|
||||||
document.body.innerHTML = '<style>.question_box,.answer_box{display:block;padding:20px;border:1px solid}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal-footer{padding:8px}</style><app-question-modal></app-question-modal><div class="cdk-overlay-container"><div class="cdk-overlay-pane question-modal-pc"></div></div>';
|
document.body.innerHTML = '<style>.question_box,.answer_box{display:block;padding:20px;border:1px solid}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal-footer{padding:8px}</style><app-question-modal></app-question-modal><div class="cdk-overlay-container"><div class="cdk-overlay-pane question-modal-pc"></div></div>';
|
||||||
@@ -41,6 +41,7 @@ const rootDir = path.resolve(__dirname, '..');
|
|||||||
if (config.missing) qaSnapshot.records = [];
|
if (config.missing) qaSnapshot.records = [];
|
||||||
const pane = document.querySelector('.question-modal-pc');
|
const pane = document.querySelector('.question-modal-pc');
|
||||||
const escape = text => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const escape = text => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const attemptLabel = () => config.attemptLabel ? '<div class="quiz-attempt-status' + (config.hiddenAttemptLabel ? ' hidden' : '') + '">' + escape(config.attemptLabel) + '</div>' : '';
|
||||||
const grade = () => {
|
const grade = () => {
|
||||||
const q = qaQuestions[qaModel.index]; qaModel.grading = true;
|
const q = qaQuestions[qaModel.index]; qaModel.grading = true;
|
||||||
const correct = !config.wrongResult && JSON.stringify(qaModel.selected.slice().sort()) === JSON.stringify(q.wanted.slice().sort());
|
const correct = !config.wrongResult && JSON.stringify(qaModel.selected.slice().sort()) === JSON.stringify(q.wanted.slice().sort());
|
||||||
@@ -58,7 +59,7 @@ const rootDir = path.resolve(__dirname, '..');
|
|||||||
const render = () => {
|
const render = () => {
|
||||||
const q = qaQuestions[qaModel.index];
|
const q = qaQuestions[qaModel.index];
|
||||||
qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : [];
|
qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : [];
|
||||||
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
pane.innerHTML = attemptLabel() + '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
||||||
const footer = pane.querySelector('.modal-footer');
|
const footer = pane.querySelector('.modal-footer');
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (footer.querySelector('button')) return;
|
if (footer.querySelector('button')) return;
|
||||||
@@ -78,13 +79,15 @@ const rootDir = path.resolve(__dirname, '..');
|
|||||||
pane.querySelectorAll('.examtest_content_list_des').forEach((node, j) => node.classList.toggle('examtest_content_list_des_checked', qaModel.selected.includes(q.page[j])));
|
pane.querySelectorAll('.examtest_content_list_des').forEach((node, j) => node.classList.toggle('examtest_content_list_des_checked', qaModel.selected.includes(q.page[j])));
|
||||||
submit();
|
submit();
|
||||||
if (config.cancelAfterClick) qaActive = false;
|
if (config.cancelAfterClick) qaActive = false;
|
||||||
|
if (config.singleAfterClick) pane.querySelector('.quiz-attempt-status').textContent = '剩余答题次数:1 次';
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
|
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
|
||||||
|
if (config.attemptInQuestion) { const note = document.createElement('span'); note.textContent = '答题次数:1 次'; pane.querySelector('.examtest_content_question').append(note); }
|
||||||
if (config.unknown) pane.innerHTML = '<div class="unrecognized-popup"><p>新题型</p><button>提交</button></div>';
|
if (config.unknown) pane.innerHTML = '<div class="unrecognized-popup"><p>新题型</p><button>提交</button></div>';
|
||||||
window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher,
|
window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher,
|
||||||
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0 }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 });
|
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 });
|
||||||
}, config);
|
}, config);
|
||||||
}
|
}
|
||||||
await setup();
|
await setup();
|
||||||
@@ -110,6 +113,36 @@ const rootDir = path.resolve(__dirname, '..');
|
|||||||
}
|
}
|
||||||
console.log('PASS 冲突、未命中、图片与未知 DOM 均不猜选、不提交');
|
console.log('PASS 冲突、未命中、图片与未知 DOM 均不猜选、不提交');
|
||||||
|
|
||||||
|
for (const attemptLabel of ['允许答题次数:1 次', '最多可答题 1 次', '剩余答题次数:一 次', '仅有 1 次答题机会', '还可补考 1 次', '总答题次数:3 次;剩余答题次数:1 次']) {
|
||||||
|
await setup({ attemptLabel }); result = await page.evaluate(() => qaSolve());
|
||||||
|
assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.match(result.reason, /1 次/);
|
||||||
|
model = await page.evaluate(() => qaModel);
|
||||||
|
assert.equal(model.optionClicks.length, 0); assert.equal(model.submits, 0); assert.equal(model.advances, 0);
|
||||||
|
assert.equal(await page.locator('.question_box').count(), 1, '仅一次机会的弹题必须保留供人工操作');
|
||||||
|
}
|
||||||
|
await setup({ attemptLabel: '允许答题次数:1 次', skipSingleAttemptQuiz: false });
|
||||||
|
result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '关闭开关后按原流程答题');
|
||||||
|
for (const config of [{ attemptLabel: '允许答题次数:2 次' }, { attemptLabel: '已答题次数:1 次' }, { attemptLabel: '答题次数:1 次' }, { attemptLabel: '允许答题次数:1 次', hiddenAttemptLabel: true }, { attemptLabel: '答题次数:1/3' }]) {
|
||||||
|
await setup(config); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '已用次数、隐藏提示及不明确分数不冒充仅一次机会');
|
||||||
|
}
|
||||||
|
await setup({ attemptInQuestion: true }); result = await page.evaluate(() => qaSolve());
|
||||||
|
assert.equal(result.status, 'complete', '题干区域的次数文字不作为弹题可用次数证据');
|
||||||
|
assert.equal(result.manualRequired, undefined);
|
||||||
|
await setup({ attemptLabel: '剩余答题次数:2 次', singleAfterClick: true }); result = await page.evaluate(() => qaSolve());
|
||||||
|
assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.match(result.reason, /1 次/);
|
||||||
|
assert.equal((await page.evaluate(() => qaModel)).submits, 0, '选中后次数变成一时也不得提交或关闭');
|
||||||
|
await setup({ attemptLabel: '剩余答题次数:2 次' });
|
||||||
|
result = await page.evaluate(() => YianQuizAdapter.solve({ document, snapshot: qaSnapshot,
|
||||||
|
matcher: { match: async (...args) => { const result = await YianAnswerMatcher.match(...args); document.querySelector('.quiz-attempt-status').textContent = '允许答题次数:1 次'; return result; } },
|
||||||
|
context: { courses: ['安全课中题'], answerInterval: 0 }, getActive: () => qaActive }));
|
||||||
|
assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true);
|
||||||
|
assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0, '匹配结束后重新核验次数,不能执行旧的点击计划');
|
||||||
|
await setup({ attemptLabel: '允许答题次数:2 次' });
|
||||||
|
result = await page.evaluate(async () => { const policy = YianAttemptPolicy; delete window.YianAttemptPolicy; try { return await qaSolve(); } finally { window.YianAttemptPolicy = policy; } });
|
||||||
|
assert.equal(result.status, 'waiting'); assert.match(result.reason, /组件未就绪/);
|
||||||
|
assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0, '开关开启时次数组件缺失不得冒险自动答题');
|
||||||
|
console.log('PASS 一次机会默认等待人工且保留弹题、可关闭开关、仅认可可见次数提示及点击前重新核验');
|
||||||
|
|
||||||
await setup({ cancelAfterClick: true }); result = await page.evaluate(() => qaSolve());
|
await setup({ cancelAfterClick: true }); result = await page.evaluate(() => qaSolve());
|
||||||
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
|
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
|
||||||
await setup();
|
await setup();
|
||||||
|
|||||||
+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', 'learning-engine.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs']) {
|
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'learning-engine.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']) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/* Complete bundle: real course .related_test -> detail -> paper after platform learned.
|
||||||
|
* Every request is intercepted; this fixture never reaches the business server. */
|
||||||
|
'use strict';
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const JSZip = require('../vendor/jszip.min.js');
|
||||||
|
|
||||||
|
const workspace = path.resolve(__dirname, '..');
|
||||||
|
const source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
|
||||||
|
const course = '1-1安全生产方针和目标';
|
||||||
|
const courseURL = 'http://10.119.67.7/clientcn/course/video-exam-workflow';
|
||||||
|
const detailURL = 'http://10.119.67.7/clientcn/exam/video-exam-workflow';
|
||||||
|
const paperURL = detailURL + '/questions?flag=test&totaltime=30&attemptId=video-linked&title=' + encodeURIComponent(course);
|
||||||
|
const fixture = '<!doctype html><meta charset="utf-8"><title>视频完成后关联考试验证</title><style>body{font:14px system-ui}video{display:block;width:640px;height:220px;background:#172a49}li{list-style:none}.related_test{cursor:pointer}.pc_examtest_content{padding:6px}.pc_examtest_content_list_des{padding:5px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}[hidden],[aria-hidden=true]{display:none!important}</style><main id="site-view"><app-course><div data-course-title>' + course + '</div><li class="cn_courseware_img active"><p><span>1.</span><span>公司安全理念、方针及目标培训</span></p><div class="menu_courseware"><div class="menu_courseware_left"><div class="cn_courseware_img_icon">视频</div><div>27秒</div></div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div></div><div class="related_test_outer"><p class="related_test"><svg-div class="app-svg-themebox"></svg-div>' + course + '</p></div></li><app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video></app-course></main>';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const savedZip = path.join(workspace, '谊安学网页.zip');
|
||||||
|
if (fs.existsSync(savedZip)) {
|
||||||
|
const zip = await JSZip.loadAsync(fs.readFileSync(savedZip));
|
||||||
|
const saved = Object.values(zip.files).find(file => /\/04\/谊安学\.html$/.test(file.name));
|
||||||
|
assert(saved, '旧归档 04 应包含课程目录');
|
||||||
|
const html = (await saved.async('string')).replace(/<script\b[\s\S]*?<\/script>/gi, '').replace(/<style\b[\s\S]*?<\/style>/gi, '');
|
||||||
|
assert.match(html, /class="related_test(?:\s[^"]*)?"[^>]*>[\s\S]*?1-1安全生产方针和目标\s*<\/p>/);
|
||||||
|
assert(html.includes('公司安全理念、方针及目标培训'), '视频标题与关联考试标题不同,必须使用考试入口的实际文字');
|
||||||
|
console.log('PASS 保存网页静态确认 .related_test 标题为真实考试主题,未运行原站脚本');
|
||||||
|
}
|
||||||
|
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||||
|
try {
|
||||||
|
for (const mode of ['all', 'videoQuiz']) {
|
||||||
|
const memory = new Map(), requests = [], blocked = [], errors = [];
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
|
||||||
|
await context.exposeBinding('__read', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
|
||||||
|
await context.exposeBinding('__write', (_, key, value) => 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: fixture });
|
||||||
|
blocked.push(route.request().url()); return route.abort();
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.goto(courseURL);
|
||||||
|
await page.evaluate(({ course, detailURL, paperURL }) => {
|
||||||
|
window.qaFlow = { relatedClicks: 0, startClicks: 0, selections: [], commits: 0, confirms: 0 };
|
||||||
|
window.qaMedia = { currentTime: 27, duration: 100, ended: false, paused: true, playbackRate: 1, playCalls: 0 };
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaMedia[key], set: value => { qaMedia[key] = value; } });
|
||||||
|
video.play = async () => { qaMedia.playCalls++; qaMedia.paused = false; };
|
||||||
|
video.pause = () => { qaMedia.paused = true; };
|
||||||
|
window.qaOffset = 0; const nativeNow = Date.now.bind(Date); Date.now = () => nativeNow() + qaOffset;
|
||||||
|
window.qaAdvanceExamTime = milliseconds => {
|
||||||
|
qaOffset += milliseconds;
|
||||||
|
const remaining = Math.max(0, 1800 - Math.floor(qaOffset / 1000));
|
||||||
|
document.querySelector('.pc_examtest_countdown .time').textContent = '00:' + String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
|
||||||
|
};
|
||||||
|
const renderPaper = () => {
|
||||||
|
history.pushState({}, '', paperURL);
|
||||||
|
const questions = Array.from({ length: 25 }, (_, i) => '<div app-exam-question><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p>[单选] ' + (i + 1) + '. 关联考试核验题' + i + '<span class="pc_examtest_content_question_value">(4分)</span></p></div><div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">A</span>错误操作' + i + '</div><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">B</span>正确措施' + i + '</div></div></div></div>').join('');
|
||||||
|
document.querySelector('#site-view').innerHTML = '<app-examtest><div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">' + course + '</div>' + questions + '</div><div class="pc_examtest_card">' + Array.from({ length: 25 }, (_, i) => '<div class="examtestcard_count_defult">' + (i + 1) + '</div>').join('') + '</div><div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_commit">交卷</div></div></app-examtest><div role="dialog" aria-hidden="true"><p>是否确认交卷?</p><button>确认交卷</button><button>取消</button></div><div data-exam-result hidden>交卷成功,成绩 100</div>';
|
||||||
|
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
|
||||||
|
node.parentElement.querySelectorAll('.pc_examtest_content_list_des').forEach(sibling => sibling.classList.remove('examtest_content_list_des_checked'));
|
||||||
|
node.classList.add('examtest_content_list_des_checked');
|
||||||
|
qaFlow.selections.push(node.querySelector('.examtest_content_list_des_optionsort').textContent);
|
||||||
|
}));
|
||||||
|
document.querySelector('.pc_examtest_commit').addEventListener('click', () => { qaFlow.commits++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'false'); });
|
||||||
|
document.querySelector('[role=dialog] button').addEventListener('click', () => { qaFlow.confirms++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false; });
|
||||||
|
};
|
||||||
|
document.querySelector('.related_test').addEventListener('click', event => {
|
||||||
|
event.stopPropagation(); qaFlow.relatedClicks++;
|
||||||
|
history.pushState({}, '', detailURL);
|
||||||
|
document.querySelector('#site-view').innerHTML = '<app-examdetail><div class="examdetail_header">' + course + ' 已答0/25</div><div>总考试次数:3 次</div><div class="examdetail_button">开始答题</div></app-examdetail>';
|
||||||
|
document.querySelector('.examdetail_button').addEventListener('click', () => { qaFlow.startClicks++; renderPaper(); });
|
||||||
|
});
|
||||||
|
}, { course, detailURL, paperURL });
|
||||||
|
await page.addScriptTag({ content: '(function(){const GM_getValue=(key,fallback)=>window.__read(key,fallback);const GM_setValue=(key,value)=>window.__write(key,value);\n' + source + '\n})();' });
|
||||||
|
await page.evaluate(() => YianAssistant.ready);
|
||||||
|
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().mode), 'all', '新安装默认范围应包含视频、课中题与关联考试');
|
||||||
|
const records = Array.from({ length: 25 }, (_, i) => ({ course, type: '单选', number: i + 1, question: '关联考试核验题' + i, options: [{ label: 'A', text: '正确措施' + i }, { label: 'B', text: '错误操作' + i }], answer: 'A' }));
|
||||||
|
await page.evaluate(async ({ records, mode }) => {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||||
|
await YianAssistant.importFiles([{ name: '视频关联考试.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||||
|
await YianAssistant.setLearningSetting('mode', mode);
|
||||||
|
await YianAssistant.setLearningSetting('answerInterval', 0);
|
||||||
|
await YianAssistant.taskAction('start');
|
||||||
|
}, { records, mode });
|
||||||
|
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused);
|
||||||
|
assert.equal(await page.evaluate(() => qaFlow.relatedClicks), 0, '平台未完成视频前不应进入关联考试');
|
||||||
|
await page.evaluate(() => { qaMedia.currentTime = 100; qaMedia.ended = true; document.querySelector('.menu_courseware_left').lastElementChild.textContent = '100秒'; });
|
||||||
|
await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'waitingPlatform'));
|
||||||
|
assert.equal(await page.evaluate(() => qaFlow.relatedClicks), 0, '播放器到末尾也必须等待平台学习完成');
|
||||||
|
await page.evaluate(() => { document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'); });
|
||||||
|
if (mode === 'videoQuiz') {
|
||||||
|
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished');
|
||||||
|
assert.equal(await page.evaluate(() => qaFlow.relatedClicks), 0, '用户明确选择仅视频和课中题时,不扩大范围自动开考');
|
||||||
|
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
|
||||||
|
console.log('PASS 明确 videoQuiz 模式仍只完成视频和课中题,不擅自开考');
|
||||||
|
} else {
|
||||||
|
await page.waitForFunction(() => qaFlow.relatedClicks === 1, null, { timeout: 10000 }).catch(async error => {
|
||||||
|
console.error('视频完成后的任务清单:', await page.evaluate(() => YianAssistant.getLearningState())); throw error;
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => qaFlow.startClicks === 1, null, { timeout: 10000 });
|
||||||
|
assert.equal(page.url(), paperURL);
|
||||||
|
await page.waitForFunction(() => YianAssistant.getState().metrics.selected === 25, null, { timeout: 15000 });
|
||||||
|
await page.waitForFunction(() => YianAssistant.getExamState().predictedScore === 100);
|
||||||
|
const model = await page.evaluate(() => qaFlow);
|
||||||
|
assert.deepEqual(model.selections, Array(25).fill('B'), '由原库 A 正确全文映射为当前 B');
|
||||||
|
assert.equal(model.relatedClicks, 1); assert.equal(model.startClicks, 1); assert.equal(model.commits, 0);
|
||||||
|
const state = await page.evaluate(() => YianAssistant.getLearningState());
|
||||||
|
assert.equal(state.tasks.filter(task => task.kind === 'exam').length, 1, '同一考试跨目录、详情和试卷不得重复领取');
|
||||||
|
assert.equal(state.tasks.find(task => task.kind === 'video').status, 'completed');
|
||||||
|
assert.equal(state.video.status, 'completed'); assert.equal(state.video.platformComplete, true, '目录完成标记同步视频面板状态');
|
||||||
|
await page.evaluate(() => qaAdvanceExamTime(6 * 60000 + 500));
|
||||||
|
await page.waitForFunction(() => YianAssistant.getExamState().submitted === true);
|
||||||
|
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished' && YianAssistant.getLearningState().completed === 2, null, { timeout: 8000 }).catch(async error => {
|
||||||
|
console.error('网站交卷后的任务清单:', await page.evaluate(() => ({ learning: YianAssistant.getLearningState(), exam: YianAssistant.getExamState() }))); throw error;
|
||||||
|
});
|
||||||
|
assert.equal(await page.evaluate(() => qaFlow.commits), 1); assert.equal(await page.evaluate(() => qaFlow.confirms), 1);
|
||||||
|
console.log('PASS 完整 bundle all 顺序模式:视频末尾→等待平台完成→原始 related_test→详情正常开考→乱序全文答题→满6分钟正常交卷→两个任务完成');
|
||||||
|
}
|
||||||
|
assert.deepEqual(blocked, []); assert.deepEqual(errors, []); assert.equal(requests.length, 1, '所有转场由本地正常按钮模拟,不连接业务站点');
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -44,6 +44,7 @@ async function main() {
|
|||||||
const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc';
|
const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc';
|
||||||
const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'];
|
const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'];
|
||||||
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question"><p class="show"><span>[单选]</span>压力达到 2 MPa 时应该怎样操作?</p><p class="hidden">[多选]隐藏模板</p><p class="hidden">[判断]隐藏模板</p></div><div class="examtest_content_list">' + texts.map((text, index) => '<div class="option-row"><div class="examtest_content_list_des"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + index) + '</span>' + text + '</div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question"><p class="show"><span>[单选]</span>压力达到 2 MPa 时应该怎样操作?</p><p class="hidden">[多选]隐藏模板</p><p class="hidden">[判断]隐藏模板</p></div><div class="examtest_content_list">' + texts.map((text, index) => '<div class="option-row"><div class="examtest_content_list_des"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + index) + '</span>' + text + '</div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
||||||
|
pane.insertAdjacentHTML('afterbegin', '<p class="attempt-status">允许答题次数:1次</p>');
|
||||||
document.querySelector('.cdk-overlay-container').append(pane);
|
document.querySelector('.cdk-overlay-container').append(pane);
|
||||||
const grade = () => {
|
const grade = () => {
|
||||||
qaQuiz.submits++;
|
qaQuiz.submits++;
|
||||||
@@ -74,17 +75,47 @@ async function main() {
|
|||||||
await YianAssistant.setLearningSetting('mode', 'videoQuiz');
|
await YianAssistant.setLearningSetting('mode', 'videoQuiz');
|
||||||
await YianAssistant.setLearningSetting('rate', 2);
|
await YianAssistant.setLearningSetting('rate', 2);
|
||||||
await YianAssistant.setLearningSetting('answerInterval', 0);
|
await YianAssistant.setLearningSetting('answerInterval', 0);
|
||||||
|
window.qaResume = { continued: 0, restarted: 0 };
|
||||||
|
const dialog = document.createElement('div'); dialog.className = 'modal-dialog dialog_box';
|
||||||
|
dialog.innerHTML = '<div class="modal-content"><div class="modal-body modal-dialog-scrollable"><div>是否继续上次播放的位置?</div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">重新开始</span></button><button type="button"><span aria-hidden="true">继续播放</span></button></div></div>';
|
||||||
|
const buttons = dialog.querySelectorAll('button');
|
||||||
|
buttons[0].onclick = () => { qaResume.restarted++; qaMedia.currentTime = 0; dialog.remove(); };
|
||||||
|
buttons[1].onclick = () => { qaResume.continued++; qaMedia.currentTime = 37.5; dialog.remove(); };
|
||||||
|
document.querySelector('.cdk-overlay-container').append(dialog);
|
||||||
await YianAssistant.taskAction('start');
|
await YianAssistant.taskAction('start');
|
||||||
});
|
});
|
||||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused && qaMedia.playbackRate === 2);
|
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused && qaMedia.playbackRate === 2);
|
||||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
|
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
|
||||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
|
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
|
||||||
|
assert.deepEqual(await page.evaluate(() => qaResume), { continued: 1, restarted: 0 });
|
||||||
|
assert.equal(await page.evaluate(() => qaMedia.currentTime), 37.5);
|
||||||
console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态');
|
console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态');
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.qaVisibility = 'hidden'; Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => qaVisibility });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => YianAssistant.getLearningState().video.status === 'backgroundPaused');
|
||||||
|
const visibilityPlays = await page.evaluate(() => qaMedia.playCalls);
|
||||||
|
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
|
||||||
|
await page.waitForFunction(() => !qaMedia.paused);
|
||||||
|
assert.equal(await page.evaluate(() => qaMedia.playCalls), visibilityPlays + 1);
|
||||||
|
assert.equal(await page.evaluate(() => qaMedia.currentTime), 37.5);
|
||||||
|
console.log('PASS 精确续播弹窗选择继续播放;网站后台暂停后返回立即续播,保留37.5秒实际位置');
|
||||||
|
|
||||||
const beforeTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
|
const beforeTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
|
||||||
for (const key of ['overview', 'video', 'exam', 'bank', 'console', 'settings', 'video']) await host.getByTestId('tab-' + key).click();
|
for (const key of ['overview', 'video', 'exam', 'bank', 'console', 'settings', 'video']) await host.getByTestId('tab-' + key).click();
|
||||||
const afterTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
|
const afterTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
|
||||||
assert.deepEqual(afterTabs, beforeTabs, '切栏不得重新播放、暂停或领取新任务');
|
assert.deepEqual(afterTabs, beforeTabs, '切栏不得重新播放、暂停或领取新任务');
|
||||||
|
await page.evaluate(() => { qaShowQuiz(); });
|
||||||
|
await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waiting');
|
||||||
|
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits + qaQuiz.closes), 0, '默认单次机会开关阻止选项/判分/关闭');
|
||||||
|
assert.match(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].reason), /1 次/);
|
||||||
|
await host.getByTestId('setting-skipSingleAttemptQuiz').uncheck();
|
||||||
|
await page.waitForFunction(() => !YianAssistant.getLearningState().skipSingleAttemptQuiz && YianAssistant.getLearningState().status === 'paused');
|
||||||
|
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits + qaQuiz.closes), 0, '关闭开关不立即作答,需明确继续');
|
||||||
|
await page.evaluate(async () => { document.querySelector('.question-modal-pc').remove(); await YianAssistant.taskAction('resume'); });
|
||||||
|
await page.waitForFunction(() => !qaMedia.paused);
|
||||||
await host.getByTestId('global-pause').click();
|
await host.getByTestId('global-pause').click();
|
||||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused);
|
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused);
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
@@ -137,6 +168,20 @@ async function main() {
|
|||||||
assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态');
|
assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态');
|
||||||
assert.equal(requests.length, 1); assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
|
assert.equal(requests.length, 1); assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
|
||||||
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
|
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
|
||||||
|
await host.getByTestId('tab-video').click();
|
||||||
|
await page.screenshot({ path: path.join(qaDir, '03-playback-switches-desktop.png') });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await host.getByTestId('setting-resumePlaybackOnReturn').scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: path.join(qaDir, '04-playback-switches-mobile.png') });
|
||||||
|
await host.getByTestId('setting-autoContinuePlayback').uncheck();
|
||||||
|
await host.getByTestId('setting-resumePlaybackOnReturn').uncheck();
|
||||||
|
await page.waitForFunction(() => !YianAssistant.getLearningState().autoContinuePlayback && !YianAssistant.getLearningState().resumePlaybackOnReturn);
|
||||||
|
await page.reload(); await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready);
|
||||||
|
const restored = await page.evaluate(() => YianAssistant.getLearningState());
|
||||||
|
for (const key of ['autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz']) assert.equal(restored[key], false, key + ' 刷新保存');
|
||||||
|
assert.equal(restored.status, 'paused');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS 单次机会保护ON/OFF真实开关与显式继续、全部三开关保存/刷新不自启、桌面/390px窄屏');
|
||||||
console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误');
|
console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误');
|
||||||
console.log('截图:' + qaDir);
|
console.log('截图:' + qaDir);
|
||||||
} finally { await browser.close(); }
|
} finally { await browser.close(); }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
- `bank-store.js`、`answer-matcher.js`:统一题库与证据匹配,岗位仅作来源信息。
|
- `bank-store.js`、`answer-matcher.js`:统一题库与证据匹配,岗位仅作来源信息。
|
||||||
- `assistant-panel.js`:六个功能栏及状态展示,仅通过回调发出用户操作。
|
- `assistant-panel.js`:六个功能栏及状态展示,仅通过回调发出用户操作。
|
||||||
|
- `attempt-policy.js`:课中题及课程考试共用的可见机会数取证;只读取指定题目/考试容器,不读全页、Angular 私有状态或业务接口。
|
||||||
- `learning-engine.js`:任务、视频、恢复、检查点、日志及多页面协调。
|
- `learning-engine.js`:任务、视频、恢复、检查点、日志及多页面协调。
|
||||||
- `exam-controller.js`:当前考试的主题依据、原始进入时间、分值核算及交卷状态机。
|
- `exam-controller.js`:当前考试的主题依据、原始进入时间、分值核算及交卷状态机。
|
||||||
- `answer-runner.js`:按配置间隔点击真实选项,保持每步上下文及全文核验。
|
- `answer-runner.js`:按配置间隔点击真实选项,保持每步上下文及全文核验。
|
||||||
@@ -68,3 +69,16 @@
|
|||||||
- 在入口、提交前复查及确认阶段统一核对开关和通用门槛。切换开关立即暂停当前答题与交卷计划,须用户明确继续后重验,不能关闭时立即提交。未知题不猜答,人工补选的未知答案不自动计正分。
|
- 在入口、提交前复查及确认阶段统一核对开关和通用门槛。切换开关立即暂停当前答题与交卷计划,须用户明确继续后重验,不能关闭时立即提交。未知题不猜答,人工补选的未知答案不自动计正分。
|
||||||
- 本次验收重点:网页整卷完整、已核验 84 分但有 4 题未匹配时,开关开启则满 6 分钟仍不点击提交/确认,关闭并明确继续重验后可按原规则交卷及确认正常未答完提醒;完整 100 分且每题核验通过,达到时长并满足其他条件后可正常自动交卷。另覆盖任何开关状态下的加载不全、分值不明、80 分、主题不明,以及开关默认值、持久化和切换中止。
|
- 本次验收重点:网页整卷完整、已核验 84 分但有 4 题未匹配时,开关开启则满 6 分钟仍不点击提交/确认,关闭并明确继续重验后可按原规则交卷及确认正常未答完提醒;完整 100 分且每题核验通过,达到时长并满足其他条件后可正常自动交卷。另覆盖任何开关状态下的加载不全、分值不明、80 分、主题不明,以及开关默认值、持久化和切换中止。
|
||||||
- 本节记录本次实施与验收范围,测试结果以本次实际执行记录为准。
|
- 本节记录本次实施与验收范围,测试结果以本次实际执行记录为准。
|
||||||
|
|
||||||
|
## 8. 2.1.2 调整:续播、单次机会保护与视频后续考试
|
||||||
|
|
||||||
|
1. 在视频栏提供三个独立、默认开启并保存的开关:`autoContinuePlayback`(自动继续上次播放)、`skipSingleAttemptQuiz`(仅 1 次答题机会时交给我处理)、`resumePlaybackOnReturn`(切回页面时自动恢复播放)。设置由协调器传给任务、课中题及考试模块,跨页恢复时保留当前策略。
|
||||||
|
2. 续播只处理唯一可见、正文匹配“是否继续上次播放的位置?”且同时含“重新开始”“继续播放”两个按钮的网站弹窗,正常点击“继续播放”。关闭开关、已暂停任务、弹窗不明或存在待处理题目时,不擅自播放或选择其他弹窗。
|
||||||
|
3. 用户确认的单次规则为“只剩 1 次或总共仅允许 1 次答题机会时,交给我手动答题”。公共机会数模块只读取当前弹题/考试范围内可见的允许、总、剩余次数和实际补考文案,1 次或 0 次阻止受影响的自动操作;明确大于 1 次保留证据,未显示时不推断。题干、选项、参考答案、隐藏节点、已答次数、已答题数和交卷人数都不作为机会数证据。
|
||||||
|
4. 在课中题选项/提交、考试入口及自动答题/交卷前复核该规则,避免只在首次读页检查。详情页机会数证据只用于同一受控考试流,不能把其他课程的次数借给当前试卷。关闭保护仍须通过原有题库全文匹配、分值、计时和交卷条件。
|
||||||
|
5. 旧站静态代码显示 `visibilitychange` 的后台分支主动暂停视频,没有返回恢复逻辑。本版在切页前保存真实播放状态,后台暂停显示“后台暂停,等待返回”,返回后按开关及实际任务状态续播。手动暂停播放器、全局或局部暂停、弹题和待人工状态优先;后台暂停不触发故障刷新,不伪造播放时长,不承诺后台一直播放。
|
||||||
|
6. 新安装默认 `all`,即“视频+考试(含课中答题)”;已有保存的模式继续使用,不覆盖用户显式的仅视频选择。顺序模式在平台确认视频完成、关联考试开放后才进入考试,仍受单次机会及题库预检约束;`video`、`videoQuiz` 不进入独立或关联考试。视频栏根据当前模式及保护设置说明后续流程,不增设重复按钮。
|
||||||
|
|
||||||
|
本次补充包 `08.zip` 只有 Angular 初始外壳,没有播放器、弹窗、次数提示 DOM 或业务 JS 资源。适配参考旧完整归档的静态模板及脚本片段,不执行下载页面中的原站代码。旧源码已提供续播正文和两个按钮、后台主动暂停、`不及格,还可补考 N 次` 及 `还可重考 N 次` 的静态证据;真实账号的播放器和考试状态仍需实站确认。
|
||||||
|
|
||||||
|
最终本地验证:`npm test` 的 15 套回归全部通过,学习引擎另完成 52 项检查。已覆盖三个开关默认值、保存、回读及切栏不触发动作,精确续播与未知弹窗,后台暂停与返回恢复、关闭恢复及用户手动暂停优先,明确 0/1/多次和缺失机会数、补考按钮、隐藏及题干干扰,课中题与考试共同保护,新安装默认与旧模式保留,以及平台确认完成后串联考试。额外验证共享设置不同键并发不丢失、旧读取及旧设置快照不覆盖新值、暂停写入期间不重新启动弹题、不同考试的次数证据隔离、关联考试完整导航及完成后不重复加入任务。桌面及 390px 窄屏检查通过;业务 URL 全部在本地拦截,未操作真实学习或考试记录,真实账号流程仍需实站验收。
|
||||||
|
|||||||
+434
-65
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 谊安学助手 2.0
|
// @name 谊安学助手 2.0
|
||||||
// @namespace yianxue-question-bank
|
// @namespace yianxue-question-bank
|
||||||
// @version 2.1.1
|
// @version 2.1.2
|
||||||
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
|
||||||
// @match http://10.119.67.7/clientcn/*
|
// @match http://10.119.67.7/clientcn/*
|
||||||
// @run-at document-idle
|
// @run-at document-idle
|
||||||
@@ -1132,7 +1132,7 @@ window.JSZip = module.exports;
|
|||||||
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
|
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
|
||||||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
|
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
|
||||||
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
|
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
|
||||||
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
|
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
|
||||||
};
|
};
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let selectionText = '';
|
let selectionText = '';
|
||||||
@@ -1567,7 +1567,7 @@ window.JSZip = module.exports;
|
|||||||
container.appendChild(button); return button;
|
container.appendChild(button); return button;
|
||||||
}
|
}
|
||||||
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
|
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
|
||||||
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频、课中题与考试']], 'all');
|
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频+考试(含课中答题)']], 'all');
|
||||||
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
|
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
|
||||||
const taskActions = el(doc, 'div', 'task-actions');
|
const taskActions = el(doc, 'div', 'task-actions');
|
||||||
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
|
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
|
||||||
@@ -1589,8 +1589,17 @@ window.JSZip = module.exports;
|
|||||||
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
|
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
|
||||||
|
|
||||||
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
|
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
|
||||||
const videoModeNote = el(doc, 'p', 'mode-note'); views.video.appendChild(videoModeNote);
|
const videoModeNote = el(doc, 'p', 'mode-note');
|
||||||
|
videoModeNote.setAttribute('data-testid', 'video-mode-note'); views.video.appendChild(videoModeNote);
|
||||||
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
|
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
|
||||||
|
learningSwitch(views.video, 'autoContinuePlayback', '自动继续上次播放', true);
|
||||||
|
views.video.appendChild(el(doc, 'p', 'hint', '运行中的视频任务出现“是否继续上次播放的位置”时,自动选择“继续播放”;关闭后由你处理。'));
|
||||||
|
learningSwitch(views.video, 'skipSingleAttemptQuiz', '仅 1 次答题机会时交给我处理', true);
|
||||||
|
views.video.appendChild(el(doc, 'p', 'hint', '含课中题和课程考试:网页明确显示仅允许或剩余 1 次机会时,不自动答题,等待你处理。次数未显示时不能推断;关闭后仍需满足原有题库核验条件。'));
|
||||||
|
learningSwitch(views.video, 'resumePlaybackOnReturn', '切回页面时自动恢复播放', true);
|
||||||
|
const playbackReturnHint = el(doc, 'p', 'hint', '只恢复切页引起的暂停。手动暂停优先;你暂停播放器、暂停任务,或有题目等待处理时,不会自动播放。');
|
||||||
|
playbackReturnHint.setAttribute('data-testid', 'playback-return-hint');
|
||||||
|
views.video.appendChild(playbackReturnHint);
|
||||||
const videoInfo = el(doc, 'dl', 'info-grid');
|
const videoInfo = el(doc, 'dl', 'info-grid');
|
||||||
const videoActions = el(doc, 'div', 'task-actions');
|
const videoActions = el(doc, 'div', 'task-actions');
|
||||||
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
|
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
|
||||||
@@ -1761,7 +1770,7 @@ window.JSZip = module.exports;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', 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: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', 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 || {};
|
||||||
@@ -1828,7 +1837,11 @@ window.JSZip = module.exports;
|
|||||||
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
|
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
|
||||||
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
|
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
|
||||||
const videoEnabled = learning.mode !== 'exam';
|
const videoEnabled = learning.mode !== 'exam';
|
||||||
setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled;
|
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
|
||||||
|
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
|
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
|
||||||
|
: '当前为“视频+考试”:平台确认视频完成后,进入已开放的关联考试。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
|
||||||
|
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);
|
||||||
@@ -2543,6 +2556,113 @@ window.JSZip = module.exports;
|
|||||||
root.YianAnswerRunner = { version: '1.1.0', create: create };
|
root.YianAnswerRunner = { version: '1.1.0', create: create };
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|
||||||
|
/* Visible attempt labels shared by course quizzes and course exams. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
const IGNORE = [
|
||||||
|
'script', 'style', 'noscript', 'template', 'textarea', 'input', 'select', 'option', 'pre', 'code', 'svg', 'canvas',
|
||||||
|
'.examtest_content_question', '.examtest_content_list', '.examtest_content_list_des',
|
||||||
|
'.pc_examtest_content_question', '.pc_examtest_content_list',
|
||||||
|
'.model_answer_wrapper', '.model_answer', '.model_dec',
|
||||||
|
'[data-question-stem]', '[data-question-text]', '[data-question-options]', '[data-answer-text]',
|
||||||
|
'.question-stem', '.question-text', '.question-options', '.question-answer', '.option-text', '.answer-text', '[role="option"]'
|
||||||
|
].join(',');
|
||||||
|
const BLOCK = /^(?:ADDRESS|ARTICLE|ASIDE|BLOCKQUOTE|DD|DIV|DL|DT|FIELDSET|FIGCAPTION|FIGURE|FOOTER|FORM|H[1-6]|HEADER|HR|LI|MAIN|NAV|OL|P|SECTION|TABLE|TBODY|TD|TFOOT|TH|THEAD|TR|UL)$/;
|
||||||
|
const DIGITS = '[0-9零〇一二三四五六七八九十百两]+';
|
||||||
|
const TARGET = '(?:答题|作答|考试)';
|
||||||
|
const CAPTION = '(?:允许|允许的|可用|可|总|总共|最多|最大|剩余|还剩|还有|还可)';
|
||||||
|
const SEPARATOR = '(?:[::=]|为|是)?';
|
||||||
|
const LIMIT = '(?:仅|只有|仅有|只限)?';
|
||||||
|
const UNIT = '(?:次|[((]次[))])?';
|
||||||
|
const forms = [
|
||||||
|
new RegExp('^(?:本次|本场)?' + CAPTION + '(?:可|允许)?' + TARGET + '(?:总|剩余)?(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
|
||||||
|
new RegExp('^(?:本次|本场)?' + TARGET + '(?:允许|可用|总|最多|最大|剩余|还剩)(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
|
||||||
|
new RegExp('^(?:本次|本场)?' + TARGET + '(?:次数|机会)' + SEPARATOR + '(?:上限|最多|最大|仅剩|还剩|剩余)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|可|能|允许)?' + TARGET + '(' + DIGITS + ')次$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:最多|总共|总|允许|还可|剩余)(?:可)?' + TARGET + '(' + DIGITS + ')次$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|剩)?(' + DIGITS + ')次' + TARGET + '机会$'),
|
||||||
|
new RegExp('^(?:本次|本场)?(?:剩余|还剩|还有)(' + DIGITS + ')次' + TARGET + '机会$'),
|
||||||
|
new RegExp('^(?:本次|本场)?还可(?:补考|重考)' + SEPARATOR + '(' + DIGITS + ')次$'),
|
||||||
|
// The saved site's showButton2 label is “不及格,还可补考 N 次”.
|
||||||
|
// The template may wrap it in parentheses after the normal retake button.
|
||||||
|
new RegExp('^不及格[,,]还可补考(' + DIGITS + ')次$'),
|
||||||
|
new RegExp('^(?:重新答题|重新考试)?[((]不及格[,,]还可补考(' + DIGITS + ')次[))]$')
|
||||||
|
];
|
||||||
|
function clean(value) {
|
||||||
|
return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/[0-9]/g, char => String(char.charCodeAt(0) - 0xff10)).replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
function visible(node) {
|
||||||
|
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],[inert]')) return false;
|
||||||
|
const view = node.ownerDocument && node.ownerDocument.defaultView;
|
||||||
|
if (!view) return false;
|
||||||
|
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
|
||||||
|
const style = view.getComputedStyle(current);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false;
|
||||||
|
}
|
||||||
|
return node.getClientRects().length > 0 || view.getComputedStyle(node).display === 'contents';
|
||||||
|
}
|
||||||
|
function numberValue(raw) {
|
||||||
|
if (/^[0-9]+$/.test(raw)) return Number(raw);
|
||||||
|
if (/^[零〇]+$/.test(raw)) return 0;
|
||||||
|
if (raw === '一') return 1;
|
||||||
|
// Only zero and one affect this policy. Other explicit Chinese counts still
|
||||||
|
// form part of the signature but are never mistaken for a single attempt.
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
function evidenceText(scope) {
|
||||||
|
const candidates = new Set();
|
||||||
|
function walk(node) {
|
||||||
|
if (node.nodeType === 3) return node.textContent;
|
||||||
|
if (node.nodeType !== 1 || node.matches(IGNORE) || !visible(node)) return '';
|
||||||
|
if (node.tagName === 'BR') return '\n';
|
||||||
|
const text = Array.from(node.childNodes, walk).join(' ');
|
||||||
|
// Small DOM rows permit labels and values in separate inline spans while
|
||||||
|
// whole pages and unrelated paragraphs are never treated as one label.
|
||||||
|
if (text.length <= 240) candidates.add(clean(text));
|
||||||
|
return BLOCK.test(node.tagName) ? '\n' + text + '\n' : text;
|
||||||
|
}
|
||||||
|
const lines = walk(scope).split(/[\n,,;;。!!]+/).map(clean).filter(Boolean);
|
||||||
|
lines.forEach(line => { if (line.length <= 240) candidates.add(line); });
|
||||||
|
// Definition lists commonly render a status label and its value separately.
|
||||||
|
scope.querySelectorAll('dt').forEach(label => {
|
||||||
|
const value = label.nextElementSibling;
|
||||||
|
if (value && value.tagName === 'DD' && visible(label) && visible(value) && !label.closest(IGNORE) && !value.closest(IGNORE)) {
|
||||||
|
candidates.add(clean(walk(label) + ' ' + walk(value)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
function read(scope) {
|
||||||
|
const empty = { blocked: false, known: false, count: null, reason: '', signature: '[]' };
|
||||||
|
// The caller must pass the current exam/quiz container. Reading Document or
|
||||||
|
// body could borrow another course's limits or a hidden background dialog.
|
||||||
|
if (!scope || scope.nodeType !== 1 || /^(?:HTML|BODY)$/.test(scope.tagName) || !visible(scope) || scope.closest(IGNORE)) return empty;
|
||||||
|
const evidence = new Set();
|
||||||
|
evidenceText(scope).forEach(raw => {
|
||||||
|
const value = raw.replace(/\s+/g, '').replace(/[::]$/, '');
|
||||||
|
for (const expression of forms) {
|
||||||
|
const match = value.match(expression);
|
||||||
|
if (!match) continue;
|
||||||
|
evidence.add(JSON.stringify([value, numberValue(match[1])]));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const labels = Array.from(evidence).sort();
|
||||||
|
const values = labels.map(item => JSON.parse(item)[1]);
|
||||||
|
const count = values.includes(0) ? 0 : values.includes(1) ? 1 : null;
|
||||||
|
return {
|
||||||
|
blocked: count !== null,
|
||||||
|
known: labels.length > 0,
|
||||||
|
count: count,
|
||||||
|
reason: count === 0 ? '网页明确提示已无可用答题或考试次数,等待人工处理' : count === 1 ? '网页明确提示仅允许或剩余 1 次答题或考试机会,等待人工处理' : '',
|
||||||
|
signature: JSON.stringify(labels.map(item => JSON.parse(item)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const api = { read: read };
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.YianAttemptPolicy = api;
|
||||||
|
})(typeof window === 'object' ? window : typeof globalThis === 'object' ? globalThis : this);
|
||||||
|
|
||||||
/* YianLearningEngine: 读取网页实际目录、控制原生播放器并协调已授权的学习任务。 */
|
/* YianLearningEngine: 读取网页实际目录、控制原生播放器并协调已授权的学习任务。 */
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -2558,6 +2678,7 @@ window.JSZip = module.exports;
|
|||||||
const LEASE_MS = 18000;
|
const LEASE_MS = 18000;
|
||||||
const SESSION_KEY = 'session.v1';
|
const SESSION_KEY = 'session.v1';
|
||||||
const CONTROL_KEY = 'control.v1';
|
const CONTROL_KEY = 'control.v1';
|
||||||
|
const POLICY_KEYS = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||||
|
|
||||||
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
|
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
|
||||||
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
||||||
@@ -2567,6 +2688,9 @@ window.JSZip = module.exports;
|
|||||||
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
|
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
|
||||||
}
|
}
|
||||||
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
|
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
|
||||||
|
function examIdentity(value) {
|
||||||
|
try { const url = new URL(value); const match = url.pathname.match(/^\/clientcn\/(?:exam|examtest)\/([^/]+)(?:\/questions)?\/?$/); return match ? url.origin + ':exam:' + match[1] : ''; } catch (_) { return ''; }
|
||||||
|
}
|
||||||
function visible(node) {
|
function visible(node) {
|
||||||
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
|
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
|
||||||
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
|
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
|
||||||
@@ -2623,6 +2747,21 @@ window.JSZip = module.exports;
|
|||||||
if (portal) return portal.closest('.question-modal-pc');
|
if (portal) return portal.closest('.question-modal-pc');
|
||||||
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
|
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
|
||||||
}
|
}
|
||||||
|
function playbackPromptOf(doc) {
|
||||||
|
// 原站 ngx-bootstrap 将模板放在 body portal,app-dialog-box 自身可能为空。
|
||||||
|
const bodies = Array.from(doc.querySelectorAll('.modal-dialog.dialog_box .modal-body,[role="dialog"] .modal-body'))
|
||||||
|
.filter(node => visible(node) && /^是否继续上次播放的位置[??]$/.test(clean(node.textContent)));
|
||||||
|
const candidates = [];
|
||||||
|
for (const body of bodies) {
|
||||||
|
const container = body.closest('.modal-content,[role="dialog"]'); if (!container) continue;
|
||||||
|
if (candidates.some(item => item.container === container)) continue;
|
||||||
|
const buttons = Array.from(container.querySelectorAll('.modal-footer button')).filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||||||
|
const keep = buttons.filter(node => clean(node.textContent) === '继续播放');
|
||||||
|
const restart = buttons.filter(node => clean(node.textContent) === '重新开始');
|
||||||
|
candidates.push({ container, body, button: keep.length === 1 && restart.length === 1 ? keep[0] : null });
|
||||||
|
}
|
||||||
|
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -2712,10 +2851,13 @@ window.JSZip = module.exports;
|
|||||||
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
|
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
|
||||||
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
|
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
|
||||||
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 sharedPaused = false;
|
let sharedPaused = false;
|
||||||
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
let manualConfirmRevision = 0; let pendingManualConfirm = null;
|
||||||
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
|
let policySessionId = '', policyReadSerial = 0;
|
||||||
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
|
const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map();
|
||||||
|
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
|
||||||
|
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
|
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
|
||||||
@@ -2725,10 +2867,32 @@ window.JSZip = module.exports;
|
|||||||
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 onVideoPause(event) {
|
function onVideoPause(event) {
|
||||||
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
|
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
|
||||||
|
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rememberPlayback() {
|
||||||
|
const snap = snapshot(); if (!snap.document || !safeURL(snap.url, snap.url)) return;
|
||||||
|
const task = state.tasks.find(t => t.id === activeTaskId); const page = describePage(snap.document, snap.url);
|
||||||
|
if (state.status !== 'running' || config.videoPaused || manualVideoPause || !task || task.kind !== 'video'
|
||||||
|
|| !['loading', 'playing', 'retrying'].includes(task.status) || !page.video || page.quiz || playbackPromptOf(snap.document) || page.video.ended || page.platformComplete) return;
|
||||||
|
if (!visibilityPlayback && (playStarted || !page.video.paused)) visibilityPlayback = { taskId: task.id, url: page.url, video: page.video, generation };
|
||||||
|
lastAdvancedAt = now(); attemptedAt = 0;
|
||||||
|
}
|
||||||
|
function onPageHidden() { pageAway = true; rememberPlayback(); }
|
||||||
|
function onPageReturn() {
|
||||||
|
if (watchedDocument && watchedDocument.visibilityState === 'hidden') return;
|
||||||
|
pageAway = false; lastAdvancedAt = now(); attemptedAt = 0;
|
||||||
|
returnResumeRequested = Boolean(visibilityPlayback && config.resumePlaybackOnReturn);
|
||||||
|
// 仍走正常调度、租约和弹题检查,事件本身不直接点击或播放。
|
||||||
|
root.setTimeout(() => { if (!destroyed) tick().catch(() => {}); }, 0);
|
||||||
|
}
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (watchedDocument && watchedDocument.visibilityState === 'hidden') onPageHidden(); else onPageReturn();
|
||||||
}
|
}
|
||||||
function notify() {
|
function notify() {
|
||||||
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused });
|
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
|
||||||
|
for (const key of POLICY_KEYS) state[key] = config[key];
|
||||||
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
|
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
|
||||||
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
|
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
|
||||||
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
|
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
|
||||||
@@ -2762,6 +2926,23 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
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'); }
|
||||||
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
|
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
|
||||||
|
function readExamAttempt(task, snap, page) {
|
||||||
|
const scope = snap.document.querySelector('app-examdetail,app-examtest,.pc_examtest');
|
||||||
|
const live = root.YianAttemptPolicy?.read(scope) || { known: false, blocked: false, count: null, reason: '', signature: '[]' };
|
||||||
|
if (!['exam', 'paper'].includes(page.kind)) return live;
|
||||||
|
const taskURL = task && (task.examPaperURL || task.url || task.pageUrl);
|
||||||
|
const sameExam = taskURL && examIdentity(taskURL) && examIdentity(taskURL) === examIdentity(page.url);
|
||||||
|
if (task && task.kind === 'exam' && (canonical(taskURL) === page.url || sameExam)) {
|
||||||
|
if (live.known) { task.attemptPolicy = clone(live); task.attemptPolicyURL = page.url; }
|
||||||
|
// 同一任务从详情进入试卷后,次数提示可能不再显示;保留已核对的证据。
|
||||||
|
else if (task.attemptPolicy && (task.attemptPolicyURL === page.url || sameExam)) return clone(task.attemptPolicy);
|
||||||
|
}
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
function getAttemptPolicy() {
|
||||||
|
const snap = snapshot(); if (!snap.document) return { known: false, blocked: false, count: null, reason: '', signature: '[]' };
|
||||||
|
return readExamAttempt(state.tasks.find(t => t.id === activeTaskId), snap, describePage(snap.document, snap.url));
|
||||||
|
}
|
||||||
async function persist() {
|
async function persist() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
|
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
|
||||||
@@ -2773,7 +2954,7 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
const currentControl = await storage.get(CONTROL_KEY);
|
const currentControl = await storage.get(CONTROL_KEY);
|
||||||
if (currentControl && currentControl.sessionId !== session.id) return;
|
if (currentControl && currentControl.sessionId !== session.id) return;
|
||||||
syncManualConfirmation(currentControl); session.config = clone(config);
|
await syncPolicies(currentControl); session.config = clone(config);
|
||||||
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
|
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
|
||||||
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
|
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
|
||||||
}
|
}
|
||||||
@@ -2806,14 +2987,25 @@ window.JSZip = module.exports;
|
|||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
|
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
|
||||||
const current = state.tasks.find(t => t.id === activeTaskId);
|
const current = state.tasks.find(t => t.id === activeTaskId);
|
||||||
|
const conflictingExam = current && task.kind === 'exam' && examIdentity(current.examPaperURL || current.url || current.pageUrl)
|
||||||
|
&& examIdentity(task.url || task.pageUrl) && examIdentity(current.examPaperURL || current.url || current.pageUrl) !== examIdentity(task.url || task.pageUrl);
|
||||||
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
|
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
|
||||||
const alias = task.current && current && current.kind === task.kind &&
|
const alias = task.current && current && current.kind === task.kind &&
|
||||||
(task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || task.kind === 'video' && task.title === current.title);
|
(task.kind === 'exam' && !conflictingExam && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title
|
||||||
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity) || (alias ? current : null);
|
&& (!current.examPaperURL || current.examPaperURL === task.url)
|
||||||
|
&& (!current.enterClickedAt || now() - current.enterClickedAt < 60000 || current.examPaperURL === task.url)
|
||||||
|
|| task.kind === 'video' && task.title === current.title);
|
||||||
|
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity
|
||||||
|
|| task.current && task.kind === t.kind && task.url && (canonical(t.examPaperURL || t.url) === canonical(task.url)
|
||||||
|
|| task.kind === 'exam' && examIdentity(t.examPaperURL || t.url || t.pageUrl) && examIdentity(t.examPaperURL || t.url || t.pageUrl) === examIdentity(task.url))) || (alias ? current : null);
|
||||||
if (!old) state.tasks.push(task);
|
if (!old) state.tasks.push(task);
|
||||||
else {
|
else {
|
||||||
if (alias && task.url) old.url = task.url;
|
if (alias && task.url) old.url = task.url;
|
||||||
if (task.status === 'completed') { old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成'; }
|
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
|
||||||
|
if (task.status === 'completed') {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2837,6 +3029,8 @@ window.JSZip = module.exports;
|
|||||||
async function initializeWork() {
|
async function initializeWork() {
|
||||||
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);
|
||||||
|
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);
|
||||||
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
|
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
|
||||||
@@ -2872,33 +3066,81 @@ 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 (session) syncManualConfirmation(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();
|
||||||
}
|
}
|
||||||
function syncManualConfirmation(shared) {
|
function syncManualConfirmation(shared) {
|
||||||
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
|
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
|
||||||
const revision = Number(shared.manualConfirmRevision) || 0;
|
const revision = Number(shared.manualConfirmRevision) || 0;
|
||||||
if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return;
|
if (revision < manualConfirmRevision) return;
|
||||||
config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision;
|
for (const key of POLICY_KEYS) if (!authoritativePolicies.has(key) && typeof shared.config[key] === 'boolean') { config[key] = shared.config[key]; state[key] = config[key]; }
|
||||||
session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete });
|
manualConfirmRevision = revision; session.config = Object.assign({}, session.config);
|
||||||
|
for (const key of POLICY_KEYS) session.config[key] = config[key];
|
||||||
|
}
|
||||||
|
function ensurePolicySession() {
|
||||||
|
const id = session?.id || '';
|
||||||
|
if (policySessionId === id) return;
|
||||||
|
policySessionId = id; authoritativePolicies.clear(); policyAppliedReads.clear(); policyLocalWrites.clear();
|
||||||
|
}
|
||||||
|
function policyKey(key, id) { return 'policy:' + (id || session.id) + ':' + key; }
|
||||||
|
async function syncPolicies(shared) {
|
||||||
|
if (!session) return;
|
||||||
|
ensurePolicySession();
|
||||||
|
syncManualConfirmation(shared);
|
||||||
|
const id = session.id, serial = ++policyReadSerial;
|
||||||
|
const writes = new Map(POLICY_KEYS.map(key => [key, policyLocalWrites.get(key) || 0]));
|
||||||
|
const records = await Promise.all(POLICY_KEYS.map(key => storage.get(policyKey(key, id))));
|
||||||
|
if (!session || session.id !== id || destroyed) return;
|
||||||
|
records.forEach((record, index) => {
|
||||||
|
const key = POLICY_KEYS[index];
|
||||||
|
if (!record || record.sessionId !== id || typeof record.value !== 'boolean'
|
||||||
|
|| pendingManualConfirm?.values && key in pendingManualConfirm.values
|
||||||
|
|| (policyLocalWrites.get(key) || 0) !== writes.get(key)
|
||||||
|
|| serial < (policyAppliedReads.get(key) || 0)) return;
|
||||||
|
// 独立键是共享策略的权威值;旧 control 全量快照不能覆盖它。
|
||||||
|
authoritativePolicies.add(key); policyAppliedReads.set(key, serial);
|
||||||
|
config[key] = record.value; state[key] = record.value;
|
||||||
|
});
|
||||||
|
session.config = Object.assign({}, session.config);
|
||||||
|
for (const key of POLICY_KEYS) session.config[key] = config[key];
|
||||||
}
|
}
|
||||||
async function control(status, force, explicitWorkerAction, manualPatch) {
|
async function control(status, force, explicitWorkerAction, manualPatch) {
|
||||||
if (!session || worker && !explicitWorkerAction) return;
|
if (!session || worker && !explicitWorkerAction) return;
|
||||||
|
const controlEpoch = generation, controlSessionId = session.id;
|
||||||
|
ensurePolicySession();
|
||||||
const current = await storage.get(CONTROL_KEY);
|
const current = await storage.get(CONTROL_KEY);
|
||||||
if (!force && current && current.sessionId !== session.id) return;
|
if (!force && current && current.sessionId !== session.id) return;
|
||||||
const sameSession = current && current.sessionId === session.id;
|
const sameSession = current && current.sessionId === session.id;
|
||||||
if (manualPatch) {
|
if (manualPatch) {
|
||||||
config.manualConfirmIncomplete = manualPatch.value;
|
if (sameSession && current.config) for (const key of POLICY_KEYS) {
|
||||||
|
if (!(key in manualPatch.values) && !authoritativePolicies.has(key) && typeof current.config[key] === 'boolean') config[key] = current.config[key];
|
||||||
|
}
|
||||||
|
Object.assign(config, manualPatch.values);
|
||||||
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
|
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
|
||||||
} else syncManualConfirmation(current);
|
const id = session.id;
|
||||||
|
await Promise.all(Object.entries(manualPatch.values).map(async ([key, value]) => {
|
||||||
|
authoritativePolicies.add(key);
|
||||||
|
await storage.set(policyKey(key, id), { sessionId: id, value, revision: manualConfirmRevision, updatedAt: now(), owner });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await syncPolicies(current);
|
||||||
|
// 策略通过独立键发布,不将旧 control 快照写回,避免覆盖另一页刚发出的暂停。
|
||||||
|
if (manualPatch && sameSession || controlEpoch !== generation || session?.id !== controlSessionId) return;
|
||||||
const sharedConfig = clone(worker && sameSession ? current.config : config);
|
const sharedConfig = clone(worker && sameSession ? current.config : config);
|
||||||
sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete;
|
for (const key of POLICY_KEYS) sharedConfig[key] = config[key];
|
||||||
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
|
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
|
||||||
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
|
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
|
||||||
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
|
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
|
||||||
}
|
}
|
||||||
async function configure(patch) {
|
async function configure(patch, restoreOptions) {
|
||||||
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
|
await initialize(); patch = patch || {};
|
||||||
|
if (restoreOptions?.restore === true && session) {
|
||||||
|
await syncPolicies(await storage.get(CONTROL_KEY));
|
||||||
|
// 普通设置快照可能来自另一个标签的旧全量写回;重载只恢复尚无权威记录的策略。
|
||||||
|
patch = Object.assign({}, patch);
|
||||||
|
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
|
||||||
|
}
|
||||||
|
const wasRunning = state.status === 'running';
|
||||||
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;
|
||||||
@@ -2911,8 +3153,12 @@ 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;
|
||||||
const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null;
|
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
|
||||||
if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; }
|
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
|
||||||
|
if (manualPatch) {
|
||||||
|
ensurePolicySession(); pendingManualConfirm = manualPatch; Object.assign(config, policies);
|
||||||
|
for (const key of Object.keys(policies)) policyLocalWrites.set(key, (policyLocalWrites.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
if (patch.clearLogs) state.logs = [];
|
if (patch.clearLogs) state.logs = [];
|
||||||
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
|
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
|
||||||
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
|
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
|
||||||
@@ -2931,14 +3177,19 @@ window.JSZip = module.exports;
|
|||||||
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
|
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
|
||||||
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
|
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
|
||||||
if (previous) await release();
|
if (previous) await release();
|
||||||
|
visibilityPlayback = null; returnResumeRequested = false;
|
||||||
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
|
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
|
||||||
return tick();
|
return tick();
|
||||||
}
|
}
|
||||||
async function pause(reason) {
|
async function pause(reason) {
|
||||||
await initialize(); generation++; state.status = 'paused'; sharedPaused = true; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
|
await initialize(); const pauseEpoch = ++generation; visibilityPlayback = null; returnResumeRequested = false; state.status = 'paused'; sharedPaused = false; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
|
||||||
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
|
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
|
||||||
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
|
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
|
||||||
await control('paused', false, true); await release(); await persist(); log('info', state.message); return getState();
|
await control('paused', false, true);
|
||||||
|
if (pauseEpoch !== generation || state.status !== 'paused') return getState();
|
||||||
|
// 暂停写入完成后才接受远端“继续”;使写入期间读到旧 running 的轮询失效。
|
||||||
|
sharedPaused = true; generation++;
|
||||||
|
await release(); await persist(); log('info', state.message); return getState();
|
||||||
}
|
}
|
||||||
async function resume() {
|
async function resume() {
|
||||||
await initialize(); if (!session) return start();
|
await initialize(); if (!session) return start();
|
||||||
@@ -2948,7 +3199,7 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
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) || t.status === 'waitingPlatform') && !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++; 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();
|
||||||
}
|
}
|
||||||
async function stop() {
|
async function stop() {
|
||||||
@@ -2962,18 +3213,21 @@ 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))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
|
if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
|
||||||
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false;
|
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
|
||||||
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);
|
||||||
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
taskState(task, 'loading', '正在通过网页现有入口打开任务');
|
||||||
|
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 : '');
|
||||||
task.navigationRequestedAt = now();
|
|
||||||
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) { (node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true; }
|
if (node) {
|
||||||
|
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -3001,7 +3255,23 @@ window.JSZip = module.exports;
|
|||||||
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: page.learnedSeconds, requiredSeconds: page.requiredSeconds, 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: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
|
||||||
|
const playbackPrompt = playbackPromptOf(snap.document);
|
||||||
|
if (playbackPrompt && !popup) {
|
||||||
|
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
|
||||||
|
if (manualVideoPause || !config.autoContinuePlayback || playbackPrompt.ambiguous || !playbackPrompt.button) {
|
||||||
|
taskState(task, 'waiting', manualVideoPause ? '用户已暂停,请手动选择续播并继续任务' : !config.autoContinuePlayback ? '自动续播已关闭,请在网页选择继续播放或重新开始后继续任务' : '续播弹窗无法唯一确认,请人工选择后继续'); return;
|
||||||
|
}
|
||||||
|
if (playbackPromptClick && playbackPromptClick.container === playbackPrompt.container) {
|
||||||
|
if (now() - playbackPromptClick.at > 15000) taskState(task, 'waiting', '已选择继续播放,但网页弹窗未关闭,请人工检查');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (epoch !== generation || state.status !== 'running' || config.videoPaused || !config.autoContinuePlayback) return;
|
||||||
|
playbackPromptClick = { container: playbackPrompt.container, at: now() }; playStarted = false; lastTime = null;
|
||||||
|
taskState(task, 'loading', '已选择继续上次位置,等待网页恢复播放器'); playbackPrompt.button.click(); return;
|
||||||
|
}
|
||||||
|
if (!playbackPrompt) playbackPromptClick = null;
|
||||||
if (popup) {
|
if (popup) {
|
||||||
|
visibilityPlayback = null; returnResumeRequested = false;
|
||||||
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
|
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
|
||||||
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
|
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
|
||||||
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
|
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
|
||||||
@@ -3009,7 +3279,7 @@ window.JSZip = module.exports;
|
|||||||
if (pendingQuiz) return;
|
if (pendingQuiz) return;
|
||||||
pendingQuiz = popup;
|
pendingQuiz = popup;
|
||||||
try {
|
try {
|
||||||
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, document: snap.document });
|
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz, document: snap.document });
|
||||||
if (epoch !== generation || state.status !== 'running') return;
|
if (epoch !== generation || state.status !== 'running') return;
|
||||||
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
|
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
|
||||||
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
|
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
|
||||||
@@ -3019,6 +3289,19 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
|
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
|
||||||
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
|
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
|
||||||
|
if ((page.hidden || pageAway) && video.paused && (playStarted || visibilityPlayback)) {
|
||||||
|
rememberPlayback(); lastAdvancedAt = now(); attemptedAt = 0;
|
||||||
|
state.video.status = 'backgroundPaused'; taskState(task, 'playing', '网站在后台暂停播放,等待切回页面后恢复'); return;
|
||||||
|
}
|
||||||
|
if (returnResumeRequested) {
|
||||||
|
const previous = visibilityPlayback; returnResumeRequested = false; visibilityPlayback = null;
|
||||||
|
if (previous && previous.taskId === task.id && previous.url === page.url && previous.video === video && previous.generation === epoch && config.resumePlaybackOnReturn) {
|
||||||
|
lastAdvancedAt = now(); attemptedAt = 0; lastTime = null;
|
||||||
|
if (video.paused) { playStarted = false; taskState(task, 'playing', '已切回页面,正在继续原播放位置'); }
|
||||||
|
}
|
||||||
|
} else if (visibilityPlayback && !page.hidden && !pageAway && video.paused && !config.resumePlaybackOnReturn) {
|
||||||
|
state.video.status = 'waiting'; taskState(task, 'waiting', '切回页面自动恢复已关闭,请手动播放后继续任务'); return;
|
||||||
|
}
|
||||||
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
|
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
|
||||||
state.video.actualRate = video.playbackRate;
|
state.video.actualRate = video.playbackRate;
|
||||||
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
|
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
|
||||||
@@ -3061,6 +3344,17 @@ window.JSZip = module.exports;
|
|||||||
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
|
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
|
||||||
}
|
}
|
||||||
async function examTick(task, snap, page) {
|
async function examTick(task, snap, page) {
|
||||||
|
if (['exam', 'paper'].includes(page.kind)) {
|
||||||
|
const sourceURL = task.examPaperURL || task.url || task.pageUrl;
|
||||||
|
const sameURL = canonical(sourceURL) === page.url || examIdentity(sourceURL) && examIdentity(sourceURL) === examIdentity(page.url);
|
||||||
|
if (!sameURL) { taskState(task, 'waiting', '当前考试与已领取任务的地址不同,请核对后开始新一轮'); return; }
|
||||||
|
if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url;
|
||||||
|
}
|
||||||
|
const attempts = readExamAttempt(task, snap, page);
|
||||||
|
if (config.skipSingleAttemptQuiz && attempts.blocked && !task.examRequested) {
|
||||||
|
state.exam = { taskId: task.id, title: task.title, stage: 'waiting', requiresManualSubmission: true, message: attempts.reason };
|
||||||
|
taskState(task, 'waiting', attempts.reason); return;
|
||||||
|
}
|
||||||
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
|
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
|
||||||
if (task.status === 'waiting' || config.examPaused) {
|
if (task.status === 'waiting' || config.examPaused) {
|
||||||
await observeExamState(task, true);
|
await observeExamState(task, true);
|
||||||
@@ -3165,14 +3459,15 @@ window.JSZip = module.exports;
|
|||||||
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
|
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
|
||||||
if (session) {
|
if (session) {
|
||||||
const controlValue = await storage.get(CONTROL_KEY);
|
const controlValue = await storage.get(CONTROL_KEY);
|
||||||
|
if (epoch !== generation) return getState();
|
||||||
if (await observeControl(controlValue)) return getState();
|
if (await observeControl(controlValue)) return getState();
|
||||||
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
|
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
|
||||||
const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete;
|
const sharedConfig = Object.assign({}, controlValue.config); for (const key of POLICY_KEYS) delete sharedConfig[key];
|
||||||
Object.assign(config, sharedConfig);
|
Object.assign(config, sharedConfig);
|
||||||
}
|
}
|
||||||
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(); }
|
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = 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' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
|
||||||
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
|
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
|
||||||
@@ -3204,8 +3499,10 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
async function observeControl(value) {
|
async function observeControl(value) {
|
||||||
if (!session) return false;
|
if (!session) return false;
|
||||||
|
const controlEpoch = generation;
|
||||||
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
|
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
|
||||||
syncManualConfirmation(shared);
|
await syncPolicies(shared);
|
||||||
|
if (controlEpoch !== generation) return true;
|
||||||
if (state.status !== 'running' && !sharedPaused) return false;
|
if (state.status !== 'running' && !sharedPaused) return false;
|
||||||
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
|
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
|
||||||
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
|
||||||
@@ -3223,10 +3520,14 @@ window.JSZip = module.exports;
|
|||||||
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
|
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
|
||||||
function destroy() {
|
function destroy() {
|
||||||
destroyed = true; generation++;
|
destroyed = true; generation++;
|
||||||
if (watchedDocument) { watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true); }
|
if (watchedDocument) {
|
||||||
|
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
|
||||||
|
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
|
||||||
|
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(() => {});
|
||||||
}
|
}
|
||||||
return { configure, start, pause, resume, stop, tick, getState, destroy, log, clearLogs };
|
return { configure, start, pause, resume, stop, tick, getState, getAttemptPolicy, destroy, log, clearLogs };
|
||||||
}
|
}
|
||||||
return { create, describePage, discoverTasks, preflightTopic };
|
return { create, describePage, discoverTasks, preflightTopic };
|
||||||
});
|
});
|
||||||
@@ -3297,11 +3598,16 @@ window.JSZip = module.exports;
|
|||||||
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
|
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
|
||||||
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
|
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
|
||||||
}
|
}
|
||||||
|
function attemptPolicy(view) {
|
||||||
|
if (!view?.container) return null;
|
||||||
|
const scope = view.container.closest(OWNER) || view.container;
|
||||||
|
return root.YianAttemptPolicy?.read?.(scope) || null;
|
||||||
|
}
|
||||||
async function run(options) {
|
async function run(options) {
|
||||||
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
|
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
|
||||||
const context = options.context || {};
|
const context = options.context || {};
|
||||||
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
|
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
|
||||||
let cancelled = false, verifiedCount = 0;
|
let cancelled = false, verifiedCount = 0, attemptBlocked = null;
|
||||||
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
|
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
|
||||||
const configured = Number(context.answerInterval);
|
const configured = Number(context.answerInterval);
|
||||||
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
|
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
|
||||||
@@ -3310,6 +3616,17 @@ window.JSZip = module.exports;
|
|||||||
if (cancelled || options.signal?.aborted) return false;
|
if (cancelled || options.signal?.aborted) return false;
|
||||||
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
|
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
|
||||||
};
|
};
|
||||||
|
const attemptAllowed = () => {
|
||||||
|
if (context.skipSingleAttemptQuiz === false) return true;
|
||||||
|
if (!root.YianAttemptPolicy || typeof root.YianAttemptPolicy.read !== 'function') {
|
||||||
|
attemptBlocked = '答题次数核验组件未就绪,已停止自动操作,请人工处理';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const policy = attemptPolicy(detect(doc));
|
||||||
|
if (!policy?.blocked) return true;
|
||||||
|
attemptBlocked = policy.reason || '网页提示课中题答题机会受限,已停止自动操作,请人工处理';
|
||||||
|
return false;
|
||||||
|
};
|
||||||
const manual = event => {
|
const manual = event => {
|
||||||
if (!event.isTrusted) return;
|
if (!event.isTrusted) return;
|
||||||
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
|
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
|
||||||
@@ -3334,16 +3651,18 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const click = node => { if (!active() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
|
const click = node => { if (!active() || !attemptAllowed() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
|
||||||
try {
|
try {
|
||||||
let view = detect(doc);
|
let view = detect(doc);
|
||||||
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
|
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
|
||||||
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
|
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
|
||||||
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
||||||
|
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
|
||||||
const original = read(view, context);
|
const original = read(view, context);
|
||||||
if (original.error) return result('waiting', original.error);
|
if (original.error) return result('waiting', original.error);
|
||||||
const matched = await matcher.match(original.question, context, options.snapshot);
|
const matched = await matcher.match(original.question, context, options.snapshot);
|
||||||
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
|
||||||
|
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
|
||||||
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
|
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
|
||||||
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
|
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
|
||||||
const current = () => {
|
const current = () => {
|
||||||
@@ -3361,7 +3680,7 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
for (const step of plan) {
|
for (const step of plan) {
|
||||||
const latest = current();
|
const latest = current();
|
||||||
if (!latest || !click(latest.nodes[step.i])) return result('waiting', '题目或任务状态已变化,停止课中答题');
|
if (!latest || !click(latest.nodes[step.i])) return result('waiting', attemptBlocked || '题目或任务状态已变化,停止课中答题', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||||||
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
|
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
|
||||||
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
|
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
|
||||||
if (!await pause(150)) return result('waiting', '课中答题已取消');
|
if (!await pause(150)) return result('waiting', '课中答题已取消');
|
||||||
@@ -3369,7 +3688,7 @@ window.JSZip = module.exports;
|
|||||||
const evidence = current();
|
const evidence = current();
|
||||||
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
|
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
|
||||||
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
|
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
|
||||||
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', '课中题提交入口无法唯一确认');
|
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', attemptBlocked || '课中题提交入口无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||||||
// The real template replaces the question with an answer overlay immediately;
|
// The real template replaces the question with an answer overlay immediately;
|
||||||
// absence of the question alone does not imply that the grade request succeeded.
|
// absence of the question alone does not imply that the grade request succeeded.
|
||||||
const graded = await waitFor(() => {
|
const graded = await waitFor(() => {
|
||||||
@@ -3388,7 +3707,7 @@ window.JSZip = module.exports;
|
|||||||
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
|
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
|
||||||
const advances = buttons(graded.container, /^(下一题|关闭)$/);
|
const advances = buttons(graded.container, /^(下一题|关闭)$/);
|
||||||
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
|
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
|
||||||
if (advances.length !== 1 || !click(advances[0])) return result('waiting', '课中题已判定正确,但下一题或关闭按钮无法唯一确认');
|
if (advances.length !== 1 || !click(advances[0])) return result('waiting', attemptBlocked || '课中题已判定正确,但下一题或关闭按钮无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
|
||||||
const next = await waitFor(() => {
|
const next = await waitFor(() => {
|
||||||
const seen = detect(doc);
|
const seen = detect(doc);
|
||||||
// CDK detach/attach can briefly render neither overlay between questions.
|
// CDK detach/attach can briefly render neither overlay between questions.
|
||||||
@@ -3520,8 +3839,24 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
function settings() { return options.getSettings ? options.getSettings() : {}; }
|
function settings() { return options.getSettings ? options.getSettings() : {}; }
|
||||||
function manualIncomplete() { return settings().manualConfirmIncomplete !== false; }
|
function manualIncomplete() { return settings().manualConfirmIncomplete !== false; }
|
||||||
|
function attemptPolicy() {
|
||||||
|
const policy = (options.getAttemptPolicy ? options.getAttemptPolicy() : root.YianAttemptPolicy?.read(doc.querySelector('app-examdetail,app-examtest,.pc_examtest'))) || {};
|
||||||
|
const active = settings().skipSingleAttemptQuiz !== false;
|
||||||
|
return { active, blocked: active && policy.blocked === true, count: policy.count == null ? null : policy.count,
|
||||||
|
reason: clean(policy.reason) || '仅有 1 次答题机会,已停止自动答题与交卷,请在原网页人工操作',
|
||||||
|
signature: active ? JSON.stringify([policy.signature || '', policy.blocked === true, policy.count == null ? null : policy.count]) : '' };
|
||||||
|
}
|
||||||
|
function blockSingleAttempt(policy) {
|
||||||
|
generation++; enabled = false;
|
||||||
|
if (['submitting', 'confirming', 'unknown'].includes(stage)) stage = 'unknown';
|
||||||
|
else stage = 'waiting';
|
||||||
|
error = policy.reason;
|
||||||
|
return emit({ skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: true, attemptCount: policy.count,
|
||||||
|
requiresManualSubmission: true, message: error });
|
||||||
|
}
|
||||||
function scopeValue(ctx, evidence, topic) {
|
function scopeValue(ctx, evidence, topic) {
|
||||||
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete()]);
|
const policy = attemptPolicy();
|
||||||
|
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete(), policy.active, policy.signature]);
|
||||||
}
|
}
|
||||||
function resultVisible() {
|
function resultVisible() {
|
||||||
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
|
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
|
||||||
@@ -3576,8 +3911,12 @@ window.JSZip = module.exports;
|
|||||||
async function enable() {
|
async function enable() {
|
||||||
const token = ++generation, requested = context();
|
const token = ++generation, requested = context();
|
||||||
enabled = false;
|
enabled = false;
|
||||||
|
const initialPolicy = attemptPolicy();
|
||||||
|
if (initialPolicy.blocked) { blockSingleAttempt(initialPolicy); return false; }
|
||||||
await observe();
|
await observe();
|
||||||
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
|
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
|
||||||
|
const currentPolicy = attemptPolicy();
|
||||||
|
if (currentPolicy.blocked) { blockSingleAttempt(currentPolicy); return false; }
|
||||||
if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) {
|
if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) {
|
||||||
stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷';
|
stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷';
|
||||||
emit({ requiresManualSubmission: true, message: error }); return false;
|
emit({ requiresManualSubmission: true, message: error }); return false;
|
||||||
@@ -3596,6 +3935,8 @@ window.JSZip = module.exports;
|
|||||||
if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; }
|
if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; }
|
||||||
scope = scopeValue(ctx, evidence, topic);
|
scope = scopeValue(ctx, evidence, topic);
|
||||||
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
|
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
|
||||||
|
const finalPolicy = attemptPolicy();
|
||||||
|
if (token === generation && finalPolicy.blocked) { blockSingleAttempt(finalPolicy); return false; }
|
||||||
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
|
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
|
||||||
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
|
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
|
||||||
}
|
}
|
||||||
@@ -3616,6 +3957,8 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
function canClick(token, ctx, confirmation) {
|
function canClick(token, ctx, confirmation) {
|
||||||
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
|
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
|
||||||
|
const policy = attemptPolicy();
|
||||||
|
if (policy.blocked) { blockSingleAttempt(policy); return false; }
|
||||||
const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic);
|
const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic);
|
||||||
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
||||||
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
|
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
|
||||||
@@ -3654,6 +3997,7 @@ window.JSZip = module.exports;
|
|||||||
return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false });
|
return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false });
|
||||||
}
|
}
|
||||||
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
|
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
|
||||||
|
const policy = attemptPolicy();
|
||||||
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
|
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
|
||||||
const configured = Number(settings().minSubmitMinutes);
|
const configured = Number(settings().minSubmitMinutes);
|
||||||
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
|
||||||
@@ -3663,8 +4007,10 @@ window.JSZip = module.exports;
|
|||||||
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
|
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
|
||||||
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
|
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
|
||||||
manualConfirmIncomplete: manualIncomplete(),
|
manualConfirmIncomplete: manualIncomplete(),
|
||||||
|
skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: policy.blocked, attemptCount: policy.count,
|
||||||
requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified),
|
requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified),
|
||||||
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
|
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
|
||||||
|
if (policy.blocked) return blockSingleAttempt(policy);
|
||||||
if (!enabled) return last;
|
if (!enabled) return last;
|
||||||
if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') {
|
if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') {
|
||||||
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
|
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
|
||||||
@@ -3762,9 +4108,16 @@ window.JSZip = module.exports;
|
|||||||
let positionRevision = 0;
|
let positionRevision = 0;
|
||||||
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 learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {} };
|
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||||
|
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: {} };
|
||||||
const inScope = () => YianSiteScope.isAllowedURL(location.href);
|
const inScope = () => YianSiteScope.isAllowedURL(location.href);
|
||||||
|
|
||||||
|
function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; }
|
||||||
|
function attemptPolicy() {
|
||||||
|
return learning?.getAttemptPolicy() || window.YianAttemptPolicy.read(document.querySelector('app-examdetail,app-examtest,.pc_examtest'));
|
||||||
|
}
|
||||||
|
|
||||||
function topicPreflight() {
|
function topicPreflight() {
|
||||||
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
|
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
|
||||||
}
|
}
|
||||||
@@ -3815,13 +4168,15 @@ window.JSZip = module.exports;
|
|||||||
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
||||||
const signature = route.root ? structureSignature(bank) : '';
|
const signature = route.root ? structureSignature(bank) : '';
|
||||||
const topic = route.mode === 'test' ? topicPreflight() : null;
|
const topic = route.mode === 'test' ? topicPreflight() : null;
|
||||||
|
const attempt = route.mode === 'test' ? attemptPolicy() : null;
|
||||||
|
const attemptBlocked = !!attempt && skipSingleAttempt() && attempt.blocked;
|
||||||
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
|
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
|
||||||
&& page.root === route.root && page.signature === signature,
|
&& page.root === route.root && page.signature === signature && !attemptBlocked,
|
||||||
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
|
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
|
||||||
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
|
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
|
||||||
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
|
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
|
||||||
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
|
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
|
||||||
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations]) : '' };
|
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations, skipSingleAttempt(), attempt.signature]) : '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const runner = window.YianAnswerRunner.create({ getContext,
|
const runner = window.YianAnswerRunner.create({ getContext,
|
||||||
@@ -3845,10 +4200,12 @@ window.JSZip = module.exports;
|
|||||||
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
|
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
|
||||||
const route = routeInfo();
|
const route = routeInfo();
|
||||||
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
|
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
|
||||||
panel.setState({ learning: Object.assign({}, learningSettings, learningState, {
|
const rendered = Object.assign({}, learningSettings, learningState, {
|
||||||
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
|
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
|
||||||
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
|
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
|
||||||
currentTitle: window.YianLearningEngine.describePage(document, location.href).title }) });
|
currentTitle: window.YianLearningEngine.describePage(document, location.href).title });
|
||||||
|
for (const [key, pending] of pendingLearningSettings) rendered[key] = pending.value;
|
||||||
|
panel.setState({ learning: rendered });
|
||||||
}
|
}
|
||||||
function refreshPanel(extra) {
|
function refreshPanel(extra) {
|
||||||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||||||
@@ -3875,6 +4232,9 @@ window.JSZip = module.exports;
|
|||||||
function setAutoAnswer(enabled) {
|
function setAutoAnswer(enabled) {
|
||||||
if (!enabled) actionEpoch++;
|
if (!enabled) actionEpoch++;
|
||||||
const context = getContext();
|
const context = getContext();
|
||||||
|
if (enabled && context.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked) {
|
||||||
|
panel.setNotice(attemptPolicy().reason, 'warning'); refreshPanel(); return false;
|
||||||
|
}
|
||||||
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
|
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
|
||||||
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
|
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
|
||||||
}
|
}
|
||||||
@@ -3941,11 +4301,11 @@ window.JSZip = module.exports;
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function changeLearningSetting(key, value) {
|
async function changeLearningSetting(key, value) {
|
||||||
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
|
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
|
||||||
if (!allowed.includes(key)) return;
|
if (!allowed.includes(key)) return;
|
||||||
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
|
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
|
||||||
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
|
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
|
||||||
if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关');
|
if (policyKeys.includes(key) && typeof value !== 'boolean') throw new Error('开关设置须为开或关');
|
||||||
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
|
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
|
||||||
if (ranges[key]) {
|
if (ranges[key]) {
|
||||||
const range = ranges[key]; value = Number(value);
|
const range = ranges[key]; value = Number(value);
|
||||||
@@ -3957,15 +4317,20 @@ window.JSZip = module.exports;
|
|||||||
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
|
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
|
||||||
throw new Error('主题关联须使用完整标题及题库课程名称列表');
|
throw new Error('主题关联须使用完整标题及题库课程名称列表');
|
||||||
}
|
}
|
||||||
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) {
|
const pending = { value }; pendingLearningSettings.set(key, pending);
|
||||||
actionEpoch++;
|
try {
|
||||||
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
|
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) {
|
||||||
await learning.pause();
|
actionEpoch++;
|
||||||
|
config.autoAnswer = false; runner.stop('执行策略或主题关联变更'); examController.disable('设置变更,须重新启动核验');
|
||||||
|
await learning.pause();
|
||||||
|
}
|
||||||
|
learningSettings = Object.assign({}, learningSettings, { [key]: value });
|
||||||
|
await learning.configure({ [key]: value });
|
||||||
|
await saveSetting('learningSettings', learningSettings);
|
||||||
|
} finally {
|
||||||
|
if (pendingLearningSettings.get(key) === pending) pendingLearningSettings.delete(key);
|
||||||
|
refreshPanel(); renderLearning();
|
||||||
}
|
}
|
||||||
learningSettings = Object.assign({}, learningSettings, { [key]: value });
|
|
||||||
await learning.configure({ [key]: value });
|
|
||||||
await saveSetting('learningSettings', learningSettings);
|
|
||||||
refreshPanel(); renderLearning();
|
|
||||||
}
|
}
|
||||||
async function taskAction(action) {
|
async function taskAction(action) {
|
||||||
actionEpoch++;
|
actionEpoch++;
|
||||||
@@ -3991,8 +4356,9 @@ window.JSZip = module.exports;
|
|||||||
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
|
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
|
||||||
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
|
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
|
||||||
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
|
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
|
||||||
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false
|
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false,
|
||||||
}), getRunnerState: () => runnerState,
|
skipSingleAttemptQuiz: skipSingleAttempt()
|
||||||
|
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
|
||||||
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
|
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
|
||||||
onState: state => { examState = state; renderLearning(); } });
|
onState: state => { examState = state; renderLearning(); } });
|
||||||
learning = window.YianLearningEngine.create({
|
learning = window.YianLearningEngine.create({
|
||||||
@@ -4012,7 +4378,7 @@ window.JSZip = module.exports;
|
|||||||
const epoch = actionEpoch;
|
const epoch = actionEpoch;
|
||||||
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
|
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
|
||||||
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
|
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
|
||||||
sourceUrl: location.href, answerInterval: quizContext.answerInterval },
|
sourceUrl: location.href, answerInterval: quizContext.answerInterval, skipSingleAttemptQuiz: quizContext.skipSingleAttemptQuiz },
|
||||||
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
|
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
|
||||||
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
|
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
|
||||||
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
|
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
|
||||||
@@ -4027,9 +4393,7 @@ window.JSZip = module.exports;
|
|||||||
},
|
},
|
||||||
onState: state => {
|
onState: state => {
|
||||||
learningState = state;
|
learningState = state;
|
||||||
if (!loading && typeof state.manualConfirmIncomplete === 'boolean') {
|
if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key];
|
||||||
learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete;
|
|
||||||
}
|
|
||||||
renderLearning();
|
renderLearning();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4054,6 +4418,9 @@ window.JSZip = module.exports;
|
|||||||
}
|
}
|
||||||
const route = routeInfo();
|
const route = routeInfo();
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
if (route.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked && config.autoAnswer) {
|
||||||
|
config.autoAnswer = false; runKey = ''; runner.stop(attemptPolicy().reason); panel.setNotice(attemptPolicy().reason, 'warning');
|
||||||
|
}
|
||||||
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
|
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||||||
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
|
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||||||
}
|
}
|
||||||
@@ -4132,7 +4499,9 @@ window.JSZip = module.exports;
|
|||||||
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
|
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
|
||||||
snapshot = values[0]; config.role = '';
|
snapshot = values[0]; config.role = '';
|
||||||
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
|
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
|
||||||
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings);
|
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings, { restore: true });
|
||||||
|
const restoredPolicies = learning.getState();
|
||||||
|
for (const key of policyKeys) learningSettings[key] = restoredPolicies[key];
|
||||||
config.allowCopy = values[2] !== false;
|
config.allowCopy = values[2] !== false;
|
||||||
const position = values[3];
|
const position = values[3];
|
||||||
// A drag during asynchronous storage loading wins over the older saved position.
|
// A drag during asynchronous storage loading wins over the older saved position.
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
| 缺题人工确认 | “考试答题”栏提供“题目不全时手动确认交卷”开关(`manualConfirmIncomplete`),默认开启;开启时任意题库缺题或未答完提醒都由用户在网站确认,关闭时允许满足原得分、计时及全文核验条件的部分题库试卷自动交卷 | 用户最新确认,默认开启 |
|
| 缺题人工确认 | “考试答题”栏提供“题目不全时手动确认交卷”开关(`manualConfirmIncomplete`),默认开启;开启时任意题库缺题或未答完提醒都由用户在网站确认,关闭时允许满足原得分、计时及全文核验条件的部分题库试卷自动交卷 | 用户最新确认,默认开启 |
|
||||||
| 题库结构 | 原主操、副操、班长题库统一合并使用,取消岗位选择及岗位隔离 | 用户已确认 |
|
| 题库结构 | 原主操、副操、班长题库统一合并使用,取消岗位选择及岗位隔离 | 用户已确认 |
|
||||||
| 题库对应 | 考试前检查当前考试主题与统一题库中课程/主题是否对应;没有对应题库则跳过该考试 | 用户已明确 |
|
| 题库对应 | 考试前检查当前考试主题与统一题库中课程/主题是否对应;没有对应题库则跳过该考试 | 用户已明确 |
|
||||||
|
| 默认任务模式 | 新安装默认“视频+考试(含课中答题)”;升级保留已经保存的模式,不覆盖用户明确的仅视频选择 | 按本次视频完成后继续考试的需求调整 |
|
||||||
|
| 续播弹窗 | “自动继续上次播放”开关默认开启;仅识别正文及两按钮完整对应的续播弹窗,选择“继续播放” | 用户新增需求 |
|
||||||
|
| 单次答题机会 | “仅 1 次答题机会时交给我处理”开关默认开启;仅剩 1 次或总共仅允许 1 次机会时,课中题和课程考试交给用户手动答题;0 次同样不自动操作 | 用户已明确机会数含义,不是本人已答 1 次 |
|
||||||
|
| 返回续播 | “切回页面时自动恢复播放”开关默认开启;只恢复切页前正在播放的视频,手动暂停及待人工状态优先 | 用户新增需求 |
|
||||||
| 执行方式 | 顺序、并行可切换,默认顺序 | 用户已确认 |
|
| 执行方式 | 顺序、并行可切换,默认顺序 | 用户已确认 |
|
||||||
| 面板布局 | 按功能分栏,桌面采用左侧功能导航、右侧内容区,窄屏改为顶部标签切换 | 按功能分栏已确认,具体排布为补充方案 |
|
| 面板布局 | 按功能分栏,桌面采用左侧功能导航、右侧内容区,窄屏改为顶部标签切换 | 按功能分栏已确认,具体排布为补充方案 |
|
||||||
| 并行数量 | 同时最多 1 个视频任务和 1 场考试;课中题属于视频任务 | 补充建议 |
|
| 并行数量 | 同时最多 1 个视频任务和 1 场考试;课中题属于视频任务 | 补充建议 |
|
||||||
@@ -41,6 +45,8 @@
|
|||||||
| 仅考试 | 不播放 | 不处理 | 主题预检、作答、计分和按条件交卷 |
|
| 仅考试 | 不播放 | 不处理 | 主题预检、作答、计分和按条件交卷 |
|
||||||
| 视频+考试 | 自动播放 | 自动精确匹配并作答 | 主题预检、作答、计分和按条件交卷 |
|
| 视频+考试 | 自动播放 | 自动精确匹配并作答 | 主题预检、作答、计分和按条件交卷 |
|
||||||
|
|
||||||
|
- 新安装默认“视频+考试”;已有保存的模式保留,不因升级覆盖用户选择。“仅视频”“视频+课中答题”完成视频后不会进入独立或关联考试,面板须说明如何切换到“视频+考试”。
|
||||||
|
- “视频+考试”模式须在平台确认视频完成、关联考试已开放后继续进入考试;只观察到播放器 ended 不够,单次机会保护及题库主题预检仍然生效。
|
||||||
- 顺序:依照任务清单完成视频及其课中题,再处理满足前置条件的关联考试。
|
- 顺序:依照任务清单完成视频及其课中题,再处理满足前置条件的关联考试。
|
||||||
- 并行:一个受控标签页处理视频及课中题,另一个处理已开放的考试;两个页面共享总任务进度,不能重复领取同一任务。
|
- 并行:一个受控标签页处理视频及课中题,另一个处理已开放的考试;两个页面共享总任务进度,不能重复领取同一任务。
|
||||||
- 并行也须遵守网站的解锁顺序。需要先完成视频才能参加的考试,不能提前启动。
|
- 并行也须遵守网站的解锁顺序。需要先完成视频才能参加的考试,不能提前启动。
|
||||||
@@ -68,6 +74,9 @@
|
|||||||
- “视频+课中答题”模式复用统一题库,以题型、完整题干、全部选项及正确答案全文严格匹配;核对所选集合并确认弹题已关闭或提交成功后继续播放。
|
- “视频+课中答题”模式复用统一题库,以题型、完整题干、全部选项及正确答案全文严格匹配;核对所选集合并确认弹题已关闭或提交成功后继续播放。
|
||||||
- 仅视频模式遇到强制弹题,或课中题无匹配、答案冲突、图片题或未适配题型时,暂停该课件并提示人工处理,保留已播放记录。
|
- 仅视频模式遇到强制弹题,或课中题无匹配、答案冲突、图片题或未适配题型时,暂停该课件并提示人工处理,保留已播放记录。
|
||||||
- 课中题暂不套用考试的 6 分钟交卷规则及 80 分门槛;是否答题正确、如何继续播放以实际弹题流程为准。
|
- 课中题暂不套用考试的 6 分钟交卷规则及 80 分门槛;是否答题正确、如何继续播放以实际弹题流程为准。
|
||||||
|
- 视频栏提供独立的“自动继续上次播放”开关(`autoContinuePlayback`),默认开启并保存。运行任务中仅在唯一可见弹窗正文对应“是否继续上次播放的位置?”且同时有“重新开始”“继续播放”两个按钮时,正常点击“继续播放”;关闭时等用户处理,不自动选择重新开始或点击其他弹窗。
|
||||||
|
- 视频栏提供“仅 1 次答题机会时交给我处理”开关(`skipSingleAttemptQuiz`),默认开启并保存,覆盖课中题和课程考试。仅从当前弹题/考试范围内明确可见的允许、总、剩余机会数以及“还可补考 N 次”等提示取证;1 次或 0 次时停止受影响的自动进入、选项和提交,等待用户正常手动处理。
|
||||||
|
- 单次规则不指“本人已经答过 1 次”。题干、选项、答案、隐藏节点、已答题数、已答次数、交卷人数及 Angular 私有状态不能作为机会数证据;未显示明确次数时不推断。关闭单次保护不会跳过题库全文核验、计分及交卷规则。
|
||||||
|
|
||||||
## 6. 统一题库与考试主题预检
|
## 6. 统一题库与考试主题预检
|
||||||
|
|
||||||
@@ -141,8 +150,9 @@
|
|||||||
- 登录失效、自动播放被浏览器拒绝、验证弹窗、提交结果不明或达到重试上限时,暂停受影响任务并提示人工处理。
|
- 登录失效、自动播放被浏览器拒绝、验证弹窗、提交结果不明或达到重试上限时,暂停受影响任务并提示人工处理。
|
||||||
- 全局“暂停”停止后续选择、交卷、任务跳转及自动刷新;“停止本轮”退出调度但保留已发生的学习与作答记录,两者不能假装撤销网站操作。
|
- 全局“暂停”停止后续选择、交卷、任务跳转及自动刷新;“停止本轮”退出调度但保留已发生的学习与作答记录,两者不能假装撤销网站操作。
|
||||||
- 脚本自己发起的恢复刷新可继续同一已授权任务;人工刷新、重新打开浏览器时恢复任务清单但默认暂停,提供“继续上次任务”。恢复前核对账号、学习计划、任务、题库版本和网站状态。
|
- 脚本自己发起的恢复刷新可继续同一已授权任务;人工刷新、重新打开浏览器时恢复任务清单但默认暂停,提供“继续上次任务”。恢复前核对账号、学习计划、任务、题库版本和网站状态。
|
||||||
- 后台指切换标签页或窗口最小化后尽量继续播放和执行;计时与进度要在恢复前台时校准,不能靠定时器回调次数虚增学习时长。
|
- 保存站点脚本显示,网站在切到后台时主动暂停播放器,返回前台没有对应恢复逻辑。本版提供“切回页面时自动恢复播放”开关(`resumePlaybackOnReturn`),默认开启并保存;记录切页前正在播放的当前视频,在返回后尝试正常续播,后台暂停显示“后台暂停,等待返回”,不误判为长时间卡顿并刷新。
|
||||||
- 不承诺关闭浏览器、设备休眠或标签被浏览器冻结期间仍持续执行。后台被网站或浏览器中断时,显示真实状态并走受限恢复流程,不能显示“正常学习”却没有平台记录。
|
- 手动暂停播放器、全局或视频任务暂停、课中题及其他待人工状态优先,不能因为返回前台覆盖用户暂停。关闭恢复开关后不因切回页面自动播放。进度及计时只用真实播放器和平台记录校准,不靠回调次数虚增时长。
|
||||||
|
- 不承诺后台持续播放,也不承诺关闭浏览器、设备休眠或标签被冻结期间持续执行。后台被网站或浏览器中断时显示真实状态,不能显示“正常学习”却没有平台记录。
|
||||||
|
|
||||||
## 9. 面板、控制台与其他设置
|
## 9. 面板、控制台与其他设置
|
||||||
|
|
||||||
@@ -153,7 +163,7 @@
|
|||||||
| 功能栏 | 主要内容与操作 |
|
| 功能栏 | 主要内容与操作 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 任务总览 | 学习计划与任务清单、四种任务模式、顺序/并行、开始/停止本轮、总进度、完成/跳过/失败/等待人工统计、预计剩余时间;显示当前视频与考试摘要,点击可进入对应功能栏 |
|
| 任务总览 | 学习计划与任务清单、四种任务模式、顺序/并行、开始/停止本轮、总进度、完成/跳过/失败/等待人工统计、预计剩余时间;显示当前视频与考试摘要,点击可进入对应功能栏 |
|
||||||
| 视频学习 | 当前课程和课件、播放位置/总时长、平台已记录学习时长与完成状态、最高 2X 的倍速设置、视频任务暂停/继续、课中题匹配及处理状态、卡顿恢复状态 |
|
| 视频学习 | 当前课程和课件、播放位置/总时长、平台已记录学习时长与完成状态、最高 2X 的倍速设置、视频任务暂停/继续、课中题匹配及处理状态、卡顿恢复状态;默认开启的续播、单次机会保护、切回恢复开关及当前模式后续流程说明 |
|
||||||
| 考试答题 | 当前考试主题与题库预检结果、自动选择答案开关、默认开启的“题目不全时手动确认交卷”开关、默认 4 秒的答题间隔、默认 6 分钟的最早交卷时长、网站剩余时间、预计得分与严格大于 80 分的门槛、交卷等待原因、答案全文核对与未处理题清单 |
|
| 考试答题 | 当前考试主题与题库预检结果、自动选择答案开关、默认开启的“题目不全时手动确认交卷”开关、默认 4 秒的答题间隔、默认 6 分钟的最早交卷时长、网站剩余时间、预计得分与严格大于 80 分的门槛、交卷等待原因、答案全文核对与未处理题清单 |
|
||||||
| 题库搜索 | 统一题库导入与状态、来源记录数/题目数/冲突数、关键词搜索、课程/主题筛选、选中文字搜索、答案全文与来源、主题关联查看和维护;不显示岗位选择 |
|
| 题库搜索 | 统一题库导入与状态、来源记录数/题目数/冲突数、关键词搜索、课程/主题筛选、选中文字搜索、答案全文与来源、主题关联查看和维护;不显示岗位选择 |
|
||||||
| 控制台 | 带时间和任务名称的运行日志、状态/警告/错误筛选、复制日志、清空显示、恢复次数及交卷结果 |
|
| 控制台 | 带时间和任务名称的运行日志、状态/警告/错误筛选、复制日志、清空显示、恢复次数及交卷结果 |
|
||||||
@@ -186,6 +196,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 启动学习计划 | 列出实际课程/课件/考试任务,已完成项目不重复执行,阶段数与任务数不混算 |
|
| 启动学习计划 | 列出实际课程/课件/考试任务,已完成项目不重复执行,阶段数与任务数不混算 |
|
||||||
| 四种模式 | 视频、课中题、独立考试按所选模式执行;仅视频遇强制题等待人工 |
|
| 四种模式 | 视频、课中题、独立考试按所选模式执行;仅视频遇强制题等待人工 |
|
||||||
|
| 新旧默认及视频后续 | 新安装默认“视频+考试”,升级保留已选模式;平台确认视频完成后才进入已开放关联考试;仅视频及视频+课中答题模式明确提示不会进入考试 |
|
||||||
| 顺序与并行 | 默认顺序;并行最多一个视频和一场考试,不重复任务且遵守前置条件 |
|
| 顺序与并行 | 默认顺序;并行最多一个视频和一场考试,不重复任务且遵守前置条件 |
|
||||||
| 最高 2X | 面板与播放器实际倍率一致;网站学习时长和完成状态独立核对 |
|
| 最高 2X | 面板与播放器实际倍率一致;网站学习时长和完成状态独立核对 |
|
||||||
| 主题预检 | 显示考试与题库对应关系,无库不自动开考;名称不同的明确关联可用 |
|
| 主题预检 | 显示考试与题库对应关系,无库不自动开考;名称不同的明确关联可用 |
|
||||||
@@ -201,6 +212,9 @@
|
|||||||
| 预计得分等于/低于 80 | 进入等待人工,不交卷、不因等待而刷新 |
|
| 预计得分等于/低于 80 | 进入等待人工,不交卷、不因等待而刷新 |
|
||||||
| 交卷计时与恢复 | 从同一场考试原始开始时间计时,恢复不清零,不产生重复提交 |
|
| 交卷计时与恢复 | 从同一场考试原始开始时间计时,恢复不清零,不产生重复提交 |
|
||||||
| 卡顿和正常暂停 | 异常按次数上限恢复;用户暂停、课中题与等待交卷不误刷新 |
|
| 卡顿和正常暂停 | 异常按次数上限恢复;用户暂停、课中题与等待交卷不误刷新 |
|
||||||
|
| 继续上次播放 | 默认开启时只点击精确识别续播弹窗的“继续播放”;关闭后留给人工,未知弹窗不点击 |
|
||||||
|
| 单次机会保护 | 明确总共或仅剩 1 次机会以及 0 次时,开启保护则课中题及考试等待人工;已答 1 次、1/40 题、交卷人数及隐藏文字不误触发;关闭仍须通过原有核验 |
|
||||||
|
| 切回恢复 | 切页前正在播放才在返回后恢复;关闭开关、手动暂停、暂停任务及待处理弹题不自动恢复;后台暂停不误报持续播放或触发故障刷新 |
|
||||||
| 后台与回到前台 | 实测切换标签页及最小化,检查网站记录;中断时显示真实状态 |
|
| 后台与回到前台 | 实测切换标签页及最小化,检查网站记录;中断时显示真实状态 |
|
||||||
| 结束汇总 | 完成、跳过、失败、需人工分别列出,存在未完成项目不显示全部完成 |
|
| 结束汇总 | 完成、跳过、失败、需人工分别列出,存在未完成项目不显示全部完成 |
|
||||||
| 功能分栏 | 六栏内容按功能归属展示,桌面与窄屏均可切换;状态与全局暂停常驻,切栏不重启任务或计时、不触发导出,搜索与筛选内容保留 |
|
| 功能分栏 | 六栏内容按功能归属展示,桌面与窄屏均可切换;状态与全局暂停常驻,切栏不重启任务或计时、不触发导出,搜索与筛选内容保留 |
|
||||||
@@ -209,12 +223,19 @@
|
|||||||
## 11. 当前样本依据与开发前待核实内容
|
## 11. 当前样本依据与开发前待核实内容
|
||||||
|
|
||||||
- 新包 01 为学习地图,02/04 为同课程详情,03/05 为同视频的不同播放状态,06 为考试详情,07 为未加载内容的页面外壳。
|
- 新包 01 为学习地图,02/04 为同课程详情,03/05 为同视频的不同播放状态,06 为考试详情,07 为未加载内容的页面外壳。
|
||||||
|
- 补充包 `08.zip` 仅为 Angular 初始外壳,没有播放器、续播弹窗、次数提示的实际 DOM 或业务 JS 资源。可参考旧完整归档的静态弹窗模板、切页暂停逻辑及补考文案,不执行原站脚本,不据空外壳推断页面交互已通过实站验证。
|
||||||
- 03/05 的播放器位置与平台已记录学习时长不同;代码也区分播放百分比、已学习时长及完成标记。2X 不能直接承诺总任务耗时减半。
|
- 03/05 的播放器位置与平台已记录学习时长不同;代码也区分播放百分比、已学习时长及完成标记。2X 不能直接承诺总任务耗时减半。
|
||||||
- 视频页虽包含课中弹题组件,但没有保存实际弹出的题干、选项及提交状态。课中题的真实结构、题型、确认方式和题库覆盖情况需要补样本验收。
|
- 视频页虽包含课中弹题组件,但没有保存实际弹出的题干、选项及提交状态。课中题的真实结构、题型、确认方式和题库覆盖情况需要补样本验收。
|
||||||
- 需要核实课程实际完成条件、可用倍速、阶段解锁条件、当前账号的后台播放规则以及并行标签页是否被平台允许。
|
- 需要核实课程实际完成条件、可用倍速、阶段解锁条件、当前账号的后台播放规则以及并行标签页是否被平台允许。
|
||||||
- 主题对应关系需要建立明确的课程/科目到统一题库主题映射;不能要求课程名与统一题库文件名完全相同,也不能只靠关键词猜测。
|
- 主题对应关系需要建立明确的课程/科目到统一题库主题映射;不能要求课程名与统一题库文件名完全相同,也不能只靠关键词猜测。
|
||||||
- 建议按“统一题库及旧数据迁移 → 任务清单与主题预检 → 单视频及进度 → 课中题 → 考试计分与定时交卷 → 顺序调度 → 并行与后台恢复”分阶段开发,每阶段完成对应验收。
|
- 建议按“统一题库及旧数据迁移 → 任务清单与主题预检 → 单视频及进度 → 课中题 → 考试计分与定时交卷 → 顺序调度 → 并行与后台恢复”分阶段开发,每阶段完成对应验收。
|
||||||
|
|
||||||
|
## 12. 2.1.2 增量确认
|
||||||
|
|
||||||
|
新增默认开启并持久保存的三个开关,分别控制自动选择“继续播放”、单次答题机会人工处理、切回页面恢复播放。用户确认的单次含义为“只剩 1 次或总共仅允许 1 次答题机会时,交给我手动答题”。保护共用于课中题和课程考试,不以本人已答次数代替剩余机会数。
|
||||||
|
|
||||||
|
修复网站主动后台暂停后返回不能及时续播的问题,同时保留用户暂停的优先级,真实显示后台暂停状态。新安装默认串联视频与考试,已有模式保留;面板根据实际模式说明视频完成后的走向。附件 `08.zip` 仅作静态外壳证据,真实账号的续播、平台完成条件及考试入口仍需实站核对;不能宣称后台始终播放。
|
||||||
|
|
||||||
## 附:用户原始需求
|
## 附:用户原始需求
|
||||||
|
|
||||||
根据我提供的谊安学网页压缩包以及之前的完成的刷题脚本,现在需要做一个完整的刷课答题的脚本,现在需要所有任务的一个进度,以及预测时间,看视频过程中可能也有答题,以及当前任务的进度,刷视频要求可以在面板选择倍速,最高是2X,答题的可以设置答题间隔时间(默认4秒)以及交卷的时间(默认6分钟),需要增加只刷视频以及视频的任务以及视频中的答题的模式,或者只有考试的模式。答题之前先确认有没有该题库,没有则跳过该考试。或者考试和视频同时可以进行,导出题库放到其他设置的面板中。面板增加一个控制台的功能,可以看当前进度,视频如果卡住或者暂停自动刷新页面,增加网页可以后台的功能。
|
根据我提供的谊安学网页压缩包以及之前的完成的刷题脚本,现在需要做一个完整的刷课答题的脚本,现在需要所有任务的一个进度,以及预测时间,看视频过程中可能也有答题,以及当前任务的进度,刷视频要求可以在面板选择倍速,最高是2X,答题的可以设置答题间隔时间(默认4秒)以及交卷的时间(默认6分钟),需要增加只刷视频以及视频的任务以及视频中的答题的模式,或者只有考试的模式。答题之前先确认有没有该题库,没有则跳过该考试。或者考试和视频同时可以进行,导出题库放到其他设置的面板中。面板增加一个控制台的功能,可以看当前进度,视频如果卡住或者暂停自动刷新页面,增加网页可以后台的功能。
|
||||||
|
|||||||
Reference in New Issue
Block a user