치지직에서 원하는 태그를 필터링 해주는 스크립트
当前为
// ==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`);
});
})();