Eroscript scroll Pagination conversion

Eroscript 무한 스크롤 방식을 페이지 방식으로 전환하는 확장프로그램 스크립트. 뒤로가기 시 DOM 교체로 페이지네이션이 사라지는 문제 수정 (자가 치유 재초기화).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Eroscript scroll Pagination conversion
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Eroscript 무한 스크롤 방식을 페이지 방식으로 전환하는 확장프로그램 스크립트. 뒤로가기 시 DOM 교체로 페이지네이션이 사라지는 문제 수정 (자가 치유 재초기화).
// @author       Irukai
// @match        https://discuss.eroscripts.com/*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 환경 설정
    const itemsPerPage = 15;
    const maxPages = 10;
    const checkInterval = 1000;
    const safeMode = true;
    const refreshTimeout = 8000;     // 갱신 시 새 아이템 대기 최대 시간(ms)
    const restoreTimeout = 4000;     // 복원 시 배치당 대기 최대 시간(ms)
    const refreshPollMs = 250;       // 폴링 간격(ms)
    const settleWaitMs = 400;        // 아이템 증가 확인 후 정착 대기(ms)
    const maxRestoreAttempts = 15;   // 복원 시 최대 배치 로드 횟수
    const multiRefreshBatches = 5;   // 연속 갱신 시 로드할 배치 수
    const spacerViewportRatio = 1.5; // 스페이서 고정 높이 (뷰포트 대비 배율)
    const stateKeyPrefix = 'epc_state:'; // sessionStorage 키 접두사

    let currentPage = 1;
    let contentContainer = null;
    let allItems = [];
    let knownIds = new Set();      // 페이지네이션에 반영된 topic ID 집합
    let paginationContainer = null;
    let spacerEl = null;           // 연쇄 로딩 차단용 고정 높이 스페이서
    let refreshBtn = null;         // 갱신 버튼 (뱃지 갱신용 참조)
    let multiRefreshBtn = null;    // 연속 갱신 버튼
    let isInitialized = false;
    let observer = null;
    let lastUrl = location.href;
    let isLoading = false;

    // 페이지네이션 업데이트 중복방지 플래그
    let isUpdatingPagination = false;

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', startScriptSafely);
    } else {
        startScriptSafely();
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // ===== 상태 저장/복원 (뒤로가기 대응, 같은 탭 세션 내 유지) =====

    function stateKey() {
        return stateKeyPrefix + location.pathname;
    }

    function saveState() {
        try {
            sessionStorage.setItem(stateKey(), JSON.stringify({
                itemCount: allItems.length,
                currentPage: currentPage
            }));
        } catch (e) {
            // sessionStorage 사용 불가 환경이면 조용히 무시
        }
    }

    function loadSavedState() {
        try {
            const raw = sessionStorage.getItem(stateKey());
            if (!raw) return null;
            const state = JSON.parse(raw);
            if (typeof state.itemCount !== 'number' || typeof state.currentPage !== 'number') return null;
            return state;
        } catch (e) {
            return null;
        }
    }

    function startScriptSafely() {
        console.log("Discourse 페이지네이션: 시작 준비 중...");

        setupKeyboardShortcuts();

        checkPageAndInitialize();
        setInterval(checkPageAndInitialize, checkInterval);

        function onUrlChange() {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                console.log("URL 변경 감지됨, 초기화 상태 리셋 및 재초기화 시작");
                resetPaginationState();
                checkPageAndInitialize();
            }
        }

        window.addEventListener('popstate', onUrlChange);

        const originalPushState = history.pushState;
        history.pushState = function() {
            originalPushState.apply(this, arguments);
            setTimeout(onUrlChange, 500);
        };
    }

    // 초기화 상태 리셋 (URL 변경 또는 DOM 교체 감지 시)
    function resetPaginationState() {
        isInitialized = false;
        knownIds = new Set();
        allItems = [];
        if (paginationContainer) {
            paginationContainer.remove();
            paginationContainer = null;
        }
        refreshBtn = null;
        multiRefreshBtn = null;
        if (spacerEl) {
            spacerEl.remove();
            spacerEl = null;
        }
        if (observer) {
            observer.disconnect();
            observer = null;
        }
        contentContainer = null;
    }

    // ===== 키보드 단축키: ←/→ 페이지 이동 =====

    function setupKeyboardShortcuts() {
        document.addEventListener('keydown', (e) => {
            if (!isInitialized || isLoading) return;
            if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;

            // 입력창/에디터에 포커스가 있으면 무시
            const t = e.target;
            if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;

            if (e.key === 'ArrowLeft') {
                if (currentPage > 1) {
                    e.preventDefault();
                    showPage(currentPage - 1);
                }
            } else if (e.key === 'ArrowRight') {
                const totalPages = Math.ceil(allItems.length / itemsPerPage);
                if (currentPage < totalPages) {
                    e.preventDefault();
                    showPage(currentPage + 1);
                }
            }
        });
    }

    function checkPageAndInitialize() {
        if (location.pathname.startsWith('/t/')) {
            if (isInitialized) {
                resetPaginationState();
            }
            return;
        }

        // 자가 치유: 뒤로가기 등으로 Discourse가 DOM을 통째로 교체하면
        // 기존 컨테이너/페이지네이션 바가 문서에서 분리(detached)됨 → 리셋 후 재초기화
        if (isInitialized) {
            const containerDetached = contentContainer && !contentContainer.isConnected;
            const paginationDetached = paginationContainer && !paginationContainer.isConnected;
            if (containerDetached || paginationDetached) {
                console.log("DOM 교체 감지 (detached), 재초기화 시작");
                resetPaginationState();
            }
        }

        if ((document.body) && !isInitialized) {
            try {
                console.log("페이지네이션 초기화 시작 (/c/ 경로)");
                initialize();
            } catch (error) {
                console.error("페이지네이션 초기화 오류:", error);
            }
        } else if (isInitialized) {
            hideNewItemsOnly(); // 자동 갱신 대신 새 아이템 숨김 + 스페이서 갱신만 수행
        }
    }

    // ===== 아이템 식별 (Ember 재렌더링에 안전하도록 topic ID 기반) =====

    function itemKey(item) {
        if (!item || !item.getAttribute) return null;
        return item.getAttribute('data-topic-id');
    }

    function isKnownItem(item) {
        const key = itemKey(item);
        if (key !== null) return knownIds.has(key);
        // topic ID가 없는 레이아웃이면 마킹 방식으로 대체
        return item.dataset && item.dataset.customKnown === '1';
    }

    // DOM 순서를 유지하면서 knownIds에 등록된 아이템만 allItems로 재구성
    // (재렌더링으로 노드가 교체돼도 항상 살아있는 노드를 참조하게 됨)
    function syncAllItemsFromDom() {
        const domItems = getDiscourseItems();
        allItems = domItems.filter(isKnownItem);
    }

    // 아직 페이지네이션에 반영되지 않은(숨겨진 채 대기 중인) 아이템 수
    function getPendingCount() {
        return getDiscourseItems().filter(item => !isKnownItem(item)).length;
    }

    // ===== 새 아이템 처리 =====

    // 무한 스크롤로 새로 로드된 아이템은 숨기기만 하고 페이지네이션에 반영하지 않음
    // (갱신 버튼을 눌렀을 때만 collectAllItems()로 반영됨)
    function hideNewItemsOnly() {
        if (!contentContainer) return;
        const domItems = getDiscourseItems();
        let hiddenNew = 0;
        domItems.forEach(item => {
            if (!isKnownItem(item)) {
                if (item.style.display !== 'none') {
                    item.style.display = 'none';
                    hiddenNew++;
                }
            }
        });
        if (hiddenNew > 0) {
            console.log(`새 아이템 ${hiddenNew}개 숨김 처리 (수동 갱신 대기)`);
        }
        syncAllItemsFromDom();
        updateSpacer();
        updateRefreshBadge();
    }

    // 현재 DOM의 모든 아이템(숨겨져 있던 새 아이템 포함)을 페이지네이션에 흡수
    function collectAllItems() {
        const domItems = getDiscourseItems();
        domItems.forEach(item => {
            const key = itemKey(item);
            if (key !== null) {
                knownIds.add(key);
            } else if (item.dataset) {
                item.dataset.customKnown = '1';
            }
        });
        allItems = domItems;
        console.log(`${allItems.length}개의 아이템을 찾았습니다.`);
        if (isInitialized) {
            allItems.forEach(item => item.style.display = 'none');
        }
    }

    // ===== 스페이서: 고정 높이(뷰포트 150%)로 연쇄 로딩 차단 + 스크롤바 길이 유지 =====

    function getSpacerHost() {
        if (!contentContainer) return null;
        if (contentContainer.tagName === 'TBODY') return contentContainer;
        const tbody = contentContainer.querySelector('tbody');
        return tbody || contentContainer;
    }

    function updateSpacer() {
        const host = getSpacerHost();
        if (!host) return;

        // 스페이서 생성 (테이블이면 tr, 아니면 div)
        if (!spacerEl || !spacerEl.isConnected) {
            if (spacerEl) spacerEl.remove();
            if (host.tagName === 'TBODY') {
                spacerEl = document.createElement('tr');
                spacerEl.className = 'custom-pagination-spacer';
                const td = document.createElement('td');
                td.colSpan = 99;
                td.style.padding = '0';
                td.style.border = 'none';
                const inner = document.createElement('div');
                inner.style.height = '0px';
                td.appendChild(inner);
                spacerEl.appendChild(td);
            } else {
                spacerEl = document.createElement('div');
                spacerEl.className = 'custom-pagination-spacer';
                spacerEl.style.height = '0px';
            }
            host.appendChild(spacerEl);
        }

        // 항상 리스트 맨 끝에 위치하도록 유지 (새 행이 뒤에 붙었을 경우)
        if (host.lastElementChild !== spacerEl) {
            host.appendChild(spacerEl);
        }

        // 고정 높이: 숨겨진 아이템이 하나라도 있으면 뷰포트의 150%, 없으면 0
        const domItems = getDiscourseItems();
        const hiddenCount = domItems.filter(i => i.style.display === 'none').length;
        const h = hiddenCount > 0 ? Math.round(window.innerHeight * spacerViewportRatio) : 0;

        const target = spacerEl.tagName === 'TR' ? spacerEl.querySelector('div') : spacerEl;
        if (target) target.style.height = h + 'px';
    }

    // ===== 폴링 대기 =====

    // 고정 대기 대신 아이템 증가를 감시해서 오판 방지
    async function waitForNewItems(prevCount, timeoutMs) {
        const timeout = timeoutMs || refreshTimeout;
        const start = Date.now();
        while (Date.now() - start < timeout) {
            await sleep(refreshPollMs);
            if (getDiscourseItems().length > prevCount) {
                await sleep(settleWaitMs); // 같은 배치의 나머지 행 도착 대기
                return true;
            }
        }
        return false;
    }

    // ===== 바닥 로딩 트리거 =====

    // 이미 바닥에 있으면 scrollTo가 no-op이라 scroll 이벤트가 발생하지 않고,
    // Discourse가 바닥 체크를 다시 하지 않아 로딩이 멈춘다.
    // 위로 살짝 올렸다가 다시 바닥으로 이동해 이벤트를 강제로 발생시킨다.
    async function triggerBottomLoad() {
        const upTarget = Math.max(0, document.body.scrollHeight - window.innerHeight * 2);
        window.scrollTo(0, upTarget);
        await sleep(60);
        window.scrollTo(0, document.body.scrollHeight);
        // 보조: 위치 변화가 없었을 경우를 대비한 합성 scroll 이벤트
        window.dispatchEvent(new Event('scroll'));
    }

    // ===== 뒤로가기 복원: 저장된 아이템 수까지 배치를 자동 재로드 =====

    async function restoreLoadedItems(targetCount) {
        if (isLoading) return;
        isLoading = true;
        console.log(`복원 시작: 목표 ${targetCount}개 (현재 ${getDiscourseItems().length}개)`);
        try {
            let attempts = 0;
            let cur = getDiscourseItems().length;
            while (cur < targetCount && attempts < maxRestoreAttempts) {
                await triggerBottomLoad();
                const gotNew = await waitForNewItems(cur, restoreTimeout);
                const next = getDiscourseItems().length;
                if (!gotNew || next === cur) {
                    console.log("더 이상 로드되지 않음, 복원 중단");
                    break;
                }
                cur = next;
                attempts++;
                console.log(`복원 진행: ${cur}/${targetCount} (시도 ${attempts})`);
            }
            window.scrollTo(0, 0);
            console.log(`복원 완료: ${cur}개 로드됨`);
        } finally {
            isLoading = false;
        }
    }

    // ===== 초기화 =====

    async function initialize() {
        if (isInitialized) return;
        console.log("Discourse 페이지네이션: 초기화 중...");
        try {
            contentContainer = findDiscourseContentContainer();
            if (!contentContainer) {
                console.log("콘텐츠 컨테이너를 아직 찾을 수 없습니다. 나중에 다시 시도합니다.");
                return;
            }
            console.log("콘텐츠 컨테이너 찾음:", contentContainer);

            if (!safeMode) {
                disableInfiniteScrollSafely();
            }

            collectAllItems();

            if (allItems.length === 0) {
                console.warn("표시할 아이템을 찾을 수 없습니다.");
                return;
            }

            createPaginationInterface();
            // 복원 중 인터벌/옵저버 재진입을 막기 위해 먼저 초기화 완료로 표시
            isInitialized = true;
            setupContentObserver();

            // 저장된 기록이 있고 DOM 아이템이 부족하면 자동 재로드
            const saved = loadSavedState();
            if (saved && saved.itemCount > getDiscourseItems().length) {
                await restoreLoadedItems(saved.itemCount);
                collectAllItems();
            }

            checkUrlForPageNumber(saved ? saved.currentPage : null);
            console.log("페이지네이션 초기화 완료!");
        } catch (error) {
            console.error("초기화 중 오류 발생:", error);
        }
    }

    function disableInfiniteScrollSafely() {
        if (window._discourse_scroll_trackers) {
            try {
                window._discourse_scroll_trackers.forEach((tracker, i) => {
                    if (tracker && typeof tracker === 'function') {
                        window._discourse_scroll_trackers[i] = function() {};
                    }
                });
            } catch (e) {
                console.error("스크롤 트래커 비활성화 오류:", e);
            }
        }
        if (window.MessageBus && safeMode === false) {
            try {
                if (typeof window.MessageBus.baseInterval === 'number') {
                    window.MessageBus.baseInterval = 120000;
                }
                if (typeof window.MessageBus.unsubscribe === 'function') {
                    window.MessageBus.unsubscribe('/latest');
                    window.MessageBus.unsubscribe('/new');
                }
                console.log("MessageBus 설정 조정됨");
            } catch (e) {
                console.error("MessageBus 조정 오류:", e);
            }
        }
    }

    function findDiscourseContentContainer() {
        const candidates = [
            document.querySelector('.topic-list tbody'),
            document.querySelector('table.topic-list'),
            document.querySelector('.topic-list'),
            document.querySelector('.latest-topic-list'),
            document.querySelector('.category-list'),
            document.querySelector('#list-area')
        ];
        for (const c of candidates) {
            if (!c) continue;
            // 토픽 페이지 하단의 추천/관련 토픽 목록은 콘텐츠 컨테이너로 오인하지 않음
            if (c.closest('.suggested-topics, #suggested-topics, .more-topics__list, .related-topics')) continue;
            return c;
        }
        return null;
    }

    function getDiscourseItems() {
        if (!contentContainer) return [];
        const rows = contentContainer.querySelectorAll('tr.topic-list-item');
        if (rows.length > 0) return Array.from(rows);
        const topicItems = contentContainer.querySelectorAll('.latest-topic-list-item');
        if (topicItems.length > 0) return Array.from(topicItems);
        const alternateItems = contentContainer.querySelectorAll('.topic-list-item');
        if (alternateItems.length > 0) return Array.from(alternateItems);
        if (contentContainer.children.length > 0) {
            // 스페이서는 아이템에서 제외
            return Array.from(contentContainer.children)
                .filter(el => !el.classList.contains('custom-pagination-spacer'));
        }
        return [];
    }

    // ===== 페이지네이션 UI =====

    function createPaginationInterface() {
        if (paginationContainer) paginationContainer.remove();

        paginationContainer = document.createElement('div');
        paginationContainer.className = 'custom-discourse-pagination';
        paginationContainer.style.cssText = `
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 20px 0;
            padding: 10px 15px;
            background-color: #ffffff;
            border-radius: 5px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
            position: sticky;
            bottom: 10px;
            z-index: 1000;
            font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            width: 100%;
            max-width: none;
            font-size: 12px;
            overflow-x: auto;
            white-space: nowrap;
            flex-wrap: nowrap;
        `;

        updatePaginationInterface();

        let inserted = false;
        const candidates = [
            document.querySelector('.topic-list'),
            document.querySelector('table.topic-list'),
            contentContainer,
            document.querySelector('.list-controls'),
            document.querySelector('#main-outlet'),
            document.querySelector('main .container'),
            document.body
        ];

        for (const c of candidates) {
            if (c && !inserted) {
                if (c === contentContainer) {
                    c.parentNode.insertBefore(paginationContainer, c.nextSibling);
                } else {
                    c.appendChild(paginationContainer);
                }
                inserted = true;
                break;
            }
        }
    }

    function updatePaginationInterface() {
        if (!paginationContainer) return;

        const totalItems = allItems.length;
        const totalPages = Math.ceil(totalItems / itemsPerPage);

        paginationContainer.innerHTML = '';
        refreshBtn = null;
        multiRefreshBtn = null;

        if (currentPage > 1) addPageButton('<<', 1);
        if (currentPage > 1) addPageButton('<', currentPage - 1);

        const half = Math.floor(maxPages / 2);
        let startPage = currentPage - half;
        let endPage = currentPage + half;

        if (maxPages % 2 === 0) endPage -= 1;

        if (startPage < 1) {
            endPage += 1 - startPage;
            startPage = 1;
        }
        if (endPage > totalPages) {
            startPage -= (endPage - totalPages);
            endPage = totalPages;
            if (startPage < 1) startPage = 1;
        }

        let displayedPages = endPage - startPage + 1;
        if (displayedPages > maxPages) {
            endPage = startPage + maxPages - 1;
            if (endPage > totalPages) endPage = totalPages;
        }

        for (let i = startPage; i <= endPage; i++) {
            addPageButton(i.toString(), i, i === currentPage);
        }

        if (currentPage < totalPages) addPageButton('>', currentPage + 1);
        if (currentPage < totalPages) addPageButton('>>', totalPages);

        addRefreshButtons();
        updateRefreshBadge();
    }

    // 갱신 버튼 뱃지: 숨겨진 채 대기 중인 아이템 수 표시
    function updateRefreshBadge() {
        if (!refreshBtn || isLoading) return;
        const pending = getPendingCount();
        refreshBtn.textContent = pending > 0 ? `갱신 (+${pending})` : '갱신';
    }

    // ===== 갱신 공통 로직: batches개의 배치를 로드하고 새 아이템 첫 페이지로 이동 =====

    async function performRefresh(batches, progressBtn) {
        if (isLoading) return;
        isLoading = true;

        const buttons = [refreshBtn, multiRefreshBtn].filter(Boolean);
        buttons.forEach(b => { b.disabled = true; });

        try {
            const savedScrollY = window.scrollY;
            const prevTotal = allItems.length;

            // 이미 숨겨진 채 대기 중인 아이템은 배치 하나로 간주
            let loadedAny = getPendingCount() > 0;
            let remaining = batches - (loadedAny ? 1 : 0);

            for (let i = 0; i < remaining; i++) {
                if (progressBtn) progressBtn.textContent = `로딩 ${i + 1}/${remaining}`;
                const cur = getDiscourseItems().length;
                await triggerBottomLoad();
                const gotNew = await waitForNewItems(cur);
                if (!gotNew) break;
                loadedAny = true;
            }

            if (!loadedAny) {
                window.scrollTo(0, savedScrollY);
                alert('추가로 로드할 항목이 없습니다.');
                return;
            }

            collectAllItems();
            updateSpacer();

            // 새로 추가된 아이템이 시작되는 페이지로 자동 이동
            const firstNewPage = Math.floor(prevTotal / itemsPerPage) + 1;
            showPage(firstNewPage); // 내부에서 updatePaginationInterface() 호출 → 버튼 상태 복구
        } finally {
            isLoading = false;
            // 실패 경로에서도 버튼 상태 복구
            if (refreshBtn) { refreshBtn.disabled = false; }
            if (multiRefreshBtn) { multiRefreshBtn.disabled = false; multiRefreshBtn.textContent = `+${multiRefreshBatches}배치`; }
            updateRefreshBadge();
        }
    }

    function addRefreshButtons() {
        const baseStyle = `
            margin-left: 6px;
            padding: 5px 12px;
            color: #fff;
            border-radius: 3px;
            cursor: pointer;
            font-size: 12px;
            white-space: nowrap;
        `;

        // 갱신 (1배치)
        refreshBtn = document.createElement('button');
        refreshBtn.textContent = '갱신';
        refreshBtn.style.cssText = baseStyle + `
            margin-left: 10px;
            background-color: #28a745;
            border: 1px solid #28a745;
        `;
        refreshBtn.addEventListener('click', async () => {
            refreshBtn.textContent = '로딩...';
            await performRefresh(1, null);
        });
        paginationContainer.appendChild(refreshBtn);

        // 연속 갱신 (N배치)
        multiRefreshBtn = document.createElement('button');
        multiRefreshBtn.textContent = `+${multiRefreshBatches}배치`;
        multiRefreshBtn.style.cssText = baseStyle + `
            background-color: #17a2b8;
            border: 1px solid #17a2b8;
        `;
        multiRefreshBtn.addEventListener('click', async () => {
            await performRefresh(multiRefreshBatches, multiRefreshBtn);
        });
        paginationContainer.appendChild(multiRefreshBtn);
    }


    function addPageButton(label, pageNum, isActive = false) {
        const button = document.createElement('button');
        button.textContent = label;
        button.dataset.page = pageNum;
        button.style.cssText = `
            margin: 0 3px;
            padding: 5px 10px;
            background-color: ${isActive ? '#0088cc' : '#f8f9fa'};
            color: ${isActive ? '#fff' : '#0088cc'};
            border: 1px solid ${isActive ? '#0088cc' : '#ddd'};
            border-radius: 3px;
            cursor: pointer;
            font-weight: ${isActive ? 'bold' : 'normal'};
            transition: all 0.2s ease;
            font-size: 12px;
            white-space: nowrap;
        `;
        button.addEventListener('click', () => {
            showPage(pageNum);
        });
        button.addEventListener('mouseover', () => {
            if (!isActive) button.style.backgroundColor = '#e9ecef';
        });
        button.addEventListener('mouseout', () => {
            if (!isActive) button.style.backgroundColor = '#f8f9fa';
        });
        paginationContainer.appendChild(button);
    }

    function showPage(pageNum) {
        // 재렌더링 대비: 표시 직전에 살아있는 DOM 노드로 재동기화
        syncAllItemsFromDom();
        if (!allItems.length) return;

        const totalPages = Math.ceil(allItems.length / itemsPerPage);
        if (pageNum < 1) pageNum = 1;
        if (pageNum > totalPages) pageNum = totalPages;

        currentPage = pageNum;
        const startIdx = (pageNum - 1) * itemsPerPage;
        const endIdx = Math.min(startIdx + itemsPerPage, allItems.length);
        allItems.forEach(item => item.style.display = 'none');
        for (let i = startIdx; i < endIdx; i++) {
            if (allItems[i]) allItems[i].style.display = '';
        }
        console.log(`페이지 ${pageNum} 표시: ${endIdx - startIdx}개 아이템`);
        updateSpacer();
        updatePaginationInterface();
        saveState(); // 뒤로가기 복원용 상태 저장
        const scrollTarget = contentContainer.offsetTop - 100;
        window.scrollTo({top: Math.max(0, scrollTarget), behavior: 'smooth'});
        if (history.pushState) {
            try {
                const pageParam = new URLSearchParams(window.location.search);
                pageParam.set('custom_page', pageNum);
                const newUrl = window.location.pathname + '?' + pageParam.toString();
                window.history.replaceState({path: newUrl}, '', newUrl);
            } catch (e) {
                console.error("URL 업데이트 오류:", e);
            }
        }
    }

    function isInsideMedia(node) {
        if (!node) return false;
        let el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
        while (el) {
            const tag = el.tagName && el.tagName.toLowerCase();
            if (tag === 'video' || tag === 'audio' || tag === 'img' || tag === 'iframe') {
                return true;
            }
            el = el.parentElement;
        }
        return false;
    }

    function setupContentObserver() {
        if (observer) observer.disconnect();

        observer = new MutationObserver((mutations) => {
            let needsUpdate = false;
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    if (paginationContainer && (paginationContainer.contains(mutation.target) || mutation.target === paginationContainer)) {
                        continue;
                    }
                    if (spacerEl && (mutation.target === spacerEl || spacerEl.contains(mutation.target))) {
                        continue;
                    }
                    if (isInsideMedia(mutation.target)) {
                        continue;
                    }
                    if (contentContainer.contains(mutation.target) || mutation.target === contentContainer) {
                        needsUpdate = true;
                        break;
                    }
                }
            }
            // 자동 갱신 제거: 새 콘텐츠는 숨김 + 스페이서 갱신 + 뱃지 갱신만 하고
            // 페이지네이션 반영은 갱신 버튼에서만 수행
            if (needsUpdate && !isUpdatingPagination) {
                isUpdatingPagination = true;
                setTimeout(() => {
                    hideNewItemsOnly();
                    isUpdatingPagination = false;
                }, 300);
            }
        });

        observer.observe(contentContainer, {childList: true, subtree: true});
        console.log("콘텐츠 변화 감지 옵저버 설정됨");
    }

    function checkUrlForPageNumber(savedPage) {
        try {
            const urlParams = new URLSearchParams(window.location.search);
            let pageParam = urlParams.get('custom_page');
            const totalItems = allItems.length;
            const totalPages = Math.ceil(totalItems / itemsPerPage);
            let pageNum = 1;
            if (pageParam !== null) {
                pageNum = parseInt(pageParam);
            } else if (savedPage !== null && savedPage !== undefined) {
                // URL에 파라미터가 없으면 저장된 페이지로 복원
                pageNum = savedPage;
            }
            if (isNaN(pageNum) || pageNum < 1) {
                pageNum = 1;
            } else if (pageNum > totalPages) {
                pageNum = totalPages > 0 ? totalPages : 1;
            }
            console.log(`페이지 번호 결정: ${pageNum}`);
            currentPage = pageNum;
            showPage(pageNum);
        } catch (e) {
            console.error("URL 파라미터 확인 오류:", e);
            showPage(1);
        }
    }

})();