Automatically load all thumbnail gallery pages with continuous scroll and dynamic URL update
// ==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();
}
})();