// ==UserScript==
// @name YouTube to Gemini 自动总结
// @namespace http://tampermonkey.net/
// @version 0.7.1
// @description 在YouTube视频中添加按钮,点击后跳转到Gemini并自动输入提示词总结视频
// @author hengyu (Optimized by Assistant)
// @match *://www.youtube.com/*
// @match *://gemini.google.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @run-at document-start // Run earlier to catch events
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- 配置 ---
// Reduced check interval for MutationObserver fallback/initial checks if needed, but Observer is primary
const CHECK_INTERVAL_MS = 200;
const YOUTUBE_ELEMENT_TIMEOUT_MS = 10000; // 等待YouTube元素的最大时间(毫秒)
const GEMINI_ELEMENT_TIMEOUT_MS = 15000; // 等待Gemini元素的最大时间(毫秒)
const GEMINI_PROMPT_EXPIRY_MS = 300000; // 提示词传输有效期5分钟
// Removed URL_CHECK_INTERVAL_MS as we now use events
// --- 调试日志 ---
const DEBUG = false; // Set to true to enable detailed logs
function debugLog(message) {
if (DEBUG) {
console.log(`[YT->Gemini Optimized] ${message}`);
}
}
// --- 辅助函数 ---
/**
* Waits for one or more elements matching the selectors to appear and be visible in the DOM.
* Prioritizes MutationObserver for efficiency, falls back to polling if needed.
* @param {string|string[]} selectors - A CSS selector string or an array of selectors.
* @param {number} timeoutMs - Maximum time to wait in milliseconds.
* @param {Element} [parent=document] - The parent element to search within.
* @returns {Promise<Element>} A promise that resolves with the found element.
*/
function waitForElement(selectors, timeoutMs, parent = document) {
const selectorArray = Array.isArray(selectors) ? selectors : [selectors];
const combinedSelector = selectorArray.join(', '); // Efficiently query all at once
return new Promise((resolve, reject) => {
// Check immediately in case the element is already present
const initialElement = findVisibleElement(combinedSelector, parent);
if (initialElement) {
debugLog(`Element found immediately: ${combinedSelector}`);
return resolve(initialElement);
}
let observer = null;
let timeoutId = null;
const cleanup = () => {
if (observer) {
observer.disconnect();
observer = null;
debugLog(`MutationObserver disconnected for: ${combinedSelector}`);
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
const onTimeout = () => {
cleanup();
debugLog(`Element not found or not visible after ${timeoutMs}ms: ${combinedSelector}`);
reject(new Error(`Element not found or not visible: ${combinedSelector}`));
};
const checkNode = (node) => {
if (node && node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node itself matches
if (node.matches(combinedSelector) && isElementVisible(node)) {
debugLog(`Element found via MutationObserver (direct match): ${combinedSelector}`);
cleanup();
resolve(node);
return true;
}
// Check if any descendant matches
const foundDescendant = findVisibleElement(combinedSelector, node);
if (foundDescendant) {
debugLog(`Element found via MutationObserver (descendant): ${combinedSelector}`);
cleanup();
resolve(foundDescendant);
return true;
}
}
return false;
};
timeoutId = setTimeout(onTimeout, timeoutMs);
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (checkNode(node)) return;
}
} else if (mutation.type === 'attributes') {
// Check if the target element itself became visible or matches now
if (checkNode(mutation.target)) return;
}
}
// Fallback check in case visibility changed without node addition/direct attribute change matching selector
const element = findVisibleElement(combinedSelector, parent);
if (element) {
debugLog(`Element found via MutationObserver (fallback check): ${combinedSelector}`);
cleanup();
resolve(element);
}
});
observer.observe(parent === document ? document.documentElement : parent, {
childList: true,
subtree: true,
attributes: true, // Observe attributes changes (like style, class, disabled)
attributeFilter: ['style', 'class', 'disabled'] // Be specific if possible
});
debugLog(`MutationObserver started for: ${combinedSelector}`);
});
}
/**
* Finds the first visible element matching the selector within the parent.
* @param {string} selector - The CSS selector.
* @param {Element} parent - The parent element.
* @returns {Element|null} The found visible element or null.
*/
function findVisibleElement(selector, parent) {
try {
const elements = parent.querySelectorAll(selector);
for (const el of elements) {
if (isElementVisible(el)) {
// Skip disabled buttons specifically, as needed by original script
if (selector.includes('button') && el.disabled) {
continue;
}
return el;
}
}
} catch (e) {
debugLog(`Error finding element with selector "${selector}": ${e}`);
}
return null;
}
/**
* Checks if an element is potentially visible to the user.
* @param {Element} el - The element to check.
* @returns {boolean} True if the element is considered visible.
*/
function isElementVisible(el) {
if (!el) return false;
// Basic check: offsetWidth/Height covers display:none and zero size
// getClientRects checks for elements like <details> summary when closed
return (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
debugLog("Text copied to clipboard via modern API.");
}).catch(err => {
debugLog(`Clipboard API failed: ${err}, using legacy method.`);
legacyClipboardCopy(text);
});
}
function legacyClipboardCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed'; // Prevent scrolling to bottom
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
debugLog(`Legacy copy attempt: ${successful ? 'Success' : 'Fail'}`);
} catch (err) {
debugLog('Failed to copy to clipboard using legacy execCommand: ' + err);
}
document.body.removeChild(textarea);
}
function showNotification(elementId, message, styles, duration = 15000) {
let existingNotification = document.getElementById(elementId);
if (existingNotification) {
// Clear existing timeout if replacing notification
const existingTimeoutId = existingNotification.dataset.timeoutId;
if (existingTimeoutId) {
clearTimeout(parseInt(existingTimeoutId));
}
existingNotification.remove();
}
const notification = document.createElement('div');
notification.id = elementId;
// Use textContent for safety, but allow basic formatting via template literal
notification.textContent = message; // More secure than innerText? Let's stick to textContent for now. Use innerHTML if HTML is needed, carefully.
Object.assign(notification.style, styles);
document.body.appendChild(notification);
const closeButton = document.createElement('button');
closeButton.textContent = '✕'; // Use textContent
Object.assign(closeButton.style, {
position: 'absolute', top: '5px', right: '10px', background: 'transparent',
border: 'none', color: 'inherit', fontSize: '16px', cursor: 'pointer', padding: '0', lineHeight: '1'
});
closeButton.onclick = () => notification.remove(); // Simplified removal
notification.appendChild(closeButton);
const timeoutId = setTimeout(() => notification.remove(), duration);
notification.dataset.timeoutId = timeoutId.toString(); // Store timeout ID
}
// --- YouTube Related ---
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.85)', color: 'white', padding: '15px 35px 15px 20px', // Adjusted padding for close button
borderRadius: '8px', zIndex: '9999', maxWidth: 'calc(100% - 40px)', textAlign: 'left',
boxSizing: 'border-box', whiteSpace: 'pre-wrap', // Use pre-wrap for better line breaks
boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
};
const BUTTON_ID = 'gemini-summarize-btn';
function isVideoPage() {
// More robust check for video page
return window.location.pathname === '/watch' && new URLSearchParams(window.location.search).has('v');
}
async function addSummarizeButton() {
// 1. Check if it's a video page
if (!isVideoPage()) {
debugLog("Not a video page, skipping button add.");
removeSummarizeButtonIfExists(); // Clean up if navigating away
return;
}
// 2. Check if button already exists
if (document.getElementById(BUTTON_ID)) {
debugLog("Summarize button already exists.");
return;
}
debugLog("Video page detected. Attempting to add summarize button...");
// 3. Define potential containers (prioritize more stable ones)
const containerSelectors = [
// Primary button containers often near subscribe/join
'#top-row.ytd-watch-metadata > #subscribe-button', // Insert *before* subscribe
'#meta-contents #subscribe-button', // Alternative path
'#owner #subscribe-button', // Another path
// Fallback locations
'#meta-contents #top-row', // Add to the end of the top row
'#above-the-fold #title', // Add near the title
'ytd-watch-metadata #actions', // Near like/dislike etc.
'#masthead #end' // Last resort in top bar
];
try {
// 4. Wait for *any* of the potential containers/anchors
// We wait for the *anchor* element to insert *relative* to it.
const anchorElement = await waitForElement(containerSelectors, YOUTUBE_ELEMENT_TIMEOUT_MS);
debugLog(`Found anchor element using selector matching: ${anchorElement.tagName}[id="${anchorElement.id}"][class="${anchorElement.className}"]`);
// Re-check if button was added concurrently while waiting
if (document.getElementById(BUTTON_ID)) {
debugLog("Button was added concurrently, skipping.");
return;
}
// 5. Create the button
const button = document.createElement('button');
button.id = BUTTON_ID;
button.textContent = '📝 Gemini摘要'; // Use textContent
// Apply styles
Object.assign(button.style, {
backgroundColor: '#1a73e8', // Google blue
color: 'white', border: 'none', borderRadius: '18px', // Match YT button style
padding: '0 16px', margin: '0 8px', cursor: 'pointer', fontWeight: '500', // Medium weight
height: '36px', // Match YT button height
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '14px', // Match YT button font size
zIndex: '100', // Ensure visibility
whiteSpace: 'nowrap', // Prevent wrapping
transition: 'background-color 0.3s ease' // Smooth hover
});
// Hover effect
button.onmouseover = () => button.style.backgroundColor = '#185abc'; // Darker blue
button.onmouseout = () => button.style.backgroundColor = '#1a73e8';
// 6. Add click listener
button.addEventListener('click', handleSummarizeClick);
// 7. Insert the button
// If we found a specific button like 'subscribe', insert before it. Otherwise, append.
if (anchorElement.id?.includes('subscribe-button') || anchorElement.tagName === 'BUTTON') {
anchorElement.parentNode.insertBefore(button, anchorElement);
debugLog(`Button inserted before anchor: ${anchorElement.id || anchorElement.tagName}`);
} else if (anchorElement.id === 'actions' || anchorElement.id === 'end' || anchorElement.id === 'top-row') {
// Append as first child for some containers, last for others might be better? Let's try first child generally
anchorElement.insertBefore(button, anchorElement.firstChild);
debugLog(`Button inserted as first child of container: ${anchorElement.id || anchorElement.tagName}`);
} else {
// Default: Append to the container found
anchorElement.appendChild(button);
debugLog(`Button appended to container: ${anchorElement.id || anchorElement.tagName}`);
}
debugLog("Summarize button successfully added!");
} catch (error) {
console.error('[YT->Gemini Optimized] Failed to add summarize button:', error);
removeSummarizeButtonIfExists(); // Clean up partial attempts if error occurs
}
}
function handleSummarizeClick() {
try {
const youtubeUrl = window.location.href;
// Try getting title more robustly
const titleElement = document.querySelector('h1.ytd-watch-metadata, #video-title, #title h1');
const videoTitle = titleElement?.textContent?.trim() || document.title.replace(/ - YouTube$/, '').trim() || 'Unknown Video';
const prompt = `请分析这个YouTube视频: ${youtubeUrl}\n\n提供一个全面的摘要,包括主要观点、关键见解和视频中讨论的重要细节,以结构化的方式分解内容,并包括任何重要的结论或要点。`;
debugLog(`Generated prompt for: ${videoTitle}`);
// Store data using GM functions
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');
debugLog("Opened Gemini tab.");
// Show notification on YouTube page
const notificationMessage = `
已跳转到 Gemini!
系统将尝试自动输入提示词并发送请求。
视频: "${videoTitle}"
(如果自动操作失败,提示词已复制到剪贴板,请手动粘贴)
`.trim();
showNotification(YOUTUBE_NOTIFICATION_ID, notificationMessage, YOUTUBE_NOTIFICATION_STYLE, 10000); // 10 second duration
// Copy to clipboard as fallback
copyToClipboard(prompt);
} catch (error) {
console.error("[YT->Gemini Optimized] Error during summarize button click:", error);
showNotification(YOUTUBE_NOTIFICATION_ID, `创建摘要时出错: ${error.message}`, { ...YOUTUBE_NOTIFICATION_STYLE, backgroundColor: '#d93025', color: 'white' }, 10000);
}
}
function removeSummarizeButtonIfExists() {
const button = document.getElementById(BUTTON_ID);
if (button) {
button.remove();
debugLog("Removed existing summarize button.");
}
}
// --- Gemini Related ---
const GEMINI_NOTIFICATION_ID = 'gemini-auto-notification';
const GEMINI_NOTIFICATION_STYLES = {
info: { backgroundColor: '#e8f4fd', color: '#1967d2', border: '1px solid #a8c7fa' }, // Google info blue
warning: { backgroundColor: '#fef7e0', color: '#a56300', border: '1px solid #fdd663' }, // Google warning yellow
error: { backgroundColor: '#fce8e6', color: '#c5221f', border: '1px solid #f7a7a5' } // Google error red
};
const BASE_GEMINI_NOTIFICATION_STYLE = {
position: 'fixed', bottom: '20px', right: '20px', padding: '15px 35px 15px 20px', // Adjusted padding
borderRadius: '8px', zIndex: '9999', maxWidth: '350px', textAlign: 'left',
boxSizing: 'border-box', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', whiteSpace: 'pre-wrap'
};
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, 12000); // 12 second duration
}
async function handleGeminiPage() {
debugLog("Gemini page detected. Checking for pending prompt...");
const prompt = GM_getValue('geminiPrompt', '');
const timestamp = GM_getValue('timestamp', 0);
const videoTitle = GM_getValue('videoTitle', 'N/A');
// Clean up expired/invalid data immediately
if (!prompt || Date.now() - timestamp > GEMINI_PROMPT_EXPIRY_MS) {
debugLog("No valid prompt found or prompt expired.");
GM_deleteValue('geminiPrompt');
GM_deleteValue('timestamp');
GM_deleteValue('videoTitle');
return;
}
debugLog("Valid prompt found. Waiting for Gemini input area...");
showGeminiNotification(`检测到来自 YouTube 的请求...\n视频: "${videoTitle}"`, "info");
// Define selectors for input area and send button
const textareaSelectors = [
// More specific selectors first
'div.input-area > div.input-box > div[contenteditable="true"]', // Common structure
'div[role="textbox"][contenteditable="true"]',
'textarea[aria-label*="Prompt"]', // Less common but possible
// Broader fallbacks
'div[contenteditable="true"]',
'textarea'
];
const sendButtonSelectors = [
// More specific selectors first
'button[aria-label*="Send message"], button[aria-label*="发送消息"]', // Common aria-labels
'button:has(span[class*="send-icon"])', // Structure based
'button.send-button', // Potential class
// Fallbacks (less reliable, might match other buttons)
'button:has(mat-icon[data-mat-icon-name="send"])', // Material icon (keep as fallback)
'button[aria-label="Run"], button[aria-label="Submit"]'
];
try {
// Wait for the input area
const textarea = await waitForElement(textareaSelectors, GEMINI_ELEMENT_TIMEOUT_MS);
debugLog("Found input area. Inserting prompt.");
// --- Input Prompt ---
textarea.focus();
let inputSuccess = false;
if (textarea.isContentEditable) {
textarea.textContent = prompt; // Use textContent for contenteditable
inputSuccess = true;
} else if (textarea.tagName === 'TEXTAREA') {
textarea.value = prompt;
inputSuccess = true;
}
if (!inputSuccess) {
throw new Error("Could not determine how to input text into the found element.");
}
// Trigger input event to ensure Gemini UI updates (e.g., enables send button)
textarea.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); // Also trigger change
debugLog("Prompt inserted and events dispatched.");
// Short delay to allow UI to potentially update (e.g., enabling send button)
await new Promise(resolve => setTimeout(resolve, 150)); // Slightly longer? 150ms
// --- Find and Click Send Button ---
debugLog("Waiting for send button to be enabled...");
const sendButton = await waitForElement(sendButtonSelectors, GEMINI_ELEMENT_TIMEOUT_MS);
// Check if button is truly clickable (not disabled)
if (sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') {
debugLog("Send button found but is disabled. Waiting a bit longer...");
await new Promise(resolve => setTimeout(resolve, 500)); // Wait half a second more
if (sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') {
throw new Error("Send button remained disabled.");
}
debugLog("Send button became enabled after waiting.");
}
debugLog("Clicking send button...");
sendButton.click();
// --- Success ---
debugLog("Successfully sent prompt to Gemini.");
const successMessage = `
已自动发送视频摘要请求!
正在为视频分析做准备:
"${videoTitle}"
请稍候...
`.trim();
showGeminiNotification(successMessage, "info");
// Clean up stored data after successful submission
GM_deleteValue('geminiPrompt');
GM_deleteValue('timestamp');
GM_deleteValue('videoTitle');
} catch (error) {
console.error('[YT->Gemini Optimized] Error handling Gemini page:', error);
showGeminiNotification(`自动操作失败: ${error.message}\n\n提示词已复制到剪贴板,请手动粘贴并发送。`, "error");
copyToClipboard(prompt); // Ensure clipboard has the prompt on error
// Optionally clear GM values even on error to prevent retries on refresh? Or keep them? Let's clear them.
GM_deleteValue('geminiPrompt');
GM_deleteValue('timestamp');
GM_deleteValue('videoTitle');
}
}
// --- Main Execution Logic ---
debugLog("Script starting execution...");
if (window.location.hostname.includes('www.youtube.com')) {
debugLog("YouTube domain detected.");
// Initial check in case the script loads after the page is ready
if (document.readyState === 'complete' || document.readyState === 'interactive') {
addSummarizeButton();
} else {
window.addEventListener('DOMContentLoaded', addSummarizeButton, { once: true });
}
// Listen for YouTube's specific navigation events (more reliable than URL polling)
// 'yt-navigate-finish' fires after navigation and content update
window.addEventListener('yt-navigate-finish', () => {
debugLog("yt-navigate-finish event detected.");
// Use requestAnimationFrame to ensure layout is likely stable after event
requestAnimationFrame(addSummarizeButton);
//setTimeout(addSummarizeButton, 50); // Small delay can sometimes help ensure elements are ready
});
// Also handle popstate for browser back/forward
window.addEventListener('popstate', () => {
debugLog("popstate event detected.");
requestAnimationFrame(addSummarizeButton);
//setTimeout(addSummarizeButton, 50);
});
// We might not need pushState override if yt-navigate-finish works reliably
/*
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(this, arguments);
debugLog("history.pushState detected.");
// Use rAF here too
requestAnimationFrame(addSummarizeButton);
// setTimeout(addSummarizeButton, 50);
};
*/
} else if (window.location.hostname.includes('gemini.google.com')) {
debugLog("Gemini domain detected.");
// Handle Gemini logic once the DOM is ready
if (document.readyState === 'complete' || document.readyState === 'interactive') {
handleGeminiPage();
} else {
window.addEventListener('DOMContentLoaded', handleGeminiPage, { once: true });
}
} else {
debugLog(`Script loaded on unrecognized domain: ${window.location.hostname}`);
}
})();