IndieGala: Auto-enter Giveaways

Automatically enters IndieGala Giveaways

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
})();