Adds quick language filters (English, Japanese, Chinese) to nhentai pages — works with SvelteKit SPA navigation
// ==UserScript==
// @name nhentai Language Filter
// @namespace http://tampermonkey.net/
// @version 1.2.0
// @description Adds quick language filters (English, Japanese, Chinese) to nhentai pages — works with SvelteKit SPA navigation
// @author Snow2122
// @icon https://nhentai.net/favicon.ico
// @match https://nhentai.net/
// @match https://nhentai.net/search*
// @match https://nhentai.net/tag/*
// @match https://nhentai.net/artist/*
// @match https://nhentai.net/group/*
// @match https://nhentai.net/parody/*
// @match https://nhentai.net/category/*
// @match https://nhentai.net/character/*
// @match https://nhentai.net/user/favorites/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// ============================================
// CONFIGURATION
// ============================================
const LANGUAGES = [
{ code: "english", label: "English", shortLabel: "EN", flag: "🇬🇧" },
{ code: "japanese", label: "Japanese", shortLabel: "JP", flag: "🇯🇵" },
{ code: "chinese", label: "Chinese", shortLabel: "CN", flag: "🇨🇳" },
];
// Every DOM element we inject gets this attribute so we can find / clean it up.
const MARKER = "data-nhi-lang";
// ============================================
// URL KEY
// Tracks which "page" we have injected for.
// We include the 'q' param so that different search queries are treated as
// different pages (triggering a fresh injection when the user switches queries).
// Page numbers are intentionally excluded so pagination doesn't reset us.
// ============================================
function getCurrentKey() {
const { pathname, search } = window.location;
const q = new URLSearchParams(search).get("q") || "";
return q ? `${pathname}?q=${encodeURIComponent(q)}` : pathname;
}
// ============================================
// STATE
// ============================================
let lastKey = null; // The URL key whose elements are currently injected
let muteTimer = null; // Debounce handle for the MutationObserver
// ============================================
// CORE: CHECK AND INJECT
//
// Called whenever the DOM changes or a navigation is detected.
// Decides whether to clean up, inject, or do nothing.
// ============================================
function checkAndInject() {
const key = getCurrentKey();
const pathname = window.location.pathname;
// ── Page changed ─────────────────────────────────────────────────────
// Remove stale injected elements from the previous page and update the
// key immediately (before injection) so retry passes skip this step.
if (lastKey !== key) {
document.querySelectorAll(`[${MARKER}]`).forEach((el) => el.remove());
lastKey = key;
}
// ── Already injected for this key ────────────────────────────────────
// Our elements are still in the DOM — nothing to do.
if (document.querySelector(`[${MARKER}]`)) return;
// ── Try to inject for the current page type ───────────────────────────
if (/\/(artist|tag|group|category|parody|character)\//.test(pathname)) {
injectNamespace();
} else if (pathname.startsWith("/search")) {
injectSearch();
} else if (/\/user\/favorites\//.test(pathname)) {
injectFavorites();
} else if (pathname === "/") {
injectHomepage();
}
// If injection returned false (target not in DOM yet), lastKey is already
// updated so the next pass skips cleanup and jumps straight to injection.
}
// MutationObserver debounce — short delay for fast hydration recovery.
// Uses its OWN timer (muteTimer) so navigation passes cannot cancel it.
function scheduleFromObserver() {
clearTimeout(muteTimer);
muteTimer = setTimeout(checkAndInject, 80);
}
// Navigation passes — fire at multiple independent delays so the
// MutationObserver debounce can NEVER cancel them.
// SvelteKit may render the new route at different speeds; hitting multiple
// windows guarantees we catch it regardless of render timing.
function onNavigate() {
[0, 150, 400, 800, 1500, 2500].forEach((d) => setTimeout(checkAndInject, d));
}
// ============================================
// HELPERS
// ============================================
function getNamespaceType() { return window.location.pathname.split("/")[1] || ""; }
function getNamespaceSlug() { return window.location.pathname.split("/")[2] || ""; }
function getSearchQuery() { return new URLSearchParams(window.location.search).get("q") || ""; }
function stripLanguage(q) {
return q.replace(/\s*language:(english|japanese|chinese)\s*/gi, " ").trim();
}
// Tags every element we create so we can find/remove them later.
function mark(el, code) {
el.setAttribute(MARKER, code);
return el;
}
// ============================================
// ELEMENT BUILDERS
// ============================================
/** A .sort-type <div> with a language filter anchor. */
function buildSortItem(lang, query, isNamespace) {
let href;
if (isNamespace) {
href = `/search?q=${encodeURIComponent(query)}+language:${lang.code}`;
} else {
const clean = stripLanguage(query);
const q = clean ? `${clean} language:${lang.code}` : `language:${lang.code}`;
href = `/search?q=${encodeURIComponent(q)}`;
}
const div = mark(document.createElement("div"), lang.code);
div.className = "sort-type";
const a = document.createElement("a");
a.href = href;
a.textContent = `${lang.label} Only`;
a.title = `Show ${lang.label} only`;
div.appendChild(a);
return div;
}
/** "All" reset link for sort containers. */
function buildSortAll(query, pageType) {
let href;
if (pageType === "namespace") {
href = window.location.origin + window.location.pathname;
} else {
const clean = stripLanguage(query);
href = clean ? `/search?q=${encodeURIComponent(clean)}` : "/";
}
const div = mark(document.createElement("div"), "all");
div.className = "sort-type";
const a = document.createElement("a");
a.href = href;
a.textContent = "All";
a.title = "Show all languages";
div.appendChild(a);
return div;
}
/** Language button for the favorites page. */
function buildFavBtn(lang, query) {
const clean = stripLanguage(query);
const q = clean ? `language:${lang.code} ${clean}` : `language:${lang.code}`;
const a = mark(document.createElement("a"), lang.code);
a.className = "btn btn-primary";
a.href = `/user/favorites/?q=${encodeURIComponent(q)}`;
a.title = `${lang.label} Only`;
a.innerHTML = `<i class="fa fa-flag"></i> ${lang.shortLabel}`;
a.style.marginLeft = "5px";
return a;
}
/** "ALL" reset button for the favorites page. */
function buildFavAllBtn(query) {
const clean = stripLanguage(query);
const href = clean
? `/user/favorites/?q=${encodeURIComponent(clean)}`
: "/user/favorites/";
const a = mark(document.createElement("a"), "all");
a.className = "btn btn-primary";
a.href = href;
a.title = "Show All Languages";
a.innerHTML = `<i class="fa fa-globe"></i> ALL`;
a.style.marginLeft = "5px";
return a;
}
// ============================================
// PAGE INJECTORS
// Each returns true on success, false if the target element isn't in the
// DOM yet (so the MutationObserver can retry later).
// ============================================
function injectNamespace() {
const sortEl = document.querySelector(".sort");
if (!sortEl) return false;
const baseQuery = `${getNamespaceType()}:${getNamespaceSlug()}`;
LANGUAGES.forEach((lang) => sortEl.appendChild(buildSortItem(lang, baseQuery, true)));
sortEl.appendChild(buildSortAll(baseQuery, "namespace"));
return true;
}
function injectSearch() {
const sortEl = document.querySelector(".sort");
if (!sortEl) return false;
const q = getSearchQuery();
const activeLang = LANGUAGES.find((l) =>
new RegExp(`language:${l.code}`, "i").test(q)
);
LANGUAGES.forEach((lang) => {
const item = buildSortItem(lang, q, false);
if (activeLang?.code === lang.code) {
const a = item.querySelector("a");
a.style.color = "#ed2553";
a.style.fontWeight = "bold";
}
sortEl.appendChild(item);
});
const allItem = buildSortAll(q, "search");
if (!activeLang) {
const a = allItem.querySelector("a");
a.style.color = "#ed2553";
a.style.fontWeight = "bold";
}
sortEl.appendChild(allItem);
return true;
}
function injectFavorites() {
const randomBtn = document.getElementById("favorites-random-button");
if (!randomBtn) return false;
const q = getSearchQuery();
// insertAdjacentElement("afterend") inserts in reverse visual order, so
// we insert ALL first, then CN → JP → EN, ending up with EN JP CN ALL.
randomBtn.insertAdjacentElement("afterend", buildFavAllBtn(q));
[...LANGUAGES].reverse().forEach((lang) => {
randomBtn.insertAdjacentElement("afterend", buildFavBtn(lang, q));
});
return true;
}
function injectHomepage() {
const menuEl = document.querySelector(".menu.right") || document.querySelector(".menu");
if (!menuEl) return false;
const container = mark(document.createElement("li"), "dropdown");
container.innerHTML = `<a href="#" style="position:relative;cursor:pointer;">🌐 Language <i class="fa fa-caret-down"></i></a>`;
const dropdown = document.createElement("ul");
Object.assign(dropdown.style, {
display: "none",
position: "absolute",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "4px",
padding: "5px 0",
minWidth: "140px",
zIndex: "1000",
listStyle: "none",
margin: "0",
});
function addHover(a) {
a.addEventListener("mouseenter", () => { a.style.background = "rgba(237,37,83,.2)"; });
a.addEventListener("mouseleave", () => { a.style.background = "transparent"; });
}
LANGUAGES.forEach((lang) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = `/search?q=language:${lang.code}`;
a.style.cssText = "display:block;padding:8px 15px;color:#fff;text-decoration:none;";
a.textContent = `${lang.flag} ${lang.label}`;
addHover(a);
li.appendChild(a);
dropdown.appendChild(li);
});
const allLi = document.createElement("li");
const allA = document.createElement("a");
allA.href = "/";
allA.style.cssText = "display:block;padding:8px 15px;color:#fff;text-decoration:none;border-top:1px solid #333;";
allA.textContent = "🌍 All";
addHover(allA);
allLi.appendChild(allA);
dropdown.appendChild(allLi);
container.appendChild(dropdown);
container.addEventListener("mouseenter", () => { dropdown.style.display = "block"; });
container.addEventListener("mouseleave", () => { dropdown.style.display = "none"; });
container.querySelector("a").addEventListener("click", (e) => e.preventDefault());
menuEl.appendChild(container);
return true;
}
// ============================================
// NAVIGATION DETECTION
//
// SvelteKit uses the History API (pushState / replaceState) for all internal
// navigation — no full page reload ever happens. We patch both methods so
// every client-side navigation triggers onNavigate().
// ============================================
function patchHistory() {
const wrap = (original) => function (...args) {
original.apply(this, args);
onNavigate();
};
history.pushState = wrap(history.pushState.bind(history));
history.replaceState = wrap(history.replaceState.bind(history));
window.addEventListener("popstate", onNavigate);
}
// ============================================
// START
// ============================================
function start() {
// 1. Patch History API for SvelteKit client-side navigation
patchHistory();
// 2. Persistent MutationObserver for hydration recovery.
// Uses scheduleFromObserver (its own debounce timer) so navigation
// passes cannot accidentally cancel it, and vice-versa.
new MutationObserver(scheduleFromObserver)
.observe(document.body, { childList: true, subtree: true });
// 3. Initial load — same multi-pass approach as navigation
onNavigate();
}
start();
})();