- package.json: Add missing @eslint/js and globals devDependencies - docker-entrypoint.sh: Fix X11 lock file name (.X1-lock → .X11-lock) - epic-claimer-new.js: Use imported solveCloudflare/isCloudflareChallenge instead of duplicate implementations - src/cloudflare.js: Fix solveCloudflare to use cfg.flaresolverr_url, remove unused imports - epic-games.js: Remove unused code (getFreeGamesFromGraphQL, exchangeTokenForCookies, FREE_GAMES_QUERY, deviceAuthLoginSuccess variable) - Run eslint --fix to clean up trailing spaces
314 lines
12 KiB
JavaScript
314 lines
12 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';
|
|
|
|
// 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
|
|
const ensureLoggedIn = async (page, context) => {
|
|
const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true';
|
|
|
|
const attemptAutoLogin = async () => {
|
|
if (!cfg.eg_email || !cfg.eg_password) return false;
|
|
|
|
try {
|
|
await page.goto(URL_LOGIN, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: cfg.login_timeout,
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
// 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').first();
|
|
if (await rememberMe.count() > 0) await rememberMe.check();
|
|
await continueBtn.click();
|
|
|
|
// 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() > 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
|
|
}
|
|
|
|
await page.waitForURL('**/free-games', { timeout: cfg.login_timeout });
|
|
return await isLoggedIn();
|
|
} catch (err) {
|
|
console.error('Auto login failed:', err);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Use imported isCloudflareChallenge and solveCloudflare from src/cloudflare.js
|
|
const isChallenge = async () => await isCloudflareChallenge(page);
|
|
|
|
const solveCloudflareChallenge = async () => {
|
|
const solution = await solveCloudflare(page, URL_CLAIM);
|
|
return solution !== null;
|
|
};
|
|
|
|
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);
|
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
|
|
|
if (await isChallenge()) {
|
|
console.warn('Cloudflare challenge detected. Attempting to solve with FlareSolverr...');
|
|
const solved = await solveCloudflareChallenge();
|
|
if (solved) {
|
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
|
continue;
|
|
}
|
|
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);
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
// 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();
|
|
};
|