From c78b84880544f487ba6ad8cf4b05c669c55ae913 Mon Sep 17 00:00:00 2001 From: Sergey Shunko Date: Wed, 26 Jun 2024 12:02:18 +0300 Subject: [PATCH] Add files via upload --- epic-games.js | 586 +++++++++++++++++++++++++------------------------- 1 file changed, 294 insertions(+), 292 deletions(-) diff --git a/epic-games.js b/epic-games.js index 5487445..495c966 100644 --- a/epic-games.js +++ b/epic-games.js @@ -1,292 +1,294 @@ -import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra -import { authenticator } from 'otplib'; -import path from 'path'; -import { existsSync, writeFileSync } from 'fs'; -import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; -import { cfg } from './src/config.js'; - -const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); - -const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; -const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM; - -console.log(datetime(), 'started checking epic-games'); - -const db = await jsonDb('epic-games.json', {}); - -if (cfg.time) console.time('startup'); - -// 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 }, - 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 - 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-${filenamify(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 - // user settings for firefox have to be put in $BROWSER_DIR/user.js - args: [ // https://wiki.mozilla.org/Firefox/CommandLineOptions - // '-kiosk', - ], -}); - -handleSIGINT(context); - -// Without stealth plugin, the website shows an hcaptcha on login with username/password and in the last step of claiming a game. It may have other heuristics like unsuccessful logins as well. After <6h (TBD) it resets to no captcha again. Getting a new IP also resets. -await stealth(context); - -if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - -const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist -await page.setViewportSize({ width: cfg.width, height: cfg.height }); // TODO workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it - -// some debug info about the page (screen dimensions, user agent, platform) -// eslint-disable-next-line no-undef -if (cfg.debug) console.debug(await page.evaluate(() => [(({ width, height, availWidth, availHeight }) => ({ width, height, availWidth, availHeight }))(window.screen), navigator.userAgent, navigator.platform, navigator.vendor])); // deconstruct screen needed since `window.screen` prints {}, `window.screen.toString()` '[object Screen]', and can't use some pick function without defining it on `page` -if (cfg.debug_network) { - // const filter = _ => true; - const filter = r => r.url().includes('store.epicgames.com'); - page.on('request', request => filter(request) && console.log('>>', request.method(), request.url())); - page.on('response', response => filter(response) && console.log('<<', response.status(), response.url())); -} - -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. - { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, // gets rid of 'To continue, please provide your date of birth', https://github.com/vogler/free-games-claimer/issues/275, USK number doesn't seem to matter, cookie from 'Fallout 3: Game of the Year Edition' - ]); - - await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto - - if (cfg.time) console.timeEnd('startup'); - if (cfg.time) console.time('login'); - - // page.click('button:has-text("Accept All Cookies")').catch(_ => { }); // Not needed anymore since we set the cookie above. Clicking this did not always work since the message was animated in too slowly. - - while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') { - 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!`); - 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 notifyBrowserLogin = async () => { - console.log('Waiting for you to login in the browser.'); - await notify('epic-games: no longer signed in and not enough options set for automatic login.'); - if (cfg.headless) { - console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); - await context.close(); // finishes potential recording - process.exit(1); - } - }; - const email = cfg.eg_email || await prompt({ message: 'Enter email' }); - if (!email) await notifyBrowserLogin(); - else { - // await page.click('text=Sign in with Epic Games'); - await page.fill('#email', email); - await page.click('button[type="submit"]'); - page.waitForSelector('.h_captcha_challenge iframe').then(async () => { - console.error('Got a captcha during login (likely due to too many attempts)! You may solve it in the browser, get a new IP or try again in a few hours.'); - await notify('epic-games: got captcha during login. Please check.'); - }).catch(_ => { }); - page.waitForSelector('p:has-text("Incorrect response.")').then(async () => { - console.error('Incorrect repsonse for captcha!'); - }).catch(_ => { }); - const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' })); - if (!password) await notifyBrowserLogin(); - await page.fill('#password', password); - await page.click('button[type="submit"]'); - // handle MFA, but don't await it - 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 - await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); - await page.click('button[type="submit"]'); - }).catch(_ => { }); - } - await page.waitForURL(URL_CLAIM); - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - } - user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin - console.log(`Signed in as ${user}`); - db.data[user] ||= {}; - if (cfg.time) console.timeEnd('login'); - if (cfg.time) console.time('claim all games'); - - // Detect free games - const game_loc = page.locator('a:has(span:text-is("Free Now"))'); - await game_loc.last().waitFor().catch(_ => { - // rarely there are no free games available -> catch Timeout - // TODO would be better to wait for alternative like 'coming soon' instead of waiting for timeout - // see https://github.com/vogler/free-games-claimer/issues/210#issuecomment-1727420943 - console.error('Seems like currently there are no free games available in your region...'); - // urls below should then be an empty list - }); - // 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 - // 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); - - for (const url of urls) { - if (cfg.time) console.time('claim game'); - await page.goto(url); // , { waitUntil: 'domcontentloaded' }); - const btnText = await page.locator('//button[@data-testid="purchase-cta-button"][not(contains(.,"Loading"))]').first().innerText(); // barrier to block until page is loaded - - // click Continue if 'This game contains mature content recommended only for ages 18+' - if (await page.locator('button:has-text("Continue")').count() > 0) { - console.log(' This game contains mature content recommended only for ages 18+'); - if (await page.locator('[data-testid="AgeSelect"]').count()) { - console.error(' Got "To continue, please provide your date of birth" - This shouldn\'t happen due to cookie set above. Please report to https://github.com/vogler/free-games-claimer/issues/275'); - await page.locator('#month_toggle').click(); - await page.locator('#month_menu li:has-text("01")').click(); - await page.locator('#day_toggle').click(); - await page.locator('#day_menu li:has-text("01")').click(); - await page.locator('#year_toggle').click(); - await page.locator('#year_menu li:has-text("1987")').click(); - } - await page.click('button:has-text("Continue")', { delay: 111 }); - await page.waitForTimeout(2000); - } - - let title; - if (await page.locator('span:text-is("About Bundle")').count()) { - // console.log(' This is a bundle containing: TODO'); - title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', ''); - } else { - 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 - - if (btnText.toLowerCase() == 'in library') { - 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 - if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed - } else if (btnText.toLowerCase() == 'requires base game') { - console.log(' Requires base game! Nothing to claim.'); - notify_game.status = 'requires base game'; - db.data[user][game_id].status ||= 'failed:requires-base-game'; - // TODO claim base game if it is free - const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href'); - console.log(' Base game:', baseUrl); - // await page.click('a:has-text("Overview")'); - urls.push(baseUrl); // add base game to the list of games to claim - urls.push(url); // add add-on itself again - } else { // GET - console.log(' Not in library yet! Click GET.'); - await page.click('[data-testid="purchase-cta-button"]', { delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough - - // click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent? - page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox? - - // click 'Yes, buy now' if 'This edition contains something you already have. Still interested?' - page.click('button:has-text("Yes, buy now")').catch(_ => { }); - - // Accept End User License Agreement (only needed once) - page.locator('input#agree').waitFor().then(async () => { - console.log(' Accept End User License Agreement (only needed once)'); - await page.locator('input#agree').check(); // TODO Bundle: got stuck here - await page.locator('button:has-text("Accept")').click(); - }).catch(_ => { }); - - // it then creates an iframe for the purchase - await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed? - const iframe = page.frameLocator('#webPurchaseContainer iframe'); - // skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region - if (await iframe.locator(':has-text("unavailable in your region")').count() > 0) { - console.error(' This product is unavailable in your region!'); - db.data[user][game_id].status = notify_game.status = 'unavailable-in-region'; - if (cfg.time) console.timeEnd('claim game'); - continue; - } - - iframe.locator('.payment-pin-code').waitFor().then(async () => { - if (!cfg.eg_parentalpin) { - console.error(' EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); - notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); - } - await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin); - await iframe.locator('button:has-text("Continue")').click({ delay: 11 }); - }).catch(_ => { }); - - if (cfg.debug) await page.pause(); - if (cfg.dryrun) { - console.log(' DRYRUN=1 -> Skip order!'); - notify_game.status = 'skipped'; - if (cfg.time) console.timeEnd('claim game'); - continue; - } - - // Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591 - await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); - - // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 - const btnAgree = iframe.locator('button:has-text("I Agree")'); - btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree' - try { - // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? - 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.'); - await notify(`epic-games: got captcha challenge right before claim of ${title}. Use VNC to solve it manually.`); // TODO could even create purchase URL, see https://github.com/vogler/free-games-claimer/pull/130 - // await page.waitForTimeout(2000); - // const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`); - // await captcha.screenshot({ path: p }); - // console.info(' Saved a screenshot of hcaptcha challenge to', p); - // console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge? - }).catch(_ => { }); // may time out if not shown - iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor().then(async () => { - console.error(' Failed to challenge captcha, please try again later.'); - await notify('epic-games: failed to challenge captcha. Please check.'); - }); - await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); // TODO Bundle: got stuck here - db.data[user][game_id].status = 'claimed'; - db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time - console.log(' Claimed successfully!'); - // context.setDefaultTimeout(cfg.timeout); - } catch (e) { - console.log(e); - // console.error(' Failed to claim! Try again if NopeCHA timed out. Click the extension to see if you ran out of credits (refill after 24h). To avoid captchas try to get a new IP or set a cookie from https://www.hcaptcha.com/accessibility'); - console.error(' Failed to claim! To avoid captchas try to get a new IP address.'); - const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`); - await page.screenshot({ path: p, fullPage: true }); - db.data[user][game_id].status = 'failed'; - } - notify_game.status = db.data[user][game_id].status; // claimed or failed - - const p = screenshot(`${game_id}.png`); - if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long... - } - if (cfg.time) console.timeEnd('claim game'); - } - if (cfg.time) console.timeEnd('claim all games'); -} catch (error) { - 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]}`); -} 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' - notify(`epic-games (${user}):
${html_game_list(notify_games)}`); - } -} -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()); -await context.close(); +import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra +import { authenticator } from 'otplib'; +import path from 'path'; +import { existsSync, writeFileSync } from 'fs'; +import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; +import { cfg } from './src/config.js'; + +const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); + +const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; +const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM; + +console.log(datetime(), 'started checking epic-games'); + +const db = await jsonDb('epic-games.json', {}); + +if (cfg.time) console.time('startup'); + +// 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 }, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0', // 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: 'ru-RU', // 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-${filenamify(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 + javaScriptEnabled: true, + // user settings for firefox have to be put in $BROWSER_DIR/user.js + args: [ // https://wiki.mozilla.org/Firefox/CommandLineOptions + // '-kiosk', + '-purgecaches' + ], +}); + +handleSIGINT(context); + +// Without stealth plugin, the website shows an hcaptcha on login with username/password and in the last step of claiming a game. It may have other heuristics like unsuccessful logins as well. After <6h (TBD) it resets to no captcha again. Getting a new IP also resets. +await stealth(context); + +if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); + +const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist +await page.setViewportSize({ width: cfg.width, height: cfg.height }); // TODO workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it + +// some debug info about the page (screen dimensions, user agent, platform) +// eslint-disable-next-line no-undef +if (cfg.debug) console.debug(await page.evaluate(() => [(({ width, height, availWidth, availHeight }) => ({ width, height, availWidth, availHeight }))(window.screen), navigator.userAgent, navigator.platform, navigator.vendor])); // deconstruct screen needed since `window.screen` prints {}, `window.screen.toString()` '[object Screen]', and can't use some pick function without defining it on `page` +if (cfg.debug_network) { + // const filter = _ => true; + const filter = r => r.url().includes('store.epicgames.com'); + page.on('request', request => filter(request) && console.log('>>', request.method(), request.url())); + page.on('response', response => filter(response) && console.log('<<', response.status(), response.url())); +} + +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. + { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, // gets rid of 'To continue, please provide your date of birth', https://github.com/vogler/free-games-claimer/issues/275, USK number doesn't seem to matter, cookie from 'Fallout 3: Game of the Year Edition' + ]); + + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto + + if (cfg.time) console.timeEnd('startup'); + if (cfg.time) console.time('login'); + + // page.click('button:has-text("Accept All Cookies")').catch(_ => { }); // Not needed anymore since we set the cookie above. Clicking this did not always work since the message was animated in too slowly. + + while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') { + 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!`); + 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 notifyBrowserLogin = async () => { + console.log('Waiting for you to login in the browser.'); + await notify('epic-games: no longer signed in and not enough options set for automatic login.'); + if (cfg.headless) { + console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); + await context.close(); // finishes potential recording + process.exit(1); + } + }; + const email = cfg.eg_email || await prompt({ message: 'Enter email' }); + if (!email) await notifyBrowserLogin(); + else { + // await page.click('text=Sign in with Epic Games'); + await page.fill('#email', email); + await page.click('button[type="submit"]'); + page.waitForSelector('.h_captcha_challenge iframe').then(async () => { + console.error('Got a captcha during login (likely due to too many attempts)! You may solve it in the browser, get a new IP or try again in a few hours.'); + await notify('epic-games: got captcha during login. Please check.'); + }).catch(_ => { }); + page.waitForSelector('p:has-text("Incorrect response.")').then(async () => { + console.error('Incorrect repsonse for captcha!'); + }).catch(_ => { }); + const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' })); + if (!password) await notifyBrowserLogin(); + await page.fill('#password', password); + await page.click('button[type="submit"]'); + // handle MFA, but don't await it + 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 + await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); + await page.click('button[type="submit"]'); + }).catch(_ => { }); + } + await page.waitForURL(URL_CLAIM); + if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); + } + user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin + console.log(`Signed in as ${user}`); + db.data[user] ||= {}; + if (cfg.time) console.timeEnd('login'); + if (cfg.time) console.time('claim all games'); + + // Detect free games + const game_loc = page.locator('a:has(span:text-is("Free Now"))'); + await game_loc.last().waitFor().catch(_ => { + // rarely there are no free games available -> catch Timeout + // TODO would be better to wait for alternative like 'coming soon' instead of waiting for timeout + // see https://github.com/vogler/free-games-claimer/issues/210#issuecomment-1727420943 + console.error('Seems like currently there are no free games available in your region...'); + // urls below should then be an empty list + }); + // 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 + // 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); + + for (const url of urls) { + if (cfg.time) console.time('claim game'); + await page.goto(url); // , { waitUntil: 'domcontentloaded' }); + const btnText = await page.locator('//button[@data-testid="purchase-cta-button"][not(contains(.,"Loading"))]').first().innerText(); // barrier to block until page is loaded + + // click Continue if 'This game contains mature content recommended only for ages 18+' + if (await page.locator('button:has-text("Continue")').count() > 0) { + console.log(' This game contains mature content recommended only for ages 18+'); + if (await page.locator('[data-testid="AgeSelect"]').count()) { + console.error(' Got "To continue, please provide your date of birth" - This shouldn\'t happen due to cookie set above. Please report to https://github.com/vogler/free-games-claimer/issues/275'); + await page.locator('#month_toggle').click(); + await page.locator('#month_menu li:has-text("01")').click(); + await page.locator('#day_toggle').click(); + await page.locator('#day_menu li:has-text("01")').click(); + await page.locator('#year_toggle').click(); + await page.locator('#year_menu li:has-text("1987")').click(); + } + await page.click('button:has-text("Continue")', { delay: 111 }); + await page.waitForTimeout(2000); + } + + let title; + if (await page.locator('span:text-is("About Bundle")').count()) { + // console.log(' This is a bundle containing: TODO'); + title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', ''); + } else { + 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 + + if (btnText.toLowerCase() == 'in library') { + 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 + if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed + } else if (btnText.toLowerCase() == 'requires base game') { + console.log(' Requires base game! Nothing to claim.'); + notify_game.status = 'requires base game'; + db.data[user][game_id].status ||= 'failed:requires-base-game'; + // TODO claim base game if it is free + const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href'); + console.log(' Base game:', baseUrl); + // await page.click('a:has-text("Overview")'); + urls.push(baseUrl); // add base game to the list of games to claim + urls.push(url); // add add-on itself again + } else { // GET + console.log(' Not in library yet! Click GET.'); + await page.click('[data-testid="purchase-cta-button"]', { delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough + + // click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent? + page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox? + + // click 'Yes, buy now' if 'This edition contains something you already have. Still interested?' + page.click('button:has-text("Yes, buy now")').catch(_ => { }); + + // Accept End User License Agreement (only needed once) + page.locator('input#agree').waitFor().then(async () => { + console.log(' Accept End User License Agreement (only needed once)'); + await page.locator('input#agree').check(); // TODO Bundle: got stuck here + await page.locator('button:has-text("Accept")').click(); + }).catch(_ => { }); + + // it then creates an iframe for the purchase + await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed? + const iframe = page.frameLocator('#webPurchaseContainer iframe'); + // skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region + if (await iframe.locator(':has-text("unavailable in your region")').count() > 0) { + console.error(' This product is unavailable in your region!'); + db.data[user][game_id].status = notify_game.status = 'unavailable-in-region'; + if (cfg.time) console.timeEnd('claim game'); + continue; + } + + iframe.locator('.payment-pin-code').waitFor().then(async () => { + if (!cfg.eg_parentalpin) { + console.error(' EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); + notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); + } + await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin); + await iframe.locator('button:has-text("Continue")').click({ delay: 11 }); + }).catch(_ => { }); + + if (cfg.debug) await page.pause(); + if (cfg.dryrun) { + console.log(' DRYRUN=1 -> Skip order!'); + notify_game.status = 'skipped'; + if (cfg.time) console.timeEnd('claim game'); + continue; + } + + // Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591 + await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); + + // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 + const btnAgree = iframe.locator('button:has-text("I Agree")'); + btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree' + try { + // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? + 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.'); + await notify(`epic-games: got captcha challenge right before claim of ${title}. Use VNC to solve it manually.`); // TODO could even create purchase URL, see https://github.com/vogler/free-games-claimer/pull/130 + // await page.waitForTimeout(2000); + // const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`); + // await captcha.screenshot({ path: p }); + // console.info(' Saved a screenshot of hcaptcha challenge to', p); + // console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge? + }).catch(_ => { }); // may time out if not shown + iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor().then(async () => { + console.error(' Failed to challenge captcha, please try again later.'); + await notify('epic-games: failed to challenge captcha. Please check.'); + }); + await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); // TODO Bundle: got stuck here + db.data[user][game_id].status = 'claimed'; + db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time + console.log(' Claimed successfully!'); + // context.setDefaultTimeout(cfg.timeout); + } catch (e) { + console.log(e); + // console.error(' Failed to claim! Try again if NopeCHA timed out. Click the extension to see if you ran out of credits (refill after 24h). To avoid captchas try to get a new IP or set a cookie from https://www.hcaptcha.com/accessibility'); + console.error(' Failed to claim! To avoid captchas try to get a new IP address.'); + const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`); + await page.screenshot({ path: p, fullPage: true }); + db.data[user][game_id].status = 'failed'; + } + notify_game.status = db.data[user][game_id].status; // claimed or failed + + const p = screenshot(`${game_id}.png`); + if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long... + } + if (cfg.time) console.timeEnd('claim game'); + } + if (cfg.time) console.timeEnd('claim all games'); +} catch (error) { + 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]}`); +} 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' + notify(`epic-games (${user}):
${html_game_list(notify_games)}`); + } +} +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()); +await context.close();