From ef35b461f791dcb173da57b08394bf18f6aff1c4 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:30:56 +0800 Subject: [PATCH] fix: recover expired task leases and persist diagnostic logs --- README.md | 16 +- assistant-panel.js | 31 ++- build-userscript.mjs | 4 +- diagnostic-logger.js | 180 +++++++++++++++ learning-engine.js | 110 +++++++-- package.json | 2 +- practice-exporter.js | 85 ++++++- tests/diagnostic-logger.cjs | 105 +++++++++ tests/diagnostics-workflow.cjs | 160 +++++++++++++ tests/lease-recovery.cjs | 295 ++++++++++++++++++++++++ tests/panel-tabs.cjs | 32 ++- tests/run.cjs | 2 +- 实施开发方案.md | 9 + 谊安学助手 2.0.user.js | 409 ++++++++++++++++++++++++++++++--- 需求.md | 13 +- 15 files changed, 1368 insertions(+), 85 deletions(-) create mode 100644 diagnostic-logger.js create mode 100644 tests/diagnostic-logger.cjs create mode 100644 tests/diagnostics-workflow.cjs create mode 100644 tests/lease-recovery.cjs diff --git a/README.md b/README.md index bae0dfc..dfcbd4a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 谊安学助手 2.0 -当前发布版本 **2.1.2**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。 +当前发布版本 **2.1.3**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。 ## 安装与启动 @@ -20,7 +20,7 @@ | 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复;续播、单次答题机会保护、切回恢复三个开关 | | 考试答题 | 自动选择开关、缺题人工确认开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 | | 题库搜索 | 统一题库导入、关键词搜索、课程筛选、选中文字搜索、来源及主题关联 | -| 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示 | +| 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示;默认开启的持续诊断记录、最近 2000 条持久诊断及 JSON/TXT 导出 | | 其他设置 | 允许复制开关、默认关闭的题库导出折叠区、异常恢复参数 | 桌面左导航右内容,窄屏顶部标签。全局状态、进度和暂停/继续常驻;切栏不启停任务、不重置计时、不触发导出。拖动标题栏移动面板,位置自动保存;搜索和设置控件不会触发拖拽。 @@ -54,6 +54,8 @@ 默认连续 60 秒没有播放进度时尝试续播,15 秒后仍失败再刷新当前视频页面,最多 3 次,两次刷新至少间隔 30 秒,次数跨刷新保存。主动暂停、课中题、等待平台确认、等待交卷或等待人工不算故障。脚本发起的恢复及任务跳转保存一次性续作标记;人工刷新默认暂停。并行页面通过共享租约、心跳及全局控制协调,打开失败或未确认时显示等待,不假称并行成功。 +同类任务被另一执行页面占用时,临时等待租约并自动重试;旧执行页停止心跳、租约失效后继续调度,不要求因此手动点击继续。拿到租约后更新状态提示,避免仍显示“等待同类任务租约释放”。另一页面仍在正常处理时继续保持互斥;课中题、单次机会保护等需要人工处理的等待不会被自动重试覆盖。 + 保存站点脚本显示:网站切到后台会主动暂停播放器,返回前台没有相应自动恢复逻辑。本版记录切页前的真实播放状态,后台暂停时显示“后台暂停,等待返回”,开启上述恢复开关后返回再尝试续播,不把后台暂停误当成长时间卡顿而刷新。不能保证后台一直播放,也不能保证浏览器关闭、设备休眠或标签冻结期间执行;恢复后按实际播放器及平台记录校准,不伪造学习时长。真实账号的解锁、倍速、后台和并行规则仍需实站验收。 ## 考试预计得分与交卷 @@ -93,7 +95,15 @@ npm run merge:banks 测试依赖 Node.js 和 Playwright/Chrome;使用本机模拟页面及中和后的保存 DOM,所有业务 URL 由测试拦截,未访问业务服务器。验证范围包括统一题库迁移/来源/冲突、真实 40 题全文乱序、题间隔、80 分门槛与 4/6 分钟边界、缺题人工确认开关两种模式、设置保存及切换暂停、加载不全等通用门槛、整卷 100 分核验后的交卷流程、防重复、视频恢复、并行心跳、六栏及拖拽/窄屏、复制/导出/站点范围和完整任务流程;本次结果以实际测试记录为准。 -源代码按职责拆分:`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`。 +源代码按职责拆分:`bank-store.js`、`answer-matcher.js`、`answer-runner.js`、`attempt-policy.js`、`diagnostic-logger.js`、`exam-controller.js`、`quiz-adapter.js`、`learning-engine.js`、`assistant-panel.js`、`practice-exporter.js`。发布仅需构建后的单个 `.user.js`。 + +## 2.1.3 更新记录 + +已在本地复现并修复两项缺陷:租约临时冲突被记作永久人工等待,以及成功恢复后仍保留旧等待提示。另一执行页面仍有效时保持互斥,旧占用失效后自动重试。学习地图归档中的课程与考试入口使用 Angular 当前页路由,不能仅凭租约提示认定用户打开了重复标签。 + +控制台保留最近 500 条运行日志,“清空显示”只清运行日志显示。新增“持续记录诊断日志”开关,默认开启,最近 2000 条诊断记录独立持久保存,刷新后保留;使用“导出诊断 JSON”“导出诊断 TXT”可一键导出,使用“清空已保存诊断”可删除这些诊断记录。关闭开关只停止新增记录,已保存内容仍可导出。两种清空都不删除任务检查点或题库;诊断日志不记录完整题干、选项、答案和 URL 查询参数。 + +`npm test` 的 18 套回归全部通过,执行权恢复另完成 12 项检查,涵盖过期自动接续、有效外页持续续租不抢占、旧等待迁移、丢失执行权停止旧动作、恢复重验及人工等待优先。完整脚本验证了 JSON/TXT 实际下载、跨刷新保留、关闭后停止新增及清空不删检查点;桌面与 390px 窄屏无相关浏览器错误。所有业务请求均在本地拦截,真实账号环境仍需实站验收。 ## 2.1.2 更新记录 diff --git a/assistant-panel.js b/assistant-panel.js index 458d5e4..c2929a6 100644 --- a/assistant-panel.js +++ b/assistant-panel.js @@ -22,6 +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, + diagnostics: { enabled: true, total: 0, dropped: 0, limit: 2000, storage: '', pending: 0, storageError: '' }, 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; @@ -555,7 +556,21 @@ invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success'); }); - views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台')); + views.console.appendChild(el(doc, 'h2', 'section-title', '故障诊断记录')); + const diagnosticSwitch = makeSwitch('持续记录诊断日志', function () { + state.diagnostics.enabled = diagnosticSwitch.input.checked; + invoke('onLogAction', 'record', diagnosticSwitch.input.checked); refreshLearning(); + }, views.console); + diagnosticSwitch.input.setAttribute('data-testid', 'diagnostic-record'); + const diagnosticStatus = el(doc, 'p', 'hint'); diagnosticStatus.setAttribute('data-testid', 'diagnostic-status'); + const diagnosticActions = el(doc, 'div', 'console-actions'); + [['exportJson', '导出诊断 JSON'], ['exportText', '导出诊断 TXT'], ['clearSaved', '清空已保存诊断']].forEach(function (entry) { + const button = el(doc, 'button', '', entry[1]); button.type = 'button'; + button.setAttribute('data-testid', 'diagnostic-' + entry[0]); + button.addEventListener('click', function () { invoke('onLogAction', entry[0]); }); + diagnosticActions.appendChild(button); + }); + views.console.append(diagnosticStatus, el(doc, 'p', 'hint', '记录任务切换、播放、执行权和交卷等待原因,刷新后可导出核对;不保存题库、题目或答案正文。关闭只停止新增记录。'), diagnosticActions, el(doc, 'h2', 'section-title', '本轮运行显示')); const logActions = el(doc, 'div', 'console-actions'); const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别'); [['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); }); @@ -660,7 +675,7 @@ }); } - 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: '已读取目录' }; + const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' }; function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; } function firstValue(object, keys, fallback) { object = object || {}; @@ -683,7 +698,7 @@ const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0); const paused = ['paused', 'stopped'].includes(learning.status); const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle'); - setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '')); + setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '') + (Number(learning.leaseWaiting) > 0 ? ' · ' + Number(learning.leaseWaiting) + ' 项等待执行权' : '')); setText(globalPause, paused ? '继续全部' : '暂停全部'); globalPause.disabled = !active && !paused; globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total); @@ -692,7 +707,7 @@ const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务')); const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle'); setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : '')); - setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : '')); + setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : Number(learning.leaseWaiting) > 0 ? ' · 待执行权' : '')); collapseSummary.hidden = Boolean(state.expanded); setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。'); Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); }); @@ -764,6 +779,13 @@ setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。'); if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n'); } + const diagnostics = state.diagnostics || {}; + diagnosticSwitch.input.checked = diagnostics.enabled !== false; + setText(diagnosticStatus, (diagnostics.enabled === false ? '已停止新增记录' : '记录已开启') + ' · 已记录 ' + Math.max(0, Number(diagnostics.total) || 0) + ' 条 / 最多 ' + (Number(diagnostics.limit) || 2000) + ' 条' + + (Number(diagnostics.pending) > 0 ? ' · 待保存 ' + Number(diagnostics.pending) + ' 条' : '') + + (Number(diagnostics.dropped) > 0 ? ' · 已移除最早 ' + Number(diagnostics.dropped) + ' 条' : '') + + (diagnostics.storage === 'memory' ? ' · 当前仅内存保存' : diagnostics.storage === 'origin' ? ' · 当前浏览器本地保存' : '') + + (diagnostics.storageError ? '\n保存异常:' + plain(diagnostics.storageError) + ';可先导出当前记录。' : '')); const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500); const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; }); const nextLogSignature = JSON.stringify([logFilter.value, logs]); @@ -930,6 +952,7 @@ if (Object.prototype.hasOwnProperty.call(update, 'learning')) { update.learning = Object.assign({}, state.learning || {}, update.learning || {}); } + if (Object.prototype.hasOwnProperty.call(update, 'diagnostics')) update.diagnostics = Object.assign({}, state.diagnostics || {}, update.diagnostics || {}); Object.assign(state, update); refresh(); if (acceptPosition) { diff --git a/build-userscript.mjs b/build-userscript.mjs index 297c4f2..b13952a 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.2 +// @version 2.1.3 // @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', 'attempt-policy.js', 'learning-engine.js', 'quiz-adapter.js', 'exam-controller.js', 'practice-exporter.js']; + 'copy-controller.js', 'diagnostic-logger.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/diagnostic-logger.js b/diagnostic-logger.js new file mode 100644 index 0000000..96f2594 --- /dev/null +++ b/diagnostic-logger.js @@ -0,0 +1,180 @@ +/* Bounded, local-only diagnostics. Never stores question or answer contents. */ +(function (root) { + 'use strict'; + const KEY = 'diagnosticLog.v1'; + const ENABLED_KEY = 'diagnosticLog.enabled'; + const LIMIT = 2000; + const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' ')); + const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i; + function safeURL(value) { + try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; } + catch (_) { return String(value).split(/[?#]/)[0].slice(0, 400); } + } + function text(value, maximum) { + let string; + try { string = String(value == null ? '' : value); } catch (_) { return '[无法读取]'; } + return string + .replace(/https?:\/\/[^\s<>"']+/gi, safeURL) + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [已隐藏]') + .replace(/\b(password|passwd|token|cookie|authorization|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[已隐藏]') + .slice(0, maximum || 700); + } + function sanitize(value, depth, seen) { + if (value == null || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string') return text(value); + if (typeof value !== 'object' || depth > 4 || seen.has(value)) return null; + seen.add(value); + let result; + if (Array.isArray(value)) result = value.slice(0, 30).map(function (entry) { return sanitize(entry, depth + 1, seen); }); + else { + result = {}; + Object.keys(value).slice(0, 100).forEach(function (key) { + if (PRIVATE_KEY.test(key) || !DETAIL_KEYS.has(key)) return; + if (typeof value[key] === 'function' || typeof value[key] === 'undefined') return; + result[key] = /^(url|pageUrl|path|route|destination)$/.test(key) && typeof value[key] === 'string' ? safeURL(value[key]) : sanitize(value[key], depth + 1, seen); + }); + } + seen.delete(value); + return result; + } + function clean(value) { try { return sanitize(value, 0, new Set()); } catch (_) { return {}; } } + function create(options) { + options = options || {}; + const store = options.storage || { name: 'memory', get: async function (_, fallback) { return fallback; }, set: async function () {} }; + const now = typeof options.now === 'function' ? options.now : Date.now; + const session = Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36); + let sequence = 0, records = [], pending = [], enabled = true, initialized = false, destroyed = false; + let dropped = 0, clearedAt = -1, timer = null, errorCount = 0, storageError = '', queue = Promise.resolve(); + let settingRevision = 0; + function timestamp() { const value = Number(now()); return Number.isFinite(value) ? value : Date.now(); } + function emit() { if (typeof options.onState === 'function') { try { options.onState(getState()); } catch (_) { /* UI failures must not interrupt tasks. */ } } } + function report(error) { + storageError = text(error && error.message || error || '日志存储失败', 240); + if (errorCount++ < 1 && typeof options.onError === 'function') { try { options.onError(new Error(storageError)); } catch (_) { /* Do not recurse through diagnostics. */ } } + } + function validate(data) { + if (!data || data.schema !== 1 || !Array.isArray(data.records)) return { records: [], dropped: 0, clearedAt: -1 }; + const cutoff = Number.isFinite(data.clearedAt) ? data.clearedAt : -1; + return { records: data.records.filter(function (item) { return item && typeof item.id === 'string' && Number.isFinite(item.time) && item.time > cutoff; }).slice(-LIMIT).map(function (item) { + return { id: text(item.id, 100), time: item.time, lastTime: Number.isFinite(item.lastTime) ? item.lastTime : item.time, + level: ['info', 'warning', 'error'].includes(item.level) ? item.level : 'info', event: text(item.event, 100), message: text(item.message), details: clean(item.details), context: clean(item.context), count: Math.max(1, Math.min(1000000, Math.floor(Number(item.count) || 1))) }; + }), dropped: Math.max(0, Number(data.dropped) || 0), clearedAt: cutoff }; + } + function merge(left, right, cutoff) { + const map = new Map(); + left.concat(right).forEach(function (item) { + if (item.time <= cutoff) return; + const old = map.get(item.id); + if (!old || item.count > old.count || item.lastTime > old.lastTime) map.set(item.id, item); + }); + const result = Array.from(map.values()).sort(function (a, b) { return a.time - b.time || a.id.localeCompare(b.id); }); + const removed = Math.max(0, result.length - LIMIT); + return { records: result.slice(-LIMIT), removed: removed }; + } + const ready = Promise.resolve().then(async function () { + const revision = settingRevision; + try { + const values = await Promise.all([store.get(KEY, null), store.get(ENABLED_KEY, true)]); + const saved = validate(values[0]); + if (revision === 0 && settingRevision === 0) enabled = values[1] !== false; + clearedAt = saved.clearedAt; dropped = saved.dropped; + records = merge(saved.records, enabled ? records : [], clearedAt).records; + if (!enabled) pending = []; + } catch (error) { report(error); } + initialized = true; + if (pending.length) schedule(); + emit(); + return getState(); + }); + function getState() { + return { enabled: enabled, total: records.length, dropped: dropped, limit: LIMIT, storage: store.name || 'local', storageError: storageError, initialized: initialized, pending: pending.length, sessionId: session }; + } + function schedule() { + if (timer || destroyed || !initialized) return; + timer = root.setTimeout(function () { timer = null; flush().catch(function () {}); }, 450); + if (timer && typeof timer.unref === 'function') timer.unref(); + } + function record(level, event, message, details) { + if (!enabled || destroyed) return false; + let context = {}; + try { context = typeof options.getContext === 'function' ? clean(options.getContext()) : {}; } catch (_) { /* Logging may never interrupt a task. */ } + const time = Math.max(timestamp(), clearedAt + 1); + const entry = { id: session + '.' + (++sequence), time: time, lastTime: time, + level: level === 'warn' ? 'warning' : ['info', 'warning', 'error'].includes(level) ? level : 'info', + event: /^[\w.:-]{1,100}$/.test(String(event || '')) ? String(event) : 'diagnostic', message: text(message), details: clean(details || {}), context: context, count: 1 }; + const previous = records[records.length - 1]; + if (previous && previous.id.startsWith(session + '.') && time - previous.lastTime >= 0 && time - previous.lastTime <= 2000 && JSON.stringify([previous.level, previous.event, previous.message, previous.details, previous.context]) === JSON.stringify([entry.level, entry.event, entry.message, entry.details, entry.context])) { + previous.count = Math.min(1000000, previous.count + 1); previous.lastTime = time; + pending.push(Object.assign({}, previous)); + } else { records.push(entry); pending.push(entry); } + // Both the visible buffer and unsaved batch are bounded, even if storage is unavailable. + if (records.length > LIMIT) { dropped += records.length - LIMIT; records = records.slice(-LIMIT); } + const batch = new Map(); pending.forEach(function (item) { batch.set(item.id, item); }); pending = Array.from(batch.values()).slice(-LIMIT); + schedule(); return true; + } + function lock(action) { + const locks = root.navigator && root.navigator.locks; + return locks && typeof locks.request === 'function' ? locks.request('yianxue-diagnostic-log-v1', action) : action(); + } + function flush() { + if (timer) { root.clearTimeout(timer); timer = null; } + queue = queue.then(async function () { + await ready; + try { + await lock(async function () { + const saved = validate(await store.get(KEY, null)); + const cutoff = Math.max(clearedAt, saved.clearedAt); + const merged = merge(saved.records, records, cutoff); + const nextDropped = Math.max(dropped, saved.dropped + merged.removed); + const payload = JSON.parse(JSON.stringify({ schema: 1, records: merged.records, dropped: nextDropped, clearedAt: cutoff })); + if (pending.length) await store.set(KEY, payload); + const savedIds = new Map(payload.records.map(function (item) { return [item.id, [item.count, item.lastTime]]; })); + pending = pending.filter(function (item) { const value = savedIds.get(item.id); return !value || item.count > value[0] || item.lastTime > value[1]; }); + const local = merge(payload.records, pending, cutoff); records = local.records; dropped = Math.max(dropped, nextDropped + local.removed); clearedAt = cutoff; + storageError = ''; + }); + } catch (error) { report(error); } + emit(); + return getState(); + }); + return queue; + } + async function setEnabled(value) { + settingRevision++; enabled = value !== false; + await ready; + if (!enabled) await flush(); + try { await store.set(ENABLED_KEY, enabled); } catch (error) { report(error); } + emit(); + return getState(); + } + function getRecords() { return JSON.parse(JSON.stringify(records)); } + async function clear() { + await ready; await flush(); + queue = queue.then(async function () { try { return await lock(async function () { + clearedAt = Math.max(timestamp(), records.reduce(function (latest, item) { return Math.max(latest, item.time); }, -1)); + records = []; pending = []; dropped = 0; + try { await store.set(KEY, { schema: 1, records: [], dropped: 0, clearedAt: clearedAt }); storageError = ''; } catch (error) { report(error); } + emit(); + return getState(); + }); } catch (error) { report(error); return getState(); } }); + return queue; + } + async function exportJSON() { + await flush(); + return JSON.stringify({ schema: 1, exportedAt: new Date(timestamp()).toISOString(), state: getState(), records: getRecords() }, null, 2); + } + async function exportText() { + await flush(); + const state = getState(); + return '谊安学助手诊断日志\n导出时间:' + new Date(timestamp()).toISOString() + '\n已保存:' + state.total + ' 条;超出容量移除:' + state.dropped + ' 条\n\n' + records.map(function (item) { + return new Date(item.time).toISOString() + ' [' + item.level + '] ' + item.event + ' ' + item.message + (item.count > 1 ? '(重复 ' + item.count + ' 次,最后 ' + new Date(item.lastTime).toISOString() + ')' : '') + '\n context=' + JSON.stringify(item.context) + '\n details=' + JSON.stringify(item.details); + }).join('\n'); + } + async function destroy() { destroyed = true; if (timer) { root.clearTimeout(timer); timer = null; } return flush(); } + return { ready: ready, record: record, setEnabled: setEnabled, getState: getState, getRecords: getRecords, clear: clear, exportJSON: exportJSON, exportText: exportText, flush: flush, destroy: destroy }; + } + const api = Object.freeze({ create: create, key: KEY, enabledKey: ENABLED_KEY, limit: LIMIT }); + root.YianDiagnosticLogger = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/learning-engine.js b/learning-engine.js index 541f74b..574bc05 100644 --- a/learning-engine.js +++ b/learning-engine.js @@ -8,7 +8,7 @@ 'use strict'; const TERMINAL = ['completed', 'skipped', 'failed', 'expanded']; - const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'retrying']; + const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'waitingLease', 'retrying']; const MODES = ['video', 'videoQuiz', 'exam', 'all']; const LEASE_MS = 18000; const SESSION_KEY = 'session.v1'; @@ -188,6 +188,7 @@ 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 logSequence = 0; let leaseBanner = null; let manualConfirmRevision = 0; let pendingManualConfirm = null; let policySessionId = '', policyReadSerial = 0; const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map(); @@ -231,9 +232,10 @@ 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; + state.leaseWaiting = counted.filter(t => t.status === 'waitingLease').length; const todo = counted.filter(t => !TERMINAL.includes(t.status)); const estimates = todo.map(t => { - if (t.status === 'waiting' || t.kind === 'course' || t.kind === 'unsupported') return null; + if (['waiting', 'waitingLease'].includes(t.status) || t.kind === 'course' || t.kind === 'unsupported') return null; if (t.kind === 'video') { const v = state.video && state.video.taskId === t.id ? state.video : t; if (!(v.duration > 0)) return null; @@ -251,13 +253,15 @@ state.etaSeconds = estimates.some(e => e == null) ? null : config.execution === 'parallel' ? Math.max(0, ...['video', 'exam'].map(kind => estimates.reduce((n, e, i) => n + (todo[i].kind === kind ? e : 0), 0))) : estimates.reduce((n, e) => n + e, 0); if (typeof options.onState === 'function') options.onState(clone(state)); } - function log(level, message, task) { - state.logs.push({ time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', message: String(message), task: task && task.title || '', taskId: task && task.id || '' }); + function log(level, message, task, details, event) { + const record = { id: owner + ':' + (++logSequence), time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', event: event || 'learning.message', message: String(message), task: task && task.title || '', taskId: task && task.id || '', details: details || {} }; + state.logs.push(record); + if (typeof options.onLog === 'function') { try { options.onLog(clone(record)); } catch (_) {} } state.logs = state.logs.slice(-500); notify(); } function taskState(task, status, reason) { const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || ''; - if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task); + if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state'); } function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); } function account(snap, page) { return clean(snap.accountId || page.accountId || ''); } @@ -294,7 +298,49 @@ if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; } } async function release() { - for (const kind of ['video', 'exam']) { const key = 'lease:' + kind; const lease = await storage.get(key); if (lease && lease.owner === owner) await storage.set(key, null); } + for (const kind of ['video', 'exam']) { + const key = 'lease:' + kind; const lease = await storage.get(key); + if (lease && lease.owner === owner) { await storage.set(key, null); log('info', '已释放' + (kind === 'video' ? '视频/课程' : '考试') + '执行权', null, lease, 'lease.released'); } + } + } + function waitForLease(task, lease, reason) { + const previous = task.leaseWait; + task.leaseWait = { previousStatus: previous?.previousStatus || (task.status === 'waitingLease' ? 'pending' : task.status), previousReason: previous ? previous.previousReason : task.reason || '', lastLoggedAt: previous?.lastLoggedAt, + since: previous?.since == null ? now() : previous.since, kind: task.kind === 'course' ? 'video' : task.kind, + owner: lease?.owner || '', taskId: lease?.taskId || '', expires: Number(lease?.expires) || 0, pageUrl: lease?.pageUrl || '', reason }; + if (!previous && activeTaskId === task.id) { + // 等待执行权期间取消本页旧动作;重新领取后仍须按当前页面重验。 + if (task.kind === 'video') { + const snap = snapshot(); const video = snap.document && describePage(snap.document, snap.url).video; + if (video) video.pause(); playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); + if (state.video) state.video.status = 'waitingLease'; + } + if (task.kind === 'exam' && task.examRequested && task.leaseWait.previousStatus !== 'waiting') { + task.examNeedsRecheck = true; + if (typeof options.onExamPause === 'function') options.onExamPause('本页已失去考试执行权,停止旧自动作答并等待重新核验'); + } + } + const secondsLeft = Math.max(0, Math.ceil((task.leaseWait.expires - now()) / 1000)); + const holder = lease?.title || lease?.taskId || '另一页面任务'; + const message = reason === 'control' ? '正在重新核对全局任务状态,暂不操作当前页面' + : '等待' + (task.leaseWait.kind === 'exam' ? '考试' : '视频/课程') + '执行权:' + holder + '由另一个页面处理,约 ' + secondsLeft + ' 秒后检查;执行权释放后自动继续'; + taskState(task, 'waitingLease', '同类任务由另一个页面处理,自动等待并重试'); + state.message = message; leaseBanner = { taskId: task.id, message }; + if (!previous || previous.owner !== task.leaseWait.owner || previous.taskId !== task.leaseWait.taskId || previous.reason !== reason || now() - (previous.lastLoggedAt || 0) >= 15000) { + task.leaseWait.lastLoggedAt = now(); + log('info', message, task, Object.assign({ ownOwner: owner }, task.leaseWait), 'lease.wait'); + } + } + function acquiredLease(task) { + if (!task.leaseWait) return; + const waited = task.leaseWait; + if (task.status === 'waitingLease') { task.status = waited.previousStatus || 'pending'; task.reason = waited.previousReason || ''; } + delete task.leaseWait; + if (leaseBanner?.taskId === task.id) { + if (state.message === leaseBanner.message) state.message = '已取得' + (task.kind === 'exam' ? '考试' : '视频/课程') + '执行权,正在继续任务'; + leaseBanner = null; + } + log('info', '等待结束,已取得执行权并重新核对任务', task, { waitedMs: Math.max(0, now() - waited.since), kind: waited.kind, previousOwner: waited.owner }, 'lease.resumed'); } async function claim(task) { const kind = task.kind === 'course' ? 'video' : task.kind; @@ -302,13 +348,16 @@ async function acquire() { const key = 'lease:' + kind; const lease = await storage.get(key); const shared = await storage.get(CONTROL_KEY); - if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false; - if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) return false; - const wanted = { owner, taskId: task.id, sessionId: session.id, expires: now() + LEASE_MS }; + if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) { waitForLease(task, null, 'control'); return false; } + if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) { waitForLease(task, lease, 'busy'); return false; } + if (lease && lease.expires <= now()) log('info', '原执行权已到期,重新领取任务', task, lease, 'lease.expired'); + const wanted = { owner, taskId: task.id, title: task.title, pageUrl: canonical(snapshot().url), sessionId: session.id, expires: now() + LEASE_MS, updatedAt: now() }; await storage.set(key, wanted); // GM存储无CAS;延后回读并在每次动作前复核。支持Web Locks时同源标签领取是互斥的。 if (!(root.navigator && root.navigator.locks)) await new Promise(resolve => root.setTimeout(resolve, 25)); - const saved = await storage.get(key); return Boolean(saved && saved.owner === owner && saved.taskId === task.id); + const saved = await storage.get(key); + if (!saved || saved.owner !== owner || saved.taskId !== task.id || saved.sessionId !== session.id) { waitForLease(task, saved, 'race'); return false; } + acquiredLease(task); log('info', '已取得' + (kind === 'exam' ? '考试' : '视频/课程') + '执行权', task, wanted, 'lease.acquired'); return true; } return root.navigator && root.navigator.locks ? root.navigator.locks.request('yian-learning-' + kind, acquire) : acquire(); } @@ -316,7 +365,9 @@ const key = 'lease:' + (task.kind === 'course' ? 'video' : task.kind); const lease = await storage.get(key); if (!lease || lease.owner !== owner || lease.taskId !== task.id || lease.sessionId !== session.id) return false; const shared = await storage.get(CONTROL_KEY); if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false; - lease.expires = now() + LEASE_MS; await storage.set(key, lease); return true; + if (lease.expires <= now()) log('info', '本页原执行权已到期,核对所有者后恢复', task, lease, 'lease.renewed'); + lease.expires = now() + LEASE_MS; lease.updatedAt = now(); lease.pageUrl = canonical(snapshot().url); lease.title = task.title; + await storage.set(key, lease); return true; } function mergeTasks(tasks, parent) { tasks.forEach(task => { @@ -375,6 +426,9 @@ const identityOK = (!saved.accountId || saved.accountId === account(snap, page)) && (!saved.planId || !snap.planId || saved.planId === snap.planId) && (!saved.bankVersion || !snap.bankVersion || saved.bankVersion === snap.bankVersion); if (identityOK) { session = saved; Object.assign(config, saved.config || {}); state.tasks = saved.tasks; state.logs = Array.isArray(saved.logs) ? saved.logs.slice(-500) : []; activeTaskId = saved.activeTaskId || ''; + for (const task of state.tasks) if (task.status === 'waiting' && task.reason === '另一个页面正在处理同类任务,避免重复领取') { + task.status = 'waitingLease'; task.leaseWait = { previousStatus: 'pending', previousReason: '', since: now() }; + } if (marker) { const request = await storage.get('worker:' + marker[1]); const control = await storage.get(CONTROL_KEY); @@ -547,7 +601,7 @@ task.preflight = preflightTopic(snap.document, snap, config.associations, task); if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; } } - if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; } + if (!(await claim(task))) return 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); @@ -618,7 +672,7 @@ 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(); } - } catch (error) { if (epoch === generation) taskState(task, 'waiting', '课中题处理失败:' + error.message); } + } catch (error) { if (epoch === generation) { log('error', '课中题处理发生异常', task, { errorName: error.name, error: error.message, stack: error.stack }, 'quiz.error'); taskState(task, 'waiting', '课中题处理失败:' + error.message); } } finally { pendingQuiz = null; } return; } @@ -644,14 +698,14 @@ playStarted = true; lastAutoPlayAt = now(); try { const promise = video.play(); if (promise && promise.catch) await promise; } - catch (_) { taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; } + catch (error) { log('warn', '播放器启动失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.playFailed'); taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; } if (epoch !== generation || state.status !== 'running') { video.pause(); return; } lastAdvancedAt = now(); } if (lastTime === null || Math.abs(currentTime - lastTime) > 0.01) { lastTime = currentTime; lastAdvancedAt = now(); attemptedAt = 0; taskState(task, 'playing', page.hidden ? '页面在后台,按真实播放与平台记录跟踪' : '视频播放中'); return; } if (!attemptedAt && now() - lastAdvancedAt >= config.stallSeconds * 1000) { attemptedAt = now(); taskState(task, 'retrying', '播放进度长时间未变化,先尝试续播'); - try { await video.play(); } catch (_) { taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); } + try { await video.play(); } catch (error) { log('warn', '播放器续播失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.resumeFailed'); taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); } return; } if (attemptedAt && now() - attemptedAt >= config.recoverySeconds * 1000) { @@ -686,6 +740,10 @@ if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url; } const attempts = readExamAttempt(task, snap, page); + if (task.examRequested && task.examNeedsRecheck) { + await observeExamState(task, true); + if (task.status === 'completed') return; + } 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; @@ -717,11 +775,12 @@ await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 }); buttons[0].click(); return; } - if (!task.examRequested || task.examPage !== page.kind) { + if (!task.examRequested || task.examNeedsRecheck || task.examPage !== page.kind) { task.examPage = page.kind; taskState(task, 'answering', '考试主题已确认,交给答题与交卷控制器'); if (typeof options.onExamStart === 'function') { const accepted = await options.onExamStart({ task: clone(task), preflight: clone(task.preflight), document: snap.document, page: page.kind, config: clone(config) }); task.examRequested = accepted !== false; + if (accepted !== false) task.examNeedsRecheck = false; if (accepted === false) { if (!task.readyWaitAt) task.readyWaitAt = now(); if (now() - task.readyWaitAt > 15000) taskState(task, 'waiting', '试卷未完整加载,请人工核对后继续'); } } else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理'); @@ -811,17 +870,24 @@ if (state.status !== 'running') { await persist(); notify(); return getState(); } if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; } if (!task) { - const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId)); + const candidates = state.tasks.filter(t => ['pending', 'waitingLease'].includes(t.status) && allows(t.kind) && (!worker || t.id === worker.taskId)); // 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。 function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); } candidates.sort((a, b) => rank(a) - rank(b)); - for (const next of candidates) { if (await activate(next, snap, page)) { task = next; break; } } + const blockedKinds = new Set(); + for (const next of candidates) { + const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue; + if (await activate(next, snap, page)) { task = next; break; } + if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; } + } } if (task && epoch === generation) { const held = await heartbeat(task) || await claim(task); - if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面'; - else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch); - else if (task.kind === 'exam') await examTick(task, snap, page); + if (held) { + acquiredLease(task); + if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch); + else if (task.kind === 'exam') await examTick(task, snap, page); + } } if (epoch === generation) await parallelTick(snap, page); if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } } @@ -829,7 +895,7 @@ if (!task && !remaining.length) { state.status = state.tasks.length ? 'finished' : 'waiting'; state.message = state.tasks.length ? '本轮调度结束;请检查完成、跳过及失败清单' : '当前页面没有可识别的任务,请打开学习地图、课程或试卷'; if (session) session.status = state.status; await control(state.status); } else if (!task && remaining.every(t => t.status === 'waiting')) { state.message = '剩余任务等待人工处理;确认后点击继续'; } await persist(); notify(); return getState(); - } catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message); return getState(); } + } catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message, null, { errorName: error.name, error: error.message, stack: error.stack }, 'learning.error'); return getState(); } finally { busy = false; } } async function observeControl(value) { diff --git a/package.json b/package.json index 255d427..8f11200 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yianxue-question-bank-exporter", - "version": "2.1.2", + "version": "2.1.3", "private": true, "description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验", "scripts": { diff --git a/practice-exporter.js b/practice-exporter.js index c30a2a8..6171d36 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -16,10 +16,15 @@ let positionRevision = 0; let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle'; let actionEpoch = 0; + let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href; + const version = '2.1.3'; 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 recordDiagnostic(level, event, message, details) { + return diagnostics?.record(level, event, message, details) || false; + } function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; } function attemptPolicy() { @@ -90,10 +95,16 @@ const runner = window.YianAnswerRunner.create({ getContext, match: (question, context) => matcher.match(question, context, snapshot), getInterval: () => learningSettings.answerInterval, - onProgress: progress => { metrics = progress; panel.setState({ metrics }); }, + onProgress: progress => { + metrics = progress; panel.setState({ metrics }); + const counts = { selected: progress.selected, pending: progress.pending, missing: progress.missing, conflict: progress.conflict, failed: progress.failed, total: progress.total }; + const signature = JSON.stringify(counts); + if (signature !== lastAnswerDiagnostic) { lastAnswerDiagnostic = signature; recordDiagnostic('info', 'answer.progress', '答案核验进度已更新', counts); } + }, onState: (state, reason) => { if (disposed) return; runnerState = state; + recordDiagnostic(state === 'paused' ? 'warning' : 'info', 'answer.state', reason || '答题状态更新', { status: state }); if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); } if (state === 'paused') { examController?.disable(reason); learning?.configure({ examPaused: true }); } if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success'); @@ -150,6 +161,7 @@ panel.setNotice('请先在题库栏确认当前考试标题与题库课程/主题的关联。', 'warning'); return false; } config.autoAnswer = !!enabled; runKey = ''; + recordDiagnostic('info', 'answer.toggle', '自动选择' + (enabled ? '开启' : '关闭'), { enabled: !!enabled }); if (!enabled) { runner.stop('自动选择已关闭,已生效的选择保留。'); examController?.disable('自动选择已关闭'); } refreshPanel(); tick(); return config.autoAnswer; } @@ -199,14 +211,34 @@ onExportEnabledChange: setExportEnabled ,onTaskAction: action => { taskAction(action).catch(error => panel.setNotice(error.message, 'error')); }, onLearningSetting: (key, value) => { changeLearningSetting(key, value).catch(error => panel.setNotice(error.message, 'error')); }, - onLogAction: action => { - if (action === 'clear') learning?.clearLogs?.(); - if (action === 'copy') { - const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n'); - if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text).catch(() => {}); - } - } + onLogAction: (action, value) => { logAction(action, value).catch(error => panel.setNotice('日志操作失败:' + error.message, 'error')); } }); + diagnostics = window.YianDiagnosticLogger.create({ + storage: { name: '本机用户脚本存储', get: (key, fallback) => store.getSetting('diagnostics:' + key, fallback), set: (key, value) => store.setSetting('diagnostics:' + key, value) }, + getContext: () => ({ version, url: location.href, pageKind: window.YianLearningEngine.describePage(document, location.href).kind, + status: learningState.status || 'initializing', mode: learningSettings.mode, execution: learningSettings.execution, + bankTotal: snapshot.total, bankRevision: snapshot.revision }), + onState: info => panel.setState({ diagnostics: info }), + onError: error => panel.setNotice('诊断日志保存失败,仍保留内存记录:' + error.message, 'warning') + }); + async function exportDiagnostics(format) { + if (!inScope() || !['json', 'txt'].includes(format)) throw new Error('请选择 JSON 或 TXT 日志格式'); + const contents = await (format === 'json' ? diagnostics.exportJSON() : diagnostics.exportText()); + const url = URL.createObjectURL(new Blob([format === 'txt' ? '\ufeff' + contents : contents], { type: format === 'json' ? 'application/json;charset=utf-8' : 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); link.href = url; link.download = '谊安学-诊断日志-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + format; + document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); + panel.setNotice('诊断日志已导出,可用来核对卡住前后的执行过程。', 'success'); return contents; + } + async function logAction(action, value) { + if (action === 'record') return diagnostics.setEnabled(value === true); + if (action === 'exportJson' || action === 'exportText') return exportDiagnostics(action === 'exportJson' ? 'json' : 'txt'); + if (action === 'clearSaved') { await diagnostics.clear(); panel.setNotice('已清空保存的诊断日志;题库和任务检查点保留。', 'success'); return; } + if (action === 'clear') learning?.clearLogs?.(); + if (action === 'copy') { + const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n'); + if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else await navigator.clipboard?.writeText(text); + } + } async function changeLearningSetting(key, value) { const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh']; @@ -226,6 +258,7 @@ throw new Error('主题关联须使用完整标题及题库课程名称列表'); } const pending = { value }; pendingLearningSettings.set(key, pending); + recordDiagnostic('info', 'settings.changed', '学习设置已修改', { name: key, requested: value }); try { if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) { actionEpoch++; @@ -242,6 +275,7 @@ } async function taskAction(action) { actionEpoch++; + recordDiagnostic('info', 'task.action', '任务操作:' + action, { action, status: learningState.status, mode: learningSettings.mode, execution: learningSettings.execution }); await ready; if (!inScope()) return; if (action === 'start' || action === 'resume') { @@ -268,7 +302,16 @@ skipSingleAttemptQuiz: skipSingleAttempt() }), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState, storage: learningStorage, onLog: (level, message) => learning?.log(level, message), - onState: state => { examState = state; renderLearning(); } }); + onState: state => { + examState = state; + const signature = JSON.stringify([state.stage, state.enabled, state.verifiedCount, state.predictedScore, state.submitted, String(state.message || '').replace(/\d+(?:\.\d+)?/g, '#')]); + if (signature !== lastExamDiagnostic) { + lastExamDiagnostic = signature; + recordDiagnostic(['unknown', 'failed'].includes(state.stage) ? 'warning' : 'info', 'exam.state', state.message || '考试状态更新', + { stage: state.stage, enabled: state.enabled, verifiedCount: state.verifiedCount, total: state.total, predictedScore: state.predictedScore, elapsedSeconds: state.elapsedSeconds, remainingSeconds: state.remainingSeconds, submitted: state.submitted, manualConfirmIncomplete: state.manualConfirmIncomplete, skipSingleAttemptQuiz: state.skipSingleAttemptQuiz }); + } + renderLearning(); + } }); learning = window.YianLearningEngine.create({ getSnapshot: () => ({ document, url: location.href, courses: courses(), bank: snapshot, bankVersion: snapshot.revision, accountId: document.querySelector('[data-user-id]')?.getAttribute('data-user-id') || '', planId: '' }), @@ -280,6 +323,7 @@ return false; }, onExamPause: reason => { setAutoAnswer(false); examController.disable(reason); }, + onLog: record => recordDiagnostic(record.level, record.event, record.message, Object.assign({ id: record.id, taskId: record.taskId, taskTitle: record.task }, record.details)), solveQuiz: async (_popup, quizContext) => { const revision = snapshot.revision; const mode = learningSettings.mode; @@ -308,6 +352,9 @@ function tick() { if (disposed) return; + if (diagnosticRoute !== location.href) { + recordDiagnostic('info', 'page.changed', '页面地址已变化', { source: diagnosticRoute, destination: location.href }); diagnosticRoute = location.href; + } if (!inScope()) { if (!scopedOut) { scopedOut = true; @@ -396,15 +443,27 @@ const timer = setInterval(tick, 600); const onNavigation = () => { runner.stop('页面地址已变化'); runKey = ''; tick(); }; window.addEventListener('popstate', onNavigation); window.addEventListener('hashchange', onNavigation); + const onError = event => recordDiagnostic('error', 'runtime.error', event.message || '网页脚本发生错误', { errorName: event.error?.name, stack: event.error?.stack }); + const onRejection = event => recordDiagnostic('error', 'runtime.rejection', event.reason?.message || String(event.reason || '异步操作失败'), { errorName: event.reason?.name, stack: event.reason?.stack }); + const onVisibility = () => recordDiagnostic('info', 'page.visibility', document.visibilityState === 'hidden' ? '页面进入后台' : '页面返回前台', { visibility: document.visibilityState }); + const onNetwork = event => recordDiagnostic(event.type === 'offline' ? 'warning' : 'info', 'network.' + event.type, event.type === 'offline' ? '浏览器报告网络已断开' : '浏览器报告网络已恢复'); + const onPageHide = () => { diagnostics.flush().catch(() => {}); }; + window.addEventListener('error', onError); window.addEventListener('unhandledrejection', onRejection); + window.addEventListener('online', onNetwork); window.addEventListener('offline', onNetwork); window.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibility); function dispose() { disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation); + window.removeEventListener('error', onError); window.removeEventListener('unhandledrejection', onRejection); + window.removeEventListener('online', onNetwork); window.removeEventListener('offline', onNetwork); window.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibility); runner.destroy(); examController.destroy(); learning.destroy(); copy.destroy(); panel.destroy(); + diagnostics.destroy().catch(() => {}); } const ready = (async () => { const positionRevisionAtRead = positionRevision; try { - const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]); + const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null), diagnostics.ready]); 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, { restore: true }); @@ -418,10 +477,14 @@ && typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null; } } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } - loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot; + loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); + recordDiagnostic('info', 'app.ready', '谊安学助手已加载', { version, mode: learningSettings.mode, execution: learningSettings.execution, total: snapshot.total }); + tick(); return snapshot; })(); const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext, setLearningSetting: changeLearningSetting, taskAction, getLearningState: () => learning.getState(), getExamState: () => examController.getState(), + getDiagnosticState: () => diagnostics.getState(), flushDiagnostics: () => diagnostics.flush(), setDiagnosticRecording: enabled => diagnostics.setEnabled(enabled === true), + clearDiagnostics: () => diagnostics.clear(), exportDiagnostics, getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, learning: learning.getState(), exam: examController.getState(), pageReady: !!page && page.ready }), exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose }); window.YianAssistant = api; window.YianQuestionBankExporter = api; diff --git a/tests/diagnostic-logger.cjs b/tests/diagnostic-logger.cjs new file mode 100644 index 0000000..2a1fd7f --- /dev/null +++ b/tests/diagnostic-logger.cjs @@ -0,0 +1,105 @@ +/* Local diagnostics: storage reload, bounded payloads, privacy and failure recovery. */ +const assert = require('node:assert/strict'); +const loggerModule = require('../diagnostic-logger.js'); +const copy = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +function memory(initial) { + const values = new Map(Object.entries(initial || {})); + return { name: 'userscript', values, writes: 0, async get(key, fallback) { return values.has(key) ? copy(values.get(key)) : fallback; }, async set(key, value) { this.writes++; values.set(key, copy(value)); } }; +} +async function main() { + let time = Date.UTC(2026, 9, 18, 10); + const storage = memory(); const states = []; + const logger = loggerModule.create({ storage, now: () => time, onState: state => states.push(state), getContext: () => ({ url: 'http://10.119.67.7/clientcn/course?id=private#secret', pageKind: 'course', taskId: 'task-1', status: 'running', password: 'private' }) }); + await logger.ready; + assert.equal(logger.getState().enabled, true); + assert.equal(logger.getState().total, 0); + logger.record('info', 'lease.wait', '等待同类执行权', { taskId: 'task-1', owner: 'other-window', expiresAt: time + 18000 }); + time += 900; + logger.record('info', 'lease.wait', '等待同类执行权', { taskId: 'task-1', owner: 'other-window', expiresAt: time + 17100 }); + assert.equal(logger.getRecords().length, 1); + assert.equal(logger.getRecords()[0].count, 2); + assert.equal(logger.getRecords()[0].lastTime, time); + assert.equal(storage.writes, 0, '批次时间前不逐条写入'); + await logger.flush(); + assert.equal(storage.writes, 1); + assert.equal(logger.getState().pending, 0); + assert.equal(states.at(-1).total, 1); + time += 2100; + logger.record('info', 'lease.wait', '等待同类执行权', { taskId: 'task-1', owner: 'other-window', expiresAt: time + 15000 }); + assert.equal(logger.getRecords().length, 2, '2秒外连续状态可独立诊断'); + const cycle = { status: 'waiting', taskId: 'task-1', pageUrl: 'http://10.119.67.7/clientcn/test?token=bad-query#bad-hash', expires: time + 10000, previousOwner: 'worker-2', waitedMs: 3000, password: 'bad', token: 'bad', authorization: 'bad', cookie: 'bad', answers: ['bad-answer'], question: 'bad-question', options: ['bad-option'], bank: { private: 'bad-bank' }, arbitrary: 'bad-arbitrary' }; + cycle.details = cycle; + logger.record('warn', 'task.wait', '失败 URL http://10.119.67.7/clientcn/test?token=bad-query#bad-hash token=bad-token password=bad-password', cycle); + const json = await logger.exportJSON(); const parsed = JSON.parse(json); + assert.equal(parsed.schema, 1); + assert.equal(parsed.records[0].context.url, 'http://10.119.67.7/clientcn/course'); + assert.equal(parsed.records[2].level, 'warning'); + assert.equal(parsed.records[2].details.taskId, 'task-1'); + assert.equal(parsed.records[2].details.pageUrl, 'http://10.119.67.7/clientcn/test'); + assert.equal(parsed.records[2].details.expires, time + 10000); + assert.equal(parsed.records[2].details.previousOwner, 'worker-2'); + assert.equal(parsed.records[2].details.details, null, '循环引用安全折断'); + for (const forbidden of ['private', 'secret', 'bad-answer', 'bad-question', 'bad-option', 'bad-bank', 'bad-query', 'bad-hash', 'bad-token', 'bad-password', 'bad-arbitrary']) assert(!json.includes(forbidden), forbidden + '不得被保存'); + const txt = await logger.exportText(); + assert(txt.includes('lease.wait')); assert(txt.includes('重复 2 次')); assert(txt.includes('owner')); + const reloaded = loggerModule.create({ storage, now: () => time }); await reloaded.ready; + assert.equal(reloaded.getRecords().length, 3, '刷新后读取已保存日志'); + await logger.setEnabled(false); + assert.equal(logger.record('error', 'ignored', '关闭后不新增'), false); + const off = loggerModule.create({ storage, now: () => time }); await off.ready; + assert.equal(off.getState().enabled, false); assert.equal(off.getState().total, 3); + await off.setEnabled(true); + time += 1; off.record('info', 'page.ready', '第二个页面继续运行'); await off.flush(); + await logger.flush(); + assert.equal(logger.getRecords().length, 4, '无待写入时导出也合并其他页面已保存日志'); + assert.equal(JSON.parse(await logger.exportJSON()).records.length, 4); + await off.clear(); + assert.equal(off.getState().total, 0); assert.equal(off.getState().dropped, 0); + await logger.flush(); + assert.equal(logger.getRecords().length, 0, '其他页面的旧缓存不能在清空后复活'); + await logger.setEnabled(true); logger.record('info', 'after.clear', '清空后同毫秒新日志'); await logger.flush(); + assert.equal(logger.getRecords().length, 1); + await Promise.all([logger.destroy(), reloaded.destroy(), off.destroy()]); + console.log('PASS defaults, persisted reload/toggle, bounded batching/dedup, cross-page merge/clear, onState and JSON/TXT privacy'); + + const batchedStore = memory(); const batched = loggerModule.create({ storage: batchedStore }); await batched.ready; + batched.record('info', 'page.ready', '自动批量保存'); + await new Promise(resolve => setTimeout(resolve, 650)); + assert.equal(batchedStore.writes, 1, '无需手动flush,1秒内自动保存'); + assert.equal(batched.getState().pending, 0); await batched.destroy(); + + const boundedStore = memory(); const bounded = loggerModule.create({ storage: boundedStore, now: () => ++time }); await bounded.ready; + for (let index = 0; index < 2510; index++) bounded.record('info', 'task.state', '状态 ' + index, { taskId: 'task-' + index }); + assert.equal(bounded.getRecords().length, 2000); assert.equal(bounded.getState().pending, 2000); + assert.equal(bounded.getState().dropped, 510); + await bounded.flush(); + assert.equal(bounded.getState().dropped, 510, '持久合并不重复计算移除数量'); + bounded.record('info', 'task.state', '状态 2510', { taskId: 'task-2510' }); await bounded.flush(); + assert.equal(bounded.getState().dropped, 511); + assert.equal(bounded.getRecords()[0].message, '状态 511'); + await bounded.destroy(); + console.log('PASS recent 2000 records and unsaved payload cap, accurate single-page eviction count'); + + const delayedStore = memory(); let unblock; + const originalGet = delayedStore.get; + delayedStore.get = async function (key, fallback) { await new Promise(resolve => { if (!unblock) unblock = resolve; else resolve(); }); return originalGet.call(this, key, fallback); }; + const delayed = loggerModule.create({ storage: delayedStore, now: () => time }); + const disabled = delayed.setEnabled(false); + await Promise.resolve(); unblock(); await disabled; + assert.equal(delayed.getState().enabled, false, '初始化前的明确开关优先于慢存储默认值'); await delayed.destroy(); + let fail = true; let errors = 0; const failedStore = memory(); const realSet = failedStore.set; + failedStore.set = async function (key, value) { if (fail) throw new Error('模拟配额不足'); return realSet.call(this, key, value); }; + const failed = loggerModule.create({ storage: failedStore, now: () => ++time, onError: () => { errors++; } }); await failed.ready; + failed.record('error', 'play.failed', '无法继续播放', { reason: 'NotAllowedError' }); + await failed.flush(); await failed.flush(); + assert.equal(errors, 1); assert.equal(failed.getState().pending, 1); assert(failed.getState().storageError.includes('配额')); + assert(JSON.parse(await failed.exportJSON()).records.some(item => item.event === 'play.failed'), '保存失败仍能导出当前记录'); + fail = false; await failed.flush(); + assert.equal(failed.getState().pending, 0); assert.equal(failed.getState().storageError, ''); await failed.destroy(); + const hostile = loggerModule.create({ storage: memory({ [loggerModule.key]: { schema: 999, records: ['invalid'] } }), onState: () => { throw new Error('UI failed'); } }); await hostile.ready; + const object = {}; Object.defineProperty(object, 'status', { enumerable: true, get() { throw new Error('bad getter'); } }); + assert.doesNotThrow(() => hostile.record('info', 'hostile', '容错', object)); + await hostile.destroy(); + console.log('PASS delayed initialization, quota failure preserves export, single error notification, storage recovery and hostile objects'); +} +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/diagnostics-workflow.cjs b/tests/diagnostics-workflow.cjs new file mode 100644 index 0000000..ca23cf6 --- /dev/null +++ b/tests/diagnostics-workflow.cjs @@ -0,0 +1,160 @@ +/* Complete userscript diagnostics workflow; every business request is intercepted. */ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { chromium } = require('playwright'); + +const workspace = path.resolve(__dirname, '..'); +const bundle = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8'); +const qaDir = path.join(os.tmpdir(), 'yianxue-diagnostics-qa'); +const course = '诊断回归测试课程'; +const targetURL = 'http://10.119.67.7/clientcn/course/diagnostics?token=SECRET'; +const settingPrefix = 'yianxue.assistant.setting.v2.'; +const learningPrefix = settingPrefix + 'learning:'; +const bankKey = 'yianxue.assistant.bank.v3'; +const html = '诊断面板本地回归

