ExH Thumbnails Auto-Load

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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