您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제, 360p 복구
// ==UserScript== // @name Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute) // @namespace http://tampermonkey.net/ // @version 4.1.2 // @description Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제, 360p 복구 // @match https://chzzk.naver.com/* // @icon https://chzzk.naver.com/favicon.ico // @grant GM.info // @grant GM.getValue // @grant GM.setValue // @grant unsafeWindow // @run-at document-start // @license MIT // ==/UserScript== (async () => { "use strict"; /** * @typedef {object} RegexConfig * @property {RegExp} adBlockDetect - 광고 차단 팝업을 감지하는 정규식 * @property {RegExp} chzzkId - URL에서 방송 ID를 추출하는 정규식 * @property {RegExp} version - 메타 정보에서 스크립트 버전을 추출하는 정규식. * @class Config * @description 스크립트의 모든 설정, 선택자, 유틸리티 함수를 중앙에서 관리하는 클래스. */ class Config { #applyCooldown = 1000; #minTimeout = 500; #defaultTimeout = 2000; #storageKeys = { quality: "chzzkPreferredQuality", autoUnmute: "chzzkAutoUnmute", debugLog: "chzzkDebugLog", screenSharpness: "chzzkScreenSharp", ignoredUpdate: "chzzkIgnoredUpdateDate", }; #selectors = { popup: 'div[class^="popup_container"]', qualityBtn: 'button[command="SettingCommands.Toggle"]', qualityMenu: 'div[class*="pzp-pc-setting-intro-quality"]', qualityItems: 'li.pzp-ui-setting-quality-item[role="menuitem"]', headerMenu: ".header_service__DyG7M", }; #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", }; #regex = { adBlockDetect: /광고\s*차단\s*프로그램.*사용\s*중/i, chzzkId: /(?:live|video)\/(?<id>[^/]+)/, version: /^\s*\/\/\s*@version\s+([\d.]+)/m, }; #debug = true; /** @returns {number} 자동 적용 기능의 최소 실행 간격 (ms) */ get applyCooldown() { return this.#applyCooldown; } /** @returns {number} 비동기 작업의 최소 타임아웃 (ms) */ get minTimeout() { return this.#minTimeout; } /** @returns {number} 비동기 작업의 기본 타임아웃 (ms) */ get defaultTimeout() { return this.#defaultTimeout; } /** @returns {object} Tampermonkey 저장소 키 목록 */ get storageKeys() { return this.#storageKeys; } /** @returns {object} DOM 요소 선택자 목록 */ get selectors() { return this.#selectors; } /** @returns {object} 콘솔 로그 스타일 목록 */ get styles() { return this.#styles; } /** @returns {RegexConfig} 정규 표현식 목록 */ get regex() { return this.#regex; } /** @returns {boolean} 디버그 로그 활성화 여부 */ get debug() { return this.#debug; } /** @param {boolean} value - 디버그 로그 활성화 상태 */ set debug(value) { this.#debug = !!value; } /** * 지정된 시간(ms)만큼 실행을 지연시킵니다. * @param {number} ms - 지연시킬 시간 (ms). * @returns {Promise<void>} */ sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * 특정 CSS 선택자에 해당하는 요소가 나타날 때까지 기다립니다. * @param {string} selector - 기다릴 요소의 CSS 선택자. * @param {number} [timeout=this.#defaultTimeout] - 대기할 최대 시간 (ms). * @returns {Promise<Element>} 발견된 요소를 resolve하는 프로미스. */ waitFor = (selector, timeout = this.#defaultTimeout) => { const effective = Math.max(timeout, this.#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 waiting for " + selector)); }, effective); }); }; /** * 텍스트에서 불필요한 공백을 정리합니다. * @param {string} txt - 정리할 원본 텍스트. * @returns {string} 정리된 텍스트. */ cleanText = (txt) => txt.trim().split(/\s+/).filter(Boolean).join(", "); /** * 텍스트에서 해상도 값을 숫자로 추출합니다. (예: "1080p" -> 1080) * @param {string} txt - 해상도 정보가 포함된 텍스트. * @returns {number|null} 추출된 해상도 숫자 또는 null. */ extractResolution = (txt) => { const m = txt.match(/(\d{3,4})p/); return m ? parseInt(m[1], 10) : null; }; /** * DOM 요소를 제거합니다. * @param {Element} el - 제거할 요소. */ removeElement = (el) => el?.remove(); /** * DOM 요소의 인라인 스타일을 모두 제거합니다. * @param {Element} el - 스타일을 제거할 요소. */ clearStyle = (el) => el?.removeAttribute("style"); // --- Logger Methods --- info = (...args) => this.#debug && console.log(...args); success = (...args) => this.#debug && console.log(...args); warn = (...args) => this.#debug && console.warn(...args); error = (...args) => this.#debug && console.error(...args); groupCollapsed = (...args) => this.#debug && console.groupCollapsed(...args); table = (...args) => this.#debug && console.table(...args); groupEnd = (...args) => this.#debug && console.groupEnd(...args); /** * 특정 요소가 나타나면 콜백 함수를 실행하는 MutationObserver를 등록합니다. * @param {string} selector - 감시할 요소의 CSS 선택자. * @param {function(Element): void} callback - 요소가 발견됐을 때 실행할 콜백 함수. * @param {boolean} [once=true] - 한 번만 실행할지 여부. */ observeElement = (selector, callback, once = true) => { const mo = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { callback(el); if (once) mo.disconnect(); } }); mo.observe(document.body, { childList: true, subtree: true }); const initial = document.querySelector(selector); if (initial) { callback(initial); if (once) mo.disconnect(); } }; } /** @type {Config} 스크립트 전역 설정 및 유틸리티 인스턴스 */ const C = new Config(); /** * @namespace updateChecker * @description 스크립트의 새로운 버전을 확인하고 사용자에게 알림을 표시합니다. */ const updateChecker = { /** * 현재 날짜를 YYYY-MM-DD 형식의 문자열로 반환합니다. * @returns {string} */ getTodayDate() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }, /** * 두 버전 문자열을 비교합니다. * @param {string} v1 * @param {string} v2 * @returns {number} v1 > v2 이면 1, v1 < v2 이면 -1, 같으면 0 */ compareVersions(v1, v2) { const parts1 = v1.split('.').map(Number); const parts2 = v2.split('.').map(Number); const len = Math.max(parts1.length, parts2.length); for (let i = 0; i < len; i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return 1; if (p1 < p2) return -1; } return 0; }, /** * 업데이트 알림 UI를 화면에 표시합니다. * @param {string} currentVersion - 현재 설치된 버전 * @param {string} newVersion - 새로 발견된 버전 */ showNotification(currentVersion, newVersion) { const notificationId = 'chzzk-update-notification'; if (document.getElementById(notificationId)) return; let updateLink = '#'; const scriptId = "534791"; if (scriptId) { updateLink = `https://gf.qytechs.cn/ko/scripts/${scriptId}`; } console.log(`[UpdateCheck] 생성된 업데이트 링크: ${updateLink}`); const notice = document.createElement('div'); notice.id = notificationId; Object.assign(notice.style, { position: 'fixed', bottom: '20px', right: '20px', backgroundColor: 'rgba(30, 30, 30, 0.8)', backdropFilter: 'blur(45px)', webkitBackdropFilter: 'blur(45px)', color: 'var(--color-content-01)', padding: '20px', borderRadius: '12px', boxShadow: '0 4px 20px rgba(0, 0, 0, 0.25)', zIndex: '99999', fontFamily: 'sans-serif', fontSize: '14px', border: '1px solid var(--color-content-chzzk-02)', transition: 'opacity 0.3s ease, transform 0.3s ease', opacity: '0', transform: 'translateY(10px)', }); notice.innerHTML = ` <h4 style="margin: 0 0 10px; font-size: 16px; color: var(--color-content-chzzk-02);">올인원 스크립트 업데이트 알림</h4> <p style="margin: 0 0 15px;">새로운 버전이 출시되었습니다! (v${currentVersion} → <strong>v${newVersion}</strong>)</p> <a href="${updateLink}" target="_blank" style="display: inline-block; background-color: #00c73c; color: white; padding: 8px 15px; border-radius: 6px; text-decoration: none; font-weight: bold; margin-right: 10px;">지금 업데이트</a> <button id="chzzk-update-close" style="background: none; border: 1px solid var(--color-content-01); color: var(--color-content-01); padding: 7px 12px; border-radius: 6px; cursor: pointer;">닫기</button> <div style="margin-top: 15px; display: flex; align-items: center;"> <input type="checkbox" id="chzzk-ignore-today" style="margin-right: 5px; accent-color: #00c73c;"> <label for="chzzk-ignore-today" style="font-size: 12px; color: var(--color-content-03); cursor: pointer;">오늘 하루 보지 않기</label> </div> `; document.body.appendChild(notice); setTimeout(() => { notice.style.opacity = '1'; notice.style.transform = 'translateY(0)'; }, 10); notice.querySelector('#chzzk-update-close').addEventListener('click', async () => { if (notice.querySelector('#chzzk-ignore-today').checked) { await GM.setValue(C.storageKeys.ignoredUpdate, this.getTodayDate()); } notice.style.opacity = '0'; notice.style.transform = 'translateY(10px)'; setTimeout(() => { notice.remove(); }, 300); }); }, /** * 스크립트 업데이트를 확인합니다. */ async check() { console.log("[UpdateCheck] >> 업데이트 확인 프로세스 시작 (최종 디버그 방식)"); try { const ignoredDate = await GM.getValue(C.storageKeys.ignoredUpdate, null); const today = this.getTodayDate(); if (ignoredDate === today) { return; } const scriptId = "534791"; if (!scriptId) { C.error("[UpdateCheck] >> 스크립트 ID가 지정되지 않았습니다."); return; } const jsonApiUrl = `https://gf.qytechs.cn/en/scripts/${scriptId}/versions.json`; const response = await fetch(jsonApiUrl, { cache: 'no-cache' }); if (!response.ok) { C.error(`[UpdateCheck] >> 네트워크 오류(${response.status})로 JSON API 확인에 실패했습니다.`); return; } const versionsData = await response.json(); if (!Array.isArray(versionsData) || versionsData.length === 0 || !versionsData[0].version) { C.error("[UpdateCheck] >> 수신된 JSON 데이터가 올바르지 않거나 버전 정보가 없습니다."); return; } const latestVersion = versionsData[0].version; const currentVersion = GM.info.script.version; C.success(`[UpdateCheck] 현재 설치된 버전: ${currentVersion}`); C.success(`[UpdateCheck] 서버에서 확인된 최신 버전: ${latestVersion}`); const comparisonResult = this.compareVersions(latestVersion, currentVersion); C.success(`[UpdateCheck] 버전 비교 결과: ${comparisonResult}`); if (comparisonResult > 0) { C.success("[UpdateCheck] >> 새 버전을 발견하여 알림을 표시합니다."); this.showNotification(currentVersion, latestVersion); } else { C.success("[UpdateCheck] >> 새 버전이 없으므로 알림을 표시하지 않습니다."); } } catch (error) { if (error instanceof TypeError && error.message.includes("GM.info")) { C.warn("[UpdateCheck] >> 스크립트 정보를 읽어오는 중 호환성 문제가 발생했으나, 업데이트 확인은 계속 진행합니다."); } else { C.error("[UpdateCheck] >> 업데이트 확인 중 예외 오류 발생:", error); } } } }; /** * @async * @function addHeaderMenu * @description 치지직 헤더에 스크립트 설정 메뉴 UI를 추가합니다. * @returns {Promise<void>} */ async function addHeaderMenu() { if (!document.getElementById('chzzk-allinone-styles')) { const customStyles = document.createElement('style'); customStyles.id = 'chzzk-allinone-styles'; customStyles.textContent = ` .allinone-settings-button:hover { background-color: var(--Surface-Interaction-Lighten-Hovered); border-radius: 6px; } .button_label__fyHZ6 { align-items: center; background-color: var(--Surface-Neutral-Base); border-radius: 6px; box-shadow: 0 2px 2px var(--Shadow-Strong),0 2px 6px 2px var(--Shadow-Base); color: var(--Content-Neutral-Cool-Stronger); display: inline-flex; font-family: -apple-system,BlinkMacSystemFont,Apple SD Gothic Neo,Helvetica,Arial,NanumGothic,나눔고딕,Malgun Gothic,맑은 고딕,Dotum,굴림,gulim,새굴림,noto sans,돋움,sans-serif; font-size: 12px; font-weight: 400; height: 27px; justify-content: center; letter-spacing: -.3px; line-height: 17px; padding: 0 9px; position: absolute; white-space: nowrap; z-index: 15000; } .allinone-tooltip-position { top: calc(100% + 2px); right: -10px; } `; document.head.appendChild(customStyles); } const toolbar = await C.waitFor('.toolbar_section__maAwZ'); if (!toolbar || toolbar.querySelector('.allinone-settings-wrapper')) return; const boxWrapper = document.createElement('div'); boxWrapper.className = 'toolbar_box__2DzCd'; const itemWrapper = document.createElement('div'); itemWrapper.className = 'toolbar_item__w9Z7l allinone-settings-wrapper'; itemWrapper.style.position = 'relative'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button_container__ppWwB button_only_icon__kahz5 button_larger__4NrSP allinone-settings-button'; btn.innerHTML = ` <svg width="28" height="28" color="currentColor" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: scale(1.4);"> <g transform="translate(8,8)"> <path d="M4.5 12a7.5 7.5 0 0 0 15 0m-15 0a7.5 7.5 0 1 1 15 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077 1.41-.513m14.095-5.13 1.41-.513M5.106 17.785l1.15-.964m11.49-9.642 1.149-.964M7.501 19.795l.75-1.3m7.5-12.99.75-1.3m-6.063 16.658.26-1.477m2.605-14.772.26-1.477m0 17.726-.26-1.477M10.698 4.614l-.26-1.477M16.5 19.794l-.75-1.299M7.5 4.205 12 12m6.894 5.785-1.149-.964M6.256 7.178l-1.15-.964m15.352 8.864-1.41-.513M4.954 9.435l-1.41-.514M12.002 12l-3.75 6.495"></path> </g> </svg> <span class="blind">올인원 환경설정</span> `; btn.addEventListener('mouseenter', () => { if (itemWrapper.querySelector('.button_label__fyHZ6')) return; const tooltip = document.createElement('span'); tooltip.className = 'button_label__fyHZ6 allinone-tooltip-position'; tooltip.textContent = '올인원 환경설정'; itemWrapper.appendChild(tooltip); }); btn.addEventListener('mouseleave', () => { const tooltip = itemWrapper.querySelector('.button_label__fyHZ6'); if (tooltip) tooltip.remove(); }); itemWrapper.appendChild(btn); boxWrapper.appendChild(itemWrapper); const profileBox = toolbar.querySelector('.toolbar_profile_button__tZxIO')?.closest('.toolbar_box__2DzCd'); if (profileBox) { toolbar.insertBefore(boxWrapper, profileBox); } else { toolbar.appendChild(boxWrapper); } const menu = document.createElement('div'); menu.className = 'allinone-settings-menu'; Object.assign(menu.style, { position: 'absolute', background: 'var(--color-bg-layer-02)', borderRadius: '10px', boxShadow: '0 8px 20px var(--color-shadow-layer01-02), 0 0 1px var(--color-shadow-layer01-01)', color: 'var(--color-content-03)', overflow: 'auto', padding: '18px', right: '0px', top: 'calc(100% + 7px)', width: '240px', zIndex: 13000, display: 'none' }); itemWrapper.appendChild(menu); const helpContent = document.createElement('div'); helpContent.className = 'allinone-help-content'; Object.assign(helpContent.style, { display: 'none', margin: '4px 0', padding: '4px 8px 4px 34px', fontFamily: 'Sandoll Nemony2, Apple SD Gothic NEO, Helvetica Neue, Helvetica, NanumGothic, Malgun Gothic, gulim, noto sans, Dotum, sans-serif', fontSize: '14px', color: 'var(--color-content-03)', whiteSpace: 'pre-wrap', }); helpContent.innerHTML = '<h2 style="color: var(--color-content-chzzk-02); margin-bottom:6px;">메뉴 사용법</h2>' + '<div style="white-space:pre-wrap; line-height:1.4; font-size:14px; color:inherit;">' + '<strong style="display:block; font-weight:600; margin:6px 0 2px;">1. 자동 언뮤트</strong>' + '방송이 시작되면 자동으로 음소거를 해제합니다. 간헐적으로 음소거 상태로 전환되는 문제를 보완하기 위해 추가된 기능입니다.\n\n' + '<strong style="display:block; font-weight:600; margin:6px 0 2px;">2. 선명한 화면</strong>' + '“선명한 화면 2.0” 옵션을 활성화하면 개발자가 제작한 외부 스크립트를 적용하여, 기본 제공되는 선명도 기능을 대체합니다.' + '</div>'; const helpBtn = document.createElement('button'); helpBtn.className = 'allinone-settings-item'; helpBtn.style.display = 'flex'; helpBtn.style.alignItems = 'center'; helpBtn.style.margin = '8px 0'; helpBtn.style.padding = '4px 8px'; helpBtn.style.fontFamily = 'Sandoll Nemony2, Apple SD Gothic NEO, Helvetica Neue, Helvetica, NanumGothic, Malgun Gothic, gulim, noto sans, Dotum, sans-serif'; helpBtn.style.fontSize = '14px'; helpBtn.style.color = 'inherit'; helpBtn.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:10px;" color="inherit"> <circle cx="12" cy="12" r="10"></circle> <path d="M9.09 9a3 3 0 1 1 5.82 1c-.5 1.3-2.91 2-2.91 2"></path> <line x1="12" y1="17" x2="12.01" y2="17"></line> </svg> <span style="margin-left:8px">도움말</span> `; helpBtn.addEventListener('click', () => { helpContent.style.display = helpContent.style.display === 'none' ? 'block' : 'none'; }); menu.appendChild(helpBtn); menu.appendChild(helpContent); const unmuteSvgOff = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75L19.5 12m0 0l2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/></svg>`; const unmuteSvgOn = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/></svg>`; const sharpSvg = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125Z"/></svg>`; const items = [ { key: C.storageKeys.autoUnmute, svg: unmuteSvgOff, onSvg: unmuteSvgOn, label: '자동 언뮤트' }, { key: C.storageKeys.screenSharpness, svg: sharpSvg, onSvg: sharpSvg, label: '선명한 화면 2.0' }, ]; items.forEach(item => { const itemBtn = document.createElement('button'); itemBtn.className = 'allinone-settings-item'; itemBtn.style.display = 'flex'; itemBtn.style.alignItems = 'center'; itemBtn.style.margin = '8px 0'; itemBtn.style.padding = '4px 8px'; itemBtn.style.fontFamily = 'Sandoll Nemony2, Apple SD Gothic NEO, Helvetica Neue, Helvetica, NanumGothic, Malgun Gothic, gulim, noto sans, Dotum, sans-serif'; itemBtn.style.fontSize = '14px'; itemBtn.style.color = 'inherit'; itemBtn.innerHTML = ` ${item.svg} <span style="margin-left:8px">${item.label}${item.key ? ' <span class="state-text">OFF</span>' : ''}</span> `; if (!item.key) { itemBtn.style.opacity = '1'; itemBtn.addEventListener('click', item.onClick); } else { GM.getValue(item.key, false).then(active => { itemBtn.style.opacity = active ? '1' : '0.4'; if (active) itemBtn.querySelector('svg').outerHTML = item.onSvg; const stateSpan = itemBtn.querySelector('.state-text'); stateSpan.textContent = active ? 'ON' : 'OFF'; }); itemBtn.addEventListener('click', async () => { const active = await GM.getValue(item.key, false); const newActive = !active; await GM.setValue(item.key, newActive); setTimeout(() => { location.reload(); }, 100); }); } menu.appendChild(itemBtn); }); btn.addEventListener('click', e => { e.stopPropagation(); menu.style.display = menu.style.display === 'block' ? 'none' : 'block'; }); document.addEventListener('click', e => { if (!menu.contains(e.target) && e.target !== btn) { menu.style.display = 'none'; } }); } window.addHeaderMenu = addHeaderMenu; unsafeWindow.toggleDebugLogs = async () => { const key = C.storageKeys.debugLog; const current = await GM.getValue(key, false); const next = !current; await GM.setValue(key, next); C.debug = next; console.log(`🛠️ Debug logs ${next ? 'ENABLED' : 'DISABLED'}`); }; /** * @namespace quality * @description 비디오 화질 설정과 관련된 기능을 관리합니다. */ 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 = C.extractResolution(raw); if (res) { await GM.setValue(C.storageKeys.quality, res); C.groupCollapsed("%c💾 [Quality] 수동 화질 저장됨", C.styles.success); C.table([{ "선택 해상도": res, 원본: C.cleanText(raw) }]); C.groupEnd(); } }, { capture: true }); }, /** * 저장된 선호 화질 값을 불러옵니다. * @returns {Promise<number>} 선호 화질. */ async getPreferred() { const stored = await GM.getValue(C.storageKeys.quality, 1080); return parseInt(stored, 10); }, /** * 저장된 선호 화질을 비디오 플레이어에 자동으로 적용합니다. * @returns {Promise<void>} */ async applyPreferred() { const now = Date.now(); if (this._applying || now - this._lastApply < C.applyCooldown) return; this._applying = true; this._lastApply = now; const target = await this.getPreferred(); let cleaned = "(선택 실패)", pick = null; try { const btn = await C.waitFor(C.selectors.qualityBtn); btn.click(); const menu = await C.waitFor(C.selectors.qualityMenu); menu.click(); await C.sleep(C.minTimeout); const items = Array.from(document.querySelectorAll(C.selectors.qualityItems)); pick = items.find((i) => C.extractResolution(i.textContent) === target) || items.find((i) => /\d+p/.test(i.textContent)) || items[0]; cleaned = pick ? C.cleanText(pick.textContent) : cleaned; if (pick) { pick.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); } else { C.warn("[Quality] 화질 항목을 찾지 못함"); } } catch (e) { C.error(`[Quality] 선택 실패: ${e.message}`); } C.groupCollapsed("%c⚙️ [Quality] 자동 화질 적용", C.styles.info); C.table([{ "대상 해상도": target }]); C.table([{ "선택 화질": cleaned, "선택 방식": pick ? "자동" : "없음" }]); C.groupEnd(); this._applying = false; }, }; /** * @namespace handler * @description 페이지의 네이티브 동작(XHR, URL 변경)을 가로채거나 감시하는 기능을 관리합니다. */ 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(), C.minTimeout); } } catch (e) { C.error(`[XHR] JSON 파싱 오류: ${e.message}`); } } }); } return oSend.call(this, body); }; C.info("[XHR] live-detail 요청 감시 시작"); }, trackURLChange() { let lastUrl = location.href; let lastId = null; const getId = (url) => (typeof url === 'string' ? (url.match(C.regex.chzzkId)?.groups?.id || null) : null); const onUrlChange = () => { const currentUrl = location.href; if (currentUrl === lastUrl) return; lastUrl = currentUrl; const id = getId(currentUrl); if (!id) { C.info("[URLChange] 방송 ID 없음"); } else if (id !== lastId) { lastId = id; setTimeout(() => { quality.applyPreferred(); injectSharpnessScript(); }, C.minTimeout); } else { C.warn(`[URLChange] 같은 방송(${id}), 스킵`); } const svg = document.getElementById("sharpnessSVGContainer"); const style = document.getElementById("sharpnessStyle"); if (svg) svg.remove(); if (style) style.remove(); if (window.sharpness) { window.sharpness.init(); window.sharpness.observeMenus(); } }; ["pushState", "replaceState"].forEach((method) => { const original = history[method]; history[method] = function (...args) { const result = original.apply(this, args); window.dispatchEvent(new Event("locationchange")); return result; }; }); window.addEventListener("popstate", () => window.dispatchEvent(new Event("locationchange")) ); window.addEventListener("locationchange", onUrlChange); }, }; /** * @namespace observer * @description MutationObserver를 사용하여 DOM 변경을 감시하고 대응하는 기능을 관리합니다. */ const observer = { start() { const mo = new MutationObserver((muts) => { for (const mut of muts) { for (const node of mut.addedNodes) { if (node.nodeType !== 1) continue; this.tryRemoveAdPopup(); let vid = null; if (node.tagName === "VIDEO") { vid = node; } else if (node.querySelector) { vid = node.querySelector("video"); } if (/^\/live\/[^/]+/.test(location.pathname) && vid) { this.unmuteAll(vid); checkAndFixLowQuality(vid); (async () => { await new Promise((resolve) => { const waitForReady = () => { if (vid.readyState >= 4) return resolve(); setTimeout(waitForReady, 100); }; waitForReady(); }); try { await vid.play(); C.success("%c▶️ [AutoPlay] 재생 성공", C.styles.info); } catch (e) { C.error(`⚠️ [AutoPlay] 재생 실패: ${e.message}`); } })(); } } } }); mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"], }); C.info("[Observer] 통합 감시 시작"); }, /** * 비디오 플레이어의 음소거를 해제합니다. * @param {HTMLVideoElement} video - 음소거를 해제할 비디오 요소. * @returns {Promise<void>} */ async unmuteAll(video) { const autoUnmute = await GM.getValue(C.storageKeys.autoUnmute, true); if (!autoUnmute) return C.info("[Unmute] 설정에 따라 스킵"); if (video.muted) { video.muted = false; C.success("[Unmute] video.muted 해제"); } const btn = document.querySelector('button.pzp-pc-volume-button[aria-label*="음소거 해제"]'); if (btn) { btn.click(); C.success("[Unmute] 버튼 클릭"); } }, /** * 광고 차단 안내 팝업을 감지하고 제거합니다. * @returns {Promise<void>} */ async tryRemoveAdPopup() { try { const popups = document.querySelectorAll(`${C.selectors.popup}:not([data-popup-handled])`); for (const popup of popups) { if (C.regex.adBlockDetect.test(popup.textContent)) { popup.dataset.popupHandled = 'true'; popup.style.display = 'none'; const btn = popup.querySelector('button'); C.groupCollapsed("✅ 광고 차단 팝업 발견! (자세한 정보는 클릭)"); C.info("발견된 전체 팝업 구조", popup); if (!btn) { C.warn("팝업 내 버튼 요소를 찾지 못했습니다."); C.groupEnd(); return; } C.info("내부에서 찾은 버튼 요소", btn); const fiberKey = Object.keys(btn).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$') ); if (!fiberKey) { C.warn("React Fiber 키를 찾지 못했습니다."); C.groupEnd(); return; } C.info("사용한 React Fiber 키:", fiberKey.split('$')[1]); const props = btn[fiberKey]?.memoizedProps || btn[fiberKey]?.return?.memoizedProps; C.info("버튼의 React Props", props); C.groupEnd(); let handlerName = null; let handlerFunc = null; if (typeof props.confirmHandler === 'function') { handlerName = 'confirmHandler'; handlerFunc = props.confirmHandler; } else if (typeof props.onClick === 'function') { handlerName = 'onClick'; handlerFunc = props.onClick; } else if (typeof props.onClickHandler === 'function') { handlerName = 'onClickHandler'; handlerFunc = props.onClickHandler; } if (handlerFunc) { handlerFunc({ isTrusted: true }); C.success(`[AdPopup] 성공: '${handlerName}' 핸들러를 사용하여 팝업을 닫았습니다.`); } return; } } } catch (e) { C.error(`[AdPopup] 자동 닫기 실패: ${e.message}`); } }, }; /** @type {boolean} 저화질 복구 기능이 현재 동작 중인지 여부를 나타내는 플래그 */ let isRecoveringQuality = false; /** * @async * @function checkAndFixLowQuality * @description 비디오 화질이 낮아졌을 경우 선호 화질로 복구를 시도합니다. * @param {HTMLVideoElement} video - 화질을 검사할 비디오 요소. * @returns {Promise<void>} */ async function checkAndFixLowQuality(video) { if (!video || video.__qualityMonitorAttached) return; video.__qualityMonitorAttached = true; C.info("[QualityCheck] 화질 모니터링 시작"); const performCheck = async () => { if (video.paused || isRecoveringQuality) return; const currentHeight = video.videoHeight; if (currentHeight === 0) return; const preferred = await quality.getPreferred(); if (currentHeight < preferred) { C.warn(`[QualityCheck] 저화질(${currentHeight}p) 감지. 선호 화질(${preferred}p)로 복구 시도.`); isRecoveringQuality = true; await quality.applyPreferred(); setTimeout(() => { isRecoveringQuality = false; C.info("[QualityCheck] 화질 복구 쿨다운 종료."); }, 120000); } }; video.addEventListener('loadedmetadata', performCheck); setInterval(performCheck, 30000); } /** * @async * @function setDebugLogging * @description 저장된 설정에 따라 디버그 로그 출력 여부를 설정합니다. * @returns {Promise<void>} */ async function setDebugLogging() { C.debug = await GM.getValue(C.storageKeys.debugLog, false); } /** * @async * @function injectSharpnessScript * @description '선명한 화면' 기능이 활성화된 경우, 관련 외부 스크립트를 주입합니다. * @returns {Promise<void>} */ async function injectSharpnessScript() { const enabled = await GM.getValue(C.storageKeys.screenSharpness, false); if (!enabled) return; const script = document.createElement("script"); script.src = "https://update.gf.qytechs.cn/scripts/534918/Chzzk%20%EC%84%A0%EB%AA%85%ED%95%9C%20%ED%99%94%EB%A9%B4%20%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C.user.js"; script.async = true; document.head.appendChild(script); C.success("%c[Sharpness] 외부 스크립트 삽입 완료", C.styles.info); } /** * @async * @function init * @description 스크립트의 주요 기능들을 초기화합니다. * @returns {Promise<void>} */ async function init() { await setDebugLogging(); updateChecker.check(); if ((await GM.getValue(C.storageKeys.quality)) === undefined) { await GM.setValue(C.storageKeys.quality, 1080); C.success("[Init] 기본 화질 1080 저장"); } if ((await GM.getValue(C.storageKeys.autoUnmute)) === undefined) { await GM.setValue(C.storageKeys.autoUnmute, true); C.success("[Init] 기본 언뮤트 ON 저장"); } await addHeaderMenu(); C.observeElement(C.selectors.headerMenu, () => { addHeaderMenu().catch(console.error); }, false); await quality.applyPreferred(); await injectSharpnessScript(); } /** * @function onDomReady * @description DOM 콘텐츠가 로드된 후 스크립트의 실행을 시작하는 진입점 함수. */ function onDomReady() { console.log("%c🔔 [ChzzkHelper] 스크립트 시작", C.styles.info); quality.observeManualSelect(); observer.start(); init().catch(console.error); } handler.interceptXHR(); handler.trackURLChange(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", onDomReady); } else { onDomReady(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址