自动展开 YouTube 评论与回复 ✅

稳定展开YouTube评论和回复,包括“显示更多回复”。兼容新界面。

// ==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 ✅
// @description  安定動作でYouTubeのコメントと返信、「他の返信を表示」も自動展開!現行UIに完全対応。
// @description:en Reliably auto-expands YouTube comments, replies, and "Show more replies". Fully updated for current UI.
// @description:ja 安定動作でYouTubeのコメントと返信、「他の返信を表示」も自動展開!現行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. Полностью адаптирован к новому интерфейсу.
// @version      5.7.1
// @namespace    https://github.com/koyasi777/youtube-auto-comment-expander
// @author       koyasi777
// @match        *://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @run-at       document-end
// @license      MIT
// @homepageURL  https://github.com/koyasi777/youtube-auto-comment-expander
// @supportURL   https://github.com/koyasi777/youtube-auto-comment-expander/issues
// ==/UserScript==

(function() {
    'use strict';

    class ConfigManager {
        constructor() {
            this.defaults = {
                scriptEnabled: true,
                debugMode: false,
                initialDelay: 2500,
                clickInterval: 130,
                expandComments: true,
                expandReplies: true,
                expandNestedReplies: true,
                expandLongComments: true,
            };
            this.config = {};
            this.load();
        }
        load() { for (const key in this.defaults) this.config[key] = GM_getValue(key, this.defaults[key]); }
        get(key) { return this.config[key]; }
        set(key, value) { this.config[key] = value; GM_setValue(key, value); }
        reset() { for (const key in this.defaults) this.set(key, this.defaults[key]); }
        registerMenu() {
            GM_registerMenuCommand('⚙️ コメント自動展開 設定', () => this.showSettingsPrompt());
            GM_registerMenuCommand('🗑️ 設定をリセット', () => { if (confirm('本当に全ての設定をリセットしますか?')) { this.reset(); alert('設定がリセットされました。ページをリロードして反映させてください。'); } });
        }
        showSettingsPrompt() {
            const newSettings = {};
            for (const key in this.defaults) {
                const currentValue = this.get(key), type = typeof this.defaults[key];
                let newValue = prompt(`${key} (${type}) [デフォルト: ${this.defaults[key]}]\n現在の値: ${currentValue}`, currentValue);
                if (newValue === null) return;
                if (type === 'boolean') newSettings[key] = newValue.toLowerCase() === 'true';
                else if (type === 'number') { newSettings[key] = parseInt(newValue, 10); if (isNaN(newSettings[key])) newSettings[key] = this.defaults[key]; }
                else newSettings[key] = newValue;
            }
            for (const key in newSettings) this.set(key, newSettings[key]);
            alert('設定が更新されました。ページをリロードして反映させてください。');
        }
    }

    class YouTubeCommentExpander {
        constructor(config) {
            this.config = config;
            this.mainObserver = null;
            this.actionObserver = null;
            this.readMoreObserver = null;
            this.rules = [
                { name: 'ExpandComments', selector: 'ytd-comments > #sections > #contents > ytd-continuation-item-renderer, ytd-engagement-panel-section-list-renderer > #contents > ytd-continuation-item-renderer', condition: () => this.config.get('expandComments') },
                { name: 'ExpandReplies', selector: '#more-replies', condition: () => this.config.get('expandReplies') },
                { name: 'ExpandNestedReplies', selector: 'ytd-comment-replies-renderer ytd-continuation-item-renderer button', condition: () => this.config.get('expandNestedReplies') },
            ];
        }

        log(level, ...args) { if (!this.config.get('debugMode')) return; console.log(`[YTCE:${level.toUpperCase()}]`, ...args); }

        setupObservers() {
            this.actionObserver = new IntersectionObserver(async (entries, observer) => {
                for (const entry of entries) {
                    if (entry.isIntersecting && this.config.get('scriptEnabled')) {
                        const target = entry.target;
                        observer.unobserve(target);
                        this.log('debug', 'Action target in view, clicking.', target);
                        await new Promise(resolve => setTimeout(resolve, this.config.get('clickInterval')));
                        const clickable = target.querySelector('button, yt-button-shape') || target;
                        clickable.click();
                    }
                }
            }, { rootMargin: '0px 0px 500px 0px' });

            if (this.config.get('expandLongComments')) {
                this.readMoreObserver = new IntersectionObserver(async (entries, observer) => {
                    for (const entry of entries) {
                        if (entry.isIntersecting && this.config.get('scriptEnabled')) {
                            const button = entry.target;
                            observer.unobserve(button);
                            this.log('debug', 'ReadMore button in view, clicking.', button);
                            await new Promise(resolve => setTimeout(resolve, this.config.get('clickInterval')));
                            button.click();
                            await new Promise(resolve => setTimeout(resolve, 200));
                            const commentViewModel = button.closest('ytd-comment-view-model, ytd-comment-renderer');
                            if (commentViewModel) {
                                const lessButton = commentViewModel.querySelector('.less-button, tp-yt-paper-button#less');
                                if (lessButton) lessButton.style.display = 'none';
                            }
                        }
                    }
                }, { threshold: 0.1 });
            }
        }

        observeNewNodes(node) {
            if (!(node instanceof Element)) return;
            for (const rule of this.rules) {
                if (rule.condition()) {
                    if (node.matches(rule.selector)) this.actionObserver.observe(node);
                    node.querySelectorAll(rule.selector).forEach(el => this.actionObserver.observe(el));
                }
            }
            if (this.readMoreObserver) {
                const readMoreSelector = '#content-text[collapsed], .more-button.ytd-comment-view-model, tp-yt-paper-button#more:not([aria-expanded="true"])';
                if (node.matches(readMoreSelector)) this.readMoreObserver.observe(node);
                node.querySelectorAll(readMoreSelector).forEach(btn => this.readMoreObserver.observe(btn));
            }
        }

        start(commentsContainer) {
            if (!this.config.get('scriptEnabled')) {
                this.log('info', 'Script is disabled by toggle, not starting.');
                return false;
            }
            if (!commentsContainer) { this.log('error', 'start() called without a valid container.'); return false; }
            this.stop();
            this.log('info', 'Comment container found. Starting observers.', commentsContainer);
            this.setupObservers();
            this.observeNewNodes(commentsContainer);
            this.mainObserver = new MutationObserver((mutations) => {
                for (const mutation of mutations) {
                    for (const node of mutation.addedNodes) this.observeNewNodes(node);
                }
            });
            this.mainObserver.observe(commentsContainer, { childList: true, subtree: true });
            this.log('info', 'All observers started.');
            return true;
        }

        stop() {
            if (this.mainObserver) { this.mainObserver.disconnect(); this.mainObserver = null; }
            if (this.actionObserver) { this.actionObserver.disconnect(); this.actionObserver = null; }
            if (this.readMoreObserver) { this.readMoreObserver.disconnect(); this.readMoreObserver = null; }
            this.log('info', 'All observers stopped and state reset.');
        }
    }

    class UIManager {
        constructor(configManager, expander) {
            this.configManager = configManager;
            this.expander = expander;
            this.toggleContainerId = 'ytce-toggle-container';
            this.toggle = null;
            this.uiObserver = null;
            this.icons = {
                on: `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false"><path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></svg>`,
                off: `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"></path></svg>`,
            };
            this.tooltips = {
                on: { ja: 'コメント自動展開: ON', en: 'Auto-expand comments: ON' },
                off: { ja: 'コメント自動展開: OFF', en: 'Auto-expand comments: OFF' }
            };
            this.injectStyles();
        }

        injectStyles() {
            GM_addStyle(`
                #${this.toggleContainerId} {
                    position: relative; display: flex; align-items: center; margin-left: 16px;
                    border: 1px solid var(--yt-spec-mono-10, #ccc); border-radius: 16px;
                    padding: 2px 8px; height: 30px; cursor: pointer;
                    background-color: var(--yt-spec-badge-chip-background, #f2f2f2);
                    box-shadow: 0 1px 2px rgba(0,0,0,0.05);
                    transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
                    -webkit-tap-highlight-color: transparent;
                }
                #${this.toggleContainerId}:hover {
                    background-color: var(--yt-spec-mono-15, #e0e0e0);
                }
                #${this.toggleContainerId}.ytce-active {
                    background-color: var(--yt-spec-brand-button-background, #1c62b9);
                    border-color: var(--yt-spec-brand-button-background, #1c62b9);
                }
                .ytce-toggle-icon {
                    width: 15px; height: 15px; margin-right: 6px;
                    display: flex; align-items: center; pointer-events: none;
                }
                .ytce-toggle-icon svg {
                    width: 15px; height: 15px;
                    fill: var(--yt-spec-icon-inactive, #606060);
                    transition: fill 0.2s ease-in-out;
                }
                #${this.toggleContainerId}.ytce-active .ytce-toggle-icon svg {
                    fill: #fff;
                }
                .ytce-toggle-switch {
                    position: relative; display: inline-block; width: 28px; height: 14px; pointer-events: none;
                }
                .ytce-toggle-slider {
                    position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
                    background-color: #aaa; transition: .3s; border-radius: 14px;
                }
                .ytce-toggle-slider:before {
                    position: absolute; content: ""; height: 10px; width: 10px;
                    left: 2px; bottom: 2px; background-color: white;
                    transition: .3s; border-radius: 50%;
                }
                input:checked + .ytce-toggle-slider {
                    background-color: var(--yt-spec-call-to-action, #065fd4);
                }
                #${this.toggleContainerId}.ytce-active input:checked + .ytce-toggle-slider {
                    background-color: rgba(255,255,255,0.4);
                }
                input:checked + .ytce-toggle-slider:before {
                    transform: translateX(14px);
                }
                #${this.toggleContainerId} .ytce-toggle-switch input {
                    opacity: 0 !important; width: 0 !important; height: 0 !important;
                    position: absolute !important; z-index: -1 !important; pointer-events: none !important;
                }
            `);
        }

        createToggleElement() {
            if (document.getElementById(this.toggleContainerId)) return null;
            const container = document.createElement('div');
            container.id = this.toggleContainerId;
            const tooltip = document.createElement('tp-yt-paper-tooltip');
            tooltip.setAttribute('role', 'tooltip');
            const tooltipText = document.createElement('div');
            tooltipText.id = 'tooltip';
            tooltipText.className = 'style-scope tp-yt-paper-tooltip';
            tooltip.appendChild(tooltipText);
            const iconDiv = document.createElement('div');
            const switchLabel = document.createElement('label');
            switchLabel.className = 'ytce-toggle-switch';
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            const slider = document.createElement('span');
            slider.className = 'ytce-toggle-slider';
            switchLabel.append(checkbox, slider);
            container.append(iconDiv, switchLabel, tooltip);
            this.toggle = { container, checkbox, iconDiv, tooltipText };
            container.addEventListener('click', (e) => {
                e.stopPropagation();
                checkbox.checked = !checkbox.checked;
                this.onToggleChange();
            });
            const initialState = this.configManager.get('scriptEnabled');
            checkbox.checked = initialState;
            this.updateToggleVisuals(initialState);
            return container;
        }

        onToggleChange() {
            const isEnabled = this.toggle.checkbox.checked;
            this.configManager.set('scriptEnabled', isEnabled);
            this.updateToggleVisuals(isEnabled);
            this.expander.log('info', `Script ${isEnabled ? 'enabled' : 'disabled'} by toggle.`);
            if (isEnabled) {
                const commentsContainer = getCurrentCommentsContainer();
                if (commentsContainer) this.expander.start(commentsContainer);
            } else {
                this.expander.stop();
            }
        }

        updateToggleVisuals(isEnabled) {
            if (!this.toggle) return;
            this.toggle.iconDiv.innerHTML = this.icons[isEnabled ? 'on' : 'off'];
            this.toggle.iconDiv.className = `ytce-toggle-icon ${isEnabled ? 'on' : 'off'}`;
            this.toggle.container.classList.toggle('ytce-active', isEnabled);
            const lang = document.documentElement.lang.startsWith('ja') ? 'ja' : 'en';
            this.toggle.tooltipText.textContent = this.tooltips[isEnabled ? 'on' : 'off'][lang];
        }

        observeCommentsHeader(containerSelector, sortMenuSelector, sortMenuLabelSelector, insertMode) {
            waitForElement(containerSelector, (container) => {
                this.stop();
                const updateUI = () => this.updateCommentsHeaderUI(sortMenuSelector, sortMenuLabelSelector, insertMode);
                this.uiObserver = new MutationObserver(updateUI);
                this.uiObserver.observe(container, { childList: true, subtree: true });
                updateUI();
                this.expander.log('info', `UI Observer started for "${containerSelector}".`);
            });
        }

        updateCommentsHeaderUI(sortMenuSelector, sortMenuLabelSelector, insertMode) {
            const sortMenu = document.querySelector(sortMenuSelector);
            if (!sortMenu) return;

            if (!document.getElementById(this.toggleContainerId)) {
                const toggleElement = this.createToggleElement();
                if (toggleElement) {
                    if (insertMode === 'append') {
                        sortMenu.parentElement.appendChild(toggleElement);
                    } else if (insertMode === 'after') {
                        sortMenu.insertAdjacentElement('afterend', toggleElement);
                    }
                    this.expander.log('debug', 'Toggle UI injected.');
                }
            }

            const label = document.querySelector(sortMenuLabelSelector);
            if (label && label.style.display !== 'none') {
                label.style.display = 'none';
                this.expander.log('debug', 'Sort menu label hidden.');
            }
        }

        initForWatchPage() {
            this.observeCommentsHeader(
                'ytd-comments#comments',
                '#comments #sort-menu',
                '#comments #sort-menu #icon-label',
                'append'
            );
        }

        initForShortsPage() {
            this.observeCommentsHeader(
                'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-comments-section"]',
                'ytd-engagement-panel-title-header-renderer #menu',
                'ytd-engagement-panel-title-header-renderer #menu #icon-label',
                'after'
            );
        }

        stop() {
            if (this.uiObserver) {
                this.uiObserver.disconnect();
                this.uiObserver = null;
                this.expander.log('info', 'UI Observer stopped.');
            }
        }
    }

    const configManager = new ConfigManager();
    let expander = null;
    let uiManager = null;
    let currentPath = '';

    function waitForElement(selector, callback, timeout = 15000) {
        let timeoutId = null;
        const observer = new MutationObserver((mutations, obs) => {
            const element = document.querySelector(selector);
            if (element) {
                if (timeoutId) clearTimeout(timeoutId);
                obs.disconnect();
                callback(element);
            }
        });
        timeoutId = setTimeout(() => {
            observer.disconnect();
            expander?.log('warn', `waitForElement timed out for selector: ${selector}`);
        }, timeout);
        observer.observe(document.body, { childList: true, subtree: true });
    }

    function getCurrentCommentsContainer() {
        if (location.pathname.startsWith('/watch')) {
            return document.querySelector('ytd-comments#comments');
        } else if (location.pathname.startsWith('/shorts/')) {
            return document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-comments-section"]');
        }
        return null;
    }

    function initializeScript() {
        const path = location.pathname + location.search;
        if (currentPath === path && expander) return;
        currentPath = path;

        if (expander) expander.stop();
        if (uiManager) uiManager.stop();

        expander = new YouTubeCommentExpander(configManager);
        uiManager = new UIManager(configManager, expander);

        setTimeout(() => {
            if (location.pathname.startsWith('/shorts/')) {
                expander.log('info', 'Shorts page detected. Initializing...');
                const commentsButtonSelector = '#comments-button button, #comments-button a';
                waitForElement(commentsButtonSelector, (button) => {
                    button.click();
                    const commentsContainerSelector = 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-comments-section"]';
                    waitForElement(commentsContainerSelector, (container) => {
                        uiManager.initForShortsPage();
                        if (configManager.get('scriptEnabled')) expander.start(container);
                    });
                });
            } else if (location.pathname.startsWith('/watch')) {
                expander.log('info', 'Watch page detected. Initializing...');
                uiManager.initForWatchPage();
                const commentsContainerSelector = 'ytd-comments#comments';
                waitForElement(commentsContainerSelector, (container) => {
                    if (configManager.get('scriptEnabled')) expander.start(container);
                });
            } else {
                expander.log('info', 'Not a watch/shorts page. Script is idle.');
            }
        }, configManager.get('initialDelay'));
    }

    configManager.registerMenu();
    window.addEventListener('yt-navigate-finish', initializeScript, true);
    initializeScript();

})();