CHZZK Chat Blocker

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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

})();