Greasy Fork

Greasy Fork is available in English.

Chzzk Auto Quality & 광고 팝업 제거

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

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

// ==UserScript==
// @name         Chzzk Auto Quality & 광고 팝업 제거
// @namespace    http://tampermonkey.net/
// @version      3.1
// @description  Chzzk 자동 선호 화질 설정, 광고 팝업 제거 및 스크롤 잠금 해제
// @match        https://chzzk.naver.com/*
// @icon         https://chzzk.naver.com/favicon.ico
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        unsafeWindow
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(async () => {
  'use strict';

  const CONFIG = {
    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"]'
    },
    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.storageKey, 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.storageKey, 1080);
      return parseInt(stored, 10);
    },
    async applyPreferred() {
      const target = await this.getPreferred();
      console.groupCollapsed('%c⚙️ [Quality] 자동 화질 선택 시작', CONFIG.styles.info);
      console.table([{ '대상 해상도': target }]);
      try {
        (await common.async.waitFor(CONFIG.selectors.qualityBtn)).click();
        (await common.async.waitFor(CONFIG.selectors.qualityMenu)).click();
        await common.async.sleep(CONFIG.minTimeout);
        const items = Array.from(document.querySelectorAll(CONFIG.selectors.qualityItems));
        const pick = items.find(i => common.text.extractResolution(i.textContent) === target)
                  || items.find(i => /\d+p/.test(i.textContent))
                  || items[0];
        if (pick) {
          pick.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
          console.table([{ '선택 화질': common.text.clean(pick.textContent) }]);
        } else {
          common.log.warn('[Quality] 화질 항목을 찾지 못함');
        }
      } catch (e) {
        common.log.error(`[Quality] 선택 실패: ${e.message}`);
      }
      console.groupEnd();
    }
  };

  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() {
      // 광고 팝업 감시
      common.observeElement(CONFIG.selectors.popup, el => {
        if (common.regex.adBlockDetect.test(el.textContent)) {
          common.dom.remove(el);
          common.dom.clearStyle(document.body);
          common.log.success('[AdPopup] 팝업 제거됨');
        }
      }, false);

      // body style 감시
      const styleWatcher = new MutationObserver(() => {
        if (document.body.style.overflow === 'hidden') {
          common.dom.clearStyle(document.body);
          common.log.info('[BodyStyle] overflow:hidden 제거됨');
        }
      });
      styleWatcher.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.storageKey)) === undefined) {
      await GM.setValue(CONFIG.storageKey, 1080);
      common.log.success('[Init] 기본 화질 1080 저장됨');
    }
    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();
  }
})();