// ==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 // @downloadURL https://update.greasyfork.icu/scripts/553209/%5B%EC%BD%94%EB%84%A4%5D%20%EC%B6%94%EC%B2%9C%20%EC%BB%B7%20%20%EC%B6%94%EC%B2%9C%EC%88%9C%20%EC%A0%95%EB%A0%AC.user.js // @updateURL https://update.greasyfork.icu/scripts/553209/%5B%EC%BD%94%EB%84%A4%5D%20%EC%B6%94%EC%B2%9C%20%EC%BB%B7%20%20%EC%B6%94%EC%B2%9C%EC%88%9C%20%EC%A0%95%EB%A0%AC.meta.js // ==/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 = '👍'; 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 }); })();