Unlock Hath Perks

Unlock Hath Perks and add other helpers

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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.3.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, mtcp: boolean, pe: boolean, fw: boolean}} */
	const uhpConfig = (() => {
		const _conf = Object.assign({
			abg: true,
			mt: true,
			mtcp: 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");
		if (!paginationEl) return console.error("No paginationEl");
		const initialNextUrl = $(NEXT_PAGE_SELECTOR)?.href ?? "";
		let commentsAnchorEl;
		let moreThumbsControlsEl;
		let statusEl;
		let viewCommentsEl;
		let resumeThumbsEl;
		if (uhpConfig.mtcp) {
			commentsAnchorEl = $("a[name=\"comments\"]");
			if (!commentsAnchorEl) return console.error("No commentsAnchorEl");
			const commentCount = $$("#cdiv > .c1").length;
			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);
			statusEl = $("#🔓-more-thumbs-status");
			viewCommentsEl = $("#🔓-view-comments");
			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 = uhpConfig.mtcp && 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 (!uhpConfig.mtcp) return;
			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 (isPaused || !hasMore || uhpConfig.mtcp && !isIntersecting) 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();
		};
		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"
			});
		};
		if (uhpConfig.mtcp) {
			ob = new IntersectionObserver((entries) => {
				isIntersecting = entries[0].isIntersecting;
				if (isIntersecting) startLoading();
			});
			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);
		} else startLoading();
	})();
	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 sub-option">
        <label class="material-switch">
          <input type="checkbox" id="🔓-conf-mtcp" value="mtcp" />
        </label>
        <span class="🔓-conf-title">Comment Panel</span>
        <span class="🔓-conf-desc">Pause thumbnail loading and jump to comments from a floating panel.</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\"]");
	const moreThumbsCheckboxEl = $("#🔓-conf-mt");
	const commentPanelCheckboxEl = $("#🔓-conf-mtcp");
	const syncMoreThumbsOptions = () => {
		commentPanelCheckboxEl.disabled = !moreThumbsCheckboxEl.checked;
	};
	for (const checkboxEl of checkboxEls) {
		checkboxEl.checked = uhpConfig[checkboxEl.value];
		checkboxEl.onchange = () => {
			uhpConfig[checkboxEl.value] = checkboxEl.checked;
			uhpDialogEl.dataset.hasChanged = true;
			syncMoreThumbsOptions();
		};
	}
	syncMoreThumbsOptions();
	$("#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;
  }

  .option-grid.sub-option {
    margin-left: 2rem;
  }

  .option-grid:has(input:disabled) {
    opacity: 0.45;
  }
}

.🔓-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:has(> input[type="checkbox"]:disabled) {
  cursor: not-allowed;
}

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
})();