// ==UserScript==
// @name nHentai Downloader
// @name:vi nHentai Downloader
// @name:zh-CN nHentai 下载器
// @name:zh-TW nHentai 下载器
// @namespace http://devs.forumvi.com
// @description Download manga on nHentai.
// @description:vi Tải truyện tranh tại NhệnTái.
// @description:zh-CN 在nHentai上下载漫画。
// @description:zh-TW 在nHentai上下载漫画。
// @version 3.2.1
// @icon http://i.imgur.com/FAsQ4vZ.png
// @author Zzbaivong
// @oujs:author baivong
// @license MIT; https://baivong.mit-license.org/license.txt
// @match http://nhentai.net/g/*
// @match https://nhentai.net/g/*
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @require https://cdn.jsdelivr.net/npm/web-streams-polyfill@3.2.1/dist/ponyfill.min.js
// @require https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/StreamSaver.min.js
// @require https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/examples/zip-stream.js
// @require https://greasyfork.org/scripts/28536-gm-config/code/GM_config.js?version=184529
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?v=a834d46
// @noframes
// @connect self
// @supportURL https://github.com/lelinhtinh/Userscript/issues
// @run-at document-idle
// @grant GM.xmlHttpRequest
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.getValue
// @grant GM.setValue
// ==/UserScript==
/* global streamSaver, ZIP */
(($, window) => {
'use strict';
const configFrame = document.createElement('div'),
$infoBlock = $('#info-block');
$infoBlock.append(configFrame);
$infoBlock.append(
'<p style="text-align:left;padding:0 10px;color:#ff7600"><i class="fa fa-exclamation-triangle"></i> Enable 3rd-party cookies to allow streaming downloads.</p>',
);
GM_config.init({
id: 'nHentaiDlConfig',
title: 'Downloader Settings',
fields: {
outputExt: {
options: ['cbz', 'zip'],
label: 'Export as',
type: 'radio',
default: 'cbz',
},
outputName: {
label: 'Filename',
type: 'select',
options: ['pretty', 'english', 'japanese'],
default: 'pretty',
},
threading: {
label: 'Max. conn. number',
type: 'unsigned int',
min: 1,
max: 16,
default: 4,
},
hideTorrentBtn: {
label: 'Hide the download torrent button',
type: 'checkbox',
default: false,
},
useProxy: {
label: 'Use DuckDuckGo proxy',
type: 'checkbox',
default: false,
},
},
frame: configFrame,
events: {
save: () => {
outputExt = GM_config.get('outputExt');
outputName = GM_config.get('outputName');
threading = GM_config.get('threading');
hideTorrentBtn = GM_config.get('hideTorrentBtn');
useProxy = GM_config.get('useProxy');
$download.find('span').text(` as ${outputExt.toUpperCase()}`);
$_download.toggle(!hideTorrentBtn);
const $saveBtn = $('#nHentaiDlConfig_saveBtn');
$saveBtn.prop('disabled', true).addClass('saved').text('Saved!');
setTimeout(() => {
$saveBtn.prop('disabled', false).removeClass('saved').text('Save');
}, 1500);
},
},
css: '#nHentaiDlConfig{width:100%!important;position:initial!important;padding:10px!important;background:#0d0d0d;border:1px solid #313131!important;border-radius:5px;text-align:left}#nHentaiDlConfig *{font-family:"Noto Sans",sans-serif}#nHentaiDlConfig .config_header{text-align:left;font-size:17px;font-weight:700;margin-bottom:20px;color:#999}#nHentaiDlConfig .reset_holder{float:left;height:30px;line-height:30px}#nHentaiDlConfig .reset{color:#4d4d4d;text-align:left}#nHentaiDlConfig .saveclose_buttons{margin:0;padding:4px;min-width:100px;height:30px;line-height:14px;border-radius:2px;border:1px solid;cursor:pointer}#nHentaiDlConfig .saveclose_buttons.saved{background:#ffeb3b;border:1px solid #ffc107}#nHentaiDlConfig #nHentaiDlConfig_closeBtn{display:none}#nHentaiDlConfig_buttons_holder{margin-top:20px;border-top:1px dashed #4d4d4d;padding-top:11px}#nHentaiDlConfig .config_var::after{clear:both;content:"";display:block}#nHentaiDlConfig .config_var{position:relative}#nHentaiDlConfig .field_label{font-size:14px;height:26px;line-height:26px;margin:0;padding:0 10px 0 0;width:60%;display:block;float:left}#nHentaiDlConfig .config_var>[type=text],#nHentaiDlConfig .config_var>div,#nHentaiDlConfig .config_var>select,#nHentaiDlConfig .config_var>textarea{width:40%;border-radius:0;display:block;height:26px;line-height:26px;padding:0 10px;float:left}#nHentaiDlConfig .config_var>textarea{height:auto;line-height:14px;padding:10px;min-height:5em}#nHentaiDlConfig .config_var>select{background:#4d4d4d;color:#d9d9d9;padding:0}#nHentaiDlConfig .config_var>select:hover{background:#666}#nHentaiDlConfig .config_var>select:focus{outline:0 none}#nHentaiDlConfig .config_var>div>label{display:inline-block;vertical-align:top;margin-right:5px}#nHentaiDlConfig .config_var>#nHentaiDlConfig_field_outputName{width:150px;text-transform:capitalize}#nHentaiDlConfig .config_var>#nHentaiDlConfig_field_threading{width:70px}#nHentaiDlConfig .config_var>div{padding:0}#nHentaiDlConfig_field_outputExt{text-transform:uppercase}#nHentaiDlConfig_field_outputExt [value=cbz]{margin-right:20px!important}',
});
/**
* Output extension
* @type {'cbz'|'zip'}
*
* Tips: Convert .zip to .cbz
* Windows
* $ ren *.zip *.cbz
* Linux
* $ rename 's/\.zip$/\.cbz/' *.zip
*/
let outputExt = GM_config.get('outputExt') || 'cbz';
/**
* File name
* @type {'pretty'|'english'|'japanese'}
*/
let outputName = GM_config.get('outputName') || 'pretty';
/**
* Multithreading
* @type {Number} [1 -> 16]
*/
let threading = GM_config.get('threading') || 4;
/**
* Hide Torrent Download button
* @type {Boolean}
*/
let hideTorrentBtn = GM_config.get('hideTorrentBtn') || false;
/**
* Use proxy from DuckDuckGo
* @type {Boolean}
*/
let useProxy = GM_config.get('useProxy') || false;
/**
* Logging
* @type {Boolean}
*/
let debug = false;
const _console = window.console;
const _time = window.console.time;
const _timeEnd = window.console.timeEnd;
const log = (...arg) => {
if (!debug) return;
_console.log(arg);
};
window.console = {
log: () => null,
clear: () => null,
};
function base64toBlob(base64Data, contentType) {
contentType = contentType || '';
const sliceSize = 1024;
const byteCharacters = atob(base64Data);
const bytesLength = byteCharacters.length;
const slicesCount = Math.ceil(bytesLength / sliceSize);
const byteArrays = new Array(slicesCount);
for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
const begin = sliceIndex * sliceSize;
const end = Math.min(begin + sliceSize, bytesLength);
const bytes = new Array(end - begin);
for (let offset = begin, i = 0; offset < end; ++i, ++offset) {
bytes[i] = byteCharacters[offset].charCodeAt(0);
}
byteArrays[sliceIndex] = new Uint8Array(bytes);
}
return new Blob(byteArrays, { type: contentType });
}
function getInfo() {
let info = '',
tags = [],
artists = [],
groups = [],
parodies = [],
characters = [],
categories = [],
languages = [];
if (gallery.title.english) info += gallery.title.english + '\r\n';
if (gallery.title.japanese) info += gallery.title.japanese + '\r\n';
if (gallery.title.pretty) info += gallery.title.pretty + '\r\n';
info += '#' + gallery.id + '\r\n';
if (gallery.tags) {
for (const tag of gallery.tags) {
if (tag.type === 'tag') tags.push(tag.name);
if (tag.type === 'artist') artists.push(tag.name);
if (tag.type === 'category') categories.push(tag.name);
if (tag.type === 'group') groups.push(tag.name);
if (tag.type === 'parody') parodies.push(tag.name);
if (tag.type === 'character') characters.push(tag.name);
if (tag.type === 'language') languages.push(tag.name);
}
}
if (tags.length) info += '\r\n' + 'Tags: ' + tags.join(', ');
if (categories.length) info += '\r\n' + 'Categories: ' + categories.join(', ');
if (groups.length) info += '\r\n' + 'Groups: ' + groups.join(', ');
if (parodies.length) info += '\r\n' + 'Parodies: ' + parodies.join(', ');
if (characters.length) info += '\r\n' + 'Characters: ' + characters.join(', ');
if (languages.length) info += '\r\n' + 'Languages: ' + languages.join(', ');
info += '\r\n\r\n' + 'Pages: ' + total;
info += '\r\n' + 'Uploaded at: ' + new Date(gallery.upload_date * 1000).toLocaleString() + '\r\n';
log(info);
return info;
}
function beforeleaving(e) {
e.preventDefault();
e.returnValue = '';
}
function end() {
window.removeEventListener('beforeunload', beforeleaving);
if (debug) _timeEnd('nHentai');
}
function done(filename) {
doc.title = `[⇓] ${filename}`;
log('COMPLETE');
end();
}
function genZip(ctrl) {
ctrl.close();
$download.html('<i class="fa fa-check"></i> Complete').css('backgroundColor', hasErr ? 'red' : 'green');
}
function dlImgError(current, success, error, err, filename) {
if (images[current].attempt < 1) {
final++;
error(err, filename);
return;
}
setTimeout(() => {
log(filename, `retry ${images[current].attempt}`);
dlImg(current, success, error);
images[current].attempt--;
}, 2000);
}
function dlImg(current, success, error) {
let url = images[current].url,
filename = url.replace(/.*\//g, '');
if (useProxy) url = `https://proxy.duckduckgo.com/iu/?u=${url}&f=1`;
filename = `000${filename}`.slice(-8);
log(filename, 'progress');
GM.xmlHttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: (response) => {
if (
response.response.type === 'text/html' ||
response.response.byteLength < 1000 ||
(response.statusText !== 'OK' && response.statusText !== '')
) {
dlImgError(current, success, error, response, filename);
return;
}
final++;
success(response, filename);
},
onerror: (err) => {
dlImgError(current, success, error, err, filename);
},
});
}
function next(ctrl) {
doc.title = `[${final}/${total}] ${comicName}`;
$download.find('strong').text(`${final}/${total}`);
log(final, current);
if (final < current) return;
final < total ? addZip(ctrl) : genZip(ctrl);
}
function addZip(ctrl) {
let max = current + threading;
if (max > total) max = total;
for (current; current < max; current++) {
log(images[current].url, 'download');
dlImg(
current,
(response, filename) => {
ctrl.enqueue({ name: filename, stream: () => response.response.stream() });
log(filename, 'success');
next(ctrl);
},
(err, filename) => {
hasErr = true;
const errGif = base64toBlob('R0lGODdhBQAFAIACAAAAAP/eACwAAAAABQAFAAACCIwPkWerClIBADs=', 'image/gif');
ctrl.enqueue({ name: `${filename}_error.gif`, stream: () => errGif.stream() });
$download.css('backgroundColor', '#FF7F7F');
log(err, 'error');
next(ctrl);
},
);
}
log(current, 'current');
}
const gallery = JSON.parse(JSON.stringify(window._gallery));
log(gallery, 'gallery');
if (!gallery) return;
let current = 0,
final = 0,
total = gallery.num_pages,
images = gallery.images.pages,
hasErr = false,
$_download = $('#download-torrent, #download'),
$download,
$config,
$configPanel,
doc = document,
comicId = gallery.id,
comicName = gallery.title[outputName] || gallery.title['english'],
zipName = `${comicName
.replace(/[\s|+=]+/g, '-')
.replace(/[:;`'"”“/\\?.,<>[\]{}!@#$%^&*]/g, '')
.replace(/・/g, '·')}.${comicId}.${outputExt}`,
readableStream,
writableStream,
inProgress = false;
if (!$_download.length) return;
GM_config.open();
$configPanel = $('#nHentaiDlConfig');
$download = $_download.clone();
$download.removeAttr('id');
$download.removeClass('btn-disabled');
$download.attr('href', '#download');
$download.find('.top').html('No login required<br>No sign up required<i></i>');
$download.append(`<span> as ${outputExt.toUpperCase()}</span>`);
$download.insertAfter($_download);
$download.before('\n');
$download.css('backgroundColor', 'cornflowerblue').on('click', (e) => {
e.preventDefault();
if (inProgress) return;
inProgress = true;
if (debug) _time('nHentai');
log({ outputExt, outputName, threading });
if (threading < 1) threading = 1;
if (threading > 16) threading = 16;
doc.title = `[⇣] ${comicName}`;
window.addEventListener('beforeunload', beforeleaving);
$download
.html('<i class="fa fa-spinner fa-spin"></i> <strong>Waiting...</strong>')
.css('backgroundColor', 'orange');
images = images.map((img, index) => {
return {
url: `https://i.nhentai.net/galleries/${gallery.media_id}/${index + 1}.${
{ j: 'jpg', p: 'png', g: 'gif' }[img.t]
}`,
attempt: 3,
};
});
log(images, 'images');
streamSaver.mitm = 'https://lelinhtinh.github.io/stream/mitm.html';
writableStream = streamSaver.createWriteStream(zipName);
const info = new Blob([getInfo()]);
readableStream = new ZIP({
start(ctrl) {
ctrl.enqueue({
name: 'info.txt',
stream: () => info.stream(),
});
},
pull(ctrl) {
addZip(ctrl);
},
});
if (window.WritableStream && readableStream.pipeTo) {
readableStream.pipeTo(writableStream).then(() => {
done(comicName);
});
} else {
const writer = writableStream.getWriter();
const reader = readableStream.getReader();
const pump = () => reader.read().then((res) => (res.done ? writer.close() : writer.write(res.value).then(pump)));
pump().then(() => done(comicName));
}
});
$configPanel.toggle();
$config = $_download.clone();
$config.removeAttr('id');
$config.removeClass('btn-disabled');
$config.attr('href', '#settings');
$config.css('min-width', '40px');
$config.html('<i class="fa fa-cog"></i><div class="top">Toggle settings<i></i></div>');
$config.insertAfter($download);
$config.on('click', (e) => {
e.preventDefault();
$configPanel.toggle('fast');
});
if (hideTorrentBtn) $_download.hide();
})(jQuery, unsafeWindow);