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