// ==UserScript==
// @name YouTube to Gemini 自动总结与字幕
// @namespace http://tampermonkey.net/
// @version 1.2
// @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
// @grant GM_addStyle
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- 配置 ---
const CHECK_INTERVAL_MS = 300; // Fallback polling interval increased to 300ms
const YOUTUBE_ELEMENT_TIMEOUT_MS = 15000; // 等待YouTube元素的最大时间(毫秒)
const GEMINI_ELEMENT_TIMEOUT_MS = 15000; // 等待Gemini元素的最大时间(毫秒)
const GEMINI_PROMPT_EXPIRY_MS = 300000; // 提示词传输有效期5分钟
const SUBTITLE_SEGMENT_DURATION_SECONDS = 1200; // 字幕分段时长,20分钟 = 1200秒
// Key selectors for ensuring page/video context is ready
const YOUTUBE_PLAYER_METADATA_SELECTOR = 'ytd-watch-metadata, #above-the-fold .title, #info-contents';
const YOUTUBE_VIDEO_TITLE_SELECTOR = 'h1.ytd-watch-metadata, #video-title, #title h1, .title';
// Thumbnail specific selectors
const THUMBNAIL_CHECK_INTERVAL_MS = 1500; // 缩略图检查间隔
// --- GM存储键 ---
const PROMPT_KEY = 'geminiPrompt';
const TITLE_KEY = 'videoTitle';
const ORIGINAL_TITLE_KEY = 'geminiOriginalVideoTitle';
const TIMESTAMP_KEY = 'timestamp';
const ACTION_TYPE_KEY = 'geminiActionType';
const VIDEO_TOTAL_DURATION_KEY = 'geminiVideoTotalDuration';
const FIRST_SEGMENT_END_TIME_KEY = 'geminiFirstSegmentEndTime';
// --- 调试日志 ---
const DEBUG = true;
function debugLog(message) {
if (DEBUG) {
console.log(`[YT->Gemini v1.2] ${message}`);
}
}
// --- 辅助函数 ---
function formatTimeHHMMSS(totalSeconds) {
if (isNaN(totalSeconds) || totalSeconds < 0) {
return '00:00:00';
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const pad = (num) => String(num).padStart(2, '0');
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function parseISO8601DurationToSeconds(durationString) {
if (!durationString || typeof durationString !== 'string' || !durationString.startsWith('PT')) return 0;
let totalSeconds = 0;
const timePart = durationString.substring(2);
const hourMatch = timePart.match(/(\d+)H/);
if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600;
const minuteMatch = timePart.match(/(\d+)M/);
if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60;
const secondMatch = timePart.match(/(\d+)S/);
if (secondMatch) totalSeconds += parseInt(secondMatch[1]);
return totalSeconds;
}
function waitForElement(selectors, timeoutMs, parent = document) {
const selectorArray = Array.isArray(selectors) ? selectors : [selectors];
const combinedSelector = selectorArray.join(', ');
return new Promise((resolve, reject) => {
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) {
if (node.matches && node.matches(combinedSelector) && isElementVisible(node)) {
debugLog(`Element found via MutationObserver (direct match): ${combinedSelector}`);
cleanup();
resolve(node);
return true;
}
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') {
if (checkNode(mutation.target)) return;
}
}
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,
attributeFilter: ['style', 'class', 'disabled']
});
debugLog(`MutationObserver started for: ${combinedSelector}`);
});
}
function findVisibleElement(selector, parent) {
try {
const elements = parent.querySelectorAll(selector);
for (const el of elements) {
if (isElementVisible(el)) {
if (selector.includes('button') && el.disabled) {
continue;
}
return el;
}
}
} catch (e) {
debugLog(`Error finding element with selector "${selector}": ${e}`);
}
return null;
}
function isElementVisible(el) {
if (!el) return false;
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';
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) {
const existingTimeoutId = existingNotification.dataset.timeoutId;
if (existingTimeoutId) {
clearTimeout(parseInt(existingTimeoutId));
}
existingNotification.remove();
}
const notification = document.createElement('div');
notification.id = elementId;
notification.textContent = message;
Object.assign(notification.style, styles);
document.body.appendChild(notification);
const closeButton = document.createElement('button');
closeButton.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();
notification.appendChild(closeButton);
const timeoutId = setTimeout(() => notification.remove(), duration);
notification.dataset.timeoutId = timeoutId.toString();
}
// --- 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',
borderRadius: '8px', zIndex: '9999', maxWidth: 'calc(100% - 40px)', textAlign: 'left',
boxSizing: 'border-box', whiteSpace: 'pre-wrap',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
};
const SUMMARY_BUTTON_ID = 'gemini-summarize-btn';
const SUBTITLE_BUTTON_ID = 'gemini-subtitle-btn';
const THUMBNAIL_BUTTON_CLASS = 'gemini-thumbnail-btn';
const THUMBNAIL_PROCESSED_FLAG = 'data-gemini-processed';
// --- Injected Styles ---
GM_addStyle(`
.${THUMBNAIL_BUTTON_CLASS} {
position: absolute;
top: 5px;
right: 5px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
z-index: 100;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.2s ease;
}
#dismissible:hover .${THUMBNAIL_BUTTON_CLASS},
ytd-grid-video-renderer:hover .${THUMBNAIL_BUTTON_CLASS},
ytd-video-renderer:hover .${THUMBNAIL_BUTTON_CLASS},
ytd-rich-item-renderer:hover .${THUMBNAIL_BUTTON_CLASS},
ytd-compact-video-renderer:hover .${THUMBNAIL_BUTTON_CLASS},
ytd-playlist-video-renderer:hover .${THUMBNAIL_BUTTON_CLASS},
ytd-reel-item-renderer:hover .${THUMBNAIL_BUTTON_CLASS} {
opacity: 1;
}
.${THUMBNAIL_BUTTON_CLASS}:hover {
background-color: rgba(0, 0, 0, 0.9);
}
`);
GM_addStyle(`
/* Responsive Grid for YouTube Homepage Thumbnails */
ytd-rich-grid-renderer > div#contents { /* Simplified selector */
display: grid !important;
/* Default: 2 columns for smaller screens or if no other media query matches */
grid-template-columns: repeat(2, 1fr) !important;
gap: 24px 16px !important; /* row-gap column-gap */
padding: 0 !important; /* Reset padding */
margin: 0 auto !important; /* Center contents if it\'s narrower than parent, or use \'0 !important\' if not needed */
width: 100% !important; /* Ensure it takes full available width for grid calculation */
/* Attempt to override YouTube\'s own column variables */
--ytd-rich-grid-items-per-row: 2 !important;
--ytd-rich-grid-posts-per-row: 2 !important; /* For posts grid if applicable */
--ytd-rich-grid-game-cards-per-row: 2 !important; /* For game cards if applicable */
--ytd-rich-grid-max-width: none !important; /* Remove max width constraint on contents if any */
}
/* Ensure grid items (video thumbnails) don\'t have conflicting fixed widths or margins from YT */
ytd-rich-grid-renderer > div#contents > ytd-rich-item-renderer, /* Simplified selector */
ytd-rich-grid-renderer > div#contents > ytd-grid-video-renderer, /* For grid video items */
ytd-rich-grid-renderer > div#contents > .ytd-rich-grid-row > .ytd-rich-grid-media, /* Common pattern for items in rows */
ytd-rich-grid-renderer > div#contents > * > ytd-thumbnail { /* Target thumbnails more broadly if direct items change */
margin: 0 !important; /* Override default margins if they conflict with grid gap */
width: auto !important; /* Let the grid cell define the width. Needed to override YT\'s inline styles or specific class widths. */
max-width: none !important; /* Ensure items can fill grid cells */
min-width: 0 !important; /* Allow items to shrink if needed */
}
/* Additional rule for the items themselves if they are direct children */
ytd-rich-grid-renderer > div#contents > ytd-rich-item-renderer,
ytd-rich-grid-renderer > div#contents > ytd-grid-video-renderer {
box-sizing: border-box !important; /* Ensure padding/border don\'t expand item beyond grid cell */
}
/* Breakpoints for responsive columns */
/* 3 columns */
@media (min-width: 1000px) { /* Adjust breakpoint as needed */
ytd-rich-grid-renderer > div#contents { /* Simplified selector */
grid-template-columns: repeat(3, 1fr) !important;
--ytd-rich-grid-items-per-row: 3 !important;
--ytd-rich-grid-posts-per-row: 3 !important;
--ytd-rich-grid-game-cards-per-row: 3 !important;
}
}
/* 4 columns */
@media (min-width: 1400px) { /* Adjust breakpoint as needed */
ytd-rich-grid-renderer > div#contents { /* Simplified selector */
grid-template-columns: repeat(4, 1fr) !important;
--ytd-rich-grid-items-per-row: 4 !important;
--ytd-rich-grid-posts-per-row: 4 !important;
--ytd-rich-grid-game-cards-per-row: 4 !important;
}
}
/* 5 columns */
@media (min-width: 1700px) { /* Adjust breakpoint as needed */
ytd-rich-grid-renderer > div#contents { /* Simplified selector */
grid-template-columns: repeat(5, 1fr) !important;
--ytd-rich-grid-items-per-row: 5 !important;
--ytd-rich-grid-posts-per-row: 5 !important;
--ytd-rich-grid-game-cards-per-row: 5 !important;
}
}
/* Ensure shelves (like Shorts, Breaking News etc.) span full width */
ytd-rich-grid-renderer > div#contents > ytd-rich-section-renderer, /* Simplified selector */
ytd-rich-grid-renderer > div#contents > ytd-reel-shelf-renderer {
grid-column: 1 / -1 !important; /* Make shelves span all columns */
width: 100% !important; /* Ensure shelves also take full width */
margin-top: 16px !important; /* Add some spacing for shelves */
margin-bottom: 16px !important;
}
`);
GM_addStyle(`
#gemini-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 9999;
width: 300px;
display: none;
}
#gemini-popup .button {
width: 100%;
padding: 10px;
margin: 5px 0;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#gemini-popup .button:hover {
background-color: #45a049;
}
#gemini-popup .status {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
display: none;
}
#gemini-popup .success {
background-color: #dff0d8;
color: #3c763d;
}
#gemini-popup .error {
background-color: #f2dede;
color: #a94442;
}
`);
function isVideoPage() {
return window.location.pathname === '/watch' && new URLSearchParams(window.location.search).has('v');
}
// ---- 缩略图按钮逻辑 ----
function getVideoInfoFromElement(element) {
try {
// 检查元素是否已经处理过
if (element.hasAttribute(THUMBNAIL_PROCESSED_FLAG) && element.getAttribute(THUMBNAIL_PROCESSED_FLAG) === 'true') {
return null;
}
let videoId = '';
const linkElement = element.querySelector('a[href*="/watch?v="]');
if (linkElement) {
const href = linkElement.getAttribute('href');
const match = href.match(/\/watch\?v=([^&]+)/);
if (match && match[1]) {
videoId = match[1];
}
}
let videoTitle = '';
const titleElement = element.querySelector('#video-title, .title, [title]');
if (titleElement) {
videoTitle = titleElement.textContent?.trim() || titleElement.getAttribute('title')?.trim() || '';
}
if (!videoId || !videoTitle) {
return null;
}
return {
id: videoId,
title: videoTitle,
url: `https://www.youtube.com/watch?v=${videoId}`
};
} catch (error) {
debugLog('获取视频信息时出错:' + error.message);
return null;
}
}
function handleThumbnailButtonClick(event, videoInfo) {
event.preventDefault();
event.stopPropagation();
try {
if (!videoInfo || !videoInfo.url || !videoInfo.title) {
throw new Error('视频信息不完整');
}
const prompt = `请分析这个YouTube视频: ${videoInfo.url}\n\n提供一个全面的摘要,包括主要观点、关键见解和视频中讨论的重要细节,以结构化的方式分解内容,并包括任何重要的结论或要点。`;
debugLog(`从缩略图生成提示词: ${videoInfo.title}`);
GM_setValue(PROMPT_KEY, prompt);
GM_setValue(TITLE_KEY, videoInfo.title);
GM_setValue(ORIGINAL_TITLE_KEY, videoInfo.title);
GM_setValue(TIMESTAMP_KEY, Date.now());
GM_setValue(ACTION_TYPE_KEY, 'summary');
window.open('https://gemini.google.com/', '_blank');
debugLog("从缩略图打开Gemini标签页。");
const notificationMessage = `
已跳转到 Gemini!
系统将尝试自动输入提示词并发送请求。
视频: "${videoInfo.title}"
(如果自动操作失败,提示词已复制到剪贴板,请手动粘贴)
`.trim();
showNotification(YOUTUBE_NOTIFICATION_ID, notificationMessage, YOUTUBE_NOTIFICATION_STYLE, 10000);
copyToClipboard(prompt);
} catch (error) {
console.error("[YT->Gemini] 处理缩略图按钮点击时出错:", error);
showNotification(YOUTUBE_NOTIFICATION_ID, `创建摘要时出错: ${error.message}`, { ...YOUTUBE_NOTIFICATION_STYLE, backgroundColor: '#d93025', color: 'white' }, 10000);
}
}
// 改进的缩略图按钮添加函数,更强大的处理逻辑
function addThumbnailButtons() {
// 为提高性能,如果是视频页面则跳过
if (isVideoPage()) {
return;
}
// 新的选择器列表,更全面的覆盖范围
const videoElementSelectors = [
'ytd-rich-item-renderer',
'ytd-grid-video-renderer',
'ytd-video-renderer',
'ytd-compact-video-renderer',
'ytd-playlist-video-renderer',
'ytd-reel-item-renderer',
'.ytd-video-preview',
'.video-card',
'.ytd-compact-playlist-renderer',
'ytd-grid-playlist-renderer'
];
try {
const videoElements = document.querySelectorAll(videoElementSelectors.join(','));
let addedButtons = 0;
videoElements.forEach(element => {
// 如果已经处理过则跳过
if (element.hasAttribute(THUMBNAIL_PROCESSED_FLAG) && element.getAttribute(THUMBNAIL_PROCESSED_FLAG) === 'true') {
return;
}
// 如果已经有按钮则跳过且标记为已处理
if (element.querySelector(`.${THUMBNAIL_BUTTON_CLASS}`)) {
element.setAttribute(THUMBNAIL_PROCESSED_FLAG, 'true');
return;
}
const thumbnailContainer = element.querySelector('#thumbnail, .thumbnail, a[href*="/watch"]');
if (!thumbnailContainer) {
return;
}
const videoInfo = getVideoInfoFromElement(element);
if (!videoInfo) {
return;
}
const button = document.createElement('button');
button.className = THUMBNAIL_BUTTON_CLASS;
button.textContent = '📝 总结';
button.title = '使用Gemini总结此视频';
button.addEventListener('click', (e) => handleThumbnailButtonClick(e, videoInfo));
thumbnailContainer.style.position = 'relative';
thumbnailContainer.appendChild(button);
// 标记为已处理
element.setAttribute(THUMBNAIL_PROCESSED_FLAG, 'true');
addedButtons++;
});
if (addedButtons > 0) {
debugLog(`成功添加了 ${addedButtons} 个缩略图按钮。`);
}
} catch (e) {
debugLog('addThumbnailButtons出错: ' + e.message);
}
}
// 设置更强大的缩略图观察系统
function setupThumbnailButtonSystem() {
// 首先,立即运行检查
addThumbnailButtons();
// DOM变化观察器 - 独立观察缩略图变化
const thumbnailObserver = new MutationObserver((mutations) => {
let shouldAddButtons = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.tagName && (
node.tagName.toLowerCase().includes('ytd-') ||
node.querySelector('ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer')
)) {
shouldAddButtons = true;
break;
}
}
}
}
if (shouldAddButtons) break;
}
if (shouldAddButtons) {
clearTimeout(window.thumbnailButtonTimeout);
window.thumbnailButtonTimeout = setTimeout(addThumbnailButtons, 300);
}
});
// 设置观察器来监视整个页面的变化
thumbnailObserver.observe(document.body, {
childList: true,
subtree: true
});
// 页面导航和其他事件处理
function handleNavigation() {
if (!isVideoPage()) {
debugLog("检测到页面导航或滚动,添加缩略图按钮");
clearTimeout(window.thumbnailButtonTimeout);
window.thumbnailButtonTimeout = setTimeout(addThumbnailButtons, 500);
}
}
// YouTube特有的导航事件
window.addEventListener('yt-navigate-finish', handleNavigation);
// 滚动事件处理(用于延迟加载的内容)
let scrollTimeout;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(handleNavigation, 500);
});
// 定期检查缩略图按钮
setInterval(() => {
if (!isVideoPage()) {
addThumbnailButtons();
}
}, THUMBNAIL_CHECK_INTERVAL_MS);
// 页面加载完成后再次检查
window.addEventListener('load', () => {
setTimeout(addThumbnailButtons, 1000);
});
}
async function addYouTubeActionButtons() {
if (!isVideoPage()) {
debugLog("addYouTubeActionButtons called on non-video page, ensuring removal.");
removeYouTubeActionButtonsIfExists();
return;
}
if (document.getElementById(SUMMARY_BUTTON_ID) || document.getElementById(SUBTITLE_BUTTON_ID)) {
debugLog("Action buttons already exist.");
return;
}
debugLog("Video page detected. Attempting to add action buttons...");
// 扩展容器选择器,添加更多备选项
const containerSelectors = [
'#top-row.ytd-watch-metadata > #subscribe-button',
'#meta-contents #subscribe-button',
'#owner #subscribe-button',
'#meta-contents #top-row',
'#above-the-fold #title',
'ytd-watch-metadata #actions',
'#masthead #end',
// 添加更通用的备选选择器
'ytd-watch-metadata',
'#above-the-fold',
'#meta',
'#info-contents',
'#primary-inner',
'#top-level-buttons-computed',
'#menu-container',
'#description'
];
try {
let anchorElement = null;
try {
// 尝试使用waitForElement查找元素
anchorElement = await waitForElement(containerSelectors, YOUTUBE_ELEMENT_TIMEOUT_MS);
debugLog(`Found anchor element using selector matching: ${anchorElement.tagName}[id="${anchorElement.id}"][class="${anchorElement.className}"]`);
} catch (error) {
debugLog("Wait for element failed, trying direct query fallback: " + error);
// 如果waitForElement失败,直接查询DOM
for (const selector of containerSelectors) {
const element = document.querySelector(selector);
if (element && isElementVisible(element)) {
anchorElement = element;
debugLog(`Found anchor via direct query: ${selector}`);
break;
}
}
// 如果仍未找到,尝试更通用的容器
if (!anchorElement) {
const fallbackContainers = [
document.querySelector('#primary-inner'),
document.querySelector('#primary'),
document.querySelector('ytd-watch-flexy'),
document.querySelector('#player-container'),
document.querySelector('#container.ytd-masthead')
];
for (const container of fallbackContainers) {
if (container && isElementVisible(container)) {
anchorElement = container;
debugLog(`Using fallback container: ${container.tagName}[id="${container.id}"]`);
break;
}
}
}
// 如果仍然没有找到任何容器,使用body作为最后的备选
if (!anchorElement) {
anchorElement = document.body;
debugLog("Using document.body as last resort container");
}
}
if (document.getElementById(SUMMARY_BUTTON_ID) || document.getElementById(SUBTITLE_BUTTON_ID)) {
debugLog("Buttons were added concurrently, skipping.");
return;
}
const summaryButton = document.createElement('button');
summaryButton.id = SUMMARY_BUTTON_ID;
summaryButton.textContent = '📝 Gemini摘要';
Object.assign(summaryButton.style, {
backgroundColor: '#1a73e8', color: 'white', border: 'none', borderRadius: '18px',
padding: '0 16px', margin: '0 8px', cursor: 'pointer', fontWeight: '500',
height: '36px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '14px', zIndex: '100', whiteSpace: 'nowrap', transition: 'background-color 0.3s ease'
});
summaryButton.onmouseover = () => summaryButton.style.backgroundColor = '#185abc';
summaryButton.onmouseout = () => summaryButton.style.backgroundColor = '#1a73e8';
summaryButton.addEventListener('click', handleSummarizeClick);
const subtitleButton = document.createElement('button');
subtitleButton.id = SUBTITLE_BUTTON_ID;
subtitleButton.textContent = '🎯 生成字幕';
Object.assign(subtitleButton.style, {
backgroundColor: '#28a745',
color: 'white', border: 'none', borderRadius: '18px',
padding: '0 16px', margin: '0 8px 0 0', cursor: 'pointer', fontWeight: '500',
height: '36px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '14px', zIndex: '100', whiteSpace: 'nowrap', transition: 'background-color 0.3s ease'
});
subtitleButton.onmouseover = () => subtitleButton.style.backgroundColor = '#218838';
subtitleButton.onmouseout = () => subtitleButton.style.backgroundColor = '#28a745';
subtitleButton.addEventListener('click', handleGenerateSubtitlesClick);
// 改进的按钮插入逻辑,具有多种回退策略
try {
if (anchorElement.id?.includes('subscribe-button') || anchorElement.tagName === 'BUTTON') {
anchorElement.parentNode.insertBefore(summaryButton, anchorElement);
anchorElement.parentNode.insertBefore(subtitleButton, summaryButton);
debugLog(`Buttons inserted before anchor: ${anchorElement.id || anchorElement.tagName}`);
} else if (anchorElement.id === 'actions' || anchorElement.id === 'end' || anchorElement.id === 'top-row') {
anchorElement.insertBefore(summaryButton, anchorElement.firstChild);
anchorElement.insertBefore(subtitleButton, summaryButton);
debugLog(`Buttons inserted as first children of container: ${anchorElement.id || anchorElement.tagName}`);
} else {
anchorElement.appendChild(subtitleButton);
anchorElement.appendChild(summaryButton);
debugLog(`Buttons appended to container: ${anchorElement.id || anchorElement.tagName}`);
}
} catch (error) {
// 如果上述所有尝试都失败,创建固定位置按钮
debugLog(`Error inserting buttons normally: ${error}, creating fixed position buttons`);
document.body.appendChild(subtitleButton);
document.body.appendChild(summaryButton);
// 更新样式为固定定位
Object.assign(summaryButton.style, {
position: 'fixed',
top: '70px',
right: '20px',
zIndex: '9999'
});
Object.assign(subtitleButton.style, {
position: 'fixed',
top: '120px',
right: '20px',
zIndex: '9999'
});
}
debugLog("Action buttons successfully added!");
} catch (error) {
console.error('[YT->Gemini] Failed to add action buttons:', error);
removeYouTubeActionButtonsIfExists();
// 最后的备选方案:在页面上添加固定位置的按钮
if (isVideoPage()) {
try {
debugLog("Attempting last resort fixed position buttons");
createFixedPositionButtons();
} catch (e) {
console.error("Even fixed buttons failed:", e);
}
}
}
}
// 创建固定位置按钮的备选函数
function createFixedPositionButtons() {
if (document.getElementById(SUMMARY_BUTTON_ID) || document.getElementById(SUBTITLE_BUTTON_ID)) {
return;
}
const summaryButton = document.createElement('button');
summaryButton.id = SUMMARY_BUTTON_ID;
summaryButton.textContent = '📝 Gemini摘要';
Object.assign(summaryButton.style, {
position: 'fixed',
top: '70px',
right: '20px',
backgroundColor: '#1a73e8',
color: 'white',
border: 'none',
borderRadius: '18px',
padding: '0 16px',
cursor: 'pointer',
fontWeight: '500',
height: '36px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
zIndex: '99999',
whiteSpace: 'nowrap'
});
summaryButton.addEventListener('click', handleSummarizeClick);
const subtitleButton = document.createElement('button');
subtitleButton.id = SUBTITLE_BUTTON_ID;
subtitleButton.textContent = '🎯 生成字幕';
Object.assign(subtitleButton.style, {
position: 'fixed',
top: '120px',
right: '20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '18px',
padding: '0 16px',
cursor: 'pointer',
fontWeight: '500',
height: '36px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
zIndex: '99999',
whiteSpace: 'nowrap'
});
subtitleButton.addEventListener('click', handleGenerateSubtitlesClick);
document.body.appendChild(summaryButton);
document.body.appendChild(subtitleButton);
debugLog("Created fixed position buttons as last resort");
}
function handleSummarizeClick() {
try {
const youtubeUrl = window.location.href;
const titleElement = document.querySelector(YOUTUBE_VIDEO_TITLE_SELECTOR);
const videoTitle = titleElement?.textContent?.trim() || document.title.replace(/ - YouTube$/, '').trim() || 'Unknown Video';
const prompt = `请分析这个YouTube视频: ${youtubeUrl}\n\n提供一个全面的摘要,包括主要观点、关键见解和视频中讨论的重要细节,以结构化的方式分解内容,并包括任何重要的结论或要点。`;
debugLog(`Summarize prompt: ${prompt}`);
GM_setValue(PROMPT_KEY, prompt);
GM_setValue(TITLE_KEY, videoTitle);
GM_setValue(ORIGINAL_TITLE_KEY, videoTitle);
GM_setValue(TIMESTAMP_KEY, Date.now());
GM_setValue(ACTION_TYPE_KEY, 'summary');
window.open('https://gemini.google.com/', '_blank');
showNotification(YOUTUBE_NOTIFICATION_ID, `已跳转到 Gemini 进行视频总结...\n"${videoTitle}"`, YOUTUBE_NOTIFICATION_STYLE);
copyToClipboard(prompt);
} catch (error) {
console.error("[YT->Gemini] Error during summarize button click:", error);
showNotification(YOUTUBE_NOTIFICATION_ID, `创建摘要时出错: ${error.message}`, { ...YOUTUBE_NOTIFICATION_STYLE, backgroundColor: '#d93025', color: 'white' }, 10000);
}
}
async function handleGenerateSubtitlesClick() {
try {
await waitForElement(YOUTUBE_PLAYER_METADATA_SELECTOR, YOUTUBE_ELEMENT_TIMEOUT_MS);
debugLog("Player metadata element found, proceeding to get video details for subtitles.");
const youtubeUrl = window.location.href;
const titleElement = document.querySelector(YOUTUBE_VIDEO_TITLE_SELECTOR);
const videoTitle = titleElement?.textContent?.trim() || document.title.replace(/ - YouTube$/, '').trim() || 'Unknown Video';
let videoDurationInSeconds = 0;
try {
const durationMetaElement = document.querySelector('meta[itemprop="duration"]');
if (durationMetaElement && durationMetaElement.content) {
videoDurationInSeconds = parseISO8601DurationToSeconds(durationMetaElement.content);
debugLog(`Video duration from meta: ${durationMetaElement.content} -> ${videoDurationInSeconds}s`);
} else {
debugLog("Duration meta tag not found or has no content.");
}
} catch (e) { debugLog("Failed to get video duration: " + e); }
if (videoDurationInSeconds <= 0) {
showNotification(YOUTUBE_NOTIFICATION_ID, "无法获取当前视频时长,无法启动字幕任务。请尝试刷新页面。", { ...YOUTUBE_NOTIFICATION_STYLE, backgroundColor: '#d93025' }, 15000);
return;
}
const startTime = 0;
const firstSegmentActualEndTimeSeconds = Math.min(videoDurationInSeconds, SUBTITLE_SEGMENT_DURATION_SECONDS);
const startTimeFormatted = formatTimeHHMMSS(startTime);
const endTimeFormatted = formatTimeHHMMSS(firstSegmentActualEndTimeSeconds);
const prompt = `${youtubeUrl}
1.不要添加自己的语言
2.变成简体中文,流畅版本。
YouTube
请提取此视频从${startTimeFormatted}到${endTimeFormatted}的完整字幕文本。`;
GM_setValue(PROMPT_KEY, prompt);
const titleForGeminiNotificationDisplay = `${videoTitle} (字幕 ${startTimeFormatted}-${endTimeFormatted})`;
GM_setValue(TITLE_KEY, titleForGeminiNotificationDisplay);
GM_setValue(ORIGINAL_TITLE_KEY, videoTitle);
GM_setValue(TIMESTAMP_KEY, Date.now());
GM_setValue(ACTION_TYPE_KEY, 'subtitle');
GM_setValue(VIDEO_TOTAL_DURATION_KEY, videoDurationInSeconds);
GM_setValue(FIRST_SEGMENT_END_TIME_KEY, firstSegmentActualEndTimeSeconds);
const youtubeNotificationMessage = `已跳转到 Gemini 生成字幕: ${startTimeFormatted} - ${endTimeFormatted}...\n"${videoTitle}"`;
showNotification(YOUTUBE_NOTIFICATION_ID, youtubeNotificationMessage, YOUTUBE_NOTIFICATION_STYLE, 15000);
window.open('https://gemini.google.com/', '_blank');
copyToClipboard(prompt);
} catch (error) {
console.error("[YT->Gemini] Error during subtitle click:", error);
showNotification(YOUTUBE_NOTIFICATION_ID, `生成字幕时出错: ${error.message}`, { ...YOUTUBE_NOTIFICATION_STYLE, backgroundColor: '#d93025' }, 15000);
}
}
function removeYouTubeActionButtonsIfExists() {
const summaryButton = document.getElementById(SUMMARY_BUTTON_ID);
if (summaryButton) {
summaryButton.remove();
debugLog("Removed existing summary button.");
}
const subtitleButton = document.getElementById(SUBTITLE_BUTTON_ID);
if (subtitleButton) {
subtitleButton.remove();
debugLog("Removed existing subtitle button.");
}
}
// --- Gemini Related ---
const GEMINI_NOTIFICATION_ID = 'gemini-auto-notification';
const GEMINI_NOTIFICATION_STYLES = {
info: { backgroundColor: '#e8f4fd', color: '#1967d2', border: '1px solid #a8c7fa' },
warning: { backgroundColor: '#fef7e0', color: '#a56300', border: '1px solid #fdd663' },
error: { backgroundColor: '#fce8e6', color: '#c5221f', border: '1px solid #f7a7a5' }
};
const BASE_GEMINI_NOTIFICATION_STYLE = {
position: 'fixed', bottom: '230px', right: '20px', padding: '15px 35px 15px 20px',
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", duration = 12000) {
const style = { ...BASE_GEMINI_NOTIFICATION_STYLE, ...(GEMINI_NOTIFICATION_STYLES[type] || GEMINI_NOTIFICATION_STYLES.info) };
if (message.length > 150) {
style.maxWidth = '500px';
}
showNotification(GEMINI_NOTIFICATION_ID, message, style, duration);
}
async function handleGeminiPage() {
debugLog("Gemini page loaded. Checking for pending action...");
const prompt = GM_getValue(PROMPT_KEY, null);
const timestamp = GM_getValue(TIMESTAMP_KEY, 0);
const notificationTitleForSimpleMessage = GM_getValue(TITLE_KEY, 'N/A');
const originalVideoTitle = GM_getValue(ORIGINAL_TITLE_KEY, 'N/A');
const actionType = GM_getValue(ACTION_TYPE_KEY, null);
const videoTotalDurationSeconds = GM_getValue(VIDEO_TOTAL_DURATION_KEY, 0);
if(actionType) GM_deleteValue(ACTION_TYPE_KEY);
debugLog(`Retrieved from GM: actionType=${actionType}, promptExists=${!!prompt}, notificationTitleForSimpleMessage=${notificationTitleForSimpleMessage}, originalVideoTitle=${originalVideoTitle}, timestamp=${timestamp}, videoTotalDuration=${videoTotalDurationSeconds}`);
const clearAllGmValues = () => {
debugLog("Clearing all GM values.");
GM_deleteValue(PROMPT_KEY);
GM_deleteValue(TITLE_KEY);
GM_deleteValue(ORIGINAL_TITLE_KEY);
GM_deleteValue(TIMESTAMP_KEY);
GM_deleteValue(VIDEO_TOTAL_DURATION_KEY);
GM_deleteValue(FIRST_SEGMENT_END_TIME_KEY);
};
if (!prompt || !actionType || Date.now() - timestamp > GEMINI_PROMPT_EXPIRY_MS) {
debugLog("No valid prompt, actionType, or prompt expired.");
clearAllGmValues();
return;
}
debugLog(`Valid action (${actionType}) found. Proceeding to interact with Gemini page.`);
const initialNotificationMessageIntro = actionType === 'summary' ? '总结' : '字幕';
showGeminiNotification(`检测到来自 YouTube 的 "${initialNotificationMessageIntro}" 请求...\n视频: "${originalVideoTitle}"`, "info", 10000);
const textareaSelectors = ['div.input-area > div.input-box > div[contenteditable="true"]', 'div[role="textbox"][contenteditable="true"]', 'textarea[aria-label*="Prompt"]', 'div[contenteditable="true"]', 'textarea'];
const sendButtonSelectors = ['button[aria-label*="Send message"], button[aria-label*="发送消息"]', 'button:has(span[class*="send-icon"])', 'button.send-button', 'button:has(mat-icon[data-mat-icon-name="send"])', 'button[aria-label="Run"], button[aria-label="Submit"]'];
const geminiSuccessNotificationDuration = 15000;
try {
debugLog("Waiting for textarea...");
const textarea = await waitForElement(textareaSelectors, GEMINI_ELEMENT_TIMEOUT_MS);
debugLog("Textarea found. Focusing and inputting prompt.");
textarea.focus();
if (textarea.isContentEditable) textarea.textContent = prompt;
else if (textarea.tagName === 'TEXTAREA') textarea.value = prompt;
else {
debugLog("Cannot input text into the found element.");
throw new Error("Could not determine how to input text into the found element.");
}
textarea.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
debugLog("Prompt inserted and events dispatched.");
await new Promise(resolve => setTimeout(resolve, 250));
debugLog("Waiting for send button...");
const sendButton = await waitForElement(sendButtonSelectors, GEMINI_ELEMENT_TIMEOUT_MS);
debugLog("Send button found. Checking if enabled.");
if (sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') {
debugLog("Send button is disabled. Waiting a bit longer...");
await new Promise(resolve => setTimeout(resolve, 600));
if (sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') {
debugLog("Send button remained disabled.");
const errorMessage = "发送按钮仍然禁用。提示词已复制,请手动粘贴并发送。";
console.warn(`[YT->Gemini] ${errorMessage}`);
showGeminiNotification(errorMessage, "warning", geminiSuccessNotificationDuration);
copyToClipboard(prompt);
clearAllGmValues();
return;
}
debugLog("Send button became enabled after waiting.");
}
debugLog("Clicking send button...");
sendButton.click();
debugLog("Prompt sent to Gemini successfully.");
let finalNotificationMessage;
const notificationMessageIntro = actionType === 'summary' ? '总结' : '字幕';
if (actionType === 'subtitle' && videoTotalDurationSeconds > SUBTITLE_SEGMENT_DURATION_SECONDS) {
const firstSegmentDisplayEndTime = formatTimeHHMMSS(SUBTITLE_SEGMENT_DURATION_SECONDS);
const suggestedNextStartTimeFormatted = firstSegmentDisplayEndTime;
const suggestedNextSegmentEndTimeSeconds = Math.min(videoTotalDurationSeconds, SUBTITLE_SEGMENT_DURATION_SECONDS * 2);
const suggestedNextEndTimeFormatted = formatTimeHHMMSS(suggestedNextSegmentEndTimeSeconds);
finalNotificationMessage = `提示... 如果您需要提取后续部分的字幕 (例如从 ${suggestedNextStartTimeFormatted} 到 ${suggestedNextEndTimeFormatted}):
1. 需要手动修改时间范围(最好不超过20分钟)
2. 若Gemini模型不是2.5 Pro,建议暂停切换
3. 若Gemini不生成或繁体字,让他"重做"
`.trim();
} else {
finalNotificationMessage = `"${notificationMessageIntro}" 请求已发送! (视频: "${notificationTitleForSimpleMessage}")`;
}
showGeminiNotification(finalNotificationMessage, "info", geminiSuccessNotificationDuration);
clearAllGmValues();
} catch (error) {
console.error('[YT->Gemini] Error on Gemini page:', error);
showGeminiNotification(`自动操作失败: ${error.message}\n提示词已复制,请手动粘贴。`, "error", geminiSuccessNotificationDuration);
copyToClipboard(prompt);
clearAllGmValues();
}
}
// --- 添加视频页面观察器确保按钮存在 ---
function createVideoPageObserver() {
const observer = new MutationObserver((mutations) => {
if (isVideoPage() && !document.getElementById(SUMMARY_BUTTON_ID)) {
debugLog("视频页面结构变化,尝试重新添加按钮");
setTimeout(addYouTubeActionButtons, 500);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style']
});
return observer;
}
// --- Main Execution Logic ---
debugLog("脚本启动...");
if (window.location.hostname.includes('www.youtube.com')) {
debugLog("YouTube域名。");
// 缩略图按钮相关初始化应先于其他功能
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setupThumbnailButtonSystem();
} else {
document.addEventListener('DOMContentLoaded', setupThumbnailButtonSystem, { once: true });
}
// 视频页面按钮逻辑
const runYouTubeLogic = async () => {
if (isVideoPage()) {
debugLog("当前是视频页面。处理按钮逻辑。");
try {
await waitForElement(YOUTUBE_PLAYER_METADATA_SELECTOR, YOUTUBE_ELEMENT_TIMEOUT_MS);
debugLog("YouTube关键视频元素已准备好执行逻辑。");
addYouTubeActionButtons();
// 创建观察器确保按钮不消失
createVideoPageObserver();
} catch (error) {
debugLog("无法找到关键YouTube视频元素或视频页面上的其他错误: " + error);
removeYouTubeActionButtonsIfExists();
// 即使waitForElement失败,也尝试添加固定位置按钮
setTimeout(createFixedPositionButtons, 2000);
}
} else {
debugLog("非视频页面。确保移除操作按钮。");
removeYouTubeActionButtonsIfExists();
// 在非视频页面特别触发缩略图添加
setTimeout(addThumbnailButtons, 300);
}
};
if (document.readyState === 'complete' || document.readyState === 'interactive') {
runYouTubeLogic();
} else {
document.addEventListener('DOMContentLoaded', runYouTubeLogic, { once: true });
}
// 页面导航事件监听
window.addEventListener('yt-navigate-finish', () => {
debugLog("检测到yt-navigate-finish事件。");
requestAnimationFrame(runYouTubeLogic);
// 导航完成后专门触发缩略图更新
setTimeout(addThumbnailButtons, 500);
});
window.addEventListener('popstate', () => {
debugLog("检测到popstate事件。");
requestAnimationFrame(runYouTubeLogic);
// 历史导航后专门触发缩略图更新
setTimeout(addThumbnailButtons, 500);
});
// 额外的加载检测以确保按钮存在
window.addEventListener('load', () => {
debugLog("检测到window加载事件。");
setTimeout(runYouTubeLogic, 1000);
// 页面完全加载后额外检查
setTimeout(addThumbnailButtons, 1500);
});
} else if (window.location.hostname.includes('gemini.google.com')) {
debugLog("Gemini域名。");
if (document.readyState === 'complete' || document.readyState === 'interactive') {
handleGeminiPage();
} else {
window.addEventListener('DOMContentLoaded', handleGeminiPage, { once: true });
}
} else {
debugLog(`脚本加载在未识别的域名上: ${window.location.hostname}`);
}
function createPopup() {
const popup = document.createElement('div');
popup.id = 'gemini-popup';
popup.innerHTML = `
<button id="gemini-start-summary" class="button">开始总结当前视频</button>
<div id="gemini-status" class="status"></div>
`;
document.body.appendChild(popup);
return popup;
}
function showPopup() {
const popup = document.getElementById('gemini-popup') || createPopup();
popup.style.display = 'block';
const startButton = document.getElementById('gemini-start-summary');
const statusDiv = document.getElementById('gemini-status');
startButton.onclick = () => {
try {
if (!isVideoPage()) {
showStatus('请在YouTube视频页面使用此功能', 'error');
return;
}
handleSummarizeClick();
showStatus('开始总结视频...', 'success');
popup.style.display = 'none';
} catch (error) {
showStatus('发生错误:' + error.message, 'error');
}
};
function showStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = 'status ' + type;
statusDiv.style.display = 'block';
}
}
function addExtensionIconClickListener() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const extensionIcon = node.querySelector('ytd-masthead #buttons ytd-button-renderer');
if (extensionIcon) {
extensionIcon.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showPopup();
});
}
}
}
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
addExtensionIconClickListener();
} else {
window.addEventListener('DOMContentLoaded', addExtensionIconClickListener, {once: true});
}
})();