YouTube - Развернуть комментарии

Автоматически раскрывает ветки комментариев на YouTube, включая "Показать ещё ответы" и "Читать дальше".

// ==UserScript==
// @name         YouTube - Развернуть комментарии
// @namespace    http://tampermonkey.net/
// @version      1.2025.519.0
// @description  Автоматически раскрывает ветки комментариев на YouTube, включая "Показать ещё ответы" и "Читать дальше".
// @author       dlw@mcprv (адаптировано для Tampermonkey)
// @match        *://*.youtube.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- НАСТРОЙКИ ПО УМОЛЧАНИЮ ---
    const DEFAULTS = {
        auto_expand: true,
        expand_show_more_replies: true,
        expand_read_more: true,
        hide_read_less_button: false,
        wait_time: 200,
        view_replies_selector: "#more-replies",
        show_more_replies_selector: ".style-scope.ytd-continuation-item-renderer button.yt-spec-button-shape-next--text",
        read_more_selector: "ytd-comments tp-yt-paper-button#more",
        read_less_selector: "ytd-comments tp-yt-paper-button#less",
        toggle_expand_shortcutkey: "",
        expand_replies_shortcutkey: "",
        expand_show_more_replies_shortcutkey: "",
        expand_read_more_shortcutkey: "",
        hide_replies_shortcutkey: "",
        remove_focus_shortcutkey: "",
        scroll_by_space: false
    };

    let options = {};

    // --- ФУНКЦИИ ДЛЯ РАБОТЫ С НАСТРОЙКАМИ (GM_setValue / GM_getValue) ---
    function loadOptions() {
        for (const key in DEFAULTS) {
            options[key] = GM_getValue(key, DEFAULTS[key]);
        }
        // Убедимся, что wait_time - это число
        options.wait_time = parseInt(options.wait_time, 10);
    }

    function saveOption(key, value) {
        options[key] = value;
        GM_setValue(key, value);
    }

    // --- РЕГИСТРАЦИЯ МЕНЮ ДЛЯ НАСТРОЕК В TAMPERMONKEY ---
    function registerMenuCommands() {
        GM_registerMenuCommand(`${options.auto_expand ? '✅' : '❌'} Автоматически раскрывать комментарии`, () => {
            saveOption('auto_expand', !options.auto_expand);
            location.reload(); // Перезагружаем страницу для применения
        });
        GM_registerMenuCommand(`${options.expand_show_more_replies ? '✅' : '❌'} Также раскрывать "Показать ещё ответы"`, () => {
            saveOption('expand_show_more_replies', !options.expand_show_more_replies);
            initSelectors();
        });
        GM_registerMenuCommand(`${options.expand_read_more ? '✅' : '❌'} Также раскрывать "Читать дальше"`, () => {
            saveOption('expand_read_more', !options.expand_read_more);
            initSelectors();
        });
        GM_registerMenuCommand("--- Команды ---");
        GM_registerMenuCommand("Раскрыть все ветки комментариев", () => expand_replies("expand_replies"));
        GM_registerMenuCommand("Раскрыть все \"Показать ещё ответы\"", () => expand_replies("expand_show_more_replies"));
        GM_registerMenuCommand("Раскрыть все \"Читать дальше\"", () => expand_replies("expand_read_more"));
        GM_registerMenuCommand("Скрыть все ветки комментариев", hide_replies);
    }


    // --- ОСНОВНАЯ ЛОГИКА СКРИПТА ---
    let observer = new MutationObserver(observe_func);
    let disconnected = false;
    let target_selector = "";
    const node_spool = new Set();
    let lock = false;

    function initSelectors() {
        target_selector = options.view_replies_selector;
        if (options.expand_show_more_replies) {
            target_selector += "," + options.show_more_replies_selector;
        }
        if (options.expand_read_more) {
            target_selector += "," + options.read_more_selector;
        }

        if (options.hide_read_less_button) {
            GM_addStyle(`${options.read_less_selector} { display: none !important; }`);
        } else {
            GM_addStyle(`${options.read_less_selector} { display: revert !important; }`);
        }
    }

    function init() {
        if (options.auto_expand) {
            observer.observe(document, { childList: true, subtree: true });
            document.addEventListener("scroll", scroll_listener);
            window.addEventListener("resize", resize_listener);
        } else {
            disconnected = true;
            observer.disconnect();
            document.removeEventListener("scroll", scroll_listener);
            window.removeEventListener("resize", resize_listener);
        }
        initSelectors();
        document.removeEventListener("keydown", shortcut_keys);
         if (Object.values(options).some(val => typeof val === 'string' && val.includes('+')) || options.scroll_by_space) {
            document.addEventListener("keydown", shortcut_keys);
        }
    }

    function observe_func(mutations) {
        if (disconnected) return;

        for (const mutation of mutations) {
            if (!mutation.addedNodes.length) continue;
            for (const node of mutation.addedNodes) {
                if (node.nodeType !== 1) continue;
                // Проверяем, существует ли узел в DOM перед запросом
                if (document.body.contains(node)) {
                   const elems = node.querySelectorAll(target_selector);
                   for (const elem of elems) {
                       node_spool.add(elem);
                   }
                }
            }
        }
        // Дополнительная проверка для "Show more replies", так как они могут появляться динамически
        if (options.expand_show_more_replies) {
            const elems = document.querySelectorAll(options.show_more_replies_selector);
            for (const elem of elems) {
                node_spool.add(elem);
            }
        }
    }

    let timer;
    function scroll_listener() {
        clearTimeout(timer);
        timer = setTimeout(click, 300);
    }

    function resize_listener() {
        click();
    }

    async function click() {
        if (lock) return;
        lock = true;
        for (const node of [...node_spool]) {
             if (!document.body.contains(node)) {
                node_spool.delete(node);
                continue;
            }
            const rect = node.getBoundingClientRect();
            // Раскрываем комментарии, которые находятся в пределах 2-х высот окна просмотра
            if (rect.top <= window.innerHeight * 2) {
                node_spool.delete(node);
                if (rect.top !== 0 && rect.height !== 0) { // Проверяем, что элемент видим
                    node.click();
                    await new Promise(resolve => setTimeout(resolve, options.wait_time));
                }
            }
        }
        lock = false;
    }

    async function expand_replies(level) {
        let selectors = [];
        if (level === "expand_replies" || level === "expand_show_more_replies" || level === "expand_read_more") {
            selectors.push(options.view_replies_selector);
        }
        if (level === "expand_show_more_replies" || level === "expand_read_more") {
            selectors.push(options.show_more_replies_selector);
        }
        if (level === "expand_read_more") {
            selectors.push(options.read_more_selector);
        }

        for (const selector of selectors) {
            const elems = document.querySelectorAll(selector);
            for (const elem of elems) {
                elem.click();
                await new Promise(resolve => setTimeout(resolve, options.wait_time));
            }
        }
    }

    async function hide_replies() {
        const elems = document.querySelectorAll("#less-replies, " + options.read_less_selector);
        for (const elem of elems) {
            elem.click();
             await new Promise(resolve => setTimeout(resolve, 50)); // Небольшая задержка
        }
    }

     function shortcut_keys(e) {
        const player = document.getElementById("movie_player");
        if (e.repeat || (e.target != document.body && e.target != player && e.target.tagName !== 'INPUT')) {
            return;
        }

        let mod = [];
        if (e.ctrlKey) mod.push("Ctrl");
        if (e.altKey) mod.push("Alt");
        if (e.shiftKey) mod.push("Shift");
        if (e.metaKey) mod.push("Meta");

        let key = e.key;
        if (key.length === 1 && key !== " ") {
            key = key.toUpperCase();
        }
        mod.push(key);
        const text = mod.join(" + ");

        if (text === options.remove_focus_shortcutkey) {
            if (document.activeElement === player) {
                document.activeElement.blur();
            } else if (document.activeElement === document.body) {
                player.focus({ preventScroll: true });
            }
            return;
        }

        if (e.target !== document.body) return;

        if (e.key === " " && options.scroll_by_space) {
            e.preventDefault();
            window.scrollByPages(e.shiftKey ? -1 : 1);
            return;
        }

        switch (text) {
            case options.toggle_expand_shortcutkey:
                saveOption('auto_expand', !options.auto_expand);
                location.reload();
                break;
            case options.expand_replies_shortcutkey:
                expand_replies("expand_replies");
                break;
            case options.expand_show_more_replies_shortcutkey:
                expand_replies("expand_show_more_replies");
                break;
             case options.expand_read_more_shortcutkey:
                expand_replies("expand_read_more");
                break;
            case options.hide_replies_shortcutkey:
                hide_replies();
                break;
        }
    }

    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            node_spool.clear(); // Очищаем пул при смене URL
        }
    }).observe(document, { subtree: true, childList: true });


    // --- ЗАПУСК СКРИПТА ---
    // Ожидаем загрузки контента перед инициализацией
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            loadOptions();
            registerMenuCommands();
            init();
        });
    } else {
        loadOptions();
        registerMenuCommands();
        init();
    }

})();