CHZZK Chat Blocker

채팅 호버 시 🚫 클릭으로 즉시 차단. 무제한 차단 목록 로컬 저장. 버튼 드래그 이동 가능. 필터링 횟수 표시. 노체 말투 차단(ON/OFF). 키워드 차단. 영구차단 통합 토글.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Advertisement:

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

Advertisement:

// ==UserScript==
// @name         CHZZK Chat Blocker
// @namespace    chzzk-chat-blocker
// @version      1.4.2
// @description  채팅 호버 시 🚫 클릭으로 즉시 차단. 무제한 차단 목록 로컬 저장. 버튼 드래그 이동 가능. 필터링 횟수 표시. 노체 말투 차단(ON/OFF). 키워드 차단. 영구차단 통합 토글.
// @match        https://chzzk.naver.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const SEL = {
        MSG_ITEM:  '[class*="_chatting_message_"]',
        USERNAME:  'button[class*="_nickname_"]',
        NICK_BTN:  'button[class*="_nickname_"]',
        CHAT_LIST: 'aside[class*="_container_1qgfi"]',
        MSG_TEXT:  '[class*="_text_"]',
    };

// --------------------------------------------------------------------------------------
// 노체 필터 시작 (Noche Filter Start)
// --------------------------------------------------------------------------------------
const NOCHE_STEMS = [
    // ── 1. 최빈출 / 대명사 / 의문사 / 어미 ────────────────────────
    '뭐','머','거','되','없','않','많','아니','하','있','잇','밌','았','었','겠','겟','것','긋','드','켜','키','도','세','개','주','이',
    '어디','누구','머라','뭐라','머래','뭐래','실화','맞','졌',
    '앗','엇','햇','왓','갓','봣','졋','줫','삿','쳣','같','놧',
    '였','엿','겼','겻','꼈','꼇','켰','켯','텼','텻','렸','렷','셨','셧','녔','녀','몄','녓','볐','볏','폈','폇',
    '랐','랏','렀','럿','웠','웟','됐','됏','댓','냈','냇','랬','렜',

    // ── 2. 일상 동작 및 상태 ──────────────────────────────────
    '보','가','오','사','싸','쏘','빠','먹','묵','놓','넣','나','빼','비','쉽','렵','럽','겁','볍','갑','살리','때리','대',
    '춥','덥','엽','겹','아프','슬프','기쁘','바쁘','나쁘','우','웃','자','쉬','지','잡','받','막','죽','눕','쓰','뛰','뜨','뜯','크','작','적','기','느리','빠르',
    '좁','넓','어둡','밝','까맣','하얗','빨갛','노랗','파랗','퍼렇','허옇','꺼멓','높','낮','짧','맵','짜','떠','지리','밀리',
    '좋','싫','싶','어떻','어쩌','낫','났','봤','했','갔','왔','쳤','줬','샀','놨','맞추','춤추','멈추','감추','낮추','늦추','비추','들추',
    '저러','이러','그러','예쁘','이쁘','어딨','어딧','다니','찍','치','띠껍','버리','뿌리','거리','올리','내리','돌리','들리','흘리','열리','뚫리','풀리','잘리','털리','팔리','딸리',

    // ── 3. 슬랭 / 감정 표현 ──────
    '선넘',
    '나대','씨부리','부들대','징징대',
    '모르','못참','알아듣',
    '소름돋',
    '기가차','버티','웃프',
    '무섭','귀찮','꼬시','숩',
    '음','읍',
    '코메디'
];

const NOCHE = new RegExp(
    `(?:(?:${NOCHE_STEMS.join('|')})노(?:이기이기|이기야|이기)?)(?![a-zA-Z0-9가-힣])`
);

