Greasy Fork

Greasy Fork is available in English.

SOOP 리캡

끼리

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         SOOP 리캡
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      2.4
// @description  끼리
// @author       헤슷
// @match        https://www.sooplive.com/*
// @match        https://play.sooplive.com/*
// @match        https://broadstatistic.sooplive.com/*
// @connect      sooplive.com
// @connect      sch.sooplive.com
// @connect      res.sooplive.com
// @connect      afevent2.sooplive.com
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @sandbox      JavaScript
// @require      https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        DEFAULT_BG: "https://i.ibb.co/Z6BXDfRH/20260112-235411-1.png",
        NO_PROFILE_IMG: "https://res.sooplive.com/images/common/thumb_profile.gif",
        MAX_ITEMS: 40
    };

    const Endpoints = {
        SEARCH: 'https://sch.sooplive.com/api.php',
        ANALYTICS: 'https://broadstatistic.sooplive.com/api/watch_statistic.php',
        USER: 'https://afevent2.sooplive.com/api/get_private_info.php'
    };

    const MemoryBank = {
        imgBuffer: new Map(),
        bjLogoUrl: new Map(),
        apiData: new Map()
    };

    const AppContext = {
        uid: localStorage.getItem('sr_uid') || "",
        activeYear: new Date().getFullYear(),
        activeMonth: new Date().getMonth() + 1,
        skinMode: localStorage.getItem('sr_skin_mode') || 'A',
        chartRef: null,
        heatmapChartRef: null,
        bgBlob: null,
        resizeCleanup: null
    };

    const ThemeColors = {
        "Challenger": "#FFD700",
        "Grandmaster": "#ff4e4e",
        "Master": "#be8bff",
        "Diamond": "#57d5ff",
        "Emerald": "#2ecc71",
        "Platinum": "#3498db",
        "Gold": "#f1c40f",
        "Silver": "#bdc3c7",
        "Bronze": "#cd7f32"
    };

    function convertTimeToSec(timeStr) {
        if (!timeStr || typeof timeStr !== 'string') return 0;
        const parts = timeStr.split(':').map(Number);
        while (parts.length < 3) parts.unshift(0);
        return (parts[0] * 3600) + (parts[1] * 60) + parts[2];
    }

    async function fetchImageBlob(targetUrl) {
        const url = targetUrl || CONFIG.NO_PROFILE_IMG;
        if (MemoryBank.imgBuffer.has(url)) {
            return MemoryBank.imgBuffer.get(url);
        }
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET", url: url, responseType: "arraybuffer", timeout: 5000,
                onload: (response) => {
                    const reader = new FileReader();
                    reader.onloadend = () => {
                        MemoryBank.imgBuffer.set(url, reader.result);
                        resolve(reader.result);
                    };
                    reader.readAsDataURL(new Blob([response.response], { type: 'image/png' }));
                },
                onerror: () => resolve(CONFIG.NO_PROFILE_IMG),
                ontimeout: () => resolve(CONFIG.NO_PROFILE_IMG)
            });
        });
    }

    function findStreamerImage(nickname) {
        if (MemoryBank.bjLogoUrl.has(nickname)) return Promise.resolve(MemoryBank.bjLogoUrl.get(nickname));
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `${Endpoints.SEARCH}?m=searchHistory&service=list&d=${encodeURIComponent(nickname)}`,
                onload: (response) => {
                    try {
                        const parsed = JSON.parse(response.responseText);
                        const match = parsed?.suggest_bj?.find(item => item.user_nick === nickname);
                        const logo = match ? match.station_logo : CONFIG.NO_PROFILE_IMG;
                        MemoryBank.bjLogoUrl.set(nickname, logo);
                        resolve(logo);
                    } catch { resolve(CONFIG.NO_PROFILE_IMG); }
                },
                onerror: () => resolve(CONFIG.NO_PROFILE_IMG)
            });
        });
    }

    function getThemeColor(points) {
        if (points >= 7000) return ThemeColors["Challenger"];
        if (points >= 5500) return ThemeColors["Grandmaster"];
        if (points >= 3500) return ThemeColors["Master"];
        if (points >= 1500) return ThemeColors["Diamond"];
        if (points >= 900) return ThemeColors["Emerald"];
        if (points >= 500) return ThemeColors["Platinum"];
        if (points >= 300) return ThemeColors["Gold"];
        if (points >= 150) return ThemeColors["Silver"];
        return ThemeColors["Bronze"];
    }

    function requestStats(uid, sDate, eDate, moduleName) {
        const key = JSON.stringify([uid, sDate, eDate, moduleName]);
        if (MemoryBank.apiData.has(key)) return Promise.resolve(MemoryBank.apiData.get(key));

        return new Promise((resolve) => {
            const formData = `szModule=${moduleName}&szMethod=watch&szStartDate=${sDate}&szEndDate=${eDate}&nPage=1&szId=${uid}`;
            GM_xmlhttpRequest({
                method: "POST", url: Endpoints.ANALYTICS, data: formData,
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                onload: (response) => {
                    try {
                        const json = JSON.parse(response.responseText);
                        const result = json.data || null;
                        MemoryBank.apiData.set(key, result);
                        resolve(result);
                    } catch { resolve(null); }
                },
                onerror: () => resolve(null)
            });
        });
    }

    function evaluateAchievements(metrics) {
        const list = [];
        const { totalHrs, attendance, dawnRate, vodRate, favRate, streamerCnt, weekendRate } = metrics;

        if (totalHrs >= 500)      list.push({ icon: "🌌", title: "SOOP의 신",    desc: `${totalHrs.toFixed(0)}h 시청`,         sub: "월 500h 이상",      color: "#FFD700" });
        else if (totalHrs >= 300) list.push({ icon: "👑", title: "SOOP 성주",    desc: `${totalHrs.toFixed(0)}h 시청`,         sub: "월 300h 이상",      color: "#ff4e4e" });
        else if (totalHrs >= 150) list.push({ icon: "💎", title: "숲의 지배자",  desc: `${totalHrs.toFixed(0)}h 시청`,         sub: "월 150h 이상",      color: "#be8bff" });
        else if (totalHrs >= 80)  list.push({ icon: "🥇", title: "프로 시청러",  desc: `${totalHrs.toFixed(0)}h 시청`,         sub: "월 80h 이상",       color: "#57d5ff" });
        else if (totalHrs >= 30)  list.push({ icon: "🥉", title: "루키 시청자",  desc: `${totalHrs.toFixed(0)}h 시청`,         sub: "월 30h 이상",       color: "#3498db" });
        else                      list.push({ icon: "🌱", title: "새싹 와쳐",    desc: `${totalHrs.toFixed(1)}h 시청`,         sub: "첫 발걸음",          color: "#2ecc71" });

        if (attendance >= 30)      list.push({ icon: "🛡️", title: "전설의 수호자", desc: `${attendance}일 출석`,  sub: "개근 달성",          color: "#FFD700" });
        else if (attendance >= 25) list.push({ icon: "📅", title: "개근의 신",     desc: `${attendance}일 출석`,  sub: "25일 이상",          color: "#f1c40f" });
        else if (attendance >= 15) list.push({ icon: "🏃", title: "성실한 발걸음", desc: `${attendance}일 출석`,  sub: "15일 이상",          color: "#bdc3c7" });

        if (dawnRate > 0.5)      list.push({ icon: "🦇", title: "심야의 박쥐",    desc: `새벽 ${(dawnRate*100).toFixed(0)}%`, sub: "새벽 50% 이상",     color: "#6c63ff" });
        else if (dawnRate > 0.2) list.push({ icon: "🌙", title: "올빼미 수호자",  desc: `새벽 ${(dawnRate*100).toFixed(0)}%`, sub: "새벽 20% 이상",     color: "#a29bfe" });

        if (vodRate > 2.0)                       list.push({ icon: "🍿", title: "VOD 광인",    desc: `VOD ${vodRate.toFixed(1)}배`,   sub: "다시보기 위주",     color: "#e17055" });
        else if (totalHrs > 20 && vodRate < 0.1) list.push({ icon: "📡", title: "생방 사수단", desc: "VOD 거의 0%",                   sub: "라이브 올인",       color: "#00c6ff" });

        if (favRate > 0.6)     list.push({ icon: "🧡", title: "일편단심",   desc: `최애 ${(favRate*100).toFixed(0)}%`, sub: "한 방송 집중",      color: "#fd79a8" });
        if (streamerCnt >= 25) list.push({ icon: "🌍", title: "박애주의자", desc: `${streamerCnt}명 시청`,              sub: "다채로운 취향",      color: "#00b894" });
        if (weekendRate > 0.3) list.push({ icon: "🔥", title: "주말의 전사", desc: `주말 ${(weekendRate*100).toFixed(0)}%`, sub: "주말 집중 시청",  color: "#e84393" });

        return list;
    }

    // 친구와 대화하는 듯한 편안한 반말로 변경된 코멘트
    function createFeedbackString(m) {
        const { totalHrs, attendance, dawnRate, favRate, weekendRate, topName } = m;
        const parts = [];

        parts.push(`이번 달에는 총 ${totalHrs.toFixed(1)}시간 방송을 봤어.`);

        if (topName && topName !== '-') {
            const favPct = (favRate * 100).toFixed(0);
            parts.push(`그중에서 ${favPct}%는 ${topName} 방송을 챙겨봤네.`);
        }

        if (dawnRate >= 0.5) {
            parts.push(`주로 다들 자는 새벽 시간에 많이 봤구나.`);
        } else if (dawnRate >= 0.25) {
            parts.push(`늦은 밤부터 새벽까지 방송을 즐겨 보는 편이야.`);
        } else if (weekendRate >= 0.45) {
            parts.push(`평일보다는 주말에 몰아서 집중적으로 봤네.`);
        } else {
            parts.push(`시간이나 요일에 안 치우치고 골고루 잘 챙겨봤어.`);
        }

        parts.push(`이번 달 출석은 총 ${attendance}일이야.`);

        return parts.join(' ');
    }

    GM_addStyle(`
        @import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800;900&display=swap');
        @import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;700&display=swap');

        #sp-launch-btn { position: fixed; z-index: 10000; padding: 16px 28px; background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%); color: white; border: none; border-radius: 15px; cursor: move; font-family: 'Pretendard'; font-weight: 800; box-shadow: 0 5px 15px rgba(0, 114, 255, 0.4); transition: 0.3s; }
        #sp-launch-btn.sp-invisible { opacity: 0; visibility: hidden; pointer-events: none; }

        #sp-modal-layer {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(5, 5, 10, 0.95);
            z-index: 100001; display: block; overflow-y: auto; padding: 50px 0; box-sizing: border-box;
        }
        .sp-dashboard-container {
            background: #0a0a0f; color: #fff; width: 1350px !important; border-radius: 45px; margin: 0 auto;
            border: 1px solid rgba(255,255,255,0.1);
            min-height: 1000px; font-family: 'Pretendard'; overflow: visible; position: relative; display: flex; flex-direction: column;
        }

        .sp-header {
            border-radius: 45px 45px 0 0; overflow: hidden; position: relative;
            background-size: cover; background-position: center; flex-shrink: 0;
            background-color: transparent;
        }

        #sp-particles { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }

        .sp-header-inner {
            position: relative; z-index: 2;
            background: linear-gradient(to bottom, rgba(10,10,15,0) 0%, rgba(10,10,15,0.4) 40%, #0a0a0f 95%, #0a0a0f 100%);
            padding: 320px 60px 20px 60px;
        }

        .sp-body { background: #0a0a0f; padding: 20px 60px 60px 60px; border-radius: 0 0 45px 45px; flex-grow: 1; display: flex; flex-direction: column; }

        .sp-stat-grid { display: grid !important; grid-template-columns: repeat(4, 1fr) !important; gap: 25px; width: 100%; margin-top: 20px; }
        .sp-card {
            background: rgba(255,255,255,0.05); padding: 25px; border-radius: 32px; border: 1px solid rgba(255,255,255,0.1);
            display: flex; align-items: center; gap: 15px; position: relative; overflow: hidden; flex-direction: row;
        }
        .sp-card.hidden-mode { background: #000 !important; border: 1px dashed rgba(255,255,255,0.2); }
        .sp-card.hidden-mode > *:not(.sp-toggle-btn) { visibility: hidden !important; opacity: 0 !important; }
        .sp-card.hidden-mode::after { content: "SECRET"; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: 900; color: #333; font-size: 14px; letter-spacing: 2px; }

        .sp-saving .sp-toggle-btn { display: none !important; }

        .sp-toggle-btn { position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,0.1); border: none; color: #fff; border-radius: 5px; font-size: 10px; padding: 2px 6px; cursor: pointer; z-index: 20; font-weight: 800; }
        .sp-card-img { width: 70px; height: 70px; border-radius: 20px; border: 2px solid #00c6ff; object-fit: cover; flex-shrink: 0; }

        .sp-list-container { display: flex; flex-direction: column; gap: 8px; height: auto; overflow: visible; }
        .sp-list-item { display: flex; align-items: center; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 10px 15px; cursor: pointer; }
        .sp-list-item:hover { background: rgba(255,255,255,0.08); border-color: var(--rank-color); }
        .sp-idx-badge { font-size: 16px; font-weight: 800; color: #666; width: 24px; text-align: center; margin-right: 12px; font-style: italic; }
        .sp-idx-badge.top3 { color: var(--rank-color); text-shadow: 0 0 5px var(--rank-color); font-size: 20px; font-weight: 900; }
        .sp-list-img { width: 42px; height: 42px; border-radius: 10px; background: #222; margin-right: 15px; border: 1px solid rgba(255,255,255,0.1); object-fit: cover; }
        .sp-list-content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 5px; }
        .sp-list-bar { width: 100%; height: 5px; background: rgba(255,255,255,0.1); border-radius: 10px; overflow: hidden; }
        .sp-list-fill { height: 100%; background: var(--rank-color); border-radius: 10px; }
        .sp-row-value { font-size: 13px; font-weight: 700; color: #ccc; margin-left: 15px; min-width: 50px; text-align: right; }

        .sp-layout-grid { display: grid !important; grid-template-columns: 380px 1fr 1fr !important; gap: 25px !important; align-items: start; }
        .sp-panel { background: rgba(255,255,255,0.02); padding: 45px; border-radius: 40px; border: 1px solid rgba(255,255,255,0.06); box-sizing: border-box; display: flex; flex-direction: column; gap: 50px; }

        .sp-badge-area { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-bottom: 50px; min-height: 70px; }

        .sp-badge {
            display: flex; align-items: center; gap: 14px;
            background: rgba(255,255,255,0.04);
            border: 1px solid rgba(255,255,255,0.09);
            border-radius: 22px; padding: 14px 22px 14px 16px;
            position: relative; cursor: default;
            transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
        }
        .sp-badge:hover {
            transform: translateY(-2px);
            border-color: var(--badge-color, #ffd700);
            box-shadow: 0 4px 20px color-mix(in srgb, var(--badge-color, #ffd700) 20%, transparent);
        }
        .sp-badge-icon {
            width: 46px; height: 46px; border-radius: 14px;
            display: flex; align-items: center; justify-content: center;
            font-size: 24px; flex-shrink: 0;
            background: color-mix(in srgb, var(--badge-color, #ffd700) 15%, transparent);
        }
        .sp-badge-body { display: flex; flex-direction: column; gap: 1px; }
        .sp-badge-title { font-size: 15px; font-weight: 900; color: #fff; letter-spacing: -0.2px; line-height: 1.2; }
        .sp-badge-desc  { font-size: 13px; font-weight: 800; color: var(--badge-color, #ffd700); line-height: 1.2; margin-top: 2px; }
        .sp-badge-sub   { font-size: 11px; color: #888; font-weight: 600; line-height: 1.2; margin-top: 1px; }

        .sp-tooltip {
            position: absolute; bottom: 130%; left: 50%; transform: translateX(-50%);
            background: #111; color: #fff; padding: 8px 14px; border-radius: 10px;
            font-size: 12px; font-weight: 600; white-space: nowrap; z-index: 1000;
            border: 1px solid rgba(255,255,255,0.15); pointer-events: none; opacity: 0; transition: 0.2s; visibility: hidden;
        }
        .sp-badge:hover .sp-tooltip { opacity: 1; visibility: visible; bottom: 115%; }

        .sp-exit { position: absolute; top: 40px; right: 40px; background: rgba(255,255,255,0.2); border: none; color: #fff; width: 55px; height: 55px; border-radius: 50%; cursor: pointer; font-size: 24px; z-index: 100; transition: 0.2s; }
        .sp-exit:hover { background: var(--rank-color); color: #000; transform: rotate(90deg); }

        .sp-select-wrap {
            position: absolute; top: 45px; left: 45px;
            display: flex; align-items: center; gap: 10px; z-index: 200;
        }
        .sp-select { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 10px 25px; border-radius: 12px; font-size: 18px; font-weight: 800; cursor: pointer; outline: none; }
        .sp-select option { background: #1a1a2e; color: #fff; }

        .sp-msg-box {
            background: rgba(255,255,255,0.025);
            border: 1px solid rgba(255,255,255,0.08);
            border-radius: 20px; margin-bottom: 40px;
            padding: 24px 30px; display: flex; align-items: flex-start; gap: 18px; min-height: 70px;
        }
        .sp-msg-text-wrap {
            display: block;
            line-height: 1.7;
            font-size: 16px;
            color: #ddd;
            font-weight: 500;
            word-break: keep-all;
            letter-spacing: -0.3px;
        }
        .sp-msg-text-wrap strong {
            color: var(--rank-color, #00c6ff);
            font-weight: 900;
        }

        .sp-loading {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.85); border-radius: 45px;
            display: none; align-items: center; justify-content: center;
            flex-direction: column; gap: 20px; font-weight: 900; font-size: 20px; z-index: 100;
        }
        .sp-spinner {
            width: 48px; height: 48px;
            border: 4px solid rgba(255,255,255,0.1);
            border-top-color: #00c6ff;
            border-radius: 50%;
            animation: sp-spin 0.8s linear infinite;
        }
        @keyframes sp-spin { to { transform: rotate(360deg); } }

        .sr-toolbar { margin-top: 60px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 35px; width: 100%; }
        .sr-opt-group { position: relative; display: flex; align-items: center; gap: 10px; }

        .sp-ctx-menu {
            position: absolute; bottom: 125%; left: 0; background: #15151a;
            border: 1px solid rgba(255,255,255,0.2); border-radius: 18px; padding: 12px;
            display: none; flex-direction: column; gap: 8px; width: 220px; z-index: 2000;
        }
        .sp-ctx-menu.active { display: flex; }
        .sp-ctx-item { background: transparent; border: none; color: #ccc; padding: 12px 15px; text-align: left; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: 14px; display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box; }
        .sp-ctx-item:hover { background: rgba(255,255,255,0.1); color: #fff; }

        .sp-tool-btn { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.2); padding: 14px 24px; border-radius: 20px; font-weight: 800; cursor: pointer; font-size: 16px; display: flex; align-items: center; gap: 8px; }
        .sp-tool-btn:hover { background: rgba(255,255,255,0.2); }
        .sp-save-btn { background: #fff; color: #000; border: none; padding: 16px 50px; border-radius: 22px; font-size: 18px; font-weight: 900; cursor: pointer; transition: 0.3s; }
        .sp-save-btn:disabled { background: #555; color: #888; cursor: not-allowed; }
        .sp-link-btn { background-image: url('https://i.ibb.co/XkJsTqHc/image.jpg'); background-size: cover; background-position: center; color: #fff; border: 2px solid rgba(255,255,255,0.2); padding: 16px 30px; border-radius: 22px; font-size: 18px; font-weight: 900; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; font-family: 'Pretendard'; position: relative; overflow: hidden; text-shadow: 0 2px 4px rgba(0,0,0,0.5); }
        #sp-upload-input { display: none; }

        .sp-anim-item { opacity: 1; }
        .sp-anim-show { opacity: 1; }

        .sp-cal-header { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; text-align: center; margin-bottom: 15px; font-size: 14px; font-weight: 900; color: #666; }
        .sp-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; width: 100%; }
        .sp-cal-day { aspect-ratio: 1/1; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 800; background: rgba(255,255,255,0.03); color: rgba(255,255,255,0.08); }
        .sp-cal-day.active { background: linear-gradient(135deg, #00c6ff, #0072ff); color: #fff !important; box-shadow: 0 5px 15px rgba(0, 114, 255, 0.4); }

        .sp-cat-list { display: flex; flex-direction: column; gap: 8px; margin-top: 15px; }
        .sp-cat-item { background: rgba(255,255,255,0.05); padding: 10px 18px; border-radius: 18px; display: flex; align-items: center; gap: 15px; border: 1px solid rgba(255,255,255,0.1); }
        .sp-cat-idx { font-weight: 900; color: #00c6ff; width: 20px; font-size: 16px; }
        .sp-cat-img { width: 30px; height: 30px; border-radius: 6px; background: #333; overflow: hidden; }
        .sp-cat-img img { width: 100%; height: 100%; object-fit: cover; }
        .sp-cat-txt { font-weight: 700; flex: 1; color: #ddd; font-size: 15px; }
        .sp-cat-cnt { background: rgba(0, 114, 255, 0.3); color: #fff; padding: 2px 10px; border-radius: 8px; font-size: 12px; font-weight: 800; }

        .sp-delta { font-size: 11px; font-weight: 800; padding: 2px 8px; border-radius: 6px; margin-top: 4px; display: inline-block; }
        .sp-delta.up { background: rgba(46, 204, 113, 0.2); color: #2ecc71; }
        .sp-delta.down { background: rgba(255, 78, 78, 0.2); color: #ff4e4e; }
        .sp-delta.same { background: rgba(255,255,255,0.1); color: #888; }

        .sp-heatmap-wrap { display: flex; flex-direction: column; gap: 6px; }
        .sp-heatmap-row { display: flex; gap: 5px; align-items: center; }
        .sp-heatmap-label { font-size: 11px; color: #555; font-weight: 700; width: 30px; text-align: right; flex-shrink: 0; }
        .sp-heatmap-cell {
            flex: 1; height: 18px; border-radius: 4px;
            background: rgba(255,255,255,0.04);
            transition: opacity 0.2s;
            cursor: default;
            position: relative;
        }
        .sp-heatmap-cell:hover::after {
            content: attr(data-tip);
            position: absolute; bottom: 130%; left: 50%; transform: translateX(-50%);
            background: #111; color: #fff; font-size: 11px; font-weight: 700;
            padding: 4px 8px; border-radius: 6px; white-space: nowrap;
            border: 1px solid rgba(255,255,255,0.2); pointer-events: none; z-index: 999;
        }
        .sp-heatmap-x { display: flex; gap: 5px; padding-left: 35px; margin-top: 4px; }
        .sp-heatmap-x span { flex: 1; text-align: center; font-size: 10px; color: #444; font-weight: 700; }
    `);

    async function initDashboard() {
        if (!AppContext.uid) {
            try {
                const response = await new Promise(resolve => {
                    GM_xmlhttpRequest({
                        method: "GET",
                        url: Endpoints.USER,
                        onload: (r) => resolve(JSON.parse(r.responseText).CHANNEL)
                    });
                });
                AppContext.uid = response.LOGIN_ID;
                localStorage.setItem('sr_uid', AppContext.uid);
            } catch (e) {
                return;
            }
        }

        let modal = document.getElementById('sp-modal-layer');
        if (!modal) {
            modal = document.createElement('div');
            modal.id = 'sp-modal-layer';
            document.body.appendChild(modal);
        }

        document.body.style.overflow = 'hidden';

        const bg = localStorage.getItem('sr_bg_url') || CONFIG.DEFAULT_BG;
        const bgBlob = await fetchImageBlob(bg);
        AppContext.bgBlob = bgBlob;

        modal.innerHTML = `
            <div class="sp-dashboard-container">
                <div class="sp-header" id="sp-bg-target" style="background-image: url('${bgBlob}');">
                    <div class="sp-header-inner">
                        <button class="sp-exit" id="sp-close-btn">✕</button>
                        <div class="sp-select-wrap">
                            <div style="font-size:18px; font-weight:800; color:#00c6ff; text-shadow: 0 0 5px rgba(0,0,0,0.8);">월별 조회: </div>
                            <select id="sp-month-sel" class="sp-select"></select>
                        </div>
                        <div class="sp-badge-area"></div>
                        <div class="sp-stat-grid">
                            <div class="sp-card sp-anim-item"></div><div class="sp-card sp-anim-item"></div>
                            <div class="sp-card sp-anim-item"></div><div class="sp-card sp-anim-item"></div>
                        </div>
                    </div>
                </div>
                <div class="sp-body">
                    <div id="sp-loader" class="sp-loading">
                        <div class="sp-spinner"></div>
                        <div>데이터 분석 중...</div>
                    </div>
                    <div class="sp-msg-box" id="sp-msg-text">
                        <div class="sp-msg-text-wrap"></div>
                    </div>
                    <div class="sp-layout-grid">
                        <div class="sp-panel">
                            <div class="sp-anim-item"><div style="font-size:20px; font-weight:900; margin-bottom:25px;">요일 활동량</div><div style="height:180px;"><canvas id="sp-chart"></canvas></div></div>
                            <div class="sp-anim-item">
                                <div style="font-size:20px; font-weight:900; margin-bottom:15px;">시간대별 시청 패턴</div>
                                <div class="sp-heatmap-wrap" id="sp-heatmap"></div>
                                <div class="sp-heatmap-x" id="sp-heatmap-x"></div>
                            </div>
                            <div class="sp-anim-item"><div style="font-size:20px; font-weight:900; margin-bottom:25px;">출석 체크</div><div class="sp-cal-wrapper"><div class="sp-cal-header"><div>SUN</div><div>MON</div><div>TUE</div><div>WED</div><div>THU</div><div>FRI</div><div>SAT</div></div><div class="sp-cal-grid"></div></div></div>
                            <div class="sp-anim-item"><div style="margin-top:0;"><div style="font-size:20px; font-weight:900;">참여 카테고리 순위</div><div id="sp-cat-list" class="sp-cat-list"></div></div></div>
                        </div>
                        <div class="sp-panel">
                            <div class="sp-anim-item"><div style="font-size:14px; font-weight:500; color:#888; margin-bottom:5px;">* 클릭하면 비공개 처리됩니다.</div><div style="font-size:20px; font-weight:900; margin-bottom:25px;">스트리머 순위 (Top ${CONFIG.MAX_ITEMS})</div><div id="sp-bj-list" class="sp-list-container"></div></div>
                        </div>
                        <div class="sp-panel">
                            <div class="sp-anim-item"><div style="font-size:14px; font-weight:500; color:#888; margin-bottom:5px;">* 클릭하면 비공개 처리됩니다.</div><div style="font-size:20px; font-weight:900; margin-bottom:25px;">VOD 시청 순위 (Top ${CONFIG.MAX_ITEMS})</div><div id="sp-vod-list" class="sp-list-container"></div></div>
                        </div>
                    </div>
                    <div class="sr-toolbar">
                        <div class="sr-opt-group">
                            <button id="sp-opt-btn" class="sp-tool-btn">⚙️ 설정</button>
                            <div class="sp-ctx-menu" id="sp-ctx-menu">
                                <label for="sp-upload-input" class="sp-ctx-item" style="display: flex; flex-direction: column; align-items: flex-start; gap: 2px;">
                                    <span>📁 배경 변경</span>
                                    <span style="font-size: 10px; color: #888;">권장: 1920x1080 <span style="color: #ff4e4e;">(Max 3MB)</span></span>
                                </label>
                            </div>
                        </div>
                        <input type="file" id="sp-upload-input" accept="image/*">
                        <div style="display: flex; gap: 12px;">
                            <a href="https://cafe.naver.com/f-e/cafes/31308909/menus/91" target="_blank" class="sp-link-btn"><span>HINDERLAND</span></a>
                            <button id="sp-save-btn" class="sp-save-btn">IMAGE SAVE</button>
                        </div>
                    </div>
                </div>
            </div>
        `;

        const closeBtn = document.getElementById('sp-close-btn');
        closeBtn.onclick = () => {
            if (AppContext.resizeCleanup) {
                AppContext.resizeCleanup();
                AppContext.resizeCleanup = null;
            }
            modal.remove();
            document.body.style.overflow = '';
            if (AppContext.chartRef) {
                AppContext.chartRef.destroy();
                AppContext.chartRef = null;
            }
            if (AppContext.heatmapChartRef) {
                AppContext.heatmapChartRef.destroy();
                AppContext.heatmapChartRef = null;
            }
        };

        const optBtn = document.getElementById('sp-opt-btn');
        const menu = document.getElementById('sp-ctx-menu');
        optBtn.onclick = (e) => { e.stopPropagation(); menu.classList.toggle('active'); };
        document.addEventListener('click', (e) => {
            if (!menu.contains(e.target) && e.target !== optBtn) menu.classList.remove('active');
        });

        const saveBtn = document.getElementById('sp-save-btn');
        saveBtn.onclick = () => {
            const el = document.querySelector('.sp-dashboard-container');
            saveBtn.innerText = "⏳ 저장 중...";
            saveBtn.disabled = true;

            el.classList.add('sp-saving');

            setTimeout(() => {
                html2canvas(el, { useCORS: true, scale: 1.5, backgroundColor: '#0a0a0f', scrollY: -window.scrollY }).then(c => {
                    const a = document.createElement('a');
                    a.download = `SOOP_RECAP_${Date.now()}.png`;
                    a.href = c.toDataURL();
                    a.click();
                    saveBtn.innerText = "IMAGE SAVE";
                    saveBtn.disabled = false;
                    el.classList.remove('sp-saving');
                }).catch(() => {
                    alert("저장 실패");
                    saveBtn.innerText = "IMAGE SAVE";
                    saveBtn.disabled = false;
                    el.classList.remove('sp-saving');
                });
            }, 100);
        };

        document.getElementById('sp-upload-input').onchange = (e) => {
            const f = e.target.files[0];
            if (f && f.size <= 3 * 1024 * 1024) {
                const r = new FileReader();
                r.onload = (ev) => {
                    localStorage.setItem('sr_bg_url', ev.target.result);
                    fetchImageBlob(ev.target.result).then(b => {
                        document.getElementById('sp-bg-target').style.backgroundImage = `url('${b}')`;
                    });
                };
                r.readAsDataURL(f);
            } else {
                alert("3MB 이하만 가능합니다.");
            }
        };

        const dateSel = document.getElementById('sp-month-sel');
        for (let i = 0; i < 4; i++) {
            const d = new Date(new Date().getFullYear(), new Date().getMonth() - i, 1);
            dateSel.innerHTML += `<option value="${d.getFullYear()}-${d.getMonth() + 1}" ${i === 0 ? 'selected' : ''}>${d.getFullYear()}년 ${d.getMonth() + 1}월</option>`;
        }
        dateSel.onchange = (e) => {
            const [y, m] = e.target.value.split('-').map(Number);
            AppContext.activeYear = y;
            AppContext.activeMonth = m;
            refreshData();
        };

        const bgTarget = document.getElementById('sp-bg-target');
        const cvs = document.createElement('canvas');
        cvs.id = 'sp-particles';
        bgTarget.appendChild(cvs);
        const cx = cvs.getContext('2d');
        const pts = [];

        const fit = () => { cvs.width = bgTarget.offsetWidth; cvs.height = bgTarget.offsetHeight; };
        window.addEventListener('resize', fit);
        AppContext.resizeCleanup = () => window.removeEventListener('resize', fit);
        fit();

        for (let i = 0; i < 45; i++) pts.push({
            x: Math.random() * cvs.width,
            y: Math.random() * cvs.height,
            vx: (Math.random() - 0.5) * 0.4,
            vy: (Math.random() - 0.5) * 0.4,
            s: Math.random() * 1 + 0.5,
            a: Math.random() * 0.5 + 0.3
        });

        const loop = () => {
            if (!document.getElementById('sp-particles')) return;
            cx.clearRect(0, 0, cvs.width, cvs.height);
            for (const p of pts) {
                p.x += p.vx; p.y += p.vy;
                if (p.x < 0 || p.x > cvs.width) p.vx *= -1;
                if (p.y < 0 || p.y > cvs.height) p.vy *= -1;
                cx.globalAlpha = p.a;
                cx.fillStyle = '#fff';
                cx.beginPath();
                cx.arc(p.x, p.y, p.s, 0, Math.PI * 2);
                cx.fill();
            }
            requestAnimationFrame(loop);
        };
        loop();

        refreshData();
    }

    async function refreshData() {
        const loader = document.getElementById('sp-loader');
        if (loader) loader.style.display = 'flex';

        try {
            const { activeYear, activeMonth, uid } = AppContext;
            const makeDate = (y, m, d) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
            const endD = new Date(activeYear, activeMonth, 0).getDate();

            const cStart = makeDate(activeYear, activeMonth, 1);
            const cEnd = makeDate(activeYear, activeMonth, endD);

            const pDate = new Date(activeYear, activeMonth - 2, 1);
            const pStart = makeDate(pDate.getFullYear(), pDate.getMonth() + 1, 1);
            const pEnd = makeDate(pDate.getFullYear(), pDate.getMonth() + 1, new Date(pDate.getFullYear(), pDate.getMonth() + 1, 0).getDate());

            const [L1, V1, L2, V2, K1] = await Promise.all([
                requestStats(uid, cStart, cEnd, 'UserLiveWatchTimeData'),
                requestStats(uid, cStart, cEnd, 'UserVodWatchTimeData'),
                requestStats(uid, pStart, pEnd, 'UserLiveWatchTimeData'),
                requestStats(uid, pStart, pEnd, 'UserVodWatchTimeData'),
                requestStats(uid, cStart, cEnd, 'UserLiveSearchKeywordData').catch(() => ({}))
            ]);

            const parseSet = (d) => {
                let sec = 0, dateSet = new Set(), bjSet = new Set(), dawn = 0, wknd = 0;
                (d?.table1?.data || []).forEach(r => {
                    const s = convertTimeToSec(r.total_watch_time);
                    if (s > 0) {
                        sec += s;
                        dateSet.add(r.day);
                        const day = new Date(r.day).getDay();
                        if (day === 0 || day === 6) wknd += s;
                    }
                });
                const stk = [...(d?.chart?.data_stack || []), ...(d?.chart?.data_stack_vod || []), ...(d?.chart?.vod_data_stack || [])];
                stk.forEach(k => {
                    if (k.bj_nick && k.bj_nick !== '기타') bjSet.add(k.bj_nick);
                    k.data?.forEach((val, idx) => { if (idx >= 1 && idx <= 6) dawn += val; });
                });
                return { sec, dateSet, bjSet, dawn, wknd };
            };

            const nowL = parseSet(L1), nowV = parseSet(V1);
            const preL = parseSet(L2), preV = parseSet(V2);

            const totalSec = nowL.sec + nowV.sec;
            const prevTotalSec = preL.sec + preV.sec;
            const hours = totalSec / 3600;
            const prevHours = prevTotalSec / 3600;

            const days = new Set([...nowL.dateSet, ...nowV.dateSet]);
            const prevDays = new Set([...preL.dateSet, ...preV.dateSet]);
            const bjs = new Set([...nowL.bjSet, ...nowV.bjSet]);

            const score = Math.floor((hours * 12) + (days.size * 60) + (bjs.size * 15));
            const themeColor = getThemeColor(score);

            const bjMap = new Map();
            (L1?.chart?.data_stack || []).forEach(x => {
                if (x.bj_nick && x.bj_nick !== '기타') {
                    const sum = (x.data || []).reduce((a, b) => a + b, 0);
                    bjMap.set(x.bj_nick, (bjMap.get(x.bj_nick) || 0) + sum);
                }
            });

            const vodMap = new Map();
            [...(V1?.chart?.data_stack || []), ...(V1?.chart?.data_stack_vod || []), ...(V1?.chart?.vod_data_stack || [])].forEach(x => {
                if (x.bj_nick && x.bj_nick !== '기타') {
                    const sum = (x.data || []).reduce((a, b) => a + b, 0);
                    vodMap.set(x.bj_nick, (vodMap.get(x.bj_nick) || 0) + sum);
                }
            });

            const totalBjMap = new Map();
            bjMap.forEach((v, k) => totalBjMap.set(k, (totalBjMap.get(k) || 0) + v));
            vodMap.forEach((v, k) => totalBjMap.set(k, (totalBjMap.get(k) || 0) + v));

            const sortedBJs = Array.from(totalBjMap.entries()).map(([n, v]) => ({ name: n, value: v })).sort((a, b) => b.value - a.value);
            const topBJ = sortedBJs[0]?.name || '-';

            const [u1, u2] = await Promise.all([findStreamerImage(topBJ), findStreamerImage(sortedBJs[1]?.name)]);
            const [pic1, pic2] = await Promise.all([fetchImageBlob(u1), fetchImageBlob(u2)]);

            const root = document.querySelector('.sp-dashboard-container');
            root.style.setProperty('--rank-color', themeColor);

            const stats = {
                totalHrs: hours,
                attendance: days.size,
                streamerCnt: bjs.size,
                dawnRate: totalSec > 0 ? (nowL.dawn + nowV.dawn) / totalSec : 0,
                vodRate: nowL.sec > 0 ? (nowV.sec / nowL.sec) : 0,
                favRate: totalSec > 0 && sortedBJs.length > 0 ? (sortedBJs[0].value / totalSec) : 0,
                weekendRate: totalSec > 0 ? (nowL.wknd + nowV.wknd) / totalSec : 0,
                topName: topBJ,
                prevHours: prevHours,
                prevDays: prevDays.size
            };

            root.querySelector('.sp-badge-area').innerHTML = evaluateAchievements(stats).map(b => `
                <div class="sp-badge sp-anim-item" style="--badge-color: ${b.color}">
                    <div class="sp-badge-icon">${b.icon}</div>
                    <div class="sp-badge-body">
                        <div class="sp-badge-title">${b.title}</div>
                        <div class="sp-badge-desc">${b.desc}</div>
                        <div class="sp-badge-sub">${b.sub}</div>
                    </div>
                </div>
            `).join('');

            const fullFeedbackText = createFeedbackString(stats);
            const msgEl = root.querySelector('#sp-msg-text');
            msgEl.innerHTML = `
                <div class="sp-msg-text-wrap">
                    ${fullFeedbackText.replace(/([0-9.]+시간|[0-9]+일|[0-9.]+%|[^\s]+ 방송)/g, '<strong>$1</strong>')}
                </div>
            `;

            const makeDelta = (now, prev, unit = '', isCount = false) => {
                if (prev === 0) return `<span class="sp-delta same">전월 데이터 없음</span>`;
                const diff = now - prev;
                const pct = Math.abs(diff / prev * 100).toFixed(0);
                if (diff > 0) return `<span class="sp-delta up">▲ ${isCount ? diff : diff.toFixed(1)}${unit} (+${pct}%)</span>`;
                if (diff < 0) return `<span class="sp-delta down">▼ ${isCount ? Math.abs(diff) : Math.abs(diff).toFixed(1)}${unit} (-${pct}%)</span>`;
                return `<span class="sp-delta same">전월과 동일</span>`;
            };

            const cards = root.querySelectorAll('.sp-card');

            [0, 1].forEach(i => {
                const target = sortedBJs[i];
                const name = target?.name || '-';
                const tStr = target ? `${(target.value / 3600).toFixed(1)}시간` : '';
                const img = i === 0 ? pic1 : pic2;
                cards[i].innerHTML = `
                    <button class="sp-toggle-btn">FOLD</button>
                    <img src="${img}" class="sp-card-img">
                    <div>
                        <div style="font-size:12px; color:#888;">MOST ${i + 1}</div>
                        <div style="font-weight:900; font-size:18px;">${name}</div>
                        <div style="font-size:13px; color:var(--rank-color); margin-top:2px; font-weight:700;">${tStr}</div>
                    </div>
                `;
                cards[i].querySelector('.sp-toggle-btn').onclick = (e) => { e.stopPropagation(); cards[i].classList.toggle('hidden-mode'); };
            });

            cards[2].innerHTML = `
                <div>
                    <div style="display:flex; gap:15px; margin-bottom:8px;">
                        <div><div style="font-size:11px; color:#00c6ff;">LIVE</div><div style="font-size:20px; font-weight:900;">${(nowL.sec / 3600).toFixed(1)}h</div></div>
                        <div><div style="font-size:11px; color:#bb8bff;">VOD</div><div style="font-size:20px; font-weight:900;">${(nowV.sec / 3600).toFixed(1)}h</div></div>
                    </div>
                    ${makeDelta(hours, prevHours, 'h')}
                </div>
            `;

            cards[3].innerHTML = `
                <div>
                    <div style="font-size:13px; color:#888;">ATTENDANCE</div>
                    <div style="font-size:32px; font-weight:900; color:#ffd700;">${days.size}일</div>
                    ${makeDelta(days.size, prevDays.size, '일', true)}
                </div>
            `;

            if (AppContext.chartRef) AppContext.chartRef.destroy();
            const wData = new Array(7).fill(0);
            const agg = (t) => (t?.table1?.data || []).forEach(r => { if (r.day) wData[new Date(r.day).getDay()] += convertTimeToSec(r.total_watch_time) / 3600; });
            agg(L1); agg(V1);

            const canvas = document.getElementById('sp-chart');
            if (canvas) {
                AppContext.chartRef = new Chart(canvas, {
                    type: 'bar',
                    data: { labels: ['일', '월', '화', '수', '목', '금', '토'], datasets: [{ data: wData.map(v => v.toFixed(1)), borderRadius: 7, backgroundColor: themeColor }] },
                    options: { responsive: true, maintainAspectRatio: false, layout: { padding: { top: 15, right: 15, bottom: 5, left: 10 } }, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: '#888' } }, y: { display: false } } }
                });
            }

            const heatmapEl = document.getElementById('sp-heatmap');
            const heatmapXEl = document.getElementById('sp-heatmap-x');
            if (heatmapEl) {
                const heatGrid = Array.from({ length: 24 }, () => new Array(7).fill(0));

                const collectHeat = (data) => {
                    (data?.table1?.data || []).forEach(r => {
                        if (!r.day) return;
                        const dayIdx = new Date(r.day).getDay();
                        const s = convertTimeToSec(r.total_watch_time);
                        if (s <= 0) return;
                        heatGrid[0][dayIdx] += s;
                    });
                    [...(data?.chart?.data_stack || []), ...(data?.chart?.vod_data_stack || []), ...(data?.chart?.data_stack_vod || [])].forEach(x => {
                        (x.data || []).forEach((val, idx) => {
                            const h = idx % 24;
                            heatGrid[h].forEach((_, d) => { heatGrid[h][d] += val / 7; });
                        });
                    });
                };
                collectHeat(L1);
                collectHeat(V1);

                const maxHeat = Math.max(...heatGrid.flat(), 1);
                const dayNames = ['일', '월', '화', '수', '목', '금', '토'];

                const groupSize = 4;
                const groups = [];
                for (let h = 0; h < 24; h += groupSize) {
                    const label = `${h}시`;
                    const row = new Array(7).fill(0);
                    for (let hh = h; hh < h + groupSize && hh < 24; hh++) {
                        for (let d = 0; d < 7; d++) row[d] += heatGrid[hh][d];
                    }
                    groups.push({ label, row });
                }

                const maxG = Math.max(...groups.flatMap(g => g.row), 1);

                heatmapEl.innerHTML = groups.map(g => {
                    const cells = g.row.map((v, di) => {
                        const intensity = v / maxG;
                        const alpha = Math.max(0.05, intensity);
                        const tip = `${dayNames[di]} ${g.label} · ${(v / 3600).toFixed(1)}h`;
                        const color = themeColor;
                        return `<div class="sp-heatmap-cell" data-tip="${tip}" style="background:${color}; opacity:${alpha.toFixed(2)};"></div>`;
                    }).join('');
                    return `<div class="sp-heatmap-row"><div class="sp-heatmap-label">${g.label}</div>${cells}</div>`;
                }).join('');

                if (heatmapXEl) {
                    heatmapXEl.innerHTML = dayNames.map(d => `<span>${d}</span>`).join('');
                }
            }

            const listEl = document.getElementById('sp-bj-list');
            if (listEl) {
                listEl.innerHTML = '';
                const max = sortedBJs[0]?.value || 1;
                const frag = document.createDocumentFragment();
                const queue = [];

                for (let i = 0; i < Math.min(sortedBJs.length, CONFIG.MAX_ITEMS); i++) {
                    const item = sortedBJs[i];
                    const w = (item.value / max) * 100;
                    const el = document.createElement('div');
                    el.className = 'sp-list-item sp-anim-item';
                    el.innerHTML = `
                        <div class="sp-idx-badge ${i < 3 ? 'top3' : ''}">${i + 1}</div>
                        <img src="${CONFIG.NO_PROFILE_IMG}" class="sp-list-img" id="list-img-${i}">
                        <div class="sp-list-content">
                            <div class="sp-list-name">${item.name}</div>
                            <div class="sp-list-bar"><div class="sp-list-fill" style="width: ${w}%"></div></div>
                        </div>
                        <div class="sp-row-value">${(item.value / 3600).toFixed(1)}h</div>
                    `;
                    el.onclick = async () => {
                        const t = el.querySelector('.sp-list-name');
                        const im = el.querySelector('.sp-list-img');
                        if (t.innerText === '비공개') {
                            t.innerText = item.name; im.style.filter = 'none';
                            if (im.dataset.real) im.src = im.dataset.real;
                        } else {
                            t.innerText = '비공개'; im.style.filter = 'blur(10px)';
                            im.dataset.real = im.src;
                            im.src = await fetchImageBlob(CONFIG.NO_PROFILE_IMG);
                        }
                    };
                    frag.appendChild(el);
                    queue.push({ name: item.name, id: `list-img-${i}` });
                }
                listEl.appendChild(frag);

                (async () => {
                    for (let i = 0; i < queue.length; i += 3) {
                        await Promise.all(queue.slice(i, i + 3).map(async q => {
                            const url = await findStreamerImage(q.name);
                            const blob = await fetchImageBlob(url);
                            const t = document.getElementById(q.id);
                            if (t && blob) t.src = blob;
                        }));
                        await new Promise(r => requestAnimationFrame(r));
                    }
                })();
            }

            const vodListEl = document.getElementById('sp-vod-list');
            if (vodListEl) {
                const sortedVODs = Array.from(vodMap.entries()).map(([n, v]) => ({ name: n, value: v })).sort((a, b) => b.value - a.value);
                vodListEl.innerHTML = '';
                const maxV = sortedVODs[0]?.value || 1;
                const fragV = document.createDocumentFragment();
                const queueV = [];

                for (let i = 0; i < Math.min(sortedVODs.length, CONFIG.MAX_ITEMS); i++) {
                    const item = sortedVODs[i];
                    const w = (item.value / maxV) * 100;
                    const el = document.createElement('div');
                    el.className = 'sp-list-item sp-anim-item';
                    el.innerHTML = `
                        <div class="sp-idx-badge ${i < 3 ? 'top3' : ''}">${i + 1}</div>
                        <img src="${CONFIG.NO_PROFILE_IMG}" class="sp-list-img" id="vod-list-img-${i}">
                        <div class="sp-list-content">
                            <div class="sp-list-name">${item.name}</div>
                            <div class="sp-list-bar"><div class="sp-list-fill" style="width: ${w}%"></div></div>
                        </div>
                        <div class="sp-row-value">${(item.value / 3600).toFixed(1)}h</div>
                    `;
                    el.onclick = async () => {
                        const t = el.querySelector('.sp-list-name');
                        const im = el.querySelector('.sp-list-img');
                        if (t.innerText === '비공개') {
                            t.innerText = item.name; im.style.filter = 'none';
                            if (im.dataset.real) im.src = im.dataset.real;
                        } else {
                            t.innerText = '비공개'; im.style.filter = 'blur(10px)';
                            im.dataset.real = im.src;
                            im.src = await fetchImageBlob(CONFIG.NO_PROFILE_IMG);
                        }
                    };
                    fragV.appendChild(el);
                    queueV.push({ name: item.name, id: `vod-list-img-${i}` });
                }
                vodListEl.appendChild(fragV);

                (async () => {
                    for (let i = 0; i < queueV.length; i += 3) {
                        await Promise.all(queueV.slice(i, i + 3).map(async q => {
                            const url = await findStreamerImage(q.name);
                            const blob = await fetchImageBlob(url);
                            const t = document.getElementById(q.id);
                            if (t && blob) t.src = blob;
                        }));
                        await new Promise(r => requestAnimationFrame(r));
                    }
                })();
            }

            const catArea = document.getElementById('sp-cat-list');
            if (catArea && K1?.table2?.data) {
                const cm = new Map();
                K1.table2.data.forEach(x => { if (x?.skey) cm.set(x.skey, (cm.get(x.skey) || 0) + parseInt(x.cnt || 0)); });
                const sc = Array.from(cm.entries()).sort((a, b) => b[1] - a[1]).slice(0, 7);

                catArea.innerHTML = sc.map((c, i) => `
                    <div class="sp-cat-item">
                        <div class="sp-cat-idx">${i + 1}</div>
                        <div class="sp-cat-img"><img id="cat-pic-${i}" src=""></div>
                        <div class="sp-cat-txt">${c[0]}</div>
                        <div class="sp-cat-cnt">${c[1]}회</div>
                    </div>
                `).join('');

                GM_xmlhttpRequest({
                    method: "GET",
                    url: `${Endpoints.SEARCH}?m=categoryList&szOrder=prefer&nListCnt=100`,
                    onload: (r) => {
                        try {
                            const lst = JSON.parse(r.responseText)?.data?.list || [];
                            sc.forEach((c, i) => {
                                const f = lst.find(it => it.category_name === c[0]);
                                const u = f?.cate_img || (f?.cate_no ? `https://res.sooplive.com/images/category/${f.cate_no}.jpg` : "");
                                const el = document.getElementById(`cat-pic-${i}`);
                                if (el && u) fetchImageBlob(u).then(b => el.src = b);
                            });
                        } catch { }
                    }
                });
            }

            const cal = root.querySelector('.sp-cal-grid');
            let h = '';
            const off = new Date(activeYear, activeMonth - 1, 1).getDay();
            for (let i = 0; i < off; i++) h += '<div class="sp-cal-day" style="visibility:hidden;"></div>';
            for (let d = 1; d <= endD; d++) {
                const k = makeDate(activeYear, activeMonth, d);
                h += `<div class="sp-cal-day ${days.has(k) ? 'active' : ''}">${d}</div>`;
            }
            cal.innerHTML = h;

            setTimeout(() => root.querySelectorAll('.sp-anim-item').forEach(e => e.classList.add('sp-anim-show')), 100);

        } catch (e) {
            console.error(e);
        }

        if (loader) loader.style.display = 'none';
    }

    const launch = () => {
        const btn = document.createElement('button');
        btn.id = 'sp-launch-btn';
        btn.innerText = "CHECK MY RECAP";

        const p = JSON.parse(localStorage.getItem('sr_pos_data') || '{"right":"30px","bottom":"30px"}');
        Object.assign(btn.style, { right: p.right, bottom: p.bottom, left: p.left || 'auto', top: p.top || 'auto' });
        document.body.appendChild(btn);

        let isDragging = false;
        let hasMoved = false;
        let origin = { x: 0, y: 0 };

        btn.onmousedown = e => {
            isDragging = true;
            hasMoved = false;
            const r = btn.getBoundingClientRect();
            origin.x = e.clientX - r.left;
            origin.y = e.clientY - r.top;
            btn.style.transition = 'none';
        };
        document.onmousemove = e => {
            if (!isDragging) return;
            const dx = Math.abs(e.clientX - origin.x - btn.getBoundingClientRect().left);
            const dy = Math.abs(e.clientY - origin.y - btn.getBoundingClientRect().top);
            if (dx > 4 || dy > 4) hasMoved = true;
            btn.style.left = (e.clientX - origin.x) + 'px';
            btn.style.top = (e.clientY - origin.y) + 'px';
            btn.style.right = 'auto';
            btn.style.bottom = 'auto';
        };
        document.onmouseup = () => {
            if (isDragging) {
                isDragging = false;
                btn.style.transition = 'all 0.3s';
                localStorage.setItem('sr_pos_data', JSON.stringify({ left: btn.style.left, top: btn.style.top }));
            }
        };
        btn.onclick = () => {
            if (hasMoved) { hasMoved = false; return; }
            initDashboard();
        };

        setInterval(() => {
            const f = document.fullscreenElement || document.webkitFullscreenElement || document.body.classList.contains('full_screen') || document.body.classList.contains('screen_mode');
            if (f) btn.classList.add('sp-invisible');
            else btn.classList.remove('sp-invisible');
        }, 1000);
    };

    launch();
})();