Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)

Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제

// ==UserScript==
// @name         Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)
// @namespace    http://tampermonkey.net/
// @version      3.5.4
// @description  Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제
// @match        https://chzzk.naver.com/*
// @icon         https://chzzk.naver.com/favicon.ico
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        unsafeWindow
// @run-at       document-start
// @license      MIT
// ==/UserScript==
;(function(){
    const originalRemoveChild = Node.prototype.removeChild;
    Node.prototype.removeChild = function(child) {
        if (!child || child.parentNode !== this) return child;
        return originalRemoveChild.call(this, child);
    };
})();

(async () => {
    'use strict';
    const APPLY_COOLDOWN = 1000;
    const CONFIG = {
        minTimeout: 500,
        defaultTimeout: 2000,
        storageKeys: {
            quality: 'chzzkPreferredQuality',
            autoUnmute: 'chzzkAutoUnmute',
            debugLog: 'chzzkDebugLog'
        },
        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"]',
            headerMenu: '.header_service__DyG7M'
        },
        styles: {
            success: 'font-weight:bold; color:green',
            error: 'font-weight:bold; color:red',
            info: 'font-weight:bold; color:skyblue',
            warn: 'font-weight:bold; color:orange'
        }
    };

    const common = {
        regex: {
            adBlockDetect: /광고\s*차단\s*프로그램.*사용\s*중/i
        },
        async: {
            sleep: ms => new Promise(r => setTimeout(r, ms)),
            waitFor: (selector, timeout = CONFIG.defaultTimeout) => {
                const effective = Math.max(timeout, CONFIG.minTimeout);
                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 waiting for ' + selector));
                    }, effective);
                });
            }
        },
        text: {
            clean: txt => txt.trim().split(/\s+/).filter(Boolean).join(', '),
            extractResolution: txt => {
                const m = txt.match(/(\d{3,4})p/);
                return m ? parseInt(m[1], 10) : null;
            }
        },
        dom: {
            remove: el => el?.remove(),
            clearStyle: el => el?.removeAttribute('style')
        },
        log: {
            DEBUG: true,
            info: msg => common.log.DEBUG && console.log(`%c${msg}`, CONFIG.styles.info),
            success: msg => common.log.DEBUG && console.log(`%c${msg}`, CONFIG.styles.success),
            warn: msg => console.warn(`%c${msg}`, CONFIG.styles.warn),
            error: msg => console.error(`%c${msg}`, CONFIG.styles.error)
        },
        observeElement: (selector, callback, once = true) => {
            const mo = new MutationObserver(() => {
                const el = document.querySelector(selector);
                if (el) callback(el);
                if (once) mo.disconnect();
            });
            mo.observe(document.body, { childList: true, subtree: true });
            const initial = document.querySelector(selector);
            if (initial) {
                callback(initial);
                if (once) mo.disconnect();
            }
        }
    };

    const TOGGLE_CLASS = 'chzzk-helper-toggle';

    async function addHeaderMenu() {
        const header = await common.async.waitFor(CONFIG.selectors.headerMenu);
        if (header.querySelector(`.${TOGGLE_CLASS}`)) return;

        const separator = document.createElement('div');
        separator.classList.add(TOGGLE_CLASS);
        separator.style.cssText = 'width:100%; height:1px; margin:4px 0; background-color:currentColor; opacity:0.2;';
        header.appendChild(separator);

        const items = header.querySelectorAll('a.header_item__MFv39');
        if (!items.length) return;
        const template = items[items.length - 1];

        const debugItem = template.cloneNode(true);
        debugItem.classList.add(TOGGLE_CLASS);
        debugItem.removeAttribute('aria-current');
        const debugSvg = `
  <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
    <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/>
  </svg>`;
        debugItem.querySelector('svg').outerHTML = debugSvg;
        const debugState = common.log.DEBUG;
        debugItem.href = '#';
        debugItem.querySelector('.header_text__SNWKj').textContent = `디버그 로그 ${debugState ? 'ON' : 'OFF'}`;
        debugItem.addEventListener('click', async e => {
            e.preventDefault();
            const newState = !await GM.getValue(CONFIG.storageKeys.debugLog, false);
            await GM.setValue(CONFIG.storageKeys.debugLog, newState);
            alert(`디버그 로그: ${newState ? 'ON' : 'OFF'}\n페이지를 새로고침합니다.`);
            location.reload();
        });
        header.appendChild(debugItem);

        const unmuteItem = template.cloneNode(true);
        unmuteItem.classList.add(TOGGLE_CLASS);
        unmuteItem.removeAttribute('aria-current');
        const unmuteState = await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
        const unmuteSvgOff = `
  <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
    <path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/>
  </svg>`;
        const unmuteSvgOn = `
  <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
    <path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/>
  </svg>`;
        unmuteItem.querySelector('svg').outerHTML = unmuteState ? unmuteSvgOn : unmuteSvgOff;
        unmuteItem.href = '#';
        unmuteItem.querySelector('.header_text__SNWKj').textContent = `음소거 해제 ${unmuteState ? 'ON' : 'OFF'}`;
        unmuteItem.addEventListener('click', async e => {
            e.preventDefault();
            const newState = !await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
            await GM.setValue(CONFIG.storageKeys.autoUnmute, newState);
            alert(`음소거 자동 해제: ${newState ? 'ON' : 'OFF'}\n페이지를 새로고침합니다.`);
            location.reload();
        });
        header.appendChild(unmuteItem);
    }

    const quality = {
        observeManualSelect() {
            document.body.addEventListener('click', async e => {
                const li = e.target.closest('li[class*="quality"]');
                if (!li) return;
                const raw = li.textContent;
                const res = common.text.extractResolution(raw);
                if (res) {
                    await GM.setValue(CONFIG.storageKeys.quality, res);
                    console.groupCollapsed('%c💾 [Quality] 수동 화질 저장됨', CONFIG.styles.success);
                    console.table([{ '선택 해상도': res, '원본': common.text.clean(raw) }]);
                    console.groupEnd();
                }
            }, { capture: true });
        },

        async getPreferred() {
            const stored = await GM.getValue(CONFIG.storageKeys.quality, 1080);
            return parseInt(stored, 10);
        },

        async applyPreferred() {
            const now = Date.now();
            if (this._applying || now - this._lastApply < APPLY_COOLDOWN) return;
            this._applying = true;
            this._lastApply = now;

            const target = await this.getPreferred();
            let cleaned = '(선택 실패)', pick = null;

            try {
                const btn = await common.async.waitFor(CONFIG.selectors.qualityBtn);
                btn.click();
                const menu = await common.async.waitFor(CONFIG.selectors.qualityMenu);
                menu.click();
                await common.async.sleep(CONFIG.minTimeout);

                const items = Array.from(document.querySelectorAll(CONFIG.selectors.qualityItems));
                pick = items.find(i => common.text.extractResolution(i.textContent) === target)
                    || items.find(i => /\d+p/.test(i.textContent))
                    || items[0];
                cleaned = pick ? common.text.clean(pick.textContent) : cleaned;
                if (pick) pick.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
                else common.log.warn('[Quality] 화질 항목을 찾지 못함');
            } catch (e) {
                common.log.error(`[Quality] 선택 실패: ${e.message}`);
            }

            console.groupCollapsed('%c⚙️ [Quality] 자동 화질 적용', CONFIG.styles.info);
            console.table([{ '대상 해상도': target }]);
            console.table([{ '선택 화질': cleaned, '선택 방식': pick ? '자동' : '없음' }]);
            console.groupEnd();

            this._applying = false;
        }
    };

    const handler = {
        interceptXHR() {
            const oOpen = XMLHttpRequest.prototype.open;
            const oSend = XMLHttpRequest.prototype.send;
            XMLHttpRequest.prototype.open = function(m, u, ...a) {
                this._url = u;
                return oOpen.call(this, m, u, ...a);
            };
            XMLHttpRequest.prototype.send = function(body) {
                if (this._url?.includes('live-detail')) {
                    this.addEventListener('readystatechange', () => {
                        if (this.readyState === 4 && this.status === 200) {
                            try {
                                const data = JSON.parse(this.responseText);
                                if (data.content?.p2pQuality) {
                                    data.content.p2pQuality = [];
                                    const mod = JSON.stringify(data);
                                    Object.defineProperty(this, 'responseText', { value: mod });
                                    Object.defineProperty(this, 'response', { value: mod });
                                    setTimeout(() => quality.applyPreferred(), CONFIG.minTimeout);
                                }
                            } catch (e) {
                                common.log.error(`[XHR] JSON 파싱 오류: ${e.message}`);
                            }
                        }
                    });
                }
                return oSend.call(this, body);
            };
            common.log.info('[XHR] live-detail 요청 감시 시작');
        },
        trackURLChange() {
            let lastUrl = location.href, lastId = null;
            const getId = url => (url.match(/live\/([\w-]+)/) ?? [])[1] || null;
            const onChange = () => {
                if (location.href === lastUrl) return;
                common.log.info(`[URLChange] ${lastUrl} → ${location.href}`);
                lastUrl = location.href;
                const id = getId(location.href);
                if (!id) return common.log.info('[URLChange] 방송 ID 없음');
                if (id !== lastId) {
                    lastId = id;
                    setTimeout(() => quality.applyPreferred(), CONFIG.minTimeout);
                } else {
                    common.log.warn(`[URLChange] 같은 방송(${id}), 스킵`);
                }
            };
            ['pushState', 'replaceState'].forEach(m => {
                const orig = history[m];
                history[m] = function() {
                    const res = orig.apply(this, arguments);
                    onChange();
                    return res;
                };
            });
            window.addEventListener('popstate', onChange);
        }
    };

    const observer = {
        start() {
            const mo = new MutationObserver(muts => {
                for (const mut of muts) {
                    for (const node of mut.addedNodes) {
                        if (node.nodeType !== 1) continue;
                        this.tryRemoveAdPopup(node);
                        let vid = null;
                        if (node.tagName === 'VIDEO') vid = node;
                        else if (node.querySelector?.('video')) vid = node.querySelector('video');
                        if (vid) {
                            this.unmuteAll(vid);
                            checkAndFixLowQuality(vid);
                        }
                    }
                }
                if (document.body.style.overflow === 'hidden') {
                    common.dom.clearStyle(document.body);
                    common.log.info('[BodyStyle] overflow:hidden 제거됨');
                }
            });
            mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
            common.log.info('[Observer] 통합 감시 시작');
        },

        async unmuteAll(video) {
            const autoUnmute = await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
            if (!autoUnmute) return common.log.info('[Unmute] 설정에 따라 스킵');
            if (video.muted) {
                video.muted = false;
                common.log.success('[Unmute] video.muted 해제');
            }
            const btn = document.querySelector('button.pzp-pc-volume-button[aria-label*="음소거 해제"]');
            if (btn) {
                btn.click();
                common.log.success('[Unmute] 버튼 클릭');
            }
        },

        async tryRemoveAdPopup(node) {
            try {
                const txt = node.innerText || '';
                if (common.regex.adBlockDetect.test(txt)) {
                    const cont = node.closest(CONFIG.selectors.popup) || node;
                    cont.remove();
                    common.dom.clearStyle(document.body);
                    console.groupCollapsed('%c✅ [AdPopup] 제거 성공', CONFIG.styles.success);
                    console.table([{ '제거된 텍스트': txt.slice(0,100), '클래스': cont.className }]);
                    console.groupEnd();
                }
            } catch (e) {
                common.log.error(`[AdPopup] 제거 실패: ${e.message}`);
            }
        }
    };

	async function checkAndFixLowQuality(video) {
		if (!video || video.__checkedAlready) return;
		video.__checkedAlready = true;

		await common.async.sleep(CONFIG.defaultTimeout);

		let height = video.videoHeight || 0;
		if (height === 0) {
			await common.async.sleep(1000);
			height = video.videoHeight || 0;
		}
		if (height === 0) {
			return;
		}

		if (height <= 360) {
			const preferred = await quality.getPreferred();
			if (preferred !== height) {
				common.log.warn(`[QualityCheck] 저화질(${height}p) 감지, ${preferred}p로 복구`);
				await quality.applyPreferred();
			} else {
				common.log.warn('[QualityCheck] 현재 해상도가 사용자 선호값과 동일하여 복구 생략');
			}
		}
	}

    async function setDebugLogging() {
        common.log.DEBUG = await GM.getValue(CONFIG.storageKeys.debugLog, false);
    }

    async function init() {
        await setDebugLogging();
        if (document.body.style.overflow === 'hidden') {
            common.dom.clearStyle(document.body);
            common.log.success('[Init] overflow 잠금 해제');
        }
        if ((await GM.getValue(CONFIG.storageKeys.quality)) === undefined) {
            await GM.setValue(CONFIG.storageKeys.quality, 1080);
            common.log.success('[Init] 기본 화질 1080 저장');
        }
        if ((await GM.getValue(CONFIG.storageKeys.autoUnmute)) === undefined) {
            await GM.setValue(CONFIG.storageKeys.autoUnmute, true);
            common.log.success('[Init] 기본 언뮤트 ON 저장');
        }
        await addHeaderMenu();
        common.observeElement(CONFIG.selectors.headerMenu, () => {
            addHeaderMenu().catch(console.error);
        }, false);

        await quality.applyPreferred();
    }

    function onDomReady() {
        console.log('%c🔔 [ChzzkHelper] 스크립트 시작', CONFIG.styles.info);
        quality.observeManualSelect();
        observer.start();
        init().catch(console.error);
    }

    handler.interceptXHR();
    handler.trackURLChange();

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', onDomReady);
    } else {
        onDomReady();
    }
})();