diff --git a/README.md b/README.md index 3f1a1d8..6bfeeb3 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,157 @@ -# 学习通自动刷课脚本 V3 稳定版 +# 学习通自动播放脚本 V3 稳定版 -当前版本基于原始 `v3_optimized.js` 的主框架整理,只保留稳定可用的核心逻辑: +这是基于原 `v3_optimized.js` 整理后的学习通课程自动化脚本。当前维护重点是稳定播放、默认 1x 倍速、自动处理同小节多任务点,并尽量避免误判导致的循环弹窗或错误滚动。 -- 自动播放视频 -- 视频结束后自动切换到下一小节 -- 章节测验页面自动跳过 -- 从“学习目标”步骤自动切到“视频”步骤 -- 手动点“2视频”页签后自动重新接管播放 +当前 Chrome 扩展版本:`3.2.0.9` -这版的目标是保持原模板的行为方式,不再额外叠加复杂的后台保活、通知、测试按钮等附加层,尽量减少冲突。 +## 主要功能 + +- 默认播放速度为 `1x`,并持续锁定,避免页面自动切回 `1.5x`。 +- 自动识别并播放视频任务点。 +- 支持同一小节内存在多个视频任务点,按顺序逐个检查和播放。 +- 如果某个视频任务点已经完成,会自动跳过并检查下一个视频任务点。 +- 如果当前小节内所有视频任务点都已完成,会直接进入下一节。 +- 自动识别教材、文档、阅读类任务点,逐步滚动到文档底部。 +- 阅读任务只有在检测到任务点完成后才会进入下一节,避免滚到底就误跳。 +- 章节测验页面会尝试跳过,并处理“当前章节还有任务点未完成,是否去完成?”弹窗。 +- 视频任务优先级高于教材阅读判断,避免把视频页误判成教材文本页。 +- 避免跨域 iframe 扫描报错刷屏。 +- 支持 Tampermonkey 用户脚本和 Chrome 开发者模式扩展两种使用方式。 ## 文件说明 -- [v3_optimized.js](/E:/code/xuexitongScript-master/v3_optimized.js) - 控制台直接执行版本 -- [v3_optimized.user.js](/E:/code/xuexitongScript-master/v3_optimized.user.js) - Tampermonkey 油猴版本 -- [test_serverchan_push.py](/E:/code/xuexitongScript-master/test_serverchan_push.py) - 独立的 Server酱推送调试脚本 +- `v3_optimized.user.js` + Tampermonkey 用户脚本版本。 -## 当前脚本行为 +- `v3_optimized.js` + 可在浏览器控制台中直接执行的版本。 -### 1. 学习目标页 +- `chrome-extension/` + Chrome 开发者模式可加载的本地扩展目录。 -如果当前小节先进入的是“学习目标”而不是视频页,脚本会尝试点击顶部的 `2视频` / `视频` 标签进入视频步骤。 +- `tests/` + 针对倍速锁定、弹窗处理、视频任务点、教材阅读、任务类型识别等逻辑的静态回归检查。 -说明: -- 这条路径已经按当前页面结构收紧,只走顶部视频标签,不再点击底部 `下一节` 按钮 -- 原因是底部按钮在当前页面里可能触发额外调试/暂停逻辑,稳定性较差 +## 推荐安装方式:Chrome 扩展 -### 2. 视频页 +1. 打开 Chrome 的 `chrome://extensions/`。 +2. 打开右上角“开发者模式”。 +3. 点击“加载已解压的扩展程序”。 +4. 选择仓库里的 `chrome-extension` 目录。 +5. 如果已经加载过旧版本,修改代码后需要点击扩展卡片上的“重新加载”。 -进入视频页后,脚本会: +当前扩展会注入 `chrome-extension/v3_optimized.user.js`,所以每次更新后都需要重新加载扩展,浏览器才会使用新版本。 + +## Tampermonkey 使用方式 + +1. 安装 Tampermonkey。 +2. 新建脚本。 +3. 复制 `v3_optimized.user.js` 的完整内容。 +4. 保存并启用脚本。 +5. 刷新学习通课程播放页面。 + +## 控制台使用方式 -- 识别 iframe 内的真实视频元素 -- 自动调用 `play()` -- 如果播放被短暂中断,则尝试恢复播放 -- 如果普通播放失败,则尝试静音播放 +1. 打开学习通课程播放页面。 +2. 按 `F12` 打开开发者工具。 +3. 进入 `Console`。 +4. 复制 `v3_optimized.js` 的完整内容并执行。 -当前默认配置: +首次执行后可手动调用: ```javascript -configs: { - playbackRate: 1.5, - autoplay: true, - retryInterval: 2000, - maxRetries: 10, - videoCheckInterval: 1000, -} +app.run() +app.nextUnit() ``` -### 3. 视频结束后 +## 当前行为说明 -视频触发结束事件后,脚本会调用 `nextUnit()`,在课程目录树中定位到下一小节并点击进入。 +### 默认倍速 -### 4. 章节测验 - -如果当前不是视频而是“章节测验”页面,脚本会尝试点击: +脚本默认配置为: ```javascript -#prevNextFocusNext +playbackRate: 1 ``` -以跳过当前测验步骤并继续后续流程。 +页面播放器如果自行改回其他倍速,脚本会在播放、暂停恢复、倍速变化和定时检查时重新设置为 `1x`。 + +### 视频任务点 -## 使用方法 +进入视频页后,脚本会: -### 方法一:控制台执行 +- 扫描当前页面和可访问 iframe 中的视频任务 iframe。 +- 找到真实的 `video` 元素并尝试播放。 +- 播放失败时尝试静音播放。 +- 视频暂停或长时间无进度时尝试恢复播放。 +- 视频结束后检查同小节内是否还有未完成的视频任务点。 -1. 打开学习通课程播放页 -2. 按 `F12` -3. 进入 `Console` -4. 复制 [v3_optimized.js](/E:/code/xuexitongScript-master/v3_optimized.js) 全部内容 -5. 粘贴并执行 +如果某个视频任务点已经显示完成,脚本会跳过它;如果本小节所有视频任务点都完成,脚本会直接进入下一节。 -首次执行后可用: +### 教材和文本阅读任务 -```javascript -app.run() -app.nextUnit() -``` +脚本会在确认当前任务不是视频、不是章节测验后,才进入阅读任务流程。 -### 方法二:Tampermonkey +阅读任务会逐步滚动页面和文档容器。滚动到底部后,如果任务点仍未完成,脚本会继续等待和检测,不会直接切到下一节。 -1. 安装 Tampermonkey -2. 导入 [v3_optimized.user.js](/E:/code/xuexitongScript-master/v3_optimized.user.js) -3. 确认脚本已启用 -4. 刷新学习通播放页面 +### 章节测验 -## 已知说明 +章节测验页面会尝试点击下一节跳过。遇到“当前章节还有任务点未完成,是否去完成?”弹窗时,脚本会根据当前页面类型处理: -### 1. 为什么有时会看到 `AbortError` +- 普通学习任务:点击“去学习”或“去完成”。 +- 章节测验:优先点击“下一节”,避免在测验页和弹窗之间反复循环。 -常见报错: +### 任务类型识别 -```text -The play() request was interrupted by a call to pause() -``` +任务类型判断的优先级为: + +1. 视频任务 +2. 章节测验 +3. 教材、文档、阅读任务 +4. 未知任务 -这通常不是视频坏了,而是: +这样可以避免视频 iframe 加载较慢时,被误判成教材阅读页面而一直滚动。 -- 页面从步骤页切到视频页时,播放器初始化会短暂停一下 -- 页面内部脚本会在加载过程中重置播放状态 -- 浏览器对媒体播放请求进行了短暂中断 +## 常见问题 -脚本会优先尝试恢复播放,而不是立即判定失败。 +### 仍然跑旧逻辑怎么办? -### 2. 为什么会出现重复日志 +如果使用 Chrome 扩展,请打开 `chrome://extensions/`,点击该扩展的“重新加载”,然后刷新学习通页面。 -如果同一页面反复粘贴执行脚本,会导致同一套监听和定时逻辑叠加,从而出现多次日志。 +如果使用 Tampermonkey,请确认脚本内容已经替换成最新的 `v3_optimized.user.js`。 -建议: +### 为什么控制台仍然有学习通自己的报错? -- 刷新页面后只执行一次 -- 如果使用油猴版,尽量不要再在控制台重复粘贴执行 +学习通页面本身会加载多个内部脚本和跨域 iframe,控制台可能出现平台自身的 warning 或 error。脚本已尽量忽略可预期的跨域 iframe 访问错误,重点看是否还有来自 `v3_optimized.user.js` 的连续异常。 -### 3. 为什么“静音播放成功”但一开始还能听到声音 +### 为什么阅读任务滚到底后没有立刻下一节? -旧逻辑里曾经会在静音启动成功后恢复声音。当前建议使用持续静音方式,不再自动恢复。 +这是有意设计。部分教材任务需要页面上报完成状态后才算任务点完成,所以脚本会等待“任务点已完成”信号,避免滚到底但任务未完成就误跳。 -## 当前调试结论 +## 验证脚本 -目前这版已经验证: +可在仓库根目录运行: -- 顶部 `2视频` 标签可以程序化点击 -- 手动进入视频页后可以自动播放 -- 静音恢复链路可用 +```bash +node tests/verify-dialog-throttle.js +node tests/verify-no-skip-and-frame-guard.js +node tests/verify-flow-fixes.js +node tests/verify-reading-task.js +node tests/verify-speed-lock.js +node tests/verify-jquery-compat.js +node tests/verify-task-classification.js +node tests/verify-video-completion-skip.js +``` -目前重点保留的是稳定性,不再继续叠加额外功能。 +也可以对主要脚本做语法检查: +```bash +node --check v3_optimized.user.js +node --check chrome-extension/v3_optimized.user.js +node --check v3_optimized.js +node --check chrome-extension/content.js +``` ## 免责声明 -本项目仅用于脚本调试、前端自动化研究和页面行为分析,请遵守目标平台的使用规定。 +本项目仅用于脚本调试、前端自动化研究和页面行为分析。请遵守目标平台的使用规定。 diff --git a/README_v2.md b/README_v2.md index 34166fe..67ad897 100644 --- a/README_v2.md +++ b/README_v2.md @@ -19,7 +19,7 @@ ### 注意事项 - 如遇到脚本出错,请先刷新页面后再次使用。 -- 默认播放速度为8倍,可以修改代码`v.playbackRate=8; `这个值来修改速度,如果电脑配置较低,请降低倍率。 +- 默认播放速度为1倍,可以修改代码`v.playbackRate=1; `这个值来修改速度。 - 测试最大倍率可以达到16倍,再快也许会报错。 ### v2版本说明文档 diff --git a/chrome-extension/README-install.md b/chrome-extension/README-install.md new file mode 100644 index 0000000..dec67d2 --- /dev/null +++ b/chrome-extension/README-install.md @@ -0,0 +1,14 @@ +# Xuexitong Script 1x Chrome Extension + +This folder is an unpacked Chrome extension wrapper around the modified userscript. + +Install manually: + +1. Open `chrome://extensions/`. +2. Enable `Developer mode`. +3. Click `Load unpacked`. +4. Select this folder: + `C:\Users\ASUS\Documents\Codex\2026-05-18\chaolucky18-xuexitongscript-https-github-com-chaolucky18\chrome-extension` +5. Refresh the Xuexitong course page. + +The bundled script sets `playbackRate` to `1`. diff --git a/chrome-extension/content.js b/chrome-extension/content.js new file mode 100644 index 0000000..70eea30 --- /dev/null +++ b/chrome-extension/content.js @@ -0,0 +1,11 @@ +(() => { + const scriptId = "codex-xuexitong-script-1x"; + if (document.getElementById(scriptId)) return; + + console.log("[Xuexitong Script 1x] content script injecting page script"); + const script = document.createElement("script"); + script.id = scriptId; + script.src = chrome.runtime.getURL("v3_optimized.user.js"); + script.onload = () => script.remove(); + (document.head || document.documentElement).appendChild(script); +})(); diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 0000000..d7f8f06 --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "Xuexitong Script 1x", + "version": "3.2.0.9", + "description": "Runs the local Xuexitong autoplay script with default playback speed set to 1x.", + "content_scripts": [ + { + "matches": [ + "*://mooc1.chaoxing.com/mycourse/studentstudy*", + "*://*.chaoxing.com/mycourse/studentstudy*", + "*://*.chaoxing.com/mooc2-ans/mycourse/studentstudy*" + ], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "web_accessible_resources": [ + { + "resources": ["v3_optimized.user.js"], + "matches": ["*://*.chaoxing.com/*"] + } + ] +} diff --git a/chrome-extension/v3_optimized.user.js b/chrome-extension/v3_optimized.user.js new file mode 100644 index 0000000..3c7fa92 --- /dev/null +++ b/chrome-extension/v3_optimized.user.js @@ -0,0 +1,1082 @@ +// ==UserScript== +// @name 学习通自动刷课脚本 V3 稳定版 +// @namespace local.codex.xuexitong +// @version 3.2.0.9 +// @description 按原版框架自动播放、自动下一节、章节测验自动跳过 +// @author Codex +// @match *://mooc1.chaoxing.com/mycourse/studentstudy* +// @match *://*.chaoxing.com/mycourse/studentstudy* +// @match *://*.chaoxing.com/mooc2-ans/mycourse/studentstudy* +// @run-at document-idle +// @grant none +// ==/UserScript== + +(function () { + if (typeof window.jQuery === 'undefined') { + const script = document.createElement('script'); + script.src = 'https://code.jquery.com/jquery-3.6.0.min.js'; + script.type = 'text/javascript'; + script.onload = function () { + console.log('jQuery loaded.'); + initializePlayer(); + }; + document.head.appendChild(script); + } else { + initializePlayer(); + } + + function initializePlayer() { + window.app = { + configs: { + playbackRate: 1, + autoplay: true, + retryInterval: 2000, + maxRetries: 10, + videoCheckInterval: 1000, + dialogCheckInterval: 1000, + taskDialogClickCooldownMs: 8000, + readingScrollInterval: 800, + readingScrollStepPx: 420, + readingBottomGraceMs: 5000, + readingMaxRounds: 90, + videoPendingRetryMs: 1200, + guardNoProgressMs: 7000, + guardResumeCooldownMs: 1500, + }, + _videoEl: null, + _treeContainerEl: null, + _isPlaying: false, + _currentRetryCount: 0, + _checkInterval: null, + _dialogCheckInterval: null, + _currentVideoTaskIndex: 0, + _videoTaskCount: 0, + _handlingVideoEnd: false, + _boundVideoEl: null, + _boundVideoHandlers: null, + _confirmGuardInstalled: false, + _lastTaskPointDialogClickAt: 0, + _readingActive: false, + _readingScrollTimer: null, + _readingBottomSince: 0, + _readingRounds: 0, + _cellData: { + cells: 0, + nCells: 0, + currentCellIndex: 0, + currentNCellIndex: 0, + currentVideoTitle: '', + }, + get cellData() { + return this._cellData; + }, + run() { + console.log('%c=== 学习通自动刷课脚本 V3 稳定版启动 ===', 'color:#4CAF50;font-size:16px;font-weight:bold'); + this._getTreeContainer(); + this._initCellData(); + this._resetVideoTaskState(); + this._getVideoEl(); + this._clearCheckInterval(); + this._installConfirmGuard(); + this._startDialogMonitoring(); + this._bindStepNavigation(); + this.play(); + }, + nextUnit() { + this._stopReadingScroll(); + console.log('%c=== 准备切换到下一小节 ===', 'color:#2196F3;font-size:14px'); + const el = this._getTreeContainer(); + const cells = el.children('ul').children('li'); + const nCells = $(cells.get(this._cellData.currentCellIndex)).find('.posCatalog_select:not(.firstLayer)'); + + if (nCells.length > this._cellData.currentNCellIndex + 1) { + const nextNIndex = this._cellData.currentNCellIndex + 1; + console.log(`%c切换到同章节下一个视频: ${nextNIndex + 1}/${nCells.length}`, 'color:#FF9800'); + this.playCurrentIndex(nCells.get(nextNIndex)); + } else { + const nextIndex = this._cellData.currentCellIndex + 1; + if (nextIndex >= cells.length) { + console.log('%c=====================================', 'color:#4CAF50;font-size:16px'); + console.log('%c==============本课程学习完成了==============', 'color:#4CAF50;font-size:16px;font-weight:bold'); + console.log('%c=====================================', 'color:#4CAF50;font-size:16px'); + this._clearCheckInterval(); + this._clearDialogInterval(); + return; + } + console.log(`%c切换到下一个章节: ${nextIndex + 1}/${cells.length}`, 'color:#FF9800'); + this._cellData.currentCellIndex = nextIndex; + this._cellData.currentNCellIndex = 0; + this.playCurrentIndex(); + } + }, + _clearCheckInterval() { + if (this._checkInterval) { + clearInterval(this._checkInterval); + this._checkInterval = null; + } + }, + _clearDialogInterval() { + if (this._dialogCheckInterval) { + clearInterval(this._dialogCheckInterval); + this._dialogCheckInterval = null; + } + }, + _startDialogMonitoring() { + this._clearDialogInterval(); + this._dialogCheckInterval = setInterval(() => { + this._handleTaskPointDialog('monitor'); + }, this.configs.dialogCheckInterval); + }, + _dispatchClick(el) { + if (!el) return false; + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return true; + }, + _normalizeText(value) { + return String(value || '').replace(/\s+/g, ''); + }, + _getDialogButtonCandidates(dialog) { + const elements = []; + if (dialog && dialog.length) { + const root = dialog.get(0); + if (root) elements.push(root); + dialog.find('button,a,span,div').each((_, el) => elements.push(el)); + } + return $(elements); + }, + _isLikelyVisibleDialog(el) { + if (!el) return false; + const $el = $(el); + const text = String($el.text() || ''); + if (text.length > 800) return false; + let rect; + try { + rect = el.getBoundingClientRect(); + } catch (e) { + return false; + } + if (!rect || rect.width < 160 || rect.height < 80) return false; + const style = window.getComputedStyle(el); + const position = style.position; + const zIndex = Number.parseInt(style.zIndex, 10); + const className = String(el.className || '').toLowerCase(); + const role = String(el.getAttribute?.('role') || '').toLowerCase(); + const zScore = Number.isFinite(zIndex) ? zIndex : 0; + const parentZScore = Math.max(0, ...$el.parents().map((_, parent) => { + const parentZ = Number.parseInt(window.getComputedStyle(parent).zIndex, 10); + return Number.isFinite(parentZ) ? parentZ : 0; + }).get()); + return role === 'dialog' + || position === 'fixed' + || zScore >= 10 + || parentZScore >= 10 + || /dialog|modal|layui|layer|wayer|pop/.test(className); + }, + _handleTaskPointDialog(reason) { + try { + const now = Date.now(); + if (now - this._lastTaskPointDialogClickAt < this.configs.taskDialogClickCooldownMs) { + return false; + } + const taskPointText = '\u5f53\u524d\u7ae0\u8282\u8fd8\u6709\u4efb\u52a1\u70b9\u672a\u5b8c\u6210'; + const goStudyText = '\u53bb\u5b66\u4e60'; + const goCompleteText = '\u53bb\u5b8c\u6210'; + const nextSectionText = '\u4e0b\u4e00\u8282'; + const isQuizTask = this._isQuizTaskPage(); + const targetTexts = isQuizTask ? [nextSectionText] : [goStudyText, goCompleteText]; + const normalize = (value) => this._normalizeText(value); + const dialogs = $('[role="dialog"]:visible,.layui-layer:visible,.wayer:visible,.wayer-dialog:visible,.modal:visible,.dialog:visible,[class*="dialog"]:visible,[class*="modal"]:visible,[class*="layer"]:visible,[class*="pop"]:visible').filter((_, el) => { + const text = normalize($(el).text()); + return this._isLikelyVisibleDialog(el) && text.includes(taskPointText) && targetTexts.some((targetText) => text.includes(targetText)); + }); + if (!dialogs.length) return false; + + const dialog = dialogs.last(); + const targetButton = this._getDialogButtonCandidates(dialog).filter((_, el) => { + const text = normalize($(el).text()); + return targetTexts.some((targetText) => { + return text === targetText || (text.includes(targetText) && $(el).children().length === 0); + }); + }).last(); + if (!targetButton.length) return false; + + console.log(`[Xuexitong Script 1x] unfinished-task dialog handled as ${isQuizTask ? 'next section' : 'go study'} (${reason})`); + this._lastTaskPointDialogClickAt = now; + if (isQuizTask) { + this._stepSwitchPending = true; + this._stepSwitchAt = now; + } + return this._dispatchClick(targetButton.get(0)); + } catch (e) { + console.warn('[Xuexitong Script 1x] dialog handling failed:', e); + return false; + } + }, + _installConfirmGuard() { + if (this._confirmGuardInstalled) return; + this._confirmGuardInstalled = true; + const taskPointText = '\u5f53\u524d\u7ae0\u8282\u8fd8\u6709\u4efb\u52a1\u70b9\u672a\u5b8c\u6210'; + const nativeConfirm = window.confirm.bind(window); + window.confirm = (message) => { + if (String(message || '').includes(taskPointText)) { + if (this._isQuizTaskPage()) { + console.log('[Xuexitong Script 1x] native unfinished-task confirm handled as quiz skip'); + return false; + } + console.log('[Xuexitong Script 1x] native unfinished-task confirm handled as go study'); + return true; + } + return nativeConfirm(message); + }; + }, + _resetVideoTaskState() { + this._detachVideoEventHandlers(); + this._videoEl = null; + this._currentVideoTaskIndex = 0; + this._videoTaskCount = 0; + this._handlingVideoEnd = false; + }, + _getCurrentStepTitle() { + const prevTitle = document.getElementsByClassName('prev_title')[0]; + return prevTitle ? (prevTitle.title || prevTitle.textContent || '').trim() : ''; + }, + _getCurrentTaskText() { + const parts = [document.title, this._getCurrentStepTitle()]; + const activeSelectors = [ + '.prev_title', + '.posCatalog_active .posCatalog_name', + '.prev_white.active', + '.prev_white.selected', + '.prev_white.current', + '.prev_white.on', + '.prev_white[aria-selected="true"]', + '[class*="prev"][class*="active"]:visible', + '[class*="prev"][class*="select"]:visible', + '[class*="prev"][class*="current"]:visible', + '[class*="prev"][class*="cur"]:visible', + '[class*="prev"][class*="on"]:visible', + 'li[aria-selected="true"]:visible', + ]; + try { + $(activeSelectors.join(',')).each((_, el) => { + parts.push(el.title || $(el).attr('title') || $(el).text()); + }); + } catch (e) {} + return this._normalizeText(parts.join(' ')); + }, + _hasVideoTaskSignal() { + if (this._videoEl || this._findVideoFramesInWindow(window).length > 0) { + return true; + } + const videoText = '\u89c6\u9891'; + if (this._getCurrentTaskText().includes(videoText)) { + return true; + } + try { + if ($('video').length > 0) return true; + return $('iframe').filter((_, frame) => { + const attrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('video') || attrs.includes('insertvideo') || attrs.includes('ans-insertvideo'); + }).length > 0; + } catch (e) { + return false; + } + }, + _isQuizTaskPage(taskText = this._getCurrentTaskText()) { + const quizText = '\u7ae0\u8282\u6d4b\u9a8c'; + if (taskText.includes(quizText)) return true; + try { + const pageText = this._normalizeText(document.body?.innerText || ''); + return pageText.includes(quizText) + || (pageText.includes('\u9898\u91cf') && (pageText.includes('\u5355\u9009\u9898') || pageText.includes('\u591a\u9009\u9898'))); + } catch (e) { + return false; + } + }, + _isReadingTaskPage(taskText = this._getCurrentTaskText()) { + if (this._hasVideoTaskSignal() || this._isQuizTaskPage(taskText)) { + return false; + } + const labels = [ + '\u6559\u6750', + '\u9605\u8bfb', + '\u6587\u6863', + '\u56fe\u6587', + '\u8d44\u6599', + ]; + if (labels.some((label) => taskText.includes(label))) { + return true; + } + try { + return $('iframe,[class*="reader"],[class*="document"],[class*="book"],[class*="pdf"]').filter((_, el) => { + const attrs = [ + el.className, + el.id, + el.title, + el.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('reader') || attrs.includes('document') || attrs.includes('book') || attrs.includes('pdf'); + }).length > 0; + } catch (e) { + return false; + } + }, + _getTaskKind() { + const taskText = this._getCurrentTaskText(); + if (this._hasVideoTaskSignal()) return 'video'; + if (this._isQuizTaskPage(taskText)) return 'quiz'; + if (this._isReadingTaskPage(taskText)) return 'reading'; + return 'unknown'; + }, + _frameTextIncludes(win, needle, depth = 0) { + if (depth > 4) return false; + try { + const doc = win.document; + if ((doc.body?.innerText || '').includes(needle)) return true; + const frames = Array.from(doc.querySelectorAll('iframe')); + return frames.some((frame) => { + try { + return frame.contentWindow && this._frameTextIncludes(frame.contentWindow, needle, depth + 1); + } catch (e) { + return false; + } + }); + } catch (e) { + return false; + } + }, + _isCurrentTaskPointComplete() { + return this._frameTextIncludes(window, '\u4efb\u52a1\u70b9\u5df2\u5b8c\u6210'); + }, + _isCompleteTaskText(text) { + const normalized = this._normalizeText(text); + const completeTexts = [ + '\u4efb\u52a1\u70b9\u5df2\u5b8c\u6210', + '\u5df2\u5b8c\u6210', + ]; + const incompleteTexts = [ + '\u5f85\u5b8c\u6210', + '\u672a\u5b8c\u6210', + '\u672a\u5b66\u4e60', + '\u8fdb\u884c\u4e2d', + ]; + return completeTexts.some((value) => normalized.includes(value)) + && !incompleteTexts.some((value) => normalized.includes(value)); + }, + _isCompleteTaskClass(value) { + const className = String(value || '').toLowerCase(); + return /(ans-)?job-?(finished|finish|done|complete|completed)|finished|complete|completed|done/.test(className) + && !/(unfinished|incomplete|uncomplete|doing|todo|wait|waiting)/.test(className); + }, + _elementHasSingleVideoFrame(el) { + if (!el) return false; + try { + return $(el).find('iframe').filter((_, frame) => { + const attrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('ans-insertvideo') || attrs.includes('insertvideo') || attrs.includes('video'); + }).length <= 1; + } catch (e) { + return false; + } + }, + _isVideoFrameTaskComplete(frame) { + if (!frame) return false; + const collectText = (el) => { + try { + return `${el.className || ''} ${el.title || ''} ${el.getAttribute?.('aria-label') || ''} ${$(el).text() || ''}`; + } catch (e) { + return ''; + } + }; + const checkElement = (el) => { + if (!el) return false; + const text = collectText(el); + return this._isCompleteTaskText(text) || this._isCompleteTaskClass(text); + }; + + try { + const docText = frame.contentWindow?.document?.body?.innerText || ''; + if (this._isCompleteTaskText(docText)) return true; + } catch (e) {} + + const candidates = []; + let current = frame; + for (let depth = 0; current && depth < 6; depth++) { + candidates.push(current); + current = current.parentElement; + } + + for (const candidate of candidates) { + if (candidate !== frame && !this._elementHasSingleVideoFrame(candidate)) continue; + if (checkElement(candidate)) return true; + try { + const siblings = []; + if (candidate.previousElementSibling) siblings.push(candidate.previousElementSibling); + if (candidate.nextElementSibling) siblings.push(candidate.nextElementSibling); + if (siblings.some((sibling) => checkElement(sibling))) return true; + } catch (e) {} + } + return false; + }, + _getNextPendingVideoTaskIndex(frames, startIndex = 0) { + for (let i = Math.max(0, startIndex); i < frames.length; i++) { + if (!this._isVideoFrameTaskComplete(frames.get(i))) { + return i; + } + console.log(`[Xuexitong Script 1x] video task ${i + 1}/${frames.length} already complete, skipping`); + } + return -1; + }, + _areAllVideoTasksComplete(frames) { + return frames.length > 0 && this._getNextPendingVideoTaskIndex(frames, 0) === -1; + }, + _getReadingScrollTargets() { + const targets = []; + const seen = new Set(); + const addElement = (el) => { + if (!el || seen.has(el)) return; + const max = Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0)); + if (max < 40) return; + let rect = { width: 1, height: 1 }; + try { + rect = el.getBoundingClientRect(); + } catch (e) {} + if (rect.width === 0 && rect.height === 0 && el !== document.body && el !== document.documentElement) return; + seen.add(el); + targets.push({ + getTop: () => Number(el.scrollTop || 0), + setTop: (value) => { + el.scrollTop = value; + el.dispatchEvent(new Event('scroll', { bubbles: true })); + el.dispatchEvent(new WheelEvent('wheel', { deltaY: this.configs.readingScrollStepPx, bubbles: true })); + }, + getMax: () => Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0)), + }); + }; + const collect = (win, depth = 0) => { + if (depth > 4) return; + try { + const doc = win.document; + addElement(doc.scrollingElement || doc.documentElement || doc.body); + Array.from(doc.querySelectorAll('div,main,section,article,body')).forEach(addElement); + Array.from(doc.querySelectorAll('iframe')).forEach((frame) => { + try { + if (frame.contentWindow) collect(frame.contentWindow, depth + 1); + } catch (e) {} + }); + } catch (e) {} + }; + collect(window); + return targets; + }, + _stopReadingScroll() { + if (this._readingScrollTimer) { + clearInterval(this._readingScrollTimer); + this._readingScrollTimer = null; + } + this._readingActive = false; + this._readingBottomSince = 0; + this._readingRounds = 0; + }, + _finishReadingTask(reason) { + console.log(`[Xuexitong Script 1x] reading task finished (${reason}), moving to next unit`); + this._stopReadingScroll(); + setTimeout(() => this.nextUnit(), 1000); + }, + _readingScrollTick() { + if (this._handleTaskPointDialog('reading')) return; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('task-complete'); + return; + } + + const targets = this._getReadingScrollTargets(); + let allBottom = targets.length > 0; + targets.forEach((target) => { + const max = target.getMax(); + const nextTop = Math.min(max, target.getTop() + this.configs.readingScrollStepPx); + target.setTop(nextTop); + if (nextTop < max - 5) allBottom = false; + }); + + if (!targets.length) { + window.scrollBy(0, this.configs.readingScrollStepPx); + allBottom = false; + } + + this._readingRounds++; + if (allBottom) { + if (!this._readingBottomSince) this._readingBottomSince = Date.now(); + } else { + this._readingBottomSince = 0; + } + + const bottomWaitMs = this._readingBottomSince ? Date.now() - this._readingBottomSince : 0; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('task-complete-after-scroll'); + } else if (bottomWaitMs >= this.configs.readingBottomGraceMs || this._readingRounds >= this.configs.readingMaxRounds) { + console.log('[Xuexitong Script 1x] reading bottom reached, waiting for task completion'); + this._readingRounds = 0; + this._readingBottomSince = Date.now(); + } + }, + _startReadingScroll() { + if (this._readingActive) return true; + this._readingActive = true; + this._readingBottomSince = 0; + this._readingRounds = 0; + console.log('[Xuexitong Script 1x] reading task detected, scrolling document'); + this._readingScrollTick(); + this._readingScrollTimer = setInterval(() => { + this._readingScrollTick(); + }, this.configs.readingScrollInterval); + return true; + }, + _handleReadingTask() { + if (this._getTaskKind() !== 'reading') return false; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('already-complete'); + return true; + } + return this._startReadingScroll(); + }, + _startVideoMonitoring() { + this._clearCheckInterval(); + this._guardLastTime = 0; + this._guardLastWallTs = 0; + this._guardLastResumeTs = 0; + this._checkInterval = setInterval(() => { + this._checkVideoStatus(); + }, this.configs.videoCheckInterval); + }, + _tryResumePlayback(reason) { + const now = Date.now(); + if (now - this._guardLastResumeTs < this.configs.guardResumeCooldownMs) { + return; + } + this._guardLastResumeTs = now; + + const video = this._getVideoEl(); + if (!video || !this._isPlaying) return; + + console.log(`%c触发视频保活恢复(${reason})`, 'color:#607D8B'); + video.play().catch((e) => { + console.warn('直接恢复播放失败,尝试静音恢复:', e); + video.muted = true; + video.play().catch((err) => { + console.error('静音恢复播放失败:', err); + }); + }); + }, + _applyPlaybackRate(video) { + if (!video) return; + const targetRate = Number(this.configs.playbackRate || 1); + if (!Number.isFinite(targetRate) || targetRate <= 0) return; + const currentRate = Number(video.playbackRate || 0); + if (Math.abs(currentRate - targetRate) > 0.01) { + video.playbackRate = targetRate; + console.log(`[Xuexitong Script 1x] playbackRate locked to ${targetRate}x`); + } + }, + _checkVideoStatus() { + try { + const video = this._getVideoEl(); + if (!video) return; + this._applyPlaybackRate(video); + + if (video.paused && this._isPlaying) { + console.log('%c检测到视频暂停,尝试恢复播放...', 'color:#FF5722'); + this._tryResumePlayback('paused'); + } else if (this._isPlaying && !video.ended) { + const now = Date.now(); + const current = Number(video.currentTime || 0); + if (this._guardLastWallTs === 0) { + this._guardLastWallTs = now; + this._guardLastTime = current; + } else { + const stalled = Math.abs(current - this._guardLastTime) < 0.01; + const stalledMs = now - this._guardLastWallTs; + if (stalled && stalledMs >= this.configs.guardNoProgressMs) { + this._tryResumePlayback('no-progress'); + this._guardLastWallTs = now; + this._guardLastTime = Number(video.currentTime || 0); + } else if (!stalled) { + this._guardLastWallTs = now; + this._guardLastTime = current; + } + } + } + + if (video.ended && this._isPlaying) { + console.log('%c检测到视频结束,准备切换下一个...', 'color:#9C27B0'); + this._handleVideoTaskEnded(); + } + } catch (e) { + console.error('视频状态检查失败:', e); + } + }, + _tryTimes: 0, + _stepAdvanceTimes: 0, + _stepSwitchAt: 0, + _stepSwitchPending: false, + _delayedNextUnitTimer: null, + _guardLastTime: 0, + _guardLastWallTs: 0, + _guardLastResumeTs: 0, + async play() { + try { + if (this._handleTaskPointDialog('before-play')) { + setTimeout(() => this.play(), 1500); + return; + } + const videoFrames = this._getVideoFrames(); + if (this._areAllVideoTasksComplete(videoFrames)) { + this._stopReadingScroll(); + this._resetVideoTaskState(); + console.log('[Xuexitong Script 1x] all video tasks in this section are complete, moving to next unit'); + setTimeout(() => this.nextUnit(), 800); + return; + } + const el = this._getVideoEl(); + if (el == null) { + const taskKind = this._getTaskKind(); + if (taskKind === 'video') { + this._stopReadingScroll(); + console.log('[Xuexitong Script 1x] video task detected but video element is still loading'); + setTimeout(() => this.play(), this.configs.videoPendingRetryMs); + return; + } + if (this._handleReadingTask()) { + return; + } + if (taskKind === 'quiz') { + console.log('[Xuexitong Script 1x] chapter quiz detected, trying to skip'); + this._dispatchClick($('#prevNextFocusNext').get(0)); + setTimeout(() => { + if (this._handleTaskPointDialog('after-quiz-next')) { + setTimeout(() => this.play(), 1500); + return; + } + this.play(); + }, 800); + return; + } + if (this._advanceLearningStep()) { + console.log('%c当前不在视频页,已尝试切到下一学习步骤,2秒后重试', 'color:#607D8B'); + setTimeout(() => { + this.play(); + }, 2000); + return; + } + console.log('%c===========跳过章节测验,2秒后继续播放==============', 'color:#607D8B'); + this._dispatchClick($('#prevNextFocusNext').get(0)); + setTimeout(() => { + if (this._handleTaskPointDialog('after-next-control')) { + setTimeout(() => this.play(), 1500); + return; + } + this.play(); + }, 800); + return; + } + + this._tryTimes = 0; + this._isPlaying = true; + this._videoEventHandle(); + this._applyPlaybackRate(el); + + try { + await el.play(); + this._applyPlaybackRate(el); + console.log(`%c视频开始播放,倍速: ${el.playbackRate}x`, 'color:#4CAF50'); + this._startVideoMonitoring(); + } catch (playError) { + console.error('视频播放失败:', playError); + this._handlePlayError(playError); + } + } catch (e) { + if (this._tryTimes > this.configs.maxRetries) { + console.error('%c视频播放失败,已达到最大重试次数', 'color:#F44336;font-weight:bold', e); + this._clearCheckInterval(); + return; + } + this._tryTimes++; + console.log(`%c播放失败,${this.configs.retryInterval / 1000}秒后重试 (${this._tryTimes}/${this.configs.maxRetries})`, 'color:#FF9800'); + setTimeout(() => { + this.play(); + }, this.configs.retryInterval); + } + }, + _advanceLearningStep() { + if (this._stepSwitchPending && Date.now() - this._stepSwitchAt < 4000) { + return true; + } + + const currentTaskText = this._getCurrentTaskText(); + const quizText = '\u7ae0\u8282\u6d4b\u9a8c'; + const videoText = '\u89c6\u9891'; + + if (currentTaskText.includes(quizText) || currentTaskText.includes(videoText) || this._hasVideoTaskSignal()) { + return false; + } + + const clickElement = (el, label) => { + if (!el) return false; + this._stepSwitchPending = true; + this._stepSwitchAt = Date.now(); + console.log(`%c尝试点击${label}`, 'color:#2196F3'); + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return true; + }; + + const videoTab = $('.prev_white:visible').filter((_, el) => { + const text = this._normalizeText($(el).text()); + return text === `2${videoText}` || text === videoText || text.includes(videoText); + }).get(0); + if (clickElement(videoTab, '“视频”页签')) { + return true; + } + + return false; + }, + _bindStepNavigation() { + if (this._stepNavigationBound) { + return; + } + this._stepNavigationBound = true; + + const reenterVideoMode = () => { + this._resetVideoTaskState(); + this._isPlaying = false; + this._stepSwitchPending = true; + this._stepSwitchAt = Date.now(); + setTimeout(() => { + try { + this._initCellData(); + } catch (e) {} + this.play(); + }, 1800); + }; + + $(document).on('click', '.prev_white', (e) => { + const text = ($(e.currentTarget).text() || '').replace(/\s+/g, ''); + if (text.includes('视频')) { + console.log(`%c检测到步骤切换点击:${text},准备重新接管视频页`, 'color:#607D8B'); + reenterVideoMode(); + } + }); + }, + _handlePlayError(error) { + console.error('播放错误详情:', error); + const video = this._getVideoEl(); + if (video) { + video.muted = true; + video.play().then(() => { + console.log('%c静音播放成功', 'color:#4CAF50'); + if (this._delayedNextUnitTimer) { + clearTimeout(this._delayedNextUnitTimer); + this._delayedNextUnitTimer = null; + } + }).catch((e) => { + console.error('静音播放也失败,将继续等待视频而不是切换下一节:', e); + if (this._tryTimes < this.configs.maxRetries) { + this._tryTimes++; + setTimeout(() => this.play(), this.configs.retryInterval); + } + }); + } + }, + playCurrentIndex(nCell) { + if (!nCell) { + const el = this._getTreeContainer(); + const cells = el.children('ul').children('li'); + const nCells = $(cells.get(this._cellData.currentCellIndex)).find('.posCatalog_select:not(.firstLayer)'); + nCell = nCells.get(this._cellData.currentNCellIndex); + } + + const $nCell = $(nCell); + const clickableSpan = $nCell.find('.posCatalog_name')[0]; + if (!clickableSpan) { + console.error('%c===========找不到可点击的课程节点,播放下一个视频失败==============', 'color:#F44336'); + setTimeout(() => this.nextUnit(), 2000); + return; + } + + console.log(`%c点击切换到: ${$(clickableSpan).attr('title') || '未知标题'}`, 'color:#2196F3'); + $(clickableSpan).click(); + this._resetVideoTaskState(); + this._isPlaying = false; + + console.log('%c等待视频加载...', 'color:#FF9800'); + setTimeout(() => { + this._initCellData(); + if (this.configs.autoplay) { + this.play(); + } + }, 3000); + }, + _initCellData() { + const el = this._getTreeContainer(); + const cells = el.children('ul').children('li'); + this._cellData.cells = cells.length; + let nCellCounts = 0; + let foundCurrent = false; + + cells.each((i, v) => { + const nCells = $(v).find('.posCatalog_select:not(.firstLayer)'); + nCellCounts += nCells.length; + nCells.each((j, e) => { + const _el = $(e); + if (_el.hasClass('posCatalog_active')) { + this._cellData.currentCellIndex = i; + this._cellData.currentNCellIndex = j; + foundCurrent = true; + const titleSpan = _el.find('.posCatalog_name')[0]; + if (titleSpan) { + this._cellData.currentVideoTitle = $(titleSpan).attr('title'); + } + } + }); + }); + + this._cellData.nCells = nCellCounts; + + if (!foundCurrent && nCellCounts > 0) { + console.warn('%c未找到当前激活的视频节点,可能需要手动选择', 'color:#FF9800'); + } + + console.log(`%c课程信息: ${this._cellData.cells}章, ${this._cellData.nCells}节, 当前: 第${this._cellData.currentCellIndex + 1}章第${this._cellData.currentNCellIndex + 1}节`, 'color:#607D8B'); + }, + _getTreeContainer() { + if (!this._treeContainerEl) { + const el = $('#coursetree'); + if (el.length <= 0) { + throw new Error('找不到视频列表'); + } + this._treeContainerEl = el; + } + return this._treeContainerEl; + }, + _findVideoFramesInWindow(win, depth = 0, result = []) { + if (depth > 4) return result; + try { + const doc = win.document; + const frames = Array.from(doc.querySelectorAll('iframe')); + frames.forEach((frame) => { + const frameAttrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + if (frameAttrs.includes('ans-insertvideo-online') || frameAttrs.includes('insertvideo') || frameAttrs.includes('video')) { + result.push(frame); + } + try { + if (frame.contentWindow) { + this._findVideoFramesInWindow(frame.contentWindow, depth + 1, result); + } + } catch (e) { + if (e && e.name === 'SecurityError') { + return; + } + console.warn('[Xuexitong Script 1x] iframe scan skipped:', e); + } + }); + } catch (e) { + if (e && e.name === 'SecurityError') { + return result; + } + console.warn('[Xuexitong Script 1x] video frame scan failed:', e); + } + return result; + }, + _getVideoFrames() { + return $(this._findVideoFramesInWindow(window)); + }, + _getVideoEl() { + if (!this._videoEl) { + try { + const frameObj = this._getVideoFrames(); + this._videoTaskCount = frameObj.length; + if (frameObj.length === 0) { + return null; + } + if (this._currentVideoTaskIndex >= frameObj.length) { + this._currentVideoTaskIndex = frameObj.length - 1; + } + if (this._currentVideoTaskIndex < 0) { + this._currentVideoTaskIndex = 0; + } + const pendingIndex = this._getNextPendingVideoTaskIndex(frameObj, this._currentVideoTaskIndex); + if (pendingIndex < 0) { + this._currentVideoTaskIndex = frameObj.length; + return null; + } + if (pendingIndex !== this._currentVideoTaskIndex) { + this._detachVideoEventHandlers(); + this._videoEl = null; + this._currentVideoTaskIndex = pendingIndex; + } + const findVideo = (frame) => { + try { + return $(frame).contents().find('video#video_html5_api,video').get(0) || null; + } catch (e) { + if (e && e.name === 'SecurityError') return null; + console.warn('[Xuexitong Script 1x] video frame not ready:', e); + return null; + } + }; + for (let i = this._currentVideoTaskIndex; i < frameObj.length; i++) { + const video = findVideo(frameObj.get(i)); + if (video) { + this._currentVideoTaskIndex = i; + this._videoEl = video; + break; + } + } + } catch (e) { + console.error('获取视频元素失败:', e); + return null; + } + } + return this._videoEl || null; + }, + _handleVideoTaskEnded() { + if (this._handlingVideoEnd) return; + this._handlingVideoEnd = true; + this._isPlaying = false; + this._clearCheckInterval(); + + const frames = this._getVideoFrames(); + this._videoTaskCount = frames.length; + const nextPendingIndex = this._getNextPendingVideoTaskIndex(frames, this._currentVideoTaskIndex + 1); + if (nextPendingIndex >= 0) { + this._currentVideoTaskIndex = nextPendingIndex; + this._detachVideoEventHandlers(); + this._videoEl = null; + console.log(`[Xuexitong Script 1x] switching to video task ${this._currentVideoTaskIndex + 1}/${frames.length}`); + setTimeout(() => { + this._handlingVideoEnd = false; + this.play(); + }, 800); + return; + } + + setTimeout(() => { + this._handlingVideoEnd = false; + this.nextUnit(); + }, 1000); + }, + _detachVideoEventHandlers() { + if (!this._boundVideoEl || !this._boundVideoHandlers) return; + Object.entries(this._boundVideoHandlers).forEach(([eventName, handler]) => { + this._boundVideoEl.removeEventListener(eventName, handler); + }); + this._boundVideoEl = null; + this._boundVideoHandlers = null; + }, + _videoEventHandle() { + const el = this._videoEl; + if (!el) { + console.log('videoEl未加载'); + return; + } + + if (this._boundVideoEl === el) return; + this._detachVideoEventHandlers(); + this._boundVideoEl = el; + this._boundVideoHandlers = { + ended: this._handleVideoEnded.bind(this), + loadedmetadata: this._handleVideoLoaded.bind(this), + play: this._handleVideoPlay.bind(this), + pause: this._handleVideoPause.bind(this), + ratechange: this._handleVideoRateChange.bind(this), + }; + Object.entries(this._boundVideoHandlers).forEach(([eventName, handler]) => { + el.addEventListener(eventName, handler); + }); + }, + _handleVideoEnded(e) { + const title = this._cellData.currentVideoTitle; + console.warn(`%c============'${title}' 播放完成=============`, 'color:#4CAF50;font-weight:bold'); + this._handleVideoTaskEnded(); + }, + _handleVideoLoaded(e) { + console.log('%c============视频加载完成=============', 'color:#2196F3'); + this._applyPlaybackRate(e.currentTarget || this._getVideoEl()); + if (this.configs.autoplay && !this._isPlaying) { + this.play(); + } + }, + _handleVideoPlay(e) { + const title = this._cellData.currentVideoTitle; + console.info(`%c============'${title}' 开始播放=============`, 'color:#4CAF50'); + this._isPlaying = true; + this._stepSwitchPending = false; + const video = this._getVideoEl(); + this._applyPlaybackRate(video); + this._guardLastTime = Number(video?.currentTime || 0); + this._guardLastWallTs = Date.now(); + if (this._delayedNextUnitTimer) { + clearTimeout(this._delayedNextUnitTimer); + this._delayedNextUnitTimer = null; + } + }, + _handleVideoRateChange(e) { + this._applyPlaybackRate(e.currentTarget || this._getVideoEl()); + }, + _handleVideoPause(e) { + console.log('%c============视频暂停=============', 'color:#FF9800'); + }, + }; + + try { + window.app.run(); + + const preventPause = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + const resumePlaybackNow = () => { + if (window.app && typeof window.app._tryResumePlayback === 'function') { + window.app._tryResumePlayback('page-event'); + } + }; + + document.addEventListener('mouseleave', preventPause); + window.addEventListener('mouseleave', preventPause); + document.addEventListener('mouseout', preventPause); + window.addEventListener('mouseout', preventPause); + + window.addEventListener('blur', () => { + console.log('%c页面失去焦点,保持播放状态', 'color:#607D8B'); + resumePlaybackNow(); + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + console.log('%c页面切到后台,尝试保持播放状态', 'color:#607D8B'); + } + resumePlaybackNow(); + }); + } catch (error) { + console.error('%c脚本运行失败: ', 'color:#F44336;font-weight:bold', error.message); + console.log('请检查是否在正确的课程播放页面,或者页面结构是否再次发生改变。'); + } + } +})(); diff --git a/tests/verify-dialog-throttle.js b/tests/verify-dialog-throttle.js new file mode 100644 index 0000000..37b5bdb --- /dev/null +++ b/tests/verify-dialog-throttle.js @@ -0,0 +1,19 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(manifest.version === "3.2.0.9", "extension version must be bumped for dialog throttling"); +assert(script.includes("_lastTaskPointDialogClickAt"), "dialog handler must track last click time"); +assert(script.includes("_isLikelyVisibleDialog"), "dialog handler must filter to likely modal layers"); +assert(script.includes("taskDialogClickCooldownMs"), "dialog handler must use a click cooldown"); +assert(!script.includes("$('body *:visible').filter"), "dialog handler must not scan every visible body descendant"); +assert(script.includes("Math.max") && script.includes("zIndex"), "dialog filtering must consider stacking context"); + +console.log("dialog throttle checks passed"); diff --git a/tests/verify-flow-fixes.js b/tests/verify-flow-fixes.js new file mode 100644 index 0000000..4c8af9f --- /dev/null +++ b/tests/verify-flow-fixes.js @@ -0,0 +1,22 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(script.includes("_currentVideoTaskIndex"), "script must track the current video task within a lesson"); +assert(script.includes("_getVideoFrames"), "script must enumerate all video task iframes"); +assert(script.includes("_handleVideoTaskEnded"), "script must route video completion through multi-video handling"); +assert(/_getNextPendingVideoTaskIndex\(frames,\s*this\._currentVideoTaskIndex \+ 1\)/.test(script), "script must advance to the next unfinished video before nextUnit"); +assert(script.includes("_handleTaskPointDialog"), "script must handle unfinished-task-point dialogs"); +assert(script.includes("\\u5f53\\u524d\\u7ae0\\u8282\\u8fd8\\u6709\\u4efb\\u52a1\\u70b9\\u672a\\u5b8c\\u6210"), "dialog handler must match the unfinished-task-point text"); +assert(script.includes("\\u53bb\\u5b66\\u4e60"), "dialog handler must choose the go-study button"); +assert(script.includes("window.confirm"), "script must guard native confirm dialogs with the same prompt"); +assert(manifest.version === "3.2.0.9", "extension version must be bumped for the flow fix"); + +console.log("flow fix checks passed"); diff --git a/tests/verify-jquery-compat.js b/tests/verify-jquery-compat.js new file mode 100644 index 0000000..9d7adf2 --- /dev/null +++ b/tests/verify-jquery-compat.js @@ -0,0 +1,17 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(manifest.version === "3.2.0.9", "extension version must be bumped for the jQuery compatibility fix"); +assert(!script.includes(".addBack("), "script must not use jQuery.addBack because Xuexitong ships an older jQuery"); +assert(script.includes("_getDialogButtonCandidates"), "dialog button matching must use a compatibility helper"); +assert(script.includes("try {") && script.includes("dialog handling failed"), "dialog handling must fail closed instead of breaking playback"); + +console.log("jquery compatibility checks passed"); diff --git a/tests/verify-no-skip-and-frame-guard.js b/tests/verify-no-skip-and-frame-guard.js new file mode 100644 index 0000000..5af3a26 --- /dev/null +++ b/tests/verify-no-skip-and-frame-guard.js @@ -0,0 +1,21 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(manifest.version === "3.2.0.9", "extension version must be bumped for no-skip/frame-guard fix"); +assert(script.includes("\\u53bb\\u5b66\\u4e60"), "unfinished-task dialog must target the go-study button"); +assert(script.includes("\\u53bb\\u5b8c\\u6210"), "unfinished-task dialog must also target go-complete wording"); +assert(!script.includes("closing unfinished-task dialog via next section"), "unfinished-task dialog must not click next section"); +assert(script.includes("return true;") && script.includes("native unfinished-task confirm handled as go study"), "native confirm must choose go-study instead of next-section"); +assert(script.includes("_findVideoFramesInWindow"), "video frame discovery must recurse safely"); +assert(script.includes("SecurityError") && script.includes("return;"), "cross-origin iframe access must be ignored without console errors"); +assert(!script.includes("get video frames failed:"), "expected cross-origin iframe access must not be logged as a hard error"); + +console.log("no-skip and frame-guard checks passed"); diff --git a/tests/verify-reading-task.js b/tests/verify-reading-task.js new file mode 100644 index 0000000..f615d13 --- /dev/null +++ b/tests/verify-reading-task.js @@ -0,0 +1,24 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(manifest.version === "3.2.0.9", "extension version must be bumped for reading-task automation"); +assert(script.includes("readingScrollInterval"), "reading automation must have an interval config"); +assert(script.includes("_handleReadingTask"), "script must route non-video reading tasks before skipping"); +assert(script.includes("_startReadingScroll"), "script must start a gradual reading scroll loop"); +assert(script.includes("_getReadingScrollTargets"), "script must find scrollable document targets"); +assert(script.includes("_isCurrentTaskPointComplete"), "script must detect task-point completion before nextUnit"); +assert(script.includes("\\u4efb\\u52a1\\u70b9\\u5df2\\u5b8c\\u6210"), "completion detection must match task-point-complete text"); +assert(script.includes("\\u6559\\u6750") && script.includes("\\u9605\\u8bfb") && script.includes("\\u6587\\u6863"), "reading task detection must include textbook/reading/document labels"); +assert(/_handleReadingTask\(\)[\s\S]*?_advanceLearningStep\(\)/.test(script), "play() must handle reading before switching steps"); +assert(/Math\.min\(max,\s*target\.getTop\(\)\s*\+\s*this\.configs\.readingScrollStepPx\)/.test(script), "reading loop must gradually move scrollTop down"); +assert(/this\.nextUnit\(\)/.test(script.slice(script.indexOf("_isCurrentTaskPointComplete"))), "completion path must continue to next unit"); + +console.log("reading task checks passed"); diff --git a/tests/verify-speed-lock.js b/tests/verify-speed-lock.js new file mode 100644 index 0000000..697b3eb --- /dev/null +++ b/tests/verify-speed-lock.js @@ -0,0 +1,20 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +assert(/playbackRate:\s*1\b/.test(script), "default playbackRate must be 1"); +assert(script.includes("_applyPlaybackRate"), "script must centralize playback-rate enforcement"); +assert(script.includes("ratechange"), "script must listen for player rate changes"); +assert(/_applyPlaybackRate\(video\)/.test(script), "monitoring loop must re-apply the configured playback rate"); +assert(manifest.version === "3.2.0.9", "extension version must be bumped so Chrome shows the updated build"); + +console.log("speed lock checks passed"); diff --git a/tests/verify-task-classification.js b/tests/verify-task-classification.js new file mode 100644 index 0000000..7fd199d --- /dev/null +++ b/tests/verify-task-classification.js @@ -0,0 +1,20 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(manifest.version === "3.2.0.9", "extension version must be bumped for task classification fixes"); +assert(script.includes("_isQuizTaskPage"), "script must explicitly detect chapter quiz pages"); +assert(script.includes("_getTaskKind"), "script must classify task pages before choosing video/reading/quiz behavior"); +assert(script.includes("_hasVideoTaskSignal"), "script must treat video task markers as stronger evidence than reading labels"); +assert(/_handleReadingTask\(\)[\s\S]*?_getTaskKind\(\)[\s\S]*?!== 'reading'/.test(script), "reading automation must only run for pages classified as reading"); +assert(/_handleTaskPointDialog\(reason\)[\s\S]*?_isQuizTaskPage\(\)[\s\S]*?return true;/.test(script), "quiz unfinished-task dialogs must be consumed without clicking go-study repeatedly"); +assert(!/if \(currentStepTitle === '.*'\s*\|\|\s*currentStepTitle === '.*'\)/.test(script), "task detection must not depend on mojibake literal titles"); + +console.log("task classification checks passed"); diff --git a/tests/verify-video-completion-skip.js b/tests/verify-video-completion-skip.js new file mode 100644 index 0000000..4bc5c72 --- /dev/null +++ b/tests/verify-video-completion-skip.js @@ -0,0 +1,22 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const script = fs.readFileSync(path.join(root, "chrome-extension", "v3_optimized.user.js"), "utf8"); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "chrome-extension", "manifest.json"), "utf8")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +assert(manifest.version === "3.2.0.9", "extension version must be bumped for video completion skip"); +assert(script.includes("_isVideoFrameTaskComplete"), "script must detect completion for an individual video frame"); +assert(script.includes("_getNextPendingVideoTaskIndex"), "script must find the next unfinished video task"); +assert(script.includes("_areAllVideoTasksComplete"), "script must detect when all video tasks in the section are complete"); +assert(/_getVideoEl\(\)[\s\S]*?_getNextPendingVideoTaskIndex\(frameObj,\s*this\._currentVideoTaskIndex\)/.test(script), "video lookup must skip completed frames before selecting a video element"); +assert(/play\(\)[\s\S]*?const videoFrames = this\._getVideoFrames\(\);[\s\S]*?_areAllVideoTasksComplete\(videoFrames\)[\s\S]*?this\.nextUnit\(\)/.test(script), "play() must go to the next section when all video tasks are already complete"); +assert(/_handleVideoTaskEnded\(\)[\s\S]*?_getNextPendingVideoTaskIndex\(frames,\s*this\._currentVideoTaskIndex \+ 1\)/.test(script), "video-ended flow must skip completed remaining video tasks"); +assert(script.includes("\\u4efb\\u52a1\\u70b9\\u5df2\\u5b8c\\u6210"), "video completion detection must include task-point complete text"); +assert(script.includes("\\u5f85\\u5b8c\\u6210") && script.includes("\\u672a\\u5b8c\\u6210"), "video completion detection must avoid skipping incomplete task markers"); + +console.log("video completion skip checks passed"); diff --git a/v2.js b/v2.js index 77f7bcb..136c2e9 100644 --- a/v2.js +++ b/v2.js @@ -16,7 +16,7 @@ function initializePlayer() { window.app = { configs: { - playbackRate: 2, /// 倍数(某些平台高倍数可能导致视频暂停,2倍是比较稳妥的速率) + playbackRate: 1, /// 倍数(默认 1 倍播放) autoplay: true, /// 自动播放 }, _videoEl: null, diff --git a/v3_optimized.js b/v3_optimized.js index 1d9ae09..f2f78cc 100644 --- a/v3_optimized.js +++ b/v3_optimized.js @@ -4,7 +4,7 @@ script.src = 'https://code.jquery.com/jquery-3.6.0.min.js'; script.type = 'text/javascript'; script.onload = function () { - console.log("jQuery loaded."); + console.log('jQuery loaded.'); initializePlayer(); }; document.head.appendChild(script); @@ -15,11 +15,18 @@ function initializePlayer() { window.app = { configs: { - playbackRate: 1.5, + playbackRate: 1, autoplay: true, retryInterval: 2000, maxRetries: 10, videoCheckInterval: 1000, + dialogCheckInterval: 1000, + taskDialogClickCooldownMs: 8000, + readingScrollInterval: 800, + readingScrollStepPx: 420, + readingBottomGraceMs: 5000, + readingMaxRounds: 90, + videoPendingRetryMs: 1200, guardNoProgressMs: 7000, guardResumeCooldownMs: 1500, }, @@ -28,46 +35,62 @@ _isPlaying: false, _currentRetryCount: 0, _checkInterval: null, + _dialogCheckInterval: null, + _currentVideoTaskIndex: 0, + _videoTaskCount: 0, + _handlingVideoEnd: false, + _boundVideoEl: null, + _boundVideoHandlers: null, + _confirmGuardInstalled: false, + _lastTaskPointDialogClickAt: 0, + _readingActive: false, + _readingScrollTimer: null, + _readingBottomSince: 0, + _readingRounds: 0, _cellData: { cells: 0, nCells: 0, currentCellIndex: 0, currentNCellIndex: 0, - currentVideoTitle: "", + currentVideoTitle: '', }, get cellData() { return this._cellData; }, run() { - console.log("%c=== 学习通自动刷脚脚本 V3 优化版启动 ===", "color:#4CAF50;font-size:16px;font-weight:bold"); + console.log('%c=== 学习通自动刷课脚本 V3 稳定版启动 ===', 'color:#4CAF50;font-size:16px;font-weight:bold'); this._getTreeContainer(); this._initCellData(); - this._videoEl = null; + this._resetVideoTaskState(); this._getVideoEl(); this._clearCheckInterval(); + this._installConfirmGuard(); + this._startDialogMonitoring(); this._bindStepNavigation(); this.play(); }, nextUnit() { - console.log("%c=== 准备切换到下一小节 ===", "color:#2196F3;font-size:14px"); + this._stopReadingScroll(); + console.log('%c=== 准备切换到下一小节 ===', 'color:#2196F3;font-size:14px'); const el = this._getTreeContainer(); - const cells = el.children("ul").children("li"); + const cells = el.children('ul').children('li'); const nCells = $(cells.get(this._cellData.currentCellIndex)).find('.posCatalog_select:not(.firstLayer)'); if (nCells.length > this._cellData.currentNCellIndex + 1) { const nextNIndex = this._cellData.currentNCellIndex + 1; - console.log(`%c切换到同章节下一个视频: ${nextNIndex + 1}/${nCells.length}`, "color:#FF9800"); + console.log(`%c切换到同章节下一个视频: ${nextNIndex + 1}/${nCells.length}`, 'color:#FF9800'); this.playCurrentIndex(nCells.get(nextNIndex)); } else { const nextIndex = this._cellData.currentCellIndex + 1; if (nextIndex >= cells.length) { - console.log("%c=====================================", "color:#4CAF50;font-size:16px"); - console.log("%c==============本课程学习完成了==============", "color:#4CAF50;font-size:16px;font-weight:bold"); - console.log("%c=====================================", "color:#4CAF50;font-size:16px"); + console.log('%c=====================================', 'color:#4CAF50;font-size:16px'); + console.log('%c==============本课程学习完成了==============', 'color:#4CAF50;font-size:16px;font-weight:bold'); + console.log('%c=====================================', 'color:#4CAF50;font-size:16px'); this._clearCheckInterval(); + this._clearDialogInterval(); return; } - console.log(`%c切换到下一个章节: ${nextIndex + 1}/${cells.length}`, "color:#FF9800"); + console.log(`%c切换到下一个章节: ${nextIndex + 1}/${cells.length}`, 'color:#FF9800'); this._cellData.currentCellIndex = nextIndex; this._cellData.currentNCellIndex = 0; this.playCurrentIndex(); @@ -79,6 +102,443 @@ this._checkInterval = null; } }, + _clearDialogInterval() { + if (this._dialogCheckInterval) { + clearInterval(this._dialogCheckInterval); + this._dialogCheckInterval = null; + } + }, + _startDialogMonitoring() { + this._clearDialogInterval(); + this._dialogCheckInterval = setInterval(() => { + this._handleTaskPointDialog('monitor'); + }, this.configs.dialogCheckInterval); + }, + _dispatchClick(el) { + if (!el) return false; + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return true; + }, + _normalizeText(value) { + return String(value || '').replace(/\s+/g, ''); + }, + _getDialogButtonCandidates(dialog) { + const elements = []; + if (dialog && dialog.length) { + const root = dialog.get(0); + if (root) elements.push(root); + dialog.find('button,a,span,div').each((_, el) => elements.push(el)); + } + return $(elements); + }, + _isLikelyVisibleDialog(el) { + if (!el) return false; + const $el = $(el); + const text = String($el.text() || ''); + if (text.length > 800) return false; + let rect; + try { + rect = el.getBoundingClientRect(); + } catch (e) { + return false; + } + if (!rect || rect.width < 160 || rect.height < 80) return false; + const style = window.getComputedStyle(el); + const position = style.position; + const zIndex = Number.parseInt(style.zIndex, 10); + const className = String(el.className || '').toLowerCase(); + const role = String(el.getAttribute?.('role') || '').toLowerCase(); + const zScore = Number.isFinite(zIndex) ? zIndex : 0; + const parentZScore = Math.max(0, ...$el.parents().map((_, parent) => { + const parentZ = Number.parseInt(window.getComputedStyle(parent).zIndex, 10); + return Number.isFinite(parentZ) ? parentZ : 0; + }).get()); + return role === 'dialog' + || position === 'fixed' + || zScore >= 10 + || parentZScore >= 10 + || /dialog|modal|layui|layer|wayer|pop/.test(className); + }, + _handleTaskPointDialog(reason) { + try { + const now = Date.now(); + if (now - this._lastTaskPointDialogClickAt < this.configs.taskDialogClickCooldownMs) { + return false; + } + const taskPointText = '\u5f53\u524d\u7ae0\u8282\u8fd8\u6709\u4efb\u52a1\u70b9\u672a\u5b8c\u6210'; + const goStudyText = '\u53bb\u5b66\u4e60'; + const goCompleteText = '\u53bb\u5b8c\u6210'; + const nextSectionText = '\u4e0b\u4e00\u8282'; + const isQuizTask = this._isQuizTaskPage(); + const targetTexts = isQuizTask ? [nextSectionText] : [goStudyText, goCompleteText]; + const normalize = (value) => this._normalizeText(value); + const dialogs = $('[role="dialog"]:visible,.layui-layer:visible,.wayer:visible,.wayer-dialog:visible,.modal:visible,.dialog:visible,[class*="dialog"]:visible,[class*="modal"]:visible,[class*="layer"]:visible,[class*="pop"]:visible').filter((_, el) => { + const text = normalize($(el).text()); + return this._isLikelyVisibleDialog(el) && text.includes(taskPointText) && targetTexts.some((targetText) => text.includes(targetText)); + }); + if (!dialogs.length) return false; + + const dialog = dialogs.last(); + const targetButton = this._getDialogButtonCandidates(dialog).filter((_, el) => { + const text = normalize($(el).text()); + return targetTexts.some((targetText) => { + return text === targetText || (text.includes(targetText) && $(el).children().length === 0); + }); + }).last(); + if (!targetButton.length) return false; + + console.log(`[Xuexitong Script 1x] unfinished-task dialog handled as ${isQuizTask ? 'next section' : 'go study'} (${reason})`); + this._lastTaskPointDialogClickAt = now; + if (isQuizTask) { + this._stepSwitchPending = true; + this._stepSwitchAt = now; + } + return this._dispatchClick(targetButton.get(0)); + } catch (e) { + console.warn('[Xuexitong Script 1x] dialog handling failed:', e); + return false; + } + }, + _installConfirmGuard() { + if (this._confirmGuardInstalled) return; + this._confirmGuardInstalled = true; + const taskPointText = '\u5f53\u524d\u7ae0\u8282\u8fd8\u6709\u4efb\u52a1\u70b9\u672a\u5b8c\u6210'; + const nativeConfirm = window.confirm.bind(window); + window.confirm = (message) => { + if (String(message || '').includes(taskPointText)) { + if (this._isQuizTaskPage()) { + console.log('[Xuexitong Script 1x] native unfinished-task confirm handled as quiz skip'); + return false; + } + console.log('[Xuexitong Script 1x] native unfinished-task confirm handled as go study'); + return true; + } + return nativeConfirm(message); + }; + }, + _resetVideoTaskState() { + this._detachVideoEventHandlers(); + this._videoEl = null; + this._currentVideoTaskIndex = 0; + this._videoTaskCount = 0; + this._handlingVideoEnd = false; + }, + _getCurrentStepTitle() { + const prevTitle = document.getElementsByClassName('prev_title')[0]; + return prevTitle ? (prevTitle.title || prevTitle.textContent || '').trim() : ''; + }, + _getCurrentTaskText() { + const parts = [document.title, this._getCurrentStepTitle()]; + const activeSelectors = [ + '.prev_title', + '.posCatalog_active .posCatalog_name', + '.prev_white.active', + '.prev_white.selected', + '.prev_white.current', + '.prev_white.on', + '.prev_white[aria-selected="true"]', + '[class*="prev"][class*="active"]:visible', + '[class*="prev"][class*="select"]:visible', + '[class*="prev"][class*="current"]:visible', + '[class*="prev"][class*="cur"]:visible', + '[class*="prev"][class*="on"]:visible', + 'li[aria-selected="true"]:visible', + ]; + try { + $(activeSelectors.join(',')).each((_, el) => { + parts.push(el.title || $(el).attr('title') || $(el).text()); + }); + } catch (e) {} + return this._normalizeText(parts.join(' ')); + }, + _hasVideoTaskSignal() { + if (this._videoEl || this._findVideoFramesInWindow(window).length > 0) { + return true; + } + const videoText = '\u89c6\u9891'; + if (this._getCurrentTaskText().includes(videoText)) { + return true; + } + try { + if ($('video').length > 0) return true; + return $('iframe').filter((_, frame) => { + const attrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('video') || attrs.includes('insertvideo') || attrs.includes('ans-insertvideo'); + }).length > 0; + } catch (e) { + return false; + } + }, + _isQuizTaskPage(taskText = this._getCurrentTaskText()) { + const quizText = '\u7ae0\u8282\u6d4b\u9a8c'; + if (taskText.includes(quizText)) return true; + try { + const pageText = this._normalizeText(document.body?.innerText || ''); + return pageText.includes(quizText) + || (pageText.includes('\u9898\u91cf') && (pageText.includes('\u5355\u9009\u9898') || pageText.includes('\u591a\u9009\u9898'))); + } catch (e) { + return false; + } + }, + _isReadingTaskPage(taskText = this._getCurrentTaskText()) { + if (this._hasVideoTaskSignal() || this._isQuizTaskPage(taskText)) { + return false; + } + const labels = [ + '\u6559\u6750', + '\u9605\u8bfb', + '\u6587\u6863', + '\u56fe\u6587', + '\u8d44\u6599', + ]; + if (labels.some((label) => taskText.includes(label))) { + return true; + } + try { + return $('iframe,[class*="reader"],[class*="document"],[class*="book"],[class*="pdf"]').filter((_, el) => { + const attrs = [ + el.className, + el.id, + el.title, + el.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('reader') || attrs.includes('document') || attrs.includes('book') || attrs.includes('pdf'); + }).length > 0; + } catch (e) { + return false; + } + }, + _getTaskKind() { + const taskText = this._getCurrentTaskText(); + if (this._hasVideoTaskSignal()) return 'video'; + if (this._isQuizTaskPage(taskText)) return 'quiz'; + if (this._isReadingTaskPage(taskText)) return 'reading'; + return 'unknown'; + }, + _frameTextIncludes(win, needle, depth = 0) { + if (depth > 4) return false; + try { + const doc = win.document; + if ((doc.body?.innerText || '').includes(needle)) return true; + const frames = Array.from(doc.querySelectorAll('iframe')); + return frames.some((frame) => { + try { + return frame.contentWindow && this._frameTextIncludes(frame.contentWindow, needle, depth + 1); + } catch (e) { + return false; + } + }); + } catch (e) { + return false; + } + }, + _isCurrentTaskPointComplete() { + return this._frameTextIncludes(window, '\u4efb\u52a1\u70b9\u5df2\u5b8c\u6210'); + }, + _isCompleteTaskText(text) { + const normalized = this._normalizeText(text); + const completeTexts = [ + '\u4efb\u52a1\u70b9\u5df2\u5b8c\u6210', + '\u5df2\u5b8c\u6210', + ]; + const incompleteTexts = [ + '\u5f85\u5b8c\u6210', + '\u672a\u5b8c\u6210', + '\u672a\u5b66\u4e60', + '\u8fdb\u884c\u4e2d', + ]; + return completeTexts.some((value) => normalized.includes(value)) + && !incompleteTexts.some((value) => normalized.includes(value)); + }, + _isCompleteTaskClass(value) { + const className = String(value || '').toLowerCase(); + return /(ans-)?job-?(finished|finish|done|complete|completed)|finished|complete|completed|done/.test(className) + && !/(unfinished|incomplete|uncomplete|doing|todo|wait|waiting)/.test(className); + }, + _elementHasSingleVideoFrame(el) { + if (!el) return false; + try { + return $(el).find('iframe').filter((_, frame) => { + const attrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('ans-insertvideo') || attrs.includes('insertvideo') || attrs.includes('video'); + }).length <= 1; + } catch (e) { + return false; + } + }, + _isVideoFrameTaskComplete(frame) { + if (!frame) return false; + const collectText = (el) => { + try { + return `${el.className || ''} ${el.title || ''} ${el.getAttribute?.('aria-label') || ''} ${$(el).text() || ''}`; + } catch (e) { + return ''; + } + }; + const checkElement = (el) => { + if (!el) return false; + const text = collectText(el); + return this._isCompleteTaskText(text) || this._isCompleteTaskClass(text); + }; + + try { + const docText = frame.contentWindow?.document?.body?.innerText || ''; + if (this._isCompleteTaskText(docText)) return true; + } catch (e) {} + + const candidates = []; + let current = frame; + for (let depth = 0; current && depth < 6; depth++) { + candidates.push(current); + current = current.parentElement; + } + + for (const candidate of candidates) { + if (candidate !== frame && !this._elementHasSingleVideoFrame(candidate)) continue; + if (checkElement(candidate)) return true; + try { + const siblings = []; + if (candidate.previousElementSibling) siblings.push(candidate.previousElementSibling); + if (candidate.nextElementSibling) siblings.push(candidate.nextElementSibling); + if (siblings.some((sibling) => checkElement(sibling))) return true; + } catch (e) {} + } + return false; + }, + _getNextPendingVideoTaskIndex(frames, startIndex = 0) { + for (let i = Math.max(0, startIndex); i < frames.length; i++) { + if (!this._isVideoFrameTaskComplete(frames.get(i))) { + return i; + } + console.log(`[Xuexitong Script 1x] video task ${i + 1}/${frames.length} already complete, skipping`); + } + return -1; + }, + _areAllVideoTasksComplete(frames) { + return frames.length > 0 && this._getNextPendingVideoTaskIndex(frames, 0) === -1; + }, + _getReadingScrollTargets() { + const targets = []; + const seen = new Set(); + const addElement = (el) => { + if (!el || seen.has(el)) return; + const max = Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0)); + if (max < 40) return; + let rect = { width: 1, height: 1 }; + try { + rect = el.getBoundingClientRect(); + } catch (e) {} + if (rect.width === 0 && rect.height === 0 && el !== document.body && el !== document.documentElement) return; + seen.add(el); + targets.push({ + getTop: () => Number(el.scrollTop || 0), + setTop: (value) => { + el.scrollTop = value; + el.dispatchEvent(new Event('scroll', { bubbles: true })); + el.dispatchEvent(new WheelEvent('wheel', { deltaY: this.configs.readingScrollStepPx, bubbles: true })); + }, + getMax: () => Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0)), + }); + }; + const collect = (win, depth = 0) => { + if (depth > 4) return; + try { + const doc = win.document; + addElement(doc.scrollingElement || doc.documentElement || doc.body); + Array.from(doc.querySelectorAll('div,main,section,article,body')).forEach(addElement); + Array.from(doc.querySelectorAll('iframe')).forEach((frame) => { + try { + if (frame.contentWindow) collect(frame.contentWindow, depth + 1); + } catch (e) {} + }); + } catch (e) {} + }; + collect(window); + return targets; + }, + _stopReadingScroll() { + if (this._readingScrollTimer) { + clearInterval(this._readingScrollTimer); + this._readingScrollTimer = null; + } + this._readingActive = false; + this._readingBottomSince = 0; + this._readingRounds = 0; + }, + _finishReadingTask(reason) { + console.log(`[Xuexitong Script 1x] reading task finished (${reason}), moving to next unit`); + this._stopReadingScroll(); + setTimeout(() => this.nextUnit(), 1000); + }, + _readingScrollTick() { + if (this._handleTaskPointDialog('reading')) return; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('task-complete'); + return; + } + + const targets = this._getReadingScrollTargets(); + let allBottom = targets.length > 0; + targets.forEach((target) => { + const max = target.getMax(); + const nextTop = Math.min(max, target.getTop() + this.configs.readingScrollStepPx); + target.setTop(nextTop); + if (nextTop < max - 5) allBottom = false; + }); + + if (!targets.length) { + window.scrollBy(0, this.configs.readingScrollStepPx); + allBottom = false; + } + + this._readingRounds++; + if (allBottom) { + if (!this._readingBottomSince) this._readingBottomSince = Date.now(); + } else { + this._readingBottomSince = 0; + } + + const bottomWaitMs = this._readingBottomSince ? Date.now() - this._readingBottomSince : 0; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('task-complete-after-scroll'); + } else if (bottomWaitMs >= this.configs.readingBottomGraceMs || this._readingRounds >= this.configs.readingMaxRounds) { + console.log('[Xuexitong Script 1x] reading bottom reached, waiting for task completion'); + this._readingRounds = 0; + this._readingBottomSince = Date.now(); + } + }, + _startReadingScroll() { + if (this._readingActive) return true; + this._readingActive = true; + this._readingBottomSince = 0; + this._readingRounds = 0; + console.log('[Xuexitong Script 1x] reading task detected, scrolling document'); + this._readingScrollTick(); + this._readingScrollTimer = setInterval(() => { + this._readingScrollTick(); + }, this.configs.readingScrollInterval); + return true; + }, + _handleReadingTask() { + if (this._getTaskKind() !== 'reading') return false; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('already-complete'); + return true; + } + return this._startReadingScroll(); + }, _startVideoMonitoring() { this._clearCheckInterval(); this._guardLastTime = 0; @@ -98,23 +558,34 @@ const video = this._getVideoEl(); if (!video || !this._isPlaying) return; - console.log(`%c触发视频保活恢复(${reason})`, "color:#607D8B"); + console.log(`%c触发视频保活恢复(${reason})`, 'color:#607D8B'); video.play().catch((e) => { - console.warn("直接恢复播放失败,尝试静音恢复:", e); + console.warn('直接恢复播放失败,尝试静音恢复:', e); video.muted = true; video.play().catch((err) => { - console.error("静音恢复播放失败:", err); + console.error('静音恢复播放失败:', err); }); }); }, + _applyPlaybackRate(video) { + if (!video) return; + const targetRate = Number(this.configs.playbackRate || 1); + if (!Number.isFinite(targetRate) || targetRate <= 0) return; + const currentRate = Number(video.playbackRate || 0); + if (Math.abs(currentRate - targetRate) > 0.01) { + video.playbackRate = targetRate; + console.log(`[Xuexitong Script 1x] playbackRate locked to ${targetRate}x`); + } + }, _checkVideoStatus() { try { const video = this._getVideoEl(); if (!video) return; + this._applyPlaybackRate(video); if (video.paused && this._isPlaying) { - console.log("%c检测到视频暂停,尝试恢复播放...", "color:#FF5722"); - this._tryResumePlayback("paused"); + console.log('%c检测到视频暂停,尝试恢复播放...', 'color:#FF5722'); + this._tryResumePlayback('paused'); } else if (this._isPlaying && !video.ended) { const now = Date.now(); const current = Number(video.currentTime || 0); @@ -125,7 +596,7 @@ const stalled = Math.abs(current - this._guardLastTime) < 0.01; const stalledMs = now - this._guardLastWallTs; if (stalled && stalledMs >= this.configs.guardNoProgressMs) { - this._tryResumePlayback("no-progress"); + this._tryResumePlayback('no-progress'); this._guardLastWallTs = now; this._guardLastTime = Number(video.currentTime || 0); } else if (!stalled) { @@ -136,12 +607,11 @@ } if (video.ended && this._isPlaying) { - console.log("%c检测到视频结束,准备切换下一个...", "color:#9C27B0"); - this._isPlaying = false; - setTimeout(() => this.nextUnit(), 1000); + console.log('%c检测到视频结束,准备切换下一个...', 'color:#9C27B0'); + this._handleVideoTaskEnded(); } } catch (e) { - console.error("视频状态检查失败:", e); + console.error('视频状态检查失败:', e); } }, _tryTimes: 0, @@ -154,44 +624,83 @@ _guardLastResumeTs: 0, async play() { try { + if (this._handleTaskPointDialog('before-play')) { + setTimeout(() => this.play(), 1500); + return; + } + const videoFrames = this._getVideoFrames(); + if (this._areAllVideoTasksComplete(videoFrames)) { + this._stopReadingScroll(); + this._resetVideoTaskState(); + console.log('[Xuexitong Script 1x] all video tasks in this section are complete, moving to next unit'); + setTimeout(() => this.nextUnit(), 800); + return; + } const el = this._getVideoEl(); if (el == null) { + const taskKind = this._getTaskKind(); + if (taskKind === 'video') { + this._stopReadingScroll(); + console.log('[Xuexitong Script 1x] video task detected but video element is still loading'); + setTimeout(() => this.play(), this.configs.videoPendingRetryMs); + return; + } + if (this._handleReadingTask()) { + return; + } + if (taskKind === 'quiz') { + console.log('[Xuexitong Script 1x] chapter quiz detected, trying to skip'); + this._dispatchClick($('#prevNextFocusNext').get(0)); + setTimeout(() => { + if (this._handleTaskPointDialog('after-quiz-next')) { + setTimeout(() => this.play(), 1500); + return; + } + this.play(); + }, 800); + return; + } if (this._advanceLearningStep()) { - console.log("%c当前不在视频页,已尝试切到下一学习步骤,2秒后重试", "color:#607D8B"); + console.log('%c当前不在视频页,已尝试切到下一学习步骤,2秒后重试', 'color:#607D8B'); setTimeout(() => { this.play(); }, 2000); return; } - console.log("%c===========跳过章节测验,2秒后继续播放==============", "color:#607D8B"); - $("#prevNextFocusNext").click(); + console.log('%c===========跳过章节测验,2秒后继续播放==============', 'color:#607D8B'); + this._dispatchClick($('#prevNextFocusNext').get(0)); setTimeout(() => { + if (this._handleTaskPointDialog('after-next-control')) { + setTimeout(() => this.play(), 1500); + return; + } this.play(); - }, 2000); + }, 800); return; } this._tryTimes = 0; this._isPlaying = true; this._videoEventHandle(); - el.playbackRate = this.configs.playbackRate; + this._applyPlaybackRate(el); try { await el.play(); - console.log(`%c视频开始播放,倍速: ${el.playbackRate}x`, "color:#4CAF50"); + this._applyPlaybackRate(el); + console.log(`%c视频开始播放,倍速: ${el.playbackRate}x`, 'color:#4CAF50'); this._startVideoMonitoring(); } catch (playError) { - console.error("视频播放失败:", playError); + console.error('视频播放失败:', playError); this._handlePlayError(playError); } } catch (e) { if (this._tryTimes > this.configs.maxRetries) { - console.error("%c视频播放失败,已达到最大重试次数", "color:#F44336;font-weight:bold", e); + console.error('%c视频播放失败,已达到最大重试次数', 'color:#F44336;font-weight:bold', e); this._clearCheckInterval(); return; } this._tryTimes++; - console.log(`%c播放失败,${this.configs.retryInterval/1000}秒后重试 (${this._tryTimes}/${this.configs.maxRetries})`, "color:#FF9800"); + console.log(`%c播放失败,${this.configs.retryInterval / 1000}秒后重试 (${this._tryTimes}/${this.configs.maxRetries})`, 'color:#FF9800'); setTimeout(() => { this.play(); }, this.configs.retryInterval); @@ -202,10 +711,11 @@ return true; } - const prevTitle = document.getElementsByClassName("prev_title")[0]; - const currentStepTitle = prevTitle ? (prevTitle.title || prevTitle.textContent || "").trim() : ""; + const currentTaskText = this._getCurrentTaskText(); + const quizText = '\u7ae0\u8282\u6d4b\u9a8c'; + const videoText = '\u89c6\u9891'; - if (currentStepTitle === "章节测验" || currentStepTitle === "视频") { + if (currentTaskText.includes(quizText) || currentTaskText.includes(videoText) || this._hasVideoTaskSignal()) { return false; } @@ -213,16 +723,16 @@ if (!el) return false; this._stepSwitchPending = true; this._stepSwitchAt = Date.now(); - console.log(`%c尝试点击${label}`, "color:#2196F3"); - el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); + console.log(`%c尝试点击${label}`, 'color:#2196F3'); + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return true; }; - const videoTab = $(".prev_white:visible").filter((_, el) => { - const text = ($(el).text() || "").replace(/\s+/g, ""); - return text === "2视频" || text === "视频"; + const videoTab = $('.prev_white:visible').filter((_, el) => { + const text = this._normalizeText($(el).text()); + return text === `2${videoText}` || text === videoText || text.includes(videoText); }).get(0); - if (clickElement(videoTab, "“视频”页签")) { + if (clickElement(videoTab, '“视频”页签')) { return true; } @@ -235,7 +745,7 @@ this._stepNavigationBound = true; const reenterVideoMode = () => { - this._videoEl = null; + this._resetVideoTaskState(); this._isPlaying = false; this._stepSwitchPending = true; this._stepSwitchAt = Date.now(); @@ -247,59 +757,56 @@ }, 1800); }; - $(document).on("click", ".prev_white", (e) => { - const text = ($(e.currentTarget).text() || "").replace(/\s+/g, ""); - if (text.includes("视频")) { - console.log(`%c检测到步骤切换点击:${text},准备重新接管视频页`, "color:#607D8B"); + $(document).on('click', '.prev_white', (e) => { + const text = ($(e.currentTarget).text() || '').replace(/\s+/g, ''); + if (text.includes('视频')) { + console.log(`%c检测到步骤切换点击:${text},准备重新接管视频页`, 'color:#607D8B'); reenterVideoMode(); } }); }, _handlePlayError(error) { - console.error("播放错误详情:", error); + console.error('播放错误详情:', error); const video = this._getVideoEl(); if (video) { video.muted = true; video.play().then(() => { - console.log("%c静音播放成功", "color:#4CAF50"); + console.log('%c静音播放成功', 'color:#4CAF50'); if (this._delayedNextUnitTimer) { clearTimeout(this._delayedNextUnitTimer); this._delayedNextUnitTimer = null; } - }).catch(e => { - console.error("静音播放也失败:", e); - if (this._delayedNextUnitTimer) { - clearTimeout(this._delayedNextUnitTimer); + }).catch((e) => { + console.error('静音播放也失败,将继续等待视频而不是切换下一节:', e); + if (this._tryTimes < this.configs.maxRetries) { + this._tryTimes++; + setTimeout(() => this.play(), this.configs.retryInterval); } - this._delayedNextUnitTimer = setTimeout(() => { - this._delayedNextUnitTimer = null; - this.nextUnit(); - }, 3000); }); } }, playCurrentIndex(nCell) { if (!nCell) { const el = this._getTreeContainer(); - const cells = el.children("ul").children("li"); + const cells = el.children('ul').children('li'); const nCells = $(cells.get(this._cellData.currentCellIndex)).find('.posCatalog_select:not(.firstLayer)'); nCell = nCells.get(this._cellData.currentNCellIndex); } const $nCell = $(nCell); - const clickableSpan = $nCell.find(".posCatalog_name")[0]; + const clickableSpan = $nCell.find('.posCatalog_name')[0]; if (!clickableSpan) { - console.error("%c===========找不到可点击的课程节点,播放下一个视频失败==============", "color:#F44336"); + console.error('%c===========找不到可点击的课程节点,播放下一个视频失败==============', 'color:#F44336'); setTimeout(() => this.nextUnit(), 2000); return; } - console.log(`%c点击切换到: ${$(clickableSpan).attr('title') || '未知标题'}`, "color:#2196F3"); + console.log(`%c点击切换到: ${$(clickableSpan).attr('title') || '未知标题'}`, 'color:#2196F3'); $(clickableSpan).click(); - this._videoEl = null; + this._resetVideoTaskState(); this._isPlaying = false; - console.log("%c等待视频加载...", "color:#FF9800"); + console.log('%c等待视频加载...', 'color:#FF9800'); setTimeout(() => { this._initCellData(); if (this.configs.autoplay) { @@ -309,7 +816,7 @@ }, _initCellData() { const el = this._getTreeContainer(); - const cells = el.children("ul").children("li"); + const cells = el.children('ul').children('li'); this._cellData.cells = cells.length; let nCellCounts = 0; let foundCurrent = false; @@ -319,7 +826,7 @@ nCellCounts += nCells.length; nCells.each((j, e) => { const _el = $(e); - if (_el.hasClass("posCatalog_active")) { + if (_el.hasClass('posCatalog_active')) { this._cellData.currentCellIndex = i; this._cellData.currentNCellIndex = j; foundCurrent = true; @@ -334,75 +841,181 @@ this._cellData.nCells = nCellCounts; if (!foundCurrent && nCellCounts > 0) { - console.warn("%c未找到当前激活的视频节点,可能需要手动选择", "color:#FF9800"); + console.warn('%c未找到当前激活的视频节点,可能需要手动选择', 'color:#FF9800'); } - console.log(`%c课程信息: ${this._cellData.cells}章, ${this._cellData.nCells}节, 当前: 第${this._cellData.currentCellIndex + 1}章第${this._cellData.currentNCellIndex + 1}节`, "color:#607D8B"); + console.log(`%c课程信息: ${this._cellData.cells}章, ${this._cellData.nCells}节, 当前: 第${this._cellData.currentCellIndex + 1}章第${this._cellData.currentNCellIndex + 1}节`, 'color:#607D8B'); }, _getTreeContainer() { if (!this._treeContainerEl) { const el = $('#coursetree'); if (el.length <= 0) { - throw new Error("找不到视频列表"); + throw new Error('找不到视频列表'); } this._treeContainerEl = el; } return this._treeContainerEl; }, + _findVideoFramesInWindow(win, depth = 0, result = []) { + if (depth > 4) return result; + try { + const doc = win.document; + const frames = Array.from(doc.querySelectorAll('iframe')); + frames.forEach((frame) => { + const frameAttrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + if (frameAttrs.includes('ans-insertvideo-online') || frameAttrs.includes('insertvideo') || frameAttrs.includes('video')) { + result.push(frame); + } + try { + if (frame.contentWindow) { + this._findVideoFramesInWindow(frame.contentWindow, depth + 1, result); + } + } catch (e) { + if (e && e.name === 'SecurityError') { + return; + } + console.warn('[Xuexitong Script 1x] iframe scan skipped:', e); + } + }); + } catch (e) { + if (e && e.name === 'SecurityError') { + return result; + } + console.warn('[Xuexitong Script 1x] video frame scan failed:', e); + } + return result; + }, + _getVideoFrames() { + return $(this._findVideoFramesInWindow(window)); + }, _getVideoEl() { if (!this._videoEl) { try { - const frameObj = $("iframe").eq(0).contents().find("iframe.ans-insertvideo-online"); + const frameObj = this._getVideoFrames(); + this._videoTaskCount = frameObj.length; if (frameObj.length === 0) { return null; } - this._videoEl = frameObj.eq(0).contents().find("video#video_html5_api").get(0); + if (this._currentVideoTaskIndex >= frameObj.length) { + this._currentVideoTaskIndex = frameObj.length - 1; + } + if (this._currentVideoTaskIndex < 0) { + this._currentVideoTaskIndex = 0; + } + const pendingIndex = this._getNextPendingVideoTaskIndex(frameObj, this._currentVideoTaskIndex); + if (pendingIndex < 0) { + this._currentVideoTaskIndex = frameObj.length; + return null; + } + if (pendingIndex !== this._currentVideoTaskIndex) { + this._detachVideoEventHandlers(); + this._videoEl = null; + this._currentVideoTaskIndex = pendingIndex; + } + const findVideo = (frame) => { + try { + return $(frame).contents().find('video#video_html5_api,video').get(0) || null; + } catch (e) { + if (e && e.name === 'SecurityError') return null; + console.warn('[Xuexitong Script 1x] video frame not ready:', e); + return null; + } + }; + for (let i = this._currentVideoTaskIndex; i < frameObj.length; i++) { + const video = findVideo(frameObj.get(i)); + if (video) { + this._currentVideoTaskIndex = i; + this._videoEl = video; + break; + } + } } catch (e) { - console.error("获取视频元素失败:", e); + console.error('获取视频元素失败:', e); return null; } } - if (!this._videoEl) { - throw new Error("视频组件Video未加载完成"); + return this._videoEl || null; + }, + _handleVideoTaskEnded() { + if (this._handlingVideoEnd) return; + this._handlingVideoEnd = true; + this._isPlaying = false; + this._clearCheckInterval(); + + const frames = this._getVideoFrames(); + this._videoTaskCount = frames.length; + const nextPendingIndex = this._getNextPendingVideoTaskIndex(frames, this._currentVideoTaskIndex + 1); + if (nextPendingIndex >= 0) { + this._currentVideoTaskIndex = nextPendingIndex; + this._detachVideoEventHandlers(); + this._videoEl = null; + console.log(`[Xuexitong Script 1x] switching to video task ${this._currentVideoTaskIndex + 1}/${frames.length}`); + setTimeout(() => { + this._handlingVideoEnd = false; + this.play(); + }, 800); + return; } - return this._videoEl; + + setTimeout(() => { + this._handlingVideoEnd = false; + this.nextUnit(); + }, 1000); + }, + _detachVideoEventHandlers() { + if (!this._boundVideoEl || !this._boundVideoHandlers) return; + Object.entries(this._boundVideoHandlers).forEach(([eventName, handler]) => { + this._boundVideoEl.removeEventListener(eventName, handler); + }); + this._boundVideoEl = null; + this._boundVideoHandlers = null; }, _videoEventHandle() { const el = this._videoEl; if (!el) { - console.log("videoEl未加载"); + console.log('videoEl未加载'); return; } - el.removeEventListener("ended", this._handleVideoEnded); - el.removeEventListener("loadedmetadata", this._handleVideoLoaded); - el.removeEventListener("play", this._handleVideoPlay); - el.removeEventListener("pause", this._handleVideoPause); - - el.addEventListener("ended", this._handleVideoEnded.bind(this)); - el.addEventListener("loadedmetadata", this._handleVideoLoaded.bind(this)); - el.addEventListener("play", this._handleVideoPlay.bind(this)); - el.addEventListener("pause", this._handleVideoPause.bind(this)); + if (this._boundVideoEl === el) return; + this._detachVideoEventHandlers(); + this._boundVideoEl = el; + this._boundVideoHandlers = { + ended: this._handleVideoEnded.bind(this), + loadedmetadata: this._handleVideoLoaded.bind(this), + play: this._handleVideoPlay.bind(this), + pause: this._handleVideoPause.bind(this), + ratechange: this._handleVideoRateChange.bind(this), + }; + Object.entries(this._boundVideoHandlers).forEach(([eventName, handler]) => { + el.addEventListener(eventName, handler); + }); }, _handleVideoEnded(e) { const title = this._cellData.currentVideoTitle; - console.warn(`%c============'${title}' 播放完成=============`, "color:#4CAF50;font-weight:bold"); - this._isPlaying = false; - this._clearCheckInterval(); - setTimeout(() => this.nextUnit(), 1000); + console.warn(`%c============'${title}' 播放完成=============`, 'color:#4CAF50;font-weight:bold'); + this._handleVideoTaskEnded(); }, _handleVideoLoaded(e) { - console.log(`%c============视频加载完成=============`, "color:#2196F3"); + console.log('%c============视频加载完成=============', 'color:#2196F3'); + this._applyPlaybackRate(e.currentTarget || this._getVideoEl()); if (this.configs.autoplay && !this._isPlaying) { this.play(); } }, _handleVideoPlay(e) { const title = this._cellData.currentVideoTitle; - console.info(`%c============'${title}' 开始播放=============`, "color:#4CAF50"); + console.info(`%c============'${title}' 开始播放=============`, 'color:#4CAF50'); this._isPlaying = true; this._stepSwitchPending = false; const video = this._getVideoEl(); + this._applyPlaybackRate(video); this._guardLastTime = Number(video?.currentTime || 0); this._guardLastWallTs = Date.now(); if (this._delayedNextUnitTimer) { @@ -410,8 +1023,11 @@ this._delayedNextUnitTimer = null; } }, + _handleVideoRateChange(e) { + this._applyPlaybackRate(e.currentTarget || this._getVideoEl()); + }, _handleVideoPause(e) { - console.log(`%c============视频暂停=============`, "color:#FF9800"); + console.log('%c============视频暂停=============', 'color:#FF9800'); }, }; @@ -424,30 +1040,30 @@ }; const resumePlaybackNow = () => { - if (window.app && typeof window.app._tryResumePlayback === "function") { - window.app._tryResumePlayback("page-event"); + if (window.app && typeof window.app._tryResumePlayback === 'function') { + window.app._tryResumePlayback('page-event'); } }; - document.addEventListener("mouseleave", preventPause); - window.addEventListener("mouseleave", preventPause); - document.addEventListener("mouseout", preventPause); - window.addEventListener("mouseout", preventPause); + document.addEventListener('mouseleave', preventPause); + window.addEventListener('mouseleave', preventPause); + document.addEventListener('mouseout', preventPause); + window.addEventListener('mouseout', preventPause); - window.addEventListener("blur", (e) => { - console.log("%c页面失去焦点,保持播放状态", "color:#607D8B"); + window.addEventListener('blur', () => { + console.log('%c页面失去焦点,保持播放状态', 'color:#607D8B'); resumePlaybackNow(); }); - document.addEventListener("visibilitychange", () => { + document.addEventListener('visibilitychange', () => { if (document.hidden) { - console.log("%c页面切到后台,尝试保持播放状态", "color:#607D8B"); + console.log('%c页面切到后台,尝试保持播放状态', 'color:#607D8B'); } resumePlaybackNow(); }); } catch (error) { - console.error("%c脚本运行失败: ", "color:#F44336;font-weight:bold", error.message); - console.log("请检查是否在正确的课程播放页面,或者页面结构是否再次发生改变。"); + console.error('%c脚本运行失败: ', 'color:#F44336;font-weight:bold', error.message); + console.log('请检查是否在正确的课程播放页面,或者页面结构是否再次发生改变。'); } } })(); diff --git a/v3_optimized.user.js b/v3_optimized.user.js index e8347b9..3c7fa92 100644 --- a/v3_optimized.user.js +++ b/v3_optimized.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 学习通自动刷课脚本 V3 稳定版 // @namespace local.codex.xuexitong -// @version 3.2.0 +// @version 3.2.0.9 // @description 按原版框架自动播放、自动下一节、章节测验自动跳过 // @author Codex // @match *://mooc1.chaoxing.com/mycourse/studentstudy* @@ -28,11 +28,18 @@ function initializePlayer() { window.app = { configs: { - playbackRate: 1.5, + playbackRate: 1, autoplay: true, retryInterval: 2000, maxRetries: 10, videoCheckInterval: 1000, + dialogCheckInterval: 1000, + taskDialogClickCooldownMs: 8000, + readingScrollInterval: 800, + readingScrollStepPx: 420, + readingBottomGraceMs: 5000, + readingMaxRounds: 90, + videoPendingRetryMs: 1200, guardNoProgressMs: 7000, guardResumeCooldownMs: 1500, }, @@ -41,6 +48,18 @@ _isPlaying: false, _currentRetryCount: 0, _checkInterval: null, + _dialogCheckInterval: null, + _currentVideoTaskIndex: 0, + _videoTaskCount: 0, + _handlingVideoEnd: false, + _boundVideoEl: null, + _boundVideoHandlers: null, + _confirmGuardInstalled: false, + _lastTaskPointDialogClickAt: 0, + _readingActive: false, + _readingScrollTimer: null, + _readingBottomSince: 0, + _readingRounds: 0, _cellData: { cells: 0, nCells: 0, @@ -55,13 +74,16 @@ console.log('%c=== 学习通自动刷课脚本 V3 稳定版启动 ===', 'color:#4CAF50;font-size:16px;font-weight:bold'); this._getTreeContainer(); this._initCellData(); - this._videoEl = null; + this._resetVideoTaskState(); this._getVideoEl(); this._clearCheckInterval(); + this._installConfirmGuard(); + this._startDialogMonitoring(); this._bindStepNavigation(); this.play(); }, nextUnit() { + this._stopReadingScroll(); console.log('%c=== 准备切换到下一小节 ===', 'color:#2196F3;font-size:14px'); const el = this._getTreeContainer(); const cells = el.children('ul').children('li'); @@ -78,6 +100,7 @@ console.log('%c==============本课程学习完成了==============', 'color:#4CAF50;font-size:16px;font-weight:bold'); console.log('%c=====================================', 'color:#4CAF50;font-size:16px'); this._clearCheckInterval(); + this._clearDialogInterval(); return; } console.log(`%c切换到下一个章节: ${nextIndex + 1}/${cells.length}`, 'color:#FF9800'); @@ -92,6 +115,443 @@ this._checkInterval = null; } }, + _clearDialogInterval() { + if (this._dialogCheckInterval) { + clearInterval(this._dialogCheckInterval); + this._dialogCheckInterval = null; + } + }, + _startDialogMonitoring() { + this._clearDialogInterval(); + this._dialogCheckInterval = setInterval(() => { + this._handleTaskPointDialog('monitor'); + }, this.configs.dialogCheckInterval); + }, + _dispatchClick(el) { + if (!el) return false; + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return true; + }, + _normalizeText(value) { + return String(value || '').replace(/\s+/g, ''); + }, + _getDialogButtonCandidates(dialog) { + const elements = []; + if (dialog && dialog.length) { + const root = dialog.get(0); + if (root) elements.push(root); + dialog.find('button,a,span,div').each((_, el) => elements.push(el)); + } + return $(elements); + }, + _isLikelyVisibleDialog(el) { + if (!el) return false; + const $el = $(el); + const text = String($el.text() || ''); + if (text.length > 800) return false; + let rect; + try { + rect = el.getBoundingClientRect(); + } catch (e) { + return false; + } + if (!rect || rect.width < 160 || rect.height < 80) return false; + const style = window.getComputedStyle(el); + const position = style.position; + const zIndex = Number.parseInt(style.zIndex, 10); + const className = String(el.className || '').toLowerCase(); + const role = String(el.getAttribute?.('role') || '').toLowerCase(); + const zScore = Number.isFinite(zIndex) ? zIndex : 0; + const parentZScore = Math.max(0, ...$el.parents().map((_, parent) => { + const parentZ = Number.parseInt(window.getComputedStyle(parent).zIndex, 10); + return Number.isFinite(parentZ) ? parentZ : 0; + }).get()); + return role === 'dialog' + || position === 'fixed' + || zScore >= 10 + || parentZScore >= 10 + || /dialog|modal|layui|layer|wayer|pop/.test(className); + }, + _handleTaskPointDialog(reason) { + try { + const now = Date.now(); + if (now - this._lastTaskPointDialogClickAt < this.configs.taskDialogClickCooldownMs) { + return false; + } + const taskPointText = '\u5f53\u524d\u7ae0\u8282\u8fd8\u6709\u4efb\u52a1\u70b9\u672a\u5b8c\u6210'; + const goStudyText = '\u53bb\u5b66\u4e60'; + const goCompleteText = '\u53bb\u5b8c\u6210'; + const nextSectionText = '\u4e0b\u4e00\u8282'; + const isQuizTask = this._isQuizTaskPage(); + const targetTexts = isQuizTask ? [nextSectionText] : [goStudyText, goCompleteText]; + const normalize = (value) => this._normalizeText(value); + const dialogs = $('[role="dialog"]:visible,.layui-layer:visible,.wayer:visible,.wayer-dialog:visible,.modal:visible,.dialog:visible,[class*="dialog"]:visible,[class*="modal"]:visible,[class*="layer"]:visible,[class*="pop"]:visible').filter((_, el) => { + const text = normalize($(el).text()); + return this._isLikelyVisibleDialog(el) && text.includes(taskPointText) && targetTexts.some((targetText) => text.includes(targetText)); + }); + if (!dialogs.length) return false; + + const dialog = dialogs.last(); + const targetButton = this._getDialogButtonCandidates(dialog).filter((_, el) => { + const text = normalize($(el).text()); + return targetTexts.some((targetText) => { + return text === targetText || (text.includes(targetText) && $(el).children().length === 0); + }); + }).last(); + if (!targetButton.length) return false; + + console.log(`[Xuexitong Script 1x] unfinished-task dialog handled as ${isQuizTask ? 'next section' : 'go study'} (${reason})`); + this._lastTaskPointDialogClickAt = now; + if (isQuizTask) { + this._stepSwitchPending = true; + this._stepSwitchAt = now; + } + return this._dispatchClick(targetButton.get(0)); + } catch (e) { + console.warn('[Xuexitong Script 1x] dialog handling failed:', e); + return false; + } + }, + _installConfirmGuard() { + if (this._confirmGuardInstalled) return; + this._confirmGuardInstalled = true; + const taskPointText = '\u5f53\u524d\u7ae0\u8282\u8fd8\u6709\u4efb\u52a1\u70b9\u672a\u5b8c\u6210'; + const nativeConfirm = window.confirm.bind(window); + window.confirm = (message) => { + if (String(message || '').includes(taskPointText)) { + if (this._isQuizTaskPage()) { + console.log('[Xuexitong Script 1x] native unfinished-task confirm handled as quiz skip'); + return false; + } + console.log('[Xuexitong Script 1x] native unfinished-task confirm handled as go study'); + return true; + } + return nativeConfirm(message); + }; + }, + _resetVideoTaskState() { + this._detachVideoEventHandlers(); + this._videoEl = null; + this._currentVideoTaskIndex = 0; + this._videoTaskCount = 0; + this._handlingVideoEnd = false; + }, + _getCurrentStepTitle() { + const prevTitle = document.getElementsByClassName('prev_title')[0]; + return prevTitle ? (prevTitle.title || prevTitle.textContent || '').trim() : ''; + }, + _getCurrentTaskText() { + const parts = [document.title, this._getCurrentStepTitle()]; + const activeSelectors = [ + '.prev_title', + '.posCatalog_active .posCatalog_name', + '.prev_white.active', + '.prev_white.selected', + '.prev_white.current', + '.prev_white.on', + '.prev_white[aria-selected="true"]', + '[class*="prev"][class*="active"]:visible', + '[class*="prev"][class*="select"]:visible', + '[class*="prev"][class*="current"]:visible', + '[class*="prev"][class*="cur"]:visible', + '[class*="prev"][class*="on"]:visible', + 'li[aria-selected="true"]:visible', + ]; + try { + $(activeSelectors.join(',')).each((_, el) => { + parts.push(el.title || $(el).attr('title') || $(el).text()); + }); + } catch (e) {} + return this._normalizeText(parts.join(' ')); + }, + _hasVideoTaskSignal() { + if (this._videoEl || this._findVideoFramesInWindow(window).length > 0) { + return true; + } + const videoText = '\u89c6\u9891'; + if (this._getCurrentTaskText().includes(videoText)) { + return true; + } + try { + if ($('video').length > 0) return true; + return $('iframe').filter((_, frame) => { + const attrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('video') || attrs.includes('insertvideo') || attrs.includes('ans-insertvideo'); + }).length > 0; + } catch (e) { + return false; + } + }, + _isQuizTaskPage(taskText = this._getCurrentTaskText()) { + const quizText = '\u7ae0\u8282\u6d4b\u9a8c'; + if (taskText.includes(quizText)) return true; + try { + const pageText = this._normalizeText(document.body?.innerText || ''); + return pageText.includes(quizText) + || (pageText.includes('\u9898\u91cf') && (pageText.includes('\u5355\u9009\u9898') || pageText.includes('\u591a\u9009\u9898'))); + } catch (e) { + return false; + } + }, + _isReadingTaskPage(taskText = this._getCurrentTaskText()) { + if (this._hasVideoTaskSignal() || this._isQuizTaskPage(taskText)) { + return false; + } + const labels = [ + '\u6559\u6750', + '\u9605\u8bfb', + '\u6587\u6863', + '\u56fe\u6587', + '\u8d44\u6599', + ]; + if (labels.some((label) => taskText.includes(label))) { + return true; + } + try { + return $('iframe,[class*="reader"],[class*="document"],[class*="book"],[class*="pdf"]').filter((_, el) => { + const attrs = [ + el.className, + el.id, + el.title, + el.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('reader') || attrs.includes('document') || attrs.includes('book') || attrs.includes('pdf'); + }).length > 0; + } catch (e) { + return false; + } + }, + _getTaskKind() { + const taskText = this._getCurrentTaskText(); + if (this._hasVideoTaskSignal()) return 'video'; + if (this._isQuizTaskPage(taskText)) return 'quiz'; + if (this._isReadingTaskPage(taskText)) return 'reading'; + return 'unknown'; + }, + _frameTextIncludes(win, needle, depth = 0) { + if (depth > 4) return false; + try { + const doc = win.document; + if ((doc.body?.innerText || '').includes(needle)) return true; + const frames = Array.from(doc.querySelectorAll('iframe')); + return frames.some((frame) => { + try { + return frame.contentWindow && this._frameTextIncludes(frame.contentWindow, needle, depth + 1); + } catch (e) { + return false; + } + }); + } catch (e) { + return false; + } + }, + _isCurrentTaskPointComplete() { + return this._frameTextIncludes(window, '\u4efb\u52a1\u70b9\u5df2\u5b8c\u6210'); + }, + _isCompleteTaskText(text) { + const normalized = this._normalizeText(text); + const completeTexts = [ + '\u4efb\u52a1\u70b9\u5df2\u5b8c\u6210', + '\u5df2\u5b8c\u6210', + ]; + const incompleteTexts = [ + '\u5f85\u5b8c\u6210', + '\u672a\u5b8c\u6210', + '\u672a\u5b66\u4e60', + '\u8fdb\u884c\u4e2d', + ]; + return completeTexts.some((value) => normalized.includes(value)) + && !incompleteTexts.some((value) => normalized.includes(value)); + }, + _isCompleteTaskClass(value) { + const className = String(value || '').toLowerCase(); + return /(ans-)?job-?(finished|finish|done|complete|completed)|finished|complete|completed|done/.test(className) + && !/(unfinished|incomplete|uncomplete|doing|todo|wait|waiting)/.test(className); + }, + _elementHasSingleVideoFrame(el) { + if (!el) return false; + try { + return $(el).find('iframe').filter((_, frame) => { + const attrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + return attrs.includes('ans-insertvideo') || attrs.includes('insertvideo') || attrs.includes('video'); + }).length <= 1; + } catch (e) { + return false; + } + }, + _isVideoFrameTaskComplete(frame) { + if (!frame) return false; + const collectText = (el) => { + try { + return `${el.className || ''} ${el.title || ''} ${el.getAttribute?.('aria-label') || ''} ${$(el).text() || ''}`; + } catch (e) { + return ''; + } + }; + const checkElement = (el) => { + if (!el) return false; + const text = collectText(el); + return this._isCompleteTaskText(text) || this._isCompleteTaskClass(text); + }; + + try { + const docText = frame.contentWindow?.document?.body?.innerText || ''; + if (this._isCompleteTaskText(docText)) return true; + } catch (e) {} + + const candidates = []; + let current = frame; + for (let depth = 0; current && depth < 6; depth++) { + candidates.push(current); + current = current.parentElement; + } + + for (const candidate of candidates) { + if (candidate !== frame && !this._elementHasSingleVideoFrame(candidate)) continue; + if (checkElement(candidate)) return true; + try { + const siblings = []; + if (candidate.previousElementSibling) siblings.push(candidate.previousElementSibling); + if (candidate.nextElementSibling) siblings.push(candidate.nextElementSibling); + if (siblings.some((sibling) => checkElement(sibling))) return true; + } catch (e) {} + } + return false; + }, + _getNextPendingVideoTaskIndex(frames, startIndex = 0) { + for (let i = Math.max(0, startIndex); i < frames.length; i++) { + if (!this._isVideoFrameTaskComplete(frames.get(i))) { + return i; + } + console.log(`[Xuexitong Script 1x] video task ${i + 1}/${frames.length} already complete, skipping`); + } + return -1; + }, + _areAllVideoTasksComplete(frames) { + return frames.length > 0 && this._getNextPendingVideoTaskIndex(frames, 0) === -1; + }, + _getReadingScrollTargets() { + const targets = []; + const seen = new Set(); + const addElement = (el) => { + if (!el || seen.has(el)) return; + const max = Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0)); + if (max < 40) return; + let rect = { width: 1, height: 1 }; + try { + rect = el.getBoundingClientRect(); + } catch (e) {} + if (rect.width === 0 && rect.height === 0 && el !== document.body && el !== document.documentElement) return; + seen.add(el); + targets.push({ + getTop: () => Number(el.scrollTop || 0), + setTop: (value) => { + el.scrollTop = value; + el.dispatchEvent(new Event('scroll', { bubbles: true })); + el.dispatchEvent(new WheelEvent('wheel', { deltaY: this.configs.readingScrollStepPx, bubbles: true })); + }, + getMax: () => Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0)), + }); + }; + const collect = (win, depth = 0) => { + if (depth > 4) return; + try { + const doc = win.document; + addElement(doc.scrollingElement || doc.documentElement || doc.body); + Array.from(doc.querySelectorAll('div,main,section,article,body')).forEach(addElement); + Array.from(doc.querySelectorAll('iframe')).forEach((frame) => { + try { + if (frame.contentWindow) collect(frame.contentWindow, depth + 1); + } catch (e) {} + }); + } catch (e) {} + }; + collect(window); + return targets; + }, + _stopReadingScroll() { + if (this._readingScrollTimer) { + clearInterval(this._readingScrollTimer); + this._readingScrollTimer = null; + } + this._readingActive = false; + this._readingBottomSince = 0; + this._readingRounds = 0; + }, + _finishReadingTask(reason) { + console.log(`[Xuexitong Script 1x] reading task finished (${reason}), moving to next unit`); + this._stopReadingScroll(); + setTimeout(() => this.nextUnit(), 1000); + }, + _readingScrollTick() { + if (this._handleTaskPointDialog('reading')) return; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('task-complete'); + return; + } + + const targets = this._getReadingScrollTargets(); + let allBottom = targets.length > 0; + targets.forEach((target) => { + const max = target.getMax(); + const nextTop = Math.min(max, target.getTop() + this.configs.readingScrollStepPx); + target.setTop(nextTop); + if (nextTop < max - 5) allBottom = false; + }); + + if (!targets.length) { + window.scrollBy(0, this.configs.readingScrollStepPx); + allBottom = false; + } + + this._readingRounds++; + if (allBottom) { + if (!this._readingBottomSince) this._readingBottomSince = Date.now(); + } else { + this._readingBottomSince = 0; + } + + const bottomWaitMs = this._readingBottomSince ? Date.now() - this._readingBottomSince : 0; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('task-complete-after-scroll'); + } else if (bottomWaitMs >= this.configs.readingBottomGraceMs || this._readingRounds >= this.configs.readingMaxRounds) { + console.log('[Xuexitong Script 1x] reading bottom reached, waiting for task completion'); + this._readingRounds = 0; + this._readingBottomSince = Date.now(); + } + }, + _startReadingScroll() { + if (this._readingActive) return true; + this._readingActive = true; + this._readingBottomSince = 0; + this._readingRounds = 0; + console.log('[Xuexitong Script 1x] reading task detected, scrolling document'); + this._readingScrollTick(); + this._readingScrollTimer = setInterval(() => { + this._readingScrollTick(); + }, this.configs.readingScrollInterval); + return true; + }, + _handleReadingTask() { + if (this._getTaskKind() !== 'reading') return false; + if (this._isCurrentTaskPointComplete()) { + this._finishReadingTask('already-complete'); + return true; + } + return this._startReadingScroll(); + }, _startVideoMonitoring() { this._clearCheckInterval(); this._guardLastTime = 0; @@ -120,10 +580,21 @@ }); }); }, + _applyPlaybackRate(video) { + if (!video) return; + const targetRate = Number(this.configs.playbackRate || 1); + if (!Number.isFinite(targetRate) || targetRate <= 0) return; + const currentRate = Number(video.playbackRate || 0); + if (Math.abs(currentRate - targetRate) > 0.01) { + video.playbackRate = targetRate; + console.log(`[Xuexitong Script 1x] playbackRate locked to ${targetRate}x`); + } + }, _checkVideoStatus() { try { const video = this._getVideoEl(); if (!video) return; + this._applyPlaybackRate(video); if (video.paused && this._isPlaying) { console.log('%c检测到视频暂停,尝试恢复播放...', 'color:#FF5722'); @@ -150,8 +621,7 @@ if (video.ended && this._isPlaying) { console.log('%c检测到视频结束,准备切换下一个...', 'color:#9C27B0'); - this._isPlaying = false; - setTimeout(() => this.nextUnit(), 1000); + this._handleVideoTaskEnded(); } } catch (e) { console.error('视频状态检查失败:', e); @@ -167,8 +637,42 @@ _guardLastResumeTs: 0, async play() { try { + if (this._handleTaskPointDialog('before-play')) { + setTimeout(() => this.play(), 1500); + return; + } + const videoFrames = this._getVideoFrames(); + if (this._areAllVideoTasksComplete(videoFrames)) { + this._stopReadingScroll(); + this._resetVideoTaskState(); + console.log('[Xuexitong Script 1x] all video tasks in this section are complete, moving to next unit'); + setTimeout(() => this.nextUnit(), 800); + return; + } const el = this._getVideoEl(); if (el == null) { + const taskKind = this._getTaskKind(); + if (taskKind === 'video') { + this._stopReadingScroll(); + console.log('[Xuexitong Script 1x] video task detected but video element is still loading'); + setTimeout(() => this.play(), this.configs.videoPendingRetryMs); + return; + } + if (this._handleReadingTask()) { + return; + } + if (taskKind === 'quiz') { + console.log('[Xuexitong Script 1x] chapter quiz detected, trying to skip'); + this._dispatchClick($('#prevNextFocusNext').get(0)); + setTimeout(() => { + if (this._handleTaskPointDialog('after-quiz-next')) { + setTimeout(() => this.play(), 1500); + return; + } + this.play(); + }, 800); + return; + } if (this._advanceLearningStep()) { console.log('%c当前不在视频页,已尝试切到下一学习步骤,2秒后重试', 'color:#607D8B'); setTimeout(() => { @@ -177,20 +681,25 @@ return; } console.log('%c===========跳过章节测验,2秒后继续播放==============', 'color:#607D8B'); - $('#prevNextFocusNext').click(); + this._dispatchClick($('#prevNextFocusNext').get(0)); setTimeout(() => { + if (this._handleTaskPointDialog('after-next-control')) { + setTimeout(() => this.play(), 1500); + return; + } this.play(); - }, 2000); + }, 800); return; } this._tryTimes = 0; this._isPlaying = true; this._videoEventHandle(); - el.playbackRate = this.configs.playbackRate; + this._applyPlaybackRate(el); try { await el.play(); + this._applyPlaybackRate(el); console.log(`%c视频开始播放,倍速: ${el.playbackRate}x`, 'color:#4CAF50'); this._startVideoMonitoring(); } catch (playError) { @@ -215,10 +724,11 @@ return true; } - const prevTitle = document.getElementsByClassName('prev_title')[0]; - const currentStepTitle = prevTitle ? (prevTitle.title || prevTitle.textContent || '').trim() : ''; + const currentTaskText = this._getCurrentTaskText(); + const quizText = '\u7ae0\u8282\u6d4b\u9a8c'; + const videoText = '\u89c6\u9891'; - if (currentStepTitle === '章节测验' || currentStepTitle === '视频') { + if (currentTaskText.includes(quizText) || currentTaskText.includes(videoText) || this._hasVideoTaskSignal()) { return false; } @@ -232,8 +742,8 @@ }; const videoTab = $('.prev_white:visible').filter((_, el) => { - const text = ($(el).text() || '').replace(/\s+/g, ''); - return text === '2视频' || text === '视频'; + const text = this._normalizeText($(el).text()); + return text === `2${videoText}` || text === videoText || text.includes(videoText); }).get(0); if (clickElement(videoTab, '“视频”页签')) { return true; @@ -248,7 +758,7 @@ this._stepNavigationBound = true; const reenterVideoMode = () => { - this._videoEl = null; + this._resetVideoTaskState(); this._isPlaying = false; this._stepSwitchPending = true; this._stepSwitchAt = Date.now(); @@ -280,14 +790,11 @@ this._delayedNextUnitTimer = null; } }).catch((e) => { - console.error('静音播放也失败:', e); - if (this._delayedNextUnitTimer) { - clearTimeout(this._delayedNextUnitTimer); + console.error('静音播放也失败,将继续等待视频而不是切换下一节:', e); + if (this._tryTimes < this.configs.maxRetries) { + this._tryTimes++; + setTimeout(() => this.play(), this.configs.retryInterval); } - this._delayedNextUnitTimer = setTimeout(() => { - this._delayedNextUnitTimer = null; - this.nextUnit(); - }, 3000); }); } }, @@ -309,7 +816,7 @@ console.log(`%c点击切换到: ${$(clickableSpan).attr('title') || '未知标题'}`, 'color:#2196F3'); $(clickableSpan).click(); - this._videoEl = null; + this._resetVideoTaskState(); this._isPlaying = false; console.log('%c等待视频加载...', 'color:#FF9800'); @@ -362,23 +869,125 @@ } return this._treeContainerEl; }, + _findVideoFramesInWindow(win, depth = 0, result = []) { + if (depth > 4) return result; + try { + const doc = win.document; + const frames = Array.from(doc.querySelectorAll('iframe')); + frames.forEach((frame) => { + const frameAttrs = [ + frame.className, + frame.id, + frame.name, + frame.title, + frame.getAttribute?.('src'), + ].join(' ').toLowerCase(); + if (frameAttrs.includes('ans-insertvideo-online') || frameAttrs.includes('insertvideo') || frameAttrs.includes('video')) { + result.push(frame); + } + try { + if (frame.contentWindow) { + this._findVideoFramesInWindow(frame.contentWindow, depth + 1, result); + } + } catch (e) { + if (e && e.name === 'SecurityError') { + return; + } + console.warn('[Xuexitong Script 1x] iframe scan skipped:', e); + } + }); + } catch (e) { + if (e && e.name === 'SecurityError') { + return result; + } + console.warn('[Xuexitong Script 1x] video frame scan failed:', e); + } + return result; + }, + _getVideoFrames() { + return $(this._findVideoFramesInWindow(window)); + }, _getVideoEl() { if (!this._videoEl) { try { - const frameObj = $('iframe').eq(0).contents().find('iframe.ans-insertvideo-online'); + const frameObj = this._getVideoFrames(); + this._videoTaskCount = frameObj.length; if (frameObj.length === 0) { return null; } - this._videoEl = frameObj.eq(0).contents().find('video#video_html5_api').get(0); + if (this._currentVideoTaskIndex >= frameObj.length) { + this._currentVideoTaskIndex = frameObj.length - 1; + } + if (this._currentVideoTaskIndex < 0) { + this._currentVideoTaskIndex = 0; + } + const pendingIndex = this._getNextPendingVideoTaskIndex(frameObj, this._currentVideoTaskIndex); + if (pendingIndex < 0) { + this._currentVideoTaskIndex = frameObj.length; + return null; + } + if (pendingIndex !== this._currentVideoTaskIndex) { + this._detachVideoEventHandlers(); + this._videoEl = null; + this._currentVideoTaskIndex = pendingIndex; + } + const findVideo = (frame) => { + try { + return $(frame).contents().find('video#video_html5_api,video').get(0) || null; + } catch (e) { + if (e && e.name === 'SecurityError') return null; + console.warn('[Xuexitong Script 1x] video frame not ready:', e); + return null; + } + }; + for (let i = this._currentVideoTaskIndex; i < frameObj.length; i++) { + const video = findVideo(frameObj.get(i)); + if (video) { + this._currentVideoTaskIndex = i; + this._videoEl = video; + break; + } + } } catch (e) { console.error('获取视频元素失败:', e); return null; } } - if (!this._videoEl) { - throw new Error('视频组件Video未加载完成'); + return this._videoEl || null; + }, + _handleVideoTaskEnded() { + if (this._handlingVideoEnd) return; + this._handlingVideoEnd = true; + this._isPlaying = false; + this._clearCheckInterval(); + + const frames = this._getVideoFrames(); + this._videoTaskCount = frames.length; + const nextPendingIndex = this._getNextPendingVideoTaskIndex(frames, this._currentVideoTaskIndex + 1); + if (nextPendingIndex >= 0) { + this._currentVideoTaskIndex = nextPendingIndex; + this._detachVideoEventHandlers(); + this._videoEl = null; + console.log(`[Xuexitong Script 1x] switching to video task ${this._currentVideoTaskIndex + 1}/${frames.length}`); + setTimeout(() => { + this._handlingVideoEnd = false; + this.play(); + }, 800); + return; } - return this._videoEl; + + setTimeout(() => { + this._handlingVideoEnd = false; + this.nextUnit(); + }, 1000); + }, + _detachVideoEventHandlers() { + if (!this._boundVideoEl || !this._boundVideoHandlers) return; + Object.entries(this._boundVideoHandlers).forEach(([eventName, handler]) => { + this._boundVideoEl.removeEventListener(eventName, handler); + }); + this._boundVideoEl = null; + this._boundVideoHandlers = null; }, _videoEventHandle() { const el = this._videoEl; @@ -387,25 +996,28 @@ return; } - el.removeEventListener('ended', this._handleVideoEnded); - el.removeEventListener('loadedmetadata', this._handleVideoLoaded); - el.removeEventListener('play', this._handleVideoPlay); - el.removeEventListener('pause', this._handleVideoPause); - - el.addEventListener('ended', this._handleVideoEnded.bind(this)); - el.addEventListener('loadedmetadata', this._handleVideoLoaded.bind(this)); - el.addEventListener('play', this._handleVideoPlay.bind(this)); - el.addEventListener('pause', this._handleVideoPause.bind(this)); + if (this._boundVideoEl === el) return; + this._detachVideoEventHandlers(); + this._boundVideoEl = el; + this._boundVideoHandlers = { + ended: this._handleVideoEnded.bind(this), + loadedmetadata: this._handleVideoLoaded.bind(this), + play: this._handleVideoPlay.bind(this), + pause: this._handleVideoPause.bind(this), + ratechange: this._handleVideoRateChange.bind(this), + }; + Object.entries(this._boundVideoHandlers).forEach(([eventName, handler]) => { + el.addEventListener(eventName, handler); + }); }, _handleVideoEnded(e) { const title = this._cellData.currentVideoTitle; console.warn(`%c============'${title}' 播放完成=============`, 'color:#4CAF50;font-weight:bold'); - this._isPlaying = false; - this._clearCheckInterval(); - setTimeout(() => this.nextUnit(), 1000); + this._handleVideoTaskEnded(); }, _handleVideoLoaded(e) { console.log('%c============视频加载完成=============', 'color:#2196F3'); + this._applyPlaybackRate(e.currentTarget || this._getVideoEl()); if (this.configs.autoplay && !this._isPlaying) { this.play(); } @@ -416,6 +1028,7 @@ this._isPlaying = true; this._stepSwitchPending = false; const video = this._getVideoEl(); + this._applyPlaybackRate(video); this._guardLastTime = Number(video?.currentTime || 0); this._guardLastWallTs = Date.now(); if (this._delayedNextUnitTimer) { @@ -423,6 +1036,9 @@ this._delayedNextUnitTimer = null; } }, + _handleVideoRateChange(e) { + this._applyPlaybackRate(e.currentTarget || this._getVideoEl()); + }, _handleVideoPause(e) { console.log('%c============视频暂停=============', 'color:#FF9800'); }, diff --git a/xuexitong.js b/xuexitong.js index 9c1e5b0..404d80d 100644 --- a/xuexitong.js +++ b/xuexitong.js @@ -78,7 +78,7 @@ function watchVideo(frameObj, v_done) { window.a = v; // 设置倍速 try { - v.playbackRate = 2; + v.playbackRate = 1; } catch (e) { console.error("倍速设置失败!此节可能有需要回复内容,不影响,跳至下一节。错误信息:" + e); nextUnit();