free-games-claimer/epic-claimer-new.js
root 5d41b323e5
All checks were successful
build-and-push / lint (push) Successful in 8s
build-and-push / sonar (push) Successful in 20s
build-and-push / docker (push) Successful in 11s
feat: Final hybrid login implementation (FlareSolverr + Cookie persistence)
- epic-claimer-new.js: Complete rewrite with practical approach
  - FlareSolverr integration for Cloudflare solving
  - Cookie persistence (saved to epic-cookies.json)
  - Auto-load cookies on startup (no login needed if valid)
  - Manual login fallback via noVNC if needed
  - Proper 2FA/OTP support
  - Better error handling and logging

- SETUP.md: Complete setup guide
  - Docker Compose examples
  - Environment variable reference
  - Troubleshooting section
  - 2FA setup instructions
  - Volume backup/restore

- README.md: Add reference to SETUP.md

- OAUTH_DEVICE_FLOW_ISSUE.md: Document why OAuth Device Flow doesn't work
  - Epic Games doesn't provide public device auth credentials
  - Client credentials flow requires registered app
  - Hybrid approach is the practical solution

How it works:
1. First run: Login via browser (FlareSolverr helps with Cloudflare)
2. Cookies saved to epic-cookies.json
3. Subsequent runs: Load cookies, no login needed
4. If cookies expire: Auto-fallback to login flow
5. Manual login via noVNC if automation fails

This is the approach used by all successful Epic Games claimer projects.
2026-03-08 14:52:13 +00:00

411 lines
14 KiB
JavaScript

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 {
const attr = await page.locator('egs-navigation').getAttribute('isloggedin');
return attr === 'true';
} catch {
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 {
await page.waitForURL('**/free-games**', { timeout: cfg.login_timeout });
L.info('Login successful');
return await isLoggedIn(page);
} catch (err) {
L.warn({ err: err.message }, 'Login URL timeout, checking if logged in anyway');
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!<br>' +
`Open noVNC: <a href="http://localhost:${cfg.novnc_port || '6080'}">http://localhost:${cfg.novnc_port || '6080'}</a><br>` +
`Login timeout: ${cfg.login_timeout / 1000}s`,
);
const maxWait = cfg.login_timeout;
const checkInterval = 5000;
let waited = 0;
while (waited < maxWait) {
await page.waitForTimeout(checkInterval);
waited += checkInterval;
if (await isLoggedIn(page)) {
L.info('Manual login detected');
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 (!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'}):<br>${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();
}
};