Chzzk_L&V: Chatting Plus

파트너·지정 스트리머 채팅 강조 / 닉네임 각종 설정 / 드롭스 접고 펼치기 / 고정댓글, 미션 자동 제어 / 채팅창 접고 펼치기 단축키( ] ) / 채팅 새로고침 버튼

安裝腳本?
作者推薦腳本

您可能也會喜歡 Chzzk_Clips: Unblock & Unmute

安裝腳本
// ==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();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址