Supjav Tools

Deep search filter, 1-click open-in-new-tab, tab manager, infinite scroll, and download panel for supjav.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Supjav Tools
// @namespace    supjav-tools
// @version      0.5.3
// @description  Deep search filter, 1-click open-in-new-tab, tab manager, infinite scroll, and download panel for supjav.
// @match        https://*.supjav.com/*
// @match        http://*.supjav.com/*
// @grant        GM_download
// @grant        GM_info
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // Tweak these if the site markup changes. itemSelectors = movie card wrappers,
  // paginationSelectors/nextLinkSelectors = pager links, searchFormSelectors = search form.
  const CONFIG = {
    gridContainerSelectors: ['main', '#main', '.site-main', '.container', '.wrap', '.content', '.list', '.video-list', '.posts', 'body'],
    itemSelectors: ['article', '.item', '.post', '.movie', '.box', '.video-card', '.video-item', '.thumb', '.grid-item'],
    paginationSelectors: ['.pagination', '.navigation', '.nav-links', '.page-nav', '.pager', '.wp-pagenavi'],
    nextLinkSelectors: ['a.next', '.next', 'a.nextpostslink', 'a[rel="next"]', 'li.next a'],
    searchFormSelectors: ['form[role="search"]', 'form.search-form', 'form.search', 'form[action*="supjav"]'],
  };

  // Current code version. VM's GM_info reports the installed copy; the literal
  // keeps desktop/extension embeds (where the metadata header is stripped) in sync.
  const SCRIPT_VERSION =
    (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) ||
    '0.5.3';

   let mediaFromNetwork = [];
   let perfObserverStarted = false;

   // The CDN's Mouflon pkey rotates continuously; the freshest token is always
   // the one the player's live media-playlist requests carry. Track the newest
   // observed token so downloads re-issue the media URL before it rotates out.
   const _pkeyAt = {};
   let _pkeyLast = null;
   function pkeyOf(url) {
     const m = typeof url === 'string' ? url.match(/[?&]pkey=([A-Za-z0-9]+)/) : null;
     return m ? m[1] : null;
   }
   function noteMediaUrl(url) {
     const pkey = pkeyOf(url);
     if (!pkey) return;
     const at = Date.now();
     _pkeyAt[pkey] = at;
     if (!_pkeyLast || _pkeyLast.at <= at) _pkeyLast = { pkey, at };
   }
   function freshestPkey(url) {
     const fromUrl = pkeyOf(url);
     const atUrl = fromUrl ? _pkeyAt[fromUrl] || 0 : -1;
     if (!_pkeyLast) return fromUrl || null;
     return _pkeyLast.at > atUrl ? _pkeyLast.pkey : fromUrl || _pkeyLast.pkey;
   }

   // DOM element cache: .btn-server/.btn-down/iframe lists are static per page,
   // so querying them once avoids repeated full-document scans. Never cache the
   // *result* of detectVideoSources() — new HLS streams appear over time and the
   // auto-monitor/panel refresh must see them.
   let domCache = null;
   function getCachedElement(selector) {
     if (!domCache) domCache = {};
     if (!domCache[selector]) {
       domCache[selector] = Array.from(document.querySelectorAll(selector));
     }
     return domCache[selector];
   }
   
   const matches = (root, selectors) => root.querySelector(selectors.join(','));
   
   function startPerformanceObserver() {
     if (perfObserverStarted) return;
     perfObserverStarted = true;
     try {
        const obs = new PerformanceObserver((list) => {
          list.getEntries().forEach((e) => {
            // Only playlists, never MSE segment files (init/chunk .mp4/.m4s).
            if (/\.(m3u8|mpd)(\?|$)/i.test(e.name)) {
              noteMediaUrl(e.name);
              if (!mediaFromNetwork.includes(e.name)) {
                mediaFromNetwork.push(e.name);
              }
            }
          });
        });
       obs.observe({ type: 'resource', buffered: true });
     } catch (e) {}
   }
   
   const SUPJAV_TOOLS = {
    // ---------------------------------------------------------------- source detection
    detectVideoSources() {
      const sources = [];
      const push = (url, kind, label) => {
        if (url && typeof url === 'string') {
          const clean = url.trim();
          if (clean && !sources.some((s) => s.url === clean)) {
            let host = '';
            try {
              host = new URL(clean).hostname;
            } catch (e) {}
            sources.push({ url: clean, kind, label: label || host });
          }
        }
      };
      const mediaKind = (u) => (/\.(m3u8|mpd)(\?|$)/i.test(u) ? 'hls' : 'video');

      const videoIframe = document.querySelector('iframe#video, iframe[allowfullscreen]');
      if (videoIframe) push(videoIframe.src, 'iframe', 'Player iframe');

      const servers = getCachedElement('.btn-server');
      servers.forEach((a) => {
        push(a.href || a.dataset.src, 'server', `Server ${(a.textContent || '').trim() || 'server'}`);
      });
      const links = getCachedElement('.btn-down');
      links.forEach((a) => {
        push(a.href, 'link', `Link ${(a.textContent || '').trim() || 'link'}`);
      });

      const iframes = getCachedElement('iframe');
      iframes.forEach((f) => {
        let doc;
        try {
          doc = f.contentDocument;
        } catch (e) {
          return;
        }
        if (!doc) return;
        const videos = doc.querySelectorAll('video');
        for (const v of videos) {
          push(v.currentSrc || v.src, 'video', 'Video tag');
          for (const s of v.querySelectorAll('source')) push(s.src, 'video', 'Video tag');
        }
        const txt = Array.from(doc.scripts).map((s) => s.textContent).join(' ');
        const hlsMatches = txt.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/g) || [];
        for (const u of hlsMatches) push(u, 'hls', 'HLS playlist');
        const mp4Matches = txt.match(/https?:\/\/[^'"\s]+\.mp4[^'"\s]*/g) || [];
        for (const u of mp4Matches) push(u, 'video', 'MP4 file');
      });

      const videos = document.querySelectorAll('video');
      for (const v of videos) {
        for (const u of [v.currentSrc, v.src]) {
          if (/^https?:\/\//i.test(u || '')) push(u, mediaKind(u), 'Video tag');
        }
        for (const s of v.querySelectorAll('source')) {
          if (/^https?:\/\//i.test(s.src || '')) push(s.src, mediaKind(s.src), 'Video tag');
        }
      }

      const scriptText = Array.from(document.scripts).map((s) => s.textContent).join(' ');
      const hlsMatches = scriptText.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/g) || [];
      for (const u of hlsMatches) push(u, 'hls', 'HLS playlist');

      const mp4Matches = scriptText.match(/https?:\/\/[^'"\s]+\.mp4[^'"\s]*/g) || [];
      for (const u of mp4Matches) push(u, 'video', 'MP4 file');

      for (const k of ['playerData', 'videoData', 'player_config', 'sources']) {
        let v;
        try {
          v = window[k];
        } catch (e) {
          continue;
        }
        if (v && typeof v === 'object') {
          const raw = JSON.stringify(v);
          const hlsMatches = (raw.match(/https?:\/\/[^"\\\s]+\.m3u8[^"\\\s]*/g) || []);
          for (const u of hlsMatches) push(u, 'hls', 'Player config');
          const mp4Matches = (raw.match(/https?:\/\/[^"\\\s]+\.mp4[^"\\\s]*/g) || []);
          for (const u of mp4Matches) push(u, 'video', 'Player config');
        }
      }

      for (const u of mediaFromNetwork) {
        noteMediaUrl(u);
        push(u, /\.m3u8|\.mpd/i.test(u) ? 'hls' : 'video', 'Live network');
      }

      const resources = performance.getEntriesByType('resource');
      for (const e of resources) {
        if (/\.m3u8(\?|$)/i.test(e.name)) {
          noteMediaUrl(e.name);
          push(e.name, 'hls', 'Network');
        }
      }

      if (window.supjavAPI && typeof window.supjavAPI.getMediaUrls === 'function') {
        try {
          for (const u of window.supjavAPI.getMediaUrls()) {
            push(u, /\.m3u8(\?|$)/i.test(u) ? 'hls' : 'video', 'Desktop capture');
          }
        } catch (e) {}
      }

      return sources;
    },

    // Parses the video code/title/studio from the current page (URL slug + DOM).
    // Used for filename templating and Downloads/<folder>/ organization.
    videoInfo() {
      if (SUPJAV_TOOLS._videoInfo) return SUPJAV_TOOLS._videoInfo;
      const info = { code: '', title: '', studio: '' };
      const slugMatch = location.pathname.match(/\/video\/([^/]+)/i);
      const slug = slugMatch ? decodeURIComponent(slugMatch[1]) : '';
      const codeMatch = slug.match(/([a-z]{2,10})[-_ ]?(\d{2,6})/i);
      if (codeMatch) {
        info.code = `${codeMatch[1]}-${codeMatch[2]}`.toUpperCase();
        info.title = slug.replace(codeMatch[0], '').replace(/[-_]+/g, ' ').trim();
      } else {
        info.title = slug.replace(/[-_]+/g, ' ').trim();
      }
      if (!info.title) {
        const h = document.querySelector('.archive-title h1, h1, .title');
        if (h) info.title = h.textContent;
      }
      const studioEl = document.querySelector(
        '.studio a, a[href*="studio"], .director a, a[href*="release"]',
      );
      if (studioEl) info.studio = studioEl.textContent;
      const safe = (s, max) =>
        (s || '')
          .replace(/\[.*?\]/g, ' ')
          .replace(/[^\w\-. ]+/g, '')
          .replace(/\s+/g, ' ')
          .trim()
          .slice(0, max);
      info.code = safe(info.code, 20).toUpperCase();
      info.title = safe(info.title, 80);
      info.studio = safe(info.studio, 40);
      SUPJAV_TOOLS._videoInfo = info;
      return info;
    },

    videoFolder() {
      return SUPJAV_TOOLS.videoInfo().studio || '';
    },

    defaultFilename(index, kind) {
      const info = SUPJAV_TOOLS.videoInfo();
      const base =
        [info.code, info.title].filter(Boolean).join('_').replace(/\s+/g, '_') ||
        `supjav_${index}`;
      const ext = kind === 'hls' ? 'm3u8' : 'mp4';
      return `${base}_${index}.${ext}`;
    },

    download(url, filename) {
      const folder = SUPJAV_TOOLS.videoFolder();
      if (typeof GM_download === 'function') {
        GM_download(url, filename);
      } else if (window.supjavAPI && typeof window.supjavAPI.download === 'function') {
        window.supjavAPI.download(url, filename, folder);
      } else {
        const a = document.createElement('a');
        a.href = url;
        a.download = filename || '';
        document.body.appendChild(a);
        a.click();
        a.remove();
      }
    },

    ensureToast() {
      if (SUPJAV_TOOLS._toast && document.contains(SUPJAV_TOOLS._toast)) return SUPJAV_TOOLS._toast;
      const t = document.createElement('div');
      t.style.cssText =
        'position:fixed;left:16px;bottom:16px;z-index:2147483647;font:12px/1.4 system-ui,sans-serif;' +
        'color:#eee;background:#1b1b1f;border:1px solid #2e7d32;border-radius:6px;padding:6px 10px;' +
        'max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
      document.body.appendChild(t);
      SUPJAV_TOOLS._toast = t;
      return t;
    },

    copyText(text) {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        try {
          return navigator.clipboard.writeText(text).catch(() => SUPJAV_TOOLS.copyTextFallback(text));
        } catch (e) {
          // Some browsers throw synchronously without clipboard permission/gesture.
          return Promise.resolve(SUPJAV_TOOLS.copyTextFallback(text));
        }
      }
      SUPJAV_TOOLS.copyTextFallback(text);
      return Promise.resolve();
    },

    copyTextFallback(text) {
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      try {
        document.execCommand('copy');
      } catch (e) {
        // execCommand can throw when the page lacks clipboard/focus; nothing to do.
      }
      ta.remove();
    },

    sendToJd(urls) {
      return SUPJAV_TOOLS.copyText(urls.join('\n')).then(() => {
        SUPJAV_TOOLS.ensureToast().textContent =
          `Copied ${urls.length} link${urls.length === 1 ? '' : 's'} — JDownloader LinkGrabber will grab it`;
      });
    },

    sendToIdm(url) {
      const a = document.createElement('a');
      a.className = 'st-idm';
      a.href = url;
      a.style.display = 'none';
      document.body.appendChild(a);
      a.click();
      a.remove();
      SUPJAV_TOOLS.ensureToast().textContent =
        'Sent to IDM — starts a browser fallback download if not intercepted';
    },

    downloadHls(url, filename, row, isAuto) {
      if (!window.supjavAPI || typeof window.supjavAPI.downloadHls !== 'function') {
        SUPJAV_TOOLS.download(url, filename);
        return;
      }
      const target = row || SUPJAV_TOOLS.ensureToast();
      const key = row || SUPJAV_TOOLS._toast;
      if (!key._progSubscribed) {
        key._progSubscribed = true;
        window.supjavAPI.onHlsProgress((p) => {
          if (key._progUrl !== url) return;
          if (p.complete) {
            target.textContent = `Auto-downloaded \u2192 ${p.filename}`;
          } else {
            const pct = Math.round((p.done / p.total) * 100);
            target.textContent = `Auto HLS ${pct}% (${p.done}/${p.total})`;
          }
        });
      }
      key._progUrl = url;
      target.textContent = 'Auto HLS 0%';
      const pkey = freshestPkey(url);
      window.supjavAPI
        .downloadHls(url, filename, !!isAuto, SUPJAV_TOOLS.videoFolder(), pkey)
        .then((res) => {
          if (res && res.started) return; // progress events update the row
          if (res && res.skipped) target.textContent = `Skipped \u2014 ${res.reason || 'already downloaded'}`;
          else if (res && res.ok) target.textContent = `Done \u2192 ${filename}`;
          else if (res && res.error) target.textContent = `HLS error: ${res.error}`;
        });
    },

    // ---------------------------------------------------------------- queue auto-mode (desktop)
    auto: false,
    _autoTried: {},
    _autoTimer: null,

    setAuto(v) {
      SUPJAV_TOOLS.auto = !!v;
      if (SUPJAV_TOOLS.auto) {
        SUPJAV_TOOLS.startAutoMonitor();
      } else if (SUPJAV_TOOLS._autoTimer) {
        clearInterval(SUPJAV_TOOLS._autoTimer);
        SUPJAV_TOOLS._autoTimer = null;
      }
    },

    startAutoMonitor() {
      if (SUPJAV_TOOLS._autoTimer) return;
      SUPJAV_TOOLS._autoTimer = setInterval(() => {
        if (!SUPJAV_TOOLS.auto) {
          clearInterval(SUPJAV_TOOLS._autoTimer);
          SUPJAV_TOOLS._autoTimer = null;
          return;
        }
        const sources = SUPJAV_TOOLS.detectVideoSources();
        const untried = sources.filter((s) => !SUPJAV_TOOLS._autoTried[s.url]);
        const hls = untried.find((s) => s.kind === 'hls');
        if (hls) {
          SUPJAV_TOOLS._autoTried[hls.url] = true;
          SUPJAV_TOOLS.downloadHls(hls.url, SUPJAV_TOOLS.defaultFilename(1, 'hls'), null, true);
          return;
        }
        // Extension-only fallback: with no HLS stream, auto-download the MP4
        // directly. The desktop app merges HLS and lets MP4-only videos hit the
        // queue watchdog, so this is gated to the extension to stay neutral.
        if (window.supjavAPI && window.supjavAPI.runtime === 'extension') {
          const mp4 = untried.find((s) => s.kind === 'mp4');
          if (mp4) {
            SUPJAV_TOOLS._autoTried[mp4.url] = true;
            SUPJAV_TOOLS.download(mp4.url, SUPJAV_TOOLS.defaultFilename(1, 'mp4'));
          }
        }
      }, 2000);
    },

    enqueue(url, title) {
      if (window.supjavAPI && typeof window.supjavAPI.enqueue === 'function') {
        return window.supjavAPI.enqueue(url, title);
      }
      return Promise.resolve(null);
    },

    // ---------------------------------------------------------------- listing page helpers
    findCards(root) {
      const cards = [];
      const seen = new Set();
      const push = (el) => {
        const linkEl = el.matches('a[href]') ? el : el.querySelector('a[href]');
        if (!linkEl || seen.has(linkEl)) return;
        const href = linkEl.href;
        if (!href || href === location.href || href.startsWith('javascript:')) return;
        seen.add(linkEl);
        const titleEl = el.querySelector('h1,h2,h3,.title,.name');
        const img = el.querySelector('img');
        const title = (titleEl ? titleEl.textContent : img ? img.alt : '').trim();
        cards.push({ el, linkEl, href, title, img });
      };
      root.querySelectorAll(CONFIG.itemSelectors.join(',')).forEach(push);
      if (!cards.length) {
        root.querySelectorAll('a[href] img').forEach((img) => {
          const a = img.closest('a[href]');
          if (a) push(a);
        });
      }
      return cards;
    },

    isListPage() {
      if (document.querySelector('#dz_video')) return false;
      return (
        SUPJAV_TOOLS.findCards(document).length >= 2 ||
        matches(document, CONFIG.searchFormSelectors) !== null ||
        SUPJAV_TOOLS.getNextLink() !== null
      );
    },

    _pageNum(href) {
      let m = href.match(/[?&]page=(\d+)/);
      if (m) return parseInt(m[1], 10);
      m = href.match(/[?&]paged=(\d+)/);
      if (m) return parseInt(m[1], 10);
      m = href.match(/\/page\/(\d+)\/?/);
      if (m) return parseInt(m[1], 10);
      return null;
    },

    getNextLink(root) {
      root = root || document;
      const current = location.href;
      for (const sel of CONFIG.paginationSelectors) {
        const pag = root.querySelector(sel);
        if (!pag) continue;
        for (const sel2 of CONFIG.nextLinkSelectors) {
          const a = pag.querySelector(sel2);
          if (a && a.href && a.href !== current) return a.href;
        }
        const any = pag.querySelector('a[href]');
        if (any && any.href && any.href !== current) return any.href;
      }
      const curPage = SUPJAV_TOOLS._pageNum(current) || 1;
      const candidates = [];
      root.querySelectorAll('a[href]').forEach((a) => {
        const href = a.href;
        if (!href || href === current) return;
        const page = SUPJAV_TOOLS._pageNum(href);
        if (page !== null) {
          candidates.push({ href, page });
          return;
        }
        const label = [a.textContent, a.getAttribute('aria-label'), a.getAttribute('title'), a.rel]
          .join(' ')
          .toLowerCase();
        if (/\bnext\b|»|nächste|suivant/.test(label)) {
          candidates.push({ href, page: curPage + 1 });
        }
      });
      candidates.sort((x, y) => x.page - y.page);
      const next = candidates.find((c) => c.page === curPage + 1);
      if (next) return next.href;
      const later = candidates.find((c) => c.page > curPage);
      if (later) return later.href;
      if (candidates.length) return candidates[0].href;
      return null;
    },

    getContainer() {
      if (SUPJAV_TOOLS._containerSel) return document.querySelector(SUPJAV_TOOLS._containerSel);
      for (const sel of CONFIG.gridContainerSelectors) {
        const el = document.querySelector(sel);
        if (el && el.querySelector(CONFIG.itemSelectors.join(','))) {
          SUPJAV_TOOLS._containerSel = sel;
          return el;
        }
      }
      return document.body;
    },

    // ---------------------------------------------------------------- card buttons + filters
    enhanceCards(root) {
      SUPJAV_TOOLS.findCards(root).forEach((c) => {
        if (!c.el.style.position) c.el.style.position = 'relative';
        if (!c.el.querySelector('.st-open')) {
          const b = document.createElement('button');
          b.className = 'st-open';
          b.textContent = 'OPEN';
          b.title = 'Open video in this tab';
          b.style.cssText =
            'position:absolute;right:4px;top:4px;z-index:5;background:#1a73e8;color:#fff;' +
            'border:0;border-radius:4px;padding:2px 8px;font:600 11px/1.6 system-ui,sans-serif;' +
            'cursor:pointer;';
          b.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            location.href = c.href;
          });
          c.el.appendChild(b);
        }
        if (!c.el.querySelector('.st-queue') && window.supjavAPI && typeof window.supjavAPI.enqueue === 'function') {
          const q = document.createElement('button');
          q.className = 'st-queue';
          q.textContent = 'QUEUE';
          q.title = 'Add to desktop download queue';
          q.style.cssText =
            'position:absolute;left:4px;top:4px;z-index:5;background:#2e7d32;color:#fff;' +
            'border:0;border-radius:4px;padding:2px 8px;font:600 11px/1.6 system-ui,sans-serif;' +
            'cursor:pointer;';
          q.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            const r = SUPJAV_TOOLS.enqueue(c.href, c.title || '');
            if (r && typeof r.then === 'function') {
              r.then((res) => {
                if (res && res.rejected) q.textContent = 'Downloaded';
                else if (res && res.queued) q.textContent = 'Added';
                else q.textContent = 'In queue';
                setTimeout(() => (q.textContent = 'QUEUE'), 1200);
              }).catch(() => {});
            } else {
              q.textContent = 'Added';
              setTimeout(() => (q.textContent = 'QUEUE'), 1200);
            }
          });
          c.el.appendChild(q);
        }
        c.el.classList.add('st-card');
      });
    },

    filters: { q: '', include: [], exclude: [], yearMin: '', yearMax: '' },

    applyFilters() {
      const f = SUPJAV_TOOLS.filters;
      const inc = f.include.map((s) => s.trim().toLowerCase()).filter(Boolean);
      const exc = f.exclude.map((s) => s.trim().toLowerCase()).filter(Boolean);
      document.querySelectorAll('.st-card').forEach((el) => {
        const title = (el.textContent || '').toLowerCase();
        let show = true;
        if (inc.length && !inc.every((k) => title.includes(k))) show = false;
        if (show && exc.length && exc.some((k) => title.includes(k))) show = false;
        if (show && (f.yearMin || f.yearMax)) {
          const years = (title.match(/(19|20)\d{2}/g) || []).map(Number);
          if (years.length) {
            if (f.yearMin && years.every((y) => y < f.yearMin)) show = false;
            if (f.yearMax && years.every((y) => y > f.yearMax)) show = false;
          }
        }
        el.style.display = show ? '' : 'none';
      });
    },

    // ---------------------------------------------------------------- infinite scroll
    _nextHref: null,
    _loading: false,
    _loadedPages: new Set(),

    startInfiniteScroll() {
      if (SUPJAV_TOOLS._sentinel) return;
      SUPJAV_TOOLS._nextHref = SUPJAV_TOOLS.getNextLink();
      if (!SUPJAV_TOOLS._nextHref) {
        if (!SUPJAV_TOOLS._retry) {
          SUPJAV_TOOLS._retry = true;
          const iv = setInterval(() => {
            SUPJAV_TOOLS._nextHref = SUPJAV_TOOLS.getNextLink();
            if (SUPJAV_TOOLS._nextHref) {
              clearInterval(iv);
              SUPJAV_TOOLS.startInfiniteScroll();
            }
          }, 1000);
          setTimeout(() => clearInterval(iv), 15000);
        }
        return;
      }
      const container = SUPJAV_TOOLS.getContainer();
      if (!container) return;
      const sentinel = document.createElement('div');
      sentinel.className = 'st-sentinel';
      sentinel.style.cssText = 'height:1px;';
      container.appendChild(sentinel);
      SUPJAV_TOOLS._sentinel = sentinel;
      new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) SUPJAV_TOOLS.loadNextPage();
      }, { rootMargin: '1200px' }).observe(sentinel);
    },

    async loadNextPage() {
      if (SUPJAV_TOOLS._loading || !SUPJAV_TOOLS._nextHref) return;
      if (SUPJAV_TOOLS._loadedPages.has(SUPJAV_TOOLS._nextHref)) {
        SUPJAV_TOOLS._nextHref = null;
        return;
      }
      SUPJAV_TOOLS._loading = true;
      const container = SUPJAV_TOOLS.getContainer();
      const indicator = document.createElement('div');
      indicator.className = 'st-loading';
      indicator.textContent = 'Loading more…';
      indicator.style.cssText =
        'padding:10px;text-align:center;color:#999;font:13px system-ui,sans-serif;';
      if (container && SUPJAV_TOOLS._sentinel)
        container.insertBefore(indicator, SUPJAV_TOOLS._sentinel);
      try {
        const res = await fetch(SUPJAV_TOOLS._nextHref, { credentials: 'same-origin' });
        if (!res.ok) throw new Error(String(res.status));
        const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
        const source = SUPJAV_TOOLS._containerSel
          ? doc.querySelector(SUPJAV_TOOLS._containerSel) || doc.body
          : doc.body;
        const cards = SUPJAV_TOOLS.findCards(source);
        if (!cards.length) {
          SUPJAV_TOOLS._nextHref = null;
          return;
        }
        const existing = new Set(
          Array.from((container || document).querySelectorAll('a[href]')).map((a) => a.href)
        );
        const frag = document.createDocumentFragment();
        cards.forEach((c) => {
          if (existing.has(c.href)) return;
          frag.appendChild(c.el.cloneNode(true));
        });
        if (container && SUPJAV_TOOLS._sentinel)
          container.insertBefore(frag, SUPJAV_TOOLS._sentinel);
        SUPJAV_TOOLS.enhanceCards(container);
        SUPJAV_TOOLS.applyFilters();
        SUPJAV_TOOLS._loadedPages.add(SUPJAV_TOOLS._nextHref);
        SUPJAV_TOOLS._nextHref = SUPJAV_TOOLS.getNextLink(doc);
      } catch (e) {
        SUPJAV_TOOLS._nextHref = null;
      } finally {
        if (indicator && indicator.parentNode) indicator.parentNode.removeChild(indicator);
        SUPJAV_TOOLS._loading = false;
      }
    },

    // ---------------------------------------------------------------- search + filter bar
    buildFilterBar() {
      const bar = document.createElement('div');
      bar.style.cssText =
        'position:sticky;top:0;z-index:9;background:#1b1b1f;color:#eee;padding:8px 10px;' +
        'border-bottom:1px solid #333;font:13px/1.5 system-ui,sans-serif;';

      const row = (label, input, extra) => {
        const w = document.createElement('div');
        w.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-right:10px;';
        const l = document.createElement('label');
        l.textContent = label;
        l.style.cssText = 'color:#999;';
        w.appendChild(l);
        w.appendChild(input);
        if (extra) w.appendChild(extra);
        return w;
      };
      const inputStyle =
        'background:#2a2a30;color:#eee;border:1px solid #444;border-radius:4px;padding:4px 6px;font:inherit;';

      const q = document.createElement('input');
      q.placeholder = 'Search site...';
      q.style.cssText = inputStyle + 'width:180px;';
      const go = document.createElement('button');
      go.textContent = 'Search';
      go.style.cssText = btnStyle();
      go.addEventListener('click', () => {
        const v = q.value.trim();
        location.href = location.origin + '/?s=' + encodeURIComponent(v);
      });
      q.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') go.click();
      });

      const inc = document.createElement('input');
      inc.placeholder = 'include words';
      inc.style.cssText = inputStyle + 'width:140px;';
      const exc = document.createElement('input');
      exc.placeholder = 'exclude words';
      exc.style.cssText = inputStyle + 'width:140px;';
      const yMin = document.createElement('input');
      yMin.type = 'number';
      yMin.placeholder = 'Year from';
      yMin.style.cssText = inputStyle + 'width:80px;';
      const yMax = document.createElement('input');
      yMax.type = 'number';
      yMax.placeholder = 'Year to';
      yMax.style.cssText = inputStyle + 'width:80px;';

      const apply = () => {
        SUPJAV_TOOLS.filters.include = inc.value.split(/[\s,]+/);
        SUPJAV_TOOLS.filters.exclude = exc.value.split(/[\s,]+/);
        SUPJAV_TOOLS.filters.yearMin = yMin.value ? parseInt(yMin.value, 10) : '';
        SUPJAV_TOOLS.filters.yearMax = yMax.value ? parseInt(yMax.value, 10) : '';
        SUPJAV_TOOLS.applyFilters();
      };
      const applyBtn = document.createElement('button');
      applyBtn.textContent = 'Filter';
      applyBtn.style.cssText = btnStyle();
      applyBtn.addEventListener('click', apply);
      const reset = document.createElement('button');
      reset.textContent = 'Reset';
      reset.style.cssText = btnStyle();
      reset.addEventListener('click', () => {
        inc.value = exc.value = yMin.value = yMax.value = '';
        SUPJAV_TOOLS.filters = { q: '', include: [], exclude: [], yearMin: '', yearMax: '' };
        SUPJAV_TOOLS.applyFilters();
      });

      bar.appendChild(row('', q, go));
      bar.appendChild(row('Include', inc));
      bar.appendChild(row('Exclude', exc));
      bar.appendChild(row('Year', yMin, yMax));
      bar.appendChild(applyBtn);
      bar.appendChild(reset);

      if (window.supjavAPI && typeof window.supjavAPI.enqueueAll === 'function') {
        const qAll = document.createElement('button');
        qAll.textContent = 'Queue all visible';
        qAll.style.cssText = btnStyle() + 'margin-left:10px;';
        qAll.addEventListener('click', () => {
          const items = [];
          document.querySelectorAll('.st-card').forEach((el) => {
            if (el.style.display === 'none') return;
            const a = el.querySelector('a[href]');
            if (!a) return;
            const titleEl = el.querySelector('h1,h2,h3,.title,.name');
            items.push({
              url: a.href,
              title: (titleEl ? titleEl.textContent : a.getAttribute('title') || '').trim().slice(0, 100),
            });
          });
          if (!items.length) {
            qAll.textContent = 'No visible cards';
            setTimeout(() => (qAll.textContent = 'Queue all visible'), 1500);
            return;
          }
          window.supjavAPI.enqueueAll(items).then((r) => {
            if (r && typeof r.added === 'number') {
              qAll.textContent = `Queued ${r.added} (${r.skipped || 0} dupes)`;
            } else {
              qAll.textContent = 'Queue unavailable';
            }
            setTimeout(() => (qAll.textContent = 'Queue all visible'), 2000);
          }).catch(() => {
            qAll.textContent = 'Queue unavailable';
            setTimeout(() => (qAll.textContent = 'Queue all visible'), 1500);
          });
        });
        bar.appendChild(qAll);
      }

      function btnStyle() {
        return (
          'background:#3a3a42;color:#eee;border:1px solid #555;border-radius:4px;' +
          'padding:4px 10px;cursor:pointer;font:inherit;'
        );
      }
      return bar;
    },

    // Serializes everything the tool "sees" on the current page so it can be
    // pasted back to verify live detection without automated site access.
    collectDebugReport() {
      const sources = SUPJAV_TOOLS.detectVideoSources();
      return {
        scriptVersion: SCRIPT_VERSION,
        url: location.href,
        title: (document.title || '').slice(0, 160),
        isList: !!SUPJAV_TOOLS.isListPage(),
        next: SUPJAV_TOOLS.getNextLink ? SUPJAV_TOOLS.getNextLink() : null,
        sourceCount: sources.length,
        sources: sources.map((s) => ({ kind: s.kind, url: s.url, label: s.label })),
        extMarker: document.documentElement.getAttribute('data-st-ext'),
        supjavApi:
          !!window.supjavAPI && typeof window.supjavAPI.enqueue === 'function',
      };
    },

    // ---------------------------------------------------------------- floating panel (sources + tabs)
    buildPanel() {
      const panel = document.createElement('div');
      panel.style.cssText =
        'position:fixed;right:16px;bottom:16px;z-index:2147483647;font:13px/1.5 system-ui,sans-serif;' +
        'color:#eee;background:#1b1b1f;border:1px solid #333;border-radius:8px;padding:10px 12px;' +
        'box-shadow:0 4px 20px rgba(0,0,0,.5);width:340px;max-height:70vh;overflow:auto;';

      const title = document.createElement('div');
      title.textContent = 'Supjav Tools';
      title.style.cssText = 'font-weight:600;margin-bottom:8px;color:#fff;';
      panel.appendChild(title);

      const label = (text) => {
        const d = document.createElement('div');
        d.textContent = text;
        d.style.cssText = 'font-weight:600;margin:10px 0 4px;color:#bbb;';
        return d;
      };

      const list = document.createElement('div');
      panel.appendChild(label('Video sources'));
      panel.appendChild(list);

      const refresh = () => {
        const sources = SUPJAV_TOOLS.detectVideoSources();
        if (JSON.stringify(sources) === panel._lastKey) return;
        panel._lastKey = JSON.stringify(sources);
        list.replaceChildren();
        if (!sources.length) {
          const empty = document.createElement('div');
          empty.textContent = 'No video sources detected on this page.';
          empty.style.cssText = 'color:#999;';
          list.appendChild(empty);
          return;
        }
        sources.forEach((s, i) => {
          const wrap = document.createElement('div');
          wrap.style.cssText = 'display:flex;gap:4px;margin:4px 0;';
          const row = document.createElement('button');
          row.textContent = `${i + 1}. ${s.kind.toUpperCase()} — ${s.label}`;
          row.title = s.url;
          row.style.cssText =
            'flex:1;padding:6px 8px;text-align:left;background:#2a2a30;color:#eee;' +
            'border:1px solid #444;border-radius:6px;cursor:pointer;overflow:hidden;' +
            'text-overflow:ellipsis;white-space:nowrap;';
          row.addEventListener('click', () => {
            const filename = SUPJAV_TOOLS.defaultFilename(i + 1, s.kind);
            if (s.kind === 'hls') {
              SUPJAV_TOOLS.downloadHls(s.url, filename, row);
            } else {
              SUPJAV_TOOLS.download(s.url, filename);
            }
          });
          wrap.appendChild(row);
          const mini = (label, title, fn) => {
            const b = document.createElement('button');
            b.textContent = label;
            b.title = title;
            b.style.cssText =
              'padding:6px 8px;background:#1b1b1f;color:#eee;border:1px solid #444;' +
              'border-radius:6px;cursor:pointer;font:600 11px/1.4 system-ui,sans-serif;';
            b.addEventListener('click', fn);
            wrap.appendChild(b);
          };
          mini(
            'IDM',
            'Send to Internet Download Manager (IDM browser extension must be installed to intercept)',
            () => SUPJAV_TOOLS.sendToIdm(s.url),
          );
          mini(
            'JD',
            'Copy link to clipboard for JDownloader LinkGrabber (enable clipboard monitoring in JDownloader settings)',
            () => SUPJAV_TOOLS.sendToJd([s.url]),
          );
          list.appendChild(wrap);
        });
      };

      // auto-monitor section
      const autoBox = document.createElement('div');
      autoBox.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;';
      const autoChk = document.createElement('input');
      autoChk.type = 'checkbox';
      autoChk.checked = SUPJAV_TOOLS.auto;
      autoChk.addEventListener('change', () => {
        SUPJAV_TOOLS.setAuto(autoChk.checked);
        localStorage.setItem('st_auto', autoChk.checked ? '1' : '0');
      });
      const autoLbl = document.createElement('span');
      autoLbl.textContent = 'Auto-monitor: auto-download video when a stream appears';
      autoLbl.style.cssText = 'color:#ccc;';
      autoBox.appendChild(autoChk);
      autoBox.appendChild(autoLbl);

      panel.appendChild(label('Auto-monitor'));
      panel.appendChild(autoBox);

      if (window.supjavAPI && typeof window.supjavAPI.enqueue === 'function') {
        const q = document.createElement('button');
        q.textContent = 'Add this video to queue';
        q.style.cssText = btnStyle() + 'width:100%;margin-top:8px;';
        q.addEventListener('click', () => {
          const t = (document.querySelector('.archive-title h1') || document.body)
            .textContent.trim()
            .slice(0, 100);
          SUPJAV_TOOLS.enqueue(location.href, t).then((r) => {
            if (r && r.queued) q.textContent = `Queued (${r.queue} in queue)`;
            else if (r && r.rejected) q.textContent = 'Already downloaded';
            else if (r && !r.queued) q.textContent = 'Already queued';
            else q.textContent = 'Queue unavailable';
            setTimeout(() => (q.textContent = 'Add this video to queue'), 2000);
          });
        });
        panel.appendChild(q);
      }

      // history section (desktop only)
      let historyBox = null;
      if (window.supjavAPI && typeof window.supjavAPI.getHistory === 'function') {
        historyBox = document.createElement('div');
        historyBox.style.cssText = 'color:#999;font:12px/1.4 system-ui,sans-serif;';
        const clearHist = document.createElement('button');
        clearHist.textContent = 'Clear history';
        clearHist.style.cssText = btnStyle() + 'margin-left:6px;';
        clearHist.addEventListener('click', () => {
          window.supjavAPI
            .clearHistory()
            .then(() => renderHistory())
            .catch(() => {});
        });
        const histLabel = label('History');
        histLabel.style.display = 'flex';
        histLabel.style.justifyContent = 'space-between';
        histLabel.style.alignItems = 'center';
        histLabel.appendChild(clearHist);
        panel.appendChild(histLabel);
        panel.appendChild(historyBox);
        const renderHistory = () => {
          window.supjavAPI
            .getHistory()
            .then((items) => {
              historyBox.replaceChildren();
              if (!items || !items.length) {
                historyBox.textContent = 'No downloads yet.';
                return;
              }
              items.slice(0, 8).forEach((h) => {
                const row = document.createElement('div');
                row.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
                const when = new Date(h.time).toLocaleTimeString([], {
                  hour: '2-digit',
                  minute: '2-digit',
                });
                row.textContent = `${when} ${h.folder ? h.folder + '/' : ''}${h.filename} [${h.status}]`;
                row.title = h.url || '';
                historyBox.appendChild(row);
              });
            })
            .catch(() => {});
        };
        renderHistory();
        SUPJAV_TOOLS._renderHistory = renderHistory;
      }

      const foot = document.createElement('div');
      foot.style.cssText =
        'margin-top:10px;padding-top:8px;border-top:1px solid #333;font:11px system-ui,sans-serif;' +
        'color:#777;display:flex;justify-content:space-between;align-items:center;';
      const ver = document.createElement('span');
      ver.textContent = `Supjav Tools v${SCRIPT_VERSION}`;
      foot.appendChild(ver);
      if (SUPJAV_TOOLS._updateInfo) {
        const up = document.createElement('a');
        up.textContent = `Update v${SUPJAV_TOOLS._updateInfo.from} \u2192 v${SUPJAV_TOOLS._updateInfo.to}`;
        up.href = 'http://127.0.0.1:28719/';
        up.target = '_blank';
        up.style.cssText = 'color:#81c784;';
        foot.appendChild(up);
      }
      panel.appendChild(foot);

      const dbg = document.createElement('button');
      dbg.textContent = 'Copy debug report';
      dbg.title = 'Copy what this page looks like to the tool (URL, sources, pagination) so it can be verified without automated site access';
      dbg.style.cssText = btnStyle() + 'width:100%;margin-top:8px;';
      dbg.addEventListener('click', () => {
        SUPJAV_TOOLS.copyText(JSON.stringify(SUPJAV_TOOLS.collectDebugReport(), null, 2)).then(() => {
          SUPJAV_TOOLS.ensureToast().textContent =
            'Debug report copied \u2014 paste it back to the assistant';
        });
      });
      panel.appendChild(dbg);

      const close = document.createElement('button');
      close.textContent = 'Close';
      close.style.cssText =
        'margin-top:10px;width:100%;padding:5px;background:transparent;color:#999;' +
        'border:1px solid #444;border-radius:6px;cursor:pointer;';
      close.addEventListener('click', () => {
        panel._timer && clearInterval(panel._timer);
        panel.remove();
        SUPJAV_TOOLS.fab.style.display = 'flex';
      });
      panel.appendChild(close);

      panel._timer = setInterval(() => {
        refresh();
        if (SUPJAV_TOOLS._renderHistory) SUPJAV_TOOLS._renderHistory();
      }, 2000);
      refresh();
      if (SUPJAV_TOOLS._renderHistory) SUPJAV_TOOLS._renderHistory();
      return panel;

      function btnStyle() {
        return (
          'background:#3a3a42;color:#eee;border:1px solid #555;border-radius:4px;' +
          'padding:3px 8px;cursor:pointer;font:inherit;'
        );
      }
    },

    versionGt(a, b) {
      const left = String(a || '')
        .split('.')
        .map((n) => parseInt(n, 10) || 0);
      const right = String(b || '')
        .split('.')
        .map((n) => parseInt(n, 10) || 0);
      for (let i = 0; i < Math.max(left.length, right.length); i++) {
        const x = left[i] || 0;
        const y = right[i] || 0;
        if (x !== y) return x > y;
      }
      return false;
    },

    // The installer server (install/installer.js) answers /version; GM_xmlhttpRequest
    // bypasses the mixed-content block so an https supjav page can reach http://127.0.0.1.
    checkForUpdate() {
      if (typeof GM_xmlhttpRequest !== 'function') return;
      try {
        const last = parseInt(localStorage.getItem('st_update_check') || '0', 10);
        if (Date.now() - last < 6 * 3600 * 1000) {
          SUPJAV_TOOLS._updateInfo = JSON.parse(localStorage.getItem('st_update_info') || 'null');
          SUPJAV_TOOLS.renderUpdateChip();
          return;
        }
      } catch (e) {
        // localStorage disabled or corrupt; fall through to a live check.
      }
      GM_xmlhttpRequest({
        method: 'GET',
        url: 'http://127.0.0.1:28719/version',
        timeout: 3000,
        onload: (res) => SUPJAV_TOOLS.handleUpdateResponse(res.responseText),
        onerror: () => SUPJAV_TOOLS.renderUpdateChip(),
        ontimeout: () => SUPJAV_TOOLS.renderUpdateChip(),
      });
    },

    handleUpdateResponse(text) {
      const available = String(text || '').trim().replace(/^v/, '');
      SUPJAV_TOOLS._updateInfo =
        available && SUPJAV_TOOLS.versionGt(available, SCRIPT_VERSION)
          ? { from: SCRIPT_VERSION, to: available }
          : null;
      try {
        localStorage.setItem('st_update_check', String(Date.now()));
        localStorage.setItem('st_update_info', JSON.stringify(SUPJAV_TOOLS._updateInfo));
      } catch (e) {
        // localStorage disabled; the chip still renders for this session.
      }
      SUPJAV_TOOLS.renderUpdateChip();
    },

    renderUpdateChip() {
      const id = 'st-update-chip';
      const old = document.getElementById(id);
      if (old) old.remove();
      if (!SUPJAV_TOOLS._updateInfo) return;
      const chip = document.createElement('div');
      chip.id = id;
      chip.textContent = `Supjav Tools update: v${SUPJAV_TOOLS._updateInfo.from} \u2192 v${SUPJAV_TOOLS._updateInfo.to}`;
      chip.style.cssText =
        'position:fixed;top:12px;right:16px;z-index:2147483647;background:#1b1b1f;color:#81c784;' +
        'border:1px solid #2e7d32;border-radius:6px;padding:6px 10px;font:12px system-ui,sans-serif;' +
        'cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.5);';
      chip.addEventListener('click', () => window.open('http://127.0.0.1:28719/', '_blank'));
      document.body.appendChild(chip);
    },

    ensureFab() {
      if (SUPJAV_TOOLS.fab) return;
      const fab = document.createElement('div');
      fab.textContent = 'DL';
      fab.style.cssText =
        'position:fixed;right:16px;bottom:16px;z-index:2147483646;width:44px;height:44px;' +
        'display:flex;align-items:center;justify-content:center;background:#e11;color:#fff;' +
        'border-radius:50%;font:700 14px system-ui,sans-serif;cursor:pointer;' +
        'box-shadow:0 4px 16px rgba(0,0,0,.5);user-select:none;';
      fab.title = 'Supjav Tools';
      fab.dataset.stVersion = SCRIPT_VERSION;
      fab.addEventListener('click', () => {
        fab.style.display = 'none';
        document.body.appendChild(SUPJAV_TOOLS.buildPanel());
      });
      document.body.appendChild(fab);
      SUPJAV_TOOLS.fab = fab;
    },

    // On video pages, if the player iframe is still about:blank, click the first
    // server button so the embed loads (desktop/extension both call this from the
    // top frame before/alongside autoplay in the subframe).
    primeVideoPlayer() {
      if (!location.pathname.includes('/video/')) return;
      const iframe = document.querySelector('iframe#video, iframe[allowfullscreen], iframe');
      const src = iframe && iframe.getAttribute('src');
      if (!src || src === 'about:blank') {
        const servers = getCachedElement('.btn-server');
        if (servers.length) servers[0].click();
      }
    },

    // Runs only in cross-origin embed subframes (extension content script with
    // all_frames). Supjav players fetch the stream only after a play action, so
    // synthesize one: click a play-looking element until a <video> appears, then
    // play() directly (muted first so autoplay is never blocked).
    startEmbedAutoplay() {
      if (SUPJAV_TOOLS._embedStarted) return;
      SUPJAV_TOOLS._embedStarted = true;
      const boot = () => {
        const v = document.querySelector('video');
        if (v) {
          try {
            v.muted = true;
            const p = v.play();
            if (p && p.then) p.then(() => { try { v.muted = false; } catch (e) {} }).catch(() => {});
          } catch (e) {}
          return;
        }
        const cands = Array.from(
          document.querySelectorAll('button, [role=button], a, .play, .vjs-big-play-button, [class*=play i], [id*=play i]'),
        );
        const btn =
          cands.find((el) => {
            const r = el.getBoundingClientRect();
            return r.width > 24 && r.height > 24;
          }) || cands[0];
        if (btn) btn.click();
      };
      boot();
      const iv = setInterval(boot, 1000);
      setTimeout(() => clearInterval(iv), 90000);
      window.__supjavAutoplay = true;
    },

    start() {
      // If the extension is installed, its content script (isolated world) owns
      // the UI and embed autoplay; a co-installed Violentmonkey copy has no
      // supjavAPI but can see the extension's DOM marker, so it must step aside.
      if (!window.supjavAPI && document.documentElement.getAttribute('data-st-ext') === '1') {
        return;
      }
      // In cross-origin embed subframes only run autoplay; never show UI there.
      let isTop = true;
      try {
        isTop = window.top === window;
      } catch (e) {}
      if (!isTop) {
        SUPJAV_TOOLS.startEmbedAutoplay();
        return;
      }
      startPerformanceObserver();
      SUPJAV_TOOLS.ensureFab();
      SUPJAV_TOOLS.primeVideoPlayer();
      SUPJAV_TOOLS.checkForUpdate();
      if (localStorage.getItem('st_auto') === '1') SUPJAV_TOOLS.setAuto(true);
      if (SUPJAV_TOOLS.isListPage()) {
        const bar = SUPJAV_TOOLS.buildFilterBar();
        const container = SUPJAV_TOOLS.getContainer();
        container.parentNode.insertBefore(bar, container);
        SUPJAV_TOOLS.enhanceCards(document);
        SUPJAV_TOOLS.startInfiniteScroll();
      }
      const observer = new MutationObserver(() => {
        if (!document.contains(SUPJAV_TOOLS.fab)) SUPJAV_TOOLS.ensureFab();
      });
      observer.observe(document.body, { childList: true, subtree: true });
    },
  };

  window.__SUPJAV_TOOLS__ = SUPJAV_TOOLS;

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => SUPJAV_TOOLS.start());
  } else {
    SUPJAV_TOOLS.start();
  }
})();