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... + } }