nHentai Downloader

Download manga on nHentai.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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            4.0.0
// @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/[email protected]/dist/ponyfill.min.js
// @require            https://cdn.jsdelivr.net/npm/[email protected]/StreamSaver.min.js
// @require            https://cdn.jsdelivr.net/npm/[email protected]/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
// @connect            proxy.duckduckgo.com
// @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';

  function patchHistoryApi() {
    const originalPushState = history.pushState;
    history.pushState = function (...args) {
      originalPushState.apply(this, args);
      window.dispatchEvent(new Event('locationchange'));
    };
    const originalReplaceState = history.replaceState;
    history.replaceState = function (...args) {
      originalReplaceState.apply(this, args);
      window.dispatchEvent(new Event('locationchange'));
    };
    window.addEventListener('popstate', () => {
      window.dispatchEvent(new Event('locationchange'));
    });
  }
  patchHistoryApi();

  const originalFetch = window.fetch;
  window.fetch = async function (...args) {
    const response = await originalFetch.apply(this, args);

    if (args[0] && typeof args[0] === 'string' && args[0].includes('/api/v2/galleries/')) {
      response
        .clone()
        .json()
        .then((data) => {
          let payload = null;
          if (data) {
            if (data.body) {
              try {
                payload = JSON.parse(data.body);
              } catch (e) {
                console.warn('Unable to parse gallery body JSON from fetch', e);
              }
            } else {
              payload = data;
            }
          }

          if (payload && applyGalleryData(payload)) {
            interceptedGalleryPayload = payload;
            console.log('Gallery data intercepted from fetch', payload);
          }
        })
        .catch((e) => {
          console.log('Gallery fetch clone response not JSON or error', e);
        });
    }

    return response;
  };

  window.addEventListener('locationchange', () => {
    console.log('locationchange', window.location.href);
    setTimeout(injection, 500);
  });

  function injection() {
    console.log('Injecting script', window.writableStream, window.writer, window.abortController);

    // Prefer AbortController for canceling active streaming download
    if (window.abortController && !window.abortController.signal.aborted) {
      window.abortController.abort();
      console.log('AbortController aborted active stream');
    }

    // Mark aborted to stop further enqueue
    isAborted = true;
    streamEnded = true;

    // Keep releaseLock for existing writer, if available
    window.writer?.releaseLock?.();

    // Reset to initial state when aborted
    resetDownload();
  }

  const configFrame = document.createElement('div');
  $('body').append(configFrame);

  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 Threads',
        type: 'unsigned int',
        min: 1,
        max: 16,
        default: 4,
      },
      useProxy: {
        label: 'Use proxy',
        type: 'checkbox',
        default: false,
      },
    },
    frame: configFrame,
    events: {
      save: () => {
        outputExt = GM_config.get('outputExt');
        outputName = GM_config.get('outputName');
        threading = GM_config.get('threading');
        useProxy = GM_config.get('useProxy');

        $download.find('span').text(` as ${outputExt.toUpperCase()}`);

        const $saveBtn = $('#nHentaiDlConfig_saveBtn');
        $saveBtn.prop('disabled', true).addClass('saved').text('Saved!');

        setTimeout(() => {
          $saveBtn.prop('disabled', false).removeClass('saved').text('Save');
          $configPanel.hide();
        }, 1500);
      },
    },
    css: '#nHentaiDlConfig{width:100%!important;max-width:320px!important;position:fixed!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;z-index:9999!important;max-height:75%!important;overflow:auto!important;background:#4d4d4d!important;border:1px solid #313131!important;border-radius:5px;text-align:left;height:auto!important;padding:10px!important;color:#d9d9d9}#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{line-height:2em;position:relative}#nHentaiDlConfig .field_label{font-size:14px;height:26px;line-height:26px;margin:0;padding:0 10px 0 0;width:40%;display:block;float:left}#nHentaiDlConfig .config_var>[type=text],#nHentaiDlConfig .config_var>div,#nHentaiDlConfig .config_var>select,#nHentaiDlConfig .config_var>textarea{width:60%;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{text-transform:capitalize;border:1px solid;border-radius:4px;width:auto}#nHentaiDlConfig .config_var>#nHentaiDlConfig_field_threading{width:70px;border:1px solid;border-radius:4px}#nHentaiDlConfig .config_var>div{padding:0}#nHentaiDlConfig_field_outputExt{text-transform:uppercase}#nHentaiDlConfig_field_outputExt [value=cbz]{margin-right:20px!important}#nHentaiDlConfig .top{background:#666!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;

  /**
   * Use proxy from DuckDuckGo
   * @type {Boolean}
   */
  let useProxy = GM_config.get('useProxy') || false;

  /**
   * Logging
   * @type {Boolean}
   */
  let debug = false;

  /**
   * Abort controller for stream cancellation
   * @type {AbortController|null}
   */
  let abortController = null;

  /**
   * Cancel button jQuery object
   */
  let $cancel = null;

  /**
   * Whether the download process is currently aborted
   * @type {boolean}
   */
  let isAborted = false;

  /**
   * Whether the readable stream has been ended or canceled
   * @type {boolean}
   */
  let streamEnded = false;

  /**
   * Active GM.xmlHttpRequest objects for aborting in-flight downloads
   * @type {Set<unknown>}
   */
  let activeRequests = new Set();

  // Gallery / download state (can be refreshed with scriptData or intercepted API response)
  let gallery = null;
  let interceptedGalleryPayload = null;
  let current = 0;
  let final = 0;
  let total = 0;
  let images = [];
  let hasErr = false;
  let comicId = null;
  let comicName = '';
  let zipName = '';

  let $_download = null;
  let $download = null;
  let $config = null;
  let $configPanel = null;
  let doc = document;
  let inProgress = false;

  function applyGalleryData(payload) {
    if (!payload || !payload.num_pages || !payload.pages || !Array.isArray(payload.pages)) {
      return false;
    }

    gallery = payload;
    current = 0;
    final = 0;
    total = gallery.num_pages;
    images = gallery.pages || [];
    hasErr = false;
    comicId = gallery.id;
    comicName = gallery.title[outputName] || gallery.title['english'] || '';
    zipName = `${comicName
      .replace(/[\s|+=]+/g, '-')
      .replace(/[:;`'"”“/\\?.,<>[\]{}!@#$%^&*]/g, '')
      .replace(/・/g, '·')}.${comicId}.${outputExt}`;

    if ($download) {
      $download.html('<i class="fa fa-download"></i> Download<span> as ' + outputExt.toUpperCase() + '</span>').css('backgroundColor', 'cornflowerblue');
    }
    if ($cancel) {
      $cancel.hide();
    }

    return true;
  }

  function refreshGalleryData() {
    if (interceptedGalleryPayload && applyGalleryData(interceptedGalleryPayload)) {
      return true;
    }

    const $scriptData = $('[data-url^="/api/v2/galleries/"]');
    if (!$scriptData || !$scriptData.length) {
      console.warn('gallery scriptData not found');
      if ($download) $download.remove();
      if ($cancel) $cancel.remove();
      return false;
    }

    let data;
    try {
      data = JSON.parse($scriptData.text().trim());
    } catch (e) {
      console.warn('invalid JSON in scriptData', e);
      if ($download) $download.remove();
      if ($cancel) $cancel.remove();
      return false;
    }

    if (!data) {
      console.warn('gallery data is empty');
      if ($download) $download.remove();
      if ($cancel) $cancel.remove();
      return false;
    }

    let payload;
    try {
      payload = data.body ? JSON.parse(data.body) : data;
    } catch (e) {
      console.warn('invalid gallery body JSON', e);
      if ($download) $download.remove();
      if ($cancel) $cancel.remove();
      return false;
    }

    if (!payload) {
      console.warn('gallery payload is empty');
      if ($download) $download.remove();
      if ($cancel) $cancel.remove();
      return false;
    }

    return applyGalleryData(payload);
  }

  function resetDownload() {
    console.log('Resetting download state');

    streamEnded = true;

    try {
      for (const req of activeRequests) {
        req?.abort?.();
      }
      activeRequests.clear();

      if (window.writer) {
        window.writer.releaseLock?.();
        window.writer = null;
      }
      if (window.reader) {
        window.reader.cancel?.();
        window.reader = null;
      }
      if (window.writableStream) {
        window.writableStream = null;
      }
      if (window.readableStream) {
        window.readableStream = null;
      }
    } catch (e) {
      console.warn('Error while resetting streams:', e);
    }

    if (abortController && !abortController.signal.aborted) {
      abortController.abort();
    }

    abortController = null;
    window.abortController = null;
    isAborted = false;
    inProgress = false;

    if (!refreshGalleryData()) {
      doc.title = '[✖] Ready';
      return;
    }

    doc.title = '[✖] Ready';
    if ($download) {
      $download.html('<i class="fa fa-download"></i> Download<span> as ' + outputExt.toUpperCase() + '</span>').css('backgroundColor', 'cornflowerblue');
    }
    if ($cancel) {
      $cancel.hide();
    }
  }

  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';

    console.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}`;
    console.log('COMPLETE');
    end();
  }

  function genZip(ctrl) {
    streamEnded = true;
    try {
      ctrl.close();
    } catch (e) {
      console.warn('genZip close error', e);
    }
    $download.html('<i class="fa fa-check"></i> Complete').css('backgroundColor', hasErr ? 'red' : 'green');
  }

  function safeEnqueue(ctrl, item) {
    if (isAborted || streamEnded || (abortController && abortController.signal.aborted)) {
      console.log('safeEnqueue skipped', item && item.name);
      return;
    }
    try {
      ctrl.enqueue(item);
    } catch (cErr) {
      // Avoid throwing on closed stream; treat as terminated
      streamEnded = true;
      console.warn('safeEnqueue error (ignored):', cErr);
    }
  }

  function dlImgError(current, success, error, err, filename) {
    if (images[current].attempt < 1) {
      final++;
      error(err, filename);
      return;
    }

    setTimeout(() => {
      console.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 (isAborted || streamEnded) {
      return;
    }

    if (useProxy) url = `https://proxy.duckduckgo.com/iu/?u=${url}&f=1`;

    filename = `000${filename}`.slice(-8);
    console.log(filename, 'progress');

    const xhr = GM.xmlHttpRequest({
      method: 'GET',
      url: url,
      responseType: 'blob',
      onload: (response) => {
        activeRequests.delete(xhr);
        if (isAborted || streamEnded) {
          console.log('dlImg onload ignored due abort or stream ended', filename);
          return;
        }

        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) => {
        activeRequests.delete(xhr);
        if (isAborted || streamEnded) {
          console.log('dlImg onerror ignored due abort or stream ended', filename);
          return;
        }
        dlImgError(current, success, error, err, filename);
      },
    });
  }

  function next(ctrl) {
    if (isAborted) {
      console.log('next() aborted, no further processing');
      return;
    }

    doc.title = `[${final}/${total}] ${comicName}`;
    $download.find('strong').text(`${final}/${total}`);
    console.log(final, current);

    if (final < current) return;
    final < total ? addZip(ctrl) : genZip(ctrl);
  }

  function addZip(ctrl) {
    if (isAborted) {
      console.log('addZip() aborted, skipping enqueue.');
      return;
    }

    let max = current + threading;
    if (max > total) max = total;

    for (current; current < max; current++) {
      console.log(images[current].url, 'download');
      dlImg(
        current,
        (response, filename) => {
          if (isAborted || streamEnded) {
            console.log('dlImg success ignored because aborted or ended', filename);
            return;
          }

          safeEnqueue(ctrl, { name: filename, stream: () => response.response.stream() });

          console.log(filename, 'success');
          next(ctrl);
        },
        (err, filename) => {
          if (isAborted || streamEnded) {
            console.log('dlImg error ignored because aborted or ended', filename);
            return;
          }

          hasErr = true;

          const errGif = base64toBlob('R0lGODdhBQAFAIACAAAAAP/eACwAAAAABQAFAAACCIwPkWerClIBADs=', 'image/gif');
          safeEnqueue(ctrl, { name: `${filename}_error.gif`, stream: () => errGif.stream() });

          $download.css('backgroundColor', '#FF7F7F');

          console.log(err, 'error');
          next(ctrl);
        },
      );
    }
    console.log(current, 'current');
  }

  if (!refreshGalleryData()) return;

  $_download = $('#download-torrent, #download');

  if (!$_download.length) return;
  GM_config.open();
  $configPanel = $('#nHentaiDlConfig');

  $download = $(
    '<button type="button" id="nHentaiDlDownload" class="btn btn-secondary tooltip" style="min-width:120px;margin:0 6px;">' +
      '<i class="fa fa-download"></i> Download<span class="top">No login required<br>No sign up required<i></i></span>' +
      '</button>',
  );
  $download.prop('disabled', false);
  $download.append(`<span> as ${outputExt.toUpperCase()}</span>`);

  $cancel = $(
    '<button type="button" id="nHentaiDlCancel" class="btn btn-secondary" style="min-width:40px;margin:0 6px;background-color:#d9534f;">' +
      '<i class="fa fa-times"></i><div class="top">Cancel<i></i></div>' +
      '</button>',
  );
  $cancel.hide();

  $config = $(
    '<button type="button" id="nHentaiDlConfigToggle" class="btn btn-secondary" style="min-width:40px;margin:0 6px;color:#d9d9d9;">' +
      '<i class="fa fa-cog"></i> Settings' +
      '</button>',
  );

  const $controlPanel = $(
    '<div id="nHentaiDlControlPanel" style="position:fixed;bottom:16px;left:50%;transform:translateX(-50%);z-index:9999;padding:8px 12px;background:rgba(0,0,0,.75);border:1px solid #333;border-radius:7px;box-shadow:0 4px 12px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;gap:6px;">' +
      '</div>',
  );

  $controlPanel.append($download, $cancel, $config);
  $('body').append($controlPanel);

  $cancel.on('click', (e) => {
    e.preventDefault();
    if (!inProgress) return;

    console.log('Cancel clicked');
    isAborted = true;
    if (abortController && !abortController.signal.aborted) {
      abortController.abort();
    }

    $cancel.hide();
    resetDownload();
  });

  $download.css('backgroundColor', 'cornflowerblue').on('click', (e) => {
    e.preventDefault();
    if (inProgress) return;
    inProgress = true;

    if (debug) _time('nHentai');
    console.log({ outputExt, outputName, threading });

    isAborted = false;
    streamEnded = false;

    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');
    if ($cancel) {
      $cancel.show();
    }

    images = images.map((img, index) => {
      return {
        url: `https://i.nhentai.net/${img.path}`,
        attempt: 3,
      };
    });
    console.log(images, 'images');

    // Setup AbortController so both reading/writing paths can support cancel
    abortController = new AbortController();
    const abortSignal = abortController.signal;
    window.abortController = abortController;

    streamSaver.mitm = 'https://lelinhtinh.github.io/stream/mitm.html';
    window.writableStream = streamSaver.createWriteStream(zipName);

    const info = new Blob([getInfo()]);
    window.readableStream = new ZIP({
      start(ctrl) {
        ctrl.enqueue({
          name: 'info.txt',
          stream: () => info.stream(),
        });
      },
      pull(ctrl) {
        addZip(ctrl);
      },
    });

    const handleAbortError = (err) => {
      if (abortSignal.aborted || isAborted) {
        console.warn('Download aborted by user.');
        streamEnded = true;
        console.error('Download error:', err);
      }
      resetDownload();
    };

    if (window.WritableStream && window.readableStream.pipeTo) {
      window.readableStream
        .pipeTo(window.writableStream, { signal: abortSignal })
        .then(() => {
          done(comicName);
          inProgress = false;
        })
        .catch(handleAbortError);
    } else {
      window.writer = window.writableStream.getWriter();
      window.reader = window.readableStream.getReader({ signal: abortSignal });
      const pump = () =>
        window.reader.read().then((res) => {
          if (res.done) {
            return window.writer.close();
          }
          return window.writer.write(res.value).then(pump);
        });
      pump()
        .then(() => {
          done(comicName);
          inProgress = false;
        })
        .catch(handleAbortError);
    }
  });

  $configPanel.toggle();
  $config.on('click', (e) => {
    e.preventDefault();
    $configPanel.toggle('fast');
  });
})(jQuery, unsafeWindow);