Greasy Fork

Greasy Fork is available in English.

Chzzk Auto Quality & 광고 팝업 제거 + 음소거 설정

Chzzk 자동 선호 화질 설정, 광고 팝업 제거, 음소거 자동 설정/해제 및 스크롤 잠금 해제

目前为 2025-05-13 提交的版本。查看 最新版本

// ==UserScript==
// @name         Chzzk Auto Quality & 광고 팝업 제거 + 음소거 설정
// @namespace    http://tampermonkey.net/
// @version      3.4.2
// @description  Chzzk 자동 선호 화질 설정, 광고 팝업 제거, 음소거 자동 설정/해제 및 스크롤 잠금 해제
// @match        https://chzzk.naver.com/*
// @icon         https://chzzk.naver.com/favicon.ico
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(async () => {
    'use strict';

    let isApplying = false;
    let lastApplyTime = 0;
    const APPLY_COOLDOWN = 1000;

    const CONFIG = {
        minTimeout: 500,
        defaultTimeout: 2000,
        storageKeys: {
            quality: 'chzzkPreferredQuality',
            autoUnmute: 'chzzkAutoUnmute'
        },
        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"]'
        },
        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'));
                    }, effective);
                });
            }
        },
        text: {
            clean: txt => txt.trim().split(/\s+/).filter(Boolean).join(', '),
            extractResolution: txt => {
                const match = txt.match(/(\d{3,4})p/);
                return match ? parseInt(match[1], 10) : null;
            }
        },
        dom: {
            remove: el => el?.remove(),
            clearStyle: el => el?.removeAttribute('style')
        },
        log: {
            info: msg => console.log(`%c${msg}`, CONFIG.styles.info),
            success: msg => 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 checkAndRun = () => {
                const el = document.querySelector(selector);
                if (el) {
                    callback(el);
                    if (once) observer.disconnects[selector]?.();
                }
            };
            const mo = new MutationObserver(checkAndRun);
            mo.observe(document.body, { childList: true, subtree: true });
            observer.disconnects[selector] = () => mo.disconnect();
            checkAndRun();
        }
    };

    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 (isApplying || now - lastApplyTime < APPLY_COOLDOWN) return;
            isApplying = true;
            lastApplyTime = 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 ? '자동 (Enter 이벤트)' : '없음' }]);
            console.groupEnd();

            isApplying = 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(method => {
                const orig = history[method];
                history[method] = function() {
                    const result = orig.apply(this, arguments);
                    onChange();
                    return result;
                };
            });
            window.addEventListener('popstate', onChange);
        }
    };

    const observer = {
        disconnects: {},
        start() {
            const mo = new MutationObserver(mutations => {
                for (const mutation of mutations) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType !== 1) continue;
                        if (node.matches?.(CONFIG.selectors.popup) && common.regex.adBlockDetect.test(node.textContent)) {
                            common.dom.remove(node);
                            common.dom.clearStyle(document.body);
                            common.log.success('[AdPopup] 팝업 제거됨');
                        }
                        if (node.tagName === 'VIDEO' || node.querySelector?.('video')) {
                            observer.unmuteAll(node.tagName === 'VIDEO' ? node : node.querySelector('video'));
                        }
                    }
                }
                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) {
                common.log.info('[Unmute] 설정에 따라 자동 해제 스킵');
                return;
            }
            if (video && video.muted) {
                video.muted = false;
                common.log.success('[Unmute] 새 비디오 muted 속성 해제됨');
            }
            const btn = document.querySelector('button.pzp-pc-volume-button[aria-label*="음소거 해제"]');
            if (btn) {
                btn.click();
                common.log.success('[Unmute] "음소거 해제" 버튼 클릭');
            }
        }
    };

    async function init() {
        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 저장됨');
        }

        GM.registerMenuCommand('음소거 자동 해제 토글', async () => {
            const current = await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
            await GM.setValue(CONFIG.storageKeys.autoUnmute, !current);
            alert(`음소거 자동 해제: ${!current ? 'ON' : 'OFF'}\n\n페이지를 새로 고침하여 변경사항을 적용합니다.`);
            location.reload();
        });

        await quality.applyPreferred();
    }

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

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

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