diff --git a/README.md b/README.md index a003114..bae0dfc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 谊安学助手 2.0 -当前发布版本 **2.1.1**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。 +当前发布版本 **2.1.2**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。 ## 安装与启动 @@ -8,7 +8,7 @@ 2. 打开 `http://10.119.67.7/clientcn/` 下的学习地图、课程、练习或考试页面。 3. 展开右下角面板,在“题库搜索”导入 [统一题库.csv](题库/统一题库.csv)。原 ZIP、三个岗位 CSV、CSV/JSON 仍可导入,无需选择岗位。 4. 若考试标题与题库课程不同,在题库栏建立明确的主题关联。关联使用完整标题和完整题库课程名;相似关键词只提供候选,不授权自动答题。 -5. 在“任务总览”选择任务模式、顺序或并行,点击“开始本轮”。人工刷新或重开浏览器后默认暂停,检查清单后点击“继续上次任务”。 +5. 在“任务总览”选择任务模式、顺序或并行,点击“开始本轮”。新安装默认“视频+考试(含课中答题)”,升级保留你已经保存的模式;若希望视频完成后进入关联考试,请确认当前模式为“视频+考试”。人工刷新或重开浏览器后默认暂停,检查清单后点击“继续上次任务”。 脚本仅在 `http://10.119.67.7/clientcn/*` 生效,含其下课程、练习及考试路径;其他主机、HTTPS、其他端口及站点目录外不会注入功能。名称继续为“谊安学助手 2.0”。 @@ -17,7 +17,7 @@ | 功能栏 | 内容 | |---|---| | 任务总览 | 四种任务模式、顺序/并行、任务清单、当前任务、总进度及预计剩余时间 | -| 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复 | +| 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复;续播、单次答题机会保护、切回恢复三个开关 | | 考试答题 | 自动选择开关、缺题人工确认开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 | | 题库搜索 | 统一题库导入、关键词搜索、课程筛选、选中文字搜索、来源及主题关联 | | 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示 | @@ -40,15 +40,21 @@ ## 视频、任务与后台 -任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,执行方式默认顺序,并行最多一个视频和一场考试。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。 +任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,新安装默认“视频+考试”,已有保存的模式继续沿用,执行方式默认顺序,并行最多一个视频和一场考试。“仅视频”和“视频+课中答题”均不会进入独立或关联考试;选择“视频+考试”后,平台确认视频完成,才进入已经开放的关联考试,仍需通过单次机会保护及题库预检。视频栏根据当前模式显示后续流程。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。 视频支持 1X、1.25X、1.5X、2X,显示实际播放器倍率。播放到末尾后继续等待平台学习完成标记;平台记录时长与播放器位置分别展示。没有明确学习时长要求或任务耗时依据时,预计时间显示待确认,2X 不代表整轮耗时必然减半。 课中答题适配保存 JS 模板中的 `app-question-modal` 与 PC CDK portal:按完整题干及选项匹配,回读选中集合,走网站“查看答案”判分,只有明确回答正确并关闭/进入下一题后才继续。仅视频模式遇强制题、无库、冲突或未知结构时等待人工,不刷新弹题。课中题不套用考试交卷规则。 +“视频学习”栏新增三个独立开关,默认开启并保存设置: + +- **自动继续上次播放**(`autoContinuePlayback`):运行中的视频任务遇到正文为“是否继续上次播放的位置?”、同时有“重新开始”和“继续播放”的明确弹窗时,选择“继续播放”。关闭后由你处理;不会点击其他未知弹窗。 +- **仅 1 次答题机会时交给我处理**(`skipSingleAttemptQuiz`):用户确认的含义是“只剩 1 次或总共仅允许 1 次答题机会”,包含课中题与课程考试。开启时停止受影响的自动进入、选项点击及提交,交给你手动操作;0 次同样不自动操作。次数只取当前题目或考试容器中明确可见的允许、总、剩余次数及“还可补考 N 次”等提示,不把本人已答 1 次、已答 1/40 题或交卷人数当成机会数。未显示明确次数时不作推断,关闭保护仍需满足原有题库和交卷条件。 +- **切回页面时自动恢复播放**(`resumePlaybackOnReturn`):只对切页前正在播放、因网站切页逻辑而暂停的当前视频,在返回后尝试正常续播。手动暂停优先,暂停任务、课中题或其他待处理状态不会被自动播放覆盖。关闭后保持暂停,按网站或面板正常继续。 + 默认连续 60 秒没有播放进度时尝试续播,15 秒后仍失败再刷新当前视频页面,最多 3 次,两次刷新至少间隔 30 秒,次数跨刷新保存。主动暂停、课中题、等待平台确认、等待交卷或等待人工不算故障。脚本发起的恢复及任务跳转保存一次性续作标记;人工刷新默认暂停。并行页面通过共享租约、心跳及全局控制协调,打开失败或未确认时显示等待,不假称并行成功。 -后台指正常切换标签页或最小化后尽量继续,恢复前台按真实时间和网站记录校准。浏览器关闭、设备休眠、标签冻结或网站强制暂停期间无法保证持续执行;中断显示真实状态,不伪造学习时长。真实账号的解锁、倍速、后台和并行规则仍需实站验收。 +保存站点脚本显示:网站切到后台会主动暂停播放器,返回前台没有相应自动恢复逻辑。本版记录切页前的真实播放状态,后台暂停时显示“后台暂停,等待返回”,开启上述恢复开关后返回再尝试续播,不把后台暂停误当成长时间卡顿而刷新。不能保证后台一直播放,也不能保证浏览器关闭、设备休眠或标签冻结期间执行;恢复后按实际播放器及平台记录校准,不伪造学习时长。真实账号的解锁、倍速、后台和并行规则仍需实站验收。 ## 考试预计得分与交卷 @@ -87,6 +93,12 @@ npm run merge:banks 测试依赖 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)。真实课中弹题交互、课程完成条件、账号登录后的并发限制与正式提交结果需在实际环境核实;识别不明时脚本等待人工,不把本地模拟通过等同于全站验收完成。 diff --git a/assistant-panel.js b/assistant-panel.js index bc79736..458d5e4 100644 --- a/assistant-panel.js +++ b/assistant-panel.js @@ -22,7 +22,7 @@ bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '', canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null, 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 selectionText = ''; @@ -457,7 +457,7 @@ container.appendChild(button); return button; } 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'); const taskActions = el(doc, 'div', 'task-actions'); const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary'); @@ -479,8 +479,17 @@ views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList); 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); + 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 videoActions = el(doc, 'div', 'task-actions'); 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 firstValue(object, keys, fallback) { object = object || {}; @@ -718,7 +727,11 @@ : '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。') + '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。'); 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; setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled; const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null); diff --git a/attempt-policy.js b/attempt-policy.js new file mode 100644 index 0000000..6709124 --- /dev/null +++ b/attempt-policy.js @@ -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); diff --git a/build-userscript.mjs b/build-userscript.mjs index b7eb369..297c4f2 100644 --- a/build-userscript.mjs +++ b/build-userscript.mjs @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; const metadata = `// ==UserScript== // @name 谊安学助手 2.0 // @namespace yianxue-question-bank -// @version 2.1.1 +// @version 2.1.2 // @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。 // @match http://10.119.67.7/clientcn/* // @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' + zip + '\nwindow.JSZip = module.exports;\n})();\n'; 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 scope = readFileSync(new URL('./site-scope.js', import.meta.url), 'utf8'); // Also enforce the boundary when a manager injects manually or ignores metadata. diff --git a/exam-controller.js b/exam-controller.js index 351cc8b..6adaf0a 100644 --- a/exam-controller.js +++ b/exam-controller.js @@ -100,8 +100,24 @@ } function settings() { return options.getSettings ? options.getSettings() : {}; } 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) { - 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() { return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]')) @@ -156,8 +172,12 @@ async function enable() { const token = ++generation, requested = context(); enabled = false; + const initialPolicy = attemptPolicy(); + if (initialPolicy.blocked) { blockSingleAttempt(initialPolicy); return false; } await observe(); 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()) { stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷'; emit({ requiresManualSubmission: true, message: error }); return false; @@ -176,6 +196,8 @@ if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; } scope = scopeValue(ctx, evidence, topic); 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) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false; } @@ -196,6 +218,8 @@ } function canClick(token, ctx, confirmation) { 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 configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; const pendingTooLong = confirmation && now() - entry.submitAt > 12000; @@ -234,6 +258,7 @@ return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false }); } const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); + const policy = attemptPolicy(); const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000); const configured = Number(settings().minSubmitMinutes); const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; @@ -243,8 +268,10 @@ entry.lastObservedAt = now(); entry.lastRemaining = remaining; emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining, manualConfirmIncomplete: manualIncomplete(), + skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: policy.blocked, attemptCount: policy.count, requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified), predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason })); + if (policy.blocked) return blockSingleAttempt(policy); if (!enabled) return last; if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') { disable('试卷、题库或主题关联已变更,请重新启动核验'); return last; diff --git a/learning-engine.js b/learning-engine.js index 58439c1..541f74b 100644 --- a/learning-engine.js +++ b/learning-engine.js @@ -13,6 +13,7 @@ const LEASE_MS = 18000; const SESSION_KEY = 'session.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 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; } } 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) { if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false; for (let n = node; n && n.nodeType === 1; n = n.parentElement) { @@ -78,6 +82,21 @@ 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; } + 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) { 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; @@ -167,10 +186,13 @@ 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 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 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 }; - 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: '先识别当前学习计划,再开始本轮任务' }; + let policySessionId = '', policyReadSerial = 0; + 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() { 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(); } 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() { - 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)); 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; @@ -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 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() { if (!session) return; 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); 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]); if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; } } @@ -261,14 +322,25 @@ tasks.forEach(task => { if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id; 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 && - (task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || 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) || (alias ? current : null); + (task.kind === 'exam' && !conflictingExam && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title + && (!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); else { 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() { 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.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 saved = await storage.get(SESSION_KEY); const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/); @@ -327,33 +401,81 @@ } 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 (session) syncManualConfirmation(await storage.get(CONTROL_KEY)); + if (session) await syncPolicies(await storage.get(CONTROL_KEY)); mergeTasks(discoverTasks(snap.document, snap.url)); notify(); } function syncManualConfirmation(shared) { if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return; const revision = Number(shared.manualConfirmRevision) || 0; - if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return; - config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision; - session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete }); + if (revision < manualConfirmRevision) return; + for (const key of POLICY_KEYS) if (!authoritativePolicies.has(key) && typeof shared.config[key] === 'boolean') { config[key] = shared.config[key]; state[key] = config[key]; } + 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) { if (!session || worker && !explicitWorkerAction) return; + const controlEpoch = generation, controlSessionId = session.id; + ensurePolicySession(); const current = await storage.get(CONTROL_KEY); if (!force && current && current.sessionId !== session.id) return; const sameSession = current && current.sessionId === session.id; 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; - } 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); - sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete; + for (const key of POLICY_KEYS) sharedConfig[key] = config[key]; // 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。 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 }); } - async function configure(patch) { - await initialize(); patch = patch || {}; const wasRunning = state.status === 'running'; + async function configure(patch, restoreOptions) { + 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.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh }); 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 (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused; if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused; - const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null; - if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; } + const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]])); + const manualPatch = Object.keys(policies).length ? { values: policies } : null; + 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 (modeChanged && wasRunning) await 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)); // 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。 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); return tick(); } 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 (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() { 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; }); 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(); } 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 (!(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 isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware); const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage; if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; } const node = resolveNode(task, snap); taskState(task, 'loading', '正在通过网页现有入口打开任务'); + task.navigationRequestedAt = now(); + if (node && task.kind === 'exam') task.examLinkClickedAt = now(); await persist(); 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 }); - 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 (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; @@ -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; 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 }); + 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) { + visibilityPlayback = null; returnResumeRequested = false; video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false; if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; } taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz'; @@ -464,7 +614,7 @@ if (pendingQuiz) return; pendingQuiz = popup; 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 (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续'); else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); } @@ -474,6 +624,19 @@ } 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 ((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 (_) {} } state.video.actualRate = video.playbackRate; 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 || '已核验,等待最早交卷时间'); } 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) { await observeExamState(task, true); @@ -620,14 +794,15 @@ if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); } if (session) { const controlValue = await storage.get(CONTROL_KEY); + if (epoch !== generation) return getState(); if (await observeControl(controlValue)) return getState(); 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); } 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); 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', '当前课程与已领取任务不同,请核对后继续'); @@ -659,8 +834,10 @@ } async function observeControl(value) { if (!session) return false; + const controlEpoch = generation; 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 (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(); @@ -678,10 +855,14 @@ function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); } function destroy() { 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(() => {}); } - 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 }; }); diff --git a/package.json b/package.json index 0d91fd4..255d427 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yianxue-question-bank-exporter", - "version": "2.1.1", + "version": "2.1.2", "private": true, "description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验", "scripts": { diff --git a/practice-exporter.js b/practice-exporter.js index 9d60f39..c30a2a8 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -16,9 +16,16 @@ let positionRevision = 0; let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle'; 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); + 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() { 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 signature = route.root ? structureSignature(bank) : ''; 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 - && 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, sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root, pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired, 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, @@ -99,10 +108,12 @@ isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null; const route = routeInfo(); 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, 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) { panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, @@ -129,6 +140,9 @@ function setAutoAnswer(enabled) { if (!enabled) actionEpoch++; 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)) { panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false; } @@ -195,11 +209,11 @@ }); 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 (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].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] }; if (ranges[key]) { 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())))) { throw new Error('主题关联须使用完整标题及题库课程名称列表'); } - if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) { - actionEpoch++; - config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验'); - await learning.pause(); + const pending = { value }; pendingLearningSettings.set(key, pending); + try { + if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) { + 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) { actionEpoch++; @@ -245,8 +264,9 @@ 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, getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, { - manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false - }), getRunnerState: () => runnerState, + manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false, + skipSingleAttemptQuiz: skipSingleAttempt() + }), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState, storage: learningStorage, onLog: (level, message) => learning?.log(level, message), onState: state => { examState = state; renderLearning(); } }); learning = window.YianLearningEngine.create({ @@ -266,7 +286,7 @@ const epoch = actionEpoch; const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher, 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' && !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision }); return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status }); @@ -281,9 +301,7 @@ }, onState: state => { learningState = state; - if (!loading && typeof state.manualConfirmIncomplete === 'boolean') { - learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete; - } + if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key]; renderLearning(); } }); @@ -308,6 +326,9 @@ } const route = routeInfo(); 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')); 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)]); snapshot = values[0]; config.role = ''; 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; const position = values[3]; // A drag during asynchronous storage loading wins over the older saved position. diff --git a/quiz-adapter.js b/quiz-adapter.js index 5326804..14599b3 100644 --- a/quiz-adapter.js +++ b/quiz-adapter.js @@ -64,11 +64,16 @@ 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))); } + 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) { const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher; const context = options.context || {}; 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 configured = Number(context.answerInterval); 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; 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 => { if (!event.isTrusted) return; const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement; @@ -101,16 +117,18 @@ } 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 { let view = detect(doc); if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查'); for (let questionIndex = 0; questionIndex < 100; questionIndex++) { if (!active()) return result('waiting', '课中答题已取消,等待重新启动'); + if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true }); const original = read(view, context); if (original.error) return result('waiting', original.error); const matched = await matcher.match(original.question, context, options.snapshot); 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 (!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 = () => { @@ -128,7 +146,7 @@ } for (const step of plan) { 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; }); if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消'); if (!await pause(150)) return result('waiting', '课中答题已取消'); @@ -136,7 +154,7 @@ 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', '课中题实际选中集合与正确答案全文不一致'); 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; // absence of the question alone does not imply that the grade request succeeded. const graded = await waitFor(() => { @@ -155,7 +173,7 @@ if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭'); const advances = buttons(graded.container, /^(下一题|关闭)$/); 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 seen = detect(doc); // CDK detach/attach can briefly render neither overlay between questions. diff --git a/tests/attempt-policy.cjs b/tests/attempt-policy.cjs new file mode 100644 index 0000000..8eab199 --- /dev/null +++ b/tests/attempt-policy.cjs @@ -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 = '<' + tag + ' id="scope">' + html + ''; + 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 次)', + '', '', + '', + '
允许答题次数:1次
', + '
剩余考试次数
1 次
' + ]; + 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('

