CHZZK 태그 필터

치지직에서 원하는 태그를 필터링 해주는 스크립트

当前为 2025-09-27 提交的版本,查看 最新版本

// ==UserScript==
// @name         CHZZK 태그 필터
// @version      1.0.0
// @description  치지직에서 원하는 태그를 필터링 해주는 스크립트
// @include      /^https:\/\/chzzk\.naver\.com\/lives\?[^#]*\btags=[^&#]+/
// @run-at       document-idle
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// @namespace https://greasyfork.org/users/1519824
// ==/UserScript==

(function () {
  "use strict";

  // ---------- 라우트 판별 ----------
  const isTagUrl = () => {
    try {
      const u = new URL(location.href);
      return u.pathname === "/lives" && u.searchParams.has("tags");
    } catch { return false; }
  };

  // ========== 저장키 & 속도 모드 ==========
  const STORAGE = {
    SPEED_MODE:    "tm_speed_mode",   // 'simple' | 'preset'
    SPEED_MS:      "tm_speed_ms",     // number(ms) for 'simple'
    SPEED_PROFILE: "tm_speed_profile" // 'slow' | 'normal' | 'fast' (when mode='preset')
  };

  const PRESETS = {
    slow:   { maxRounds: 250, idleRounds: 4, minStepPx: 400,  maxStepPx: 1000, minDelayMs: 450, maxDelayMs: 900,  longPauseEvery: 6, longPauseMsMin: 1500, longPauseMsMax: 2500, networkIdleMs: 800,  networkIdleTimeout: 9000, backToTop: true },
    normal: { maxRounds: 200, idleRounds: 3, minStepPx: 400,  maxStepPx: 1100, minDelayMs: 220, maxDelayMs: 480,  longPauseEvery: 6, longPauseMsMin: 900,  longPauseMsMax: 1600, networkIdleMs: 600,  networkIdleTimeout: 7000, backToTop: true },
    fast:   { maxRounds: 160, idleRounds: 2, minStepPx: 450,  maxStepPx: 1300, minDelayMs: 120, maxDelayMs: 260,  longPauseEvery: 6, longPauseMsMin: 700,  longPauseMsMax: 1200, networkIdleMs: 450,  networkIdleTimeout: 5000, backToTop: true },
  };

  function deriveConfigFromMs(msInput) {
    const ms = clamp(Number(msInput) || 300, 100, 1500);
    const k = ms / 300;
    const cfg = { ...PRESETS.normal };
    cfg.minDelayMs        = Math.max(80,  Math.round(ms * 0.7));
    cfg.maxDelayMs        = Math.max(cfg.minDelayMs + 40, Math.round(ms * 1.4));
    cfg.longPauseEvery    = 6;
    cfg.longPauseMsMin    = Math.round(ms * 3.2);
    cfg.longPauseMsMax    = Math.round(ms * 5.0);
    cfg.networkIdleMs     = Math.round(ms * 2.0);
    cfg.networkIdleTimeout= Math.round(ms * 18.0);
    cfg.minStepPx         = clamp(Math.round(450 - (ms - 300) * 0.25), 300, 600);
    cfg.maxStepPx         = clamp(Math.round(1150 - (ms - 300) * 0.45), 800, 1500);
    cfg.idleRounds        = ms >= 700 ? 4 : 3;
    cfg.maxRounds         = clamp(Math.round(190 * (1 + 0.3 * (k - 1))), 140, 260);
    return cfg;
  }
  function getSpeedCfg() {
    const mode = GM_getValue(STORAGE.SPEED_MODE, "simple");
    if (mode === "simple") {
      const ms = GM_getValue(STORAGE.SPEED_MS, 300);
      return deriveConfigFromMs(ms);
    } else {
      const prof = GM_getValue(STORAGE.SPEED_PROFILE, "normal");
      return PRESETS[prof] || PRESETS.normal;
    }
  }

  // ========== 셀렉터 ==========
  const SEL = {
    cardLi: 'li.navigation_component_item__iMPOI',
    tagAnchor: 'a[href*="/lives?tags="]',
    fallbackSpan: 'span.video_card_category__xQ15T.video_card_tag__4NF6R',
  };

  // ========== 상태 ==========
  /** [{ li: HTMLLIElement, tags: Set<string> }] */
  let INDEX = [];
  let AUTO_SCROLLING = false;
  let INITIAL_AUTOSCROLL_DONE = false;
  let LIST_OBS = null; // MutationObserver

  let ACTIVE = /** @type {{mode:'none'|'or'|'and', tags:string[]}} */({
    mode: 'none',
    tags: []
  });

  // ========== 유틸 ==========
  const debounce = (fn, ms = 300) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const nfc = (s) => (s || "").normalize("NFC");
  const clamp = (n, a, b) => Math.min(Math.max(n, a), b);
  const rand = (a, b) => Math.random() * (b - a) + a;

  function parseInputTags(raw) {
    return (raw || "").split(/[, ]+/).map(s => nfc(s.trim())).filter(Boolean);
  }
  function getPageTag() {
    try {
      const t = new URL(location.href).searchParams.get("tags");
      return t ? nfc(decodeURIComponent(t).trim()) : null;
    } catch { return null; }
  }
  function getCardNodes() {
    let nodes = Array.from(document.querySelectorAll(SEL.cardLi));
    if (nodes.length === 0) {
      nodes = Array.from(document.querySelectorAll("li")).filter(li => li.querySelector(SEL.tagAnchor));
    }
    return nodes;
  }

  // ========== 전역 스타일 ==========
  function ensureStyle() {
    if (document.getElementById("tm-filter-style")) return;
    const s = document.createElement("style");
    s.id = "tm-filter-style";
    s.textContent = `
      .tm-hide { display: none !important; }
      .tm-hit  { outline: 2px solid rgba(255,255,0,.85) !important; border-radius: 6px !important; }
    `;
    document.head.appendChild(s);
  }

  // ========== 네트워크 유휴 감지 ==========
  const NetMon = (() => {
    let inflight = 0;
    if (window.fetch) {
      const origFetch = window.fetch;
      window.fetch = function(...args) {
        inflight++;
        return origFetch.apply(this, args).finally(() => { inflight = Math.max(0, inflight - 1); });
      };
    }
    (function hookXHR(){
      const Orig = window.XMLHttpRequest;
      if (!Orig) return;
      function X() {
        const xhr = new Orig();
        xhr.addEventListener('loadstart', () => inflight++);
        xhr.addEventListener('loadend',   () => { inflight = Math.max(0, inflight - 1); });
        return xhr;
      }
      window.XMLHttpRequest = X;
      X.prototype = Orig.prototype;
    })();
    async function waitForIdle(idleMs, timeoutMs) {
      const start = Date.now();
      let idleStart = inflight === 0 ? Date.now() : 0;
      while (true) {
        if (inflight === 0) {
          if (idleStart === 0) idleStart = Date.now();
          if (Date.now() - idleStart >= idleMs) return true;
        } else {
          idleStart = 0;
        }
        if (Date.now() - start > timeoutMs) return false;
        await sleep(80);
      }
    }
    return { waitForIdle: (idleMs, timeoutMs) => waitForIdle(idleMs, timeoutMs) };
  })();

  // ========== 태그 추출 ==========
  function extractTagsFromLi(li) {
    const tags = new Set();
    const anchors = li.querySelectorAll(SEL.tagAnchor);
    anchors.forEach(a => {
      try {
        const u = new URL(a.getAttribute("href"), location.origin);
        const t = u.searchParams.get("tags");
        if (t) tags.add(nfc(decodeURIComponent(t).trim()));
      } catch {}
    });
    if (tags.size === 0) {
      li.querySelectorAll(SEL.fallbackSpan).forEach(sp => {
        const text = nfc((sp.textContent || "").trim());
        if (text) tags.add(text);
      });
    }
    return tags;
  }
  function findListContainer() {
    const anyLi = document.querySelector(SEL.cardLi);
    if (anyLi && anyLi.parentElement) return anyLi.parentElement;
    return (
      document.querySelector("main ul, main ol") ||
      document.querySelector("#__next ul, #__next ol") ||
      document.querySelector("main") ||
      document.querySelector("#__next") ||
      document.body
    );
  }

  // ========== 인덱싱 ==========
  function setStatus(msg){ const el=document.getElementById("tm-index-status"); if(el) el.textContent=msg; }

  function buildIndex() {
    ensureStyle();
    const nodes = getCardNodes();
    INDEX = nodes.map(li => ({ li, tags: extractTagsFromLi(li) }));
    setStatus(`인덱싱 완료: ${INDEX.length}개 카드`);
    applyActiveFilter();
  }
  const scheduleReindex = debounce(() => { if (!AUTO_SCROLLING) buildIndex(); }, 250);

  // ========== 사람처럼 자동 스크롤 ==========
  async function humanLikeAutoScrollAndIndex() {
    if (AUTO_SCROLLING) return;
    AUTO_SCROLLING = true;
    setStatus("자동 스크롤 중…");

    const CFG = getSpeedCfg();
    let stepCount = 0;
    let lastCount = getCardNodes().length;
    let lastHeight = document.documentElement.scrollHeight;
    let idle = 0;

    for (let round = 1; round <= CFG.maxRounds; round++) {
      const stepPx = Math.round(rand(CFG.minStepPx, CFG.maxStepPx));
      const delay  = Math.round(rand(CFG.minDelayMs, CFG.maxDelayMs));

      try { window.scrollBy({ top: stepPx, left: 0, behavior: 'smooth' }); } catch { window.scrollBy(0, stepPx); }
      await sleep(delay);
      await NetMon.waitForIdle(CFG.networkIdleMs, CFG.networkIdleTimeout);

      stepCount++;
      if (stepCount % CFG.longPauseEvery === 0) {
        await sleep(Math.round(rand(CFG.longPauseMsMin, CFG.longPauseMsMax)));
      }

      const curCount  = getCardNodes().length;
      const curHeight = document.documentElement.scrollHeight;
      const nearBottom = window.scrollY + window.innerHeight >= curHeight - 4;

      if (curCount > lastCount || curHeight > lastHeight) { idle = 0; lastCount = curCount; lastHeight = curHeight; }
      else { idle++; }

      if (nearBottom && idle >= CFG.idleRounds) break;
    }

    if (CFG.backToTop) {
      try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
    }

    buildIndex();
    AUTO_SCROLLING = false;
    INITIAL_AUTOSCROLL_DONE = true;
  }

  // ========== 숨김/강조 & 필터 ==========
  function setHidden(li, hidden) { li.classList.toggle('tm-hide', !!hidden); }
  function setHit(li, on)      { li.classList.toggle('tm-hit',  !!on); }

  function clearFilter(silent=false) {
    INDEX.forEach(({li}) => { setHidden(li, false); setHit(li, false); });
    ACTIVE = { mode:'none', tags:[] };
    if (!silent) setStatus("필터 해제: 전체 표시");
  }
  function filterOR(tags, silent=false) {
    const want = new Set(tags.map(nfc));
    let shown=0, hidden=0;
    INDEX.forEach(({ li, tags }) => {
      const matched = [...tags].some(t => want.has(t));
      setHidden(li, !matched); setHit(li, matched);
      matched ? shown++ : hidden++;
    });
    ACTIVE = { mode:'or', tags:[...want] };
    if (!silent) setStatus(`하나라도 포함: 표시 ${shown} / 숨김 ${hidden}`);
  }
  function filterAND(input, silent=false) {
    const wants = [...input.map(nfc)];
    const pt = getPageTag();
    if (pt && !wants.includes(pt)) wants.unshift(pt);
    let shown=0, hidden=0;
    INDEX.forEach(({ li, tags }) => {
      const matched = wants.every(t => tags.has(t));
      setHidden(li, !matched); setHit(li, matched);
      matched ? shown++ : hidden++;
    });
    ACTIVE = { mode:'and', tags:wants };
    if (!silent) setStatus(`모두 포함(교집합): 표시 ${shown} / 숨김 ${hidden}`);
  }
  function applyActiveFilter() {
    if (ACTIVE.mode === 'none') return;
    if (ACTIVE.mode === 'or')   return filterOR(ACTIVE.tags, true);
    if (ACTIVE.mode === 'and')  return filterAND(ACTIVE.tags, true);
  }

  // ========== UI 마운트/언마운트 ==========
  function getPanel() { return document.getElementById("tm-index-panel"); }
  function getBubble() { return document.getElementById("tm-index-bubble"); }

  function mountPanel() {
    if (getPanel()) return;
    ensureStyle();

    const panel = document.createElement("div");
    panel.id = "tm-index-panel";
    panel.style.cssText = `
      position: fixed; right: 16px; bottom: 16px; z-index: 999999;
      background: rgba(20,20,20,.96); color: #fff; padding: 12px; border-radius: 12px;
      box-shadow: 0 8px 22px rgba(0,0,0,.45); width: 360px; font-size: 13px; backdrop-filter: blur(4px);
    `;
    panel.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
        <div style="font-weight:700">태그 필터</div>
        <button id="tm-close" title="숨기기"
                style="background:#333;color:#fff;border:1px solid #555;border-radius:8px;padding:2px 8px;cursor:pointer;">닫기</button>
      </div>

      <label style="display:block;margin:6px 0 4px;opacity:.85">태그(쉼표/공백 구분, 정확 일치):</label>
      <input id="tm-tags" type="text" placeholder="예) 노래 게임"
             style="width:100%;padding:8px;border-radius:8px;border:1px solid #444;background:#111;color:#fff"/>

      <div style="display:flex;gap:8px;margin-top:10px">
        <button id="tm-or"  style="flex:1;padding:8px;border-radius:8px;border:1px solid #555;background:#1e90ff;color:#fff;cursor:pointer;">하나라도 포함</button>
        <button id="tm-and" style="flex:1;padding:8px;border-radius:8px;border:1px solid #555;background:#32cd32;color:#000;cursor:pointer;">모두 포함(교집합)</button>
      </div>

      <div style="display:flex;gap:8px;margin-top:8px">
        <button id="tm-clear" style="flex:1;padding:6px;border-radius:8px;border:1px solid #555;background:#333;color:#fff;cursor:pointer;">필터 해제</button>
        <button id="tm-min"   style="padding:6px 10px;border-radius:8px;border:1px solid #555;background:#333;color:#fff;cursor:pointer;">작게</button>
      </div>

      <div id="tm-index-status" style="margin-top:8px;opacity:.8">준비 중… (백그라운드 스크롤 후 인덱싱)</div>
    `;
    document.body.appendChild(panel);

    const $in = panel.querySelector("#tm-tags");
    panel.querySelector("#tm-or").addEventListener("click", () => {
      const tags = parseInputTags($in.value);
      if (tags.length < 1) return setStatus("태그를 1개 이상 입력하세요.");
      filterOR(tags);
    });
    panel.querySelector("#tm-and").addEventListener("click", () => {
      const tags = parseInputTags($in.value);
      if (tags.length < 1) return setStatus("태그를 1개 이상 입력하세요.");
      filterAND(tags);
    });
    panel.querySelector("#tm-clear").addEventListener("click", () => clearFilter());
    panel.querySelector("#tm-close").addEventListener("click", () => { panel.style.display="none"; makeBubble(); });

    panel.querySelector("#tm-min").addEventListener("click", () => {
      const collapsed = panel.getAttribute("data-collapsed")==="1";
      const statusEl = document.getElementById("tm-index-status");
      if (!collapsed) {
        panel.setAttribute("data-collapsed","1"); panel.style.width="230px";
        $in.style.display="none"; statusEl.style.display="none";
        panel.querySelector("#tm-min").textContent="크게";
      } else {
        panel.setAttribute("data-collapsed","0"); panel.style.width="360px";
        $in.style.display=""; statusEl.style.display="";
        panel.querySelector("#tm-min").textContent="작게";
      }
    });
  }

  function makeBubble() {
    if (getBubble()) return;
    const b = document.createElement("button");
    b.id = "tm-index-bubble";
    b.textContent = "태그 필터";
    b.style.cssText = `
      position: fixed; right: 16px; bottom: 16px; z-index: 999999;
      background: #1e90ff; color: #fff; border: none; border-radius: 999px;
      padding: 10px 14px; box-shadow: 0 6px 18px rgba(0,0,0,.35); cursor: pointer;
    `;
    b.addEventListener("click", () => {
      const panel = getPanel();
      if (panel) panel.style.display = "";
      b.remove();
    });
    document.body.appendChild(b);
  }

  function unmountUI() {
    const p = getPanel(); if (p) p.remove();
    const b = getBubble(); if (b) b.remove();
  }

  // ========== SPA/무한스크롤 옵저버 ==========
  function watchList() {
    unwatchList();
    const container = findListContainer(); if (!container) return;
    LIST_OBS = new MutationObserver(() => { if (!AUTO_SCROLLING) scheduleReindex(); });
    LIST_OBS.observe(container, { childList: true, subtree: true });
  }
  function unwatchList() {
    if (LIST_OBS) { LIST_OBS.disconnect(); LIST_OBS = null; }
  }

  // ========== 라우트 전환 처리 ==========
  async function handleRouteChange() {
    if (isTagUrl()) {
      // 태그 URL: UI 표시 + 감시 시작 + 인덱싱 준비
      mountPanel();
      watchList();
      if (!INITIAL_AUTOSCROLL_DONE && !AUTO_SCROLLING) {
        try { await humanLikeAutoScrollAndIndex(); }
        catch { AUTO_SCROLLING = false; buildIndex(); }
      } else if (!AUTO_SCROLLING && INDEX.length === 0) {
        buildIndex();
      } else {
        // 이미 인덱스가 있으면 필터만 재적용
        applyActiveFilter();
      }
    } else {
      // 비태그 URL: UI/옵저버 제거, 상태 초기화
      unwatchList();
      unmountUI();
      INDEX = [];
      ACTIVE = { mode:'none', tags:[] };
      AUTO_SCROLLING = false;
      INITIAL_AUTOSCROLL_DONE = false;
    }
  }

  // 첫 로딩 시
  handleRouteChange();

  // 히스토리 후킹: pushState/replaceState/popstate/hashchange
  (function hookHistory() {
    const wrap = (obj, key) => {
      const orig = obj[key];
      if (typeof orig !== "function") return;
      obj[key] = function () {
        const r = orig.apply(this, arguments);
        setTimeout(handleRouteChange, 50);
        return r;
      };
    };
    wrap(history, "pushState");
    wrap(history, "replaceState");
    window.addEventListener("popstate", () => setTimeout(handleRouteChange, 50));
    window.addEventListener("hashchange", () => setTimeout(handleRouteChange, 50));
  })();

  // 폴백: URL 폴링(일부 프레임워크 대비)
  let lastHref = location.href;
  setInterval(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      handleRouteChange();
    }
  }, 400);

  // ========== TM 메뉴 ==========
  GM_registerMenuCommand("인덱싱 속도: 간단 설정(숫자 ms)", () => {
    const curMs = GM_getValue(STORAGE.SPEED_MS, 300);
    const v = prompt(
      `현재 기본 지연(ms): ${curMs}\n- 숫자가 작을수록 빠르게 스크롤합니다.\n- 권장 범위: 100 ~ 1500`,
      String(curMs)
    );
    if (v == null) return;
    const n = Math.round(Number(v));
    if (!Number.isFinite(n) || n < 50 || n > 5000) { alert("50~5000 사이의 숫자를 입력해주세요."); return; }
    GM_setValue(STORAGE.SPEED_MS, n);
    GM_setValue(STORAGE.SPEED_MODE, "simple");
    alert(`속도가 ${n}ms로 설정되었습니다.`);
  });

  GM_registerMenuCommand("인덱싱 속도: 프리셋 선택 (느림/보통/빠름)", () => {
    const cur = GM_getValue(STORAGE.SPEED_PROFILE, "normal");
    const v = prompt(`현재 프리셋: ${cur}\n입력: slow | normal | fast`, cur);
    if (!v) return;
    const choice = v.trim().toLowerCase();
    if (!["slow","normal","fast"].includes(choice)) return alert("slow | normal | fast 중에서 입력하세요.");
    GM_setValue(STORAGE.SPEED_PROFILE, choice);
    GM_setValue(STORAGE.SPEED_MODE, "preset");
    alert(`프리셋이 "${choice}"로 설정되었습니다.`);
  });

  GM_registerMenuCommand("현재 속도 보기 (ms)", () => {
    const mode = GM_getValue(STORAGE.SPEED_MODE, "simple");
    let ms;
    if (mode === "simple") {
      ms = GM_getValue(STORAGE.SPEED_MS, 300);
    } else {
      const cfg = getSpeedCfg();
      ms = Math.round((cfg.minDelayMs + cfg.maxDelayMs) / 2);
    }
    alert(`${ms} ms`);
  });
})();