From acc337a4f99606dd3a786dc2cb33eb27623bef95 Mon Sep 17 00:00:00 2001 From: Omair Date: Wed, 24 May 2023 20:12:00 +0100 Subject: [PATCH 1/3] xbox: add config values --- config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config.js b/config.js index 32974e1..b420a5a 100644 --- a/config.js +++ b/config.js @@ -37,6 +37,10 @@ export const cfg = { gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD, gog_newsletter: process.env.GOG_NEWSLETTER == '1', // do not unsubscribe from newsletter after claiming a game // OTP only via GOG_EMAIL, can't add app... + // auth xbox + xbox_email: process.env.XBOX_EMAIL || process.env.EMAIL, + xbox_password: process.env.XBOX_PASSWORD || process.env.PASSWORD, + xbox_otpkey: process.env.XBOX_OTPKEY, // TODO unimplemented // experimmental - likely to change pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores From a7dcfe72ccea6434b4f1b6b04ddbb61a96c011fb Mon Sep 17 00:00:00 2001 From: Omair Date: Wed, 24 May 2023 20:12:33 +0100 Subject: [PATCH 2/3] xbox: add implementation for xbox games with gold --- config.js | 3 +- xbox.js | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 xbox.js diff --git a/config.js b/config.js index b420a5a..b1e31e6 100644 --- a/config.js +++ b/config.js @@ -40,8 +40,7 @@ export const cfg = { // auth xbox xbox_email: process.env.XBOX_EMAIL || process.env.EMAIL, xbox_password: process.env.XBOX_PASSWORD || process.env.PASSWORD, - xbox_otpkey: process.env.XBOX_OTPKEY, // TODO unimplemented - + xbox_otpkey: process.env.XBOX_OTPKEY, // experimmental - likely to change pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores pg_claimdlc: process.env.PG_CLAIMDLC == '1', // prime-gaming: claim in-game content diff --git a/xbox.js b/xbox.js new file mode 100644 index 0000000..501f4f1 --- /dev/null +++ b/xbox.js @@ -0,0 +1,256 @@ +import { firefox } from "playwright-firefox"; // stealth plugin needs no outdated playwright-extra +import { authenticator } from "otplib"; +import { + datetime, + handleSIGINT, + html_game_list, + jsonDb, + notify, + prompt, +} from "./util.js"; +import path from "path"; +import { existsSync, writeFileSync } from "fs"; +import { cfg } from "./config.js"; + +// ### SETUP +const URL_CLAIM = "https://www.xbox.com/en-US/live/gold"; // #gameswithgold"; + +console.log(datetime(), "started checking xbox"); + +const db = await jsonDb("xbox.json"); +db.data ||= {}; + +handleSIGINT(); + +// https://playwright.dev/docs/auth#multi-factor-authentication +const context = await firefox.launchPersistentContext(cfg.dir.browser, { + headless: cfg.headless, + viewport: { width: cfg.width, height: cfg.height }, + locale: "en-US", // ignore OS locale to be sure to have english text for locators -> done via /en in URL +}); + +if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); + +const page = context.pages().length + ? context.pages()[0] + : await context.newPage(); // should always exist + +const notify_games = []; +let user; + +main(); + +async function main() { + try { + await performLogin(); + await getAndSaveUser(); + await redeemFreeGames(); + } catch (error) { + console.error(error); + process.exitCode ||= 1; + if (error.message && process.exitCode != 130) + notify(`xbox failed: ${error.message.split("\n")[0]}`); + } finally { + await db.write(); // write out json db + if (notify_games.filter((g) => g.status != "existed").length) { + // don't notify if all were already claimed + notify(`xbox (${user}):
${html_game_list(notify_games)}`); + } + await context.close(); + } +} + +async function performLogin() { + await page.goto(URL_CLAIM, { waitUntil: "domcontentloaded" }); // default 'load' takes forever + + const signInLocator = page + .getByRole("link", { + name: "Sign in to your account", + }) + .first(); + const usernameLocator = page + .getByRole("button", { + name: "Account manager for", + }) + .first(); + + await Promise.any([signInLocator.waitFor(), usernameLocator.waitFor()]); + + if (await usernameLocator.isVisible()) { + return; // logged in using saved cookie + } else if (await signInLocator.isVisible()) { + console.error("Not signed in anymore."); + await signInLocator.click(); + await signInToXbox(); + } else { + console.error("lost! where am i?"); + } +} + +async function signInToXbox() { + page.waitForLoadState("domcontentloaded"); + if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in + console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`); + + // ### FETCH EMAIL/PASS + if (cfg.xbox_email && cfg.xbox_password) + console.info("Using email and password from environment."); + else + console.info( + "Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode)." + ); + const email = cfg.xbox_email || (await prompt({ message: "Enter email" })); + const password = + email && + (cfg.xbox_password || + (await prompt({ + type: "password", + message: "Enter password", + }))); + // ### FILL IN EMAIL/PASS + if (email && password) { + const usernameLocator = page + .getByPlaceholder("Email, phone, or Skype") + .first(); + const passwordLocator = page.getByPlaceholder("Password").first(); + + await Promise.any([ + usernameLocator.waitFor(), + passwordLocator.waitFor(), + ]); + + // username may already be saved from before, if so, skip to filling in password + if (await page.getByPlaceholder("Email, phone, or Skype").isVisible()) { + await usernameLocator.fill(email); + await page.getByRole("button", { name: "Next" }).click(); + } + + await passwordLocator.fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + + // handle MFA, but don't await it + page.locator('input[name="otc"]') + .waitFor() + .then(async () => { + console.log("Two-Step Verification - Enter security code"); + console.log( + await page + .locator('div[data-bind="text: description"]') + .innerText() + ); + const otp = + (cfg.xbox_otpkey && + authenticator.generate(cfg.xbox_otpkey)) || + (await prompt({ + type: "text", + message: "Enter two-factor sign in code", + validate: (n) => + n.toString().length == 6 || + "The code must be 6 digits!", + })); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them + await page.type('input[name="otc"]', otp.toString()); + await page + .getByLabel("Don't ask me again on this device") + .check(); // Trust this Browser + await page.getByRole("button", { name: "Verify" }).click(); + }) + .catch((_) => {}); + + // Trust this browser, but don't await it + page.getByLabel("Don't show this again") + .waitFor() + .then(async () => { + await page.getByLabel("Don't show this again").check(); + await page.getByRole("button", { name: "Yes" }).click(); + }) + .catch((_) => {}); + } else { + console.log("Waiting for you to login in the browser."); + await notify( + "xbox: no longer signed in and not enough options set for automatic login." + ); + if (cfg.headless) { + console.log( + "Run `SHOW=1 node xbox` to login in the opened browser." + ); + await context.close(); + process.exit(1); + } + } + + // ### VERIFY SIGNED IN + await page.waitForURL(`${URL_CLAIM}**`); + + if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); +} + +async function getAndSaveUser() { + user = await page.locator("#mectrl_currentAccount_primary").innerHTML(); + console.log(`Signed in as '${user}'`); + db.data[user] ||= {}; +} + +async function redeemFreeGames() { + const monthlyGamesLocator = await page.locator(".f-size-large").all(); + + const monthlyGamesPageLinks = await Promise.all( + monthlyGamesLocator.map( + async (el) => await el.locator("a").getAttribute("href") + ) + ); + console.log("Free games:", monthlyGamesPageLinks); + + for (const url of monthlyGamesPageLinks) { + await page.goto(url); + + const title = await page.locator("h1").first().innerText(); + const game_id = page.url().split("/").pop(); + db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only! + console.log("Current free game:", title); + const notify_game = { title, url, status: "failed" }; + notify_games.push(notify_game); // status is updated below + + // SELECTORS + const getBtnLocator = page.getByText("GET", { exact: true }).first(); + const installToLocator = page + .getByText("INSTALL TO", { exact: true }) + .first(); + + await Promise.any([ + getBtnLocator.waitFor(), + installToLocator.waitFor(), + ]); + + if (await installToLocator.isVisible()) { + console.log(" Already in library! Nothing to claim."); + notify_game.status = "existed"; + db.data[user][game_id].status ||= "existed"; // does not overwrite claimed or failed + } else if (await getBtnLocator.isVisible()) { + console.log(" Not in library yet! Click GET."); + await getBtnLocator.click(); + + // wait for popup + await page + .locator('iframe[name="purchase-sdk-hosted-iframe"]') + .waitFor(); + const popupLocator = page.frameLocator( + "[name=purchase-sdk-hosted-iframe]" + ); + + const finalGetBtnLocator = popupLocator.getByText("GET"); + await finalGetBtnLocator.waitFor(); + await finalGetBtnLocator.click(); + + await page.getByText("Thank you for your purchase.").waitFor(); + notify_game.status = "claimed"; + db.data[user][game_id].status = "claimed"; + db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time + console.log(" Claimed successfully!"); + } + + // notify_game.status = db.data[user][game_id].status; // claimed or failed + + // const p = path.resolve(cfg.dir.screenshots, playstation-plus', `${game_id}.png`); + // if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long... + } +} From 8c6ff57054894e99a8f671c271a847bd8ac3394b Mon Sep 17 00:00:00 2001 From: Omair Date: Mon, 26 Jun 2023 15:46:41 -0400 Subject: [PATCH 3/3] xbox: update readme and dockerfile with xbox info/scripts --- Dockerfile | 2 +- README.md | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index ebad9eb..5d8ee90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,4 +77,4 @@ ENV SHOW 1 # Script to setup display server & VNC is always executed. ENTRYPOINT ["docker-entrypoint.sh"] # Default command to run. This is replaced by appending own command, e.g. `docker run ... node prime-gaming` to only run this script. -CMD node epic-games; node prime-gaming; node gog +CMD node epic-games; node prime-gaming; node gog; node xbox; diff --git a/README.md b/README.md index 047d0ce..3938546 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Claims free games periodically on - [Epic Games Store](https://www.epicgames.com/store/free-games) - [Amazon Prime Gaming](https://gaming.amazon.com) - [GOG](https://www.gog.com) -- [Xbox Live Games with Gold](https://www.xbox.com/en-US/live/gold#gameswithgold) - planned +- [Xbox Live Games with Gold](https://www.xbox.com/en-US/live/gold#gameswithgold) ([experimental](https://github.com/vogler/free-games-claimer/issues/19)) - [Unreal Engine (Assets)](https://www.unrealengine.com/marketplace/en-US/assets?count=20&sortBy=effectiveDate&sortDir=DESC&start=0&tag=4910) ([experimental](https://github.com/vogler/free-games-claimer/issues/44), same login as Epic Games) Pull requests welcome :) @@ -24,7 +24,7 @@ Easy option: [install Docker](https://docs.docker.com/get-docker/) (or [podman]( ``` docker run --rm -it -p 6080:6080 -v fgc:/fgc/data --pull=always ghcr.io/vogler/free-games-claimer ``` -This will run `node epic-games; node prime-gaming; node gog` - if you only want to claim games for one of the stores, you can override the default command by appending e.g. `node epic-games` at the end of the `docker run` command, or if you want several `bash -c "node epic-games.js; node gog.js"`. +This will run `node epic-games; node prime-gaming; node gog; node xbox;` - if you only want to claim games for one of the stores, you can override the default command by appending e.g. `node epic-games` at the end of the `docker run` command, or if you want several `bash -c "node epic-games.js; node gog.js"`. Data (including json files with claimed games, codes to redeem, screenshots) is stored in the Docker volume `fgc`.
@@ -86,6 +86,9 @@ Available options/variables and their default values: | GOG_EMAIL | | GOG email for login. Overrides EMAIL. | | GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. | | GOG_NEWSLETTER | 0 | Do not unsubscribe from newsletter after claiming a game if 1. | +| XBOX_EMAIL | | Xbox email for login. Overrides EMAIL. | +| XBOX_PASSWORD | | Xbox password for login. Overrides PASSWORD. | +| XBOX_OTPKEY | | Xbox MFA OTP key. | See `config.js` for all options. @@ -113,6 +116,7 @@ To get the OTP key, it is easiest to follow the store's guide for adding an auth - **Epic Games**: visit [password & security](https://www.epicgames.com/account/password), enable 'third-party authenticator app', copy the 'Manual Entry Key' and use it to set `EG_OTPKEY`. - **Prime Gaming**: visit Amazon 'Your Account › Login & security', 2-step verification › Manage › Add new app › Can't scan the barcode, copy the bold key and use it to set `PG_OTPKEY` - **GOG**: only offers OTP via email +- **Xbox**: visit [additional security](https://account.live.com/proofs/manage/additional) > Add a new way to sign in or verify > Use an app > Set up a different Authenticator app > I can't scan the bar code > copy the bold key and use it to set `XBOX_OTPKEY` Beware that storing passwords and OTP keys as clear text may be a security risk. Use a unique/generated password! TODO: maybe at least offer to base64 encode for storage. @@ -130,13 +134,16 @@ Claiming the Amazon Games works out-of-the-box, however, for games on external s Keys and URLs are printed to the console, included in notifications and saved in `data/prime-gaming.json`. A screenshot of the page with the key is also saved to `data/screenshots`. [TODO](https://github.com/vogler/free-games-claimer/issues/5): ~~redeem keys on external stores.~~ +### Xbox Games With Gold +Run `node xbox` (locally or in docker). + ### Run periodically #### How often? Epic Games usually has two free games *every week*, before Christmas every day. Prime Gaming has new games *every month* or more often during Prime days. -GOG usually has one new game every couples of weeks. +GOG usually has one new game every couples of weeks. Xbox usually has two games *every month*. -It is save to run the scripts every day. +It is safe to run the scripts every day. #### How to schedule? The container/scripts will claim currently available games and then exit.