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 { setPuppeteerCookies } from './src/cookie.js'; import { getAccountAuth, setAccountAuth } 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 (GraphQL API mode)'); if (cfg.eg_mode === 'new') { const { claimEpicGamesNew } = await import('./epic-claimer-new.js'); await claimEpicGamesNew(); process.exit(0); } 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);'); } 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', 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); await stealth(context); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); 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 if (cfg.debug) { const debugInfo = await page.evaluate(() => { const { width, height, availWidth, availHeight } = window.screen; return { screen: { width, height, availWidth, availHeight }, userAgent: navigator.userAgent, }; }); console.debug(debugInfo); } if (cfg.debug_network) { 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; // 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 (unused - kept for reference) 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 /** @type {import('playwright-firefox').Cookie} */ const bearerCookie = { 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 - unused) 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: '/' }, { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, ]); await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); if (cfg.time) console.timeEnd('startup'); if (cfg.time) console.time('login'); // Try device auth first (unused - kept for reference) 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. 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); 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(); process.exit(1); } }; 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.'); await notify('epic-games: captcha encountered; please solve manually in browser.'); await page.waitForTimeout(cfg.login_timeout); continue; } const email = cfg.eg_email || await prompt({ message: 'Enter email' }); if (email) { await page.fill('#email', email); const password = cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }); if (password) { 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 { await error.waitFor({ timeout: 15000 }); console.error('Login error:', await error.innerText()); console.log('Please login in the browser!'); } catch { return; } }; const watchMfaStep = async () => { try { await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); 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'); console.log(`Signed in as ${user}`); db.data[user] ||= {}; if (cfg.time) console.timeEnd('login'); if (cfg.time) console.time('claim all games'); // Get free games const freeGames = await getAllFreeGames(); console.log('Free games:', freeGames.map(g => g.productName)); // 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'); 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(); // 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"'); 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; 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 ', ''); } else { title = await page.locator('h1').first().innerText(); } 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: ${purchaseUrl}`); notify_game.status = 'existed'; 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.offerId].status ||= 'failed:requires-base-game'; } else { console.log(' Not in library yet! Click', btnText); await purchaseBtn.click({ delay: 11 }); // 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 } await page.waitForSelector('#webPurchaseContainer iframe'); const iframe = page.frameLocator('#webPurchaseContainer iframe'); 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.offerId].status = notify_game.status = 'unavailable-in-region'; if (cfg.time) console.timeEnd('claim game'); continue; } const enterParentalPinIfNeeded = async () => { try { await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 }); 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 { return; } }; enterParentalPinIfNeeded(); 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; } await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); const btnAgree = iframe.locator('button:has-text("I Accept")'); try { 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.offerId].status = 'claimed'; db.data[user][game.offerId].time = datetime(); console.log(' Claimed successfully!'); // 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! To avoid captchas try to get a new IP address.'); const p = screenshot('failed', `${game.offerId}_${filenamify(datetime())}.png`); await page.screenshot({ path: p, fullPage: true }); db.data[user][game.offerId].status = 'failed'; } notify_game.status = db.data[user][game.offerId].status; 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); if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`); } finally { 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();