Soop Advanced Home

최근글 표시 + 팬 영역 접기/펼치기

// ==UserScript==
// @name         Soop Advanced Home
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  최근글 표시 + 팬 영역 접기/펼치기
// @author       Coding Slave
// @match        https://www.sooplive.co.kr/station/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 설정 및 상수
    const CONFIG = {
        // 팬 영역 설정
        FAN: {
            SELECTOR: '[class*="ServiceLeftMenu_fanWrapper"], [class*="fanWrapper"], [data-testid*="fan"]'
        },

        // 네비게이션 설정
        NAV: {
            SELECTORS: [
                '.__soopui__NavContent-module__navContent___DQ9Xi',
                '[class*="__soopui__NavContent-module__navContent"]',
                '[class*="navContent"]',
                'nav',
                '.nav',
                '.navigation'
            ]
        },

        // API 설정
        API: {
            BASE_URL: 'https://api-channel.sooplive.co.kr/v1.1/channel',
            BOARD_API_BASE_URL: 'https://chapi.sooplive.co.kr/api',
            MAX_BOARD_PAGES: 3,
            BOARD_POSTS_PER_PAGE: 50
        },

        // 시간 설정
        TIME: {
            BADGE_TIME_WINDOW_HOURS: 24,
            MONITOR_INTERVAL_MS: 1000,
            BOARD_WATCHER_INTERVAL_MS: 1000,
            OBSERVER_TIMEOUT_MS: 15000,
            BOARD_BADGE_DELAY_MS: 200,
            FAN_AREA_DELAY_MS: 500
        },

        // CSS 클래스
        CSS: {
            RELOCATED_BOARD_CLASS: 'soop-board-relocated',
            FAN_TOGGLE_CLASS: 'soop-fan-toggle'
        },

        // MutationObserver 설정
        OBSERVER: {
            CONFIG: {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['style', 'class']
            }
        }
    };

    // 상태 관리
    const State = {
        fanAreaProcessed: false,
        boardBadgeProcessed: false,
        boardBadgeInProgress: false,
        initExecuted: false,
        // 뱃지 데이터 캐시
        cachedBoardList: null,
        cachedBoardData: null
    };

    // 게시판 메뉴 관리

    const BoardMenuManager = {
        relocateBoardMenu() {
            // 이미 처리되었으면 중단
            if (document.querySelector(`.${CONFIG.CSS.RELOCATED_BOARD_CLASS}`)) {
                return false;
            }

            const navContainer = this.findNavigationContainer();
            if (!navContainer) {
                return false;
            }

            const boardContainer = this.findBoardContainer(navContainer);
            if (!boardContainer) {
                return false;
            }

            // 게시판 메뉴 전체를 Catch 다음으로 이동
            return this.moveBoardMenuAfterCatch(boardContainer, navContainer);
        },

        findNavigationContainer() {
            for (const selector of CONFIG.NAV.SELECTORS) {
                const container = document.querySelector(selector);
                if (container) return container;
            }
            return null;
        },

        findBoardMenuContainer() {
            // 이동된 게시판 메뉴 우선 찾기
            let boardMenu = document.querySelector(`.${CONFIG.CSS.RELOCATED_BOARD_CLASS}`);
            if (boardMenu) {
                // 드롭다운 메뉴 내부 찾기
                const dropdown = boardMenu.querySelector('ul, .dropdown, [class*="menu"]');
                if (dropdown) {
                    return dropdown;
                }
                // 하위 요소들 직접 찾기
                return boardMenu;
            }

            // 이동되지 않은 원본 게시판 메뉴 찾기
            const navContainer = this.findNavigationContainer();
            if (!navContainer) return null;

            const allElements = navContainer.querySelectorAll('p, span, div, li, a, button');
            for (const element of allElements) {
                if (element.textContent && element.textContent.trim() === '게시판') {
                    let container = element;
                    while (container && container !== navContainer) {
                        if (container.tagName === 'LI' ||
                            container.classList.contains('navContentItem') ||
                            container.classList.contains('menu-item')) {
                            // 드롭다운 메뉴 찾기
                            const dropdown = container.querySelector('ul, .dropdown, [class*="menu"]');
                            if (dropdown) {
                                return dropdown;
                            }
                            return container;
                        }
                        container = container.parentElement;
                    }
                    break;
                }
            }

            return null;
        },

        findBoardContainer(navContainer) {
            const elements = navContainer.querySelectorAll('p, span, div, li, a, button');
            for (const element of elements) {
                if (element.textContent && element.textContent.trim() === '게시판') {
                    let container = element;
                    while (container && container !== navContainer) {
                        if (container.tagName === 'LI' ||
                            container.classList.contains('navContentItem') ||
                            container.classList.contains('menu-item')) {
                            return container;
                        }
                        container = container.parentElement;
                    }
                    break;
                }
            }
            return null;
        },

        moveBoardMenuAfterCatch(boardContainer, navContainer) {
            // Catch 메뉴 컨테이너 찾기 (1단계: 텍스트 매칭만)
            const allElements = navContainer.querySelectorAll('*');
            let catchContainer = null;

            for (const element of allElements) {
                const text = element.textContent ? element.textContent.trim() : '';
                if (text === 'Catch' || text === 'CATCH' || text === 'catch') {
                    // Catch 메뉴의 컨테이너 찾기
                    let container = element;
                    while (container && container !== navContainer) {
                        if (container.tagName === 'LI' ||
                            container.classList.contains('navContentItem') ||
                            container.classList.contains('menu-item')) {
                            catchContainer = container;
                            break;
                        }
                        container = container.parentElement;
                    }
                    if (catchContainer) break;
                }
            }

            if (!catchContainer) {
                return false;
            }

            // 게시판을 Catch 다음으로 이동
            boardContainer.classList.add(CONFIG.CSS.RELOCATED_BOARD_CLASS);

            if (catchContainer.parentNode) {
                catchContainer.parentNode.insertBefore(boardContainer, catchContainer.nextSibling);
                return true;
            }

            return false;
        }
    };


    // 게시판 뱃지 관리

    const BadgeManager = {
        async checkBoardBadge() {
            if (State.boardBadgeProcessed || State.boardBadgeInProgress) return;

            State.boardBadgeInProgress = true;

            const streamerId = this.getStreamerId();
            if (!streamerId) {
                State.boardBadgeInProgress = false;
                return;
            }

            try {
                const boardList = await this.fetchBoardList(streamerId);
                if (!boardList) {
                    State.boardBadgeInProgress = false;
                    return;
                }

                const boardData = await this.fetchBoardData(streamerId);
                if (!boardData) {
                    State.boardBadgeInProgress = false;
                    return;
                }

                this.addIndividualBoardBadges(boardList, boardData);
                State.cachedBoardList = boardList;
                State.cachedBoardData = boardData;
                State.boardBadgeProcessed = true;
            } catch (error) {
            } finally {
                State.boardBadgeInProgress = false;
            }
        },

        getStreamerId() {
            const urlMatch = window.location.pathname.match(/\/station\/([^\/]+)/);
            return urlMatch ? urlMatch[1] : null;
        },

        async fetchBoardList(streamerId) {
            try {
                const apiUrl = `${CONFIG.API.BASE_URL}/${streamerId}/menu`;

                const response = await fetch(apiUrl);
                if (response.ok) {
                    const data = await response.json();
                    return data.board || [];
                }
                return null;
            } catch (error) {
                return null;
            }
        },

        async fetchBoardData(streamerId) {
            try {
                // 24시간 전 날짜 계산 (YYYY-MM-DD HH:mm:ss 형식)
                const now = new Date();
                const oneDayAgo = new Date(now.getTime() - (CONFIG.TIME.BADGE_TIME_WINDOW_HOURS * 60 * 60 * 1000));
                const startDate = oneDayAgo.toISOString().slice(0, 19).replace('T', ' ');

                let allData = [];
                let page = 1;
                const maxPages = CONFIG.API.MAX_BOARD_PAGES; // 최대 페이지 제한 (API 부하 방지)

                while (page <= maxPages) {
                    // 실제 SoopLive API 엔드포인트 (24시간 이내 글만)
                    const apiUrl = `${CONFIG.API.BOARD_API_BASE_URL}/${streamerId}/board/?per_page=${CONFIG.API.BOARD_POSTS_PER_PAGE}&start_date=${encodeURIComponent(startDate)}&end_date=&field=title,contents,user_nick,user_id,hashtags&keyword=&type=all&order_by=reg_date&board_number=&page=${page}`;

                    const response = await fetch(apiUrl);
                    if (!response.ok) break;

                    const data = await response.json();
                    if (!data || !data.data || !Array.isArray(data.data) || data.data.length === 0) break;

                    allData = allData.concat(data.data);

                    // 마지막 페이지이거나, 가져온 데이터 중에 24시간 이내가 아닌 글이 있으면 중단
                    const hasOldPosts = data.data.some(post => {
                        const postTime = new Date(post.reg_date).getTime();
                        const oneDayAgoTime = oneDayAgo.getTime();
                        return postTime < oneDayAgoTime;
                    });

                    if (hasOldPosts || data.data.length < CONFIG.API.BOARD_POSTS_PER_PAGE) {
                        break; // 더 이상 확인할 필요 없음
                    }

                    page++;
                }

                return { data: allData };
            } catch (error) {
                return null;
            }
        },

        getBoardsWithRecentPosts(boardData, boardList) {
            if (!boardData || !boardData.data || !Array.isArray(boardData.data) || !boardList) {
                return new Set();
            }

            const boardsWithRecentPosts = new Set();
            const currentTime = Date.now();
            const oneDayAgo = currentTime - (CONFIG.TIME.BADGE_TIME_WINDOW_HOURS * 60 * 60 * 1000);

            // 각 게시판별로 새 글 확인
            for (const board of boardList) {
                if (!board.bbsNo) {
                    continue;
                }

                // 해당 게시판의 게시물들 필터링 (문자열/숫자 변환 고려)
                const boardPosts = boardData.data.filter(post => {
                    const postBbsNo = String(post.bbs_no);
                    const boardBbsNo = String(board.bbsNo);
                    return postBbsNo === boardBbsNo;
                });

                const recentPosts = boardPosts.filter(post => {
                    const postTime = new Date(post.reg_date).getTime();
                    return postTime > oneDayAgo;
                });

                if (recentPosts.length > 0) {
                    boardsWithRecentPosts.add(board.bbsNo);
                }
            }

            return boardsWithRecentPosts;
        },

        addIndividualBoardBadges(boardList, boardData) {
            const boardsWithRecentPosts = this.getBoardsWithRecentPosts(boardData, boardList);
            if (boardsWithRecentPosts.size === 0) {
                State.boardBadgeProcessed = true;
                return;
            }

            const boardContainer = BoardMenuManager.findBoardMenuContainer();
            if (!boardContainer) {
                State.boardBadgeProcessed = true;
                return;
            }

            const boardItems = boardContainer.querySelectorAll('li, a, button, [role="menuitem"]');
            for (const item of boardItems) {
                const itemText = item.textContent ? item.textContent.trim() : '';
                if (!itemText || itemText === '게시판') continue;

                const matchedBoard = boardList.find(board => board.name === itemText);
                if (matchedBoard && boardsWithRecentPosts.has(matchedBoard.bbsNo)) {
                    this.addBadgeToBoardItem(item);
                }
            }

            State.boardBadgeProcessed = true;
        },

        reapplyBadgesIfNeeded() {
            // 캐시된 데이터가 없으면 중단
            if (!State.cachedBoardList || !State.cachedBoardData) {
                return;
            }

            // 게시판 메뉴 컨테이너가 있는지 확인
            const boardContainer = BoardMenuManager.findBoardMenuContainer();
            if (!boardContainer) {
                return;
            }

            // 뱃지가 모두 존재하는지 확인
            const boardsWithRecentPosts = this.getBoardsWithRecentPosts(State.cachedBoardData, State.cachedBoardList);
            if (boardsWithRecentPosts.size === 0) {
                return;
            }

            const boardItems = boardContainer.querySelectorAll('li, a, button, [role="menuitem"]');
            let missingBadges = 0;

            for (const item of boardItems) {
                const itemText = item.textContent ? item.textContent.trim() : '';
                if (!itemText || itemText === '게시판') continue;

                const matchedBoard = State.cachedBoardList.find(board => board.name === itemText);
                if (matchedBoard && boardsWithRecentPosts.has(matchedBoard.bbsNo)) {
                    // 뱃지가 없으면 추가
                    if (!item.querySelector('.soop-badge')) {
                        this.addBadgeToBoardItem(item);
                        missingBadges++;
                    }
                }
            }

            // 뱃지 재적용 완료 (디버깅용 로그 제거)
        },

        addBadgeToBoardItem(boardItem) {
            if (boardItem.querySelector('.soop-badge')) return;

            const badge = document.createElement('span');
            badge.className = 'soop-badge';
            badge.style.cssText = `
                position: absolute;
                top: 50%;
                right: 6px;
                transform: translateY(-50%);
                width: 4px;
                height: 4px;
                background-color: #ff4444;
                border-radius: 50%;
                z-index: 1000;
                pointer-events: none;
            `;

            if (getComputedStyle(boardItem).position === 'static') {
                boardItem.style.position = 'relative';
            }

            boardItem.appendChild(badge);
        }
    };


    // CSS 스타일 관리

    const StyleManager = {
        initCustomStyles() {
            if (document.querySelector('#soop-custom-styles')) return;

            const style = document.createElement('style');
            style.id = 'soop-custom-styles';
            style.textContent = `
                /* 게시판 포스트 리스트 스타일 */
                .PostList_postList__mrPhL td,
                .PostList_postList__mrPhL th {
                    height: 35px !important;
                }

                /* 좌측 패널 게시판 하위 메뉴 패딩 */
                .__soopui__NavContentItem-module__Depth2___R0MMC.__soopui__NavContentItem-module__on___PfEd-.__soopui__SecondDepth-module__SecondDepth___AzP-0 {
                    padding-left: 24px !important;
                }
            `;
            document.head.appendChild(style);
        }
    };

    // 팬 영역 관리

    const FanAreaManager = {
        findFanArea() {
            return document.querySelector(CONFIG.FAN.SELECTOR);
        },

        setupFanAreaCollapse() {
            const fanArea = this.findFanArea();
            if (!fanArea || State.fanAreaProcessed) return;

            this.createFanToggleUI(fanArea);
            State.fanAreaProcessed = true;
        },

        createFanToggleUI(fanArea) {
            let isCollapsed = true;

            const updateFanAreaVisibility = () => {
                fanArea.style.display = isCollapsed ? 'none' : '';
                toggleBtn.textContent = isCollapsed ? '▼' : '▲';
            };

            const forceCollapse = () => {
                isCollapsed = true;
                updateFanAreaVisibility();
            };

            const updateTheme = () => {
                const isDark = document.documentElement.getAttribute('dark') === 'true';
                const textColor = isDark ? '#e6e6e6' : '#222';
                const borderColor = isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)';
                const bgColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)';

                label.style.color = textColor;
                toggleBtn.style.borderColor = borderColor;
                toggleBtn.style.backgroundColor = bgColor;
                toggleBtn.style.color = textColor;
            };

            // UI 생성
            const container = document.createElement('div');
            container.className = 'soop-fan-toggle-container';
            container.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; margin: 4px 0;';

            const label = document.createElement('span');
            label.textContent = '열혈팬/구독팬 보기';
            label.style.cssText = 'font-size: 13px; font-weight: 500;';

            const toggleBtn = document.createElement('button');
            toggleBtn.className = 'soop-fan-toggle';
            toggleBtn.title = '열혈팬/구독팬 접기/펼치기';
            toggleBtn.style.cssText = 'padding: 2px 6px; font-size: 14px; cursor: pointer; margin-left: 8px; border: 1px solid; border-radius: 3px; background: transparent;';

            container.appendChild(label);
            container.appendChild(toggleBtn);
            fanArea.parentNode?.insertBefore(container, fanArea);

            // 초기 설정
            updateTheme();
            forceCollapse();

            // 이벤트 리스너
            toggleBtn.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                isCollapsed = !isCollapsed;
                updateFanAreaVisibility();
            });

            // 테마 변경 감시
            const themeObserver = new MutationObserver(updateTheme);
            themeObserver.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['dark']
            });

            // 팬 영역 상태 유지 감시
            const visibilityObserver = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    if (mutation.type === 'attributes' &&
                        (mutation.attributeName === 'style' || mutation.attributeName === 'class')) {
                        if (fanArea.style.display !== 'none' && isCollapsed) {
                            forceCollapse();
                        }
                    }
                });
            });

            visibilityObserver.observe(fanArea, {
                attributes: true,
                attributeFilter: ['style', 'class']
            });

            // 메모리 정리
            const cleanup = () => {
                themeObserver.disconnect();
                visibilityObserver.disconnect();
            };
            window.addEventListener('beforeunload', cleanup);
        }
    };

    // 유틸리티 함수

    // DOM 변경 감시
    function setupObserver() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    BoardMenuManager.relocateBoardMenu();
                    BadgeManager.reapplyBadgesIfNeeded();

                    if (!State.fanAreaProcessed) {
                        FanAreaManager.setupFanAreaCollapse();
                    }

                    // 게시판 메뉴 변경 감지 시 뱃지 처리 (더 신중하게)
                    if (!State.boardBadgeProcessed && !State.boardBadgeInProgress) {
                        // 약간 더 긴 지연 후 확인
                        setTimeout(() => {
                            if (!State.boardBadgeProcessed && !State.boardBadgeInProgress) {
                                const boardContainer = BoardMenuManager.findBoardMenuContainer();
                                if (boardContainer) {
                                    const boardItems = boardContainer.querySelectorAll('li, a, button, [role="menuitem"]');
                                    const validItems = Array.from(boardItems).filter(item => {
                                        const text = item.textContent ? item.textContent.trim() : '';
                                        return text && text !== '게시판';
                                    });
                                    if (validItems.length > 0) {
                                        BadgeManager.checkBoardBadge();
                                    }
                                }
                            }
                        }, CONFIG.TIME.BOARD_BADGE_DELAY_MS * 2); // 더 긴 지연
                    }
                }
            });
        });

        observer.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => observer.disconnect(), CONFIG.TIME.OBSERVER_TIMEOUT_MS);
    }

    // 게시판 메뉴 감시
    function setupBoardMenuWatcher() {
        const boardWatcher = setInterval(() => {
            const boardContainer = BoardMenuManager.findBoardMenuContainer();
            if (boardContainer && !State.boardBadgeProcessed && !State.boardBadgeInProgress) {
                // 게시판 메뉴가 실제로 아이템을 가지고 있는지 확인
                const boardItems = boardContainer.querySelectorAll('li, a, button, [role="menuitem"]');
                const validItems = Array.from(boardItems).filter(item => {
                    const text = item.textContent ? item.textContent.trim() : '';
                    return text && text !== '게시판';
                });

                // 최소 1개 이상의 유효한 게시판 아이템이 있어야 처리 시작
                if (validItems.length > 0) {
                    BadgeManager.checkBoardBadge();
                    clearInterval(boardWatcher);
                }
            }
        }, CONFIG.TIME.BOARD_WATCHER_INTERVAL_MS);

        setTimeout(() => clearInterval(boardWatcher), CONFIG.TIME.OBSERVER_TIMEOUT_MS);
    }

    // 초기화
    function init() {
        if (State.initExecuted) return;
        State.initExecuted = true;

        // 초기 실행 (게시판 뱃지 제외)
        StyleManager.initCustomStyles();
        BoardMenuManager.relocateBoardMenu();
        FanAreaManager.setupFanAreaCollapse();

        // 지속적 모니터링
        const monitorInterval = setInterval(() => {
            BoardMenuManager.relocateBoardMenu();
            BadgeManager.reapplyBadgesIfNeeded();
        }, CONFIG.TIME.MONITOR_INTERVAL_MS);

        setTimeout(() => {
            if (!State.fanAreaProcessed) {
                FanAreaManager.setupFanAreaCollapse();
            }
        }, CONFIG.TIME.FAN_AREA_DELAY_MS);

        setTimeout(() => clearInterval(monitorInterval), CONFIG.TIME.OBSERVER_TIMEOUT_MS);

        setupObserver();
        setupBoardMenuWatcher();

        // 추가: 페이지 로드 후 일정 시간 대기 후 뱃지 처리 시도
        setTimeout(() => {
            if (!State.boardBadgeProcessed && !State.boardBadgeInProgress) {
                const boardContainer = BoardMenuManager.findBoardMenuContainer();
                if (boardContainer) {
                    const boardItems = boardContainer.querySelectorAll('li, a, button, [role="menuitem"]');
                    const validItems = Array.from(boardItems).filter(item => {
                        const text = item.textContent ? item.textContent.trim() : '';
                        return text && text !== '게시판';
                    });
                    if (validItems.length > 0) {
                        BadgeManager.checkBoardBadge();
                    }
                }
            }
        }, 3000); // 3초 후 시도
    }

    // 실행
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();