├── image ├── demo.gif └── developer.png ├── startocr.py ├── README.md ├── startocr.bat └── NJU-enhance.js /image/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hronrad/LMS-enhancement/HEAD/image/demo.gif -------------------------------------------------------------------------------- /image/developer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hronrad/LMS-enhancement/HEAD/image/developer.png -------------------------------------------------------------------------------- /startocr.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify 2 | from flask_cors import CORS 3 | import ddddocr 4 | import base64 5 | 6 | app = Flask(__name__) 7 | CORS(app) # 允许所有来源跨域 8 | 9 | # 初始化ddddocr 10 | ocr = ddddocr.DdddOcr(show_ad=False) 11 | 12 | @app.route('/ocr', methods=['POST']) 13 | def ocr_api(): 14 | try: 15 | data = request.get_json() 16 | if not data or 'image' not in data: 17 | return jsonify({'error': '缺少图像数据'}), 400 18 | 19 | img_b64 = data.get('image', '') 20 | img_bytes = base64.b64decode(img_b64) 21 | text = ocr.classification(img_bytes) 22 | 23 | # 清理结果,只保留字母和数字 24 | clean_text = ''.join(c for c in text if c.isalnum()) 25 | 26 | print(f"识别结果: '{text}' -> '{clean_text}'") 27 | return jsonify({'text': clean_text}) 28 | 29 | except Exception as e: 30 | print(f"OCR识别错误: {e}") 31 | return jsonify({'error': str(e)}), 500 32 | 33 | @app.route('/health', methods=['GET']) 34 | def health(): 35 | return jsonify({'status': 'OK'}) 36 | 37 | if __name__ == '__main__': 38 | print("启动ddddocr验证码识别服务...") 39 | print("服务地址: http://127.0.0.1:5000") 40 | app.run(host='127.0.0.1', port=5000, debug=False) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 南大LMS智慧教育平台|MOOC增强 2 | 3 | [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) 4 | [![Browser Support](https://img.shields.io/badge/Browser-Chrome%20%7C%20Firefox%20%7C%20Edge%20%7C%20Safari-4285F4?logo=googlechrome&logoColor=white)](https://www.tampermonkey.net/) 5 | [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6?logo=windows&logoColor=white)](https://www.tampermonkey.net/) 6 | [![Tampermonkey](https://img.shields.io/badge/Tampermonkey-Compatible-green?logo=tampermonkey)](https://www.tampermonkey.net/) 7 | 8 | 请使用篡改猴|油猴插件 Tampermonkey 来进行安装 9 | 10 | 最新功能(v 0.30):可通过配置ocr服务自动识别登录验证码;可自动下载课件。 11 | 12 | ## 一键安装 13 | 14 | ### 第一步 15 | **请先安装 [Tampermonkey](https://www.tampermonkey.net/) 浏览器扩展** 16 | 17 | 各浏览器具体链接: 18 | 19 | [![Chrome](https://img.shields.io/badge/Chrome-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white)](https://chromewebstore.google.com/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo) 20 | [![Firefox](https://img.shields.io/badge/Firefox-FF7139?style=for-the-badge&logo=firefox&logoColor=white)](https://addons.mozilla.org/zh-CN/firefox/addon/tampermonkey/) 21 | [![Edge](https://img.shields.io/badge/Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white)](https://microsoftedge.microsoft.com/addons/detail/%E7%AF%A1%E6%94%B9%E7%8C%B4/iikmkjmpaadaobahmlepeloendndfphd) 22 | [![Safari](https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white)](https://apps.apple.com/us/app/tampermonkey/id1482490089) 23 | [![Opera](https://img.shields.io/badge/Opera-FF1B2D?style=for-the-badge&logo=opera&logoColor=white)](https://addons.opera.com/zh-cn/extensions/details/tampermonkey-beta/) 24 | 25 | ### 第二步 26 | 第一次安装需要打开浏览器的开发者模式。 27 | 28 | Edge浏览器: 地址栏输入edge://extensions/并回车,打开开发人员模式 29 | edge 30 | 31 | Chrome浏览器: 地址栏输入chrome://extensions/并回车,打开开发人员模式 32 | 33 | 其他浏览器同理。 34 | 35 | ### 第三步 36 | 点击下面的按钮直接安装最新版本: 37 | 38 | [![Install Script](https://img.shields.io/badge/Install-UserScript-green?style=for-the-badge&logo=tampermonkey)](https://greasyfork.org/zh-CN/scripts/546406-%E5%8D%97%E5%A4%A7lms%E6%99%BA%E6%85%A7%E6%95%99%E8%82%B2%E5%B9%B3%E5%8F%B0-mooc%E5%A2%9E%E5%BC%BA/) 39 | 40 | ## 自动更新 41 | 42 | 脚本已配置自动更新功能 43 | 44 | ## 配置OCR验证码识别 45 | 如果需要自动识别登录验证码,请按以下步骤操作(如已有相应API接口可直接在第二步填入): 46 | 47 | ### 第一步 48 | 下载startocr.bat文件并保存在本地任意目录,双击运行,等待命令行窗口提示`启动ddddocr验证码识别服务...\\ 49 | 服务地址: http://127.0.0.1:5000`即运行成功,无需关闭该 cmd 窗口。 50 | 51 | 该脚本为傻瓜式一键操作脚本,如该批处理脚本无法正常运行,请手动运行`startocr.py`文件,确保已安装 Python3 和 ddddocr 库。 52 | 53 | ### 第二步 54 | 在登录页面 authserver.nju.edu.cn 的右侧菜单开启自动识别,并填入API地址`http://127.0.0.1:5000/ocr`。 55 | 56 | ## 功能简介 57 | 1. ▶️解除自动暂停,可后台播放 58 | 2. 🚫解除禁用右键,不可跳转进度条等限制 59 | 3. ⏩自动加速视频课程完成进度 60 | 4. ⏭️播放完成后自动播放下一个视频,跳过无视频页面 61 | 5. 🚀侧边菜单可控制多种倍速0.1x/1x/3x/16x 62 | 6. 🚀可自动识别登录验证码(需配置OCR服务) 63 | 64 | ## 使用演示 65 | 其他功能自动开启,如需倍速请按下图从右侧菜单选择所需倍速 66 | ![演示动图](./image/demo.gif) 67 | 68 | ## 温馨提示 69 | - 当您在对应网站看到页面右侧蓝色隐藏倍速按钮时,代表脚本已经成功安装并生效。 70 | 71 | - 如遇页面卡顿/功能失效等问题,以及“系统繁忙”提示时,请立即通过 `ctrl + shift + R` 刷新缓存,以及 `F5` 强制刷新。 72 | 73 | - 建议遵循适度使用和非必要不使用的准则。使用过程中您可随时选择关闭该脚本。由使用此脚本导致的任何问题请自行承担风险。 74 | 75 | - 欢迎反馈问题和建议! 76 | 77 | ## TODO List 78 | 79 | 📋 **功能扩展计划** 80 | 81 | - ☐ 82 | 83 | 84 | **⭐ 如果这个脚本对您有帮助,请给个 Star 支持一下!(\*╹▽╹\*)** 85 | 86 | (P.S.引流:欢迎关注B站 Hronrad) -------------------------------------------------------------------------------- /startocr.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp 65001 >nul 3 | title OCR验证码识别服务 - 一键安装并启动 4 | color 0A 5 | 6 | echo ======================================== 7 | echo OCR验证码识别服务 8 | echo 全自动安装和启动 9 | echo ======================================== 10 | echo. 11 | 12 | REM ============================================ 13 | REM 第一步:检查Python环境 14 | REM ============================================ 15 | echo [步骤 1/5] 检查Python环境... 16 | python --version >nul 2>&1 17 | if %errorlevel% neq 0 ( 18 | color 0C 19 | echo. 20 | echo ======================================== 21 | echo [错误] 未检测到Python环境! 22 | echo ======================================== 23 | echo. 24 | echo 请先安装 Python 3.7 或更高版本 25 | echo 下载地址: https://www.python.org/downloads/ 26 | echo. 27 | echo 安装时请务必勾选: 28 | echo [✓] Add Python to PATH 29 | echo. 30 | echo 安装完成后,请重新运行本脚本 31 | echo ======================================== 32 | echo. 33 | pause 34 | exit /b 1 35 | ) 36 | 37 | python --version 38 | echo [✓] Python环境正常 39 | echo. 40 | 41 | REM ============================================ 42 | REM 第二步:检查pip工具 43 | REM ============================================ 44 | echo [步骤 2/5] 检查pip工具... 45 | python -m pip --version >nul 2>&1 46 | if %errorlevel% neq 0 ( 47 | echo [!] pip不可用,正在尝试修复... 48 | python -m ensurepip --default-pip 49 | if %errorlevel% neq 0 ( 50 | color 0C 51 | echo [错误] 无法修复pip,请手动重装Python 52 | pause 53 | exit /b 1 54 | ) 55 | ) 56 | echo [✓] pip工具正常 57 | echo. 58 | 59 | REM ============================================ 60 | REM 第三步:检查依赖库是否已安装 61 | REM ============================================ 62 | echo [步骤 3/5] 检查依赖库... 63 | python -c "import flask, flask_cors, ddddocr" >nul 2>&1 64 | if %errorlevel% neq 0 ( 65 | echo [!] 检测到依赖未安装,开始自动安装... 66 | echo. 67 | 68 | REM 升级pip 69 | echo [3.1] 升级pip工具... 70 | python -m pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple >nul 2>&1 71 | echo [✓] pip已升级 72 | echo. 73 | 74 | REM 安装依赖 75 | echo [3.2] 安装依赖库(使用清华镜像源)... 76 | echo 这可能需要几分钟,请耐心等待... 77 | echo. 78 | 79 | echo 正在安装 flask... 80 | python -m pip install flask==2.3.2 -i https://pypi.tuna.tsinghua.edu.cn/simple 81 | if %errorlevel% neq 0 ( 82 | echo [!] 清华源失败,尝试官方源... 83 | python -m pip install flask==2.3.2 84 | if %errorlevel% neq 0 ( 85 | color 0C 86 | echo [错误] flask 安装失败 87 | pause 88 | exit /b 1 89 | ) 90 | ) 91 | echo [✓] flask 安装完成 92 | 93 | echo 正在安装 flask-cors... 94 | python -m pip install flask-cors==4.0.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 95 | if %errorlevel% neq 0 ( 96 | python -m pip install flask-cors==4.0.0 97 | if %errorlevel% neq 0 ( 98 | color 0C 99 | echo [错误] flask-cors 安装失败 100 | pause 101 | exit /b 1 102 | ) 103 | ) 104 | echo [✓] flask-cors 安装完成 105 | 106 | echo 正在安装 ddddocr(OCR识别引擎)... 107 | python -m pip install ddddocr==1.4.11 -i https://pypi.tuna.tsinghua.edu.cn/simple 108 | if %errorlevel% neq 0 ( 109 | python -m pip install ddddocr==1.4.11 110 | if %errorlevel% neq 0 ( 111 | color 0C 112 | echo [错误] ddddocr 安装失败 113 | pause 114 | exit /b 1 115 | ) 116 | ) 117 | echo [✓] ddddocr 安装完成 118 | 119 | echo. 120 | echo ======================================== 121 | echo [✓] 所有依赖安装成功! 122 | echo ======================================== 123 | echo. 124 | ) else ( 125 | echo [✓] 所有依赖已安装 126 | echo. 127 | ) 128 | 129 | REM ============================================ 130 | REM 第四步:最终检查 131 | REM ============================================ 132 | echo [步骤 4/5] 最终环境检查... 133 | python -c "import flask, flask_cors, ddddocr" >nul 2>&1 134 | if %errorlevel% neq 0 ( 135 | color 0C 136 | echo [错误] 依赖库验证失败 137 | pause 138 | exit /b 1 139 | ) 140 | echo [✓] 环境检查完成,一切就绪! 141 | echo. 142 | 143 | REM ============================================ 144 | REM 第五步:启动服务 145 | REM ============================================ 146 | echo [步骤 5/5] 启动OCR识别服务... 147 | echo. 148 | color 0B 149 | echo ======================================== 150 | echo 服务启动中... 151 | echo ======================================== 152 | echo. 153 | echo 服务地址: http://127.0.0.1:5000 154 | echo 健康检查: http://127.0.0.1:5000/health 155 | echo. 156 | echo 提示: 157 | echo * 保持此窗口打开,服务才能运行 158 | echo * 按 Ctrl+C 可以停止服务 159 | echo * 关闭窗口也会停止服务 160 | echo ======================================== 161 | echo. 162 | 163 | REM 检查startocr.py是否存在 164 | if not exist "startocr.py" ( 165 | color 0C 166 | echo [错误] 找不到 startocr.py 文件 167 | echo 请确保此脚本与 startocr.py 在同一目录 168 | pause 169 | exit /b 1 170 | ) 171 | 172 | REM 启动服务 173 | python startocr.py 174 | 175 | REM 服务停止后的处理 176 | echo. 177 | echo ======================================== 178 | echo 服务已停止 179 | echo ======================================== 180 | echo. 181 | pause 182 | -------------------------------------------------------------------------------- /NJU-enhance.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 南大LMS智慧教育平台|MOOC增强 3 | // @namespace http://tampermonkey.net/ 4 | // @version 0.30 5 | // @description 南大LMS平台与MOOC平台加速进度/自动下一个/智能停止/无视频自动跳转/视频倍速控制/解除播放限制 + 验证码自动识别 + 自动下载课件 6 | // @author Hronrad 7 | // @license GPL-3.0-only 8 | // @match https://lms.nju.edu.cn/* 9 | // @match https://www.icourse163.org/* 10 | // @match https://icourse163.org/* 11 | // @match https://authserver.nju.edu.cn/authserver/login* 12 | // @grant none 13 | // ==/UserScript== 14 | 15 | (function() { 16 | 'use strict'; 17 | 18 | let isUserPaused = false; 19 | let lastUserAction = 0; 20 | let processedRequests = new Set(); 21 | let isVirtualRequest = false; 22 | let allVideosCompleted = false; 23 | let scriptPaused = false; 24 | let noVideoCheckCount = 0; 25 | const MAX_NO_VIDEO_CHECKS = 5; 26 | let currentSpeed = 1; 27 | let processedVideos = new Set(); 28 | let contentReady = false; 29 | let pageLoadTime = Date.now(); 30 | 31 | const SPEED_STORAGE_KEY = `lms-video-speed-${location.hostname}`; 32 | 33 | const isICourse163 = location.hostname.includes('icourse163.org'); 34 | const isAuthServer = location.hostname.includes('authserver.nju.edu.cn'); 35 | 36 | function checkContentReady() { 37 | const hasMainContent = document.querySelector('[ng-view]') || 38 | document.querySelector('.main-content') || 39 | document.querySelector('#main') || 40 | document.querySelector('.content-area'); 41 | 42 | const hasAngular = window.angular && document.querySelector('[ng-app]'); 43 | const timeElapsed = Date.now() - pageLoadTime > 2000; 44 | 45 | const ready = (hasMainContent || hasAngular) && timeElapsed; 46 | 47 | return ready; 48 | } 49 | 50 | function waitForContentReady(callback, maxWait = 15000) { 51 | const startTime = Date.now(); 52 | 53 | function check() { 54 | if (checkContentReady()) { 55 | contentReady = true; 56 | callback(); 57 | } else if (Date.now() - startTime < maxWait) { 58 | setTimeout(check, 1000); 59 | } else { 60 | contentReady = true; 61 | callback(); 62 | } 63 | } 64 | 65 | check(); 66 | } 67 | 68 | function handlePageChange() { 69 | scriptPaused = false; 70 | allVideosCompleted = false; 71 | noVideoCheckCount = 0; 72 | contentReady = false; 73 | pageLoadTime = Date.now(); 74 | 75 | waitForContentReady(() => {}); 76 | } 77 | 78 | function setupPageChangeListener() { 79 | let currentUrl = location.href; 80 | let currentHash = location.hash; 81 | 82 | const observer = new MutationObserver(() => { 83 | if (location.href !== currentUrl || location.hash !== currentHash) { 84 | currentUrl = location.href; 85 | currentHash = location.hash; 86 | handlePageChange(); 87 | } 88 | }); 89 | 90 | observer.observe(document.body, { 91 | childList: true, 92 | subtree: true 93 | }); 94 | 95 | window.addEventListener('hashchange', handlePageChange); 96 | window.addEventListener('popstate', handlePageChange); 97 | } 98 | 99 | function loadSavedSpeed() { 100 | try { 101 | const savedSpeed = localStorage.getItem(SPEED_STORAGE_KEY); 102 | if (savedSpeed) { 103 | const speed = parseFloat(savedSpeed); 104 | if ([0.1, 1, 3, 16].includes(speed)) { 105 | currentSpeed = speed; 106 | } 107 | } 108 | } catch (e) {} 109 | } 110 | 111 | function saveSpeed(speed) { 112 | try { 113 | localStorage.setItem(SPEED_STORAGE_KEY, speed.toString()); 114 | window.dispatchEvent(new CustomEvent('lms-speed-changed', { 115 | detail: { speed, timestamp: Date.now() } 116 | })); 117 | } catch (e) {} 118 | } 119 | 120 | function syncSpeedAcrossTabs() { 121 | window.addEventListener('lms-speed-changed', (e) => { 122 | if (e.detail.speed !== currentSpeed) { 123 | currentSpeed = e.detail.speed; 124 | applySpeedToVideos(); 125 | updateSpeedButton(); 126 | } 127 | }); 128 | 129 | window.addEventListener('storage', (e) => { 130 | if (e.key === SPEED_STORAGE_KEY && e.newValue) { 131 | const newSpeed = parseFloat(e.newValue); 132 | if ([0.1, 1, 3, 16].includes(newSpeed) && newSpeed !== currentSpeed) { 133 | currentSpeed = newSpeed; 134 | applySpeedToVideos(); 135 | updateSpeedButton(); 136 | } 137 | } 138 | }); 139 | } 140 | 141 | function applySpeedToVideos() { 142 | document.querySelectorAll('video').forEach(video => { 143 | if (video.playbackRate !== currentSpeed) { 144 | video.playbackRate = currentSpeed; 145 | } 146 | }); 147 | } 148 | 149 | function updateSpeedButton() { 150 | const speedButton = document.getElementById('lms-speed-button'); 151 | const speedMenu = document.getElementById('lms-speed-menu'); 152 | 153 | if (speedButton) { 154 | speedButton.innerHTML = `${currentSpeed}x`; 155 | } 156 | 157 | if (speedMenu) { 158 | speedMenu.querySelectorAll('div').forEach((div, i) => { 159 | const itemSpeed = [0.1, 1, 3, 16][i]; 160 | div.style.background = itemSpeed === currentSpeed ? '#e3f2fd' : 'white'; 161 | div.style.fontWeight = itemSpeed === currentSpeed ? 'bold' : 'normal'; 162 | }); 163 | } 164 | } 165 | 166 | function removeVideoRestrictions() { 167 | const videos = document.querySelectorAll('video:not([data-restrictions-removed])'); 168 | 169 | videos.forEach(video => { 170 | video.setAttribute('data-restrictions-removed', 'true'); 171 | video.setAttribute('allow-foward-seeking', 'true'); 172 | video.setAttribute('data-allow-download', 'true'); 173 | video.setAttribute('allow-right-click', 'true'); 174 | video.removeAttribute('forward-seeking-warning'); 175 | video.controls = true; 176 | video.oncontextmenu = null; 177 | }); 178 | } 179 | 180 | function removePageRestrictions() { 181 | document.oncontextmenu = null; 182 | document.onselectstart = null; 183 | document.ondragstart = null; 184 | document.onkeydown = null; 185 | } 186 | 187 | function monitorRestrictions() { 188 | const observer = new MutationObserver((mutations) => { 189 | let needsUpdate = false; 190 | 191 | mutations.forEach((mutation) => { 192 | if (mutation.type === 'childList') { 193 | mutation.addedNodes.forEach((node) => { 194 | if (node.nodeType === 1 && (node.tagName === 'VIDEO' || node.querySelector('video'))) { 195 | needsUpdate = true; 196 | } 197 | }); 198 | } 199 | }); 200 | 201 | if (needsUpdate) { 202 | setTimeout(removeVideoRestrictions, 200); 203 | } 204 | }); 205 | 206 | observer.observe(document.body, { 207 | childList: true, 208 | subtree: true 209 | }); 210 | } 211 | 212 | function createSpeedControlUI() { 213 | if (document.getElementById('lms-speed-container')) return; 214 | 215 | const container = document.createElement('div'); 216 | container.id = 'lms-speed-container'; 217 | container.style.cssText = ` 218 | position: fixed; 219 | top: 50%; 220 | right: -45px; 221 | transform: translateY(-50%); 222 | z-index: 10000; 223 | transition: right 0.3s ease; 224 | display: flex; 225 | flex-direction: column; 226 | align-items: flex-end; 227 | `; 228 | 229 | const speedButton = document.createElement('button'); 230 | speedButton.id = 'lms-speed-button'; 231 | speedButton.innerHTML = `${currentSpeed}x`; 232 | speedButton.style.cssText = ` 233 | width: 60px; 234 | height: 35px; 235 | background: #007bff; 236 | color: white; 237 | border: none; 238 | border-radius: 8px 0 0 8px; 239 | font-size: 14px; 240 | cursor: pointer; 241 | box-shadow: 0 4px 12px rgba(0,123,255,0.3); 242 | transition: all 0.3s ease; 243 | margin-bottom: 5px; 244 | `; 245 | 246 | const speedMenu = document.createElement('div'); 247 | speedMenu.id = 'lms-speed-menu'; 248 | speedMenu.style.cssText = ` 249 | background: white; 250 | border: 1px solid #ddd; 251 | border-radius: 8px 0 0 8px; 252 | box-shadow: 0 8px 24px rgba(0,0,0,0.15); 253 | min-width: 80px; 254 | overflow: hidden; 255 | opacity: 0; 256 | transform: translateX(10px); 257 | transition: all 0.3s ease; 258 | pointer-events: none; 259 | `; 260 | 261 | [0.1, 1, 3, 16].forEach(speed => { 262 | const item = document.createElement('div'); 263 | item.textContent = `${speed}x`; 264 | item.style.cssText = ` 265 | padding: 10px 16px; 266 | cursor: pointer; 267 | transition: background 0.2s ease; 268 | font-size: 13px; 269 | text-align: center; 270 | ${speed === currentSpeed ? 'background: #e3f2fd; font-weight: bold;' : ''} 271 | `; 272 | item.onmouseenter = () => item.style.background = speed === currentSpeed ? '#bbdefb' : '#f5f5f5'; 273 | item.onmouseleave = () => item.style.background = speed === currentSpeed ? '#e3f2fd' : 'white'; 274 | item.onclick = () => { 275 | setVideoSpeed(speed); 276 | speedButton.innerHTML = `${speed}x`; 277 | updateMenuSelection(speedMenu, speed); 278 | }; 279 | speedMenu.appendChild(item); 280 | }); 281 | 282 | // 添加设置选项(分割线) 283 | const divider = document.createElement('div'); 284 | divider.style.cssText = 'height: 1px; background: #ddd; margin: 5px 0;'; 285 | speedMenu.appendChild(divider); 286 | 287 | const settingsItem = document.createElement('div'); 288 | settingsItem.textContent = '⚙️ 设置'; 289 | settingsItem.style.cssText = ` 290 | padding: 10px 16px; 291 | cursor: pointer; 292 | transition: background 0.2s ease; 293 | font-size: 13px; 294 | text-align: center; 295 | `; 296 | settingsItem.onmouseenter = () => settingsItem.style.background = '#f5f5f5'; 297 | settingsItem.onmouseleave = () => settingsItem.style.background = 'white'; 298 | settingsItem.onclick = () => showSettingsPanel(); 299 | speedMenu.appendChild(settingsItem); 300 | 301 | function updateMenuSelection(menu, selectedSpeed) { 302 | menu.querySelectorAll('div').forEach((div, i) => { 303 | const itemSpeed = [0.1, 1, 3, 16][i]; 304 | div.style.background = itemSpeed === selectedSpeed ? '#e3f2fd' : 'white'; 305 | div.style.fontWeight = itemSpeed === selectedSpeed ? 'bold' : 'normal'; 306 | }); 307 | } 308 | 309 | container.appendChild(speedButton); 310 | container.appendChild(speedMenu); 311 | 312 | let isExpanded = false; 313 | let hideTimeout; 314 | 315 | function showControls() { 316 | clearTimeout(hideTimeout); 317 | isExpanded = true; 318 | container.style.right = '0px'; 319 | speedButton.style.background = '#0056b3'; 320 | speedButton.style.transform = 'scale(1.05)'; 321 | speedMenu.style.opacity = '1'; 322 | speedMenu.style.transform = 'translateX(0)'; 323 | speedMenu.style.pointerEvents = 'auto'; 324 | } 325 | 326 | function hideControls() { 327 | hideTimeout = setTimeout(() => { 328 | isExpanded = false; 329 | container.style.right = '-45px'; 330 | speedButton.style.background = '#007bff'; 331 | speedButton.style.transform = 'scale(1)'; 332 | speedMenu.style.opacity = '0'; 333 | speedMenu.style.transform = 'translateX(10px)'; 334 | speedMenu.style.pointerEvents = 'none'; 335 | }, 300); 336 | } 337 | 338 | container.onmouseenter = showControls; 339 | container.onmouseleave = hideControls; 340 | 341 | speedButton.onclick = (e) => { 342 | e.stopPropagation(); 343 | if (isExpanded) { 344 | speedMenu.style.display = speedMenu.style.display === 'none' ? 'block' : 'none'; 345 | } 346 | }; 347 | 348 | document.addEventListener('click', (e) => { 349 | if (!container.contains(e.target)) { 350 | speedMenu.style.display = 'block'; 351 | } 352 | }); 353 | 354 | const hoverIndicator = document.createElement('div'); 355 | hoverIndicator.style.cssText = ` 356 | position: absolute; 357 | right: 0; 358 | top: 50%; 359 | transform: translateY(-50%); 360 | width: 3px; 361 | height: 30px; 362 | background: linear-gradient(45deg, #007bff, #0056b3); 363 | border-radius: 3px 0 0 3px; 364 | opacity: 0.7; 365 | animation: pulse 2s infinite; 366 | `; 367 | 368 | const style = document.createElement('style'); 369 | style.textContent = ` 370 | @keyframes pulse { 371 | 0%, 100% { opacity: 0.7; } 372 | 50% { opacity: 0.3; } 373 | } 374 | `; 375 | document.head.appendChild(style); 376 | 377 | container.appendChild(hoverIndicator); 378 | document.body.appendChild(container); 379 | } 380 | 381 | function setVideoSpeed(speed) { 382 | currentSpeed = speed; 383 | saveSpeed(speed); 384 | applySpeedToVideos(); 385 | updateSpeedButton(); 386 | } 387 | 388 | function initICourse163() { 389 | loadSavedSpeed(); 390 | syncSpeedAcrossTabs(); 391 | removeVideoRestrictions(); 392 | removePageRestrictions(); 393 | monitorRestrictions(); 394 | createSpeedControlUI(); 395 | 396 | setInterval(() => { 397 | applySpeedToVideos(); 398 | }, 2000); 399 | } 400 | 401 | if (isICourse163) { 402 | if (document.readyState === 'loading') { 403 | document.addEventListener('DOMContentLoaded', () => setTimeout(initICourse163, 500)); 404 | } else { 405 | setTimeout(initICourse163, 500); 406 | } 407 | return; 408 | } 409 | 410 | loadSavedSpeed(); 411 | syncSpeedAcrossTabs(); 412 | 413 | Object.defineProperty(document, 'hidden', { get: () => false, configurable: true }); 414 | Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true }); 415 | document.addEventListener('visibilitychange', (e) => e.stopImmediatePropagation(), true); 416 | 417 | const originalOpen = XMLHttpRequest.prototype.open; 418 | XMLHttpRequest.prototype.open = function(method, url, ...args) { 419 | this._method = method; 420 | this._url = url; 421 | this._isVirtual = isVirtualRequest; 422 | return originalOpen.call(this, method, url, ...args); 423 | }; 424 | 425 | const originalSend = XMLHttpRequest.prototype.send; 426 | XMLHttpRequest.prototype.send = function(data) { 427 | const url = this._url || ''; 428 | 429 | if (scriptPaused) { 430 | return originalSend.call(this, data); 431 | } 432 | 433 | if (!this._isVirtual && 434 | (url.includes('/statistics/api/online-videos') || 435 | url.includes('/api/course/activities-read/')) && 436 | this._method === 'POST' && data) { 437 | 438 | try { 439 | const jsonData = JSON.parse(data); 440 | const requestKey = `${url}-${JSON.stringify(jsonData)}`; 441 | 442 | if (!processedRequests.has(requestKey)) { 443 | processedRequests.add(requestKey); 444 | createVirtualSessions(url, jsonData); 445 | setTimeout(() => processedRequests.delete(requestKey), 10000); 446 | } 447 | } catch (e) {} 448 | } 449 | 450 | return originalSend.call(this, data); 451 | }; 452 | 453 | function createVirtualSessions(url, originalData) { 454 | if (scriptPaused) return; 455 | 456 | const sessionCount = 10; 457 | const maxDuration = 30; 458 | const originalDuration = (originalData.end || 0) - (originalData.start || 0); 459 | const isLargeDuration = originalDuration > maxDuration; 460 | 461 | for (let i = 1; i < sessionCount; i++) { 462 | setTimeout(() => { 463 | if (scriptPaused) return; 464 | 465 | const virtualData = JSON.parse(JSON.stringify(originalData)); 466 | 467 | if (isLargeDuration) { 468 | const segmentDuration = Math.min(maxDuration, Math.floor(originalDuration / sessionCount) + 5); 469 | const baseStart = originalData.start || 0; 470 | 471 | virtualData.start = baseStart + (i - 1) * segmentDuration + Math.floor(Math.random() * 3); 472 | virtualData.end = virtualData.start + segmentDuration + Math.floor(Math.random() * 3); 473 | 474 | if (virtualData.end > originalData.end) { 475 | virtualData.end = originalData.end; 476 | } 477 | 478 | if (virtualData.start >= virtualData.end) { 479 | virtualData.start = virtualData.end - Math.min(5, segmentDuration); 480 | } 481 | } else { 482 | if (virtualData.start !== undefined) { 483 | virtualData.start += Math.floor(Math.random() * 3); 484 | } 485 | if (virtualData.end !== undefined) { 486 | virtualData.end += Math.floor(Math.random() * 3); 487 | } 488 | } 489 | 490 | const duration = (virtualData.end || 0) - (virtualData.start || 0); 491 | if (duration <= 0 || duration > maxDuration * 2) { 492 | return; 493 | } 494 | 495 | fetch(url, { 496 | method: 'POST', 497 | headers: { 498 | 'Content-Type': 'application/json', 499 | 'X-Requested-With': 'XMLHttpRequest' 500 | }, 501 | body: JSON.stringify(virtualData), 502 | credentials: 'same-origin' 503 | }).then(response => {}).catch(error => {}); 504 | 505 | }, i * 400 + Math.random() * 300); 506 | } 507 | } 508 | 509 | function detectUserAction(e) { 510 | const target = e.target; 511 | 512 | if (target.closest('.vjs-play-control') || 513 | target.closest('.vjs-big-play-button') || 514 | target.closest('button') || 515 | target.tagName === 'BUTTON') { 516 | 517 | lastUserAction = Date.now(); 518 | 519 | setTimeout(() => { 520 | document.querySelectorAll('video').forEach(video => { 521 | if (video.paused) { 522 | isUserPaused = true; 523 | } 524 | }); 525 | }, 100); 526 | } 527 | } 528 | 529 | document.addEventListener('click', detectUserAction, true); 530 | document.addEventListener('keydown', (e) => { 531 | if (e.code === 'Space') { 532 | lastUserAction = Date.now(); 533 | } 534 | }, true); 535 | 536 | function hasNextButton() { 537 | try { 538 | const angular = window.angular; 539 | if (angular) { 540 | const scope = angular.element(document.body).scope(); 541 | if ((scope && scope.navigation && scope.navigation.nextItem) || 542 | (scope && scope.nextActivity)) { 543 | return true; 544 | } 545 | } 546 | } catch (e) {} 547 | 548 | const nextSelectors = [ 549 | 'button[ng-click*="changeActivity(nextActivity)"]', 550 | 'button[ng-if="nextActivity"]', 551 | 'a[ng-click*="goToNextTopic()"]', 552 | 'a.next[ng-if*="!isLastTopic()"]', 553 | 'span.icon-student-circle[ng-click*="navigation.goNext"]', 554 | 'button[ng-click*="goNext"]', 555 | 'a.next[ng-click="goToNextTopic()"]', 556 | 'button.button[ng-click*="changeActivity(nextActivity)"]' 557 | ]; 558 | 559 | for (const selector of nextSelectors) { 560 | const nextButton = document.querySelector(selector); 561 | if (nextButton && nextButton.offsetParent !== null) { 562 | return true; 563 | } 564 | } 565 | 566 | try { 567 | const nextTopicLink = document.querySelector('a.next[ng-click="goToNextTopic()"]'); 568 | if (nextTopicLink) { 569 | const scope = window.angular.element(nextTopicLink).scope(); 570 | if (scope && typeof scope.isLastTopic === 'function') { 571 | if (!scope.isLastTopic() && nextTopicLink.offsetParent !== null) { 572 | return true; 573 | } 574 | } 575 | } 576 | 577 | const nextActivityBtn = document.querySelector('button[ng-click*="changeActivity(nextActivity)"]'); 578 | if (nextActivityBtn) { 579 | const scope = window.angular.element(nextActivityBtn).scope(); 580 | if (scope && scope.nextActivity && nextActivityBtn.offsetParent !== null) { 581 | return true; 582 | } 583 | } 584 | } catch (e) {} 585 | 586 | const elements = document.querySelectorAll('button, a'); 587 | for (const el of elements) { 588 | if (el.textContent.includes('下一个') && el.offsetParent !== null) { 589 | return true; 590 | } 591 | } 592 | 593 | return false; 594 | } 595 | 596 | function hasVideos() { 597 | return document.querySelectorAll('video').length > 0; 598 | } 599 | 600 | function checkAllVideosCompleted() { 601 | const videos = document.querySelectorAll('video'); 602 | if (videos.length === 0) return false; 603 | 604 | return Array.from(videos).every(video => { 605 | const isEnded = video.ended; 606 | const isDurationComplete = video.duration > 0 && 607 | Math.abs(video.currentTime - video.duration) < 1; 608 | const isNearComplete = video.duration > 0 && 609 | video.currentTime / video.duration >= 0.98; 610 | 611 | return isEnded || isDurationComplete || isNearComplete; 612 | }); 613 | } 614 | 615 | function checkNoVideoAutoNext() { 616 | if (scriptPaused) return; 617 | 618 | if (!contentReady) { 619 | return; 620 | } 621 | 622 | if (!hasVideos()) { 623 | if (hasNextButton()) { 624 | noVideoCheckCount++; 625 | if (noVideoCheckCount >= MAX_NO_VIDEO_CHECKS) { 626 | noVideoCheckCount = 0; 627 | autoClickNext(); 628 | } 629 | } else { 630 | pauseScript(); 631 | } 632 | } else { 633 | noVideoCheckCount = 0; 634 | } 635 | } 636 | 637 | function pauseScript() { 638 | if (scriptPaused) return; 639 | 640 | scriptPaused = true; 641 | allVideosCompleted = true; 642 | 643 | document.querySelectorAll('video').forEach(video => { 644 | if (!video.paused) { 645 | video.pause(); 646 | } 647 | }); 648 | } 649 | 650 | function keepVideoPlaying() { 651 | if (scriptPaused) return; 652 | 653 | document.querySelectorAll('video').forEach(video => { 654 | if (video.paused) { 655 | const timeSinceUserAction = Date.now() - lastUserAction; 656 | 657 | if (isUserPaused && timeSinceUserAction < 3000) { 658 | return; 659 | } 660 | 661 | if (video.readyState >= 2) { 662 | video.play().then(() => { 663 | isUserPaused = false; 664 | }).catch(() => {}); 665 | } 666 | } else { 667 | if (isUserPaused && Date.now() - lastUserAction > 2000) { 668 | isUserPaused = false; 669 | } 670 | } 671 | }); 672 | } 673 | 674 | function performVirtualUserAction() { 675 | if (scriptPaused) return; 676 | 677 | const videos = document.querySelectorAll('video'); 678 | const playButtons = document.querySelectorAll('.vjs-play-control'); 679 | 680 | if (videos.length > 0 && !isUserPaused) { 681 | videos.forEach((video, index) => { 682 | if (!video.paused) { 683 | if (playButtons[index]) { 684 | playButtons[index].dispatchEvent(new MouseEvent('click', { 685 | bubbles: true, 686 | cancelable: true, 687 | view: window 688 | })); 689 | } else { 690 | video.pause(); 691 | } 692 | 693 | setTimeout(() => { 694 | if (scriptPaused) return; 695 | 696 | if (playButtons[index]) { 697 | playButtons[index].dispatchEvent(new MouseEvent('click', { 698 | bubbles: true, 699 | cancelable: true, 700 | view: window 701 | })); 702 | } else { 703 | video.play().catch(() => {}); 704 | } 705 | }, 100); 706 | } 707 | }); 708 | } 709 | } 710 | 711 | function setupVideoCompletionHandler() { 712 | const videos = document.querySelectorAll('video:not([data-completion-handler])'); 713 | 714 | videos.forEach(video => { 715 | video.setAttribute('data-completion-handler', 'true'); 716 | video.playbackRate = currentSpeed; 717 | 718 | video.addEventListener('ended', function() { 719 | setTimeout(() => { 720 | if (checkAllVideosCompleted()) { 721 | if (hasNextButton()) { 722 | autoClickNext(); 723 | } else { 724 | pauseScript(); 725 | } 726 | } else { 727 | autoClickNext(); 728 | } 729 | }, 2000); 730 | }); 731 | }); 732 | } 733 | 734 | function autoClickNext() { 735 | if (scriptPaused) return; 736 | 737 | try { 738 | const angular = window.angular; 739 | if (angular) { 740 | const scope = angular.element(document.body).scope(); 741 | 742 | if (scope && scope.nextActivity && scope.changeActivity) { 743 | scope.changeActivity(scope.nextActivity); 744 | scope.$apply(); 745 | return; 746 | } 747 | 748 | if (scope && scope.goToNextTopic) { 749 | scope.goToNextTopic(); 750 | scope.$apply(); 751 | return; 752 | } 753 | 754 | if (scope && scope.navigation && scope.navigation.goNext) { 755 | scope.navigation.goNext(); 756 | scope.$apply(); 757 | return; 758 | } 759 | } 760 | } catch (e) {} 761 | 762 | const nextSelectors = [ 763 | 'button[ng-click*="changeActivity(nextActivity)"]', 764 | 'button[ng-if="nextActivity"]', 765 | 'a[ng-click*="goToNextTopic()"]', 766 | 'a.next[ng-if*="!isLastTopic()"]', 767 | 'button[ng-click*="goNext"]', 768 | 'a.next[ng-click="goToNextTopic()"]', 769 | 'button.button[ng-click*="changeActivity(nextActivity)"]' 770 | ]; 771 | 772 | for (const selector of nextSelectors) { 773 | const nextButton = document.querySelector(selector); 774 | if (nextButton && nextButton.offsetParent !== null) { 775 | if (nextButton.hasAttribute('ng-click') && window.angular) { 776 | try { 777 | const scope = window.angular.element(nextButton).scope(); 778 | if (scope) { 779 | scope.$eval(nextButton.getAttribute('ng-click')); 780 | scope.$apply(); 781 | return; 782 | } 783 | } catch (e) {} 784 | } 785 | 786 | nextButton.click(); 787 | return; 788 | } 789 | } 790 | 791 | const allElements = document.querySelectorAll('button, a, span[ng-click]'); 792 | for (const element of allElements) { 793 | const text = element.textContent.trim(); 794 | const ngClick = element.getAttribute('ng-click') || ''; 795 | 796 | if ((text.includes('下一个') || ngClick.includes('changeActivity') || 797 | ngClick.includes('goToNextTopic') || ngClick.includes('goNext')) && 798 | element.offsetParent !== null) { 799 | 800 | if (ngClick && window.angular) { 801 | try { 802 | const scope = window.angular.element(element).scope(); 803 | if (scope) { 804 | scope.$eval(ngClick); 805 | scope.$apply(); 806 | return; 807 | } 808 | } catch (e) {} 809 | } 810 | 811 | element.click(); 812 | return; 813 | } 814 | } 815 | 816 | pauseScript(); 817 | } 818 | 819 | setInterval(keepVideoPlaying, 2000); 820 | setInterval(performVirtualUserAction, 1000); 821 | setInterval(() => { 822 | setupVideoCompletionHandler(); 823 | applySpeedToVideos(); 824 | }, 3000); 825 | setInterval(checkNoVideoAutoNext, 6000); 826 | 827 | function init() { 828 | keepVideoPlaying(); 829 | setupVideoCompletionHandler(); 830 | createSpeedControlUI(); 831 | removeVideoRestrictions(); 832 | removePageRestrictions(); 833 | monitorRestrictions(); 834 | applySpeedToVideos(); 835 | setupPageChangeListener(); 836 | 837 | waitForContentReady(() => { 838 | setTimeout(checkNoVideoAutoNext, 3000); 839 | }); 840 | } 841 | 842 | if (document.readyState === 'loading') { 843 | document.addEventListener('DOMContentLoaded', () => { 844 | setTimeout(init, 1000); 845 | }); 846 | } else { 847 | setTimeout(init, 1000); 848 | } 849 | 850 | // ======= 全局设置管理 ======= 851 | const GlobalSettings = { 852 | config: { 853 | captchaAuto: true, 854 | captchaApi: 'http://127.0.0.1:5000/ocr' 855 | }, 856 | load() { 857 | try { 858 | const saved = localStorage.getItem('lms-enhance-settings'); 859 | if (saved) Object.assign(this.config, JSON.parse(saved)); 860 | } catch (e) {} 861 | }, 862 | save() { 863 | try { 864 | localStorage.setItem('lms-enhance-settings', JSON.stringify(this.config)); 865 | } catch (e) {} 866 | } 867 | }; 868 | GlobalSettings.load(); 869 | 870 | function showSettingsPanel() { 871 | let panel = document.getElementById('lms-settings-panel'); 872 | if (panel) { 873 | panel.style.display = 'flex'; 874 | return; 875 | } 876 | 877 | panel = document.createElement('div'); 878 | panel.id = 'lms-settings-panel'; 879 | panel.style.cssText = ` 880 | display: flex; 881 | position: fixed; 882 | top: 0; 883 | left: 0; 884 | width: 100%; 885 | height: 100%; 886 | background: rgba(0,0,0,0.5); 887 | z-index: 20000; 888 | align-items: center; 889 | justify-content: center; 890 | `; 891 | 892 | const content = document.createElement('div'); 893 | content.style.cssText = ` 894 | background: white; 895 | border-radius: 12px; 896 | padding: 24px; 897 | min-width: 400px; 898 | max-width: 500px; 899 | box-shadow: 0 8px 32px rgba(0,0,0,0.3); 900 | `; 901 | 902 | content.innerHTML = ` 903 |
904 |

