Unlock Hath Perks

Unlock Hath Perks and add other helpers

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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