NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name NHentai Konnichiwa
// @author naiymu
// @version 1.0.1
// @license MIT; https://raw.githubusercontent.com/naiymu/nhentai-konnichiwa/main/LICENSE
// @namespace https://github.com/naiymu/nhentai-konnichiwa
// @homepage https://github.com/naiymu/nhentai-konnichiwa
// @downloadURL https://github.com/naiymu/nhentai-konnichiwa/raw/main/nhentai-konnichiwa.user.js
// @updateURL https://github.com/naiymu/nhentai-konnichiwa/raw/main/nhentai-konnichiwa.user.js
// @supportURL https://github.com/naiymu/nhentai-konnichiwa/issues
// @description A simple usercript for downloading doujinshi from NHentai and mirrors
// @match https://nhentai.net/*
// @match https://nhentai.xxx/*
// @match https://nyahentai.red/*
// @match https://nhentai.to/*
// @match https://nhentai.website/*
// @connect nhentai.net
// @connect i3.nhentai.net
// @connect cdn.nhentai.xxx
// @connect nhentai.com
// @connect t.dogehls.xyz
// @grant GM_addStyle
// @grant GM_download
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// @icon https://raw.githubusercontent.com/naiymu/nhentai-konnichiwa/main/assets/icon.png
// ==/UserScript==
GM_addStyle (
`
.relative {
position: relative !important;
}
.download-check {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
height: 20px;
width: 20px;
accent-color: #ed2553;
}
.download-check:focus,
.download-check:hover {
box-shadow: 0 0 10px 0 #ed2553;
}
.red-box {
background-color: #ed2553;
color: white;
border: none;
outline: none;
font-size: 16px;
width: 50px;
height: 35px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.check-center {
display: flex!important;
align-items: center!important;
justify-content: center!important;
text-align: center;
}
.red-box {
cursor: pointer;
}
.red-box:hover {
background-color: #4d4d4d;
}
.red-box>i,
.red-box>.download-check {
margin-right: 5px;
}
.red-box>.download-check {
accent-color: #1f1f1f;
}
.red-box>.download-check:focus,
.red-box>.download-check:hover {
box-shadow: none;
}
.red-box:disabled {
background-color: #1f1f1f;
cursor: default;
}
.download-div {
position: fixed;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.div-horizontal {
flex-direction: row !important;
}
.fa-spinner,
.fa-circle-notch {
animation: spin 1.5s infinite linear;
}
@keyframes spin {
100% {transform: rotate(359deg)};
}
.red-box>.download-check {
position: relative;
}
.config-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 999999;
visibility: hidden;
}
.visible {
visibility: visible;
}
.config-div {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
max-width: 850px;
max-height: 480px;
padding: 50px;
transform: translate(-50%, -50%);
background-color: #1f1f1f;
border-radius: 5px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
overflow: scroll;
}
.config-element-div {
width: 100%;
display: inline-grid;
grid: auto / 200px auto;
margin-bottom: 15px
}
.config-element-div>input {
color: #000 !important;
}
.config-element-div>label {
grid-column-start: 1;
}
.config-element-div>input[type='checkbox'] {
justify-self: left;
}
.config-element-div>*:not(label) {
grid-column-start: 2;
border-radius: 0;
border: none;
background-color: #d9d9d9 !important;
}
.config-btn-div {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
}
.config-reset:hover {
cursor: pointer;
color: #ed2553;
}
.heading {
width: 100%;
border-bottom: solid 1px #d9d9d9;
text-align: left;
}
`
);
const apis = {
net: "https://nhentai.net/api/gallery/",
com: "https://nhentai.com/api/comics/",
}
const mediaUrls = {
net: "https://i3.nhentai.net/galleries/",
xxx: "https://cdn.nhentai.xxx/g/",
hls: "https://t.dogehls.xyz/galleries/",
com: "https://cdn.nhentai.com/nhentai/storage/images/",
}
const btnStates = {
enabled: "<i class='fa fa-download'></i>",
fetching: "<i id='btn-spinner' class='fa fa-spinner'></i>",
downloading: "<i id='btn-spinner' class='fa fa-circle-notch'></i>",
config: "<i class='fa fa-cog'></i>",
};
const methods = {
NET: "NET",
COM: "COM"
};
const titleFormats = {
PR: "pretty",
EN: "english",
JP: "japanese",
ID: "id",
}
const saveJSONModes = {
NO: "Don't save",
FI: "Save as JSON file",
CB: "Copy to clipboard",
}
const fileNameSeps = {
SP: "Space",
HY: "Hyphen",
US: "Underscore",
}
const btnOrientations = {
VR: "Vertical",
HR: "Horizontal",
}
const CONFIG = {
method: {
label: 'Domain to use',
type: 'select',
options: [methods.NET,methods.COM],
default: methods.NET,
},
simulN: {
label: 'Download batch size',
type: 'int',
min: 1,
max: 50,
default: 10,
},
titleFormat: {
label: 'Title format',
type: 'select',
options: [titleFormats.PR,titleFormats.EN,titleFormats.JP,
titleFormats.ID],
default: titleFormats.PR,
},
fileNamePrep: {
label: 'Filename to prepend',
type: 'text',
size: 20,
default: "",
},
fileNameSep: {
label: 'Filename separator',
type: 'select',
options: [fileNameSeps.SP,fileNameSeps.HY,fileNameSeps.US],
default: fileNameSeps.SP,
},
saveJSONMode: {
label: 'Save JSON',
type: 'select',
options: [saveJSONModes.NO,saveJSONModes.FI,saveJSONModes.CB],
default: saveJSONModes.NO,
},
includeGroups: {
label: 'Include groups in authors',
type: 'checkbox',
default: false,
},
btnOrientation: {
label: 'Button orientation',
type: 'select',
options: [btnOrientations.VR, btnOrientations.HR],
default: btnOrientations.VR,
}
}
// Config Options saved after download button click
var method, simulN, includeGroups, titleFormat,
saveJSONMode, fileNamePrep, fileNameSep, btnOrientation;
// Buttons and Divs
var downloadDiv, downloadBtn, configBtn, checkAllDiv, checkAll,
configWrapper, configDiv, saveConfigBtn, closeConfigBtn,
resetConfigAnchor, clearDownloadBtn;
// Download info
var currentDownloads = 0;
var info = JSON.parse(GM_getValue('info') || '[]');
var queue = JSON.parse(GM_getValue('queue') || '[]');
// Config options
var configOptions = JSON.parse(GM_getValue('configOptions') || '{}');
function disableButton(btnStatus) {
downloadBtn.disabled = true;
downloadBtn.innerHTML = btnStatus;
}
function enableButton() {
downloadBtn.disabled = false;
downloadBtn.innerHTML = btnStates.enabled;
}
function createNode(element, classes=[]) {
var node = document.createElement(element);
for(let cls of classes) {
node.classList.add(cls);
}
return node;
}
function createLabel(id, text) {
var label = createNode('label');
label.innerHTML = text;
label.setAttribute('for', id);
return label;
}
function getExtension(type) {
switch(type) {
case 'j': return '.jpg';
case 'p': return '.png';
case 'g': return '.gif';
}
}
function updateConfig() {
method = configOptions.method;
simulN = configOptions.simulN;
titleFormat = configOptions.titleFormat;
includeGroups = configOptions.includeGroups;
saveJSONMode = configOptions.saveJSONMode;
fileNamePrep = configOptions.fileNamePrep;
fileNameSep = configOptions.fileNameSep;
btnOrientation = configOptions.btnOrientation;
}
function saveConfig(reset=false) {
for(const [key, value] of Object.entries(CONFIG)) {
var element = document.getElementById(`config-${key}`);
var configValue;
switch(value.type) {
case 'checkbox':
configValue = element.checked;
break;
case 'int':
configValue = element.value;
if(value.max && configValue > value.max) {
configValue = value.max;
}
if(value.min && configValue < value.min) {
configValue = value.min;
}
break;
default:
configValue = element.value;
}
configValue = reset ? value.default : configValue;
configOptions[key] = configValue;
element.value = configValue;
if(value.type == 'checkbox') element.checked = configValue;
}
if(configOptions.btnOrientation == btnOrientations.HR) {
downloadDiv.classList.add('div-horizontal');
}
else {
downloadDiv.classList.remove('div-horizontal');
}
GM_setValue('configOptions', JSON.stringify(configOptions));
updateConfig();
}
function addConfigMenu() {
var heading = createNode('h3', ['heading']);
heading.innerHTML = "nhentai-konnichiwa";
configDiv.appendChild(heading);
for(const [key, value] of Object.entries(CONFIG)) {
var div = createNode('div', ['config-element-div']);
var element, id, label;
switch(value.type) {
case 'select':
element = createNode('select');
for(let i=0; i<value.options.length; i++) {
var optionElement = createNode('option');
var option = value.options[i];
optionElement.text = option;
optionElement.value = option;
element.appendChild(optionElement);
}
break;
case 'int':
element = createNode('input');
element.type = 'number';
if(value.min != undefined) {
element.min = value.min;
}
if(value.max != undefined) {
element.max = value.max;
}
break;
case 'checkbox':
element = createNode('input');
element.type = 'checkbox';
break;
case 'text':
element = createNode('input');
element.type = 'text';
element.maxLength = value.size;
element.defaultValue = value.default;
break;
}
id = `config-${key}`;
element.id = id;
var configValue = configOptions[key]
? configOptions[key]
: value.default;
if(value.type == 'checkbox') {
element.checked = configValue;
}
else {
element.value = configValue;
}
// If key does not exist in configOptions, add it
if(!configOptions[key]) {
configOptions[key] = configValue;
}
label = createLabel(id, value.label);
div.appendChild(label);
div.appendChild(element);
configDiv.appendChild(div);
updateConfig();
}
var btnDiv = createNode('div', ['config-btn-div']);
saveConfigBtn = createNode('button', ['red-box']);
saveConfigBtn.innerHTML = "Save";
saveConfigBtn.addEventListener('click', () => {
saveConfig();
});
closeConfigBtn = createNode('button', ['red-box']);
closeConfigBtn.innerHTML = "Close";
closeConfigBtn.addEventListener('click', () => {
configWrapper.classList.remove('visible');
});
clearDownloadBtn = createNode('a', ['config-reset']);
clearDownloadBtn.innerHTML = "Clear and stop downloads";
clearDownloadBtn.addEventListener('click', () => {
queue = [];
GM_setValue('queue', JSON.stringify(queue));
});
resetConfigAnchor = createNode('a', ['config-reset']);
resetConfigAnchor.innerHTML = 'Reset to default';
resetConfigAnchor.addEventListener('click', () => {
saveConfig(true);
});
btnDiv.appendChild(saveConfigBtn);
btnDiv.appendChild(closeConfigBtn);
btnDiv.appendChild(clearDownloadBtn);
btnDiv.appendChild(resetConfigAnchor);
configDiv.appendChild(btnDiv);
}
(async function() {
'use strict';
configWrapper = createNode('div', ['config-wrapper']);
configDiv = createNode('div', ['config-div']);
configWrapper.addEventListener('click', () => {
if(event.target != configWrapper) {
return;
}
configWrapper.classList.remove('visible');
});
configWrapper.appendChild(configDiv);
addConfigMenu();
var aList = document.querySelectorAll(".gallery > a, #cover > a");
for(let a of aList) {
var ref = a.href;
var parent = a.parentElement;
var code;
code = ref.split("/g/")[1];
if(code.endsWith("/")) {
code = code.split("/")[0];
}
var check = createNode('input', ['download-check']);
check.type = 'checkbox';
check.value = code;
parent.classList.add("relative");
parent.appendChild(check);
}
let classes = ['download-div'];
if(configOptions.btnOrientation == btnOrientations.HR) {
classes.push('div-horizontal');
}
downloadDiv = createNode('div', classes);
downloadBtn = createNode('button', ['red-box']);
enableButton();
configBtn = createNode('button', ['red-box']);
configBtn.innerHTML = btnStates.config;
configBtn.addEventListener("click", (event) => {
configWrapper.classList.add('visible');
});
checkAllDiv = createNode('div', ['red-box', 'relative']);
checkAll = createNode('input', ['download-check']);
checkAll.type = 'checkbox';
checkAll.addEventListener('change', () => {
let toCheck = checkAll.checked;
let boxes = document.querySelectorAll('.download-check');
for(let box of boxes) {
if(box === checkAll) {
continue;
}
box.checked = toCheck;
}
});
checkAllDiv.appendChild(checkAll);
downloadBtn.addEventListener("click", async () => {
updateConfig();
if(info.length > 0) {
info = [];
GM_setValue('info', JSON.stringify(info));
}
switch(fileNameSep) {
case fileNameSeps.SP: fileNameSep = " "; break;
case fileNameSeps.HY: fileNameSep = "-"; break;
case fileNameSeps.US: fileNameSep = "_"; break;
}
var checked = document.querySelectorAll(".download-check:checked");
if(checked.length > 0) {
disableButton(btnStates.fetching);
}
else {
return;
}
for(const c of checked) {
var code = c.getAttribute("value");
await addInfo(code);
GM_setValue('info', JSON.stringify(info));
}
if(method == methods.COM) {
await comPopulateQueue();
}
else {
await netPopulateQueue();
}
GM_setValue('queue', JSON.stringify(queue));
disableButton(btnStates.downloading);
downloadQueue();
});
downloadDiv.appendChild(downloadBtn);
downloadDiv.appendChild(configBtn);
downloadDiv.appendChild(checkAllDiv);
document.body.appendChild(downloadDiv);
document.body.appendChild(configWrapper);
if(queue.length > 0) {
disableButton(btnStates.downloading);
downloadQueue();
}
})();
async function addInfo(code) {
var apiUrl = apis.net + code;
var res = await makeGetRequest(apiUrl);
var obj = JSON.parse(res.responseText);
var title;
if(titleFormat == 'id') {
title = `${obj.id}`;
}
else {
title = obj.title[titleFormat];
title = title.replace(/\.+$/, "");
title = title.replace(/^\.+/, "");
}
var pages = obj.num_pages;
var tagList = obj.tags;
var artists = [];
var tags = [];
for(let i=0; i<tagList.length; i++) {
let tagItem = tagList[i];
if(tagItem.type == "artist"
|| (includeGroups && tagItem.type == "group")) {
artists.push(tagItem.name);
}
if(tagItem.type == "tag") {
tags.push(tagItem.name);
}
}
var mediaPrefix, mediaUrl;
var host = location.hostname;
if(host == 'nhentai.xxx' || host == 'nyahentai.red') {
mediaPrefix = mediaUrls.xxx;
}
else if(host == 'nhentai.to' || host == 'nhentai.website') {
mediaPrefix = mediaUrls.hls;
}
else {
mediaPrefix = mediaUrls.net;
}
mediaUrl = `${mediaPrefix}${obj.media_id}/`;
const constTitleExists = info.some(el => el.title === title);
if(constTitleExists) {
title += " - "+code;
}
fileNamePrep = fileNamePrep.trim();
var namePrep = "";
if(fileNamePrep != "") {
namePrep = fileNamePrep + fileNameSep;
}
var coverExtension = getExtension(obj.images.pages[0].t);
await info.push({
code: code,
title: title,
artists: artists,
tags: tags,
pages: pages,
mediaUrl: mediaUrl,
namePrep: namePrep,
coverExtension: coverExtension,
pagesInfo: obj.images.pages,
});
}
async function addToQueue(item, mediaId=null) {
var pages = item.pages;
var title = item.title;
var mediaUrl = item.mediaUrl;
var namePrep = item.namePrep;
for(let i=0; i<pages; i++) {
var extension = getExtension(item.pagesInfo[i].t);
let page = i + 1;
var imgUrl;
if(mediaId != null) {
imgUrl = `${mediaUrls.com}${mediaId}/${page}${extension}`;
}
else {
imgUrl = `${mediaUrl}${page}${extension}`;
}
await queue.push({
page: page,
url: imgUrl,
title: title,
mediaUrl:mediaUrl,
namePrep: namePrep,
extension: extension,
});
}
}
async function netPopulateQueue() {
for(const item of info) {
await addToQueue(item);
}
}
async function makeGetRequest(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: (response) => {
resolve(response);
},
onerror: (error) => {
reject(error);
}
})
});
}
async function comGetMediaUrl(item, slug) {
try {
var apiUrl = apis.com + slug;
const response = await makeGetRequest(apiUrl);
var obj = JSON.parse(response.responseText);
var mediaId = obj.id;
await addToQueue(item, mediaId);
}
catch(error) {
console.error("apis.com request failed with error code"
+error.status
+". Message is "
+error.responseText);
}
}
async function comInitXHR(item) {
try {
var code = item.code;
var url = `https://nhentai.com/g/${code}`;
const response = await makeGetRequest(url);
var slug = response.finalUrl.replace(/\/$/, "");
slug = slug.split("/").pop();
if(slug == "404") {
console.warn(item.title
+" not found on nhentai.com. "
+"Falling back to"
+location.hostname);
await addToQueue(item);
return;
}
await comGetMediaUrl(item, slug);
}
catch(error) {
console.error("comSlug request failed with error code"
+error.status
+". Message is "
+error.responseText);
}
}
async function comPopulateQueue() {
for(const item of info) {
await comInitXHR(item);
}
}
async function downloadQueue() {
while(queue.length > 0) {
if(currentDownloads >= simulN) {
await sleep(125);
continue;
}
var item = queue.shift();
GM_setValue('queue', JSON.stringify(queue));
download(item);
}
}
function saveJSON() {
var data = [];
for(var item of info) {
data.push({
dirName: item.title,
dirCover: `${item.namePrep}1${item.coverExtension}`,
authors: item.artists,
tags: item.tags,
});
}
var fileContent = {
'directories': data
}
fileContent = JSON.stringify(fileContent, null, 2);
if(saveJSONMode == saveJSONModes.CB) {
GM_setClipboard(fileContent, 'text');
}
else {
var blob = new Blob([fileContent],
{type: 'application/json'});
var jsonUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
var fileName = `${Date.now()}.json`;
a.download = fileName;
a.href = jsonUrl;
a.click();
}
}
function download(item) {
const fileName = `${item.title}/${item.namePrep}${item.page}${item.extension}`;
GM_download({
url: item.url,
name: fileName,
saveAs: false,
onerror: (error, details) => {
// Could not download. Try again with NET
if(method != methods.NET) {
item.url = `${item.mediaUrl}${item.page}${item.extension}`;
}
queue.unshift(item);
GM_setValue('queue', JSON.stringify(queue));
currentDownloads--;
},
onload: () => {
currentDownloads--;
if(queue.length == 0 && currentDownloads == 0) {
enableButton();
if(saveJSONMode != saveJSONModes.NO) {
saveJSON();
}
}
},
});
currentDownloads++;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}