Allows you to filter tags, channels and slots on Chzzk.
// ==UserScript== // @name 치지직 필터 // @name:en Chzzk Filter // @namespace Certify4113.greasyfork.org // @version 3.0.1 // @description 치지직에서 태그, 채널, 슬롯을 원하는대로 필터링할 수 있습니다. // @description:en Allows you to filter tags, channels and slots on Chzzk. // @author Certify4113 // @match https://*.chzzk.naver.com/* // @grant none // @run-at document-start // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/xhook.min.js // @icon https://i.ibb.co/cKtGNzPP/cheese.png // @license MIT // ==/UserScript== (function () { // localStorage 마이그레이션 try { const migrations = [ { oldKey: "tagBlockingEnabled", newKey: "tagFilterEnabled" }, { oldKey: "channelBlockingEnabled", newKey: "channelFilterEnabled" }, { oldKey: "categoryBlockingEnabled", newKey: "slotFilterEnabled" }, { oldKey: "bannerBlockingEnabled", newKey: "bannerFilterEnabled" }, { oldKey: "categories", newKey: "slots" }, ]; for (const { oldKey, newKey } of migrations) { const oldValue = localStorage.getItem(oldKey); if (oldValue !== null) { if (localStorage.getItem(newKey) === null) { localStorage.setItem(newKey, oldValue); } localStorage.removeItem(oldKey); } } } catch (e) { // localStorage 접근 에러 시 } const getState = (key) => localStorage.getItem(key) !== "false"; const isTagFilterEnabled = () => getState("tagFilterEnabled"); const isChannelFilterEnabled = () => getState("channelFilterEnabled"); const isSlotFilterEnabled = () => getState("slotFilterEnabled"); const isBannerFilterEnabled = () => getState("bannerFilterEnabled"); const setState = (key, value) => localStorage.setItem(key, String(value)); let filteredTags = new Set(); let filteredChannels = new Set(); let allowedSlots = new Set(); // 기본 허용 슬롯 const defaultSlots = [ "팔로잉 채널 라이브", "이 방송 어때요?", "이런 카테고리는 어때요?", ]; try { const savedTags = localStorage.getItem("tags"); if (savedTags) { savedTags .split("\n") .map((t) => t.trim()) .filter((t) => t.length > 0) .forEach((t) => filteredTags.add(t)); } } catch (e) { // localStorage 접근 에러 시 } try { const savedChannels = localStorage.getItem("channels"); if (savedChannels) { savedChannels .split("\n") .map((t) => { const res = t.trim(); return res .replace("https://chzzk.naver.com/live/", "") .replace("http://chzzk.naver.com/live/", "") .replace("https://chzzk.naver.com/", "") .replace("http://chzzk.naver.com/", ""); }) .filter((t) => t.length == 32) .forEach((t) => filteredChannels.add(t)); } } catch (e) { // localStorage 접근 에러 시 } try { const savedSlots = localStorage.getItem("slots"); if (savedSlots !== null && savedSlots.trim() !== "") { // 유저가 수정한 유효한 값이 있으면 그 값 사용 const split = savedSlots .split("\n") .map((t) => t.trim()) .filter((t) => t.length > 0); split.forEach((c) => allowedSlots.add(c)); } else { // 저장된 값이 없거나 비어있으면 기본값 사용 allowedSlots = new Set(defaultSlots); } } catch (e) { // localStorage 접근 에러 시 allowedSlots = new Set(defaultSlots); } // ######################################################### // 페이지 디자인 수정 - CSS 주입 방식 // ######################################################### // 홈 배너 빈 컨테이너 항상 숨기기 (() => { const style = document.createElement("style"); style.textContent = `[class*="home_list_container"]:has(#home_banner) { display: none !important; }`; if (document.head) { document.head.appendChild(style); } else { document.documentElement.appendChild(style); } })(); // 상단 광고 배너 차단 (() => { if (!isBannerFilterEnabled()) return; const bannerFilterStyle = document.createElement("style"); bannerFilterStyle.id = "chzzk-banner-filter"; bannerFilterStyle.textContent = ` /* 상단 배너 숨기기 */ [class*="skin_container"] { display: none !important; } /* 배너가 존재할 때만 연관 요소 스타일 조정 */ /* 헤더 영역 이동 */ [class*="skin_container"] ~ [class*="header_container"] { transform: translateY(0) !important; } /* 사이드바 영역 이동 */ [class*="layout_glive"]:has([class*="skin_container"]) [class*="aside_container"] { transform: translateY(60px) !important; height: calc(100vh - 60px) !important; } /* 본문 영역 이동 */ [class*="layout_glive"]:has([class*="skin_container"]) [class*="layout_body"] { padding-top: 0 !important; min-height: calc(100vh - 60px) !important; } /* 내비게이션 탭 이동*/ [class*="layout_glive"]:has([class*="skin_container"]) [class*="navigation_component_tab"] { top: 60px !important; } /* 내비게이션 sticky 헤더 이동*/ [class*="layout_glive"]:has([class*="skin_container"]) [class*="navigation_component_is_sticky"] { top: 60px !important; } /* 내비게이션 필터 이동 - tab이 있는 페이지*/ [class*="layout_glive"]:has([class*="skin_container"]):has([class*="navigation_component_tab"]) [class*="navigation_component_filter"] { top: 103px !important; } /* 내비게이션 필터 이동 - sticky 헤더가 있는 페이지*/ [class*="layout_glive"]:has([class*="skin_container"]):has([class*="navigation_component_is_sticky"]) [class*="navigation_component_filter"] { top: 119px !important; } /* 내비게이션 필터 이동 - tab도 sticky 헤더도 없는 페이지*/ [class*="layout_glive"]:has([class*="skin_container"]):not(:has([class*="navigation_component_tab"])):not(:has([class*="navigation_component_is_sticky"])) [class*="navigation_component_filter"] { top: 60px !important; } /* 채널 페이지 탭 네비게이션 이동 */ [class*="layout_glive"]:has([class*="skin_container"]) [class*="channel_tab"] { top: 60px !important; } /* 채널 페이지 필터/옵션 헤더 이동 */ [class*="layout_glive"]:has([class*="skin_container"]) [class*="channel_component_header"] { top: 103px !important; } /* 카테고리 페이지 탭 네비게이션 이동 */ [class*="layout_glive"]:has([class*="skin_container"]) [class*="category_tab"] { top: 60px !important; } /* 편성표 wrapper 이동 - 스크롤 전 */ [class*="layout_glive"]:has([class*="schedule_main"]):has([class*="skin_container"]:not([class*="skin_is_scrolled"])) [class*="schedule_wrapper"] { transform: translateY(-160px) !important; } /* 편성표 wrapper 이동 - 스크롤 후 */ [class*="layout_glive"]:has([class*="schedule_main"]):has([class*="skin_is_scrolled"]) [class*="schedule_wrapper"] { transform: translateY(-50px) !important; } `; // document-start 시점이므로 documentElement에 바로 추가 if (document.head) { document.head.appendChild(bannerFilterStyle); } else { document.documentElement.appendChild(bannerFilterStyle); } })(); // ######################################################### // 필터 로직 유틸리티 함수 // ######################################################### // 중첩 프로퍼티 접근 헬퍼 const getNestedValue = (obj, path) => path.split(".").reduce((acc, key) => acc && acc[key], obj); // 태그/채널 필터 (개별 콘텐츠 단위) const filterContentByTagsAndChannels = ( content, tagFieldName, channelFieldName, ) => { // 태그 필터 if (isTagFilterEnabled()) { const categoryValue = tagFieldName ? content[tagFieldName] : null; if (categoryValue && filteredTags.has(categoryValue)) return false; if (content.tags?.some((tag) => filteredTags.has(tag))) return false; } // 채널 필터 if (isChannelFilterEnabled() && channelFieldName) { const channelId = getNestedValue(content, channelFieldName); if (channelId && filteredChannels.has(channelId)) return false; } return true; }; // JSON 응답 파싱 → 변환 → 재직렬화 공통 래퍼 const withParsedResponse = (response, fn) => { try { const origin = JSON.parse(response.text); fn(origin); response.text = JSON.stringify(origin); } catch (error) { console.error(error); } }; // content.data 배열 필터링 const filterResponseData = (response, tagFieldName, channelFieldName) => { withParsedResponse(response, (origin) => { if (origin.content?.data && Array.isArray(origin.content.data)) { origin.content.data = origin.content.data.filter((item) => filterContentByTagsAndChannels(item, tagFieldName, channelFieldName), ); } }); }; // 슬롯 필터링 const filterSlots = (slots) => slots.filter((slot) => { // 카테고리 필터링 if ( isSlotFilterEnabled() && slot.slotTitle && !allowedSlots.has(slot.slotTitle) ) { return false; } // 슬롯 내부 콘텐츠 필터링 if (Array.isArray(slot.slotContents)) { slot.slotContents = slot.slotContents.filter((item) => filterContentByTagsAndChannels( item, "liveCategoryValue", "channel.channelId", ), ); } return true; }); // ######################################################### // 필터 로직 // ######################################################### // callback → xhook이 비동기 모드로 동작하여 callback() 호출 전까지 응답 전달을 보류 const INTERNAL_MARKER = "_cf=1"; xhook.after(function (request, response, callback) { const url = request.url; // 내부 요청은 필터링하지 않고 즉시 통과 if (url.includes(INTERNAL_MARKER)) { return callback(response); } // API 요청만 처리 if (!url.includes("api.chzzk.naver.com/service")) { return callback(response); } // 메인 페이지 - 초기 슬롯 요청 필터링 if ( url.includes( "api.chzzk.naver.com/service/v1/topics/HOME/sub-topics/HOME/main", ) && !url.includes("/main/slots") ) { try { const origin = JSON.parse(response.text); if (origin.content && origin.content.slots) { origin.content.slots = filterSlots(origin.content.slots); } // 슬롯 필터 활성 & 추가 로딩할 슬롯이 있는 경우 if ( isSlotFilterEnabled() && origin.content && origin.content.remainingSlotNos && origin.content.remainingSlotNos.length > 0 ) { const remaining = origin.content.remainingSlotNos; // API는 slotNos를 최대 5개까지만 허용 → 5개씩 나눠서 요청 const BATCH_SIZE = 5; const batches = []; for (let i = 0; i < remaining.length; i += BATCH_SIZE) { batches.push(remaining.slice(i, i + BATCH_SIZE)); } const fetchBatch = (slotNos) => { const slotsUrl = `https://api.chzzk.naver.com/service/v1/topics/HOME/sub-topics/HOME/main/slots?slotNos=${slotNos.join(",")}&${INTERNAL_MARKER}`; return fetch(slotsUrl, { credentials: "include" }) .then((res) => res.json()) .catch((err) => { console.error("[Chzzk Filter] 배치 로딩 실패:", err); return null; }); }; Promise.all(batches.map(fetchBatch)) .then((results) => { for (const data of results) { if (data && data.content && data.content.slots) { const additionalSlots = filterSlots(data.content.slots); origin.content.slots.push(...additionalSlots); } } origin.content.remainingSlotNos = []; response.text = JSON.stringify(origin); callback(response); }) .catch((err) => { console.error("[Chzzk Filter] 추가 슬롯 로딩 실패:", err); response.text = JSON.stringify(origin); callback(response); }); return; // callback은 Promise 안에서 호출됨 } response.text = JSON.stringify(origin); } catch (error) { console.error(error); } return callback(response); } // 메인 페이지 - 추가 슬롯 요청 필터링 else if ( url.includes( "api.chzzk.naver.com/service/v1/topics/HOME/sub-topics/HOME/main/slots", ) ) { try { const origin = JSON.parse(response.text); if (origin.content && origin.content.slots) { origin.content.slots = filterSlots(origin.content.slots); } response.text = JSON.stringify(origin); } catch (error) { console.error(error); } return callback(response); } // 전체 방송-라이브 페이지 필터링 else if (url.includes("api.chzzk.naver.com/service/v1/lives")) { filterResponseData(response, "liveCategoryValue", "channel.channelId"); } // 전체 방송-동영상 페이지 필터링 else if (url.includes("api.chzzk.naver.com/service/v1/home/videos")) { filterResponseData(response, "videoCategoryValue", "channel.channelId"); } // 인기 클립 페이지 필터링 else if ( url.includes("api.chzzk.naver.com/service/v1/home/recommended/clips") ) { filterResponseData(response, null, "ownerChannelId"); } // 카테고리 리스트 페이지 필터링 else if (url.includes("api.chzzk.naver.com/service/v1/categories/live")) { filterResponseData(response, "categoryValue", null); } // 카테고리별 라이브 페이지 필터링 else if ( url.includes("api.chzzk.naver.com/service/v2/categories/") && url.includes("/lives") ) { filterResponseData(response, "liveCategoryValue", "channel.channelId"); } // 카테고리별 동영상 페이지 필터링 else if ( url.includes("api.chzzk.naver.com/service/v2/categories/") && url.includes("/videos") ) { filterResponseData(response, "videoCategoryValue", "channel.channelId"); } // 카테고리별 클립 페이지 필터링 else if ( url.includes("api.chzzk.naver.com/service/v2/categories/") && url.includes("/clips") ) { filterResponseData(response, null, "ownerChannelId"); } return callback(response); }); // ######################################################### // 설정 에디터 UI 생성 및 동작 // ######################################################### const COLORS = { chzzkGreen: "rgb(0, 230, 147)", background: { primary: "#141517", secondary: "#1e2022", hover: "hsla(0, 0%, 100%, 0.05)", overlay: "rgba(0, 0, 0, 0.8)", }, text: { primary: "#ffffff", secondary: "#cccccc", muted: "#888888", }, border: { primary: "#444444", secondary: "#666666", }, shadow: { light: "rgba(0, 0, 0, 0.18)", medium: "rgba(0, 0, 0, 0.3)", dark: "rgba(0, 0, 0, 0.5)", overlay: "rgba(0, 0, 0, 0.8)", }, }; // 물음표 아이콘과 툴팁 생성 함수 const createQuestionIcon = (description) => { const questionIcon = document.createElement("div"); questionIcon.innerHTML = `<svg width="16" height="16" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M0.877075 7.49972C0.877075 3.84204 3.84222 0.876892 7.49991 0.876892C11.1576 0.876892 14.1227 3.84204 14.1227 7.49972C14.1227 11.1574 11.1576 14.1226 7.49991 14.1226C3.84222 14.1226 0.877075 11.1574 0.877075 7.49972ZM7.49991 1.82689C4.36689 1.82689 1.82708 4.36671 1.82708 7.49972C1.82708 10.6327 4.36689 13.1726 7.49991 13.1726C10.6329 13.1726 13.1727 10.6327 13.1727 7.49972C13.1727 4.36671 10.6329 1.82689 7.49991 1.82689ZM8.24993 10.5C8.24993 10.9142 7.91414 11.25 7.49993 11.25C7.08571 11.25 6.74993 10.9142 6.74993 10.5C6.74993 10.0858 7.08571 9.75 7.49993 9.75C7.91414 9.75 8.24993 10.0858 8.24993 10.5ZM6.05003 6.25C6.05003 5.57211 6.63511 4.925 7.50003 4.925C8.36496 4.925 8.95003 5.57211 8.95003 6.25C8.95003 6.74118 8.68002 6.99212 8.21447 7.27494C8.16251 7.30651 8.10258 7.34131 8.03847 7.37854L8.03841 7.37858C7.85521 7.48497 7.63788 7.61119 7.47449 7.73849C7.23214 7.92732 6.95003 8.23198 6.95003 8.7C6.95004 9.00376 7.19628 9.25 7.50004 9.25C7.8024 9.25 8.04778 9.00601 8.05002 8.70417L8.05056 8.7033C8.05924 8.6896 8.08493 8.65735 8.15058 8.6062C8.25207 8.52712 8.36508 8.46163 8.51567 8.37436L8.51571 8.37433C8.59422 8.32883 8.68296 8.27741 8.78559 8.21506C9.32004 7.89038 10.05 7.35382 10.05 6.25C10.05 4.92789 8.93511 3.825 7.50003 3.825C6.06496 3.825 4.95003 4.92789 4.95003 6.25C4.95003 6.55376 5.19628 6.8 5.50003 6.8C5.80379 6.8 6.05003 6.55376 6.05003 6.25Z" fill="#cccccc" /> </svg>`; questionIcon.style.width = "16px"; questionIcon.style.height = "16px"; questionIcon.style.display = "flex"; questionIcon.style.alignItems = "center"; questionIcon.style.justifyContent = "center"; questionIcon.style.cursor = "help"; questionIcon.style.display = "flex"; questionIcon.style.transform = "translateY(1.5px)"; questionIcon.className = "question-icon"; // 툴팁 생성 const tooltip = document.createElement("div"); tooltip.textContent = description; tooltip.style.position = "absolute"; tooltip.style.backgroundColor = COLORS.background.secondary; tooltip.style.color = COLORS.text.primary; tooltip.style.padding = "8px 12px"; tooltip.style.borderRadius = "6px"; tooltip.style.fontSize = "12px"; tooltip.style.maxWidth = "250px"; tooltip.style.whiteSpace = "normal"; tooltip.style.lineHeight = "1.4"; tooltip.style.boxShadow = `0 2px 8px ${COLORS.shadow.medium}`; tooltip.style.zIndex = "100001"; tooltip.style.pointerEvents = "none"; tooltip.style.opacity = "0"; tooltip.style.transition = "opacity 0.2s"; tooltip.style.border = `1px solid ${COLORS.border.primary}`; let tooltipTimeout = null; // 물음표 아이콘에 툴팁 이벤트 추가 questionIcon.onmouseenter = (e) => { if (tooltipTimeout) { clearTimeout(tooltipTimeout); tooltipTimeout = null; } if (!document.body.contains(tooltip)) { const rect = questionIcon.getBoundingClientRect(); tooltip.style.left = rect.left + "px"; tooltip.style.top = rect.bottom + 5 + "px"; document.body.appendChild(tooltip); } tooltip.style.opacity = "1"; }; questionIcon.onmouseleave = () => { tooltip.style.opacity = "0"; tooltipTimeout = setTimeout(() => { if (tooltip.parentNode) { tooltip.parentNode.removeChild(tooltip); } tooltipTimeout = null; }, 200); }; return questionIcon; }; const editor = (() => { let editorEl = null; let backdropEl = null; let isOpen = false; // 에디터 생성 함수 const createEditor = () => { // 배경 생성 backdropEl = document.createElement("div"); backdropEl.style.position = "fixed"; backdropEl.style.top = "0"; backdropEl.style.left = "0"; backdropEl.style.width = "100%"; backdropEl.style.height = "100%"; backdropEl.style.backgroundColor = COLORS.shadow.dark; backdropEl.style.zIndex = "99998"; backdropEl.style.display = "none"; // 에디터 컨테이너 생성 editorEl = document.createElement("div"); editorEl.style.position = "fixed"; editorEl.style.top = "50%"; editorEl.style.left = "50%"; editorEl.style.transform = "translate(-50%, -50%)"; editorEl.style.width = "600px"; editorEl.style.maxWidth = "90%"; editorEl.style.maxHeight = "90vh"; if (window.innerWidth <= 768) { editorEl.style.width = "95%"; editorEl.style.maxWidth = "95%"; editorEl.style.maxHeight = "95vh"; editorEl.style.padding = "15px"; } editorEl.style.backgroundColor = COLORS.background.primary; editorEl.style.borderRadius = "8px"; editorEl.style.padding = "20px"; editorEl.style.zIndex = "99999"; editorEl.style.display = "none"; editorEl.style.boxShadow = `0 4px 20px ${COLORS.shadow.dark}`; editorEl.style.color = COLORS.text.primary; editorEl.style.fontFamily = "-apple-system,BlinkMacSystemFont,Malgun Gothic,맑은 고딕,Helvetica,Arial,sans-serif"; editorEl.style.overflowY = "auto"; editorEl.style.overflowX = "hidden"; editorEl.id = "chzzk-filter-editor"; editorEl.style.scrollbarWidth = "thin"; editorEl.style.scrollbarColor = `${COLORS.border.secondary} ${COLORS.background.primary}`; if (!document.getElementById("chzzk-filter-scrollbar-style")) { const scrollbarStyle = document.createElement("style"); scrollbarStyle.id = "chzzk-filter-scrollbar-style"; scrollbarStyle.textContent = ` #chzzk-filter-editor::-webkit-scrollbar { width: 8px; } #chzzk-filter-editor::-webkit-scrollbar-track { background: ${COLORS.background.primary}; border-radius: 4px; } #chzzk-filter-editor::-webkit-scrollbar-thumb { background: ${COLORS.border.secondary}; border-radius: 4px; } #chzzk-filter-editor::-webkit-scrollbar-thumb:hover { background: ${COLORS.border.primary}; } `; document.head.appendChild(scrollbarStyle); } if (!document.getElementById("chzzk-filter-placeholder-style")) { const placeholderStyle = document.createElement("style"); placeholderStyle.id = "chzzk-filter-placeholder-style"; placeholderStyle.textContent = ` #chzzk-filter-editor textarea::placeholder { white-space: pre-wrap !important; line-height: 1.4; } `; document.head.appendChild(placeholderStyle); } // 필터 목록 입력란 생성 함수 const createSection = ( title, description, storageKey, placeholder, isEnabled, ) => { const section = document.createElement("div"); section.style.marginBottom = "20px"; // 헤더 영역 const header = document.createElement("div"); header.style.display = "flex"; header.style.justifyContent = "space-between"; header.style.alignItems = "flex-start"; header.style.marginBottom = "10px"; // 제목과 설명 컨테이너 const titleContainer = document.createElement("div"); titleContainer.style.flex = "1"; titleContainer.style.marginRight = "15px"; titleContainer.style.display = "flex"; titleContainer.style.alignItems = "center"; titleContainer.style.gap = "4px"; titleContainer.style.minWidth = "0"; // flex 아이템이 줄어들 수 있도록 const titleLabel = document.createElement("div"); titleLabel.textContent = title; titleLabel.style.fontWeight = "bold"; titleLabel.style.fontSize = "16px"; titleLabel.style.margin = "0"; titleLabel.style.color = COLORS.text.primary; titleLabel.style.whiteSpace = "nowrap"; // 제목은 줄바꿈 방지 titleLabel.style.flexShrink = "0"; // 제목은 줄어들지 않도록 // 물음표 아이콘 생성 const questionIcon = createQuestionIcon(description); titleContainer.appendChild(titleLabel); titleContainer.appendChild(questionIcon); // 토글 스위치 const toggle = document.createElement("div"); toggle.style.position = "relative"; toggle.style.width = "40px"; toggle.style.height = "20px"; toggle.style.backgroundColor = isEnabled ? COLORS.chzzkGreen : COLORS.border.secondary; toggle.style.borderRadius = "10px"; toggle.style.cursor = "pointer"; toggle.style.transition = "background-color 0.2s"; toggle.style.flexShrink = "0"; const toggleButton = document.createElement("div"); toggleButton.style.position = "absolute"; toggleButton.style.top = "2px"; toggleButton.style.left = isEnabled ? "22px" : "2px"; toggleButton.style.width = "16px"; toggleButton.style.height = "16px"; toggleButton.style.backgroundColor = "white"; toggleButton.style.borderRadius = "50%"; toggleButton.style.transition = "left 0.2s"; toggleButton.style.boxShadow = `0 2px 4px ${COLORS.shadow.light}`; toggle.appendChild(toggleButton); header.appendChild(titleContainer); header.appendChild(toggle); const textarea = document.createElement("textarea"); textarea.id = `editor-${storageKey}`; textarea.style.width = "100%"; textarea.style.height = "120px"; textarea.style.padding = "10px"; textarea.style.borderRadius = "4px"; textarea.style.border = `1px solid ${COLORS.border.primary}`; textarea.style.backgroundColor = isEnabled ? COLORS.background.secondary : COLORS.background.primary; textarea.style.resize = "vertical"; textarea.style.fontFamily = "-apple-system,BlinkMacSystemFont,Malgun Gothic,맑은 고딕,Helvetica,Arial,sans-serif"; textarea.style.fontSize = "13px"; textarea.style.overflowX = "auto"; textarea.style.whiteSpace = "nowrap"; textarea.placeholder = placeholder; textarea.disabled = !isEnabled; textarea.style.opacity = isEnabled ? "1" : "0.5"; // localStorage에서 데이터 불러오기 try { const savedData = localStorage.getItem(storageKey); if (savedData !== null && savedData.trim() !== "") { textarea.value = savedData; } else if (storageKey === "slots") { // 슬롯은 기본값 표시 textarea.value = defaultSlots.join("\n"); } } catch (e) { // localStorage 접근 에러 시 (사생활 보호 모드 등) if (storageKey === "slots") { textarea.value = defaultSlots.join("\n"); } } // 토글 클릭 이벤트 toggle.onclick = () => { const configs = { tags: { getter: isTagFilterEnabled, setter: (value) => setState("tagFilterEnabled", value), }, channels: { getter: isChannelFilterEnabled, setter: (value) => setState("channelFilterEnabled", value), }, slots: { getter: isSlotFilterEnabled, setter: (value) => setState("slotFilterEnabled", value), }, }; const config = configs[storageKey]; if (config) { const newState = !config.getter(); config.setter(newState); // UI 업데이트 toggle.style.backgroundColor = newState ? COLORS.chzzkGreen : COLORS.border.secondary; toggleButton.style.left = newState ? "22px" : "2px"; textarea.disabled = !newState; textarea.style.opacity = newState ? "1" : "0.5"; textarea.style.backgroundColor = newState ? COLORS.background.secondary : COLORS.background.primary; } }; section.appendChild(header); section.appendChild(textarea); return section; }; // 태그, 채널, 카테고리 입력란 editorEl.appendChild( createSection( "1. 태그 필터", "필터링하고 싶은 태그를 한 줄에 하나씩 입력하세요.", "tags", "예시)\n종합 게임\n버튜버", isTagFilterEnabled(), ), ); editorEl.appendChild( createSection( "2. 채널 필터", "필터링하고 싶은 채널 URL 또는 ID를 한 줄에 하나씩 입력하세요.", "channels", "예시)\nhttps://chzzk.naver.com/channel/1234567890\n1234567890", isChannelFilterEnabled(), ), ); editorEl.appendChild( createSection( "3. 슬롯 필터", "허용하고 싶은 슬롯을 한 줄에 하나씩 입력하세요.", "slots", "예시)\n팔로잉 채널 라이브\n이 방송 어때요?\n이런 카테고리는 어때요?", isSlotFilterEnabled(), ), ); // 배너 차단 토글 섹션 const bannerToggleSection = document.createElement("div"); bannerToggleSection.style.marginBottom = "20px"; const bannerHeader = document.createElement("div"); bannerHeader.style.display = "flex"; bannerHeader.style.justifyContent = "space-between"; bannerHeader.style.alignItems = "flex-start"; bannerHeader.style.marginBottom = "10px"; const bannerLabelContainer = document.createElement("div"); bannerLabelContainer.style.display = "flex"; bannerLabelContainer.style.alignItems = "center"; bannerLabelContainer.style.gap = "4px"; bannerLabelContainer.style.minWidth = "0"; const bannerLabel = document.createElement("label"); bannerLabel.textContent = "4. 상단 배너 차단"; bannerLabel.style.fontWeight = "bold"; bannerLabel.style.fontSize = "16px"; bannerLabel.style.color = COLORS.text.primary; bannerLabel.style.margin = "0"; bannerLabel.style.whiteSpace = "nowrap"; bannerLabel.style.flexShrink = "0"; const bannerQuestionIcon = createQuestionIcon( "페이지 상단의 광고 배너를 숨깁니다. 배너가 없는 경우에는 영향이 없습니다.", ); bannerLabelContainer.appendChild(bannerLabel); bannerLabelContainer.appendChild(bannerQuestionIcon); // 배너 차단 토글 스위치 const bannerToggle = document.createElement("div"); bannerToggle.style.position = "relative"; bannerToggle.style.width = "40px"; bannerToggle.style.height = "20px"; bannerToggle.style.backgroundColor = isBannerFilterEnabled() ? COLORS.chzzkGreen : COLORS.border.secondary; bannerToggle.style.borderRadius = "10px"; bannerToggle.style.cursor = "pointer"; bannerToggle.style.transition = "background-color 0.2s"; bannerToggle.style.flexShrink = "0"; bannerToggle.setAttribute("data-storage-key", "banner"); const bannerToggleButton = document.createElement("div"); bannerToggleButton.style.position = "absolute"; bannerToggleButton.style.top = "2px"; bannerToggleButton.style.left = isBannerFilterEnabled() ? "22px" : "2px"; bannerToggleButton.style.width = "16px"; bannerToggleButton.style.height = "16px"; bannerToggleButton.style.backgroundColor = "white"; bannerToggleButton.style.borderRadius = "50%"; bannerToggleButton.style.transition = "left 0.2s"; bannerToggleButton.style.boxShadow = `0 2px 4px ${COLORS.shadow.light}`; bannerToggle.appendChild(bannerToggleButton); // 배너 토글 클릭 이벤트 bannerToggle.onclick = () => { const newState = !isBannerFilterEnabled(); setState("bannerFilterEnabled", newState); bannerToggle.style.backgroundColor = newState ? COLORS.chzzkGreen : COLORS.border.secondary; bannerToggleButton.style.left = newState ? "22px" : "2px"; }; bannerHeader.appendChild(bannerLabelContainer); bannerHeader.appendChild(bannerToggle); bannerToggleSection.appendChild(bannerHeader); editorEl.appendChild(bannerToggleSection); // 하단 버튼 영역 const bottomActions = document.createElement("div"); bottomActions.style.display = "flex"; bottomActions.style.flexDirection = "row"; bottomActions.style.gap = "10px"; bottomActions.style.marginTop = "20px"; // 적용 버튼 const applyBtn = document.createElement("button"); applyBtn.textContent = "적용"; applyBtn.style.padding = "12px 16px"; applyBtn.style.borderRadius = "4px"; applyBtn.style.border = "none"; applyBtn.style.backgroundColor = COLORS.chzzkGreen; applyBtn.style.color = COLORS.background.primary; applyBtn.style.cursor = "pointer"; applyBtn.style.fontWeight = "bold"; applyBtn.style.fontSize = "14px"; applyBtn.style.flex = "1"; applyBtn.onclick = () => { // localStorage에 입력값 저장 localStorage.setItem( "tags", document.getElementById("editor-tags").value, ); localStorage.setItem( "channels", document.getElementById("editor-channels").value, ); localStorage.setItem( "slots", document.getElementById("editor-slots").value, ); // 즉시 새로고침 location.reload(); }; // 닫기 버튼 const closeBtn = document.createElement("button"); closeBtn.textContent = "닫기"; closeBtn.style.padding = "12px 16px"; closeBtn.style.borderRadius = "4px"; closeBtn.style.border = "1px solid #dc3545"; closeBtn.style.backgroundColor = "transparent"; closeBtn.style.color = "#dc3545"; closeBtn.style.cursor = "pointer"; closeBtn.style.fontWeight = "bold"; closeBtn.style.fontSize = "14px"; closeBtn.onclick = close; bottomActions.appendChild(applyBtn); bottomActions.appendChild(closeBtn); editorEl.appendChild(bottomActions); document.body.appendChild(backdropEl); document.body.appendChild(editorEl); }; // 에디터 열기/닫기/토글 함수 const open = () => { if (!editorEl) { createEditor(); } backdropEl.style.display = "block"; editorEl.style.display = "block"; isOpen = true; }; const close = () => { if (backdropEl && editorEl) { backdropEl.style.display = "none"; editorEl.style.display = "none"; } isOpen = false; }; const toggle = () => { if (isOpen) { close(); isOpen = false; } else { open(); isOpen = true; } }; return toggle; })(); // 툴바에 에디터 열기 버튼 추가 const editorOpenButton = document.createElement("div"); editorOpenButton.style.marginRight = "1px"; editorOpenButton.style.color = "inherit"; editorOpenButton.style.flex = "none"; editorOpenButton.style.position = "relative"; editorOpenButton.style.width = "40px"; editorOpenButton.style.height = "40px"; editorOpenButton.style.borderRadius = "4px"; editorOpenButton.style.display = "flex"; editorOpenButton.style.alignItems = "center"; editorOpenButton.style.justifyContent = "center"; editorOpenButton.style.cursor = "pointer"; editorOpenButton.innerHTML = `<svg width="25" height="25" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.91" d="M12,22.5h0A11.87,11.87,0,0,1,2.45,10.86V3.41L12,1.5l9.55,1.91v7.45A11.87,11.87,0,0,1,12,22.5Z"/> <circle fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.91" cx="12" cy="12" r="4.77"/> <line fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.91" x1="15.38" y1="8.62" x2="8.66" y2="15.34"/> </svg>`; editorOpenButton.onclick = editor; const span = document.createElement("span"); span.style.fontSize = "12px"; span.style.fontWeight = "400"; span.style.left = "50%"; span.style.letterSpacing = "-.3px"; span.style.lineHeight = "17px"; span.style.height = "27px"; span.style.position = "absolute"; span.style.top = "calc(100% + 2px)"; span.style.whiteSpace = "nowrap"; span.style.backgroundColor = "#2e3033"; span.style.color = "#dfe2ea"; span.style.borderRadius = "6px"; span.style.boxShadow = "0 2px 2px rgba(0,0,0,.3), 0 2px 6px 2px rgba(0,0,0,.2)"; span.style.display = "none"; span.style.alignItems = "center"; span.style.justifyContent = "center"; span.style.padding = "0 9px"; span.style.transform = "translateX(-50%)"; span.innerText = "치지직 필터"; editorOpenButton.appendChild(span); editorOpenButton.onmouseover = () => { editorOpenButton.style.setProperty( "background-color", COLORS.background.hover, "important", ); span.style.display = "inline-flex"; }; editorOpenButton.onmouseout = () => { editorOpenButton.style.setProperty( "background-color", "transparent", "important", ); span.style.display = "none"; }; editorOpenButton.id = "chzzk-filter-editor-open"; // ######################################################### // 통합 MutationObserver // ######################################################### const startObserver = () => { const insertButton = () => { const existingButton = document.getElementById( "chzzk-filter-editor-open", ); if (existingButton) { const toolbarContainer = existingButton.closest( '[class*="toolbar_section"]', ); if (toolbarContainer) { return true; } existingButton.remove(); } const toolbarContainer = document.querySelector( '[class*="toolbar_section"]', ); if (!toolbarContainer) { return false; } const toolbarBoxes = toolbarContainer.querySelectorAll( '[class*="toolbar_box"]', ); if (toolbarBoxes.length > 0) { const lastBox = toolbarBoxes[toolbarBoxes.length - 1]; lastBox.insertBefore(editorOpenButton, lastBox.firstChild); return true; } return false; }; const observer = new MutationObserver((mutations) => { const isToolbarRelated = mutations.some((mutation) => { return Array.from(mutation.addedNodes || []).some((node) => { if (node.nodeType === Node.ELEMENT_NODE) { return ( node.querySelector('[class*="toolbar_section"]') || node.matches?.('[class*="toolbar_section"]') ); } return false; }); }); if (!isToolbarRelated) { return; } insertButton(); }); observer.observe(document.body, { childList: true, subtree: true, }); }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", startObserver); } else { startObserver(); } })();