// ==UserScript==
// @name Kone 사이트 콘 시스템 (이미지 삽입 + 콘 목록)
// @namespace http://tampermonkey.net/
// @version 3.0
// @description ~콘 이미지 삽입 및 댓글창 옆 콘 목록 표시
// @author You
// @match https://kone.gg/*
// @match https://*.kone.gg/*
// @grant none
// @run-at document-end
// @downloadURL https://update.greasyfork.icu/scripts/536839/Kone%20%EC%82%AC%EC%9D%B4%ED%8A%B8%20%EC%BD%98%20%EC%8B%9C%EC%8A%A4%ED%85%9C%20%28%EC%9D%B4%EB%AF%B8%EC%A7%80%20%EC%82%BD%EC%9E%85%20%2B%20%EC%BD%98%20%EB%AA%A9%EB%A1%9D%29.user.js
// @updateURL https://update.greasyfork.icu/scripts/536839/Kone%20%EC%82%AC%EC%9D%B4%ED%8A%B8%20%EC%BD%98%20%EC%8B%9C%EC%8A%A4%ED%85%9C%20%28%EC%9D%B4%EB%AF%B8%EC%A7%80%20%EC%82%BD%EC%9E%85%20%2B%20%EC%BD%98%20%EB%AA%A9%EB%A1%9D%29.meta.js
// ==/UserScript==
(function() {
'use strict';
// API 설정
const API_BASE_URL = 'https://kon-image-api.pages.dev/api';
const KON_API_URL = `${API_BASE_URL}/kon`;
const KON_LIST_API_URL = `${API_BASE_URL}/kon/list`;
// 기본 이미지 URL (API 실패시 사용)
const FALLBACK_IMAGE_URL = 'https://static.wtable.co.kr/image/production/service/recipe/1500/adf710c9-e45c-4782-a07a-4fec0ed86e5b.jpg?size=800x800';
// ~콘으로 끝나는 텍스트를 찾는 정규식
const KON_REGEX = /(?:\()?(.+?)\s*콘(?:\))?$/;
// 이미 처리된 요소들을 추적하기 위한 Set
const processedElements = new WeakSet();
// API 응답 캐시
const apiCache = new Map();
const konListCache = new Map();
// 댓글창 셀렉터
const COMMENT_TEXTAREA_SELECTOR = 'body > div.flex-1 > div > main > div > div > div > div.flex.flex-col.border-zinc-300.md\\:rounded-lg.md\\:border.lg\\:w-3\\/4.dark\\:border-zinc-700 > div.overflow-hidden.border-t.border-zinc-300.bg-white.pb-2.dark\\:border-zinc-700.dark\\:bg-zinc-800 > div.p-4.py-2 > div > div > textarea';
// 콘 목록 UI 상태
let konListPanel = null;
let isKonListVisible = false;
let currentKonList = [];
// === 기존 콘 이미지 삽입 기능 ===
// API 호출 함수
async function fetchKonData(konName) {
if (apiCache.has(konName)) {
return apiCache.get(konName);
}
try {
const response = await fetch(`${KON_API_URL}/${encodeURIComponent(konName)}`, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
referrerPolicy: 'no-referrer',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('API 응답:', data);
apiCache.set(konName, data);
setTimeout(() => apiCache.delete(konName), 5 * 60 * 1000);
return data;
} catch (error) {
console.error('API 호출 실패:', error);
const fallbackData = {
success: false,
exists: false,
message: 'API 호출 실패: ' + error.message
};
apiCache.set(konName, fallbackData);
setTimeout(() => apiCache.delete(konName), 30 * 1000);
return fallbackData;
}
}
async function insertImageForKonElements() {
const pElements = document.querySelectorAll('p');
for (const pElement of pElements) {
if (processedElements.has(pElement)) {
continue;
}
const textContent = pElement.textContent.trim();
const match = textContent.match(KON_REGEX);
if (match) {
const fullText = match[0];
const konName = match[1] + '콘';
console.log('~콘으로 끝나는 p 태그 발견:', konName);
processedElements.add(pElement);
try {
const apiResponse = await fetchKonData(konName);
const newDiv = document.createElement('div');
Array.from(pElement.attributes).forEach(attr => {
newDiv.setAttribute(attr.name, attr.value);
});
newDiv.innerHTML = pElement.innerHTML;
if (apiResponse.success && apiResponse.exists && apiResponse.data) {
const img = document.createElement('img');
img.src = apiResponse.data.imageUrl;
img.alt = `${konName} 이미지`;
img.title = apiResponse.data.description || konName;
img.style.cssText = `
width: 100px;
height: 100px;
object-fit: contain;
display: block;
margin: 10px 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
`;
img.onerror = function() {
console.warn(`이미지 로드 실패: ${apiResponse.data.imageUrl}`);
this.src = FALLBACK_IMAGE_URL;
};
newDiv.appendChild(img);
console.log(`${konName} 이미지 추가 완료 (서버에서 가져옴)`);
} else {
const img = document.createElement('img');
img.src = FALLBACK_IMAGE_URL;
img.alt = `${konName} 이미지 (기본)`;
img.title = `${konName} (기본 이미지)`;
img.style.cssText = `
width: 100px;
height: 100px;
object-fit: contain;
display: block;
margin: 10px 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
opacity: 0.7;
`;
newDiv.appendChild(img);
console.log(`${konName} 기본 이미지 추가 완료 (서버에 없음)`);
}
pElement.parentNode.replaceChild(newDiv, pElement);
} catch (error) {
console.error('이미지 처리 중 오류:', error);
processedElements.delete(pElement);
}
}
}
}
// === 콘 목록 UI 기능 ===
// 콘 목록 조회
async function fetchKonList(search = '', limit = 20, offset = 0) {
const cacheKey = `${search}_${limit}_${offset}`;
if (konListCache.has(cacheKey)) {
return konListCache.get(cacheKey);
}
try {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString()
});
if (search.trim()) {
params.append('search', search.trim());
}
const response = await fetch(`${KON_LIST_API_URL}?${params}`, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
referrerPolicy: 'no-referrer',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('콘 목록 API 응답:', data);
konListCache.set(cacheKey, data);
setTimeout(() => konListCache.delete(cacheKey), 2 * 60 * 1000); // 2분 캐시
return data;
} catch (error) {
console.error('콘 목록 API 호출 실패:', error);
return {
success: false,
message: '콘 목록을 불러오는 중 오류가 발생했습니다: ' + error.message
};
}
}
// 콘 목록 UI 생성
function createKonListUI() {
// 콘 목록 패널 생성
konListPanel = document.createElement('div');
konListPanel.id = 'kon-list-panel';
konListPanel.style.cssText = `
position: fixed;
bottom: 100px;
right: 20px;
width: 350px;
max-height: 500px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
z-index: 9999;
display: none;
font-family: 'Pretendard', sans-serif;
overflow: hidden;
`;
// 다크 모드 스타일 추가
if (document.documentElement.classList.contains('dark')) {
konListPanel.style.background = '#27272a';
konListPanel.style.borderColor = '#3f3f46';
konListPanel.style.color = '#e4e4e7';
}
konListPanel.innerHTML = `
로딩 중...
콘 목록을 불러올 수 없습니다.
`;
document.body.appendChild(konListPanel);
// 이벤트 리스너 추가
setupKonListEvents();
}
// 콘 목록 이벤트 설정
function setupKonListEvents() {
const closeBtn = document.getElementById('kon-list-close');
const searchInput = document.getElementById('kon-search-input');
// 닫기 버튼
closeBtn.addEventListener('click', hideKonList);
// 검색 입력
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadKonList(e.target.value);
}, 300);
});
// 패널 외부 클릭시 닫기
document.addEventListener('click', (e) => {
if (konListPanel && isKonListVisible && !konListPanel.contains(e.target) && !e.target.closest('#kon-list-toggle')) {
hideKonList();
}
});
}
// 콘 목록 로드
async function loadKonList(search = '') {
const loadingEl = document.getElementById('kon-list-loading');
const itemsEl = document.getElementById('kon-list-items');
const errorEl = document.getElementById('kon-list-error');
// 로딩 상태 표시
loadingEl.style.display = 'flex';
itemsEl.style.display = 'none';
errorEl.style.display = 'none';
try {
const response = await fetchKonList(search, 50, 0); // 최대 50개
if (response.success) {
currentKonList = response.data.kons;
renderKonList(currentKonList);
} else {
throw new Error(response.message || '콘 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('콘 목록 로드 오류:', error);
loadingEl.style.display = 'none';
errorEl.style.display = 'block';
errorEl.textContent = error.message;
}
}
// 콘 목록 렌더링
function renderKonList(kons) {
const loadingEl = document.getElementById('kon-list-loading');
const itemsEl = document.getElementById('kon-list-items');
const errorEl = document.getElementById('kon-list-error');
loadingEl.style.display = 'none';
errorEl.style.display = 'none';
if (kons.length === 0) {
itemsEl.innerHTML = '콘이 없습니다.
';
} else {
itemsEl.innerHTML = kons.map(kon => `
${kon.name}
${kon.description ? `
${kon.description}
` : ''}
`).join('');
}
itemsEl.style.display = 'block';
// 콘 클릭 이벤트 추가
itemsEl.querySelectorAll('.kon-item').forEach(item => {
item.addEventListener('click', () => {
const konName = item.dataset.konName;
insertKonToComment(konName);
hideKonList();
});
});
}
// 댓글창에 콘 텍스트 삽입
function insertKonToComment(konName) {
const textarea = document.querySelector(COMMENT_TEXTAREA_SELECTOR);
if (textarea) {
const currentValue = textarea.value;
const newValue = currentValue + (currentValue ? ' ' : '') + konName;
textarea.value = newValue;
textarea.focus();
// 커서를 끝으로 이동
textarea.setSelectionRange(newValue.length, newValue.length);
// 이벤트 발생 (React 등에서 감지할 수 있도록)
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
console.log(`댓글창에 "${konName}" 삽입 완료`);
} else {
console.warn('댓글창을 찾을 수 없습니다.');
}
}
// 버튼 상태 추적
let konButtonCreated = false;
// 콘 목록 토글 버튼 생성
function createKonToggleButton() {
// 이미 버튼이 있고 DOM에 존재하면 재생성하지 않음
const existingBtn = document.getElementById('kon-list-toggle');
if (existingBtn && document.body.contains(existingBtn)) {
return;
}
// 기존 버튼 제거
if (existingBtn) {
existingBtn.remove();
}
konButtonCreated = true;
// 우측 하단 버튼 영역 찾기 (여러 셀렉터로 시도)
let buttonContainer = null;
// 첫 번째 시도: 고정된 우측 하단 버튼 영역
const floatingButtons = document.querySelector('.fixed.inset-x-0.bottom-0 .flex.gap-2, .fixed .flex.gap-2');
if (floatingButtons) {
buttonContainer = floatingButtons;
}
// 두 번째 시도: opacity가 있는 우측 하단 버튼들
if (!buttonContainer) {
const opacityContainer = document.querySelector('.opacity-80.hover\\:opacity-100');
if (opacityContainer) {
buttonContainer = opacityContainer;
}
}
// 세 번째 시도: XPath 기반으로 찾기
if (!buttonContainer) {
const xpath = '/html/body/div[1]/div/main/div/div/div/div[1]/div[6]/div/div/div';
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
if (result.singleNodeValue) {
buttonContainer = result.singleNodeValue;
}
}
// 네 번째 시도: 일반적인 우측 하단 고정 버튼 영역 찾기
if (!buttonContainer) {
const fixedBottomElements = document.querySelectorAll('[style*="position: fixed"], .fixed');
for (const element of fixedBottomElements) {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
// 우측 하단에 있는 요소 찾기
if ((style.position === 'fixed' || element.classList.contains('fixed')) &&
rect.right > window.innerWidth * 0.7 &&
rect.bottom > window.innerHeight * 0.7) {
// flex gap이 있는 컨테이너 찾기
const flexContainer = element.querySelector('.flex.gap-2, [style*="gap"]');
if (flexContainer) {
buttonContainer = flexContainer;
break;
}
}
}
}
if (!buttonContainer) {
console.warn('우측 하단 버튼 영역을 찾을 수 없습니다. 기본 위치에 버튼을 생성합니다.');
// 기본 위치에 버튼 생성
createFloatingKonButton();
return;
}
const toggleBtn = document.createElement('div');
toggleBtn.id = 'kon-list-toggle';
toggleBtn.innerHTML = `
🥤
`;
toggleBtn.title = '콘 목록 보기';
toggleBtn.style.cssText = `
pointer-events: auto;
z-index: 10000;
position: relative;
`;
// 기존 버튼들과 동일한 스타일 적용
const existingButton = buttonContainer.querySelector('div');
if (existingButton) {
const buttonStyle = window.getComputedStyle(existingButton);
const innerDiv = toggleBtn.querySelector('.kon-button-inner');
// 기존 버튼과 비슷한 스타일 적용
innerDiv.style.width = buttonStyle.width || '40px';
innerDiv.style.height = buttonStyle.height || '40px';
innerDiv.style.backgroundColor = '#10b981'; // 콘 색상 (녹색)
innerDiv.style.border = buttonStyle.border || '1px solid #e5e7eb';
// 다크 모드 확인
if (document.documentElement.classList.contains('dark')) {
innerDiv.style.backgroundColor = '#059669';
innerDiv.style.borderColor = '#3f3f46';
}
}
// 이벤트 리스너 추가 (한 번만)
toggleBtn.addEventListener('click', handleKonButtonClick, { once: false });
toggleBtn.addEventListener('mouseenter', handleKonButtonHover, { passive: true });
toggleBtn.addEventListener('mouseleave', handleKonButtonLeave, { passive: true });
// 기존 버튼들과 함께 배치
buttonContainer.appendChild(toggleBtn);
console.log('콘 목록 버튼이 우측 하단 버튼 영역에 추가되었습니다.');
}
// 이벤트 핸들러들을 별도 함수로 분리
function handleKonButtonClick(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
console.log('콘 버튼 클릭됨');
toggleKonList();
}
function handleKonButtonHover(e) {
const innerDiv = e.currentTarget.querySelector('.kon-button-inner');
if (innerDiv) {
innerDiv.style.transform = 'scale(1.05)';
innerDiv.style.backgroundColor = document.documentElement.classList.contains('dark') ? '#047857' : '#059669';
}
}
function handleKonButtonLeave(e) {
const innerDiv = e.currentTarget.querySelector('.kon-button-inner');
if (innerDiv) {
innerDiv.style.transform = 'scale(1)';
innerDiv.style.backgroundColor = document.documentElement.classList.contains('dark') ? '#059669' : '#10b981';
}
}
// 기본 위치에 떠있는 버튼 생성 (백업 방법)
function createFloatingKonButton() {
const toggleBtn = document.createElement('div');
toggleBtn.id = 'kon-list-toggle';
toggleBtn.style.cssText = `
position: fixed;
bottom: 80px;
right: 80px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #10b981;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
transition: all 0.3s ease;
font-size: 24px;
user-select: none;
pointer-events: auto;
${document.documentElement.classList.contains('dark') ? 'background-color: #059669;' : ''}
`;
toggleBtn.innerHTML = '🥤';
toggleBtn.title = '콘 목록 보기';
toggleBtn.addEventListener('click', handleKonButtonClick, { once: false });
toggleBtn.addEventListener('mouseenter', (e) => {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.backgroundColor = document.documentElement.classList.contains('dark') ? '#047857' : '#059669';
}, { passive: true });
toggleBtn.addEventListener('mouseleave', (e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.backgroundColor = document.documentElement.classList.contains('dark') ? '#059669' : '#10b981';
}, { passive: true });
document.body.appendChild(toggleBtn);
console.log('콘 목록 버튼이 기본 위치에 생성되었습니다.');
}
// 콘 목록 표시/숨김 토글
function toggleKonList() {
if (isKonListVisible) {
hideKonList();
} else {
showKonList();
}
}
// 콘 목록 표시
function showKonList() {
if (!konListPanel) {
createKonListUI();
}
konListPanel.style.display = 'block';
isKonListVisible = true;
// 처음 열 때만 콘 목록 로드
if (currentKonList.length === 0) {
loadKonList();
}
}
// 콘 목록 숨김
function hideKonList() {
if (konListPanel) {
konListPanel.style.display = 'none';
}
isKonListVisible = false;
}
// === 초기화 및 DOM 감지 ===
function initializeKonSystem() {
// 콘 이미지 삽입 실행
insertImageForKonElements();
// 콘 목록 버튼 생성 (중복 체크 포함)
if (!document.getElementById('kon-list-toggle')) {
createKonToggleButton();
}
}
// DOM 변화 감지 (디바운싱 적용)
let observerTimeout;
function observeChanges() {
const observer = new MutationObserver(function(mutations) {
let shouldCheckImages = false;
let shouldCheckButton = false;
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
shouldCheckImages = true;
// 버튼이 없어진 경우에만 재생성 체크
if (!document.getElementById('kon-list-toggle')) {
shouldCheckButton = true;
}
}
});
}
});
if (shouldCheckImages || shouldCheckButton) {
// 디바운싱: 100ms 내에 여러 변화가 있으면 마지막 것만 실행
clearTimeout(observerTimeout);
observerTimeout = setTimeout(() => {
if (shouldCheckImages) {
insertImageForKonElements();
}
if (shouldCheckButton) {
createKonToggleButton();
}
}, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// 페이지 로드 완료 후 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => {
initializeKonSystem();
observeChanges();
}, 500);
});
} else {
setTimeout(() => {
initializeKonSystem();
observeChanges();
}, 500);
}
// 페이지 로드 이벤트
window.addEventListener('load', function() {
setTimeout(initializeKonSystem, 1000);
});
// URL 변경 감지 (SPA 대응)
let currentUrl = location.href;
new MutationObserver(() => {
if (location.href !== currentUrl) {
currentUrl = location.href;
// 캐시 클리어
apiCache.clear();
konListCache.clear();
currentKonList = [];
// 버튼 상태 초기화
konButtonCreated = false;
// UI 정리
if (konListPanel) {
konListPanel.remove();
konListPanel = null;
isKonListVisible = false;
}
// 기존 버튼 제거
const existingBtn = document.getElementById('kon-list-toggle');
if (existingBtn) {
existingBtn.remove();
}
setTimeout(initializeKonSystem, 1000);
}
}).observe(document, { subtree: true, childList: true });
})();