- // ==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 });
- })();