自動展開 YouTube 評論與回覆 ✅

穩定展開YouTube評論和回覆,包括「顯示更多回覆」。支援最新介面。

目前為 2025-04-24 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube コメントと返信を自動展開 ✅
// @name:en      YouTube Auto Expand Comments and Replies ✅
// @name:ja      YouTube コメントと返信を自動展開 ✅
// @name:zh-CN   自动展开 YouTube 评论与回复 ✅
// @name:zh-TW   自動展開 YouTube 評論與回覆 ✅
// @name:ko      YouTube 댓글 및 답글 자동 확장 ✅
// @name:fr      Déploiement automatique des commentaires YouTube ✅
// @name:es      Expansión automática de comentarios de YouTube ✅
// @name:de      YouTube-Kommentare automatisch erweitern ✅
// @name:pt-BR   Expandir automaticamente os comentários do YouTube ✅
// @name:ru      Авторазворачивание комментариев на YouTube ✅
// @version      2.8.0
// @description         安定動作でYouTubeのコメントと返信、「他の返信を表示」も自動展開!現行UIに完全対応。
// @description:en      Reliably auto-expands YouTube comments, replies, and "Show more replies". Fully updated for current UI.
// @description:zh-CN   稳定展开YouTube评论和回复,包括“显示更多回复”。兼容新界面。
// @description:zh-TW   穩定展開YouTube評論和回覆,包括「顯示更多回覆」。支援最新介面。
// @description:ko      YouTube의 댓글과 답글을 안정적으로 자동 확장. 최신 UI에 대응.
// @description:fr      Déploie automatiquement les commentaires et réponses YouTube. Compatible avec la nouvelle interface.
// @description:es      Expande automáticamente los comentarios y respuestas en YouTube. Totalmente actualizado para la nueva interfaz.
// @description:de      Erweiterung von YouTube-Kommentaren und Antworten – automatisch und zuverlässig. Für aktuelle Oberfläche optimiert.
// @description:pt-BR   Expande automaticamente comentários e respostas no YouTube. Compatível com a nova UI.
// @description:ru      Автоматически разворачивает комментарии и ответы на YouTube. Полностью адаптирован к новому интерфейсу.
// @namespace    https://github.com/koyasi777/youtube-auto-expand-comments
// @author       koyasi777
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        none
// @run-at       document-end
// @license      MIT
// @homepageURL  https://github.com/koyasi777/youtube-auto-expand-comments
// @supportURL   https://github.com/koyasi777/youtube-auto-expand-comments/issues
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = Object.freeze({
        SCROLL_THROTTLE: 250,
        MUTATION_THROTTLE: 150,
        INITIAL_DELAY: 1500,
        CLICK_INTERVAL: 500,
        EXPANDED_CLASS: 'yt-auto-expanded',
        DEBUG: false
    });

    const SELECTORS = Object.freeze({
        COMMENTS: 'ytd-comments#comments',
        COMMENT_THREAD: 'ytd-comment-thread-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])',
        CONTINUATION_REPLIES: 'ytd-comment-replies-renderer ytd-continuation-item-renderer ytd-button-renderer button:not([disabled])'
    });

    class YouTubeCommentExpander {
        constructor() {
            this.observer = null;
            this.isProcessing = false;
            this.expandedComments = new Set();
            this.scrollHandler = this.throttle(() => this.processVisibleElements(), CONFIG.SCROLL_THROTTLE);
        }

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

        throttle(fn, delay) {
            let last = 0;
            return (...args) => {
                const now = Date.now();
                if (now - last >= delay) {
                    last = now;
                    fn.apply(this, args);
                }
            };
        }

        getCommentId(thread) {
            const timestamp = thread.querySelector('#header-author time')?.getAttribute('datetime') || '';
            const id = thread.getAttribute('data-thread-id') || '';
            return `${id}-${timestamp}`;
        }

        isCommentExpanded(thread) {
            return this.expandedComments.has(this.getCommentId(thread));
        }

        markAsExpanded(thread) {
            thread.classList.add(CONFIG.EXPANDED_CLASS);
            this.expandedComments.add(this.getCommentId(thread));
        }

        async clickElements(selector) {
            const elements = Array.from(document.querySelectorAll(selector));
            let clickedAny = false;
            for (const el of elements) {
                const thread = el.closest(SELECTORS.COMMENT_THREAD);
                if (thread && this.isCommentExpanded(thread)) continue;

                el.scrollIntoView({ behavior: 'auto', block: 'center' });
                await new Promise(r => setTimeout(r, 100));

                if (el.disabled || el.getAttribute('aria-expanded') === 'true') continue;

                try {
                    el.click();
                    clickedAny = true;
                    if (thread) this.markAsExpanded(thread);
                    this.log('Clicked:', selector);
                    await new Promise(r => setTimeout(r, CONFIG.CLICK_INTERVAL));
                } catch (e) {
                    this.log('Click error:', e);
                }
            }
            return clickedAny;
        }

        async processVisibleElements() {
            if (this.isProcessing) return;
            this.isProcessing = true;
            try {
                let loop;
                do {
                    loop = false;
                    loop = await this.clickElements(SELECTORS.MORE_COMMENTS) || loop;
                    loop = await this.clickElements(SELECTORS.SHOW_REPLIES) || loop;
                    loop = await this.clickElements(SELECTORS.HIDDEN_REPLIES) || loop;
                    loop = await this.clickElements(SELECTORS.CONTINUATION_REPLIES) || loop;
                } while (loop);
            } finally {
                this.isProcessing = false;
            }
        }

        setupMutationObserver() {
            const container = document.querySelector(SELECTORS.COMMENTS);
            if (!container) return false;
            this.observer = new MutationObserver(this.throttle(() => {
                this.processVisibleElements();
            }, CONFIG.MUTATION_THROTTLE));
            this.observer.observe(container, { childList: true, subtree: true });
            return true;
        }

        setupIntersectionObserver() {
            const io = new IntersectionObserver(entries => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        try { entry.target.click(); }
                        catch(e) { this.log('IO click error', e); }
                    }
                });
            }, { rootMargin: '0px', threshold: 0 });

            const observeButtons = () => {
                document.querySelectorAll(
                    `${SELECTORS.MORE_COMMENTS},${SELECTORS.SHOW_REPLIES},${SELECTORS.HIDDEN_REPLIES},${SELECTORS.CONTINUATION_REPLIES}`
                ).forEach(btn => io.observe(btn));
            };

            observeButtons();
            const container = document.querySelector(SELECTORS.COMMENTS);
            if (container) {
                new MutationObserver(observeButtons).observe(container, { childList: true, subtree: true });
            }
        }

        async init() {
            if (!location.pathname.startsWith('/watch')) return;
            for (let i = 0; i < 10 && !document.querySelector(SELECTORS.COMMENTS); i++) {
                await new Promise(r => setTimeout(r, CONFIG.INITIAL_DELAY));
            }
            if (!document.querySelector(SELECTORS.COMMENTS)) return;

            window.addEventListener('scroll', this.scrollHandler, { passive: true });
            this.setupMutationObserver();
            this.setupIntersectionObserver();
            await this.processVisibleElements();
            this.log('Expander initialized');
        }

        cleanup() {
            if (this.observer) this.observer.disconnect();
            window.removeEventListener('scroll', this.scrollHandler);
            this.expandedComments.clear();
        }
    }

    const expander = new YouTubeCommentExpander();
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => setTimeout(() => expander.init(), CONFIG.INITIAL_DELAY));
    } else {
        setTimeout(() => expander.init(), CONFIG.INITIAL_DELAY);
    }

    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            expander.cleanup();
            setTimeout(() => expander.init(), CONFIG.INITIAL_DELAY);
        }
    }).observe(document.body, { childList: true, subtree: true });
})();