CHZZK Chat Blocker

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

Advertisement:

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

Advertisement:

// ==UserScript==
// @name         CHZZK Chat Blocker
// @namespace    chzzk-chat-blocker
// @version      1.4.4
// @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_"]',
    };

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

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

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

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

function isNoche(text) {
    if (!text) return false;
    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대_')
        .replace(/(피|치|리|시|디|니|티|비|레|키|지|미)아노/g, '$1아_');
        
    const match = NOCHE_REGEX.exec(cleanText);
    return match ? match[0] : false;
}
// ======================================================================================
// ── 1. 노체 관련 정의 (Noche Definitions) - 복사 영역 끝 ──
// ======================================================================================




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

})();