// ==UserScript==
// @name E HENTAI VIEW ENHANCE
// @name:zh-CN E绅士阅读强化
// @namespace https://github.com/MapoMagpie/eh-view-enhance
// @version 4.1.16
// @author MapoMagpie
// @description e-hentai.org better viewer, All of thumbnail images exhibited in grid, and show the best quality image.
// @description:zh-CN E绅士阅读强化,一目了然的缩略图网格陈列,漫画形式的大图阅读。
// @license MIT
// @icon https://exhentai.org/favicon.ico
// @match https://exhentai.org/g/*
// @match https://e-hentai.org/g/*
// @match https://nhentai.net/g/*
// @match https://steamcommunity.com/id/*/screenshots*
// @match https://hitomi.la/*/*
// @match https://www.pixiv.net/*
// @exclude https://nhentai.net/g/*/*/
// @require https://cdn.jsdelivr.net/npm/jszip@3.1.5/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
// @require https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js
// @connect exhentai.org
// @connect e-hentai.org
// @connect hath.network
// @connect nhentai.net
// @connect hitomi.la
// @connect akamaihd.net
// @connect i.pximg.net
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @downloadURL none
// ==/UserScript==
(function (fileSaver, JSZip, Hammer) {
'use strict';
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
// src/native/alias.ts
var _GM_getValue = /* @__PURE__ */ (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
var _GM_setValue = /* @__PURE__ */ (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
function defaultConf() {
const screenWidth = window.screen.width;
const colCount = screenWidth > 2500 ? 7 : screenWidth > 1900 ? 6 : 5;
return {
backgroundImage: `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAfElEQVR42mP8z/CfCgIwDEgwAIAL0Fq3MDD5iQcn0/BgDpDAn0/AvywA4kUEZ7gUkXBoAM5gUQUaJ6eClOyBjALcAAAAASUVORK5CYII=`,
colCount,
readMode: "singlePage",
autoLoad: true,
fetchOriginal: false,
restartIdleLoader: 8e3,
threads: 3,
downloadThreads: 3,
timeout: 40,
version: "4.1.10",
debug: true,
first: true,
reversePages: false,
pageHelperAbTop: "unset",
pageHelperAbLeft: "20px",
pageHelperAbBottom: "20px",
pageHelperAbRight: "unset",
imgScale: 0,
stickyMouse: "enable",
autoPageInterval: 1e4,
autoPlay: false,
filenameTemplate: "{number}-{title}",
preventScrollPageTime: 200,
archiveVolumeSize: 1500
};
}
const VERSION = "4.1.10";
const CONFIG_KEY = "ehvh_cfg_";
function getConf() {
let cfgStr = _GM_getValue(CONFIG_KEY);
if (cfgStr) {
let cfg2 = JSON.parse(cfgStr);
if (cfg2.version === VERSION) {
return confHealthCheck(cfg2);
}
}
let cfg = defaultConf();
saveConf(cfg);
return cfg;
}
function confHealthCheck($conf) {
let changed = false;
if ($conf.pageHelperAbTop !== "unset") {
$conf.pageHelperAbTop = Math.max(parseInt($conf.pageHelperAbTop), 500) + "px";
changed = true;
}
if ($conf.pageHelperAbBottom !== "unset") {
$conf.pageHelperAbBottom = Math.max(parseInt($conf.pageHelperAbBottom), 5) + "px";
changed = true;
}
if ($conf.pageHelperAbLeft !== "unset") {
$conf.pageHelperAbLeft = Math.max(parseInt($conf.pageHelperAbLeft), 5) + "px";
changed = true;
}
if ($conf.pageHelperAbRight !== "unset") {
$conf.pageHelperAbRight = Math.max(parseInt($conf.pageHelperAbRight), 5) + "px";
changed = true;
}
if (!$conf.archiveVolumeSize) {
$conf.archiveVolumeSize = 1500;
changed = true;
}
if (changed) {
saveConf($conf);
}
return $conf;
}
function saveConf(c) {
_GM_setValue(CONFIG_KEY, JSON.stringify(c));
}
const ConfigNumberKeys = ["colCount", "threads", "downloadThreads", "timeout", "autoPageInterval", "preventScrollPageTime"];
const ConfigBooleanKeys = ["fetchOriginal", "autoLoad", "reversePages", "autoPlay"];
const ConfigSelectKeys = ["readMode", "stickyMouse"];
const conf = getConf();
function evLog(msg, ...info) {
if (conf.debug) {
console.log((/* @__PURE__ */ new Date()).toLocaleString(), "EHVP:" + msg, ...info);
}
}
const HOST_REGEX = /\/\/([^\/]*)\//;
function xhrWapper(url, respType, cb) {
_GM_xmlhttpRequest({
method: "GET",
url,
timeout: conf.timeout * 1e3,
responseType: respType,
headers: {
"Host": HOST_REGEX.exec(url)[1],
// "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:106.0) Gecko/20100101 Firefox/106.0",
"Accept": "image/avif,image/webp,*/*",
// "Accept-Language": "en-US,en;q=0.5",
// "Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Referer": window.location.href,
// "Sec-Fetch-Dest": "image",
// "Sec-Fetch-Mode": "no-cors",
// "Sec-Fetch-Site": "cross-site",
"Cache-Control": "public,max-age=3600,immutable"
},
...cb
});
}
var FetchState = /* @__PURE__ */ ((FetchState2) => {
FetchState2[FetchState2["FAILED"] = 0] = "FAILED";
FetchState2[FetchState2["URL"] = 1] = "URL";
FetchState2[FetchState2["DATA"] = 2] = "DATA";
FetchState2[FetchState2["DONE"] = 3] = "DONE";
return FetchState2;
})(FetchState || {});
class IMGFetcher {
root;
imgElement;
pageUrl;
bigImageUrl;
stage = 1 /* URL */;
tryTimes = 0;
lock = false;
/// 0: not rendered, 1: rendered tumbinal, 2: rendered big image
rendered = 0;
data;
contentType;
blobUrl;
title;
downloadState;
onFinishedEventContext;
onFailedEventContext;
downloadBar;
timeoutId;
settings;
constructor(node, settings) {
this.root = node;
this.imgElement = node.firstChild;
this.pageUrl = this.imgElement.getAttribute("ahref");
this.title = this.imgElement.getAttribute("title") || "untitle.jpg";
this.downloadState = { total: 100, loaded: 0, readyState: 0 };
this.onFinishedEventContext = /* @__PURE__ */ new Map();
this.onFailedEventContext = /* @__PURE__ */ new Map();
this.settings = settings;
this.root.addEventListener("click", (event) => settings.onClick?.(event));
}
// 刷新下载状态
setDownloadState(newState) {
this.downloadState = { ...this.downloadState, ...newState };
if (this.downloadState.readyState === 4) {
if (this.downloadBar && this.downloadBar.parentNode) {
this.downloadBar.parentNode.removeChild(this.downloadBar);
}
return;
}
if (!this.downloadBar) {
const downloadBar = document.createElement("div");
downloadBar.classList.add("downloadBar");
downloadBar.innerHTML = `
`;
this.downloadBar = downloadBar;
this.root.appendChild(this.downloadBar);
}
if (this.downloadBar) {
this.downloadBar.querySelector("progress").setAttribute("value", this.downloadState.loaded / this.downloadState.total * 100 + "");
}
this.settings.downloadStateReporter?.(this.downloadState);
}
async start(index) {
if (this.lock)
return;
this.lock = true;
try {
this.changeStyle(0 /* ADD */);
await this.fetchImage();
this.changeStyle(1 /* REMOVE */, 0 /* SUCCESS */);
this.onFinishedEventContext.forEach((callback) => callback(index, this));
} catch (error) {
this.changeStyle(1 /* REMOVE */, 1 /* FAILED */);
evLog(`IMG-FETCHER ERROR:`, error);
this.stage = 0 /* FAILED */;
this.onFailedEventContext.forEach((callback) => callback(index, this));
} finally {
this.lock = false;
}
}
onFinished(eventId, callback) {
this.onFinishedEventContext.set(eventId, callback);
}
onFailed(eventId, callback) {
this.onFailedEventContext.set(eventId, callback);
}
retry() {
if (this.stage !== 3 /* DONE */) {
this.changeStyle(1 /* REMOVE */);
this.stage = 1 /* URL */;
}
}
async fetchImage() {
this.tryTimes = 0;
while (this.tryTimes < 3) {
switch (this.stage) {
case 0 /* FAILED */:
case 1 /* URL */:
let url = await this.fetchImageURL();
if (url !== null) {
this.bigImageUrl = url;
this.stage = 2 /* DATA */;
} else {
this.tryTimes++;
}
break;
case 2 /* DATA */:
const ret = await this.fetchImageData();
if (ret !== null) {
[this.data, this.contentType] = ret;
this.blobUrl = URL.createObjectURL(new Blob([this.data], { type: this.contentType }));
if (this.rendered === 2) {
this.imgElement.src = this.blobUrl;
}
this.stage = 3 /* DONE */;
} else {
this.stage = 1 /* URL */;
this.tryTimes++;
}
break;
case 3 /* DONE */:
return;
}
}
throw new Error(`Fetch image failed, reach max try times, current stage: ${this.stage}`);
}
async fetchImageURL() {
try {
const imageURL = await this.settings.matcher.matchImgURL(this.pageUrl, this.tryTimes > 0);
if (!imageURL) {
evLog("Fetch URL failed, the URL is empty");
return null;
}
return imageURL;
} catch (error) {
evLog(`Fetch URL error:`, error);
return null;
}
}
async fetchImageData() {
try {
const data = await this.fetchBigImage();
if (data == null) {
throw new Error(`Data is null, image url:${this.bigImageUrl}`);
}
const type = data.type;
return data.arrayBuffer().then((buffer) => [new Uint8Array(buffer), type]);
} catch (error) {
evLog(`Fetch image data error:`, error);
return null;
}
}
render() {
switch (this.rendered) {
case 0:
case 1:
if (this.blobUrl) {
this.imgElement.src = this.blobUrl;
} else {
this.imgElement.src = this.imgElement.getAttribute("asrc");
}
this.rendered = 2;
break;
}
}
unrender() {
if (this.rendered === 1 || this.rendered === 0)
return;
this.rendered = 1;
this.imgElement.src = this.imgElement.getAttribute("asrc");
}
//立刻将当前元素的src赋值给大图元素
setNow(index) {
this.settings.setNow?.(index);
if (this.stage === 3 /* DONE */) {
this.onFinishedEventContext.forEach((callback) => callback(index, this));
}
}
async fetchBigImage() {
if (this.bigImageUrl?.startsWith("blob:")) {
return await fetch(this.bigImageUrl).then((resp) => resp.blob());
}
const imgFetcher = this;
return new Promise(async (resolve, reject) => {
xhrWapper(imgFetcher.bigImageUrl, "blob", {
onload: function(response) {
let data = response.response;
if (data.type === "text/html") {
console.error("warn: fetch big image data type is not blob: ", data);
}
try {
imgFetcher.setDownloadState({ readyState: response.readyState });
} catch (error) {
evLog("warn: fetch big image data onload setDownloadState error:", error);
}
resolve(data);
},
onerror: function(response) {
reject(`error:${response.error}, response:${response.response}`);
},
ontimeout: function() {
reject("timeout");
},
onprogress: function(response) {
imgFetcher.setDownloadState({ total: response.total, loaded: response.loaded, readyState: response.readyState });
},
onloadstart: function() {
imgFetcher.setDownloadState(imgFetcher.downloadState);
}
});
});
}
changeStyle(action, fetchStatus) {
switch (action) {
case 0 /* ADD */:
this.imgElement.classList.add("fetching");
break;
case 1 /* REMOVE */:
this.imgElement.classList.remove("fetching");
break;
}
switch (fetchStatus) {
case 0 /* SUCCESS */:
this.imgElement.classList.add("fetched");
this.imgElement.classList.remove("fetch-failed");
break;
case 1 /* FAILED */:
this.imgElement.classList.add("fetch-failed");
this.imgElement.classList.remove("fetched");
break;
default:
this.imgElement.classList.remove("fetched");
this.imgElement.classList.remove("fetch-failed");
}
}
}
const lang = navigator.language;
const i18nIndex = lang === "zh-CN" ? 1 : 0;
class I18nValue extends Array {
constructor(...value) {
super(...value);
}
get() {
return this[i18nIndex];
}
}
const i18n = {
imageScale: new I18nValue("SCALE", "缩放"),
download: new I18nValue("DL", "下载"),
config: new I18nValue("CONF", "配置"),
autoPagePlay: new I18nValue("PLAY", "播放"),
autoPagePause: new I18nValue("PAUSE", "暂停"),
autoPlay: new I18nValue("Auto Page", "自动翻页"),
autoPlayTooltip: new I18nValue("Auto Page when entering the big image readmode.", "当阅读大图时,开启自动播放模式。"),
preventScrollPageTime: new I18nValue("Flip Page Time", "滚动翻页时间"),
preventScrollPageTimeTooltip: new I18nValue("In Read Mode:Single Page, when scrolling through the content, prevent immediate page flipping when reaching the bottom, improve the reading experience. Set to 0 to disable this feature, measured in milliseconds.", "在单页阅读模式下,滚动浏览时,阻止滚动到底部时立即翻页,提升阅读体验。设置为0时则为禁用此功能,单位为毫秒。"),
collapse: new I18nValue("FOLD", "收起"),
columns: new I18nValue("Columns", "每行数量"),
readMode: new I18nValue("Read Mode", "阅读模式"),
autoPageInterval: new I18nValue("Auto Page Interval", "自动翻页间隔"),
autoPageIntervalTooltip: new I18nValue("Use the mouse wheel on Input box to adjust the interval time.", "在输入框上使用鼠标滚轮快速修改间隔时间"),
readModeTooltip: new I18nValue("Switch to the next picture when scrolling, otherwise read continuously", "滚动时切换到下一张图片,否则连续阅读"),
maxPreloadThreads: new I18nValue("PreloadThreads", "最大同时加载"),
maxPreloadThreadsTooltip: new I18nValue("Max Preload Threads", "大图浏览时,每次滚动到下一张时,预加载的图片数量,大于1时体现为越看加载的图片越多,将提升浏览体验。"),
maxDownloadThreads: new I18nValue("DonloadThreads", "最大同时下载"),
maxDownloadThreadsTooltip: new I18nValue("Max Download Threads, suggest: <5", "下载模式下,同时加载的图片数量,建议小于等于5"),
timeout: new I18nValue("Timeout(second)", "超时时间(秒)"),
bestQuality: new I18nValue("RawImage", "最佳质量"),
autoLoad: new I18nValue("AutoLoad", "自动加载"),
autoLoadTooltip: new I18nValue("", "进入本脚本的浏览模式后,即使不浏览也会一张接一张的加载图片。直至所有图片加载完毕。"),
bestQualityTooltip: new I18nValue("enable will download the original source, cost more traffic and quotas", "启用后,将加载未经过压缩的原档文件,下载打包后的体积也与画廊所标体积一致。
注意:这将消耗更多的流量与配额,请酌情启用。"),
forceDownload: new I18nValue("Take Loaded", "强制下载已加载的"),
downloadStart: new I18nValue("Start Download", "开始下载"),
downloading: new I18nValue("Downloading...", "下载中..."),
downloadFailed: new I18nValue("Failed(Retry)", "下载失败(重试)"),
downloaded: new I18nValue("Downloaded", "下载完成"),
packaging: new I18nValue("Packaging...", "打包中..."),
reversePages: new I18nValue("Reverse Pages", "反向翻页"),
reversePagesTooltip: new I18nValue("Clicking on the side navigation, if enable then reverse paging, which is a reading style similar to Japanese manga where pages are read from right to left.", "点击侧边导航时,是否反向翻页,反向翻页类似日本漫画那样的从右到左的阅读方式。"),
stickyMouse: new I18nValue("Sticky Mouse", "黏糊糊鼠标"),
stickyMouseTooltip: new I18nValue("In non-continuous reading mode, scroll a single image automatically by moving the mouse.", "非连续阅读模式下,通过鼠标移动来自动滚动单张图片。"),
dragToMove: new I18nValue("Drag to Move", "拖动移动"),
originalCheck: new I18nValue("Enable RawImage Transient", "未启用最佳质量图片,点击此处临时开启最佳质量"),
help: new I18nValue(`
Scale Image | mouse right + wheel or -/= |
Open Image(In thumbnails) | Enter |
Exit Image(In big mode) | Enter/Esc |
Open Specific Page(In thumbnails) | Input number(no echo) + Enter |
Switch Page | →/← |
Scroll Image | ↑/↓/Space |
图片缩放 | 鼠标右键+滚轮 或 -/= |
打开大图(缩略图模式下) | 回车 |
退出大图(大图模式下) | 回车/Esc |
打开指定图片(缩略图模式下) | 直接输入数字(不回显) + 回车 |
切换图片 | →/← |
滚动图片 | ↑/↓ |