ExH Thumbnails Auto-Load

Automatically load all thumbnail gallery pages with continuous scroll and dynamic URL update

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ExH Thumbnails Auto-Load
// @namespace    exh-autoload
// @version      2.2.2
// @description  Automatically load all thumbnail gallery pages with continuous scroll and dynamic URL update
// @license MIT
// @match        https://exhentai.org/g/*
// @match        https://e-hentai.org/g/*
// @match        http://exhentai.org/g/*
// @match        http://e-hentai.org/g/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// ==/UserScript==
/* jshint esversion: 11 */

(function () {
  'use strict';

  const TAG = '[ExH Auto-Load]';
  function log(...args) { console.log(TAG, ...args); }

  // ─── Settings ─────────────────────────────────────────────────────
  const _gv = typeof GM_getValue === 'function' ? GM_getValue : (k, d) => {
    try { return JSON.parse(localStorage.getItem('exh_al_' + k)) ?? d; } catch { return d; }
  };
  const _sv = typeof GM_setValue === 'function' ? GM_setValue : (k, v) => {
    try { localStorage.setItem('exh_al_' + k, JSON.stringify(v)); } catch {}
  };

  let loadMode = _gv('loadMode', 'thumbnails');
  const CONTINUOUS_DELAY_MS = 600;

  // ─── Debug ────────────────────────────────────────────────────────
  const debugPanel = document.createElement('div');
  debugPanel.id = 'exh-al-debug';
  debugPanel.style.cssText = `
    position: fixed; bottom: 10px; right: 10px; z-index: 999999;
    background: #1a1a2e; color: #0f0; font: 11px/1.4 monospace;
    padding: 8px 12px; border: 2px solid #0f0; border-radius: 6px;
    max-width: 380px; max-height: 300px; overflow-y: auto;
    opacity: 0.92; white-space: pre-wrap; display: none;
  `;
  function debugLog(msg) {
    log(msg);
    debugPanel.textContent += msg + '\n';
    debugPanel.scrollTop = debugPanel.scrollHeight;
  }

  debugLog('=== SCRIPT LOADED v2.2.0 ===');
  debugLog('URL: ' + location.href);
  debugLog('loadMode: ' + loadMode);

  // ─── Config & State ───────────────────────────────────────────────
  let autoLoadActive = false;
  let isLoading = false;
  let currentPage = -1;
  let maxPage = -1;
  let nextToLoad = -1;
  let loadedSections = new Map();
  let stopRequested = false;
  let totalImages = 0;
  let gdtGridClass = 'gt200';
  let imagesPerPage = 20; // will be detected from first page

  // ─── Helpers ──────────────────────────────────────────────────────
  function getBaseUrl() { return location.origin + location.pathname; }

  function getPageFromUrl(url) {
    try { const u = new URL(url); const p = u.searchParams.get('p'); return p !== null ? parseInt(p, 10) : 0; }
    catch { return 0; }
  }

  function buildPageUrl(pageNum) {
    const base = getBaseUrl();
    return pageNum > 0 ? base + '?p=' + pageNum : base;
  }

  function detectMaxPage() {
    let max = 0;
    document.querySelectorAll('.gtb a').forEach(a => { const p = getPageFromUrl(a.href); if (p > max) max = p; });
    document.querySelectorAll('.gtb td[onclick]').forEach(td => {
      const onclick = td.getAttribute('onclick') || '';
      const m = onclick.match(/Math\.min\((\d+)/);
      if (m) { const val = parseInt(m[1], 10); if (val > max) max = val; }
    });
    return max;
  }

  function detectTotalImages() {
    const gpc = document.querySelector('p.gpc');
    if (!gpc) return 0;
    const match = gpc.textContent.match(/of\s+([\d,]+)/);
    return match ? parseInt(match[1].replace(/,/g, ''), 10) : 0;
  }

  function detectGdtGridClass(gdtEl) {
    const el = gdtEl || document.getElementById('gdt');
    if (!el) return 'gt200';
    const m = el.className.match(/gt\d+/);
    debugLog('Grid class: ' + (m ? m[0] : 'gt200'));
    return m ? m[0] : 'gt200';
  }

  function detectImagesPerPage() {
    const gdt = document.getElementById('gdt');
    if (!gdt) return 20;
    const count = Array.from(gdt.children).filter(c => c.tagName === 'A').length;
    debugLog('Images per page: ' + count);
    return count > 0 ? count : 20;
  }

  // ─── Thumbnail observer ──────────────────────────────────────────
  function waitForThumbnails(sectionEl) {
    return new Promise((resolve) => {
      const urls = new Set();

      const thumbDivs = sectionEl.querySelectorAll('div[title][style*="url("]');
      thumbDivs.forEach(div => {
        const styleMatch = (div.getAttribute('style') || '').match(/url\(["']?([^"')]+)/);
        if (styleMatch) urls.add(styleMatch[1]);
      });

      const imgTags = sectionEl.querySelectorAll('img[src]');
      imgTags.forEach(img => {
        const src = img.getAttribute('src');
        if (src && !src.startsWith('data:')) urls.add(src);
      });

      if (urls.size === 0) { resolve(); return; }

      debugLog('Waiting for ' + urls.size + ' unique thumbnails...');
      let remaining = urls.size;

      urls.forEach(imgUrl => {
        const img = new Image();
        img.onload = img.onerror = () => {
          remaining--;
          if (remaining <= 0) { debugLog('All thumbnails loaded'); resolve(); }
        };
        img.src = imgUrl;
      });
    });
  }

  // ─── UI: Styles ──────────────────────────────────────────────────
  const style = document.createElement('style');
  style.textContent = `
    /* Make .gtb the positioning context — keep its original layout intact */
    .gtb.exh-al-ready {
      position: relative !important;
    }

    /* Toggle + Gear: positioned absolutely right next to the table */
    /* top is set dynamically via JS (positionControls) to align flush with the table row */
    .exh-al-controls {
      position: absolute;
      top: 0;
      left: 0;
      display: inline-flex;
      align-items: stretch;
      z-index: 10;
    }

    /* Toggle — looks like a pagination cell */
    .exh-al-toggle-wrap {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      padding: 0 8px;
      height: 17px;
      background: #34353b;
      border: 1px solid #000000;
      cursor: pointer;
      user-select: none;
      transition: background 0.15s;
      white-space: nowrap;
    }
    .exh-al-toggle-wrap:hover {
      background: #43464e;
    }
    .exh-al-toggle-wrap.active {
      background: #5a3a6a;
      border-color: #8a5aaa;
    }
    .exh-al-toggle-wrap.active:hover {
      background: #6a4a7a;
    }
    .exh-al-toggle-wrap .exh-al-btn {
      font-size: 10pt;
      font-weight: bold;
      color: #f1f1f1;
      pointer-events: none;
    }
    .exh-al-toggle-wrap.active .exh-al-btn {
      color: #e8d0f8;
    }
    .exh-al-toggle-wrap .exh-al-status {
      font-size: 9pt;
      color: #b0b0b0;
      pointer-events: none;
      white-space: nowrap;
    }
    .exh-al-toggle-wrap .exh-al-status .exh-al-page {
      color: #a8d8a8;
      font-weight: bold;
    }
    .exh-al-toggle-wrap .exh-al-status .exh-al-done {
      color: #8ac;
      font-weight: bold;
    }

    /* Gear icon */
    .exh-al-gear {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 17px;
      height: 17px;
      background: #34353b;
      border: 1px solid #000000;
      border-left: none;
      cursor: pointer;
      user-select: none;
      color: #9a9aaa;
      font-size: 10pt;
      transition: background 0.15s;
    }
    .exh-al-gear:hover {
      background: #43464e;
      color: #f1f1f1;
    }

    /* Settings dropdown */
    .exh-al-settings {
      position: absolute;
      top: 100%;
      right: 0;
      z-index: 99999;
      background: #34353b;
      border: 1px solid #000000;
      padding: 8px 10px;
      font-size: 9pt;
      color: #f1f1f1;
      white-space: nowrap;
      display: none;
    }
    .exh-al-settings.open { display: block; }
    .exh-al-settings label {
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 3px 0;
      cursor: pointer;
    }
    .exh-al-settings input[type="radio"] {
      display: inline-block !important;
      margin: 0 4px 0 0;
      accent-color: #8a5aaa;
    }
    .exh-al-settings .exh-al-settings-title {
      font-weight: bold;
      margin-bottom: 4px;
      color: #c8b8e8;
    }
    .exh-al-settings .exh-al-settings-desc {
      color: #888;
      font-size: 8pt;
      margin-top: 4px;
    }

    /* Page divider */
    .exh-al-divider {
      max-width: 1180px;
      margin: 0 auto;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 6px 0;
      font-size: 10pt;
      color: #9a9aaa;
      background: #3a3b42;
      border-top: 1px dashed #555;
      border-bottom: 1px dashed #555;
      user-select: none;
    }
    .exh-al-divider .exh-al-pagenum {
      color: #c8b8e8;
      font-weight: bold;
      margin: 0 4px;
    }

    /* Section grid */
    .exh-al-section {
      background: #4f535b;
      border: 1px solid #000000;
      min-width: 700px;
      max-width: 1180px;
      margin: 0 auto;
      padding: 15px;
      clear: both;
    }
  `;
  document.head.appendChild(style);

  // ─── UI: Toggle + Gear ───────────────────────────────────────────
  let controlsId = 0;

  function createControls() {
    const instanceId = controlsId++;
    const radioName = 'exh-al-mode-' + instanceId;
    const wrap = document.createElement('div');
    wrap.className = 'exh-al-controls';

    const toggle = document.createElement('div');
    toggle.className = 'exh-al-toggle-wrap';

    const btn = document.createElement('span');
    btn.className = 'exh-al-btn';
    btn.textContent = '\u25B6 Auto-Load';

    const status = document.createElement('span');
    status.className = 'exh-al-status';

    toggle.appendChild(btn);
    toggle.appendChild(status);

    toggle.addEventListener('click', function (e) {
      e.stopPropagation();
      e.preventDefault();
      debugLog('TOGGLE CLICKED! active=' + autoLoadActive);
      onToggleClick();
    });

    const gear = document.createElement('div');
    gear.className = 'exh-al-gear';
    gear.textContent = '\u2699';

    const settings = document.createElement('div');
    settings.className = 'exh-al-settings';
    settings.innerHTML = `
      <div class="exh-al-settings-title">Load Mode</div>
      <label>
        <input type="radio" name="${radioName}" value="thumbnails">
        Wait for Thumbnails
      </label>
      <label>
        <input type="radio" name="${radioName}" value="continuous">
        Continuous
      </label>
      <div class="exh-al-settings-desc">
        Wait: loads next page after all thumbnails finish<br>
        Continuous: fixed delay between page loads
      </div>
    `;

    // Set checked state programmatically (avoids conflicts with duplicate names)
    const checkedRadio = settings.querySelector('input[value="' + loadMode + '"]');
    if (checkedRadio) checkedRadio.checked = true;

    settings.querySelectorAll('input[type="radio"]').forEach(radio => {
      radio.addEventListener('change', function (e) {
        e.stopPropagation();
        loadMode = this.value;
        _sv('loadMode', loadMode);
        debugLog('Load mode changed to: ' + loadMode);
        // Sync all other settings dropdowns
        document.querySelectorAll('.exh-al-settings input[value="' + loadMode + '"]').forEach(r => {
          r.checked = true;
        });
        closeAllSettings();
      });
      radio.addEventListener('click', function (e) { e.stopPropagation(); });
    });

    gear.appendChild(settings);

    gear.addEventListener('click', function (e) {
      e.stopPropagation();
      e.preventDefault();
      const wasOpen = settings.classList.contains('open');
      closeAllSettings();
      if (!wasOpen) settings.classList.add('open');
    });

    wrap.appendChild(toggle);
    wrap.appendChild(gear);

    return wrap;
  }

  function closeAllSettings() {
    document.querySelectorAll('.exh-al-settings').forEach(s => s.classList.remove('open'));
  }
  document.addEventListener('click', () => closeAllSettings());

  function injectToggles() {
    const allGtb = document.querySelectorAll('.gtb');
    debugLog('Found ' + allGtb.length + ' .gtb elements');

    allGtb.forEach((gtb, idx) => {
      if (gtb.querySelector('.exh-al-controls')) return;

      const table = gtb.querySelector('table');
      if (!table) return;

      gtb.classList.add('exh-al-ready');

      const controls = createControls();
      gtb.appendChild(controls);

      requestAnimationFrame(() => positionControls(gtb, table, controls));

      debugLog('Controls injected into .gtb[' + idx + ']');
    });

    // Reposition on resize
    window.addEventListener('resize', repositionAllControls);
  }

  function positionControls(gtb, table, controls) {
    const gtbRect = gtb.getBoundingClientRect();
    const tableRect = table.getBoundingClientRect();

    const tableRight = tableRect.right - gtbRect.left;
    controls.style.left = (tableRight + 4) + 'px';

    const tableTop = tableRect.top - gtbRect.top;
    controls.style.top = tableTop + 'px';
  }

  function repositionAllControls() {
    document.querySelectorAll('.gtb.exh-al-ready').forEach(gtb => {
      const table = gtb.querySelector('table.ptt, table.ptb');
      const controls = gtb.querySelector('.exh-al-controls');
      if (table && controls) {
        positionControls(gtb, table, controls);
      }
    });
  }

  function updateToggleUI() {
    const toggles = document.querySelectorAll('.exh-al-toggle-wrap');
    const loaded = loadedSections.size;
    const total = maxPage + 1;

    toggles.forEach(toggle => {
      const btn = toggle.querySelector('.exh-al-btn');
      const status = toggle.querySelector('.exh-al-status');

      if (autoLoadActive) {
        toggle.classList.add('active');
        btn.textContent = '\u25A0 Auto-Load';
      } else {
        toggle.classList.remove('active');
        btn.textContent = '\u25B6 Auto-Load';
      }

      if (isLoading) {
        status.innerHTML = 'Loading page <span class="exh-al-page">' + (nextToLoad + 1) + '</span> of ' + total;
      } else if (loaded >= total) {
        status.innerHTML = '<span class="exh-al-done">\u2713 All pages loaded</span>';
      } else {
        status.innerHTML = '';
      }
    });
  }

  // ─── Core: Fetch & Append ─────────────────────────────────────────
  async function fetchAndAppendPage(pageNum) {
    if (loadedSections.has(pageNum)) return true;

    const url = buildPageUrl(pageNum);
    debugLog('Fetching page ' + pageNum + ' -> ' + url);
    isLoading = true;
    updateToggleUI();

    try {
      const resp = await fetch(url, { credentials: 'include' });
      if (!resp.ok) throw new Error('HTTP ' + resp.status);

      const html = await resp.text();
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const newGdt = doc.querySelector('#gdt');
      if (!newGdt || !newGdt.children.length) return false;

      // Re-detect grid class from the fetched page (in case settings changed)
      const fetchedGridClass = detectGdtGridClass(newGdt);

      const divider = document.createElement('div');
      divider.className = 'exh-al-divider';
      divider.innerHTML = 'Page <span class="exh-al-pagenum">' + (pageNum + 1) + '</span>';

      const section = document.createElement('div');
      section.className = 'exh-al-section ' + fetchedGridClass;
      section.dataset.page = pageNum;

      Array.from(newGdt.childNodes).forEach(child => {
        section.appendChild(document.importNode(child, true));
      });

      const bottomPagination = findBottomPagination();
      if (bottomPagination) {
        bottomPagination.parentNode.insertBefore(divider, bottomPagination);
        bottomPagination.parentNode.insertBefore(section, bottomPagination);
      } else {
        document.body.appendChild(divider);
        document.body.appendChild(section);
      }

      loadedSections.set(pageNum, { element: section, divider });
      registerSection(section, pageNum);
      updateShowingText();

      debugLog('Page ' + pageNum + ' OK. Sections: ' + loadedSections.size);
      return true;
    } catch (e) {
      debugLog('FETCH ERROR page ' + pageNum + ': ' + e.message);
      return false;
    } finally {
      isLoading = false;
      updateToggleUI();
    }
  }

  function findBottomPagination() {
    const allGtb = document.querySelectorAll('.gtb');
    if (allGtb.length >= 2) return allGtb[allGtb.length - 1];
    if (allGtb.length === 1) return allGtb[0];
    return null;
  }

  function updateShowingText() {
    const gpc = document.querySelector('p.gpc');
    if (!gpc) return;
    if (!totalImages) totalImages = detectTotalImages();
    if (!totalImages) totalImages = (maxPage + 1) * imagesPerPage;

    // Count actual thumbnail items in the original #gdt + all loaded sections
    const gdt = document.getElementById('gdt');
    let totalLoaded = 0;
    if (gdt) {
      totalLoaded += Array.from(gdt.children).filter(c => c.tagName === 'A').length;
    }
    loadedSections.forEach((data) => {
      if (data.element) {
        totalLoaded += Array.from(data.element.children).filter(c => c.tagName === 'A').length;
      }
    });

    gpc.textContent = 'Showing 1 - ' + totalLoaded + ' of ' + totalImages.toLocaleString() + ' images';
  }

  // ─── Auto-Load Loop ──────────────────────────────────────────────
  async function startAutoLoad() {
    debugLog('=== START AUTO-LOAD (mode: ' + loadMode + ') ===');
    autoLoadActive = true;
    stopRequested = false;
    updateToggleUI();

    while (nextToLoad <= maxPage && !stopRequested) {
      const success = await fetchAndAppendPage(nextToLoad);
      if (!success) break;
      nextToLoad++;
      updateToggleUI();

      if (nextToLoad <= maxPage && !stopRequested) {
        if (loadMode === 'thumbnails') {
          const lastData = loadedSections.get(nextToLoad - 1);
          if (lastData && lastData.element) {
            debugLog('Waiting for thumbnails in page ' + (nextToLoad - 1) + '...');
            await waitForThumbnails(lastData.element);
            debugLog('Thumbnails done, proceeding');
          }
        } else {
          await delay(CONTINUOUS_DELAY_MS);
        }
      }
    }

    autoLoadActive = false;
    updateToggleUI();
    debugLog('Auto-load stopped.');
  }

  function stopAutoLoad() {
    debugLog('=== STOP AUTO-LOAD ===');
    stopRequested = true;
    autoLoadActive = false;
    updateToggleUI();
  }

  function onToggleClick() {
    if (autoLoadActive) stopAutoLoad(); else startAutoLoad();
  }

  function delay(ms) { return new Promise(r => setTimeout(r, ms)); }

  function registerSection(sectionEl, pageNum) {
    sectionEl.dataset.page = pageNum;
    loadedSections.set(pageNum, { element: sectionEl });
  }

  function registerOriginalSection() {
    const gdt = document.getElementById('gdt');
    if (!gdt) return;
    gdt.dataset.page = currentPage;
    gdt.classList.add('exh-al-section');
    loadedSections.set(currentPage, { element: gdt });
  }

  function updateUrlForPage(pageNum) {
    const cur = getPageFromUrl(location.href);
    if (cur === pageNum) return;
    try { history.replaceState(null, '', buildPageUrl(pageNum)); } catch {}
  }

  function setupScrollUrlUpdate() {
    let ticking = false;
    window.addEventListener('scroll', () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => { updateUrlBasedOnViewport(); ticking = false; });
    }, { passive: true });
  }

  function updateUrlBasedOnViewport() {
    const vc = window.innerHeight / 2;
    let closest = currentPage, dist = Infinity;
    loadedSections.forEach((data, pn) => {
      const el = data.element;
      if (!el || !el.getBoundingClientRect) return;
      const r = el.getBoundingClientRect();
      const d = Math.abs(r.top + r.height / 2 - vc);
      if (d < dist) { dist = d; closest = pn; }
    });
    updateUrlForPage(closest);
  }

  // ─── Keyboard ────────────────────────────────────────────────────
  document.addEventListener('keydown', (e) => {
    if (e.shiftKey && e.key === 'L') { e.preventDefault(); onToggleClick(); }
    if (e.shiftKey && e.key === 'D') { e.preventDefault(); debugPanel.style.display = debugPanel.style.display === 'none' ? 'block' : 'none'; }
  });

  // ─── Init ─────────────────────────────────────────────────────────
  function init() {
    debugLog('--- init() START ---');

    const gdt = document.getElementById('gdt');
    if (!gdt) {
      debugLog('FATAL: #gdt not found');
      document.body.appendChild(debugPanel);
      debugPanel.style.display = 'block';
      return;
    }

    gdtGridClass = detectGdtGridClass();
    imagesPerPage = detectImagesPerPage();
    currentPage = getPageFromUrl(location.href);
    maxPage = detectMaxPage();
    totalImages = detectTotalImages();
    nextToLoad = currentPage + 1;

    debugLog('currentPage: ' + currentPage + ' | maxPage: ' + maxPage + ' | gridClass: ' + gdtGridClass);

    if (nextToLoad > maxPage) {
      debugLog('On last page, nothing to load');
      document.body.appendChild(debugPanel);
      return;
    }

    injectToggles();
    registerOriginalSection();
    setupScrollUrlUpdate();
    updateUrlForPage(currentPage);

    document.body.appendChild(debugPanel);
    debugLog('--- init() COMPLETE ---');
    debugLog('Shift+D to toggle debug panel');
  }

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