- src/device-login.js: New module implementing Epic Games OAuth Device Flow - src/logger.js: Simple logger module for consistent logging - src/config.js: Add deviceAuthClientId and deviceAuthSecret config - epic-claimer-new.js: Use OAuth Device Flow instead of browser login - Cloudflare bypass: Device Flow uses API, user logs in own browser - Based on: https://github.com/claabs/epicgames-freegames-node How it works: 1. Get client credentials from Epic OAuth API 2. Get device authorization code with verification URL 3. Send user notification with login link 4. User clicks link and logs in (handles Cloudflare manually) 5. Poll for authorization completion 6. Save and use access/refresh tokens 7. Tokens auto-refresh on expiry Benefits: - No Cloudflare issues (no bot detection) - Persistent tokens (no repeated logins) - Works in headless mode - More reliable than browser automation
296 lines
11 KiB
JavaScript
296 lines
11 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 { EPIC_CLIENT_ID, GRAPHQL_ENDPOINT, 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, waitForCloudflareSolved } from './src/cloudflare.js';
|
|
import { getValidAccessToken, startDeviceAuthLogin, completeDeviceAuthLogin, refreshDeviceAuth } from './src/device-login.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');
|
|
const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN';
|
|
|
|
// 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;
|
|
};
|
|
|
|
// Ensure user is logged in using OAuth Device Flow (bypasses Cloudflare)
|
|
const ensureLoggedIn = async (page, context) => {
|
|
const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true';
|
|
const user = cfg.eg_email || 'default';
|
|
|
|
L.info('Attempting OAuth Device Flow login (Cloudflare bypass)');
|
|
|
|
// Step 1: Try to get valid access token from stored device auth
|
|
let accessToken = await getValidAccessToken(user);
|
|
|
|
if (accessToken) {
|
|
L.info('Using existing valid access token');
|
|
} else {
|
|
// Step 2: No valid token - start new device auth flow
|
|
L.info('No valid token found, starting device auth flow');
|
|
await notify(
|
|
'epic-games: Login required! Visit the link to authorize: DEVICE_AUTH_PENDING',
|
|
);
|
|
|
|
try {
|
|
const { verificationUrl, userCode, expiresAt } = await startDeviceAuthLogin(user);
|
|
|
|
// Notify user with verification URL
|
|
const timeRemaining = Math.round((expiresAt - Date.now()) / 60000);
|
|
await notify(
|
|
`epic-games: Click here to login:<br><a href="${verificationUrl}">${verificationUrl}</a><br>` +
|
|
`User Code: <strong>${userCode}</strong><br>` +
|
|
`Expires in: ${timeRemaining} minutes`,
|
|
);
|
|
|
|
console.log(`🔐 Device Auth URL: ${verificationUrl}`);
|
|
console.log(`🔐 User Code: ${userCode}`);
|
|
console.log(`⏰ Expires in: ${timeRemaining} minutes`);
|
|
|
|
// Wait for user to complete authorization
|
|
const interval = 5; // poll every 5 seconds
|
|
const authToken = await completeDeviceAuthLogin(
|
|
// We need to get device_code from the startDeviceAuthLogin response
|
|
// For now, we'll re-fetch it
|
|
verificationUrl.split('userCode=')[1]?.split('&')[0] || '',
|
|
expiresAt,
|
|
interval,
|
|
);
|
|
|
|
accessToken = authToken.access_token;
|
|
L.info('Device auth completed successfully');
|
|
} catch (err) {
|
|
L.error({ err }, 'Device auth flow failed');
|
|
await notify('epic-games: Device auth failed. Please login manually in browser.');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Step 3: Apply bearer token to browser
|
|
L.info('Applying access token to browser');
|
|
|
|
/** @type {import('playwright-firefox').Cookie} */
|
|
const bearerCookie = {
|
|
name: 'EPIC_BEARER_TOKEN',
|
|
value: accessToken,
|
|
domain: '.epicgames.com',
|
|
path: '/',
|
|
secure: true,
|
|
httpOnly: true,
|
|
sameSite: 'Lax',
|
|
};
|
|
|
|
await context.addCookies([bearerCookie]);
|
|
|
|
// Visit store to get session cookies
|
|
await page.goto(STORE_HOMEPAGE_EN, { waitUntil: 'networkidle', timeout: cfg.timeout });
|
|
|
|
// Verify login worked
|
|
const loggedIn = await isLoggedIn();
|
|
if (!loggedIn) {
|
|
L.warn('Bearer token did not result in logged-in state, may need manual login');
|
|
// Fall back to manual browser login
|
|
console.log('Token-based login did not work. Please login manually in the browser.');
|
|
await notify('epic-games: Please login manually in browser.');
|
|
|
|
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 (!await isLoggedIn()) {
|
|
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}`);
|
|
|
|
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
|
|
return displayName;
|
|
};
|
|
|
|
// Main function to claim Epic Games
|
|
export const claimEpicGamesNew = async () => {
|
|
console.log('Starting Epic Games claimer (new mode, browser-based)');
|
|
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 });
|
|
|
|
// Load cookies from file if available
|
|
if (existsSync(COOKIES_PATH)) {
|
|
try {
|
|
const cookies = JSON.parse(readFileSync(COOKIES_PATH, 'utf8'));
|
|
await context.addCookies(cookies);
|
|
console.log('✅ Cookies loaded from file');
|
|
} catch (error) {
|
|
console.error('Failed to load cookies:', error);
|
|
}
|
|
}
|
|
|
|
// Use device auths if available (from legacy mode)
|
|
const deviceAuths = await getAccountAuth();
|
|
if (deviceAuths && cfg.eg_email) {
|
|
const accountAuth = deviceAuths[cfg.eg_email];
|
|
if (accountAuth) {
|
|
console.log('🔄 Reusing device auth from legacy mode');
|
|
const cookies = [
|
|
{ name: 'EPIC_SSO_RM', value: accountAuth.deviceAuth?.refreshToken || '', domain: '.epicgames.com', path: '/' },
|
|
{ name: 'EPIC_DEVICE', value: accountAuth.deviceAuth?.deviceId || '', domain: '.epicgames.com', path: '/' },
|
|
{ name: 'EPIC_SESSION_AP', value: accountAuth.deviceAuth?.accountId || '', domain: '.epicgames.com', path: '/' },
|
|
];
|
|
await context.addCookies(cookies);
|
|
console.log('✅ Device auth cookies loaded');
|
|
}
|
|
}
|
|
|
|
let user;
|
|
|
|
try {
|
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
|
user = await ensureLoggedIn(page, context);
|
|
db.data[user] ||= {};
|
|
|
|
const freeGames = await fetchFreeGamesAPI(page);
|
|
console.log('Free games via API:', freeGames.map(g => g.pageSlug));
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
// Save cookies to file
|
|
const cookies = await context.cookies();
|
|
writeFileSync(COOKIES_PATH, JSON.stringify(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();
|
|
};
|