// ==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
// ==/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();
})();