[코네] 추천 컷 & 추천순 정렬

kone.gg에 추천 컷 & 추천순/추천율 정렬 기능을 추가합니다.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         [코네] 추천 컷 & 추천순 정렬
// @namespace    http://tampermonkey.net/
// @version      2.11
// @description  kone.gg에 추천 컷 & 추천순/추천율 정렬 기능을 추가합니다.
// @author       ducktail
// @match        https://kone.gg/*
// @match        https://kone.gg/s/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// ==/UserScript==

(function () {
  'use strict';

  // ---------- selectors / constants ----------
  const SELECTORS = {
    POST: 'div.relative.group\\/post-wrapper, div.relative.group\\/post, div.group\\/post-wrapper',
    RECO: 'div[class*="text-red-"][class*="font-bold"], span[class*="text-red-"][class*="font-bold"]',
    VIEWS_HOT: 'div.col-span-2.text-xs.text-center[class*="text-zinc-"]',
    LIST_CONTAINER_CANDIDATE:
      'main, div[class*="container"], div[class*="mx-auto"], div[class*="px-"], div[class*="py-"]',
    PAGINATION: 'nav[aria-label*="Pagination"], div.flex.justify-center.mt-4, div.mt-6:has(a[href*="p="])',
    AVATAR: 'img.rounded-full',
    WRITE_BUTTON_TEXT: 'a,button',
  };

  const DATE_PATTERNS = [/^\d{2}:\d{2}$/, /^\d{2}\.\d{2}$/, /^\d{4}\.\d{2}\.\d{2}$/];
  const PRESETS = [30, 50, 100, 150, 300];
  const PERIODS = {
    today: '오늘', '3days': '3 일', '7days': '7 일', '1month': '1 개월',
    '3months': '3 개월', '6months': '6 개월', all: '전체',
  };
  const SORT_MODES = { reco: '추천 수', ratio: '추천율' };

  // ---------- persistent state ----------
  let threshold = GM_getValue('recoThreshold', 30);
  let selectedPeriod = GM_getValue('sortPeriod', '7days');
  let sortMode = GM_getValue('sortMode', 'reco');
  let isFilterEnabled = JSON.parse(sessionStorage.getItem('isFilterEnabled') ?? 'false');
  let topN = parseInt(GM_getValue('topN', 300), 10) || 300;

  // ---------- small utils ----------
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const span = (s) => h('span', { html: s });
  const once = (fn) => { let done=false; return (...a)=>{ if(done) return; done=true; return fn(...a); }; };
  function debounce(fn, wait = 120){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), wait); }; }
  function text(el){ return (el?.textContent || '').replace(/\s+/g, ' ').trim(); }
  function isHotModeByURL(){ return new URL(location.href).searchParams.get('mode') === 'hot'; }
  function isHotModeByDOM(){
    const active = $$( 'a,button,span' ).filter(el => {
      const cls = (el.className || '') + ' ' + (el.getAttribute('data-state') || '');
      const activeish = /active|selected|current|aria-current/i.test(cls) || el.getAttribute('aria-current') === 'page';
      const t = text(el);
      return activeish && /개념글|HOT|Hot|hot/i.test(t);
    });
    return active.length > 0;
  }
  function isHotContext(){ return isHotModeByURL() || isHotModeByDOM(); }
  const parseNum = (s) => parseInt(String(s||'').replace(/[^\d]/g,''),10)||0;

  function h(tag, { className = '', attrs = {}, html = '', children = [] } = {}) {
    const el = document.createElement(tag);
    if (className) el.className = className;
    for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
    if (html) el.innerHTML = html;
    for (const c of children) el.appendChild(c);
    return el;
  }
  function svg(paths) {
    const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('fill', 'none');
    s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '1.8');
    s.setAttribute('aria-hidden', 'true'); s.innerHTML = paths; return s;
  }
  const Icons = {
    thumb: () => svg(`<path stroke-linecap="round" stroke-linejoin="round" d="M14 9V5a3 3 0 00-3-3l-1 5-4 6v7h7a3 3 0 003-3v-7h-2zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3"/>`),
    play:  () => svg(`<path stroke-linecap="round" stroke-linejoin="round" d="M6 4l16 8-16 8V4z"/>`),
    stop:  () => svg(`<rect x="6" y="6" width="12" height="12" rx="2"/>`),
    info:  () => svg(`<circle cx="12" cy="12" r="9"/><path stroke-linecap="round" d="M12 8h.01M11 12h2v4h-2z"/>`),
  };

  // ---------- style (unified palette + layout/UX fixes) ----------
  injectCSS(`
    :root{
      --kp-base:#27272A;
      --kp-surface:#1C1D20; --kp-surface-2:#202226; --kp-border:#2A2B2F;
      --kp-fg:#F1F1F3; --kp-dim:#B7B9C0;
      --kp-chip-bg:var(--kp-base); --kp-chip-hover:#303136;
      --kp-field-bg:#202124; --kp-field-border:#2E3036; --kp-field-hover:#26282D;
      /* darker premium purple */
      --kp-accent:#3a2ea8; --kp-accent-strong:#2f2592;
      --kp-okay:#2B7B6B; --kp-okay-strong:#236456; --kp-red:#8A1B1B;
      --kp-track:#2A2B31; --kp-focus:0 0 0 2px rgba(58,46,168,.38);
      --kp-h:34px;
    }
    @media (prefers-color-scheme: light){
      :root{
        --kp-surface:#FAFAFB; --kp-surface-2:#F4F5F7; --kp-border:#E6E7EB;
        --kp-fg:#111827; --kp-dim:#6B7280;
        --kp-chip-bg:var(--kp-base); --kp-chip-hover:#34343A;
        --kp-field-bg:#FFFFFF; --kp-field-border:#E5E7EB; --kp-field-hover:#F3F4F6;
        --kp-accent:#3a2ea8; --kp-accent-strong:#2f2592;
        --kp-track:#E7E7EE; --kp-focus:0 0 0 2px rgba(58,46,168,.28);
      }
    }

    .kp-hidden{ display:none !important; }

    /* Panel: slimmer width to avoid empty space on the right */
    .kp-panel{
      position:fixed; z-index:9999;
      width:auto; max-width:min(380px, calc(100vw - 24px)); min-width:320px;
      display:none; flex-direction:column; gap:12px; padding:14px;
      background:var(--kp-surface); color:var(--kp-fg); border:1px solid var(--kp-border);
      border-radius:.75rem; box-shadow:0 10px 40px rgba(0,0,0,.40);
    }
    .kp-panel[data-open="true"]{ display:flex; }

    .kp-section{ display:flex; flex-direction:column; gap:8px; }
    .kp-row{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
    .kp-row.center{ justify-content:center; }

    .kp-label{ font-size:.84rem; color:var(--kp-dim); }
    .kp-status{ font-size:1.08rem; font-weight:650; margin-top:10px; text-align:center; }

    .kp-chip{
      visibility:hidden; display:inline-flex; align-items:center; gap:.35rem;
      padding:.375rem .6rem; border-radius:9999px; height:var(--kp-h);
      background:var(--kp-chip-bg); color:#fff; border:none;
      box-shadow:0 1px 0 rgba(0,0,0,.15); cursor:pointer;
      transition: background-color .15s ease, transform .06s ease;
    }
    .kp-chip.kp-visible{ visibility:visible; }
    .kp-chip:hover{ background:var(--kp-chip-hover); }
    .kp-chip svg{ width:18px; height:18px; }

    .kp-badge{ font-size:.75rem; line-height:1; padding:.25rem .5rem; border-radius:.5rem; background:#232428; color:#B7B9C0; border:1px solid #363841; }
    .kp-badge[data-on="true"]{ background:var(--kp-okay); color:#fff; border-color:transparent; }

    .kp-select, .kp-input, .kp-btn{
      height:var(--kp-h);
      border-radius:.5rem; border:1px solid var(--kp-field-border);
      background:var(--kp-field-bg); color:var(--kp-fg); padding:0 .75rem; font-size:.92rem;
      white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
      transition: background .15s, border-color .15s, box-shadow .15s, transform .06s, filter .15s;
    }
    .kp-select:hover, .kp-input:hover{
      background:var(--kp-field-hover); border-color:var(--kp-accent); box-shadow:var(--kp-focus);
    }
    .kp-select:focus, .kp-select:focus-visible,
    .kp-input:focus, .kp-input:focus-visible,
    .kp-select[data-open="true"], .kp-input:focus-within{
      background:var(--kp-field-hover); border-color:var(--kp-accent); box-shadow:var(--kp-focus); outline:0;
    }
    .kp-input[type="number"]{ width:100px; }

    .kp-btn{
      display:inline-flex; align-items:center; justify-content:center; gap:.5rem; cursor:pointer;
      line-height:1; padding:0 .9rem;
    }
    .kp-btn svg{ width:16px; height:16px; }
    .kp-btn.primary{ background:var(--kp-accent); color:#fff; border-color:transparent; }
    .kp-btn.primary:hover{ filter:brightness(1.08); }
    .kp-btn.danger{ background:var(--kp-red); color:#fff; border-color:transparent; }
    .kp-btn:active{ transform: translateY(1px); }

    .kp-toggle{ position:relative; width:46px; height:26px; border-radius:9999px; background:#3C3D42; border:1px solid var(--kp-border); cursor:pointer; }
    .kp-toggle[data-on="true"]{ background:var(--kp-okay); }
    .kp-toggle:before{ content:""; position:absolute; top:2px; left:2px; width:20px; height:20px; border-radius:9999px; background:#fff; transform:translateX(0); transition: transform .15s; }
    .kp-toggle[data-on="true"]:before{ transform:translateX(20px); }

    .kp-note{ display:flex; flex-direction:column; align-items:center; gap:.6rem; font-size:.98rem; color:var(--kp-dim); background:var(--kp-surface-2); padding:.8rem; border-radius:.5rem; border:1px solid var(--kp-border); text-align:center; border-left:2px solid var(--kp-accent); }

    .kp-progress{ height:8px; border-radius:9999px; background:var(--kp-track); overflow:hidden; }
    .kp-progress > i{ display:block; height:100%; width:0%; background:var(--kp-accent); }
    .kp-progress.indeterminate > i{ animation: kp-indet 2.1s ease-in-out infinite; }
    @keyframes kp-indet{ 0%{width:0%} 80%{width:100%} 81%{width:0%} 100%{width:0%} }

    .kp-launcher{ position:fixed; right:16px; bottom:16px; z-index:9998; }
  `);
  function injectCSS(css){ const s=document.createElement('style'); s.textContent=css; document.head.appendChild(s); }

  // ---------- date / reco / views parsing ----------
  function parseDateText(t){
    const now = new Date(); t=(t||'').trim();
    if (/^\d{2}:\d{2}$/.test(t)){ const [h,m]=t.split(':').map(Number); return new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m); }
    if (/^\d{2}\.\d{2}$/.test(t)){ const [mm,dd]=t.split('.').map(Number); const d=new Date(now.getFullYear(), mm-1, dd); if (d>now) d.setFullYear(d.getFullYear()-1); return d; }
    if (/^\d{4}\.\d{2}\.\d{2}$/.test(t)){ const [y,mm,dd]=t.split('.').map(Number); return new Date(y, mm-1, dd); }
    return new Date(0);
  }
  function findDateElem(post){
    const nodes = post.querySelectorAll('time, div, span');
    for (const el of nodes){ const tx=text(el); if (DATE_PATTERNS.some((re)=>re.test(tx))) return el; }
    return null;
  }
  function extractReco(post){
    if (post?.dataset?.kpReco) return parseInt(post.dataset.kpReco,10)||0;
    const red = post.querySelector(SELECTORS.RECO);
    if (red){ const m=text(red).match(/[\d,]+/); if (m){ const v=parseNum(m[0]); post.dataset.kpReco=String(v); return v; } }
    const d = findDateElem(post); const rowText = text(post);
    if (d){
      const dt = text(d); const idx = rowText.indexOf(dt);
      const tail = idx>=0 ? rowText.slice(idx+dt.length) : rowText;
      const nums = (tail.match(/[\d,]+/g)||[]).map(parseNum);
      if (nums.length){ const v=nums[nums.length-1]; post.dataset.kpReco=String(v); return v; }
    }
    const nums = (rowText.match(/[\d,]+/g)||[]).map(parseNum);
    const v = nums.length ? nums[nums.length-1] : 0; post.dataset.kpReco=String(v); return v;
  }
  function extractViews(post){
    if (post?.dataset?.kpViews) return parseInt(post.dataset.kpViews,10)||0;
    const hotCells = $$(SELECTORS.VIEWS_HOT, post);
    if (hotCells.length){
      const nums = hotCells.map(el => parseNum(text(el))).filter(n => Number.isFinite(n) && n >= 0);
      if (nums.length){ const v = Math.max(...nums); if (v > 0){ post.dataset.kpViews=String(v); return v; } }
    }
    const nodes = post.querySelectorAll('span,div,em,i');
    for (const el of nodes){
      const tx = text(el);
      const m = tx.match(/조회\s*[: ]\s*([\d,]+)/i) || tx.match(/views?\s*[: ]\s*([\d,]+)/i);
      if (m){ const v=parseNum(m[1]); post.dataset.kpViews=String(v); return v; }
    }
    const dEl = findDateElem(post);
    const row = text(post);
    if (dEl){
      const dt = text(dEl);
      const idx = row.indexOf(dt);
      const tail = idx>=0 ? row.slice(idx+dt.length) : row;
      const nums2 = (tail.match(/[\d,]+/g)||[]).map(parseNum);
      if (nums2.length >= 2){
        const beforeLast = nums2.slice(0, -1);
        const v2 = Math.max(...beforeLast);
        if (isFinite(v2)){ post.dataset.kpViews=String(v2); return v2; }
      }
    }
    const all = (row.match(/[\d,]+/g)||[]).map(parseNum);
    const v = all.length >= 2 ? all[all.length-2] : 0; post.dataset.kpViews=String(v); return v;
  }

  // ---------- list container ----------
  let _cachedContainer = null;
  function findListContainer(){
    if (_cachedContainer && document.contains(_cachedContainer)) return _cachedContainer;
    const posts = $$(SELECTORS.POST);
    if (!posts.length) return null;
    let cand = posts[0].parentElement;
    for (let depth=0; cand && depth<6; depth++){
      const cnt = $$(SELECTORS.POST, cand).length;
      if (cnt >= posts.length*0.8){ _cachedContainer=cand; return cand; }
      cand = cand.parentElement;
    }
    for (const c of $$(SELECTORS.LIST_CONTAINER_CANDIDATE)){
      const cnt = $$(SELECTORS.POST, c).length;
      if (cnt >= posts.length*0.8){ _cachedContainer=c; return c; }
    }
    _cachedContainer = posts[0].parentElement || document.body;
    return _cachedContainer;
  }

  // ---------- filter ----------
  function applyFilter(){
    const posts = $$(SELECTORS.POST);
    if (!isFilterEnabled){
      for (const p of posts) p.classList.remove('kp-hidden');
      return;
    }
    for (const post of posts){
      const reco = extractReco(post);
      if (reco < threshold) post.classList.add('kp-hidden'); else post.classList.remove('kp-hidden');
    }
  }

  // ---------- period ----------
  function getCutoff(period){
    const now = new Date(); const d = new Date(now);
    switch(period){
      case 'today':   return new Date(now.getFullYear(), now.getMonth(), now.getDate());
      case '3days':   d.setDate(d.getDate()-3); return d;
      case '7days':   d.setDate(d.getDate()-7); return d;
      case '1month':  d.setMonth(d.getMonth()-1); return d;
      case '3months': d.setMonth(d.getMonth()-3); return d;
      case '6months': d.setMonth(d.getMonth()-6); return d;
      default:        return null;
    }
  }

  // ---------- fetch/sort performance (caches die on refresh) ----------
  let currentAbort = null;
  let containerObserver = null;
  let dockObserver = null;
  let lastURL = location.href;
  let urlPollTimer = null;

  const PAGE_CACHE = new Map();
  const SORT_CACHE = new Map();

  function ctxKey(period){
    const u = new URL(location.href);
    const cat = u.searchParams.get('category') || '';
    const hot = isHotContext() ? 'hot' : 'all';
    return `${u.pathname}|${cat}|${hot}|${period}`;
  }

  function nodeFromHTML(html){
    const t = document.createElement('template');
    t.innerHTML = html.trim();
    return t.content.firstElementChild;
  }

  async function collectPosts(period, signal, onProgress){
    const key = ctxKey(period);
    if (SORT_CACHE.has(key)) return SORT_CACHE.get(key);

    const cutoff = getCutoff(period);
    const cutoffMs = cutoff ? cutoff.getTime() : 0;
    const baseUrl = new URL(location.href);
    const parser = new DOMParser();

    let page = 1;
    const BATCH = 4;
    const MAX_PAGES = 3000;
    const records = [];
    let stop = false;
    let fetchedTotal = 0;

    async function fetchPage(p){
      const u = new URL(baseUrl);
      u.searchParams.set('p', String(p));
      const urlStr = u.toString();

      let html;
      if (PAGE_CACHE.has(urlStr)) html = PAGE_CACHE.get(urlStr);
      else {
        const res = await fetch(urlStr, { signal });
        if (!res.ok) return { page: p, items: [], none: true, olderHit: false };
        html = await res.text();
        PAGE_CACHE.set(urlStr, html);
      }

      const doc = parser.parseFromString(html, 'text/html');
      const pagePosts = $$(SELECTORS.POST, doc);
      if (!pagePosts.length) return { page: p, items: [], none: true, olderHit: false };

      const items = [];
      let olderHit = false;

      for (const pp of pagePosts){
        const dEl = findDateElem(pp);
        if (!dEl) continue;
        const pDate = parseDateText(text(dEl));
        const pMs = +pDate;
        if (cutoff && pMs < cutoffMs){ olderHit = true; break; }
        const reco = extractReco(pp);
        const views = extractViews(pp);
        const ratio = views > 0 ? (reco / views) : 0;
        items.push({ html: pp.outerHTML, reco, views, ratio, pDateMs: pMs });
      }
      return { page: p, items, none: false, olderHit };
    }

    while (!stop && page <= MAX_PAGES){
      const tasks = [];
      for (let i=0;i<BATCH && page <= MAX_PAGES;i++, page++) tasks.push(fetchPage(page));
      const batch = await Promise.all(tasks);
      batch.sort((a,b)=>a.page-b.page);

      for (const r of batch){
        if (r.none){ stop = true; break; }
        records.push(...r.items);
        fetchedTotal += r.items.length;
        onProgress?.({ page: r.page, total: fetchedTotal });
        if (r.olderHit){ stop = true; break; }
      }
      await sleep(16);
    }

    SORT_CACHE.set(key, records);
    return records;
  }

  // ---------- (NEW) open posts in background tab after sorting ----------
  // Minimal English comments as requested:

  // Flag toggled only after sort; disabled on navigation.
  let bgOpenEnabled = false;

  // Enable/disable background-open behavior.
  function setBackgroundOpenEnabled(on){
    bgOpenEnabled = !!on;
  }

  // Find the primary post link within a post element.
  function findPostLink(post, clickedAnchor){
    // Prefer an anchor to a single post page (/s/)
    const prefer = clickedAnchor?.closest('a[href*="/s/"]');
    if (prefer) return prefer;

    const bySelector =
      post.querySelector('a[href*="/s/"], a[href^="/s/"], a[href^="https://kone.gg/s/"]');
    if (bySelector) return bySelector;

    // Fallback: first anchor inside the post
    return clickedAnchor || post.querySelector('a[href]');
  }

  // Global click capture: when enabled, left-click on a post opens in a background tab.
  function setupBackgroundOpenListener(){
    document.addEventListener('click', function(e){
      if (!bgOpenEnabled) return;
      if (e.defaultPrevented) return;
      if (e.button !== 0) return;          // left click only
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // respect user modifiers

      // Ignore clicks inside our own UI
      if (e.target.closest('#kp-panel, .kp-launcher')) return;

      const post = e.target.closest(SELECTORS.POST);
      if (!post) return;

      const a = findPostLink(post, e.target.closest('a[href]'));
      if (!a || !a.href) return;

      // Prevent site SPA navigation
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();

      // Normalize URL (handle relative)
      let url = a.getAttribute('href') || a.href;
      if (url.startsWith('/')) url = location.origin + url;

      // Open in background tab (do not activate)
      try {
        GM_openInTab(url, { active: false, insert: true, setParent: true });
      } catch (err) {
        // Fallback: still try to open, some engines may focus; best effort.
        window.open(url, '_blank', 'noopener');
      }
    }, true); // capture
  }

  setupBackgroundOpenListener();

  async function loadAndSort(period, topLimit, mode){
    const container = findListContainer();
    if (!container){ toast('게시물 목록 컨테이너를 찾을 수 없습니다.'); return; }

    const status = h('div', { className:'kp-section' });
    const progress = h('div', { className:'kp-progress indeterminate' });
    const bar = h('i'); progress.appendChild(bar);
    status.append(
      h('div', { className:'kp-status', html:`게시물 로딩 및 정렬 중… <span class="kp-label" style="display:block;margin-top:4px;">기준: ${SORT_MODES[mode]||'추천 수'}</span>` }),
      progress
    );

    $$(SELECTORS.PAGINATION).forEach((el)=>el.remove());

    const holder = h('div', { className:'kp-section', children:[status] });
    const original = document.createDocumentFragment();
    while (container.firstChild) original.appendChild(container.firstChild);
    container.appendChild(holder);

    if (currentAbort) currentAbort.abort();
    currentAbort = new AbortController();

    try{
      let lastPageShown = 0;
      const results = await collectPosts(period, currentAbort.signal, ({page,total})=>{
        lastPageShown = Math.max(lastPageShown, page);
        status.querySelector('.kp-status').innerHTML =
          `페이지 ${lastPageShown} 로딩 중… <span class="kp-label" style="color:var(--kp-dim)">(누적 ${total}개)</span>`;
      });

      progress.classList.remove('indeterminate');

      const sorted = (mode === 'ratio')
        ? results.slice().sort((a,b)=> (b.ratio - a.ratio) || (b.reco - a.reco) || (b.views - a.views))
        : results.slice().sort((a,b)=> (b.reco - a.reco) || (b.views - a.views));

      const frag = document.createDocumentFragment();
      const limit = topLimit || 300;
      const n = Math.min(limit, sorted.length);
      for (let i=0;i<n;i++){
        if (i % 25 === 0) bar.style.width = `${Math.round((i / n) * 100)}%`;
        frag.appendChild(nodeFromHTML(sorted[i].html));
      }
      bar.style.width = '100%';

      container.replaceChildren(frag);

      // (NEW) Enable background-open only after a sort has been applied.
      setBackgroundOpenEnabled(true);
    } catch (err){
      if (err?.name === 'AbortError') toast('정렬이 취소되었습니다.');
      else { console.error(err); toast('게시물 로딩 중 오류가 발생했습니다.'); }
      container.replaceChildren(original);
    } finally {
      currentAbort = null;
      applyFilter();
    }
  }

  // ---------- UI ----------
  function createToolbarAndPanel(){
    const badge = h('span', { className:'kp-badge', html: isFilterEnabled ? `≥${threshold}` : 'OFF', attrs:{ 'data-on': String(isFilterEnabled) } });
    const launcher = h('button', { className:'kp-chip', attrs:{ id:'kp-launcher', type:'button', title:'' }, children:[ Icons.thumb(), badge ] });

    const panel = h('section', { className:'kp-panel', attrs:{ id:'kp-panel', role:'dialog', 'aria-modal':'true', 'aria-label':'추천 도구' } });

    // Filter section
    const filterSec = h('div', { className:'kp-section' });
    filterSec.appendChild(h('div', { className:'kp-label', html:'추천 컷' }));

    const presetSelect = h('select', { className:'kp-select', attrs:{ 'aria-label':'추천 컷 프리셋' } });
    for (const p of PRESETS){
      const opt = h('option', { html:String(p), attrs:{ value:String(p) } });
      if (p===threshold) opt.selected = true;
      presetSelect.appendChild(opt);
    }
    const optCustom = h('option', { html:'커스텀', attrs:{ value:'custom' } });
    if (!PRESETS.includes(threshold)) optCustom.selected = true;
    presetSelect.appendChild(optCustom);

    const customInput = h('input', { className:'kp-input', attrs:{ type:'number', min:'0', step:'1', placeholder:'예: 42', value: !PRESETS.includes(threshold) ? String(threshold) : '', 'aria-label':'사용자 지정 추천 컷' } });
    customInput.style.display = !PRESETS.includes(threshold) ? 'inline-block' : 'none';

    const toggle = h('button', { className:'kp-toggle', attrs:{ 'data-on': String(isFilterEnabled), role:'switch', 'aria-checked': String(isFilterEnabled) } });

    const filterRow = h('div', { className:'kp-row', children:[
      presetSelect, customInput,
      h('span', { className:'kp-label', html:'필터' }), toggle
    ]});

    const noteSlot = h('div', { className: 'kp-note-slot' });

    filterSec.appendChild(filterRow);
    filterSec.appendChild(noteSlot);

    // Sorter section
    const sorterSec = h('div', { className:'kp-section' });
    sorterSec.appendChild(h('div', { className:'kp-label', html:'정렬' }));

    const periodSelect = h('select', { className:'kp-select', attrs:{ 'aria-label':'기간 선택' } });
    Object.entries(PERIODS).forEach(([k,v])=>{
      const o = h('option', { html:v, attrs:{ value:k } });
      if (k===selectedPeriod) o.selected = true; periodSelect.appendChild(o);
    });

    const modeSelect = h('select', { className:'kp-select', attrs:{ 'aria-label':'정렬 기준' } });
    Object.entries(SORT_MODES).forEach(([k,v])=>{
      const o = h('option', { html:v, attrs:{ value:k } });
      if (k===sortMode) o.selected = true; modeSelect.appendChild(o);
    });

    const topSelect = h('select', { className:'kp-select', attrs:{ 'aria-label':'상위 개수' } });
    [100,200,300,500].forEach((n)=>{
      const o = h('option', { html:`상위 ${n}`, attrs:{ value:String(n) } });
      if (n===topN) o.selected = true; topSelect.appendChild(o);
    });

    // Centered action button row
    const runBtn = h('button', { className:'kp-btn primary', attrs:{ type:'button', id:'kp-run' }, children:[ Icons.play(), span('정렬 실행') ] });
    let isRunning = false;
    function setRunState(running){
      isRunning = running;
      runBtn.className = `kp-btn ${running ? 'danger' : 'primary'}`;
      runBtn.innerHTML = '';
      runBtn.appendChild(running ? Icons.stop() : Icons.play());
      runBtn.appendChild(span(running ? '취소' : '정렬 실행'));
    }
    const selectsRow = h('div', { className:'kp-row', children:[ periodSelect, modeSelect, topSelect ] });
    const actionsRow = h('div', { className:'kp-row center', children:[ runBtn ] });

    sorterSec.appendChild(selectsRow);
    sorterSec.appendChild(actionsRow);

    panel.append(filterSec, sorterSec);
    document.body.append(panel);

    // (safe initial dock)
    const fb = h('div', { className:'kp-launcher' });
    fb.appendChild(launcher);
    document.body.appendChild(fb);
    ensureDock(launcher);

    // anchored panel
    const reposition = () => positionPanelUnder(launcher, panel);
    launcher.addEventListener('click', (ev) => {
      ev.preventDefault(); ev.stopPropagation();
      const open = panel.getAttribute('data-open') === 'true';
      const next = !open;
      panel.setAttribute('data-open', String(next));
      if (next){
        reposition();
        window.addEventListener('scroll', reposition, { passive:true });
        window.addEventListener('resize', reposition, { passive:true });
      } else {
        window.removeEventListener('scroll', reposition);
        window.removeEventListener('resize', reposition);
      }
    });

    // select active visual state
    const wireSelectActive = (sel) => {
      const setOpen = (on)=> sel.setAttribute('data-open', on ? 'true' : 'false');
      sel.addEventListener('focus', ()=>setOpen(true));
      sel.addEventListener('blur', ()=>setOpen(false));
      sel.addEventListener('mousedown', ()=>setOpen(true));
      sel.addEventListener('keyup', (e)=>{ if (e.key === 'Escape') setOpen(false); });
      sel.addEventListener('change', ()=>setOpen(false));
    };
    [presetSelect, periodSelect, modeSelect, topSelect].forEach(wireSelectActive);

    // handlers
    presetSelect.addEventListener('change', () => {
      if (presetSelect.value === 'custom'){ customInput.style.display = 'inline-block'; customInput.focus(); }
      else {
        customInput.style.display = 'none';
        threshold = parseInt(presetSelect.value, 10); GM_setValue('recoThreshold', threshold);
        updateBadge(); if (isFilterEnabled) applyFilter();
      }
    });
    customInput.addEventListener('input', debounce(()=>{
      const val = parseInt(customInput.value, 10);
      if (!isNaN(val)){ threshold = val; GM_setValue('recoThreshold', threshold); updateBadge(); if (isFilterEnabled) applyFilter(); }
    }, 200));

    toggle.addEventListener('click', () => {
      isFilterEnabled = !isFilterEnabled;
      sessionStorage.setItem('isFilterEnabled', JSON.stringify(isFilterEnabled));
      toggle.setAttribute('data-on', String(isFilterEnabled));
      toggle.setAttribute('aria-checked', String(isFilterEnabled));
      updateBadge(); applyFilter();
    });

    periodSelect.addEventListener('change', () => { selectedPeriod = periodSelect.value; GM_setValue('sortPeriod', selectedPeriod); });
    modeSelect.addEventListener('change', () => { sortMode = modeSelect.value; GM_setValue('sortMode', sortMode); });
    topSelect.addEventListener('change', () => { topN = parseInt(topSelect.value, 10); GM_setValue('topN', topN); });
    runBtn.addEventListener('click', async () => {
      if (!isRunning){ setRunState(true); try { await loadAndSort(selectedPeriod, topN, sortMode); } finally { setRunState(false); } }
      else { currentAbort?.abort(); }
    });

    function updateBadge(){
      badge.textContent = isFilterEnabled ? `≥${threshold}` : 'OFF';
      badge.setAttribute('data-on', String(isFilterEnabled));
    }

    // hot-mode hint
    updateHotNoteSlot();
    function updateHotNoteSlot(){
      const slot = noteSlot; if (!slot) return;
      slot.innerHTML = '';
      if (!isHotContext()){
        const note = h('div', { className:'kp-note' });
        note.appendChild(h('div', { className:'kp-note-text', html:'해당 스크립트는 개념글에서만<br>정상적으로 작동합니다.' }));
        const actions = h('div', { className:'kp-note-actions' });
        const btn = h('button', { className:'kp-btn', attrs:{ type:'button' }, children:[ span('개념글로 이동') ] });
        btn.addEventListener('click', goToHotPreserveCategory);
        actions.appendChild(btn);
        note.appendChild(actions);
        slot.appendChild(note);
      }
    }
    panel._updateHotNoteSlot = updateHotNoteSlot;
  }

  // ---------- positioning ----------
  function positionPanelUnder(anchor, panel){
    const r = anchor.getBoundingClientRect();
    const top = Math.round(r.bottom + 8);
    const left = Math.round(Math.max(8, Math.min(r.left, window.innerWidth - panel.offsetWidth - 8)));
    panel.style.top = `${top}px`; panel.style.left = `${left}px`; panel.style.right = ''; panel.style.bottom = '';
  }

  // ---------- hot navigation ----------
  function goToHotPreserveCategory(){
    const u = new URL(location.href);
    const cur = new URL(location.href);
    const cat = cur.searchParams.get('category');
    if (cat) u.searchParams.set('category', cat);
    u.searchParams.set('mode','hot');
    history.pushState({}, '', u.toString());
    onPotentialURLChange();
  }

  // ---------- docking (no flicker, persistent) ----------
  function ensureDock(launcher){
    let paused = false;
    let pauseTimer = null;

    const pauseDock = (ms=600) => { paused = true; clearTimeout(pauseTimer); pauseTimer = setTimeout(()=>{ paused = false; }, ms); };
    ['mouseenter','pointerdown','click','touchstart'].forEach(evt=>{
      launcher.addEventListener(evt, ()=>pauseDock(), { passive:true });
    });

    const attachFallback = () => {
      if (!launcher.parentElement || !launcher.parentElement.classList || !launcher.parentElement.classList.contains('kp-launcher')){
        const fb2 = h('div', { className:'kp-launcher' });
        fb2.appendChild(launcher);
        document.body.appendChild(fb2);
      }
      launcher.classList.add('kp-visible');
    };

    const isAlreadyBefore = (node, ref) =>
      node && ref && node.parentElement === ref.parentElement && node.nextSibling === ref;

    const tryDock = () => {
      if (!document.contains(launcher)) attachFallback();
      if (paused) return false;

      const write = $$(`${SELECTORS.WRITE_BUTTON_TEXT}`).find((el)=>/글쓰기/.test(el.textContent||''));
      if (write && write.parentElement){
        if (isAlreadyBefore(launcher, write)) {
          launcher.classList.add('kp-visible');
          launcher.dataset.docked = 'left';
          return true;
        }
        const oldParent = launcher.parentElement;
        write.parentElement.insertBefore(launcher, write);
        launcher.style.marginRight = '8px';
        if (oldParent && oldParent.classList && oldParent.classList.contains('kp-launcher')) oldParent.remove();
        launcher.classList.add('kp-visible');
        launcher.dataset.docked = 'left';
        return true;
      }

      attachFallback();
      launcher.dataset.docked = '';
      return false;
    };

    if (dockObserver) dockObserver.disconnect();
    const debTry = debounce(tryDock, 80);
    dockObserver = new MutationObserver(debTry);
    dockObserver.observe(document.body, { childList:true, subtree:true });

    let attempts = 0;
    const tick = setInterval(() => { tryDock(); if (++attempts > 200) clearInterval(tick); }, 180);
    tryDock();
  }

  // ---------- toast ----------
  let toastTimer = null;
  function toast(message, ms = 2000){
    const el = h('div', { className:'kp-chip kp-visible', attrs:{ style:'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:10000;background:var(--kp-surface-2);' }, children:[ Icons.info(), span(message) ] });
    document.body.appendChild(el);
    clearTimeout(toastTimer); toastTimer = setTimeout(()=>el.remove(), ms);
  }

  // ---------- SPA / navigation detection ----------
  function attachObserver(){
    if (containerObserver) containerObserver.disconnect();
    const container = findListContainer();
    if (container){
      const reapply = debounce(applyFilter, 100);
      containerObserver = new MutationObserver(reapply);
      containerObserver.observe(container, { childList:true, subtree:true });
    }
  }

  function onLocationChange(){
    currentAbort?.abort();
    attachObserver();
    applyFilter();

    // Disable BG open after navigation; it will be re-enabled after the next sort.
    setBackgroundOpenEnabled(false);

    const panel = $('#kp-panel');
    if (panel && typeof panel._updateHotNoteSlot === 'function') panel._updateHotNoteSlot();

    const launcher = $('#kp-launcher');
    if (launcher) ensureDock(launcher);
  }

  function onPotentialURLChange(){
    if (location.href !== lastURL){
      lastURL = location.href;
      window.dispatchEvent(new Event('locationchange'));
    } else {
      onLocationChange();
    }
  }

  function enableSPAHooks(){
    const fire = () => onPotentialURLChange();
    const push = history.pushState; const replace = history.replaceState;
    history.pushState = function(){ push.apply(this, arguments); fire(); };
    history.replaceState = function(){ replace.apply(this, arguments); fire(); };
    window.addEventListener('popstate', fire);

    document.addEventListener('click', (e) => {
      const a = e.target.closest && e.target.closest('a[href]');
      if (!a) return;
      const u = new URL(a.href, location.href);
      if (u.origin === location.origin){ setTimeout(onPotentialURLChange, 180); }
    }, true);

    if (urlPollTimer) clearInterval(urlPollTimer);
    urlPollTimer = setInterval(() => { if (location.href !== lastURL) onPotentialURLChange(); }, 1200);

    window.addEventListener('locationchange', onLocationChange);
  }

  // ---------- bootstrap ----------
  const boot = once(() => {
    const segs = location.pathname.split('/').filter(Boolean);
    if (segs.length > 2) return;

    if (!document.getElementById('kp-panel')) createToolbarAndPanel();
    applyFilter(); attachObserver(); enableSPAHooks();
  });

  window.addEventListener('load', boot, { once:true });
  setTimeout(()=>{ if (!document.getElementById('kp-panel')) boot(); }, 1200);
})();