diff --git a/README.md b/README.md
index 2f2ecd9..18cf520 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@ 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
-
[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 :)
@@ -118,6 +118,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
+
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.
@@ -135,14 +136,18 @@ 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.~~
+
+
+
### 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.
Unreal Engine has new assets to claim *every first Tuesday of a 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.
diff --git a/config.js b/config.js
index 336db99..f12f99e 100644
--- a/config.js
+++ b/config.js
@@ -39,7 +39,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,
// 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...
+ }
+}