1.公司安全理念、方针及目标培训
From 1b497286dc2c97beb94fbfd6236a73da636a6b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E6=98=9F=E5=B9=BB=E6=B5=B7?= <30327324256@qq.com> Date: Sun, 18 Oct 2026 02:12:06 +0800 Subject: [PATCH] fix: resume video playback and advance linked exams with attempt controls --- README.md | 24 +- assistant-panel.js | 23 +- attempt-policy.js | 106 ++++++++ build-userscript.mjs | 4 +- exam-controller.js | 29 +- learning-engine.js | 243 ++++++++++++++--- package.json | 2 +- practice-exporter.js | 67 +++-- quiz-adapter.js | 28 +- tests/attempt-policy.cjs | 91 +++++++ tests/exam-controller.cjs | 75 ++++- tests/learning-engine.cjs | 290 +++++++++++++++++++- tests/panel-tabs.cjs | 51 ++++ tests/quiz-adapter.cjs | 39 ++- tests/run.cjs | 2 +- tests/video-exam-workflow.cjs | 125 +++++++++ tests/video-workflow.cjs | 45 +++ 实施开发方案.md | 14 + 谊安学助手 2.0.user.js | 499 +++++++++++++++++++++++++++++----- 需求.md | 27 +- 20 files changed, 1629 insertions(+), 155 deletions(-) create mode 100644 attempt-policy.js create mode 100644 tests/attempt-policy.cjs create mode 100644 tests/video-exam-workflow.cjs 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 + '' + tag + '>'; + return YianAttemptPolicy.read(document.querySelector('#scope')); + }, { html, tag }); + } + const positive = [ + '允许答题次数:1次', '总考试次数:1', '最多作答次数为一次', '剩余考试次数:1 次', + '可用答题机会:1次', '考试总次数:1次', '答题次数上限:1次', '允许答题次数:仅1次', '考试机会:仅剩1次', + '本次仅可答题1次', '仅允许考试一次', '仅有1次答题机会', '本场只剩一次考试机会', + '允许作答1次', '总共可考试1次', '还可补考 1 次', '还可补考一次', '剩余答题次数:1次', + '剩余 1 次答题机会', '还有一次考试机会', '还可重考1次', + '不及格,还可补考 1 次', '(不及格,还可补考 1 次)', + '', '', + '', + '
总考试次数: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题要答
' + ]; + 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次
还可补考0次
'); + assert.equal(stable.signature, (await read('剩余考试次数:1次
')).signature); + await page.evaluate(() => { document.querySelector('#scope').insertAdjacentHTML('afterend', '视频一
等待人工处理的题
允许考试次数: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 = '允许考试次数: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次
新题型
1.公司安全理念、方针及目标培训