From d05c18415602ce3763d8226e273a80e400ffa875 Mon Sep 17 00:00:00 2001 From: nocci Date: Wed, 31 Dec 2025 12:30:02 +0000 Subject: [PATCH] feat: enhance new epic claimer with cookie persistence and oauth device flow --- README.md | 1 + epic-claimer-new.js | 290 ++++++++++++++++++++++++++------------------ 2 files changed, 175 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index ecb406b..2f5b4c6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Common options: - `WIDTH`, `HEIGHT` (browser size) - `TIMEOUT`, `LOGIN_TIMEOUT` (seconds) - Epic: `EG_MODE=legacy|new` (legacy Playwright flow or neuer API-getriebener Claimer), `EG_PARENTALPIN`, `EG_EMAIL`, `EG_PASSWORD`, `EG_OTPKEY` +- Epic (new mode): Cookies werden unter `data/browser/epic-cookies.json` persistiert; OAuth Device Code Flow benรถtigt ggf. einmalige Freigabe im Browser. - Login: `EMAIL`, `PASSWORD` global; per store `EG_EMAIL`, `EG_PASSWORD`, `EG_OTPKEY`, `PG_EMAIL`, `PG_PASSWORD`, `PG_OTPKEY`, `GOG_EMAIL`, `GOG_PASSWORD` - Prime Gaming: `PG_REDEEM=1` (auto-redeem keys, experimental), `PG_CLAIMDLC=1`, `PG_TIMELEFT=` to skip long-remaining offers - Screenshots: `SCREENSHOTS_DIR` (default `data/screenshots`) diff --git a/epic-claimer-new.js b/epic-claimer-new.js index 91da99f..2ba5632 100644 --- a/epic-claimer-new.js +++ b/epic-claimer-new.js @@ -1,12 +1,15 @@ 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 chalk from 'chalk'; 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 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'; const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); @@ -21,77 +24,174 @@ const fetchFreeGamesAPI = async () => { const mapping = g.catalogNs?.mappings?.[0]; return { title: g.title, - namespace: mapping?.pageSlug ? mapping.id : g.catalogNs?.mappings?.[0]?.id, + 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 ({ email, password, 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)...'); + const deviceResponse = await axios.post('https://api.epicgames.dev/epic/oauth/deviceCode', { + client_id: '34a02cf8f4414e29b159cdd02e6184bd', + scope: 'account.basicprofile account.userentitlements', + }); + 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) => { while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') { console.error('Not signed in anymore. Please login in the browser or here in the terminal.'); 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_LOGIN, { waitUntil: 'domcontentloaded' }); - if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); - else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); - - const notifyBrowserLogin = async () => { - console.log('Waiting for you to login in the browser.'); - await notify('epic-games: no longer signed in and not enough options set for automatic login.'); - if (cfg.headless) { - console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); - await context.close(); - process.exit(1); - } - }; - - const email = cfg.eg_email || await prompt({ message: 'Enter email' }); - if (!email) { - await notifyBrowserLogin(); - await page.waitForURL(URL_CLAIM); - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - continue; + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); + 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.fill('#email', email); - const password = cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }); - if (password) { - await page.fill('#password', password); - await page.click('button[type="submit"]'); - } else { - await notifyBrowserLogin(); - await page.waitForURL(URL_CLAIM); - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - continue; - } - - const watchMfaStep = async () => { - try { - await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); - console.log('Enter the security code to continue - security code sent to your email/device.'); - 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 { - return; - } - }; - watchMfaStep(); - - await page.waitForURL(URL_CLAIM); - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); + 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)'); + 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)); @@ -113,90 +213,48 @@ export const claimEpicGamesNew = async () => { const page = context.pages().length ? context.pages()[0] : await context.newPage(); await page.setViewportSize({ width: cfg.width, height: cfg.height }); - const notify_games = []; let user; try { - await context.addCookies([ - { name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }, - { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, - ]); + const auth = await getValidAuth({ + email: cfg.eg_email, + password: cfg.eg_password, + otpKey: cfg.eg_otpkey, + reuseCookies: true, + cookiesPath: COOKIES_PATH, + }); + + await context.addCookies(auth.cookies); + console.log('โœ… Cookies loaded:', auth.cookies.length); await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); user = await ensureLoggedIn(page, context); db.data[user] ||= {}; for (const game of freeGames) { - const purchaseUrl = `https://store.epicgames.com/purchase?namespace=${game.namespace}&offers=${game.offerId}`; - console.log('Processing', chalk.blue(game.title), purchaseUrl); - const notify_game = { title: game.title, url: purchaseUrl, status: 'failed' }; - notify_games.push(notify_game); - - await page.goto(purchaseUrl, { waitUntil: 'domcontentloaded' }); - - const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first(); - await purchaseBtn.waitFor({ timeout: cfg.timeout }); - const btnText = (await purchaseBtn.innerText()).toLowerCase(); - - if (btnText.includes('library')) { - console.log(' Already in library.'); - notify_game.status = 'existed'; - db.data[user][game.offerId] = { title: game.title, time: datetime(), url: purchaseUrl, status: 'existed' }; - continue; - } - if (cfg.dryrun) { - console.log(' DRYRUN=1 -> Skip order!'); - notify_game.status = 'skipped'; - db.data[user][game.offerId] = { title: game.title, time: datetime(), url: purchaseUrl, status: 'skipped' }; - continue; - } - - await purchaseBtn.click({ delay: 10 }); - await page.waitForSelector('#webPurchaseContainer iframe'); - 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 - } - } - - try { - await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); - const btnAgree = iframe.locator('button:has-text("I Accept")'); - try { - await btnAgree.waitFor({ timeout: 10000 }); - await btnAgree.click(); - } catch { - // not required - } - await page.locator('text=Thanks for your order!').waitFor({ state: 'attached', timeout: cfg.timeout }); - notify_game.status = 'claimed'; - db.data[user][game.offerId] = { title: game.title, time: datetime(), url: purchaseUrl, status: 'claimed' }; - console.log(' Claimed successfully!'); - } catch (e) { - console.error(' Failed to claim:', e.message); - notify_game.status = 'failed'; - db.data[user][game.offerId] = { title: game.title, time: datetime(), url: purchaseUrl, status: 'failed' }; - const p = screenshot('failed', `${game.offerId}_${filenamify(datetime())}.png`); - await page.screenshot({ path: p, fullPage: true }).catch(() => {}); - } + 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:'); + 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}):
${html_game_list(notify_games)}`); + notify(`epic-games (new ${user || 'unknown'}):
${html_game_list(notify_games)}`); } } + if (cfg.debug && context) { console.log(JSON.stringify(await context.cookies(), null, 2)); }