258 lines
17 KiB
JavaScript
258 lines
17 KiB
JavaScript
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 { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js';
|
|
import { cfg } from './config.js';
|
|
|
|
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');
|
|
db.data ||= {};
|
|
|
|
handleSIGINT();
|
|
|
|
// get current promotionalOffers from json instead of checking the website
|
|
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // otherwise got UNABLE_TO_GET_ISSUER_CERT_LOCALLY
|
|
const promoJson = await (await fetch(`https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?country=${cfg.eg_country}`)).json(); // ?locale=en-US
|
|
const currentGames = promoJson.data.Catalog.searchStore.elements.filter(e => e.promotions?.promotionalOffers?.length);
|
|
const gameURL = e => `https://store.epicgames.com/p/${e.offerMappings[0].pageSlug}`; // e.urlSlug may be wrong and lead to 404, e.catalogNs.mappings[0].pageSlug leads to base game for add-ons!
|
|
console.log('Free games:', currentGames.map(e => `${e.title} - ${gameURL(e)}`));
|
|
|
|
// TODO check if there are new games to claim before launching browser? https://github.com/vogler/free-games-claimer/issues/29
|
|
// Options:
|
|
// 1. Check order history (https://www.epicgames.com/account/v2/payment/ajaxGetOrderHistory) - only contains the last 10 orders
|
|
// 2. Check epic-games.json - would need to know the logged in user for `cfg.dir.browser`
|
|
// However, this may not always speed up the process since a game may have already been claimed before.
|
|
|
|
// https://www.nopecha.com extension source from https://github.com/NopeCHA/NopeCHA/releases/tag/0.1.16
|
|
// const ext = path.resolve('nopecha'); // used in Chromium, currently not needed in Firefox
|
|
|
|
// https://playwright.dev/docs/auth#multi-factor-authentication
|
|
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
|
|
// chrome will not work in linux arm64, only chromium
|
|
// channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
|
|
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 for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
|
|
locale: "en-US", // ignore OS locale to be sure to have english text for locators
|
|
recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
|
|
recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
|
|
args: [ // https://peter.sh/experiments/chromium-command-line-switches
|
|
// don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.'
|
|
// '--restore-last-session', // does not apply for crash/killed
|
|
'--hide-crash-restore-bubble',
|
|
// `--disable-extensions-except=${ext}`,
|
|
// `--load-extension=${ext}`,
|
|
],
|
|
// ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
|
|
});
|
|
|
|
// 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
|
|
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent));
|
|
if (cfg.record && cfg.debug) {
|
|
// 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.
|
|
|
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
|
|
|
|
// 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('a[role="button"]:has-text("Sign In")').count() > 0) {
|
|
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 email = cfg.eg_email || await prompt({message: 'Enter email'});
|
|
const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'}));
|
|
if (email && password) {
|
|
await page.click('text=Sign in with Epic Games');
|
|
await page.fill('#email', email);
|
|
await page.fill('#password', password);
|
|
await page.click('button[type="submit"]');
|
|
page.waitForSelector('#h_captcha_challenge_login_prod iframe').then(() => {
|
|
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.');
|
|
notify('epic-games: got captcha during login. Please check.');
|
|
}).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.type('input[name="code-input-0"]', otp.toString());
|
|
await page.click('button[type="submit"]');
|
|
}).catch(_ => { });
|
|
} else {
|
|
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);
|
|
}
|
|
}
|
|
await page.waitForURL(URL_CLAIM);
|
|
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
|
|
}
|
|
user = await page.locator('#user span').first().innerHTML();
|
|
console.log(`Signed in as ${user}`);
|
|
db.data[user] ||= {};
|
|
|
|
// This URL will order all free games, but it will fail if some games have already been claimed:
|
|
// const purchaseURL = 'https://store.epicgames.com/purchase?' + currentGames.map(e => `offers=1-${e.namespace}-${e.id}`).join('&');
|
|
for (const game of currentGames) {
|
|
const url = gameURL(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+');
|
|
await page.click('button:has-text("Continue")', { delay: 111 });
|
|
await page.waitForTimeout(2000);
|
|
}
|
|
|
|
// const title = await page.locator('h1').first().innerText();
|
|
const title = game.title;
|
|
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
|
|
|
|
const p = path.resolve(cfg.dir.screenshots, 'epic-games', `${game_id}.png`);
|
|
if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
|
|
|
|
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")');
|
|
} else { // GET
|
|
console.log(' Not in library yet! Claim!');
|
|
// go to purchase of unclaimed game - https://github.com/vogler/free-games-claimer/issues/127
|
|
const purchaseURL = `https://store.epicgames.com/purchase?offers=1-${game.namespace}-${game.id}`;
|
|
await page.goto(purchaseURL);
|
|
|
|
// 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?
|
|
|
|
// 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();
|
|
await page.locator('button:has-text("Accept")').click();
|
|
}).catch(_ => { });
|
|
|
|
// skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region
|
|
if (await page.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';
|
|
continue;
|
|
}
|
|
|
|
page.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 page.locator('input.payment-pin-code__input').first().type(cfg.eg_parentalpin);
|
|
await page.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';
|
|
continue;
|
|
}
|
|
|
|
// After successful order using the `purchaseURL`-method, the page is just empty, without any 'Thanks for your order', so we wait for the response of their API. Note: no await, and start waiting before final click to 'Place Order'.
|
|
const r = page.waitForResponse(r => r.url().startsWith('https://payment-website-pci.ol.epicgames.com/purchase/confirm-order'));
|
|
|
|
// Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591
|
|
await page.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 = page.locator('button:has-text("I Agree")');
|
|
btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree'
|
|
|
|
// May fail if game is already claimed with text 'Sorry, there is an error with your cart and we cannot complete the purchase. Please close this window and check your cart list.'
|
|
|
|
try {
|
|
// context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s?
|
|
const captcha = page.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 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
|
|
// await page.waitForSelector('text=Thanks for your order!'); // not shown for order via `purchaseURL`
|
|
const rt = await (await r).text(); // TODO blocks if not claimed?
|
|
const rj = JSON.parse(rt);
|
|
if (rj?.receiptResponse?.orderStatus != 'COMPLETED') {
|
|
console.error('Unexpected confirm-order response. Message:', rj.message);
|
|
console.log(rj);
|
|
continue;
|
|
}
|
|
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 = path.resolve(cfg.dir.screenshots, 'epic-games', '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
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error); // .toString()?
|
|
process.exitCode ||= 1;
|
|
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()));
|
|
await context.close();
|