function isNoche(text) {
    const normalized = text.normalize('NFC').replace(/\s+/g, ' ').trim();
    
    // 1. 외래어 및 띄어쓰기 오탐 패턴 치환
    const cleanText = normalized
        .replace(/보노보노/g, '보노보_')
        .replace(/프로보노/g, '프로보_')
        .replace(/테크노/g, '테크_')
        .replace(/오레가노/g, '오레가_')
        .replace(/니나노/g, '니나_')
        .replace(/시나노/g, '시나_')
        .replace(/아키노/g, '아키_')
        .replace(/키노라이츠/g, '키노라이_')
        .replace(/도노도노/g, '도노도_')
        .replace(/((?:^|[^가-힣]))나노/g, '$1나_')
        .replace(/빈지노/g, '빈지_')
        .replace(/카지노/g, '카지_')
        .replace(/알비노/g, '알비_')
        .replace(/밤비노/g, '밤비_')
        .replace(/비비노\s*(?:앱|평점|어플|사이트|검색|와인|등급|포인트)/g, '비비_')
        .replace(/와인\s*비비노/g, '와인_비비_')
        .replace(/푸치노/g, '푸치_')
        .replace(/파치노/g, '파치_')
        .replace(/치노\s*(?:팬츠|바지|셔츠|룩|핏|아웃핏|코디|원단)/g, '치_')
        .replace(/([^가-힣0-9\s]|^)이노(?![가-힣])/g, '$1이_')
        .replace(/아두이노/g, '아두_')
        .replace(/다이노/g, '다이_')
        .replace(/세이노/g, '세이_')
        .replace(/마이노/g, '마이_')
        .replace(/레이노/g, '레이_')
        .replace(/(^|[^가-힣]|극)대노/g, '$1대_');
    return NOCHE.test(cleanText);
}
// --------------------------------------------------------------------------------------
// 노체 필터 끝 (Noche Filter End)
// --------------------------------------------------------------------------------------




    // ==========================================
    // [모듈 1] Storage
    // ==========================================
    const Storage = {
        KEY: 'chzzk_blocked_users_v1',
        POS_KEY: 'chzzk_blocker_btn_pos',
        KW_KEY: 'chzzk_keyword_filter_v1',
        _set: new Set(),
        _arr: [],
        _kwArr: [],
        _kwSet: new Set(),
        _globalBan: true,
        _nocheEnabled: true,
        _saveTimer: null,

        load() {
            try {
                const raw = localStorage.getItem(this.KEY);
                this._arr = raw ? JSON.parse(raw) : [];
            } catch { this._arr = []; }
            this._set = new Set(this._arr);
        },

        save() {
            localStorage.setItem(this.KEY, JSON.stringify(this._arr));
        },

        savePos(x, y, w, h) {
            const fromRight  = window.innerWidth  - x - w;
            const fromBottom = window.innerHeight - y - h;
            const anchorX = x < fromRight  ? 'left'   : 'right';
            const anchorY = y < fromBottom ? 'top'    : 'bottom';
            const offX    = anchorX === 'left' ? x : fromRight;
            const offY    = anchorY === 'top'  ? y : fromBottom;
            localStorage.setItem(this.POS_KEY, JSON.stringify({ anchorX, anchorY, offX, offY }));
        },

        loadPos(w, h) {
            try {
                const raw = localStorage.getItem(this.POS_KEY);
                if (!raw) return null;
                const p = JSON.parse(raw);
                if (p.anchorX !== undefined) {
                    const x = p.anchorX === 'left'
                        ? p.offX
                        : window.innerWidth  - p.offX - w;
                    const y = p.anchorY === 'top'
                        ? p.offY
                        : window.innerHeight - p.offY - h;
                    return { x, y };
                }
                if (p.rx !== undefined) return { x: p.rx * window.innerWidth, y: p.ry * window.innerHeight };
                return { x: p.x, y: p.y };
            } catch { return null; }
        },

        has(nick) { return this._set.has(nick); },

        add(nick) {
            if (this._set.has(nick)) return;
            this._set.add(nick);
            this._arr.push(nick);
            this._scheduleSave();
        },

        _scheduleSave() {
            if (this._saveTimer) return;
            this._saveTimer = setTimeout(() => {
                this._saveTimer = null;
                this.save();
            }, 500);
        },

        flush() {
            if (this._saveTimer) {
                clearTimeout(this._saveTimer);
                this._saveTimer = null;
            }
            this.save();
        },

        remove(nick) {
            this._set.delete(nick);
            this._arr = this._arr.filter(n => n !== nick);
            this.save();
        },

        getAll() { return [...this._arr]; },
        count()  { return this._arr.length; },

        exportJSON() {
            const blob = new Blob(
                [JSON.stringify({ users: this._arr }, null, 2)],
                { type: 'application/json' }
            );
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            const d = new Date();
            const localDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
            a.download = `chzzk_blocklist_${localDate}.json`;
            a.click();
            URL.revokeObjectURL(a.href);
        },


        importJSON(data) {
            if (!data || !Array.isArray(data.users)) return 0;
            let added = 0;
            for (const nick of data.users) {
                if (typeof nick === 'string' && !this._set.has(nick)) {
                    this._set.add(nick);
                    this._arr.push(nick);
                    added++;
                }
            }
            if (added > 0) this.save();
            return added;
        },

        loadKeywords() {
            try {
                const raw = localStorage.getItem(this.KW_KEY);
                if (!raw) return;
                const data = JSON.parse(raw);
                let arr = Array.isArray(data.keywords) ? data.keywords : [];
                if (arr.length > 0 && typeof arr[0] === 'string') {
                    arr = arr.map(k => ({ kw: k }));
                } else {
                    arr = arr.map(e => ({ kw: e.kw }));
                }
                this._kwArr = arr;
                this._globalBan = data.globalBan === false ? false : true;
                this._nocheEnabled = data.nocheEnabled === false ? false : true;
            } catch { this._kwArr = []; }
            this._kwSet = new Set(this._kwArr.map(e => e.kw));
        },

        saveKeywords() {
            localStorage.setItem(this.KW_KEY, JSON.stringify({ keywords: this._kwArr, globalBan: this._globalBan, nocheEnabled: this._nocheEnabled }));
        },

        getGlobalBan() { return this._globalBan; },

        setGlobalBan(v) {
            this._globalBan = !!v;
            this.saveKeywords();
        },

        getNocheEnabled() { return this._nocheEnabled; },

        setNocheEnabled(v) {
            this._nocheEnabled = !!v;
            this.saveKeywords();
        },

        addKeyword(kw) {
            const k = kw.trim();
            if (!k || this._kwSet.has(k)) return false;
            this._kwArr.push({ kw: k });
            this._kwSet.add(k);
            this.saveKeywords();
            return true;
        },

        removeKeyword(kw) {
            this._kwSet.delete(kw);
            this._kwArr = this._kwArr.filter(e => e.kw !== kw);
            this.saveKeywords();
        },

        getKeywords() { return [...this._kwArr]; },

        matchKeyword(text) {
            if (this._kwArr.length === 0) return null;
            const lower = text.toLowerCase();
            for (const entry of this._kwArr) {
                if (lower.includes(entry.kw.toLowerCase())) return entry;
            }
            return null;
        }
    };

    // ==========================================
    // [필터링 카운터]
    // ==========================================
    const FilterCount = {
        _count: 0,
        _rafId: null,
        increment() {
            this._count++;
            if (this._rafId) return;
            this._rafId = requestAnimationFrame(() => {
                this._rafId = null;
                UI.updateBadge(this._count);
            });
        },
        reset() {
            this._count = 0;
            if (this._rafId) { cancelAnimationFrame(this._rafId); this._rafId = null; }
            UI.updateBadge(0);
        },
        get() { return this._count; }
    };

    // ==========================================
    // [모듈 2] BlockEngine
    // ==========================================
    const BlockEngine = {
        // 메시지 객체(fiber 데이터)를 받아 차단 여부 반환
        shouldBlock(msg) {
            const nick = msg.profile?.nickname ?? '';
            const raw = msg.content;
            const text = typeof raw === 'string' ? raw : '';

            if (nick && Storage.has(nick)) {
                FilterCount.increment();
                return true;
            }

            if (Storage.getNocheEnabled() && text && isNoche(text)) {
                if (nick && Storage.getGlobalBan()) {
                    Storage.add(nick);
                    UI.requestUpdatePanel();
                }
                FilterCount.increment();
                return true;
            }

            if (text && Storage.matchKeyword(text)) {
                if (nick && Storage.getGlobalBan()) {
                    Storage.add(nick);
                    UI.requestUpdatePanel();
                }
                FilterCount.increment();
                return true;
            }

            return false;
        },

        // ban 버튼 부착 (호버 이벤트용)
        attachBanBtn(msgNode, nick) {
            if (!nick) return;
            if (msgNode.querySelector('.chzzk-ban-btn')) return;
            const nickBtn = msgNode.querySelector(SEL.NICK_BTN);
            if (!nickBtn) return;

            const btn = document.createElement('button');
            btn.className = 'chzzk-ban-btn';
            btn.textContent = '🚫';
            btn.title = `${nick} 차단`;
            btn.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                Storage.add(nick);
                ChatInterceptor.reprocess();
                UI.updatePanel();
            };
            nickBtn.parentElement.style.position = 'relative';
            nickBtn.parentElement.appendChild(btn);
        }
    };

    // ==========================================
    // [모듈 3] ChatInterceptor
    // ==========================================
    const ChatInterceptor = {
        _pollTimer: null,
        _hook: null,
        _wrapped: false,
        _cache: new WeakMap(),
        _POLL_INTERVAL: 500,
        _POLL_TIMEOUT: 30000,

        init() {
            this._startPoll();
            this._listenRouteChange();
            this._listenHover();
        },

        invalidateCache() {
            this._cache = new WeakMap();
        },

        // --- 폴링: 채팅 컨테이너 감지 후 fiber 부착 ---
        _startPoll() {
            if (this._pollTimer) clearInterval(this._pollTimer);
            if (this._msgObserver) { this._msgObserver.disconnect(); this._msgObserver = null; }
            this._hook = null;
            this._wrapped = false;
            this.invalidateCache();
            FilterCount.reset();

            const tryAttach = () => {
                const container = document.querySelector(SEL.CHAT_LIST);
                if (!container) return false;
                const hook = this._findChatHook(container);
                if (!hook) return false;
                this._hook = hook;
                this._wrapDispatch(hook);
                if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
                if (this._msgObserver) { this._msgObserver.disconnect(); this._msgObserver = null; }
                return true;
            };

            // 폴링으로 먼저 시도
            let elapsed = 0;
            this._pollTimer = setInterval(() => {
                elapsed += this._POLL_INTERVAL;
                if (tryAttach()) return;
                if (elapsed >= this._POLL_TIMEOUT) {
                    clearInterval(this._pollTimer);
                    this._pollTimer = null;
                    // 타임아웃 후 메시지 DOM 출현 시 재시도
                    const container = document.querySelector(SEL.CHAT_LIST);
                    const target = container || document.body;
                    this._msgObserver = new MutationObserver(() => {
                        if (tryAttach()) {
                            this._msgObserver.disconnect();
                            this._msgObserver = null;
                        }
                    });
                    this._msgObserver.observe(target, { childList: true, subtree: true });
                }
            }, this._POLL_INTERVAL);
        },

        // --- React fiber 탐색 ---
        _getFiber(domNode) {
            const key = Object.keys(domNode).find(k => k.startsWith('__reactFiber'));
            return key ? domNode[key] : null;
        },

        _isChatArray(val) {
            if (!Array.isArray(val) || val.length === 0) return false;
            const first = val[0];
            return first && typeof first === 'object' &&
                   'key' in first && 'profile' in first && 'content' in first;
        },

        _findChatHook(container) {
            // aside BFS 대신 실제 메시지 노드에서 .return 체인(부모 방향)으로 탐색
            const msgNode = container.querySelector(SEL.MSG_ITEM);
            if (!msgNode) return null;

            // 메시지 노드 또는 조상에서 fiber 획득
            let el = msgNode;
            let fiber = null;
            while (el) {
                const key = Object.keys(el).find(k => k.startsWith('__reactFiber'));
                if (key) { fiber = el[key]; break; }
                el = el.parentElement;
            }
            if (!fiber) return null;

            // return 체인(부모 방향)으로 올라가며 채팅 배열 hook 탐색
            let f = fiber;
            while (f) {
                let s = f.memoizedState;
                while (s) {
                    if (this._isChatArray(s.memoizedState) && s.queue?.dispatch) {
                        return s;
                    }
                    s = s.next;
                }
                f = f.return;
            }
            return null;
        },

        // --- dispatch 래핑 ---
        _wrapDispatch(hook) {
            if (this._wrapped) return;
            this._wrapped = true;

            const original = hook.queue.dispatch;
            const self = this;
            hook.queue.dispatch = function (action) {
                let filtered = action;
                if (Array.isArray(action)) {
                    filtered = self._filterMessages(action);
                } else if (action && Array.isArray(action.payload)) {
                    const result = self._filterMessages(action.payload);
                    filtered = result.length === 0 ? action : { ...action, payload: result };
                } else if (typeof action === 'function') {
                    filtered = (prev) => self._filterMessages(action(prev));
                }
                return original(filtered);
            };
            hook.queue.dispatch._cbpWrapped = true;

            // 이미 렌더된 메시지에도 필터 소급 적용
            try {
                original(prev => Array.isArray(prev) ? [...prev] : prev);
            } catch (e) {
                console.error('[ChatBlocker] 초기 메시지 필터 적용 실패:', e);
            }
        },

        // --- 필터 함수 ---
        _filterMessages(messages) {
            if (!Array.isArray(messages)) return messages;
            return messages.filter(msg => {
                if (this._cache.has(msg)) return this._cache.get(msg);
                const pass = !BlockEngine.shouldBlock(msg);
                this._cache.set(msg, pass);
                return pass;
            });
        },

        // --- 설정 변경 후 현재 채팅 재처리 ---
        reprocess() {
            this.invalidateCache();
            if (!this._hook) return;
            const current = this._hook.memoizedState;
            if (Array.isArray(current) && this._hook.queue?.dispatch) {
                this._hook.queue.dispatch([...current]);
            }
        },

        // --- 호버 이벤트로 ban 버튼 부착 ---
        _listenHover() {
            document.addEventListener('mouseover', (e) => {
                const msgNode = e.target.closest(SEL.MSG_ITEM);
                if (!msgNode) return;
                const nickEl = msgNode.querySelector(SEL.USERNAME);
                const nick = nickEl?.textContent.trim();
                if (nick) BlockEngine.attachBanBtn(msgNode, nick);
            }, { passive: true });
        },

        // --- 라우트 변경 대응 ---
        _listenRouteChange() {
            const onRouteChange = () => {
                FilterCount.reset();
                setTimeout(() => this._startPoll(), 500);
            };

            const origPush = history.pushState.bind(history);
            history.pushState = function (...args) {
                origPush(...args);
                onRouteChange();
            };
            const origReplace = history.replaceState.bind(history);
            history.replaceState = function (...args) {
                origReplace(...args);
                onRouteChange();
            };
            window.addEventListener('popstate', onRouteChange);
        }
    };

    // ==========================================
    // [모듈 4] UI
    // ==========================================
    const UI = {
        panel: null,
        countEl: null,
        listEl: null,
        kwListEl: null,
        _visible: false,
        _panelRaf: null,

        init() {
            this.injectStyles();
            this.createToggleBtn();
            this.createPanel();
        },

        injectStyles() {
            const s = document.createElement('style');
            s.textContent = `
                .chzzk-ban-btn {
                    display: inline-block;
                    opacity: 0;
                    pointer-events: none;
                    transition: opacity 0.15s;
                    background: rgba(220, 38, 38, 0.85);
                    color: #fff;
                    border: none;
                    border-radius: 4px;
                    width: 18px; height: 18px;
                    font-size: 11px; line-height: 18px;
                    text-align: center;
                    cursor: pointer;
                    padding: 0;
                    margin-left: 4px;
                    vertical-align: middle;
                    flex-shrink: 0;
                }
                [class*="_chatting_message_"]:hover .chzzk-ban-btn {
                    opacity: 1;
                    pointer-events: auto;
                }
                .chzzk-ban-btn:hover {
                    background: rgba(185, 28, 28, 1);
                    transform: scale(1.1);
                }

                #chzzk-blocker-btn {
                    position: fixed;
                    z-index: 999999;
                    background: #18181b;
                    color: #fff;
                    border: 1px solid #3f3f46;
                    border-radius: 8px;
                    padding: 8px 13px;
                    font-size: 13px; font-weight: 700;
                    cursor: grab;
                    box-shadow: 0 4px 16px rgba(0,0,0,0.4);
                    display: flex; align-items: center; gap: 6px;
                    font-family: sans-serif;
                    user-select: none;
                }
                #chzzk-blocker-btn:hover { background: #27272a; }
                #chzzk-blocker-btn.dragging { cursor: grabbing; opacity: 0.85; }
                #chzzk-blocker-count {
                    background: #dc2626;
                    border-radius: 999px;
                    padding: 1px 6px;
                    font-size: 10px;
                    display: none;
                }

                #chzzk-blocker-panel {
                    position: fixed;
                    z-index: 999999;
                    width: 320px;
                    background: #18181b;
                    border: 1px solid #3f3f46;
                    border-radius: 12px;
                    box-shadow: 0 8px 32px rgba(0,0,0,0.5);
                    font-family: sans-serif;
                    color: #fff;
                    display: none;
                    flex-direction: column;
                    overflow: hidden;
                }
                #chzzk-blocker-panel.visible { display: flex; }

                .cbp-header {
                    padding: 14px 16px 10px;
                    border-bottom: 1px solid #27272a;
                    display: flex; align-items: center; justify-content: space-between;
                }
                .cbp-title { font-size: 14px; font-weight: 800; color: #fff; }
                .cbp-close {
                    background: none; border: none; color: #71717a;
                    cursor: pointer; font-size: 16px; line-height: 1; padding: 0;
                }
                .cbp-close:hover { color: #fff; }

                .cbp-list {
                    max-height: 150px;
                    overflow-y: auto;
                    padding: 6px 0;
                }
                .cbp-list::-webkit-scrollbar { width: 4px; }
                .cbp-list::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 2px; }

                .cbp-empty {
                    padding: 20px 16px;
                    text-align: center;
                    color: #52525b;
                    font-size: 12px;
                }
                .cbp-item {
                    display: flex; align-items: center;
                    padding: 7px 16px;
                    gap: 8px;
                    border-bottom: 1px solid #27272a;
                }
                .cbp-item:last-child { border-bottom: none; }
                .cbp-nick {
                    flex: 1;
                    font-size: 13px; color: #e4e4e7;
                    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
                }
                .cbp-del {
                    background: none;
                    border: 1px solid #3f3f46;
                    color: #71717a;
                    border-radius: 4px;
                    padding: 2px 8px;
                    font-size: 11px;
                    cursor: pointer;
                    flex-shrink: 0;
                }
                .cbp-del:hover { border-color: #dc2626; color: #dc2626; }

                .cbp-footer {
                    padding: 10px 12px;
                    border-top: 1px solid #27272a;
                    display: flex; gap: 6px;
                }
                .cbp-btn {
                    flex: 1;
                    padding: 7px 0;
                    border-radius: 6px;
                    font-size: 12px; font-weight: 700;
                    cursor: pointer;
                    border: 1px solid #3f3f46;
                    background: #27272a; color: #a1a1aa;
                }
                .cbp-btn:hover { background: #3f3f46; color: #fff; }

                .cbp-section {
                    border-top: 1px solid #27272a;
                    padding: 10px 16px 6px;
                }
                .cbp-section-title {
                    font-size: 12px; font-weight: 700; color: #a1a1aa;
                    margin-bottom: 6px;
                    display: flex; align-items: center; justify-content: space-between;
                }
                .cbp-kw-ban-label {
                    display: flex; align-items: center; gap: 5px;
                    font-size: 11px; font-weight: 400; color: #71717a;
                    cursor: pointer;
                }
                .cbp-kw-ban-label input { cursor: pointer; accent-color: #dc2626; }
                .cbp-kw-ban-label:hover { color: #a1a1aa; }
                .cbp-kw-input-row {
                    display: flex; gap: 6px; margin-bottom: 6px; align-items: center;
                }
                .cbp-kw-input {
                    flex: 1;
                    background: #27272a; border: 1px solid #3f3f46;
                    color: #e4e4e7; border-radius: 5px;
                    padding: 5px 8px; font-size: 12px;
                    outline: none;
                }
                .cbp-kw-input:focus { border-color: #71717a; }
                .cbp-kw-input::placeholder { color: #52525b; }
                .cbp-kw-add {
                    background: #27272a; border: 1px solid #3f3f46;
                    color: #a1a1aa; border-radius: 5px;
                    padding: 5px 10px; font-size: 12px; font-weight: 700;
                    cursor: pointer; flex-shrink: 0;
                }
                .cbp-kw-add:hover { background: #3f3f46; color: #fff; }
                .cbp-kw-list {
                    max-height: 100px; overflow-y: auto;
                }
                .cbp-kw-list::-webkit-scrollbar { width: 4px; }
                .cbp-kw-list::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 2px; }
                .cbp-kw-empty {
                    padding: 8px 0;
                    text-align: center; color: #52525b; font-size: 11px;
                }
                .cbp-kw-item {
                    display: flex; align-items: center;
                    padding: 4px 0; gap: 6px;
                    border-bottom: 1px solid #27272a;
                }
                .cbp-kw-item:last-child { border-bottom: none; }
                .cbp-kw-text {
                    flex: 1; font-size: 12px; color: #e4e4e7;
                    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
                }
                .cbp-kw-del {
                    background: none; border: 1px solid #3f3f46;
                    color: #71717a; border-radius: 4px;
                    padding: 1px 6px; font-size: 11px; cursor: pointer; flex-shrink: 0;
                }
                .cbp-kw-del:hover { border-color: #dc2626; color: #dc2626; }
            `;
            document.head.appendChild(s);
        },

        createToggleBtn() {
            const btn = document.createElement('button');
            btn.id = 'chzzk-blocker-btn';

            const countEl = document.createElement('span');
            countEl.id = 'chzzk-blocker-count';
            this.countEl = countEl;

            btn.appendChild(document.createTextNode('🚫 차단'));
            btn.appendChild(countEl);
            document.body.appendChild(btn);

            const savedPos = Storage.loadPos(btn.offsetWidth, btn.offsetHeight);
            if (savedPos) {
                const clampedX = Math.max(0, Math.min(window.innerWidth  - btn.offsetWidth,  savedPos.x));
                const clampedY = Math.max(0, Math.min(window.innerHeight - btn.offsetHeight, savedPos.y));
                btn.style.left = clampedX + 'px';
                btn.style.top  = clampedY + 'px';
            } else {
                btn.style.right  = '20px';
                btn.style.bottom = '20px';
            }

            let isDragging = false;
            let startX, startY, startLeft, startTop;
            let moved = false;

            btn.addEventListener('mousedown', (e) => {
                isDragging = true;
                moved = false;

                const rect = btn.getBoundingClientRect();
                startX    = e.clientX;
                startY    = e.clientY;
                startLeft = rect.left;
                startTop  = rect.top;

                btn.style.right  = 'auto';
                btn.style.bottom = 'auto';
                btn.style.left   = startLeft + 'px';
                btn.style.top    = startTop  + 'px';

                btn.classList.add('dragging');
                e.preventDefault();
            });

            document.addEventListener('mousemove', (e) => {
                if (!isDragging) return;
                const dx = e.clientX - startX;
                const dy = e.clientY - startY;

                if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;

                let newLeft = startLeft + dx;
                let newTop  = startTop  + dy;

                newLeft = Math.max(0, Math.min(window.innerWidth  - btn.offsetWidth,  newLeft));
                newTop  = Math.max(0, Math.min(window.innerHeight - btn.offsetHeight, newTop));

                btn.style.left = newLeft + 'px';
                btn.style.top  = newTop  + 'px';

                this._updatePanelPos(btn);
            });

            const reclamp = () => {
                const pos = Storage.loadPos(btn.offsetWidth, btn.offsetHeight);
                if (!pos) return;
                const cx = Math.max(0, Math.min(window.innerWidth  - btn.offsetWidth,  pos.x));
                const cy = Math.max(0, Math.min(window.innerHeight - btn.offsetHeight, pos.y));
                btn.style.left = cx + 'px';
                btn.style.top  = cy + 'px';
                this._updatePanelPos(btn);
            };
            window.addEventListener('resize', reclamp);
            document.addEventListener('fullscreenchange', reclamp);

            document.addEventListener('mouseup', () => {
                if (!isDragging) return;
                isDragging = false;
                btn.classList.remove('dragging');

                const x = parseFloat(btn.style.left);
                const y = parseFloat(btn.style.top);
                Storage.savePos(x, y, btn.offsetWidth, btn.offsetHeight);

                if (!moved) this.togglePanel();
            });
        },

        createPanel() {
            const panel = document.createElement('div');
            panel.id = 'chzzk-blocker-panel';
            panel.innerHTML = `
                <div class="cbp-header">
                    <span class="cbp-title">🚫 차단 목록</span>
                    <button class="cbp-close" id="cbp-close-btn">✕</button>
                </div>
                <div class="cbp-list" id="cbp-list"></div>
                <div class="cbp-section">
                    <div class="cbp-section-title">
                        <span>🔍 키워드 필터</span>
                    </div>
                    <div style="display:flex; gap:12px; margin-bottom:8px;">
                        <label class="cbp-kw-ban-label"><input type="checkbox" id="cbp-noche-enabled-chk"> 노체 말투 차단</label>
                        <label class="cbp-kw-ban-label"><input type="checkbox" id="cbp-kw-global-ban-chk"> 영구차단</label>
                    </div>
                    <div class="cbp-kw-input-row">
                        <input type="text" class="cbp-kw-input" id="cbp-kw-input" placeholder="키워드 입력 후 Enter">
                        <button class="cbp-kw-add" id="cbp-kw-add-btn">추가</button>
                    </div>
                    <div class="cbp-kw-list" id="cbp-kw-list"></div>
                </div>
                <div class="cbp-footer">
                    <button class="cbp-btn" id="cbp-export-btn">📤 내보내기</button>
                    <button class="cbp-btn" id="cbp-import-btn">📥 불러오기</button>
                </div>
            `;
            document.body.appendChild(panel);
            this.panel   = panel;
            this.listEl  = panel.querySelector('#cbp-list');
            this.kwListEl = panel.querySelector('#cbp-kw-list');

            panel.querySelector('#cbp-close-btn').onclick = () => this.hidePanel();
            panel.querySelector('#cbp-export-btn').onclick = () => Storage.exportJSON();

            const kwInput = panel.querySelector('#cbp-kw-input');
            const addKw = () => {
                if (Storage.addKeyword(kwInput.value)) {
                    kwInput.value = '';
                    this.updateKeywordList();
                    ChatInterceptor.reprocess();
                }
            };
            panel.querySelector('#cbp-kw-add-btn').onclick = addKw;
            kwInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addKw(); });

            const nocheChk = panel.querySelector('#cbp-noche-enabled-chk');
            nocheChk.checked = Storage.getNocheEnabled();
            nocheChk.onchange = () => {
                Storage.setNocheEnabled(nocheChk.checked);
                ChatInterceptor.reprocess();
            };

            const globalBanChk = panel.querySelector('#cbp-kw-global-ban-chk');
            globalBanChk.checked = Storage.getGlobalBan();
            globalBanChk.onchange = () => Storage.setGlobalBan(globalBanChk.checked);

            panel.querySelector('#cbp-import-btn').onclick = () => {
                const input = document.createElement('input');
                input.type = 'file';
                input.accept = '.json,.txt';
                input.onchange = (e) => {
                    const file = e.target.files[0];
                    if (!file) return;
                    const reader = new FileReader();
                    reader.onload = (ev) => {
                        try {
                            const data = JSON.parse(ev.target.result);
                            const added = Storage.importJSON(data);
                            ChatInterceptor.reprocess();
                            this.updatePanel();
                            alert(`${added}명 추가됨 (중복 제외)`);
                        } catch {
                            alert('파일 형식이 올바르지 않습니다.');
                        }
                    };
                    reader.readAsText(file);
                };
                input.click();
            };
        },

        _updatePanelPos(btn) {
            if (!this.panel || !this._visible) return;
            const rect     = btn.getBoundingClientRect();
            const panelH   = this.panel.offsetHeight || 300;
            const panelW   = this.panel.offsetWidth  || 320;
            const margin   = 8;

            let top  = rect.top - panelH - margin;
            let left = rect.left;

            if (top < 0) top = rect.bottom + margin;
            if (left + panelW > window.innerWidth) left = rect.right - panelW;

            this.panel.style.left = left + 'px';
            this.panel.style.top  = top  + 'px';
            this.panel.style.right  = 'auto';
            this.panel.style.bottom = 'auto';
        },

        togglePanel() {
            this._visible ? this.hidePanel() : this.showPanel();
        },

        showPanel() {
            this._visible = true;
            this.updatePanel();
            this.updateKeywordList();
            this.panel.classList.add('visible');
            const btn = document.getElementById('chzzk-blocker-btn');
            if (btn) this._updatePanelPos(btn);
        },

        hidePanel() {
            this._visible = false;
            this.panel.classList.remove('visible');
        },

        updateBadge(n) {
            if (!this.countEl) return;
            if (n > 0) {
                this.countEl.textContent = n;
                this.countEl.style.display = 'inline-block';
            } else {
                this.countEl.style.display = 'none';
            }
        },

        updateKeywordList() {
            if (!this.kwListEl) return;
            const keywords = Storage.getKeywords();
            this.kwListEl.innerHTML = '';
            if (keywords.length === 0) {
                this.kwListEl.innerHTML = '<div class="cbp-kw-empty">등록된 키워드가 없습니다</div>';
                return;
            }
            [...keywords].reverse().forEach(entry => {
                const row = document.createElement('div');
                row.className = 'cbp-kw-item';

                const kwEl = document.createElement('span');
                kwEl.className = 'cbp-kw-text';
                kwEl.textContent = entry.kw;
                kwEl.title = entry.kw;

                const delBtn = document.createElement('button');
                delBtn.className = 'cbp-kw-del';
                delBtn.textContent = '삭제';
                delBtn.onclick = () => {
                    Storage.removeKeyword(entry.kw);
                    this.updateKeywordList();
                    ChatInterceptor.reprocess();
                };

                row.appendChild(kwEl);
                row.appendChild(delBtn);
                this.kwListEl.appendChild(row);
            });
        },

        requestUpdatePanel() {
            if (this._panelRaf) return;
            this._panelRaf = requestAnimationFrame(() => {
                this._panelRaf = null;
                this.updatePanel();
            });
        },

        updatePanel() {
            const count = Storage.count();

            const titleEl = this.panel ? this.panel.querySelector('.cbp-title') : null;
            if (titleEl) titleEl.textContent = `🚫 차단 목록 (${count}명)`;

            if (!this._visible || !this.listEl) return;
            this.listEl.innerHTML = '';

            if (count === 0) {
                this.listEl.innerHTML = '<div class="cbp-empty">차단된 사용자가 없습니다</div>';
                return;
            }

            const LIMIT = 20;
            const all = Storage.getAll().reverse();
            all.slice(0, LIMIT).forEach(nick => {
                const row = document.createElement('div');
                row.className = 'cbp-item';

                const nickEl = document.createElement('span');
                nickEl.className = 'cbp-nick';
                nickEl.textContent = nick;
                nickEl.title = nick;

                const delBtn = document.createElement('button');
                delBtn.className = 'cbp-del';
                delBtn.textContent = '해제';
                delBtn.onclick = () => {
                    Storage.remove(nick);
                    ChatInterceptor.reprocess();
                    this.updatePanel();
                };

                row.appendChild(nickEl);
                row.appendChild(delBtn);
                this.listEl.appendChild(row);
            });

            if (all.length > LIMIT) {
                const more = document.createElement('div');
                more.className = 'cbp-empty';
                more.textContent = `...외 ${all.length - LIMIT}명 (전체 목록은 내보내기로 확인)`;
                this.listEl.appendChild(more);
            }
        }
    };

    // ==========================================
    // 진입점
    // ==========================================
    Storage.load();
    Storage.loadKeywords();
    UI.init();
    UI.updatePanel();
    ChatInterceptor.init();

    window.addEventListener('beforeunload', () => Storage.flush());
    document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden') Storage.flush();
    });

})();