nhentai Language Filter

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();