Sacerd0s / Hugin oRO-Charinfo-Crawler

// ==UserScript==
// @name         Hugin oRO-Charinfo-Crawler
// @namespace    hugin
// @version      0.2
// @description  Grab oRO Character level and EXP info.
// @author       Sacer
// @match        https://cp.originsro.org/masteraccount/characters/
// @grant        none
// @licence      MIT
// ==/UserScript==
/*jshint esversion: 8 */
(async function() {
    'use strict';
    'use esversion: 8';
    const DEV = false;
    // Cache-Schlüssel
    const lsName = 'hugin-name';
    const lsSecret = 'hugin-secret';
    const lsLastUpdated = 'hugin-last-updated';

    // Munin URL
    const muninUrl = 'https://munin.info:8081/api/update';

    // Funktion um die Chardaten zu sammeln.
    const getCharData = async function() {
        console.log('getchardata');
        // Control Panel URL
        const cpUrl = 'https://cp.originsro.org';
        // DOMParser um aus reinem Text (Strings) ein für JavaScript
        // verarbeitbares Element zu generieren.
        // Wird in Zeile 42 (:D) benötigt)
        const domParser = new DOMParser();
        // Liste die später alle hält Charinfos.
        const chars = [];
        // Hauptcontainer der Webseite in der die Tabelle liegt.
        const main = document.getElementsByClassName('main');
        // Die Tabelle innerhalb dieses Containers.
        const charTable = main[0].getElementsByClassName('table');
        // Die Links zu den Charakterdetailseiten:
        const charLinks = charTable[0].getElementsByClassName('link-to-character');

        // Schleife über die Links zu den Charaktern.
        // Heißt: Ich behandle einen Link (Element) nach dem anderen
        for (let i = 0; i < (DEV ? 1 : charLinks.length); i++) {
            // Das HTML Element mit dem Link,
            // ergo das einzelne Element
            // aus der Liste `charLinks`
            // an der stelle `i`
            // `i` ist anfangs 0 und wird nach jedem
            // Durchgang +1 nach oben gerechnet.
            const tag = charLinks[i];
            // Endlich der Link zu den Charakterdetails!
            const url = tag.getAttribute('href');
            // Den Inhalt (HTML) der Charakterdetails holen
            const charHTMLResponse = await fetch(cpUrl + url);
            // Die Antwort von "gib mir die Seite (fetch)" in Text "umwandeln"
            const charHTMLText = await charHTMLResponse.text();
            // Den Text zu Element umwandeln
            const charHTML = domParser.parseFromString(charHTMLText, 'text/html');
            // Jetzt werden 2 Tabellen benötigt:
            // 1. Character Info
            // 2. Details
            // Beide liegen in "main", glücklicherweise in der passenden Reihenfolge
            const charMain = charHTML.getElementsByClassName('main');
            const charTables = charMain[0].getElementsByClassName('table');
            const charInfoTable = charTables[0];
            const charDetailsTable = charTables[1];

            // Aus den Tabellen brauchen wir nun die Reihen
            // und aus diesen Reihen wiederum die Zellen
            const charInfoRows = charInfoTable.getElementsByTagName('tr');
            const charDetailsRows = charDetailsTable.getElementsByTagName('tr');
            // Aus den einzelnen Zellen können wir uns dann die Informationen holen,
            // die wir für unsere Charlist/EXP-Chart benötigen.
            // Um Missverständnisse auszuschließen, hier eine kurze Liste,
            // welches Element welche Information hält.
            // Kurzer Exkurs:
            // Listen werden vom Computer ab 0 gezählt.
            // Um ein Element zu erhalten, muss ich eine Nummer in []-Klammers anhängen.
            //
            // Info: "<- *" = "Diese Info wird an den Dienst übertragen"
            //
            // Character Info:
            // 1. charInfo[0] = Name <- *
            // 2. charInfo[1] = Character ID
            // 3. charInfo[2] = Char Deletion Pin
            // 4. charInfo[3] = Account
            // 5. charInfo[4] = Character Slot
            // 6. charInfo[5] = Job Class <- *
            // 7. charInfo[6] = Level <- *
            // 8. charInfo[7] = Troubleshooting
            //
            // Details:
            //  1. charDetails[0] = Base EXP <- *
            //  2. charDetails[1] = Base EXP %
            //  3. charDetails[2] = Job EXP <- *
            //  4. charDetails[3] = Job EXP %
            //  5. charDetails[4] = Location
            //  6. charDetails[5] = Save Point
            //  7. charDetails[6] = Zeny
            //  8. charDetails[7] = Jobchange Level
            //  9. charDetails[8] = Last Online
            // 10. charDetails[9] = Total Online Time
            const charInfoName = charInfoRows[0].getElementsByTagName('td');
            const charInfoJob = charInfoRows[5].getElementsByTagName('td');
            const charInfoLevel = charInfoRows[6].getElementsByTagName('td');
            const charDetailsBaseEXP = charDetailsRows[0].getElementsByTagName('td');
            const charDetailsJobEXP = charDetailsRows[2].getElementsByTagName('td');
            // Wir Bereiten uns ein sog. "Objekt" vor, dass unsere Informationen hält
            // .trim() entfernt dabei Leerzeichen vorn und hinten, macht aus "   Susi  " also "Susi"
            const char = {
                name: charInfoName[0].innerHTML,
                jobClass: charInfoJob[0].innerHTML.trim(),
                level: charInfoLevel[0].innerHTML.trim(),
                exp: {
                    base: charDetailsBaseEXP[0].innerHTML.trim(),
                    job: charDetailsJobEXP[0].innerHTML.trim(),
                },
            };
            // Nun müssen die Daten noch etwas bereinigt werden.
            // Zum einen den Inhalt aus den Zellen holen,
            // zum anderen Zeug drumrum entfernen, dass wir wirklich nur
            // den reinen Wert haben.
            // Beim Namen ist noch ein Icon enthalten, das muss raus
            char.name = char.name.replace(/<span.*>\s+/gmi, '').trim();
            // Base und JobEXP sind noch "Strings", also Text und ggf. mit Trennkommas.
            char.exp.base = parseInt(char.exp.base.replace(/,/g,''));
            char.exp.job = parseInt(char.exp.job.replace(/,/g,''));
            // Base und Job Level sind im Format "b / j" vorhanden,
            // das muss noch getrennt werden:
            const level = char.level.split('/');
            char.level = {
                base: parseInt(level[0].trim()),
                job: parseInt(level[1].trim()),
            };
            // Charinfo der Liste hinzufügen.
            chars.push(char);
        }

        // Ausgabe der zu übertragenden Daten in der Konsole.
        // Auf der Seite mit der Charliste Rechtsklick -> Untersuchen -> Reiter "Console"
        // zum Überprüfen öffnen.
        console.log(chars);
        window.huginCharData = chars;
        return chars;
    };
    // Funktion um Daten an Munin zu senden
    const sendData = function(name, secret, data) {
        // Daten zusammenfassen zum Absenden
        fetch(muninUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({name, secret, data}) // body data type must match "Content-Type" header
        }).then(function(resp) {
            // Todo: Auf post response reagieren.
            localStorage.setItem(lsName, name);
            localStorage.setItem(lsSecret, secret);
            localStorage.setItem(lsLastUpdated, new Date());
        });
    };
    // Funktion zur Erstellung des Parser-Control-Panels
    const createControlPanel = function() {
        const render = function(contents=[], style='') {
            const main = document.getElementsByClassName("main");
            const panelHeading = main[0].getElementsByClassName("panel-heading");
            const cpBody = document.createElement("div");
            // Controlpanel vorbereiten
            cpBody.id = "cp-hugin";
            cpBody.innerHTML = contents.join("\n") + "<style>" + style + "</style>";
            // Controlpanel einhängen
            panelHeading[0].append(cpBody);
        };

        // Cache auslesen
        const cache = {
            name: localStorage.getItem(lsName),
            secret: localStorage.getItem(lsSecret),
            lastUpdated: localStorage.getItem(lsLastUpdated),
        };
        const hasUpdateTimeout = (cache.lastUpdated && new Date().getTime() - new Date(cache.lastUpdated).getTime() <= 1000 * 60 * 60 * 24);

        const contents = [];
        let style =
          "#cp-hugin{background-color:#d4e3d2;margin-top:10px;padding:10px 12px;border:2px solid #599956;border-radius:5px;}";
        style += "#cp-hugin > p{margin:0 0 10px 0;}";
        style += "#cp-hugin > p:last-child{margin-bottom:0;}";

        // CP Title
        contents.push(
          '<p class="cp-title">HUGIN <small>(oRO Charinfo Crawler)</small></p>'
        );
        style += "#cp-hugin p.cp-title{color:#a73d16;font-weight:900;}";
        style += "#cp-hugin p.cp-title small{color:#000;font-size:75%;}";

        // Loading Indicator
        contents.push(
          '<div id="hugin-loading">' +
            "<p>Sammle Charinfos...</p>" +
            '<div class="cp-progress"><div class="indeterminate"></div></div>' +
            "</div>"
        );
        style += '#cp-hugin .cp-progress{position:relative;height:4px;display:block;width:100%;background-color:#a0c49b;border-radius:2px;background-clip:padding-box;margin:.5rem 0 1rem 0;overflow:hidden}#cp-hugin .cp-progress .indeterminate{background-color:#527d57}#cp-hugin .cp-progress .indeterminate:before{content:"";position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left,right;-webkit-animation:indeterminate 2.1s cubic-bezier(.65,.815,.735,.395) infinite;animation:indeterminate 2.1s cubic-bezier(.65,.815,.735,.395) infinite}#cp-hugin .cp-progress .indeterminate:after{content:"";position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left,right;-webkit-animation:indeterminate-short 2.1s cubic-bezier(.165,.84,.44,1) infinite;animation:indeterminate-short 2.1s cubic-bezier(.165,.84,.44,1) infinite;-webkit-animation-delay:1.15s;animation-delay:1.15s}@-webkit-keyframes indeterminate{0%{left:-35%;right:100%}60%{left:100%;right:-90%}100%{left:100%;right:-90%}}@keyframes indeterminate{0%{left:-35%;right:100%}60%{left:100%;right:-90%}100%{left:100%;right:-90%}}@-webkit-keyframes indeterminate-short{0%{left:-200%;right:100%}60%{left:107%;right:-8%}100%{left:107%;right:-8%}}@keyframes indeterminate-short{0%{left:-200%;right:100%}60%{left:107%;right:-8%}100%{left:107%;right:-8%}}';

        // Mainframe
        let mainframe = '<div id="hugin-contents">' +
            '<form id="hugin-data-form">' +
            '<label for="hugin-name">Name</label>' +
            '<input type="text" placeholder="Name..." name="name" id="hugin-name" ' + (cache.name ? 'value="' + cache.name + '"' : '') + '/>' +
            '<label for="hugin-name">Secret (<u>!!NICHT!!</u> dein Passwort!!)</label>' +
            '<input type="text" placeholder="Secret..." name="secret" id="hugin-secret" ' + (cache.secret ? 'value="' + cache.secret + '"' : '') + '/>';

        // 24h Cache -> ms * seconds * minutes * hours
        // 1000 ms = 60 Sekunden
        // 60 Sekunden = 1 Minute usw...
        // Darum 1000(ms) * 60(s) * 60(m) * 24(h)
        if (hasUpdateTimeout) {
            const updated = new Date(cache.lastUpdated);
            mainframe +=
                '<p class="cp-last-updated">Zuletzt aktualisiert: ' +
                updated.getDate()+'.'+(updated.getMonth()+1)+'.'+updated.getFullYear()+' '+updated.getHours()+':'+updated.getMinutes()+':'+updated.getSeconds() +
                '</p>';
        } else {
            const btnLabel = cache.name && cache.secret && cache.lastUpdated ? 'Daten aktualisieren' : 'Daten senden';
            mainframe += '<p class="tos-warning">ToS-WARNUNG!<br />Das automatische Sammeln von Daten aus der oRO-Webseite ist <i>eigentlich</i> nicht erlaubt.</br>' +
                'Die Regel wurde vor Allem wegen Marketcrawlern eingeführt, weniger für diesen Fall.<br />' +
                'Dennoch: Nutzung auf eigene Gefahr!<br />' +
                'Siehe: <a href="https://originsro.org/tos#s6t4-website-crawling" target="_blank">ToS#Website-Crawling</a></p>';
            mainframe += '<input type="button" id="hugin-form-submit" value="'+btnLabel+'" />';
            style += '#cp-hugin .tos-warning{font-weight:900;color:#a73d16;}';
        }

        mainframe += '</form>' +
            '</div>' +
            '</div>';
        contents.push(mainframe);
        style += '#hugin-contents{display:none;}';
        style += 'form#hugin-data-form label[for="hugin-name"]{display: block;}';
        style += "form#hugin-data-form input{margin-bottom: 10px;}";
        style += 'form#hugin-data-form input[type="button"]{display: block;}';

        render(contents, style);


        if (hasUpdateTimeout) {
            // Ladebalken entfernen
            document.getElementById('hugin-loading').remove();
            // Formular anzeigen
            document.getElementById('hugin-contents').style = 'display:block;';
        } else {
            getCharData().then(function(data, res) {
                // Ladebalken entfernen
                document.getElementById('hugin-loading').remove();
                // Formular anzeigen
                document.getElementById('hugin-contents').style = 'display:block;';
            });
        }

        // Submitbutton rausholen
        const submit = document.getElementById("hugin-form-submit");
        // Auf Klick reagieren.
        submit && submit.addEventListener("click", function(e) {
            e.preventDefault();
            const form = e.currentTarget.parentElement;
            const name = form.querySelector("#hugin-name");
            const secret = form.querySelector("#hugin-secret");

            if (name.value.length === 0) {
                alert('Name ist leer');
                return;
            } else if(name.value.length > 26 || !name.value.match(/^[a-zA-Z]+$/g)) {
                alert('Name enthält ungültige Zeichen oder ist zu lang (max 26 Zeichen). Erlaubt sind a-z und A-Z. Kein Leerzeichen, keine Sonderzeichen, keine Umlaute etc. (Nein James, der Bindestrich in "a-z" oder "A-Z" heißt nicht, dass er eine Sondererlaubnis hat)');
                return;
            }

            if (name.value.length === 0) {
                alert('Secret ist leer');
                return;
            } else if(name.value.length > 64 || !name.value.match(/^[a-zA-Z0-9]+$/g)) {
                alert('Name enthält ungültige Zeichen oder ist zu lang (max 64 Zeichen). Erlaubt sind a-z, A-Z und 0-9. Kein Leerzeichen, keine Sonderzeichen, keine Umlaute etc. (Nein James, der Bindestrich in "a-z", "0-9" oder "A-Z" heißt nicht, dass er eine Sondererlaubnis hat)');
                return;
            }

            sendData(name.value, secret.value, window.huginCharData);
        });
      };

    createControlPanel();
}
)();