您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
파트너·지정 스트리머 채팅 강조 / 닉네임 각종 설정 / 드롭스 접고 펼치기 / 고정댓글, 미션 자동 제어 / 채팅창 접고 펼치기 단축키( ] ) / 채팅 새로고침 버튼
// ==UserScript== // @name Chzzk_L&V: Chatting Plus // @namespace Chzzk_Live&VOD: Chatting Plus // @version 2.1.0 // @description 파트너·지정 스트리머 채팅 강조 / 닉네임 각종 설정 / 드롭스 접고 펼치기 / 고정댓글, 미션 자동 제어 / 채팅창 접고 펼치기 단축키( ] ) / 채팅 새로고침 버튼 // @author DOGJIP // @match https://chzzk.naver.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @run-at document-end // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=chzzk.naver.com // ==/UserScript== (function() { 'use strict'; // 기본 설정 const DEFAULTS = { streamer: ['고수달','냐 미 Nyami','새 담','청 묘','침착맨','삼식123','레니아워 RenieHouR'], exception: ['인챈트 봇','픽셀봇','스텔라이브 봇'], fixUnreadable: true, removeHighlight: true, truncateName: true, dropsToggle: true, missionHover: true }; // chzzk_knife_tracker용 설정 객체 const KNIFE_CONFIG = { chatContainerSelector: '.live_chatting_list_container__vwsbZ', chatListSelector: '.live_chatting_list_wrapper__a5XTV', maxMessages: 100, defaultStreamers: DEFAULTS.streamer, defaultExceptions: DEFAULTS.exception, }; // 사용자 설정 불러오기(GM_getValue) let streamer = GM_getValue('streamer', DEFAULTS.streamer); let exception = GM_getValue('exception', DEFAULTS.exception); const ENABLE_FIX_UNREADABLE_COLOR = GM_getValue('fixUnreadable', DEFAULTS.fixUnreadable); const ENABLE_REMOVE_BG_COLOR = GM_getValue('removeHighlight', DEFAULTS.removeHighlight); const ENABLE_TRUNCATE_NICKNAME = GM_getValue('truncateName', DEFAULTS.truncateName); const ENABLE_DROPS_TOGGLE = GM_getValue('dropsToggle', DEFAULTS.dropsToggle); const ENABLE_MISSION_HOVER = GM_getValue('missionHover', DEFAULTS.missionHover); let chatObserver = null; let pendingNodes = []; let processScheduled = false; let isChatOpen = true; // 초기 상태: 열림 let refreshButton = null; // 채팅 리프레쉬 버튼 function scheduleProcess() { if (processScheduled) return; processScheduled = true; window.requestAnimationFrame(() => { pendingNodes.forEach(processChatMessage); pendingNodes = []; processScheduled = false; }); } const LIGHT_GREEN = "rgb(102, 200, 102)"; const Background_SKYBLUE = 'rgba(173, 216, 230, 0.15)'; const colorCache = new Map(); // key: CSS color string, value: 가시성(true=보임, false=지우기) GM_addStyle(` /* 오버레이 */ #cp-settings-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.3); display: flex; align-items: center; justify-content: center; z-index: 9999; overflow: auto; pointer-events: none; } /* 패널: 연회색 배경 */ #cp-settings-panel { background: #b0b0b0; color: #111; padding: 1rem; border-radius: 8px; width: 480px; max-width: 90%; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-family: sans-serif; pointer-events: auto; } #cp-settings-panel h3 { margin-top: 0; color: #111; } /* 입력창 */ #cp-settings-panel textarea { width: 100%; height: 80px; margin-bottom: 0.75rem; background: #fff; color: #111; border: 1px solid #ccc; border-radius: 4px; padding: 0.5rem; resize: vertical; } /* 버튼 컨테이너: flex layout */ #cp-settings-panel > div { display: flex; gap: 0.5rem; justify-content: flex-end; } /* 버튼 공통 */ #cp-settings-panel button { padding: 0.5rem 1rem; border: none; border-radius: 4px; font-size: 0.9rem; cursor: pointer; } /* 저장 버튼 */ #cp-settings-panel button#cp-save-btn, #cp-settings-panel button#cp-exc-save-btn { background: #007bff; color: #fff; } /* 취소 버튼 */ #cp-settings-panel button#cp-cancel-btn, #cp-settings-panel button#cp-exc-cancel-btn { background: #ddd; color: #111; /* margin-left: auto; */ } /* 버튼 호버 시 약간 어두워지기 */ #cp-settings-panel button:hover { opacity: 0.9; } /* Highlight 클래스 */ .cp-highlight { color: rgb(102, 200, 102) !important; font-weight: bold !important; text-transform: uppercase !important; } /* 설정 체크박스 레이아웃 */ .cp-setting-row { //display: flex; gap: 0.5rem; margin: 0.5rem 0; font-size: 0.8rem; } .cp-setting-label { flex: 1; display: flex; align-items: center; gap: 0.2rem; } /* 백그라운드 색설정 */ .cp-bg { background-color: rgba(173, 216, 230, 0.15) !important; } /* 채팅 리프레쉬 버튼 스타일 */ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `); function showCombinedPanel() { if (document.getElementById('cp-settings-overlay')) return; // overlay & panel 기본 구조 재사용 const overlay = document.createElement('div'); overlay.id = 'cp-settings-overlay'; const panel = document.createElement('div'); panel.id = 'cp-settings-panel'; // 현재 저장된 값 불러오기 const curStreamers = GM_getValue('streamer', DEFAULTS.streamer).join(', '); const curExceptions= GM_getValue('exception', DEFAULTS.exception).join(', '); panel.innerHTML = ` <h3>강조/제외 닉네임 설정</h3> <label>연두색으로 강조할 닉네임 (콤마로 구분 //파트너 기본 지원):</label> <textarea id="cp-streamer-input">${curStreamers}</textarea> <label>배경색 강조 제외할 닉네임 (콤마로 구분 //매니저 봇등):</label> <textarea id="cp-exception-input">${curExceptions}</textarea> <label><h4>유틸 기능 (온/오프)------------------------------------------------------</h4></label> <div class="cp-setting-row"> <label class="cp-setting-label"> <input type="checkbox" id="cp-fix-unread" ${ENABLE_FIX_UNREADABLE_COLOR ? 'checked' : ''}> 투명 닉네임 제거</label> <label class="cp-setting-label"> <input type="checkbox" id="cp-remove-hl" ${ENABLE_REMOVE_BG_COLOR ? 'checked' : ''}> 형광펜 제거)</label> <label class="cp-setting-label"> <input type="checkbox" id="cp-truncate" ${ENABLE_TRUNCATE_NICKNAME ? 'checked' : ''}> 길이 제한 (최대:10자)</label> </div> <div class="cp-setting-row"> <label class="cp-setting-label"> <input type="checkbox" id="cp-drops-toggle" ${ENABLE_DROPS_TOGGLE ? 'checked' : ''}> 드롭스 토글 기능</label> <label class="cp-setting-label"> <input type="checkbox" id="cp-mission-hover" ${ENABLE_MISSION_HOVER ? 'checked' : ''}> 고정 댓글, 미션 자동 펼치고 접기 <br>(처음 펼침, 마우스 지나가면 접힘)</label> </div> <label><h4>-----------------------------------------------------------------------------</h4></label> <label><h5>추가기능: 키보드 " ] " 버튼을 눌러 채팅창을 접고 펼칠 수 있습니다.</h4></label> <label><h5>추가기능: 채팅 입력창 옆에 새로고침 버튼으로 채팅창만 새로고침 가능합니다.</h4></label> <div> <button id="cp-save-btn">저장</button> <button id="cp-cancel-btn">취소</button> </div> <div style="font-size:0.75rem; text-align:right; margin-top:0.5rem;"> Enter ↵: 저장 Esc : 취소 (저장시 새로고침 및 적용) </div> `; overlay.appendChild(panel); document.body.appendChild(overlay); panel.setAttribute('tabindex', '0'); panel.focus(); panel.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); panel.querySelector('#cp-save-btn').click(); } else if (e.key === 'Escape') { e.preventDefault(); panel.querySelector('#cp-cancel-btn').click(); } }); panel.querySelector('#cp-save-btn').addEventListener('click', () => { const s = panel.querySelector('#cp-streamer-input').value; const e = panel.querySelector('#cp-exception-input').value; const fixUnread = panel.querySelector('#cp-fix-unread').checked; const removeHl = panel.querySelector('#cp-remove-hl').checked; const truncateName = panel.querySelector('#cp-truncate').checked; const dropsToggleVal = panel.querySelector('#cp-drops-toggle').checked; GM_setValue('streamer', Array.from(new Set(s.split(',').map(x=>x.trim()).filter(x=>x))) ); GM_setValue('exception', Array.from(new Set(e.split(',').map(x=>x.trim()).filter(x=>x))) ); GM_setValue('fixUnreadable', fixUnread); GM_setValue('removeHighlight', removeHl); GM_setValue('truncateName', truncateName); GM_setValue('dropsToggle', dropsToggleVal); GM_setValue('missionHover', document.querySelector('#cp-mission-hover').checked); document.body.removeChild(overlay); location.reload(); }); panel.querySelector('#cp-cancel-btn').addEventListener('click', () => { document.body.removeChild(overlay); }); } // 유틸: 닉네임 색상이 너무 어두운 경우 스타일 제거 function fixUnreadableNicknameColor(nicknameElem) { if (!nicknameElem) return; // 하이라이트 색상은 검사 제외 const cssColor = window.getComputedStyle(nicknameElem).color; if (cssColor === LIGHT_GREEN) return; // 캐시 검사 (이미 검사한 값 제외)미 if (colorCache.has(cssColor)) { if (colorCache.get(cssColor) === false) { nicknameElem.style.color = ''; } return; } // 밝기 계산 로직 const rgbaMatch = cssColor.match(/rgba?\((\d+), ?(\d+), ?(\d+)(?:, ?([0-9.]+))?\)/); if (!rgbaMatch) return; const r = parseInt(rgbaMatch[1], 10); const g = parseInt(rgbaMatch[2], 10); const b = parseInt(rgbaMatch[3], 10); const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1; const brightness = (r * 299 + g * 587 + b * 114) / 1000; const visibility = brightness * a; if (visibility < 50) nicknameElem.style.color = ''; colorCache.set(cssColor, visibility >= 50); } // 유틸: 닉네임 배경 제거 function removeBackgroundColor(nicknameElem) { if (!nicknameElem) return; const bgTarget = nicknameElem.querySelector('[style*="background-color"]'); if (bgTarget) bgTarget.style.removeProperty('background-color'); } // 유틸: 닉네임 자르기 function truncateNickname(nicknameElem, maxLen = 10) { if (!nicknameElem) return; const textSpan = nicknameElem.querySelector('.name_text__yQG50'); if (!textSpan) return; const fullText = textSpan.textContent; if (fullText.length >= 13) { textSpan.textContent = fullText.slice(0, maxLen) + '...'; } } // 채팅 메시지 처리 function processChatMessage(messageElem) { if (messageElem.getAttribute('data-partner-processed') === 'true') return; const isPartner = !!messageElem.querySelector('[class*="name_icon__zdbVH"]'); const badgeImg = messageElem.querySelector('.badge_container__a64XB img[src*="manager.png"], .badge_container__a64XB img[src*="streamer.png"]'); const isManager = badgeImg?.src.includes('manager.png'); const isStreamer = badgeImg?.src.includes('streamer.png'); const nicknameElem = messageElem.querySelector('.live_chatting_username_nickname__dDbbj'); const textElem = messageElem.querySelector('.live_chatting_message_text__DyleH'); if (ENABLE_FIX_UNREADABLE_COLOR) fixUnreadableNicknameColor(nicknameElem); if (ENABLE_REMOVE_BG_COLOR) removeBackgroundColor(nicknameElem); if (ENABLE_TRUNCATE_NICKNAME) truncateNickname(nicknameElem); const nameText = nicknameElem?.querySelector('.name_text__yQG50')?.textContent.trim() || ''; const isManualStreamer = streamer.includes(nameText); // 연두색 스타일 if ((!isManager && !isStreamer) && (isPartner || isManualStreamer)) { nicknameElem && nicknameElem.classList.add('cp-highlight'); textElem && textElem.classList.add('cp-highlight'); } // 배경 강조 if ((isPartner || isStreamer || isManager || isManualStreamer) && !exception.includes(nameText)) { messageElem.classList.add('cp-bg'); } messageElem.setAttribute('data-partner-processed', 'true'); } // 채팅 옵저버 설정 function setupChatObserver() { if (chatObserver) chatObserver.disconnect(); const chatContainer = document.querySelector('[class*="live_chatting_list_wrapper__"], [class*="vod_chatting_list__"]'); if (!chatContainer) return setTimeout(setupChatObserver, 500); chatContainer.querySelectorAll('[class^="live_chatting_message_chatting_message__"]').forEach(processChatMessage); chatObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType !== 1) return; if (node.className.includes('live_chatting_message_chatting_message__')) { pendingNodes.push(node); } else { node.querySelectorAll('[class^="live_chatting_message_chatting_message__"]') .forEach(n => pendingNodes.push(n)); } }); }); scheduleProcess(); }); chatObserver.observe(chatContainer, { childList: true, subtree: false }); } // 미션창 + 고정 채팅 자동 접고 펼치기 (영역 클릭하여 접고 펼치기 유지) function setupMissionHover(retry = 0) { // 1) 미션창 wrapper const fixedWrapper = document.querySelector('.live_chatting_list_fixed__Wy3TT'); if (!fixedWrapper) { if (retry < 10) { return setTimeout(() => setupMissionHover(retry + 1), 500); } return; } // 2) 토글 버튼을 찾아주는 유틸 const getButtons = () => { const missionBtn = fixedWrapper.querySelector('.live_chatting_fixed_mission_folded_button__bBWS2'); const chatContainer = document.querySelector('.live_chatting_fixed_container__2tQz6'); const chatBtn = chatContainer ?.querySelector('.live_chatting_fixed_control__FCHpN button:not([aria-haspopup])'); return { missionBtn, chatContainer, chatBtn }; }; // 3) 모두 펼치기 const openAll = () => { const { missionBtn, chatBtn } = getButtons(); if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'false') { missionBtn.click(); } if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'false') { chatBtn.click(); } }; // 4) 모두 접기 const closeAll = () => { const { missionBtn, chatBtn } = getButtons(); if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'true') { missionBtn.click(); } if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'true') { chatBtn.click(); } }; // 5) 초기에는 무조건 펼친 상태로 openAll(); // 6) 한 번만 바인딩 if (fixedWrapper._missionHoverBound) return; fixedWrapper._missionHoverBound = true; // --- 클릭 플래그 초기화 (짝수: 닫힌 상태, 홀수: 열린 상태) --- const clickState = { chat: 0, // 채팅 영역 클릭 횟수 mission: 0 // 미션 영역 클릭 횟수 }; // 7) 클릭 영역을 확대: fixedWrapper 내부 클릭 시 '미션 영역'으로, chatContainer 내부 클릭 시 '채팅 영역'으로 인식 fixedWrapper.addEventListener('click', (e) => { if (!e.isTrusted) return; // 프로그램적 클릭 제외 const { chatContainer } = getButtons(); // (2-2~2-4 처리 위한 플래그 토글) if (chatContainer && chatContainer.contains(e.target)) { // 채팅 영역 내부 클릭 clickState.chat += 1; } else { // fixedWrapper 내부이지만 chatContainer 외부 => 미션 영역 클릭 clickState.mission += 1; } }); // 8) 마우스 들어오면 모두 펼치기 fixedWrapper.addEventListener('pointerenter', () => { openAll(); }); // 9) 마우스 나가면 클릭 플래그에 따라 상태 유지 또는 접기 fixedWrapper.addEventListener('pointerleave', () => { const { missionBtn, chatContainer, chatBtn } = getButtons(); const chatClickedOdd = (clickState.chat % 2) === 1; const missionClickedOdd = (clickState.mission % 2) === 1; // 2-1. 클릭 없이 단순히 지나간 경우 (둘 다 닫기) if (!chatClickedOdd && !missionClickedOdd) { closeAll(); return; } // 2-2. 채팅만 홀수번 클릭한 경우: 채팅 열리고, 미션 닫힘 if (chatClickedOdd && !missionClickedOdd) { if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'false') { chatBtn.click(); } if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'true') { missionBtn.click(); } return; } // 2-3. 미션만 홀수번 클릭한 경우: 미션 열리고, 채팅 닫힘 if (!chatClickedOdd && missionClickedOdd) { if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'false') { missionBtn.click(); } if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'true') { chatBtn.click(); } return; } // 2-4. 양쪽 모두 홀수번 클릭한 경우: 둘 다 열기 if (chatClickedOdd && missionClickedOdd) { if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'false') { missionBtn.click(); } if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'false') { chatBtn.click(); } } }); } // ▽ 드롭스 토글용 CSS GM_addStyle(` #drops_info.drops-collapsed .live_information_drops_wrapper__gQBUq, #drops_info.drops-collapsed .live_information_drops_text__xRtWS, #drops_info.drops-collapsed .live_information_drops_default__jwWot, #drops_info.drops-collapsed .live_information_drops_area__7VJJr { display: none !important; } .live_information_drops_icon_drops__2YXie { transition: transform .2s; } #drops_info.drops-collapsed .live_information_drops_icon_drops__2YXie { transform: rotate(-90deg); } .live_information_drops_toggle_icon { margin-left: 10px; font-size: 18px; cursor: pointer; display: inline-block; } `); // === 키입력 ] 을 통해 채팅 접고 펼치기 === function closeChat() { const btn = document.querySelector('.live_chatting_header_button__t2pa1'); if (btn) { btn.click(); } else { console.warn('채팅 접기 버튼을 찾을 수 없습니다.'); } } function openChat() { const btn = document .querySelector('svg[viewBox="0 0 38 34"]') ?.closest('button'); if (btn) { btn.click(); } else { console.warn('기본 채팅 토글 버튼을 찾을 수 없습니다.'); } } function onKeydown(e) { const tag = e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return; if (e.key === ']') { if (isChatOpen) { closeChat(); isChatOpen = false; } else { openChat(); isChatOpen = true; } } } window.addEventListener('keydown', onKeydown); function initDropsToggle() { const container = document.getElementById('drops_info'); if (!container || container.classList.contains('drops-init')) return; const header = container.querySelector('.live_information_drops_header__920BX'); if (!header) return; // 마크 표시 및 초기 숨김 상태 const toggleIcon = document.createElement('span'); toggleIcon.classList.add('live_information_drops_toggle_icon'); toggleIcon.textContent = '▼'; header.appendChild(toggleIcon); header.style.cursor = 'pointer'; container.classList.add('drops-collapsed'); container.classList.add('drops-init'); header.addEventListener('click', () => { const collapsed = container.classList.toggle('drops-collapsed'); toggleIcon.textContent = collapsed ? '▼' : '▲'; }); } function setupDropsToggleObserver() { initDropsToggle(); const obs = new MutationObserver(() => { initDropsToggle(); }); obs.observe(document.body, { childList: true, subtree: true }); } // === 채팅 리프레쉬 기능 === // 채팅 리프레쉬 버튼 생성 function createRefreshButton() { const button = document.createElement('button'); button.className = 'button_container__ppWwB button_only_icon__kahz5 button_not_disabled_style__+f4-T'; button.type = 'button'; button.title = '채팅 새로고침'; button.style.cssText = ` width: 28px; height: 28px; margin-right: 8px; background: transparent; border: none; cursor: pointer; border-radius: 4px; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; `; // 새로고침 아이콘 SVG button.innerHTML = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C9.25022 4 6.82447 5.38734 5.38451 7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 7.5L5.38451 7.5L5.38451 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> `; // 호버 효과 button.addEventListener('mouseenter', () => { if (!button.disabled) { button.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; } }); button.addEventListener('mouseleave', () => { button.style.backgroundColor = 'transparent'; }); // 클릭 이벤트 button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); refreshChatAndReinit(); }); return button; } // 버튼 비활성화 (로딩 상태) function disableRefreshButton() { if (refreshButton) { refreshButton.disabled = true; refreshButton.style.opacity = '0.5'; refreshButton.style.pointerEvents = 'none'; const svg = refreshButton.querySelector('svg'); if (svg) { svg.style.animation = 'spin 1s linear infinite'; } } } // 버튼 활성화 (정상 상태) function enableRefreshButton() { if (refreshButton) { refreshButton.disabled = false; refreshButton.style.opacity = '1'; refreshButton.style.pointerEvents = 'auto'; const svg = refreshButton.querySelector('svg'); if (svg) { svg.style.animation = ''; } } } // 채팅 리프레쉬 및 재초기화 기능 function refreshChatAndReinit() { console.log('채팅 새로고침 및 재초기화 시작'); disableRefreshButton(); if (clickMoreMenuButton()) { clickChatPopupButton(); } else { console.log('더보기 메뉴 버튼을 찾을 수 없음'); // 실패 시 2초 후 버튼 복원 setTimeout(enableRefreshButton, 2000); } } // UI 버튼을 채팅 입력창에 추가 function addRefreshButtonToUI() { const chatInputContainer = document.querySelector('.live_chatting_input_tools__OPA1R'); const sendButton = document.querySelector('#send_chat_or_donate'); if (chatInputContainer && sendButton && !refreshButton) { refreshButton = createRefreshButton(); chatInputContainer.insertBefore(refreshButton, sendButton); console.log('새로고침 버튼이 UI에 추가됨'); } } // 더보기 메뉴 버튼 클릭 function clickMoreMenuButton() { const moreButton = document.querySelector('button.live_chatting_header_button__t2pa1[aria-label="더보기 메뉴"]'); if (moreButton) { moreButton.click(); console.log('더보기 메뉴 클릭됨'); return true; } return false; } // 채팅창 팝업 버튼 클릭 function clickChatPopupButton() { let attempts = 0; const maxAttempts = 20; const findPopupButton = setInterval(() => { attempts++; console.log(`채팅창 팝업 버튼 찾는 중... (${attempts}/${maxAttempts})`); const popupButtons = document.querySelectorAll('button.layer_button__fFPB8'); for (let popupButton of popupButtons) { const spans = popupButton.querySelectorAll('span'); for (let span of spans) { if (span.textContent.includes('채팅창 팝업')) { console.log('채팅창 팝업 버튼 찾음, 클릭 시도'); popupButton.click(); console.log('채팅창 팝업 버튼 클릭됨'); clearInterval(findPopupButton); setTimeout(() => { findAndClickChatViewButton(); }, 300); return; } } } if (attempts >= maxAttempts) { console.log('채팅창 팝업 버튼을 찾을 수 없음'); clearInterval(findPopupButton); setTimeout(enableRefreshButton, 2000); } }, 100); } // 채팅보기 버튼 찾아서 클릭 및 재초기화 function findAndClickChatViewButton() { console.log('채팅보기 버튼 찾기 시작'); let attempts = 0; const maxAttempts = 50; const findChatViewButton = setInterval(() => { attempts++; console.log(`채팅보기 버튼 찾는 중... (${attempts}/${maxAttempts})`); const chatViewButtons = document.querySelectorAll('button.no_content_button__fFsAz'); for (let button of chatViewButtons) { if (button.textContent.includes('채팅보기')) { console.log('메인 창에서 채팅보기 버튼 발견 - 즉시 클릭'); button.click(); console.log('채팅보기 버튼 클릭됨 (채팅 새로고침 완료)'); clearInterval(findChatViewButton); // 채팅 새로고침 후 모든 기능 재초기화 setTimeout(() => { console.log('채팅 리프레쉬 후 재초기화 시작'); // 설정값 새로 불러오기 streamer = GM_getValue('streamer', DEFAULTS.streamer); exception = GM_getValue('exception', DEFAULTS.exception); // 모든 기능 재설정 setupChatObserver(); if (ENABLE_MISSION_HOVER) setupMissionHover(); if (ENABLE_DROPS_TOGGLE) setupDropsToggleObserver(); initKnifeTracker(KNIFE_CONFIG); // 리프레쉬 버튼 재추가 (기존 버튼이 사라질 수 있음) refreshButton = null; setTimeout(addRefreshButtonToUI, 200); console.log('채팅 리프레쉬 후 재초기화 완료'); enableRefreshButton(); }, 1000); return; } } if (attempts >= maxAttempts) { console.log('채팅보기 버튼을 찾을 수 없음'); clearInterval(findChatViewButton); setTimeout(enableRefreshButton, 2000); } }, 50); } // DOM 변화 감지하여 리프레쉬 버튼 추가 (SPA 대응) function observeForRefreshButton() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { // 채팅 입력창이 나타나면 버튼 추가 중복으로 버튼이 생겨서 제외처리함 if (document.querySelector('.live_chatting_input_tools__OPA1R') && !refreshButton) { //setTimeout(addRefreshButtonToUI, 100); } // 버튼이 사라진 경우 재생성 if (refreshButton && !document.body.contains(refreshButton)) { console.log('리프레쉬 버튼이 사라져서 재생성'); refreshButton = null; setTimeout(addRefreshButtonToUI, 100); } } }); }); observer.observe(document.body, { childList: true, subtree: true }); } function setupSPADetection() { let lastUrl = location.href; const onUrlChange = () => { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(() => { setupChatObserver(); if (ENABLE_MISSION_HOVER) setupMissionHover(); if (ENABLE_DROPS_TOGGLE) setupDropsToggleObserver(); initKnifeTracker(KNIFE_CONFIG); // SPA 페이지 변경 시 리프레쉬 버튼도 재추가 refreshButton = null; setTimeout(addRefreshButtonToUI, 300); }, 500); } }; ['pushState', 'replaceState'].forEach(method => { const orig = history[method]; history[method] = function(...args) { orig.apply(this, args); onUrlChange(); }; }); window.addEventListener('popstate', onUrlChange); } // ==== chzzk_knife_tracker 함수화 버전 ==== // /** * 채팅창 상단에 주요 유저 메시지만 모아 보여주는 기능을 초기화합니다. * @param {Object} options * @param {string} options.chatContainerSelector – 채팅 컨테이너 셀렉터 * @param {string} options.chatListSelector – 채팅 리스트 셀렉터 * @param {number} options.maxMessages – 보관할 최대 메시지 개수 * @param {string[]} options.defaultStreamers – 기본 스트리머 닉네임 목록 * @param {string[]} options.defaultExceptions – 기본 예외 닉네임 목록 */ // ==== chzzk_knife_tracker 함수화 버전 (수정됨) ==== // /** * 채팅창 상단에 주요 유저 메시지만 모아 보여주는 기능을 초기화합니다. * @param {Object} options * @param {string} options.chatContainerSelector – 채팅 컨테이너 셀렉터 * @param {string} options.chatListSelector – 채팅 리스트 셀렉터 * @param {number} options.maxMessages – 보관할 최대 메시지 개수 * @param {string[]} options.defaultStreamers – 기본 스트리머 닉네임 목록 * @param {string[]} options.defaultExceptions – 기본 예외 닉네임 목록 */ function initKnifeTracker({ chatContainerSelector, chatListSelector, maxMessages = 100, defaultStreamers = [], defaultExceptions = [], }) { const styleId = 'knifeTracker'; const filteredMessages = []; let knifeObserver = null; // — GM 저장소에서 실제 설정 불러오기 — const manualStreamers = GM_getValue('streamer', defaultStreamers); const exceptions = GM_getValue('exception', defaultExceptions); // 1) 스타일 주입 (수정됨: column-reverse 제거) const css = ` #filtered-chat-box { display: flex; flex-direction: column; height: 70px; overflow-y: auto; padding: 8px 8px 0 8px; margin: 0; border-bottom: 2px solid #444; border-radius: 0 0 6px 6px; background-color: rgba(30, 30, 30, 0.8); scrollbar-width: none; resize: vertical; min-height: 38px; max-height: 350px; position: relative; } .live_chatting_list_wrapper__a5XTV, .live_chatting_list_container__vwsbZ { margin-top: 0 !important; padding-top: 0 !important; } .live_chatting_list_fixed__Wy3TT { top: 0 !important; } `; function injectStyles() { if (document.head.querySelector(`#${styleId}`)) return; const s = document.createElement('style'); s.id = styleId; s.textContent = css; document.head.appendChild(s); } function shouldTrackUser(node) { // 1) 닉네임 텍스트 const nicknameElem = node.querySelector('.live_chatting_username_nickname__dDbbj'); const nameText = nicknameElem ?.querySelector('.name_text__yQG50') ?.textContent.trim() || ''; // 2) 파트너 아이콘 (원본과 동일한 클래스) const isPartner = !!node.querySelector('[class*="name_icon__zdbVH"]'); // 3) 매니저/스트리머 뱃지 (원본과 동일한 selector) const badgeImg = node.querySelector( '.badge_container__a64XB img[src*="manager.png"], ' + '.badge_container__a64XB img[src*="streamer.png"]' ); const isManager = badgeImg?.src.includes('manager.png'); const isStreamer = badgeImg?.src.includes('streamer.png'); // 4) 수동 지정 스트리머 const isManualStreamer = manualStreamers.includes(nameText); // 5) 예외 닉네임 const isException = exceptions.includes(nameText); // — 원본 CP-bg 조건과 동일하게 — return !isException && ( isPartner || isStreamer || isManager || isManualStreamer ); } // 3) 박스 만들기 (수정됨) function createFilteredBox() { const container = document.querySelector(chatContainerSelector); if (!container || document.getElementById('filtered-chat-box')) return; const box = document.createElement('div'); box.id = 'filtered-chat-box'; container.parentElement.insertBefore(box, container); injectStyles(); // 기존 메시지들을 시간순으로 표시 (최신 것부터 위에) filteredMessages.forEach(m => { const clone = m.cloneNode(true); resizeVerificationMark(clone); box.appendChild(clone); }); // 자동 스크롤을 맨 위로 (최신 메시지가 보이도록) box.scrollTop = 0; } // 4) 새 메시지 감시 (수정됨) const collectedMessages = new Set(); let lastKnownMessageCount = 0; function observeNewMessages() { const list = document.querySelector(chatListSelector); if (!list) return; // 초기 메시지 개수 저장 lastKnownMessageCount = list.children.length; if (knifeObserver) knifeObserver.disconnect(); knifeObserver = new MutationObserver(mutations => { mutations.forEach(m => { for (const node of m.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (!node.matches('.live_chatting_list_item__0SGhw')) continue; const nickname = node.querySelector('.name_text__yQG50')?.textContent?.trim() || ''; const message = node.querySelector('.live_chatting_message_chatting_message__7TKns')?.textContent?.trim() || ''; const key = `${nickname}:${message}`; if (collectedMessages.has(key)) continue; collectedMessages.add(key); if (node._knifeProcessed) continue; node._knifeProcessed = true; if (!node.querySelector('[class^="live_chatting_message_container__"]')) continue; if (!shouldTrackUser(node)) continue; const box = document.getElementById('filtered-chat-box'); if (!box) return; const clone = node.cloneNode(true); replaceBlockWithInline(clone); resizeVerificationMark(clone); // 새로운 메시지가 DOM의 어느 위치에 추가되었는지 확인 const chatList = document.querySelector(chatListSelector); const currentMessageCount = chatList.children.length; const nodeIndex = Array.from(chatList.children).indexOf(node); // 스크롤로 인해 맨 위에 추가된 과거 메시지인지 판단 // (전체 메시지 수가 증가했지만 새 메시지가 맨 위쪽에 위치한 경우) const isScrollLoadedMessage = nodeIndex < Math.min(10, lastKnownMessageCount); if (isScrollLoadedMessage) { // 스크롤로 로드된 과거 메시지는 맨 아래에 추가 box.appendChild(clone); filteredMessages.push(clone); // 개수 제한 (맨 위에서 제거) if (filteredMessages.length > maxMessages) { const removed = filteredMessages.shift(); const firstChild = box.firstChild; if (firstChild) box.removeChild(firstChild); } } else { // 실시간 메시지는 맨 위에 추가 box.insertBefore(clone, box.firstChild); filteredMessages.unshift(clone); // 개수 제한 (맨 아래에서 제거) if (filteredMessages.length > maxMessages) { const removed = filteredMessages.pop(); const lastChild = box.lastChild; if (lastChild) box.removeChild(lastChild); } // 새로 추가된 메시지로 포커스/스크롤 이동 (최신 메시지가 보이도록) try { // 방법 1: 새로 추가된 요소로 스크롤 clone.scrollIntoView({ behavior: 'instant', block: 'start' }); // 방법 2: 강제 스크롤 (즉시 실행) box.scrollTop = 0; box.scrollTo(0, 0); // 방법 3: DOM 업데이트 후 스크롤 setTimeout(() => { box.scrollTop = 0; box.scrollTo(0, 0); }, 0); // 방법 4: requestAnimationFrame을 사용한 정확한 타이밍 스크롤 requestAnimationFrame(() => { box.scrollTop = 0; box.scrollTo(0, 0); }); // 방법 5: 임시 포커스와 스크롤 setTimeout(() => { if (clone.tabIndex === undefined) clone.tabIndex = -1; clone.focus(); clone.blur(); box.scrollTop = 0; }, 50); // 방법 6: 마지막으로 스크롤 강제 적용 setTimeout(() => { box.scrollTop = 0; box.scrollTo(0, 0); }, 100); // 방법 7: 스크롤 이벤트 리스너 추가 const observer = new MutationObserver(() => { box.scrollTop = 0; box.scrollTo(0, 0); }); observer.observe(box, { childList: true }); // 메시지가 완전히 추가된 후에 스크롤 이벤트 리스너 제거 setTimeout(() => { observer.disconnect(); // 마지막으로 스크롤 위치 강제 설정 box.scrollTop = box.scrollHeight; box.scrollTo(0, box.scrollHeight); }, 200); } catch (e) { console.error('스크롤 처리 중 오류:', e); console.log('스크롤 이동 실패:', e); } } // 메시지 개수 업데이트 lastKnownMessageCount = currentMessageCount; // 스크롤 위치를 최신 메시지로 강제 설정 setTimeout(() => { const box = document.getElementById('filtered-chat-box'); if (box) { // 스크롤 위치를 마지막 메시지로 이동 const lastMessage = box.lastChild; if (lastMessage) { lastMessage.scrollIntoView({ behavior: 'instant', block: 'start' }); } box.scrollTop = box.scrollHeight; box.scrollTo(0, box.scrollHeight); } }, 0); } }); }); knifeObserver.observe(list, { childList: true, subtree: true }); } // 페이지 로드 시 기존 채팅 메시지들을 처리하는 함수 (완전 수정) function processExistingMessages() { const list = document.querySelector(chatListSelector); if (!list) return; const existingMessages = Array.from(list.querySelectorAll('.live_chatting_list_item__0SGhw')); // 기존 메시지들을 최신순으로 처리 (최신 것부터) existingMessages.reverse().forEach(node => { if (node._knifeProcessed) return; node._knifeProcessed = true; if (!node.querySelector('[class^="live_chatting_message_container__"]')) return; if (!shouldTrackUser(node)) return; const clone = node.cloneNode(true); replaceBlockWithInline(clone); resizeVerificationMark(clone); // 기존 메시지들은 최신순으로 배열에 추가 filteredMessages.unshift(clone); if (filteredMessages.length > maxMessages) filteredMessages.pop(); }); } function replaceBlockWithInline(node) { const messageElement = node.querySelector('.live_chatting_message_chatting_message__7TKns'); if (!messageElement || messageElement.tagName !== 'DIV') return; const span = document.createElement('span'); span.className = messageElement.className; span.innerHTML = messageElement.innerHTML; span.style.paddingLeft = '0px'; messageElement.replaceWith(span); } // 복사된 메시지에서 .blind(인증마크) 폰트 크기 등 조정 function resizeVerificationMark(node) { // 인증마크 (.blind) 뿐만 아니라 다른 아이콘들도 처리 const verified = node.querySelector('.live_chatting_username_nickname__dDbbj .blind'); if (verified) { // 폰트 크기 줄이고 위치·투명도 조절 verified.style.fontSize = '10px'; verified.style.lineHeight = '1'; verified.style.verticalAlign = 'middle'; verified.style.marginLeft = '4px'; verified.style.opacity = '0.8'; } // name_icon__zdbVH 클래스를 가진 모든 아이콘들 크기 조정 const nameIcons = node.querySelectorAll('[class*="name_icon__zdbVH"]'); nameIcons.forEach(icon => { icon.style.width = '14px'; icon.style.height = '14px'; icon.style.marginTop = '1px'; // 배경 이미지가 있는 경우 backgroundSize도 조정 if (icon.style.backgroundImage) { icon.style.backgroundSize = '14px 14px'; } }); // 뱃지 이미지들도 크기 조정 const badgeImages = node.querySelectorAll('.badge_container__a64XB img'); badgeImages.forEach(img => { img.style.width = '14px'; img.style.height = '14px'; img.style.marginRight = '2px'; }); } // 5) 채팅 준비 완료 후 초기화 function waitForChatThenInit() { const obs = new MutationObserver((_, o) => { const c = document.querySelector(chatContainerSelector); const l = document.querySelector(chatListSelector); if (c && l) { o.disconnect(); injectStyles(); processExistingMessages(); // 기존 메시지 처리 먼저 실행 createFilteredBox(); observeNewMessages(); } }); obs.observe(document.body, { childList: true, subtree: true }); } waitForChatThenInit(); } // 설정 메뉴 추가 GM_registerMenuCommand("⚙️ Chzzk: Chatting Plus 설정 변경", showCombinedPanel); // 초기화 function init() { setupChatObserver(); setupSPADetection(); initKnifeTracker(KNIFE_CONFIG); if (ENABLE_MISSION_HOVER) setupMissionHover(); if (ENABLE_DROPS_TOGGLE) setupDropsToggleObserver(); // 리프레쉬 버튼 관련 초기화 observeForRefreshButton(); setTimeout(addRefreshButtonToUI, 1000); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();