// ==UserScript==
// @name nHentai Helper
// @name:zh-CN nHentai 助手
// @name:zh-TW nHentai 助手
// @namespace https://github.com/Tsuk1ko
// @version 2.15.3
// @icon https://nhentai.net/favicon.ico
// @description Download nHentai doujin as compression file easily, and add some useful features. Also support NyaHentai.
// @description:zh-CN 为 nHentai 增加压缩打包下载方式以及一些辅助功能,同时支持 NyaHentai
// @description:zh-TW 爲 nHentai 增加壓縮打包下載方式以及一些輔助功能,同時支持 NyaHentai
// @author Jindai Kirin
// @match https://nhentai.net/*
// @match https://nhentai.xxx/*
// @match https://nhentai.to/*
// @match https://nhentai.website/*
// @include /^https:\/\/([^\/]*\.)?(nya|dog|cat|bug|qq|fox|ee|yy)hentai[0-9]*\./
// @connect nhentai.net
// @connect i.nhentai.net
// @connect json2jsonp.com
// @connect i0.mspcdn9.xyz
// @connect cdn.nhentai.xxx
// @connect t.dogehls.xyz
// @license GPL-3.0
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_getResourceText
// @resource notycss https://code.bdstatic.com/npm/noty@3.1.4/lib/noty.css
// @require https://code.bdstatic.com/npm/jquery@3.6.0/dist/jquery.min.js
// @require https://code.bdstatic.com/npm/file-saver@2.0.5/dist/FileSaver.min.js
// @require https://code.bdstatic.com/npm/jquery-pjax@2.0.1/jquery.pjax.js
// @require https://code.bdstatic.com/npm/vue@2.6.14/dist/vue.min.js
// @require https://code.bdstatic.com/npm/noty@3.1.4/lib/noty.min.js
// @require https://code.bdstatic.com/npm/md5@2.3.0/dist/md5.min.js
// @require https://code.bdstatic.com/npm/comlink@4.3.1/dist/umd/comlink.min.js
// @require https://code.bdstatic.com/npm/localforage@1.10.0/dist/localforage.min.js
// @run-at document-end
// @noframes
// @homepageURL https://github.com/Tsuk1ko/nhentai-helper
// @supportURL https://github.com/Tsuk1ko/nhentai-helper/issues
// ==/UserScript==
(async () => {
'use strict';
// 防 nhentai console 屏蔽
if (localStorage.getItem('NHENTAI_HELPER_DEBUG') && typeof unsafeWindow.App !== 'undefined') {
const isNodeOrElement =
typeof Node === 'object' && typeof HTMLElement === 'object'
? o => o instanceof Node || o instanceof HTMLElement
: o => o && typeof o === 'object' && typeof o.nodeType === 'number' && typeof o.nodeName === 'string';
const c = unsafeWindow.console;
c._clear = c.clear;
c.clear = () => {};
c._log = c.log;
c.log = function () {
const args = Array.from(arguments).filter(value => !isNodeOrElement(value));
if (args.length) return c._log(...args);
};
unsafeWindow.Date = Date;
}
Array.prototype.remove = function (index) {
if (index > -1) return this.splice(index, 1)[0];
};
const WORKER_THREAD_NUM = ((navigator && navigator.hardwareConcurrency) || 2) - 1;
const _log = (...args) => console.log('[nhentai-helper]', ...args);
const _warn = (...args) => console.warn('[nhentai-helper]', ...args);
const _error = (...args) => console.error('[nhentai-helper]', ...args);
class JSZipWorkerPool {
constructor() {
this.pool = [];
this.WORKER_URL = URL.createObjectURL(
new Blob(
[
'importScripts("https://code.bdstatic.com/npm/comlink@4.3.1/dist/umd/comlink.min.js","https://code.bdstatic.com/npm/jszip@3.7.1/dist/jszip.min.js");class JSZipWorker{constructor(){this.zip=new JSZip}file(name,{data:data}){this.zip.file(name,data)}generateAsync(options,onUpdate){return this.zip.generateAsync(options,onUpdate).then(data=>Comlink.transfer({data:data},[data]))}}Comlink.expose(JSZipWorker);',
],
{ type: 'text/javascript' }
)
);
for (let id = 0; id < WORKER_THREAD_NUM; id++) {
this.pool.push({
id,
JSZip: null,
idle: true,
});
}
}
createWorker() {
const worker = new Worker(this.WORKER_URL);
return Comlink.wrap(worker);
}
async generateAsync(files, options, onUpdate) {
const worker = this.pool.find(({ idle }) => idle);
if (!worker) throw new Error('No avaliable JSZip worker.');
worker.idle = false;
if (!worker.JSZip) worker.JSZip = this.createWorker();
const zip = await new worker.JSZip();
for (const { name, data } of files) {
await zip.file(name, Comlink.transfer({ data }, [data]));
}
return zip
.generateAsync(
options,
Comlink.proxy(data => onUpdate({ workerId: worker.id, ...data }))
)
.then(({ data }) => {
worker.idle = true;
return data;
});
}
}
const jsZipPool = new JSZipWorkerPool();
class JSZip {
constructor() {
this.files = [];
}
file(name, data) {
this.files.push({ name, data });
}
generateAsync(options, onUpdate) {
return jsZipPool.generateAsync(this.files, options, onUpdate);
}
}
// 下载线程数
let THREAD = GM_getValue('thread_num', 8);
GM_registerMenuCommand('Download thread', () => {
let num;
do {
num = prompt('Please input the number of threads you want (1~32):', THREAD);
if (num === null) return;
num = Number(num);
} while (isNaN(num) || num < 1 || num > 32);
THREAD = num;
GM_setValue('thread_num', num);
});
// 在新窗口打开本子
let OPEN_ON_NEW_TAB = GM_getValue('open_on_new_tab', true);
GM_registerMenuCommand('Open on new tab', () => {
OPEN_ON_NEW_TAB = confirm(`Do you want to open gallery page on a new tab?
Current: ${OPEN_ON_NEW_TAB ? 'Yes' : 'No'}
Please refresh to take effect after modification.`);
GM_setValue('open_on_new_tab', OPEN_ON_NEW_TAB);
});
// 自定义下载地址
let CUSTOM_DOWNLOAD_URL = GM_getValue('custom_download_url', '');
GM_registerMenuCommand('Custom download URL', () => {
const input = prompt(
`WARNING: Please don't set it if you don't know what this does.
Set it empty will restore it to default.
Available placeholders:
{{mid}} - Media ID
{{index}} - Page index, starting from 1
{{ext}} - Image file extension`,
CUSTOM_DOWNLOAD_URL
);
if (input === null) return;
CUSTOM_DOWNLOAD_URL = input.trim();
GM_setValue('custom_download_url', CUSTOM_DOWNLOAD_URL);
});
// 自定义压缩文件名
const CF_EXT_OLD = GM_getValue('cf_ext');
if (CF_EXT_OLD) {
GM_setValue('cf_name', `{{japanese}}.${CF_EXT_OLD}`);
GM_deleteValue('cf_ext');
}
let CF_NAME = GM_getValue('cf_name', '{{japanese}}.zip');
GM_registerMenuCommand('Compression filename', () => {
const input = prompt(
`You can custom the naming of downloaded compression file, including the file extension.
Set it empty will restore it to default.
Available placeholders:
{{english}} - English name of doujin
{{japanese}} - Japanese name of doujin
{{pretty}} - English simple title of doujin
{{id}} - Gallery ID
{{pages}} - Number of pages`,
CF_NAME
);
if (input === null) return;
CF_NAME = input.trim() || '{{japanese}}.zip';
GM_setValue('cf_name', CF_NAME);
});
// 自定义压缩级别
let C_LEVEL = GM_getValue('c_lv', 0);
GM_registerMenuCommand('Compression level', () => {
let num;
do {
num = prompt(
`Please input a number (0-9) as compression level:
0: store (no compression)
1: lowest (best speed)
...
9: highest (best compression)`,
C_LEVEL
);
if (num === null) return;
num = Number(num.trim());
} while (isNaN(num) || num < 0 || num > 9);
C_LEVEL = num;
GM_setValue('c_lv', C_LEVEL);
});
const getCompressionOptions = () => {
if (C_LEVEL === 0) return {};
return {
compression: 'DEFLATE',
compressionOptions: { level: C_LEVEL },
};
};
// 文件名补零
let FILENAME_LENGTH = GM_getValue('filename_length', 0);
GM_registerMenuCommand('Filename length', () => {
let num;
do {
num = prompt(
`Please input the minimum image filename length you want (≥0), zeros will be padded to the start of filename when its length lower than this value:`,
FILENAME_LENGTH
);
if (num === null) return;
num = Number(num);
} while (isNaN(num) || num < 0);
FILENAME_LENGTH = num;
GM_setValue('filename_length', num);
});
// 自动取消下载过的本子
let AUTO_CANCEL_DOWNLOADED_DOUJIN = GM_getValue('auto_cancel_downloaded_doujin', false);
GM_registerMenuCommand('Auto cancel downloaded doujin', () => {
AUTO_CANCEL_DOWNLOADED_DOUJIN = confirm(`Do you want to automatically cancel downloaded doujin?
Current: ${AUTO_CANCEL_DOWNLOADED_DOUJIN ? 'Yes' : 'No'}`);
GM_setValue('auto_cancel_downloaded_doujin', AUTO_CANCEL_DOWNLOADED_DOUJIN);
});
// 自动重试
let AUTO_RETRY_WHEN_ERROR_OCCURS = GM_getValue('auto_retry_when_error_occurs', false);
GM_registerMenuCommand('Auto retry when error occurs', () => {
AUTO_RETRY_WHEN_ERROR_OCCURS = confirm(`Do you want to automatically retry when error occurs?
Current: ${AUTO_RETRY_WHEN_ERROR_OCCURS ? 'Yes' : 'No'}`);
GM_setValue('auto_retry_when_error_occurs', AUTO_RETRY_WHEN_ERROR_OCCURS);
});
GM_addStyle(GM_getResourceText('notycss'));
GM_addStyle(
'.download-zip:disabled{cursor:wait}.gallery>.download-zip{position:absolute;z-index:1;left:0;top:0;opacity:.8}.gallery:hover>.download-zip{opacity:1}#download-panel::-webkit-scrollbar{width:6px;background-color:rgba(0,0,0,.7)}#download-panel::-webkit-scrollbar-thumb{background-color:rgba(255,255,255,.6)}#download-panel{overflow-x:hidden;position:fixed;top:20vh;right:0;width:calc(50vw - 620px);max-width:300px;min-width:150px;max-height:60vh;background-color:rgba(0,0,0,.7);z-index:100;font-size:12px;overflow-y:scroll}.download-item{position:relative;white-space:nowrap;padding:2px;overflow:visible}.download-item-cancel{cursor:pointer;position:absolute;top:0;right:-30px;color:#F44336;font-size:20px;line-height:30px;width:30px}.download-item.can-cancel:hover{width:calc(100% - 30px)}.download-item-title{overflow:hidden;text-overflow:ellipsis;text-align:left}.download-item-progress{background-color:rgba(0,0,255,.5);line-height:10px}.download-error .download-item-progress{background-color:rgba(255,0,0,.5)}.download-compressing .download-item-progress{background-color:rgba(0,255,0,.5)}.download-item-progress-text{transform:scale(.8)}#page-container{position:relative}#gp-view-mode-btn{position:absolute;right:0;top:0;margin:0}.btn-noty-green{background-color:#66BB6A!important}.btn-noty-blue{background-color:#42A5F5!important}.btn-noty:hover{filter:brightness(1.15)}.noty_buttons{padding-top:0!important}@media screen and (max-width:768px){#page-container{padding-top:40px}}.pages-input{-webkit-appearance:none;display:inline-block;border-radius:3px;padding:0 0.1em 0 1em;font-size:1em;width:100%;height:40px;border:0;vertical-align:top;margin-top:5px}.gallery.downloaded .caption{color:#999}'
);
$('body').append('<div id="download-panel"></div>');
const getTextFromTemplate = (template, values) =>
Object.keys(values).reduce((pre, key) => pre.replace(new RegExp(`{{${key}}}`, 'g'), values[key]), template);
const getDpDlExt = () => {
const paths = CF_NAME.split('.');
const ext = paths[paths.length - 1];
if (typeof ext === 'string') return ext.toUpperCase();
return 'ZIP';
};
const EXT = { p: 'png', j: 'jpg', g: 'gif' };
const getExtension = ({ t, extension }) => {
const ext = (t && EXT[t]) || extension;
if (!ext) throw new Error(`Unknown type "${t}"`);
return ext;
};
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// 页面类型
const pageType = {
gallery: /^\/g\/[0-9]+\/?(\?.*)?$/.test(window.location.pathname),
galleryPage: /^\/g\/[0-9]+(\/list)?\/[0-9]+\/?(\?.*)?$/.test(window.location.pathname),
list: $('.gallery').length > 0,
};
const isNHentai = window.location.host === 'nhentai.net';
const isNHentaiX = window.location.host === 'nhentai.xxx';
const isNHentaiTo = window.location.host === 'nhentai.to' || window.location.host === 'nhentai.website';
// 队列
class AsyncQueue {
constructor(thread = 1) {
this.queue = [];
this.running = false;
this.thread = thread;
}
get runningThreadNum() {
return this.queue.filter(({ running }) => running).length;
}
push(fn, info) {
this.queue.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
running: false,
fn,
info,
});
}
async start() {
if (this.thread <= 1) {
if (this.running || this.queue.length === 0) return;
this.running = true;
do {
await this.queue[0].fn();
this.queue.shift();
} while (this.queue.length > 0);
this.running = false;
} else {
const running = this.runningThreadNum;
if (running >= this.thread || this.queue.length === running) return;
const idleItems = this.queue.filter(({ running }) => !running);
for (let i = 0; i < Math.min(idleItems.length, this.thread - running); i++) {
const item = idleItems[i];
item.running = true;
item.fn().then(() => {
this.queue.remove(this.queue.findIndex(({ id }) => id === item.id));
this.start();
});
}
}
}
skipFromError() {
this.queue.shift();
return this.restartFromError();
}
restartFromError() {
this.running = false;
return this.start();
}
}
// 下载队列
const dlQueue = new AsyncQueue();
dlQueue.skip = false;
// 压缩队列
const zipQueue = new AsyncQueue(WORKER_THREAD_NUM);
// 下载历史
const dlGidStore = await (async () => {
const store = localforage.createInstance({
name: 'nhentai_helper',
storeName: 'dl_history_gid',
});
await store.ready();
return store;
})().catch(_error);
const dlTitleStore = await (async () => {
const store = localforage.createInstance({
name: 'nhentai_helper',
storeName: 'dl_history',
});
await store.ready();
let historyNeedToBeMigration;
try {
historyNeedToBeMigration = JSON.parse(localStorage.getItem('downloadHistory'));
} catch (e) {
_error(e);
}
if (Array.isArray(historyNeedToBeMigration)) {
localStorage.removeItem('downloadHistory');
historyNeedToBeMigration.forEach(key => {
if (typeof key !== 'string' || !/[0-9a-z]{32}/.test(key)) return;
store.setItem(key, true);
});
}
return store;
})().catch(_error);
const markAsDownloaded = (gid, title) => {
dlGidStore && dlGidStore.setItem(String(gid), true);
dlTitleStore && dlTitleStore.setItem(MD5(title.replace(/\s/g, '')), true).catch(_error);
};
const isDownloadedByGid = async gid => {
try {
return dlGidStore && (await dlGidStore.getItem(String(gid))) === true;
} catch (e) {
_error(e);
}
return false;
};
const isDownloadedByTitle = async title => {
if (!dlTitleStore) return false;
try {
const md5v2 = MD5(title.replace(/\s/g, ''));
if ((await dlTitleStore.getItem(md5v2)) === true) return true;
const md5v1 = MD5(title);
if ((await dlTitleStore.getItem(md5v1)) === true) {
dlTitleStore.setItem(md5v2, true);
dlTitleStore.removeItem(md5v1);
return true;
}
} catch (e) {
_error(e);
}
return false;
};
// 对话框
const notyConfirmOption = {
type: 'error',
layout: 'bottomRight',
theme: 'nest',
timeout: false,
closeWith: [],
};
const downloadAgainConfirm = async (title, hasQueue = false) => {
if (AUTO_CANCEL_DOWNLOADED_DOUJIN) {
downloadedTip(title, hasQueue);
return false;
}
return new Promise(resolve => {
const n = new Noty({
...notyConfirmOption,
text: `"${title}" is already downloaded${hasQueue ? ' or in queue' : ''}.<br>Do you want to download again?`,
buttons: [
Noty.button('YES', 'btn btn-noty-blue btn-noty', () => {
n.close();
resolve(true);
}),
Noty.button('NO', 'btn btn-noty-green btn-noty', () => {
n.close();
resolve(false);
}),
],
});
n.show();
});
};
const errorRetryConfirm = (action, noCb, yesCb) => {
if (AUTO_RETRY_WHEN_ERROR_OCCURS) {
errorRetryTip(action);
yesCb && yesCb();
return;
}
const n = new Noty({
...notyConfirmOption,
text: `Error occurred while ${action}, retry?`,
buttons: [
Noty.button('NO', 'btn btn-noty-blue btn-noty', () => {
n.close();
noCb && noCb();
}),
Noty.button('YES', 'btn btn-noty-green btn-noty', () => {
n.close();
yesCb && yesCb();
}),
],
});
n.show();
};
const downloadedTip = (title, hasQueue = false) => {
new Noty({
type: 'info',
layout: 'bottomRight',
theme: 'nest',
closeWith: [],
timeout: 4000,
text: `"${title}" is already downloaded${hasQueue ? ' or in queue' : ''}.`,
}).show();
};
const errorRetryTip = action => {
new Noty({
type: 'warning',
layout: 'bottomRight',
theme: 'nest',
closeWith: [],
timeout: 3000,
text: `Error occurred while ${action}, retrying...`,
}).show();
};
// 下载面板
Vue.component('download-item', {
props: ['item', 'index'],
computed: {
width() {
const { page, done, compressing, compressingPercent } = this.item;
return compressing ? compressingPercent.toFixed(2) : page && done ? ((100 * done) / page).toFixed(2) : 0;
},
canCancel() {
return !this.item.compressing;
},
},
watch: {
'item.error': error => {
if (!error || this.item.compressing) return;
errorRetryConfirm(
'downloading',
() => {
dlQueue.skipFromError();
},
() => {
this.item.error = false;
dlQueue.restartFromError();
}
);
},
},
methods: {
cancel() {
if (this.index === 0) {
dlQueue.skip = true;
} else {
const { info } = dlQueue.queue.remove(this.index);
if (info && typeof info.cancel === 'function') info.cancel();
}
},
},
template:
'<div class="download-item" :class="{ \'download-error\': item.error, \'download-compressing\': item.compressing && !item.error, \'can-cancel\': canCancel }" :title="item.title"><div class="download-item-cancel" v-if="canCancel" @click="cancel"><i class="fa fa-times"></i></div><div class="download-item-title">{{item.title}}</div><div class="download-item-progress" :style="{ width: `${width}%` }"><div class="download-item-progress-text">{{ width }}%</div></div></div>',
});
Vue.component('download-list', {
props: ['zipList', 'dlList'],
template:
'<div id="download-panel"><download-item v-for="(item, index) in zipList" :item="item" :index="index" :key="index" /><download-item v-for="(item, index) in dlList" :item="item" :index="index" :key="index" /></div>',
});
new Vue({
el: '#download-panel',
// TODO: 两个都改造
data: {
dlQueue: dlQueue.queue,
zipQueue: zipQueue.queue,
},
computed: {
zipList() {
return this.zipQueue.map(({ info }) => info);
},
dlList() {
return this.dlQueue.map(({ info }) => info);
},
infoList() {
return [...this.zipList, ...this.dlList];
},
},
created() {
window.addEventListener('beforeunload', event => {
if (!this.infoList.length) return;
event.preventDefault();
event.returnValue = '';
});
},
watch: {
infoList(val) {
sessionStorage.setItem('queueInfos', JSON.stringify(val));
},
},
template: '<download-list v-if="infoList.length" :zipList="zipList" :dlList="dlList" />',
});
// 网络请求
const get = (url, responseType = 'json', retry = 3) =>
new Promise((resolve, reject) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType,
onerror: e => {
if (retry === 0) reject(e);
else {
_warn('Network error, retry.');
setTimeout(() => {
resolve(get(url, responseType, retry - 1));
}, 1000);
}
},
onload: ({ status, response }) => {
if (status === 200) resolve(response);
else if (retry === 0) reject(new Error(`${status} ${url}`));
else {
_warn(status, url);
setTimeout(() => {
resolve(get(url, responseType, retry - 1));
}, 500);
}
},
});
} catch (error) {
reject(error);
}
});
const proxyGetJSON = url =>
get(`https://json2jsonp.com/?url=${encodeURIComponent(url)}&callback=cbfunc`, '').then(jsonp =>
JSON.parse(jsonp.replace(/^cbfunc\((.*)\)$/, '$1'))
);
const nhentaiGalleryApi = gid => {
const url = `https://nhentai.net/api/gallery/${gid}`;
return isNHentai ? get(url) : proxyGetJSON(url);
};
const getDownloadURL = isNHentai
? (mid, filename) => `https://i.nhentai.net/galleries/${mid}/${filename}`
: isNHentaiX
? (mid, filename) => `https://cdn.nhentai.xxx/g/${mid}/${filename}`
: isNHentaiTo
? (mid, filename) => `https://t.dogehls.xyz/galleries/${mid}/${filename}`
: (mid, filename) => `https://i0.mspcdn9.xyz/galleries/${mid}/${filename}`;
// 伪多线程
const multiThread = async (tasks, promiseFunc) => {
const threads = [];
let taskIndex = 0;
const run = async threadID => {
while (true) {
const i = taskIndex++;
if (i >= tasks.length) break;
await promiseFunc(tasks[i], threadID);
}
};
// 创建线程
for (let threadID = 0; threadID < THREAD; threadID++) {
await sleep(Math.min(2000 / THREAD, 300));
threads.push(run(threadID));
}
return Promise.all(threads);
};
// 获取本子信息
const getGallery = async gid => {
const gallery = unsafeWindow.gallery;
const {
id,
media_id,
title: { english, japanese, pretty },
images: { pages },
num_pages,
} = gid
? await nhentaiGalleryApi(gid)
: typeof gallery === 'undefined'
? await nhentaiGalleryApi((gid = /\/g\/([0-9]+)/.exec(window.location.pathname)[1]))
: (gid = gallery.id) && gallery;
const p = [];
(Array.isArray(pages) ? pages : Object.values(pages)).forEach((page, i) => {
p.push({
i: i + 1,
t: getExtension(page),
});
});
const info = {
gid,
mid: media_id,
title: japanese || english,
pages: p,
cfName: getTextFromTemplate(CF_NAME, {
english,
japanese: japanese || english,
pretty,
id,
pages: num_pages,
}),
};
_log(info);
return info;
};
// 下载本子
const downloadGallery = async ({ mid, pages, cfName }, $btn = null, $btnTxt = null, headTxt = false, rangeChecks) => {
if (rangeChecks && rangeChecks.length) {
pages = pages.filter(({ i }) => rangeChecks.some(check => check(i)));
}
const info = (dlQueue.queue[0] && dlQueue.queue[0].info) || {};
info.done = 0;
const zip = await new JSZip();
const btnDownloadProgress = () => {
if ($btnTxt) $btnTxt.text(`${headTxt ? `Download ${getDpDlExt()} ` : ''}${info.done}/${pages.length}`);
};
const btnCompressingProgress = (percent = 0) => {
if ($btnTxt) $btnTxt.text(`${headTxt ? 'Compressing ' : ''}${percent.toFixed()}%`);
};
btnDownloadProgress();
const dlPromise = (page, threadID) => {
if (info.error || dlQueue.skip) return;
const url = CUSTOM_DOWNLOAD_URL
? getTextFromTemplate(CUSTOM_DOWNLOAD_URL, {
mid,
index: page.i,
ext: page.t,
})
: getDownloadURL(mid, `${page.i}.${page.t}`);
_log(`[${threadID}] ${url}`);
return get(url, 'arraybuffer')
.then(async data => {
zip.file(`${String(page.i).padStart(FILENAME_LENGTH, 0)}.${page.t}`, data);
info.done++;
btnDownloadProgress();
})
.catch(e => {
info.error = true;
throw e;
});
};
await multiThread(pages, dlPromise);
if (dlQueue.skip) {
dlQueue.skip = false;
if ($btnTxt) $btnTxt.text(`${headTxt ? `Download ${getDpDlExt()} ` : ''}`);
if ($btn) $btn.attr('disabled', false);
return {
zipFn: async () => ({}),
zipInfo: null,
};
}
return {
zipFn: async () => {
info.compressing = true;
btnCompressingProgress();
_log('Start compressing', cfName);
let lastZipFile = '';
const data = await zip.generateAsync(
{
type: 'arraybuffer',
...getCompressionOptions(),
},
({ workerId, percent, currentFile }) => {
if (lastZipFile !== currentFile && currentFile) {
lastZipFile = currentFile;
_log(`[${workerId}] Compressing ${percent.toFixed(2)}%`, currentFile);
}
btnCompressingProgress(percent);
info.compressingPercent = percent;
}
);
_log('Done');
if ($btnTxt) $btnTxt.text(`${headTxt ? `Download ${getDpDlExt()} ` : ''}✓`);
if ($btn) $btn.attr('disabled', false);
return new File([data], cfName, { type: 'application/zip' });
},
zipInfo: info,
};
};
const downloadG = async (gid, $btn = null, $btnTxt = null, headTxt = '') =>
downloadGallery(await getGallery(gid), $btn, $btnTxt, headTxt);
// 语言过滤
const langFilter = (lang, $node) => {
const getNode = $node ? selector => $node.find(selector) : selector => $(selector);
if (Number(lang) === 0) getNode('.gallery').removeClass('hidden');
else {
getNode(`.gallery[data-tags~=${lang}]`).removeClass('hidden');
getNode(`.gallery:not([data-tags~=${lang}])`).addClass('hidden');
}
};
// 本子浏览模式
const applyGPViewStyle = gpViewMode => {
if (gpViewMode)
$('body').append(
`<style id="gp-view-mode-style">#image-container img{width:auto;max-width:calc(100vw - 20px);max-height:${
isNHentaiX ? '100vh' : 'calc(100vh - 65px)'
}}</style>`
);
else $('#gp-view-mode-style').remove();
};
// 功能初始化
const init = () => {
if (pageType.gallery) {
// 本子详情页
const $btnTxt = $(`<span class="download-zip-txt">Download ${getDpDlExt()}</span>`);
const $btn = $('<button class="btn btn-secondary download-zip"><i class="fa fa-download"></i> </button>').append(
$btnTxt
);
const $pagesInput = $('<input class="pages-input" placeholder="Download pages (e.g. 1-10,12,14,18-)">');
$('#info > .buttons').append($btn).after($pagesInput);
let zip, pagesInput, info;
$btn.on('click', async () => {
const rangeChecks = $pagesInput
.val()
.split(',')
.filter(range => parseInt(range))
.map(range => {
const [start, end] = range.split('-').map(num => parseInt(num));
if (typeof end === 'undefined') return page => page === start;
else if (Number.isNaN(end)) return page => page >= start;
else return page => start <= page && page <= end;
});
$btn.attr('disabled', true);
try {
if (!info) info = await getGallery();
const downloaded = (await isDownloadedByGid(info.gid)) || (await isDownloadedByTitle(info.title));
if (downloaded && !(await downloadAgainConfirm(info.title))) {
$btn.attr('disabled', false);
markAsDownloaded(info.gid, info.title);
return;
}
if (!zip || pagesInput !== $pagesInput.val()) {
zip = await (await downloadGallery(info, $btn, $btnTxt, true, rangeChecks)).zipFn();
pagesInput = $pagesInput.val();
}
if (!zip) return;
saveAs(zip);
markAsDownloaded(info.gid, info.title);
} catch (error) {
$btn.attr('disabled', false);
$btnTxt.text('Error');
_error(error);
}
});
} else if (pageType.list) {
// 语言过滤
const $langFilter = $(
'<select id="lang-filter"><option value="0">None</option><option value="29963">Chinese</option><option value="6346">Japanese</option><option value="12227">English</option></select>'
);
$('ul.menu.left').append($('<li style="padding:0 10px;user-select:none">Filter: </li>').append($langFilter));
$langFilter.on('change', function () {
langFilter(this.value);
sessionStorage.setItem('lang-filter', this.value);
});
// 本子列表页
$('.gallery').each(handleGallery);
new MutationObserver(mutations => {
mutations.forEach(({ addedNodes }) => {
addedNodes.forEach(node => {
const $node = $(node);
$node.find('.gallery').each(handleGallery);
(val => val && langFilter(val, $node))($langFilter.val());
});
});
}).observe($('#content')[0], { childList: true });
function handleGallery() {
const $gallery = $(this);
const $a = $gallery.find('a.cover');
if (OPEN_ON_NEW_TAB) $a.attr('target', '_blank');
const gid = /[0-9]+/.exec($a.attr('href'))[0];
const $btnTxt = $('<span class="download-zip-txt"></span>');
const $btn = $(
'<button class="btn btn-secondary download-zip"><i class="fa fa-download"></i> </button>'
).append($btnTxt);
$gallery.prepend($btn);
const cancel = () => {
$btn.attr('disabled', false);
$btnTxt.text('');
};
const markGalleryDownloaded = () => {
$gallery.addClass('downloaded');
};
isDownloadedByGid(gid).then(downloaded => downloaded && markGalleryDownloaded());
$btn.on('click', () => {
let skipDownloadedCheck = false;
(async function startDownload() {
$btn.attr('disabled', true);
$btnTxt.text('Wait');
if (!skipDownloadedCheck && (await isDownloadedByGid(gid))) {
const title = $gallery.find('.caption').text();
if (!(await downloadAgainConfirm(title))) {
cancel();
return;
}
skipDownloadedCheck = true;
}
let gallery;
try {
gallery = await getGallery(gid);
} catch (e) {
_error(e);
$btn.attr('disabled', false);
$btnTxt.text('Error');
errorRetryConfirm('getting information', null, startDownload);
}
if (
!skipDownloadedCheck &&
((await isDownloadedByTitle(gallery.title)) ||
dlQueue.queue.some(({ info: { title } }) => title === gallery.title))
) {
if (!(await downloadAgainConfirm(gallery.title, true))) {
cancel();
markAsDownloaded(gid, gallery.title);
markGalleryDownloaded();
return;
}
}
dlQueue.push(
async () => {
const { zipFn, zipInfo } = await downloadGallery(gallery, $btn, $btnTxt);
if (zipInfo) {
zipQueue.push(async () => {
const zip = await zipFn();
if (!zip) {
cancel();
return;
}
saveAs(zip);
markAsDownloaded(gid, gallery.title);
markGalleryDownloaded();
}, zipInfo);
zipQueue.start();
}
},
{
gid,
title: gallery.title,
page: gallery.pages.length,
done: 0,
error: false,
compressing: false,
compressingPercent: 0,
cancel,
}
);
dlQueue.start();
})();
});
}
// 左右键翻页
$(document).on('keydown', event => {
switch (event.key) {
case 'ArrowLeft':
$('.pagination .previous').trigger('click');
break;
case 'ArrowRight':
$('.pagination .next').trigger('click');
break;
}
});
// 还原记住的语言过滤
const rememberedLANG = sessionStorage.getItem('lang-filter');
if (rememberedLANG) {
$langFilter.val(rememberedLANG);
langFilter(rememberedLANG);
}
// 还原下载队列
const dlQueueInfos = JSON.parse(sessionStorage.getItem('queueInfos'));
if (dlQueueInfos) {
for (const info of dlQueueInfos) {
const { gid, title } = info;
dlQueue.push(async () => {
const { zipFn, zipInfo } = await downloadG(gid);
if (zipInfo) {
zipQueue.push(async () => {
const zip = await zipFn();
if (!zip) return;
saveAs(zip);
markAsDownloaded(gid, title);
}, zipInfo);
zipQueue.start();
}
}, info);
}
}
dlQueue.start();
} else if (pageType.galleryPage && !isNHentai) {
// 本子在线阅读
const gpViewModeText = ['[off]', '[on]'];
let gpViewMode = GM_getValue('gp_view_mode', 0);
applyGPViewStyle(gpViewMode);
$('#page-container').prepend(
`<button id="gp-view-mode-btn" class="btn btn-secondary"><i class="fa fa-arrows-v"></i> <span>100% view height</span> <span id="gp-view-mode-switch-text">${gpViewModeText[gpViewMode]}</span></button>`
);
const $gpvmst = $('#gp-view-mode-switch-text');
$('#gp-view-mode-btn').on('click', () => {
gpViewMode = 1 - gpViewMode;
GM_setValue('gp_view_mode', gpViewMode);
$gpvmst.text(gpViewModeText[gpViewMode]);
applyGPViewStyle(gpViewMode);
});
}
};
$(document).pjax('.pagination a, .sort a', {
container: '#content',
fragment: '#content',
timeout: 10000,
});
$(document).on('pjax:end', () => {
// 防止翻页出现 pjax 参数
$('.pagination a').each(function () {
const $this = $(this);
const href = $this.attr('href');
const isRelative = href.startsWith('/');
const url = new URL(isRelative ? `${location.protocol}//${location.host}${href}` : href);
url.searchParams.delete('_pjax');
$this.attr('href', isRelative ? `${url.pathname}${url.search}` : url.href);
});
// 加载 lazyload 图片
const { App } = unsafeWindow;
if (typeof App !== 'undefined') App.prototype.install_lazy_loader();
});
init();
})();