run eslint --fix .

This commit is contained in:
Ralf Vogler 2023-11-08 01:16:49 +01:00
parent 011eddf97a
commit 0832ae57f5
10 changed files with 259 additions and 263 deletions

View file

@ -11,7 +11,9 @@ export const cfg = {
dryrun: process.env.DRYRUN == '1', // don't claim anything dryrun: process.env.DRYRUN == '1', // don't claim anything
interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip
show: process.env.SHOW == '1', // run non-headless show: process.env.SHOW == '1', // run non-headless
get headless() { return !this.debug && !this.show }, get headless() {
return !this.debug && !this.show;
},
width: Number(process.env.WIDTH) || 1920, // width of the opened browser width: Number(process.env.WIDTH) || 1920, // width of the opened browser
height: Number(process.env.HEIGHT) || 1080, // height of the opened browser height: Number(process.env.HEIGHT) || 1080, // height of the opened browser
timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s
@ -23,7 +25,7 @@ export const cfg = {
return { return {
browser: process.env.BROWSER_DIR || dataDir('browser'), // for multiple accounts or testing browser: process.env.BROWSER_DIR || dataDir('browser'), // for multiple accounts or testing
screenshots: process.env.SCREENSHOTS_DIR || dataDir('screenshots'), // set to 0 to disable screenshots screenshots: process.env.SCREENSHOTS_DIR || dataDir('screenshots'), // set to 0 to disable screenshots
} };
}, },
// auth epic-games // auth epic-games
eg_email: process.env.EG_EMAIL || process.env.EMAIL, eg_email: process.env.EG_EMAIL || process.env.EMAIL,

View file

