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