Last active
June 30, 2023 05:26
-
-
Save AviDuda/10006d9e9c9b94f5122a0c69516dfd3c to your computer and use it in GitHub Desktop.
itch.io bundle claimer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name itch.io - claim bundle items | |
// @namespace https://raccoon.land/ | |
// @version 1.3 | |
// @description Claims all items from itch bundles | |
// @author Avi Duda | |
// @match https://*.itch.io/* | |
// @icon https://icons.duckduckgo.com/ip2/itch.io.ico | |
// @grant GM.setValue | |
// @grant GM.getValue | |
// @grant GM.deleteValue | |
// @grant GM.listValues | |
// @grant GM.xmlHttpRequest | |
// @connect itch.io | |
// ==/UserScript== | |
(async function() { | |
'use strict'; | |
const WAIT_FOR_MS = 800; | |
const STORAGE_status = "claim_status"; | |
const STORAGE_bundle = "claim_bundle"; | |
const STORAGE_page = "claim_page"; | |
const STORAGE_max_pages = "claim_max_pages"; | |
const STATE_ready = 1; | |
const STATE_claiming = 2; | |
let processedItems = 0; | |
let totalItems = 0; | |
let promises = []; | |
(async function init() { | |
const status = await GM.getValue(STORAGE_status, STATE_ready); | |
const storedBundle = await GM.getValue(STORAGE_bundle); | |
if (window.location.host === "itch.io") { | |
if (window.location.toString().includes("://itch.io/bundle/download/")) { | |
// We're on bundle's item list page | |
addClaimAllButton(); | |
if (await isRunning() && window.location.pathname === storedBundle) { | |
const currentPage = new URL(location.href).searchParams.get("page") ?? 1; | |
GM.setValue(STORAGE_page, Number(currentPage)); | |
claimItems(); | |
} | |
} | |
} else { | |
// itch.io subdomain | |
if (document.body.dataset.page_name === "view_game") { | |
// We're on a specific store page | |
claimOnStorePage(document, res => { | |
if (res.status === 200) { | |
window.location.reload(); | |
} | |
}); | |
} | |
} | |
})(); | |
/** Add a Claim all button next to the search box */ | |
async function addClaimAllButton() { | |
const filterOptions = document.querySelector('.filter_options'); | |
filterOptions.style = "gap: 1em;" | |
const claimAllButton = document.createElement("a"); | |
claimAllButton.classList.add("button"); | |
claimAllButton.classList.add("claim-all-button"); | |
filterOptions.appendChild(claimAllButton); | |
const claimForms = document.querySelectorAll(".game_row form") ?? []; | |
const elementsWithoutDownload = getUrlsWithoutDownload(); | |
totalItems = claimForms.length + elementsWithoutDownload.length; | |
updateClaimBtnText(); | |
claimAllButton.addEventListener("click", claimAllButtonClick); | |
} | |
/** Set up claiming */ | |
async function claimAllButtonClick(e) { | |
if (e.target.classList.contains("disabled")) { | |
const storedBundle = await GM.getValue(STORAGE_bundle); | |
alert(`You can claim one bundle at a time. Currently claiming bundle ${storedBundle}`); | |
return; | |
} | |
if (await isRunning()) { | |
finishClaiming(); | |
} else { | |
GM.setValue(STORAGE_status, STATE_claiming); | |
GM.setValue(STORAGE_bundle, window.location.pathname); | |
const currentPage = new URL(location.href).searchParams.get("page") ?? 1; | |
GM.setValue(STORAGE_page, Number(currentPage)); | |
const maxPagesEl = document.querySelector(".next_page ~ .pager_label a"); | |
const maxPages = maxPagesEl ? Number(maxPagesEl.innerText) : 1; | |
GM.setValue(STORAGE_max_pages, maxPages); | |
claimItems(); | |
} | |
} | |
/** Claim items on a bundle page */ | |
function claimItems() { | |
claimItemsWithDownload(); | |
claimItemsWithoutDownload(); | |
Promise.all(promises).then(() => goToNextPage()); | |
} | |
/** Claim items which have a download form */ | |
function claimItemsWithDownload() { | |
const claimForms = document.querySelectorAll(".game_row form"); | |
claimForms.forEach((claimForm, i) => { | |
promises.push(new Promise((resolve, reject) => { | |
setTimeout(() => { | |
sendForm(claimForm, { | |
onload: async (res) => { | |
processedItems += 1; | |
await updateClaimBtnText(); | |
resolve(); | |
} | |
}); | |
}, WAIT_FOR_MS * i); | |
})); | |
}); | |
} | |
/** Helper to get unclaimed URLs on a bundle page without a download link */ | |
function getUrlsWithoutDownload() { | |
return [... document.querySelectorAll(".game_row")].map((item, i) => { | |
if (item.querySelector(".file_count") !== null) { | |
return false; | |
} | |
const titleUrl = item.querySelector(".game_title a").href; | |
const viewPageUrl = item.querySelector(".button_row .button")?.href; | |
// Unclaimed items have the same URL in the title and in the View page link | |
if (titleUrl === viewPageUrl) { | |
return viewPageUrl; | |
} else { | |
return false; | |
} | |
}).filter(item => item !== false); | |
} | |
/** Some items don't have any downloads, claim them via their store page */ | |
function claimItemsWithoutDownload() { | |
async function processItem(resolve) { | |
processedItems += 1; | |
await updateClaimBtnText(); | |
resolve(); | |
} | |
getUrlsWithoutDownload().forEach((url, i) => { | |
promises.push(new Promise((resolve, reject) => { | |
setTimeout(() => { | |
const storeDOM = GM.xmlHttpRequest({ | |
url, | |
responseType: "document", | |
onload: async res => { | |
if (res.status === 200) { | |
const formFound = claimOnStorePage(res.response, async res2 => await processItem(resolve)); | |
if (!formFound) { | |
processItem(resolve); | |
} | |
} else { | |
processItem(resolve); | |
} | |
} | |
}); | |
}, WAIT_FOR_MS * i) | |
})); | |
}); | |
} | |
/** Attempt going to the next page on a bundle page */ | |
async function goToNextPage() { | |
const currentPage = await GM.getValue(STORAGE_page); | |
const maxPages = await GM.getValue(STORAGE_max_pages); | |
if (currentPage < maxPages) { | |
GM.setValue(STORAGE_page, currentPage + 1); | |
setTimeout(() => { | |
window.location.search = "?page=" + (currentPage + 1) | |
}, WAIT_FOR_MS); | |
} else { | |
finishClaiming(); | |
} | |
} | |
/** Clean up when claiming has been finished or cancelled */ | |
async function finishClaiming() { | |
let keys = await GM.listValues(); | |
for (let key of keys) { | |
GM.deleteValue(key); | |
} | |
window.location.reload(); | |
} | |
/** | |
* Claim a download on individual store pages | |
* | |
* We don't care about the current state here, claim even when the user | |
* looks at an unclaimed item from another purchase | |
*/ | |
function claimOnStorePage(el = document, onload = () => {}) { | |
const claimForm = el.querySelector(".purchase_banner form"); | |
if (claimForm) { | |
sendForm(claimForm, { | |
onload | |
}); | |
return true; | |
} | |
return false; | |
} | |
/** Helper to check if we're currently claiming something */ | |
async function isRunning() { | |
const status = await GM.getValue(STORAGE_status, STATE_ready); | |
const isRunning = status !== STATE_ready; | |
return isRunning; | |
} | |
async function updateClaimBtnText() { | |
const claimAllButton = document.querySelector(".claim-all-button"); | |
const storedBundle = await GM.getValue(STORAGE_bundle); | |
let btnText = await isRunning() ? "Stop claiming" : "Claim all"; | |
if (await isRunning() && storedBundle !== null && window.location.pathname !== storedBundle) { | |
claimAllButton.classList.add("disabled"); | |
btnText = "Claiming another bundle"; | |
} | |
btnText += ` <span>(${processedItems}/${totalItems})</span>`; | |
claimAllButton.innerHTML = btnText; | |
} | |
function sendForm(formEl, details = {}) { | |
const action = formEl.attributes.action?.textContent ?? window.location; | |
const formData = new FormData(formEl); | |
// Add any items with a name to formData (fixes buttons not being there) | |
formEl.querySelectorAll("[name]").forEach(el => { | |
if (!formData.has(el.name)) { | |
formData.append(el.name, el.value); | |
} | |
}); | |
GM.xmlHttpRequest({ | |
url: action, | |
method: "POST", | |
data: new URLSearchParams(formData).toString(), | |
headers: { | |
"Content-Type": "application/x-www-form-urlencoded" | |
}, | |
...details | |
}); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Works in 2023!