// ==UserScript==
// @name Cosmos Manga View
// @name:zh-CN 宇宙漫画视图
// @namespace https://github.com/MapoMagpie/eh-view-enhance
// @version 4.2.1
// @author MapoMagpie
// @description Improve the comic reading experience by displaying all thumbnails, Auto loading large images, Downloading as archive, and keeping the site’s load low.
// @description:zh-CN 提升漫画阅读体验,陈列所有缩略图,自动加载大图,打包下载,同时保持对站点的低负载。
// @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/*
// @match https://yande.re/post*
// @exclude https://nhentai.net/g/*/*/
// @exclude https://yande.re/post/show/*
// @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
// @connect ehgt.org
// @connect files.yande.re
// @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: 5e3,
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,
convertTo: "GIF",
autoCollapsePanels: true,
minifyPageHelper: "inBigMode"
};
}
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 === void 0) {
$conf.archiveVolumeSize = 1500;
changed = true;
}
if ($conf.convertTo === void 0) {
$conf.convertTo = "GIF";
changed = true;
}
if ($conf.autoCollapsePanels === void 0) {
$conf.autoCollapsePanels = true;
changed = true;
}
if ($conf.minifyPageHelper === void 0) {
$conf.minifyPageHelper = "inBigMode";
changed = true;
}
if ($conf.restartIdleLoader === 8e3) {
$conf.restartIdleLoader = 5e3;
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", "autoCollapsePanels"];
const ConfigSelectKeys = ["readMode", "stickyMouse", "minifyPageHelper"];
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
});
}
function fetchImage(url) {
return new Promise((resolve, reject) => {
xhrWapper(url, "blob", {
onload: (response) => resolve(response.response),
onerror: (error) => reject(error)
});
});
}
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 {
node;
originURL;
stage = 1 /* URL */;
tryTimes = 0;
lock = false;
/// 0: not rendered, 1: rendered tumbinal, 2: rendered big image
rendered = 0;
data;
contentType;
blobUrl;
downloadState;
onFinishedEventContext;
onFailedEventContext;
downloadBar;
timeoutId;
settings;
constructor(root, settings) {
this.node = root;
this.node.onclick = (event) => settings.onClick?.(event);
this.downloadState = { total: 100, loaded: 0, readyState: 0 };
this.onFinishedEventContext = /* @__PURE__ */ new Map();
this.onFailedEventContext = /* @__PURE__ */ new Map();
this.settings = settings;
}
// 刷新下载状态
setDownloadState(newState) {
this.downloadState = { ...this.downloadState, ...newState };
this.node.progress(this.downloadState);
this.settings.downloadStateReporter?.(this.downloadState);
}
async start(index) {
if (this.lock)
return;
this.lock = true;
try {
this.node.changeStyle("fetching");
await this.fetchImage();
this.node.changeStyle("fetched");
this.onFinishedEventContext.forEach((callback) => callback(index, this));
} catch (error) {
this.node.changeStyle("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.node.changeStyle();
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.originURL = 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 }));
this.node.onloaded(this.blobUrl, this.contentType, this.data.byteLength);
if (this.rendered === 2) {
this.node.render();
}
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.node.href, 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.originURL}`);
}
return data.arrayBuffer().then((buffer) => [new Uint8Array(buffer), data.type]);
} catch (error) {
evLog(`Fetch image data error:`, error);
return null;
}
}
render() {
switch (this.rendered) {
case 0:
case 1:
this.node.render();
this.rendered = 2;
break;
}
}
unrender() {
if (this.rendered === 1 || this.rendered === 0)
return;
this.rendered = 1;
this.node.unrender();
}
//立刻将当前元素的src赋值给大图元素
setNow(index) {
this.settings.setNow?.(index);
if (this.stage === 3 /* DONE */) {
this.onFinishedEventContext.forEach((callback) => callback(index, this));
}
}
async fetchBigImage() {
if (this.originURL?.startsWith("blob:")) {
return await fetch(this.originURL).then((resp) => resp.blob());
}
const imgFetcher = this;
return new Promise(async (resolve, reject) => {
xhrWapper(imgFetcher.originURL, "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);
}
});
});
}
}
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("Raw Image", "最佳质量"),
autoLoad: new I18nValue("Auto Load", "自动加载"),
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.", "点击侧边导航时,是否反向翻页,反向翻页类似日本漫画那样的从右到左的阅读方式。"),
autoCollapsePanels: new I18nValue("Auto Flod Control Panels", "自动收起控制面板"),
autoCollapsePanelsTooltip: new I18nValue("When the mouse is moved out of the control panel, the control panel will automatically fold. If disabled, the display of the control panel can only be toggled through the button on the control bar.", "当鼠标移出控制面板时,自动收起控制面板。禁用此选项后,只能通过控制栏上的按钮切换控制面板的显示。"),
stickyMouse: new I18nValue("Sticky Mouse", "黏糊糊鼠标"),
stickyMouseTooltip: new I18nValue("In non-continuous reading mode, scroll a single image automatically by moving the mouse.", "非连续阅读模式下,通过鼠标移动来自动滚动单张图片。"),
minifyPageHelper: new I18nValue("Minify Control Bar", "最小化控制栏"),
minifyPageHelperTooltip: new I18nValue("Minify Control Bar", "最小化控制栏"),
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 |
Toggle Auto Load | p |
图片缩放 | 鼠标右键+滚轮 或 -/= |
打开大图(缩略图模式下) | 回车 |
退出大图(大图模式下) | 回车/Esc |
打开指定图片(缩略图模式下) | 直接输入数字(不回显) + 回车 |
切换图片 | →/← |
滚动图片 | ↑/↓ |
开关自动加载 | p |