diff --git a/README.md b/README.md
index 7cb43932..f7ccd697 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Claims free games periodically on
- [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)
+- [PlayStation](https://www.playstation.com/en-us/ps-plus/whats-new/#monthly-games) - experimental
Pull requests welcome :)
@@ -87,6 +88,14 @@ 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. |
+| PS_EMAIL | | PlayStation email for login. Overrides EMAIL. |
+| PS_PASSWORD | | PlayStation password for login. Overrides PASSWORD. |
+| PS_OTPKEY | | PlayStation MFA OTP key. |
+| PS_LOCALE | en-us | Configurable locale to use the store. Examples are en-us, en-gb, de-at, ... |
+| PS_PLUS_GAMES | 1 | Claim monthly ps plus games. Requires PS Plus Essential or higher subscription. |
+| PS_GAME_CATALOG | 0 | Claim over 400 game catalog games. Requires PS Extra or higher. |
+| PS_CLASSICS_CATALOG | 0 | Currently not implemented! Requires PS Extra or higher. |
+
See `config.js` for all options.
@@ -114,6 +123,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
+- **PlayStation**: visit [account settings](https://id.sonyentertainmentnetwork.com/id/management_ca/?smcid=pdc%3Aen-us%3Aweb-toolbar-account%3Aaccount%20settings) > Security > 'edit' 2-Step Verification > Authenticator App > copy the key and use it to set `PS_OTPKEY`. Note: If you have SMS or another auth setup you need to switch to an Authenticator App like Duo.
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.
@@ -131,12 +141,17 @@ 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.~~
+
+### PlayStation
+Run `node playstation` (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.
Unreal Engine has new assets to claim *every first Tuesday of a month*.
+PlayStation releases 2-3 new games on a monthly basis for PlayStation Plus Essential.
It is save to run the scripts every day.
diff --git a/config.js b/config.js
index e604d0fb..42f9d6a6 100644
--- a/config.js
+++ b/config.js
@@ -43,4 +43,13 @@ export const cfg = {
// 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
+
+ // playstation
+ ps_email: process.env.PS_EMAIL || process.env.EMAIL,
+ ps_password: process.env.PS_PASSWORD || process.env.PASSWORD,
+ ps_otpkey: process.env.PS_OTPKEY,
+ ps_locale: "en-us" || process.env.PS_LOCALE,
+ ps_plus_games: true || process.env.PS_PLUS_GAMES == '1',
+ ps_game_catalog: false || process.env.PS_GAME_CATALOG == '1',
+ ps_classics_catalog: false || process.env.PS_CLASSICS_CATALOG == '1',
};
diff --git a/playstation.js b/playstation.js
new file mode 100644
index 00000000..9a33bd12
--- /dev/null
+++ b/playstation.js
@@ -0,0 +1,411 @@
+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,
+ stealth,
+} from "./util.js";
+import path from "path";
+import { existsSync, writeFileSync } from "fs";
+import { cfg } from "./config.js";
+
+// ### SETUP
+const URL_CLAIM = "https://www.playstation.com/" + cfg.ps_locale + "/ps-plus/whats-new/";
+
+console.log(datetime(), "started checking playstation");
+
+const db = await jsonDb("playstation.json", {});
+db.data ||= {};
+
+handleSIGINT();
+
+const notify_games = [];
+let user;
+let page;
+let context;
+setup();
+
+export async function setup() {
+ // https://playwright.dev/docs/auth#multi-factor-authentication
+ context = await firefox.launchPersistentContext(cfg.dir.browser, {
+ // chrome will not work in linux arm64, only chromium
+ // channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
+ headless: cfg.headless,
+ viewport: { width: cfg.width, height: cfg.height },
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated?
+ // userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
+ locale: "en-US", // ignore OS locale to be sure to have english text for locators
+ recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
+ recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
+ args: [ // https://peter.sh/experiments/chromium-command-line-switches
+ // don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.'
+ // '--restore-last-session', // does not apply for crash/killed
+ '--hide-crash-restore-bubble',
+ // `--disable-extensions-except=${ext}`,
+ // `--load-extension=${ext}`,
+ ],
+ // ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
+ });
+
+ await stealth(context);
+
+ if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
+
+ page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
+ startPlaystation();
+}
+
+async function startPlaystation() {
+ try {
+ await performLogin();
+ await getAndSaveUser();
+ if (cfg.ps_plus_games) {
+ await claimPSPlusGames();
+ }
+ if (cfg.ps_game_catalog) {
+ await claimGameCatalog();
+ }
+ if (cfg.ps_classics_catalog) {
+ console.log("NOT IMPLEMENTED");
+ // for some reason these games are not linked on the website so we would need to search for them manually?
+ }
+ } catch (error) {
+ console.error(error);
+ process.exitCode ||= 1;
+ if (error.message && process.exitCode != 130)
+ notify(`playstation 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(
+ `playstation (${user}):
${html_game_list(notify_games)}`
+ );
+ }
+ if (page.video()) console.log('Recorded video:', await page.video().path());
+ await context.close();
+ }
+}
+
+async function performLogin() {
+ // the page gets stuck sometimes and requires a reload
+ await page.goto(URL_CLAIM, { waitUntil: "domcontentloaded" });
+
+ const signInLocator = page.locator('button[data-track-click="web:select-sign-in-button"]').first();
+ const profileIconLocator = page.locator(".profile-icon").first();
+
+ const mainPageBaseUrl = "https://playstation.com";
+ const loginPageBaseUrl = "https://my.account.sony.com";
+
+ async function isSignedIn() {
+ await Promise.any([
+ signInLocator.waitFor(),
+ profileIconLocator.waitFor(),
+ ]);
+ return !(await signInLocator.isVisible());
+ }
+
+ if (!(await isSignedIn())) {
+ await signInLocator.click();
+
+ await page.waitForLoadState("networkidle");
+
+ if (await page.url().indexOf(mainPageBaseUrl) === 0) {
+ if (await isSignedIn()) {
+ return; // logged in using saved cookie
+ } else {
+ console.error("stuck in login loop, try clearing cookies");
+ }
+ } else if (await page.url().indexOf(loginPageBaseUrl) === 0) {
+ console.error("Not signed in anymore.");
+ await signInToPSN();
+ await page.waitForURL(URL_CLAIM);
+ if (!(await isSignedIn())) {
+ console.log("Login attempt failed. Trying again.");
+ return await performLogin();
+ }
+ } else {
+ console.error("lost! where am i?", await page.url());
+ }
+ }
+}
+
+async function signInToPSN() {
+ await page.waitForSelector("#kekka-main");
+ 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.ps_email && cfg.ps_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.ps_email || (await prompt({ message: "Enter email" }));
+ const password =
+ email &&
+ (cfg.ps_password ||
+ (await prompt({
+ type: "password",
+ message: "Enter password",
+ })));
+
+ // ### FILL IN EMAIL/PASS
+ if (email && password) {
+ await page.locator("#signin-entrance-input-signinId").fill(email);
+ await page.locator("#signin-entrance-button").click(); // Next button
+ await page.waitForSelector("#signin-password-input-password");
+ await page.locator("#signin-password-input-password").fill(password);
+ await page.locator("#signin-password-button").click();
+
+ // ### CHECK FOR CAPTCHA
+ page.frameLocator('iframe[title="Verification challenge"]').locator("#FunCaptcha")
+ .waitFor()
+ .then(() => {
+ console.error(
+ "Got a captcha during login (likely due to too many attempts)! You may solve it in the browser, get a new IP or try again in a few hours."
+ );
+ notify(
+ "playstation: got captcha during login. Please check."
+ );
+ })
+ .catch((_) => { });
+
+ // handle MFA
+ await page.locator('input[title="Enter Code"]');
+ console.log("Two-Step Verification - Enter security code");
+ console.log(await page.locator(".description-regular").innerText());
+ const otp =
+ (cfg.ps_otpkey &&
+ authenticator.generate(cfg.ps_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[title="Enter Code"]', otp.toString());
+ await page
+ .locator(".checkbox-container")
+ .locator("button")
+ .first()
+ .click(); // Trust this Browser
+ await page.click("button.primary-button");
+ } else {
+ console.log("Waiting for you to login in the browser.");
+ await notify(
+ "playstation: no longer signed in and not enough options set for automatic login."
+ );
+ if (cfg.headless) {
+ console.log(
+ "Run `SHOW=1 node playstation` to login in the opened browser."
+ );
+ await context.close();
+ process.exit(1);
+ }
+ }
+ if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
+}
+
+async function getAndSaveUser() {
+ user = await page.locator(".psw-c-secondary").innerText();
+ console.log(`Signed in as '${user}'`);
+ db.data[user] ||= {};
+}
+
+async function purchaseFromCart() {
+ const iFrame = await page.frameLocator('iframe[name="embeddedcart"]');
+ const totalPrice = await iFrame.locator("#total-price .summary__row--value").innerText();
+
+ if (totalPrice.includes("0,00") && totalPrice.length == 5) {
+ console.log("Actually free game");
+ await iFrame.locator(".password-prompt__input").fill(cfg.ps_password);
+ await iFrame.locator("#verification-ImmediatePaymentWarning").click();
+ await iFrame.locator(".confirm-purchase__button").click();
+ } else {
+ console.log("Something seems to be wrong with the total price '" + totalPrice + "' expecting 0,00 and 5 length");
+ }
+}
+
+async function claimGame(url) {
+ console.log("Open: " + url);
+ await page.goto(url, { waitUntil: 'networkidle' });
+
+ if (await page.url().includes("/error")) {
+ console.log("Landed on an error page. The game might not exist in your region. Skipping.");
+ return;
+ }
+ const signInLocator = page.locator('button[data-track-click="web:select-sign-in-button"]').first();
+
+ if (await signInLocator.isVisible()) {
+ console.log("lost the login - trying to recover");
+ await performLogin();
+ await claimGame(url);
+ return;
+ }
+
+ var prefix;
+ if (url.includes("store.playstation.com")) {
+ const gameDiv = await page.locator(".psw-l-anchor").first();
+ if (gameDiv.isVisible()) {
+ prefix = ".psw-l-anchor ";
+ }
+ else {
+ prefix = ".psw-l-grid ";
+ }
+ } else {
+ prefix = ".gamehero ";
+ }
+
+ const title = await page.locator(prefix + "h1").first().innerText();
+
+ const game_id = page
+ .url()
+ .split("/")
+ .filter((x) => !!x)
+ .pop();
+ db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only!
+ console.log("Current title:", title);
+ const notify_game = { title, url, status: "failed" };
+ notify_games.push(notify_game); // status is updated below
+
+ // SELECTORS
+ const purchased = page
+ .locator(prefix + 'a[data-track-click="ctaWithPrice:download"]:visible')
+ .first();
+ const addToCart = page // the base game may not be the free one, look for any edition
+ .locator(prefix + 'button[data-track-click="ctaWithPrice:addToCart"]:visible')
+ .first();
+ const inCart = page // the base game may not be the free one, look for any edition
+ .locator(prefix + 'button[data-track-click="ctaWithPrice:inCart"]:visible')
+ .first();
+ const addToLibrary = page // the base game may not be the free one, look for any edition
+ .locator(prefix + 'button[data-track-click="ctaWithPrice:addToLibrary"]:visible')
+ .first();
+ const cantPurchase = page // the base game may not be the free one, look for any edition
+ .locator(prefix + 'span[data-qa="mfeCtaMain#cantPurchaseText"]:visible')
+ .first();
+
+ await Promise.any([addToCart.waitFor(), inCart.waitFor(), addToLibrary.waitFor(), purchased.waitFor(), cantPurchase.waitFor()]);
+
+ if (await purchased.isVisible() || await cantPurchase.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
+ await db.write();
+ } else if (await inCart.isVisible()) {
+ console.log("Not in library yet! But in cart.");
+ await inCart.click();
+
+ await purchaseFromCart();
+
+ await purchased.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!");
+ await db.write();
+ } else if (await addToLibrary.isVisible()) {
+ console.log("Not in library yet! Click ADD TO LIBRARY.");
+ await addToLibrary.click();
+
+ await Promise.any([purchased.waitFor(), cantPurchase.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!");
+ await db.write();
+ } else if (await addToCart.isVisible()) {
+ console.log("Not in library yet! Click ADD TO CART.");
+ const psIcon = await page.locator(prefix + "span[data-qa='mfeCtaMain#offer0#serviceIcon#ps-plus']").first();
+ if (!await psIcon.isVisible()) {
+ console.log("No PS+ icon present. The game might not be free in your region. Skipping.");
+ const p = path.resolve(cfg.dir.screenshots, 'playstation', `${game_id}.png`);
+ if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false });
+ return;
+ }
+ await addToCart.click();
+
+ await purchaseFromCart();
+
+ await Promise.any([purchased.waitFor(), cantPurchase.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!");
+ await db.write();
+ }
+
+ notify_game.status = db.data[user][game_id].status; // claimed or failed
+
+ const p = path.resolve(cfg.dir.screenshots, 'playstation', `${game_id}.png`);
+ if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false });
+}
+
+async function claimPSPlusGames() {
+ // ### GET LIST OF FREE GAMES
+ console.log("Claim PS+ games");
+ const monthlyGamesBlock = await page.locator(
+ ".cmp-experiencefragment--your-latest-monthly-games"
+ );
+ const monthlyGamesLocator = await monthlyGamesBlock.locator(".box").all();
+
+ const monthlyGamesPageLinks = await Promise.all(
+ monthlyGamesLocator.map(async (el) => {
+ const urlSlug = await el
+ .locator(".cta__primary")
+ .getAttribute("href");
+ // standardize URLs
+ return (urlSlug.charAt(0) === "/"
+ ? `https://www.playstation.com${urlSlug}` // base url may not be present, add it back
+ : urlSlug)
+ .split('#').shift(); // url may have anchor tag, remove it
+ })
+ );
+ console.log("PS+ games:", monthlyGamesPageLinks);
+
+ for (const url of monthlyGamesPageLinks) {
+ if (!isClaimedUrl(url)) {
+ await claimGame(url);
+ }
+ }
+}
+
+async function claimGameCatalog() {
+ console.log("Claim game catalog");
+ await page.goto("https://www.playstation.com/" + cfg.ps_locale + "/ps-plus/games/#game-cat-a-z");
+
+ const catalogGames = await page.locator(".autogameslist a").all();
+
+ const catalogGameUrls = await Promise.all(
+ catalogGames.map(async (catalogGame) => {
+ var urlSlug = await catalogGame.getAttribute("href");
+
+ urlSlug = urlSlug.replace("en-gb", cfg.ps_locale).replace("en-us", cfg.ps_locale).substring(0, urlSlug.indexOf("?"));
+ //console.log(urlSlug);
+ return urlSlug; // url may have anchor tag, remove it
+ })
+ );
+ console.log("Total catalog games:", catalogGameUrls.length);
+ const filteredCatalogGameUrls = catalogGameUrls.filter(function (url) { return !isClaimedUrl(url); }).sort();
+ console.log("Non claimed catalog games:", filteredCatalogGameUrls.length, "Hint: Not all of the games are free in your region.");
+
+ for (const url of filteredCatalogGameUrls) {
+ await claimGame(url);
+ }
+}
+
+function isClaimedUrl(url) {
+ try {
+ var status = db.data[user][url.split("/").filter((x) => !!x).pop()]["status"];
+ return status === "existed" || status === "claimed";
+ } catch (error) {
+ return false;
+ }
+}