Sleazy Fork is available in English.

e-hentai retriever

e-hentai & exhentai image url retriever

// ==UserScript==
// @name        e-hentai retriever
// @namespace   https://github.com/s25g5d4/e-hentai-retriever
// @description e-hentai & exhentai image url retriever
// @include     /^https?://e-hentai.org/s/.*/
// @include     /^https?://exhentai.org/s/.*/
// @version     4.4.1
// @author      s25g5d4
// @homepageURL https://github.com/s25g5d4/e-hentai-retriever
// @grant       GM_xmlhttpRequest
// @grant       unsafeWindow
// ==/UserScript==

(function () {
  'use strict';

  function styleInject(css, ref) {
    if ( ref === void 0 ) ref = {};
    var insertAt = ref.insertAt;

    if (!css || typeof document === 'undefined') { return; }

    var head = document.head || document.getElementsByTagName('head')[0];
    var style = document.createElement('style');
    style.type = 'text/css';

    if (insertAt === 'top') {
      if (head.firstChild) {
        head.insertBefore(style, head.firstChild);
      } else {
        head.appendChild(style);
      }
    } else {
      head.appendChild(style);
    }

    if (style.styleSheet) {
      style.styleSheet.cssText = css;
    } else {
      style.appendChild(document.createTextNode(css));
    }
  }

  var css_248z = "#i3 a {\n  display: inline-block;\n  position: relative;\n}\n\n#i3.force-img-full-height a img {\n\twidth:auto !important;\n\theight:100vh !important;\n}\n\n.close,\n.swap,\n.page-number {\n\tposition: absolute;\n\twidth: 32px;\n\theight: 32px;\n\tmargin: 8px;\n\tz-index: 999;\n  opacity: 0;\n  transition: opacity 0.25s;\n\tbackground-color: rgba(255, 255, 255, 0.3);\n}\n\n.close {\n\ttop: 0;\n\tright: 0;\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAmElEQVRYhc2USQ6AMAwD+/9PhyuRAs3igVrqCTR2m2UtL1u8Hj3sdkjz0MOCQ5o7j+iDOsTWgwyRZhMhykxliDZLEWLMmABkr9gByfuoAsQmKQPGd8mbAW7eDYHoV/NsCFxH3/6I+h8xAZ/tgMo/mDkWogOUhZiAxiEUt2gzlHUss4hOTjPJWd6y8UXSDaFWqQyUUo1Iy3lcN6p2mB7qGCwAAAAASUVORK5CYII=);\n}\n\n.swap {\n\ttop: 0;\n\tleft: 0;\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAdklEQVRYhe3TwQ0AIQhE0enA7b9JS2BPezExERb9HpiEI/KiIlUqZ2I3AFCECUaYYMQI+IXokwMjhQNCCPwGmqTHWal/IJKrtsDVlA3Y17Bwnns4/lb4TzX5161lDo9UJ4eHAZmIMGCGOB4cMCKw4IAPUalszwvTfbCPlftI4QAAAABJRU5ErkJggg==);\n}\n\n.page-number {\n\tbottom: 0;\n\tright: 0;\n\tfont-size: 16px;\n\tline-height: 32px;\n\tcolor: black;\n}\n\n.close:hover,\n.swap:hover,\n.page-number:hover {\n  opacity: 1;\n}\n\n.hidden {\n\tdisplay: none !important;\n}\n\n.show-hidden {\n\tfont-size: larger;\n\tmargin-left: 5px;\n}\n";
  styleInject(css_248z);

  class Queue {
      constructor(limit, timeout = 0, delay = 0) {
          this.limit = limit;
          this.timeout = timeout;
          this.delay = delay;
          this.slot = [];
          this.q = [];
      }
      queue(executor, name) {
          const job = new Promise((resolve, reject) => {
              this.q.push({
                  name,
                  run: executor,
                  resolve,
                  reject,
                  isTimeout: false,
                  timeoutId: undefined
              });
          });
          // console.log(`queue: job ${name} queued`);
          this.dequeue();
          return job;
      }
      dequeue() {
          const { q, slot, limit, timeout, delay } = this;
          if (slot.length < limit && q.length >= 1) {
              const job = q.shift();
              slot.push(job);
              // console.log(`queue: job ${job.name} started`);
              if (timeout) {
                  job.timeoutId = window.setTimeout(() => this.jobTimeout(job), timeout);
              }
              const onFulfilled = (data) => {
                  if (job.isTimeout) {
                      return;
                  }
                  this.removeJob(job);
                  window.setTimeout(() => this.dequeue(), delay); // force dequeue() run after current dequeue()
                  if (job.timeoutId) {
                      window.clearTimeout(job.timeoutId);
                  }
                  // console.log(`queue: job ${job.name} resolved`);
                  job.resolve(data);
              };
              const onRejected = (reason) => {
                  if (job.isTimeout) {
                      return;
                  }
                  this.removeJob(job);
                  setTimeout(() => this.dequeue(), delay);
                  if (job.timeoutId) {
                      window.clearTimeout(job.timeoutId);
                  }
                  // console.log(`queue: job ${job.name} rejected`);
                  job.reject(reason);
              };
              job.run(onFulfilled, onRejected);
          }
      }
      jobTimeout(job) {
          this.removeJob(job);
          // console.log(`queue: job ${job.name} timeout`);
          job.reject(new Error(`queue: job ${job.name} timeout`));
          job = null;
      }
      removeJob(job) {
          let index = this.slot.indexOf(job);
          if (index >= 0) {
              this.slot.splice(index, 1);
              return;
          }
          index = this.q.indexOf(job);
          if (index >= 0) {
              this.q.splice(index, 1);
          }
      }
  }

  // Origin from window.fetch polyfill
  // https://github.com/github/fetch
  // License https://github.com/github/fetch/blob/master/LICENSE
  const COFetch = (input, init = {}) => {
      let request;
      if (Request.prototype.isPrototypeOf(input) && !init) {
          request = input;
      }
      else {
          request = new Request(input, init);
      }
      const headers = Object.assign({}, request.headers);
      let anonymous = true;
      if (request.credentials === 'include') {
          anonymous = false;
      }
      const onload = (resolve, reject, gmxhr) => {
          const init = {
              url: gmxhr.finalUrl || request.url,
              status: gmxhr.status,
              statusText: gmxhr.statusText,
              headers: undefined
          };
          try {
              const rawHeaders = gmxhr.responseHeaders.trim().replace(/\r\n(\s+)/g, '$1').split('\r\n').map(e => e.split(/:/));
              const header = new Headers();
              rawHeaders.forEach(e => {
                  header.append(e[0].trim(), e[1].trim());
              });
              init.headers = header;
              const res = new Response(gmxhr.response, init);
              resolve(res);
          }
          catch (e) {
              reject(e);
          }
      };
      const onerror = (resolve, reject, gmxhr) => {
          reject(new TypeError('Network request failed'));
      };
      return new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
              anonymous,
              method: request.method,
              url: request.url,
              headers: headers,
              responseType: 'blob',
              data: init.body,
              onload: onload.bind(null, resolve, reject),
              onerror: onerror.bind(null, resolve, reject)
          });
      });
  };

  var domain;

  // This constructor is used to store event handlers. Instantiating this is
  // faster than explicitly calling `Object.create(null)` to get a "clean" empty
  // object (tested with v8 v4.9).
  function EventHandlers() {}
  EventHandlers.prototype = Object.create(null);

  function EventEmitter() {
    EventEmitter.init.call(this);
  }

  // nodejs oddity
  // require('events') === require('events').EventEmitter
  EventEmitter.EventEmitter = EventEmitter;

  EventEmitter.usingDomains = false;

  EventEmitter.prototype.domain = undefined;
  EventEmitter.prototype._events = undefined;
  EventEmitter.prototype._maxListeners = undefined;

  // By default EventEmitters will print a warning if more than 10 listeners are
  // added to it. This is a useful default which helps finding memory leaks.
  EventEmitter.defaultMaxListeners = 10;

  EventEmitter.init = function() {
    this.domain = null;
    if (EventEmitter.usingDomains) {
      // if there is an active domain, then attach to it.
      if (domain.active ) ;
    }

    if (!this._events || this._events === Object.getPrototypeOf(this)._events) {
      this._events = new EventHandlers();
      this._eventsCount = 0;
    }

    this._maxListeners = this._maxListeners || undefined;
  };

  // Obviously not all Emitters should be limited to 10. This function allows
  // that to be increased. Set to zero for unlimited.
  EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) {
    if (typeof n !== 'number' || n < 0 || isNaN(n))
      throw new TypeError('"n" argument must be a positive number');
    this._maxListeners = n;
    return this;
  };

  function $getMaxListeners(that) {
    if (that._maxListeners === undefined)
      return EventEmitter.defaultMaxListeners;
    return that._maxListeners;
  }

  EventEmitter.prototype.getMaxListeners = function getMaxListeners() {
    return $getMaxListeners(this);
  };

  // These standalone emit* functions are used to optimize calling of event
  // handlers for fast cases because emit() itself often has a variable number of
  // arguments and can be deoptimized because of that. These functions always have
  // the same number of arguments and thus do not get deoptimized, so the code
  // inside them can execute faster.
  function emitNone(handler, isFn, self) {
    if (isFn)
      handler.call(self);
    else {
      var len = handler.length;
      var listeners = arrayClone(handler, len);
      for (var i = 0; i < len; ++i)
        listeners[i].call(self);
    }
  }
  function emitOne(handler, isFn, self, arg1) {
    if (isFn)
      handler.call(self, arg1);
    else {
      var len = handler.length;
      var listeners = arrayClone(handler, len);
      for (var i = 0; i < len; ++i)
        listeners[i].call(self, arg1);
    }
  }
  function emitTwo(handler, isFn, self, arg1, arg2) {
    if (isFn)
      handler.call(self, arg1, arg2);
    else {
      var len = handler.length;
      var listeners = arrayClone(handler, len);
      for (var i = 0; i < len; ++i)
        listeners[i].call(self, arg1, arg2);
    }
  }
  function emitThree(handler, isFn, self, arg1, arg2, arg3) {
    if (isFn)
      handler.call(self, arg1, arg2, arg3);
    else {
      var len = handler.length;
      var listeners = arrayClone(handler, len);
      for (var i = 0; i < len; ++i)
        listeners[i].call(self, arg1, arg2, arg3);
    }
  }

  function emitMany(handler, isFn, self, args) {
    if (isFn)
      handler.apply(self, args);
    else {
      var len = handler.length;
      var listeners = arrayClone(handler, len);
      for (var i = 0; i < len; ++i)
        listeners[i].apply(self, args);
    }
  }

  EventEmitter.prototype.emit = function emit(type) {
    var er, handler, len, args, i, events, domain;
    var doError = (type === 'error');

    events = this._events;
    if (events)
      doError = (doError && events.error == null);
    else if (!doError)
      return false;

    domain = this.domain;

    // If there is no 'error' event listener then throw.
    if (doError) {
      er = arguments[1];
      if (domain) {
        if (!er)
          er = new Error('Uncaught, unspecified "error" event');
        er.domainEmitter = this;
        er.domain = domain;
        er.domainThrown = false;
        domain.emit('error', er);
      } else if (er instanceof Error) {
        throw er; // Unhandled 'error' event
      } else {
        // At least give some kind of context to the user
        var err = new Error('Uncaught, unspecified "error" event. (' + er + ')');
        err.context = er;
        throw err;
      }
      return false;
    }

    handler = events[type];

    if (!handler)
      return false;

    var isFn = typeof handler === 'function';
    len = arguments.length;
    switch (len) {
      // fast cases
      case 1:
        emitNone(handler, isFn, this);
        break;
      case 2:
        emitOne(handler, isFn, this, arguments[1]);
        break;
      case 3:
        emitTwo(handler, isFn, this, arguments[1], arguments[2]);
        break;
      case 4:
        emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]);
        break;
      // slower
      default:
        args = new Array(len - 1);
        for (i = 1; i < len; i++)
          args[i - 1] = arguments[i];
        emitMany(handler, isFn, this, args);
    }

    return true;
  };

  function _addListener(target, type, listener, prepend) {
    var m;
    var events;
    var existing;

    if (typeof listener !== 'function')
      throw new TypeError('"listener" argument must be a function');

    events = target._events;
    if (!events) {
      events = target._events = new EventHandlers();
      target._eventsCount = 0;
    } else {
      // To avoid recursion in the case that type === "newListener"! Before
      // adding it to the listeners, first emit "newListener".
      if (events.newListener) {
        target.emit('newListener', type,
                    listener.listener ? listener.listener : listener);

        // Re-assign `events` because a newListener handler could have caused the
        // this._events to be assigned to a new object
        events = target._events;
      }
      existing = events[type];
    }

    if (!existing) {
      // Optimize the case of one listener. Don't need the extra array object.
      existing = events[type] = listener;
      ++target._eventsCount;
    } else {
      if (typeof existing === 'function') {
        // Adding the second element, need to change to array.
        existing = events[type] = prepend ? [listener, existing] :
                                            [existing, listener];
      } else {
        // If we've already got an array, just append.
        if (prepend) {
          existing.unshift(listener);
        } else {
          existing.push(listener);
        }
      }

      // Check for listener leak
      if (!existing.warned) {
        m = $getMaxListeners(target);
        if (m && m > 0 && existing.length > m) {
          existing.warned = true;
          var w = new Error('Possible EventEmitter memory leak detected. ' +
                              existing.length + ' ' + type + ' listeners added. ' +
                              'Use emitter.setMaxListeners() to increase limit');
          w.name = 'MaxListenersExceededWarning';
          w.emitter = target;
          w.type = type;
          w.count = existing.length;
          emitWarning(w);
        }
      }
    }

    return target;
  }
  function emitWarning(e) {
    typeof console.warn === 'function' ? console.warn(e) : console.log(e);
  }
  EventEmitter.prototype.addListener = function addListener(type, listener) {
    return _addListener(this, type, listener, false);
  };

  EventEmitter.prototype.on = EventEmitter.prototype.addListener;

  EventEmitter.prototype.prependListener =
      function prependListener(type, listener) {
        return _addListener(this, type, listener, true);
      };

  function _onceWrap(target, type, listener) {
    var fired = false;
    function g() {
      target.removeListener(type, g);
      if (!fired) {
        fired = true;
        listener.apply(target, arguments);
      }
    }
    g.listener = listener;
    return g;
  }

  EventEmitter.prototype.once = function once(type, listener) {
    if (typeof listener !== 'function')
      throw new TypeError('"listener" argument must be a function');
    this.on(type, _onceWrap(this, type, listener));
    return this;
  };

  EventEmitter.prototype.prependOnceListener =
      function prependOnceListener(type, listener) {
        if (typeof listener !== 'function')
          throw new TypeError('"listener" argument must be a function');
        this.prependListener(type, _onceWrap(this, type, listener));
        return this;
      };

  // emits a 'removeListener' event iff the listener was removed
  EventEmitter.prototype.removeListener =
      function removeListener(type, listener) {
        var list, events, position, i, originalListener;

        if (typeof listener !== 'function')
          throw new TypeError('"listener" argument must be a function');

        events = this._events;
        if (!events)
          return this;

        list = events[type];
        if (!list)
          return this;

        if (list === listener || (list.listener && list.listener === listener)) {
          if (--this._eventsCount === 0)
            this._events = new EventHandlers();
          else {
            delete events[type];
            if (events.removeListener)
              this.emit('removeListener', type, list.listener || listener);
          }
        } else if (typeof list !== 'function') {
          position = -1;

          for (i = list.length; i-- > 0;) {
            if (list[i] === listener ||
                (list[i].listener && list[i].listener === listener)) {
              originalListener = list[i].listener;
              position = i;
              break;
            }
          }

          if (position < 0)
            return this;

          if (list.length === 1) {
            list[0] = undefined;
            if (--this._eventsCount === 0) {
              this._events = new EventHandlers();
              return this;
            } else {
              delete events[type];
            }
          } else {
            spliceOne(list, position);
          }

          if (events.removeListener)
            this.emit('removeListener', type, originalListener || listener);
        }

        return this;
      };
      
  // Alias for removeListener added in NodeJS 10.0
  // https://nodejs.org/api/events.html#events_emitter_off_eventname_listener
  EventEmitter.prototype.off = function(type, listener){
      return this.removeListener(type, listener);
  };

  EventEmitter.prototype.removeAllListeners =
      function removeAllListeners(type) {
        var listeners, events;

        events = this._events;
        if (!events)
          return this;

        // not listening for removeListener, no need to emit
        if (!events.removeListener) {
          if (arguments.length === 0) {
            this._events = new EventHandlers();
            this._eventsCount = 0;
          } else if (events[type]) {
            if (--this._eventsCount === 0)
              this._events = new EventHandlers();
            else
              delete events[type];
          }
          return this;
        }

        // emit removeListener for all listeners on all events
        if (arguments.length === 0) {
          var keys = Object.keys(events);
          for (var i = 0, key; i < keys.length; ++i) {
            key = keys[i];
            if (key === 'removeListener') continue;
            this.removeAllListeners(key);
          }
          this.removeAllListeners('removeListener');
          this._events = new EventHandlers();
          this._eventsCount = 0;
          return this;
        }

        listeners = events[type];

        if (typeof listeners === 'function') {
          this.removeListener(type, listeners);
        } else if (listeners) {
          // LIFO order
          do {
            this.removeListener(type, listeners[listeners.length - 1]);
          } while (listeners[0]);
        }

        return this;
      };

  EventEmitter.prototype.listeners = function listeners(type) {
    var evlistener;
    var ret;
    var events = this._events;

    if (!events)
      ret = [];
    else {
      evlistener = events[type];
      if (!evlistener)
        ret = [];
      else if (typeof evlistener === 'function')
        ret = [evlistener.listener || evlistener];
      else
        ret = unwrapListeners(evlistener);
    }

    return ret;
  };

  EventEmitter.listenerCount = function(emitter, type) {
    if (typeof emitter.listenerCount === 'function') {
      return emitter.listenerCount(type);
    } else {
      return listenerCount.call(emitter, type);
    }
  };

  EventEmitter.prototype.listenerCount = listenerCount;
  function listenerCount(type) {
    var events = this._events;

    if (events) {
      var evlistener = events[type];

      if (typeof evlistener === 'function') {
        return 1;
      } else if (evlistener) {
        return evlistener.length;
      }
    }

    return 0;
  }

  EventEmitter.prototype.eventNames = function eventNames() {
    return this._eventsCount > 0 ? Reflect.ownKeys(this._events) : [];
  };

  // About 1.5x faster than the two-arg version of Array#splice().
  function spliceOne(list, index) {
    for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1)
      list[i] = list[k];
    list.pop();
  }

  function arrayClone(arr, i) {
    var copy = new Array(i);
    while (i--)
      copy[i] = arr[i];
    return copy;
  }

  function unwrapListeners(arr) {
    var ret = new Array(arr.length);
    for (var i = 0; i < ret.length; ++i) {
      ret[i] = arr[i].listener || arr[i];
    }
    return ret;
  }

  class EhRetriever extends EventEmitter {
      constructor(url, html) {
          super();
          const testEXHentaiUrl = /^https?:\/\/(?:e-|ex)hentai\.org\//;
          if (typeof url !== 'string') {
              throw new TypeError('invalid `url`, expected a string');
          }
          if (!testEXHentaiUrl.test(url)) {
              throw new TypeError(`invalid url: ${url}`);
          }
          this.url = url;
          this.html = html;
          this.gallery = { gid: '', token: '' };
          this.referer = url;
          this.showkey = '';
          this.ehentaiHost = testEXHentaiUrl.exec(url)[0].slice(0, -1);
          this.q = new Queue(3, 3000, 1000);
          this.pages = this.init();
          this.pages.then(() => this.emit('ready'));
      }
      async init() {
          if (!this.html) {
              this.html = await this.fetch(this.url).then(res => res.text());
          }
          const galleryURL = this.html.match(/hentai\.org\/g\/(\d+)\/([a-z0-9]+)/i);
          const showkey = this.html.match(/showkey="([^"]+)"/i);
          if (galleryURL) {
              this.gallery.gid = galleryURL[1];
              this.gallery.token = galleryURL[2];
          }
          else {
              throw new Error("Can't get gallery URL");
          }
          if (showkey) {
              this.showkey = showkey[1];
          }
          else {
              throw new Error("Can't get showkey");
          }
          return await this.getAllPageURL();
      }
      async getAllPageURL() {
          const { ehentaiHost, gallery: { gid, token } } = this;
          const firstPage = await this.fetch(`${ehentaiHost}/g/${gid}/${token}`).then(res => res.text());
          let pageNum;
          const pageLinksTable = firstPage.match(/<table[^>]*class="ptt"[^>]*>((?:[^<]*)(?:<(?!\/table>)[^<]*)*)<\/table>/);
          if (pageLinksTable) {
              const pageLinks = pageLinksTable[1].match(/g\/[^/]+\/[^/]+\/\?p=\d+/g);
              if (pageLinks) {
                  pageNum = Math.max.apply(null, pageLinks.map(e => parseInt(/\d+$/.exec(e)[0], 10)));
              }
              else {
                  pageNum = 0;
              }
          }
          else {
              throw new Error('Cant get page numbers');
          }
          const allPages = await Promise.all(Array(pageNum).fill(undefined).map((e, i) => {
              return this.fetch(`${ehentaiHost}/g/${gid}/${token}/?p=${i + 1}`).then(res => res.text());
          }));
          allPages.unshift(firstPage);
          return allPages
              .map(p => this.parsePage(p))
              .reduce((p, c) => p.concat(c), []); // 2d array to 1d
      }
      parsePage(page) {
          const gdtMatcher = /<div[^>]*id="gdt"[^>]*>/g;
          gdtMatcher.exec(page);
          const gtbMatcher = /<div[^>]*class="gtb"[^>]*>/g;
          gtbMatcher.lastIndex = gdtMatcher.lastIndex;
          gtbMatcher.exec(page);
          const gdtContent = page.substring(gdtMatcher.lastIndex, gtbMatcher.lastIndex);
          return Array.from(gdtContent.matchAll(/s\/(\w+)\/\d+-(\d+)/g)).map(([, imgkey, page]) => ({ imgkey, page: parseInt(page, 10) }));
      }
      fetch(url, options = {}) {
          if (typeof url !== 'string') {
              return Promise.reject(new TypeError('invalid `url`, expected a string'));
          }
          if (url.search(/^https?:\/\//) < 0) {
              return Promise.reject(new TypeError(`invalid url: ${url}`));
          }
          const cofetchOptions = {
              method: 'GET',
              credentials: 'include',
              headers: {
                  'User-Agent': navigator.userAgent,
                  Referer: this.referer
              }
          };
          for (const key of Object.keys(options)) {
              if (key === 'headers') {
                  Object.assign(cofetchOptions.headers, options.headers);
              }
              else {
                  cofetchOptions[key] = options[key];
              }
          }
          return this.q.queue((resolve, reject) => {
              COFetch(url, cofetchOptions).then(resolve).catch(reject);
          }, `Fetch ${url} ${JSON.stringify(cofetchOptions)}`);
      }
      async retrieve(start = 0, stop = -1) {
          const pages = await this.pages;
          if (start < 0 || start >= pages.length || isNaN(start)) {
              throw new RangeError(`invalid start number: ${start}`);
          }
          if (stop < 0) {
              stop = pages.length - 1;
          }
          else if (stop < start || stop >= pages.length || isNaN(stop)) {
              throw new RangeError(`invalid stop number: ${stop}, start: ${start}`);
          }
          const retrievePages = pages.slice(start, stop + 1);
          const loadPage = async (e) => {
              if (e.imgsrc && e.filename) {
                  return Promise.resolve(e);
              }
              const fetchPage = await this.fetch(`${this.ehentaiHost}/api.php`, {
                  method: 'POST',
                  headers: { 'Content-Type': 'application/json' },
                  // assign e = {'imgkey': ..., 'page': ...} to object literal {'method': ..., 'gid': ..., 'showkey': ...}
                  // does not modify e
                  body: JSON.stringify(Object.assign({
                      method: 'showpage',
                      gid: this.gallery.gid,
                      showkey: this.showkey
                  }, e))
              }).then(res => res.json());
              this.emit('load', {
                  current: e.page - start,
                  total: stop - start + 1
              });
              return fetchPage;
          };
          const imagePages = await Promise.all(retrievePages.map(loadPage));
          imagePages.forEach((e, i) => {
              retrievePages[i].filename = e.i.match(/>([^:]+):/)[1].trim();
              retrievePages[i].imgsrc = e.i3.match(/src="([^"]+)"/)[1];
              retrievePages[i].failnl = new Set([e.i6.match(/nl\('([^']+)'/)[1]]);
              retrievePages[i].style = e.i3.match(/style="([^"]+)"/)[1];
              retrievePages[i].url = e.s;
          });
          return retrievePages;
      }
      async fail(index) {
          const pages = await this.pages;
          const failPage = pages[index - 1];
          const failnl = [...failPage.failnl.values()].map(e => `nl=${e}`).join('&');
          const res = await this.fetch(`${this.ehentaiHost}/${failPage.url}?${failnl}`).then(res => res.text());
          const parsed = res.match(/<img[^>]*id="img"[^>]*src="([^"]+)"[^>]*.*onclick="return nl\('([^']+)'\)/i);
          if (parsed) {
              failPage.imgsrc = parsed[1];
              failPage.failnl.add(parsed[2]);
              return failPage;
          }
          return null;
      }
  }

  const LoadTimeout = 10000;
  // helper functions
  const $ = (selector) => document.querySelector(selector);
  const $$ = (selector) => Array.from(document.querySelectorAll(selector));
  const buttonsFragment = document.createDocumentFragment();
  const buttonReverse = document.createElement('button');
  const buttonDoubleFrame = document.createElement('button');
  const buttonRetrieve = document.createElement('button');
  const buttonRange = document.createElement('button');
  const buttonFullHeight = document.createElement('button');
  buttonsFragment.appendChild(buttonReverse);
  buttonsFragment.appendChild(buttonDoubleFrame);
  buttonsFragment.appendChild(buttonFullHeight);
  buttonsFragment.appendChild(buttonRetrieve);
  buttonsFragment.appendChild(buttonRange);
  buttonReverse.textContent = 'Reverse';
  buttonDoubleFrame.textContent = 'Double Frame';
  buttonRetrieve.textContent = 'Retrieve!';
  buttonRange.textContent = 'Set Range';
  buttonFullHeight.textContent = 'View Height';
  $('#i1').insertBefore(buttonsFragment, $('#i2'));
  let ehentaiResize;
  let originalWidth;
  let ehr;
  let showHiddenImageLink = false;
  const reload = (event) => {
      event.stopPropagation();
      event.preventDefault();
      const target = event.target;
      if (target.dataset.locked === 'true') {
          return;
      }
      target.dataset.locked = 'true';
      ehr.fail(parseInt(target.dataset.page, 10)).then(imgInfo => {
          target.src = imgInfo.imgsrc;
          target.parentElement.href = imgInfo.imgsrc;
          target.dataset.locked = 'false';
      });
  };
  const showImage = (event) => {
      event.stopPropagation();
      event.preventDefault();
      $$('#i3 a').forEach(e => {
          e.classList.remove('hidden');
      });
      event.target.remove();
      showHiddenImageLink = false;
  };
  const hideImage = (event) => {
      event.stopPropagation();
      event.preventDefault();
      event.target.parentElement.classList.add('hidden');
      if (!showHiddenImageLink) {
          const showHiddenImage = document.createElement('a');
          showHiddenImage.href = '';
          showHiddenImage.textContent = 'show hidden image';
          showHiddenImage.classList.add('show-hidden');
          showHiddenImage.addEventListener('click', showImage);
          buttonRetrieve.insertAdjacentElement('afterend', showHiddenImage);
          showHiddenImageLink = true;
      }
  };
  const swapImage = (event) => {
      event.stopPropagation();
      event.preventDefault();
      const right = event.target.parentElement;
      const left = right.previousElementSibling;
      if (left) {
          left.parentElement.insertBefore(right, left);
      }
  };
  const scrollNextImage = (event) => {
      if (event.keyCode !== 9) {
          return;
      }
      event.preventDefault();
      const bodyOffset = document.body.getBoundingClientRect().top;
      const pageY = window.pageYOffset;
      const imgs = Array.from($$('#i3 a img'));
      const isLast = !imgs.some((e, i) => {
          const imgOffset = e.getBoundingClientRect().top - bodyOffset;
          if (pageY - imgOffset < -1) {
              window.scrollTo(0, imgOffset);
              return true;
          }
      });
      if (isLast) {
          window.scrollTo(0, imgs[0].getBoundingClientRect().top - bodyOffset);
      }
  };
  buttonReverse.addEventListener('click', event => {
      const i3 = $('#i3');
      const imgs = $$('#i3 > a[data-page]');
      if (buttonReverse.textContent === 'Reverse') {
          buttonReverse.textContent = 'Original Order';
          imgs
              .sort((a, b) => a.offsetTop - b.offsetTop)
              .reduce((p, c) => {
              const l = p.at(-1);
              if (!l || l.at(-1).offsetTop !== c.offsetTop) {
                  return [...p, [c]];
              }
              l.push(c);
              return p;
          }, [])
              .flatMap(e => e.reverse())
              .forEach(e => i3.appendChild(e));
      }
      else {
          buttonReverse.textContent = 'Reverse';
          imgs
              .sort((a, b) => parseInt(a.dataset.page, 10) - parseInt(b.dataset.page, 10))
              .forEach(e => i3.appendChild(e));
      }
  });
  buttonDoubleFrame.addEventListener('click', event => {
      if (!ehentaiResize) {
          try {
              ehentaiResize = unsafeWindow.onresize;
          }
          catch (e) {
              console.log(e);
          }
      }
      const imgWidths = $$('#i3 a:not(.hidden) img').map(e => e.getBoundingClientRect().width);
      const avg = imgWidths.reduce((p, c) => p + c) / imgWidths.length;
      const filtered = imgWidths.filter(v => (v < avg * 1.5 && v > avg * 0.5));
      const filteredMax = Math.max.apply(null, filtered);
      if (!originalWidth) {
          originalWidth = parseInt($('#i1').style.width, 10);
      }
      if (buttonDoubleFrame.textContent === 'Double Frame') {
          buttonDoubleFrame.textContent = 'Reset Frame';
          try {
              unsafeWindow.onresize = null;
          }
          catch (e) {
              console.log(e);
          }
          $('#i1').style.maxWidth = (filteredMax * 2 + 20) + 'px';
          $('#i1').style.width = (filteredMax * 2 + 20) + 'px';
      }
      else {
          buttonDoubleFrame.textContent = 'Double Frame';
          try {
              unsafeWindow.onresize = ehentaiResize;
              ehentaiResize();
          }
          catch (e) {
              console.log(e);
              $('#i1').style.maxWidth = originalWidth + 'px';
              $('#i1').style.width = originalWidth + 'px';
          }
      }
  });
  buttonRetrieve.addEventListener('click', event => {
      buttonRetrieve.setAttribute('disabled', '');
      buttonRange.setAttribute('disabled', '');
      buttonRetrieve.textContent = 'Initializing...';
      if (!ehr) {
          ehr = new EhRetriever(location.href, document.body.innerHTML);
      }
      ehr.on('ready', () => {
          buttonRetrieve.textContent = `Ready to retrieve`;
      });
      ehr.on('load', (progress) => {
          buttonRetrieve.textContent = `Retrieving ${progress.current}/${progress.total}`;
      });
      let retrieve;
      if ($('#ehrstart')) {
          const start = parseInt($('#ehrstart').value, 10);
          const stop = parseInt($('#ehrstop').value, 10);
          const pageNumMax = parseInt($('div.sn').textContent.match(/\/\s*(\d+)/)[1], 10);
          if (stop < start || start <= 0 || start > pageNumMax || stop > pageNumMax) {
              window.alert(`invalid range: ${start} - ${stop}, accepted range: 1 - ${pageNumMax}`);
              buttonRetrieve.textContent = 'Retrieve!';
              buttonRetrieve.removeAttribute('disabled');
              return;
          }
          retrieve = ehr.retrieve(start - 1, stop - 1);
          $('#ehrsetrange').remove();
      }
      else {
          retrieve = ehr.retrieve();
          buttonRange.remove();
      }
      retrieve.then(pages => {
          $('#i3 a').remove();
          const template = document.createElement('template');
          template.innerHTML = pages
              .map(e => `
        <a href="${e.imgsrc}" data-page="${e.page}">
          <img src="${e.imgsrc}" style="${e.style}" data-page="${e.page}" data-locked="false" />
          <div class="close"></div>
          <div class="swap"></div>
          <div class="page-number">${e.page}</div>
        </a>`)
              .join('');
          template.content.querySelectorAll('img').forEach(e => {
              e.addEventListener('error', function onError(event) {
                  e.removeEventListener('error', onError);
                  reload(event);
              });
              let timeout;
              {
                  timeout = window.setTimeout(() => {
                      console.log(`timeout: page ${e.dataset.page}`);
                      const clickEvent = new MouseEvent('click', {
                          bubbles: true,
                          cancelable: true,
                          view: window
                      });
                      e.dispatchEvent(clickEvent);
                  }, LoadTimeout);
                  e.addEventListener('load', function onload() {
                      e.removeEventListener('load', onload);
                      clearTimeout(timeout);
                  });
              }
          });
          $('#i3').appendChild(template.content);
          $('#i3').addEventListener('click', event => {
              if (event.target.nodeName === 'IMG') {
                  reload(event);
              }
              else if (event.target.classList.contains('close')) {
                  hideImage(event);
              }
              else if (event.target.classList.contains('swap')) {
                  swapImage(event);
              }
              else if (event.target.classList.contains('page-number')) {
                  event.preventDefault();
                  event.stopPropagation();
              }
          });
          buttonRetrieve.textContent = 'Done!';
          buttonDoubleFrame.removeAttribute('disabled');
          buttonFullHeight.removeAttribute('disabled');
          document.onkeydown = null;
          document.addEventListener('keydown', scrollNextImage);
      }).catch(e => { console.log(e); });
  });
  buttonRange.addEventListener('click', event => {
      // override e-hentai's viewing shortcut
      document.onkeydown = undefined;
      const pageNum = $('div.sn').textContent.match(/(\d+)\s*\/\s*(\d+)/).slice(1);
      buttonRange.insertAdjacentHTML('afterend', `<span id="ehrsetrange"><input type="number" id="ehrstart" value="${pageNum[0]}" min="1" max="${pageNum[1]}"> - <input type="number" id="ehrstop" value="${pageNum[1]}" min="1" max="${pageNum[1]}"></span>`);
      buttonRange.remove();
  });
  buttonFullHeight.addEventListener('click', event => {
      $('#i3').classList.toggle('force-img-full-height');
  });

})();