Greasy Fork is available in English.

Grabber

Grab links from 9anime!

// ==UserScript==
// @name        Grabber
// @namespace   https://github.com/lap00zza/
// @version     1.0.0
// @description Grab links from 9anime!
// @author      Jewel Mahanta
// @icon        https://image.ibb.co/fnOY7k/icon48.png
// @match       *://9anime.to/watch/*
// @match       *://9anime.is/watch/*
// @match       *://9anime.tv/watch/*
// @grant       GM_addStyle
// @grant       GM_xmlhttpRequest
// @grant       GM_setClipboard
// @license     MIT License
// ==/UserScript==

/******/ (function(modules) { // webpackBootstrap
/******/ 	// The module cache
/******/ 	var installedModules = {};
/******/
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/
/******/ 		// Check if module is in cache
/******/ 		if(installedModules[moduleId]) {
/******/ 			return installedModules[moduleId].exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = installedModules[moduleId] = {
/******/ 			i: moduleId,
/******/ 			l: false,
/******/ 			exports: {}
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ 		// Flag the module as loaded
/******/ 		module.l = true;
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/
/******/
/******/ 	// expose the modules object (__webpack_modules__)
/******/ 	__webpack_require__.m = modules;
/******/
/******/ 	// expose the module cache
/******/ 	__webpack_require__.c = installedModules;
/******/
/******/ 	// define getter function for harmony exports
/******/ 	__webpack_require__.d = function(exports, name, getter) {
/******/ 		if(!__webpack_require__.o(exports, name)) {
/******/ 			Object.defineProperty(exports, name, {
/******/ 				configurable: false,
/******/ 				enumerable: true,
/******/ 				get: getter
/******/ 			});
/******/ 		}
/******/ 	};
/******/
/******/ 	// getDefaultExport function for compatibility with non-harmony modules
/******/ 	__webpack_require__.n = function(module) {
/******/ 		var getter = module && module.__esModule ?
/******/ 			function getDefault() { return module['default']; } :
/******/ 			function getModuleExports() { return module; };
/******/ 		__webpack_require__.d(getter, 'a', getter);
/******/ 		return getter;
/******/ 	};
/******/
/******/ 	// Object.prototype.hasOwnProperty.call
/******/ 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ 	// __webpack_public_path__
/******/ 	__webpack_require__.p = "";
/******/
/******/ 	// Load entry module and return exports
/******/ 	return __webpack_require__(__webpack_require__.s = 1);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {

"use strict";


Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.fileSafeString = fileSafeString;
exports.createMetadataFile = createMetadataFile;
exports.pad = pad;
exports.qParams = qParams;
exports.getURL = getURL;
exports.searchParams2Obj = searchParams2Obj;
exports.mergeObject = mergeObject;
exports.ajaxGet = ajaxGet;
/* eslint prefer-arrow-callback: "error" */
/* eslint-env es6 */

/**
 * Just as the function name says!
 * We remove the illegal characters.
 * @param filename
 * @returns {string}
 */
function fileSafeString(filename) {
  var re = /[\\/<>*?:"|]/gi;
  return filename.replace(re, '');
}

// metadataUrl is a part of createMetadataFile
var metadataUrl = null;
/**
 * This functions generates the blob for the `metadata.json`
 * file and returns an url to this blob.
 * @param {object} metadata
 * @returns {*}
 */
function createMetadataFile(metadata) {
  var data = new window.Blob([JSON.stringify(metadata, null, '\t')], { type: 'text/json' });
  // If we are replacing a previously generated
  // file we need to manually revoke the object
  // URL to avoid memory leaks.
  if (metadataUrl !== null) {
    window.URL.revokeObjectURL(metadataUrl);
  }
  metadataUrl = window.URL.createObjectURL(data);
  return metadataUrl;
}

/**
 * Generates a 3 digit episode id from the given
 * id. This is id is helpful while sorting files.
 * @param {string} num - The episode id
 * @returns {string} - The 3 digit episode id
 */
function pad(num) {
  if (num.length >= 3) {
    return num;
  } else {
    return ('000' + num).slice(-3);
  }
}

/**
 * Generate the query parameter string from an
 * object.
 * @param {object} params
 * @returns {string}
 */
function qParams(params) {
  var qParams = '';
  var dKeys = Object.keys(params);
  for (var i = 0; i < dKeys.length; i++) {
    if (i === 0) {
      qParams += dKeys[i] + '=' + params[dKeys[i]];
    } else {
      qParams += '&' + dKeys[i] + '=' + params[dKeys[i]];
    }
  }
  return qParams;
}

// parser is a part of getURL
var parser = exports.parser = document.createElement('a');
/**
 * Get a url from a uri string.
 * Credits to jlong for this implementation idea:
 * https://gist.github.com/jlong/2428561
 * @param {string} uriString
 * @returns {string}
 */
function getURL(uriString) {
  parser.href = uriString;
  return parser.protocol + '//' + parser.hostname + parser.pathname;
}

/**
 * Converts the searchParams in then uri string to
 * an object.
 * @param {string} uriString
 * @returns {object}
 */
function searchParams2Obj(uriString) {
  parser.href = uriString;
  // HTMLHyperlinkElementUtils.search returns a search
  // string, also called a query string containing a '?'
  // followed by the parameters of the URL. We don't need
  // the '?' so we slice it.
  var searchParams = parser.search.slice(1);
  // All search params are delimited by '&'.
  // So we split them into an array and iterate
  // through it to get the keys and values.
  var search = searchParams.split('&');
  var searchObj = {};
  for (var i = 0; i < search.length; i++) {
    var searchSplit = search[i].split('=');
    if (searchSplit[0] !== '' && searchSplit[1] !== undefined) {
      searchObj[searchSplit[0]] = searchSplit[1];
    }
  }
  return searchObj;
}

/**
 * A simple helper function that merges 2 objects.
 * @param {object} obj1
 * @param {object} obj2
 * @returns {object}
 */
function mergeObject(obj1, obj2) {
  var obj3 = {};
  for (var a in obj1) {
    if (obj1.hasOwnProperty(a)) {
      obj3[a] = obj1[a];
    }
  }
  for (var b in obj2) {
    if (obj2.hasOwnProperty(b)) {
      obj3[b] = obj2[b];
    }
  }
  return obj3;
}

/**
 * Promise based AJAX Get.
 * @param {string} url
 * @param {object} params
 * @returns {Promise}
 */
function ajaxGet(url, params) {
  return new Promise(function (resolve, reject) {
    var xhr = new window.XMLHttpRequest();
    xhr.open('GET', url + '?' + qParams(params), true);
    xhr.onload = function () {
      if (xhr.status === 200) {
        try {
          resolve(xhr.responseText);
        } catch (e) {
          reject(e);
        }
      } else {
        reject(xhr.statusText);
      }
    };
    xhr.onerror = function () {
      reject(xhr.statusText);
    };
    xhr.send();
  });
}

/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {

"use strict";


var _api = __webpack_require__(2);

var api = _interopRequireWildcard(_api);

var _style = __webpack_require__(3);

var _style2 = _interopRequireDefault(_style);

var _utils = __webpack_require__(0);

var utils = _interopRequireWildcard(_utils);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }

console.log('Grabber ' + GM_info.script.version + ' is now running!');

// Welcome folks! This is the main script for Grabber.
// Below are a few terminologies that you will find
// helpful.
// dl -> Download
// rv -> RapidVideo
// 9a -> 9anime

/* global GM_info, GM_setClipboard */
/* eslint prefer-arrow-callback: "error" */
/* eslint-env es6 */

var dlInProgress = false; // global switch to indicate dl status
var dlEpisodeIds = []; // list of id's currently being grabbed
var dlServerType = '';
var dlAggregateLinks = ''; // stores all the grabbed links as a single string
var dlQuality = '360p'; /* preferred quality */
var ts = document.getElementsByTagName('body')[0].dataset['ts']; // ts is needed to send API requests
var animeName = document.querySelectorAll('h1.title')[0].innerHTML;
var metadata = {
  animeName: animeName,
  animeUrl: window.location.href,
  files: []

  // Apply styles
};(0, _style2.default)();

// Append the status bar
var servers = document.getElementById('servers');
var statusContainer = document.createElement('div');
statusContainer.classList.add('grabber__notification');
statusContainer.innerHTML = '<span>Grabber \u2605</span>\n  <span>Quality:</span>\n  <select id="grabber__quality">\n      <option value="360p">360p</option>\n      <option value="480p">480p</option>\n      <option value="720p">720p</option>\n      <option value="1080p">1080p</option>\n  </select>\n  \u2713\n  <span>Status:</span>\n  <div id="grabber__status">ready! Press Grab All to start.</div>\n  <div id="grabber__links-box">\n    <span class="links_header">The completed links are in the box below and also copied to your clipboard.</span>\n    <textarea id="grabber__links" readonly></textarea>\n    <button class="grabber__btn" id="grabber__copy">Copy to clipboard</button>\n    <button class="grabber__btn" id="grabber__hide-links-box">Hide links</button>\n  </div>';

// Attach the status container
servers.insertBefore(statusContainer, servers.firstChild);

// Add functionality for te copy button and the hide links button
document.getElementById('grabber__hide-links-box').addEventListener('click', function () {
  document.getElementById('grabber__links-box').style.display = 'none';
});
document.getElementById('grabber__copy').addEventListener('click', function () {
  document.getElementById('grabber__links').select();
  document.execCommand('copy');
});

/**
 * A small helper function to add a message on the status bar.
 * @param {string} message
 */
function status(message) {
  document.getElementById('grabber__status').innerHTML = message;
}

/**
 * Set the download links on the textarea and show the links-box
 * @param links
 */
function setLinks(links) {
  document.getElementById('grabber__links').value = links;
  document.getElementById('grabber__links-box').style.display = 'block';
}

// Disable inputs when grabbing begins.
function disableInputs() {
  document.getElementById('grabber__quality').setAttribute('disabled', 'disabled');
  var btns = document.getElementsByClassName('grabber__btn');
  for (var i = 0; i < btns.length; i++) {
    btns[i].setAttribute('disabled', 'disabled');
  }
}

// Enable inputs once grabbing is done.
function enableInputs() {
  document.getElementById('grabber__quality').removeAttribute('disabled');
  var btns = document.getElementsByClassName('grabber__btn');
  for (var i = 0; i < btns.length; i++) {
    btns[i].removeAttribute('disabled');
  }
}

/**
 * Prepares the metadata by adding some more relevant
 * keys, generates the metadata.json and appends it to
 * the status bar.
 */
function prepareMetadata() {
  metadata['timestamp'] = new Date().toISOString();
  metadata['server'] = dlServerType;
  var a = document.createElement('a');
  a.href = utils.createMetadataFile(metadata);
  a.id = 'grabber__metadata-link';
  a.appendChild(document.createTextNode('metadata.json'));
  a.download = 'metadata.json';
  statusContainer.appendChild(a);
}

/**
 * This function requeue's the processGrabber to run after
 * 2 seconds to avoid overloading the 9anime API and/or
 * getting our IP flagged as bot. Once all the episodes are
 * done, and if the server was RapidVideo, a link to
 * download 'metadata.json' is added. This can then be used
 * by other programs or the user to rename the files.
 */
function requeue() {
  if (dlEpisodeIds.length !== 0) {
    window.dlTimeout = setTimeout(processGrabber, 2000);
  } else {
    // Metadata only for RapidVideo
    if (dlServerType === 'RapidVideo') {
      // prepare the metadata
      prepareMetadata();
    }

    clearTimeout(window.dlTimeout);
    dlInProgress = false;
    enableInputs(); /* Enable the buttons and quality select */
    status('All done~');
    setLinks(dlAggregateLinks);
    GM_setClipboard(dlAggregateLinks);
  }
}

/***
 * This is the main function that handles the
 * entire grabbing process. It is scheduled to
 * run every 2 seconds by requeue.
 * @todo: refactor this function and make it cleaner
 */
function processGrabber() {
  var ep = dlEpisodeIds.shift();
  status('Fetching ' + ep.num);

  var params = {
    ts: ts,
    id: ep.id,
    update: 0
  };

  api.grabber(params).then(function (resp) {
    switch (dlServerType) {
      case 'RapidVideo':
        api.videoLinksRV(resp['target']).then(function (resp) {
          dlAggregateLinks += encodeURI(resp[0]['file']) + '\n';
          var fileSafeName = utils.fileSafeString(animeName + '-ep_' + ep.num + '-' + resp[0]['label'] + '.mp4');
          // Metadata only for RapidVideo
          metadata.files.push({
            original: api.rvOriginal(resp[0]['file']),
            real: fileSafeName
          });
          status('Completed ' + ep.num);
          requeue();
        }).catch(function (err) {
          console.debug(err);
          status('<span class="grabber--fail">Failed ' + ep.num + '</span>');
          requeue();
        });
        break;

      case '9anime':
        var data = {
          ts: ts,
          id: resp['params']['id'],
          options: resp['params']['options'],
          token: resp['params']['token'],
          mobile: 0
        };
        api.videoLinks9a(data, resp['grabber']).then(function (resp) {
          // resp is of the format
          // {data: [{file: '', label: '', type: ''}], error: null, token: ''}
          // data contains the files array.
          var data = resp['data'];
          for (var i = 0; i < data.length; i++) {
            // NOTE: this part is basically making sure that we only get
            // links for the quality we select. Not all of them. If the
            // preferred quality is not present it wont grab any.
            if (data[i]['label'] === dlQuality) {
              var title = utils.fileSafeString(animeName + '-ep_' + ep.num + '-' + data[i]['label']);
              dlAggregateLinks += encodeURI(data[i]['file'] + '?&title=' + title + '&type=video/' + data[i]['type']) + '\n';
            }
          }
          status('Completed ' + ep.num);
          requeue();
        }).catch(function (err) {
          console.debug(err);
          status('<span class="grabber--fail">Failed ' + ep.num + '</span>');
          requeue();
        });
        break;
    }
  }).catch(function (err) {
    console.debug(err);
    status('<span class="grabber--fail">Failed ' + ep.num + '</span>');
    requeue();
  });
}

/***
 * Generates a nice looking 'Grab All' button that are
 * added below the server labels.
 * @param {string} type
 *    The server type to generate this button for. Example:
 *    RapidVideo, Openload etc. Currently only support
 *    grabbing RapidVideo links.
 * @returns {Element}
 *    'Grab All' button for specified server
 */
function generateDlBtn(type) {
  var dlBtn = document.createElement('button');
  dlBtn.dataset['type'] = type;
  dlBtn.classList.add('grabber__btn');
  dlBtn.appendChild(document.createTextNode('Grab All'));

  // 1> Click handler
  dlBtn.addEventListener('click', function () {
    var serverDiv = this.parentNode.parentNode;
    var epLinks = serverDiv.getElementsByTagName('a');
    for (var i = 0; i < epLinks.length; i++) {
      dlEpisodeIds.push({
        num: utils.pad(epLinks[i].dataset['base']),
        id: epLinks[i].dataset['id']
      });
    }
    if (!dlInProgress) {
      status('starting grabber...');
      dlServerType = this.dataset['type'];
      dlInProgress = true;
      dlAggregateLinks = '';
      dlQuality = document.getElementById('grabber__quality').value;
      disableInputs(); /* disable the buttons and quality select */
      var mLink = document.getElementById('grabber__metadata-link');
      if (mLink) statusContainer.removeChild(mLink);
      // Metadata only for RapidVideo
      if (dlServerType === 'RapidVideo') metadata.files = [];
      processGrabber();
    }
  });
  return dlBtn;
}

// Attach the 'Grab All' button to RapidVideo for now.
var serverLabels = document.querySelectorAll('.server.row > label');
for (var i = 0; i < serverLabels.length; i++) {
  // Remove the leading and trailing whitespace
  // from the server labels.
  var serverLabel = serverLabels[i].innerText.trim();
  if (/RapidVideo/i.test(serverLabel)) {
    serverLabels[i].appendChild(generateDlBtn('RapidVideo'));
  } else if (/Server\s+F/i.test(serverLabel)) {
    serverLabels[i].appendChild(generateDlBtn('9anime'));
  }
}

/***/ }),
/* 2 */
/***/ (function(module, exports, __webpack_require__) {

"use strict";


Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.generateToken = generateToken;
exports.grabber = grabber;
exports.videoLinks9a = videoLinks9a;
exports.videoLinksRV = videoLinksRV;
exports.rvOriginal = rvOriginal;

var _utils = __webpack_require__(0);

var utils = _interopRequireWildcard(_utils);

function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }

// The parts/functions marked as [*] are part of
// 9anime encryption scheme. If they make no sense
// (and they probably should not anyway), just skip
// to the parts after it.

var DD = 'gIXCaNh'; // This might change in the future

// [*]
/* global GM_xmlhttpRequest */
/* eslint prefer-arrow-callback: "error" */
/* eslint-env es6 */

function s(t) {
  var e = void 0;
  var i = 0;
  for (e = 0; e < t.length; e++) {
    i += t.charCodeAt(e) * e + e;
  }
  return i;
}

// [*]
function a(t, e) {
  var i = void 0;
  var n = 0;
  for (i = 0; i < Math.max(t.length, e.length); i++) {
    n += i < e.length ? e.charCodeAt(i) : 0;
    n += i < t.length ? t.charCodeAt(i) : 0;
  }
  return Number(n).toString(16);
}

// [*]
function generateToken(data, initialState) {
  var keys = Object.keys(data);
  var _ = s(DD) + (initialState || 0);
  for (var i = 0; i < keys.length; i++) {
    var key = keys[i];
    var trans = a(DD + key, data[key].toString());
    _ += s(trans);
  }
  return _ - 30;
}

/**
 * Get the grabber info from the 9anime API.
 * @param {object} params
 *    A list of query parameters to send to the API.
 * @returns {Promise}
 */
function grabber(params) {
  params['_'] = generateToken(params);

  return new Promise(function (resolve, reject) {
    utils.ajaxGet('/ajax/episode/info', params).then(function (resp) {
      resolve(JSON.parse(resp));
    }).catch(function (err) {
      reject(err);
    });
  });
}

/**
 * Fetch 9anime video links for Server F4 etc.
 *    The 9anime url to grab videos
 * @param {object} data
 *    A list of query parameters to send to the API.
 * @param {string} grabberUri
 * @returns {Promise}
 */
function videoLinks9a(data, grabberUri) {
  var url = utils.getURL(grabberUri);
  // The grabber url has additional search params
  // we need to add those to 'data' before generating
  // the token.
  var sParams = utils.searchParams2Obj(grabberUri);
  var merged = utils.mergeObject(data, sParams);
  var initState = s(a(DD + url, ''));
  merged['_'] = generateToken(merged, initState);

  return new Promise(function (resolve, reject) {
    utils.ajaxGet(url, merged).then(function (resp) {
      resolve(JSON.parse(resp));
    }).catch(function (err) {
      reject(err);
    });
  });
}

/**
 * This function does the following
 * 1. fetch the RapidVideo page
 * 2. regex match and get the video sources
 * 3. get the video links
 * @param {string} url - The RapidVideo url to grab videos
 * @returns {Promise}
 */
function videoLinksRV(url) {
  var re = /("sources": \[)(.*)(}])/g;

  return new Promise(function (resolve, reject) {
    // We are using GM_xmlhttpRequest since we need to make
    // cross origin requests.
    GM_xmlhttpRequest({
      method: 'GET',
      url: url,
      onload: function onload(response) {
        try {
          var blob = response.responseText.match(re)[0];
          var parsed = JSON.parse('{' + blob + '}');
          // the parsed structure is like this
          // {
          //   sources: [
          //     {default: "true", file: "FILE_URL", label: "720p", res: "720"}
          //   ]
          // }
          resolve(parsed['sources']);
        } catch (e) {
          reject(e);
        }
      },
      onerror: function onerror(response) {
        reject(response.responseText);
      }
    });
  });
}

/**
 * Generates the name of the original mp4 file (RapidVideo).
 * @param {string} url
 * @returns {*}
 */
function rvOriginal(url) {
  var re = /\/+[a-z0-9]+.mp4/gi;
  var match = url.match(re);
  if (match.length > 0) {
    // since the regex us something like this
    // "/806FH0BFUQHP1LBGPWPZM.mp4" we need to
    // remove the starting slash
    return match[0].slice(1);
  } else {
    return '';
  }
}

/***/ }),
/* 3 */
/***/ (function(module, exports, __webpack_require__) {

"use strict";


Object.defineProperty(exports, "__esModule", {
    value: true
});
exports.default = applyStyle;
/* global GM_addStyle */

var styles = "\n  #grabber__metadata-link {\n      margin-left: 5px;\n  }\n  .grabber--fail {\n      color: indianred;\n  }\n  .grabber__btn {\n      border: 1px solid #555;\n      border-radius: 2px;\n      background-color: #16151c;\n      color: #888;\n      padding: 1px 5px 1px 5px;\n      margin-top: 5px;\n  }\n  .grabber__btn:hover {\n      background-color: #111111;\n  }\n  .grabber__btn:active {\n      background-color: #151515;\n  }\n\n  .grabber__btn:disabled {\n      color: #888;\n      background-color: #222;\n  }\n\n  .grabber__notification {\n      padding: 0 10px;\n      margin-bottom: 10px;\n      color: #888;\n  }\n  .grabber__notification > span {\n      display: inline-block;\n      font-weight: 500;\n  }\n  .grabber__notification > #grabber__status {\n      margin-left: 5px;\n      display: inline-block;\n      color: #888;\n  }\n  #grabber__quality {\n      background: inherit;\n      border-radius: 2px;\n      color: #888;\n      border: 1px solid #555;\n  }\n\n  .links_header {\n    color: #5e5e5e;\n  }\n  #grabber__links-box {\n    display: none;\n    border-bottom: 1px solid #1e1c25;\n    padding-bottom: 10px;\n  }\n  #grabber__links {\n     width: 100%;\n     height: 200px;\n     background: #0f0e13;\n     color: #9a9a9a;\n     border: 5px solid #1e1c25;\n     border-radius: 5px;\n     margin-top: 5px;\n     padding: 10px;\n     resize: none;\n  }\n\n  #grabber__quality:disabled {\n      background: #222;\n      color: #888;\n  }\n  #grabber__quality > option {\n      background: #16151c;\n  }\n  ";
function applyStyle() {
    GM_addStyle(styles);
}

/***/ })
/******/ ]);