总考试次数:3次

剩余考试次数:1次

')).count, 1); + assert.equal((await read('

总考试次数:1次

还可补考0次

')).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次是正确答案', + '
仅有1次答题机会
', + '
(不及格,还可补考1次)是这道题的答案
', + '
重新答题(不及格,还可补考1次)
', + '
允许答题次数1次
', + '
还可补考1次
', + '
剩余考试次数1次
', + '
总考试次数1次
', '
允许答题次数1次
', + '', '', + '', '
总考试次数1次
', + '
剩余考试次数1次
', '
还可补考1次
', + '', '', + '
考试已就绪
', '

这里只剩1题要答

' + ]; + 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('

总考试次数:3次

'); + const after = await read('

总考试次数:2次

'); + 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('

剩余考试次数:1次

'); + assert.equal(stable.signature, (await read('

剩余考试次数:1次

')).signature); + await page.evaluate(() => { document.querySelector('#scope').insertAdjacentHTML('afterend', '还可补考0次'); }); + 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; }); diff --git a/tests/exam-controller.cjs b/tests/exam-controller.cjs index ebbd625..8fbbe21 100644 --- a/tests/exam-controller.cjs +++ b/tests/exam-controller.cjs @@ -25,8 +25,8 @@ function paperHTML(total = 25) { page.on('pageerror', error => errors.push(error.message)); 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 } = {}) { - await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete }) => { + 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, skipSingleAttemptQuiz, attemptPolicy }) => { window.qaExam?.destroy(); window.qaRunner?.destroy(); document.documentElement.innerHTML = html.replace(/^/, '').replace(/^/, '').replace(/<\/html>$/, ''); window.qaClock = { now: 0 }; @@ -48,6 +48,8 @@ function paperHTML(total = 25) { window.qaRunnerState = 'complete'; window.qaSettings = { minSubmitMinutes: 6 }; 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 = '分值待定'; 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', () => { @@ -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; }, set: async (key, value) => { if (qaStorageHook) await qaStorageHook(key, value); qaStorage.set(key, structuredClone(value)); } }, 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 }) }); 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() }; }); } async function tickAt(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 }); assert.equal(initial.total, 25); assert.equal(initial.totalScore, 100, '按实际 DOM 分值计算 25×4=100'); @@ -339,7 +383,7 @@ function paperHTML(total = 25) { assert.equal(inferred.waitSeconds, 120); 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(); const blocked = await page.evaluate(async mutation => { qaSetClock(360000); @@ -356,13 +400,16 @@ function paperHTML(total = 25) { if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] }; if (mutation === 'pause') qaExam.disable('用户已暂停'); 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() }; }, mutation); assert.equal(blocked.model.commitClicks, 0, '异步保存期间发生 ' + mutation + ' 时禁止旧提交点击'); 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 tickAt(360); 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 === 'pause') qaExam.disable('用户已暂停'); 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() }; }, mutation); @@ -417,6 +467,19 @@ function paperHTML(total = 25) { }); assert.equal(interruptedWrite.returned, 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 在读写期间被暂停后不复活'); await setup(); diff --git a/tests/learning-engine.cjs b/tests/learning-engine.cjs index 97d9921..3781076 100644 --- a/tests/learning-engine.cjs +++ b/tests/learning-engine.cjs @@ -6,6 +6,7 @@ const { chromium } = require('playwright'); const ZIP = require('../vendor/jszip.min.js'); const workspace = path.resolve(__dirname, '..'); 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 origin = 'http://10.119.67.7/clientcn/'; const videoHTML = '
测试课程
  • 视频一

    视频
  • '; @@ -20,6 +21,7 @@ async function main() { await page.goto(origin + 'course/test'); await page.addScriptTag({ content: core }); await page.addScriptTag({ content: source }); + await page.addScriptTag({ content: attemptSource }); let checks = 0; try { async function setup(html = videoHTML, config = {}, settings = {}) { @@ -27,7 +29,9 @@ async function main() { window.qaEngine?.destroy(); history.replaceState(null, '', '/clientcn/course/test'); 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.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 }; 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'); 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', '

    等待人工处理的题

    '); + }, 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 = ''; + 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', ''); }); } + 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 = '
    测试课程 已答0/40
    开始答题
    '; + for (const enabled of [true, false]) { + await setup(examDetail.replace('', '

    允许考试次数:1次

    '), { 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 = '
    测试课程
    '; }); + await tick(); assert.equal(await page.evaluate(() => qaEngine.getAttemptPolicy().blocked), true, '试卷无次数标签时仍保留同一考试详情证据'); + } + checks++; + } + await setup(examDetail.replace('', '

    允许考试次数:1次

    '), { 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 = '
    测试课程

    允许考试次数:3次

    '; + }); + 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 = '
    测试课程
    '; + }); + assert.equal(await page.evaluate(() => qaEngine.getAttemptPolicy().count), 1, '同一考试ID的试卷继承详情次数,外部考试不污染'); checks++; await setup(examDetail, { mode: 'exam' }); await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1); await page.evaluate(() => { document.body.innerHTML = '
    测试课程
    '; }); @@ -198,6 +295,17 @@ async function main() { checks++; } + await setup(videoHTML.replace('', ''), { 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('
    没有题库的考试
    开始答题
    ', { 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++; @@ -231,10 +339,11 @@ async function main() { qaEngine = qaCreate(); await qaEngine.tick(); holdNext = true; const oldTick = qaEngine.tick(); await captured; 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.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式存储确认工作页,暂停信号同步,完全隔离真实业务网络。 const shared = new Map(); @@ -274,7 +383,8 @@ async function main() { assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused', '策略同步不能自动恢复任务'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1, '修改共享策略期间不得重启答题'); 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++; 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, '全局继续后重新核验考试'); @@ -341,6 +451,178 @@ async function main() { 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++; + // 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, []); console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)'); } finally { await context.close(); await browser.close(); } diff --git a/tests/panel-tabs.cjs b/tests/panel-tabs.cjs index b4084c3..ce81c3a 100644 --- a/tests/panel-tabs.cjs +++ b/tests/panel-tabs.cjs @@ -165,6 +165,51 @@ async function main() { console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state'); 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'); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]); 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); await page.setViewportSize({ width: 390, height: 844 }); await visible(); 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(); assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked()); await screenshot('03a-mobile-incomplete-switch-on.png'); diff --git a/tests/quiz-adapter.cjs b/tests/quiz-adapter.cjs index fc85f7e..23d2526 100644 --- a/tests/quiz-adapter.cjs +++ b/tests/quiz-adapter.cjs @@ -25,7 +25,7 @@ const rootDir = path.resolve(__dirname, '..'); const context = await browser.newContext({ viewport: { width: 1000, height: 800 } }); 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)); - 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 = {}) { await page.evaluate(config => { document.body.innerHTML = '
    '; @@ -41,6 +41,7 @@ const rootDir = path.resolve(__dirname, '..'); if (config.missing) qaSnapshot.records = []; const pane = document.querySelector('.question-modal-pc'); const escape = text => String(text).replace(/&/g, '&').replace(//g, '>'); + const attemptLabel = () => config.attemptLabel ? '
    ' + escape(config.attemptLabel) + '
    ' : ''; const grade = () => { const q = qaQuestions[qaModel.index]; qaModel.grading = true; 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 q = qaQuestions[qaModel.index]; qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : []; - pane.innerHTML = '
    '; + pane.innerHTML = attemptLabel() + '
    '; const footer = pane.querySelector('.modal-footer'); const submit = () => { 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]))); submit(); if (config.cancelAfterClick) qaActive = false; + if (config.singleAfterClick) pane.querySelector('.quiz-attempt-status').textContent = '剩余答题次数:1 次'; })); }; render(); 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 = '

    新题型

    '; 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); } await setup(); @@ -110,6 +113,36 @@ const rootDir = path.resolve(__dirname, '..'); } 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()); assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0); await setup(); diff --git a/tests/run.cjs b/tests/run.cjs index 35dc7d9..d52212f 100644 --- a/tests/run.cjs +++ b/tests/run.cjs @@ -1,6 +1,6 @@ const { spawnSync } = require('node:child_process'); 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' }); if (result.status !== 0) process.exit(result.status || 1); } diff --git a/tests/video-exam-workflow.cjs b/tests/video-exam-workflow.cjs new file mode 100644 index 0000000..1eb6cbe --- /dev/null +++ b/tests/video-exam-workflow.cjs @@ -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 = '视频完成后关联考试验证
    ' + course + '
  • 1.公司安全理念、方针及目标培训

  • '; + +(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(//gi, '').replace(//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) => '

    [单选] ' + (i + 1) + '. 关联考试核验题' + i + '(4分)

    A错误操作' + i + '
    B正确措施' + i + '
    ').join(''); + document.querySelector('#site-view').innerHTML = '
    ' + course + '
    ' + questions + '
    ' + Array.from({ length: 25 }, (_, i) => '
    ' + (i + 1) + '
    ').join('') + '
    00:30:00
    交卷
    '; + 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 = '
    ' + course + ' 已答0/25
    总考试次数:3 次
    开始答题
    '; + 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; }); diff --git a/tests/video-workflow.cjs b/tests/video-workflow.cjs index 405b5c3..ae7fd5d 100644 --- a/tests/video-workflow.cjs +++ b/tests/video-workflow.cjs @@ -44,6 +44,7 @@ async function main() { const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc'; const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对']; pane.innerHTML = '
    '; + pane.insertAdjacentHTML('afterbegin', '

    允许答题次数:1次

    '); document.querySelector('.cdk-overlay-container').append(pane); const grade = () => { qaQuiz.submits++; @@ -74,17 +75,47 @@ async function main() { await YianAssistant.setLearningSetting('mode', 'videoQuiz'); await YianAssistant.setLearningSetting('rate', 2); await YianAssistant.setLearningSetting('answerInterval', 0); + window.qaResume = { continued: 0, restarted: 0 }; + const dialog = document.createElement('div'); dialog.className = 'modal-dialog dialog_box'; + dialog.innerHTML = ''; + 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 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().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 状态'); + 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 })); 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 })); 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 page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused); await page.evaluate(async () => { @@ -137,6 +168,20 @@ async function main() { assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态'); 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); + 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('截图:' + qaDir); } finally { await browser.close(); } diff --git a/实施开发方案.md b/实施开发方案.md index dee0254..a2df9f4 100644 --- a/实施开发方案.md +++ b/实施开发方案.md @@ -17,6 +17,7 @@ - `bank-store.js`、`answer-matcher.js`:统一题库与证据匹配,岗位仅作来源信息。 - `assistant-panel.js`:六个功能栏及状态展示,仅通过回调发出用户操作。 +- `attempt-policy.js`:课中题及课程考试共用的可见机会数取证;只读取指定题目/考试容器,不读全页、Angular 私有状态或业务接口。 - `learning-engine.js`:任务、视频、恢复、检查点、日志及多页面协调。 - `exam-controller.js`:当前考试的主题依据、原始进入时间、分值核算及交卷状态机。 - `answer-runner.js`:按配置间隔点击真实选项,保持每步上下文及全文核验。 @@ -68,3 +69,16 @@ - 在入口、提交前复查及确认阶段统一核对开关和通用门槛。切换开关立即暂停当前答题与交卷计划,须用户明确继续后重验,不能关闭时立即提交。未知题不猜答,人工补选的未知答案不自动计正分。 - 本次验收重点:网页整卷完整、已核验 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 全部在本地拦截,未操作真实学习或考试记录,真实账号流程仍需实站验收。 diff --git a/谊安学助手 2.0.user.js b/谊安学助手 2.0.user.js index 04cc207..1c869b3 100644 --- a/谊安学助手 2.0.user.js +++ b/谊安学助手 2.0.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 谊安学助手 2.0 // @namespace yianxue-question-bank -// @version 2.1.1 +// @version 2.1.2 // @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。 // @match http://10.119.67.7/clientcn/* // @run-at document-idle @@ -1132,7 +1132,7 @@ window.JSZip = module.exports; bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '', canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null, 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 selectionText = ''; @@ -1567,7 +1567,7 @@ window.JSZip = module.exports; container.appendChild(button); return button; } 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'); const taskActions = el(doc, 'div', 'task-actions'); 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.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); + 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 videoActions = el(doc, 'div', 'task-actions'); 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 firstValue(object, keys, fallback) { object = object || {}; @@ -1828,7 +1837,11 @@ window.JSZip = module.exports; : '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。') + '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。'); 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; setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled; const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null); @@ -2543,6 +2556,113 @@ window.JSZip = module.exports; root.YianAnswerRunner = { version: '1.1.0', create: create }; })(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: 读取网页实际目录、控制原生播放器并协调已授权的学习任务。 */ (function (root, factory) { 'use strict'; @@ -2558,6 +2678,7 @@ window.JSZip = module.exports; const LEASE_MS = 18000; const SESSION_KEY = 'session.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 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; } } 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) { if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false; 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'); 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) { 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; @@ -2712,10 +2851,13 @@ window.JSZip = module.exports; 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 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 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 }; - 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: '先识别当前学习计划,再开始本轮任务' }; + let policySessionId = '', policyReadSerial = 0; + 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() { 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(); } 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() { - 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)); 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; @@ -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 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() { if (!session) return; 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); 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]); if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; } } @@ -2806,14 +2987,25 @@ window.JSZip = module.exports; tasks.forEach(task => { if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id; 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 && - (task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || 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) || (alias ? current : null); + (task.kind === 'exam' && !conflictingExam && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title + && (!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); else { 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() { 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.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 saved = await storage.get(SESSION_KEY); 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); } } 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(); } function syncManualConfirmation(shared) { if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return; const revision = Number(shared.manualConfirmRevision) || 0; - if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return; - config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision; - session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete }); + if (revision < manualConfirmRevision) return; + for (const key of POLICY_KEYS) if (!authoritativePolicies.has(key) && typeof shared.config[key] === 'boolean') { config[key] = shared.config[key]; state[key] = config[key]; } + 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) { if (!session || worker && !explicitWorkerAction) return; + const controlEpoch = generation, controlSessionId = session.id; + ensurePolicySession(); const current = await storage.get(CONTROL_KEY); if (!force && current && current.sessionId !== session.id) return; const sameSession = current && current.sessionId === session.id; 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; - } 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); - sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete; + for (const key of POLICY_KEYS) sharedConfig[key] = config[key]; // 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。 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 }); } - async function configure(patch) { - await initialize(); patch = patch || {}; const wasRunning = state.status === 'running'; + async function configure(patch, restoreOptions) { + 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.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh }); 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 (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused; if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused; - const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null; - if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; } + const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]])); + const manualPatch = Object.keys(policies).length ? { values: policies } : null; + 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 (modeChanged && wasRunning) await 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)); // 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。 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); return tick(); } 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 (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() { 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; }); 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(); } 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 (!(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 isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware); const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage; if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; } const node = resolveNode(task, snap); taskState(task, 'loading', '正在通过网页现有入口打开任务'); + task.navigationRequestedAt = now(); + if (node && task.kind === 'exam') task.examLinkClickedAt = now(); await persist(); 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 }); - 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 (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; @@ -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; 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 }); + 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) { + visibilityPlayback = null; returnResumeRequested = false; video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false; if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; } taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz'; @@ -3009,7 +3279,7 @@ window.JSZip = module.exports; if (pendingQuiz) return; pendingQuiz = popup; 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 (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续'); 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 (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 (_) {} } state.video.actualRate = video.playbackRate; 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 || '已核验,等待最早交卷时间'); } 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) { 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 (session) { const controlValue = await storage.get(CONTROL_KEY); + if (epoch !== generation) return getState(); if (await observeControl(controlValue)) return getState(); 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); } 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); 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', '当前课程与已领取任务不同,请核对后继续'); @@ -3204,8 +3499,10 @@ window.JSZip = module.exports; } async function observeControl(value) { if (!session) return false; + const controlEpoch = generation; 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 (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(); @@ -3223,10 +3520,14 @@ window.JSZip = module.exports; function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); } function destroy() { 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(() => {}); } - 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 }; }); @@ -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]')) .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) { const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher; const context = options.context || {}; 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 configured = Number(context.answerInterval); 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; 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 => { if (!event.isTrusted) return; const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement; @@ -3334,16 +3651,18 @@ window.JSZip = module.exports; } 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 { let view = detect(doc); if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查'); for (let questionIndex = 0; questionIndex < 100; questionIndex++) { if (!active()) return result('waiting', '课中答题已取消,等待重新启动'); + if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true }); const original = read(view, context); if (original.error) return result('waiting', original.error); const matched = await matcher.match(original.question, context, options.snapshot); 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 (!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 = () => { @@ -3361,7 +3680,7 @@ window.JSZip = module.exports; } for (const step of plan) { 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; }); if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消'); if (!await pause(150)) return result('waiting', '课中答题已取消'); @@ -3369,7 +3688,7 @@ window.JSZip = module.exports; 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', '课中题实际选中集合与正确答案全文不一致'); 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; // absence of the question alone does not imply that the grade request succeeded. const graded = await waitFor(() => { @@ -3388,7 +3707,7 @@ window.JSZip = module.exports; if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭'); const advances = buttons(graded.container, /^(下一题|关闭)$/); 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 seen = detect(doc); // 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 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) { - 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() { 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() { const token = ++generation, requested = context(); enabled = false; + const initialPolicy = attemptPolicy(); + if (initialPolicy.blocked) { blockSingleAttempt(initialPolicy); return false; } await observe(); 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()) { stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷'; 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; } scope = scopeValue(ctx, evidence, topic); 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) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false; } @@ -3616,6 +3957,8 @@ window.JSZip = module.exports; } function canClick(token, ctx, confirmation) { 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 configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; const pendingTooLong = confirmation && now() - entry.submitAt > 12000; @@ -3654,6 +3997,7 @@ window.JSZip = module.exports; return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false }); } const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); + const policy = attemptPolicy(); const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000); const configured = Number(settings().minSubmitMinutes); const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; @@ -3663,8 +4007,10 @@ window.JSZip = module.exports; entry.lastObservedAt = now(); entry.lastRemaining = remaining; emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining, manualConfirmIncomplete: manualIncomplete(), + skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: policy.blocked, attemptCount: policy.count, requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified), predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason })); + if (policy.blocked) return blockSingleAttempt(policy); if (!enabled) return last; if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') { disable('试卷、题库或主题关联已变更,请重新启动核验'); return last; @@ -3762,9 +4108,16 @@ window.JSZip = module.exports; let positionRevision = 0; let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle'; 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); + 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() { 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 signature = route.root ? structureSignature(bank) : ''; 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 - && 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, sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root, pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired, 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, @@ -3845,10 +4200,12 @@ window.JSZip = module.exports; isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null; const route = routeInfo(); 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, 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) { panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, @@ -3875,6 +4232,9 @@ window.JSZip = module.exports; function setAutoAnswer(enabled) { if (!enabled) actionEpoch++; 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)) { panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false; } @@ -3941,11 +4301,11 @@ window.JSZip = module.exports; }); 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 (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].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] }; if (ranges[key]) { 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())))) { throw new Error('主题关联须使用完整标题及题库课程名称列表'); } - if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) { - actionEpoch++; - config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验'); - await learning.pause(); + const pending = { value }; pendingLearningSettings.set(key, pending); + try { + if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) { + 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) { 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) }; examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot, getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, { - manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false - }), getRunnerState: () => runnerState, + manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false, + skipSingleAttemptQuiz: skipSingleAttempt() + }), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState, storage: learningStorage, onLog: (level, message) => learning?.log(level, message), onState: state => { examState = state; renderLearning(); } }); learning = window.YianLearningEngine.create({ @@ -4012,7 +4378,7 @@ window.JSZip = module.exports; const epoch = actionEpoch; const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher, 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' && !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision }); return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status }); @@ -4027,9 +4393,7 @@ window.JSZip = module.exports; }, onState: state => { learningState = state; - if (!loading && typeof state.manualConfirmIncomplete === 'boolean') { - learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete; - } + if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key]; renderLearning(); } }); @@ -4054,6 +4418,9 @@ window.JSZip = module.exports; } const route = routeInfo(); 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')); 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)]); snapshot = values[0]; config.role = ''; 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; const position = values[3]; // A drag during asynchronous storage loading wins over the older saved position. diff --git a/需求.md b/需求.md index 04f16de..24b68e9 100644 --- a/需求.md +++ b/需求.md @@ -23,6 +23,10 @@ | 缺题人工确认 | “考试答题”栏提供“题目不全时手动确认交卷”开关(`manualConfirmIncomplete`),默认开启;开启时任意题库缺题或未答完提醒都由用户在网站确认,关闭时允许满足原得分、计时及全文核验条件的部分题库试卷自动交卷 | 用户最新确认,默认开启 | | 题库结构 | 原主操、副操、班长题库统一合并使用,取消岗位选择及岗位隔离 | 用户已确认 | | 题库对应 | 考试前检查当前考试主题与统一题库中课程/主题是否对应;没有对应题库则跳过该考试 | 用户已明确 | +| 默认任务模式 | 新安装默认“视频+考试(含课中答题)”;升级保留已经保存的模式,不覆盖用户明确的仅视频选择 | 按本次视频完成后继续考试的需求调整 | +| 续播弹窗 | “自动继续上次播放”开关默认开启;仅识别正文及两按钮完整对应的续播弹窗,选择“继续播放” | 用户新增需求 | +| 单次答题机会 | “仅 1 次答题机会时交给我处理”开关默认开启;仅剩 1 次或总共仅允许 1 次机会时,课中题和课程考试交给用户手动答题;0 次同样不自动操作 | 用户已明确机会数含义,不是本人已答 1 次 | +| 返回续播 | “切回页面时自动恢复播放”开关默认开启;只恢复切页前正在播放的视频,手动暂停及待人工状态优先 | 用户新增需求 | | 执行方式 | 顺序、并行可切换,默认顺序 | 用户已确认 | | 面板布局 | 按功能分栏,桌面采用左侧功能导航、右侧内容区,窄屏改为顶部标签切换 | 按功能分栏已确认,具体排布为补充方案 | | 并行数量 | 同时最多 1 个视频任务和 1 场考试;课中题属于视频任务 | 补充建议 | @@ -41,6 +45,8 @@ | 仅考试 | 不播放 | 不处理 | 主题预检、作答、计分和按条件交卷 | | 视频+考试 | 自动播放 | 自动精确匹配并作答 | 主题预检、作答、计分和按条件交卷 | +- 新安装默认“视频+考试”;已有保存的模式保留,不因升级覆盖用户选择。“仅视频”“视频+课中答题”完成视频后不会进入独立或关联考试,面板须说明如何切换到“视频+考试”。 +- “视频+考试”模式须在平台确认视频完成、关联考试已开放后继续进入考试;只观察到播放器 ended 不够,单次机会保护及题库主题预检仍然生效。 - 顺序:依照任务清单完成视频及其课中题,再处理满足前置条件的关联考试。 - 并行:一个受控标签页处理视频及课中题,另一个处理已开放的考试;两个页面共享总任务进度,不能重复领取同一任务。 - 并行也须遵守网站的解锁顺序。需要先完成视频才能参加的考试,不能提前启动。 @@ -68,6 +74,9 @@ - “视频+课中答题”模式复用统一题库,以题型、完整题干、全部选项及正确答案全文严格匹配;核对所选集合并确认弹题已关闭或提交成功后继续播放。 - 仅视频模式遇到强制弹题,或课中题无匹配、答案冲突、图片题或未适配题型时,暂停该课件并提示人工处理,保留已播放记录。 - 课中题暂不套用考试的 6 分钟交卷规则及 80 分门槛;是否答题正确、如何继续播放以实际弹题流程为准。 +- 视频栏提供独立的“自动继续上次播放”开关(`autoContinuePlayback`),默认开启并保存。运行任务中仅在唯一可见弹窗正文对应“是否继续上次播放的位置?”且同时有“重新开始”“继续播放”两个按钮时,正常点击“继续播放”;关闭时等用户处理,不自动选择重新开始或点击其他弹窗。 +- 视频栏提供“仅 1 次答题机会时交给我处理”开关(`skipSingleAttemptQuiz`),默认开启并保存,覆盖课中题和课程考试。仅从当前弹题/考试范围内明确可见的允许、总、剩余机会数以及“还可补考 N 次”等提示取证;1 次或 0 次时停止受影响的自动进入、选项和提交,等待用户正常手动处理。 +- 单次规则不指“本人已经答过 1 次”。题干、选项、答案、隐藏节点、已答题数、已答次数、交卷人数及 Angular 私有状态不能作为机会数证据;未显示明确次数时不推断。关闭单次保护不会跳过题库全文核验、计分及交卷规则。 ## 6. 统一题库与考试主题预检 @@ -141,8 +150,9 @@ - 登录失效、自动播放被浏览器拒绝、验证弹窗、提交结果不明或达到重试上限时,暂停受影响任务并提示人工处理。 - 全局“暂停”停止后续选择、交卷、任务跳转及自动刷新;“停止本轮”退出调度但保留已发生的学习与作答记录,两者不能假装撤销网站操作。 - 脚本自己发起的恢复刷新可继续同一已授权任务;人工刷新、重新打开浏览器时恢复任务清单但默认暂停,提供“继续上次任务”。恢复前核对账号、学习计划、任务、题库版本和网站状态。 -- 后台指切换标签页或窗口最小化后尽量继续播放和执行;计时与进度要在恢复前台时校准,不能靠定时器回调次数虚增学习时长。 -- 不承诺关闭浏览器、设备休眠或标签被浏览器冻结期间仍持续执行。后台被网站或浏览器中断时,显示真实状态并走受限恢复流程,不能显示“正常学习”却没有平台记录。 +- 保存站点脚本显示,网站在切到后台时主动暂停播放器,返回前台没有对应恢复逻辑。本版提供“切回页面时自动恢复播放”开关(`resumePlaybackOnReturn`),默认开启并保存;记录切页前正在播放的当前视频,在返回后尝试正常续播,后台暂停显示“后台暂停,等待返回”,不误判为长时间卡顿并刷新。 +- 手动暂停播放器、全局或视频任务暂停、课中题及其他待人工状态优先,不能因为返回前台覆盖用户暂停。关闭恢复开关后不因切回页面自动播放。进度及计时只用真实播放器和平台记录校准,不靠回调次数虚增时长。 +- 不承诺后台持续播放,也不承诺关闭浏览器、设备休眠或标签被冻结期间持续执行。后台被网站或浏览器中断时显示真实状态,不能显示“正常学习”却没有平台记录。 ## 9. 面板、控制台与其他设置 @@ -153,7 +163,7 @@ | 功能栏 | 主要内容与操作 | |---|---| | 任务总览 | 学习计划与任务清单、四种任务模式、顺序/并行、开始/停止本轮、总进度、完成/跳过/失败/等待人工统计、预计剩余时间;显示当前视频与考试摘要,点击可进入对应功能栏 | -| 视频学习 | 当前课程和课件、播放位置/总时长、平台已记录学习时长与完成状态、最高 2X 的倍速设置、视频任务暂停/继续、课中题匹配及处理状态、卡顿恢复状态 | +| 视频学习 | 当前课程和课件、播放位置/总时长、平台已记录学习时长与完成状态、最高 2X 的倍速设置、视频任务暂停/继续、课中题匹配及处理状态、卡顿恢复状态;默认开启的续播、单次机会保护、切回恢复开关及当前模式后续流程说明 | | 考试答题 | 当前考试主题与题库预检结果、自动选择答案开关、默认开启的“题目不全时手动确认交卷”开关、默认 4 秒的答题间隔、默认 6 分钟的最早交卷时长、网站剩余时间、预计得分与严格大于 80 分的门槛、交卷等待原因、答案全文核对与未处理题清单 | | 题库搜索 | 统一题库导入与状态、来源记录数/题目数/冲突数、关键词搜索、课程/主题筛选、选中文字搜索、答案全文与来源、主题关联查看和维护;不显示岗位选择 | | 控制台 | 带时间和任务名称的运行日志、状态/警告/错误筛选、复制日志、清空显示、恢复次数及交卷结果 | @@ -186,6 +196,7 @@ |---|---| | 启动学习计划 | 列出实际课程/课件/考试任务,已完成项目不重复执行,阶段数与任务数不混算 | | 四种模式 | 视频、课中题、独立考试按所选模式执行;仅视频遇强制题等待人工 | +| 新旧默认及视频后续 | 新安装默认“视频+考试”,升级保留已选模式;平台确认视频完成后才进入已开放关联考试;仅视频及视频+课中答题模式明确提示不会进入考试 | | 顺序与并行 | 默认顺序;并行最多一个视频和一场考试,不重复任务且遵守前置条件 | | 最高 2X | 面板与播放器实际倍率一致;网站学习时长和完成状态独立核对 | | 主题预检 | 显示考试与题库对应关系,无库不自动开考;名称不同的明确关联可用 | @@ -201,6 +212,9 @@ | 预计得分等于/低于 80 | 进入等待人工,不交卷、不因等待而刷新 | | 交卷计时与恢复 | 从同一场考试原始开始时间计时,恢复不清零,不产生重复提交 | | 卡顿和正常暂停 | 异常按次数上限恢复;用户暂停、课中题与等待交卷不误刷新 | +| 继续上次播放 | 默认开启时只点击精确识别续播弹窗的“继续播放”;关闭后留给人工,未知弹窗不点击 | +| 单次机会保护 | 明确总共或仅剩 1 次机会以及 0 次时,开启保护则课中题及考试等待人工;已答 1 次、1/40 题、交卷人数及隐藏文字不误触发;关闭仍须通过原有核验 | +| 切回恢复 | 切页前正在播放才在返回后恢复;关闭开关、手动暂停、暂停任务及待处理弹题不自动恢复;后台暂停不误报持续播放或触发故障刷新 | | 后台与回到前台 | 实测切换标签页及最小化,检查网站记录;中断时显示真实状态 | | 结束汇总 | 完成、跳过、失败、需人工分别列出,存在未完成项目不显示全部完成 | | 功能分栏 | 六栏内容按功能归属展示,桌面与窄屏均可切换;状态与全局暂停常驻,切栏不重启任务或计时、不触发导出,搜索与筛选内容保留 | @@ -209,12 +223,19 @@ ## 11. 当前样本依据与开发前待核实内容 - 新包 01 为学习地图,02/04 为同课程详情,03/05 为同视频的不同播放状态,06 为考试详情,07 为未加载内容的页面外壳。 +- 补充包 `08.zip` 仅为 Angular 初始外壳,没有播放器、续播弹窗、次数提示的实际 DOM 或业务 JS 资源。可参考旧完整归档的静态弹窗模板、切页暂停逻辑及补考文案,不执行原站脚本,不据空外壳推断页面交互已通过实站验证。 - 03/05 的播放器位置与平台已记录学习时长不同;代码也区分播放百分比、已学习时长及完成标记。2X 不能直接承诺总任务耗时减半。 - 视频页虽包含课中弹题组件,但没有保存实际弹出的题干、选项及提交状态。课中题的真实结构、题型、确认方式和题库覆盖情况需要补样本验收。 - 需要核实课程实际完成条件、可用倍速、阶段解锁条件、当前账号的后台播放规则以及并行标签页是否被平台允许。 - 主题对应关系需要建立明确的课程/科目到统一题库主题映射;不能要求课程名与统一题库文件名完全相同,也不能只靠关键词猜测。 - 建议按“统一题库及旧数据迁移 → 任务清单与主题预检 → 单视频及进度 → 课中题 → 考试计分与定时交卷 → 顺序调度 → 并行与后台恢复”分阶段开发,每阶段完成对应验收。 +## 12. 2.1.2 增量确认 + +新增默认开启并持久保存的三个开关,分别控制自动选择“继续播放”、单次答题机会人工处理、切回页面恢复播放。用户确认的单次含义为“只剩 1 次或总共仅允许 1 次答题机会时,交给我手动答题”。保护共用于课中题和课程考试,不以本人已答次数代替剩余机会数。 + +修复网站主动后台暂停后返回不能及时续播的问题,同时保留用户暂停的优先级,真实显示后台暂停状态。新安装默认串联视频与考试,已有模式保留;面板根据实际模式说明视频完成后的走向。附件 `08.zip` 仅作静态外壳证据,真实账号的续播、平台完成条件及考试入口仍需实站核对;不能宣称后台始终播放。 + ## 附:用户原始需求 根据我提供的谊安学网页压缩包以及之前的完成的刷题脚本,现在需要做一个完整的刷课答题的脚本,现在需要所有任务的一个进度,以及预测时间,看视频过程中可能也有答题,以及当前任务的进度,刷视频要求可以在面板选择倍速,最高是2X,答题的可以设置答题间隔时间(默认4秒)以及交卷的时间(默认6分钟),需要增加只刷视频以及视频的任务以及视频中的答题的模式,或者只有考试的模式。答题之前先确认有没有该题库,没有则跳过该考试。或者考试和视频同时可以进行,导出题库放到其他设置的面板中。面板增加一个控制台的功能,可以看当前进度,视频如果卡住或者暂停自动刷新页面,增加网页可以后台的功能。