Sleazy Fork is available in English.

e-hentai preload next page

preload and cache next page image for faster loading

// ==UserScript==
// @name        e-hentai preload next page
// @description preload and cache next page image for faster loading
// @author      owowed <island@owowed.moe>
// @version     0.1.3
// @namespace   e.owowed.moe
// @license     GPL-3.0-or-later
// @match       *://e-hentai.org/s/*
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_deleteValue
// @grant       GM.xmlHttpRequest
// @grant       GM_info
// @connect     hath.network
// @require     https://cdn.jsdelivr.net/npm/@trim21/gm-fetch@0.1.15/dist/gm_fetch.js#sha256-Q0UpKw6Eod6c9OWELIjizD2ejwO+8/EaFxnb8IsSCN0=
// @run-at      document-end
// @copyright   Licensed under GPL-3.0-or-later. View license at https://spdx.org/licenses/GPL-3.0-or-later.html
// ==/UserScript==

;3 ;3 ;3


// Debug

const enableLogging = GM_getValue("log", true);

function log(...args) {
    if (!enableLogging) return args.at(-1);
    console.log("[e-hentai preload next page]", ...args);
    return args.at(-1);
}

// Storage

const getMaxImageCache = () => GM_getValue("max-image-cache", 10);
const getMaxImageCacheIdle = () => GM_getValue("max-img-cache-idle") ?? GM_getValue("max-image-cache-idle", -1);
const getPersistentCacheTimeout = () => GM_getValue("persistent-cache-timeout", 2);
const getPersistentCacheToggle = () => GM_getValue("persistent-cache-toggle", true);
const getPersistentCacheLimit = () => GM_getValue("persistent-cache-limit", 30);
const getPersistentCacheKeys = () => GM_getValue("persistent-cache.keys", []);

let getPersistentCache = () => {
    const cacheKeys = getPersistentCacheKeys();
    const cache = {};

    for (const key of cacheKeys) {
        cache[key] = GM_getValue(`persistent-cache.img--${key}`);
    }

    return cache;
};

let updatePersistentCache = (newCache) => {
    const cacheKeys = getPersistentCacheKeys()
        .concat(Object.keys(newCache))
        .filter((val, idx, arr) => arr.indexOf(val) == idx); // unique
    
    while (cacheKeys.length > getPersistentCacheLimit()) {        
        const deletedKey = cacheKeys.shift();
        GM_deleteValue(`persistent-cache.img--${deletedKey}`);
    }

    GM_setValue("persistent-cache.keys", cacheKeys);

    for (const k of cacheKeys) {
        if (!newCache[k]) continue;
        GM_setValue(`persistent-cache.img--${k}`, newCache[k]);
    }
};

if (!getPersistentCacheToggle()) {
    getPersistentCache = updatePersistentCache = () => ({});
}

async function syncPersistentCache(cacheStore) {
    const persistentCache = getPersistentCache();

    for (const [key, value] of Object.entries(cacheStore)) {
        if (persistentCache[key]) continue;
        persistentCache[key] = await convertBlobToDataUrlBase64(value);
    }

    for (const [key, value] of Object.entries(persistentCache)) {
        if (cacheStore[key] || value == undefined) continue;
        cacheStore[key] = convertDataUrlBase64ToBlob(value);
    }

    updatePersistentCache(persistentCache);
    log("SYNC CACHE");
}

function clearPersistentCache(cacheStore) {
    const persistentCacheKeys = getPersistentCacheKeys();

    for (const key of persistentCacheKeys) {
        GM_deleteValue(`persistent-cache.img--${key}`);
    }

    GM_setValue(`persistent-cache.keys`);

    if (cacheStore) {
        for (const key of Object.keys(cacheStore)) {
            delete cacheStore[key];
        }
    }

    log("CACHE CLEARED");
}

// HTML-CSS setup