@ -28,7 +28,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
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: '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 firefox (macOS): Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0 // userAgent firefox (macOS): Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
// userAgent firefox (docker): Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0 // userAgent firefox (docker): Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0
locale: "en-US", // ignore OS locale to be sure to have english text for locators 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 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 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
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -64,7 +64,7 @@ const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5*24*60*60*1000).toISOString(), domain: '.epicgames.com', path: '/'}]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago. await context.addCookies([{ name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago.
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
@ -77,12 +77,12 @@ try {
console.error('Not signed in anymore. Please login in the browser or here in the terminal.'); console.error('Not signed in anymore. Please login in the browser or here in the terminal.');
if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`); if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`);
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in 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!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' });
if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); if (cfg.eg_email && cfg.eg_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).'); 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.eg_email || await prompt({message: 'Enter email'}); const email = cfg.eg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
// await page.click('text=Sign in with Epic Games'); // await page.click('text=Sign in with Epic Games');
await page.fill('#email', email); await page.fill('#email', email);
@ -100,7 +100,7 @@ try {
page.waitForURL('**/id/login/mfa**').then(async () => { page.waitForURL('**/id/login/mfa**').then(async () => {
console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...'); console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...');
// TODO locator for text (email or app?) // TODO locator for text (email or app?)
const otp = cfg.eg_otpkey && authenticator.generate(cfg.eg_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 const otp = cfg.eg_otpkey && authenticator.generate(cfg.eg_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.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
}).catch(_ => { }); }).catch(_ => { });
@ -134,7 +134,7 @@ try {
// clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25 // clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25
// debug showed that in those cases the href was still correct, so we `goto` the urls instead of clicking. // debug showed that in those cases the href was still correct, so we `goto` the urls instead of clicking.
// Alternative: parse the json loaded to build the page https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions // Alternative: parse the json loaded to build the page https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions
// filter data.Catalog.searchStore.elements for .promotions.promotionalOffers being set and build URL with .catalogNs.mappings[0].pageSlug or .urlSlug if not set to some wrong id like it was the case for spirit-of-the-north-f58a66 - this is also what's done here: https://github.com/claabs/epicgames-freegames-node/blob/938a9653ffd08b8284ea32cf01ac8727d25c5d4c/src/puppet/free-games.ts#L138-L213 // i.e. filter data.Catalog.searchStore.elements for .promotions.promotionalOffers being set and build URL with .catalogNs.mappings[0].pageSlug or .urlSlug if not set to some wrong id like it was the case for spirit-of-the-north-f58a66 - this is also what's done here: https://github.com/claabs/epicgames-freegames-node/blob/938a9653ffd08b8284ea32cf01ac8727d25c5d4c/src/puppet/free-games.ts#L138-L213
const urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href'))); const urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href')));
const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s); const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s);
console.log('Free games:', urls); console.log('Free games:', urls);
@ -235,7 +235,7 @@ try {
// console.info(' Saved a screenshot of hcaptcha challenge to', p); // console.info(' Saved a screenshot of hcaptcha challenge to', p);
// console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge? // console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge?
}).catch(_ => { }); // may time out if not shown }).catch(_ => { }); // may time out if not shown
await page.locator('text=Thanks for your order!').waitFor({state: 'attached'}); await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' });
db.data[user][game_id].status = 'claimed'; db.data[user][game_id].status = 'claimed';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(' Claimed successfully!'); console.log(' Claimed successfully!');
@ -260,8 +260,7 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`);
notify(`epic-games failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { // don't notify if all have status 'existed', 'manual', 'requires base game', 'unavailable-in-region', 'skipped' if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { // don't notify if all have status 'existed', 'manual', 'requires base game', 'unavailable-in-region', 'skipped'

25
gog.js
View file

@ -14,7 +14,7 @@ const db = await jsonDb('gog.json', {});
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, 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 locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL
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 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/gog-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools recordHar: cfg.record ? { path: `data/record/gog-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -31,7 +31,7 @@ const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{name: 'CookieConsent', value: '{stamp:%274oR8MJL+bxVlG6g+kl2we5+suMJ+Tv7I4C5d4k+YY4vrnhCD+P23RQ==%27%2Cnecessary:true%2Cpreferences:true%2Cstatistics:true%2Cmarketing:true%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1672331618201%2Cregion:%27de%27}', domain: 'www.gog.com', path: '/'}]); // to not waste screen space when non-headless await context.addCookies([{ name: 'CookieConsent', value: '{stamp:%274oR8MJL+bxVlG6g+kl2we5+suMJ+Tv7I4C5d4k+YY4vrnhCD+P23RQ==%27%2Cnecessary:true%2Cpreferences:true%2Cstatistics:true%2Cmarketing:true%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1672331618201%2Cregion:%27de%27}', domain: 'www.gog.com', path: '/' }]); // to not waste screen space when non-headless
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
@ -45,11 +45,11 @@ try {
await page.waitForSelector('#GalaxyAccountsFrameContainer iframe'); // TODO needed? await page.waitForSelector('#GalaxyAccountsFrameContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#GalaxyAccountsFrameContainer iframe'); const iframe = page.frameLocator('#GalaxyAccountsFrameContainer iframe');
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in 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!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
if (cfg.gog_email && cfg.gog_password) console.info('Using email and password from environment.'); if (cfg.gog_email && cfg.gog_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).'); 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.gog_email || await prompt({message: 'Enter email'}); const email = cfg.gog_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.gog_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.gog_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
iframe.locator('a[href="/logout"]').click().catch(_ => { }); // Click 'Change account' (email from previous login is set in some cookie) iframe.locator('a[href="/logout"]').click().catch(_ => { }); // Click 'Change account' (email from previous login is set in some cookie)
await iframe.locator('#login_username').fill(email); await iframe.locator('#login_username').fill(email);
@ -58,9 +58,9 @@ try {
// handle MFA, but don't await it // handle MFA, but don't await it
iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => { iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => {
console.log('Two-Step Verification - Enter security code'); console.log('Two-Step Verification - Enter security code');
console.log(await iframe.locator('.form__description').innerText()) console.log(await iframe.locator('.form__description').innerText());
const otp = await prompt({type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 4 || 'The code must be 4 digits!'}); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them const otp = await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 4 || 'The code must be 4 digits!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), {delay: 10}); await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), { delay: 10 });
await iframe.locator('#second_step_authentication_send').click(); await iframe.locator('#second_step_authentication_send').click();
await page.waitForTimeout(1000); // TODO still needed with wait for username below? await page.waitForTimeout(1000); // TODO still needed with wait for username below?
}).catch(_ => { }); }).catch(_ => { });
@ -71,7 +71,7 @@ try {
notify('gog: got captcha during login. Please check.'); notify('gog: got captcha during login. Please check.');
// TODO solve reCAPTCHA? // TODO solve reCAPTCHA?
}).catch(_ => { }); }).catch(_ => { });
await page.waitForSelector('#menuUsername') await page.waitForSelector('#menuUsername');
} else { } else {
console.log('Waiting for you to login in the browser.'); console.log('Waiting for you to login in the browser.');
await notify('gog: no longer signed in and not enough options set for automatic login.'); await notify('gog: no longer signed in and not enough options set for automatic login.');
@ -129,7 +129,7 @@ try {
notify_games.push({ title, url, status }); notify_games.push({ title, url, status });
if (status == 'claimed' && !cfg.gog_newsletter) { if (status == 'claimed' && !cfg.gog_newsletter) {
console.log("Unsubscribe from 'Promotions and hot deals' newsletter"); console.log('Unsubscribe from \'Promotions and hot deals\' newsletter');
await page.goto('https://www.gog.com/en/account/settings/subscriptions'); await page.goto('https://www.gog.com/en/account/settings/subscriptions');
await page.locator('li:has-text("Marketing communications through Trusted Partners") label').uncheck(); await page.locator('li:has-text("Marketing communications through Trusted Partners") label').uncheck();
await page.locator('li:has-text("Promotions and hot deals") label').uncheck(); await page.locator('li:has-text("Promotions and hot deals") label').uncheck();
@ -139,13 +139,12 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`gog failed: ${error.message.split('\n')[0]}`);
notify(`gog failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed
notify(`gog (${user}):<br>${html_game_list(notify_games)}`); notify(`gog (${user}):<br>${html_game_list(notify_games)}`);
} }
} }
if (page.video()) console.log('Recorded video:', await page.video().path()) if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close(); await context.close();

