Skip to content

Instantly share code, notes, and snippets.

@AviDuda
Last active June 30, 2023 05:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AviDuda/10006d9e9c9b94f5122a0c69516dfd3c to your computer and use it in GitHub Desktop.
Save AviDuda/10006d9e9c9b94f5122a0c69516dfd3c to your computer and use it in GitHub Desktop.
itch.io bundle claimer
// ==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
});
}
})();
@ryanpcmcquen
Copy link

Works in 2023!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment