Soop Advanced Home

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
    }
})();