YouTube to Gemini Auto Summarizer

在YouTube视频中添加按钮,点击后跳转到Gemini并自动输入提示词总结视频 (Optimized for speed)

目前為 2025-05-05 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube to Gemini Auto Summarizer 
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  在YouTube视频中添加按钮,点击后跳转到Gemini并自动输入提示词总结视频 (Optimized for speed)
// @author       hengyu (Optimized by Assistant)
// @match        *://www.youtube.com/watch?*
// @match        *://gemini.google.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @run-at       document-idle
// @license      MIT  
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const CHECK_INTERVAL_MS = 100; // How often to check for elements (milliseconds)
    const YOUTUBE_ELEMENT_TIMEOUT_MS = 10000; // Max time to wait for YouTube elements (milliseconds)
    const GEMINI_ELEMENT_TIMEOUT_MS = 15000; // Max time to wait for Gemini elements (milliseconds)
    const GEMINI_PROMPT_EXPIRY_MS = 300000; // 5 minutes validity for the prompt transfer

    // --- Debug Logging ---
    function debugLog(message) {
        console.log(`[YouTube to Gemini] ${message}`);
    }

    // --- Helper Functions ---
    function waitForElement(selector, timeoutMs, parent = document) {
        return new Promise((resolve, reject) => {
            let element = parent.querySelector(selector);
            if (element && element.offsetWidth > 0 && element.offsetHeight > 0) {
                return resolve(element);
            }

            const intervalId = setInterval(() => {
                element = parent.querySelector(selector);
                if (element && element.offsetWidth > 0 && element.offsetHeight > 0) {
                    clearInterval(intervalId);
                    clearTimeout(timeoutId);
                    resolve(element);
                }
            }, CHECK_INTERVAL_MS);

            const timeoutId = setTimeout(() => {
                clearInterval(intervalId);
                debugLog(`Element not found or not visible after ${timeoutMs}ms: ${selector}`);
                reject(new Error(`Element not found or not visible: ${selector}`));
            }, timeoutMs);
        });
    }

     function waitForElements(selectors, timeoutMs, parent = document) {
        return new Promise((resolve, reject) => {
            let foundElement = null;
            const startTime = Date.now();

            function checkElements() {
                for (const selector of selectors) {
                    const elements = parent.querySelectorAll(selector);
                    for (const el of elements) {
                        // Check for visibility (basic check)
                        if (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0) {
                             // Additional check for send button state if applicable
                            if (selectors.some(s => s.includes('button')) && el.disabled) {
                                continue; // Skip disabled buttons if looking for a send button
                            }
                            foundElement = el;
                            break;
                        }
                    }
                    if (foundElement) break;
                }

                if (foundElement) {
                    clearInterval(intervalId);
                    clearTimeout(timeoutId);
                    resolve(foundElement);
                } else if (Date.now() - startTime > timeoutMs) {
                    clearInterval(intervalId);
                     debugLog(`Elements not found or not visible after ${timeoutMs}ms: ${selectors.join(', ')}`);
                    reject(new Error(`Elements not found or not visible: ${selectors.join(', ')}`));
                }
            }

            const intervalId = setInterval(checkElements, CHECK_INTERVAL_MS);
            const timeoutId = setTimeout(() => {
                 clearInterval(intervalId);
                 if (!foundElement) {
                     debugLog(`Elements not found or not visible after ${timeoutMs}ms: ${selectors.join(', ')}`);
                     reject(new Error(`Elements not found or not visible: ${selectors.join(', ')}`));
                 }
             }, timeoutMs);

             // Initial check
             checkElements();
        });
    }


    function copyToClipboard(text) {
        const textarea = document.createElement('textarea');
        textarea.value = text;
        document.body.appendChild(textarea);
        textarea.select();
        try {
            document.execCommand('copy');
        } catch (err) {
            debugLog('Failed to copy to clipboard using execCommand.');
            // Fallback or notification could be added here
        }
        document.body.removeChild(textarea);
    }

    function showNotification(elementId, message, styles, duration = 15000) {
         // Remove existing notification first
         let existingNotification = document.getElementById(elementId);
         if (existingNotification) {
             document.body.removeChild(existingNotification);
         }

        const notification = document.createElement('div');
        notification.id = elementId;
        notification.innerText = message;
        Object.assign(notification.style, styles); // Apply base styles

        document.body.appendChild(notification);

        // Add close button
        const closeButton = document.createElement('button');
        closeButton.innerText = '✕';
        // Basic styling, adjust as needed
        closeButton.style.position = 'absolute';
        closeButton.style.top = '5px';
        closeButton.style.right = '10px';
        closeButton.style.background = 'transparent';
        closeButton.style.border = 'none';
        closeButton.style.color = 'inherit'; // Inherit color from notification
        closeButton.style.fontSize = '16px';
        closeButton.style.cursor = 'pointer';
        closeButton.onclick = function() {
             if (document.body.contains(notification)) {
                 document.body.removeChild(notification);
             }
        };
        notification.appendChild(closeButton);

        // Auto-remove after duration
        const timeoutId = setTimeout(() => {
            if (document.body.contains(notification)) {
                document.body.removeChild(notification);
            }
        }, duration);
         // Store timeout ID if needed for cancellation
         notification.dataset.timeoutId = timeoutId;
    }

    // --- YouTube Specific ---
    const YOUTUBE_NOTIFICATION_ID = 'gemini-yt-notification';
    const YOUTUBE_NOTIFICATION_STYLE = {
        position: 'fixed',
        bottom: '20px',
        left: '50%',
        transform: 'translateX(-50%)',
        backgroundColor: 'rgba(0,0,0,0.8)',
        color: 'white',
        padding: '20px',
        borderRadius: '8px',
        zIndex: '9999',
        maxWidth: '80%',
        textAlign: 'left',
        whiteSpace: 'pre-line'
    };

    function addSummarizeButton() {
        // Check if it's a watch page (adjust if URL structure is different)
        // Using the provided URL pattern from the original script for consistency
         if (!window.location.href.includes('youtube.com/watch')) {
            debugLog("Not a watch page (based on URL check), button not added.");
            return;
        }

        if (document.getElementById('gemini-summarize-btn')) {
            debugLog("Summarize button already exists.");
            return;
        }

        // Use the original selector, wait for it
        waitForElement('#masthead #end', YOUTUBE_ELEMENT_TIMEOUT_MS)
            .then(container => {
                 if (document.getElementById('gemini-summarize-btn')) return; // Double check

                const button = document.createElement('button');
                button.id = 'gemini-summarize-btn';
                button.innerText = '📝 Gemini摘要';
                // Apply original styles
                Object.assign(button.style, {
                    backgroundColor: '#2F80ED',
                    color: 'white',
                    border: 'none',
                    borderRadius: '4px',
                    padding: '8px 16px',
                    margin: '0 16px',
                    cursor: 'pointer',
                    fontWeight: 'bold',
                    height: '36px', // Match YouTube's button height
                    display: 'flex',
                    alignItems: 'center'
                });


                button.addEventListener('click', function() {
                    const youtubeUrl = window.location.href;
                    // Attempt to get a cleaner title
                    const videoTitle = document.querySelector('h1.ytd-watch-metadata')?.textContent?.trim() || document.title.replace(' - YouTube', '');

                    const prompt = `请分析这个YouTube视频: ${youtubeUrl}\n\n提供一个全面的摘要,包括主要观点、关键见解和视频中讨论的重要细节,以结构化的方式分解内容,并包括任何重要的结论或要点。`;

                    GM_setValue('geminiPrompt', prompt);
                    GM_setValue('videoTitle', videoTitle);
                    GM_setValue('timestamp', Date.now());

                    // Open Gemini in a new tab
                    window.open('https://gemini.google.com/', '_blank');

                    const notificationMessage = `
已跳转到Gemini!
系统将尝试自动输入并发送提示。

如果自动操作失败,提示词已复制到剪贴板,您可以手动粘贴。

视频: "${videoTitle}"
                    `;
                    showNotification(YOUTUBE_NOTIFICATION_ID, notificationMessage.trim(), YOUTUBE_NOTIFICATION_STYLE);
                    copyToClipboard(prompt); // Backup copy
                });

                // Add the button to the container
                 container.insertBefore(button, container.firstChild);
                 debugLog("Summarize button added successfully.");

            })
            .catch(error => {
                debugLog(`Could not add YouTube button: ${error.message}`);
            });
    }

    // --- Gemini Specific ---
    const GEMINI_NOTIFICATION_ID = 'gemini-auto-notification';
    const GEMINI_NOTIFICATION_STYLES = {
        info: {
            backgroundColor: '#e8f4fd', color: '#0866c2', border: '1px solid #b8daff'
        },
        warning: {
            backgroundColor: '#fff3e0', color: '#b35d00', border: '1px solid #ffe0b2'
        },
        error: {
            backgroundColor: '#fdecea', color: '#c62828', border: '1px solid #ffcdd2'
        }
    };
    const BASE_GEMINI_NOTIFICATION_STYLE = {
         position: 'fixed', bottom: '20px', right: '20px', padding: '15px 20px',
         borderRadius: '8px', zIndex: '9999', maxWidth: '350px', textAlign: 'left',
         boxShadow: '0 4px 12px rgba(0,0,0,0.15)', whiteSpace: 'pre-line'
    };

    function showGeminiNotification(message, type = "info") {
        const style = { ...BASE_GEMINI_NOTIFICATION_STYLE, ...(GEMINI_NOTIFICATION_STYLES[type] || GEMINI_NOTIFICATION_STYLES.info) };
        showNotification(GEMINI_NOTIFICATION_ID, message, style, 10000);
    }


    async function handleGemini() {
         debugLog("Gemini page detected. Checking for prompt...");

        const prompt = GM_getValue('geminiPrompt', '');
        const timestamp = GM_getValue('timestamp', 0);
        const videoTitle = GM_getValue('videoTitle', 'N/A');

        // Check if prompt exists and is recent
        if (!prompt || Date.now() - timestamp > GEMINI_PROMPT_EXPIRY_MS) {
            debugLog("No valid prompt found in storage or it expired.");
            GM_deleteValue('geminiPrompt'); // Clean up expired/invalid prompt
            GM_deleteValue('timestamp');
            GM_deleteValue('videoTitle');
            return;
        }

        debugLog("Valid prompt found. Waiting for Gemini input area...");

        // Use the original selectors from the script
         const textareaSelectors = [
            'div[class*="text-input-field"][class*="with-toolbox-drawer"]', // Specific from screenshot
            'div[class*="input-area"]', // General area
            'div[contenteditable="true"]', // Content editable divs often used
            'div[class*="textarea-wrapper"]',
            'textarea', // Standard textarea
            'div[role="textbox"]' // Accessibility role
        ];

        try {
            // Wait for the textarea to appear and be interactable
            const textarea = await waitForElements(textareaSelectors, GEMINI_ELEMENT_TIMEOUT_MS);
            debugLog("Textarea found. Attempting to input prompt.");

            // Input the text - trying different methods for compatibility
            let inputSuccess = false;
            try {
                 textarea.focus(); // Focus first

                 if (textarea.isContentEditable) {
                     textarea.innerText = prompt; // Method 1: for contentEditable divs
                 } else if (textarea.tagName.toLowerCase() === 'textarea') {
                     textarea.value = prompt; // Method 2: for <textarea>
                 } else {
                     // Method 3: Fallback using execCommand (less reliable now)
                     document.execCommand('insertText', false, prompt);
                 }
                 // Trigger events to make sure the framework detects the change
                 textarea.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
                 textarea.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
                 inputSuccess = true;
                 debugLog("Prompt inserted into textarea.");
             } catch (inputError) {
                debugLog(`Error inserting text: ${inputError}. Trying clipboard fallback.`);
                showGeminiNotification("无法自动填入提示词。请手动粘贴。\n提示词已复制到剪贴板。", "error");
                copyToClipboard(prompt);
                 // Clean up storage as we failed
                 GM_deleteValue('geminiPrompt');
                 GM_deleteValue('timestamp');
                 GM_deleteValue('videoTitle');
                 return; // Stop further execution if input fails
             }


            if (inputSuccess) {
                 // Wait a very short moment for UI to potentially update after input
                 await new Promise(resolve => setTimeout(resolve, 100)); // 100ms delay

                 debugLog("Waiting for send button...");
                // Use the original selectors
                 const sendButtonSelectors = [
                     'button:has(mat-icon[data-mat-icon-name="send"])', // Icon inside button
                     'mat-icon[data-mat-icon-name="send"]', // The icon itself (might need parent click)
                     'button:has(span.mat-mdc-button-touch-target)', // Material components structure
                     'button.mat-mdc-icon-button', // General Material icon button
                     'button[id*="submit"]', // Buttons with 'submit' in ID
                     // Fallbacks based on aria-label (language-dependent)
                     'button[aria-label="Run"]',
                     'button[aria-label="Send"]',
                     'button[aria-label="Submit"]',
                     'button[aria-label="发送"]' // Chinese label
                 ];

                 try {
                    // Wait for the send button to appear and be clickable
                    let sendButtonElement = await waitForElements(sendButtonSelectors, GEMINI_ELEMENT_TIMEOUT_MS);
                     debugLog("Send button found.");

                    // If the found element is the icon, get the parent button
                    if (sendButtonElement.tagName.toLowerCase() === 'mat-icon') {
                         const parentButton = sendButtonElement.closest('button');
                         if (parentButton && !parentButton.disabled) {
                             sendButtonElement = parentButton;
                         } else {
                            throw new Error("Send icon found, but parent button is missing or disabled.");
                         }
                     }

                    // Check if the button is enabled
                    if (sendButtonElement.disabled) {
                         debugLog("Send button is disabled. Waiting a bit longer...");
                         // Wait a bit more, maybe it enables after input validation
                         await new Promise(resolve => setTimeout(resolve, 500));
                         if (sendButtonElement.disabled) {
                            throw new Error("Send button remained disabled.");
                         }
                     }

                    // Click the button
                    sendButtonElement.click();
                    debugLog("Send button clicked successfully.");

                    // Success notification
                     const successMessage = `
已自动发送视频摘要请求!

正在分析视频: "${videoTitle}"

请稍候,Gemini正在处理您的请求...
                    `;
                    showGeminiNotification(successMessage.trim(), "info");

                    // Clean up storage after successful operation
                    GM_deleteValue('geminiPrompt');
                    GM_deleteValue('timestamp');
                    GM_deleteValue('videoTitle');

                 } catch (buttonError) {
                    debugLog(`Send button error: ${buttonError.message}`);
                    showGeminiNotification("找不到或无法点击发送按钮。\n提示词已填入,请手动点击发送。", "warning");
                     // Keep prompt in storage for manual use if button click fails
                 }
             }

        } catch (textareaError) {
            debugLog(`Textarea error: ${textareaError.message}`);
            showGeminiNotification("无法找到Gemini输入框。\n请手动粘贴提示词。\n提示词已复制到剪贴板。", "error");
            copyToClipboard(prompt); // Ensure prompt is available
             // Clean up storage as we failed early
            GM_deleteValue('geminiPrompt');
            GM_deleteValue('timestamp');
            GM_deleteValue('videoTitle');
        }
    }

    // --- Main Execution Logic ---
    const isYouTube = window.location.hostname.includes('youtube.com') || window.location.hostname.includes('www.youtube.com'); // Added www.youtube.com just in case
    const isGemini = window.location.hostname.includes('gemini.google.com');

    if (isYouTube) {
        debugLog("YouTube page detected. Initializing button adder.");
        // Initial attempt to add the button
        addSummarizeButton();

        // Observe URL changes for SPA navigation
        let lastUrl = location.href;
        const observer = new MutationObserver(() => {
            const currentUrl = location.href;
            if (currentUrl !== lastUrl) {
                lastUrl = currentUrl;
                 debugLog(`URL changed to: ${currentUrl}. Re-checking for button placement.`);
                // Wait a brief moment for the new page content to potentially load
                setTimeout(addSummarizeButton, 500); // Reduced delay
            }
        });
        // Observe the body for broader changes, but consider performance if issues arise
        observer.observe(document.body, { childList: true, subtree: true });
        debugLog("MutationObserver set up for URL changes.");

    } else if (isGemini) {
        // Use window.onload or a short delay to ensure GM_getValue is ready after page load
        // document-idle should be sufficient, but adding a small safety delay.
        setTimeout(handleGemini, 500); // Start Gemini logic slightly after idle
    }

})();