// ==UserScript==
// @name CHZZK 태그 필터
// @version 1.1.0
// @description 치지직에서 원하는 태그를 필터링 해주는 스크립트
// @include /^https:\/\/chzzk\.naver\.com\/lives\?[^#]*\btags=[^&#]+/
// @include /^https:\/\/chzzk\.naver\.com\/category\/[^/]+\/[^/]+\/lives(?:[?#].*)?$/
// @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";
/* =========================
* 0) 글로벌 설정(유지보수 지점)
* ========================= */
// (A) 스크립트/동작 기본값
const SCRIPT = {
VERSION: "1.9.4",
DEFAULT_SEGMENT: "and", // 'or' | 'and'
};
// (B) 상태·문구·접근성 텍스트 (← 여기만 바꾸면 UI 문구 전체 반영)
const TXT = {
PANEL_TITLE: "태그·카테고리 필터",
BTN_INDEX: "인덱싱 시작(스크롤)",
BTN_CLOSE: "닫기",
TAG_LABEL: "태그",
TAG_PLACEHOLDER: "태그 추가…",
CAT_LABEL: "카테고리",
CAT_PLACEHOLDER: "카테고리 추가…",
SEG_OR: "태그 중 하나라도",
SEG_AND: "태그 모두 포함",
BTN_APPLY: "필터 적용",
BTN_CLEAR: "초기화",
WARN_NEED_INDEX: "인덱싱이 아직 시작되지 않았습니다. ‘인덱싱 시작(스크롤)’을 눌러주세요.",
STATUS_DONE_PREFIX: "인덱싱 완료: ",
STATUS_INCR_PREFIX: "인덱싱(증분): ",
STATUS_CLEAR: "필터 해제: 전체 표시",
STATUS_OR_SUM: "하나라도 포함 + 카테고리(라벨): 표시 {shown} / 숨김 {hidden}",
STATUS_AND_SUM: "모두 포함(교집합) + 카테고리(라벨): 표시 {shown} / 숨김 {hidden}",
STATUS_CAT_ONLY_CLEAR: "카테고리만 적용되어 있어 전체 필터를 해제했습니다.",
STATUS_CAT_CLEARED: "카테고리 해제됨",
INDEXING_IN_PROGRESS_SUFFIX: " 중 …",
BUBBLE: "태그·카테고리 필터",
// 메뉴(선택): 아래도 바꾸고 싶다면 이 키들만 수정
MENU_SIMPLE_SPEED: "인덱싱 속도: 간단 설정(숫자 ms)",
MENU_PRESET_SPEED: "인덱싱 속도: 프리셋 선택 (느림/보통/빠름)",
MENU_SHOW_SPEED: "현재 속도 보기 (ms)",
PROMPT_SIMPLE_SPEED: (cur) => `현재 기본 지연(ms): ${cur}\n- 숫자가 작을수록 빠릅니다 (권장 100~1500)`,
ALERT_SIMPLE_SPEED_RANGE: "50~5000 사이 숫자를 입력하세요.",
PROMPT_PRESET_SPEED: (cur) => `현재 프리셋: ${cur}\n입력: slow | normal | fast`,
ALERT_PRESET_INVALID: "slow | normal | fast 중 선택",
ALERT_SPEED_SET: (n) => `속도가 ${n}ms로 설정되었습니다.`,
ALERT_PRESET_SET: (p) => `"${p}" 프리셋으로 설정되었습니다.`,
};
// (C) 선택자 모음 (클래스명 변경 시 여기만 고치면 됨)
const SEL = {
cardLi: 'li.navigation_component_item__iMPOI',
tagAnchor: 'a[href*="/lives?tags="]',
fallbackSpan: 'span.video_card_category__xQ15T.video_card_tag__4NF6R',
cardContainer: '.video_card_item__lOC8Y',
categoryAnchor: 'a[href^="/category/"]'
};
// (D) 속도/스크롤 설정 저장 키 & 프리셋
const STORAGE = { SPEED_MODE:"tm_speed_mode", SPEED_MS:"tm_speed_ms", SPEED_PROFILE:"tm_speed_profile" };
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},
};
// (E) 스타일(CSS)
const STYLE_CSS = `
.tm-hide{display:none!important}.tm-hit{outline:2px solid rgba(255,255,0,.85)!important;border-radius:6px!important}
.tm-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.tm-chiprow{display:flex;gap:6px;flex-wrap:wrap;align-items:center;background:#111;padding:8px;border:1px solid #444;border-radius:10px}
.tm-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:#2a2a2a;color:#e5e7eb;border:1px solid #444;font-size:12px}
.tm-chip button{all:unset;cursor:pointer;opacity:.8;padding:0 2px}
.tm-chip button:hover{opacity:1}
.tm-chipinput{flex:1;min-width:120px;background:transparent;border:none;color:#fff;outline:none;padding:6px;font-size:13px}
.tm-seg{display:flex;background:#1e1e1e;border:1px solid #2c2c2c;border-radius:12px;overflow:hidden}
.tm-seg button{flex:1 0 0;padding:10px 12px;border:none;background:transparent;color:#d1d5db;cursor:pointer}
.tm-seg button[aria-checked="true"]{background:#1e90ff;color:#fff}
.tm-seg button:disabled{opacity:.5;cursor:default}
.tm-btn{padding:8px 10px;border-radius:8px;border:1px solid #555;background:#333;color:#fff;cursor:pointer}
.tm-btn.primary{background:#1e90ff;border-color:#1e90ff}
.tm-btn:disabled{opacity:.5;cursor:default}
`;
/* =========================
* 1) 유틸
* ========================= */
const clamp=(n,a,b)=>Math.min(Math.max(n,a),b);
const rand=(a,b)=>Math.random()*(b-a)+a;
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
const nfc=(s)=>(s||"").normalize("NFC");
const debounce=(fn,ms=300)=>{ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms); }; };
const isTagUrl = () => { try { const u=new URL(location.href); return u.pathname==="/lives" && u.searchParams.has("tags"); } catch { return false; } };
const isCategoryUrl = () => /^\/category\/[^/]+\/[^/]+\/lives$/.test(location.pathname);
const isSupportedUrl = () => isTagUrl() || isCategoryUrl();
function getPageTag(){ if(!isTagUrl())return null; try{ const t=new URL(location.href).searchParams.get("tags"); return t? nfc(decodeURIComponent(t).trim()):null; }catch{ return null; } }
function getPageCategoryPath(){ if(!isCategoryUrl())return null; const m=location.pathname.match(/^\/category\/([^/]+)\/([^/]+)\/lives$/); return m?`${nfc(m[1])}/${nfc(m[2])}`:null; }
function parseTokens(raw){ return (raw||"").split(/[, ]+/).map(s=>nfc(s.trim())).filter(Boolean); }
function deriveConfigFromMs(msInput){
const raw=Number(msInput); const ms=clamp(Number.isFinite(raw)?raw:300,100,1500);
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*((ms/300)-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); }
const prof=GM_getValue(STORAGE.SPEED_PROFILE,"normal"); return PRESETS[prof]||PRESETS.normal;
}
function ensureStyle(){
if(document.getElementById("tm-filter-style")) return;
const s=document.createElement("style");
s.id="tm-filter-style";
s.textContent=STYLE_CSS;
document.head.appendChild(s);
}
/* =========================
* 2) 네트워크 유휴 모니터
* ========================= */
const NetMon=(()=>{ let inflight=0;
if(!window.__tmFetchWrapped && typeof window.fetch==="function"){
const orig=window.fetch.bind(window);
window.fetch=function(...args){ inflight++; return orig(...args).finally(()=>{ inflight=Math.max(0,inflight-1); }); };
Object.defineProperty(window,"__tmFetchWrapped",{value:true});
}
if(!XMLHttpRequest.prototype.__tmPatched){
const origSend=XMLHttpRequest.prototype.send; const MARK=Symbol("tm-xhr");
XMLHttpRequest.prototype.send=function(...args){ if(!this[MARK]){ this[MARK]=true; inflight++; this.addEventListener("loadend",()=>{ inflight=Math.max(0,inflight-1); },{once:true}); } return origSend.apply(this,args); };
Object.defineProperty(XMLHttpRequest.prototype,"__tmPatched",{value:true});
}
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:(a,b)=>waitForIdle(a,b) };
})();
/* =========================
* 3) 인덱싱 대상 추출/메타 파서
* ========================= */
const META=new WeakMap(); // li -> {tags:Set, catsLabel:Set, catsPath:Set}
function extractMetaFromLi(li){
const cached=META.get(li); if(cached) return cached;
const tags=new Set(), catsLabel=new Set(), catsPath=new Set();
// 태그: 앵커 href ?tags=… 또는 폴백 span 텍스트
li.querySelectorAll(SEL.tagAnchor).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); }); }
// 카테고리: 컨테이너가 앵커인 경우까지 포함
const scope=li.querySelector(SEL.cardContainer) || li;
const anchors=[...(scope.matches?.(SEL.categoryAnchor)?[scope]:[]), ...scope.querySelectorAll(SEL.categoryAnchor)];
anchors.forEach(a=>{
const href=a.getAttribute("href")||""; const m=href.match(/^\/category\/([^/]+)\/([^/]+)\/lives(?:[?#].*)?$/);
if(m){ const label=nfc((a.textContent||"").replace(/\s+/g," ").trim()); if(label) catsLabel.add(label); catsPath.add(`${nfc(m[1])}/${nfc(m[2])}`); }
});
const meta={tags,catsLabel,catsPath}; META.set(li,meta); return meta;
}
function isCardLi(node){
return node && node.nodeType===1 &&
(node.matches?.(SEL.cardLi) ||
(node.tagName==="LI" && (node.querySelector?.(SEL.tagAnchor) || node.querySelector?.(SEL.categoryAnchor))));
}
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)||li.querySelector(SEL.categoryAnchor));
}
return nodes;
}
function findListContainer(){
const any=document.querySelector(SEL.cardLi);
if(any && any.parentElement) return any.parentElement;
return document.querySelector("main ul, main ol") || document.querySelector("#__next ul, #__next ol") || document.querySelector("main") || document.querySelector("#__next") || document.body;
}
/* =========================
* 4) 인덱싱 & 변경 감지
* ========================= */
const INDEX_SET=new Set();
let INDEX_READY=false;
let AUTO_SCROLLING=false;
let LIST_OBS=null;
function statusEl(){ return document.getElementById("tm-index-status"); }
function setStatus(msg){ const el=statusEl(); if(el) el.textContent=msg; }
function fullReindex(){
ensureStyle();
INDEX_SET.clear();
const nodes=getCardNodes();
nodes.forEach(li=>{ INDEX_SET.add(li); extractMetaFromLi(li); });
INDEX_READY=true;
setStatus(`${TXT.STATUS_DONE_PREFIX}${INDEX_SET.size}개 카드`);
applyActiveFilter();
}
function incrementalIndex({addedLis=[],removedLis=[]}){
if(!INDEX_READY) return;
let changed=false;
removedLis.forEach(li=>{ if(INDEX_SET.delete(li)) changed=true; });
addedLis.forEach(li=>{ if(!INDEX_SET.has(li)){ INDEX_SET.add(li); extractMetaFromLi(li); changed=true; } });
if(changed){ setStatus(`${TXT.STATUS_INCR_PREFIX}${INDEX_SET.size}개 카드`); applyActiveFilter(); }
}
let mutationBurst=0;
const SAFE_REINDEX=debounce(()=>{ mutationBurst=0; },600);
const scheduleReindex=debounce(()=>{ if(INDEX_READY) fullReindex(); },250);
async function humanLikeAutoScrollAndIndex(){
if(AUTO_SCROLLING) return;
AUTO_SCROLLING=true; setStatus(TXT.BTN_INDEX + TXT.INDEXING_IN_PROGRESS_SUFFIX);
const CFG=getSpeedCfg();
let stepCount=0, lastCount=INDEX_SET.size||getCardNodes().length, lastHeight=document.documentElement.scrollHeight, idle=0;
for(let round=1; round<=CFG.maxRounds; round++){
const stepPx=Math.round(rand(CFG.minStepPx,CFG.maxStepPx)), 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, curHeight=document.documentElement.scrollHeight, 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); } }
fullReindex();
AUTO_SCROLLING=false; setStatus(`${TXT.STATUS_DONE_PREFIX}${INDEX_SET.size}개 카드`);
}
function scanAddedLis(root){
if(!root || root.nodeType!==1) return [];
const arr=[]; if(isCardLi(root)) arr.push(root);
root.querySelectorAll?.(SEL.cardLi).forEach(li=>arr.push(li));
root.querySelectorAll?.("li").forEach(li=>{ if(!arr.includes(li) && (li.querySelector?.(SEL.tagAnchor)||li.querySelector?.(SEL.categoryAnchor))) arr.push(li); });
return arr;
}
function scanRemovedLis(root){ return isCardLi(root)?[root]:[]; }
function watchList(){
unwatchList();
const container=findListContainer(); if(!container) return;
LIST_OBS=new MutationObserver(muts=>{
const added=[], removed=[];
for(const m of muts){ m.addedNodes?.forEach(n=>added.push(...scanAddedLis(n))); m.removedNodes?.forEach(n=>removed.push(...scanRemovedLis(n))); }
mutationBurst += added.length + removed.length;
if(mutationBurst>120){ mutationBurst=0; SAFE_REINDEX(); return INDEX_READY && fullReindex(); }
SAFE_REINDEX();
if(added.length||removed.length) incrementalIndex({addedLis:added,removedLis:removed});
else scheduleReindex();
});
LIST_OBS.observe(container,{childList:true,subtree:true});
}
function unwatchList(){ if(LIST_OBS){ LIST_OBS.disconnect(); LIST_OBS=null; } }
/* =========================
* 5) 필터 코어
* ========================= */
let ACTIVE={mode:'none',tags:[],userCat:null}; // 현재 적용 상태
function setHidden(li,h){ li.classList.toggle('tm-hide',!!h); }
function setHit(li,on){ li.classList.toggle('tm-hit',!!on); }
function makeCategoryTester(input){ const s=nfc((input||"").trim()); if(!s) return ()=>true; return (labels)=> labels.has(s); }
function pageCategoryTester(){ const pc=getPageCategoryPath(); if(!pc) return ()=>true; return (paths)=> paths.has(pc); }
function guardIndexed(){ if(!INDEX_READY || INDEX_SET.size===0){ setStatus(TXT.WARN_NEED_INDEX); return false; } return true; }
function clearFilter(silent=false){
if(!guardIndexed()) return;
INDEX_SET.forEach(li=>{ setHidden(li,false); setHit(li,false); });
ACTIVE={mode:'none',tags:[],userCat:null};
if(!silent) setStatus(TXT.STATUS_CLEAR);
}
function filterOR(tags,userCat=null,silent=false){
if(!guardIndexed()) return;
const want=new Set(tags.map(nfc));
const testUserCat=makeCategoryTester(userCat);
const testPageCat=pageCategoryTester();
let shown=0,hidden=0;
INDEX_SET.forEach(li=>{
const {tags,catsLabel,catsPath}=extractMetaFromLi(li);
const tagOK= want.size===0 ? true : [...tags].some(t=>want.has(t));
const catOK= testUserCat(catsLabel,catsPath) && testPageCat(catsPath);
const ok=tagOK && catOK;
setHidden(li,!ok); setHit(li,ok); ok?shown++:hidden++;
});
ACTIVE={mode:'or',tags:[...want],userCat:userCat? nfc(userCat):null};
if(!silent) setStatus(TXT.STATUS_OR_SUM.replace("{shown}",shown).replace("{hidden}",hidden));
}
function filterAND(input,userCat=null,silent=false){
if(!guardIndexed()) return;
const wants=[...input.map(nfc)];
const pt=getPageTag(); if(pt && !wants.includes(pt)) wants.unshift(pt); // 태그 페이지 기본 태그 자동 포함
const testUserCat=makeCategoryTester(userCat);
const testPageCat=pageCategoryTester();
let shown=0,hidden=0;
INDEX_SET.forEach(li=>{
const {tags,catsLabel,catsPath}=extractMetaFromLi(li);
const tagOK= wants.length===0 ? true : wants.every(t=>tags.has(t));
const catOK= testUserCat(catsLabel,catsPath) && testPageCat(catsPath);
const ok=tagOK && catOK;
setHidden(li,!ok); setHit(li,ok); ok?shown++:hidden++;
});
ACTIVE={mode:'and',tags:wants,userCat:userCat? nfc(userCat):null};
if(!silent) setStatus(TXT.STATUS_AND_SUM.replace("{shown}",shown).replace("{hidden}",hidden));
}
function applyActiveFilter(){
if(!guardIndexed()) return;
if(ACTIVE.mode==='none'){ if(ACTIVE.userCat){ return filterOR([], ACTIVE.userCat, true); } return; }
if(ACTIVE.mode==='or') return filterOR(ACTIVE.tags, ACTIVE.userCat, true);
if(ACTIVE.mode==='and') return filterAND(ACTIVE.tags, ACTIVE.userCat, true);
}
/* =========================
* 6) UI (칩 + 세그먼트)
* ========================= */
const TAGS=[]; // string[]
let CAT=null; // string|null
let SEG=SCRIPT.DEFAULT_SEGMENT; // 'or' | 'and'
function renderTagChips(){
const wrap=document.getElementById('tm-tag-chips');
if(!wrap) return;
wrap.innerHTML='';
TAGS.forEach((t,i)=>{
const chip=document.createElement('span');
chip.className='tm-chip'; chip.dataset.index=String(i);
chip.innerHTML=`<span>${t}</span><button title="삭제" aria-label="삭제">×</button>`;
chip.querySelector('button').addEventListener('click',()=>{
TAGS.splice(i,1);
renderTagChips();
});
wrap.appendChild(chip);
});
}
function renderCatChip(){
const area=document.getElementById('tm-cat-area');
const chips=document.getElementById('tm-cat-chips');
const input=document.getElementById('tm-cat-input');
if(!area||!chips||!input) return;
chips.innerHTML='';
if(CAT){
const chip=document.createElement('span');
chip.className='tm-chip';
chip.innerHTML=`<span>${CAT}</span><button title="삭제" aria-label="삭제">×</button>`;
chip.querySelector('button').addEventListener('click',()=>{
const wasOnlyCat = (ACTIVE.userCat != null) && (
(ACTIVE.mode === 'none') ||
(ACTIVE.mode === 'or' && (!ACTIVE.tags || ACTIVE.tags.length === 0))
);
CAT=null;
renderCatChip();
if(!INDEX_READY) return;
if(wasOnlyCat){
clearFilter(true);
setStatus(TXT.STATUS_CAT_ONLY_CLEAR);
}else{
ACTIVE.userCat=null;
applyActiveFilter();
setStatus(TXT.STATUS_CAT_CLEARED);
}
});
chips.appendChild(chip);
input.value='';
input.placeholder=TXT.CAT_PLACEHOLDER;
} else {
input.placeholder=TXT.CAT_PLACEHOLDER;
}
}
function setSeg(mode){
SEG=mode==='and'?'and':'or';
const bOr=document.getElementById('tm-seg-or');
const bAnd=document.getElementById('tm-seg-and');
if(bOr && bAnd){
bOr.setAttribute('aria-checked', SEG==='or'?'true':'false');
bAnd.setAttribute('aria-checked', SEG==='and'?'true':'false');
}
}
function updateCategoryUiVisibility(){
const area=document.getElementById('tm-cat-area');
if(!area) return;
area.style.display = isCategoryUrl() ? 'none' : '';
}
function mountPanel(){
if(document.getElementById("tm-index-panel")){ updateCategoryUiVisibility(); setStatus(TXT.WARN_NEED_INDEX); 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:420px;font-size:13px;backdrop-filter:blur(4px);`;
panel.innerHTML=`
<div class="tm-row" style="justify-content:space-between;margin-bottom:8px">
<div style="font-weight:700">${TXT.PANEL_TITLE}</div>
<div class="tm-row">
<button id="tm-start-auto" class="tm-btn" style="background:#6a5acd;border-color:#7b68ee">${TXT.BTN_INDEX}</button>
<button id="tm-close" class="tm-btn">${TXT.BTN_CLOSE}</button>
</div>
</div>
<div style="margin:6px 0 4px;opacity:.85">${TXT.TAG_LABEL}</div>
<div id="tm-tag-box" class="tm-chiprow">
<div id="tm-tag-chips" class="tm-row"></div>
<input id="tm-tag-input" class="tm-chipinput" placeholder="${TXT.TAG_PLACEHOLDER}" />
</div>
<div id="tm-cat-area" style="margin-top:10px">
<div style="margin:6px 0 4px;opacity:.85">${TXT.CAT_LABEL}</div>
<div id="tm-cat-box" class="tm-chiprow">
<div id="tm-cat-chips" class="tm-row"></div>
<input id="tm-cat-input" class="tm-chipinput" placeholder="${TXT.CAT_PLACEHOLDER}" />
</div>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<div class="tm-seg" role="radiogroup" aria-label="태그 포함 방식" style="flex:1">
<button id="tm-seg-or" role="radio" aria-checked="${SCRIPT.DEFAULT_SEGMENT==='or'}">${TXT.SEG_OR}</button>
<button id="tm-seg-and" role="radio" aria-checked="${SCRIPT.DEFAULT_SEGMENT==='and'}">${TXT.SEG_AND}</button>
</div>
<button id="tm-apply" class="tm-btn primary">${TXT.BTN_APPLY}</button>
<button id="tm-clear" class="tm-btn">${TXT.BTN_CLEAR}</button>
</div>
<div id="tm-index-status" style="margin-top:8px;opacity:.9;color:#ffd166;font-weight:600">${TXT.WARN_NEED_INDEX}</div>
`;
document.body.appendChild(panel);
// Elements
const $tagInput = panel.querySelector('#tm-tag-input');
const $catInput = panel.querySelector('#tm-cat-input');
// Tag input
function commitTagsFromInput(){
const tokens=parseTokens($tagInput.value);
if(tokens.length){
tokens.forEach(t=>{ if(!TAGS.includes(t)) TAGS.push(t); });
$tagInput.value=''; renderTagChips();
}
}
$tagInput.addEventListener('keydown',(e)=>{
if(['Enter',',',' '].includes(e.key)){ e.preventDefault(); commitTagsFromInput(); }
else if(e.key==='Backspace' && !$tagInput.value){ TAGS.pop(); renderTagChips(); }
});
$tagInput.addEventListener('paste',()=> setTimeout(commitTagsFromInput,0));
$tagInput.addEventListener('blur', commitTagsFromInput);
// Category input (single)
function commitCategoryFromInput(){
const t = parseTokens($catInput.value)[0];
if(!t) return;
CAT = t; $catInput.value=''; renderCatChip();
}
$catInput?.addEventListener('keydown',(e)=>{
if(['Enter',',',' '].includes(e.key)){ e.preventDefault(); commitCategoryFromInput(); }
});
$catInput?.addEventListener('blur', commitCategoryFromInput);
// Segmented
panel.querySelector('#tm-seg-or').addEventListener('click', ()=> setSeg('or'));
panel.querySelector('#tm-seg-and').addEventListener('click', ()=> setSeg('and'));
// Apply / Clear
panel.querySelector('#tm-apply').addEventListener('click', ()=>{
if(!guardIndexed()) return;
if(SEG==='or') filterOR([...TAGS], CAT||null);
else filterAND([...TAGS], CAT||null);
});
panel.querySelector('#tm-clear').addEventListener('click', ()=>{
if(!guardIndexed()) return;
TAGS.splice(0,TAGS.length); CAT=null;
renderTagChips(); renderCatChip();
clearFilter();
});
// Manual indexing
panel.querySelector("#tm-start-auto").addEventListener("click",()=>{ if(!AUTO_SCROLLING) humanLikeAutoScrollAndIndex(); });
// Close
panel.querySelector("#tm-close").addEventListener("click",()=>{ panel.style.display="none"; makeBubble(); });
// 초기 렌더
renderTagChips();
renderCatChip();
setSeg(SCRIPT.DEFAULT_SEGMENT);
updateCategoryUiVisibility();
}
function makeBubble(){
if(document.getElementById("tm-index-bubble")) return;
const b=document.createElement("button");
b.id="tm-index-bubble";
b.textContent=TXT.BUBBLE;
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 p=document.getElementById("tm-index-panel"); if(p) p.style.display=""; b.remove(); });
document.body.appendChild(b);
}
function unmountUI(){ document.getElementById("tm-index-panel")?.remove(); document.getElementById("tm-index-bubble")?.remove(); }
/* =========================
* 7) 라우팅/감시 & 메뉴
* ========================= */
async function handleRouteChange(){
if(isSupportedUrl()){
INDEX_SET.clear(); INDEX_READY=false;
if(isCategoryUrl()){ CAT=null; if(ACTIVE.userCat) ACTIVE.userCat=null; }
mountPanel(); setStatus(TXT.WARN_NEED_INDEX); updateCategoryUiVisibility();
watchList();
} else {
unwatchList(); unmountUI(); INDEX_SET.clear(); INDEX_READY=false; ACTIVE={mode:'none',tags:[],userCat:null}; AUTO_SCROLLING=false;
TAGS.splice(0,TAGS.length); CAT=null; SEG=SCRIPT.DEFAULT_SEGMENT;
}
}
handleRouteChange();
(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),{passive:true});
window.addEventListener("hashchange",()=>setTimeout(handleRouteChange,50),{passive:true});
})();
let lastHref=location.href; setInterval(()=>{ if(location.href!==lastHref){ lastHref=location.href; handleRouteChange(); } },400);
// Tampermonkey 메뉴 (문구도 TXT에서 제어)
GM_registerMenuCommand(TXT.MENU_SIMPLE_SPEED, ()=>{
const curMs=GM_getValue(STORAGE.SPEED_MS,300);
const v=prompt(TXT.PROMPT_SIMPLE_SPEED(curMs), String(curMs));
if(v==null) return; const n=Math.round(Number(v));
if(!Number.isFinite(n)||n<50||n>5000) return alert(TXT.ALERT_SIMPLE_SPEED_RANGE);
GM_setValue(STORAGE.SPEED_MS,n); GM_setValue(STORAGE.SPEED_MODE,"simple"); alert(TXT.ALERT_SPEED_SET(n));
});
GM_registerMenuCommand(TXT.MENU_PRESET_SPEED, ()=>{
const cur=GM_getValue(STORAGE.SPEED_PROFILE,"normal");
const v=prompt(TXT.PROMPT_PRESET_SPEED(cur), cur);
if(!v) return; const c=v.trim().toLowerCase();
if(!["slow","normal","fast"].includes(c)) return alert(TXT.ALERT_PRESET_INVALID);
GM_setValue(STORAGE.SPEED_PROFILE,c); GM_setValue(STORAGE.SPEED_MODE,"preset"); alert(TXT.ALERT_PRESET_SET(c));
});
GM_registerMenuCommand(TXT.MENU_SHOW_SPEED, ()=>{
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`);
});
})();