Unlock Hath Perks

Unlock Hath Perks and add other helpers

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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