kemono.party importer: Patreon

Provides a button to add a one-click importer for patreon content

This script is marked as "external"
This script was commisioned by someone else, and I will not check if it works. If it breaks, contact me
Click here to install Browse More Scripts
// ==UserScript==
// @name         kemono.party importer: Patreon
// @namespace    1788de4e09f079dbe2138354150004afc50aae5c
// @version      1.6
// @description  Provides a button to add a one-click importer for patreon content
// @author       /u/AyrA_ch
// @match        https://*.patreon.com/*
// @match        https://patreon.com/*
// @run-at       document-idle
// @grant        GM_cookie
// @external     true
// ==/UserScript==

//Version history
// 1.6 - Re-attach export form if it's removed by patreon scripts
// 1.5 - Fix timeout function always firing
// 1.4 - Added detection for when the cookie fnction is not working
// 1.3 - The banner at the bottom can now be closed
// 1.2 - Debug logger
// 1.1 - Added restriction on how often a user can import
// 1.0 - Initial version (basic importer)

(function () {
    'use strict';
    //Icons for the button
    const HTML = {
        //Floppy disk (importer ready)
        floppy: "\uD83D\uDCBE",
        //Hourglass (waiting for cookie data)
        wait: "\u231B",
        //Red cross (import delay has not elapsed yet)
        blocked: "\u274C",
        //Banner close button
        close: "\u274C"
    };
    //Known error codes. This helps to find out where an error happens if a user reports it.
    //Entries are indexed as follows:
    //0: error(true) or warning(false)
    //1: code
    //2: message
    //Warnings will not show the code and error name
    const ERR = {
        nobox: [true, 1, "Search form should be present on main page but is not. Please report this error"],
        cookie: [true, 2, "Unable to read your cookies"],
        dom: [true, 3, "Search form found but it's not in the expected location"],
        delay: [false, 4, "You're trying to import too often"],
        importremind: [false, 5, "You have not imported content for a long time. Click on the " + HTML.floppy + " next to the search box"],
        noimport: [false, 6, "You have never imported anything. Click on the " + HTML.floppy + " next to the search box"],
        cookiefunc: [true, 7, "GM_cookie is missing from your script manager. Ask the script manager developer to implement this."],
        cookietimeout: [true, 8, "Timeout reading cookies. The browser is not responding to our cookie read request."],
        formdeleted: [true, 9, "Patreon has deleted the entire search form. If this error persists after reloading, report this error."]
    };
    //Number of seconds a user has to wait between imports.
    const importDelay = 43200; //12 hours
    //Remind the user if he hasn't imported in a long time.
    const importReminder = 86400 * 7; //7 days
    //This style makes the button fit more in line with the rest of the toolbar
    const btnStyle = 'width:2rem;height:2rem;background-color:transparent;border:none;';
    //Patreon uses randomly generated class names, so we anchor to a known static name
    //In our case, it's the search box, because its name is always "q" and is unlikely to change.
    let box = document.querySelector("input[name='q']");

    //Holds the banner div
    let banner = null;
    //Holds the session id
    let sessionId = null;

    //Function to be used as debug logger
    const dbg = console.debug.bind(console, "IMPORTER:");

    //Shows error banner
    const showErr = function (name, details) {
        let msg = "Unknown error";
        //Build error message
        if (ERR[name]) {
            if (ERR[name][0]) {
                msg = "#" + ERR[name][1] + " " + name.toUpperCase() + ": " + ERR[name][2];
            } else {
                msg = ERR[name][2];
            }
        }
        if (details) {
            msg += "; " + details;
        }
        if (!banner) {
            //Create banner if it doesn't exists
            banner = document.createElement("div");
            banner.setAttribute("style", "position:fixed;bottom:0px;left:0px;right:0px;" +
                "height:2rem;text-align:center;padding-top:1rem");
            document.body.appendChild(banner);
        }

        //Nonexistent entries are considered errors too
        if (!ERR[name] || ERR[name][0]) {
            //Show errors in red
            banner.style.backgroundColor = "#FEE";
            banner.style.color = "#F00";
            banner.textContent = "kemono importer is not functioning properly. " +
                "If this error persists, contact support with this code: " + msg;
        } else {
            //Show warning as yellow
            banner.style.backgroundColor = "#FFA";
            banner.style.color = "#000";
            banner.textContent = "Warning: " + msg;
        }
        //Add close button to banner
        const a = document.createElement("button");
        a.setAttribute("style",
            "padding:0px;" +
            "display:inline-block;" +
            "float:right;" +
            "margin-right:2em;" +
            "border:none;" +
            "background:transparent;");
        a.textContent = HTML.close;
        a.addEventListener("click", function (e) {
            e.preventDefault();
            banner.parentNode.removeChild(banner);
            banner = null;
        });
        banner.appendChild(a);
        dbg("Error:", name, details);
    };

    const lastImportDate = function () {
        return +localStorage.lastImport;
    }

    //Checks whether the importDelay has elapsed or not
    const canImport = function () {
        const lastImported = lastImportDate();
        //Returns true, if lastImported is NaN or if the time has elapsed
        return !lastImported || Date.now() - lastImported >= importDelay * 1000;
    };
    //Checks whether we should remind the user to import again
    const remindImport = function () {
        const lastImported = lastImportDate();
        if (lastImported) {
            return Date.now() - lastImported >= importReminder * 1000;
        }
        return false;
    };
    //Callback for the cookie result function
    const cookieResult = function (cookies, err) {
        console.timeEnd("read cookie");
        dbg("Got cookie response");
        const submit = box.querySelector("[type='submit']");
        if (!err) {
            const c = sessionId = cookies[0];
            if (!c) {
                console.log("User not logged in. No session cookie found.");
                return;
            }
            //Set the cookie value and enable the submit button
            const cookiebox = box.querySelector("[name='session_key']");
            submit.value = canImport() ? HTML.floppy : HTML.blocked;
            submit.disabled = false;
            cookiebox.value = c.value;
            dbg("Ready to import");
        } else {
            //Got an error
            submit.value = HTML.blocked;
            showErr("cookie", err);
            //Maybe add a retry?
            dbg("Cookie error");
        }
    };

    console.group("importer init");
    console.time("importer init");

    if (!box) {
        //No search box. Maybe on some page without the form
        if (location.pathname === "/home") {
            dbg("Searchbox can't be found on home. Needs script rewrite");
            showErr("nobox");
        } else {
            dbg("No search form available. Will not look for cookie");
        }
        return;
    }
    //Find containing list of search box (value of nodeName is always uppercase)
    while (box && box.nodeName !== "UL") {
        dbg("Trying to find parent tag. Current:", box.nodeName);
        box = box.parentNode;
    }
    if (!box) {
        dbg("DOM changed. Needs script rewrite.");
        //list container of box not found
        showErr("dom");
        return;
    }
    dbg("Found form container. DOM still has the expected layout.");
    dbg("Creating import form");
    const list = box.appendChild(document.createElement("li"));

    //Setting innerHTML is dirty but I'm not going to set up an entire form through the DOM.
    //
    //The reason you should not do this (apart from potential XSS if you're using user data)
    //is because it kills all events on all elements that are already inside.
    //It also causes a complete re-render of this page section.
    //In our case, the element is empty since we just created it.
    //If you really need a way around this, there is "element.insertAdjacentHTML"
    //See the showErr function for how to use this.
    list.innerHTML = "<form method='post' action='https://kemono.party/api/import'>" +
        "<input type='hidden' name='service' value='patreon' />" +
        "<input type='hidden' name='session_key' value='' />" +
        "<input type='submit' value='" + HTML.wait + "' disabled title='Import all creators on kemono.party' style='" + btnStyle + "' />" +
        "</form>";
    //Asks user if he really wants to send over the cookie.
    //Prevents accidental imports if the user just tried to reach his profile icon which is next to the import button.
    //It also prevents the user from importing too often
    list.querySelector("form").addEventListener("submit", function (e) {
        let nextImport = null;
        let lastImported = lastImportDate();
        dbg("Import attempt");
        //Check if this is the first import attempt
        if (!lastImported) {
            dbg("This is a first import");
            lastImported = "never";
        } else {
            //Check if the delay between imports has elapsed
            if (!canImport()) {
                //Construct date of earliest possible next import
                nextImport = new Date(+lastImported + importDelay * 1000);
                dbg("User is importing too often. Next attempt is at", nextImport);
            }
            //Construct date of last import
            lastImported = new Date(+lastImported).toLocaleString();
        }
        if (!canImport()) {
            //User tries too often
            e.preventDefault();
            //Get delay in minutes
            var delay = ((nextImport.getTime() - Date.now()) / 60000) | 0;
            if (delay < 60) {
                delay += " minutes";
            } else {
                //Convert to nice time string
                var h = (delay / 60 | 0);
                var m = delay % 60;
                delay = h + " hour" + (h === 1 ? '' : 's') + ", " + m + " minute" + (m === 1 ? '' : 's');
            }
            //Show detailed import error
            showErr("delay", "Try again at " + nextImport.toLocaleString() + " (in " + delay + ")");
            return false;
        }
        if (!confirm("Send your session cookie to kemono.party to import all posts of all creators on patreon?\nDate of last import: " + lastImported)) {
            //Import cancelled by user
            dbg("User cancelled the import");
            e.preventDefault();
            return false;
        }
        //Save time of last import
        localStorage.lastImport = Date.now();
        dbg("Updated import timestamp to", new Date());
        return true;
    });

    //Check if an element is part of the document
    const isInDocument = function (x) {
        while (x) {
            if (x === document.documentElement) {
                return true;
            }
            x = x.parentNode;
        }
        return false;
    };

    //Observe form for changes that hide the exporter
    const MO = new MutationObserver(function () {
        if (!isInDocument(box)) {
            MO.disconnect();
            showErr("formdeleted");
        }
        if (list.parentNode !== box) {
            dbg("Had to reattach export form");
            //Do not immediately add it back or we may create a feedback loop
            if (!MO.timer) {
                MO.timer = setTimeout(function () {
                    MO.timer = null;
                    box.appendChild(list);
                }, 500);
            }
        }
    });

    MO.observe(document.body, {
        subtree: true,
        childList: true
    });

    if (remindImport()) {
        dbg("user has not imported data for a long time");
        showErr("importremind");
    } else if (!+localStorage.lastImport) {
        dbg("user has never imported data");
        showErr("noimport");
    }
    console.timeEnd("importer init");
    console.groupEnd("importer init");

    //Requesting the session cookie. This is callback based
    console.time("read cookie");
    if (typeof(GM_cookie) === typeof(function () {})) {
        GM_cookie("list", {
            name: "session_id"
        }, cookieResult);
        setTimeout(function () {
            if (sessionId === null) {
                dbg("No session after 5 seconds");
                showErr("cookietimeout");
            }
        }, 5000);
    } else {
        dbg("Client is probably using a different or outdated script manager");
        showErr("cookiefunc");
    }
})();

/*
LICENSE:
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
The full license text can be found here: http://creativecommons.org/licenses/by-nc-sa/4.0/
The link has an easy to understand version of the license and the full license text.

DISCLAIMER:
THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.
*/

Copyright © 2018 by Kevin Gut 📧 | More services | Generated for 44.192.54.67