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 { 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 } from './src/cloudflare.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'); // 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; }; // Check if logged in const isLoggedIn = async page => { try { // Wait for egs-navigation element to be present await page.locator('egs-navigation').waitFor({ state: 'attached', timeout: 5000 }); const attr = await page.locator('egs-navigation').getAttribute('isloggedin'); const isLogged = attr === 'true'; L.trace({ isLogged, attr }, 'Login status check'); return isLogged; } catch (err) { L.trace({ err: err.message }, 'Login status check failed'); return false; } }; // Browser-based login with FlareSolverr support const attemptBrowserLogin = async (page, context) => { if (!cfg.eg_email || !cfg.eg_password) { L.warn('No email/password configured'); return false; } try { L.info({ email: cfg.eg_email }, 'Attempting browser login'); console.log('📝 Logging in with email/password...'); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded', timeout: cfg.login_timeout, }); // Check for Cloudflare and solve if needed await page.waitForTimeout(2000); // Let page stabilize try { if (await isCloudflareChallenge(page)) { L.warn('Cloudflare challenge detected during login'); console.log('☁️ Cloudflare detected, attempting to solve...'); if (cfg.flaresolverr_url) { const solution = await solveCloudflare(page, URL_LOGIN); if (solution) { console.log('✅ Cloudflare solved by FlareSolverr'); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); } else { console.log('⚠️ FlareSolverr failed, may need manual solve'); } } else { console.log('⚠️ FlareSolverr not configured, may need manual solve'); } } } catch (err) { L.warn({ err: err.message }, 'Cloudflare check failed'); } const emailField = page.locator('input[name="email"], input#email, input[aria-label="Sign in with email"]').first(); const passwordField = page.locator('input[name="password"], input#password').first(); const continueBtn = page.locator('button:has-text("Continue"), button#continue, button[type="submit"]').first(); // Step 1: Email + continue if (await emailField.count() > 0) { await emailField.fill(cfg.eg_email); await continueBtn.click(); await page.waitForTimeout(1000); } // Step 2: Password + submit try { await passwordField.waitFor({ timeout: cfg.login_visible_timeout }); await passwordField.fill(cfg.eg_password); const rememberMe = page.locator('input[name="rememberMe"], #rememberMe').first(); if (await rememberMe.count() > 0) await rememberMe.check(); await continueBtn.click(); } catch (err) { L.warn({ err: err.message }, 'Password field not found, may already be logged in'); return await isLoggedIn(page); } // MFA step try { await page.waitForURL('**/id/login/mfa**', { timeout: 15000 }); console.log('🔐 2FA detected'); 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!', }); const codeInputs = page.locator('input[name^="code-input"]'); if (await codeInputs.count() > 0) { const digits = otp.toString().split(''); for (let i = 0; i < digits.length; i++) { const input = codeInputs.nth(i); await input.fill(digits[i]); } } else { await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); } await continueBtn.click(); } catch { // No MFA required L.trace('No MFA required'); } // Wait for successful login try { L.trace('Waiting for navigation to free-games page'); await page.waitForURL('**/free-games**', { timeout: cfg.login_timeout }); // Give page time to fully load and egs-navigation to update L.trace('Waiting for page to stabilize'); await page.waitForTimeout(3000); // Check multiple times to ensure stable login state for (let i = 0; i < 3; i++) { const logged = await isLoggedIn(page); if (logged) { L.info('Login confirmed'); return true; } L.trace({ attempt: i + 1 }, 'Login not yet confirmed, retrying'); await page.waitForTimeout(2000); } L.warn('Login URL reached but login status not confirmed'); return false; } catch (err) { L.warn({ err: err.message }, 'Login URL timeout, checking if logged in anyway'); await page.waitForTimeout(3000); return await isLoggedIn(page); } } catch (err) { L.error({ err }, 'Browser login failed'); return false; } }; // Ensure user is logged in const ensureLoggedIn = async (page, context) => { L.info('Checking login status'); // Check if already logged in (from saved cookies) if (await isLoggedIn(page)) { const displayName = await page.locator('egs-navigation').getAttribute('displayname'); L.info({ user: displayName }, 'Already logged in (from cookies)'); console.log(`✅ Already signed in as ${displayName}`); return displayName; } L.info('Not logged in, attempting login'); console.log('📝 Not logged in, starting login process...'); // Try browser login with email/password const logged = await attemptBrowserLogin(page, context); if (!logged) { L.error('Browser login failed'); console.log('❌ Automatic login failed.'); // If headless, we can't do manual login if (cfg.headless) { const msg = 'Login failed in headless mode. Run with SHOW=1 to login manually via noVNC.'; console.error(msg); await notify(`epic-games: ${msg}`); throw new Error('Login failed, headless mode'); } // Wait for manual login in visible browser console.log('⏳ Waiting for manual login in browser...'); console.log(` Open noVNC at: http://localhost:${cfg.novnc_port || '6080'}`); await notify( 'epic-games: Manual login required!
' + `Open noVNC: http://localhost:${cfg.novnc_port || '6080'}
` + `Login timeout: ${cfg.login_timeout / 1000}s`, ); const maxWait = cfg.login_timeout; const checkInterval = 5000; let waited = 0; let loginConfirmed = false; while (waited < maxWait) { await page.waitForTimeout(checkInterval); waited += checkInterval; // Check multiple times for stable state for (let i = 0; i < 2; i++) { if (await isLoggedIn(page)) { // Confirm it's stable await page.waitForTimeout(2000); if (await isLoggedIn(page)) { loginConfirmed = true; break; } } } if (loginConfirmed) { L.info('Manual login detected and confirmed'); console.log('✅ Manual login detected!'); break; } // Progress update every 30 seconds if (waited % 30000 === 0) { const remaining = Math.round((maxWait - waited) / 1000); console.log(` Still waiting... ${remaining}s remaining`); } } if (!loginConfirmed && !await isLoggedIn(page)) { 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}`); return displayName; }; // Save cookies to file const saveCookies = async context => { try { const cookies = await context.cookies(); writeFileSync(COOKIES_PATH, JSON.stringify(cookies, null, 2)); L.trace({ cookieCount: cookies.length }, 'Cookies saved'); } catch (err) { L.warn({ err: err.message }, 'Failed to save cookies'); } }; // Load cookies from file const loadCookies = async context => { if (!existsSync(COOKIES_PATH)) { L.trace('No saved cookies found'); return false; } try { const cookies = JSON.parse(readFileSync(COOKIES_PATH, 'utf8')); await context.addCookies(cookies); L.info({ cookieCount: cookies.length }, 'Loaded saved cookies'); console.log('✅ Loaded saved cookies'); return true; } catch (err) { L.warn({ err: err.message }, 'Failed to load cookies'); return false; } }; // Main function to claim Epic Games export const claimEpicGamesNew = async () => { console.log('🚀 Starting Epic Games claimer (new mode)'); 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 }); let user; try { // Load saved cookies await loadCookies(context); await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // Ensure logged in user = await ensureLoggedIn(page, context); db.data[user] ||= {}; // Fetch free games const freeGames = await fetchFreeGamesAPI(page); console.log('🎮 Free games available:', freeGames.length); if (freeGames.length > 0) { console.log(' ' + freeGames.map(g => g.title).join(', ')); } // Claim each game for (const game of freeGames) { if (cfg.time) console.time('claim game'); 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, }; if (cfg.time) console.timeEnd('claim game'); } // Save cookies for next run await saveCookies(context); console.log('✅ Epic Games claimer completed'); } catch (error) { process.exitCode ||= 1; console.error('--- Exception:'); console.error(error); if (error.message && process.exitCode !== 130) { notify(`epic-games (new) failed: ${error.message.split('\n')[0]}`); } } finally { await db.write(); // Send notification if games were claimed or failed if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) { notify(`epic-games (${user || 'unknown'}):
${html_game_list(notify_games)}`); } if (cfg.debug && context) { console.log('Cookies:', JSON.stringify(await context.cookies(), null, 2)); } if (page.video()) { console.log('Recorded video:', await page.video().path()); } await context.close(); } };