nhentai Language Filter

Adds quick language filters (English, Japanese, Chinese) to nhentai pages — works with SvelteKit SPA navigation

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();

})();