diff --git a/config.js b/config.js
index f12f99e1..0962159b 100644
--- a/config.js
+++ b/config.js
@@ -11,7 +11,9 @@ export const cfg = {
dryrun: process.env.DRYRUN == '1', // don't claim anything
interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip
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
height: Number(process.env.HEIGHT) || 1080, // height of the opened browser
timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s
@@ -23,7 +25,7 @@ export const cfg = {
return {
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
- }
+ };
},
// auth epic-games
eg_email: process.env.EG_EMAIL || process.env.EMAIL,
diff --git a/epic-games.js b/epic-games.js
index dcfce73e..06d66cce 100644
--- a/epic-games.js
+++ b/epic-games.js
@@ -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 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
- 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
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
@@ -64,7 +64,7 @@ const notify_games = [];
let user;
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
@@ -77,12 +77,12 @@ try {
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.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' });
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).');
- const email = cfg.eg_email || await prompt({message: 'Enter email'});
- const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'}));
+ const email = cfg.eg_email || await prompt({ message: 'Enter email' });
+ const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
// await page.click('text=Sign in with Epic Games');
await page.fill('#email', email);
@@ -100,7 +100,7 @@ try {
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 ...');
// 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.click('button[type="submit"]');
}).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
// 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
- // 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 urls = urlSlugs.map(s => 'https://store.epicgames.com' + s);
console.log('Free games:', urls);
@@ -235,7 +235,7 @@ try {
// 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?
}).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].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(' Claimed successfully!');
@@ -260,8 +260,7 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
- if (error.message && process.exitCode != 130)
- notify(`epic-games failed: ${error.message.split('\n')[0]}`);
+ if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`);
} finally {
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'
diff --git a/gog.js b/gog.js
index aaffd7b6..c6868512 100644
--- a/gog.js
+++ b/gog.js
@@ -14,7 +14,7 @@ const db = await jsonDb('gog.json', {});
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
+ 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
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
@@ -31,7 +31,7 @@ const notify_games = [];
let user;
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
@@ -45,11 +45,11 @@ try {
await page.waitForSelector('#GalaxyAccountsFrameContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#GalaxyAccountsFrameContainer iframe');
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.');
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 password = email && (cfg.gog_password || await prompt({type: 'password', message: 'Enter password'}));
+ const email = cfg.gog_email || await prompt({ message: 'Enter email' });
+ const password = email && (cfg.gog_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
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);
@@ -58,9 +58,9 @@ try {
// handle MFA, but don't await it
iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => {
console.log('Two-Step Verification - Enter security code');
- 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
- await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), {delay: 10});
+ 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
+ await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), { delay: 10 });
await iframe.locator('#second_step_authentication_send').click();
await page.waitForTimeout(1000); // TODO still needed with wait for username below?
}).catch(_ => { });
@@ -71,7 +71,7 @@ try {
notify('gog: got captcha during login. Please check.');
// TODO solve reCAPTCHA?
}).catch(_ => { });
- await page.waitForSelector('#menuUsername')
+ await page.waitForSelector('#menuUsername');
} else {
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.');
@@ -129,7 +129,7 @@ try {
notify_games.push({ title, url, status });
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.locator('li:has-text("Marketing communications through Trusted Partners") label').uncheck();
await page.locator('li:has-text("Promotions and hot deals") label').uncheck();
@@ -139,13 +139,12 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
- if (error.message && process.exitCode != 130)
- notify(`gog failed: ${error.message.split('\n')[0]}`);
+ if (error.message && process.exitCode != 130) notify(`gog 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(`gog (${user}):
${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();
diff --git a/migrate.js b/migrate.js
index 41bbe13c..b2db9453 100644
--- a/migrate.js
+++ b/migrate.js
@@ -4,8 +4,7 @@ import { JSONFile } from 'lowdb/node';
import { datetime } from './util.js';
const datetime_UTCtoLocalTimezone = async file => {
- if (!existsSync(file))
- return console.error('File does not exist:', file);
+ if (!existsSync(file)) return console.error('File does not exist:', file);
const db = new Low(new JSONFile(file));
await db.read();
db.data ||= {};
diff --git a/notify-test.js b/notify-test.js
index d1386570..9d593c09 100644
--- a/notify-test.js
+++ b/notify-test.js
@@ -1,5 +1,5 @@
/* 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
diff --git a/prime-gaming.js b/prime-gaming.js
index ae7879e4..e60caf78 100644
--- a/prime-gaming.js
+++ b/prime-gaming.js
@@ -16,7 +16,7 @@ const db = await jsonDb('prime-gaming.json', {});
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
+ locale: 'en-US', // ignore OS locale to be sure to have english text for locators
recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
recordHar: cfg.record ? { path: `data/record/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
@@ -44,11 +44,11 @@ try {
console.error('Not signed in anymore.');
await page.click('button:has-text("Sign 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.');
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 password = email && (cfg.pg_password || await prompt({type: 'password', message: 'Enter password'}));
+ const email = cfg.pg_email || await prompt({ message: 'Enter email' });
+ const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
await page.fill('[name=email]', email);
await page.fill('[name=password]', password);
@@ -66,7 +66,7 @@ try {
page.waitForURL('**/ap/mfa**').then(async () => {
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App');
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.click('input[type="submit"]');
}).catch(_ => { });
@@ -128,10 +128,10 @@ try {
const slug = await card.locator('a:has-text("Claim")').first().getAttribute('href');
const url = 'https://gaming.amazon.com' + slug.split('?')[0];
// 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) {
- console.log('Current free game:', title); //, url);
+ for (const { title, url } of external_info) {
+ console.log('Current free game:', title); // , url);
await page.goto(url, { waitUntil: 'domcontentloaded' });
if (cfg.debug) await page.pause();
if (cfg.dryrun) continue;
@@ -224,9 +224,9 @@ try {
await page2.click('[type="submit"]'); // click Redeem
const r2t = await (await r2).text();
if (r2t == '{}') {
- redeem_action = 'redeemed';
- console.log(' Redeemed successfully.');
- db.data[user][title].status = 'claimed and redeemed';
+ redeem_action = 'redeemed';
+ console.log(' Redeemed successfully.');
+ db.data[user][title].status = 'claimed and redeemed';
} else {
console.debug(` Response 2: ${r2t}`);
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.waitForTimeout(1000); // wait for fade in animation
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
}
@@ -357,7 +357,7 @@ try {
console.debug(' LinkAccountButton label:', unlinked_store);
const match = unlinked_store.match(/Link (.*) account/);
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?
unlinked_store = 'epic-games';
}
@@ -386,8 +386,7 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
- if (error.message && process.exitCode != 130)
- notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
+ if (error.message && process.exitCode != 130) notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
} finally {
await db.write(); // write out json db
if (notify_games.length) { // list should only include claimed games
diff --git a/unrealengine.js b/unrealengine.js
index 64b9c554..5bbf9884 100644
--- a/unrealengine.js
+++ b/unrealengine.js
@@ -23,7 +23,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
viewport: { width: cfg.width, height: cfg.height },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated?
// userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
- locale: "en-US", // ignore OS locale to be sure to have english text for locators
+ locale: 'en-US', // ignore OS locale to be sure to have english text for locators
recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
recordHar: cfg.record ? { path: `data/record/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
@@ -42,7 +42,7 @@ const notify_games = [];
let user;
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
@@ -52,12 +52,12 @@ try {
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.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' });
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).');
- const email = cfg.eg_email || await prompt({message: 'Enter email'});
- const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'}));
+ const email = cfg.eg_email || await prompt({ message: 'Enter email' });
+ const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
await page.click('text=Sign in with Epic Games');
await page.fill('#email', email);
@@ -71,7 +71,7 @@ try {
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 ...');
// 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.click('button[type="submit"]');
}).catch(_ => { });
@@ -105,7 +105,7 @@ try {
const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below
// 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');
if (db.data[user][id].status != 'claimed') {
db.data[user][id].status = 'existed';
@@ -128,7 +128,7 @@ try {
const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' ');
console.log('Price: ', price[1], 'instead of', price[0]);
if (price[1] != '0') {
- const err = 'Price is not 0! Exit! Please report.'
+ const err = 'Price is not 0! Exit! Please report.';
console.error(err);
notify('unrealengine: ' + err);
process.exit(1);
@@ -142,7 +142,7 @@ try {
// maybe: Accept End User License Agreement
page.locator('[name=accept-label]').check().then(() => {
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(_ => { });
await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#webPurchaseContainer iframe');
@@ -165,7 +165,7 @@ try {
const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
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.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
await page.waitForSelector('text=Thank you');
for (const id of ids) {
@@ -192,8 +192,7 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
- if (error.message && process.exitCode != 130)
- notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
+ if (error.message && process.exitCode != 130) notify(`unrealengine 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
@@ -201,5 +200,5 @@ try {
}
}
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();
diff --git a/util.js b/util.js
index 0ab0912f..16ae8062 100644
--- a/util.js
+++ b/util.js
@@ -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
});
-export const stealth = async (context) => {
+export const stealth = async context => {
// 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
const enabledEvasions = [
@@ -47,13 +47,13 @@ export const stealth = async (context) => {
'sourceurl',
// 'user-agent-override', // doesn't work since playwright has no page.browser()
'webgl.vendor',
- 'window.outerdimensions'
+ 'window.outerdimensions',
];
const stealth = {
callbacks: [],
async evaluateOnNewDocument(...args) {
this.callbacks.push({ cb: args[0], a: args[1] });
- }
+ },
};
for (const e of enabledEvasions) {
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();
const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
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('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
// single prompt that just returns the non-empty value instead of an object
// @ts-ignore
-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 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 });
// notifications via apprise CLI
import { exec } from 'child_process';
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();
const title = cfg.notify_title ? `-t ${cfg.notify_title}` : '';
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
+export const escapeHtml = unsafe => unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('\'', ''');
export const html_game_list = games => games.map(g => `- ${escapeHtml(g.title)} (${g.status})`).join('
');
diff --git a/version.js b/version.js
index d992afb2..7f875cfd 100644
--- a/version.js
+++ b/version.js
@@ -1,9 +1,9 @@
// check if running the latest version
-import {log} from 'console';
+import { log } from 'console';
import { exec } from 'child_process';
-const execp = (cmd) => new Promise((resolve, reject) => {
+const execp = cmd => new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
if (stderr) console.error(`stderr: ${stderr}`);
// 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', {
- // headers: { accept: 'application/vnd.github.VERSION.sha' }
- })).json();
+ // headers: { accept: 'application/vnd.github.VERSION.sha' }
+})).json();
// log(gh);
log('Local commit:', sha, new Date(date));
diff --git a/xbox.js b/xbox.js
index a2150f1a..54cccf23 100644
--- a/xbox.js
+++ b/xbox.js
@@ -1,37 +1,37 @@
-import { firefox } from "playwright-firefox"; // stealth plugin needs no outdated playwright-extra
-import { authenticator } from "otplib";
+import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
+import { authenticator } from 'otplib';
import {
- datetime,
- handleSIGINT,
- html_game_list,
- jsonDb,
- notify,
- prompt,
-} from "./util.js";
-import { cfg } from "./config.js";
+ datetime,
+ handleSIGINT,
+ html_game_list,
+ jsonDb,
+ notify,
+ prompt,
+} from './util.js';
+import { cfg } from './config.js';
// ### 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 ||= {};
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
+ 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
+ ? context.pages()[0]
+ : await context.newPage(); // should always exist
const notify_games = [];
let user;
@@ -39,216 +39,212 @@ let user;
main();
async function main() {
- try {
- await performLogin();
- await getAndSaveUser();
- await redeemFreeGames();
- } catch (error) {
- console.error(error);
- process.exitCode ||= 1;
- if (error.message && process.exitCode != 130)
- notify(`xbox failed: ${error.message.split("\n")[0]}`);
- } finally {
- await db.write(); // write out json db
- if (notify_games.filter((g) => g.status != "existed").length) {
- // don't notify if all were already claimed
- notify(`xbox (${user}):
${html_game_list(notify_games)}`);
- }
- await context.close();
+ try {
+ await performLogin();
+ await getAndSaveUser();
+ await redeemFreeGames();
+ } catch (error) {
+ console.error(error);
+ process.exitCode ||= 1;
+ if (error.message && process.exitCode != 130) notify(`xbox failed: ${error.message.split('\n')[0]}`);
+ } finally {
+ await db.write(); // write out json db
+ if (notify_games.filter(g => g.status != 'existed').length) {
+ // don't notify if all were already claimed
+ notify(`xbox (${user}):
${html_game_list(notify_games)}`);
}
+ await context.close();
+ }
}
async function performLogin() {
- await page.goto(URL_CLAIM, { waitUntil: "domcontentloaded" }); // default 'load' takes forever
-
- const signInLocator = page
- .getByRole("link", {
- name: "Sign in to your account",
- })
- .first();
- const usernameLocator = page
- .getByRole("button", {
- name: "Account manager for",
- })
- .first();
-
- await Promise.any([signInLocator.waitFor(), usernameLocator.waitFor()]);
-
- if (await usernameLocator.isVisible()) {
- return; // logged in using saved cookie
- } else if (await signInLocator.isVisible()) {
- console.error("Not signed in anymore.");
- await signInLocator.click();
- await signInToXbox();
- } else {
- console.error("lost! where am i?");
- }
+ await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
+
+ const signInLocator = page
+ .getByRole('link', {
+ name: 'Sign in to your account',
+ })
+ .first();
+ const usernameLocator = page
+ .getByRole('button', {
+ name: 'Account manager for',
+ })
+ .first();
+
+ await Promise.any([signInLocator.waitFor(), usernameLocator.waitFor()]);
+
+ if (await usernameLocator.isVisible()) {
+ return; // logged in using saved cookie
+ } else if (await signInLocator.isVisible()) {
+ console.error('Not signed in anymore.');
+ await signInLocator.click();
+ await signInToXbox();
+ } else {
+ console.error('lost! where am i?');
+ }
}
async function signInToXbox() {
- page.waitForLoadState("domcontentloaded");
- if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
- console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
-
- // ### FETCH EMAIL/PASS
- if (cfg.xbox_email && cfg.xbox_password)
- console.info("Using email and password from environment.");
- else
- console.info(
- "Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode)."
- );
- const email = cfg.xbox_email || (await prompt({ message: "Enter email" }));
- const password =
+ page.waitForLoadState('domcontentloaded');
+ if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
+ console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
+
+ // ### FETCH EMAIL/PASS
+ if (cfg.xbox_email && cfg.xbox_password) console.info('Using email and password from environment.');
+ else console.info(
+ 'Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).',
+ );
+ const email = cfg.xbox_email || await prompt({ message: 'Enter email' });
+ const password =
email &&
(cfg.xbox_password ||
- (await prompt({
- type: "password",
- message: "Enter password",
- })));
+ await prompt({
+ type: 'password',
+ message: 'Enter password',
+ }));
// ### FILL IN EMAIL/PASS
- if (email && password) {
- const usernameLocator = page
- .getByPlaceholder("Email, phone, or Skype")
- .first();
- const passwordLocator = page.getByPlaceholder("Password").first();
-
- await Promise.any([
- usernameLocator.waitFor(),
- passwordLocator.waitFor(),
- ]);
-
- // username may already be saved from before, if so, skip to filling in password
- if (await page.getByPlaceholder("Email, phone, or Skype").isVisible()) {
- await usernameLocator.fill(email);
- await page.getByRole("button", { name: "Next" }).click();
- }
-
- await passwordLocator.fill(password);
- await page.getByRole("button", { name: "Sign in" }).click();
-
- // handle MFA, but don't await it
- page.locator('input[name="otc"]')
- .waitFor()
- .then(async () => {
- console.log("Two-Step Verification - Enter security code");
- console.log(
- await page
- .locator('div[data-bind="text: description"]')
- .innerText()
- );
- const otp =
- (cfg.xbox_otpkey &&
- authenticator.generate(cfg.xbox_otpkey)) ||
- (await prompt({
- type: "text",
- message: "Enter two-factor sign in code",
- validate: (n) =>
- n.toString().length == 6 ||
- "The code must be 6 digits!",
- })); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
- await page.type('input[name="otc"]', otp.toString());
- await page
- .getByLabel("Don't ask me again on this device")
- .check(); // Trust this Browser
- await page.getByRole("button", { name: "Verify" }).click();
- })
- .catch((_) => {});
-
- // Trust this browser, but don't await it
- page.getByLabel("Don't show this again")
- .waitFor()
- .then(async () => {
- await page.getByLabel("Don't show this again").check();
- await page.getByRole("button", { name: "Yes" }).click();
- })
- .catch((_) => {});
- } else {
- console.log("Waiting for you to login in the browser.");
- await notify(
- "xbox: no longer signed in and not enough options set for automatic login."
+ if (email && password) {
+ const usernameLocator = page
+ .getByPlaceholder('Email, phone, or Skype')
+ .first();
+ const passwordLocator = page.getByPlaceholder('Password').first();
+
+ await Promise.any([
+ usernameLocator.waitFor(),
+ passwordLocator.waitFor(),
+ ]);
+
+ // username may already be saved from before, if so, skip to filling in password
+ if (await page.getByPlaceholder('Email, phone, or Skype').isVisible()) {
+ await usernameLocator.fill(email);
+ await page.getByRole('button', { name: 'Next' }).click();
+ }
+
+ await passwordLocator.fill(password);
+ await page.getByRole('button', { name: 'Sign in' }).click();
+
+ // handle MFA, but don't await it
+ page.locator('input[name="otc"]')
+ .waitFor()
+ .then(async () => {
+ console.log('Two-Step Verification - Enter security code');
+ console.log(
+ await page
+ .locator('div[data-bind="text: description"]')
+ .innerText(),
);
- if (cfg.headless) {
- console.log(
- "Run `SHOW=1 node xbox` to login in the opened browser."
- );
- await context.close();
- process.exit(1);
- }
+ 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}**`);
+ // ### VERIFY SIGNED IN
+ await page.waitForURL(`${URL_CLAIM}**`);
- if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
+ if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
}
async function getAndSaveUser() {
- user = await page.locator("#mectrl_currentAccount_primary").innerHTML();
- console.log(`Signed in as '${user}'`);
- db.data[user] ||= {};
+ user = await page.locator('#mectrl_currentAccount_primary').innerHTML();
+ console.log(`Signed in as '${user}'`);
+ db.data[user] ||= {};
}
async function redeemFreeGames() {
- const monthlyGamesLocator = await page.locator(".f-size-large").all();
-
- const monthlyGamesPageLinks = await Promise.all(
- monthlyGamesLocator.map(
- async (el) => await el.locator("a").getAttribute("href")
- )
- );
- console.log("Free games:", monthlyGamesPageLinks);
-
- for (const url of monthlyGamesPageLinks) {
- await page.goto(url);
-
- const title = await page.locator("h1").first().innerText();
- const game_id = page.url().split("/").pop();
- db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only!
- console.log("Current free game:", title);
- const notify_game = { title, url, status: "failed" };
- notify_games.push(notify_game); // status is updated below
-
- // SELECTORS
- const getBtnLocator = page.getByText("GET", { exact: true }).first();
- const installToLocator = page
- .getByText("INSTALL TO", { exact: true })
- .first();
-
- await Promise.any([
- getBtnLocator.waitFor(),
- installToLocator.waitFor(),
- ]);
-
- if (await installToLocator.isVisible()) {
- console.log(" Already in library! Nothing to claim.");
- notify_game.status = "existed";
- db.data[user][game_id].status ||= "existed"; // does not overwrite claimed or failed
- } else if (await getBtnLocator.isVisible()) {
- console.log(" Not in library yet! Click GET.");
- await getBtnLocator.click();
-
- // wait for popup
- await page
- .locator('iframe[name="purchase-sdk-hosted-iframe"]')
- .waitFor();
- const popupLocator = page.frameLocator(
- "[name=purchase-sdk-hosted-iframe]"
- );
-
- const finalGetBtnLocator = popupLocator.getByText("GET");
- await finalGetBtnLocator.waitFor();
- await finalGetBtnLocator.click();
-
- await page.getByText("Thank you for your purchase.").waitFor();
- notify_game.status = "claimed";
- db.data[user][game_id].status = "claimed";
- db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
- console.log(" Claimed successfully!");
- }
-
- // notify_game.status = db.data[user][game_id].status; // claimed or failed
-
- // const p = path.resolve(cfg.dir.screenshots, playstation-plus', `${game_id}.png`);
- // if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
+ const monthlyGamesLocator = await page.locator('.f-size-large').all();
+
+ const monthlyGamesPageLinks = await Promise.all(
+ monthlyGamesLocator.map(
+ async el => await el.locator('a').getAttribute('href'),
+ ),
+ );
+ console.log('Free games:', monthlyGamesPageLinks);
+
+ for (const url of monthlyGamesPageLinks) {
+ await page.goto(url);
+
+ const title = await page.locator('h1').first().innerText();
+ const game_id = page.url().split('/').pop();
+ db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only!
+ console.log('Current free game:', title);
+ const notify_game = { title, url, status: 'failed' };
+ notify_games.push(notify_game); // status is updated below
+
+ // SELECTORS
+ const getBtnLocator = page.getByText('GET', { exact: true }).first();
+ const installToLocator = page
+ .getByText('INSTALL TO', { exact: true })
+ .first();
+
+ await Promise.any([
+ getBtnLocator.waitFor(),
+ installToLocator.waitFor(),
+ ]);
+
+ if (await installToLocator.isVisible()) {
+ console.log(' Already in library! Nothing to claim.');
+ notify_game.status = 'existed';
+ db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed
+ } else if (await getBtnLocator.isVisible()) {
+ console.log(' Not in library yet! Click GET.');
+ await getBtnLocator.click();
+
+ // wait for popup
+ await page
+ .locator('iframe[name="purchase-sdk-hosted-iframe"]')
+ .waitFor();
+ const popupLocator = page.frameLocator(
+ '[name=purchase-sdk-hosted-iframe]',
+ );
+
+ const finalGetBtnLocator = popupLocator.getByText('GET');
+ await finalGetBtnLocator.waitFor();
+ await finalGetBtnLocator.click();
+
+ await page.getByText('Thank you for your purchase.').waitFor();
+ notify_game.status = 'claimed';
+ db.data[user][game_id].status = 'claimed';
+ db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
+ console.log(' Claimed successfully!');
}
+
+ // notify_game.status = db.data[user][game_id].status; // claimed or failed
+
+ // const p = path.resolve(cfg.dir.screenshots, playstation-plus', `${game_id}.png`);
+ // if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
+ }
}