- 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.
411 lines
14 KiB
JavaScript
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();
|
|
}
|
|
};
|