ExH Thumbnails Auto-Load

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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