// ==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 results 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 result = JSON.parse(responseText);
if (result === null) {
warn("No details found for appid '%s'", appid);
return;
}
if (result[appid].success !== true) {
error("Failed to get details for appid '%s'", appid, result);
return;
}
const { fullgame, type } = result[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 result = condition();
if (result) {
return result;
}
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();
})();