diff --git a/epic-games.js b/epic-games.js
index beb3cd7..653a00e 100644
--- a/epic-games.js
+++ b/epic-games.js
@@ -1,17 +1,20 @@
-import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
+import { firefox } from 'playwright-firefox';
import { authenticator } from 'otplib';
import chalk from 'chalk';
import path from 'node:path';
import { existsSync, writeFileSync, appendFileSync } from 'node:fs';
import { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js';
+import { EPIC_CLIENT_ID, GRAPHQL_ENDPOINT, FREE_GAMES_PROMOTIONS_ENDPOINT, STORE_HOMEPAGE_EN, EPIC_PURCHASE_ENDPOINT, ID_LOGIN_ENDPOINT } from './src/constants.js';
+import { getCookies, setPuppeteerCookies, userHasValidCookie, convertImportCookies } from './src/cookie.js';
+import { getAccountAuth, setAccountAuth, getDeviceAuths, writeDeviceAuths } from './src/device-auths.js';
const screenshot = (...a) => path.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');
+console.log(datetime(), 'started checking epic-games (GraphQL API mode)');
if (cfg.eg_mode === 'new') {
const { claimEpicGamesNew } = await import('./epic-claimer-new.js');
@@ -26,7 +29,7 @@ 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
+ appendFileSync(browserPrefs, 'user_pref("webgl.disabled", true);');
} else {
console.log(browserPrefs, 'does not exist yet, will patch it on next run. Restart the script if you get a captcha.');
}
@@ -35,28 +38,25 @@ if (existsSync(browserPrefs)) {
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', // Windows UA avoids "device not supported"; update when browser version changes
- 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
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0',
+ locale: 'en-US',
+ recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined,
+ recordHar: cfg.record ? { path: `data/record/eg-${filenamify(datetime())}.har` } : undefined,
+ handleSIGINT: false,
+ args: [],
});
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 }); // 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();
+await page.setViewportSize({ width: cfg.width, height: cfg.height });
-// some debug info about the page (screen dimensions, user agent)
+// some debug info about the page
if (cfg.debug) {
- /* global window, navigator */
const debugInfo = await page.evaluate(() => {
const { width, height, availWidth, availHeight } = window.screen;
return {
@@ -66,8 +66,8 @@ if (cfg.debug) {
});
console.debug(debugInfo);
}
+
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()));
@@ -76,36 +76,249 @@ if (cfg.debug_network) {
const notify_games = [];
let user;
+// GraphQL query for free games
+const FREE_GAMES_QUERY = {
+ operationName: 'searchStoreQuery',
+ variables: {
+ allowCountries: 'US',
+ category: 'games/edition/base|software/edition/base|editors|bundles/games',
+ count: 1000,
+ country: 'US',
+ sortBy: 'relevancy',
+ sortDir: 'DESC',
+ start: 0,
+ withPrice: true,
+ },
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: '7d58e12d9dd8cb14c84a3ff18d360bf9f0caa96bf218f2c5fda68ba88d68a437',
+ },
+ },
+};
+
+// Generate login redirect URL
+const generateLoginRedirect = (redirectUrl) => {
+ const loginRedirectUrl = new URL(ID_LOGIN_ENDPOINT);
+ loginRedirectUrl.searchParams.set('noHostRedirect', 'true');
+ loginRedirectUrl.searchParams.set('redirectUrl', redirectUrl);
+ loginRedirectUrl.searchParams.set('client_id', EPIC_CLIENT_ID);
+ return loginRedirectUrl.toString();
+};
+
+// Generate checkout URL with login redirect
+const generateCheckoutUrl = (offers) => {
+ const offersParams = offers
+ .map((offer) => `&offers=1-${offer.offerNamespace}-${offer.offerId}`)
+ .join('');
+ const checkoutUrl = `${EPIC_PURCHASE_ENDPOINT}?highlightColor=0078f2${offersParams}&orderId&purchaseToken&showNavigation=true`;
+ return generateLoginRedirect(checkoutUrl);
+};
+
+// Get free games from GraphQL API
+const getFreeGamesFromGraphQL = async () => {
+ const items = [];
+ let start = 0;
+ const pageLimit = 1000;
+
+ do {
+ const response = await page.evaluate(async (query, startOffset) => {
+ const variables = { ...query.variables, start: startOffset };
+ const resp = await fetch(GRAPHQL_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ operationName: query.operationName,
+ variables: JSON.stringify(variables),
+ extensions: JSON.stringify(query.extensions),
+ }),
+ });
+ return await resp.json();
+ }, [FREE_GAMES_QUERY, start]);
+
+ const elements = response.data?.Catalog?.searchStore?.elements;
+ if (!elements) break;
+
+ items.push(...elements);
+ start += pageLimit;
+ } while (items.length < pageLimit);
+
+ // Filter free games
+ const freeGames = items.filter(game =>
+ game.price?.totalPrice?.discountPrice === 0
+ );
+
+ // Deduplicate by productSlug
+ const uniqueGames = new Map();
+ for (const game of freeGames) {
+ if (!uniqueGames.has(game.productSlug)) {
+ uniqueGames.set(game.productSlug, game);
+ }
+ }
+
+ return Array.from(uniqueGames.values()).map(game => ({
+ offerId: game.id,
+ offerNamespace: game.namespace,
+ productName: game.title,
+ productSlug: game.productSlug || game.urlSlug,
+ }));
+};
+
+// Get free games from promotions API (weekly free games)
+const getFreeGamesFromPromotions = async () => {
+ const response = await page.evaluate(async () => {
+ const resp = await fetch(FREE_GAMES_PROMOTIONS_ENDPOINT + '?locale=en-US&country=US&allowCountries=US');
+ return await resp.json();
+ });
+
+ const nowDate = new Date();
+ const elements = response.data?.Catalog?.searchStore?.elements || [];
+
+ return elements.filter(offer => {
+ if (!offer.promotions) return false;
+
+ return offer.promotions.promotionalOffers.some(innerOffers =>
+ innerOffers.promotionalOffers.some(pOffer => {
+ const startDate = new Date(pOffer.startDate);
+ const endDate = new Date(pOffer.endDate);
+ const isFree = pOffer.discountSetting?.discountPercentage === 0;
+ return startDate <= nowDate && nowDate <= endDate && isFree;
+ })
+ );
+ }).map(game => ({
+ offerId: game.id,
+ offerNamespace: game.namespace,
+ productName: game.title,
+ productSlug: game.productSlug || game.urlSlug,
+ }));
+};
+
+// Get all free games
+const getAllFreeGames = async () => {
+ try {
+ const weeklyGames = await getFreeGamesFromPromotions();
+ console.log('Found', weeklyGames.length, 'weekly free games');
+ return weeklyGames;
+ } catch (e) {
+ console.error('Failed to get weekly free games:', e.message);
+ return [];
+ }
+};
+
+// Login with device auth - attempts to use stored auth token
+const loginWithDeviceAuth = async () => {
+ const deviceAuth = await getAccountAuth(cfg.eg_email || 'default');
+
+ if (deviceAuth && deviceAuth.access_token) {
+ console.log('Using stored device auth');
+
+ // Set the bearer token cookie for authentication
+ const bearerCookie = /** @type {import('playwright-firefox').Cookie} */ ({
+ name: 'EPIC_BEARER_TOKEN',
+ value: deviceAuth.access_token,
+ expires: new Date(deviceAuth.expires_at).getTime() / 1000,
+ domain: '.epicgames.com',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'Lax',
+ });
+
+ await context.addCookies([bearerCookie]);
+
+ // Visit store to get session cookies
+ await page.goto(STORE_HOMEPAGE_EN, { waitUntil: 'networkidle' });
+
+ // Check if login worked
+ const isLoggedIn = await page.locator('egs-navigation').getAttribute('isloggedin') === 'true';
+ if (isLoggedIn) {
+ console.log('Successfully logged in with device auth');
+ return true;
+ }
+ }
+
+ return false;
+};
+
+// Exchange token for cookies (alternative method)
+const exchangeTokenForCookies = async (accessToken) => {
+ try {
+ const cookies = await page.evaluate(async (token) => {
+ const resp = await fetch('https://store.epicgames.com/', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ },
+ });
+ return await resp.headers.get('set-cookie');
+ }, accessToken);
+
+ return cookies;
+ } catch {
+ return null;
+ }
+};
+
+// Save device auth
+const saveDeviceAuth = async (accessToken, refreshToken, expiresAt) => {
+ const deviceAuth = {
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ expires_at: expiresAt,
+ expires_in: 86400,
+ token_type: 'bearer',
+ account_id: 'unknown',
+ client_id: EPIC_CLIENT_ID,
+ internal_client: true,
+ client_service: 'account',
+ displayName: 'User',
+ app: 'epic-games',
+ in_app_id: 'unknown',
+ product_id: 'unknown',
+ refresh_expires: 604800,
+ refresh_expires_at: new Date(Date.now() + 604800000).toISOString(),
+ application_id: 'unknown',
+ };
+
+ await setAccountAuth(cfg.eg_email || 'default', deviceAuth);
+ console.log('Device auth saved');
+};
+
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'
+ { name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' },
+ { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' },
]);
- await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
+ await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
if (cfg.time) console.timeEnd('startup');
if (cfg.time) console.time('login');
+ // Try device auth first
+ const deviceAuthLoginSuccess = await loginWithDeviceAuth();
+
+ // If device auth failed, try regular login
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.');
+ console.error('Not signed in. 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
+ if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout);
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
+ await context.close();
process.exit(1);
}
};
- // If captcha or "Incorrect response" is visible, do not auto-submit; wait for manual solve.
const hasCaptcha = await page.locator('.h_captcha_challenge iframe, text=Incorrect response').count() > 0;
if (hasCaptcha) {
console.warn('Captcha/Incorrect response detected. Please solve manually in the browser.');
@@ -122,6 +335,7 @@ try {
await page.fill('#password', password);
await page.click('button[type="submit"]');
} else await notifyBrowserLogin();
+
const error = page.locator('#form-error-message');
const watchLoginError = async () => {
try {
@@ -132,58 +346,74 @@ try {
return;
}
};
+
const watchMfaStep = async () => {
try {
await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout });
- 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 ...');
- 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');
+ 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!' });
await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
await page.click('button[type="submit"]');
} catch {
return;
}
};
+
watchLoginError();
watchMfaStep();
} else await notifyBrowserLogin();
+
await page.waitForURL(URL_CLAIM);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
}
- user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin
+
+ user = await page.locator('egs-navigation').getAttribute('displayname');
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
- // waiting for timeout; alternative would be waiting for "coming soon"
- // 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);
+ // Get free games
+ const freeGames = await getAllFreeGames();
+ console.log('Free games:', freeGames.map(g => g.productName));
- for (const url of urls) {
+ // Generate checkout link for all free games (available for all games)
+ const checkoutUrl = freeGames.length > 0 ? generateCheckoutUrl(freeGames) : null;
+ if (checkoutUrl) {
+ console.log('Generated checkout URL:', checkoutUrl);
+
+ // Send notification with checkout link
+ await notify(`epic-games (${user}):
Free games available!
Click here to claim: ${checkoutUrl}`);
+ }
+
+ // Also save to database for reference
+ freeGames.forEach(game => {
+ const purchaseUrl = `https://store.epicgames.com/${game.productSlug}`;
+ db.data[user][game.offerId] ||= {
+ title: game.productName,
+ time: datetime(),
+ url: purchaseUrl,
+ checkoutUrl: checkoutUrl || purchaseUrl
+ };
+ });
+
+ // Claim each game individually (for detailed tracking)
+ for (const game of freeGames) {
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
+
+ const purchaseUrl = `https://store.epicgames.com/${game.productSlug}`;
+ await page.goto(purchaseUrl);
+
+ const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first();
await purchaseBtn.waitFor();
- const btnText = (await purchaseBtn.innerText()).toLowerCase(); // barrier to block until page is loaded
+ const btnText = (await purchaseBtn.innerText()).toLowerCase();
// 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');
+ console.error(' Got "To continue, please provide your date of birth"');
await page.locator('#month_toggle').click();
await page.locator('#month_menu li:has-text("01")').click();
await page.locator('#day_toggle').click();
@@ -196,66 +426,49 @@ try {
}
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') {
+ const existedInDb = db.data[user][game.offerId];
+ db.data[user][game.offerId] ||= { title, time: datetime(), url: purchaseUrl, checkoutUrl: checkoutUrl };
+ console.log('Current free game:', chalk.blue(title));
+
+ const notify_game = { title, url: purchaseUrl, status: 'failed' };
+ notify_games.push(notify_game);
+
+ if (btnText == 'in library' || btnText == 'owned') {
console.log(' Already in library! Nothing to claim.');
- if (!existedInDb) await notify(`Game already in library: ${url}`);
+ if (!existedInDb) await notify(`Game already in library: ${purchaseUrl}`);
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
+ db.data[user][game.offerId].status ||= 'existed';
+ if (db.data[user][game.offerId].status.startsWith('failed')) db.data[user][game.offerId].status = 'manual';
} 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';
- // if base game is free, add to queue as well
- 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")');
- // re-add original add-on to queue after base game
- urls.push(baseUrl, url); // add base game to the list of games to claim and re-add add-on itself
- } else { // GET
+ db.data[user][game.offerId].status ||= 'failed:requires-base-game';
+ } else {
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
+ await purchaseBtn.click({ delay: 11 });
- // Accept End User License Agreement (only needed once)
- const acceptEulaIfShown = async () => {
- try {
- await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 });
- 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 {
- return;
- }
- };
- acceptEulaIfShown();
+ // Accept EULA if shown
+ try {
+ await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 });
+ console.log(' Accept End User License Agreement');
+ await page.locator('input#agree').check();
+ await page.locator('button:has-text("Accept")').click();
+ } catch {
+ // EULA not shown
+ }
- // it then creates an iframe for the purchase
await page.waitForSelector('#webPurchaseContainer iframe');
const iframe = page.frameLocator('#webPurchaseContainer iframe');
- // skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46
+
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';
+ db.data[user][game.offerId].status = notify_game.status = 'unavailable-in-region';
if (cfg.time) console.timeEnd('claim game');
continue;
}
@@ -283,75 +496,77 @@ try {
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")');
- const acceptIfRequired = async () => {
- try {
- await btnAgree.waitFor({ timeout: 10000 });
- await btnAgree.click();
- } catch {
- return;
- }
- }; // EU: wait for and click 'I Agree'
- acceptIfRequired();
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 watchCaptchaChallenge = async () => {
- try {
- await captcha.waitFor({ timeout: 10000 });
- 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 for.\nGame link: ${url}`);
- } catch {
- return;
- }
- }; // may time out if not shown
- const watchCaptchaFailure = async () => {
- try {
- await iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor({ timeout: 10000 });
- console.error(' Failed to challenge captcha, please try again later.');
- await notify('epic-games: failed to challenge captcha. Please check.');
- } catch {
- return;
- }
- };
- watchCaptchaChallenge();
- watchCaptchaFailure();
+ await btnAgree.waitFor({ timeout: 10000 });
+ await btnAgree.click();
+ } catch {
+ // EU: wait for and click 'I Agree'
+ }
+
+ try {
await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' });
- db.data[user][game_id].status = 'claimed';
- db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
+ db.data[user][game.offerId].status = 'claimed';
+ db.data[user][game.offerId].time = datetime();
console.log(' Claimed successfully!');
- // context.setDefaultTimeout(cfg.timeout);
+
+ // Save device auth if we got a new token
+ const cookies = await context.cookies();
+ const bearerCookie = cookies.find(c => c.name === 'EPIC_BEARER_TOKEN');
+ if (bearerCookie?.value) {
+ await saveDeviceAuth(bearerCookie.value, 'refresh_token_placeholder', new Date(Date.now() + 86400000).toISOString());
+ }
} 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`);
+ const p = screenshot('failed', `${game.offerId}_${filenamify(datetime())}.png`);
await page.screenshot({ path: p, fullPage: true });
- db.data[user][game_id].status = 'failed';
+ db.data[user][game.offerId].status = 'failed';
}
- notify_game.status = db.data[user][game_id].status; // claimed or failed
+ notify_game.status = db.data[user][game.offerId].status;
- const p = screenshot(`${game_id}.png`);
- if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
+ const p = screenshot(`${game.offerId}.png`);
+ if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false });
}
+
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()?
+ console.error(error);
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'
+ await db.write();
+
+ // Save cookies
+ const cookies = await context.cookies();
+ // Convert cookies to EpicCookie format for setPuppeteerCookies
+ const epicCookies = cookies.map(c => ({
+ domain: c.domain,
+ hostOnly: !c.domain.startsWith('.'),
+ httpOnly: c.httpOnly,
+ name: c.name,
+ path: c.path,
+ sameSite: c.sameSite === 'Lax' ? 'no_restriction' : 'unspecified',
+ secure: c.secure,
+ session: !c.expires,
+ storeId: '0',
+ value: c.value,
+ id: 0,
+ expirationDate: c.expires ? Math.floor(c.expires) : undefined,
+ }));
+ await setPuppeteerCookies(cfg.eg_email || 'default', epicCookies);
+
+ if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) {
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();
diff --git a/package-lock.json b/package-lock.json
index 1f14566..903bc52 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,7 +22,8 @@
},
"devDependencies": {
"@stylistic/eslint-plugin-js": "^4.2.0",
- "eslint": "^9.26.0"
+ "eslint": "^9.26.0",
+ "typescript": "^5.9.3"
},
"engines": {
"node": ">=17"
@@ -2876,6 +2877,20 @@
"node": ">= 0.6"
}
},
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -4863,6 +4878,12 @@
"mime-types": "^3.0.0"
}
},
+ "typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true
+ },
"universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
diff --git a/package.json b/package.json
index cf2b41b..8944516 100644
--- a/package.json
+++ b/package.json
@@ -20,12 +20,12 @@
"node": ">=17"
},
"dependencies": {
+ "axios": "^1.7.9",
"chalk": "^5.4.1",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"enquirer": "^2.4.1",
"fingerprint-injector": "^2.1.66",
- "axios": "^1.7.9",
"lowdb": "^7.0.1",
"otplib": "^12.0.1",
"playwright-firefox": "^1.52.0",
@@ -33,6 +33,7 @@
},
"devDependencies": {
"@stylistic/eslint-plugin-js": "^4.2.0",
- "eslint": "^9.26.0"
+ "eslint": "^9.26.0",
+ "typescript": "^5.9.3"
}
}
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..9985af6
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,43 @@
+// Epic Games API Constants
+// Based on https://github.com/claabs/epicgames-freegames-node
+
+export const EPIC_CLIENT_ID = '875a3b57d3a640a6b7f9b4e883463ab4';
+export const CSRF_ENDPOINT = 'https://www.epicgames.com/id/api/csrf';
+export const ACCOUNT_CSRF_ENDPOINT = 'https://www.epicgames.com/account/v2/refresh-csrf';
+export const ACCOUNT_SESSION_ENDPOINT = 'https://www.epicgames.com/account/personal';
+export const LOGIN_ENDPOINT = 'https://www.epicgames.com/id/api/login';
+export const REDIRECT_ENDPOINT = 'https://www.epicgames.com/id/api/redirect';
+export const GRAPHQL_ENDPOINT = 'https://store.epicgames.com/graphql';
+export const ARKOSE_BASE_URL = 'https://epic-games-api.arkoselabs.com';
+export const CHANGE_EMAIL_ENDPOINT = 'https://www.epicgames.com/account/v2/api/email/change';
+export const USER_INFO_ENDPOINT = 'https://www.epicgames.com/account/v2/personal/ajaxGet';
+export const RESEND_VERIFICATION_ENDPOINT = 'https://www.epicgames.com/account/v2/resendEmailVerification';
+export const REPUTATION_ENDPOINT = 'https://www.epicgames.com/id/api/reputation';
+export const STORE_CONTENT = 'https://store-content-ipv4.ak.epicgames.com/api/en-US/content';
+export const EMAIL_VERIFY = 'https://www.epicgames.com/id/api/email/verify';
+export const SETUP_MFA = 'https://www.epicgames.com/account/v2/security/ajaxUpdateTwoFactorAuthSettings';
+export const FREE_GAMES_PROMOTIONS_ENDPOINT = 'https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions';
+export const STORE_HOMEPAGE = 'https://store.epicgames.com/';
+export const STORE_HOMEPAGE_EN = `${STORE_HOMEPAGE}en-US/`;
+export const STORE_CART_EN = `${STORE_HOMEPAGE}en-US/cart`;
+export const ORDER_CONFIRM_ENDPOINT = 'https://payment-website-pci.ol.epicgames.com/purchase/confirm-order';
+export const ORDER_PREVIEW_ENDPOINT = 'https://payment-website-pci.ol.epicgames.com/purchase/order-preview';
+export const EPIC_PURCHASE_ENDPOINT = 'https://www.epicgames.com/store/purchase';
+export const MFA_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/api/login/mfa';
+export const UNREAL_SET_SID_ENDPOINT = 'https://www.unrealengine.com/id/api/set-sid';
+export const TWINMOTION_SET_SID_ENDPOINT = 'https://www.twinmotion.com/id/api/set-sid';
+export const CLIENT_REDIRECT_ENDPOINT = `https://www.epicgames.com/id/api/client/${EPIC_CLIENT_ID}`;
+export const AUTHENTICATE_ENDPOINT = `https://www.epicgames.com/id/api/authenticate`;
+export const LOCATION_ENDPOINT = `https://www.epicgames.com/id/api/location`;
+export const PHASER_F_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/phaser/f';
+export const PHASER_BATCH_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/phaser/batch';
+export const TALON_IP_ENDPOINT = 'https://talon-service-v4-prod.ak.epicgames.com/v1/init/ip';
+export const TALON_INIT_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/init';
+export const TALON_EXECUTE_ENDPOINT = 'https://talon-service-v4-prod.ak.epicgames.com/v1/init/execute';
+export const TALON_WEBSITE_BASE = 'https://talon-website-prod.ak.epicgames.com';
+export const TALON_REFERRER = 'https://talon-website-prod.ak.epicgames.com/challenge?env=prod&flow=login_prod&origin=https%3A%2F%2Fwww.epicgames.com';
+export const ACCOUNT_OAUTH_TOKEN = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token';
+export const ACCOUNT_OAUTH_DEVICE_AUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization';
+export const ID_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/login';
+export const EULA_AGREEMENTS_ENDPOINT = 'https://eulatracking-public-service-prod-m.ol.epicgames.com/eulatracking/api/public/agreements';
+export const REQUIRED_EULAS = ['epicgames_privacy_policy_no_table', 'egstore'];
diff --git a/src/cookie.ts b/src/cookie.ts
new file mode 100644
index 0000000..33e7f69
--- /dev/null
+++ b/src/cookie.ts
@@ -0,0 +1,171 @@
+// Cookie management for Epic Games
+// Based on https://github.com/claabs/epicgames-freegames-node
+
+import fs from 'node:fs';
+import path from 'node:path';
+import tough from 'tough-cookie';
+import { filenamify } from './util.js';
+import { dataDir } from './util.js';
+
+const CONFIG_DIR = dataDir('config');
+const DEFAULT_COOKIE_NAME = 'default';
+
+// Ensure config directory exists
+if (!fs.existsSync(CONFIG_DIR)) {
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
+}
+
+function getCookiePath(username) {
+ const fileSafeUsername = filenamify(username);
+ const cookieFilename = path.join(CONFIG_DIR, `${fileSafeUsername}-cookies.json`);
+ return cookieFilename;
+}
+
+// Cookie whitelist - only these cookies are stored
+const COOKIE_WHITELIST = ['EPIC_SSO_RM', 'EPIC_SESSION_AP', 'EPIC_DEVICE'];
+
+// Cookie jar cache
+const cookieJars = new Map();
+
+function getCookieJar(username) {
+ let cookieJar = cookieJars.get(username);
+ if (cookieJar) {
+ return cookieJar;
+ }
+ const cookieFilename = getCookiePath(username);
+ cookieJar = new tough.CookieJar();
+ cookieJars.set(username, cookieJar);
+ return cookieJar;
+}
+
+// Convert EditThisCookie format to tough-cookie file store format
+export function editThisCookieToToughCookieFileStore(etc) {
+ const tcfs = {};
+
+ etc.forEach((etcCookie) => {
+ const domain = etcCookie.domain.replace(/^\./, '');
+ const expires = etcCookie.expirationDate
+ ? new Date(etcCookie.expirationDate * 1000).toISOString()
+ : undefined;
+ const { path: cookiePath, name } = etcCookie;
+
+ if (COOKIE_WHITELIST.includes(name)) {
+ const temp = {
+ [domain]: {
+ [cookiePath]: {
+ [name]: {
+ key: name,
+ value: etcCookie.value,
+ expires,
+ domain,
+ path: cookiePath,
+ secure: etcCookie.secure,
+ httpOnly: etcCookie.httpOnly,
+ hostOnly: etcCookie.hostOnly,
+ },
+ },
+ },
+ };
+ Object.assign(tcfs, temp);
+ }
+ });
+
+ return tcfs;
+}
+
+// Get cookies as simple object
+export function getCookies(username) {
+ const cookieJar = getCookieJar(username);
+ const cookies = cookieJar.toJSON()?.cookies || [];
+ return cookies.reduce((accum, cookie) => {
+ if (cookie.key && cookie.value) {
+ return { ...accum, [cookie.key]: cookie.value };
+ }
+ return accum;
+ }, {});
+}
+
+// Get raw cookies in tough-cookie file store format
+export async function getCookiesRaw(username) {
+ const cookieFilename = getCookiePath(username);
+ try {
+ const existingCookies = JSON.parse(fs.readFileSync(cookieFilename, 'utf8'));
+ return existingCookies;
+ } catch {
+ return {};
+ }
+}
+
+// Set cookies from Playwright/Cookie format
+export async function setPuppeteerCookies(username, newCookies) {
+ const cookieJar = getCookieJar(username);
+
+ for (const cookie of newCookies) {
+ const domain = cookie.domain.replace(/^\./, '');
+ const tcfsCookie = new tough.Cookie({
+ key: cookie.name,
+ value: cookie.value,
+ expires: cookie.expires ? new Date(cookie.expires * 1000) : undefined,
+ domain,
+ path: cookie.path,
+ secure: cookie.secure,
+ httpOnly: cookie.httpOnly,
+ hostOnly: !cookie.domain.startsWith('.'),
+ });
+
+ try {
+ await cookieJar.setCookie(tcfsCookie, `https://${domain}`);
+ } catch (err) {
+ console.error('Error setting cookie:', err);
+ }
+ }
+}
+
+// Delete cookies for a user
+export async function deleteCookies(username) {
+ const cookieFilename = getCookiePath(username || DEFAULT_COOKIE_NAME);
+ try {
+ fs.unlinkSync(cookieFilename);
+ } catch {
+ // File doesn't exist, that's fine
+ }
+}
+
+// Check if user has a valid cookie
+export async function userHasValidCookie(username, cookieName) {
+ const cookieFilename = getCookiePath(username);
+ try {
+ const fileExists = fs.existsSync(cookieFilename);
+ if (!fileExists) return false;
+
+ const cookieData = JSON.parse(fs.readFileSync(cookieFilename, 'utf8'));
+ const rememberCookieExpireDate = cookieData['epicgames.com']?.['/']?.[cookieName]?.expires;
+ if (!rememberCookieExpireDate) return false;
+
+ return new Date(rememberCookieExpireDate) > new Date();
+ } catch {
+ return false;
+ }
+}
+
+// Convert imported cookies (EditThisCookie format)
+export async function convertImportCookies(username) {
+ const cookieFilename = getCookiePath(username);
+ const fileExists = fs.existsSync(cookieFilename);
+
+ if (fileExists) {
+ try {
+ const cookieData = fs.readFileSync(cookieFilename, 'utf8');
+ const cookieTest = JSON.parse(cookieData);
+
+ if (Array.isArray(cookieTest)) {
+ // Convert from EditThisCookie format
+ const tcfsCookies = editThisCookieToToughCookieFileStore(cookieTest);
+ fs.writeFileSync(cookieFilename, JSON.stringify(tcfsCookies, null, 2));
+ }
+ } catch {
+ // Invalid format, delete file
+ fs.unlinkSync(cookieFilename);
+ }
+ }
+}
diff --git a/src/device-auths.ts b/src/device-auths.ts
new file mode 100644
index 0000000..2fe0f88
--- /dev/null
+++ b/src/device-auths.ts
@@ -0,0 +1,38 @@
+// Device authentication management for Epic Games
+// Based on https://github.com/claabs/epicgames-freegames-node
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { dataDir } from './util.js';
+
+const CONFIG_DIR = dataDir('config');
+const deviceAuthsFilename = path.join(CONFIG_DIR, 'device-auths.json');
+
+// Ensure config directory exists
+if (!fs.existsSync(CONFIG_DIR)) {
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
+}
+
+export async function getDeviceAuths() {
+ try {
+ const deviceAuths = JSON.parse(fs.readFileSync(deviceAuthsFilename, 'utf-8'));
+ return deviceAuths;
+ } catch {
+ return undefined;
+ }
+}
+
+export async function getAccountAuth(account) {
+ const deviceAuths = await getDeviceAuths();
+ return deviceAuths?.[account];
+}
+
+export async function writeDeviceAuths(deviceAuths) {
+ fs.writeFileSync(deviceAuthsFilename, JSON.stringify(deviceAuths, null, 2));
+}
+
+export async function setAccountAuth(account, accountAuth) {
+ const existingDeviceAuths = (await getDeviceAuths()) ?? {};
+ existingDeviceAuths[account] = accountAuth;
+ await writeDeviceAuths(existingDeviceAuths);
+}