free-games-claimer/epic-claimer-new.js

344 lines
13 KiB
JavaScript

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 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');
const passwordField = page.locator('input[name="password"], input#password');
const continueBtn = page.locator('button:has-text("Continue"), button#continue, button[type="submit"]');
// step 1: email + continue
if (await emailField.count()) {
await emailField.fill(cfg.eg_email);
await continueBtn.first().click().catch(() => {});
}
// 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 () => {
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 codeInputs = page.locator('input[name^="code-input"]');
if (await codeInputs.count()) {
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.first().click().catch(async () => {
await page.click('button[type="submit"]').catch(() => {});
});
} catch {
// no MFA
}
await page.waitForURL('**/free-games', { timeout: cfg.login_timeout }).catch(() => {});
return await isLoggedIn();
} catch {
return false;
}
};
const isChallenge = async () => {
const cfFrame = page.locator('iframe[title*="Cloudflare"], iframe[src*="challenges"]');
const cfText = page.locator('text=Verify you are human');
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.');
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()) {
console.warn('Cloudflare challenge detected. Solve the captcha in the browser (no automation).');
await notify('epic-games (new): Cloudflare challenge, please solve manually in browser.');
await page.waitForTimeout(cfg.login_timeout);
continue;
}
const logged = await attemptAutoLogin();
if (logged) break;
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);
}
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'}):<br>${html_game_list(notify_games)}`);
}
}
if (cfg.debug && context) {
console.log(JSON.stringify(await context.cookies(), null, 2));
}
await context.close();
};
export default claimEpicGamesNew;