YouTube视频链接提取器

提取YouTube频道所有公开视频的链接

// ==UserScript==
// @name         YouTube视频链接提取器
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  提取YouTube频道所有公开视频的链接
// @author       huayuhuia
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @grant        none
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // 样式
    const styles = `
        .yt-extractor-btn {
            position: fixed;
            top: 70px;
            right: 20px;
            background-color: red;
            color: white;
            border: none;
            border-radius: 4px;
            padding: 8px 12px;
            font-size: 14px;
            cursor: pointer;
            z-index: 9999;
            font-weight: bold;
        }
        .yt-extractor-modal {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: white;
            border-radius: 8px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.2);
            width: 80%;
            max-width: 800px;
            max-height: 80vh;
            z-index: 10000;
            display: flex;
            flex-direction: column;
        }
        .yt-extractor-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 12px 16px;
            border-bottom: 1px solid #e0e0e0;
        }
        .yt-extractor-title {
            font-size: 18px;
            font-weight: bold;
            margin: 0;
        }
        .yt-extractor-close {
            background: none;
            border: none;
            font-size: 20px;
            cursor: pointer;
            color: #606060;
        }
        .yt-extractor-content {
            padding: 16px;
            overflow-y: auto;
            flex-grow: 1;
        }
        .yt-extractor-footer {
            padding: 12px 16px;
            border-top: 1px solid #e0e0e0;
            display: flex;
            justify-content: space-between;
        }
        .yt-extractor-status {
            color: #606060;
        }
        .yt-extractor-actions button {
            margin-left: 8px;
            padding: 6px 12px;
            border-radius: 4px;
            border: none;
            cursor: pointer;
        }
        .yt-extractor-copy {
            background-color: #065fd4;
            color: white;
        }
        .yt-extractor-download {
            background-color: #2ba640;
            color: white;
        }
        .yt-extractor-overlay {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: rgba(0,0,0,0.5);
            z-index: 9999;
        }
        .yt-extractor-progress {
            width: 100%;
            height: 4px;
            background-color: #f0f0f0;
            margin-top: 8px;
        }
        .yt-extractor-progress-bar {
            height: 100%;
            background-color: red;
            width: 0%;
            transition: width 0.3s;
        }
        .yt-extractor-video-list {
            list-style: none;
            padding: 0;
            margin: 0;
        }
        .yt-extractor-video-item {
            padding: 8px 0;
            border-bottom: 1px solid #f0f0f0;
        }
        .yt-extractor-video-item:last-child {
            border-bottom: none;
        }
        .yt-extractor-video-title {
            font-weight: bold;
            margin-bottom: 4px;
        }
        .yt-extractor-video-link {
            color: #065fd4;
            text-decoration: none;
            word-break: break-all;
        }
        .yt-extractor-video-meta {
            font-size: 12px;
            color: #606060;
            margin-top: 4px;
        }
    `;

    // 添加样式
    const styleElement = document.createElement('style');
    styleElement.textContent = styles;
    document.head.appendChild(styleElement);

    // 创建提取按钮
    function createExtractorButton() {
        const button = document.createElement('button');
        button.className = 'yt-extractor-btn';
        button.textContent = '提取视频链接';
        button.addEventListener('click', startExtraction);
        document.body.appendChild(button);
    }

    // 创建模态框
    function createModal() {
        const overlay = document.createElement('div');
        overlay.className = 'yt-extractor-overlay';
        
        const modal = document.createElement('div');
        modal.className = 'yt-extractor-modal';
        
        const header = document.createElement('div');
        header.className = 'yt-extractor-header';
        
        const title = document.createElement('h2');
        title.className = 'yt-extractor-title';
        title.textContent = '视频链接提取器';
        
        const closeBtn = document.createElement('button');
        closeBtn.className = 'yt-extractor-close';
        closeBtn.textContent = '×';
        closeBtn.addEventListener('click', () => {
            document.body.removeChild(overlay);
        });
        
        header.appendChild(title);
        header.appendChild(closeBtn);
        
        const content = document.createElement('div');
        content.className = 'yt-extractor-content';
        
        const statusDiv = document.createElement('div');
        statusDiv.className = 'yt-extractor-status';
        statusDiv.textContent = '准备提取视频链接...';
        
        const progressContainer = document.createElement('div');
        progressContainer.className = 'yt-extractor-progress';
        
        const progressBar = document.createElement('div');
        progressBar.className = 'yt-extractor-progress-bar';
        
        progressContainer.appendChild(progressBar);
        
        const videoList = document.createElement('ul');
        videoList.className = 'yt-extractor-video-list';
        
        content.appendChild(statusDiv);
        content.appendChild(progressContainer);
        content.appendChild(videoList);
        
        const footer = document.createElement('div');
        footer.className = 'yt-extractor-footer';
        
        const footerStatus = document.createElement('div');
        footerStatus.className = 'yt-extractor-status';
        footerStatus.textContent = '视频数量: 0';
        
        const actions = document.createElement('div');
        actions.className = 'yt-extractor-actions';
        
        const copyBtn = document.createElement('button');
        copyBtn.className = 'yt-extractor-copy';
        copyBtn.textContent = '复制链接';
        copyBtn.addEventListener('click', () => {
            copyToClipboard(videoList);
        });
        
        const downloadBtn = document.createElement('button');
        downloadBtn.className = 'yt-extractor-download';
        downloadBtn.textContent = '下载TXT';
        downloadBtn.addEventListener('click', () => {
            downloadAsTxt(videoList);
        });
        
        actions.appendChild(copyBtn);
        actions.appendChild(downloadBtn);
        
        footer.appendChild(footerStatus);
        footer.appendChild(actions);
        
        modal.appendChild(header);
        modal.appendChild(content);
        modal.appendChild(footer);
        
        overlay.appendChild(modal);
        document.body.appendChild(overlay);
        
        return {
            overlay,
            modal,
            statusDiv,
            progressBar,
            videoList,
            footerStatus
        };
    }

    // 复制到剪贴板
    function copyToClipboard(videoList) {
        const links = [];
        const items = videoList.querySelectorAll('.yt-extractor-video-item');
        
        items.forEach(item => {
            const title = item.querySelector('.yt-extractor-video-title').textContent;
            const link = item.querySelector('.yt-extractor-video-link').href;
            links.push(`${title}\n${link}\n`);
        });
        
        const text = links.join('\n');
        
        navigator.clipboard.writeText(text).then(() => {
            alert('链接已复制到剪贴板!');
        }).catch(err => {
            console.error('复制失败:', err);
            alert('复制失败,请手动复制。');
        });
    }

    // 下载为TXT
    function downloadAsTxt(videoList) {
        const links = [];
        const items = videoList.querySelectorAll('.yt-extractor-video-item');
        
        items.forEach(item => {
            const title = item.querySelector('.yt-extractor-video-title').textContent;
            const link = item.querySelector('.yt-extractor-video-link').href;
            const meta = item.querySelector('.yt-extractor-video-meta')?.textContent || '';
            links.push(`标题: ${title}\n链接: ${link}\n${meta}\n${'='.repeat(80)}\n`);
        });
        
        const text = links.join('\n');
        const channelName = getChannelName();
        const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `youtube_videos_${channelName}_${new Date().toISOString().slice(0, 10)}.txt`;
        a.click();
        URL.revokeObjectURL(url);
    }

    // 获取频道名称
    function getChannelName() {
        const url = window.location.href;
        const match = url.match(/\/@([^\/]+)/);
        if (match) {
            return match[1];
        }
        
        // 尝试从页面获取频道名称
        const channelNameElement = document.querySelector('#channel-name');
        if (channelNameElement) {
            return channelNameElement.textContent.trim().replace(/\s+/g, '_');
        }
        
        return 'channel';
    }

    // 检查是否在频道页面
    function isChannelPage() {
        return window.location.href.includes('/channel/') || 
               window.location.href.includes('/@') || 
               window.location.href.includes('/c/') || 
               window.location.href.includes('/user/');
    }

    // 检查是否在视频页面
    function isVideosTab() {
        return window.location.href.includes('/videos');
    }

    // 开始提取
    function startExtraction() {
        if (!isChannelPage()) {
            alert('请在YouTube频道页面使用此功能!');
            return;
        }
        
        if (!isVideosTab()) {
            // 如果不在视频标签页,跳转到视频标签页
            const currentUrl = window.location.href;
            const videosUrl = currentUrl.endsWith('/') ? `${currentUrl}videos` : `${currentUrl}/videos`;
            window.location.href = videosUrl;
            return;
        }
        
        const modalElements = createModal();
        
        // 开始提取视频
        extractVideos(modalElements);
    }

    // 提取视频
    async function extractVideos(modalElements) {
        const { statusDiv, progressBar, videoList, footerStatus } = modalElements;
        
        statusDiv.textContent = '正在提取视频链接...';
        
        const videos = [];
        let lastHeight = 0;
        let scrollAttempts = 0;
        const maxScrollAttempts = 300; // 最大滚动次数
        
        while (scrollAttempts < maxScrollAttempts) {
            // 滚动到页面底部
            window.scrollTo(0, document.documentElement.scrollHeight);
            
            // 等待内容加载
            await new Promise(resolve => setTimeout(resolve, 1500));
            
            // 提取当前页面上的视频
            const currentVideos = extractCurrentVideos();
            
            // 更新视频列表
            for (const video of currentVideos) {
                if (!videos.some(v => v.id === video.id)) {
                    videos.push(video);
                    
                    // 添加到UI
                    const item = document.createElement('li');
                    item.className = 'yt-extractor-video-item';
                    
                    const title = document.createElement('div');
                    title.className = 'yt-extractor-video-title';
                    title.textContent = video.title;
                    
                    const link = document.createElement('a');
                    link.className = 'yt-extractor-video-link';
                    link.href = video.url;
                    link.textContent = video.url;
                    link.target = '_blank';
                    
                    const meta = document.createElement('div');
                    meta.className = 'yt-extractor-video-meta';
                    if (video.published || video.views) {
                        meta.textContent = [video.published, video.views].filter(Boolean).join(' • ');
                    }
                    
                    item.appendChild(title);
                    item.appendChild(link);
                    item.appendChild(meta);
                    
                    videoList.appendChild(item);
                }
            }
            
            // 更新状态
            statusDiv.textContent = `已找到 ${videos.length} 个视频,继续滚动加载更多...`;
            footerStatus.textContent = `视频数量: ${videos.length}`;
            
            // 检查是否已经到达底部
            const currentHeight = document.documentElement.scrollHeight;
            if (currentHeight === lastHeight) {
                scrollAttempts++;
                if (scrollAttempts >= 3) { // 连续3次没有新内容,认为已到达底部
                    break;
                }
            } else {
                scrollAttempts = 0;
                lastHeight = currentHeight;
            }
            
            // 更新进度条
            progressBar.style.width = `${Math.min((scrollAttempts / 3) * 100, 100)}%`;
        }
        
        // 完成提取
        statusDiv.textContent = `提取完成!共找到 ${videos.length} 个视频。`;
        progressBar.style.width = '100%';
    }

    // 从当前页面提取视频
    function extractCurrentVideos() {
        const videos = [];
        
        console.log('开始提取视频...');
        
        // 尝试多种可能的视频容器选择器
        const selectors = [
            'ytd-grid-video-renderer', 
            'ytd-rich-item-renderer', 
            'ytd-video-renderer',
            'ytd-compact-video-renderer',
            'ytd-reel-item-renderer',
            // 更通用的选择器
            'div[id="dismissible"]',
            'div[class*="ytd-rich-grid-media"]',
            'div[class*="ytd-grid-video"]'
        ];
        
        // 使用更通用的选择器组合
        const selectorString = selectors.join(', ');
        console.log(`使用选择器: ${selectorString}`);
        
        const videoElements = document.querySelectorAll(selectorString);
        console.log(`找到 ${videoElements.length} 个可能的视频元素`);
        
        // 遍历所有找到的元素
        videoElements.forEach((element, index) => {
            try {
                console.log(`处理第 ${index + 1} 个元素...`);
                
                // 尝试多种可能的链接选择器
                const linkSelectors = [
                    'a#video-title', 
                    'a.yt-simple-endpoint.style-scope.ytd-video-renderer',
                    'a[href*="watch?v="]',
                    'a[title]',
                    'a[aria-label]'
                ];
                
                // 尝试每个选择器直到找到有效的链接元素
                let linkElement = null;
                for (const selector of linkSelectors) {
                    const el = element.querySelector(selector);
                    if (el && el.href && el.href.includes('watch?v=')) {
                        linkElement = el;
                        console.log(`找到链接元素,使用选择器: ${selector}`);
                        break;
                    }
                }
                
                // 如果没有找到链接元素,尝试查找任何带有href的a标签
                if (!linkElement) {
                    const allLinks = element.querySelectorAll('a[href]');
                    for (const link of allLinks) {
                        if (link.href && link.href.includes('watch?v=')) {
                            linkElement = link;
                            console.log('找到备用链接元素');
                            break;
                        }
                    }
                }
                
                if (!linkElement) {
                    console.log('未找到有效的链接元素,跳过');
                    return;
                }
                
                const videoUrl = linkElement.href;
                if (!videoUrl || !videoUrl.includes('watch?v=')) {
                    console.log('链接不是有效的视频URL,跳过');
                    return;
                }
                
                // 提取视频ID
                const videoId = new URL(videoUrl).searchParams.get('v');
                if (!videoId) {
                    console.log('无法提取视频ID,跳过');
                    return;
                }
                
                // 提取标题 - 尝试多种方法
                let title = '';
                if (linkElement.getAttribute('title')) {
                    title = linkElement.getAttribute('title');
                } else if (linkElement.getAttribute('aria-label')) {
                    title = linkElement.getAttribute('aria-label');
                } else if (linkElement.textContent.trim()) {
                    title = linkElement.textContent.trim();
                } else {
                    // 尝试从父元素中查找标题
                    const possibleTitleElements = element.querySelectorAll('h3, .title, [id*="title"]');
                    for (const el of possibleTitleElements) {
                        if (el.textContent.trim()) {
                            title = el.textContent.trim();
                            break;
                        }
                    }
                }
                
                if (!title) {
                    title = `视频 ${videoId}`;
                }
                
                // 提取发布时间和观看次数 - 尝试多种方法
                let published = '';
                let views = '';
                
                // 1. 尝试标准元数据选择器
                const metaSelectors = [
                    '#metadata-line span', 
                    '.metadata-line span', 
                    '#metadata span',
                    '.ytd-video-meta-block span',
                    '[id*="metadata"] span',
                    '[class*="metadata"] span'
                ];
                
                for (const selector of metaSelectors) {
                    const metaElements = element.querySelectorAll(selector);
                    if (metaElements.length >= 2) {
                        views = metaElements[0].textContent.trim();
                        published = metaElements[1].textContent.trim();
                        break;
                    } else if (metaElements.length === 1) {
                        views = metaElements[0].textContent.trim();
                        break;
                    }
                }
                
                // 2. 如果上面的方法失败,使用正则表达式从元素文本中提取
                if (!views && !published) {
                    const infoText = element.textContent;
                    
                    // 匹配观看次数
                    const viewsPatterns = [
                        /(\d+(\.\d+)?[KMB]?次观看)/,
                        /(\d+(\.\d+)?[KMB]? views)/,
                        /(\d+(\.\d+)?[KMB]?播放)/,
                        /观看次数:(\d+(\.\d+)?[KMB]?)/
                    ];
                    
                    for (const pattern of viewsPatterns) {
                        const match = infoText.match(pattern);
                        if (match) {
                            views = match[0];
                            break;
                        }
                    }
                    
                    // 匹配发布时间
                    const timePatterns = [
                        /((\d+)天前|(\d+)小时前|(\d+)分钟前|(\d+)秒前)/,
                        /((\d+) days ago|(\d+) hours ago|(\d+) minutes ago|(\d+) seconds ago)/,
                        /((\d+)天|(\d+)小时|(\d+)分钟|(\d+)秒)/
                    ];
                    
                    for (const pattern of timePatterns) {
                        const match = infoText.match(pattern);
                        if (match) {
                            published = match[0];
                            break;
                        }
                    }
                }
                
                // 添加视频到列表
                videos.push({
                    id: videoId,
                    title: title,
                    url: videoUrl,
                    published: published,
                    views: views
                });
                
                console.log(`成功提取视频: ${title} (${videoId})`);
            } catch (e) {
                console.error('提取视频信息时出错:', e);
            }
        });
        
        console.log(`本次提取到 ${videos.length} 个视频`);
        return videos;
    }

    // 修改提取视频函数,增加延迟和重试机制
    async function extractVideos(modalElements) {
        const { statusDiv, progressBar, videoList, footerStatus } = modalElements;
        
        statusDiv.textContent = '正在提取视频链接...';
        
        const videos = [];
        let lastHeight = 0;
        let scrollAttempts = 0;
        let emptyAttempts = 0;
        const maxScrollAttempts = 300; // 最大滚动次数
        const maxEmptyAttempts = 5; // 最大空提取次数
        
        while (scrollAttempts < maxScrollAttempts && emptyAttempts < maxEmptyAttempts) {
            // 滚动到页面底部
            window.scrollTo(0, document.documentElement.scrollHeight);
            
            // 等待内容加载 - 增加等待时间
            await new Promise(resolve => setTimeout(resolve, 2000));
            
            // 提取当前页面上的视频
            const currentVideos = extractCurrentVideos();
            
            if (currentVideos.length === 0) {
                emptyAttempts++;
                console.log(`未找到视频,空提取尝试 ${emptyAttempts}/${maxEmptyAttempts}`);
                statusDiv.textContent = `正在加载视频,请稍候... (${emptyAttempts}/${maxEmptyAttempts})`;
            } else {
                emptyAttempts = 0; // 重置空提取计数
            }
            
            // 更新视频列表
            for (const video of currentVideos) {
                if (!videos.some(v => v.id === video.id)) {
                    videos.push(video);
                    
                    // 添加到UI
                    const item = document.createElement('li');
                    item.className = 'yt-extractor-video-item';
                    
                    const title = document.createElement('div');
                    title.className = 'yt-extractor-video-title';
                    title.textContent = video.title;
                    
                    const link = document.createElement('a');
                    link.className = 'yt-extractor-video-link';
                    link.href = video.url;
                    link.textContent = video.url;
                    link.target = '_blank';
                    
                    const meta = document.createElement('div');
                    meta.className = 'yt-extractor-video-meta';
                    if (video.published || video.views) {
                        meta.textContent = [video.published, video.views].filter(Boolean).join(' • ');
                    }
                    
                    item.appendChild(title);
                    item.appendChild(link);
                    item.appendChild(meta);
                    
                    videoList.appendChild(item);
                }
            }
            
            // 更新状态
            statusDiv.textContent = `已找到 ${videos.length} 个视频,继续滚动加载更多...`;
            footerStatus.textContent = `视频数量: ${videos.length}`;
            
            // 检查是否已经到达底部
            const currentHeight = document.documentElement.scrollHeight;
            if (currentHeight === lastHeight) {
                scrollAttempts++;
                if (scrollAttempts >= 3) { // 连续3次没有新内容,认为已到达底部
                    break;
                }
            } else {
                scrollAttempts = 0;
                lastHeight = currentHeight;
            }
            
            // 更新进度条
            progressBar.style.width = `${Math.min((scrollAttempts / 3) * 100, 100)}%`;
        }
        
        // 完成提取
        if (videos.length > 0) {
            statusDiv.textContent = `提取完成!共找到 ${videos.length} 个视频。`;
        } else {
            statusDiv.textContent = `未能找到视频。请尝试刷新页面或检查控制台日志。`;
        }
        progressBar.style.width = '100%';
    }

    // 复制到剪贴板
    function copyToClipboard(videoList) {
        const links = [];
        const items = videoList.querySelectorAll('.yt-extractor-video-item');
        
        console.log(`准备复制 ${items.length} 个视频链接`); // 添加调试信息
        
        items.forEach(item => {
            const title = item.querySelector('.yt-extractor-video-title').textContent;
            const link = item.querySelector('.yt-extractor-video-link').href;
            links.push(`${title}\n${link}\n`);
        });
        
        const text = links.join('\n');
        
        if (text.trim() === '') {
            alert('没有找到视频链接可复制!');
            return;
        }
        
        // 使用备用方法复制到剪贴板
        try {
            navigator.clipboard.writeText(text).then(() => {
                alert('链接已复制到剪贴板!');
            }).catch(err => {
                console.error('使用navigator.clipboard复制失败:', err);
                fallbackCopy(text);
            });
        } catch (e) {
            console.error('复制到剪贴板出错:', e);
            fallbackCopy(text);
        }
    }
    
    // 备用复制方法
    function fallbackCopy(text) {
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        
        try {
            const successful = document.execCommand('copy');
            if (successful) {
                alert('链接已复制到剪贴板!');
            } else {
                alert('复制失败,请手动复制。');
            }
        } catch (err) {
            console.error('备用复制方法失败:', err);
            alert('复制失败,请手动复制。');
        }
        
        document.body.removeChild(textarea);
    }

    // 初始化
    function init() {
        // 等待页面加载完成
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', createExtractorButton);
        } else {
            createExtractorButton();
        }
        
        // 监听URL变化
        let lastUrl = location.href;
        new MutationObserver(() => {
            const url = location.href;
            if (url !== lastUrl) {
                lastUrl = url;
                
                // 移除旧按钮
                const oldButton = document.querySelector('.yt-extractor-btn');
                if (oldButton) {
                    oldButton.remove();
                }
                
                // 添加新按钮
                setTimeout(createExtractorButton, 1000);
            }
        }).observe(document, { subtree: true, childList: true });
    }

    // 启动脚本
    init();
})();