325 lines
22 KiB
JavaScript
325 lines
22 KiB
JavaScript
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
|
|
import { authenticator } from 'otplib';
|
|
import chalk from 'chalk';
|
|
import path from 'path';
|
|
import { existsSync, writeFileSync, appendFileSync } 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');
|
|
|
|
const browserPrefs = path.join(cfg.dir.browser, 'prefs.js');
|
|
if (existsSync(browserPrefs)) {
|
|
console.log('Adding webgl.disabled to', browserPrefs);
|
|
appendFileSync(browserPrefs, 'user_pref("webgl.disabled", true);'); // apparently Firefox removes duplicates (and sorts), so no problem appending every time
|
|
} else {
|
|
console.log(browserPrefs, 'does not exist yet, will patch it on next run. Restart the script if you get a captcha.');
|
|
}
|
|
|
|
// 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: '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');
|
|
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 response for captcha!');
|
|
}).catch(_ => { });
|
|
await page.fill('#email', email);
|
|
// await page.click('button[type="submit"]'); login was split in two steps for some time, now email and password are on the same form again
|
|
const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
|
|
if (!password) await notifyBrowserLogin();
|
|
else {
|
|
await page.fill('#password', password);
|
|
await page.click('button[type="submit"]');
|
|
}
|
|
const error = page.locator('#form-error-message');
|
|
error.waitFor().then(async () => {
|
|
console.error('Login error:', await error.innerText());
|
|
console.log('Please login in the browser!');
|
|
}).catch(_ => { });
|
|
// 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 purchaseBtn = page.locator('button[data-testid="purchase-cta-button"] >> :has-text("e"), :has-text("i")').first(); // when loading, the button text is empty -> need to wait for some text {'get', 'in library', 'requires base game'} -> just wait for e or i to not be too specific; :text-matches("\w+") somehow didn't work - https://github.com/vogler/free-games-claimer/issues/375
|
|
await purchaseBtn.waitFor();
|
|
const btnText = (await purchaseBtn.innerText()).toLowerCase(); // 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;
|
|
let bundle_includes;
|
|
if (await page.locator('span:text-is("About Bundle")').count()) {
|
|
title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', '');
|
|
// h1 first didn't exist for bundles but now it does... However h1 would e.g. be 'Fallout® Classic Collection' instead of 'Fallout Classic Collection'
|
|
try {
|
|
bundle_includes = await Promise.all((await page.locator('.product-card-top-row h5').all()).map(b => b.innerText()));
|
|
} catch (e) {
|
|
console.error('Failed to get "Bundle Includes":', e);
|
|
}
|
|
} else {
|
|
title = await page.locator('h1').first().innerText();
|
|
}
|
|
const game_id = page.url().split('/').pop();
|
|
const existedInDb = db.data[user][game_id];
|
|
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:', chalk.blue(title));
|
|
if (bundle_includes) console.log(' This bundle includes:', bundle_includes);
|
|
const notify_game = { title, url, status: 'failed' };
|
|
notify_games.push(notify_game); // status is updated below
|
|
|
|
if (btnText == 'in library') {
|
|
console.log(' Already in library! Nothing to claim.');
|
|
if (!existedInDb) await notify(`Game already in library: ${url}`);
|
|
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 == '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")');
|
|
// TODO handle this via function call for base game above since this will never terminate if DRYRUN=1
|
|
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', btnText);
|
|
await purchaseBtn.click({ 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(':has-text("end user license agreement")').waitFor().then(async () => {
|
|
console.log(' Accept End User License Agreement (only needed once)');
|
|
console.log(page.innerHTML);
|
|
console.log('Please report the HTML above here: https://github.com/vogler/free-games-claimer/issues/371');
|
|
await page.locator('input#agree').check(); // TODO Bundle: got stuck here; likely unrelated to bundle and locator just changed: https://github.com/vogler/free-games-claimer/issues/371
|
|
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 Accept")');
|
|
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 <a href="${url}">${title}</a>. Use VNC to solve it manually.`); // TODO not all apprise services understand HTML: https://github.com/vogler/free-games-claimer/pull/417
|
|
await notify(`epic-games: got captcha challenge for.\nGame link: ${url}`);
|
|
// 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.');
|
|
}).catch(_ => { });
|
|
await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); // TODO Bundle: got stuck here, but normal game now as well
|
|
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}):<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();
|