Telegram Web K - Multi Video Downloader (Educational)

Adds a download button to download selected videos in Telegram Web K (web.telegram.org/k). Educational purposes only. May break with Telegram updates.

目前为 2025-04-03 提交的版本。查看 最新版本

// ==UserScript==
// @name         Telegram Web K - Multi Video Downloader (Educational)
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Adds a download button to download selected videos in Telegram Web K (web.telegram.org/k). Educational purposes only. May break with Telegram updates.
// @author       Your Name (or Anonymous)
// @match        https://web.telegram.org/k/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=telegram.org
// @grant        GM_download
// @grant        GM_log
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const DOWNLOAD_BUTTON_TEXT = '⬇️ Download';
    // Selectors (IMPORTANT: These might change with Telegram updates!)
    const SELECTION_TOOLBAR_SELECTOR = '.bubbles-selection-container'; // Container for Forward/Delete buttons
    const MESSAGE_ITEM_SELECTOR = '.message-list-item'; // General selector for a message container
    const SELECTED_MESSAGE_SELECTOR = '.message-list-item.selected'; // Selector for a selected message
    const VIDEO_SELECTOR = 'video'; // Selector for the video element within a message
    const BUTTON_CONTAINER_SELECTOR = '.bubble-button-wrapper'; // Wrapper for existing buttons like Forward/Delete
    const REFERENCE_BUTTON_SELECTOR = '.bubble-button'; // Class for existing buttons to mimic style/placement
    // --- End Configuration ---

    const DOWNLOAD_BUTTON_ID = 'multi-video-download-button';
    let observer = null;

    function log(message) {
        GM_log(`[Telegram Video Downloader] ${message}`);
    }

    function getSelectedVideoSources() {
        const selectedVideos = [];
        const selectedMessages = document.querySelectorAll(SELECTED_MESSAGE_SELECTOR);
        log(`Found ${selectedMessages.length} selected messages.`);

        selectedMessages.forEach((msgElement, index) => {
            const videoElement = msgElement.querySelector(VIDEO_SELECTOR);
            if (videoElement && videoElement.src) {
                // Try to find a timestamp for a better filename
                let timestamp = Date.now() + index; // Fallback timestamp
                const timeElement = msgElement.querySelector('.message-time .time'); // Adjust if selector changes
                if (timeElement && timeElement.textContent) {
                   // Simple approach: use text content as part of name (might need refining)
                   timestamp = timeElement.textContent.replace(/[:\s]/g, '_');
                }
                 const messageId = msgElement.dataset.mid || `idx${index}`; // Try to get message id

                const filename = `telegram_video_${messageId}_${timestamp}.mp4`; // Default extension

                selectedVideos.push({
                    src: videoElement.src,
                    filename: filename,
                    element: msgElement // Keep reference if needed later
                });
                log(`Found video in selected message ${index + 1}: src=${videoElement.src.substring(0, 50)}...`);
            } else {
                 log(`Selected message ${index + 1} does not contain a detectable video element or src.`);
            }
        });
        return selectedVideos;
    }

    async function downloadVideo(videoInfo) {
        log(`Attempting download for: ${videoInfo.filename} from ${videoInfo.src.substring(0,50)}...`);
        try {
            if (videoInfo.src.startsWith('blob:')) {
                // Fetch the blob data
                const response = await fetch(videoInfo.src);
                if (!response.ok) {
                    throw new Error(`Failed to fetch blob: ${response.status} ${response.statusText}`);
                }
                const blobData = await response.blob();
                log(`Fetched blob data for ${videoInfo.filename}, size: ${blobData.size}, type: ${blobData.type}`);

                // Use GM_download with the fetched blob
                 GM_download({
                    url: URL.createObjectURL(blobData), // Create a temporary URL for GM_download
                    name: videoInfo.filename,
                    onload: () => {
                        log(`Successfully initiated download for ${videoInfo.filename}`);
                        // Revoke the temporary URL once download starts (or shortly after)
                        // Note: onload might fire too early. Adding a small delay or relying on browser cleanup.
                        // URL.revokeObjectURL(url); // Potentially revoke later if issues arise
                    },
                    onerror: (err) => {
                        log(`Error downloading ${videoInfo.filename}: ${err.error} - ${err.details}`);
                         console.error("GM_download error details:", err);
                    },
                    ontimeout: () => {
                         log(`Download timed out for ${videoInfo.filename}`);
                    }
                });

            } else {
                // If it's a direct URL (less likely for restricted), download directly
                log(`Source is not a blob, attempting direct GM_download for ${videoInfo.filename}`);
                GM_download({
                    url: videoInfo.src,
                    name: videoInfo.filename,
                     onload: () => log(`Successfully initiated direct download for ${videoInfo.filename}`),
                     onerror: (err) => log(`Error downloading ${videoInfo.filename}: ${err.error} - ${err.details}`),
                     ontimeout: () => log(`Download timed out for ${videoInfo.filename}`)
                });
            }
        } catch (error) {
            log(`Failed to process download for ${videoInfo.filename}: ${error.message}`);
            console.error(error);
            alert(`Error downloading video: ${videoInfo.filename}\n${error.message}\nCheck console for details (Ctrl+Shift+I or F12).`);
        }
    }

    function handleDownloadClick() {
        const videosToDownload = getSelectedVideoSources();
        if (videosToDownload.length === 0) {
            log("No videos found in selected messages.");
            alert("No videos found in the selected messages.");
            return;
        }

        log(`Starting download process for ${videosToDownload.length} videos.`);
        alert(`Attempting to download ${videosToDownload.length} video(s). Check browser download progress.`);

        videosToDownload.forEach(videoInfo => {
            // Add a small delay between downloads to avoid overwhelming the browser/GM_download
            setTimeout(() => downloadVideo(videoInfo), 50); // 50ms delay between each download start
        });
    }

    function addDownloadButton(toolbar) {
        if (document.getElementById(DOWNLOAD_BUTTON_ID)) {
            //log("Download button already exists.");
            return; // Already added
        }

        const buttonContainer = toolbar.querySelector(BUTTON_CONTAINER_SELECTOR);
        const referenceButton = toolbar.querySelector(REFERENCE_BUTTON_SELECTOR);

        if (!buttonContainer || !referenceButton) {
            log("Could not find button container or reference button. Cannot add download button.");
            // Fallback: Append directly to toolbar if container not found
            if(!buttonContainer && toolbar) {
                 log("Attempting fallback: Appending button directly to toolbar.");
                 buttonContainer = toolbar;
            } else {
                return; // Give up if toolbar itself isn't found
            }
        }

        const downloadButton = document.createElement('button');
        downloadButton.id = DOWNLOAD_BUTTON_ID;
        downloadButton.textContent = DOWNLOAD_BUTTON_TEXT;
        downloadButton.className = referenceButton.className; // Copy classes from existing button
        downloadButton.type = 'button'; // Important for preventing form submissions

        // Copy some basic styles - might need more specific styling if needed
        downloadButton.style.cssText = window.getComputedStyle(referenceButton).cssText;
        // Add specific style if needed:
        // downloadButton.style.marginLeft = '8px'; // Example spacing

        downloadButton.addEventListener('click', handleDownloadClick);

        // Insert after the last existing button wrapper or at the end
        const lastButtonWrapper = toolbar.querySelectorAll(BUTTON_CONTAINER_SELECTOR);
         if (lastButtonWrapper.length > 0) {
             // Create a similar wrapper if needed
              const wrapper = document.createElement('div');
              wrapper.className = BUTTON_CONTAINER_SELECTOR; // Use the same wrapper class
              wrapper.style.marginLeft = '8px'; // Add some space
              wrapper.appendChild(downloadButton);
              lastButtonWrapper[lastButtonWrapper.length - 1].parentNode.insertBefore(wrapper, lastButtonWrapper[lastButtonWrapper.length - 1].nextSibling);
              log("Download button added inside a new wrapper.");
         } else if (buttonContainer) {
            // Fallback if wrapper selector failed but container found
             buttonContainer.appendChild(downloadButton);
             log("Download button added directly to button container (fallback).");
         }


    }

    function removeDownloadButton() {
        const button = document.getElementById(DOWNLOAD_BUTTON_ID);
        if (button) {
            // Remove the button and its potential wrapper
            const wrapper = button.closest(`.${BUTTON_CONTAINER_SELECTOR}`); // Try to find its wrapper
            if(wrapper && wrapper.contains(button) && wrapper.querySelectorAll(REFERENCE_BUTTON_SELECTOR).length === 0) {
                 // If the wrapper only contains our button, remove the wrapper
                 wrapper.remove();
                 log("Download button and its wrapper removed.");
            } else if (button.parentElement) {
                 // Otherwise, just remove the button itself
                 button.remove();
                 log("Download button removed.");
            }
        }
    }

    function observeDOM() {
        log("Setting up MutationObserver...");
        observer = new MutationObserver((mutationsList, obs) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    // Check for addition of the selection toolbar
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            let toolbar = null;
                            if (node.matches && node.matches(SELECTION_TOOLBAR_SELECTOR)) {
                                toolbar = node;
                            } else if (node.querySelector) {
                                toolbar = node.querySelector(SELECTION_TOOLBAR_SELECTOR);
                            }

                            if (toolbar) {
                                log("Selection toolbar appeared.");
                                // Add a small delay to ensure toolbar content is populated
                                setTimeout(() => addDownloadButton(toolbar), 100);
                            }
                        }
                    });

                    // Check for removal of the selection toolbar
                    mutation.removedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            if (node.matches && node.matches(SELECTION_TOOLBAR_SELECTOR)) {
                                log("Selection toolbar removed.");
                                // No need to call remove explicitly if button was *inside* the toolbar
                                // But call it just in case it was added elsewhere or for cleanup
                                removeDownloadButton();
                            } else if (node.querySelector && node.querySelector(`#${DOWNLOAD_BUTTON_ID}`)) {
                                // If a container holding the button was removed
                                log("Container holding download button removed.");
                                removeDownloadButton(); // Ensure state is clean
                            }
                        }
                    });
                }
            }

             // Fallback check: Sometimes the toolbar might exist but content changes
             const existingToolbar = document.querySelector(SELECTION_TOOLBAR_SELECTOR);
             if (existingToolbar && !document.getElementById(DOWNLOAD_BUTTON_ID)) {
                 log("Toolbar exists but button missing, attempting to add.");
                 setTimeout(() => addDownloadButton(existingToolbar), 100); // Try adding if selection active but button isn't there
             } else if (!existingToolbar && document.getElementById(DOWNLOAD_BUTTON_ID)) {
                 log("Toolbar disappeared but button exists, attempting to remove.");
                 removeDownloadButton(); // Clean up if toolbar gone but button remains
             }

        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        log("MutationObserver is now watching the DOM.");
    }

    // --- Script Initialization ---
    log("Script starting...");
    // Wait for the main app interface to likely be loaded
    // Telegram Web K loads dynamically, so waiting a bit can help.
    const initialCheckInterval = setInterval(() => {
        // Use a selector that indicates the main chat UI is ready
        if (document.querySelector('.chat-input') || document.querySelector('.chat-list')) {
            clearInterval(initialCheckInterval);
            log("Main UI detected. Initializing observer.");
            observeDOM();
        } else {
            log("Waiting for main UI...");
        }
    }, 1000); // Check every second

    // Make sure to disconnect observer on page unload/script removal if needed,
    // though Tampermonkey usually handles script lifecycle well.
    window.addEventListener('beforeunload', () => {
        if (observer) {
            observer.disconnect();
            log("MutationObserver disconnected.");
        }
    });

})();