nHentai Downloader

Download manga on nHentai.

  1. // ==UserScript==
  2. // @name nHentai Downloader
  3. // @name:vi nHentai Downloader
  4. // @name:zh-CN nHentai 下载器
  5. // @name:zh-TW nHentai 下载器
  6. // @namespace http://devs.forumvi.com
  7. // @description Download manga on nHentai.
  8. // @description:vi Tải truyện tranh tại NhệnTái.
  9. // @description:zh-CN 在nHentai上下载漫画。
  10. // @description:zh-TW 在nHentai上下载漫画。
  11. // @version 3.2.1
  12. // @icon http://i.imgur.com/FAsQ4vZ.png
  13. // @author Zzbaivong
  14. // @oujs:author baivong
  15. // @license MIT; https://baivong.mit-license.org/license.txt
  16. // @match http://nhentai.net/g/*
  17. // @match https://nhentai.net/g/*
  18. // @require https://code.jquery.com/jquery-3.6.0.min.js
  19. // @require https://cdn.jsdelivr.net/npm/web-streams-polyfill@3.2.1/dist/ponyfill.min.js
  20. // @require https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/StreamSaver.min.js
  21. // @require https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/examples/zip-stream.js
  22. // @require https://greasyfork.org/scripts/28536-gm-config/code/GM_config.js?version=184529
  23. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?v=a834d46
  24. // @noframes
  25. // @connect self
  26. // @supportURL https://github.com/lelinhtinh/Userscript/issues
  27. // @run-at document-idle
  28. // @grant GM.xmlHttpRequest
  29. // @grant GM_xmlhttpRequest
  30. // @grant unsafeWindow
  31. // @grant GM_getValue
  32. // @grant GM_setValue
  33. // @grant GM.getValue
  34. // @grant GM.setValue
  35. // ==/UserScript==
  36.  
  37. /* global streamSaver, ZIP */
  38. (($, window) => {
  39. 'use strict';
  40.  
  41. const configFrame = document.createElement('div'),
  42. $infoBlock = $('#info-block');
  43.  
  44. $infoBlock.append(configFrame);
  45. $infoBlock.append(
  46. '<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>',
  47. );
  48.  
  49. GM_config.init({
  50. id: 'nHentaiDlConfig',
  51. title: 'Downloader Settings',
  52. fields: {
  53. outputExt: {
  54. options: ['cbz', 'zip'],
  55. label: 'Export as',
  56. type: 'radio',
  57. default: 'cbz',
  58. },
  59. outputName: {
  60. label: 'Filename',
  61. type: 'select',
  62. options: ['pretty', 'english', 'japanese'],
  63. default: 'pretty',
  64. },
  65. threading: {
  66. label: 'Max. conn. number',
  67. type: 'unsigned int',
  68. min: 1,
  69. max: 16,
  70. default: 4,
  71. },
  72. hideTorrentBtn: {
  73. label: 'Hide the download torrent button',
  74. type: 'checkbox',
  75. default: false,
  76. },
  77. useProxy: {
  78. label: 'Use DuckDuckGo proxy',
  79. type: 'checkbox',
  80. default: false,
  81. },
  82. },
  83. frame: configFrame,
  84. events: {
  85. save: () => {
  86. outputExt = GM_config.get('outputExt');
  87. outputName = GM_config.get('outputName');
  88. threading = GM_config.get('threading');
  89. hideTorrentBtn = GM_config.get('hideTorrentBtn');
  90. useProxy = GM_config.get('useProxy');
  91.  
  92. $download.find('span').text(` as ${outputExt.toUpperCase()}`);
  93. $_download.toggle(!hideTorrentBtn);
  94.  
  95. const $saveBtn = $('#nHentaiDlConfig_saveBtn');
  96. $saveBtn.prop('disabled', true).addClass('saved').text('Saved!');
  97.  
  98. setTimeout(() => {
  99. $saveBtn.prop('disabled', false).removeClass('saved').text('Save');
  100. }, 1500);
  101. },
  102. },
  103. 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}',
  104. });
  105.  
  106. /**
  107. * Output extension
  108. * @type {'cbz'|'zip'}
  109. *
  110. * Tips: Convert .zip to .cbz
  111. * Windows
  112. * $ ren *.zip *.cbz
  113. * Linux
  114. * $ rename 's/\.zip$/\.cbz/' *.zip
  115. */
  116. let outputExt = GM_config.get('outputExt') || 'cbz';
  117.  
  118. /**
  119. * File name
  120. * @type {'pretty'|'english'|'japanese'}
  121. */
  122. let outputName = GM_config.get('outputName') || 'pretty';
  123.  
  124. /**
  125. * Multithreading
  126. * @type {Number} [1 -> 16]
  127. */
  128. let threading = GM_config.get('threading') || 4;
  129.  
  130. /**
  131. * Hide Torrent Download button
  132. * @type {Boolean}
  133. */
  134. let hideTorrentBtn = GM_config.get('hideTorrentBtn') || false;
  135.  
  136. /**
  137. * Use proxy from DuckDuckGo
  138. * @type {Boolean}
  139. */
  140. let useProxy = GM_config.get('useProxy') || false;
  141.  
  142. /**
  143. * Logging
  144. * @type {Boolean}
  145. */
  146. let debug = false;
  147.  
  148. const _console = window.console;
  149. const _time = window.console.time;
  150. const _timeEnd = window.console.timeEnd;
  151. const log = (...arg) => {
  152. if (!debug) return;
  153. _console.log(arg);
  154. };
  155. window.console = {
  156. log: () => null,
  157. clear: () => null,
  158. };
  159.  
  160. function base64toBlob(base64Data, contentType) {
  161. contentType = contentType || '';
  162. const sliceSize = 1024;
  163. const byteCharacters = atob(base64Data);
  164. const bytesLength = byteCharacters.length;
  165. const slicesCount = Math.ceil(bytesLength / sliceSize);
  166. const byteArrays = new Array(slicesCount);
  167.  
  168. for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
  169. const begin = sliceIndex * sliceSize;
  170. const end = Math.min(begin + sliceSize, bytesLength);
  171.  
  172. const bytes = new Array(end - begin);
  173. for (let offset = begin, i = 0; offset < end; ++i, ++offset) {
  174. bytes[i] = byteCharacters[offset].charCodeAt(0);
  175. }
  176. byteArrays[sliceIndex] = new Uint8Array(bytes);
  177. }
  178. return new Blob(byteArrays, { type: contentType });
  179. }
  180.  
  181. function getInfo() {
  182. let info = '',
  183. tags = [],
  184. artists = [],
  185. groups = [],
  186. parodies = [],
  187. characters = [],
  188. categories = [],
  189. languages = [];
  190.  
  191. if (gallery.title.english) info += gallery.title.english + '\r\n';
  192. if (gallery.title.japanese) info += gallery.title.japanese + '\r\n';
  193. if (gallery.title.pretty) info += gallery.title.pretty + '\r\n';
  194. info += '#' + gallery.id + '\r\n';
  195.  
  196. if (gallery.tags) {
  197. for (const tag of gallery.tags) {
  198. if (tag.type === 'tag') tags.push(tag.name);
  199. if (tag.type === 'artist') artists.push(tag.name);
  200. if (tag.type === 'category') categories.push(tag.name);
  201. if (tag.type === 'group') groups.push(tag.name);
  202. if (tag.type === 'parody') parodies.push(tag.name);
  203. if (tag.type === 'character') characters.push(tag.name);
  204. if (tag.type === 'language') languages.push(tag.name);
  205. }
  206. }
  207. if (tags.length) info += '\r\n' + 'Tags: ' + tags.join(', ');
  208. if (categories.length) info += '\r\n' + 'Categories: ' + categories.join(', ');
  209. if (groups.length) info += '\r\n' + 'Groups: ' + groups.join(', ');
  210. if (parodies.length) info += '\r\n' + 'Parodies: ' + parodies.join(', ');
  211. if (characters.length) info += '\r\n' + 'Characters: ' + characters.join(', ');
  212. if (languages.length) info += '\r\n' + 'Languages: ' + languages.join(', ');
  213.  
  214. info += '\r\n\r\n' + 'Pages: ' + total;
  215. info += '\r\n' + 'Uploaded at: ' + new Date(gallery.upload_date * 1000).toLocaleString() + '\r\n';
  216.  
  217. log(info);
  218. return info;
  219. }
  220.  
  221. function beforeleaving(e) {
  222. e.preventDefault();
  223. e.returnValue = '';
  224. }
  225.  
  226. function end() {
  227. window.removeEventListener('beforeunload', beforeleaving);
  228. if (debug) _timeEnd('nHentai');
  229. }
  230.  
  231. function done(filename) {
  232. doc.title = `[⇓] ${filename}`;
  233. log('COMPLETE');
  234. end();
  235. }
  236.  
  237. function genZip(ctrl) {
  238. ctrl.close();
  239. $download.html('<i class="fa fa-check"></i> Complete').css('backgroundColor', hasErr ? 'red' : 'green');
  240. }
  241.  
  242. function dlImgError(current, success, error, err, filename) {
  243. if (images[current].attempt < 1) {
  244. final++;
  245. error(err, filename);
  246. return;
  247. }
  248.  
  249. setTimeout(() => {
  250. log(filename, `retry ${images[current].attempt}`);
  251. dlImg(current, success, error);
  252. images[current].attempt--;
  253. }, 2000);
  254. }
  255.  
  256. function dlImg(current, success, error) {
  257. let url = images[current].url,
  258. filename = url.replace(/.*\//g, '');
  259.  
  260. if (useProxy) url = `https://proxy.duckduckgo.com/iu/?u=${url}&f=1`;
  261.  
  262. filename = `000${filename}`.slice(-8);
  263. log(filename, 'progress');
  264.  
  265. GM.xmlHttpRequest({
  266. method: 'GET',
  267. url: url,
  268. responseType: 'blob',
  269. onload: (response) => {
  270. if (
  271. response.response.type === 'text/html' ||
  272. response.response.byteLength < 1000 ||
  273. (response.statusText !== 'OK' && response.statusText !== '')
  274. ) {
  275. dlImgError(current, success, error, response, filename);
  276. return;
  277. }
  278.  
  279. final++;
  280. success(response, filename);
  281. },
  282. onerror: (err) => {
  283. dlImgError(current, success, error, err, filename);
  284. },
  285. });
  286. }
  287.  
  288. function next(ctrl) {
  289. doc.title = `[${final}/${total}] ${comicName}`;
  290. $download.find('strong').text(`${final}/${total}`);
  291. log(final, current);
  292.  
  293. if (final < current) return;
  294. final < total ? addZip(ctrl) : genZip(ctrl);
  295. }
  296.  
  297. function addZip(ctrl) {
  298. let max = current + threading;
  299. if (max > total) max = total;
  300.  
  301. for (current; current < max; current++) {
  302. log(images[current].url, 'download');
  303. dlImg(
  304. current,
  305. (response, filename) => {
  306. ctrl.enqueue({ name: filename, stream: () => response.response.stream() });
  307.  
  308. log(filename, 'success');
  309. next(ctrl);
  310. },
  311. (err, filename) => {
  312. hasErr = true;
  313.  
  314. const errGif = base64toBlob('R0lGODdhBQAFAIACAAAAAP/eACwAAAAABQAFAAACCIwPkWerClIBADs=', 'image/gif');
  315. ctrl.enqueue({ name: `${filename}_error.gif`, stream: () => errGif.stream() });
  316.  
  317. $download.css('backgroundColor', '#FF7F7F');
  318.  
  319. log(err, 'error');
  320. next(ctrl);
  321. },
  322. );
  323. }
  324. log(current, 'current');
  325. }
  326.  
  327. const gallery = JSON.parse(JSON.stringify(window._gallery));
  328. log(gallery, 'gallery');
  329. if (!gallery) return;
  330.  
  331. let current = 0,
  332. final = 0,
  333. total = gallery.num_pages,
  334. images = gallery.images.pages,
  335. hasErr = false,
  336. $_download = $('#download-torrent, #download'),
  337. $download,
  338. $config,
  339. $configPanel,
  340. doc = document,
  341. comicId = gallery.id,
  342. comicName = gallery.title[outputName] || gallery.title['english'],
  343. zipName = `${comicName
  344. .replace(/[\s|+=]+/g, '-')
  345. .replace(/[:;`'"”“/\\?.,<>[\]{}!@#$%^&*]/g, '')
  346. .replace(/・/g, '·')}.${comicId}.${outputExt}`,
  347. readableStream,
  348. writableStream,
  349. inProgress = false;
  350.  
  351. if (!$_download.length) return;
  352. GM_config.open();
  353. $configPanel = $('#nHentaiDlConfig');
  354.  
  355. $download = $_download.clone();
  356. $download.removeAttr('id');
  357. $download.removeClass('btn-disabled');
  358. $download.attr('href', '#download');
  359. $download.find('.top').html('No login required<br>No sign up required<i></i>');
  360. $download.append(`<span> as ${outputExt.toUpperCase()}</span>`);
  361.  
  362. $download.insertAfter($_download);
  363. $download.before('\n');
  364.  
  365. $download.css('backgroundColor', 'cornflowerblue').on('click', (e) => {
  366. e.preventDefault();
  367. if (inProgress) return;
  368. inProgress = true;
  369.  
  370. if (debug) _time('nHentai');
  371. log({ outputExt, outputName, threading });
  372.  
  373. if (threading < 1) threading = 1;
  374. if (threading > 16) threading = 16;
  375.  
  376. doc.title = `[⇣] ${comicName}`;
  377. window.addEventListener('beforeunload', beforeleaving);
  378.  
  379. $download
  380. .html('<i class="fa fa-spinner fa-spin"></i> <strong>Waiting...</strong>')
  381. .css('backgroundColor', 'orange');
  382.  
  383. images = images.map((img, index) => {
  384. return {
  385. url: `https://i.nhentai.net/galleries/${gallery.media_id}/${index + 1}.${
  386. { j: 'jpg', p: 'png', g: 'gif' }[img.t]
  387. }`,
  388. attempt: 3,
  389. };
  390. });
  391. log(images, 'images');
  392.  
  393. streamSaver.mitm = 'https://lelinhtinh.github.io/stream/mitm.html';
  394. writableStream = streamSaver.createWriteStream(zipName);
  395.  
  396. const info = new Blob([getInfo()]);
  397. readableStream = new ZIP({
  398. start(ctrl) {
  399. ctrl.enqueue({
  400. name: 'info.txt',
  401. stream: () => info.stream(),
  402. });
  403. },
  404. pull(ctrl) {
  405. addZip(ctrl);
  406. },
  407. });
  408.  
  409. if (window.WritableStream && readableStream.pipeTo) {
  410. readableStream.pipeTo(writableStream).then(() => {
  411. done(comicName);
  412. });
  413. } else {
  414. const writer = writableStream.getWriter();
  415. const reader = readableStream.getReader();
  416. const pump = () => reader.read().then((res) => (res.done ? writer.close() : writer.write(res.value).then(pump)));
  417. pump().then(() => done(comicName));
  418. }
  419. });
  420.  
  421. $configPanel.toggle();
  422. $config = $_download.clone();
  423. $config.removeAttr('id');
  424. $config.removeClass('btn-disabled');
  425. $config.attr('href', '#settings');
  426. $config.css('min-width', '40px');
  427. $config.html('<i class="fa fa-cog"></i><div class="top">Toggle settings<i></i></div>');
  428.  
  429. $config.insertAfter($download);
  430. $config.on('click', (e) => {
  431. e.preventDefault();
  432. $configPanel.toggle('fast');
  433. });
  434.  
  435. if (hideTorrentBtn) $_download.hide();
  436. })(jQuery, unsafeWindow);