GM_addStyle(`
    #owo-icon-pfp {
        display: inline-block;
        width: 20px;
        height: 20px;
        background: url(https://files.catbox.moe/gg9tzd.jpg);
        background-size: contain;
    }

    #i2 {
        position: relative;
    }

    #ehp-settings-activator {
        position: absolute;
        left: 6px;
        bottom: 1px;

        font-size: 0.8rem;

        &, * {
            cursor: pointer;
        }

        &:is(:hover, :has(:checked)) {
            color: #8F4701;
        }
    }

    #ehp-cache-indicator {
        position: absolute;
        right: 10px;
        bottom: 1px;

        font-size: 0.8rem;

        &[state=cached] {
            color: goldenrod;
        }

        &[state="not cached"] {
            color: red;
        }

        &[state="cache downloading"] {
            color: darkslategrey;
        }
    }
        
    #ehp-settings {
        position: absolute;
        
        display: none;
        background: #EDEBDF;
        border: 1px solid #5C0D12;

        padding: 12px 0;
        margin: 12px auto;
        width: 531px;
        min-width: 200px;
        resize: both;
        text-wrap: auto !important;

        #ehp-settings-activator:has(input:checked) + & {
            display: flex;
            flex-flow: column;
            gap: 8px;
        }

        input {
            width: 5em;
            text-align: center;
        }

        .popup-title {
            font-weight: bold;
            font-style: italic;
            font-size: 12pt;
        }
        
        .entry-title {
            font-size: 10pt;
        }
        
        .note {
            font-style: italic;
        }

        .credits {
            display: flex;
            justify-content: center;
            align-items: center;

            :first-child {
                margin-right: 2px;
            }
        }

        .version {
            position: absolute;
            right: 16px;
            bottom: 1px;
        }
    }
`);

const settingsEvent = new EventTarget();

function initHtmlElements() {
    const settingsPage = Object.assign(document.createElement("div"), {
        id: "ehp-settings",
        innerHTML: `
            <div>
                <div class="popup-title">e-hentai preload next page</div>
                <div class="note credits">
                    <div>~ made with pleasure by</div>
                    <div id="owo-icon-pfp"></div>
                    <div>owowed ~</div>
                </div>
            </div>
            <div class="entry">
                <div class="entry-title">Max Image Cache</div>
                <div>Set maximum image to cache. Userscript will stop caching images if it reaches the maximum limit.</div>
                <div>Be mindful to set the limit, making it too high can lead to temporary site-ban and high bandwitdh.</div>
                <input type="number" id="max-image-cache" value="${getMaxImageCache()}"/> images
            </div>
            <div class="entry">
                <div class="entry-title">Max Image Cache while Idling</div>
                <div>Set maximum image to cache while the browser is idling. Userscript will cache images if it detects the browser is idling. Set "-1" to disable this.</div>
                <input type="number" id="max-image-cache-idle" value="${getMaxImageCacheIdle()}"/> images
            </div>
            <div class="entry">
                <div class="entry-title">Saved Cache</div>
                <div>Toggle to enable local image caching. Image cache will be stored locally in your userscript storage, and load later for faster loading time.</div>
                <input type="checkbox" id="persistent-cache-toggle" checked="${getPersistentCacheToggle()}"/>
            </div>
            <div class="entry">
                <div class="entry-title">Saved Cache Limit</div>
                <div>Set storage limit by the maximum number of saved image.</div>
                <input type="number" id="persistent-cache-limit" value="${getPersistentCacheLimit()}"/> images
            </div>
            <div class="entry">
                <div class="entry-title">Saved Cache Timeout</div>
                <div>Set time when the userscript will reset your saved cache. If you set it to 3, then saved cache will reset every 3 day from now.</div>
                <input type="number" id="persistent-cache-timeout" value="${getPersistentCacheTimeout()}"/> days
            </div>
            <div>
                <button id="sync-persistent-cache">Sync Saved Cache</button>
                <button id="reset-persistent-cache">Reset Saved Cache</button>
            </div>
            <div class="note">settings are automatically saved and applied</div>
            <div class="note version">v${GM_info.script.version}</div>
        `
    });

    const settingsInput = Array.from(settingsPage.querySelectorAll(".entry"))
        .map(e => e.querySelector("input"));
    const createChangeEvent = (key) => Object.assign(new Event("change"), { key })

    for (const input of settingsInput) {
        input.addEventListener("change", () => {
            GM_setValue(input.id, getInputValue(input));
            settingsEvent.dispatchEvent(createChangeEvent(input.id));
        });
    }

    document.addEventListener("visibilitychange", () => {
        if (document.visibilityState != "visible") return;
        for (const input of settingsInput) {
            const newValue = GM_getValue(input.id);
            if (newValue == undefined
                || newValue == getInputValue(input)) continue;
            setInputValue(input, GM_getValue(input.id));
            settingsEvent.dispatchEvent(createChangeEvent(input.id));
        }
    });

    settingsPage.querySelector("#reset-persistent-cache")
        .addEventListener("click", () => {
            settingsEvent.dispatchEvent(new Event("reset-persistent-cache"));
        });

    settingsPage.querySelector("#sync-persistent-cache")
        .addEventListener("click", () => {
            settingsEvent.dispatchEvent(new Event("sync-persistent-cache"));
        });

    const activator = Object.assign(document.createElement("div"), {
        id: "ehp-settings-activator",
        className: "link-hover",
        role: "button",
        innerHTML: `
            <label>
                <input type="checkbox" hidden/>
                [ image preloading settings ]
            </label>
        `
    });

    const cacheIndicator = document.createElement("div");
    cacheIndicator.id = "ehp-cache-indicator";

    const appendPoint = document.querySelector("#i2");

    appendPoint.appendChild(activator);
    appendPoint.appendChild(settingsPage);
    appendPoint.appendChild(cacheIndicator);
}

