YouTube Auto Expand Comments and Replies

Automatically expand comments and replies on YouTube with performance optimization

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

Bạn sẽ cần cài đặt một tiện ích mở rộng như Tampermonkey hoặc Violentmonkey để cài đặt kịch bản này.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         YouTube Auto Expand Comments and Replies
// @name:zh-CN   YouTube 自动展开评论和回复
// @name:zh-TW   YouTube 自動展開評論和回覆
// @name:ja      YouTube コメントと返信を自動展開
// @name:ko      YouTube 댓글 및 답글 자동 확장
// @name:es      Expansión automática de comentarios y respuestas de YouTube
// @name:fr      Expansion automatique des commentaires et réponses YouTube
// @name:de      Automatische Erweiterung von YouTube-Kommentaren und Antworten
// @namespace    https://github.com/SuperNG6/YouTube-Comment-Script
// @author       SuperNG6
// @version      1.6
// @description  Automatically expand comments and replies on YouTube with performance optimization
// @license      MIT
// @description:zh-CN  优化性能的YouTube视频评论自动展开
// @description:zh-TW  優化性能的YouTube視頻評論自動展開
// @description:ja     パフォーマンスを最適化したYouTubeコメント自動展開
// @description:ko     성능이 최적화된 YouTube 댓글 자동 확장
// @description:es     Expansión automática de comentarios de YouTube con rendimiento optimizado
// @description:fr     Extension automatique des commentaires YouTube avec optimisation des performances
// @description:de     Automatische Erweiterung von YouTube-Kommentaren mit Leistungsoptimierung
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = Object.freeze({
        // Performance settings
        SCROLL_THROTTLE: 250,      // Throttle scroll events (ms)
        MUTATION_THROTTLE: 150,    // Throttle mutation observer (ms)
        INITIAL_DELAY: 1500,       // Initial delay before starting (ms)
        CLICK_INTERVAL: 500,       // Interval between clicks (ms)
        
        // Operation limits
        MAX_RETRIES: 5,            // Maximum retries for finding comments
        MAX_CLICKS_PER_BATCH: 3,   // Maximum clicks per operation
        SCROLL_THRESHOLD: 0.8,     // Scroll threshold for loading (0-1)
        
        // State tracking
        EXPANDED_CLASS: 'yt-auto-expanded',  // Class to mark expanded items
        STATE_CHECK_INTERVAL: 2000, // Interval to check expanded state (ms)
        
        // Debug mode
        DEBUG: false
    });

    // Selectors map for better maintainability
    const SELECTORS = Object.freeze({
        COMMENTS: 'ytd-comments#comments',
        COMMENTS_SECTION: 'ytd-item-section-renderer#sections',
        REPLIES: 'ytd-comment-replies-renderer',
        MORE_COMMENTS: 'ytd-continuation-item-renderer #button:not([disabled])',
        SHOW_REPLIES: '#more-replies > yt-button-shape > button:not([disabled])',
        HIDDEN_REPLIES: 'ytd-comment-replies-renderer ytd-button-renderer#more-replies button:not([disabled])',
        EXPANDED_REPLIES: 'div#expander[expanded]',
        COMMENT_THREAD: 'ytd-comment-thread-renderer'
    });

    class YouTubeCommentExpander {
        constructor() {
            this.observer = null;
            this.retryCount = 0;
            this.isProcessing = false;
            this.lastScrollTime = 0;
            this.lastMutationTime = 0;
            this.expandedComments = new Set();
            this.scrollHandler = this.throttle(this.handleScroll.bind(this), CONFIG.SCROLL_THROTTLE);
        }

        log(...args) {
            if (CONFIG.DEBUG) {
                console.log('[YouTube Comment Expander]', ...args);
            }
        }

        // Utility: Throttle function
        throttle(func, limit) {
            let inThrottle;
            return function(...args) {
                if (!inThrottle) {
                    func.apply(this, args);
                    inThrottle = true;
                    setTimeout(() => inThrottle = false, limit);
                }
            };
        }

        // Utility: Generate unique ID for comment thread
        getCommentId(element) {
            const dataContext = element.getAttribute('data-context') || '';
            const timestamp = element.querySelector('#header-author time')?.getAttribute('datetime') || '';
            return `${dataContext}-${timestamp}`;
        }

        // Check if comment is already expanded
        isCommentExpanded(element) {
            const commentId = this.getCommentId(element);
            return this.expandedComments.has(commentId);
        }

        // Mark comment as expanded
        markAsExpanded(element) {
            const commentId = this.getCommentId(element);
            element.classList.add(CONFIG.EXPANDED_CLASS);
            this.expandedComments.add(commentId);
        }

        // Check if element is truly visible and clickable
        isElementClickable(element) {
            if (!element || !element.offsetParent || element.disabled) {
                return false;
            }
            
            const rect = element.getBoundingClientRect();
            const isVisible = (
                rect.top >= 0 &&
                rect.left >= 0 &&
                rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
                rect.right <= (window.innerWidth || document.documentElement.clientWidth)
            );

            // Additional checks for button state
            const isButton = element.tagName.toLowerCase() === 'button';
            const isEnabled = !element.disabled && !element.hasAttribute('disabled');
            const hasCorrectAriaExpanded = !element.hasAttribute('aria-expanded') || 
                                         element.getAttribute('aria-expanded') === 'false';

            return isVisible && isEnabled && (!isButton || hasCorrectAriaExpanded);
        }

        // Safely click elements with expanded state tracking
        async clickElements(selector, maxClicks = CONFIG.MAX_CLICKS_PER_BATCH) {
            let clickCount = 0;
            const elements = Array.from(document.querySelectorAll(selector));
            
            for (const element of elements) {
                if (clickCount >= maxClicks) break;
                
                const commentThread = element.closest(SELECTORS.COMMENT_THREAD);
                if (commentThread && this.isCommentExpanded(commentThread)) {
                    continue;
                }

                if (this.isElementClickable(element)) {
                    try {
                        element.scrollIntoView({ behavior: "auto", block: "center" });
                        await new Promise(resolve => setTimeout(resolve, 100));
                        
                        const wasClicked = element.click();
                        if (wasClicked && commentThread) {
                            this.markAsExpanded(commentThread);
                            clickCount++;
                            this.log(`Clicked and marked as expanded: ${selector}`);
                        }
                        
                        await new Promise(resolve => setTimeout(resolve, CONFIG.CLICK_INTERVAL));
                    } catch (error) {
                        this.log(`Click error: ${error.message}`);
                    }
                }
            }
            
            return clickCount > 0;
        }

        // Monitor expanded state
        monitorExpandedState() {
            setInterval(() => {
                const expandedThreads = document.querySelectorAll(`${SELECTORS.COMMENT_THREAD}.${CONFIG.EXPANDED_CLASS}`);
                expandedThreads.forEach(thread => {
                    const hasExpandedContent = thread.querySelector(SELECTORS.EXPANDED_REPLIES);
                    if (!hasExpandedContent) {
                        const commentId = this.getCommentId(thread);
                        this.expandedComments.delete(commentId);
                        thread.classList.remove(CONFIG.EXPANDED_CLASS);
                    }
                });
            }, CONFIG.STATE_CHECK_INTERVAL);
        }

        // Process visible elements
        async processVisibleElements() {
            if (this.isProcessing) return;
            this.isProcessing = true;

            try {
                const clickedMore = await this.clickElements(SELECTORS.MORE_COMMENTS);
                const clickedReplies = await this.clickElements(SELECTORS.SHOW_REPLIES);
                const clickedHidden = await this.clickElements(SELECTORS.HIDDEN_REPLIES);

                return clickedMore || clickedReplies || clickedHidden;
            } finally {
                this.isProcessing = false;
            }
        }

        // Handle scroll events
        async handleScroll() {
            const now = Date.now();
            if (now - this.lastScrollTime < CONFIG.SCROLL_THROTTLE) return;
            this.lastScrollTime = now;

            const scrollPosition = window.scrollY + window.innerHeight;
            const documentHeight = document.documentElement.scrollHeight;
            
            if (scrollPosition / documentHeight > CONFIG.SCROLL_THRESHOLD) {
                await this.processVisibleElements();
            }
        }

        // Setup mutation observer
        setupObserver() {
            const commentsSection = document.querySelector(SELECTORS.COMMENTS_SECTION);
            if (!commentsSection) return false;

            this.observer = new MutationObserver(
                this.throttle(async (mutations) => {
                    const now = Date.now();
                    if (now - this.lastMutationTime < CONFIG.MUTATION_THROTTLE) return;
                    this.lastMutationTime = now;

                    const hasRelevantChanges = mutations.some(mutation => 
                        mutation.addedNodes.length > 0 || 
                        mutation.attributeName === 'hidden' ||
                        mutation.attributeName === 'disabled'
                    );

                    if (hasRelevantChanges) {
                        await this.processVisibleElements();
                    }
                }, CONFIG.MUTATION_THROTTLE)
            );

            this.observer.observe(commentsSection, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['hidden', 'disabled', 'aria-expanded']
            });

            return true;
        }

        // Initialize the expander
        async init() {
            if (this.retryCount >= CONFIG.MAX_RETRIES) {
                this.log('Max retries reached, aborting initialization');
                return;
            }

            // Check if we're on a video page
            if (!window.location.pathname.startsWith('/watch')) {
                return;
            }

            // Wait for comments section
            if (!document.querySelector(SELECTORS.COMMENTS)) {
                this.retryCount++;
                this.log(`Retrying initialization (${this.retryCount}/${CONFIG.MAX_RETRIES})`);
                setTimeout(() => this.init(), CONFIG.INITIAL_DELAY);
                return;
            }

            // Setup observers and handlers
            if (this.setupObserver()) {
                window.addEventListener('scroll', this.scrollHandler, { passive: true });
                this.monitorExpandedState();
                await this.processVisibleElements();
                this.log('Initialization complete');
            }
        }

        // Cleanup resources
        cleanup() {
            if (this.observer) {
                this.observer.disconnect();
                this.observer = null;
            }
            window.removeEventListener('scroll', this.scrollHandler);
            this.expandedComments.clear();
        }
    }

    // Initialize the expander when the page is ready
    const expander = new YouTubeCommentExpander();
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => setTimeout(() => expander.init(), CONFIG.INITIAL_DELAY));
    } else {
        setTimeout(() => expander.init(), CONFIG.INITIAL_DELAY);
    }

    // Handle page navigation (for YouTube's SPA)
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            expander.cleanup();
            setTimeout(() => expander.init(), CONFIG.INITIAL_DELAY);
        }
    }).observe(document.querySelector('body'), { childList: true, subtree: true });
})();