// ==UserScript== // @name Chzzk Auto Quality & 광고 팝업 제거 // @namespace http://tampermonkey.net/ // @version 2.0 // @icon https://play-lh.googleusercontent.com/wvo3IB5dTJHyjpIHvkdzpgbFnG3LoVsqKdQ7W3IoRm-EVzISMz9tTaIYoRdZm1phL_8 // @description Chzzk 자동 선호 화질 설정, 광고 팝업 제거 및 스크롤 잠금 해제 // @match https://chzzk.naver.com/* // @grant none // @require https://unpkg.com/xhook@latest/dist/xhook.min.js // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; // ----- 설정(JSON) ----- const CONFIG = { styles: { bold: 'font-weight:bold', success: 'font-weight:bold; color:green', error: 'font-weight:bold; color:red', info: 'font-weight:bold; color:skyblue', warn: 'font-weight:bold; color:orange' }, minTimeout: 500, defaultTimeout: 2000, storageKey: 'chzzkPreferredQuality', selectors: { popup: 'div[class^="popup_container"]', qualityBtn: 'button[class*="pzp-pc-setting-button"]', qualityMenu: 'div[class*="pzp-pc-setting-intro-quality"]', qualityItems: 'li[class*="quality-item"], li[class*="quality"]' } }; const { styles, minTimeout, defaultTimeout, storageKey, selectors } = CONFIG; console.log(`%c🔔 [Chzzk] 스크립트 로드 완료`, styles.info); const minTimeoutSec = (minTimeout / 1000); console.log( `%c⚠️ [Guide] timeout은 최소 ${minTimeoutSec}초 (${minTimeout}ms) 이상이어야 하며, ` + `이보다 작으면 자동 조정됩니다. 이 경우 재생이 멈추거나 품질 목록을 찾지 못할 수 있습니다.`, styles.warn ); // ----- 팝업 제거 ----- function handleAdBlockPopup() { const popup = document.querySelector(selectors.popup); if (popup && popup.textContent.includes('광고 차단 프로그램을 사용 중이신가요')) { popup.remove(); document.body.removeAttribute('style'); console.log(`%c✅ [AdBlockPopup] 팝업 제거됨`, styles.success); } } // ----- 요소 대기 헬퍼 ----- function waitFor(selector, timeout = defaultTimeout) { const effective = Math.max(timeout, minTimeout); if (timeout < minTimeout) { console.warn(`%c⚠️ [waitFor] timeout이 최소값(${minTimeout}ms) 미만이어서 ${minTimeout}ms로 보정되었습니다.`, styles.warn); } return new Promise((resolve, reject) => { const el = document.querySelector(selector); if (el) return resolve(el); const mo = new MutationObserver(() => { const found = document.querySelector(selector); if (found) { mo.disconnect(); resolve(found); } }); mo.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { mo.disconnect(); reject(new Error('Timeout')); }, effective); }); } // ----- 수동 화질 선택 감지 & 저장 ----- function observeManualQualitySelect() { document.body.addEventListener('click', e => { const li = e.target.closest('li[class*="quality"]'); if (!li) return; const chosen = li.textContent.trim(); localStorage.setItem(storageKey, chosen); console.log(`%c💾 [Quality] 수동 화질 선택 저장: ${chosen}`, styles.success); }, { capture: true }); } // ----- 저장된 선호 화질 불러오기 ----- function getPreferredQuality() { const pref = localStorage.getItem(storageKey); if (pref) { console.log(`%c🔍 [Quality] 로컬 저장 화질 불러옴: ${pref}`, styles.info); return pref; } return '1080p'; } // ----- 자동 화질 선택 ----- async function selectPreferredQuality() { const target = getPreferredQuality(); console.log(`%c⚙️ [Quality] '${target}' 자동 선택 시도`, styles.info); try { const btn = await waitFor(selectors.qualityBtn); btn.click(); const menu = await waitFor(selectors.qualityMenu); menu.click(); await new Promise(r => setTimeout(r, minTimeout)); const items = Array.from(document.querySelectorAll(selectors.qualityItems)); let pick = items.find(i => i.textContent.includes(target)); if (!pick) { const regex = /\d+p/; pick = items.find(i => regex.test(i.textContent)); } if (!pick && items.length) { pick = items[0]; } if (pick) { pick.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); console.log(`%c✅ [Quality] '${pick.textContent.trim()}' 선택 완료`, styles.success); } else { console.warn(`%c⚠️ [Quality] 품질 목록을 찾지 못했습니다. 셀렉터나 timeout을 확인하세요.`, styles.warn); } } catch (e) { console.error(`%c❌ [Quality] 자동 선택 중 에러: ${e.message}`, styles.error); } } // ----- xhook 후크: P2P 제한 해제 & 화질 선택 ----- xhook.after((req, res) => { if (req.url.includes('live-detail')) { try { const data = JSON.parse(res.text); if (data.content?.p2pQuality) { data.content.p2pQuality = []; Object.defineProperty(data.content, 'p2pQuality', { writable: false }); } res.text = JSON.stringify(data); } catch (err) { console.error(`%c❌ [xhook] JSON 처리 오류: ${err.message}`, styles.error); } setTimeout(selectPreferredQuality, minTimeout); } }); // ----- 방송 ID 추출 및 비교 ----- let lastVideoId = null; function getVideoIdFromUrl(url) { const match = url.match(/live\/([\w-]+)/); return match ? match[1] : null; } // ----- SPA URL 변경 감지 ----- (function watchUrlChange() { let lastUrl = location.href; let lastVideoId = null; function getVideoIdFromUrl(url) { const match = url.match(/live\/([\w-]+)/); return match ? match[1] : null; } const onChange = () => { if (location.href !== lastUrl) { console.log(`%c🔄 [URLChange] ${lastUrl} → ${location.href}`, styles.info); const newVideoId = getVideoIdFromUrl(location.href); lastUrl = location.href; // 방송 ID가 있을 경우에만 비교 if (newVideoId) { if (newVideoId !== lastVideoId) { lastVideoId = newVideoId; setTimeout(selectPreferredQuality, minTimeout); } else { console.log(`%c⏩ [URLChange] 같은 방송(${newVideoId}), 품질 재설정 생략`, styles.warn); } } else { console.log(`%cℹ️ [URLChange] 방송 ID 없음(${location.href}), 품질 설정 건너뜀`, styles.info); } } }; const _push = history.pushState; history.pushState = function() { _push.apply(this, arguments); onChange(); }; const _replace = history.replaceState; history.replaceState = function() { _replace.apply(this, arguments); onChange(); }; window.addEventListener('popstate', onChange); })(); // ----- 팝업 감시 ----- let observer; function startObserver() { if (observer) observer.disconnect(); observer = new MutationObserver(handleAdBlockPopup); observer.observe(document.body, { childList: true, subtree: true }); console.log(`%c🔍 [Observer] 팝업 감시 시작`, styles.bold); } // ----- 초기화 ----- observeManualQualitySelect(); startObserver(); })();