import axios from 'axios'; import { firefox } from 'playwright-firefox'; import { authenticator } from 'otplib'; import path from 'node:path'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; import { cfg } from './src/config.js'; const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json'); const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN'; const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); const fetchFreeGamesAPI = async () => { const resp = await axios.get('https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions', { params: { locale: 'en-US', country: 'US', allowCountries: 'US,DE,AT,CH,GB' }, }); return resp.data?.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 pollForTokens = async (deviceCode, maxAttempts = 30) => { for (let i = 0; i < maxAttempts; i++) { try { const response = await axios.post('https://api.epicgames.dev/epic/oauth/token', { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: deviceCode, client_id: '34a02cf8f4414e29b159cdd02e6184bd', }); if (response.data?.access_token) { console.log('✅ OAuth successful'); return response.data; } } catch (e) { if (e.response?.data?.error === 'authorization_pending') { await new Promise(r => setTimeout(r, 5000)); continue; } throw e; } } throw new Error('OAuth timeout'); }; const exchangeTokenForCookies = async accessToken => { const response = await axios.get('https://store.epicgames.com/', { headers: { Authorization: `bearer ${accessToken}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', }, }); const cookies = response.headers['set-cookie']?.map(cookie => { const [name, value] = cookie.split(';')[0].split('='); return { name, value, domain: '.epicgames.com', path: '/' }; }) || []; // also persist bearer token explicitly cookies.push({ name: BEARER_TOKEN_NAME, value: accessToken, domain: '.epicgames.com', path: '/' }); return cookies; }; const getValidAuth = async ({ otpKey, reuseCookies, cookiesPath }) => { if (reuseCookies && existsSync(cookiesPath)) { const cookies = JSON.parse(readFileSync(cookiesPath, 'utf8')); const bearerCookie = cookies.find(c => c.name === BEARER_TOKEN_NAME); if (bearerCookie?.value) { console.log('🔄 Reusing existing bearer token from cookies'); return { bearerToken: bearerCookie.value, cookies }; } } console.log('🔐 Starting fresh OAuth device flow (manual approval required)...'); let deviceResponse; try { deviceResponse = await axios.post('https://api.epicgames.dev/epic/oauth/deviceCode', { client_id: '34a02cf8f4414e29b159cdd02e6184bd', scope: 'account.basicprofile account.userentitlements', }); } catch (e) { console.error('Device code flow failed (fallback to manual login):', e.response?.status || e.message); return { bearerToken: null, cookies: [] }; } const { device_code, user_code, verification_uri_complete } = deviceResponse.data; console.log(`📱 Open: ${verification_uri_complete}`); console.log(`💳 Code: ${user_code}`); const tokens = await pollForTokens(device_code); if (otpKey) { const totpCode = authenticator.generate(otpKey); console.log(`🔑 TOTP Code (generated): ${totpCode}`); try { const refreshed = await axios.post('https://api.epicgames.dev/epic/oauth/token', { grant_type: 'refresh_token', refresh_token: tokens.refresh_token, code_verifier: totpCode, }); tokens.access_token = refreshed.data.access_token; } catch { // ignore if refresh fails; use original token } } const cookies = await exchangeTokenForCookies(tokens.access_token); writeFileSync(cookiesPath, JSON.stringify(cookies, null, 2)); console.log('💾 Cookies saved to', cookiesPath); return { bearerToken: tokens.access_token, cookies }; }; const ensureLoggedIn = async (page, context) => { const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true'; const attemptAutoLogin = async () => { // Epic login form if (!cfg.eg_email || !cfg.eg_password) return false; try { await page.goto('https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM, { waitUntil: 'domcontentloaded' }); const emailField = page.locator('input[name="email"], input#email'); const passwordField = page.locator('input[name="password"], input#password'); // Some flows pre-fill email and show only password field if (await emailField.count()) await emailField.fill(cfg.eg_email); await passwordField.waitFor({ timeout: cfg.login_visible_timeout }); await passwordField.fill(cfg.eg_password); await page.click('button[type="submit"]'); // MFA step try { await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); 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 { // no MFA } await page.waitForURL('**/free-games', { timeout: cfg.login_timeout }).catch(() => {}); return await isLoggedIn(); } catch { return false; } }; const isChallenge = async () => { const cfFrame = page.locator('iframe[title*="Cloudflare"], iframe[src*="challenges"]'); const cfText = page.locator('text=Verify you are human'); return await cfFrame.count() > 0 || await cfText.count() > 0; }; while (!await isLoggedIn()) { console.error('Not signed in anymore. Trying automatic login, otherwise please login in the browser.'); 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_CLAIM, { waitUntil: 'domcontentloaded' }); if (await isChallenge()) { console.warn('Cloudflare challenge detected. Solve the captcha in the browser (no automation).'); await notify('epic-games (new): Cloudflare challenge, please solve manually in browser.'); await page.waitForTimeout(cfg.login_timeout); continue; } const logged = await attemptAutoLogin(); if (logged) break; console.log('Waiting for manual login in the browser (cookies might be invalid).'); await notify('epic-games (new): please login in browser; cookies invalid or expired.'); 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); } const user = await page.locator('egs-navigation').getAttribute('displayname'); console.log(`Signed in as ${user}`); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); return user; }; 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 p = screenshot('failed', `${game.offerId || game.pageSlug}_${filenamify(datetime())}.png`); await page.screenshot({ path: p, fullPage: true }).catch(() => {}); console.error(' Failed to claim:', e.message); } return notify_game; }; export const claimEpicGamesNew = async () => { console.log('Starting Epic Games claimer (new mode, cookies + API)'); const db = await jsonDb('epic-games.json', {}); const notify_games = []; const freeGames = await fetchFreeGamesAPI(); console.log('Free games via API:', freeGames.map(g => g.pageSlug)); 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 }); let user; try { const auth = await getValidAuth({ email: cfg.eg_email, password: cfg.eg_password, otpKey: cfg.eg_otpkey, reuseCookies: true, cookiesPath: COOKIES_PATH, }); if (auth.cookies?.length) { await context.addCookies(auth.cookies); console.log('✅ Cookies loaded:', auth.cookies.length); } else { console.log('⚠️ No cookies loaded; using manual login via browser.'); } await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); user = await ensureLoggedIn(page, context); db.data[user] ||= {}; 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, }; } await writeFileSync(COOKIES_PATH, JSON.stringify(await context.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(); }; export default claimEpicGamesNew;