// ==UserScript== // @name Chzzk Auto Quality & 광고 팝업 제거 + 음소거 설정 // @namespace http://tampermonkey.net/ // @version 3.4.2 // @description Chzzk 자동 선호 화질 설정, 광고 팝업 제거, 음소거 자동 설정/해제 및 스크롤 잠금 해제 (1회성 unmute만 적용) // @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 // @downloadURL none // ==/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(); } })();