增强脚本设置

905 | 906 |
907 |
908 | 912 |
913 | 914 | 915 |
916 |
917 |
918 | 919 | 920 |
921 | `; 922 | 923 | panel.appendChild(content); 924 | document.body.appendChild(panel); 925 | 926 | // 关闭按钮 927 | document.getElementById('close-settings').onclick = () => panel.style.display = 'none'; 928 | document.getElementById('cancel-settings').onclick = () => panel.style.display = 'none'; 929 | panel.onclick = (e) => { 930 | if (e.target === panel) panel.style.display = 'none'; 931 | }; 932 | 933 | // 保存按钮 934 | document.getElementById('save-settings').onclick = () => { 935 | GlobalSettings.config.captchaAuto = document.getElementById('setting-captcha-auto').checked; 936 | GlobalSettings.config.captchaApi = document.getElementById('setting-captcha-api').value.trim(); 937 | GlobalSettings.save(); 938 | panel.style.display = 'none'; 939 | // 更新验证码助手配置 940 | if (typeof CaptchaHelper !== 'undefined') { 941 | CaptchaHelper.config.auto = GlobalSettings.config.captchaAuto; 942 | CaptchaHelper.config.api = GlobalSettings.config.captchaApi; 943 | CaptchaHelper.createRetryBtn(); 944 | } 945 | }; 946 | } 947 | 948 | // ======= 验证码自动识别与设置 ======= 949 | const CaptchaHelper = { 950 | config: { 951 | auto: GlobalSettings.config.captchaAuto, 952 | api: GlobalSettings.config.captchaApi 953 | }, 954 | getInput() { 955 | return document.querySelector('#captchaResponse'); 956 | }, 957 | getImg() { 958 | return document.querySelector('#captchaImg'); 959 | }, 960 | getRow() { 961 | const input = this.getInput(); 962 | return input ? input.parentNode : null; 963 | }, 964 | async recognize() { 965 | const img = this.getImg(); 966 | if (!img) return ''; 967 | const canvas = document.createElement('canvas'); 968 | canvas.width = img.naturalWidth || img.width; 969 | canvas.height = img.naturalHeight || img.height; 970 | canvas.getContext('2d').drawImage(img, 0, 0); 971 | const base64 = canvas.toDataURL('image/png').split(',')[1]; 972 | try { 973 | const resp = await fetch(this.config.api, { 974 | method: 'POST', 975 | headers: { 'Content-Type': 'application/json' }, 976 | body: JSON.stringify({ image: base64 }) 977 | }); 978 | if (resp.ok) { 979 | const data = await resp.json(); 980 | return (data.text || '').slice(0, 4); 981 | } 982 | } catch (e) {} 983 | return ''; 984 | }, 985 | async autoFill(force = false) { 986 | if (!this.config.auto && !force) return; 987 | const input = this.getInput(); 988 | if (!input) return; 989 | if (!force && input.value) return; 990 | const text = await this.recognize(); 991 | if (text) input.value = text; 992 | }, 993 | createRetryBtn() { 994 | const btnId = 'captcha-retry-btn'; 995 | let btn = document.getElementById(btnId); 996 | const row = this.getRow(); 997 | if (!row) return; 998 | if (this.config.auto) { 999 | if (!btn) { 1000 | btn = document.createElement('button'); 1001 | btn.id = btnId; 1002 | btn.textContent = '重新识别'; 1003 | btn.type = 'button'; 1004 | btn.style.cssText = 'margin-left:8px;padding:2px 8px;font-size:12px;background:#007bff;color:#fff;border:none;border-radius:3px;cursor:pointer;'; 1005 | btn.onclick = () => { 1006 | const input = this.getInput(); 1007 | if (input) input.value = ''; 1008 | this.autoFill(true); 1009 | }; 1010 | row.appendChild(btn); 1011 | } 1012 | btn.style.display = ''; 1013 | } else { 1014 | if (btn) btn.style.display = 'none'; 1015 | } 1016 | }, 1017 | init() { 1018 | // 页面验证码出现时自动识别和按钮 1019 | const observer = new MutationObserver(() => { 1020 | const img = this.getImg(); 1021 | if (img && img.src && !img.dataset.captchaProcessed) { 1022 | img.dataset.captchaProcessed = 'true'; 1023 | img.addEventListener('load', () => setTimeout(() => this.autoFill(), 300)); 1024 | if (img.complete) setTimeout(() => this.autoFill(), 300); 1025 | } 1026 | this.createRetryBtn(); 1027 | }); 1028 | observer.observe(document.body, { childList: true, subtree: true }); 1029 | setTimeout(() => { 1030 | this.autoFill(); 1031 | this.createRetryBtn(); 1032 | }, 1000); 1033 | } 1034 | }; 1035 | 1036 | if (location.hostname.includes('authserver.nju.edu.cn')) { 1037 | CaptchaHelper.init(); 1038 | } 1039 | 1040 | // ======= 课件批量下载(API获取)======= 1041 | if (location.hostname === 'lms.nju.edu.cn' && location.pathname.includes('/course/')) { 1042 | const btn = document.createElement('button'); 1043 | btn.textContent = '📥 下载全部课件'; 1044 | btn.style.cssText = 'position:fixed;bottom:80px;right:20px;z-index:9999;padding:12px 20px;background:#28BD6E;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.2)'; 1045 | btn.onclick = async () => { 1046 | // 从URL提取课程ID 1047 | const courseIdMatch = location.pathname.match(/\/course\/(\d+)/); 1048 | if (!courseIdMatch) return alert('无法识别课程ID'); 1049 | const courseId = courseIdMatch[1]; 1050 | 1051 | // 获取sub_course_id(从URL hash或默认为0) 1052 | const hashMatch = location.hash.match(/sub_course_id=(\d+)/); 1053 | const subCourseId = hashMatch ? hashMatch[1] : '0'; 1054 | 1055 | btn.textContent = '⏳ 正在获取课件列表...'; 1056 | btn.disabled = true; 1057 | 1058 | try { 1059 | // 调用API获取所有活动 1060 | const response = await fetch(`/api/courses/${courseId}/activities?sub_course_id=${subCourseId}`, { 1061 | credentials: 'same-origin', 1062 | headers: { 1063 | 'X-Requested-With': 'XMLHttpRequest' 1064 | } 1065 | }); 1066 | 1067 | if (!response.ok) throw new Error('API请求失败'); 1068 | const data = await response.json(); 1069 | 1070 | // 提取所有课件(type=material)中的uploads 1071 | const allFiles = []; 1072 | if (data.activities) { 1073 | data.activities.forEach(activity => { 1074 | if (activity.type === 'material' && activity.uploads) { 1075 | activity.uploads.forEach(upload => { 1076 | if (upload.reference_id && upload.name) { 1077 | allFiles.push({ 1078 | name: upload.name, 1079 | reference_id: upload.reference_id, 1080 | url: `/api/uploads/reference/${upload.reference_id}/blob`, 1081 | activity_title: activity.title, 1082 | type: upload.type, 1083 | allow_download: upload.allow_download || false 1084 | }); 1085 | } 1086 | }); 1087 | } 1088 | }); 1089 | } 1090 | 1091 | btn.textContent = '📥 下载全部课件'; 1092 | btn.disabled = false; 1093 | 1094 | if (!allFiles.length) return alert('未找到课件'); 1095 | 1096 | // 筛选可下载文件 1097 | const downloadableFiles = allFiles.filter(f => f.allow_download); 1098 | 1099 | // 显示前10个文件名(标注是否可下载) 1100 | const preview = allFiles.slice(0, 10).map((f, i) => 1101 | `${i + 1}. [${f.activity_title}] ${f.name}${f.allow_download ? ' (可下载)' : ''}` 1102 | ).join('\n'); 1103 | const message = `找到 ${allFiles.length} 个文件,其中可下载 ${downloadableFiles.length} 个\n\n前10个文件:\n${preview}${allFiles.length > 10 ? '\n...' : ''}\n\n确认下载 ${downloadableFiles.length} 个可下载文件?`; 1104 | 1105 | if (!confirm(message)) return; 1106 | 1107 | if (!downloadableFiles.length) return alert('没有可下载的文件'); 1108 | 1109 | // 依次下载可下载文件 1110 | downloadableFiles.forEach((file, i) => { 1111 | setTimeout(() => { 1112 | const a = document.createElement('a'); 1113 | a.href = file.url; 1114 | a.download = file.name; 1115 | a.target = '_blank'; 1116 | document.body.appendChild(a); 1117 | a.click(); 1118 | document.body.removeChild(a); 1119 | }, i * 800); 1120 | }); 1121 | 1122 | } catch (error) { 1123 | btn.textContent = '📥 下载全部课件'; 1124 | btn.disabled = false; 1125 | alert('获取课件列表失败: ' + error.message); 1126 | console.error('下载失败:', error); 1127 | } 1128 | }; 1129 | setTimeout(() => document.body.appendChild(btn), 2000); 1130 | } 1131 | 1132 | })(); --------------------------------------------------------------------------------