Infinite scroll reader for HentaiNexus with auto image loading and reading position tracking.
// ==UserScript==
// @name HentaiNexus Infinite Reader
// @name:ja HentaiNexus 無限スクロールリーダー
// @name:zh-CN HentaiNexus 无限滚动阅读器
// @namespace https://hentainexus.com/
// @version 1.0
// @author L1Z4RD
// @license MIT
// @match https://hentainexus.com/read/*
// @run-at document-idle
// @grant none
// @description Infinite scroll reader for HentaiNexus with auto image loading and reading position tracking.
// @description:ja 画像の自動読み込みと読書位置の追跡に対応したHentaiNexus用無限スクロールリーダー。
// @description:zh-CN 支持自动加载图片并记录阅读位置的 HentaiNexus 无限滚动阅读器。
// ==/UserScript==
(function () {
'use strict';
const LOAD_BUFFER = 6;
const UNLOAD_BUFFER = 10;
let initialized = false;
let currentHash = null;
const wait = setInterval(() => {
if (window.pageData && window.pageData.length && !initialized) {
initialized = true;
console.log("✅ pageData detected");
buildReader(window.pageData);
clearInterval(wait);
}
}, 100);
function getStartIndex(data) {
const hash = location.hash.replace('#', '');
if (!hash) return 0;
if (hash === 'end') return data.length - 1;
const index = data.findIndex(p => p.url_label === hash);
return index >= 0 ? index : 0;
}
function buildReader(data) {
console.log("🚀 Building infinite reader...");
const startIndex = getStartIndex(data);
const old = document.getElementById('reader_section');
if (old) old.remove();
const container = document.createElement('div');
container.style.maxWidth = '900px';
container.style.margin = '0 auto';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '16px';
document.body.appendChild(container);
data.forEach((page, index) => {
if (page.type !== 'image') return;
const wrapper = document.createElement('div');
wrapper.dataset.index = index;
wrapper.style.minHeight = '300px';
const img = document.createElement('img');
img.dataset.src = page.image;
img.style.width = '100%';
img.style.opacity = '0';
img.style.transition = 'opacity 0.3s';
wrapper.appendChild(img);
container.appendChild(wrapper);
});
setupObserver(container, data);
setupScrollTracking(container, data);
setTimeout(() => {
const target = container.querySelector(`[data-index="${startIndex}"]`);
if (target) {
target.scrollIntoView({ behavior: 'instant', block: 'start' });
}
maintainMemory(container, startIndex);
}, 100);
}
function setupObserver(container, data) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const wrapper = entry.target;
const img = wrapper.querySelector('img');
const index = parseInt(wrapper.dataset.index);
if (!img) return;
if (entry.isIntersecting) {
loadImage(img);
maintainMemory(container, index);
}
});
}, {
rootMargin: '1200px'
});
container.querySelectorAll('[data-index]').forEach(el => observer.observe(el));
}
function setupScrollTracking(container, data) {
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
updateCurrentPage(container, data);
ticking = false;
});
ticking = true;
}
});
}
function updateCurrentPage(container, data) {
const wrappers = container.querySelectorAll('[data-index]');
const viewportCenter = window.innerHeight / 2;
let closest = null;
let closestDistance = Infinity;
wrappers.forEach(wrapper => {
const rect = wrapper.getBoundingClientRect();
const elementCenter = rect.top + rect.height / 2;
const distance = Math.abs(viewportCenter - elementCenter);
if (distance < closestDistance) {
closestDistance = distance;
closest = wrapper;
}
});
if (!closest) return;
const index = parseInt(closest.dataset.index);
const label = data[index]?.url_label;
if (!label || currentHash === label) return;
currentHash = label;
history.replaceState(null, '', '#' + label);
}
function loadImage(img) {
if (img.src) return;
img.src = img.dataset.src;
img.onload = () => {
img.style.opacity = '1';
};
img.onerror = () => {
setTimeout(() => {
img.src = img.dataset.src;
}, 1000);
};
}
function unloadImage(img) {
if (!img.src) return;
img.style.opacity = '0';
setTimeout(() => {
img.removeAttribute('src');
}, 200);
}
function maintainMemory(container, currentIndex) {
const wrappers = container.querySelectorAll('[data-index]');
wrappers.forEach(wrapper => {
const index = parseInt(wrapper.dataset.index);
const img = wrapper.querySelector('img');
if (!img) return;
if (Math.abs(index - currentIndex) <= LOAD_BUFFER) {
loadImage(img);
}
if (Math.abs(index - currentIndex) > UNLOAD_BUFFER) {
unloadImage(img);
}
});
}
})();