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)); }