Automatically enters IndieGala Giveaways
// ==UserScript== // @name IndieGala: Auto-enter Giveaways // @version 2.6.1 // @description Automatically enters IndieGala Giveaways // @author Hafas (https://github.com/Hafas/) // @match https://www.indiegala.com/giveaways* // @grant GM.xmlHttpRequest // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js // @connect api.steampowered.com // @connect store.steampowered.com // @namespace https://greasyfork.org/users/56087 // ==/UserScript== (function () { /** * change values to customize the script's behaviour * preferably in your script manager to avoid overrides on updates * if they aren't set they will default to the values below */ const options = { skipOwnedGames: false, skipDLCs: false, // set to 0 to ignore the number of participants maxParticipants: 0, // set to 0 to ignore the price maxPrice: 0, // minimum giveaway level minLevel: 0, // Array of names of games: ["game1","game2","game3"] gameBlacklist: [], onlyEnterGuaranteed: false, // Array of names of users: ["user1","user2","user3"] userBlacklist: [], // Some giveaways don't link to the game directly but to a sub containing that game. IndieGala is displaying these games as "not owned" even if you own that game skipSubGiveaways: false, interceptAlert: false, // how many minutes to wait at the end of the line until restarting from the beginning waitOnEnd: 60, // how many seconds to wait between entering giveaways delay: 1, // Display logs debug: false, // Your Steam API key (keep it private!): "A1B2C3D4E5F6H7I8J9K10L11M12N13O1" steamApiKey: "", // Your Steam user id: "12345678901234567" steamUserId: "", // how many tickets to buy in extra odds giveaways extraTickets: 1 }; /** * current user state */ const my = { level: 10, coins: 240, nextRecharge: 60 * 60 * 1000, ownedGames: new Set() }; const state = { currentPage: 1, currentDocument: document }; /** * entry point of the script */ async function start () { if (!getCurrentPage()) { //I'm not on a giveaway list page. Script stops here. log("Current page is not a giveway list page. Stopping script."); return; } try { await withFailSafeAsync(getOptionsFromCache)(); const [userData, ownedGames] = await Promise.all([ withFailSafeAsync(getUserData)(), withFailSafeAsync(getOwnedGames)() ]); setUserData(userData); setOwnedGames(ownedGames); await waitForGiveaways(); while (okToContinue()) { log("currentPage: %s, myData:", state.currentPage, my); const giveaways = parseGiveaways(); setOwned(giveaways); await setGameInfo(giveaways); await enterGiveaways(giveaways); if (okToContinue() && hasNext()) { await loadNextPage(); } else { break; } } const waitOnEnd = options.waitOnEnd * 60 * 1000; info("Nothing to do. Waiting %s minutes", options.waitOnEnd); setTimeout(reload, waitOnEnd); } catch (err) { error("Something went wrong:", err); } } const IdType = { APP: Symbol(), SUB: Symbol() }; /** * returns true if the logged in user has coins available. * if not, it will return false and trigger navigation to the first giveaway page on recharge */ function okToContinue () { if (my.coins === 0) { info("No coins available"); return false; } return true; } async function getUserData () { const response = await request("/get_user_info?show_coins=True", { maxRetries: 0 }); /** * the API occasionally returns in the `html` property something like: * [...]`$('#ajax_get_user_data').toggle('fast');\n\t});\n</script><script async type="text/javascript" src="/_Incapsula_Resource?`[...] * with unescaped quotation marks which r###lts to a parsing error when trying to parse it as a JSON * The workaround is to just return the payload as text and extract the desired information with regular expressions */ return response.text(); } const LEVEL_PATTERN = /"giveaways_user_lever": ([0-9]+)/; const COINS_PATTERN = /"silver_coins_tot": ([0-9]+)/; /** * collects user information including level, coins and next recharge */ function setUserData (text) { let match = LEVEL_PATTERN.exec(text); if (!match) { error("unable to determine level"); } else { my.level = parseInt(match[1]); } match = COINS_PATTERN.exec(text); if (!match) { error("unable to determine #coins"); } else { my.coins = parseInt(match[1]); } } async function getOwnedGames() { const fetchOwnedGames = options.skipOwnedGames || options.skipDLCs === "missing_basegame"; if (!fetchOwnedGames) { return []; } const { steamApiKey, steamUserId } = options; if (!steamApiKey || !steamUserId) { warn("You must set both 'steamApiKey' and 'steamUserId' to use 'skipOwnedGames'! Proceeding without checking owned games"); return []; } let ownedGames = await getFromCache("ownedGames"); if (ownedGames) { return ownedGames; } const { responseText } = await corsRequest(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${steamApiKey}&steamid=${steamUserId}&format=json`); const { games } = JSON.parse(responseText).response; ownedGames = games.map(({ appid }) => appid); await saveToCache("ownedGames", ownedGames, 60); return ownedGames; } /** * sets the owned-property of each giveaway */ function setOwned (giveaways) { giveaways.forEach((giveaway) => { giveaway.owned = my.ownedGames.has(Number(giveaway.steamId)); if (giveaway.owned) { log("I seem to own '%s' (gameId: '%s')", giveaway.name, giveaway.gameId); } else { log("I don't seem to own '%s' (gameId: '%s')", giveaway.name, giveaway.gameId); } }); } async function setGameInfo (giveaways) { const fetchGameInfo = options.skipDLCs; if (!fetchGameInfo) { return; } const appids = Array.from( new Set( giveaways.map(({ steamId }) => steamId) ) ); const appsDetails = await getFromCache("appsDetails", {}); await Promise.all( appids.map( async (appid) => { const details = appsDetails[appid]; if (details) { return; } const { responseText } = await corsRequest(`https://store.steampowered.com/api/appdetails?appids=${appid}`); const r###lt = JSON.parse(responseText); if (r###lt === null) { warn("No details found for appid '%s'", appid); return; } if (r###lt[appid].success !== true) { error("Failed to get details for appid '%s'", appid, r###lt); return; } const { fullgame, type } = r###lt[appid].data; const basegame = fullgame ? Number(fullgame.appid) : undefined; appsDetails[appid] = { basegame, type }; } ) ) await saveToCache("appsDetails", appsDetails); giveaways.forEach((giveaway) => { const appid = giveaway.steamId; const details = appsDetails[appid]; if (details) { const { basegame, type } = details; giveaway.gameType = type; if (basegame) { giveaway.ownBasegame = my.ownedGames.has(basegame); } } }); } function setOwnedGames (data) { my.ownedGames = new Set(data); } /** * iterates through each giveaway and enters them, if possible and desired */ async function enterGiveaways (giveaways) { log("Entering giveaways", giveaways); for (let giveaway of giveaways) { if (!giveaway.shouldEnter()) { continue; } const numberOfEntries = giveaway.extraOdds ? options.extraTickets - giveaway.boughtTickets : 1; for (let i = 0; i < numberOfEntries; ++i) { const payload = await giveaway.enter(); log("giveaway entered", "payload", payload); switch (payload.status) { case "ok": { my.coins = payload.silver_tot; giveaway.boughtTickets += 1; break; } case "silver": { // we know that our coins value is lower than the price to enter this giveaway, so we can set a guessed value my.coins = Math.min(my.coins, giveaway.price - 1); break; } case "level": { // level hasn't been set properly on initialization - now we can set a guessed value my.level = Math.min(my.level, giveaway.minLevel - 1); break; } default: { error("Failed to enter giveaway. Status: %s. Code: %s, My: %o", payload.status, payload.code, my); } } const delay = options.delay * 1000; log("waiting some msec:", delay); await wait(delay); } } } /** * */ async function waitForGiveaways () { log("waiting giveaways to appear"); await waitForChange(() => document.querySelector(".page-contents-list .items-list-row")); log("giveaways are here. Continue ..."); } /** * parses the DOM and extracts the giveaway. Returns Giveaway-Objects, which include the following properties: id {String} - the giveaway id name {String} - name of the game price {Integer} - the coins needed to enter the giveaway minLevel {Integer} - the minimum level to enter the giveaway participants {Integer} - the current number of participants, that entered that giveaway guaranteed {Boolean} - whether or not the giveaway is a guaranteed one by {String} - name of the user who created the giveaway entered {Boolean} - wheter or not the logged in user has already entered the giveaway steamId {String} - the id Steam gave this game idType {"APP" | "SUB" | null} - "APP" if the steamId is an appId. "SUB" if the steamId is a subId. null if this script is not sure gameId {String} - the gameId IndieGala gave this game. It's usually the appId with or without a suffix, or the subId with a "sub_"-prefix */ function parseGiveaways () { return Array.from(state.currentDocument.querySelectorAll(".page-contents-list .items-list-row .items-list-col")).map((giveawayDOM) => { // we can extract the game id from the image const imageURL = giveawayDOM.getElementsByTagName("img")[0].dataset.imgSrc; const [, , typeString, steamId] = new URL(imageURL).pathname.split("/"); const gameId = steamId; let idType; switch (typeString) { case "apps": { idType = IdType.APP; break; } case "bundles": case "subs": { idType = IdType.SUB; break; } default: { error("Unrecognized id type in '%s'", imageURL); idType = null; } } return new Giveaway({ id: getGiveawayId(giveawayDOM), name: getGiveawayName(giveawayDOM), price: getGiveawayPrice(giveawayDOM), minLevel: getGiveawayMinLevel(giveawayDOM), //will be filled in later in setOwned() owned: undefined, participants: getGiveawayParticipants(giveawayDOM), guaranteed: getGiveawayGuaranteed(giveawayDOM), by: getGiveawayBy(giveawayDOM), boughtTickets: getGiveawayBoughtTickets(giveawayDOM), extraOdds: getGiveawayExtraOdds(giveawayDOM), steamId: steamId, idType: idType, gameId: gameId, gameType: undefined, ownBasegame: undefined }); }); } const withFailSafe = (fn) => (...args) => { try { return fn(...args); } catch (err) { error(...args, err); return undefined; } } const withFailSafeAsync = (fn) => async (...args) => { try { return await fn(...args); } catch (err) { error(...args, err); return undefined; } } const getGiveawayId = withFailSafe((giveawayDOM) => { const linkToGiveaway = giveawayDOM.getElementsByTagName("a")[0].attributes.href.value; return linkToGiveaway.split("/").slice(-1)[0]; }); const getGiveawayName = withFailSafe((giveawayDOM) => giveawayDOM.querySelector("a[title]").attributes.title.value); const getGiveawayPrice = withFailSafe((giveawayDOM) => parseInt(giveawayDOM.querySelector("[data-price]")?.dataset.price)); const getGiveawayMinLevel = withFailSafe((giveawayDOM) => { const levelElement = giveawayDOM.querySelector(".items-list-item-type span"); if (!levelElement) { return 0; } // the text is something like "Lev. 1". Just extract the number. return parseInt(levelElement.textContent.match(/[0-9]+/)[0]); }); const getGiveawayParticipants = withFailSafe((giveawayDOM) => parseInt(giveawayDOM.getElementsByClassName("items-list-item-data-right-bottom")[0]?.textContent)); const getGiveawayGuaranteed = withFailSafe((giveawayDOM) => giveawayDOM.getElementsByClassName("items-list-item-type")[0].classList.contains("items-list-item-type-guaranteed")); // the page does not show who made the giveaway anymore const getGiveawayBy = withFailSafe((/*giveawayDOM*/) => ""); const getGiveawayBoughtTickets = withFailSafe((giveawayDOM) => { if (giveawayDOM.getElementsByClassName("items-list-item-ticket").length === 0) { // entered single ticket giveaway return 1; } const extraOddsElement = giveawayDOM.querySelector("aside.extra-odds .palette-color-11"); if (!extraOddsElement) { // not entered single ticket giveaway return 0; } // extra odds giveaway return parseInt(extraOddsElement.textContent); }); const getGiveawayExtraOdds = withFailSafe((giveawayDOM) => giveawayDOM.getElementsByClassName("fa-clone").length !== 0); /** * utility function that checks if a name is in a blacklist */ const isInBlacklist = (blacklist) => (name) => { if (!Array.isArray(blacklist)) { return false; } for (var i = 0; i < blacklist.length; ++i) { var blacklistItem = blacklist[i]; if (blacklistItem instanceof RegExp) { if (blacklistItem.test(name)) { return true; } } if (name === blacklistItem) { return true; } } return false; } /** * whether or not a game by name is in the blacklist */ const isInGameBlacklist = isInBlacklist(options.gameBlacklist); /** * whether or not a user by name is in the blacklist */ const isInUserBlacklist = isInBlacklist(options.userBlacklist); class Giveaway { constructor (props) { for (let key in props) { if (props.hasOwnProperty(key)) { this[key] = props[key]; } } } /** * returns true if the script can and should enter a giveaway */ shouldEnter () { if (this.boughtTickets && !this.extraOdds) { log("Not entering '%s' because I already entered", this.name); return false; } if (this.extraOdds && this.boughtTickets >= options.extraTickets) { log("Not entering '%s' because I already entered %s times (extraTickets: %s)", this.name, this.boughtTickets, options.extraTickets); return false; } if (this.owned && options.skipOwnedGames) { log("Not entering '%s' because I already own it (skipOwnedGames? %s)", this.name, options.skipOwnedGames); return false; } if (this.gameType === "dlc" && options.skipDLCs) { if (options.skipDLCs === "missing_basegame") { if (!this.ownBasegame) { log("Not entering '%s' because I don't own the basegame of this DLC (skipDLCs? %s)", this.name, options.skipDLCs); return false; } } else { log("Not entering '%s' because the game is a DLC (skipDLCs? %s)", this.name, options.skipDLCs); return false; } } if (isInGameBlacklist(this.name)) { log("Not entering '%s' because this game is on my blacklist", this.name); return false; } if (isInUserBlacklist(this.by)) { log("Not entering '%s' because the user '%s' is on my blacklist", this.name, this.by); return false; } if (!this.guaranteed && options.onlyEnterGuaranteed) { log("Not entering '%s' because the key is not guaranteed to work (onlyEnterGuaranteed? %s)", this.name, options.onlyEnteredGuaranteed); return false; } if (options.maxParticipants && this.participants > options.maxParticipants) { log("Not entering '%s' because of too many are participating (participants: %s, max: %s)", this.name, this.participants, options.maxParticipants); return false; } if (options.maxPrice && this.price > options.maxPrice) { log("Not entering '%s' because of too expensive price (price: %s, max: %s)", this.name, this.price, options.maxPrice); return false; } if (this.idType === IdType.SUB && options.skipSubGiveaways) { log("Not entering '%s' because this giveaway is linked to a sub (skipSubGiveaways? %s)", this.name, options.skipSubGiveaways); return false; } if (this.minLevel > my.level) { log("Not entering '%s' because my level is insufficient (mine: %s, needed: %s)", this.name, my.level, this.minLevel); return false; } if (this.minLevel < options.minLevel) { log("Not entering '%s' because level is too low (level: %s, min: %s)", this.name, this.minLevel, options.minLevel); return false; } if (this.price > my.coins) { log("Not entering '%s' because my funds are insufficient (mine: %s, needed: %s)", this.name, my.coins, this.price); return false; } return true; } /** * sends a POST-request to enter a giveaway */ async enter () { info("Entering giveaway", this); const response = await request("/giveaways/join", { method: "POST", body: JSON.stringify({ id: this.id }), headers: { "X-Requested-With": "XMLHttpRequest" } }); return response.json(); } } /** * load the DOM of the next page, parse it, and place it in `state.currentDocument` for further processing */ async function loadNextPage () { info("loading next page"); const nextPage = state.currentPage + 1; const target = `/giveaways/ajax/${nextPage}/expiry/asc/level/${my.level === 0 ? "0" : "all"}`; const response = await request(target); const json = await response.json(); if (json.status === "ok") { state.currentPage = json.current_page; state.currentDocument = new DOMParser().parseFromString(json.html, "text/html"); } } /** * calls console[method] if debug is enabled */ const printDebug = (method) => (...args) => { if (options.debug) { console[method](...args); } } const log = printDebug("log"); const error = printDebug("error"); const info = printDebug("info"); const warn = printDebug("warn"); var PAGE_NUMBER_PATTERN = /^\/giveaways(?:\/([0-9]+)\/|\/?$)/; /** * returns the current giveaway page */ function getCurrentPage () { var currentPath = window.location.pathname; var match = PAGE_NUMBER_PATTERN.exec(currentPath); if (match === null) { return null; } if (!match[1]) { return 1; } return parseInt(match[1]); } /** * returns true if there is a next page */ function hasNext () { //find the red links and see if one of them is "NEXT" const nextLink = state.currentDocument.querySelector(".page-link-cont .fa-angle-right"); return Boolean(nextLink); } if (options.interceptAlert) { window.alert = function (message) { warn("alert intercepted:", message); }; } /** * sends an HTTP-Request */ async function request (resource, _options = {}, retryCounter = 0) { const { maxRetries = 2, ...otherOptions } = _options; if (retryCounter > maxRetries) { // retry 3 times at most throw new Error(`request to ${resource} failed too often`); } const options = Object.assign({ credentials: "include" }, otherOptions); try { const response = await fetch(document.location.origin + resource, options); if (response.ok) { return response; } const timeoutDelay = response.status === 403 ? 60 * 1000 : 10 * 1000; await wait(timeoutDelay); // retry return request(resource, _options, retryCounter + 1); } catch (err) { await wait(1000); // retry return request(resource, _options, retryCounter + 1); } } async function corsRequest (resource, options) { return new Promise((resolve, reject) => { GM.xmlHttpRequest(Object.assign({ method: "GET", url: resource, anonymous: true }, options, { onerror (response) { error("corsRequest failed", response); reject(); }, onload (response) { resolve(response); } })); }); } function wait (timeout) { return new Promise((resolve) => setTimeout(resolve, timeout)); } function reload () { log("reloading page"); window.location.reload(); } async function waitForChange (condition, timeout = 300) { while (true) { const r###lt = condition(); if (r###lt) { return r###lt; } await wait(timeout); } } async function getFromCache (...args) { const [key, defaultValue] = args; const rawValue = await GM.getValue(key, defaultValue); if (rawValue === undefined && args.length === 2) { return defaultValue; } if (!rawValue || typeof rawValue !== "string") { return rawValue; } try { const { expires, value } = JSON.parse(rawValue); if (expires && new Date().getTime() > new Date(expires).getTime()) { //value has expired await GM.deleteValue(key); return GM.getValue(key, defaultValue); } return value; } catch (error) { if (error instanceof SyntaxError) { return rawValue; } throw error; } } async function saveToCache (key, value, duration) { // if duration is not set then the resource does not expire const expires = duration ? new Date(new Date().getTime() + duration * 60 * 1000) : null const object = { expires, value }; await GM.setValue(key, JSON.stringify(object)); } /* * reads values from userscript manager and falls back to the defaults * also adds simple menu entries to set the values through the userscript manager */ async function getOptionsFromCache () { const optionNames = Object.keys(options); await Promise.all(optionNames.map(async (optionName) => { try { if (optionName === "gameBlacklist" || optionName === "userBlacklist") { options[optionName] = JSON.parse(await GM.getValue(optionName, JSON.stringify(options[optionName]))); } else { options[optionName] = await GM.getValue(optionName, options[optionName]); } } catch (err) { error("Something went wrong:", err); } // https://github.com/greasemonkey/greasemonkey/issues/1860#issuecomment-32908169 GM_registerMenuCommand("Set variable " + optionName, () => { try { var input = JSON.parse(prompt("Value for " + optionName, JSON.stringify(options[optionName]))); if (input == null) { return; } if (Array.isArray(input)) { input = JSON.stringify(input); } GM.setValue(optionName, input); options[optionName] = input; info("Changed %s to %s", optionName, options[optionName]); } catch (err) { error("Something went wrong:", err); } }); })); } start(); })();