diff --git a/.gitignore b/.gitignore index 7983ad4..cc33550 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ data/ *.env +.continue diff --git a/epic-claimer-new.js b/epic-claimer-new.js index 3943fc6..b8bef91 100644 --- a/epic-claimer-new.js +++ b/epic-claimer-new.js @@ -1,155 +1,63 @@ -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 isLoggedIn = async () => { + try { + return await page.locator('egs-navigation').getAttribute('isloggedin') === 'true'; + } catch (err) { + console.error('Error checking login status:', err); + return false; + } + }; 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, input[aria-label="Sign in with email"]'); - const passwordField = page.locator('input[name="password"], input#password'); - const continueBtn = page.locator('button:has-text("Continue"), button#continue, button[type="submit"]'); + await page.goto('https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM, { + waitUntil: 'domcontentloaded', + timeout: cfg.login_timeout + }); + + // Add more robust selector handling + 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(); + + // Debugging logging + console.log('Login page loaded, checking email field'); // step 1: email + continue - if (await emailField.count()) { + if (await emailField.count() > 0) { await emailField.fill(cfg.eg_email); - await continueBtn.first().click().catch(() => {}); + await continueBtn.click().catch(err => { + console.error('Error clicking continue button:', err); + }); } // step 2: password + submit await passwordField.waitFor({ timeout: cfg.login_visible_timeout }); await passwordField.fill(cfg.eg_password); - const rememberMe = page.locator('input[name="rememberMe"], #rememberMe'); - if (await rememberMe.count()) await rememberMe.check().catch(() => {}); - await continueBtn.first().click().catch(async () => { + + const rememberMe = page.locator('input[name="rememberMe"], #rememberMe').first(); + if (await rememberMe.count() > 0) await rememberMe.check().catch(() => { }); + + await continueBtn.click().catch(async (err) => { + console.error('Error clicking continue button:', err); await page.click('button[type="submit"]').catch(() => {}); }); // 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!' }); + 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()) { + if (await codeInputs.count() > 0) { const digits = otp.toString().split(''); for (let i = 0; i < digits.length; i++) { const input = codeInputs.nth(i); @@ -158,15 +66,21 @@ const ensureLoggedIn = async (page, context) => { } else { await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); } - await continueBtn.first().click().catch(async () => { + + await continueBtn.click().catch(async () => { await page.click('button[type="submit"]').catch(() => {}); }); - } catch { - // no MFA + } catch (mfaError) { + console.warn('MFA step failed or not needed:', mfaError); } - await page.waitForURL('**/free-games', { timeout: cfg.login_timeout }).catch(() => {}); + + await page.waitForURL('**/free-games', { timeout: cfg.login_timeout }).catch(err => { + console.error('Failed to navigate to free games page:', err); + }); + return await isLoggedIn(); - } catch { + } catch (err) { + console.error('Auto login failed:', err); return false; } }; @@ -177,11 +91,18 @@ const ensureLoggedIn = async (page, context) => { 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.'); + let loginAttempts = 0; + const MAX_LOGIN_ATTEMPTS = 3; + + while (!await isLoggedIn() && loginAttempts < MAX_LOGIN_ATTEMPTS) { + loginAttempts++; + console.error(`Not signed in (Attempt ${loginAttempts}). 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()) { @@ -196,149 +117,25 @@ const ensureLoggedIn = async (page, context) => { 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); } + if (loginAttempts >= MAX_LOGIN_ATTEMPTS) { + console.error('Maximum login attempts reached. Exiting.'); + await context.close(); + process.exit(1); + } + 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; +}; \ No newline at end of file