Unlock Hath Perks

Unlock Hath Perks and add other helpers

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 or Violentmonkey 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       Unlock Hath Perks
// @name:zh-TW 解鎖 Hath Perks
// @name:zh-CN 解锁 Hath Perks
// @description       Unlock Hath Perks and add other helpers
// @description:zh-TW 解鎖 Hath Perks 及增加一些小工具
// @description:zh-CN 解锁 Hath Perks 及增加一些小工具
// @namespace   https://flandre.in/github
// @version     3.2.0
// @match       https://e-hentai.org/*
// @match       https://exhentai.org/*
// @icon        https://i.imgur.com/JsU0vTd.png
// @grant       GM_getValue
// @grant       GM_setValue
// @noframes
// @author      FlandreDaisuki
// @supportURL  https://github.com/FlandreDaisuki/browser-extensions/issues
// @homepageURL https://github.com/FlandreDaisuki/browser-extensions/blob/main/userscripts/UnlockHathPerks/README.md
// @license     MPLv2
// ==/UserScript==
(function() {
	//#region ../common.js
	const noop = () => {};
	const $find = (el, selectors) => el.querySelector(selectors);
	const $ = (selectors) => document.querySelector(selectors);
	const $$ = (selectors) => Array.from(document.querySelectorAll(selectors));
	const $el = (tag, attr = {}, cb = noop) => {
		const el = document.createElement(tag);
		if (typeof attr === "string") el.textContent = attr;
		else Object.assign(el, attr);
		cb(el);
		return el;
	};
	const $html = (htmlText) => {
		const tmpEl = $el("div");
		tmpEl.innerHTML = htmlText;
		return tmpEl.firstElementChild;
	};
	const $style = (stylesheet) => $el("style", stylesheet, (el) => document.head.appendChild(el));
	const sleep = (ms) => new Promise((resolve) => {
		setTimeout(resolve, ms);
	});
	//#endregion
	//#region index.js
	/** @type {{abg: boolean, mt: boolean, pe: boolean, fw: boolean}} */
	const uhpConfig = (() => {
		const _conf = Object.assign({
			abg: true,
			mt: true,
			pe: true,
			fw: false
		}, GM_getValue("uhp"));
		GM_setValue("uhp", _conf);
		return new Proxy(_conf, { set(target, propertyKey, value) {
			const r = Reflect.set(target, propertyKey, value);
			GM_setValue("uhp", _conf);
			return r;
		} });
	})();
	if (uhpConfig.abg) $style("iframe[src*=\"juicyads\"] { display:none !important; }");
	if (uhpConfig.mt) (async () => {
		const u = new URL(location);
		if (!location.pathname.startsWith("/g/")) return;
		if (u.searchParams.get("report") === "select") return;
		if (u.searchParams.get("act") === "expunge") return;
		const NEXT_PAGE_SELECTOR = ".ptt td:last-child > a";
		const IMAGE_PARENT_SELECTOR = "#gdt";
		const imgParentEl = $(IMAGE_PARENT_SELECTOR);
		if (!imgParentEl) return console.error("No imgParentEl");
		const paginationEl = $("table.ptb");
		const commentsAnchorEl = $("a[name=\"comments\"]");
		if (!paginationEl) return console.error("No paginationEl");
		if (!commentsAnchorEl) return console.error("No commentsAnchorEl");
		const initialNextUrl = $(NEXT_PAGE_SELECTOR)?.href ?? "";
		const commentCount = $$("#cdiv > .c1").length;
		const moreThumbsControlsEl = $el("div", {
			className: location.host === "exhentai.org" ? "dark" : "",
			id: "🔓-more-thumbs-controls"
		});
		moreThumbsControlsEl.innerHTML = `
      <span id="🔓-more-thumbs-status" aria-live="polite"></span>
      <button id="🔓-view-comments" type="button">
        Comments${commentCount ? ` (${commentCount})` : ""} ↓
      </button>
      <button id="🔓-resume-thumbs" type="button" hidden>Resume thumbs ↑</button>
    `;
		document.body.appendChild(moreThumbsControlsEl);
		const statusEl = $("#🔓-more-thumbs-status");
		const viewCommentsEl = $("#🔓-view-comments");
		const resumeThumbsEl = $("#🔓-resume-thumbs");
		const isCommentHash = (hash) => hash === "#comments" || hash === "#ulcomment" || /^#c\d+$/.test(hash);
		const getCommentHashTarget = () => {
			const name = location.hash.slice(1);
			return document.getElementById(name) ?? document.getElementsByName(name)[0] ?? commentsAnchorEl;
		};
		const getThumbCount = () => Array.from(imgParentEl.children).filter((imgEl) => !imgEl.classList.contains("c")).length;
		let isPaused = isCommentHash(location.hash);
		let hasMore = Boolean(initialNextUrl);
		let isIntersecting = false;
		let isLoading = false;
		let loadFailed = false;
		let navigationIntent = 0;
		let currentLoadPromise = Promise.resolve();
		let ob;
		const updateControls = () => {
			if (loadFailed) {
				moreThumbsControlsEl.dataset.state = "error";
				statusEl.textContent = `Stopped · ${getThumbCount()} thumbnails`;
			} else if (!hasMore) {
				moreThumbsControlsEl.dataset.state = "complete";
				statusEl.textContent = `All ${getThumbCount()} thumbnails`;
			} else if (isPaused) {
				moreThumbsControlsEl.dataset.state = "paused";
				statusEl.textContent = `Paused · ${getThumbCount()} thumbnails`;
			} else if (isLoading) {
				moreThumbsControlsEl.dataset.state = "loading";
				statusEl.textContent = `Loading · ${getThumbCount()} thumbnails`;
			} else {
				moreThumbsControlsEl.dataset.state = "ready";
				statusEl.textContent = `${getThumbCount()} thumbnails`;
			}
			resumeThumbsEl.hidden = !isPaused || !hasMore;
		};
		/** @param {string} initUrl */
		async function* newPagedImgElsGen(initUrl) {
			let url = initUrl;
			/** @type {HTMLElement[]} */
			let imgEls = [];
			while (url) {
				const resp = await fetch(url, { credentials: "same-origin" });
				url = "";
				imgEls = [];
				if (!resp.ok) throw new Error(`Could not load thumbnails: HTTP ${resp.status}`);
				const html = await resp.text();
				const docEl = new DOMParser().parseFromString(html, "text/html").documentElement;
				imgEls = Array.from($find(docEl, IMAGE_PARENT_SELECTOR)?.children ?? []);
				url = $find(docEl, NEXT_PAGE_SELECTOR)?.href ?? "";
				yield {
					hasNextPage: Boolean(url),
					imgEls
				};
			}
			return {
				hasNextPage: false,
				imgEls: []
			};
		}
		const pagedImgEls = newPagedImgElsGen(initialNextUrl);
		const appendNextPage = async () => {
			const pagedImgElsResult = await pagedImgEls.next();
			if (pagedImgElsResult.done) {
				hasMore = false;
				return;
			}
			for (const imgEl of pagedImgElsResult.value.imgEls) if (!imgEl.classList.contains("c")) imgParentEl.appendChild(imgEl);
			hasMore = pagedImgElsResult.value.hasNextPage;
		};
		const loadVisiblePages = async () => {
			isLoading = true;
			updateControls();
			try {
				while (true) {
					if (!isIntersecting || isPaused || !hasMore) break;
					await appendNextPage();
					updateControls();
					if (!isPaused && hasMore) await sleep(300);
				}
			} catch (error) {
				loadFailed = true;
				hasMore = false;
				console.error(error);
			} finally {
				isLoading = false;
				if (!hasMore) ob.disconnect();
				updateControls();
			}
		};
		const startLoading = () => {
			if (isLoading || isPaused || !hasMore) return;
			currentLoadPromise = loadVisiblePages();
		};
		ob = new IntersectionObserver((entries) => {
			isIntersecting = entries[0].isIntersecting;
			if (isIntersecting) startLoading();
		});
		const pauseAndViewComments = async (targetEl, behavior = "smooth") => {
			const intent = ++navigationIntent;
			isPaused = true;
			isIntersecting = false;
			ob.disconnect();
			updateControls();
			await currentLoadPromise;
			if (intent !== navigationIntent || !isPaused) return;
			targetEl.scrollIntoView({
				behavior,
				block: "start"
			});
		};
		const resumeMoreThumbs = () => {
			navigationIntent += 1;
			isPaused = false;
			updateControls();
			ob.observe(paginationEl);
			paginationEl.scrollIntoView({
				behavior: "smooth",
				block: "center"
			});
		};
		viewCommentsEl.onclick = () => pauseAndViewComments(commentsAnchorEl);
		resumeThumbsEl.onclick = resumeMoreThumbs;
		window.addEventListener("hashchange", () => {
			if (isCommentHash(location.hash)) pauseAndViewComments(getCommentHashTarget(), "auto");
		});
		updateControls();
		if (isPaused) requestAnimationFrame(() => pauseAndViewComments(getCommentHashTarget(), "auto"));
		else if (hasMore) ob.observe(paginationEl);
	})();
	if (uhpConfig.pe) (async () => {
		if (!$("input[name=\"f_search\"]")) return;
		if (!$(".itg")) return;
		const isTableLayout = Boolean($("table.itg"));
		const NEXT_PAGE_SELECTOR = ".ptt td:last-child > a, .searchnav a[href*=\"next=\"]";
		const IMAGE_PARENT_SELECTOR = isTableLayout ? "table.itg > tbody" : "div.itg";
		const imgParentEl = $(IMAGE_PARENT_SELECTOR);
		if (!imgParentEl) return console.error("No imgParentEl");
		imgParentEl.innerHTML = "";
		const statusEl = $el("h1", {
			textContent: "Loading...",
			id: "🔓-status"
		});
		$("table.ptb, .itg + .searchnav, #favform + .searchnav").replaceWith(statusEl);
		/** @param {string} initUrl */
		async function* newPagedImgElsGen(initUrl) {
			let url = initUrl;
			/** @type {HTMLElement[]} */
			let imgEls = [];
			while (url) {
				const resp = await fetch(url, { credentials: "same-origin" });
				url = "";
				imgEls = [];
				if (resp.ok) {
					const html = await resp.text();
					const docEl = new DOMParser().parseFromString(html, "text/html").documentElement;
					imgEls = Array.from($find(docEl, IMAGE_PARENT_SELECTOR)?.children ?? []);
					url = $find(docEl, NEXT_PAGE_SELECTOR)?.href ?? "";
				}
				yield imgEls;
			}
			return [];
		}
		const pagedImgEls = newPagedImgElsGen(location.href);
		const replaceResult = async (ob) => {
			const pagedImgElsResult = await pagedImgEls.next();
			if (pagedImgElsResult.done) {
				statusEl.textContent = "End";
				return ob.disconnect();
			}
			for (const imgEl of pagedImgElsResult.value) imgParentEl.appendChild(imgEl);
		};
		let isIntersecting = false;
		const ob = new IntersectionObserver(async (entries) => {
			isIntersecting = entries[0].isIntersecting;
			if (isIntersecting) do {
				await replaceResult(ob);
				await sleep(300);
				if (!isIntersecting) break;
			} while (true);
		});
		ob.observe(statusEl);
	})();
	if (uhpConfig.fw) document.body.classList.add("🔓-full-width");
	const uhpDialogEl = $el("dialog", { id: "🔓-dialog" });
	uhpDialogEl.className = location.host === "exhentai.org" ? "dark" : "";
	uhpDialogEl.innerHTML = `
  <fieldset>
    <legend>Unlock Hath Perks</legend>
    <div role="group">

      <div class="option-grid">
        <label class="material-switch">
          <input type="checkbox" id="🔓-conf-abg" value="abg" />
        </label>
        <span class="🔓-conf-title">Ads-Be-Gone</span>
        <span class="🔓-conf-desc">Remove ads. You can use it with adblock webextensions.</span>
      </div>

      <div class="option-grid">
        <label class="material-switch">
          <input type="checkbox" id="🔓-conf-mt" value="mt" />
        </label>
        <span class="🔓-conf-title">More Thumbs</span>
        <span class="🔓-conf-desc">Scroll infinitely in gallery pages.</span>
      </div>

      <div class="option-grid">
        <label class="material-switch">
          <input type="checkbox" id="🔓-conf-pe" value="pe" />
        </label>
        <span class="🔓-conf-title">Page Enlargement</span>
        <span class="🔓-conf-desc">Scroll infinitely in search results pages.</span>
      </div>

      <div class="option-grid">
        <label class="material-switch">
          <input type="checkbox" id="🔓-conf-fw" value="fw" />
        </label>
        <span class="🔓-conf-title">Full Width</span>
        <span class="🔓-conf-desc">Utilize your monitor.</span>
      </div>

    </div>
  </fieldset>
`;
	uhpDialogEl.onclick = (evt) => {
		if (evt.target === uhpDialogEl) {
			uhpDialogEl.close();
			if (uhpDialogEl.dataset.hasChanged) location.reload();
		}
	};
	document.body.appendChild(uhpDialogEl);
	/** @type {HTMLInputElement[]} */
	const checkboxEls = $$("dialog#🔓-dialog input[type=\"checkbox\"]");
	for (const checkboxEl of checkboxEls) {
		checkboxEl.checked = uhpConfig[checkboxEl.value];
		checkboxEl.onchange = () => {
			uhpConfig[checkboxEl.value] = checkboxEl.checked;
			uhpDialogEl.dataset.hasChanged = true;
		};
	}
	$("#nb").appendChild($html(`
    <div>
      <a id="🔓-entry" href="javascript:;">Unlock Hath Perks</a>
    </div>
  `));
	$("a#🔓-entry").onclick = () => uhpDialogEl.showModal();
	$style(`
/* nav bar */
#nb {
  width: initial;
  max-width: initial;
  max-height: initial;
  justify-content: center;
}

/* search input */
table.itc + p.nopm {
  display: flex;
  flex-flow: row wrap;
  justify-content: center;
}
input[name="f_search"] {
  width: 100%;
}

/* /favorites.php */
input[name="favcat"] + div {
  display: flex;
  flex-flow: row wrap;
  justify-content: center;
  gap: 8px;
}

/* gallery grid */
.gl1t {
  display: flex;
  flex-flow: column;
}
.gl1t > .gl3t {
  flex: 1;
}
.gl1t > .gl3t > a {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}`);
	$style(`
#🔓-status {
  text-align: center;
  font-size: 3rem;
  clear: both;
  padding: 2rem 0;
}

#🔓-more-thumbs-controls {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 100;
  display: flex;
  align-items: center;
  gap: 0.65rem;
  max-width: calc(100vw - 2rem);
  padding: 0.65rem 0.75rem;
  border: 2px solid #7f1d1d;
  border-radius: 0.75rem;
  color: #3f0b0b;
  background: #fffaf0;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
  font-family: system-ui, sans-serif;
  font-size: 0.9rem;

  &.dark {
    border-color: #f0b429;
    color: #fff;
    background: #181a1f;
  }

  &[data-state="complete"] {
    border-color: #16803c;
  }

  &.dark[data-state="complete"] {
    border-color: #4ade80;
  }

  &[data-state="error"] {
    border-color: #dc2626;
  }

  > span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: 700;
  }

  > span::before {
    display: inline-block;
    margin-right: 0.4rem;
    color: #7f1d1d;
    content: "●";
  }

  &[data-state="loading"] > span::before {
    color: #ca8a04;
    content: "↻";
    animation: uhp-spin 1s linear infinite;
  }

  &[data-state="paused"] > span::before {
    color: #ca8a04;
    content: "⏸";
  }

  &[data-state="complete"] > span::before {
    color: #16803c;
    content: "✓";
  }

  &.dark[data-state="complete"] > span::before {
    color: #4ade80;
  }

  &[data-state="error"] > span::before {
    color: #dc2626;
    content: "!";
  }

  > button {
    flex: none;
    padding: 0.4rem 0.65rem;
    border: 1px solid #7f1d1d;
    border-radius: 0.45rem;
    color: #fff;
    background: #7f1d1d;
    font: inherit;
    font-weight: 700;
    cursor: pointer;
  }

  > button:hover {
    filter: brightness(1.2);
  }

  &.dark > button {
    border-color: #f0b429;
    color: #181a1f;
    background: #f0b429;
  }

  > #🔓-resume-thumbs {
    border-color: #0369a1;
    background: #0369a1;
  }

  &.dark > #🔓-resume-thumbs {
    border-color: #38bdf8;
    background: #38bdf8;
  }

  > button[hidden] {
    display: none;
  }
}

@keyframes uhp-spin {
  to { transform: rotate(360deg); }
}

#🔓-dialog {
  padding: 1.2rem;
  background-color: floralwhite;
  border-radius: 1rem;
  font-size: 1.4rem;
  color: darkred;
  max-width: 950px;

  &.dark {
    background-color: dimgray;
    color: ghostwhite;
  }

  fieldset > legend {
    font-size: 2rem;
  }

  .option-grid {
    display: grid;
    grid-template-columns: max-content 14rem 1fr;
    column-gap: 1rem;
    padding: 0.5rem 1rem;
    align-items: center;
  }
}

.🔓-full-width :where(#gdt, div.ido) {
  max-width: initial !important;
  margin: 1rem !important;
}

@supports (display:grid) {
  .🔓-full-width .gld {
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 0.5rem;
  }
}

/* Modified https://bootsnipp.com/snippets/featured/material-design-switch */
label.material-switch > input[type="checkbox"] {
  display: none;
}

label.material-switch {
  display: inline-block;
  position: relative;
  margin: 6px;
  border-radius: 8px;
  width: 40px;
  height: 16px;
  opacity: 0.3;
  background-color: rgb(0, 0, 0);
  box-shadow: inset 0px 0px 10px rgba(0, 0, 0, 0.5);
  transition: all 0.4s ease-in-out;
  cursor: pointer;
}

label.material-switch::after {
  position: absolute;
  top: -4px;
  left: -4px;
  border-radius: 16px;
  width: 24px;
  height: 24px;
  content: "";
  background-color: rgb(255, 255, 255);
  box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
  transition: all 0.3s ease-in-out;
}

label.material-switch:has(> input[type="checkbox"]:checked) {
  background-color: #0e0;
  opacity: 0.7;
}

label.material-switch:has(> input[type="checkbox"]:checked)::after {
  background-color: inherit;
  left: 20px;
}`);
	//#endregion
})();