function getInputValue(input) {
    if (input.type == "checkbox") return input.checked;
    else if (input.type == "number") return parseInt(input.value);
}

function setInputValue(input, value) {
    if (input.type == "checkbox") input.checked = value;
    else if (input.type == "number") input.value = value;
}

// Main

!async function () {
    let cacheStore = {};
    
    initHtmlElements();

    // load cache from saved cache, and then use it for the page
    
    syncPersistentCache(cacheStore);
    loadCache(cacheStore);

    // load cache when page changes
    
    let lastPage = -1;
    observePageChange(() => {
        if (lastPage == history.state.page) return;
        
        log("PAGE CHANGE");
        
        initHtmlElements();
        loadCache(cacheStore);
        
        lastPage = history.state.page;
    });

    // save cache locally when the user switches/closes tab

    let timeToSync = true;
    document.addEventListener("visibilitychange", () => {
        if (document.visibilityState != "hidden") return;
        if (!timeToSync) return;

        syncPersistentCache(cacheStore);
        timeToSync = false;
        setTimeout(() => timeToSync = true, Math.floor(10 * 1000 * Math.random()));
    });

    // caching while idling

    let idleCachingStarted = false;

    const startIdleCaching = () => {
        if (getMaxImageCacheIdle() != -1 && !idleCachingStarted) {
            const createIdleTimer = () => {
                return setTimeout(() => {
                    log("IDLE CACHE", getMaxImageCacheIdle());
                    fetchNextImageCache(cacheStore, { maxImageCache: getMaxImageCacheIdle(), skipFirstCheck: true });
                }, 16 * 60 * 1000); // 16 minutes
            };
            
            let idleTimer = createIdleTimer();
            const resetIdleTimer = () => {
                clearTimeout(idleTimer);
                idleTimer = createIdleTimer();
            };

            document.addEventListener("mousemove", () => resetIdleTimer());
            document.addEventListener("keydown", () => resetIdleTimer());
            document.addEventListener("click", () => resetIdleTimer());
            document.addEventListener("visibilitychange", () => document.visibilityState == "visible" && resetIdleTimer());

            idleCachingStarted = true;
        }
    }

    startIdleCaching();

    // settings

    settingsEvent.addEventListener("change", () => {
        fetchNextImageCache(cacheStore, { skipFirstCheck: true });
        startIdleCaching();
    });

    settingsEvent.addEventListener("reset-persistent-cache", () => {
        clearPersistentCache(cacheStore);
        fetchNextImageCache(cacheStore);
    });

    settingsEvent.addEventListener("sync-persistent-cache", () => {
        syncPersistentCache(cacheStore);
    });

    // reset persistent cache passed set days

    const persistentCacheTimeout = getPersistentCacheTimeout();    
    const fiveDayPassedMilliseconds = persistentCacheTimeout * 24 * 60 * 60 * 1000; // day * hour * minute * second * millisecond
    let persistentCacheLastTimeout = GM_getValue("persistent-cache-last-timeout", Date.now());

    function persistentCacheCheck() {
        const currentDate = Date.now();
        const timeDifference = currentDate - persistentCacheLastTimeout;

        if (timeDifference >= fiveDayPassedMilliseconds) {
            persistentCacheLastTimeout = currentDate;
            GM_setValue("persistent-cache-last-timeout", persistentCacheLastTimeout);
            clearPersistentCache(cacheStore);
            log("PERSISTENT CACHE TIMEOUT");
        }
    }

    if (GM_getValue("persistent-cache-last-timeout") == undefined) {
        GM_setValue("persistent-cache-last-timeout", persistentCacheLastTimeout);
    }

    if (persistentCacheTimeout != -1) {
        persistentCacheCheck();
        setInterval(() => persistentCacheCheck(), 10 * 60 * 60 * 1000); // check every 10 hours
    }
}();

