Greasy Fork

Greasy Fork is available in English.

[코네] 추천 컷 & 추천순 정렬

kone.gg에 추천 컷과 추천순 정렬 기능을 추가

// ==UserScript==
// @name         [코네] 추천 컷 & 추천순 정렬
// @namespace    http://tampermonkey.net/
// @version      1.31
// @description  kone.gg에 추천 컷과 추천순 정렬 기능을 추가
// @author       ducktail
// @match        https://kone.gg/*
// @match        https://kone.gg/s/*
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    // CSS selectors for DOM elements
    const POST_SELECTOR = 'div.relative.group\\/post-wrapper';
    const RECO_SELECTOR = 'div[class*="text-red-500"][class*="font-bold"]';
    const DATE_PATTERN = /^\d{2}:\d{2}$|^\d{2}\.\d{2}$/;

    // Preset thresholds for recommendation filter
    const PRESETS = [30, 50, 100, 150, 300];

    // Sorting periods
    const PERIODS = {
        'today': '오늘',
        '3days': '3 일',
        '7days': '7 일',
        '1month': '1 개월',
        '3months': '3 개월',
        '6months': '6 개월',
        'all': '전체'
    };

    // Persistent storage for user preferences
    let threshold = GM_getValue('recoThreshold', 30);
    let isFilterEnabled = JSON.parse(sessionStorage.getItem('isFilterEnabled')) ?? false;
    let selectedPeriod = GM_getValue('sortPeriod', '7days');

    /**
     * Filters posts based on recommendation count threshold.
     */
    function filterPosts() {
        const posts = document.querySelectorAll(POST_SELECTOR);
        posts.forEach(post => {
            post.style.display = 'flex'; // Default to visible
            if (!isFilterEnabled) return;

            const recoElem = post.querySelector(RECO_SELECTOR);
            if (recoElem) {
                const recoText = recoElem.textContent.trim();
                const recoMatch = recoText.match(/\d+/);
                const recoCount = recoMatch ? parseInt(recoMatch[0], 10) : 0;
                if (recoCount < threshold) {
                    post.style.display = 'none';
                }
            }
        });
    }

    /**
     * Parses the post date string into a Date object.
     * @param {string} dateText - The date text from the post.
     * @returns {Date} Parsed date or epoch if invalid.
     */
    function parsePostDate(dateText) {
        const now = new Date();
        if (/^\d{2}:\d{2}$/.test(dateText)) {
            const [h, m] = dateText.split(':').map(Number);
            return new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m);
        } else if (/^\d{2}\.\d{2}$/.test(dateText)) {
            const [month, day] = dateText.split('.').map(Number);
            let year = now.getFullYear();
            const postDate = new Date(year, month - 1, day);
            if (postDate > now) {
                postDate.setFullYear(--year);
            }
            return postDate;
        }
        return new Date(0); // Fallback for invalid dates
    }

    /**
     * Calculates the cutoff date for the selected period.
     * @param {string} period - The sorting period key.
     * @returns {Date|null} Cutoff date or null for 'all'.
     */
    function getCutoff(period) {
        const now = new Date();
        switch (period) {
            case 'today':
                return new Date(now.getFullYear(), now.getMonth(), now.getDate());
            case '3days':
                now.setDate(now.getDate() - 3);
                return now;
            case '7days':
                now.setDate(now.getDate() - 7);
                return now;
            case '1month':
                now.setMonth(now.getMonth() - 1);
                return now;
            case '3months':
                now.setMonth(now.getMonth() - 3);
                return now;
            case '6months':
                now.setMonth(now.getMonth() - 6);
                return now;
            case 'all':
                return null;
            default:
                return null;
        }
    }

    /**
     * Loads posts asynchronously, sorts by recommendation count, and displays top 300.
     * @param {string} period - The sorting period key.
     */
    async function loadAndSortPosts(period) {
        const posts = [];
        let page = 1;
        const cutoff = getCutoff(period);
        const baseUrl = new URL(location.href);
        const listContainer = document.querySelector(POST_SELECTOR)?.parentElement;
        if (!listContainer) {
            alert('게시물 목록 컨테이너를 찾을 수 없습니다.');
            return;
        }

        // Create and display loading indicator
        const loadingDiv = document.createElement('div');
        loadingDiv.style.textAlign = 'center';
        loadingDiv.style.padding = '16px';
        loadingDiv.style.color = '#a1a1aa';
        loadingDiv.textContent = '게시물 로딩 중...';
        listContainer.innerHTML = ''; // Clear container
        listContainer.appendChild(loadingDiv);

        // Limit to prevent infinite loop (e.g., max 5000 pages)
        const MAX_PAGES = 5000;
        while (page <= MAX_PAGES) {
            baseUrl.searchParams.set('p', page);
            loadingDiv.textContent = `페이지 ${page} 로딩 중... (현재 ${posts.length}개 게시물 로드됨)`;

            try {
                const response = await fetch(baseUrl.toString());
                if (!response.ok) break;

                const text = await response.text();
                const parser = new DOMParser();
                const doc = parser.parseFromString(text, 'text/html');
                const pagePosts = doc.querySelectorAll(POST_SELECTOR);
                if (pagePosts.length === 0) break;

                let continueLoading = true;
                for (const pp of pagePosts) {
                    // Locate date element
                    const dateElem = Array.from(pp.querySelectorAll('div')).find(el =>
                        DATE_PATTERN.test(el.textContent.trim())
                    );
                    if (!dateElem) continue;

                    const dateText = dateElem.textContent.trim();
                    const postDate = parsePostDate(dateText);
                    if (cutoff && postDate < cutoff) {
                        continueLoading = false;
                        break; // Early exit if past cutoff
                    }

                    // Extract recommendation count
                    const recoElem = pp.querySelector(RECO_SELECTOR);
                    const recoText = recoElem ? recoElem.textContent.trim() : '0';
                    const recoMatch = recoText.match(/\d+/);
                    const reco = recoMatch ? parseInt(recoMatch[0], 10) : 0;

                    if (!cutoff || postDate >= cutoff) {
                        const importedPost = document.importNode(pp, true);
                        posts.push({ node: importedPost, reco, postDate });
                    }
                }

                if (!continueLoading) break;
                page++;
            } catch (error) {
                console.error('게시물 로딩 중 오류:', error);
                break;
            }
        }

        // Sort by recommendation descending
        posts.sort((a, b) => b.reco - a.reco);

        // Display top 300
        const topPosts = posts.slice(0, 300);
        listContainer.innerHTML = '';
        topPosts.forEach(p => listContainer.appendChild(p.node));

        // Remove pagination controls
        const pagination = document.querySelector('div.flex.justify-center.mt-4');
        if (pagination) pagination.remove();

        // Apply filter if enabled
        filterPosts();
    }

    /**
     * Creates the UI panel for filter and sorter controls.
     */
    function createUI() {
        // Skip UI creation on individual post pages
        const pathSegments = location.pathname.split('/').filter(Boolean);
        if (pathSegments.length > 2) return;

        const uiContainer = document.createElement('div');
        uiContainer.style.position = 'fixed';
        uiContainer.style.top = '60px';
        uiContainer.style.right = '20px';
        uiContainer.style.zIndex = '9999';
        uiContainer.style.backgroundColor = '#18181b';
        uiContainer.style.color = '#e4e4e7';
        uiContainer.style.padding = '16px';
        uiContainer.style.borderRadius = '6px';
        uiContainer.style.display = 'none';
        uiContainer.style.flexDirection = 'column';
        uiContainer.style.gap = '16px';

        // Filter section
        const filterLabel = document.createElement('label');
        filterLabel.textContent = '추천 컷:';
        filterLabel.style.display = 'block';
        filterLabel.style.fontSize = '0.875rem';
        filterLabel.style.fontWeight = '500';
        filterLabel.style.marginBottom = '4px';
        uiContainer.appendChild(filterLabel);

        const filterControls = document.createElement('div');
        filterControls.style.display = 'flex';
        filterControls.style.alignItems = 'center';
        filterControls.style.gap = '8px';

        const filterSelect = document.createElement('select');
        filterSelect.style.backgroundColor = '#27272a';
        filterSelect.style.color = '#e4e4e7';
        filterSelect.style.border = '1px solid #4b5563';
        filterSelect.style.borderRadius = '6px';
        filterSelect.style.padding = '4px';
        filterSelect.style.fontSize = '0.875rem';
        PRESETS.forEach(p => {
            const option = document.createElement('option');
            option.value = p;
            option.textContent = p;
            if (p === threshold) option.selected = true;
            filterSelect.appendChild(option);
        });
        const customOption = document.createElement('option');
        customOption.value = 'custom';
        customOption.textContent = '커스텀';
        filterSelect.appendChild(customOption);
        filterControls.appendChild(filterSelect);

        const filterInput = document.createElement('input');
        filterInput.type = 'number';
        filterInput.min = '0';
        filterInput.placeholder = '';
        filterInput.style.backgroundColor = '#27272a';
        filterInput.style.color = '#e4e4e7';
        filterInput.style.border = '1px solid #4b5563';
        filterInput.style.borderRadius = '6px';
        filterInput.style.padding = '4px';
        filterInput.style.fontSize = '0.875rem';
        filterInput.style.width = '80px';
        filterInput.style.display = (!PRESETS.includes(threshold) && threshold) ? 'inline-block' : 'none';
        if (filterInput.style.display === 'inline-block') {
            filterInput.value = threshold;
            filterSelect.value = '커스텀';
        }
        filterControls.appendChild(filterInput);

        const toggleBtn = document.createElement('button');
        toggleBtn.textContent = isFilterEnabled ? '필터 ON' : '필터 OFF';
        toggleBtn.style.borderRadius = '6px';
        toggleBtn.style.padding = '4px 12px';
        toggleBtn.style.fontSize = '0.875rem';
        toggleBtn.style.color = '#ffffff';
        updateToggleBtnStyle(toggleBtn, isFilterEnabled);
        filterControls.appendChild(toggleBtn);

        uiContainer.appendChild(filterControls);

        // Filter event listeners
        filterSelect.addEventListener('change', () => {
            if (filterSelect.value === 'custom') {
                filterInput.style.display = 'inline-block';
                filterInput.focus();
            } else {
                filterInput.style.display = 'none';
                threshold = parseInt(filterSelect.value, 10);
                GM_setValue('recoThreshold', threshold);
                if (isFilterEnabled) filterPosts();
            }
        });

        filterInput.addEventListener('input', () => {
            const newValue = parseInt(filterInput.value, 10);
            if (!isNaN(newValue)) {
                threshold = newValue;
                GM_setValue('recoThreshold', threshold);
                if (isFilterEnabled) filterPosts();
            }
        });

        toggleBtn.addEventListener('click', () => {
            isFilterEnabled = !isFilterEnabled;
            sessionStorage.setItem('isFilterEnabled', JSON.stringify(isFilterEnabled));
            toggleBtn.textContent = isFilterEnabled ? '필터 ON' : '필터 OFF';
            updateToggleBtnStyle(toggleBtn, isFilterEnabled);
            filterPosts();
        });

        /**
         * Updates the style of the filter toggle button.
         * @param {HTMLElement} btn - The button element.
         * @param {boolean} enabled - Whether the filter is enabled.
         */
        function updateToggleBtnStyle(btn, enabled) {
            if (enabled) {
                btn.style.backgroundColor = '#14b8a6';
                btn.onmouseover = () => btn.style.backgroundColor = '#0d9488';
                btn.onmouseout = () => btn.style.backgroundColor = '#14b8a6';
            } else {
                btn.style.backgroundColor = '#64748b';
                btn.onmouseover = () => btn.style.backgroundColor = '#475569';
                btn.onmouseout = () => btn.style.backgroundColor = '#64748b';
            }
        }

        // Sorter section (only on /s/* pages)
        if (location.pathname.startsWith('/s/')) {
            const sorterLabel = document.createElement('label');
            sorterLabel.textContent = '추천순 정렬:';
            sorterLabel.style.display = 'block';
            sorterLabel.style.fontSize = '0.875rem';
            sorterLabel.style.fontWeight = '500';
            sorterLabel.style.marginBottom = '4px';
            uiContainer.appendChild(sorterLabel);

            const sorterControls = document.createElement('div');
            sorterControls.style.display = 'flex';
            sorterControls.style.alignItems = 'center';
            sorterControls.style.gap = '8px';

            const sorterSelect = document.createElement('select');
            sorterSelect.style.backgroundColor = '#27272a';
            sorterSelect.style.color = '#e4e4e7';
            sorterSelect.style.border = '1px solid #4b5563';
            sorterSelect.style.borderRadius = '6px';
            sorterSelect.style.padding = '4px';
            sorterSelect.style.fontSize = '0.875rem';
            Object.entries(PERIODS).forEach(([key, label]) => {
                const option = document.createElement('option');
                option.value = key;
                option.textContent = label;
                if (key === selectedPeriod) option.selected = true;
                sorterSelect.appendChild(option);
            });
            sorterControls.appendChild(sorterSelect);

            const applyBtn = document.createElement('button');
            applyBtn.textContent = '상위 300개 로드';
            applyBtn.style.backgroundColor = '#8b5cf6';
            applyBtn.style.color = '#ffffff';
            applyBtn.style.borderRadius = '6px';
            applyBtn.style.padding = '4px 12px';
            applyBtn.style.fontSize = '0.875rem';
            applyBtn.onmouseover = () => applyBtn.style.backgroundColor = '#7c3aed';
            applyBtn.onmouseout = () => applyBtn.style.backgroundColor = '#8b5cf6';
            sorterControls.appendChild(applyBtn);

            uiContainer.appendChild(sorterControls);

            // Sorter event listeners
            sorterSelect.addEventListener('change', () => {
                selectedPeriod = sorterSelect.value;
                GM_setValue('sortPeriod', selectedPeriod);
            });

            applyBtn.addEventListener('click', () => {
                loadAndSortPosts(selectedPeriod);
            });
        }

        document.body.appendChild(uiContainer);

        // Create toggle button with icon
        const profileIcon = document.querySelector('img.rounded-full');
        if (profileIcon) {
            const profileParent = profileIcon.closest('a') || profileIcon.parentElement;
            const toggleBtn = document.createElement('button');
            toggleBtn.innerHTML = '&#x1F44D;';
            toggleBtn.style.marginLeft = '8px';
            toggleBtn.style.width = '32px';
            toggleBtn.style.height = '32px';
            toggleBtn.style.borderRadius = '9999px';
            toggleBtn.style.backgroundColor = '#3f3f46';
            toggleBtn.style.display = 'flex';
            toggleBtn.style.alignItems = 'center';
            toggleBtn.style.justifyContent = 'center';
            toggleBtn.style.border = 'none';
            toggleBtn.style.cursor = 'pointer';
            toggleBtn.style.color = '#e4e4e7';
            toggleBtn.onmouseover = () => toggleBtn.style.backgroundColor = '#27272a';
            toggleBtn.onmouseout = () => toggleBtn.style.backgroundColor = '#3f3f46';
            toggleBtn.title = '필터/정렬 UI 토글';
            profileParent.after(toggleBtn);

            toggleBtn.addEventListener('click', () => {
                uiContainer.style.display = uiContainer.style.display === 'none' ? 'flex' : 'none';
            });
        }
    }

    // Initialize on page load
    window.addEventListener('load', () => {
        createUI();
        filterPosts();
    });

    // Observe DOM changes for dynamic content
    const observer = new MutationObserver(filterPosts);
    observer.observe(document.body, { childList: true, subtree: true });

})();