CHZZK Chat Blocker

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

(function () {
    'use strict';

    const SEL = {
        MSG_ITEM:  '[class*="live_chatting_list_item"]',
        USERNAME:  '[class*="name_text"]',
        NICK_BTN:  '[class*="live_chatting_message_nickname"]',
        CHAT_LIST: '[class*="live_chatting_list_wrapper"]',
        MSG_TEXT:  '[class*="live_chatting_message_text"]',
    };

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

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

    // ── 3. 슬랭 / 감정 표현 (단독 '치' 오탐 방지를 위해 합성어로 매칭) ──────
    '구라치','사기치','깝치','까지치','꼴값치','잘치','선넘',
    '나대','씨부리','부들대','징징대','우기',
    '모르','못참','알아듣',
    '빡치','소름돋',
    '기가차','버티','웃프',
    '무섭','귀찮','알빠','꼬시'
];

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

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, '카지_');
    if (NOCHE.test(cleanText)) {
        return true;
    }
    
    // 2. 이노 전용 판단 (앞 글자에 받침이 있는 경우 또는 숫자인 경우 차단)
    const inoRegex = /([가-힣0-9])이노(?![a-zA-Z0-9가-힣])/m;
    const match = normalized.match(inoRegex);
    if (match) {
        const prevChar = match[1];
        if (/[0-9]/.test(prevChar)) {
            return true; // 숫자가 앞에 붙은 경우(예: 1이노, 3이노) 바로 차단
        }
        const charCode = prevChar.charCodeAt(0);
        if (charCode >= 0xAC00 && charCode <= 0xD7A3) {
            const tailIndex = (charCode - 0xAC00) % 28;
            if (tailIndex > 0) {
                return true;
            }
        }
    }
    
    return false;
}
// --------------------------------------------------------------------------------------
// 노체 필터 끝 (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);
            a.download = `chzzk_blocklist_${new Date().toISOString().slice(0,10)}.json`;
            a.click();
            URL.revokeObjectURL(a.href);
        },

        importJSON(data) {
            if (!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 : [];
                // 구버전(string[] 또는 {kw, ban}[]) 마이그레이션
                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 = {
        _processed: new WeakSet(),

        processMsg(node) {
            if (!(node instanceof HTMLElement)) return;
            if (!node.matches(SEL.MSG_ITEM)) return;
            if (this._processed.has(node)) return;

            const nickEl = node.querySelector(SEL.USERNAME);
            const nick   = nickEl ? nickEl.textContent.trim() : null;
            const textEl = node.querySelector(SEL.MSG_TEXT);
            const text   = textEl ? textEl.textContent.trim() : '';

            if (nick && Storage.has(nick)) {
                this.hide(node);
            } else if (Storage.getNocheEnabled() && isNoche(text)) {
                if (nick && Storage.getGlobalBan()) Storage.add(nick);
                this.hide(node);
                UI.requestUpdatePanel();
            } else {
                const kwMatch = Storage.matchKeyword(text);
                if (kwMatch) {
                    if (nick && Storage.getGlobalBan()) Storage.add(nick);
                    this.hide(node);
                    UI.requestUpdatePanel();
                } else {
                    this.attachBanBtn(node, nick);
                }
            }

            this._processed.add(node);
        },

        hide(node) {
            node.style.display = 'none';
            FilterCount.increment();
        },

        reprocessAll() {
            this._processed = new WeakSet();
            document.querySelectorAll(SEL.MSG_ITEM).forEach(n => this.processMsg(n));
        },

        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);
                this.hide(msgNode);
                UI.updatePanel();
            };
            nickBtn.parentElement.style.position = 'relative';
            nickBtn.parentElement.appendChild(btn);
        }
    };

    // ==========================================
    // [모듈 3] Observer
    // ==========================================
    const Observer = {
        _mo: null,
        _bodyMo: null,
        _pending: new Set(),
        _raf: null,

        init() {
            this._waitForChat();
            this._listenRouteChange();
        },

        _startObserve(target) {
            if (this._mo) this._mo.disconnect();
            this._mo = new MutationObserver((mutations) => {
                for (const mut of mutations) {
                    for (const node of mut.addedNodes) {
                        if (!(node instanceof HTMLElement)) continue;
                        if (node.matches?.(SEL.MSG_ITEM)) this._pending.add(node);
                        node.querySelectorAll(SEL.MSG_ITEM).forEach(n => this._pending.add(n));
                    }
                }
                if (this._raf) return;
                this._raf = requestAnimationFrame(() => {
                    this._raf = null;
                    if (this._pending.size === 0) return;
                    const batch = [...this._pending];
                    this._pending.clear();
                    batch.forEach(n => BlockEngine.processMsg(n));
                });
            });
            this._mo.observe(target, { childList: true, subtree: true });
            document.querySelectorAll(SEL.MSG_ITEM).forEach(n => BlockEngine.processMsg(n));
        },

        _waitForChat() {
            if (this._bodyMo) this._bodyMo.disconnect();
            const chatList = document.querySelector(SEL.CHAT_LIST);
            if (chatList) {
                this._startObserve(chatList);
                return;
            }
            this._bodyMo = new MutationObserver(() => {
                const cl = document.querySelector(SEL.CHAT_LIST);
                if (cl) {
                    this._bodyMo.disconnect();
                    this._bodyMo = null;
                    this._startObserve(cl);
                }
            });
            this._bodyMo.observe(document.body, { childList: true, subtree: true });
        },

        _listenRouteChange() {
            const onRouteChange = () => {
                BlockEngine._processed = new WeakSet();
                this._pending.clear();
                FilterCount.reset();
                setTimeout(() => this._waitForChat(), 300);
            };

            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*="live_chatting_list_item"]: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: 300px;
                    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: 8px;
                    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 style="display:flex;gap: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>
                    <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();
                    BlockEngine.reprocessAll();
                }
            };
            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);
                BlockEngine.reprocessAll();
            };

            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);
                            BlockEngine.reprocessAll();
                            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  || 300;
            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();
                    BlockEngine.reprocessAll();
                };

                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);
                    BlockEngine.reprocessAll();
                    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();
    Observer.init();

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

})();