SuperMoodle

让你的Moodle变聪明。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         SuperMoodle
// @namespace    http://tampermonkey.net/
// @version      7.0.0
// @description  让你的Moodle变聪明。
// @author       Chengyu
// @match        *://moodle.scnu.edu.cn/*
// @match        *://sso.scnu.edu.cn/*
// @match        *://*.scnu.edu.cn/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=scnu.edu.cn
// @license GNU GPLv3
// @grant        unsafeWindow
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const currentUrl = window.location.href;
    const isTopWindow = window.self === window.top;

    // ==========================================
    // 全局设置:油猴账号密码保险箱 (来自 Script 2)
    // ==========================================
    const ssoAccount = GM_getValue('sso_account', '');
    const ssoPassword = GM_getValue('sso_password', '');

    GM_registerMenuCommand('⚙️ 设置/修改 SSO 账号密码 (用于全自动续期)', () => {
        const acc = prompt('请输入华师统一认证账号(学号):', ssoAccount);
        if (acc !== null) {
            const pwd = prompt('请输入统一认证密码:', ssoPassword);
            if (pwd !== null) {
                GM_setValue('sso_account', acc.trim());
                GM_setValue('sso_password', pwd.trim());
                alert('✅ 账号保存成功!');
            }
        }
    });

    // =========================================================================
    // 模块一:SSO 页面处理 (Iframe 或 顶层窗口)
    // =========================================================================
    if (currentUrl.includes('sso.scnu.edu.cn')) {
        // 战场 B:SSO 确认页面 (auth.html) —— 自动点击“确定登录”
        if (currentUrl.includes('openapi/auth.html') && !isTopWindow) {
            console.log("%c[SSO 黑客] 🥷 发现确认登录页!正在强行点击确定...", "color: #00bcd4; font-weight: bold; background: #222;");
            if (typeof unsafeWindow.gotoApp === 'function') {
                unsafeWindow.gotoApp();
            } else {
                const confirmBtn = document.querySelector('a[href*="gotoApp"]');
                if (confirmBtn) confirmBtn.click();
            }
        }

        // 战场 C:SSO 登录页面 (user/login.html) —— 自动填表并提交
        if (currentUrl.includes('user/login.html') && !isTopWindow) {
            if (!ssoAccount || !ssoPassword) {
                console.warn('[SSO 突击队] 缺少账号密码,无法自动爆破登录!请在油猴菜单栏设置。');
                return;
            }
            console.log("%c[SSO 突击队] 💣 发现登录表单,正在填弹...", "color: #ff9800; font-weight: bold; background: #222;");
            setTimeout(() => {
                const accInput = document.getElementById('account');
                const pwdInput = document.getElementById('password');
                const loginBtn = document.getElementById('btn-password-login');

                if (accInput && pwdInput && loginBtn) {
                    accInput.value = ssoAccount;
                    pwdInput.value = ssoPassword;
                    console.log(`%c[SSO 突击队] 账号注入成功,引爆表单!`, "color: #f44336; font-weight: bold;");
                    loginBtn.click();
                }
            }, 800);
        }
        return; // SSO 页面处理完毕,终止后续无关逻辑
    }

    // =========================================================================
    // 模块二:H5P Iframe 嵌套处理 (后台潜伏逻辑)
    // =========================================================================
    if (!isTopWindow) {
        const isH5PIframe = window.name === 'h5player' || currentUrl.includes('h5p');
        if (isH5PIframe) {
            const TARGET_PROGRESS = 0.95;
            const CHECK_INTERVAL = 1000;

            function getAllVideos(doc) {
                let vids = Array.from(doc.querySelectorAll('video'));
                const iframes = doc.querySelectorAll('iframe');
                iframes.forEach(ifr => {
                    try {
                        if (ifr.contentDocument) vids = vids.concat(getAllVideos(ifr.contentDocument));
                    } catch (e) {}
                });
                return vids;
            }

            let videoInterval = setInterval(() => {
                const videos = getAllVideos(document);
                if (videos.length === 0) {
                    window.top.postMessage({ type: 'H5P_VIDEO_LOADING', msg: '穿透寻找内层视频...' }, '*');
                    return;
                }

                let mainVideo = null;
                let maxDuration = 0;
                videos.forEach(v => {
                    const d = v.duration;
                    if (d && !isNaN(d) && d > maxDuration) {
                        maxDuration = d;
                        mainVideo = v;
                    }
                });

                if (!mainVideo || maxDuration === 0) {
                    window.top.postMessage({ type: 'H5P_VIDEO_LOADING', msg: '等待 CDN 缓冲...' }, '*');
                    return;
                }

                if (mainVideo.paused) {
                    mainVideo.play().catch(e => {
                        mainVideo.muted = true;
                        mainVideo.play();
                    });
                }

                const currentTime = mainVideo.currentTime || 0;
                const progress = currentTime / maxDuration;

                window.top.postMessage({ type: 'H5P_VIDEO_PROGRESS', progress: progress, duration: maxDuration }, '*');

                if (progress >= TARGET_PROGRESS) clearInterval(videoInterval);
            }, CHECK_INTERVAL);
        }
        return; // 子页面逻辑结束
    }

    // =========================================================================
    // ↓↓↓ 以下全部为 Moodle 顶层窗口 (Top Window) 逻辑 ↓↓↓
    // =========================================================================
    if (!currentUrl.includes('moodle.scnu.edu.cn')) return;

    // ==========================================
    // 模块三:后台保活机制 (Moodle Session)
    // ==========================================
    if (!currentUrl.includes('login/index.php')) {
        const CHECK_INTERVAL = 3 * 60 * 1000;
        const TIMEOUT_MS = 5000;
        const MOODLE_SSO_ENTRY = 'https://sso.scnu.edu.cn/AccountService/openapi/auth.html?client_id=3f86b543c74eed80e7d72658699f6345&response_type=code&redirect_url=https://moodle.scnu.edu.cn/auth/sso/login.php';

        console.log("%c[Moodle 综合版] 🚀 侦察与保活模块已挂载!", "color: #00e676; font-weight: bold; background: #222; padding: 4px;");

        function showReviveToast() {
            const toast = document.createElement('div');
            toast.id = 'moodle-ghost-toast';
            toast.className = 'alert alert-success alert-block fade in alert-dismissible';
            toast.style.cssText = 'position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 999999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-weight: bold; padding-right: 40px;';
            toast.innerHTML = `
                🚀 <strong>系统提示:</strong>刚刚检测到您的登录已失效,插件已在后台为您<strong>狠狠敲打Moodle</strong>!
                <br>
                <span style="font-weight: normal; font-size: 0.9em;">如果发现页面上的按钮点击无效或报错,请点击 <a href="javascript:window.location.reload();" style="text-decoration: underline; color: #155724; font-weight: bold;">刷新当前页面</a></span>
                <button type="button" class="close" data-dismiss="alert" aria-label="Close" onclick="this.parentElement.remove();" style="position: absolute; top: 0; right: 0; padding: 0.75rem 1.25rem; color: inherit; background: transparent; border: 0; cursor: pointer; font-size: 1.5rem; line-height: 1;">
                    <span aria-hidden="true">&times;</span>
                </button>
            `;
            document.body.appendChild(toast);
            setTimeout(() => { if (document.getElementById('moodle-ghost-toast')) document.getElementById('moodle-ghost-toast').remove(); }, 10000);
        }

        async function checkSessionAlive() {
            try {
                const res = await fetch('/my/?_=' + Date.now(), { cache: 'no-store', redirect: 'follow' });
                const html = await res.text();
                const isDead = html.includes('<a target="_self" href="https://moodle.scnu.edu.cn/login/index.php">Moodle Login Page</a>') || html.includes('login/index.php');

                if (isDead) {
                    console.warn(`%c[Moodle 警报] 🚨 抓到死亡指纹!确认掉线!召唤隐藏 Iframe...`, "color: #ff5722; font-weight: bold;");
                    triggerSilentRenew();
                }
            } catch (e) { console.error('[Moodle 网络异常]', e); }
        }

        function triggerSilentRenew() {
            const ghostFrame = document.createElement('iframe');
            ghostFrame.style.display = 'none';
            ghostFrame.id = 'ghost-sso-frame';
            ghostFrame.src = MOODLE_SSO_ENTRY;
            document.body.appendChild(ghostFrame);

            let renewSuccess = false;
            const pollTimer = setInterval(async () => {
                try {
                    const frameUrl = ghostFrame.contentWindow.location.href;
                    if (frameUrl && frameUrl.includes('moodle.scnu.edu.cn')) {
                        clearInterval(pollTimer);
                        renewSuccess = true;
                        setTimeout(async () => {
                            await updatePageSesskey();
                            console.log(`%c[Moodle 奇迹] 🎯 Iframe 杀穿 SSO 归来!接应成功!`, "color: #00bcd4; font-weight: bold; font-size: 15px; background: #333; padding: 4px;");
                            showReviveToast();
                            ghostFrame.remove();
                        }, 800);
                    }
                } catch (e) {}
            }, 1000);

            setTimeout(() => {
                if (!renewSuccess) {
                    clearInterval(pollTimer);
                    console.error("%c[Moodle 彻底失败] ❌ 终极爆破超时!", "color: #f44336; font-weight: bold;");
                    ghostFrame.remove();
                }
            }, TIMEOUT_MS);
        }

        async function updatePageSesskey() {
            try {
                const res = await fetch('/my/?_=' + Date.now(), { cache: 'no-store' });
                const html = await res.text();
                const sesskeyMatch = html.match(/"sesskey":"([^"]+)"/);
                if (sesskeyMatch && sesskeyMatch[1] && unsafeWindow.M && unsafeWindow.M.cfg) {
                    unsafeWindow.M.cfg.sesskey = sesskeyMatch[1];
                }
            } catch (e) {}
        }

        unsafeWindow.forceGhostReauth = triggerSilentRenew;
        setTimeout(checkSessionAlive, 2000);
        setInterval(checkSessionAlive, CHECK_INTERVAL);
    }

    // ==========================================
    // 模块四:音频下载助手
    // ==========================================
    if (currentUrl.includes('mod/resource/view.php')) {
        window.addEventListener('load', function() {
            let audioSrc = "";
            const sourceTag = document.querySelector('audio.vjs-tech source');
            const fallbackLink = document.querySelector('.mediafallbacklink');

            if (sourceTag) audioSrc = sourceTag.src;
            else if (fallbackLink) audioSrc = fallbackLink.href;

            if (!audioSrc) return;

            const titleElement = document.querySelector('h1.h2') || document.querySelector('h2');
            const fileName = titleElement ? titleElement.innerText.trim() + ".mp3" : "audio_download.mp3";

            const downloadBtn = document.createElement('button');
            downloadBtn.innerHTML = '💾 点击下载音频文件';
            downloadBtn.style.cssText = `margin: 15px 0; padding: 10px 20px; background-color: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; font-weight: bold; display: block; box-shadow: 0 2px 5px rgba(0,0,0,0.2);`;

            downloadBtn.onclick = function() {
                fetch(audioSrc)
                    .then(response => response.blob())
                    .then(blob => {
                        const url = window.URL.createObjectURL(blob);
                        const a = document.createElement('a');
                        a.href = url;
                        a.download = fileName;
                        document.body.appendChild(a);
                        a.click();
                        window.URL.revokeObjectURL(url);
                        document.body.removeChild(a);
                    })
                    .catch(() => window.open(audioSrc, '_blank'));
            };

            const header = document.querySelector('.page-header-headings') || document.querySelector('.activity-header');
            if (header) header.appendChild(downloadBtn);
            else document.body.prepend(downloadBtn);
        });
    }

    // ==========================================
    // 模块五:水课自动连播 (H5P / 普通资源)
    // ==========================================
    const isH5PActivity = currentUrl.includes('h5pactivity');
    const isFsResource = currentUrl.includes('fsresource');

    if (isH5PActivity || isFsResource) {
        const STORAGE_KEY = '__gemini_autoplay_enabled__';
        let isEnabled = localStorage.getItem(STORAGE_KEY) === 'true';
        const modeText = isH5PActivity ? "H5P 互动视频" : "普通视频资源";

        // 注入专业版 CSS 样式
        const style = document.createElement('style');
        style.innerHTML = `
            #gemini-ui-container { position: fixed; top: 80px; right: 20px; width: 290px; background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; color: #e2e8f0; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; z-index: 999999; box-shadow: 0 10px 30px rgba(0,0,0,0.5); overflow: hidden; transition: all 0.3s ease; }
            .gemini-header { display: flex; align-items: center; padding: 12px 16px; background: rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255,255,255,0.05); font-weight: 600; font-size: 15px; letter-spacing: 0.5px; }
            .gemini-dot { width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; margin-right: 10px; box-shadow: 0 0 8px transparent; transition: all 0.3s; }
            .gemini-dot.active { background: #10b981; box-shadow: 0 0 8px #10b981; }
            .gemini-dot.warning { background: #fbbf24; box-shadow: 0 0 8px #fbbf24; }
            .gemini-body { padding: 16px; line-height: 1.6; min-height: 70px; }
            .gemini-footer { display: flex; gap: 10px; padding: 12px 16px; background: rgba(0, 0, 0, 0.2); border-top: 1px solid rgba(255,255,255,0.05); }
            .gemini-btn { flex: 1; padding: 8px 0; border: none; border-radius: 6px; font-weight: 500; cursor: pointer; transition: all 0.2s; font-size: 13px; }
            .gemini-btn-primary { background: #0ea5e9; color: #fff; } .gemini-btn-primary:hover { background: #0284c7; }
            .gemini-btn-secondary { background: #475569; color: #fff; } .gemini-btn-secondary:hover { background: #334155; }
            .gemini-btn-danger { background: #ef4444; color: #fff; } .gemini-btn-danger:hover { background: #dc2626; }
            .gemini-highlight { color: #38bdf8; font-weight: bold; } .gemini-success { color: #10b981; font-weight: bold; } .gemini-warning { color: #fbbf24; font-weight: bold; }
        `;
        document.head.appendChild(style);

        const uiBox = document.createElement('div');
        uiBox.id = 'gemini-ui-container';
        document.body.appendChild(uiBox);

        function renderUI() {
            if (isEnabled) {
                uiBox.innerHTML = `
                    <div class="gemini-header"><span class="gemini-dot active" id="gemini-status-dot"></span><span class="gemini-title">自动连播 (运行中)</span></div>
                    <div class="gemini-body" id="gemini-status-text">正在初始化监控...<br><span style="color:#94a3b8;font-size:12px;">模式: ${modeText}</span></div>
                    <div class="gemini-footer"><button id="btn-stop" class="gemini-btn gemini-btn-danger">停止连播</button></div>
                `;
                document.getElementById('btn-stop').addEventListener('click', () => {
                    localStorage.setItem(STORAGE_KEY, 'false');
                    isEnabled = false;
                    location.reload();
                });
            } else {
                uiBox.innerHTML = `
                    <div class="gemini-header"><span class="gemini-dot"></span><span class="gemini-title">自动连播 (已暂停)</span></div>
                    <div class="gemini-body">检测到: <span class="gemini-highlight">${modeText}</span><br><span style="color:#94a3b8;font-size:13px;margin-top:5px;display:block;">点击启用后,后续剧集将全自动连播,无需再次确认。</span></div>
                    <div class="gemini-footer">
                        <button id="btn-start" class="gemini-btn gemini-btn-primary">🚀 启用连播</button>
                        <button id="btn-close" class="gemini-btn gemini-btn-secondary">关闭</button>
                    </div>
                `;
                document.getElementById('btn-start').addEventListener('click', () => {
                    localStorage.setItem(STORAGE_KEY, 'true');
                    isEnabled = true;
                    renderUI();
                    startScript();
                });
                document.getElementById('btn-close').addEventListener('click', () => { uiBox.style.display = 'none'; });
            }
        }

        function updateStatus(html, isWarning = false) {
            const statusEl = document.getElementById('gemini-status-text');
            const dotEl = document.getElementById('gemini-status-dot');
            if (statusEl) statusEl.innerHTML = html;
            if (dotEl) dotEl.className = isWarning ? 'gemini-dot warning' : 'gemini-dot active';
        }

        renderUI();
        if (isEnabled) startScript();

        function getMoodleId(url) {
            const match = url.match(/[?&]id=(\d+)/);
            return match ? match[1] : null;
        }

        function jumpToNextId(reason = "视频看完啦!") {
            const currentId = getMoodleId(currentUrl);
            if (!currentId) { updateStatus(`<span class="gemini-warning">⚠️ 网址中未发现 id 参数,无法跳转</span>`, true); return; }
            const nextId = parseInt(currentId) + 1;
            const newUrl = currentUrl.replace(/([?&]id=)(\d+)/, (match, p1, p2) => p1 + nextId);

            updateStatus(`<span class="gemini-success">✅ ${reason}</span><br>当前ID: ${currentId}<br>即将跳转: <span class="gemini-highlight">${nextId}</span><br><span style="color:#94a3b8;font-size:12px;">(2秒后自动执行...)</span>`);
            setTimeout(() => { window.location.href = newUrl; }, 2000);
        }

        function startScript() {
            if (isH5PActivity) {
                const TARGET_PROGRESS = 0.95;
                const NO_VIDEO_JUMP_DELAY = 8000;
                let hasReceivedVideoMsg = false;

                window.addEventListener('message', (event) => {
                    if (event.data && event.data.type === 'H5P_VIDEO_PROGRESS') {
                        hasReceivedVideoMsg = true;
                        const percent = (event.data.progress * 100).toFixed(1);
                        const totalMins = (event.data.duration / 60).toFixed(1);
                        updateStatus(`<span class="gemini-highlight">▶ 正在吃掉讨厌的H5P视频</span><br>视频长度: ${totalMins} 分钟<br>真实进度: <span class="gemini-success">${percent}%</span> (目标:95%)`);
                        if (event.data.progress >= TARGET_PROGRESS) jumpToNextId(`达到 95% 安全进度`);
                    } else if (event.data && event.data.type === 'H5P_VIDEO_LOADING') {
                        hasReceivedVideoMsg = true;
                        updateStatus(`<span class="gemini-warning">⏳ 锁定 H5P 组件</span><br>${event.data.msg}`, true);
                    }
                });

                setTimeout(() => { if (!hasReceivedVideoMsg) jumpToNextId("未探测到有效H5P视频内容,自动跳过"); }, NO_VIDEO_JUMP_DELAY);
            } else if (isFsResource) {
                const CHECK_INTERVAL = 800;
                const JUMP_THRESHOLD = 0.998;
                const NO_VIDEO_WAIT_TIME = 6;
                let hasTriggeredJump = false;
                let noVideoCounter = 0;
                let videoFound = false;

                try { window.alert = function() { return true; }; window.confirm = function() { return true; }; } catch (e) {}

                function getPageProgress() {
                    const specificNodes = document.querySelectorAll('.num-bfjd, .number, .process-num');
                    for (let node of specificNodes) {
                        const text = node.innerText.trim();
                        if (/\d/.test(text) && !text.includes("需要") && !text.includes("达到")) {
                            const num = parseFloat(text);
                            if (!isNaN(num)) return num;
                        }
                    }
                    const bodyText = document.body.innerText;
                    const progressRegex = /(?:播放进度|观看进度|完成度|进度|score).{0,15}?(\d+(?:\.\d+)?)%/gi;
                    let match;
                    while ((match = progressRegex.exec(bodyText)) !== null) {
                        const numStr = match[1], fullMatchStr = match[0], index = match.index;
                        const contextStart = Math.max(0, index - 15);
                        const contextEnd = Math.min(bodyText.length, index + fullMatchStr.length + 20);
                        const context = bodyText.substring(contextStart, contextEnd);
                        if (context.includes("需要") || context.includes("达到") || context.includes("要求")) continue;
                        return parseFloat(numStr);
                    }
                    return null;
                }

                function tryPlayVideo(video) {
                    if (!video) return;
                    const playBtn = document.querySelector('.vjs-big-play-button') || document.querySelector('button[title="Play"]');
                    if (playBtn && playBtn.offsetParent) playBtn.click();
                    else video.play().catch(() => { video.muted = true; video.play(); });
                }

                setInterval(() => {
                    if (hasTriggeredJump) return;
                    const pageProgress = getPageProgress();
                    if (pageProgress !== null && pageProgress >= 100) {
                        hasTriggeredJump = true;
                        jumpToNextId(`检测到页面进度 ${pageProgress}%`);
                        return;
                    }

                    const video = document.querySelector('video');
                    if (!video) {
                        if (document.readyState !== 'complete') return;
                        noVideoCounter++;
                        if (!videoFound) updateStatus(`<span class="gemini-warning">🔍 正在寻找视频标签... (${noVideoCounter}s)</span>`, true);
                        if (noVideoCounter * (CHECK_INTERVAL/1000) >= NO_VIDEO_WAIT_TIME) {
                            hasTriggeredJump = true;
                            jumpToNextId("非视频资源页,自动跳过");
                        }
                        return;
                    }

                    videoFound = true;
                    noVideoCounter = 0;
                    const duration = video.duration || 0;
                    const currentTime = video.currentTime || 0;
                    const percent = duration > 0 ? (currentTime / duration) : 0;
                    const timeLeft = duration - currentTime;

                    const isVideoDone = video.ended || percent > JUMP_THRESHOLD || (duration > 0 && timeLeft < 0.5);

                    if (isVideoDone) {
                        if (pageProgress !== null && pageProgress < 100) {
                            updateStatus(`<span class="gemini-warning">⚠️ 视频已播完,但进度仅 ${pageProgress}%</span><br>正在重播刷时长...`, true);
                            video.currentTime = 0;
                            tryPlayVideo(video);
                            return;
                        }

                        const bodyText = document.body.innerText;
                        if (bodyText.includes("状态为未完成") || bodyText.includes("状态为:未完成")) {
                            updateStatus(`<span class="gemini-warning">⏳ 等待状态变更为“已完成”...</span>`, true);
                            return;
                        }

                        hasTriggeredJump = true;
                        jumpToNextId("视频播放完成");
                    } else {
                        if (video.paused) {
                            updateStatus(`<span class="gemini-warning">⏸️ 异常暂停,尝试恢复...</span>`, true);
                            tryPlayVideo(video);
                        } else {
                            let infoText = `<span class="gemini-highlight">▶ 视频监控中</span><br>播放进度: ${Math.floor(percent * 100)}%`;
                            if (pageProgress !== null) infoText += `<br>页面进度: <span class="gemini-success">${pageProgress}%</span>`;
                            updateStatus(infoText);
                        }
                    }
                }, CHECK_INTERVAL);
            }
        }
    }
})();