Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Playstation Plus Monthly Games #165

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 playstation-plus;
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Claims free games periodically on
- <img src="https://static.wikia.nocookie.net/this-war-of-mine/images/1/1a/Logo_GoG.png/revision/latest?cb=20160711062658" width="32"/> [GOG](https://www.gog.com)
- <img src="https://www.freepnglogos.com/uploads/xbox-logo-picture-png-14.png" width="32"/> [Xbox Live Games with Gold](https://www.xbox.com/en-US/live/gold#gameswithgold) - planned
- <img src="https://cdn2.unrealengine.com/ue-logo-white-e34b6ba9383f.svg" width="32"/> [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)
- <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/00/PlayStation_logo.svg/400px-PlayStation_logo.svg.png" width="32"/> [PlayStation Plus](https://www.playstation.com/en-us/ps-plus/whats-new/#monthly-games) ([experimental](https://github.com/vogler/free-games-claimer/issues/141))

Pull requests welcome :)

Expand All @@ -24,7 +25,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-playstation-plus;` - 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`.

<details>
Expand Down Expand Up @@ -86,6 +87,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. |
| PSP_EMAIL | | PlayStation email for login. Overrides EMAIL. |
| PSP_PASSWORD | | PlayStation password for login. Overrides PASSWORD. |
| PSP_OTPKEY | | PlayStation MFA OTP key. |

See `config.js` for all options.

Expand Down Expand Up @@ -113,6 +117,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 `PSP_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.

Expand All @@ -130,11 +135,15 @@ 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 Plus
Run `node playstation-plus` (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.
PlayStation Plus usually has two or three new games *every month*.

It is save to run the scripts every day.

Expand Down
4 changes: 4 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 playstation-plus
psp_email: process.env.PSP_EMAIL || process.env.EMAIL,
psp_password: process.env.PSP_PASSWORD || process.env.PASSWORD,
psp_otpkey: process.env.PSP_OTPKEY,

// experimmental - likely to change
pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores
Expand Down
269 changes: 269 additions & 0 deletions playstation-plus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
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,
retryOnError,
} 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/en-us/ps-plus/whats-new";
Copy link

@Barokai Barokai Aug 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi,
could you make the locale configurable too, please? as there are different country stores, in my example, "de-at"
With en-us in the URL, I can't claim the games even when being logged in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, I likely won't be able to work on this PR anytime soon. If anyone else wants to take over this PR, feel free to.

This automation is mostly working. However, sometimes the login detection/state can get in a weird state and have noticed it fail for me quite often.

  • this part for detecting page load sometimes never resolves (domcontentloaded does not always trigger)
  • if the page load does resolve, the sign in button locator may not be specific enough, sometimes (rarely) it redirects to account management page?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @OJ7! The load/login I can have a look at without a Playstation Plus subscription I guess?

@Barokai I just tried that e.g. en-de does not work. If we can't list the games for a country without having to change to its language, it will be problematic for text locators.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vogler yup, no subscription needed.

FYI I did some testing with another account (Turkish) and had to change it to en-tr for it to redeem games. Otherwise, there would be an unknown error redeeming them sometimes.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Barokai I just tried that e.g. en-de does not work. If we can't list the games for a country without having to change to its language, it will be problematic for text locators.

you're right en-de doesn't work as it is a non-existent locale.
didn't check all the locators to be honest, thought they were only about class and ID selectors, didn't see the text only ones - only way to solve this would be to make a lookup table for the languages and all the translations of text-only locators...

Copy link
Contributor

@4n4n4s 4n4n4s Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might push an updates version of this this weekend i have been working on some things already :)


console.log(datetime(), "started checking playstation plus");

const db = await jsonDb("playstation-plus.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(`playstation-plus 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-plus (${user}):<br>${html_game_list(notify_games)}`
);
}
await context.close();
}
}

async function performLogin() {
// the page gets stuck sometimes and requires a reload
await retryOnError(() => page.goto(URL_CLAIM, { timeout: 20_000, 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 (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 (page.url().indexOf(loginPageBaseUrl) === 0) {
console.error("Not signed in anymore.");
await signInToPSN();
} else {
console.error("lost! where am i?");
}
}
}

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.psp_email && cfg.psp_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.psp_email || (await prompt({ message: "Enter email" }));
const password =
email &&
(cfg.psp_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-plus: got captcha during login. Please check."
);
})
.catch((_) => { });

// handle MFA, but don't await it
page.locator('input[title="Enter Code"]')
.waitFor()
.then(async () => {
console.log("Two-Step Verification - Enter security code");
console.log(
await page.locator(".description-regular").innerText()
);
const otp =
(cfg.psp_otpkey &&
authenticator.generate(cfg.psp_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");
})
.catch((_) => { });
} else {
console.log("Waiting for you to login in the browser.");
await notify(
"playstation-plus: no longer signed in and not enough options set for automatic login."
);
if (cfg.headless) {
console.log(
"Run `SHOW=1 node playstation-plus` 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(".psw-c-secondary").innerText();
console.log(`Signed in as '${user}'`);
db.data[user] ||= {};
}

async function redeemFreeGames() {
// ### GET LIST OF FREE 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("Free games:", monthlyGamesPageLinks);

for (const url of monthlyGamesPageLinks) {
await page.goto(url);

const gameCard = page.locator(".content-grid").first();
await gameCard.waitFor();
const title = await gameCard.locator("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 free game:", title);
const notify_game = { title, url, status: "failed" };
notify_games.push(notify_game); // status is updated below

// SELECTORS
const inLibrary = page
.locator('span:has-text("In library")')
.first();
const purchased = page
.locator('span:has-text("Purchased")')
.first();
const addToLibrary = page // the base game may not be the free one, look for any edition
.locator('button[data-track-click="ctaWithPrice:addToLibrary"]')
.nth(1);

await Promise.any([addToLibrary.waitFor(), purchased.waitFor(), inLibrary.waitFor()]);

if (await inLibrary.isVisible() || await purchased.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 addToLibrary.isVisible()) {
console.log(" Not in library yet! Click ADD TO LIBRARY.");
await addToLibrary.click();

await inLibrary.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...
}
}
37 changes: 32 additions & 5 deletions util.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,33 @@ export const jsonDb = async file => {


export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

/**
* Retries a Promise a specified number of times on error.
*
* @param {function(): Promise<any>} promiseFn - A function that returns a Promise.
* @param {number} maxRetries - The maximum number of retries to attempt on error.
* @returns {Promise<any>} - A Promise that resolves with the result of the successful attempt or rejects with the last error if all retries fail.
*/
export const retryOnError = (promiseFn, maxRetries = 1) => {
return new Promise((resolve, reject) => {
const executePromise = async (remainingRetries) => {
try {
const result = await promiseFn();
resolve(result);
} catch (error) {
if (remainingRetries > 0) {
console.log(`Retrying... ${remainingRetries - 1} retries left.`);
executePromise(remainingRetries - 1); // Retry the Promise with one less retry
} else {
reject(error); // No more retries left, reject with the last error
}
}
};

executePromise(maxRetries);
});
};
// date and time as UTC (no timezone offset) in nicely readable and sortable format, e.g., 2022-10-06 12:05:27.313
export const datetimeUTC = (d = new Date()) => d.toISOString().replace('T', ' ').replace('Z', '');
// same as datetimeUTC() but for local timezone, e.g., UTC + 2h for the above in DE
Expand Down Expand Up @@ -105,11 +132,11 @@ export const notify = (html) => new Promise((resolve, reject) => {
const title = cfg.notify_title ? `-t ${cfg.notify_title}` : '';
exec(`apprise ${cfg.notify} -i html '${title}' -b '${html}'`, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
if (error.message.includes('command not found')) {
console.info('Run `pip install apprise`. See https://github.com/vogler/free-games-claimer#notifications');
}
return resolve();
console.log(`error: ${error.message}`);
if (error.message.includes('command not found')) {
console.info('Run `pip install apprise`. See https://github.com/vogler/free-games-claimer#notifications');
}
return resolve();
}
if (stderr) console.error(`stderr: ${stderr}`);
if (stdout) console.log(`stdout: ${stdout}`);
Expand Down