Itch.io Web Integration

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

目前為 2020-06-09 提交的版本,檢視 最新版本

// ==UserScript==
// @name        Itch.io Web Integration
// @namespace   Lex@GreasyFork
// @match       *://*/*
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @version     0.1.8.2
// @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.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.previousElementSibling;
            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>`;
                
                claimBtn = document.createElement("span");
                claimBtn.style = `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;
            if (game.breadcrumbs.startsWith("Physical")) {
                const physicalTag = document.createElement("span");
                physicalTag.title = "Physical game";
                physicalTag.innerText = '📖';
                span.append(physicalTag);
            }
        }
        
        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)) {
            const game = parsePage(window.location.href, document);
            setItchGameCache(window.location.href, game);
        }
        const as = getItchLinks();
        as.forEach(handleLink);
    }
    
    versionCacheInvalidator();
    handlePage();
})();