Greasy Fork

Greasy Fork is available in English.

Chzzk Auto Quality & 광고 팝업 제거

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

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

// ==UserScript==
// @name         Chzzk Auto Quality & 광고 팝업 제거
// @namespace    http://tampermonkey.net/
// @version      2.2
// @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
// ==/UserScript==

(function () {
	'use strict';

	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);
	console.log(`%c⚠️ [Guide] 최소 timeout은 ${minTimeout}ms 이상이어야 합니다.`, styles.warn);

	function cleanQualityText(raw) {
		return raw.trim().split(/\s+/).filter(Boolean).join(', ');
	}

	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로 보정됨`, 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 extractResolution(text) {
		const match = text.match(/(\d{3,4})p/);
		return match ? parseInt(match[1], 10) : null;
	}

	function observeManualQualitySelect() {
		document.body.addEventListener('click', e => {
			const li = e.target.closest('li[class*="quality"]');
			if (!li) return;
			const raw = li.textContent.trim();
			const resolution = extractResolution(raw);
			if (resolution) {
				localStorage.setItem(storageKey, resolution);
				console.groupCollapsed('%c💾 [Quality] 수동 화질 선택 저장됨', styles.success);
				console.table([{
					'선택한 해상도': resolution,
					'원본 텍스트': cleanQualityText(raw),
					'저장 키': storageKey
				}]);
				console.groupEnd();
			}
		}, { capture: true });
	}

	function getPreferredQuality() {
		const stored = localStorage.getItem(storageKey);
		const pref = stored ? parseInt(stored, 10) : null;

		if (pref) {
			console.groupCollapsed('%c🔍 [Quality] 저장된 선호 화질 불러오기', styles.info);
			console.table([{ '선호 화질': pref, '저장 위치': 'localStorage' }]);
			console.groupEnd();
			return pref;
		}
		return 1080;
	}

	async function selectPreferredQuality() {
		const target = getPreferredQuality();
		console.groupCollapsed('%c⚙️ [Quality] 자동 화질 선택 시작', styles.info);
		console.table([{ '대상 해상도': target }]);

		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 => extractResolution(i.textContent) === target);
			if (!pick) pick = items.find(i => /\d+p/.test(i.textContent));
			if (!pick && items.length) pick = items[0];

			if (pick) {
				const cleaned = cleanQualityText(pick.textContent);
				pick.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
				console.table([{ '선택된 화질': cleaned, '선택 방식': '자동 (Enter 이벤트)' }]);
			} else {
				console.warn(`%c⚠️ [Quality] 품질 항목을 찾지 못함`, styles.warn);
			}
		} catch (e) {
			console.error(`%c❌ [Quality] 자동 선택 중 에러: ${e.message}`, styles.error);
		}
		console.groupEnd();
	}

	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);
		}
	});

	(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;

				if (newVideoId) {
					if (newVideoId !== lastVideoId) {
						lastVideoId = newVideoId;
						setTimeout(selectPreferredQuality, minTimeout);
					} else {
						console.log(`%c⏩ [URLChange] 같은 방송(${newVideoId}), 품질 재설정 생략`, styles.warn);
					}
				} else {
					console.log(`%cℹ️ [URLChange] 방송 ID 없음, 건너뜀`, 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 adPopupObserver;

	function startObserver() {
		if (adPopupObserver) adPopupObserver.disconnect();
		adPopupObserver = new MutationObserver(handleAdBlockPopup);
		adPopupObserver.observe(document.body, {
			childList: true,
			subtree: true
		});
		console.log(`%c🔍 [Observer] 광고 팝업 감시 시작`, styles.bold);
	}

	function observeBodyStyleChanges() {
		const observer = new MutationObserver(mutations => {
			for (const m of mutations) {
				if (m.type === 'attributes' && m.attributeName === 'style' &&
					document.body.style.overflow === 'hidden') {
					document.body.removeAttribute('style');
					console.log(`%c♻️ [BodyStyle] overflow:hidden 감지, style 제거`, styles.info);
				}
			}
		});
		observer.observe(document.body, {
			attributes: true,
			attributeFilter: ['style']
		});
		console.log(`%c👀 [Observer] body.style 감시 시작`, styles.bold);
	}

	// 초기화
	observeManualQualitySelect();
	startObserver();

	// ✅ /lives 에서만 body style 감시 실행
	if (location.pathname === '/lives') {
		observeBodyStyleChanges();
	}
})();