View file

@ -4,8 +4,7 @@ import { JSONFile } from 'lowdb/node';
import { datetime } from './util.js'; import { datetime } from './util.js';
const datetime_UTCtoLocalTimezone = async file => { const datetime_UTCtoLocalTimezone = async file => {
if (!existsSync(file)) if (!existsSync(file)) return console.error('File does not exist:', file);
return console.error('File does not exist:', file);
const db = new Low(new JSONFile(file)); const db = new Low(new JSONFile(file));
await db.read(); await db.read();
db.data ||= {}; db.data ||= {};

View file

@ -1,5 +1,5 @@
/* eslint-disable no-constant-condition */ /* eslint-disable no-constant-condition */
import { delay, html_game_list, notify } from "./util.js"; import { delay, html_game_list, notify } from './util.js';
const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL

View file

@ -16,7 +16,7 @@ const db = await jsonDb('prime-gaming.json', {});
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
locale: "en-US", // ignore OS locale to be sure to have english text for locators 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 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/pg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools recordHar: cfg.record ? { path: `data/record/pg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -44,11 +44,11 @@ try {
console.error('Not signed in anymore.'); console.error('Not signed in anymore.');
await page.click('button:has-text("Sign in")'); await page.click('button:has-text("Sign in")');
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in 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!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
if (cfg.pg_email && cfg.pg_password) console.info('Using email and password from environment.'); if (cfg.pg_email && cfg.pg_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).'); 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.pg_email || await prompt({message: 'Enter email'}); const email = cfg.pg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.pg_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
await page.fill('[name=email]', email); await page.fill('[name=email]', email);
await page.fill('[name=password]', password); await page.fill('[name=password]', password);
@ -66,7 +66,7 @@ try {
page.waitForURL('**/ap/mfa**').then(async () => { page.waitForURL('**/ap/mfa**').then(async () => {
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App'); console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App');
await page.check('[name=rememberDevice]'); await page.check('[name=rememberDevice]');
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_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 const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_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.locator('input[name=otpCode]').pressSequentially(otp.toString()); await page.locator('input[name=otpCode]').pressSequentially(otp.toString());
await page.click('input[type="submit"]'); await page.click('input[type="submit"]');
}).catch(_ => { }); }).catch(_ => { });
@ -128,10 +128,10 @@ try {
const slug = await card.locator('a:has-text("Claim")').first().getAttribute('href'); const slug = await card.locator('a:has-text("Claim")').first().getAttribute('href');
const url = 'https://gaming.amazon.com' + slug.split('?')[0]; const url = 'https://gaming.amazon.com' + slug.split('?')[0];
// await (await card.$('text=Claim')).click(); // goes to URL of game, no need to wait // await (await card.$('text=Claim')).click(); // goes to URL of game, no need to wait
external_info.push({title, url}); external_info.push({ title, url });
} }
for (const {title, url} of external_info) { for (const { title, url } of external_info) {
console.log('Current free game:', title); //, url); console.log('Current free game:', title); // , url);
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
if (cfg.debug) await page.pause(); if (cfg.debug) await page.pause();
if (cfg.dryrun) continue; if (cfg.dryrun) continue;
@ -224,9 +224,9 @@ try {
await page2.click('[type="submit"]'); // click Redeem await page2.click('[type="submit"]'); // click Redeem
const r2t = await (await r2).text(); const r2t = await (await r2).text();
if (r2t == '{}') { if (r2t == '{}') {
redeem_action = 'redeemed'; redeem_action = 'redeemed';
console.log(' Redeemed successfully.'); console.log(' Redeemed successfully.');
db.data[user][title].status = 'claimed and redeemed'; db.data[user][title].status = 'claimed and redeemed';
} else { } else {
console.debug(` Response 2: ${r2t}`); console.debug(` Response 2: ${r2t}`);
console.log(' Unknown Response 2 - please report in https://github.com/vogler/free-games-claimer/issues/5'); console.log(' Unknown Response 2 - please report in https://github.com/vogler/free-games-claimer/issues/5');
@ -298,7 +298,7 @@ try {
await page.keyboard.press('End'); // scroll to bottom to show all games await page.keyboard.press('End'); // scroll to bottom to show all games
await page.waitForTimeout(1000); // wait for fade in animation await page.waitForTimeout(1000); // wait for fade in animation
const viewportSize = page.viewportSize(); // current viewport size const viewportSize = page.viewportSize(); // current viewport size
await page.setViewportSize({...viewportSize, height: 3000}); // increase height, otherwise element screenshot is cut off at the top and bottom await page.setViewportSize({ ...viewportSize, height: 3000 }); // increase height, otherwise element screenshot is cut off at the top and bottom
await games.screenshot({ path: p }); // screenshot of all claimed games await games.screenshot({ path: p }); // screenshot of all claimed games
} }
@ -357,7 +357,7 @@ try {
console.debug(' LinkAccountButton label:', unlinked_store); console.debug(' LinkAccountButton label:', unlinked_store);
const match = unlinked_store.match(/Link (.*) account/); const match = unlinked_store.match(/Link (.*) account/);
if (match && match.length == 2) unlinked_store = match[1]; if (match && match.length == 2) unlinked_store = match[1];
} else if(await page.locator('text=Link game account').count()) { // epic-games only? } else if (await page.locator('text=Link game account').count()) { // epic-games only?
console.error(' Missing account linking (epic-games specific button?):', await page.locator('button[data-a-target="gms-cta"]').innerText()); // TODO needed? console.error(' Missing account linking (epic-games specific button?):', await page.locator('button[data-a-target="gms-cta"]').innerText()); // TODO needed?
unlinked_store = 'epic-games'; unlinked_store = 'epic-games';
} }
@ -386,8 +386,7 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.length) { // list should only include claimed games if (notify_games.length) { // list should only include claimed games

View file

@ -23,7 +23,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
viewport: { width: cfg.width, height: cfg.height }, 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: '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 // 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 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 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/ue-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools recordHar: cfg.record ? { path: `data/record/ue-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -42,7 +42,7 @@ const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5*24*60*60*1000).toISOString(), domain: '.epicgames.com', path: '/'}]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago. await context.addCookies([{ name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago.
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
@ -52,12 +52,12 @@ try {
console.error('Not signed in anymore. Please login in the browser or here in the terminal.'); console.error('Not signed in anymore. Please login in the browser or here in the terminal.');
if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`); if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`);
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in 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!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' });
if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); if (cfg.eg_email && cfg.eg_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).'); 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.eg_email || await prompt({message: 'Enter email'}); const email = cfg.eg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
await page.click('text=Sign in with Epic Games'); await page.click('text=Sign in with Epic Games');
await page.fill('#email', email); await page.fill('#email', email);
@ -71,7 +71,7 @@ try {
page.waitForURL('**/id/login/mfa**').then(async () => { page.waitForURL('**/id/login/mfa**').then(async () => {
console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...'); console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...');
// TODO locator for text (email or app?) // TODO locator for text (email or app?)
const otp = cfg.eg_otpkey && authenticator.generate(cfg.eg_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 const otp = cfg.eg_otpkey && authenticator.generate(cfg.eg_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.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
}).catch(_ => { }); }).catch(_ => { });
@ -105,7 +105,7 @@ try {
const notify_game = { title, url, status: 'failed' }; const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
// if (await p.locator('.btn .add-review-btn').count()) { // did not work // if (await p.locator('.btn .add-review-btn').count()) { // did not work
if((await p.getAttribute('class')).includes('asset--owned')) { if ((await p.getAttribute('class')).includes('asset--owned')) {
console.log(' ↳ Already claimed'); console.log(' ↳ Already claimed');
if (db.data[user][id].status != 'claimed') { if (db.data[user][id].status != 'claimed') {
db.data[user][id].status = 'existed'; db.data[user][id].status = 'existed';
@ -128,7 +128,7 @@ try {
const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' '); const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' ');
console.log('Price: ', price[1], 'instead of', price[0]); console.log('Price: ', price[1], 'instead of', price[0]);
if (price[1] != '0') { if (price[1] != '0') {
const err = 'Price is not 0! Exit! Please <a href="https://github.com/vogler/free-games-claimer/issues/44">report</a>.' const err = 'Price is not 0! Exit! Please <a href="https://github.com/vogler/free-games-claimer/issues/44">report</a>.';
console.error(err); console.error(err);
notify('unrealengine: ' + err); notify('unrealengine: ' + err);
process.exit(1); process.exit(1);
@ -142,7 +142,7 @@ try {
// maybe: Accept End User License Agreement // maybe: Accept End User License Agreement
page.locator('[name=accept-label]').check().then(() => { page.locator('[name=accept-label]').check().then(() => {
console.log('Accept End User License Agreement'); console.log('Accept End User License Agreement');
page.locator('span:text-is("Accept")').click() // otherwise matches 'Accept All Cookies' page.locator('span:text-is("Accept")').click(); // otherwise matches 'Accept All Cookies'
}).catch(_ => { }); }).catch(_ => { });
await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed? await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#webPurchaseContainer iframe'); const iframe = page.frameLocator('#webPurchaseContainer iframe');
@ -165,7 +165,7 @@ try {
const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe'); const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
captcha.waitFor().then(async () => { // don't await, since element may not be shown captcha.waitFor().then(async () => { // don't await, since element may not be shown
// console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.') // console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.')
console.error(' Got hcaptcha challenge! Lost trust due to too many login attempts? You can solve the captcha in the browser or get a new IP address.') console.error(' Got hcaptcha challenge! Lost trust due to too many login attempts? You can solve the captcha in the browser or get a new IP address.');
}).catch(_ => { }); // may time out if not shown }).catch(_ => { }); // may time out if not shown
await page.waitForSelector('text=Thank you'); await page.waitForSelector('text=Thank you');
for (const id of ids) { for (const id of ids) {
@ -192,8 +192,7 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed
@ -201,5 +200,5 @@ try {
} }
} }
if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies())); if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies()));
if (page.video()) console.log('Recorded video:', await page.video().path()) if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close(); await context.close();

19
util.js
View file

@ -27,7 +27,7 @@ export const handleSIGINT = (context = null) => process.on('SIGINT', async () =>
if (context) await context.close(); // in order to save recordings also on SIGINT, we need to disable Playwright's handleSIGINT and close the context ourselves if (context) await context.close(); // in order to save recordings also on SIGINT, we need to disable Playwright's handleSIGINT and close the context ourselves
}); });
export const stealth = async (context) => { export const stealth = async context => {
// stealth with playwright: https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212 // stealth with playwright: https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212
// https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions // https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
const enabledEvasions = [ const enabledEvasions = [
@ -47,13 +47,13 @@ export const stealth = async (context) => {
'sourceurl', 'sourceurl',
// 'user-agent-override', // doesn't work since playwright has no page.browser() // 'user-agent-override', // doesn't work since playwright has no page.browser()
'webgl.vendor', 'webgl.vendor',
'window.outerdimensions' 'window.outerdimensions',
]; ];
const stealth = { const stealth = {
callbacks: [], callbacks: [],
async evaluateOnNewDocument(...args) { async evaluateOnNewDocument(...args) {
this.callbacks.push({ cb: args[0], a: args[1] }); this.callbacks.push({ cb: args[0], a: args[1] });
} },
}; };
for (const e of enabledEvasions) { for (const e of enabledEvasions) {
const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`); const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`);
@ -70,7 +70,10 @@ export const stealth = async (context) => {
import Enquirer from 'enquirer'; const enquirer = new Enquirer(); import Enquirer from 'enquirer'; const enquirer = new Enquirer();
const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
enquirer.on('prompt', prompt => { enquirer.on('prompt', prompt => {
const t = setTimeout(() => { prompt.hint = () => 'timeout'; prompt.cancel(); }, timeout); const t = setTimeout(() => {
prompt.hint = () => 'timeout';
prompt.cancel();
}, timeout);
prompt.on('submit', _ => clearTimeout(t)); prompt.on('submit', _ => clearTimeout(t));
prompt.on('cancel', _ => clearTimeout(t)); prompt.on('cancel', _ => clearTimeout(t));
}); });
@ -78,14 +81,14 @@ const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
enquirer.use(timeoutPlugin(cfg.login_timeout)); // TODO may not want to have this timeout for all prompts; better extend Prompt and add a timeout prompt option enquirer.use(timeoutPlugin(cfg.login_timeout)); // TODO may not want to have this timeout for all prompts; better extend Prompt and add a timeout prompt option
// single prompt that just returns the non-empty value instead of an object // single prompt that just returns the non-empty value instead of an object
// @ts-ignore // @ts-ignore
export const prompt = o => enquirer.prompt({name: 'name', type: 'input', message: 'Enter value', ...o}).then(r => r.name).catch(_ => {}); export const prompt = o => enquirer.prompt({ name: 'name', type: 'input', message: 'Enter value', ...o }).then(r => r.name).catch(_ => {});
export const confirm = o => prompt({type: 'confirm', message: 'Continue?', ...o}); export const confirm = o => prompt({ type: 'confirm', message: 'Continue?', ...o });
// notifications via apprise CLI // notifications via apprise CLI
import { exec } from 'child_process'; import { exec } from 'child_process';
import { cfg } from './config.js'; import { cfg } from './config.js';
export const notify = (html) => new Promise((resolve, reject) => { export const notify = html => new Promise((resolve, reject) => {
if (!cfg.notify) return resolve(); if (!cfg.notify) return resolve();
const title = cfg.notify_title ? `-t ${cfg.notify_title}` : ''; const title = cfg.notify_title ? `-t ${cfg.notify_title}` : '';
exec(`apprise ${cfg.notify} -i html '${title}' -b '${html}'`, (error, stdout, stderr) => { exec(`apprise ${cfg.notify} -i html '${title}' -b '${html}'`, (error, stdout, stderr) => {
@ -102,6 +105,6 @@ export const notify = (html) => new Promise((resolve, reject) => {
}); });
}); });
export const escapeHtml = (unsafe) => unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;'); export const escapeHtml = unsafe => unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('\'', '&#039;');
export const html_game_list = games => games.map(g => `- <a href="${g.url}">${escapeHtml(g.title)}</a> (${g.status})`).join('<br>'); export const html_game_list = games => games.map(g => `- <a href="${g.url}">${escapeHtml(g.title)}</a> (${g.status})`).join('<br>');

View file

@ -1,9 +1,9 @@
// check if running the latest version // check if running the latest version
import {log} from 'console'; import { log } from 'console';
import { exec } from 'child_process'; import { exec } from 'child_process';
const execp = (cmd) => new Promise((resolve, reject) => { const execp = cmd => new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout, stderr) => {
if (stderr) console.error(`stderr: ${stderr}`); if (stderr) console.error(`stderr: ${stderr}`);
// if (stdout) console.log(`stdout: ${stdout}`); // if (stdout) console.log(`stdout: ${stdout}`);
@ -35,8 +35,8 @@ if (process.env.NOVNC_PORT) {
} }
const gh = await (await fetch('https://api.github.com/repos/vogler/free-games-claimer/commits/main', { const gh = await (await fetch('https://api.github.com/repos/vogler/free-games-claimer/commits/main', {
// headers: { accept: 'application/vnd.github.VERSION.sha' } // headers: { accept: 'application/vnd.github.VERSION.sha' }
})).json(); })).json();
// log(gh); // log(gh);
log('Local commit:', sha, new Date(date)); log('Local commit:', sha, new Date(date));

386
xbox.js
View file

@ -1,37 +1,37 @@
import { firefox } from "playwright-firefox"; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from "otplib"; import { authenticator } from 'otplib';
import { import {
datetime, datetime,
handleSIGINT, handleSIGINT,
html_game_list, html_game_list,
jsonDb, jsonDb,
notify, notify,
prompt, prompt,
} from "./util.js"; } from './util.js';
import { cfg } from "./config.js"; import { cfg } from './config.js';
// ### SETUP // ### SETUP
const URL_CLAIM = "https://www.xbox.com/en-US/live/gold"; // #gameswithgold"; const URL_CLAIM = 'https://www.xbox.com/en-US/live/gold'; // #gameswithgold";
console.log(datetime(), "started checking xbox"); console.log(datetime(), 'started checking xbox');
const db = await jsonDb("xbox.json"); const db = await jsonDb('xbox.json');
db.data ||= {}; db.data ||= {};
handleSIGINT(); handleSIGINT();
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, 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 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); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length const page = context.pages().length
? context.pages()[0] ? context.pages()[0]
: await context.newPage(); // should always exist : await context.newPage(); // should always exist
const notify_games = []; const notify_games = [];
let user; let user;
@ -39,216 +39,212 @@ let user;
main(); main();
async function main() { async function main() {
try { try {
await performLogin(); await performLogin();
await getAndSaveUser(); await getAndSaveUser();
await redeemFreeGames(); await redeemFreeGames();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
process.exitCode ||= 1; process.exitCode ||= 1;
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`xbox failed: ${error.message.split('\n')[0]}`);
notify(`xbox failed: ${error.message.split("\n")[0]}`); } finally {
} finally { await db.write(); // write out json db
await db.write(); // write out json db if (notify_games.filter(g => g.status != 'existed').length) {
if (notify_games.filter((g) => g.status != "existed").length) { // don't notify if all were already claimed
// don't notify if all were already claimed notify(`xbox (${user}):<br>${html_game_list(notify_games)}`);
notify(`xbox (${user}):<br>${html_game_list(notify_games)}`);
}
await context.close();
} }
await context.close();
}
} }
async function performLogin() { async function performLogin() {
await page.goto(URL_CLAIM, { waitUntil: "domcontentloaded" }); // default 'load' takes forever await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
const signInLocator = page const signInLocator = page
.getByRole("link", { .getByRole('link', {
name: "Sign in to your account", name: 'Sign in to your account',
}) })
.first(); .first();
const usernameLocator = page const usernameLocator = page
.getByRole("button", { .getByRole('button', {
name: "Account manager for", name: 'Account manager for',
}) })
.first(); .first();
await Promise.any([signInLocator.waitFor(), usernameLocator.waitFor()]); await Promise.any([signInLocator.waitFor(), usernameLocator.waitFor()]);
if (await usernameLocator.isVisible()) { if (await usernameLocator.isVisible()) {
return; // logged in using saved cookie return; // logged in using saved cookie
} else if (await signInLocator.isVisible()) { } else if (await signInLocator.isVisible()) {
console.error("Not signed in anymore."); console.error('Not signed in anymore.');
await signInLocator.click(); await signInLocator.click();
await signInToXbox(); await signInToXbox();
} else { } else {
console.error("lost! where am i?"); console.error('lost! where am i?');
} }
} }
async function signInToXbox() { async function signInToXbox() {
page.waitForLoadState("domcontentloaded"); page.waitForLoadState('domcontentloaded');
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in 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!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
// ### FETCH EMAIL/PASS // ### FETCH EMAIL/PASS
if (cfg.xbox_email && cfg.xbox_password) if (cfg.xbox_email && cfg.xbox_password) console.info('Using email and password from environment.');
console.info("Using email and password from environment."); else console.info(
else 'Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).',
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 =
const email = cfg.xbox_email || (await prompt({ message: "Enter email" }));
const password =
email && email &&
(cfg.xbox_password || (cfg.xbox_password ||
(await prompt({ await prompt({
type: "password", type: 'password',
message: "Enter password", message: 'Enter password',
}))); }));
// ### FILL IN EMAIL/PASS // ### FILL IN EMAIL/PASS
if (email && password) { if (email && password) {
const usernameLocator = page const usernameLocator = page
.getByPlaceholder("Email, phone, or Skype") .getByPlaceholder('Email, phone, or Skype')
.first(); .first();
const passwordLocator = page.getByPlaceholder("Password").first(); const passwordLocator = page.getByPlaceholder('Password').first();
await Promise.any([ await Promise.any([
usernameLocator.waitFor(), usernameLocator.waitFor(),
passwordLocator.waitFor(), passwordLocator.waitFor(),
]); ]);
// username may already be saved from before, if so, skip to filling in password // username may already be saved from before, if so, skip to filling in password
if (await page.getByPlaceholder("Email, phone, or Skype").isVisible()) { if (await page.getByPlaceholder('Email, phone, or Skype').isVisible()) {
await usernameLocator.fill(email); await usernameLocator.fill(email);
await page.getByRole("button", { name: "Next" }).click(); 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 passwordLocator.fill(password);
await page.waitForURL(`${URL_CLAIM}**`); await page.getByRole('button', { name: 'Sign in' }).click();
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); // 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() { async function getAndSaveUser() {
user = await page.locator("#mectrl_currentAccount_primary").innerHTML(); user = await page.locator('#mectrl_currentAccount_primary').innerHTML();
console.log(`Signed in as '${user}'`); console.log(`Signed in as '${user}'`);
db.data[user] ||= {}; db.data[user] ||= {};
} }
async function redeemFreeGames() { async function redeemFreeGames() {
const monthlyGamesLocator = await page.locator(".f-size-large").all(); const monthlyGamesLocator = await page.locator('.f-size-large').all();
const monthlyGamesPageLinks = await Promise.all( const monthlyGamesPageLinks = await Promise.all(
monthlyGamesLocator.map( monthlyGamesLocator.map(
async (el) => await el.locator("a").getAttribute("href") async el => await el.locator('a').getAttribute('href'),
) ),
); );
console.log("Free games:", monthlyGamesPageLinks); console.log('Free games:', monthlyGamesPageLinks);
for (const url of monthlyGamesPageLinks) { for (const url of monthlyGamesPageLinks) {
await page.goto(url); await page.goto(url);
const title = await page.locator("h1").first().innerText(); const title = await page.locator('h1').first().innerText();
const game_id = page.url().split("/").pop(); 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! 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); console.log('Current free game:', title);
const notify_game = { title, url, status: "failed" }; const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
// SELECTORS // SELECTORS
const getBtnLocator = page.getByText("GET", { exact: true }).first(); const getBtnLocator = page.getByText('GET', { exact: true }).first();
const installToLocator = page const installToLocator = page
.getByText("INSTALL TO", { exact: true }) .getByText('INSTALL TO', { exact: true })
.first(); .first();
await Promise.any([ await Promise.any([
getBtnLocator.waitFor(), getBtnLocator.waitFor(),
installToLocator.waitFor(), installToLocator.waitFor(),
]); ]);
if (await installToLocator.isVisible()) { if (await installToLocator.isVisible()) {
console.log(" Already in library! Nothing to claim."); console.log(' Already in library! Nothing to claim.');
notify_game.status = "existed"; notify_game.status = 'existed';
db.data[user][game_id].status ||= "existed"; // does not overwrite claimed or failed db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed
} else if (await getBtnLocator.isVisible()) { } else if (await getBtnLocator.isVisible()) {
console.log(" Not in library yet! Click GET."); console.log(' Not in library yet! Click GET.');
await getBtnLocator.click(); await getBtnLocator.click();
// wait for popup // wait for popup
await page await page
.locator('iframe[name="purchase-sdk-hosted-iframe"]') .locator('iframe[name="purchase-sdk-hosted-iframe"]')
.waitFor(); .waitFor();
const popupLocator = page.frameLocator( const popupLocator = page.frameLocator(
"[name=purchase-sdk-hosted-iframe]" '[name=purchase-sdk-hosted-iframe]',
); );
const finalGetBtnLocator = popupLocator.getByText("GET"); const finalGetBtnLocator = popupLocator.getByText('GET');
await finalGetBtnLocator.waitFor(); await finalGetBtnLocator.waitFor();
await finalGetBtnLocator.click(); await finalGetBtnLocator.click();
await page.getByText("Thank you for your purchase.").waitFor(); await page.getByText('Thank you for your purchase.').waitFor();
notify_game.status = "claimed"; notify_game.status = 'claimed';
db.data[user][game_id].status = "claimed"; db.data[user][game_id].status = 'claimed';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(" Claimed successfully!"); 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...
} }
// 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...
}
} }