ExH Thumbnails Auto-Load

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==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();
  }
})();