Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Epic: game data and purchase URL from JSON API #130

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Available options/variables and their default values:
| EG_PASSWORD | | Epic Games password for login. Overrides PASSWORD. |
| EG_OTPKEY | | Epic Games MFA OTP key. |
| EG_PARENTALPIN | | Epic Games Parental Controls PIN. |
| EG_COUNTRY | US | Epic Games [country of account](https://www.epicgames.com/account/personal). Set to avoid unavailable-in-region. |
| PG_EMAIL | | Prime Gaming email for login. Overrides EMAIL. |
| PG_PASSWORD | | Prime Gaming password for login. Overrides PASSWORD. |
| PG_OTPKEY | | Prime Gaming MFA OTP key. |
Expand Down
1 change: 1 addition & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const cfg = {
eg_password: process.env.EG_PASSWORD || process.env.PASSWORD,
eg_otpkey: process.env.EG_OTPKEY,
eg_parentalpin: process.env.EG_PARENTALPIN,
eg_country: process.env.EG_COUNTRY || 'US', // This should fit your account's country since sometimes there are replacements for games that are unavailable-in-region. See country/region under https://www.epicgames.com/account/personal and use its two-letter country code.
// auth prime-gaming
pg_email: process.env.PG_EMAIL || process.env.EMAIL,
pg_password: process.env.PG_PASSWORD || process.env.PASSWORD,
Expand Down
77 changes: 48 additions & 29 deletions epic-games.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ 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.productSlug || 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

Expand Down Expand Up @@ -106,18 +119,10 @@ try {
console.log(`Signed in as ${user}`);
db.data[user] ||= {};

// Detect free games
const game_loc = page.locator('a:has(span:text-is("Free Now"))');
await game_loc.last().waitFor();
// clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25
// debug showed that in those cases the href was still correct, so we `goto` the urls instead of clicking.
// Alternative: parse the json loaded to build the page https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions
// filter data.Catalog.searchStore.elements for .promotions.promotionalOffers being set and build URL with .catalogNs.mappings[0].pageSlug or .urlSlug if not set to some wrong id like it was the case for spirit-of-the-north-f58a66 - this is also what's done here: https://github.com/claabs/epicgames-freegames-node/blob/938a9653ffd08b8284ea32cf01ac8727d25c5d4c/src/puppet/free-games.ts#L138-L213
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) {
// 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

Expand All @@ -128,13 +133,17 @@ try {
await page.waitForTimeout(2000);
}

const title = await page.locator('h1').first().innerText();
// 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';
Expand All @@ -149,8 +158,11 @@ try {
console.log(' Base game:', baseUrl);
// await page.click('a:has-text("Overview")');
} 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
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}`;
console.log(' purchaseURL:', purchaseURL);
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?
Expand All @@ -162,23 +174,20 @@ try {
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) {
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;
}

iframe.locator('.payment-pin-code').waitFor().then(async () => {
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 iframe.locator('input.payment-pin-code__input').first().type(cfg.eg_parentalpin);
await iframe.locator('button:has-text("Continue")').click({ delay: 11 });
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();
Expand All @@ -188,15 +197,21 @@ try {
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 iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 });
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 = iframe.locator('button:has-text("I Agree")');
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 = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
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.')
Expand All @@ -206,7 +221,14 @@ try {
// console.info(' Saved a screenshot of hcaptcha challenge to', p);
// console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge?
}).catch(_ => { }); // may time out if not shown
await page.waitForSelector('text=Thanks for your order!');
// 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!');
Expand All @@ -220,9 +242,6 @@ try {
db.data[user][game_id].status = 'failed';
}
notify_game.status = db.data[user][game_id].status; // claimed or failed

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...
}
}
} catch (error) {
Expand Down