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();
};