Itch.io Web Integration

Shows if an Itch.io link has been claimed or not

Per 09-06-2020. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name        Itch.io Web Integration
// @namespace   Lex@GreasyFork
// @match       *://*/*
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @version     0.1.8.4
// @author      Lex
// @description Shows if an Itch.io link has been claimed or not
// @connect     itch.io
// ==/UserScript==

(function(){
    'use strict';

    const CACHE_VERSION_KEY = "CacheVersion";
    const INVALIDATION_TIME = 5*60*60*1000; // 5 hour cache time
    const ITCH_GAME_CACHE_KEY = 'ItchGameCache';
    var ItchGameCache;
    
    // Promise wrapper for GM_xmlhttpRequest
    const Request = details => new Promise((resolve, reject) => {
        details.onerror = details.ontimeout = reject;
        details.onload = resolve;
        GM_xmlhttpRequest(details);
    });
    
    function versionCacheInvalidator() {
        const sVersion = v => {
            if (typeof v !== 'string' || !v.match(/\d+\.\d+/)) return 0;
            return parseFloat(v.match(/\d+\.\d+/)[0]);
        }
        const prev = sVersion(GM_getValue(CACHE_VERSION_KEY, '0.0'));
        if (prev < 0.1) {
            console.log(`${GM_info.script.version} > ${prev}`);
            console.log(`New minor version of ${GM_info.script.name} detected. Invalidating cache.`)
            _clearItchCache();
        }
        GM_setValue(CACHE_VERSION_KEY, GM_info.script.version);
    }
    
    function _clearItchCache() {
        ItchGameCache = {};
        _saveItchCache();
    }
    
    function loadItchCache() {
        ItchGameCache = JSON.parse(GM_getValue(ITCH_GAME_CACHE_KEY, '{}'));
    }
    
    function _saveItchCache() {
        if (ItchGameCache === undefined) return;
        GM_setValue(ITCH_GAME_CACHE_KEY, JSON.stringify(ItchGameCache));
    }
    
    function setItchGameCache(key, game) {
        loadItchCache(); // refresh our cache in case another tab has edited it
        ItchGameCache[key] = game;
        _saveItchCache();
    }
    
    function deleteItchGameCache(key) {
        if (key === undefined) return;
        loadItchCache();
        delete ItchGameCache[key];
        _saveItchCache();
    }
    
    function getItchGameCache(link) {
        if (!ItchGameCache) loadItchCache();
        if (Object.prototype.hasOwnProperty.call(ItchGameCache, link)) {
            return ItchGameCache[link];
        }
        return null;
    }
    
    async function claimGame(url) {
        const parser = new DOMParser();
        
        const purchase_url = url + "/purchase";
        console.log("Getting purchase page: " + purchase_url);
        const purchase_resp = await Request({method: "GET", url: purchase_url});
        const purchase_dom = parser.parseFromString(purchase_resp.responseText, 'text/html');
        const download_csrf_token = purchase_dom.querySelector("form.form").csrf_token.value;
        
        const download_url_resp = await Request({
            method: "POST",
            url: url + "/download_url",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            data: 'csrf_token='+encodeURIComponent(download_csrf_token)
        });
        const downloadUrl = JSON.parse(download_url_resp.responseText).url;
        console.log("Received download url: " + downloadUrl);

        const download_resp = await Request({method: "GET", url: downloadUrl});
        const dom = parser.parseFromString(download_resp.responseText, 'text/html');
        const claimForm = dom.querySelector(".claim_to_download_box form");
        const claim_csrf_token = claimForm.csrf_token.value;
        const claim_key_url = claimForm.action;

        console.log("Claiming game using " + claim_key_url);
        const claim_key_resp = await Request({
            method: "POST",
            url: claim_key_url,
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            data: 'csrf_token='+encodeURIComponent(claim_csrf_token)
        });
        return /You claimed this/.test(claim_key_resp.responseText);
    }
    
    // Parses a DOM into a game object
    function parsePage(url, dom) {
        // Gets the inner text of an element if it can be found otherwise returns undefined
        const txt = query => { const e = dom.querySelector(query); return e && e.innerText.trim(); };
        
        const game = {};
        
        game.cachetime = (new Date()).getTime();
        game.url = url;
        game.title = txt('h1.game_title');
        
        game.isOwned = dom.querySelector(".purchase_banner_inner .key_row .ownership_reason") !== null;        
        game.isClaimable = [...dom.querySelectorAll(".buy_btn")].filter(e => e.innerText == "Download or claim").length > 0;
        game.isFree = [...dom.querySelectorAll("span[itemprop=price]")].filter(e => e.innerText === "$0.00 USD").length > 0;
        game.hasPurchase = [...dom.querySelectorAll("span[itemprop=price]")].filter(e => e.innerText !== "$0.00 USD").length > 0;
        game.hasFreeDownload = [...dom.querySelectorAll("a.download_btn,a.buy_btn")].filter(e => e.innerText == "Download" || e.innerText == "Download Now").length > 0;
        game.hasCommunityCopies = document.querySelector(".reward_footer") !== null;
        const copiesBlock = document.querySelector(".remaining_count");
        game.communityCopies = copiesBlock && copiesBlock.innerText.match(/\d+/) && copiesBlock.innerText.match(/\d+/)[0];
        game.communityCopies = game.communityCopies || 0;
        game.original_price = txt("span.original_price");
        game.price = txt("span[itemprop=price]");
        game.saleRate = txt(".sale_rate");
        game.breadcrumbs = txt(".breadcrumbs");
        return game;
    }
    
    // Sends an XHR request and parses the results into a game object
    async function fetchItchGame(url) {
        const response = await Request({method: "GET",
                                 url: url});
        if (response.status != 200) {
            console.log(`Error ${response.status} fetching page ${url}`);
            return null;
        }
        const parser = new DOMParser();
        const dom = parser.parseFromString(response.responseText, 'text/html');
        return parsePage(url, dom);
    }
    
    // Loads an itch game from cache or fetches the page if needed
    async function getItchGame(url) {
        let game = getItchGameCache(url);
        if (game !== null) {
            const isExpired = (new Date()).getTime() - game.cachetime > INVALIDATION_TIME;
            // Expiration checking currently disabled
            /*if (isExpired) {
                game = null;
            }*/
        }
        if (game === null) {
            game = await fetchItchGame(url);
            if (game !== null)
                setItchGameCache(url, game);
        }
        return game;
    }
    
    async function claimClicked(a, game) {
        console.log("Attempting to claim " + game.url);
        a.innerText += ' ⌛';
        a.onclick = null;
        const success = await claimGame(game.url);
        if (success === true) {
            a.style.display = "none";
            const ownMark = a.parentElement.firstChild;
            ownMark.innerHTML = `<span title="Successfully claimed">✔️</span>`;
            deleteItchGameCache(game.url);
        } else {
            a.innerHTML = `❗ Error`;
        }
    }
    
    // Appends the isOwned tag to an anchor link
    function appendTags(a, game) {
        const div = document.createElement("div");
        div.style.display = "inline-block";
        const span = document.createElement("span");
        div.append(span);
        span.style = "margin-left: 5px; background:rgb(230,230,230); padding: 2px; border-radius: 2px";
        
        if (game === null) {
            span.innerHTML = `<span title="Status unknown. Try refreshing.">❓</span>`;
        } else if (game.isOwned) {
            span.innerHTML = `<span title="Game is already claimed on itch.io">✔️</span>`;
        } else {
            if (!game.isClaimable) {
                if (game.hasFreeDownload && !game.hasPurchase) {
                    span.innerHTML = `<span title="Game is a free download but not claimable">🆓</span>`;
                } else if (game.price) {
                    span.innerHTML = `<span title="🛒 Game costs ${game.price}">🛒</span>`;
                } else {
                    span.innerHTML = `<span title="Status unknown">👽</span>`;
                }
            } else {
                const origPrice = game.original_price ? ` 🛒 Original price: ${game.original_price} 💸 Current Price: ${game.price}` : '';
                span.innerHTML = `<span title="Game is claimable but you haven't claimed it.${origPrice}">❌</span>`;
                
                const claimBtn = document.createElement("span");
                claimBtn.style = `margin-left: 2px; padding: 2px; cursor:pointer; background:rgb(220,220,220); border-radius: 5px`;
                claimBtn.className = "ClaimButton";
                claimBtn.innerText = "🛄 Claim Game";
                claimBtn.onclick = function(event) { claimClicked(event.target, game); };
                span.after(claimBtn);
            }
        }
        if (game.hasCommunityCopies) {
            const communityTag = document.createElement("span");
            communityTag.title = `This game has ${game.communityCopies} Community Copies availible.`;
            communityTag.innerText = '👪';
            span.append(communityTag);
        }
        if (game !== null && game.breadcrumbs) {
            span.firstChild.title += ' ℹ️ ' + game.breadcrumbs;
            if (!a.title)
                a.title = game.breadcrumbs;
            const tags = {
                //"Games": { icon: '🎮', title: "Video game" },
                "Tools": { icon: '🛠️', title: "Tool" },
                "Game assets": { icon: '🗃️', title: "Game asset" },
                "Comics": { icon: '🗨️', title: "Comic" },
                "Books": { icon: '📘', title: "Book" },
                "Physical games": { icon: '📖', title: "Physical game" },
                "Soundtracks": { icon: '🎵', title: "Soundtrack" },
                "Game mods": { icon: '⚙️', title: "Game mod" },
            }
            const category = game.breadcrumbs.split("›")[0].trim();
            if (Object.prototype.hasOwnProperty.call(tags, category)) {
                const tag = document.createElement("span");
                tag.title = tags[category].title;
                tag.innerText = tags[category].icon;
                span.append(tag);
            }
        }
        
        a.after(div);
    }
    
    function addClickHandler(a) {
        a.addEventListener('mouseup', event => {
            deleteItchGameCache(event.target.href);
        });
    }

    // Handles an itch.io link on a page
    async function handleLink(a) {
        addClickHandler(a);
        const game = await getItchGame(a.href);
        appendTags(a, game);
    }
    
    function isGameUrl(url) {
        return /^https:\/\/[^.]+\.itch\.io\/[^/]+$/.test(url);
    }
    
    // Finds all the itch.io links on the current page
    function getItchLinks() {
        let links = [...document.querySelectorAll("a[href*='itch.io/']")];
        links = links.filter(a => isGameUrl(a.href));
        links = links.filter(a => !a.classList.contains("return_link"));
        links = links.filter(a => { const t = a.textContent.trim(); return t !== "" && t !== "GIF"; });
        return links;
    }
    
    function handlePage() {
        if (isGameUrl(window.location.href)) {
            // If we're on an Itch game page, update the cached details
            const game = parsePage(window.location.href, document);
            setItchGameCache(window.location.href, game);
        }
        // Try to find any itch links on the page and tag them
        const as = getItchLinks();
        as.forEach(handleLink);
    }
    
    versionCacheInvalidator();
    handlePage();
})();