-
-
Notifications
You must be signed in to change notification settings - Fork 154
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
Closed
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e3d10ab
playstation: add config values
OJ7 af3bb52
playstation: add implementation for playstation plus monthly games
OJ7 e2a59dd
playstation: update readme and dockerfile with PS info/scripts
OJ7 f0cf343
playstation: handle purchased titles, add better waiting for login st…
OJ7 6a5cbdb
playstation: remove anchor link from game url
OJ7 f08f334
playstation: better handling for initial page load
OJ7 5ea6487
playstation: handle non-base free games
OJ7 0b73780
playstation: use better locator for sign in button
OJ7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
|
||
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... | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
domcontentloaded
does not always trigger)There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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...
There was a problem hiding this comment.
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 :)