function getCacheIndicator() {
    const element = document.getElementById("ehp-cache-indicator");

    const setState = (state) => {
        element.setAttribute("state", state);
        element.textContent = state;
    };

    setState("not cached");

    return {
        setState,
        element
    };
}

function loadCache(cacheStore) {
    const locationCacheId = getCacheId(window.location);
    const cacheIndicator = getCacheIndicator();

    if (cacheStore[locationCacheId]) {
        document.querySelector("#img").src = URL.createObjectURL(cacheStore[locationCacheId]);
        cacheIndicator.setState("cached");
        log("USING CACHE");
    } else {
        cacheIndicator.setState("cache downloading")
        cacheImage(cacheStore, document.querySelector("#img").src, locationCacheId)
            .then(() => syncPersistentCache(cacheStore))
            .then(() => cacheIndicator.setState("cached"));
    }

    fetchNextImageCache(cacheStore).then((cachePromises) => {
        if (cachePromises) Promise.all(cachePromises).then(() => syncPersistentCache(cacheStore));
    });
}

async function fetchNextImageCache(
    cacheStore,
    { maxImageCache = getMaxImageCache(), skipFirstCheck = false } = {}
) {
    const nextPageAnchor = document.querySelector("#next");
    
    if (nextPageAnchor == null) return;
    
    const maxImageCacheCalculated = Math.min(maxImageCache, getPersistentCacheLimit());
    const nextImageUrlIterator = fetchNextImageUrl("#next", { startingAnchor: nextPageAnchor });
    const nextPageCacheId = getCacheId(nextPageAnchor.href);
    
    if (cacheStore[nextPageCacheId] && !skipFirstCheck) return;

    const cachePromises = [];

    for (let iteration = 0; iteration < maxImageCacheCalculated; iteration++) {
        const { pageOrigin, imageUrl } = await nextImageUrlIterator.next().then(it => it.value);
        const nextPageCacheId = getCacheId(pageOrigin);
        
        if (imageUrl == undefined) return;
        if (cacheStore[nextPageCacheId]) continue;
        
        const promise = cacheImage(cacheStore, imageUrl, nextPageCacheId);
        cachePromises.push(promise);
    }
    
    return cachePromises;
}

function getCacheId(pageOrigin) {
    if (pageOrigin instanceof Location || pageOrigin instanceof URL) {
        return pageOrigin.pathname;
    }
    return new URL(pageOrigin).pathname;
}

async function cacheImage(cacheStore, imageUrl, cacheId) {
    // if image cache already exists, then return
    if (cacheStore[cacheId]) return;

    log("CACHING...... " + imageUrl);

    // avoid CORS by using GM_fetch
    const imageBlob = await GM_fetch(imageUrl).then(res => res.blob()).catch(err => "nope err");

    if (imageBlob == "nope err") return;

    // update cache store along with persistent cache
    cacheStore[cacheId] = imageBlob;

    log("...... DONE");
}

async function* fetchNextImageUrl(persistentSelector, { startingAnchor = document.querySelector(persistentSelector) } = {}) {
    if (startingAnchor == null) return;
    // preview next page to get image URL
    const html = await fetch(startingAnchor.href).then(i => i.text()); 

    const domParser = new DOMParser();
    const parsedDom = domParser.parseFromString(html, "text/html");

    // get next image URL
    const nextPageImg = parsedDom.querySelector("#img");

    yield { pageOrigin: startingAnchor.href, imageUrl: nextPageImg.src, parsedDom };
    yield* fetchNextImageUrl(persistentSelector, { startingAnchor: parsedDom.querySelector(persistentSelector) });
}

function observePageChange(callback, { once } = {}) {
    const i3 = document.querySelector("#i3");
    const mut = new MutationObserver(() => {
        if (once) mut.disconnect();
        callback();
    });
    
    mut.observe(i3, {
        childList: true
    });

    return mut;
}

async function convertBlobToDataUrlBase64(blob) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onloadend = () => resolve(reader.result);
        reader.onerror = reject;
        reader.readAsDataURL(blob);
    });
}

function convertDataUrlBase64ToBlob(dataUrlBase64) {
    const [mimeString, base64Data] = dataUrlBase64.split("data:")[1].split(";base64,");
    const uint8array = Uint8Array.from(atob(base64Data), char => char.charCodeAt(0));
    const blob = new Blob([uint8array], { type: mimeString || undefined });
    return blob;
}