// ==UserScript==
// @name Chzzk Auto Quality & 광고 팝업 제거 + 음소거 설정
// @namespace http://tampermonkey.net/
// @version 3.4.1
// @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() {
new Promise(resolve => {
common.observeElement(CONFIG.selectors.popup, el => {
const rawText = el.textContent.trim();
console.groupCollapsed('%c[AdPopup] 팝업 감지됨', CONFIG.styles.info);
console.log('팝업 내용:', rawText);
if (common.regex.adBlockDetect.test(rawText)) {
common.dom.remove(el);
common.dom.clearStyle(document.body);
console.log('%c[AdPopup] 팝업 삭제 완료', CONFIG.styles.success);
resolve();
} else {
console.warn('%c[AdPopup] 텍스트가 일치하지 않아 삭제하지 않음', CONFIG.styles.warn);
}
console.groupEnd();
}, false);
}).then(() => {
console.log('%c[AdPopup] 주기적 광고 팝업 검사 시작', CONFIG.styles.info);
setInterval(() => {
const popups = document.querySelectorAll(CONFIG.selectors.popup);
for (const popup of popups) {
const text = popup.textContent.trim();
if (common.regex.adBlockDetect.test(text)) {
popup.remove();
document.body.style.overflow = 'auto';
console.log('%c[AdPopup] 주기 검사로 팝업 제거', CONFIG.styles.success);
}
}
}, 5000);
});
const mo = new MutationObserver(() => {
if (document.body.style.overflow === 'hidden') {
common.dom.clearStyle(document.body);
common.log.info('[BodyStyle] overflow:hidden 제거됨');
}
});
mo.observe(document.body, {
attributes: true,
attributeFilter: ['style']
});
common.log.info('[Observer] 통합 감시 시작');
}
};
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'}`);
});
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();
}
})();