Add files via upload

This commit is contained in:
Sergey Shunko 2024-06-26 12:02:18 +03:00 committed by GitHub
parent 920fc4d186
commit c78b848805
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,292 +1,294 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib'; import { authenticator } from 'otplib';
import path from 'path'; import path from 'path';
import { existsSync, writeFileSync } from 'fs'; import { existsSync, writeFileSync } from 'fs';
import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js'; import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a);
const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; 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; const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM;
console.log(datetime(), 'started checking epic-games'); console.log(datetime(), 'started checking epic-games');
const db = await jsonDb('epic-games.json', {}); const db = await jsonDb('epic-games.json', {});
if (cfg.time) console.time('startup'); if (cfg.time) console.time('startup');
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated? userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; 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 (macOS): Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
// userAgent firefox (docker): Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0 // userAgent firefox (docker): Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0
locale: 'en-US', // ignore OS locale to be sure to have english text for locators locale: '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 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 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 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 javaScriptEnabled: true,
args: [ // https://wiki.mozilla.org/Firefox/CommandLineOptions // user settings for firefox have to be put in $BROWSER_DIR/user.js
// '-kiosk', 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. handleSIGINT(context);
await stealth(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.
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); await stealth(context);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
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
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
// some debug info about the page (screen dimensions, user agent, platform) 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
// 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` // some debug info about the page (screen dimensions, user agent, platform)
if (cfg.debug_network) { // eslint-disable-next-line no-undef
// const filter = _ => true; 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`
const filter = r => r.url().includes('store.epicgames.com'); if (cfg.debug_network) {
page.on('request', request => filter(request) && console.log('>>', request.method(), request.url())); // const filter = _ => true;
page.on('response', response => filter(response) && console.log('<<', response.status(), response.url())); 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;
const notify_games = [];
try { let user;
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. try {
{ 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 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'); 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.time('login');
if (cfg.time) console.timeEnd('startup');
// 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. if (cfg.time) console.time('login');
while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') { // 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.
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.`); while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') {
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in console.error('Not signed in anymore. Please login in the browser or here in the terminal.');
console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`); if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`);
await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' });
const notifyBrowserLogin = async () => { if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.');
console.log('Waiting for you to login in the browser.'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).');
await notify('epic-games: no longer signed in and not enough options set for automatic login.'); const notifyBrowserLogin = async () => {
if (cfg.headless) { console.log('Waiting for you to login in the browser.');
console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); await notify('epic-games: no longer signed in and not enough options set for automatic login.');
await context.close(); // finishes potential recording if (cfg.headless) {
process.exit(1); 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 { const email = cfg.eg_email || await prompt({ message: 'Enter email' });
// await page.click('text=Sign in with Epic Games'); if (!email) await notifyBrowserLogin();
await page.fill('#email', email); else {
await page.click('button[type="submit"]'); // await page.click('text=Sign in with Epic Games');
page.waitForSelector('.h_captcha_challenge iframe').then(async () => { await page.fill('#email', email);
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 page.click('button[type="submit"]');
await notify('epic-games: got captcha during login. Please check.'); page.waitForSelector('.h_captcha_challenge iframe').then(async () => {
}).catch(_ => { }); 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.');
page.waitForSelector('p:has-text("Incorrect response.")').then(async () => { await notify('epic-games: got captcha during login. Please check.');
console.error('Incorrect repsonse for captcha!'); }).catch(_ => { });
}).catch(_ => { }); page.waitForSelector('p:has-text("Incorrect response.")').then(async () => {
const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' })); console.error('Incorrect repsonse for captcha!');
if (!password) await notifyBrowserLogin(); }).catch(_ => { });
await page.fill('#password', password); const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
await page.click('button[type="submit"]'); if (!password) await notifyBrowserLogin();
// handle MFA, but don't await it await page.fill('#password', password);
page.waitForURL('**/id/login/mfa**').then(async () => { await page.click('button[type="submit"]');
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 ...'); // handle MFA, but don't await it
// TODO locator for text (email or app?) page.waitForURL('**/id/login/mfa**').then(async () => {
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 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 ...');
await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); // TODO locator for text (email or app?)
await page.click('button[type="submit"]'); 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
}).catch(_ => { }); await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
} await page.click('button[type="submit"]');
await page.waitForURL(URL_CLAIM); }).catch(_ => { });
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); }
} await page.waitForURL(URL_CLAIM);
user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
console.log(`Signed in as ${user}`); }
db.data[user] ||= {}; user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin
if (cfg.time) console.timeEnd('login'); console.log(`Signed in as ${user}`);
if (cfg.time) console.time('claim all games'); db.data[user] ||= {};
if (cfg.time) console.timeEnd('login');
// Detect free games if (cfg.time) console.time('claim all games');
const game_loc = page.locator('a:has(span:text-is("Free Now"))');
await game_loc.last().waitFor().catch(_ => { // Detect free games
// rarely there are no free games available -> catch Timeout const game_loc = page.locator('a:has(span:text-is("Free Now"))');
// TODO would be better to wait for alternative like 'coming soon' instead of waiting for timeout await game_loc.last().waitFor().catch(_ => {
// see https://github.com/vogler/free-games-claimer/issues/210#issuecomment-1727420943 // rarely there are no free games available -> catch Timeout
console.error('Seems like currently there are no free games available in your region...'); // TODO would be better to wait for alternative like 'coming soon' instead of waiting for timeout
// urls below should then be an empty list // 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...');
// clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25 // urls below should then be an empty list
// 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 // clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25
// 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 // debug showed that in those cases the href was still correct, so we `goto` the urls instead of clicking.
const urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href'))); // Alternative: parse the json loaded to build the page https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions
const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s); // 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
console.log('Free games:', urls); const urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href')));
const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s);
for (const url of urls) { console.log('Free games:', urls);
if (cfg.time) console.time('claim game');
await page.goto(url); // , { waitUntil: 'domcontentloaded' }); for (const url of urls) {
const btnText = await page.locator('//button[@data-testid="purchase-cta-button"][not(contains(.,"Loading"))]').first().innerText(); // barrier to block until page is loaded if (cfg.time) console.time('claim game');
await page.goto(url); // , { waitUntil: 'domcontentloaded' });
// click Continue if 'This game contains mature content recommended only for ages 18+' const btnText = await page.locator('//button[@data-testid="purchase-cta-button"][not(contains(.,"Loading"))]').first().innerText(); // barrier to block until page is loaded
if (await page.locator('button:has-text("Continue")').count() > 0) {
console.log(' This game contains mature content recommended only for ages 18+'); // click Continue if 'This game contains mature content recommended only for ages 18+'
if (await page.locator('[data-testid="AgeSelect"]').count()) { if (await page.locator('button:has-text("Continue")').count() > 0) {
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'); console.log(' This game contains mature content recommended only for ages 18+');
await page.locator('#month_toggle').click(); if (await page.locator('[data-testid="AgeSelect"]').count()) {
await page.locator('#month_menu li:has-text("01")').click(); 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('#day_toggle').click(); await page.locator('#month_toggle').click();
await page.locator('#day_menu li:has-text("01")').click(); await page.locator('#month_menu li:has-text("01")').click();
await page.locator('#year_toggle').click(); await page.locator('#day_toggle').click();
await page.locator('#year_menu li:has-text("1987")').click(); await page.locator('#day_menu li:has-text("01")').click();
} await page.locator('#year_toggle').click();
await page.click('button:has-text("Continue")', { delay: 111 }); await page.locator('#year_menu li:has-text("1987")').click();
await page.waitForTimeout(2000); }
} 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'); let title;
title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', ''); if (await page.locator('span:text-is("About Bundle")').count()) {
} else { // console.log(' This is a bundle containing: TODO');
title = await page.locator('h1').first().innerText(); title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', '');
} } else {
const game_id = page.url().split('/').pop(); title = await page.locator('h1').first().innerText();
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 game_id = page.url().split('/').pop();
const notify_game = { title, url, status: 'failed' }; db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only!
notify_games.push(notify_game); // status is updated below console.log('Current free game:', title);
const notify_game = { title, url, status: 'failed' };
if (btnText.toLowerCase() == 'in library') { notify_games.push(notify_game); // status is updated below
console.log(' Already in library! Nothing to claim.');
notify_game.status = 'existed'; if (btnText.toLowerCase() == 'in library') {
db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed console.log(' Already in library! Nothing to claim.');
if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed notify_game.status = 'existed';
} else if (btnText.toLowerCase() == 'requires base game') { db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed
console.log(' Requires base game! Nothing to claim.'); if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed
notify_game.status = 'requires base game'; } else if (btnText.toLowerCase() == 'requires base game') {
db.data[user][game_id].status ||= 'failed:requires-base-game'; console.log(' Requires base game! Nothing to claim.');
// TODO claim base game if it is free notify_game.status = 'requires base game';
const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href'); db.data[user][game_id].status ||= 'failed:requires-base-game';
console.log(' Base game:', baseUrl); // TODO claim base game if it is free
// await page.click('a:has-text("Overview")'); const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href');
urls.push(baseUrl); // add base game to the list of games to claim console.log(' Base game:', baseUrl);
urls.push(url); // add add-on itself again // await page.click('a:has-text("Overview")');
} else { // GET urls.push(baseUrl); // add base game to the list of games to claim
console.log(' Not in library yet! Click GET.'); urls.push(url); // add add-on itself again
await page.click('[data-testid="purchase-cta-button"]', { delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough } else { // GET
console.log(' Not in library yet! Click GET.');
// click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent? await page.click('[data-testid="purchase-cta-button"]', { delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough
page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox?
// click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent?
// click 'Yes, buy now' if 'This edition contains something you already have. Still interested?' page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox?
page.click('button:has-text("Yes, buy now")').catch(_ => { });
// click 'Yes, buy now' if 'This edition contains something you already have. Still interested?'
// Accept End User License Agreement (only needed once) page.click('button:has-text("Yes, buy now")').catch(_ => { });
page.locator('input#agree').waitFor().then(async () => {
console.log(' Accept End User License Agreement (only needed once)'); // Accept End User License Agreement (only needed once)
await page.locator('input#agree').check(); // TODO Bundle: got stuck here page.locator('input#agree').waitFor().then(async () => {
await page.locator('button:has-text("Accept")').click(); console.log(' Accept End User License Agreement (only needed once)');
}).catch(_ => { }); await page.locator('input#agree').check(); // TODO Bundle: got stuck here
await page.locator('button:has-text("Accept")').click();
// it then creates an iframe for the purchase }).catch(_ => { });
await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#webPurchaseContainer iframe'); // it then creates an iframe for the purchase
// skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed?
if (await iframe.locator(':has-text("unavailable in your region")').count() > 0) { const iframe = page.frameLocator('#webPurchaseContainer iframe');
console.error(' This product is unavailable in your region!'); // skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region
db.data[user][game_id].status = notify_game.status = 'unavailable-in-region'; if (await iframe.locator(':has-text("unavailable in your region")').count() > 0) {
if (cfg.time) console.timeEnd('claim game'); console.error(' This product is unavailable in your region!');
continue; 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.'); iframe.locator('.payment-pin-code').waitFor().then(async () => {
notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); if (!cfg.eg_parentalpin) {
} console.error(' EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.');
await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin); notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.');
await iframe.locator('button:has-text("Continue")').click({ delay: 11 }); }
}).catch(_ => { }); await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin);
await iframe.locator('button:has-text("Continue")').click({ delay: 11 });
if (cfg.debug) await page.pause(); }).catch(_ => { });
if (cfg.dryrun) {
console.log(' DRYRUN=1 -> Skip order!'); if (cfg.debug) await page.pause();
notify_game.status = 'skipped'; if (cfg.dryrun) {
if (cfg.time) console.timeEnd('claim game'); console.log(' DRYRUN=1 -> Skip order!');
continue; 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 });
// Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591
// I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 });
const btnAgree = iframe.locator('button:has-text("I Agree")');
btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree' // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872
try { const btnAgree = iframe.locator('button:has-text("I Agree")');
// context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree'
const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe'); try {
captcha.waitFor().then(async () => { // don't await, since element may not be shown // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s?
// console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.') const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
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.'); captcha.waitFor().then(async () => { // don't await, since element may not be shown
await notify(`epic-games: got captcha challenge right before claim of <a href="${url}">${title}</a>. Use VNC to solve it manually.`); // TODO could even create purchase URL, see https://github.com/vogler/free-games-claimer/pull/130 // console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.')
// await page.waitForTimeout(2000); 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.');
// const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`); await notify(`epic-games: got captcha challenge right before claim of <a href="${url}">${title}</a>. Use VNC to solve it manually.`); // TODO could even create purchase URL, see https://github.com/vogler/free-games-claimer/pull/130
// await captcha.screenshot({ path: p }); // await page.waitForTimeout(2000);
// console.info(' Saved a screenshot of hcaptcha challenge to', p); // const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`);
// 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? // await captcha.screenshot({ path: p });
}).catch(_ => { }); // may time out if not shown // console.info(' Saved a screenshot of hcaptcha challenge to', p);
iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor().then(async () => { // console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge?
console.error(' Failed to challenge captcha, please try again later.'); }).catch(_ => { }); // may time out if not shown
await notify('epic-games: failed to challenge captcha. Please check.'); 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 page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); // TODO Bundle: got stuck here await notify('epic-games: failed to challenge captcha. Please check.');
db.data[user][game_id].status = 'claimed'; });
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); // TODO Bundle: got stuck here
console.log(' Claimed successfully!'); db.data[user][game_id].status = 'claimed';
// context.setDefaultTimeout(cfg.timeout); db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
} catch (e) { console.log(' Claimed successfully!');
console.log(e); // context.setDefaultTimeout(cfg.timeout);
// 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'); } catch (e) {
console.error(' Failed to claim! To avoid captchas try to get a new IP address.'); console.log(e);
const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`); // 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');
await page.screenshot({ path: p, fullPage: true }); console.error(' Failed to claim! To avoid captchas try to get a new IP address.');
db.data[user][game_id].status = 'failed'; const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`);
} await page.screenshot({ path: p, fullPage: true });
notify_game.status = db.data[user][game_id].status; // claimed or failed db.data[user][game_id].status = 'failed';
}
const p = screenshot(`${game_id}.png`); notify_game.status = db.data[user][game_id].status; // claimed or failed
if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
} const p = screenshot(`${game_id}.png`);
if (cfg.time) console.timeEnd('claim game'); if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
} }
if (cfg.time) console.timeEnd('claim all games'); if (cfg.time) console.timeEnd('claim game');
} catch (error) { }
process.exitCode ||= 1; if (cfg.time) console.timeEnd('claim all games');
console.error('--- Exception:'); } catch (error) {
console.error(error); // .toString()? process.exitCode ||= 1;
if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`); console.error('--- Exception:');
} finally { console.error(error); // .toString()?
await db.write(); // write out json db if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`);
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' } finally {
notify(`epic-games (${user}):<br>${html_game_list(notify_games)}`); 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}):<br>${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(); 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();