import { firefox } from 'playwright-firefox'; import { authenticator } from 'otplib'; import path from 'node:path'; import { existsSync, readFileSync, writeFileSync } 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'; import { solveCloudflare, isCloudflareChallenge, waitForCloudflareSolved } from './src/cloudflare.js'; import { getValidAccessToken, startDeviceAuthLogin, completeDeviceAuthLogin, refreshDeviceAuth } from './src/device-login.js'; import logger from './src/logger.js'; const L = logger.child({ module: 'epic-claimer-new' }); // Fetch Free Games from API using page.evaluate (browser context) const fetchFreeGamesAPI = async page => { const response = await page.evaluate(async () => { const resp = await fetch(FREE_GAMES_PROMOTIONS_ENDPOINT + '?locale=en-US&country=US&allowCountries=US,DE,AT,CH,GB'); return await resp.json(); }); return response?.Catalog?.searchStore?.elements ?.filter(g => g.promotions?.promotionalOffers?.[0]) ?.map(g => { const offer = g.promotions.promotionalOffers[0].promotionalOffers[0]; const mapping = g.catalogNs?.mappings?.[0]; return { title: g.title, namespace: mapping?.id || g.productSlug, pageSlug: mapping?.pageSlug || g.urlSlug, offerId: offer?.offerId, }; }) || []; }; 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; const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json'); const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN'; // Claim game function const claimGame = async (page, game) => { const purchaseUrl = `https://store.epicgames.com/${game.pageSlug}`; console.log(`🎮 ${game.title} → ${purchaseUrl}`); const notify_game = { title: game.title, url: purchaseUrl, status: 'failed' }; await page.goto(purchaseUrl, { waitUntil: 'networkidle' }); const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first(); await purchaseBtn.waitFor({ timeout: cfg.timeout }); const btnText = (await purchaseBtn.textContent() || '').toLowerCase(); if (btnText.includes('library') || btnText.includes('owned')) { notify_game.status = 'existed'; return notify_game; } if (cfg.dryrun) { notify_game.status = 'skipped'; return notify_game; } await purchaseBtn.click({ delay: 50 }); try { await page.waitForSelector('#webPurchaseContainer iframe', { timeout: 15000 }); const iframe = page.frameLocator('#webPurchaseContainer iframe'); if (cfg.eg_parentalpin) { try { await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 }); await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin); await iframe.locator('button:has-text("Continue")').click({ delay: 11 }); } catch { // no PIN needed } } await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); try { await iframe.locator('button:has-text("I Accept")').click({ timeout: 5000 }); } catch { // not required } await page.locator('text=Thanks for your order!').waitFor({ state: 'attached', timeout: cfg.timeout }); notify_game.status = 'claimed'; } catch (e) { notify_game.status = 'failed'; const screenshotPath = path.resolve(cfg.dir.screenshots, 'epic-games', 'failed', `${game.offerId || game.pageSlug}_${filenamify(datetime())}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => { }); console.error(' Failed to claim:', e.message); } return notify_game; }; // Ensure user is logged in using OAuth Device Flow (bypasses Cloudflare) const ensureLoggedIn = async (page, context) => { const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true'; const user = cfg.eg_email || 'default'; L.info('Attempting OAuth Device Flow login (Cloudflare bypass)'); // Step 1: Try to get valid access token from stored device auth let accessToken = await getValidAccessToken(user); if (accessToken) { L.info('Using existing valid access token'); } else { // Step 2: No valid token - start new device auth flow L.info('No valid token found, starting device auth flow'); await notify( 'epic-games: Login required! Visit the link to authorize: DEVICE_AUTH_PENDING', ); try { const { verificationUrl, userCode, expiresAt } = await startDeviceAuthLogin(user); // Notify user with verification URL const timeRemaining = Math.round((expiresAt - Date.now()) / 60000); await notify( `epic-games: Click here to login:
${verificationUrl}
` + `User Code: ${userCode}
` + `Expires in: ${timeRemaining} minutes`, ); console.log(`🔐 Device Auth URL: ${verificationUrl}`); console.log(`🔐 User Code: ${userCode}`); console.log(`⏰ Expires in: ${timeRemaining} minutes`); // Wait for user to complete authorization const interval = 5; // poll every 5 seconds const authToken = await completeDeviceAuthLogin( // We need to get device_code from the startDeviceAuthLogin response // For now, we'll re-fetch it verificationUrl.split('userCode=')[1]?.split('&')[0] || '', expiresAt, interval, ); accessToken = authToken.access_token; L.info('Device auth completed successfully'); } catch (err) { L.error({ err }, 'Device auth flow failed'); await notify('epic-games: Device auth failed. Please login manually in browser.'); throw err; } } // Step 3: Apply bearer token to browser L.info('Applying access token to browser'); /** @type {import('playwright-firefox').Cookie} */ const bearerCookie = { name: 'EPIC_BEARER_TOKEN', value: accessToken, 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', timeout: cfg.timeout }); // Verify login worked const loggedIn = await isLoggedIn(); if (!loggedIn) { L.warn('Bearer token did not result in logged-in state, may need manual login'); // Fall back to manual browser login console.log('Token-based login did not work. Please login manually in the browser.'); await notify('epic-games: Please login manually in browser.'); if (cfg.headless) { console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); await context.close(); process.exit(1); } await page.waitForTimeout(cfg.login_timeout); if (!await isLoggedIn()) { throw new Error('Manual login did not complete within timeout'); } } const displayName = await page.locator('egs-navigation').getAttribute('displayname'); L.info({ user: displayName }, 'Successfully logged in'); console.log(`✅ Signed in as ${displayName}`); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); return displayName; }; // Main function to claim Epic Games export const claimEpicGamesNew = async () => { console.log('Starting Epic Games claimer (new mode, browser-based)'); const db = await jsonDb('epic-games.json', {}); const notify_games = []; 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, }); 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 }); // Load cookies from file if available if (existsSync(COOKIES_PATH)) { try { const cookies = JSON.parse(readFileSync(COOKIES_PATH, 'utf8')); await context.addCookies(cookies); console.log('✅ Cookies loaded from file'); } catch (error) { console.error('Failed to load cookies:', error); } } // Use device auths if available (from legacy mode) const deviceAuths = await getAccountAuth(); if (deviceAuths && cfg.eg_email) { const accountAuth = deviceAuths[cfg.eg_email]; if (accountAuth) { console.log('🔄 Reusing device auth from legacy mode'); const cookies = [ { name: 'EPIC_SSO_RM', value: accountAuth.deviceAuth?.refreshToken || '', domain: '.epicgames.com', path: '/' }, { name: 'EPIC_DEVICE', value: accountAuth.deviceAuth?.deviceId || '', domain: '.epicgames.com', path: '/' }, { name: 'EPIC_SESSION_AP', value: accountAuth.deviceAuth?.accountId || '', domain: '.epicgames.com', path: '/' }, ]; await context.addCookies(cookies); console.log('✅ Device auth cookies loaded'); } } let user; try { await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); user = await ensureLoggedIn(page, context); db.data[user] ||= {}; const freeGames = await fetchFreeGamesAPI(page); console.log('Free games via API:', freeGames.map(g => g.pageSlug)); for (const game of freeGames) { const result = await claimGame(page, game); notify_games.push(result); db.data[user][game.offerId || game.pageSlug] = { title: game.title, time: datetime(), url: `https://store.epicgames.com/${game.pageSlug}`, status: result.status, }; } // Save cookies to file const cookies = await context.cookies(); writeFileSync(COOKIES_PATH, JSON.stringify(cookies, null, 2)); } catch (error) { process.exitCode ||= 1; console.error('--- Exception (new epic):'); console.error(error); if (error.message && process.exitCode !== 130) notify(`epic-games (new) failed: ${error.message.split('\n')[0]}`); } finally { await db.write(); if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) { notify(`epic-games (new ${user || 'unknown'}):
${html_game_list(notify_games)}`); } } if (cfg.debug && context) { console.log(JSON.stringify(await context.cookies(), null, 2)); } await context.close(); };