etc / E-Hentai Automated Downloads

// ==UserScript==
// @name           E-Hentai Automated Downloads
// @description    Automates downloads through the Doggie Bag Archiver
// @include        http://g.e-hentai.org/*
// @include        http://exhentai.org/*
// @include        https://exhentai.org/*
// @grant          GM_xmlhttpRequest
// @run-at         document-start
// @author         etc
// @version        2.0.1
// @namespace      https://greasyfork.org/users/2168
// ==/UserScript==

/* * * * * promise-polyfill * * * * */

// https://github.com/taylorhakes/promise-polyfill
// Copyright (c) 2014 Taylor Hakes
// Copyright (c) 2014 Forbes Lindesay
// MIT License
!function(e){function n(){}function t(e,n){return function(){e.apply(n,arguments)}}function o(e){if("object"!=typeof this)throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=void 0,this._deferreds=[],s(e,this)}function i(e,n){for(;3===e._state;)e=e._value;return 0===e._state?void e._deferreds.push(n):(e._handled=!0,void o._immediateFn(function(){var t=1===e._state?n.onFulfilled:n.onRejected;if(null===t)return void(1===e._state?r:u)(n.promise,e._value);var o;try{o=t(e._value)}catch(i){return void u(n.promise,i)}r(n.promise,o)}))}function r(e,n){try{if(n===e)throw new TypeError("A promise cannot be resolved with itself.");if(n&&("object"==typeof n||"function"==typeof n)){var i=n.then;if(n instanceof o)return e._state=3,e._value=n,void f(e);if("function"==typeof i)return void s(t(i,n),e)}e._state=1,e._value=n,f(e)}catch(r){u(e,r)}}function u(e,n){e._state=2,e._value=n,f(e)}function f(e){2===e._state&&0===e._deferreds.length&&o._immediateFn(function(){e._handled||o._unhandledRejectionFn(e._value)});for(var n=0,t=e._deferreds.length;n<t;n++)i(e,e._deferreds[n]);e._deferreds=null}function c(e,n,t){this.onFulfilled="function"==typeof e?e:null,this.onRejected="function"==typeof n?n:null,this.promise=t}function s(e,n){var t=!1;try{e(function(e){t||(t=!0,r(n,e))},function(e){t||(t=!0,u(n,e))})}catch(o){if(t)return;t=!0,u(n,o)}}var a=setTimeout;o.prototype["catch"]=function(e){return this.then(null,e)},o.prototype.then=function(e,t){var o=new this.constructor(n);return i(this,new c(e,t,o)),o},o.all=function(e){var n=Array.prototype.slice.call(e);return new o(function(e,t){function o(r,u){try{if(u&&("object"==typeof u||"function"==typeof u)){var f=u.then;if("function"==typeof f)return void f.call(u,function(e){o(r,e)},t)}n[r]=u,0===--i&&e(n)}catch(c){t(c)}}if(0===n.length)return e([]);for(var i=n.length,r=0;r<n.length;r++)o(r,n[r])})},o.resolve=function(e){return e&&"object"==typeof e&&e.constructor===o?e:new o(function(n){n(e)})},o.reject=function(e){return new o(function(n,t){t(e)})},o.race=function(e){return new o(function(n,t){for(var o=0,i=e.length;o<i;o++)e[o].then(n,t)})},o._immediateFn="function"==typeof setImmediate&&function(e){setImmediate(e)}||function(e){a(e,0)},o._unhandledRejectionFn=function(e){"undefined"!=typeof console&&console&&console.warn("Possible Unhandled Promise Rejection:",e)},o._setImmediateFn=function(e){o._immediateFn=e},o._setUnhandledRejectionFn=function(e){o._unhandledRejectionFn=e},"undefined"!=typeof module&&module.exports?module.exports=o:e.Promise||(e.Promise=o)}(this);

/* * * * * Resources * * * * */

var icons = {
    download : 'M8.037,11.166L14.5,22.359c0.825,1.43,2.175,1.43,3,0l6.463-11.194c0.826-1.429,0.15-2.598-' +
               '1.5-2.598H9.537C7.886,8.568,7.211,9.737,8.037,11.166z',
    torrent  : 'M22.404,13.585c0-5.319-4.313-9.631-9.632-9.631c-5.32,0-9.632,4.313-9.632,9.631c0,4.1,2.5' +
               '67,7.593,6.177,8.982l-1.818-8.45l-0.514-2.388L6.075,7.505L9.6,6.746l1.303,6.059c0.352,1.636,1.0' +
               '94,2.514,2.316,2.25c0.967-0.208,1.377-0.995,1.487-1.597c0.049-0.228,0.013-0.51-0.047-0.786l-1.4' +
               '43-6.705L16.74,5.21l1.646,7.651c0.662,3.077,2.454,3.548,2.454,3.548s-2.419,0.521-3.433,0.738c-1' +
               '.012,0.219-1.694-1.591-1.694-1.591l-0.07,0.015c-0.288,0.785-0.613,2.06-3.127,2.602c-0.184,0.039' +
               '-0.364,0.064-0.542,0.083l1.064,4.948C18.232,23.063,22.404,18.814,22.404,13.585z',
    picker   : 'M22.727,18.242L4.792,27.208l8.966-8.966l-4.483-4.484l17.933-8.966l-8.966,8.966L22.727,18.242z',
    done     : 'M2.379,14.729 5.208,11.899 12.958,19.648 25.877,6.733 28.707,9.561 12.958,25.308z'
};

var loadingGIF =
        'R0lGODlhEgASAMQaAHl5d66urMXFw3l5dpSUk5WVlKOjoq+vrsbGw6Sko7u7uaWlpbm5t3h4doiIhtLSz4aGhJaWlsbGxNHRzrC' +
        'wr5SUkqKiobq6uNHRz4eHhf///wAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFCgAaACwAAAAAEgASAAAFaq' +
        'AmjmRplstyrkmbrCNFaUZtaFF0HvyhWRZNYVgwBY4BEmFJOB1NlYpJoYBpHI7RZXtZZb4ZEbd7AodFDIYVAjFJJCYA4ISoI0hyu' +
        'UnAF2geDxoDgwMnfBoYiRgaDQ1WiIqPJBMTkpYaIQAAIfkEBQoAGgAsAQABABAAEAAABWSgJo4aRZEoeaxHOiqKFsyBtizopV9y' +
        'nfwJ0o43MhgNKAYjZbGQJBLXKBLRIK4IaWFbEHgFUoKYoPFKRZUK6fFIORwojBxDytgzpDkdANDc8SQTExp8fBoQEGcDiwNnJA0' +
        'NLiEAACH5BAUKABoALAEAAQAQABAAAAVloCaOmqKQKHmtVzpKksa2FIUiOKIxjHb8B5JgKCAFjgHUMHUkPR6u0WKhwVgx0YQ2cc' +
        'W6DGCDZjKJiiwWEgCQikRQ6zWpQC+QBviBxuHQEP4EKA0NGhmGGRoVFWaHiGYjEBAuIQAAIfkEBQoAGgAsAQABABAAEAAABWSgJ' +
        'o6aJJEoiaxIOj6PJsyCpigopmNyff0X0o43AgZJk0mKwSABAK4RhaJ5PqOH7GHAHUQD4ICm0YiKwCSHI7VYoDLwDClBT5Di8khE' +
        'Y+gbUBAQGgWEBRoWFmYEiwRmJBUVLiEAACH5BAUKABoALAEAAQAQABAAAAVloCaO2vOQKImtWDoCgMa2koTCsDZNGuIjpIFwQBI' +
        'YBahGI2UkORyukUKhyVgz0Yv2csW6thcNBBIVMRikSCRFoaAK8ALpQD+QCHiCZrHQBP4BKBUVGgmGCX6BUQaMBmUkFhYuIQAAIf' +
        'kEBQoAGgAsAQABABAAEAAABWagJo4aAJAoaZrp6DjaIA/a86BZnmlNo2FADEm3GwWFJAgkNZmQIpHWSCLRFK4FKWKLIHgJUoFYo' +
        'KlUpCIxabFIKRSohDxButgvJIPeoKFQNHd4JBYWGgeHBxoMDGgBjgFoJI4tIQAAIfkEBQoAGgAsAQABABAAEAAABWSgJo6a45Ao' +
        'ma1ZOkaRxrYAgBZ4oUGQVtckgpBAGhgHqEol1WiQFgvX6PHQJK4JKWaLMXgNWq7GYpGKJhMShZKSSFCH+IGEqCNIgXxAo1BoBIA' +
        'CKHkaF4YXf4JSh4hmIwwMLiEAACH5BAUKABoALAEAAQAQABAAAAVloCaOWhSRKFmsRToui0bMhOY4aKInWlVpmWCGZCgaSMIhyW' +
        'JJQSAkCsU1AgA0h+yBarUGvgHqYDzQfKmiRoOkUKQeD9RlfiFh7hgSvS6RaPB5JAwMGgiGCBoTE2gCjQJoJI0uIQAAOw==';

/* * * * * UI utilities * * * * */

var getIcon = function(name,color) {
    return 'url("data:image/svg+xml,<svg viewBox=\'0 0 30 30\' preserveAspectRatio=\'true\' xmlns=\'http' +
        '://www.w3.org/2000/svg\'><path fill=\'' + color + '\' d=\'' + icons[name] + '\'/></svg>")';
};

var createButton = function(data) {
    var result = document.createElement(data.hasOwnProperty('type') ? data.type : 'a');
    if (data.hasOwnProperty('class')) result.className = data.class;
    if (data.hasOwnProperty('title')) result.title = data.title;
    if (data.hasOwnProperty('onClick')) result.addEventListener('click',data.onClick,false);
    if (data.hasOwnProperty('parent')) data.parent.appendChild(result);
    if (data.hasOwnProperty('target')) result.setAttribute('target',data.target);
    if (data.hasOwnProperty('style'))
        result.style.cssText = Object.keys(data.style).map(function(x) { return x + ': ' + data.style[x] + 'px'; }).join('; ');
    return result;
};

/* * * * * Utilities * * * * */

var xhr = function(data) {
    var request = {
        method: data.method,
        url: data.url,
        onload: data.callback
    };
    if (data.headers) request.headers = data.headers;
    if (data.onerror) request.onerror = data.onerror;
    if (data.body && data.body.constructor == String) request.data = data.body;
    else if (data.body) request.data = JSON.stringify(data.body);
    GM_xmlhttpRequest(request);
};

/* * * * * Download steps * * * * */

var obtainArchiverKey = function(data) {
    return new Promise(function(resolve, reject) {
        xhr({
            method: 'GET',
            url: window.location.protocol + '//' + window.location.host + '/g/' + data.galleryId + '/' +
                data.galleryToken + '?random=' + Date.now(),
            callback: function(response) {
                var div = document.createElement('div');
                div.innerHTML = response.responseText.replace(/src=/g, 'no-src=');
                var target = div.querySelector('[onclick*="archiver.php"]');
                if (!target) data.error = 'could not resolve archiver key';
                else {
                    var tokens = target.getAttribute('onclick').match(/or=([^'"]+)/);
                    if (!tokens) data.error = 'could not resolve archiver key';
                    else data.archiverKey = tokens[1];
                }
                if (data.error) reject(data);
                else resolve(data);
            },
            onerror: function() {
                data.error = 'could not open gallery\'s page';
                reject(data);
            }
        });
    });
};

var obtainTorrentFile = function(data) {
    return new Promise(function(resolve, reject) {
        xhr({
            method: 'GET',
            url: window.location.protocol + '//' + window.location.host +
                '/gallerytorrents.php?gid=' + data.galleryId + '&t=' + data.galleryToken,
            callback: function(response) {
                var div = document.createElement('div');
                div.innerHTML = response.responseText.replace(/src=/g, 'no-src=');
                var forms = div.querySelectorAll('form'), result = null;
                for (var i=0;i<forms.length;++i) {
                    var link = forms[i].querySelector('a');
                    if (!link) continue;
                    var posted = document.evaluate('.//span[contains(text(),"Posted")]', forms[i], null, 9, null).singleNodeValue;
                    var seeds = document.evaluate('.//span[contains(text(),"Seeds")]', forms[i], null, 9, null).singleNodeValue;
                    if (!posted || !seeds) continue;
                    posted = new Date(posted.nextSibling.textContent.trim());
                    seeds = parseInt(seeds.nextSibling.textContent, 10);
                    if (seeds === 0) continue;
                    if (result == null || (result.date - posted < 0))
                        result = { date: posted, link: link.href };
                }
                if (result === null) data.error = 'could not find any seeded torrent';
                else data.fileUrl = result.link;
                if (data.error) reject(data);
                else resolve(data);
            },
            onerror: function() {
                data.error = 'could not obtain torrent list';
                reject(data);
            }
        });
    });
};

var submitDownloadRequest = function(data) {
    return new Promise(function(resolve, reject) {
        if (!data || data.error) {
            resolve(data);
            return;
        }
        xhr({
            method: 'POST',
            url: window.location.protocol + '//' + window.location.host + '/archiver.php?gid=' + data.galleryId +
                '&token=' + data.galleryToken + '&or=' + data.archiverKey.replace(/--/, '-'),
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: 'dltype=org&dlcheck=Download+Original+Archive',
            callback: function(response) {
                var div = document.createElement('div'), url = null;
                div.innerHTML = response.responseText.replace(/src=/g, 'no-src=');
                var target = div.querySelector('#continue > a');
                if (target) url = target.href;
                else {
                    var targets = div.querySelectorAll('script');
                    for (var i=0;i<targets.length;++i) {
                        var match = targets[i].textContent.match(/location\s*=\s*"(.+?)"/);
                        if (!match) continue;
                        url = match[1];
                        break;
                    }
                }
                if (url) data.archiverUrl = url;
                else data.error = 'could not resolve archiver URL';
                if (data.error) reject(data);
                else resolve(data);
            },
            onerror: function() {
                data.error = 'could not access archiver';
                reject(data);
            }

        });
    });
};

var waitForDownloadLink = function(data) {
    return new Promise(function(resolve, reject) {
        if (!data || data.error) {
            resolve(data);
            return;
        }
        xhr({
            method: 'GET',
            url: data.archiverUrl,
            callback: function(response) {
                if (/The file was successfully prepared/i.test(response.responseText)) {
                    var div = document.createElement('div');
                    div.innerHTML = response.responseText.replace(/src=/g, 'no-src=');
                    var target = div.querySelector('#db a');
                    if (target) {
                        var archiverUrl = new URL(data.archiverUrl);
                        data.fileUrl = archiverUrl.protocol + '//' + archiverUrl.host + target.getAttribute('href');
                    } else data.error = 'could not resolve file URL';
                } else
                    data.error = 'archiver did not provide file URL';
                if (data.error) reject(data);
                else resolve(data);
            },
            onerror: function() {
                data.error = 'could not contact archiver';
                if (/https/.test(window.location.protocol)) {
                    data.error += '; this is most likely caused by mixed-content security policies enforced by the' +
                        ' browser that need to be disabled by the user. If you have no clue how to do that, you' +
                        ' should probably Google "how to disable mixed-content blocking".';
                } else {
                    data.error += '; please check whether your browser is not blocking XHR requests towards' +
                        ' 3rd-party URLs';
                }
                reject(data);
            }
        });
    });
};

var downloadFile = function(data) {
    return new Promise(function(resolve, reject) {
        if (!data || data.error) {
            resolve(data);
            return;
        }
        var a = document.createElement('a');
        a.href = data.fileUrl;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        document.body.appendChild(a);
        resolve(data);
    });
};

var updateUI = function(data) {
    if (!data || data.error) return;
    var temp = (data.isTorrent ? torrentQueue[data.galleryId] : archiveQueue[data.galleryId]);
    temp.button.className = temp.button.className.replace(/\s*working/, '') + ' requested';
};

var handleFailure = function(data) {
    if (!data) return;
    var temp = (data.isTorrent ? torrentQueue[data.galleryId] : archiveQueue[data.galleryId]);
    temp.button.className = temp.button.className.replace(/\s*working/, '');
    alert('Could not complete operation.\nReason: ' + (data.error || 'unknown'));
};

/* * * * * State management * * * * */

var archiveQueue = { }, torrentQueue = { };

var requestDownload = function(e) {
    if (/working|requested/.test(e.target.className)) return; 
    e.preventDefault();
    e.target.className += ' working';
    var isTorrent = /torrentLink/.test(e.target.className);
    var tokens = e.target.getAttribute('target').match(/\/g\/(\d+)\/([0-9a-z]+)/i);
    var galleryId = parseInt(tokens[1], 10), galleryToken = tokens[2];
    if (!isTorrent) {
        archiveQueue[galleryId] = { token: galleryToken, button: e.target };
        obtainArchiverKey({ galleryId: galleryId, galleryToken: galleryToken, isTorrent: false })
            .then(submitDownloadRequest, handleFailure)
            .then(waitForDownloadLink, handleFailure)
            .then(downloadFile, handleFailure)
            .then(updateUI, handleFailure);
    } else {
        torrentQueue[galleryId] = { token: galleryToken, button: e.target };
        obtainTorrentFile({ galleryId: galleryId, galleryToken: galleryToken, isTorrent: true })
            .then(downloadFile, handleFailure)
            .then(updateUI, handleFailure);
    }
};

/* * * * * UI setup * * * * */

window.addEventListener('load', function() {

    // button generation (thumbnail list)
    var thumbnails = document.querySelectorAll('.id3 > a'), n = thumbnails.length;
    while (n --> 0) {
        var bottom = Math.max(0,parseInt(thumbnails[n].parentNode.style.height,10) - thumbnails[n].firstChild.height);
        var right  = Math.max(0,0.5 * (200 - thumbnails[n].firstChild.width));
        createButton({ class: 'automatedButton downloadLink', title: 'Automated download', target: thumbnails[n].href,
            style: { bottom: bottom, right: right }, onClick: requestDownload, parent: thumbnails[n] });
        createButton({ class: 'automatedButton torrentLink', title: 'Torrent download', target: thumbnails[n].href,
            style: { bottom: bottom, left: 1 }, onClick: requestDownload, parent: thumbnails[n] });
    }

    // button generation (gallery)
    var bigThumbnail = document.querySelector('#gd1 > img');
    if (bigThumbnail !== null) {
        var bottom = bigThumbnail.parentNode.parentNode.clientHeight - bigThumbnail.offsetTop - bigThumbnail.height - 1;
        var right  = bigThumbnail.parentNode.parentNode.clientWidth - bigThumbnail.offsetLeft - bigThumbnail.width - 2;
        var left   = bigThumbnail.offsetLeft + 1;
        createButton({ class: 'automatedButton downloadLink', title: 'Automated download', target: window.location.href,
            style: { bottom: bottom, right: right }, onClick: requestDownload, parent: bigThumbnail.parentNode });
        createButton({ class: 'automatedButton torrentLink', title: 'Torrent download', target: window.location.href,
            style: { bottom: bottom, left: left }, onClick: requestDownload, parent: bigThumbnail.parentNode });
    }

    // button generation (row list)
    var rows = document.querySelectorAll('.it5 > a'), n = rows.length;
    while (n --> 0) {
        var div = createButton({ type: 'div', class: 'automatedPicker', onClick: requestDownload, parent: rows[n].parentNode });
        var picker = createButton({ type: 'div', parent: div });
        createButton({ type: 'div', class: 'automatedInline torrentLink', title: 'Torrent download', 
            target: rows[n].href, parent: picker });
        createButton({ type: 'div', class: 'automatedInline downloadLink', title: 'Automated download',
            target: rows[n].href, parent: picker });
    }

    // document style
    var style = document.createElement('style');
    style.innerHTML =
        '.automatedButton { display: none; position: absolute; text-align: left; cursor: pointer; padding: 8px;' +
        'color: white; margin-right: 1px; font-size: 20px; line-height: 11px; }' +
        '.downloadLink  { background-image: ' + getIcon('download','rgb(0,0,0)') + '; background-color: rgba(98,220,151,1); }' +
        '.torrentLink  { background-image: ' + getIcon('torrent','rgb(0,0,0)') + '; background-color: rgba(98,182,210,1); }' +
        '.torrentLink:not(.requested) { background-position: 2px 2px; }' +
        '.requested  { background-image: ' + getIcon('done','rgb(0,0,0)') + '; }' +
        '.requested, .working { background-color: rgba(255,143,113,1); }' +
        '.automatedButton.downloadLink  { border-radius: 0 0 5px 0 !important; width: 12px; height: 12px; }' +
        '.automatedButton.torrentLink  { border-radius: 0 0 0 5px !important; width: 12px; height: 12px; }' +
        '#gd1 > .automatedButton { border-radius: 0 0 0 0 !important; }' +
        '.working { background-image: url(data:image/gif;base64,' + loadingGIF + ') !important; background-repeat: no-repeat; }' +
        '.automatedInline.working { background-position: 3px 3px; }' +
        '.automatedButton.working { width: 18px; height: 18px; font-size: 0px; background-position: 5px 5px; padding: 5px !important; }' +
        '.automatedPicker { background-image: ' + getIcon('picker','rgb(252,0,97)') + '; width: 16px;' + 
        'height: 16px; float: left; cursor: pointer; }' +
        '.automatedButton:hover, .automatedInline:hover { background-color: rgba(255,199,139,1) !important; color: black !important; }' +
        '*:hover > .automatedButton, .automatedButton.working, .automatedButton.requested { display: block !important; }' +
        '.EHADiframe { width: 0px !important; height: 0px !important; opacity: 0 !important; }' +
        '.automatedPicker > div { display: none; z-index: 2; position: absolute; top: -4px; text-align: center; }' +
        '.automatedPicker:hover > div, .automatedPicker > div:hover { display: block; }' +
        '.automatedInline { padding: 3px; border: 1px solid black; width: 17px; height: 17px; display: inline-block; }' +
        '.automatedInline:first-child { border-right: none !important; }';
    document.head.appendChild(style);

}, false);