Shikimori Comments Pagination

Пагинация комментариев

Pada tanggal 08 Mei 2025. Lihat %(latest_version_link).

// ==UserScript==
// @name         Shikimori Comments Pagination
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Пагинация комментариев
// @author       karuma
// @license      MIT
// @match        https://shikimori.one/*
// @grant        GM_addStyle
// ==/UserScript==

(function () {
    'use strict';


    const COMMENTS_PER_PAGE = 5; // Число комментариев на странице
    const CHECK_INTERVAL = 500; // Частота проверки элементов на странице (Не ставить сликшом маленький)
    const TimeoutToScroll = 200; // Задержка перед скролом к пагинатору. (После нажатия на вперед/назад)
    const EnableScroll = true; // true/false - после после обновления блока комментариев скролл до пагинатора

    GM_addStyle(`
        .shiki-comments-pagination {
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 20px 0;
            gap: 10px;
            padding: 10px;
            border-radius: 4px;
        }
        .shiki-comments-pagination button {
            background: #579;
            color: white;
            border: none;
            border-radius: 4px;
            padding: 5px 10px;
            cursor: pointer;
            min-width: 30px;
            transition: background 0.2s;
        }
        .shiki-comments-pagination button:hover {
            background: #467;
        }
        .shiki-comments-pagination button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .shiki-comments-pagination input {
            width: 60px;
            text-align: center;
            padding: 5px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        .shiki-comments-pagination .page-info {
            margin: 0 10px;
            font-size: 14px;
        }
        .shiki-comments-loading {
            opacity: 0.7;
            pointer-events: none;
        }
        .b-spoiler_inline.opened {
         background-color: #f5f5f5;
         color: #333;
         padding: 2px 4px;
         border-radius: 3px;
          box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
}
.b-spoiler_block {
            cursor: pointer;
            display: inline;
            margin: 0 1px;
        }
        .b-spoiler_block > span[tabindex="0"] {
            display: inline;
            padding: 1px 4px;
            background-color: #687687;
            color: #fff;
            font-size: 12px;
            font-family: inherit;
            border-radius: 2px;
            transition: all 0.15s ease;
            line-height: 1.3;
        }
        .b-spoiler_block:hover > span[tabindex="0"] {
            background-color: #5a6775;
        }
        .b-spoiler_block.is-opened > span[tabindex="0"] {
            background-color: #f5f5f5;
            color: #333;
            box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
        }
        .b-spoiler_block > div {
            display: none;
            margin-top: 3px;
            padding: 5px;
            background: #f5f5f5;
            border-radius: 2px;
            border: 1px solid #e0e0e0;
        }
        .b-spoiler_block.is-opened > div {
            display: block;
        }
    `);

    /* ========== ОБРАБОТКА СПОЙЛЕРОВ И УДАЛЕНИЯ ========== */

    // Функция для раскрытия/закрытия inline-спойлеров (текстовых)
    function bindSpoilerDeleteButtons(container) {
        container.querySelectorAll('.b-spoiler_inline').forEach(spoiler => {
            // Клонируем элемент (это удалит все предыдущие обработчики)
            const newSpoiler = spoiler.cloneNode(true);

            // Добавляем наш обработчик
            newSpoiler.addEventListener('click', async function(e) {
                e.stopPropagation(); // Останавливаем всплытие

                if (this.classList.contains('opened')) {
                    // Закрываем спойлер
                    if (this.dataset.originalContent) {
                        this.innerHTML = this.dataset.originalContent;
                    }
                    this.classList.remove('opened');
                } else {
                    // Открываем спойлер
                    this.dataset.originalContent = this.innerHTML;
                    this.innerHTML = this.textContent.trim();
                    this.classList.add('opened');
                }
            });

            // Заменяем оригинальный элемент клоном
            spoiler.parentNode.replaceChild(newSpoiler, spoiler);
        });
    }

    // Функция для раскрытия block-спойлеров (с контентом)
    function bindSpoilerBlockButtons(container) {


        container.querySelectorAll('.b-spoiler_block').forEach(spoilerBlock => {
            const newSpoilerBlock = spoilerBlock.cloneNode(true);
            const spoilerTitle = newSpoilerBlock.querySelector('span[tabindex="0"]');
            const contentDiv = newSpoilerBlock.querySelector('div');

            if (!spoilerTitle || !contentDiv) return;

            contentDiv.style.display = 'none';

            spoilerTitle.addEventListener('click', (e) => {
                e.stopPropagation();

                if (contentDiv.style.display === 'none') {
                    contentDiv.style.display = 'block';
                    newSpoilerBlock.classList.add('is-opened');
                    initImageModalViewer(contentDiv);
                    initVideoModalViewer(contentDiv);
                } else {
                    contentDiv.style.display = 'none';
                    newSpoilerBlock.classList.remove('is-opened');
                }

            });
            // Заменяем оригинальный элемент клоном
            spoilerBlock.parentNode.replaceChild(newSpoilerBlock, spoilerBlock);
        });

    }


    // Кликабельность картинок
    function initImageModalViewer(container) {
        const containerEl = typeof container === 'string'
        ? document.querySelector(container)
        : container;

        if (!containerEl) return;

        containerEl.querySelectorAll('img').forEach(el => {
            if (!el.src || el.closest('.b-video')) return;

            const originalStyles = el.getAttribute('style');
            const preview = el.cloneNode(true);

            preview.addEventListener('click', function (e) {
                e.preventDefault();
                e.stopPropagation();

                // Создание модального окна при клике
                const modal = document.createElement('div');
                modal.style.cssText = `
                display: flex;
                position: fixed;
                z-index: 9999;
                left: 0;
                top: 0;
                width: 100%;
                height: 100%;
                background: rgba(0,0,0,0.95);
                cursor: zoom-out;
                align-items: center;
                justify-content: center;
                overflow: auto;
            `;

                const img = document.createElement('img');
                img.src = el.src.replace('/thumbnail/', '/original/').replace('/x48/', '/x160/');
                img.style.cssText = `
                max-width: 90vw;
                max-height: 90vh;
                display: block;
                object-fit: contain;
                animation: fadeInScale 0.3s ease-out;
            `;

                const closeBtn = document.createElement('span');
                closeBtn.innerHTML = '×';
                closeBtn.style.cssText = `
                position: fixed;
                top: 20px;
                right: 30px;
                font-size: 40px;
                font-weight: bold;
                cursor: pointer;
                color: white;
                text-shadow: 0 0 5px rgba(0,0,0,0.8);
                z-index: 10000;
            `;

                const style = document.createElement('style');
                style.textContent = `
                @keyframes fadeInScale {
                    from { opacity: 0; transform: scale(0.95); }
                    to { opacity: 1; transform: scale(1); }
                }
            `;
                document.head.appendChild(style);

                const close = () => {
                    modal.remove();
                    style.remove();
                    document.body.style.overflow = '';
                    document.removeEventListener('keydown', onKeydown);
                };

                const onKeydown = (e) => {
                    if (e.key === 'Escape') close();
                };

                modal.addEventListener('click', e => {
                    if (e.target === modal || e.target === img) close();
                });
                closeBtn.addEventListener('click', close);
                document.addEventListener('keydown', onKeydown);

                modal.append(img, closeBtn);
                document.body.append(modal);
                document.body.style.overflow = 'hidden';
            });

            el.parentNode.replaceChild(preview, el);

            if (originalStyles) {
                preview.setAttribute('style', originalStyles);
            }

            if (!originalStyles || !originalStyles.includes('cursor:')) {
                preview.style.cursor = 'zoom-in';
            }
        });
    }

    function initVideoModalViewer(container) {
        const containerEl = typeof container === 'string'
        ? document.querySelector(container)
        : container;

        if (!containerEl) return;

        containerEl.querySelectorAll('.b-video.youtube .video-link').forEach(link => {
            if (!link.dataset.href) return;

            const youtubeUrl = link.dataset.href;
            const videoId = youtubeUrl.match(/embed\/([^?]+)/)?.[1] ||
                  youtubeUrl.match(/youtu\.be\/([^?]+)/)?.[1] ||
                  youtubeUrl.match(/v=([^&]+)/)?.[1];

            if (!videoId) return;

            const preview = link.querySelector('img');
            if (!preview) return;

            const originalStyles = preview.getAttribute('style');
            link.removeAttribute('href');

            link.onclick = function (e) {
                e.preventDefault();
                e.stopPropagation();

                const modal = document.createElement('div');
                modal.style.cssText = `
                display: flex;
                position: fixed;
                z-index: 9999;
                left: 0;
                top: 0;
                width: 100%;
                height: 100%;
                background: rgba(0,0,0,0.95);
                cursor: zoom-out;
                align-items: center;
                justify-content: center;
                overflow: auto;
            `;

                const videoContainer = document.createElement('div');
                videoContainer.style.cssText = `
                position: relative;
                width: 90vw;
                max-width: 1200px;
                height: 0;
                padding-bottom: 56.25%;
                animation: fadeInScale 0.3s ease-out;
            `;

                const iframe = document.createElement('iframe');
                iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`;
                iframe.style.cssText = `
                position: absolute;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                border: none;
            `;
                iframe.setAttribute('allowfullscreen', '');
                iframe.setAttribute('allow', 'autoplay');

                const closeBtn = document.createElement('span');
                closeBtn.innerHTML = '×';
                closeBtn.style.cssText = `
                position: fixed;
                top: 20px;
                right: 30px;
                font-size: 40px;
                font-weight: bold;
                cursor: pointer;
                color: white;
                text-shadow: 0 0 5px rgba(0,0,0,0.8);
                z-index: 10000;
            `;

                const style = document.createElement('style');
                style.textContent = `
                @keyframes fadeInScale {
                    from { opacity: 0; transform: scale(0.95); }
                    to { opacity: 1; transform: scale(1); }
                }
            `;
                document.head.appendChild(style);

                const close = () => {
                    modal.remove();
                    style.remove();
                    document.body.style.overflow = '';
                    document.removeEventListener('keydown', onKeydown);
                };

                const onKeydown = (e) => {
                    if (e.key === 'Escape') close();
                };

                modal.addEventListener('click', e => {
                    if (e.target === modal) close();
                });
                closeBtn.addEventListener('click', close);
                document.addEventListener('keydown', onKeydown);

                videoContainer.appendChild(iframe);
                modal.append(videoContainer, closeBtn);
                document.body.append(modal);
                document.body.style.overflow = 'hidden';
            };

            if (!originalStyles || !originalStyles.includes('cursor:')) {
                preview.style.cursor = 'zoom-in';
            }
        });
    }


    /* ========== КЛАСС ДЛЯ РАБОТЫ С БЛОКАМИ КОММЕНТАРИЕВ ========== */

    class CommentsBlock {
        constructor(container) {
            // Инициализация свойств
            this.container = container; // DOM-элемент контейнера
            this.loader = container.querySelector('.comments-loader');// Элемент загрузки
            this.fetchId = null;// ID для запросов
            this.entityId = null; // Может быть topicId или userId
            this.entityType = null; // 'Topic' или 'User'
            this.currentPage = 1;// Текущая страница
            this.totalPages = 1;// Всего страниц
            this.pagination = null;// Элемент пагинации
            this.dataskip = parseInt(this.loader.getAttribute('data-skip'));
            this.datacount = parseInt(this.loader.getAttribute('data-count'));
            this.html
            this.init();
        }
        // Основная инициализация
        init() {
            if (!this.loader) return;

            const ids = this.getCommentsIDs();
            if (!ids) return;

            this.fetchId = ids.fetchId;
            this.entityId = ids.entityId;
            this.entityType = ids.entityType;
            // Рассчитываем общее количество страниц
            const commentsCount = this.datacount + this.dataskip || 0;
            this.totalPages = Math.max(1, Math.ceil(commentsCount / COMMENTS_PER_PAGE));
        }

        // Создание и загрузка блока комментариев
        async CreateCommentsBlock() {
            // Проверяем наличие всех необходимых данных перед загрузкой
            if (!this.hasRequiredAttributes()) {
                console.error('Cannot create comments block - missing required attributes');
                return false;
            }

            await this.loadComments();
            this.renderPagination();
        }

        // Проверка наличия всех необходимых атрибутов
        hasRequiredAttributes() {
            if (!this.loader) {
                console.error('Missing comments loader element');
                return false;
            }

            const urlTemplate = this.loader.getAttribute('data-clickloaded-url-template');
            if (!urlTemplate) {
                console.error('Missing data-clickloaded-url-template attribute');
                return false;
            }

            this.ids = this.getCommentsIDs();
            if (!this.ids || !this.ids.fetchId || !this.ids.entityId || !this.ids.entityType) {
                console.error('Invalid or missing IDs in URL template');
                return false;
            }

            const count = this.loader.getAttribute('data-count');
            if (!count) {
                console.error('Missing data-count attribute');
                return false;
            }

            return true;
        }
        // Получение идентификаторов топика
        getCommentsIDs() {
            try {
                const urlTemplate = this.loader.getAttribute('data-clickloaded-url-template');
                if (!urlTemplate) return null;

                const matches = urlTemplate.match(/\/fetch\/(\d+)\/(Topic|User)\/(\d+)/);
                if (!matches || matches.length < 4) return null;

                return {
                    fetchId: matches[1],
                    entityId: matches[3],
                    entityType: matches[2] // 'Topic' или 'User'
                };
            } catch (error) {
                console.error('Error parsing comment IDs:', error);
                return null;
            }
        }

        //Создание Url для запроса
        buildCommentsUrl(offset) {
            // Формируем URL в зависимости от типа сущности
            if (this.entityType === 'User') {
                return `https://shikimori.one/comments/fetch/${this.fetchId}/User/${this.entityId}/${offset}/${COMMENTS_PER_PAGE}`;
            } else {
                // По умолчанию считаем, что это Topic
                return `https://shikimori.one/comments/fetch/${this.fetchId}/Topic/${this.entityId}/${offset}/${COMMENTS_PER_PAGE}`;
            }
        }


        /**
 * Загружает комментарии с автоматическим повтором при ошибках
 * @param {string} url - URL для запроса
 * @param {number} [maxRetries] - Максимальное количество попыток (по умолчанию: 4)
 * @param {number} [retryDelay] - Задержка между попытками в миллисекундах (по умолчанию: 2 секунды)
 * @returns {Promise<string>} HTML-контент комментариев
 * @throws {Error} Если все попытки завершились ошибкой
 */
        async fetchComments(url, maxRetries = 4, initialRetryDelay = 2000) {
            let lastError = null;

            for (let attempt = 1; attempt <= maxRetries; attempt++) {
                try {
                    const response = await fetch(url);

                    // Обработка HTTP-ошибок
                    if (!response.ok) {
                        // Особый случай: Too Many Requests (429)
                        if (response.status === 429) {
                            const retryAfter = parseInt(response.headers.get('Retry-After') || initialRetryDelay / 1000, 10);
                            await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
                            continue; // Повторяем попытку без увеличения счетчика
                        }
                        throw new Error(`HTTP error ${response.status} ${response.statusText}`);
                    }

                    const data = await response.json();
                    if (!data?.content) throw new Error('Invalid response format: missing content');
                    return data;

                } catch (error) {
                    lastError = error;

                    if (attempt < maxRetries) {
                        const currentDelay = initialRetryDelay * Math.pow(2, attempt - 1);
                        await new Promise(resolve => setTimeout(resolve, currentDelay));
                    }
                }
            }

            throw lastError || new Error('All retry attempts failed');
        }
        // Основная функция загрузки
        async loadComments() {
            try {
                const offset = (this.currentPage - 1) * COMMENTS_PER_PAGE;
                this.container.classList.add('shiki-comments-loading');
                const data = await this.fetchComments(this.buildCommentsUrl(offset));
                this.container.innerHTML = data.content;
                jQuery(this.container).process(data.JS_EXPORTS);
                bindSpoilerDeleteButtons(this.container);
                bindSpoilerBlockButtons(this.container);
                initImageModalViewer(this.container);
                initVideoModalViewer(this.container);
            } catch (error) {
                console.error('Ошибка загрузки комментариев:', error);
            } finally {
                this.container.classList.remove('shiki-comments-loading');
            }
        }


        // Создание интерфейса пагинации
        renderPagination() {
            if (this.pagination) {
                this.pagination.remove(); // Удаляем старую пагинацию
            }
            function ScrollToPagination () {
                this.pagination.scrollIntoView({
                    behavior: 'smooth', // Плавная прокрутка
                    block: 'start'// Выравнивание по верхнему краю элемента
                });
            }
            // Создаем новый элемент пагинации
            this.pagination = document.createElement('div');
            this.pagination.className = 'shiki-comments-pagination';
            this.pagination.classList.add('l-page');
            this.pagination.innerHTML = `
                <button class="prev-page">&lt; Назад</button>
                <span class="page-info">Страница ${this.currentPage} из ${this.totalPages}</span>
                <input type="number" class="page-input" min="1" max="${this.totalPages}" value="${this.currentPage}">
                <button class="next-page">Вперед &gt;</button>
            `;

            // Находим editor-container (может быть рядом с контейнером или в другом месте)
            const editorContainer = this.container.closest('.b-topic')?.querySelector('.editor-container') ||
                  document.querySelector('.editor-container');

            // Вставляем после editor-container если найден, иначе после контейнера комментариев
            const insertAfter = editorContainer || this.container;
            insertAfter.parentNode.insertBefore(this.pagination, insertAfter.nextSibling);

            function ScrollIntoPagination(container) {
                if (EnableScroll) {
                    setTimeout(() => {
                        // Получаем позицию элемента относительно документа
                        const elementRect = container.getBoundingClientRect();
                        const scrollPosition = elementRect.bottom + window.pageYOffset - window.innerHeight;

                        // Добавляем отступ -100px сверху
                        window.scrollTo({
                            top: scrollPosition - (-80),
                            behavior: 'instant' // или 'smooth' для плавной прокрутки
                        });
                    },TimeoutToScroll) // Задержка
                }
            }
            // Обработчики событий для кнопок пагинации
            this.pagination.querySelector('.prev-page').addEventListener('click', async () => {
                if (this.currentPage > 1) {
                    this.currentPage--;
                    await this.loadComments();
                    this.renderPagination(); // Обновляем отображение
                    ScrollIntoPagination(this.pagination);
                }
            });

            // Обработчик для поля ввода страницы
            this.pagination.querySelector('.next-page').addEventListener('click', async () => {
                if (this.currentPage < this.totalPages) {
                    this.currentPage++;
                    await this.loadComments();
                    this.renderPagination();
                    ScrollIntoPagination(this.pagination);
                }
            });

            this.pagination.querySelector('.page-input').addEventListener('change', async (e) => {
                const newPage = parseInt(e.target.value, 10);
                if (newPage >= 1 && newPage <= this.totalPages) {
                    this.currentPage = newPage;
                    await this.loadComments();
                    this.renderPagination();
                    ScrollIntoPagination(this.pagination);
                }
            });
        }
    }

    /* ========== ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ И ФУНКЦИИ ========== */
    let initializedBlocks = new WeakMap(); // Хранит инициализированные блоки
    let isInitializing = false; // Флаг для защиты от повторной инициализации

    async function init() {
        if (isInitializing) return;
        isInitializing = true;

        console.log('Parallel INIT started');
        const updatedBlocks = document.querySelectorAll('.b-comments');

        try {
            // Создаем массив промисов для всех блоков
            const initializationPromises = Array.from(updatedBlocks).map(async (container) => {
                try {
                    if (!initializedBlocks.has(container)) {
                        const instance = new CommentsBlock(container);
                        initializedBlocks.set(container, instance);
                        await instance.CreateCommentsBlock();
                        console.log('Successfully initialized:', container);
                    } else {
                        await initializedBlocks.get(container).CreateCommentsBlock();
                    }
                } catch (error) {
                    console.error(`Error processing block ${container}:`, error);
                    // Пробрасываем ошибку дальше, если нужно прервать все операции
                    throw error;
                }
            });

            // Ожидаем завершения ВСЕХ операций параллельно
            await Promise.all(initializationPromises);

        } catch (error) {
            console.error('Global initialization error:', error);
        } finally {
            isInitializing = false;
        }
    }

    let checkInterval = null;
    let lastKnownBlocks = [];


    // Функция для проверки новых блоков комментариев на странице и инициализации
    async function observeNewComments() {
        if (checkInterval) clearInterval(checkInterval);

        console.log("Запуск наблюдения за .b-comments");

        checkInterval = setInterval(async () => {
            const currentBlocks = Array.from(document.querySelectorAll('.b-comments'));

            if (currentBlocks.length !== lastKnownBlocks.length ||
                currentBlocks.some(block => !lastKnownBlocks.includes(block))) {
                console.log("Обнаружены изменения в .b-comments");
                await init(); // Добавлен await для асинхронной init()
                lastKnownBlocks = currentBlocks;
            }
        }, CHECK_INTERVAL);

        const initialBlocks = Array.from(document.querySelectorAll('.b-comments'));
        if (initialBlocks.length > 0) {
            await init();
            lastKnownBlocks = initialBlocks;
        }
    }

    function stopObserving() {
        if (checkInterval) {
            clearInterval(checkInterval);
            checkInterval = null;
        }
    }
    window.addEventListener('popstate', (event) => {
        window.location.reload();
        console.log('Сработал popstate!', event.state);
    });
    observeNewComments();

})();
长期地址
遇到问题?请前往 GitHub 提 Issues。