诊断面板本地模拟

' + course + '
  • 诊断演示视频

    视频
    尚未完成
  • '; +const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__diagnosticsRead(key,fallback);const GM_setValue=(key,value)=>window.__diagnosticsWrite(key,value);\n' + bundle + '\n})();'; + +(async () => { + fs.mkdirSync(qaDir, { recursive: true }); + const memory = new Map(); + const errors = [], navigationRequests = [], unexpectedRequests = []; + const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); + try { + const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true }); + await context.exposeBinding('__diagnosticsRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback); + await context.exposeBinding('__diagnosticsWrite', (_, key, value) => memory.set(key, structuredClone(value))); + await context.route('**/*', route => { + if (route.request().isNavigationRequest() && route.request().url() === targetURL) { + navigationRequests.push(route.request().url()); + return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: html }); + } + unexpectedRequests.push(route.request().url()); return route.abort(); + }); + const page = await context.newPage(); + page.on('pageerror', error => errors.push(error.message)); + async function install(offset = 0) { + await page.evaluate(offset => { + window.qaOffset = offset; + Date.now = () => Date.UTC(2026, 9, 18, 10) + qaOffset; + window.qaMedia = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1, plays: 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.plays++; qaMedia.paused = false; }; + video.pause = () => { qaMedia.paused = true; }; + }, offset); + await page.addScriptTag({ content: sandbox }); + await page.evaluate(() => YianAssistant.ready); + assert.equal(await page.evaluate(() => typeof YianAssistant.getDiagnosticState), 'function', '完整bundle必须提供真实诊断API'); + } + async function consoleTab() { + const expand = page.getByRole('button', { name: '展开题库助手', exact: true }); + if (await expand.isVisible()) await expand.click(); + await page.getByTestId('tab-console').click(); + await page.getByTestId('diagnostic-status').waitFor({ state: 'visible' }); + } + async function download(format, invoke) { + const waiting = page.waitForEvent('download'); + await invoke(); + const result = await waiting; + assert.match(result.suggestedFilename(), new RegExp('\\.' + format + '$')); + assert.equal(await result.failure(), null); + const destination = path.join(qaDir, 'diagnostics-' + format + '.' + format); + await result.saveAs(destination); + const text = fs.readFileSync(destination, 'utf8'); + for (const forbidden of ['SECRET', '不可写入诊断的题干', '不可写入诊断的正确答案']) assert(!text.includes(forbidden), forbidden + '不得出现在导出诊断中'); + return text; + } + const protectedStorage = () => [...memory].filter(([key]) => key === bankKey || key.startsWith(learningPrefix)).sort(([a], [b]) => a.localeCompare(b)); + + await page.goto(targetURL); await install(); + assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().enabled), true); + const bank = [{ course, type: '单选', question: '不可写入诊断的题干', options: [{ label: 'A', text: '不可写入诊断的正确答案' }, { label: 'B', text: '错误项' }], answer: 'A' }]; + await page.evaluate(async bank => { + const bytes = new TextEncoder().encode(JSON.stringify(bank)); + await YianAssistant.importFiles([{ name: '诊断回归题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]); + await YianAssistant.taskAction('start'); + }, bank); + await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused); + assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1); + const control = memory.get(learningPrefix + 'control.v1'); + const task = await page.evaluate(() => YianAssistant.getLearningState().tasks.find(task => task.kind === 'video')); + const held = memory.get(learningPrefix + 'lease:video'); + assert(held && held.owner, '正常任务首先领取真实执行权'); + memory.set(learningPrefix + 'lease:video', { ...held, owner: 'fixture-foreign-owner', sessionId: control.sessionId, taskId: task.id, expires: Date.UTC(2026, 9, 18, 10) + 18000, pageUrl: targetURL }); + await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.status === 'waitingLease')); + const playsDuringWait = await page.evaluate(() => qaMedia.plays); + await page.waitForTimeout(650); + assert.equal(await page.evaluate(() => qaMedia.plays), playsDuringWait, '有效外部执行权下不能重复发起播放'); + await page.evaluate(() => { qaOffset += 19000; qaMedia.currentTime += 1; }); + await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'playing')); + await page.evaluate(() => YianAssistant.flushDiagnostics()); + await consoleTab(); + assert.equal(await page.getByTestId('diagnostic-record').isChecked(), true); + const jsonText = await download('json', () => page.getByTestId('diagnostic-exportJson').click()); + const first = JSON.parse(jsonText); + assert.equal(first.schema, 1); assert(Array.isArray(first.records) && first.records.length > 0); + const events = new Set(first.records.map(record => record.event)); + for (const event of ['lease.wait', 'lease.expired', 'lease.acquired', 'lease.resumed', 'task.action']) assert(events.has(event), '完整工作流必须记录 ' + event); + const waitEntry = first.records.find(record => record.event === 'lease.wait'); + assert.equal(waitEntry.details.owner, 'fixture-foreign-owner'); + assert.equal(waitEntry.context.url, targetURL.split('?')[0]); + const textExport = await download('txt', () => page.getByTestId('diagnostic-exportText').click()); + assert(textExport.includes('lease.wait') && textExport.includes('lease.resumed')); + await page.screenshot({ path: path.join(qaDir, '01-desktop-diagnostics.png'), fullPage: false }); + console.log('PASS 完整bundle真实执行权等待→19秒后恢复→JSON/TXT下载,记录动作及执行权事件并移除URL查询和题目正文'); + + // A manual reload starts paused, reads the persisted records, and keeps all + // previous event identities available for diagnosis. + const previousIds = first.records.map(record => record.id); + await page.reload(); await install(19000); await consoleTab(); + assert.equal(await page.evaluate(() => YianAssistant.getLearningState().status), 'paused'); + const reloaded = JSON.parse(await download('json', () => page.evaluate(() => YianAssistant.exportDiagnostics('json')))); + const reloadedIds = new Set(reloaded.records.map(record => record.id)); + for (const id of previousIds) assert(reloadedIds.has(id), '刷新后保留原诊断记录 ' + id); + assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1); + assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks.length), 1); + console.log('PASS 手动刷新后任务暂停,持久诊断与题库、任务检查点均保留'); + + // Exercise the rendered recording switch, rather than monkeypatching a + // logger. Actual pause/resume operations then produce no new records. + await page.getByTestId('diagnostic-record').uncheck(); + await page.waitForFunction(() => YianAssistant.getDiagnosticState().enabled === false); + await page.evaluate(() => YianAssistant.flushDiagnostics()); + const countWhenOff = await page.evaluate(() => YianAssistant.getDiagnosticState().total); + await page.evaluate(async () => { await YianAssistant.taskAction('resume'); await YianAssistant.taskAction('pause'); }); + await page.waitForTimeout(700); await page.evaluate(() => YianAssistant.flushDiagnostics()); + assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().total), countWhenOff); + assert.equal(await page.getByTestId('diagnostic-record').isChecked(), false); + assert((await page.getByTestId('diagnostic-status').textContent()).includes('已停止新增记录')); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByTestId('diagnostic-exportText').scrollIntoViewIfNeeded(); + const panelBox = await page.locator('#yian-question-assistant').boundingBox(); + assert(panelBox && panelBox.x >= -1 && panelBox.x + panelBox.width <= 391, '390px宽下诊断面板保持在视口内'); + await page.screenshot({ path: path.join(qaDir, '02-mobile-diagnostics.png'), fullPage: false }); + console.log('PASS 诊断记录开关关闭后真实任务状态变更不再新增日志,390px操作可用'); + + const checkpointBefore = structuredClone(protectedStorage()); + const stateBefore = await page.evaluate(() => ({ tasks: YianAssistant.getLearningState().tasks, total: YianAssistant.getSnapshot().total })); + assert(checkpointBefore.some(([key]) => key.endsWith('session.v1')), '测试必须包含已保存任务检查点'); + await page.getByTestId('diagnostic-clearSaved').click(); + await page.waitForFunction(() => YianAssistant.getDiagnosticState().total === 0); + await page.evaluate(() => YianAssistant.flushDiagnostics()); + assert.deepEqual(protectedStorage(), checkpointBefore, '清空诊断不能改写题库、学习任务或检查点'); + assert.deepEqual(await page.evaluate(() => ({ tasks: YianAssistant.getLearningState().tasks, total: YianAssistant.getSnapshot().total })), stateBefore); + const cleared = JSON.parse(await download('json', () => page.evaluate(() => YianAssistant.exportDiagnostics('json')))); + assert.deepEqual(cleared.records, []); + await page.reload(); await install(19000); await consoleTab(); + assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().enabled), false, '关闭记录设置跨刷新保留'); + assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().total), 0, '清空记录不能被刷新旧缓存复活'); + assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1); + assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks.length), 1); + console.log('PASS 清空已保存诊断只删除诊断,保留任务、题库、检查点及关闭设置'); + + assert.deepEqual(errors, []); assert.deepEqual(unexpectedRequests, []); + assert.equal(navigationRequests.length, 3, '初次打开与两次刷新全由route本地提供,零真实业务请求'); + await context.close(); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/lease-recovery.cjs b/tests/lease-recovery.cjs new file mode 100644 index 0000000..35f7fa8 --- /dev/null +++ b/tests/lease-recovery.cjs @@ -0,0 +1,295 @@ +/* Isolated scheduler lease regression: all business routes are local fixtures. */ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const workspace = path.resolve(__dirname, '..'); +const sources = ['question-bank-core.js', 'attempt-policy.js', 'learning-engine.js'] + .map(file => fs.readFileSync(path.join(workspace, file), 'utf8')); +const origin = 'http://10.119.67.7/clientcn/'; +const course = '租约恢复测试课程'; +const videoHTML = '
    ' + course + '
  • 测试视频

    视频
    尚未完成
  • '; + +(async () => { + const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); + let checks = 0; + const allErrors = [], allUnexpected = []; + try { + async function fixture(options = {}) { + const memory = new Map(); + const context = await browser.newContext(); + await context.exposeBinding('__leaseRead', (_, key) => memory.has(key) ? structuredClone(memory.get(key)) : null); + await context.exposeBinding('__leaseWrite', (_, key, value) => memory.set(key, structuredClone(value))); + await context.route('**/*', route => { + if (route.request().isNavigationRequest() && route.request().url().startsWith(origin)) { + return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: '' }); + } + allUnexpected.push(route.request().url()); return route.abort(); + }); + const page = await context.newPage(); + page.on('pageerror', error => allErrors.push(error.message)); + await page.goto(origin + 'course/lease-recovery'); + for (const content of sources) await page.addScriptTag({ content }); + await page.evaluate(async ({ videoHTML, course, options }) => { + document.body.innerHTML = videoHTML; + window.qaClock = 100000; + window.qaRelated = 0; window.qaEnter = 0; window.qaExamStarts = 0; window.qaExamPauses = 0; window.qaExamActive = false; window.qaPlay = 0; + window.qaExamState = { stage: 'answering', total: 40, verifiedCount: 0 }; + window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 }; + const video = document.querySelector('video'); + for (const key of Object.keys(qaVideo)) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: value => { qaVideo[key] = value; } }); + video.play = async () => { qaPlay++; qaVideo.paused = false; }; + video.pause = () => { qaVideo.paused = true; }; + window.qaSnapshot = { accountId: 'lease-account', bankVersion: 1, planId: 'lease-plan', courses: [course] }; + window.qaStorage = { get: key => __leaseRead(key), set: (key, value) => __leaseWrite(key, value) }; + document.querySelector('.related_test').onclick = () => { + qaRelated++; + history.pushState({}, '', '/clientcn/exam/lease-recovery'); + document.body.innerHTML = '
    ' + course + ' 已答0/40
    ' + (options.singleAttempt ? '

    剩余考试次数:1次

    ' : '') + '
    开始答题
    '; + document.querySelector('.examdetail_button').onclick = () => { + qaEnter++; + history.pushState({}, '', '/clientcn/exam/lease-recovery/questions'); + document.body.innerHTML = '
    ' + course + '
    '; + }; + }; + window.qaCreate = () => YianLearningEngine.create({ + ownerId: 'local-owner', now: () => qaClock, storage: qaStorage, + getSnapshot: () => Object.assign({ document, url: location.href }, qaSnapshot), + getExamState: () => qaExamState, + onExamStart: async () => { qaExamStarts++; qaExamActive = true; return true; }, + onExamPause: () => { qaExamPauses++; qaExamActive = false; }, navigate: async () => { throw new Error('fixture only uses the actual associated exam element'); } + }); + window.qaEngine = qaCreate(); + await qaEngine.configure({ mode: 'all', execution: 'sequential' }); + await qaEngine.start(); + }, { videoHTML, course, options }); + const tick = delta => page.evaluate(async delta => { qaClock += delta || 0; return qaEngine.tick(); }, delta); + const read = () => page.evaluate(() => ({ state: qaEngine.getState(), related: qaRelated, entered: qaEnter, starts: qaExamStarts, examPauses: qaExamPauses, examActive: qaExamActive, plays: qaPlay, videoPaused: qaVideo.paused })); + async function blockExam() { + const state = await page.evaluate(async () => { + const control = await qaStorage.get('control.v1'); + const task = qaEngine.getState().tasks.find(item => item.kind === 'exam'); + await qaStorage.set('lease:exam', { owner: 'other-page-owner', sessionId: control.sessionId, taskId: task.id, expires: qaClock + 18000 }); + document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'); + await qaEngine.tick(); + return qaEngine.tick(); + }); + const current = await read(); + assert.equal(state.status, 'running'); assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0); + assert.match(state.message + ' ' + state.tasks.find(item => item.kind === 'exam').reason, /租约|执行权|另一(?:个)?页面/); + } + async function runUntilExam() { + for (let i = 0; i < 5; i++) await tick(0); + const current = await read(); + assert.equal(current.related, 1, '租约失效后自动点击原关联考试入口'); + assert.equal(current.entered, 1, '不需要用户再次点击继续即可进入本场试卷'); + assert.equal(current.starts, 1, '仅启动一次当前考试控制器'); + assert.doesNotMatch(current.state.message, /等待同类任务租约释放|等待(?:视频|考试)执行权/, '领取成功后不得残留等待执行权提示'); + return current; + } + return { page, context, memory, tick, read, blockExam, runUntilExam, close: () => context.close() }; + } + + // Upgrade the exact persisted 2.1.2 wait reason. A controlled reload keeps + // this running session while the old waiting task becomes retryable. + { + const f = await fixture(); + try { + await f.blockExam(); + const restored = await f.page.evaluate(async () => { + qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); + const saved = await qaStorage.get('session.v1'); + const exam = saved.tasks.find(task => task.kind === 'exam'); + exam.status = 'waiting'; exam.reason = '另一个页面正在处理同类任务,避免重复领取'; + delete exam.leaseWait; + saved.activeTaskId = ''; saved.status = 'running'; + await qaStorage.set('session.v1', saved); + await qaStorage.set('recover:' + location.href, { sessionId: saved.id, taskId: '', accountId: 'lease-account', expires: qaClock + 60000 }); + qaEngine = qaCreate(); return qaEngine.tick(); + }); + assert.equal(restored.status, 'running'); assert.equal((await f.read()).related, 0); + await f.tick(18001); await f.runUntilExam(); checks++; + } finally { await f.close(); } + } + + // The completed video leaves a pending exam whose previous executor closed. + // An unexpired lease waits, then the very same running session retries itself. + { + const f = await fixture(); + try { + await f.blockExam(); await f.tick(17999); + assert.equal((await f.read()).related, 0, '有效期内不能重复打开考试'); + await f.tick(2); await f.runUntilExam(); checks++; + } finally { await f.close(); } + } + + // A real second browser page renews its lease. The coordinator must leave + // that page alone for arbitrarily many lease lifetimes, then recover once it + // stops heartbeating. Its writes use the same shared storage binding. + { + const f = await fixture(); + try { + await f.blockExam(); + const other = await f.context.newPage(); + other.on('pageerror', error => allErrors.push(error.message)); + await other.goto(origin + 'exam/other-lease-holder'); + for (let i = 0; i < 4; i++) { + await other.evaluate(async clock => { + const held = await __leaseRead('lease:exam'); + if (!held || held.owner !== 'other-page-owner') throw new Error('另一个真实页面的租约被抢占'); + held.expires = clock + 18000; await __leaseWrite('lease:exam', held); + }, 100000 + (i + 1) * 15000); + await f.tick(15000); + const current = await f.read(); + assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0); + assert.equal(f.memory.get('lease:exam').owner, 'other-page-owner'); + } + await other.close(); await f.tick(18001); await f.runUntilExam(); checks++; + } finally { await f.close(); } + } + + // An active task can lose a lease without entering the async-quiz path. + // Restoring its own lease lets it continue and must clear the old banner. + { + const f = await fixture(); + try { + await f.page.evaluate(async () => { + const held = await qaStorage.get('lease:video'); + held.owner = 'other-page-owner'; held.expires = qaClock + 18000; await qaStorage.set('lease:video', held); + }); + const blocked = await f.tick(1000); + assert.match(blocked.message, /租约|执行权/); + const before = await f.read(); + assert.equal(before.videoPaused, true, '丢失执行权后立即暂停本页旧视频'); + await f.page.evaluate(async () => { + const held = await qaStorage.get('lease:video'); held.owner = 'local-owner'; held.expires = qaClock - 1; + await qaStorage.set('lease:video', held); + qaVideo.currentTime += 1; + }); + const recovered = await f.tick(1000); + assert.equal(recovered.status, 'running'); + assert.equal(f.memory.get('lease:video').owner, 'local-owner'); + assert.ok(f.memory.get('lease:video').expires > 102000); + assert.doesNotMatch(recovered.message, /等待同类任务租约释放|等待(?:视频|考试)执行权/); + const after = await f.read(); + assert.equal(after.starts, before.starts); + assert.equal(after.videoPaused, false, '重新取得执行权后正常恢复原视频'); + assert.equal(after.plays, before.plays + 1, '恢复时重启本页播放器一次'); checks++; + } finally { await f.close(); } + } + + // Losing an already-started exam lease invalidates its old answer/submit + // controller immediately. Waiting is automatic, while reacquisition must + // call the normal exam entry callback again to reverify the current paper. + { + const f = await fixture(); + try { + await f.blockExam(); await f.tick(18001); await f.runUntilExam(); + const initial = await f.read(); + assert.equal(initial.examActive, true); + await f.page.evaluate(async () => { + const held = await qaStorage.get('lease:exam'); + held.owner = 'other-exam-page'; held.expires = qaClock + 18000; + await qaStorage.set('lease:exam', held); + }); + const blocked = await f.tick(1); + let current = await f.read(); + assert.equal(blocked.status, 'running', '执行权等待不需要重新人工启动全部任务'); + assert.equal(current.state.tasks.find(task => task.kind === 'exam').status, 'waitingLease'); + assert.equal(current.examPauses, initial.examPauses + 1, '首次丢失考试执行权立即停止旧答题控制器'); + assert.equal(current.examActive, false); assert.equal(current.starts, 1); + assert.equal(f.memory.get('lease:exam').owner, 'other-exam-page', '不得抢占有效的其他页面考试执行权'); + await f.tick(17998); current = await f.read(); + assert.equal(current.examPauses, initial.examPauses + 1, '等待轮询不重复暂停或开启旧控制器'); + assert.equal(current.examActive, false); assert.equal(current.starts, 1); + assert.equal(f.memory.get('lease:exam').owner, 'other-exam-page'); + await f.tick(2); current = await f.read(); + assert.equal(current.starts, 2, '过期重新领取后必须重新核验当前试卷'); + assert.equal(current.examActive, true); + assert.equal(f.memory.get('lease:exam').owner, 'local-owner'); + assert.equal(current.entered, 1, '重验已有试卷不得再次点击开始答题'); + await f.tick(0); assert.equal((await f.read()).starts, 2, '核验通过后不重复重启考试控制器'); + checks++; + } finally { await f.close(); } + } + + // Reacquiring a manual-confirmation exam must keep its read-only waiting + // state instead of silently restarting the automatic answer controller. + { + const f = await fixture(); + try { + await f.blockExam(); await f.tick(18001); await f.runUntilExam(); + await f.page.evaluate(() => { + qaExamState = { stage: 'waiting', total: 40, verifiedCount: 32, predictedScore: 80, requiresManualSubmission: true, message: '本场等待用户手动确认交卷' }; + qaExamActive = false; + }); + await f.tick(0); + const initial = await f.read(); + assert.equal(initial.state.tasks.find(task => task.kind === 'exam').status, 'waiting'); + await f.page.evaluate(async () => { + const held = await qaStorage.get('lease:exam'); held.owner = 'other-exam-page'; held.expires = qaClock + 18000; + await qaStorage.set('lease:exam', held); + }); + await f.tick(1); await f.tick(18000); await f.tick(0); + const current = await f.read(); + assert.equal(current.state.tasks.find(task => task.kind === 'exam').status, 'waiting', '人工确认等待不能因执行权恢复而转回自动作答'); + assert.equal(current.starts, 1); assert.equal(current.examActive, false); + assert.equal(current.examPauses, initial.examPauses, '已处于人工等待时保留原考试控制器的只读结果同步'); + checks++; + } finally { await f.close(); } + } + + // Lease expiry is never an authorization to ignore user pause or identity. + for (const protection of ['globalPause', 'examPause', 'bankVersion', 'account']) { + const f = await fixture(); + try { + await f.blockExam(); + await f.page.evaluate(async protection => { + if (protection === 'globalPause') await qaEngine.pause(); + else if (protection === 'examPause') await qaEngine.configure({ examPaused: true }); + else if (protection === 'bankVersion') qaSnapshot.bankVersion = 2; + else qaSnapshot.accountId = 'different-account'; + }, protection); + await f.tick(18001); for (let i = 0; i < 4; i++) await f.tick(0); + const current = await f.read(); + assert.equal(current.entered, 0, protection + '不得自动开始考试'); + assert.equal(current.starts, 0, protection + '不得启动答题控制器'); + if (protection !== 'examPause') assert.equal(current.state.status, 'paused'); + checks++; + } finally { await f.close(); } + } + + // Topic preflight is rerun after waiting, so a no-longer-associated bank + // cannot inherit a previously matched topic merely because the lease freed. + { + const f = await fixture(); + try { + await f.blockExam(); + await f.page.evaluate(() => { qaSnapshot.courses = ['其他课程']; }); + await f.tick(18001); for (let i = 0; i < 4; i++) await f.tick(0); + const current = await f.read(); + assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0); + const exam = current.state.tasks.find(task => task.kind === 'exam'); + assert.match(exam.reason, /题库|主题/); checks++; + } finally { await f.close(); } + } + + // The normal one-attempt rule still runs on the detail page after a lease + // retry opens its associated exam, with no early entry or controller start. + { + const f = await fixture({ singleAttempt: true }); + try { + await f.blockExam(); await f.tick(18001); + for (let i = 0; i < 5; i++) await f.tick(0); + const current = await f.read(); + assert.equal(current.related, 1); assert.equal(current.entered, 0); assert.equal(current.starts, 0); + assert.equal(current.state.exam.requiresManualSubmission, true); + assert.match(current.state.tasks.find(task => task.kind === 'exam').reason, /1 次|1次/); checks++; + } finally { await f.close(); } + } + assert.deepEqual(allErrors, []); assert.deepEqual(allUnexpected, []); + console.log('lease-recovery:', checks, 'checks passed (自动接续、真实第二页续租、旧提示清理、暂停/身份/主题/单次机会保护;零真实业务请求)'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/panel-tabs.cjs b/tests/panel-tabs.cjs index ce81c3a..fd62950 100644 --- a/tests/panel-tabs.cjs +++ b/tests/panel-tabs.cjs @@ -34,7 +34,7 @@ async function main() { window.qaPanel = YianAssistantPanel.create({ onTaskAction: action => qaCalls.actions.push(action), onLearningSetting: (key, value) => qaCalls.settings.push([key, JSON.parse(JSON.stringify(value))]), - onLogAction: action => qaCalls.logs.push(action), + onLogAction: (action, value) => qaCalls.logs.push(value === undefined ? action : [action, value]), onSearch: (query, settings) => qaCalls.search.push({ query, settings }), onAutoChange: value => qaCalls.auto.push(value), onCopyChange: value => qaCalls.copy.push(value), @@ -236,6 +236,26 @@ async function main() { await host.getByRole('button', { name: '复制日志', exact: true }).click(); await host.getByRole('button', { name: '清空显示', exact: true }).click(); assert.deepEqual(await page.evaluate(() => qaCalls.logs), ['copy', 'clear']); + const diagnosticSwitch = host.getByRole('switch', { name: '持续记录诊断日志', exact: true }); + assert(await diagnosticSwitch.isChecked(), '持久诊断记录默认开启'); + await diagnosticSwitch.uncheck(); + assert.deepEqual(await page.evaluate(() => qaCalls.logs.at(-1)), ['record', false]); + await page.evaluate(() => qaPanel.setState({ diagnostics: { enabled: false, total: 37, dropped: 2, pending: 1, storage: 'userscript' } })); + assert((await host.getByTestId('diagnostic-status').textContent()).includes('已停止新增记录')); + assert((await host.getByTestId('diagnostic-status').textContent()).includes('已记录 37 条')); + await tab('bank').click(); await tab('console').click(); + assert.equal(await diagnosticSwitch.isChecked(), false, '切栏保留日志开关'); + for (const [name, action] of [['导出诊断 JSON', 'exportJson'], ['导出诊断 TXT', 'exportText'], ['清空已保存诊断', 'clearSaved']]) { + await host.getByRole('button', { name, exact: true }).click(); + assert.equal(await page.evaluate(() => qaCalls.logs.at(-1)), action); + } + await page.evaluate(() => qaPanel.setState({ diagnostics: { storageError: '模拟配额不足' } })); + assert((await host.getByTestId('diagnostic-status').textContent()).includes('可先导出当前记录')); + assert.equal(await diagnosticSwitch.isChecked(), false, '部分诊断状态更新保留关闭值'); + await diagnosticSwitch.check(); + assert.deepEqual(await page.evaluate(() => qaCalls.logs.at(-1)), ['record', true]); + await page.evaluate(() => qaPanel.setState({ diagnostics: { enabled: true, storageError: '', pending: 0 } })); + await diagnosticSwitch.scrollIntoViewIfNeeded(); await screenshot('05-desktop-diagnostics.png'); await page.evaluate(() => qaPanel.setState({ learning: { logs: Array.from({ length: 510 }, (_, index) => ({ time: '10:00:00', level: 'info', message: '任务状态 ' + index })) } })); await host.getByRole('combobox', { name: '日志级别' }).selectOption('all'); assert.equal(await host.locator('.log-entry').count(), 500); @@ -245,7 +265,12 @@ async function main() { assert.equal(await host.getByTestId('global-pause').textContent(), '继续全部'); await host.getByTestId('global-pause').click(); assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'resume'); - console.log('PASS filtered/capped logs and shared global pause/resume'); + await page.evaluate(() => qaPanel.setState({ learning: { status: 'running', waiting: 0, leaseWaiting: 1, video: { status: 'waitingLease' }, tasks: [{ id: 'lease1', type: 'exam', title: '等待中的考试', status: 'waitingLease' }] } })); + assert((await host.getByTestId('learning-status').textContent()).includes('1 项等待执行权')); + assert(!(await host.getByTestId('learning-status').textContent()).includes('等待人工')); + await tab('overview').click(); + assert((await pane('overview').textContent()).includes('等待执行权(自动重试)')); + console.log('PASS filtered/capped runtime logs, persisted diagnostics controls/export/clear and shared global pause/resume; lease waits are distinct from manual waits'); await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ position: { left: 100, top: 20 }, learning: { logs: [], status: 'running' } })); @@ -264,6 +289,9 @@ async function main() { await control.scrollIntoViewIfNeeded(); assert(await control.isVisible()); assert(await control.isChecked()); } await screenshot('03b-mobile-playback-switches.png'); + await tab('console').click(); await diagnosticSwitch.scrollIntoViewIfNeeded(); + assert(await diagnosticSwitch.isVisible()); assert(await diagnosticSwitch.isChecked()); + await screenshot('06-mobile-diagnostics.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/run.cjs b/tests/run.cjs index d52212f..4ee4c69 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', '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']) { +for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'diagnostic-logger.cjs', 'learning-engine.cjs', 'lease-recovery.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs', 'video-exam-workflow.cjs', 'diagnostics-workflow.cjs']) { const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' }); if (result.status !== 0) process.exit(result.status || 1); } diff --git a/实施开发方案.md b/实施开发方案.md index a2df9f4..d0cba02 100644 --- a/实施开发方案.md +++ b/实施开发方案.md @@ -82,3 +82,12 @@ 本次补充包 `08.zip` 只有 Angular 初始外壳,没有播放器、弹窗、次数提示 DOM 或业务 JS 资源。适配参考旧完整归档的静态模板及脚本片段,不执行下载页面中的原站代码。旧源码已提供续播正文和两个按钮、后台主动暂停、`不及格,还可补考 N 次` 及 `还可重考 N 次` 的静态证据;真实账号的播放器和考试状态仍需实站确认。 最终本地验证:`npm test` 的 15 套回归全部通过,学习引擎另完成 52 项检查。已覆盖三个开关默认值、保存、回读及切栏不触发动作,精确续播与未知弹窗,后台暂停与返回恢复、关闭恢复及用户手动暂停优先,明确 0/1/多次和缺失机会数、补考按钮、隐藏及题干干扰,课中题与考试共同保护,新安装默认与旧模式保留,以及平台确认完成后串联考试。额外验证共享设置不同键并发不丢失、旧读取及旧设置快照不覆盖新值、暂停写入期间不重新启动弹题、不同考试的次数证据隔离、关联考试完整导航及完成后不重复加入任务。桌面及 390px 窄屏检查通过;业务 URL 全部在本地拦截,未操作真实学习或考试记录,真实账号流程仍需实站验收。 + +## 9. 2.1.3 调整:租约等待与持久日志 + +- 将同类租约冲突与人工确认等待区分。临时冲突保留自动调度资格,在旧占用失效后重新领取;有效占用不强制抢占。领取成功时清除旧租约等待文案并按实际视频、考试状态更新面板。 +- 完整导航及受控恢复仍须核对同一会话、任务、账号、目标页面和有效的一次性标记;手动刷新或新开普通页面继续默认暂停。所有实际动作仍受租约、全局暂停和当前页面证据约束。 +- 运行日志继续显示最近 500 条,“清空显示”只清运行日志显示。新增“持续记录诊断日志”开关并默认开启,独立持久保存最近 2000 条诊断记录,刷新后保留;“导出诊断 JSON”“导出诊断 TXT”导出已存记录,“清空已保存诊断”只删除诊断。关闭开关只停止新增,已有记录仍可导出;清空不删除任务检查点、原始考试计时或题库。诊断不含完整题干、选项、答案和 URL 查询参数,多页写入需避免旧页面把新记录覆盖。 +- 归档 `01/70-es2015` 的学习地图入口调用 `onSelect` 后通过基类 `navigate` 调用 Angular Router;课程及考试入口均无直接 `window.open`。这排除了该归档入口必然保留旧地图标签的假设,但不推断用户的其他入口行为。 +- 本地已复现同类租约领取失败后不再重试、成功恢复后仍显示旧等待提示两项缺陷;这不代表已确认用户必然有多个标签。验证重点为:同类领取失败后租约过期能重试、恢复后提示与动作一致、有效执行器保持互斥、暂停及人工等待仍有效,以及诊断默认值/关闭后可导出/刷新保留/两种清空/2000 条上限和敏感内容过滤。结果以本次实际回归记录为准,不将静态样本及模拟通过等同于真实账号验收。 +- 最终验证:`npm test` 的 18 套回归通过;执行权恢复 12 项检查、原学习引擎 52 项检查通过。已实际下载诊断 JSON/TXT 并核对执行权事件、刷新恢复、关闭及清空效果,验证 URL 参数和题目正文不进入导出;桌面和 390px 窄屏检查通过,业务 URL 全部本地拦截。 diff --git a/谊安学助手 2.0.user.js b/谊安学助手 2.0.user.js index 1c869b3..c388992 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.2 +// @version 2.1.3 // @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。 // @match http://10.119.67.7/clientcn/* // @run-at document-idle @@ -1108,6 +1108,187 @@ window.JSZip = module.exports; global.YianCopyController = { create: create }; })(typeof window !== 'undefined' ? window : globalThis); +/* Bounded, local-only diagnostics. Never stores question or answer contents. */ +(function (root) { + 'use strict'; + const KEY = 'diagnosticLog.v1'; + const ENABLED_KEY = 'diagnosticLog.enabled'; + const LIMIT = 2000; + const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' ')); + const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i; + function safeURL(value) { + try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; } + catch (_) { return String(value).split(/[?#]/)[0].slice(0, 400); } + } + function text(value, maximum) { + let string; + try { string = String(value == null ? '' : value); } catch (_) { return '[无法读取]'; } + return string + .replace(/https?:\/\/[^\s<>"']+/gi, safeURL) + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [已隐藏]') + .replace(/\b(password|passwd|token|cookie|authorization|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[已隐藏]') + .slice(0, maximum || 700); + } + function sanitize(value, depth, seen) { + if (value == null || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string') return text(value); + if (typeof value !== 'object' || depth > 4 || seen.has(value)) return null; + seen.add(value); + let result; + if (Array.isArray(value)) result = value.slice(0, 30).map(function (entry) { return sanitize(entry, depth + 1, seen); }); + else { + result = {}; + Object.keys(value).slice(0, 100).forEach(function (key) { + if (PRIVATE_KEY.test(key) || !DETAIL_KEYS.has(key)) return; + if (typeof value[key] === 'function' || typeof value[key] === 'undefined') return; + result[key] = /^(url|pageUrl|path|route|destination)$/.test(key) && typeof value[key] === 'string' ? safeURL(value[key]) : sanitize(value[key], depth + 1, seen); + }); + } + seen.delete(value); + return result; + } + function clean(value) { try { return sanitize(value, 0, new Set()); } catch (_) { return {}; } } + function create(options) { + options = options || {}; + const store = options.storage || { name: 'memory', get: async function (_, fallback) { return fallback; }, set: async function () {} }; + const now = typeof options.now === 'function' ? options.now : Date.now; + const session = Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36); + let sequence = 0, records = [], pending = [], enabled = true, initialized = false, destroyed = false; + let dropped = 0, clearedAt = -1, timer = null, errorCount = 0, storageError = '', queue = Promise.resolve(); + let settingRevision = 0; + function timestamp() { const value = Number(now()); return Number.isFinite(value) ? value : Date.now(); } + function emit() { if (typeof options.onState === 'function') { try { options.onState(getState()); } catch (_) { /* UI failures must not interrupt tasks. */ } } } + function report(error) { + storageError = text(error && error.message || error || '日志存储失败', 240); + if (errorCount++ < 1 && typeof options.onError === 'function') { try { options.onError(new Error(storageError)); } catch (_) { /* Do not recurse through diagnostics. */ } } + } + function validate(data) { + if (!data || data.schema !== 1 || !Array.isArray(data.records)) return { records: [], dropped: 0, clearedAt: -1 }; + const cutoff = Number.isFinite(data.clearedAt) ? data.clearedAt : -1; + return { records: data.records.filter(function (item) { return item && typeof item.id === 'string' && Number.isFinite(item.time) && item.time > cutoff; }).slice(-LIMIT).map(function (item) { + return { id: text(item.id, 100), time: item.time, lastTime: Number.isFinite(item.lastTime) ? item.lastTime : item.time, + level: ['info', 'warning', 'error'].includes(item.level) ? item.level : 'info', event: text(item.event, 100), message: text(item.message), details: clean(item.details), context: clean(item.context), count: Math.max(1, Math.min(1000000, Math.floor(Number(item.count) || 1))) }; + }), dropped: Math.max(0, Number(data.dropped) || 0), clearedAt: cutoff }; + } + function merge(left, right, cutoff) { + const map = new Map(); + left.concat(right).forEach(function (item) { + if (item.time <= cutoff) return; + const old = map.get(item.id); + if (!old || item.count > old.count || item.lastTime > old.lastTime) map.set(item.id, item); + }); + const result = Array.from(map.values()).sort(function (a, b) { return a.time - b.time || a.id.localeCompare(b.id); }); + const removed = Math.max(0, result.length - LIMIT); + return { records: result.slice(-LIMIT), removed: removed }; + } + const ready = Promise.resolve().then(async function () { + const revision = settingRevision; + try { + const values = await Promise.all([store.get(KEY, null), store.get(ENABLED_KEY, true)]); + const saved = validate(values[0]); + if (revision === 0 && settingRevision === 0) enabled = values[1] !== false; + clearedAt = saved.clearedAt; dropped = saved.dropped; + records = merge(saved.records, enabled ? records : [], clearedAt).records; + if (!enabled) pending = []; + } catch (error) { report(error); } + initialized = true; + if (pending.length) schedule(); + emit(); + return getState(); + }); + function getState() { + return { enabled: enabled, total: records.length, dropped: dropped, limit: LIMIT, storage: store.name || 'local', storageError: storageError, initialized: initialized, pending: pending.length, sessionId: session }; + } + function schedule() { + if (timer || destroyed || !initialized) return; + timer = root.setTimeout(function () { timer = null; flush().catch(function () {}); }, 450); + if (timer && typeof timer.unref === 'function') timer.unref(); + } + function record(level, event, message, details) { + if (!enabled || destroyed) return false; + let context = {}; + try { context = typeof options.getContext === 'function' ? clean(options.getContext()) : {}; } catch (_) { /* Logging may never interrupt a task. */ } + const time = Math.max(timestamp(), clearedAt + 1); + const entry = { id: session + '.' + (++sequence), time: time, lastTime: time, + level: level === 'warn' ? 'warning' : ['info', 'warning', 'error'].includes(level) ? level : 'info', + event: /^[\w.:-]{1,100}$/.test(String(event || '')) ? String(event) : 'diagnostic', message: text(message), details: clean(details || {}), context: context, count: 1 }; + const previous = records[records.length - 1]; + if (previous && previous.id.startsWith(session + '.') && time - previous.lastTime >= 0 && time - previous.lastTime <= 2000 && JSON.stringify([previous.level, previous.event, previous.message, previous.details, previous.context]) === JSON.stringify([entry.level, entry.event, entry.message, entry.details, entry.context])) { + previous.count = Math.min(1000000, previous.count + 1); previous.lastTime = time; + pending.push(Object.assign({}, previous)); + } else { records.push(entry); pending.push(entry); } + // Both the visible buffer and unsaved batch are bounded, even if storage is unavailable. + if (records.length > LIMIT) { dropped += records.length - LIMIT; records = records.slice(-LIMIT); } + const batch = new Map(); pending.forEach(function (item) { batch.set(item.id, item); }); pending = Array.from(batch.values()).slice(-LIMIT); + schedule(); return true; + } + function lock(action) { + const locks = root.navigator && root.navigator.locks; + return locks && typeof locks.request === 'function' ? locks.request('yianxue-diagnostic-log-v1', action) : action(); + } + function flush() { + if (timer) { root.clearTimeout(timer); timer = null; } + queue = queue.then(async function () { + await ready; + try { + await lock(async function () { + const saved = validate(await store.get(KEY, null)); + const cutoff = Math.max(clearedAt, saved.clearedAt); + const merged = merge(saved.records, records, cutoff); + const nextDropped = Math.max(dropped, saved.dropped + merged.removed); + const payload = JSON.parse(JSON.stringify({ schema: 1, records: merged.records, dropped: nextDropped, clearedAt: cutoff })); + if (pending.length) await store.set(KEY, payload); + const savedIds = new Map(payload.records.map(function (item) { return [item.id, [item.count, item.lastTime]]; })); + pending = pending.filter(function (item) { const value = savedIds.get(item.id); return !value || item.count > value[0] || item.lastTime > value[1]; }); + const local = merge(payload.records, pending, cutoff); records = local.records; dropped = Math.max(dropped, nextDropped + local.removed); clearedAt = cutoff; + storageError = ''; + }); + } catch (error) { report(error); } + emit(); + return getState(); + }); + return queue; + } + async function setEnabled(value) { + settingRevision++; enabled = value !== false; + await ready; + if (!enabled) await flush(); + try { await store.set(ENABLED_KEY, enabled); } catch (error) { report(error); } + emit(); + return getState(); + } + function getRecords() { return JSON.parse(JSON.stringify(records)); } + async function clear() { + await ready; await flush(); + queue = queue.then(async function () { try { return await lock(async function () { + clearedAt = Math.max(timestamp(), records.reduce(function (latest, item) { return Math.max(latest, item.time); }, -1)); + records = []; pending = []; dropped = 0; + try { await store.set(KEY, { schema: 1, records: [], dropped: 0, clearedAt: clearedAt }); storageError = ''; } catch (error) { report(error); } + emit(); + return getState(); + }); } catch (error) { report(error); return getState(); } }); + return queue; + } + async function exportJSON() { + await flush(); + return JSON.stringify({ schema: 1, exportedAt: new Date(timestamp()).toISOString(), state: getState(), records: getRecords() }, null, 2); + } + async function exportText() { + await flush(); + const state = getState(); + return '谊安学助手诊断日志\n导出时间:' + new Date(timestamp()).toISOString() + '\n已保存:' + state.total + ' 条;超出容量移除:' + state.dropped + ' 条\n\n' + records.map(function (item) { + return new Date(item.time).toISOString() + ' [' + item.level + '] ' + item.event + ' ' + item.message + (item.count > 1 ? '(重复 ' + item.count + ' 次,最后 ' + new Date(item.lastTime).toISOString() + ')' : '') + '\n context=' + JSON.stringify(item.context) + '\n details=' + JSON.stringify(item.details); + }).join('\n'); + } + async function destroy() { destroyed = true; if (timer) { root.clearTimeout(timer); timer = null; } return flush(); } + return { ready: ready, record: record, setEnabled: setEnabled, getState: getState, getRecords: getRecords, clear: clear, exportJSON: exportJSON, exportText: exportText, flush: flush, destroy: destroy }; + } + const api = Object.freeze({ create: create, key: KEY, enabledKey: ENABLED_KEY, limit: LIMIT }); + root.YianDiagnosticLogger = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof window !== 'undefined' ? window : globalThis); + /* 独立题库助手面板:页面正文与面板样式互不影响。 */ (function (global) { 'use strict'; @@ -1132,6 +1313,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, + diagnostics: { enabled: true, total: 0, dropped: 0, limit: 2000, storage: '', pending: 0, storageError: '' }, 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; @@ -1665,7 +1847,21 @@ window.JSZip = module.exports; invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success'); }); - views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台')); + views.console.appendChild(el(doc, 'h2', 'section-title', '故障诊断记录')); + const diagnosticSwitch = makeSwitch('持续记录诊断日志', function () { + state.diagnostics.enabled = diagnosticSwitch.input.checked; + invoke('onLogAction', 'record', diagnosticSwitch.input.checked); refreshLearning(); + }, views.console); + diagnosticSwitch.input.setAttribute('data-testid', 'diagnostic-record'); + const diagnosticStatus = el(doc, 'p', 'hint'); diagnosticStatus.setAttribute('data-testid', 'diagnostic-status'); + const diagnosticActions = el(doc, 'div', 'console-actions'); + [['exportJson', '导出诊断 JSON'], ['exportText', '导出诊断 TXT'], ['clearSaved', '清空已保存诊断']].forEach(function (entry) { + const button = el(doc, 'button', '', entry[1]); button.type = 'button'; + button.setAttribute('data-testid', 'diagnostic-' + entry[0]); + button.addEventListener('click', function () { invoke('onLogAction', entry[0]); }); + diagnosticActions.appendChild(button); + }); + views.console.append(diagnosticStatus, el(doc, 'p', 'hint', '记录任务切换、播放、执行权和交卷等待原因,刷新后可导出核对;不保存题库、题目或答案正文。关闭只停止新增记录。'), diagnosticActions, el(doc, 'h2', 'section-title', '本轮运行显示')); const logActions = el(doc, 'div', 'console-actions'); const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别'); [['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); }); @@ -1770,7 +1966,7 @@ window.JSZip = module.exports; }); } - 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: '已读取目录' }; + const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' }; function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; } function firstValue(object, keys, fallback) { object = object || {}; @@ -1793,7 +1989,7 @@ window.JSZip = module.exports; const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0); const paused = ['paused', 'stopped'].includes(learning.status); const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle'); - setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '')); + setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '') + (Number(learning.leaseWaiting) > 0 ? ' · ' + Number(learning.leaseWaiting) + ' 项等待执行权' : '')); setText(globalPause, paused ? '继续全部' : '暂停全部'); globalPause.disabled = !active && !paused; globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total); @@ -1802,7 +1998,7 @@ window.JSZip = module.exports; const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务')); const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle'); setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : '')); - setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : '')); + setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : Number(learning.leaseWaiting) > 0 ? ' · 待执行权' : '')); collapseSummary.hidden = Boolean(state.expanded); setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。'); Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); }); @@ -1874,6 +2070,13 @@ window.JSZip = module.exports; setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。'); if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n'); } + const diagnostics = state.diagnostics || {}; + diagnosticSwitch.input.checked = diagnostics.enabled !== false; + setText(diagnosticStatus, (diagnostics.enabled === false ? '已停止新增记录' : '记录已开启') + ' · 已记录 ' + Math.max(0, Number(diagnostics.total) || 0) + ' 条 / 最多 ' + (Number(diagnostics.limit) || 2000) + ' 条' + + (Number(diagnostics.pending) > 0 ? ' · 待保存 ' + Number(diagnostics.pending) + ' 条' : '') + + (Number(diagnostics.dropped) > 0 ? ' · 已移除最早 ' + Number(diagnostics.dropped) + ' 条' : '') + + (diagnostics.storage === 'memory' ? ' · 当前仅内存保存' : diagnostics.storage === 'origin' ? ' · 当前浏览器本地保存' : '') + + (diagnostics.storageError ? '\n保存异常:' + plain(diagnostics.storageError) + ';可先导出当前记录。' : '')); const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500); const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; }); const nextLogSignature = JSON.stringify([logFilter.value, logs]); @@ -2040,6 +2243,7 @@ window.JSZip = module.exports; if (Object.prototype.hasOwnProperty.call(update, 'learning')) { update.learning = Object.assign({}, state.learning || {}, update.learning || {}); } + if (Object.prototype.hasOwnProperty.call(update, 'diagnostics')) update.diagnostics = Object.assign({}, state.diagnostics || {}, update.diagnostics || {}); Object.assign(state, update); refresh(); if (acceptPosition) { @@ -2673,7 +2877,7 @@ window.JSZip = module.exports; 'use strict'; const TERMINAL = ['completed', 'skipped', 'failed', 'expanded']; - const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'retrying']; + const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'waitingLease', 'retrying']; const MODES = ['video', 'videoQuiz', 'exam', 'all']; const LEASE_MS = 18000; const SESSION_KEY = 'session.v1'; @@ -2853,6 +3057,7 @@ window.JSZip = module.exports; 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 logSequence = 0; let leaseBanner = null; let manualConfirmRevision = 0; let pendingManualConfirm = null; let policySessionId = '', policyReadSerial = 0; const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map(); @@ -2896,9 +3101,10 @@ window.JSZip = module.exports; 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; + state.leaseWaiting = counted.filter(t => t.status === 'waitingLease').length; const todo = counted.filter(t => !TERMINAL.includes(t.status)); const estimates = todo.map(t => { - if (t.status === 'waiting' || t.kind === 'course' || t.kind === 'unsupported') return null; + if (['waiting', 'waitingLease'].includes(t.status) || t.kind === 'course' || t.kind === 'unsupported') return null; if (t.kind === 'video') { const v = state.video && state.video.taskId === t.id ? state.video : t; if (!(v.duration > 0)) return null; @@ -2916,13 +3122,15 @@ window.JSZip = module.exports; state.etaSeconds = estimates.some(e => e == null) ? null : config.execution === 'parallel' ? Math.max(0, ...['video', 'exam'].map(kind => estimates.reduce((n, e, i) => n + (todo[i].kind === kind ? e : 0), 0))) : estimates.reduce((n, e) => n + e, 0); if (typeof options.onState === 'function') options.onState(clone(state)); } - function log(level, message, task) { - state.logs.push({ time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', message: String(message), task: task && task.title || '', taskId: task && task.id || '' }); + function log(level, message, task, details, event) { + const record = { id: owner + ':' + (++logSequence), time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', event: event || 'learning.message', message: String(message), task: task && task.title || '', taskId: task && task.id || '', details: details || {} }; + state.logs.push(record); + if (typeof options.onLog === 'function') { try { options.onLog(clone(record)); } catch (_) {} } state.logs = state.logs.slice(-500); notify(); } function taskState(task, status, reason) { const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || ''; - if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task); + if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state'); } function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); } function account(snap, page) { return clean(snap.accountId || page.accountId || ''); } @@ -2959,7 +3167,49 @@ window.JSZip = module.exports; if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; } } async function release() { - for (const kind of ['video', 'exam']) { const key = 'lease:' + kind; const lease = await storage.get(key); if (lease && lease.owner === owner) await storage.set(key, null); } + for (const kind of ['video', 'exam']) { + const key = 'lease:' + kind; const lease = await storage.get(key); + if (lease && lease.owner === owner) { await storage.set(key, null); log('info', '已释放' + (kind === 'video' ? '视频/课程' : '考试') + '执行权', null, lease, 'lease.released'); } + } + } + function waitForLease(task, lease, reason) { + const previous = task.leaseWait; + task.leaseWait = { previousStatus: previous?.previousStatus || (task.status === 'waitingLease' ? 'pending' : task.status), previousReason: previous ? previous.previousReason : task.reason || '', lastLoggedAt: previous?.lastLoggedAt, + since: previous?.since == null ? now() : previous.since, kind: task.kind === 'course' ? 'video' : task.kind, + owner: lease?.owner || '', taskId: lease?.taskId || '', expires: Number(lease?.expires) || 0, pageUrl: lease?.pageUrl || '', reason }; + if (!previous && activeTaskId === task.id) { + // 等待执行权期间取消本页旧动作;重新领取后仍须按当前页面重验。 + if (task.kind === 'video') { + const snap = snapshot(); const video = snap.document && describePage(snap.document, snap.url).video; + if (video) video.pause(); playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); + if (state.video) state.video.status = 'waitingLease'; + } + if (task.kind === 'exam' && task.examRequested && task.leaseWait.previousStatus !== 'waiting') { + task.examNeedsRecheck = true; + if (typeof options.onExamPause === 'function') options.onExamPause('本页已失去考试执行权,停止旧自动作答并等待重新核验'); + } + } + const secondsLeft = Math.max(0, Math.ceil((task.leaseWait.expires - now()) / 1000)); + const holder = lease?.title || lease?.taskId || '另一页面任务'; + const message = reason === 'control' ? '正在重新核对全局任务状态,暂不操作当前页面' + : '等待' + (task.leaseWait.kind === 'exam' ? '考试' : '视频/课程') + '执行权:' + holder + '由另一个页面处理,约 ' + secondsLeft + ' 秒后检查;执行权释放后自动继续'; + taskState(task, 'waitingLease', '同类任务由另一个页面处理,自动等待并重试'); + state.message = message; leaseBanner = { taskId: task.id, message }; + if (!previous || previous.owner !== task.leaseWait.owner || previous.taskId !== task.leaseWait.taskId || previous.reason !== reason || now() - (previous.lastLoggedAt || 0) >= 15000) { + task.leaseWait.lastLoggedAt = now(); + log('info', message, task, Object.assign({ ownOwner: owner }, task.leaseWait), 'lease.wait'); + } + } + function acquiredLease(task) { + if (!task.leaseWait) return; + const waited = task.leaseWait; + if (task.status === 'waitingLease') { task.status = waited.previousStatus || 'pending'; task.reason = waited.previousReason || ''; } + delete task.leaseWait; + if (leaseBanner?.taskId === task.id) { + if (state.message === leaseBanner.message) state.message = '已取得' + (task.kind === 'exam' ? '考试' : '视频/课程') + '执行权,正在继续任务'; + leaseBanner = null; + } + log('info', '等待结束,已取得执行权并重新核对任务', task, { waitedMs: Math.max(0, now() - waited.since), kind: waited.kind, previousOwner: waited.owner }, 'lease.resumed'); } async function claim(task) { const kind = task.kind === 'course' ? 'video' : task.kind; @@ -2967,13 +3217,16 @@ window.JSZip = module.exports; async function acquire() { const key = 'lease:' + kind; const lease = await storage.get(key); const shared = await storage.get(CONTROL_KEY); - if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false; - if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) return false; - const wanted = { owner, taskId: task.id, sessionId: session.id, expires: now() + LEASE_MS }; + if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) { waitForLease(task, null, 'control'); return false; } + if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) { waitForLease(task, lease, 'busy'); return false; } + if (lease && lease.expires <= now()) log('info', '原执行权已到期,重新领取任务', task, lease, 'lease.expired'); + const wanted = { owner, taskId: task.id, title: task.title, pageUrl: canonical(snapshot().url), sessionId: session.id, expires: now() + LEASE_MS, updatedAt: now() }; await storage.set(key, wanted); // GM存储无CAS;延后回读并在每次动作前复核。支持Web Locks时同源标签领取是互斥的。 if (!(root.navigator && root.navigator.locks)) await new Promise(resolve => root.setTimeout(resolve, 25)); - const saved = await storage.get(key); return Boolean(saved && saved.owner === owner && saved.taskId === task.id); + const saved = await storage.get(key); + if (!saved || saved.owner !== owner || saved.taskId !== task.id || saved.sessionId !== session.id) { waitForLease(task, saved, 'race'); return false; } + acquiredLease(task); log('info', '已取得' + (kind === 'exam' ? '考试' : '视频/课程') + '执行权', task, wanted, 'lease.acquired'); return true; } return root.navigator && root.navigator.locks ? root.navigator.locks.request('yian-learning-' + kind, acquire) : acquire(); } @@ -2981,7 +3234,9 @@ window.JSZip = module.exports; const key = 'lease:' + (task.kind === 'course' ? 'video' : task.kind); const lease = await storage.get(key); if (!lease || lease.owner !== owner || lease.taskId !== task.id || lease.sessionId !== session.id) return false; const shared = await storage.get(CONTROL_KEY); if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false; - lease.expires = now() + LEASE_MS; await storage.set(key, lease); return true; + if (lease.expires <= now()) log('info', '本页原执行权已到期,核对所有者后恢复', task, lease, 'lease.renewed'); + lease.expires = now() + LEASE_MS; lease.updatedAt = now(); lease.pageUrl = canonical(snapshot().url); lease.title = task.title; + await storage.set(key, lease); return true; } function mergeTasks(tasks, parent) { tasks.forEach(task => { @@ -3040,6 +3295,9 @@ window.JSZip = module.exports; const identityOK = (!saved.accountId || saved.accountId === account(snap, page)) && (!saved.planId || !snap.planId || saved.planId === snap.planId) && (!saved.bankVersion || !snap.bankVersion || saved.bankVersion === snap.bankVersion); if (identityOK) { session = saved; Object.assign(config, saved.config || {}); state.tasks = saved.tasks; state.logs = Array.isArray(saved.logs) ? saved.logs.slice(-500) : []; activeTaskId = saved.activeTaskId || ''; + for (const task of state.tasks) if (task.status === 'waiting' && task.reason === '另一个页面正在处理同类任务,避免重复领取') { + task.status = 'waitingLease'; task.leaseWait = { previousStatus: 'pending', previousReason: '', since: now() }; + } if (marker) { const request = await storage.get('worker:' + marker[1]); const control = await storage.get(CONTROL_KEY); @@ -3212,7 +3470,7 @@ window.JSZip = module.exports; task.preflight = preflightTopic(snap.document, snap, config.associations, task); if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; } } - if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; } + if (!(await claim(task))) return 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); @@ -3283,7 +3541,7 @@ window.JSZip = module.exports; 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(); } - } catch (error) { if (epoch === generation) taskState(task, 'waiting', '课中题处理失败:' + error.message); } + } catch (error) { if (epoch === generation) { log('error', '课中题处理发生异常', task, { errorName: error.name, error: error.message, stack: error.stack }, 'quiz.error'); taskState(task, 'waiting', '课中题处理失败:' + error.message); } } finally { pendingQuiz = null; } return; } @@ -3309,14 +3567,14 @@ window.JSZip = module.exports; playStarted = true; lastAutoPlayAt = now(); try { const promise = video.play(); if (promise && promise.catch) await promise; } - catch (_) { taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; } + catch (error) { log('warn', '播放器启动失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.playFailed'); taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; } if (epoch !== generation || state.status !== 'running') { video.pause(); return; } lastAdvancedAt = now(); } if (lastTime === null || Math.abs(currentTime - lastTime) > 0.01) { lastTime = currentTime; lastAdvancedAt = now(); attemptedAt = 0; taskState(task, 'playing', page.hidden ? '页面在后台,按真实播放与平台记录跟踪' : '视频播放中'); return; } if (!attemptedAt && now() - lastAdvancedAt >= config.stallSeconds * 1000) { attemptedAt = now(); taskState(task, 'retrying', '播放进度长时间未变化,先尝试续播'); - try { await video.play(); } catch (_) { taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); } + try { await video.play(); } catch (error) { log('warn', '播放器续播失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.resumeFailed'); taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); } return; } if (attemptedAt && now() - attemptedAt >= config.recoverySeconds * 1000) { @@ -3351,6 +3609,10 @@ window.JSZip = module.exports; if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url; } const attempts = readExamAttempt(task, snap, page); + if (task.examRequested && task.examNeedsRecheck) { + await observeExamState(task, true); + if (task.status === 'completed') return; + } 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; @@ -3382,11 +3644,12 @@ window.JSZip = module.exports; await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 }); buttons[0].click(); return; } - if (!task.examRequested || task.examPage !== page.kind) { + if (!task.examRequested || task.examNeedsRecheck || task.examPage !== page.kind) { task.examPage = page.kind; taskState(task, 'answering', '考试主题已确认,交给答题与交卷控制器'); if (typeof options.onExamStart === 'function') { const accepted = await options.onExamStart({ task: clone(task), preflight: clone(task.preflight), document: snap.document, page: page.kind, config: clone(config) }); task.examRequested = accepted !== false; + if (accepted !== false) task.examNeedsRecheck = false; if (accepted === false) { if (!task.readyWaitAt) task.readyWaitAt = now(); if (now() - task.readyWaitAt > 15000) taskState(task, 'waiting', '试卷未完整加载,请人工核对后继续'); } } else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理'); @@ -3476,17 +3739,24 @@ window.JSZip = module.exports; if (state.status !== 'running') { await persist(); notify(); return getState(); } if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; } if (!task) { - const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId)); + const candidates = state.tasks.filter(t => ['pending', 'waitingLease'].includes(t.status) && allows(t.kind) && (!worker || t.id === worker.taskId)); // 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。 function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); } candidates.sort((a, b) => rank(a) - rank(b)); - for (const next of candidates) { if (await activate(next, snap, page)) { task = next; break; } } + const blockedKinds = new Set(); + for (const next of candidates) { + const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue; + if (await activate(next, snap, page)) { task = next; break; } + if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; } + } } if (task && epoch === generation) { const held = await heartbeat(task) || await claim(task); - if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面'; - else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch); - else if (task.kind === 'exam') await examTick(task, snap, page); + if (held) { + acquiredLease(task); + if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch); + else if (task.kind === 'exam') await examTick(task, snap, page); + } } if (epoch === generation) await parallelTick(snap, page); if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } } @@ -3494,7 +3764,7 @@ window.JSZip = module.exports; if (!task && !remaining.length) { state.status = state.tasks.length ? 'finished' : 'waiting'; state.message = state.tasks.length ? '本轮调度结束;请检查完成、跳过及失败清单' : '当前页面没有可识别的任务,请打开学习地图、课程或试卷'; if (session) session.status = state.status; await control(state.status); } else if (!task && remaining.every(t => t.status === 'waiting')) { state.message = '剩余任务等待人工处理;确认后点击继续'; } await persist(); notify(); return getState(); - } catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message); return getState(); } + } catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message, null, { errorName: error.name, error: error.message, stack: error.stack }, 'learning.error'); return getState(); } finally { busy = false; } } async function observeControl(value) { @@ -4108,10 +4378,15 @@ window.JSZip = module.exports; let positionRevision = 0; let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle'; let actionEpoch = 0; + let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href; + const version = '2.1.3'; 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 recordDiagnostic(level, event, message, details) { + return diagnostics?.record(level, event, message, details) || false; + } function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; } function attemptPolicy() { @@ -4182,10 +4457,16 @@ window.JSZip = module.exports; const runner = window.YianAnswerRunner.create({ getContext, match: (question, context) => matcher.match(question, context, snapshot), getInterval: () => learningSettings.answerInterval, - onProgress: progress => { metrics = progress; panel.setState({ metrics }); }, + onProgress: progress => { + metrics = progress; panel.setState({ metrics }); + const counts = { selected: progress.selected, pending: progress.pending, missing: progress.missing, conflict: progress.conflict, failed: progress.failed, total: progress.total }; + const signature = JSON.stringify(counts); + if (signature !== lastAnswerDiagnostic) { lastAnswerDiagnostic = signature; recordDiagnostic('info', 'answer.progress', '答案核验进度已更新', counts); } + }, onState: (state, reason) => { if (disposed) return; runnerState = state; + recordDiagnostic(state === 'paused' ? 'warning' : 'info', 'answer.state', reason || '答题状态更新', { status: state }); if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); } if (state === 'paused') { examController?.disable(reason); learning?.configure({ examPaused: true }); } if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success'); @@ -4242,6 +4523,7 @@ window.JSZip = module.exports; panel.setNotice('请先在题库栏确认当前考试标题与题库课程/主题的关联。', 'warning'); return false; } config.autoAnswer = !!enabled; runKey = ''; + recordDiagnostic('info', 'answer.toggle', '自动选择' + (enabled ? '开启' : '关闭'), { enabled: !!enabled }); if (!enabled) { runner.stop('自动选择已关闭,已生效的选择保留。'); examController?.disable('自动选择已关闭'); } refreshPanel(); tick(); return config.autoAnswer; } @@ -4291,14 +4573,34 @@ window.JSZip = module.exports; onExportEnabledChange: setExportEnabled ,onTaskAction: action => { taskAction(action).catch(error => panel.setNotice(error.message, 'error')); }, onLearningSetting: (key, value) => { changeLearningSetting(key, value).catch(error => panel.setNotice(error.message, 'error')); }, - onLogAction: action => { - if (action === 'clear') learning?.clearLogs?.(); - if (action === 'copy') { - const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n'); - if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text).catch(() => {}); - } - } + onLogAction: (action, value) => { logAction(action, value).catch(error => panel.setNotice('日志操作失败:' + error.message, 'error')); } }); + diagnostics = window.YianDiagnosticLogger.create({ + storage: { name: '本机用户脚本存储', get: (key, fallback) => store.getSetting('diagnostics:' + key, fallback), set: (key, value) => store.setSetting('diagnostics:' + key, value) }, + getContext: () => ({ version, url: location.href, pageKind: window.YianLearningEngine.describePage(document, location.href).kind, + status: learningState.status || 'initializing', mode: learningSettings.mode, execution: learningSettings.execution, + bankTotal: snapshot.total, bankRevision: snapshot.revision }), + onState: info => panel.setState({ diagnostics: info }), + onError: error => panel.setNotice('诊断日志保存失败,仍保留内存记录:' + error.message, 'warning') + }); + async function exportDiagnostics(format) { + if (!inScope() || !['json', 'txt'].includes(format)) throw new Error('请选择 JSON 或 TXT 日志格式'); + const contents = await (format === 'json' ? diagnostics.exportJSON() : diagnostics.exportText()); + const url = URL.createObjectURL(new Blob([format === 'txt' ? '\ufeff' + contents : contents], { type: format === 'json' ? 'application/json;charset=utf-8' : 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); link.href = url; link.download = '谊安学-诊断日志-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + format; + document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); + panel.setNotice('诊断日志已导出,可用来核对卡住前后的执行过程。', 'success'); return contents; + } + async function logAction(action, value) { + if (action === 'record') return diagnostics.setEnabled(value === true); + if (action === 'exportJson' || action === 'exportText') return exportDiagnostics(action === 'exportJson' ? 'json' : 'txt'); + if (action === 'clearSaved') { await diagnostics.clear(); panel.setNotice('已清空保存的诊断日志;题库和任务检查点保留。', 'success'); return; } + if (action === 'clear') learning?.clearLogs?.(); + if (action === 'copy') { + const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n'); + if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else await navigator.clipboard?.writeText(text); + } + } async function changeLearningSetting(key, value) { const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh']; @@ -4318,6 +4620,7 @@ window.JSZip = module.exports; throw new Error('主题关联须使用完整标题及题库课程名称列表'); } const pending = { value }; pendingLearningSettings.set(key, pending); + recordDiagnostic('info', 'settings.changed', '学习设置已修改', { name: key, requested: value }); try { if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) { actionEpoch++; @@ -4334,6 +4637,7 @@ window.JSZip = module.exports; } async function taskAction(action) { actionEpoch++; + recordDiagnostic('info', 'task.action', '任务操作:' + action, { action, status: learningState.status, mode: learningSettings.mode, execution: learningSettings.execution }); await ready; if (!inScope()) return; if (action === 'start' || action === 'resume') { @@ -4360,7 +4664,16 @@ window.JSZip = module.exports; skipSingleAttemptQuiz: skipSingleAttempt() }), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState, storage: learningStorage, onLog: (level, message) => learning?.log(level, message), - onState: state => { examState = state; renderLearning(); } }); + onState: state => { + examState = state; + const signature = JSON.stringify([state.stage, state.enabled, state.verifiedCount, state.predictedScore, state.submitted, String(state.message || '').replace(/\d+(?:\.\d+)?/g, '#')]); + if (signature !== lastExamDiagnostic) { + lastExamDiagnostic = signature; + recordDiagnostic(['unknown', 'failed'].includes(state.stage) ? 'warning' : 'info', 'exam.state', state.message || '考试状态更新', + { stage: state.stage, enabled: state.enabled, verifiedCount: state.verifiedCount, total: state.total, predictedScore: state.predictedScore, elapsedSeconds: state.elapsedSeconds, remainingSeconds: state.remainingSeconds, submitted: state.submitted, manualConfirmIncomplete: state.manualConfirmIncomplete, skipSingleAttemptQuiz: state.skipSingleAttemptQuiz }); + } + renderLearning(); + } }); learning = window.YianLearningEngine.create({ getSnapshot: () => ({ document, url: location.href, courses: courses(), bank: snapshot, bankVersion: snapshot.revision, accountId: document.querySelector('[data-user-id]')?.getAttribute('data-user-id') || '', planId: '' }), @@ -4372,6 +4685,7 @@ window.JSZip = module.exports; return false; }, onExamPause: reason => { setAutoAnswer(false); examController.disable(reason); }, + onLog: record => recordDiagnostic(record.level, record.event, record.message, Object.assign({ id: record.id, taskId: record.taskId, taskTitle: record.task }, record.details)), solveQuiz: async (_popup, quizContext) => { const revision = snapshot.revision; const mode = learningSettings.mode; @@ -4400,6 +4714,9 @@ window.JSZip = module.exports; function tick() { if (disposed) return; + if (diagnosticRoute !== location.href) { + recordDiagnostic('info', 'page.changed', '页面地址已变化', { source: diagnosticRoute, destination: location.href }); diagnosticRoute = location.href; + } if (!inScope()) { if (!scopedOut) { scopedOut = true; @@ -4488,15 +4805,27 @@ window.JSZip = module.exports; const timer = setInterval(tick, 600); const onNavigation = () => { runner.stop('页面地址已变化'); runKey = ''; tick(); }; window.addEventListener('popstate', onNavigation); window.addEventListener('hashchange', onNavigation); + const onError = event => recordDiagnostic('error', 'runtime.error', event.message || '网页脚本发生错误', { errorName: event.error?.name, stack: event.error?.stack }); + const onRejection = event => recordDiagnostic('error', 'runtime.rejection', event.reason?.message || String(event.reason || '异步操作失败'), { errorName: event.reason?.name, stack: event.reason?.stack }); + const onVisibility = () => recordDiagnostic('info', 'page.visibility', document.visibilityState === 'hidden' ? '页面进入后台' : '页面返回前台', { visibility: document.visibilityState }); + const onNetwork = event => recordDiagnostic(event.type === 'offline' ? 'warning' : 'info', 'network.' + event.type, event.type === 'offline' ? '浏览器报告网络已断开' : '浏览器报告网络已恢复'); + const onPageHide = () => { diagnostics.flush().catch(() => {}); }; + window.addEventListener('error', onError); window.addEventListener('unhandledrejection', onRejection); + window.addEventListener('online', onNetwork); window.addEventListener('offline', onNetwork); window.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibility); function dispose() { disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation); + window.removeEventListener('error', onError); window.removeEventListener('unhandledrejection', onRejection); + window.removeEventListener('online', onNetwork); window.removeEventListener('offline', onNetwork); window.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibility); runner.destroy(); examController.destroy(); learning.destroy(); copy.destroy(); panel.destroy(); + diagnostics.destroy().catch(() => {}); } const ready = (async () => { const positionRevisionAtRead = positionRevision; try { - const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]); + const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null), diagnostics.ready]); 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, { restore: true }); @@ -4510,10 +4839,14 @@ window.JSZip = module.exports; && typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null; } } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } - loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot; + loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); + recordDiagnostic('info', 'app.ready', '谊安学助手已加载', { version, mode: learningSettings.mode, execution: learningSettings.execution, total: snapshot.total }); + tick(); return snapshot; })(); const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext, setLearningSetting: changeLearningSetting, taskAction, getLearningState: () => learning.getState(), getExamState: () => examController.getState(), + getDiagnosticState: () => diagnostics.getState(), flushDiagnostics: () => diagnostics.flush(), setDiagnosticRecording: enabled => diagnostics.setEnabled(enabled === true), + clearDiagnostics: () => diagnostics.clear(), exportDiagnostics, getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, learning: learning.getState(), exam: examController.getState(), pageReady: !!page && page.ready }), exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose }); window.YianAssistant = api; window.YianQuestionBankExporter = api; diff --git a/需求.md b/需求.md index 24b68e9..bfa8a92 100644 --- a/需求.md +++ b/需求.md @@ -185,8 +185,9 @@ ### 9.3 日志、导出与题库变更 -- 控制台是面板内可查看的运行日志,记录时间、任务名称、状态、主题对应、匹配数量、预计得分、交卷等待/结果及恢复原因和次数;建议保留最近 500 条,支持复制和清空显示。 +- 控制台是面板内可查看的运行日志,记录时间、任务名称、状态、主题对应、匹配数量、预计得分、交卷等待/结果及恢复原因和次数;保留最近 500 条,支持复制和清空显示。 - 清空控制台显示不清除任务清单或恢复检查点;日志不记录登录凭据和完整敏感接口数据。 +- “持续记录诊断日志”开关默认开启,将最近 2000 条诊断记录独立持久保存,刷新后仍可查看和导出。提供“导出诊断 JSON”“导出诊断 TXT”“清空已保存诊断”;关闭开关只停止新增记录,已有记录仍可导出。诊断日志不包含完整题干、选项、答案和 URL 查询参数;清空诊断不删除任务检查点或题库。 - 题库导出移动到“其他设置”,保留独立开关与折叠区,默认关闭,开启后才允许导出;原有 CSV/JSON/TXT 格式和仅练习页导出规则不变。 - 保留题库导入、搜索正确答案、允许复制及答案全文核对功能;取消岗位切换,显示统一题库状态与课程/主题筛选。题库或主题关联变更时暂停受影响的答题任务,取消旧交卷计划并重新预检。 @@ -198,6 +199,8 @@ | 四种模式 | 视频、课中题、独立考试按所选模式执行;仅视频遇强制题等待人工 | | 新旧默认及视频后续 | 新安装默认“视频+考试”,升级保留已选模式;平台确认视频完成后才进入已开放关联考试;仅视频及视频+课中答题模式明确提示不会进入考试 | | 顺序与并行 | 默认顺序;并行最多一个视频和一场考试,不重复任务且遵守前置条件 | +| 同类任务临时占用 | 租约冲突自动等待和重试;占用失效后继续调度,成功领取后清除旧等待提示;人工确认等待不被自动恢复覆盖 | +| 日志保存与清空 | 运行日志显示最近 500 条,清空只影响显示;默认持续保存最近 2000 条诊断,刷新后可导出 JSON/TXT,关闭只停止新增;清空诊断不删除检查点或题库 | | 最高 2X | 面板与播放器实际倍率一致;网站学习时长和完成状态独立核对 | | 主题预检 | 显示考试与题库对应关系,无库不自动开考;名称不同的明确关联可用 | | 统一题库 | 三岗位题库全部可检索和匹配,无岗位选择;重复题保留来源,答案冲突不盲目覆盖 | @@ -236,6 +239,14 @@ 修复网站主动后台暂停后返回不能及时续播的问题,同时保留用户暂停的优先级,真实显示后台暂停状态。新安装默认串联视频与考试,已有模式保留;面板根据实际模式说明视频完成后的走向。附件 `08.zip` 仅作静态外壳证据,真实账号的续播、平台完成条件及考试入口仍需实站核对;不能宣称后台始终播放。 +## 13. 2.1.3 调度与问题记录 + +用户反馈“等待同类任务租约释放,暂不操作当前页面”后没有进入考试。租约冲突属于暂时调度状态,应自动复查,不直接变成必须手动继续的等待;领取成功后必须同步实际任务及常驻提示。另一执行页面仍有效时保持互斥,不能为了跳转考试抢占其他正在进行的视频或考试。平台确认完成、当前执行模式、考试主题及单次机会保护等条件继续有效。 + +本地已复现租约临时冲突变为永久人工等待,以及成功领取后旧提示未清除的两项缺陷;不能据此断言用户当前存在重复标签。归档学习地图的课程和考试入口使用 Angular 当前页导航,静态证据不能代替实站验收。 + +控制台继续显示最近 500 条运行日志,“清空显示”仅清显示。新增默认开启的“持续记录诊断日志”,独立持久保存最近 2000 条诊断,刷新后保留,提供“导出诊断 JSON”“导出诊断 TXT”“清空已保存诊断”。关闭开关只停止新增,已存内容仍可导出;诊断不含完整题干、选项、答案和 URL 查询参数,清空诊断不删除任务检查点或题库。 + ## 附:用户原始需求 根据我提供的谊安学网页压缩包以及之前的完成的刷题脚本,现在需要做一个完整的刷课答题的脚本,现在需要所有任务的一个进度,以及预测时间,看视频过程中可能也有答题,以及当前任务的进度,刷视频要求可以在面板选择倍速,最高是2X,答题的可以设置答题间隔时间(默认4秒)以及交卷的时间(默认6分钟),需要增加只刷视频以及视频的任务以及视频中的答题的模式,或者只有考试的模式。答题之前先确认有没有该题库,没有则跳过该考试。或者考试和视频同时可以进行,导出题库放到其他设置的面板中。面板增加一个控制台的功能,可以看当前进度,视频如果卡住或者暂停自动刷新页面,增加网页可以后台的功能。