Sleazy Fork is available in English.
A simple usercript for downloading doujinshi from NHentai and mirrors
// ==UserScript== // @name NHentai Konnichiwa // @author naiymu // @version 1.1.12 // @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 // @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/* // @exclude /https:\/\/n.*hentai.(red|net|xxx|to|website)\/g\/[0-9]*\/[0-9]+\/?$/ // @connect nhentai.xxx // @connect cdn.nload.xyz // @connect i3.nhentai.net // @connect cdn.nhentai.xxx // @connect nhentai.com // @connect t.dogehls.xyz // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.0/jszip.min.js // @require https://unpkg.com/[email protected]/dist/umd/comlink.min.js // @grant GM_addStyle // @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 { position: relative; background-color: #ed2553; color: white; border: none; outline: none; font-size: 16px; width: 80px; height: 35px; border-radius: 5px; display: flex; align-items: center; justify-content: center; text-align: center; cursor: pointer; } .percent { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); } .downloading-span, .compressing-span { display: inline-block; position: absolute; top: 0; left: 0; width: 50px; height: 100%; border-radius: 5px; } .downloading-span { background-color: #03c03c; } .compressing-span { background-color: #0047ab; } .red-box:hover { background-color: #4d4d4d; } .red-box>i, .red-box>.download-check { margin-right: 5px; } .download-check-all { width: 20px; height: 20px; cursor: pointer; 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)}; } .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: 550px; 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 netMediaUrl = "https://i3.nhentai.net/galleries/"; 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 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 = { simulN: { label: 'Download batch size', type: 'int', min: 1, max: 50, default: 10, }, compressionLevel: { label: 'Compression level', type: 'int', min: 0, max: 9, default: 0, }, 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: 30, 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, }, openInNewTab: { label: 'Open galleries in new tab', type: 'checkbox', default: true, }, autorestart: { label: 'Auto restart downloads', type: 'checkbox', default: true, } }; const WORKER_THREAD_NUM = ((navigator && navigator.hardwareConcurrency) || 2) - 1; class JSZipWorkerPool { constructor() { this.pool = []; this.WORKER_URL = URL.createObjectURL( new Blob( [ `importScripts( 'https://unpkg.com/comlink/dist/umd/comlink.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js' ); class JSZipWorker { constructor() { this.zip = new JSZip; } file(title, name, {data:data}) { this.zip.folder(title).file(name, data); } async generateAsync(options, onUpdate) { const data = await this.zip.generateAsync(options, onUpdate); const url = URL.createObjectURL(data); return Comlink.transfer({url:url}); } } 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 available JSZip worker'); worker.idle = false; if(!worker.JSZip) worker.JSZip = this.createWorker(); const zip = await new worker.JSZip(); for(const {title, name, data} of files) { await zip.file(title, name, Comlink.transfer({data}, [data])); } return zip .generateAsync( options, Comlink.proxy((data) => onUpdate({workerId: worker.id, ...data})) ) .then(({url}) => { worker.idle = true; return url; }); } } const jsZipPool = new JSZipWorkerPool(); class JSZip { constructor() { this.files = []; } file(title, name, data) { this.files.push({title, name, data}); } generateAsync(options, onUpdate) { return jsZipPool.generateAsync(this.files, options, onUpdate); } } // nhentai.net API URL const netAPI = "https://nhentai.net/api/gallery/"; // nhentai.xxx page URL const xxxPage = "https://nhentai.xxx/g/"; // If we are on nhentai.net const onNET = location.hostname == 'nhentai.net'; // DOM parser for later var parser = new DOMParser(); // Saved config options var configOptions = JSON.parse(GM_getValue('configOptions') || '{}'); // Buttons and Divs var downloadDiv, downloadBtn, downloadPercent, downloadingSpan, compressingSpan, configWrapper, configDiv; // Download info var downloading = false, cancelled = false, total = 0, downloaded = 0, currentDownloads = 0, queue = [], info = JSON.parse(sessionStorage.getItem('info') || '[]'); // Final zip var zip; function disableButton(btnState) { downloadingSpan.style.width = 0; compressingSpan.style.width = 0; downloadBtn.disabled = true; downloadPercent.innerHTML = btnState; } function enableButton() { downloadingSpan.style.width = 0; compressingSpan.style.width = 0; downloadBtn.disabled = false; downloadPercent.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'; case 'w': return '.webp'; } } function saveConfig(reset=false) { const oldOpenInNewTab = configOptions.openInNewTab; 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(configValue > value.max) { configValue = value.max; } if(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)); if(configOptions.openInNewTab != oldOpenInNewTab) { var gLinks = document.querySelectorAll('.gallery > a, .gallerythumb'); for(let a of gLinks) { if(configOptions.openInNewTab) { a.setAttribute('target', '_blank'); } else { a.removeAttribute('target'); } } } } function addConfigMenu() { var saveConfigBtn, closeConfigBtn, cancelAnchor, resetConfigAnchor; 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.hasOwnProperty(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); } 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'); }); cancelAnchor = createNode('a', ['config-reset']); cancelAnchor.innerHTML = "Cancel downloads"; cancelAnchor.addEventListener('click', () => { cancelDownload(); }); resetConfigAnchor = createNode('a', ['config-reset']); resetConfigAnchor.innerHTML = 'Reset to default'; resetConfigAnchor.addEventListener('click', () => { saveConfig(true); }); btnDiv.appendChild(saveConfigBtn); btnDiv.appendChild(closeConfigBtn); btnDiv.appendChild(cancelAnchor); btnDiv.appendChild(resetConfigAnchor); configDiv.appendChild(btnDiv); } (async function() { 'use strict'; window.addEventListener('beforeunload', (e) => { if(downloading) { e.preventDefault(); return ''; } }); var aList, configBtn, checkAllDiv, checkAll; 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(); aList = document.querySelectorAll(".gallery > a, #cover > a"); for(let a of aList) { if(configOptions.openInNewTab) a.setAttribute('target', '_blank'); 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); } if(configOptions.openInNewTab) { aList = document.getElementsByClassName("gallerythumb"); for(let a of aList) { a.setAttribute('target', '_blank'); } } let classes = ['download-div']; if(configOptions.btnOrientation == btnOrientations.HR) { classes.push('div-horizontal'); } downloadDiv = createNode('div', classes); downloadBtn = createNode('button', ['red-box']); downloadPercent = createNode('span', ['percent']); downloadingSpan = createNode('span', ['downloading-span']); compressingSpan = createNode('span', ['compressing-span']); enableButton(); downloadBtn.appendChild(downloadingSpan); downloadBtn.appendChild(compressingSpan); downloadBtn.appendChild(downloadPercent); 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-all']); 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 () => { var checked = document.querySelectorAll(".download-check:checked"); if(checked.length > 0) { disableButton(btnStates.fetching); } else { return; } zip = await new JSZip(); switch(configOptions.fileNameSep) { case fileNameSeps.SP: configOptions.fileNameSep = " "; break; case fileNameSeps.HY: configOptions.fileNameSep = "-"; break; case fileNameSeps.US: configOptions.fileNameSep = "_"; break; } for(let c of checked) { c.checked = false; } for(const c of checked) { var code = c.getAttribute("value"); await addInfo(code); sessionStorage.setItem('info', JSON.stringify(info)); } startDownload(); }); downloadDiv.appendChild(downloadBtn); downloadDiv.appendChild(configBtn); downloadDiv.appendChild(checkAllDiv); document.body.appendChild(downloadDiv); document.body.appendChild(configWrapper); if(info.length > 0) { if(configOptions.autorestart) { queue = []; startDownload(); } else { info = []; sessionStorage.removeItem('info'); } } })(); function startDownload() { downloading = true; cancelled = false; currentDownloads = 0; downloaded = 0; zip = new JSZip(); populateQueue(); total = queue.length; disableButton(btnStates.downloading); downloadQueue(); } function cancelDownload() { info = []; queue = []; sessionStorage.removeItem('info'); currentDownloads = 0; downloading = false; cancelled = true; enableButton(); } function cleanString(string) { string = string.replace(/(\.+$)|(^\.+)|(\|)/g, ''); string = string.replace(/\\\/\:\;/g, configOptions.fileNameSep); string = string.replace(/\s\s+/, ' '); string = string.trim(); return string; } async function makeGetRequest(url, code = null) { return new Promise((resolve, reject) => { if(onNET) { fetch(url, { method: 'GET', mode: 'same-origin', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, referrerPolicy: 'same-origin', }) .then(response => resolve(response.json())); } else { GM_xmlhttpRequest({ method: "GET", url: url, onload: (response) => { resolve(parseXXXResponse(response, code)); }, onerror: (error) => { reject(error); } }); } }); } function addInfoNET(obj, code) { var title; if(configOptions.titleFormat == 'id') { title = `${obj.id}`; } else { title = obj.title[configOptions.titleFormat]; title = cleanString(title); } 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" || (configOptions.includeGroups && tagItem.type == "group")) { artists.push(tagItem.name); } if(tagItem.type == "tag") { tags.push(tagItem.name); } } var mediaUrl = `${netMediaUrl}${obj.media_id}/`; const constTitleExists = info.some((el) => el.title === title); if(constTitleExists) { title += " - "+code; } var fileNamePrep = configOptions.fileNamePrep; fileNamePrep = cleanString(fileNamePrep); var namePrep = ""; if(fileNamePrep != "") { namePrep = fileNamePrep + configOptions.fileNameSep; } var coverExtension = getExtension(obj.images.pages[0].t); info.push({ code: code, title: title, artists: artists, tags: tags, pages: pages, mediaUrl: mediaUrl, namePrep: namePrep, coverExtension: coverExtension, pagesInfo: obj.images.pages, }); } async function addInfo(code) { var apiUrl, obj; if(onNET) { apiUrl = netAPI + code; obj = await makeGetRequest(apiUrl); addInfoNET(obj, code); } else { apiUrl = xxxPage + code; obj = await makeGetRequest(apiUrl, code); info.push(obj); } } function parseXXXResponse(response, code) { var htmlDoc = parser.parseFromString(response.responseText, 'text/html'); var title, artists = [], tags = [], pages, mediaUrl, pagesInfo = [], coverExtension; var titleTemplate = string => { return `${string}.title > span`; } const cleanRegex = /(\[[^\]]*\])|(\([^)]*\))|(\{[^}]*\})|([\.\|\~]*)/g; switch(configOptions.titleFormat) { case 'english': title = htmlDoc.querySelector(titleTemplate('h1')); title = title.textContent; break; case 'japanese': title = htmlDoc.querySelector(titleTemplate('h2')); title = title.textContent; break; case 'pretty': default: title = htmlDoc.querySelector(titleTemplate('h1')); title = title.textContent; title = title.replace(cleanRegex, ''); title = title.replace(/\s\s+/g, ' '); title = title.trim(); title = title.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } var tagTemplate = string => { return `a[href*='${string}/'] > span.name`; } var artistSpans = htmlDoc.querySelectorAll(tagTemplate('artist')); for(var artist of artistSpans) { artists.push(artist.textContent); } if(configOptions.includeGroups) { var groupSpans = htmlDoc.querySelectorAll(tagTemplate('group')); for(var group of groupSpans) { artists.push(group.textContent); } } var tagSpans = htmlDoc.querySelectorAll(tagTemplate('tag')); for(var tag of tagSpans) { tags.push(tag.textContent); } pages = htmlDoc.querySelector(".tag[href*='#'] > span.name"); pages = parseInt(pages.textContent); var fileNamePrep = configOptions.fileNamePrep; fileNamePrep = cleanString(fileNamePrep); var namePrep = ""; if(fileNamePrep != "") { namePrep = fileNamePrep + configOptions.fileNameSep; } var thumbs = htmlDoc.querySelectorAll("a.gallerythumb > img"); mediaUrl = thumbs[0].src; mediaUrl = mediaUrl.substring(0, mediaUrl.lastIndexOf("/")+1); for(var thumb of thumbs) { var extension = thumb.src.split('.').pop().charAt(0); pagesInfo.push({t: extension}); } coverExtension = getExtension(pagesInfo[0].t); var obj = { code: code, title: title, artists: artists, tags: tags, pages: pages, mediaUrl: mediaUrl, namePrep: namePrep, coverExtension: coverExtension, pagesInfo: pagesInfo, } return obj; } async function addToQueue(item, mediaId=null) { var pages = item.pages; var title = item.title; var namePrep = item.namePrep; var mediaUrl = item.mediaUrl; for(let i=0; i<pages; i++) { var extension = getExtension(item.pagesInfo[i].t); let page = i + 1; var imgUrl = `${mediaUrl}${page}${extension}`; queue.push({ page: page, url: imgUrl, title: title, namePrep: namePrep, extension: extension, }); } } function populateQueue() { for(const item of info) { addToQueue(item); } } async function downloadQueue() { while(queue.length > 0) { if(currentDownloads >= configOptions.simulN) { await sleep(125); continue; } var item = queue.shift(); download(item); } } function saveJSON(fileName) { 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(configOptions.saveJSONMode == saveJSONModes.CB) { GM_setClipboard(fileContent, 'text'); return; } fileContent = new TextEncoder().encode(fileContent); zip.file('', fileName, fileContent.buffer); } function generateZip() { var dateName = Date.now(); var compressionType = configOptions.compressionLevel == 0 ? 'STORE' : 'DEFLATE'; var zipName = `${dateName}.zip`; var jsonName = `${dateName}.json`; if(configOptions.saveJSONMode != saveJSONModes.NO) { saveJSON(jsonName); } zip.generateAsync( { type: 'blob', compression: compressionType, compressionOptions: { level: configOptions.compressionLevel, }}, ({workerId, percent, currentFile}) => { var fraction = percent / 100; if(fraction == 1) { enableButton(); return; } downloadPercent.innerHTML = `${percent.toFixed(2)}%`; compressingSpan.style.width = fraction * downloadBtn.offsetWidth + 'px'; } ) .then((url) => { var a = createNode('a'); a.download = zipName; a.href = url; a.click(); }) .then(() => { info = []; sessionStorage.removeItem('info'); downloading = false; }); } function download(item) { currentDownloads++; const fileName = `${item.namePrep}${item.page}${item.extension}`; GM_xmlhttpRequest({ method: 'GET', url: item.url, responseType: 'arraybuffer', onload: (response) => { if(cancelled) return; var data = response.response; zip.file(item.title, fileName, data); currentDownloads--; downloaded++; var fraction = downloaded/total; downloadPercent.innerHTML = `${(fraction * 100).toFixed(2)}%`; downloadingSpan.style.width = fraction * downloadBtn.offsetWidth + 'px'; if(queue.length == 0 && currentDownloads <= 0) { disableButton(btnStates.downloading); generateZip(); } }, onerror: (error) => { currentDownloads--; console.warn(`Could not download '${item.title}' - page ${item.page}`); } }); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }