Twitch Drop Auto-Claim

Auto-Claims drops, while attempting to evade bot detection and claim quickly.

// ==UserScript==
// @name         Twitch Drop Auto-Claim
// @namespace    https://greasyfork.org/en/users/1077259-synthetic
// @version      0.13
// @description  Auto-Claims drops, while attempting to evade bot detection and claim quickly.
// @author       @Synthetic
// @license      MIT
// @match        https://www.twitch.tv/inventory
// @match        https://www.twitch.tv/drops/inventory
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitch.tv
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    // The current version
    const VERSION = 0.13;

    // Page element selectors
    const PROGRESS_BAR = 'div.tw-progress-bar';
    const CLAIM_DROP = 'button.ScCoreButton-sc-ocjdkq-0.ScCoreButtonPrimary-sc-ocjdkq-1.caieTg.eHSNkH';

    // Handy constants
    const NOW = (new Date()).getTime();

    /**
     * The rate to use if none can be calculated, in seconds.
     *
     * The rate is used to calculate the next refresh,
     * and is defined as the time taken to progress 1%.
     */
    const THIRTY_RATE = 18; // seconds

    /**
     * The maximum time before the next refresh, in seconds.
     *
     * We don't want to calculate too early, as the longer the timeframe
     * we have the more accurate the rate calculation will be.
     * We also don't want to trigger the bot by refreshing too much.
     */
    const MAX_REFRESH = 1800; // seconds

    /**
     * The maximum age of a previous read, in seconds.
     *
     * If we find a previous read but it's too old we just ignore it,
     * as it is unlikely to be relevant to this drop.
     */
    const THRESHOLD = 15000; // seconds

    /**
     * A buffer to add to the final refresh to ensure we have hit 100%, in seconds.
     *
     * As we try to accurately calculate the time required to reach 100% we may fall just short.
     * This buffer is used to try to ensure we are just over rather than just under.
     */
    const TIME_BUFFER = 10; // seconds

    /**
     * A buffer to add when checking expected refresh times.
     *
     * Even though we set a refresh of a specific interval the difference
     * between load times will not exactly match that figure, so we use this buffer
     * when checking whether the load time is expected.
     */
    const REFRESH_BUFFER = 30; // seconds

    /**
     * The delay between clicking multiple Claim Now buttons.
     *
     * If we click too quickly we are identified as a bot.
     */
    const CLICK_DELAY = 8000; // miliseconds

    /**
     * The delay to wait to see if all mutations are done.
     *
     */
    const MUTATE_DELAY = 5000; // miliseconds

    /**
     * Dumps an object to the console.
     *
     * @param  object o The object to dump.
     * @return void
     */
    const dump = (o) => {
        for (var p in o) {
            if ((o[p] != null) && (typeof o[p] == 'object')) {
                console.group(p);
                    dump(o[p]);
                console.groupEnd();
            } else {
                console.log(p, o[p]);
            }
        }
    }

    /**
     * Returns the base storage object.
     *
     * @return object
     */
    const getDefaults = () => {
        return JSON.parse(
            JSON.stringify(
                {
                    base: {
                        time: null,
                        progress: null,
                        offset: null,
                    },
                    last: {
                        time: null,
                        progress: null,
                        expected: null,
                    },
                    version: VERSION
                }
            )
        );
    }

    /**
     * Retrieves stored data
     *
     * @return object|boolean
     */
    const getPrevious = () => {
        var previous = GM_getValue('previous');
        if (typeof previous == 'undefined' || previous == false) {
            return false;
        }
        try {
            previous = JSON.parse(previous);
        } catch (e) {
            return false;
        }
        return previous;
    };

    /**
     * Converts a calculated progress rate into a fixed value (30m, 45m, 1hr, 2hr, 3hr, ... 15hr).
     *
     * @param  integer rate The calculated rate.
     * @return integer      The fixed rate.
     */
    const fixedRate = (rate) => {
        var diff = 10000;
        var fixed = THIRTY_RATE;
        const options = [18, 27];
        for (var i = 1; i <= 15; i++) {
            options.push(i * 36);
        }
        options.forEach((r) => {
            if (Math.abs(rate - r) < diff) {
                diff = Math.abs(rate - r);
                fixed = r;
            }
        });
        return fixed;
    };

    /**
     * Sets the timer to refresh the page.
     *
     * @param  integer refresh The number of seconds to wait before refreshing.
     * @return void
     */
    const setTimer = (refresh) => {
        console.log('Setting refresh of', refresh, 'seconds');
        console.log('Next load', new Date((new Date()).getTime() + refresh * 1000));
        window.setTimeout(
            () => {
                window.location.reload();
            },
            refresh * 1000
        );
        startCountdown();
    };

    /**
     * Clicks any Claim button, with a short delay between each click.
     *
     * @return Promise
     */
    const claimDrop = new Promise((resolve, reject) => {
        const nodes = document.querySelectorAll(CLAIM_DROP);
        console.log(nodes);
        if (nodes.length == 0) {
            resolve(false);
        }
        for (var i = 0; i < nodes.length; i++) {
            console.log(nodes[i]);
            window.setTimeout(
                (node) => { node.click(); console.log('click') },
                i * CLICK_DELAY,
                nodes[i]
            );
        }
        window.setTimeout(() => { resolve(true); }, --i * CLICK_DELAY);
    });

    const startCountdown = () => {
        window.setInterval(
            () => {
                document.title = title + ' (' + (--refresh).toString() + ')';
            },
            1000
        );
    };

    /**
     * Runs once the widgets have finally load.
     * Contains all the logic used to calculate the page refresh.
     *
     * @param  integer progress The largest progress value.
     * @return void
     */
    const processPage = () => {
        var rate = THIRTY_RATE;
        var progress = 0;
        var nodes = document.querySelectorAll(PROGRESS_BAR);
        if (nodes.length) {
            progresses = [...nodes]
                .map((node) => {
                    return Number(node.getAttribute('aria-valuenow'));
                })
                .filter((progress) => {
                    return progress <= 100;
                })
                .sort((a, b) => { return a == b ? 0 : (a < b ? -1 : 1) });
            progress = progresses.pop();
            console.log('Progress', progress);
        }
        claimDrop
            .then((claimed) => {
                if (claimed) {
                    progress = progresses.pop();
                    if (typeof progress == 'undefined') {
                        progress = 0;
                    }
                    refresh = rate * (100 - progress);
                    previous = getDefaults();
                    previous.base = {
                        time: NOW,
                        progress: progress,
                        offset: 0,
                    };
                } else {
                    if (previous) {
                        const increase = {
                            base: progress - previous.base.progress,
                            last: progress - previous.last.progress,
                        }
                        if (increase.last < 1) {
                            previous = false;
                            console.log('No increase since last load, resetting data')
                        } else {
                            rate = fixedRate(Math.ceil(interval.base / increase.base));
                            if (previous.last.expected) {
                                var reduce = true;
                                var diff = 0;
                                console.log('Expected increase of', previous.last.expected);
                                console.log('Actual increase is', increase.last)
                                if (previous.last.expected == increase.last) {
                                    diff = Math.floor(previous.last.rate * previous.base.offset);
                                } else if (Math.abs(interval.last - previous.last.refresh) > REFRESH_BUFFER) {
                                    console.log('Not a full refesh');
                                    const expected = Math.floor(interval.last / rate);
                                    console.log('New expected increase of', expected);
                                    if (increase.last > expected) {
                                        diff = interval.last - expected * rate;
                                    } else {
                                        reduce = increase.last < expected;
                                    }
                                }
                                if (diff > 0) {
                                    console.log('Reduced base time by', diff, 'seconds');
                                    previous.base.time -= diff * 1000;
                                }
                                if (reduce) {
                                    previous.base.offset /= 2;
                                    if (previous.base.offset < 0.01) {
                                        previous.base.offset = 0;
                                    }
                                }
                            }
                        }
                    }
                    if (!previous) {
                        rate = THIRTY_RATE;
                        previous = getDefaults();
                        previous.base = {
                            time: NOW,
                            progress: progress,
                            offset: 0.5,
                        };
                    }
                    console.log('Rate', rate);
                    refresh = (100 - progress) * rate;
                    previous.last.expected = null;
                    if (previous.last.progress !== null) {
                        if (refresh < MAX_REFRESH) {
                            var p = Math.min(100, previous.base.progress + (interval.base / rate));
                            console.log('Accurate progress', p.toFixed(3));
                            // NOTE:
                            // Sometimes p > progress
                            // Do we rely on time/rate (p), and assume ui has not been updated recently, or:
                            // p = Math.min(p, progress + 0.5);
                            refresh = Math.max(rate, Math.ceil((100 - p) * rate) + TIME_BUFFER);
                        } else if (previous.base.offset > 0) {
                            previous.last.expected = Math.floor(MAX_REFRESH / rate);
                            refresh = (previous.last.expected * rate) - Math.floor(rate * previous.base.offset);
                        }
                    }
                    refresh = Math.min(refresh, MAX_REFRESH);
                    console.log('Refresh', refresh);
                }
                previous.last.time = NOW;
                previous.last.progress = progress;
                previous.last.rate = rate;
                previous.last.refresh = refresh;
                GM_setValue('previous', JSON.stringify(previous));
                setTimer(refresh);
            });
    }

    /**
     * Runs when the dom updates, used to gain access to the progress bars, when they finally load.
     *
     * @param  array mutationsList The list of mutations.
     * @return void
     */
    const onMutate = (mutationsList) => {
        clearTimeout(timeout);
        clearTimeout(loading);
        loading = window.setTimeout(processPage, MUTATE_DELAY);
    };

    console.log('Loaded at', new Date());
    var timeout;
    var refresh = null;
    var interval;
    var loading;
    var progresses;
    const title = document.title;
    var previous = getPrevious();

    if (previous) {
        console.log('Baseline', new Date(previous.base.time));
        console.log('Last seen', new Date(previous.last.time));
        interval = {
            base: Math.round((NOW - previous.base.time) / 1000),
            last: Math.round((NOW - previous.last.time) / 1000)
        }
        console.log('Interval', interval.base);
        if ((interval.base > THRESHOLD) || (interval.last > MAX_REFRESH + REFRESH_BUFFER)) {
            previous = false;
            console.log('Interval is too large');
        }
    }

    if (!previous) {
        console.log('No relevant read');
    }

    // If we can't find a progress bar after 10s this will set a refesh
    timeout = window.setTimeout(
        () => {
            GM_setValue('previous', false);
            refresh = 100 * THIRTY_RATE;
            setTimer(refresh);
        },
        10000
    );

    var observer = new MutationObserver(onMutate);
    observer.observe(document.body, { childList: true, subtree: true });

})();