Telegram Media Downloader (Batch Support) (by AFU IT) v1.2

Download images, GIFs, videos, and voice messages from private channels + batch download selected media

当前为 2025-06-05 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Telegram Media Downloader (Batch Support) (by AFU IT) v1.2
// @name:en      Telegram Media Downloader (Batch Support) (by AFU IT) v1.2
// @version      1.2
// @description  Download images, GIFs, videos, and voice messages from private channels + batch download selected media
// @author       AFU IT
// @license      GNU GPLv3
// @telegram     https://t.me/afuituserscript
// @match        https://web.telegram.org/*
// @match        https://webk.telegram.org/*
// @match        https://webz.telegram.org/*
// @icon         https://img.icons8.com/color/452/telegram-app--v5.png
// @grant        none
// @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader
// ==/UserScript==

(function() {
    'use strict';

    // Enhanced Logger
    const logger = {
        info: (message, fileName = null) => {
            console.log(`[TG-Silent] ${fileName ? `${fileName}: ` : ""}${message}`);
        },
        error: (message, fileName = null) => {
            console.error(`[TG-Silent] ${fileName ? `${fileName}: ` : ""}${message}`);
        },
        warn: (message, fileName = null) => {
            console.warn(`[TG-Silent] ${fileName ? `${fileName}: ` : ""}${message}`);
        }
    };

    const hashCode = (s) => {
        var h = 0, l = s.length, i = 0;
        if (l > 0) {
            while (i < l) {
                h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
            }
        }
        return h >>> 0;
    };

    // Progress tracking
    let batchProgress = {
        current: 0,
        total: 0,
        container: null
    };

    // Create batch progress bar
    const createBatchProgress = () => {
        if (document.getElementById('tg-batch-progress')) return;

        const progressContainer = document.createElement('div');
        progressContainer.id = 'tg-batch-progress';
        progressContainer.style.cssText = `
            position: fixed;
            bottom: 100px;
            right: 20px;
            width: 280px;
            background: rgba(0,0,0,0.9);
            color: white;
            padding: 12px 16px;
            border-radius: 12px;
            z-index: 999998;
            display: none;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            box-shadow: 0 4px 20px rgba(0,0,0,0.3);
        `;

        const progressText = document.createElement('div');
        progressText.id = 'tg-batch-progress-text';
        progressText.style.cssText = `
            margin-bottom: 8px;
            font-size: 13px;
            font-weight: 500;
        `;

        const progressBarBg = document.createElement('div');
        progressBarBg.style.cssText = `
            width: 100%;
            height: 4px;
            background: rgba(255,255,255,0.2);
            border-radius: 2px;
            overflow: hidden;
        `;

        const progressBarFill = document.createElement('div');
        progressBarFill.id = 'tg-batch-progress-fill';
        progressBarFill.style.cssText = `
            height: 100%;
            background: #8774e1;
            width: 0%;
            transition: width 0.3s ease;
            border-radius: 2px;
        `;

        progressBarBg.appendChild(progressBarFill);
        progressContainer.appendChild(progressText);
        progressContainer.appendChild(progressBarBg);
        document.body.appendChild(progressContainer);

        batchProgress.container = progressContainer;
    };

    // Update batch progress
    const updateBatchProgress = (current, total, text) => {
        const progressText = document.getElementById('tg-batch-progress-text');
        const progressFill = document.getElementById('tg-batch-progress-fill');
        const container = batchProgress.container;

        if (progressText && progressFill && container) {
            progressText.textContent = text || `Processing ${current}/${total}...`;
            const percent = total > 0 ? (current / total) * 100 : 0;
            progressFill.style.width = `${percent}%`;
            container.style.display = 'block';

            if (current >= total && total > 0) {
                setTimeout(() => {
                    container.style.display = 'none';
                }, 3000);
            }
        }
    };

    // Silent download functions
    const tel_download_image = (imageUrl) => {
        const fileName = (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg";
        const a = document.createElement("a");
        document.body.appendChild(a);
        a.href = imageUrl;
        a.download = fileName;
        a.click();
        document.body.removeChild(a);
        logger.info("Image download triggered", fileName);
    };

    const tel_download_video = (url) => {
        return new Promise((resolve, reject) => {
            fetch(url)
            .then(response => response.blob())
            .then(blob => {
                const fileName = (Math.random() + 1).toString(36).substring(2, 10) + ".mp4";
                const blobUrl = window.URL.createObjectURL(blob);

                const a = document.createElement("a");
                document.body.appendChild(a);
                a.href = blobUrl;
                a.download = fileName;
                a.click();
                document.body.removeChild(a);
                window.URL.revokeObjectURL(blobUrl);

                logger.info("Video download triggered", fileName);
                resolve();
            })
            .catch(error => {
                logger.error("Video download failed", error);
                reject(error);
            });
        });
    };

    // Prevent media viewer from opening
    const preventMediaViewerOpen = () => {
        document.addEventListener('click', (e) => {
            const target = e.target;

            if (window.isDownloadingBatch &&
                (target.closest('.album-item') || target.closest('.media-container'))) {

                const albumItem = target.closest('.album-item');
                if (albumItem && albumItem.querySelector('.video-time')) {
                    logger.info('Preventing video popup during batch download');
                    e.preventDefault();
                    e.stopPropagation();
                    e.stopImmediatePropagation();
                    return false;
                }
            }
        }, true);
    };

    // Function to construct video URL from data-mid
    const constructVideoUrl = (dataMid, peerId) => {
        const patterns = [
            `stream/${encodeURIComponent(JSON.stringify({
                dcId: 5,
                location: {
                    _: "inputDocumentFileLocation",
                    id: dataMid,
                    access_hash: "0",
                    file_reference: []
                },
                mimeType: "video/mp4",
                fileName: `video_${dataMid}.mp4`
            }))}`,
            `stream/${dataMid}`,
            `video/${dataMid}`,
            `media/${dataMid}`
        ];

        return patterns[0];
    };

    // Function to get video URL without opening media viewer
    const getVideoUrlSilently = async (albumItem, dataMid) => {
        logger.info(`Getting video URL silently for data-mid: ${dataMid}`);

        const existingVideo = document.querySelector(`video[src*="${dataMid}"], video[data-mid="${dataMid}"]`);
        if (existingVideo && (existingVideo.src || existingVideo.currentSrc)) {
            const videoUrl = existingVideo.src || existingVideo.currentSrc;
            logger.info(`Found existing video URL: ${videoUrl}`);
            return videoUrl;
        }

        const peerId = albumItem.getAttribute('data-peer-id');
        const constructedUrl = constructVideoUrl(dataMid, peerId);
        logger.info(`Constructed video URL: ${constructedUrl}`);

        try {
            const response = await fetch(constructedUrl, { method: 'HEAD' });
            if (response.ok) {
                logger.info('Constructed URL is valid');
                return constructedUrl;
            }
        } catch (error) {
            logger.warn('Constructed URL test failed, will try alternative method');
        }

        return new Promise((resolve) => {
            logger.info('Trying silent click method...');

            window.isDownloadingBatch = true;

            const mediaViewers = document.querySelectorAll('.media-viewer-whole, .media-viewer');
            mediaViewers.forEach(viewer => {
                viewer.style.display = 'none';
                viewer.style.visibility = 'hidden';
                viewer.style.pointerEvents = 'none';
            });

            const clickEvent = new MouseEvent('click', {
                bubbles: false,
                cancelable: true,
                view: window
            });

            albumItem.dispatchEvent(clickEvent);

            setTimeout(() => {
                const video = document.querySelector('video');
                if (video && (video.src || video.currentSrc)) {
                    const videoUrl = video.src || video.currentSrc;
                    logger.info(`Found video URL via silent click: ${videoUrl}`);

                    const mediaViewer = document.querySelector('.media-viewer-whole');
                    if (mediaViewer) {
                        mediaViewer.style.display = 'none';
                        mediaViewer.style.visibility = 'hidden';
                        mediaViewer.style.opacity = '0';
                        mediaViewer.style.pointerEvents = 'none';

                        const escapeEvent = new KeyboardEvent('keydown', {
                            key: 'Escape',
                            code: 'Escape',
                            keyCode: 27,
                            which: 27,
                            bubbles: true
                        });
                        document.dispatchEvent(escapeEvent);
                    }

                    window.isDownloadingBatch = false;
                    resolve(videoUrl);
                } else {
                    logger.warn('Could not get video URL, using fallback');
                    window.isDownloadingBatch = false;
                    resolve(constructedUrl);
                }
            }, 100);
        });
    };

    // Get count of selected messages (not individual media items)
    const getSelectedMessageCount = () => {
        const selectedBubbles = document.querySelectorAll('.bubble.is-selected');
        return selectedBubbles.length;
    };

    // Get all media URLs from selected bubbles
    const getSelectedMediaUrls = async () => {
        const mediaUrls = [];
        const selectedBubbles = document.querySelectorAll('.bubble.is-selected');

        let processedCount = 0;
        const totalBubbles = selectedBubbles.length;

        window.isDownloadingBatch = true;

        for (const bubble of selectedBubbles) {
            logger.info('Processing bubble:', bubble.className);

            const albumItems = bubble.querySelectorAll('.album-item.is-selected');

            if (albumItems.length > 0) {
                logger.info(`Found album with ${albumItems.length} items`);

                for (let index = 0; index < albumItems.length; index++) {
                    const albumItem = albumItems[index];
                    const dataMid = albumItem.getAttribute('data-mid');

                    updateBatchProgress(processedCount, totalBubbles * 2, `Analyzing album item ${index + 1}...`);

                    const videoTime = albumItem.querySelector('.video-time');
                    const playButton = albumItem.querySelector('.btn-circle.video-play');
                    const isVideo = videoTime && playButton;

                    const mediaPhoto = albumItem.querySelector('.media-photo');

                    if (isVideo) {
                        logger.info(`Album item ${index + 1} is a VIDEO (duration: "${videoTime.textContent}")`);

                        const videoUrl = await getVideoUrlSilently(albumItem, dataMid);

                        if (videoUrl) {
                            mediaUrls.push({
                                type: 'video',
                                url: videoUrl,
                                dataMid: dataMid
                            });
                        }
                    } else if (mediaPhoto && mediaPhoto.src && !mediaPhoto.src.includes('data:')) {
                        logger.info(`Album item ${index + 1} is an IMAGE`);
                        mediaUrls.push({
                            type: 'image',
                            url: mediaPhoto.src,
                            dataMid: dataMid
                        });
                    }

                    await new Promise(resolve => setTimeout(resolve, 50));
                }
            } else {
                updateBatchProgress(processedCount, totalBubbles, `Processing single media...`);

                const videos = bubble.querySelectorAll('.media-video, video');
                let hasVideo = false;

                videos.forEach(video => {
                    const videoSrc = video.src || video.currentSrc;
                    if (videoSrc && !videoSrc.includes('data:')) {
                        mediaUrls.push({
                            type: 'video',
                            url: videoSrc
                        });
                        hasVideo = true;
                        logger.info('Found single video:', videoSrc);
                    }
                });

                if (!hasVideo) {
                    const images = bubble.querySelectorAll('.media-photo');
                    images.forEach(img => {
                        const isVideoThumbnail = img.closest('.media-video') ||
                                               img.closest('video') ||
                                               bubble.querySelector('.video-time') ||
                                               bubble.querySelector('.btn-circle.video-play');

                        if (!isVideoThumbnail && img.src && !img.src.includes('data:')) {
                            mediaUrls.push({
                                type: 'image',
                                url: img.src
                            });
                            logger.info('Found single image:', img.src);
                        }
                    });
                }
            }

            processedCount++;
        }

        window.isDownloadingBatch = false;

        logger.info(`Total media found: ${mediaUrls.length}`);
        return mediaUrls;
    };

    // Show Telegram-style stay on page warning
    const showStayOnPageWarning = () => {
        const existingWarning = document.getElementById('tg-stay-warning');
        if (existingWarning) return;

        // Check if dark mode is enabled
        const isDarkMode = document.querySelector("html").classList.contains("night") ||
                          document.querySelector("html").classList.contains("theme-dark") ||
                          document.body.classList.contains("night") ||
                          document.body.classList.contains("theme-dark");

        const warning = document.createElement('div');
        warning.id = 'tg-stay-warning';
        warning.style.cssText = `
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: ${isDarkMode ? 'var(--color-background-secondary, #212121)' : 'var(--color-background-secondary, #ffffff)'};
            color: ${isDarkMode ? 'var(--color-text, #ffffff)' : 'var(--color-text, #000000)'};
            padding: 16px 20px;
            border-radius: 12px;
            z-index: 999999;
            font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
            font-size: 14px;
            font-weight: 400;
            box-shadow: 0 4px 16px rgba(0, 0, 0, ${isDarkMode ? '0.4' : '0.15'});
            border: 1px solid ${isDarkMode ? 'var(--color-borders, #3e3e3e)' : 'var(--color-borders, #e4e4e4)'};
            max-width: 320px;
            animation: slideDown 0.3s ease;
        `;

        warning.innerHTML = `
            <div style="display: flex; align-items: flex-start; gap: 12px;">
                <div style="
                    width: 20px;
                    height: 20px;
                    border-radius: 50%;
                    background: var(--color-primary, #8774e1);
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    flex-shrink: 0;
                    margin-top: 1px;
                ">
                    <span style="color: white; font-size: 12px; font-weight: bold;">!</span>
                </div>
                <div style="flex: 1;">
                    <div style="font-weight: 500; margin-bottom: 4px;">Downloading Media</div>
                    <div style="opacity: 0.7; font-size: 13px; line-height: 1.4;">Please stay on this page while the download is in progress.</div>
                </div>
                <button onclick="this.closest('#tg-stay-warning').remove()" style="
                    background: none;
                    border: none;
                    color: ${isDarkMode ? 'var(--color-text-secondary, #aaaaaa)' : 'var(--color-text-secondary, #707579)'};
                    cursor: pointer;
                    font-size: 18px;
                    padding: 0;
                    width: 20px;
                    height: 20px;
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    border-radius: 50%;
                    transition: background-color 0.15s ease;
                    flex-shrink: 0;
                " onmouseover="this.style.backgroundColor='${isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}'" onmouseout="this.style.backgroundColor='transparent'">×</button>
            </div>
        `;

        const style = document.createElement('style');
        style.textContent = `
            @keyframes slideDown {
                from {
                    transform: translateX(-50%) translateY(-10px);
                    opacity: 0;
                    scale: 0.95;
                }
                to {
                    transform: translateX(-50%) translateY(0);
                    opacity: 1;
                    scale: 1;
                }
            }
        `;
        document.head.appendChild(style);

        document.body.appendChild(warning);

        setTimeout(() => {
            if (warning.parentNode) {
                warning.style.animation = 'slideDown 0.3s ease reverse';
                setTimeout(() => warning.remove(), 300);
            }
        }, 8000);
    };

    // Silent batch download
    const silentBatchDownload = async () => {
        logger.info('Starting silent batch download...');

        showStayOnPageWarning();

        const nativeSuccess = await tryNativeDownload();

        if (!nativeSuccess) {
            updateBatchProgress(0, 1, 'Analyzing selected media...');
            const mediaUrls = await getSelectedMediaUrls();

            if (mediaUrls.length === 0) {
                logger.warn('No media URLs found in selected messages');
                return;
            }

            logger.info(`Downloading ${mediaUrls.length} media items silently...`);

            for (let i = 0; i < mediaUrls.length; i++) {
                const media = mediaUrls[i];
                try {
                    updateBatchProgress(i, mediaUrls.length, `Downloading ${media.type} ${i + 1}/${mediaUrls.length}...`);

                    if (media.type === 'image') {
                        tel_download_image(media.url);
                    } else if (media.type === 'video') {
                        await tel_download_video(media.url);
                    }

                    await new Promise(resolve => setTimeout(resolve, 500));
                } catch (error) {
                    logger.error(`Failed to download ${media.type}: ${error.message}`);
                }
            }

            updateBatchProgress(mediaUrls.length, mediaUrls.length, `Completed: ${mediaUrls.length} files downloaded`);
            logger.info('Silent batch download completed');
        }
    };

    // Try native Telegram download
    const tryNativeDownload = () => {
        return new Promise((resolve) => {
            const firstSelected = document.querySelector('.bubble.is-selected');
            if (!firstSelected) {
                resolve(false);
                return;
            }

            const rightClickEvent = new MouseEvent('contextmenu', {
                bubbles: true,
                cancelable: true,
                view: window,
                button: 2,
                buttons: 2,
                clientX: 100,
                clientY: 100
            });

            firstSelected.dispatchEvent(rightClickEvent);

            setTimeout(() => {
                const contextMenu = document.querySelector('#bubble-contextmenu');
                if (contextMenu) {
                    contextMenu.style.display = 'none';
                    contextMenu.style.visibility = 'hidden';
                    contextMenu.style.opacity = '0';
                    contextMenu.style.pointerEvents = 'none';

                    const menuItems = contextMenu.querySelectorAll('.btn-menu-item');
                    let downloadFound = false;

                    menuItems.forEach(item => {
                        const textElement = item.querySelector('.btn-menu-item-text');
                        if (textElement && textElement.textContent.trim() === 'Download selected') {
                            logger.info('Using native download...');
                            item.click();
                            downloadFound = true;
                        }
                    });

                    setTimeout(() => {
                        if (contextMenu) {
                            contextMenu.classList.remove('active', 'was-open');
                            contextMenu.style.display = 'none';
                        }
                    }, 50);

                    resolve(downloadFound);
                } else {
                    resolve(false);
                }
            }, 50);
        });
    };

    // Create download button
    const createBatchDownloadButton = () => {
        const existingBtn = document.getElementById('tg-batch-download-btn');
        if (existingBtn) {
            // Use message count instead of individual media count
            const count = getSelectedMessageCount();
            const countSpan = existingBtn.querySelector('.media-count');
            if (countSpan) {
                countSpan.textContent = count > 0 ? count : '';
                countSpan.style.display = count > 0 ? 'flex' : 'none';
            }
            return;
        }

        const downloadBtn = document.createElement('button');
        downloadBtn.id = 'tg-batch-download-btn';
        downloadBtn.title = 'Download Selected Files Silently';

        downloadBtn.innerHTML = `
            <svg class="download-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                <path d="M5 20h14v-2H5v2zM12 4v12l-4-4h3V4h2v8h3l-4 4z" fill="white" stroke="white" stroke-width="0.5"/>
            </svg>
            <svg class="loading-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display: none;">
                <circle cx="12" cy="12" r="10" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
                    <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
                    <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
                </circle>
            </svg>
            <span class="media-count" style="
                position: absolute;
                top: -6px;
                right: -6px;
                background: #ff4757;
                color: white;
                border-radius: 11px;
                width: 22px;
                height: 22px;
                font-size: 12px;
                font-weight: bold;
                display: none;
                align-items: center;
                justify-content: center;
                box-shadow: 0 2px 6px rgba(0,0,0,0.3);
                border: 2px solid white;
            "></span>
        `;

        Object.assign(downloadBtn.style, {
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            zIndex: '999999',
            background: '#8774e1',
            border: 'none',
            borderRadius: '50%',
            color: 'white',
            cursor: 'pointer',
            padding: '13px',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            width: '54px',
            height: '54px',
            boxShadow: '0 4px 16px rgba(135, 116, 225, 0.4)',
            transition: 'all 0.2s ease',
            fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
        });

        downloadBtn.addEventListener('mouseenter', () => {
            if (!downloadBtn.disabled) {
                downloadBtn.style.background = '#7c6ce0';
                downloadBtn.style.transform = 'scale(1.05)';
            }
        });

        downloadBtn.addEventListener('mouseleave', () => {
            if (!downloadBtn.disabled) {
                downloadBtn.style.background = '#8774e1';
                downloadBtn.style.transform = 'scale(1)';
            }
        });

        downloadBtn.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();

            const count = getSelectedMessageCount();
            if (count === 0) {
                alert('Please select some messages first');
                return;
            }

            downloadBtn.disabled = true;
            downloadBtn.style.cursor = 'wait';
            downloadBtn.querySelector('.download-icon').style.display = 'none';
            downloadBtn.querySelector('.loading-icon').style.display = 'block';
            downloadBtn.title = 'Downloading... Please stay on this page';

            logger.info(`Silent batch download started for ${count} selected messages...`);

            try {
                await silentBatchDownload();
            } catch (error) {
                logger.error('Batch download failed:', error);
            }

            downloadBtn.disabled = false;
            downloadBtn.style.cursor = 'pointer';
            downloadBtn.querySelector('.download-icon').style.display = 'block';
            downloadBtn.querySelector('.loading-icon').style.display = 'none';
            downloadBtn.title = 'Download Selected Files Silently';
        });

        document.body.appendChild(downloadBtn);
        logger.info('Silent batch download button created');
    };

    // Monitor selection changes
    const monitorSelection = () => {
        const observer = new MutationObserver(() => {
            setTimeout(createBatchDownloadButton, 100);
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['class']
        });
    };

    // Initialize
    const init = () => {
        logger.info('Initializing silent Telegram downloader...');

        createBatchProgress();
        createBatchDownloadButton();
        monitorSelection();
        preventMediaViewerOpen();

        setInterval(createBatchDownloadButton, 2000);

        logger.info('Silent downloader ready!');
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        setTimeout(init, 1000);
    }

    logger.info("Silent Telegram Media Downloader initialized.");

})();