Greasy Fork

Greasy Fork is available in English.

Universal Video Downloader (Stealth + Smart Save)

Downloads streams. Features: Stealth mode, Smart Desktop/Mobile handling, Concurrent downloading.

当前为 2025-12-18 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Universal Video Downloader (Stealth + Smart Save)
// @namespace    http://tampermonkey.net/
// @version      9.0
// @description  Downloads streams. Features: Stealth mode, Smart Desktop/Mobile handling, Concurrent downloading.
// @author       Minoa
// @license      MIT
// @match        *://*/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/m3u8-parser.min.js
// @require      https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/ffmpeg.js
// @resource     classWorkerURL  https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/258.ffmpeg.js
// @resource     coreURL         https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js
// @resource     wasmURL         https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.wasm
// @grant        GM_addStyle
// @grant        GM_getResourceURL
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // CONFIGURATION
    // ==========================================
    let floatingButton = null;
    let hiddenToggle = null;
    let debugConsole = null;
    
    // State
    let isHidden = GM_getValue('uvs_hidden', false);
    let isDebug = GM_getValue('uvs_debug', false);
    let pressTimer = null;
    let pressStartTime = 0;
    
    // Download Settings
    const CONCURRENCY = 3;
    const MAX_RETRIES = 3;
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    
    // Data Stores
    const detectedM3U8Urls = new Set();
    const allDetectedVideos = new Map();
    const downloadedBlobs = new Map();
    
    // FFmpeg
    let ffmpegInstance = null;
    let ffmpegLoaded = false;
    let wasmBinaryCache = null;

    const isTwitter = location.hostname.includes('twitter.com') || location.hostname.includes('x.com');

    // Theme
    const THEME = {
        bg: 'rgba(20, 20, 20, 0.6)',
        border: 'rgba(255, 255, 255, 0.1)',
        text: '#ffffff',
        accent: '#d4a373',
        success: '#4ade80',
        error: '#f87171'
    };

    // ==========================================
    // STYLES
    // ==========================================
    GM_addStyle(`
        /* Main Button */
        #uvs-container { position: fixed; top: 15px; left: 15px; width: 46px; height: 46px; z-index: 2147483647; isolation: isolate; pointer-events: auto; transition: opacity 0.3s; }
        #uvs-float { position: absolute; top: 3px; left: 3px; width: 40px; height: 40px; background: ${THEME.bg}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: ${THEME.accent}; border: 1px solid ${THEME.border}; border-radius: 50%; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.2s, background 0.2s; box-shadow: 0 4px 15px rgba(0,0,0,0.3); user-select: none; }
        #uvs-float:hover { transform: scale(1.05); background: rgba(40, 40, 40, 0.8); }
        #uvs-svg { position: absolute; top: 0; left: 0; pointer-events: none; transform: rotate(-90deg); }
        
        /* Stealth Toggle (Tiny Checkbox) */
        #uvs-hidden-toggle {
            position: fixed; top: 5px; right: 5px; width: 12px; height: 12px;
            border: 1px solid rgba(255,255,255,0.3); background: rgba(0,0,0,0.1);
            z-index: 2147483647; cursor: pointer; opacity: 0.1;
            transition: opacity 0.2s, background 0.2s;
            border-radius: 2px;
        }
        #uvs-hidden-toggle:hover { opacity: 0.8; background: rgba(255,255,255,0.1); }
        #uvs-hidden-toggle.checked { background: ${THEME.success}; opacity: 0.6; }

        /* Notifications */
        .uvs-notification { position: fixed; top: 75px; left: 15px; background: ${THEME.bg}; backdrop-filter: blur(12px); color: ${THEME.text}; padding: 10px 16px; border-radius: 12px; border: 1px solid ${THEME.border}; font-family: sans-serif; font-size: 13px; font-weight: 500; z-index: 2147483646; display: flex; align-items: center; gap: 10px; box-shadow: 0 8px 20px rgba(0,0,0,0.25); animation: uvs-slide-in 0.3s ease-out; }
        @keyframes uvs-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
        
        /* Popup */
        #uvs-popup { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 2147483645; display: flex; align-items: center; justify-content: center; }
        #uvs-popup-content { background: rgba(30, 30, 30, 0.85); backdrop-filter: blur(16px); border: 1px solid ${THEME.border}; border-radius: 16px; width: 400px; max-width: 90%; max-height: 80vh; overflow-y: auto; color: #eee; font-family: sans-serif; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
        .uvs-item { padding: 14px 20px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s; }
        .uvs-item:hover { background: rgba(255,255,255,0.1); }
        .uvs-tag { font-size: 10px; padding: 3px 8px; border-radius: 6px; background: rgba(255,255,255,0.1); margin-right: 10px; color: #ccc; }
        
        /* Debug Console */
        #uvs-debug { position: fixed; bottom: 0; left: 0; width: 100%; height: 150px; background: rgba(0,0,0,0.9); color: #0f0; font-family: monospace; font-size: 11px; padding: 10px; overflow-y: auto; z-index: 2147483647; display: none; border-top: 1px solid #333; }
        
        /* Twitter */
        .uvs-tw-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 999px; cursor: pointer; color: rgb(113, 118, 123); transition: 0.2s; }
        .uvs-tw-btn:hover { background: rgba(212, 163, 115, 0.1); color: ${THEME.accent}; }
        .uvs-tw-btn svg { width: 20px; height: 20px; fill: currentColor; }
    `);

    // ==========================================
    // HELPERS
    // ==========================================
    const VIDEO_EXT = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v'];

    function sanitizeFilename(name) {
        return (name || 'video').replace(/[<>:"\/\\|?*\x00-\x1F]/g, '').replace(/\s+/g, '_').substring(0, 150);
    }

    function resolveUrl(baseUrl, relativeUrl) {
        try { return new URL(relativeUrl, baseUrl).href; } catch (e) { return relativeUrl; }
    }

    function log(msg) {
        if (isDebug) {
            console.log('[UVS]', msg);
            if (debugConsole) {
                const line = document.createElement('div');
                line.textContent = `> ${msg}`;
                debugConsole.appendChild(line);
                debugConsole.scrollTop = debugConsole.scrollHeight;
            }
        }
    }

    // ==========================================
    // FFMPEG ENGINE
    // ==========================================
    async function initFFmpeg() {
        if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance;
        notify('⟳ Loading Engine...');
        try {
            if (!wasmBinaryCache) {
                const wasmURL = GM_getResourceURL('wasmURL', false);
                const resp = await fetch(wasmURL);
                wasmBinaryCache = await resp.arrayBuffer();
            }
            ffmpegInstance = new window.FFmpegWASM.FFmpeg();
            ffmpegInstance.on('progress', ({ progress }) => updateProgress(Math.round(progress * 100)));
            await ffmpegInstance.load({
                classWorkerURL: GM_getResourceURL('classWorkerURL', false),
                coreURL: GM_getResourceURL('coreURL', false),
                wasmBinary: wasmBinaryCache,
            });
            ffmpegLoaded = true;
            notify('✓ Engine Ready', 'success');
            return ffmpegInstance;
        } catch(e) {
            notify('✕ Engine Failed', 'error');
            throw e;
        }
    }

    async function convertToMP4(tsBlob, filename) {
        const ffmpeg = await initFFmpeg();
        const inputName = 'input.ts';
        const outputName = filename.endsWith('.mp4') ? filename : filename + '.mp4';
        
        notify('⟳ Converting...');
        try {
            await ffmpeg.writeFile(inputName, new Uint8Array(await tsBlob.arrayBuffer()));
            await ffmpeg.exec(['-i', inputName, '-c', 'copy', '-movflags', 'faststart', outputName]);
            const data = await ffmpeg.readFile(outputName);
            await ffmpeg.deleteFile(inputName);
            await ffmpeg.deleteFile(outputName);
            return new Blob([data.buffer], { type: 'video/mp4' });
        } catch(e) {
            notify('✕ Conversion Failed', 'error');
            throw e;
        }
    }

    // ==========================================
    // NETWORK INTERCEPTOR
    // ==========================================
    function isM3U8(url) { return url && (url.includes('.m3u8') || url.includes('.m3u')); }
    function isVideoUrl(url) {
        if (!url || url.startsWith('blob:')) return false;
        const clean = url.split('?')[0].toLowerCase();
        return VIDEO_EXT.some(ext => clean.endsWith(ext));
    }

    const originalFetch = window.fetch;
    window.fetch = async function(...args) {
        const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
        const response = await originalFetch.apply(this, args);
        if (url && isM3U8(url)) {
            const clone = response.clone();
            clone.text().then(text => handleM3U8Detection(url, text)).catch(() => {});
        } else if (url && isVideoUrl(url)) {
            handleVideoDetection(url);
        }
        return response;
    };

    const originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url) {
        this.addEventListener('load', function() {
            try {
                if (url && isVideoUrl(url)) handleVideoDetection(url);
                if (url && (!this.responseType || this.responseType === 'text')) {
                    if (isM3U8(url) || (this.responseText && this.responseText.trim().startsWith('#EXTM3U'))) {
                        handleM3U8Detection(url, this.responseText);
                    }
                }
            } catch(e) {}
        });
        return originalOpen.apply(this, arguments);
    };

    function handleVideoDetection(url) {
        const fullUrl = resolveUrl(location.href, url);
        if (!allDetectedVideos.has(fullUrl)) {
            log(`Video detected: ${fullUrl}`);
            allDetectedVideos.set(fullUrl, {
                type: 'direct',
                url: fullUrl,
                title: fullUrl.split('/').pop().split('?')[0],
                timestamp: Date.now()
            });
            updateButtonState();
        }
    }

    function handleM3U8Detection(url, content) {
        const fullUrl = resolveUrl(location.href, url);
        if (detectedM3U8Urls.has(fullUrl)) return;
        detectedM3U8Urls.add(fullUrl);

        try {
            const parser = new m3u8Parser.Parser();
            parser.push(content);
            parser.end();
            const manifest = parser.manifest;

            if (manifest.playlists && manifest.playlists.length > 0) return;

            if (manifest.segments && manifest.segments.length > 0) {
                let duration = 0;
                manifest.segments.forEach(s => duration += s.duration);
                log(`Stream detected: ${fullUrl} (${duration}s)`);
                
                allDetectedVideos.set(fullUrl, {
                    type: 'm3u8',
                    url: fullUrl,
                    manifest: manifest,
                    duration: duration,
                    title: `Stream (${Math.ceil(duration)}s)`,
                    timestamp: Date.now()
                });
                
                notify(`✓ Stream Detected (${Math.ceil(duration)}s)`, 'success');
                updateButtonState();
            }
        } catch(e) { log('Error parsing M3U8'); }
    }

    // ==========================================
    // UI & INTERACTION
    // ==========================================
    function createUI() {
        if (floatingButton) return;
        
        // 1. Hidden Toggle (Top Right)
        hiddenToggle = document.createElement('div');
        hiddenToggle.id = 'uvs-hidden-toggle';
        hiddenToggle.title = 'Show Video Downloader';
        if (!isHidden) hiddenToggle.style.display = 'none';
        
        hiddenToggle.onclick = () => {
            isHidden = false;
            GM_setValue('uvs_hidden', false);
            hiddenToggle.style.display = 'none';
            document.getElementById('uvs-container').style.display = 'block';
            notify('Button Restored');
        };
        document.body.appendChild(hiddenToggle);

        // 2. Debug Console
        debugConsole = document.createElement('div');
        debugConsole.id = 'uvs-debug';
        if (isDebug) debugConsole.style.display = 'block';
        document.body.appendChild(debugConsole);

        // 3. Main Button
        const container = document.createElement('div');
        container.id = 'uvs-container';
        if (isHidden) container.style.display = 'none';
        
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('width', '46'); svg.setAttribute('height', '46');
        svg.id = 'uvs-svg';
        
        const track = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        track.setAttribute('cx', '23'); track.setAttribute('cy', '23');
        track.setAttribute('r', '21'); track.setAttribute('fill', 'none');
        track.setAttribute('stroke', THEME.success);
        track.setAttribute('stroke-width', '2');
        track.setAttribute('stroke-opacity', '0.25');
        
        const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        circle.setAttribute('cx', '23'); circle.setAttribute('cy', '23');
        circle.setAttribute('r', '21'); circle.setAttribute('fill', 'none');
        circle.setAttribute('stroke', THEME.success);
        circle.setAttribute('stroke-width', '2');
        circle.setAttribute('stroke-dasharray', '132');
        circle.setAttribute('stroke-dashoffset', '132');
        circle.setAttribute('stroke-linecap', 'round');
        
        svg.appendChild(track);
        svg.appendChild(circle);
        container.appendChild(svg);

        const btn = document.createElement('div');
        btn.id = 'uvs-float';
        btn.innerHTML = '⬇';
        btn.progressCircle = circle;

        // Long Press Logic
        let pressInterval;
        
        const startPress = (e) => {
            if (e.button !== 0 && e.type !== 'touchstart') return; // Only left click
            pressStartTime = Date.now();
            
            pressInterval = setInterval(() => {
                const duration = Date.now() - pressStartTime;
                if (duration > 10000) btn.innerHTML = '🪲'; // Bug
                else if (duration > 5000) btn.innerHTML = '👁️'; // Eye
            }, 100);
        };

        const endPress = (e) => {
            clearInterval(pressInterval);
            const duration = Date.now() - pressStartTime;
            
            // Reset Icon
            updateButtonState();

            if (duration > 10000) {
                // Toggle Debug
                isDebug = !isDebug;
                GM_setValue('uvs_debug', isDebug);
                debugConsole.style.display = isDebug ? 'block' : 'none';
                notify(`Debug Mode: ${isDebug ? 'ON' : 'OFF'}`);
            } else if (duration > 5000) {
                // Hide Button
                isHidden = true;
                GM_setValue('uvs_hidden', true);
                container.style.display = 'none';
                hiddenToggle.style.display = 'block';
                notify('Button Hidden (Check top-right)');
            } else if (duration < 500) {
                // Normal Click
                handleClick();
            }
        };

        btn.addEventListener('mousedown', startPress);
        btn.addEventListener('mouseup', endPress);
        btn.addEventListener('touchstart', startPress);
        btn.addEventListener('touchend', endPress);

        container.appendChild(btn);
        document.body.appendChild(container);
        floatingButton = btn;
    }

    function handleClick() {
        const videos = Array.from(allDetectedVideos.values()).sort((a, b) => b.timestamp - a.timestamp);
        if (videos.length === 0) notify('✕ No videos', 'error');
        else if (videos.length === 1) processVideo(videos[0]);
        else showPopup(videos);
    }

    function updateButtonState() {
        if (allDetectedVideos.size > 0 && !floatingButton) createUI();
        if (floatingButton) floatingButton.innerHTML = allDetectedVideos.size > 1 ? '☰' : '⬇';
    }

    function updateProgress(percent) {
        if (!floatingButton) return;
        floatingButton.progressCircle.setAttribute('stroke-dashoffset', 132 - (132 * percent / 100));
    }

    function notify(msg, type = 'info') {
        const div = document.createElement('div');
        div.className = 'uvs-notification';
        let icon = 'ℹ';
        if (type === 'success') { div.style.borderColor = THEME.success; div.style.color = THEME.success; icon = '✓'; }
        if (type === 'error') { div.style.borderColor = THEME.error; div.style.color = THEME.error; icon = '✕'; }
        div.innerHTML = `<span style="font-size:16px">${icon}</span> <span>${msg}</span>`;
        document.body.appendChild(div);
        setTimeout(() => div.remove(), 3500);
    }

    function showPopup(videos) {
        document.getElementById('uvs-popup')?.remove();
        const overlay = document.createElement('div');
        overlay.id = 'uvs-popup';
        const content = document.createElement('div');
        content.id = 'uvs-popup-content';
        
        const header = document.createElement('div');
        header.style.padding = '15px 20px'; header.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; header.style.fontWeight = 'bold';
        header.innerText = `Detected Videos (${videos.length})`;
        content.appendChild(header);

        videos.forEach(v => {
            const row = document.createElement('div');
            row.className = 'uvs-item';
            row.innerHTML = `<div><span class="uvs-tag">${v.type === 'm3u8' ? 'STREAM' : 'MP4'}</span><span>${v.title}</span></div><div>⬇</div>`;
            row.onclick = () => { overlay.remove(); processVideo(v); };
            content.appendChild(row);
        });

        const close = document.createElement('div');
        close.style.padding = '12px'; close.style.textAlign = 'center'; close.style.cursor = 'pointer'; close.style.fontSize = '12px'; close.style.opacity = '0.7';
        close.innerText = 'Close';
        close.onclick = () => overlay.remove();
        content.appendChild(close);

        overlay.appendChild(content);
        document.body.appendChild(overlay);
        overlay.onclick = (e) => { if(e.target === overlay) overlay.remove(); };
    }

    async function processVideo(video, customName = null) {
        const filename = customName || sanitizeFilename(document.title);
        if (video.type === 'direct') {
            handleFinalOutput(null, video.url, filename);
        } else {
            downloadM3U8(video, filename);
        }
    }

    // ==========================================
    // DOWNLOAD & OUTPUT
    // ==========================================
    async function downloadM3U8(video, filename) {
        if (downloadedBlobs.has(video.url)) return handleFinalOutput(downloadedBlobs.get(video.url), null, filename);

        notify('⟳ Downloading...');
        updateProgress(0);

        try {
            const segments = video.manifest.segments;
            const baseUrl = video.url;
            const results = new Array(segments.length);
            let completed = 0;
            let currentIndex = 0;
            let hasError = false;

            const worker = async () => {
                while (currentIndex < segments.length && !hasError) {
                    const i = currentIndex++;
                    const segUrl = resolveUrl(baseUrl, segments[i].uri);
                    let attempts = 0;
                    let success = false;
                    while(attempts < MAX_RETRIES && !success) {
                        try {
                            const res = await fetch(segUrl);
                            if (!res.ok) throw new Error(`Status ${res.status}`);
                            results[i] = await res.arrayBuffer();
                            success = true;
                        } catch(e) {
                            attempts++;
                            if (attempts === MAX_RETRIES) { hasError = true; throw e; }
                            await new Promise(r => setTimeout(r, 1000));
                        }
                    }
                    completed++;
                    updateProgress(Math.round((completed / segments.length) * 100));
                }
            };

            const workers = [];
            for (let k = 0; k < CONCURRENCY; k++) workers.push(worker());
            await Promise.all(workers);

            if (hasError) throw new Error("Network errors");

            notify('⟳ Stitching...');
            const mergedBlob = new Blob(results, { type: 'video/mp2t' });
            const mp4Blob = await convertToMP4(mergedBlob, filename);
            downloadedBlobs.set(video.url, mp4Blob);
            
            handleFinalOutput(mp4Blob, null, filename);
            updateProgress(0);
            notify('✓ Complete', 'success');

        } catch(e) {
            log(e);
            notify('✕ Error: ' + e.message, 'error');
            updateProgress(0);
        }
    }

    function handleFinalOutput(blob, url, filename) {
        const finalName = filename.endsWith('.mp4') ? filename : filename + '.mp4';

        // 1. Mobile: Share
        if (isMobile && navigator.share && blob) {
            const file = new File([blob], finalName, { type: 'video/mp4' });
            if (navigator.canShare && navigator.canShare({ files: [file] })) {
                navigator.share({ files: [file], title: finalName }).catch(e => log('Share failed: ' + e));
                return;
            }
        }

        // 2. Desktop: Auto Download
        if (!isMobile) {
            try {
                const downloadUrl = blob ? URL.createObjectURL(blob) : url;
                const a = document.createElement('a');
                a.href = downloadUrl;
                a.download = finalName;
                document.body.appendChild(a);
                a.click();
                setTimeout(() => { 
                    document.body.removeChild(a); 
                    if(blob) URL.revokeObjectURL(downloadUrl); 
                }, 1000);
                notify('✓ Saved to Disk', 'success');
                return;
            } catch(e) {
                log('Download failed, trying clipboard');
            }
        }

        // 3. Fallback: Clipboard
        const textToCopy = url || "Video Blob (Cannot copy blob URL)";
        navigator.clipboard.writeText(textToCopy).then(() => {
            notify('✓ Link Copied', 'success');
        }).catch(() => {
            notify('✕ Save Failed', 'error');
        });
    }

    if (isTwitter) {
        const obs = new MutationObserver(() => {
            document.querySelectorAll('article[data-testid="tweet"]').forEach(tweet => {
                const grp = tweet.querySelector('div[role="group"]');
                if (!grp || grp.querySelector('.uvs-tw-btn')) return;
                const btn = document.createElement('div'); btn.className = 'uvs-tw-btn';
                btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 16 17.7 10.3 16.29 8.88 13 12.18 V2.59 h-2 v9.59 L7.7 8.88 6.29 10.3 Z M21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"></path></svg>`;
                btn.onclick = (e) => {
                    e.stopPropagation();
                    const vids = Array.from(allDetectedVideos.values()).filter(v => v.type === 'm3u8');
                    if(vids.length) processVideo(vids[0], sanitizeFilename(`twitter_${Date.now()}`));
                    else notify('ℹ Play video first', 'info');
                };
                grp.appendChild(btn);
            });
        });
        obs.observe(document.body, { childList: true, subtree: true });
    }

})();