// ==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();
})();