- 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
482 lines
19 KiB
JavaScript
482 lines
19 KiB
JavaScript
import { firefox } from 'playwright-firefox';
|
|
import { authenticator } from 'otplib';
|
|
import chalk from 'chalk';
|
|
import path from 'node:path';
|
|
import { existsSync, writeFileSync, appendFileSync } 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';
|
|
|
|
const screenshot = (...a) => path.resolve(cfg.dir.screenshots, 'epic-games', ...a);
|
|
|
|
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;
|
|
|
|
console.log(datetime(), 'started checking epic-games (GraphQL API mode)');
|
|
|
|
if (cfg.eg_mode === 'new') {
|
|
const { claimEpicGamesNew } = await import('./epic-claimer-new.js');
|
|
await claimEpicGamesNew();
|
|
process.exit(0);
|
|
}
|
|
|
|
const db = await jsonDb('epic-games.json', {});
|
|
|
|
if (cfg.time) console.time('startup');
|
|
|
|
const browserPrefs = path.join(cfg.dir.browser, 'prefs.js');
|
|
if (existsSync(browserPrefs)) {
|
|
console.log('Adding webgl.disabled to', browserPrefs);
|
|
appendFileSync(browserPrefs, 'user_pref("webgl.disabled", true);');
|
|
} else {
|
|
console.log(browserPrefs, 'does not exist yet, will patch it on next run. Restart the script if you get a captcha.');
|
|
}
|
|
|
|
// https://playwright.dev/docs/auth#multi-factor-authentication
|
|
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 });
|
|
|
|
// some debug info about the page
|
|
if (cfg.debug) {
|
|
const debugInfo = await page.evaluate(() => {
|
|
const { width, height, availWidth, availHeight } = window.screen;
|
|
return {
|
|
screen: { width, height, availWidth, availHeight },
|
|
userAgent: navigator.userAgent,
|
|
};
|
|
});
|
|
console.debug(debugInfo);
|
|
}
|
|
|
|
if (cfg.debug_network) {
|
|
const filter = r => r.url().includes('store.epicgames.com');
|
|
page.on('request', request => filter(request) && console.log('>>', request.method(), request.url()));
|
|
page.on('response', response => filter(response) && console.log('<<', response.status(), response.url()));
|
|
}
|
|
|
|
const notify_games = [];
|
|
let user;
|
|
|
|
// Generate login redirect URL
|
|
const generateLoginRedirect = redirectUrl => {
|
|
const loginRedirectUrl = new URL(ID_LOGIN_ENDPOINT);
|
|
loginRedirectUrl.searchParams.set('noHostRedirect', 'true');
|
|
loginRedirectUrl.searchParams.set('redirectUrl', redirectUrl);
|
|
loginRedirectUrl.searchParams.set('client_id', EPIC_CLIENT_ID);
|
|
return loginRedirectUrl.toString();
|
|
};
|
|
|
|
// Generate checkout URL with login redirect
|
|
const generateCheckoutUrl = offers => {
|
|
const offersParams = offers
|
|
.map(offer => `&offers=1-${offer.offerNamespace}-${offer.offerId}`)
|
|
.join('');
|
|
const checkoutUrl = `${EPIC_PURCHASE_ENDPOINT}?highlightColor=0078f2${offersParams}&orderId&purchaseToken&showNavigation=true`;
|
|
return generateLoginRedirect(checkoutUrl);
|
|
};
|
|
|
|
// Get free games from promotions API (weekly free games)
|
|
const getFreeGamesFromPromotions = async () => {
|
|
const response = await page.evaluate(async () => {
|
|
const resp = await fetch(FREE_GAMES_PROMOTIONS_ENDPOINT + '?locale=en-US&country=US&allowCountries=US');
|
|
return await resp.json();
|
|
});
|
|
|
|
const nowDate = new Date();
|
|
const elements = response.data?.Catalog?.searchStore?.elements || [];
|
|
|
|
return elements.filter(offer => {
|
|
if (!offer.promotions) return false;
|
|
|
|
return offer.promotions.promotionalOffers.some(innerOffers => innerOffers.promotionalOffers.some(pOffer => {
|
|
const startDate = new Date(pOffer.startDate);
|
|
const endDate = new Date(pOffer.endDate);
|
|
const isFree = pOffer.discountSetting?.discountPercentage === 0;
|
|
return startDate <= nowDate && nowDate <= endDate && isFree;
|
|
}));
|
|
}).map(game => ({
|
|
offerId: game.id,
|
|
offerNamespace: game.namespace,
|
|
productName: game.title,
|
|
productSlug: game.productSlug || game.urlSlug,
|
|
}));
|
|
};
|
|
|
|
// Get all free games
|
|
const getAllFreeGames = async () => {
|
|
try {
|
|
const weeklyGames = await getFreeGamesFromPromotions();
|
|
console.log('Found', weeklyGames.length, 'weekly free games');
|
|
return weeklyGames;
|
|
} catch (e) {
|
|
console.error('Failed to get weekly free games:', e.message);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Login with device auth - attempts to use stored auth token
|
|
const loginWithDeviceAuth = async () => {
|
|
const deviceAuth = await getAccountAuth(cfg.eg_email || 'default');
|
|
|
|
if (deviceAuth && deviceAuth.access_token) {
|
|
console.log('Using stored device auth');
|
|
|
|
// Set the bearer token cookie for authentication
|
|
/** @type {import('playwright-firefox').Cookie} */
|
|
const bearerCookie = {
|
|
name: 'EPIC_BEARER_TOKEN',
|
|
value: deviceAuth.access_token,
|
|
expires: new Date(deviceAuth.expires_at).getTime() / 1000,
|
|
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' });
|
|
|
|
// Check if login worked
|
|
const isLoggedIn = await page.locator('egs-navigation').getAttribute('isloggedin') === 'true';
|
|
if (isLoggedIn) {
|
|
console.log('Successfully logged in with device auth');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Save device auth
|
|
const saveDeviceAuth = async (accessToken, refreshToken, expiresAt) => {
|
|
const deviceAuth = {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
expires_at: expiresAt,
|
|
expires_in: 86400,
|
|
token_type: 'bearer',
|
|
account_id: 'unknown',
|
|
client_id: EPIC_CLIENT_ID,
|
|
internal_client: true,
|
|
client_service: 'account',
|
|
displayName: 'User',
|
|
app: 'epic-games',
|
|
in_app_id: 'unknown',
|
|
product_id: 'unknown',
|
|
refresh_expires: 604800,
|
|
refresh_expires_at: new Date(Date.now() + 604800000).toISOString(),
|
|
application_id: 'unknown',
|
|
};
|
|
|
|
await setAccountAuth(cfg.eg_email || 'default', deviceAuth);
|
|
console.log('Device auth saved');
|
|
};
|
|
|
|
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: '/' },
|
|
]);
|
|
|
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
|
|
|
if (cfg.time) console.timeEnd('startup');
|
|
if (cfg.time) console.time('login');
|
|
|
|
// Try device auth first
|
|
await loginWithDeviceAuth();
|
|
|
|
// If device auth failed, try regular login
|
|
while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') {
|
|
console.error('Not signed in. 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 hasCaptcha = await page.locator('.h_captcha_challenge iframe').count() > 0 || await page.locator('text=Incorrect response').count() > 0;
|
|
if (hasCaptcha) {
|
|
console.warn('Captcha/Incorrect response detected. Please solve manually in the browser.');
|
|
await notify('epic-games: captcha encountered; please solve manually in browser.');
|
|
await page.waitForTimeout(cfg.login_timeout);
|
|
continue;
|
|
}
|
|
|
|
const email = cfg.eg_email || await prompt({ message: 'Enter email' });
|
|
if (email) {
|
|
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();
|
|
|
|
const error = page.locator('#form-error-message');
|
|
const watchLoginError = async () => {
|
|
try {
|
|
await error.waitFor({ timeout: 15000 });
|
|
console.error('Login error:', await error.innerText());
|
|
console.log('Please login in the browser!');
|
|
} catch {
|
|
return;
|
|
}
|
|
};
|
|
|
|
const watchMfaStep = async () => {
|
|
try {
|
|
await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout });
|
|
console.log('Enter the security code to continue');
|
|
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;
|
|
}
|
|
};
|
|
|
|
watchLoginError();
|
|
watchMfaStep();
|
|
} else await notifyBrowserLogin();
|
|
|
|
await page.waitForURL(URL_CLAIM);
|
|
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
|
|
}
|
|
|
|
user = await page.locator('egs-navigation').getAttribute('displayname');
|
|
console.log(`Signed in as ${user}`);
|
|
db.data[user] ||= {};
|
|
|
|
if (cfg.time) console.timeEnd('login');
|
|
if (cfg.time) console.time('claim all games');
|
|
|
|
// Get free games
|
|
const freeGames = await getAllFreeGames();
|
|
console.log('Free games:', freeGames.map(g => g.productName));
|
|
|
|
// Generate checkout link for all free games (available for all games)
|
|
const checkoutUrl = freeGames.length > 0 ? generateCheckoutUrl(freeGames) : null;
|
|
if (checkoutUrl) {
|
|
console.log('Generated checkout URL:', checkoutUrl);
|
|
|
|
// Send notification with checkout link
|
|
await notify(`epic-games (${user}):<br>Free games available!<br>Click here to claim: <a href="${checkoutUrl}">${checkoutUrl}</a>`);
|
|
}
|
|
|
|
// Also save to database for reference
|
|
freeGames.forEach(game => {
|
|
const purchaseUrl = `https://store.epicgames.com/${game.productSlug}`;
|
|
db.data[user][game.offerId] ||= {
|
|
title: game.productName,
|
|
time: datetime(),
|
|
url: purchaseUrl,
|
|
checkoutUrl: checkoutUrl || purchaseUrl,
|
|
};
|
|
});
|
|
|
|
// Claim each game individually (for detailed tracking)
|
|
for (const game of freeGames) {
|
|
if (cfg.time) console.time('claim game');
|
|
|
|
const purchaseUrl = `https://store.epicgames.com/${game.productSlug}`;
|
|
await page.goto(purchaseUrl);
|
|
|
|
const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first();
|
|
await purchaseBtn.waitFor();
|
|
const btnText = (await purchaseBtn.innerText()).toLowerCase();
|
|
|
|
// click Continue if 'This game contains mature content recommended only for ages 18+'
|
|
if (await page.locator('button:has-text("Continue")').count() > 0) {
|
|
console.log(' This game contains mature content recommended only for ages 18+');
|
|
if (await page.locator('[data-testid="AgeSelect"]').count()) {
|
|
console.error(' Got "To continue, please provide your date of birth"');
|
|
await page.locator('#month_toggle').click();
|
|
await page.locator('#month_menu li:has-text("01")').click();
|
|
await page.locator('#day_toggle').click();
|
|
await page.locator('#day_menu li:has-text("01")').click();
|
|
await page.locator('#year_toggle').click();
|
|
await page.locator('#year_menu li:has-text("1987")').click();
|
|
}
|
|
await page.click('button:has-text("Continue")', { delay: 111 });
|
|
await page.waitForTimeout(2000);
|
|
}
|
|
|
|
let title;
|
|
if (await page.locator('span:text-is("About Bundle")').count()) {
|
|
title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', '');
|
|
} else {
|
|
title = await page.locator('h1').first().innerText();
|
|
}
|
|
|
|
const existedInDb = db.data[user][game.offerId];
|
|
db.data[user][game.offerId] ||= { title, time: datetime(), url: purchaseUrl, checkoutUrl: checkoutUrl };
|
|
console.log('Current free game:', chalk.blue(title));
|
|
|
|
const notify_game = { title, url: purchaseUrl, status: 'failed' };
|
|
notify_games.push(notify_game);
|
|
|
|
if (btnText == 'in library' || btnText == 'owned') {
|
|
console.log(' Already in library! Nothing to claim.');
|
|
if (!existedInDb) await notify(`Game already in library: ${purchaseUrl}`);
|
|
notify_game.status = 'existed';
|
|
db.data[user][game.offerId].status ||= 'existed';
|
|
if (db.data[user][game.offerId].status.startsWith('failed')) db.data[user][game.offerId].status = 'manual';
|
|
} else if (btnText == 'requires base game') {
|
|
console.log(' Requires base game! Nothing to claim.');
|
|
notify_game.status = 'requires base game';
|
|
db.data[user][game.offerId].status ||= 'failed:requires-base-game';
|
|
} else {
|
|
console.log(' Not in library yet! Click', btnText);
|
|
await purchaseBtn.click({ delay: 11 });
|
|
|
|
// Accept EULA if shown
|
|
try {
|
|
await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 });
|
|
console.log(' Accept End User License Agreement');
|
|
await page.locator('input#agree').check();
|
|
await page.locator('button:has-text("Accept")').click();
|
|
} catch {
|
|
// EULA not shown
|
|
}
|
|
|
|
await page.waitForSelector('#webPurchaseContainer iframe');
|
|
const iframe = page.frameLocator('#webPurchaseContainer iframe');
|
|
|
|
if (await iframe.locator(':has-text("unavailable in your region")').count() > 0) {
|
|
console.error(' This product is unavailable in your region!');
|
|
db.data[user][game.offerId].status = notify_game.status = 'unavailable-in-region';
|
|
if (cfg.time) console.timeEnd('claim game');
|
|
continue;
|
|
}
|
|
|
|
const enterParentalPinIfNeeded = async () => {
|
|
try {
|
|
await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 });
|
|
if (!cfg.eg_parentalpin) {
|
|
console.error(' EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.');
|
|
notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.');
|
|
}
|
|
await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin);
|
|
await iframe.locator('button:has-text("Continue")').click({ delay: 11 });
|
|
} catch {
|
|
return;
|
|
}
|
|
};
|
|
enterParentalPinIfNeeded();
|
|
|
|
if (cfg.debug) await page.pause();
|
|
if (cfg.dryrun) {
|
|
console.log(' DRYRUN=1 -> Skip order!');
|
|
notify_game.status = 'skipped';
|
|
if (cfg.time) console.timeEnd('claim game');
|
|
continue;
|
|
}
|
|
|
|
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 {
|
|
// EU: wait for and click 'I Agree'
|
|
}
|
|
|
|
try {
|
|
await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' });
|
|
db.data[user][game.offerId].status = 'claimed';
|
|
db.data[user][game.offerId].time = datetime();
|
|
console.log(' Claimed successfully!');
|
|
|
|
// Save device auth if we got a new token
|
|
const cookies = await context.cookies();
|
|
const bearerCookie = cookies.find(c => c.name === 'EPIC_BEARER_TOKEN');
|
|
if (bearerCookie?.value) {
|
|
await saveDeviceAuth(bearerCookie.value, 'refresh_token_placeholder', new Date(Date.now() + 86400000).toISOString());
|
|
}
|
|
} catch (e) {
|
|
console.log(e);
|
|
console.error(' Failed to claim! To avoid captchas try to get a new IP address.');
|
|
const p = screenshot('failed', `${game.offerId}_${filenamify(datetime())}.png`);
|
|
await page.screenshot({ path: p, fullPage: true });
|
|
db.data[user][game.offerId].status = 'failed';
|
|
}
|
|
notify_game.status = db.data[user][game.offerId].status;
|
|
|
|
const p = screenshot(`${game.offerId}.png`);
|
|
if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false });
|
|
}
|
|
|
|
if (cfg.time) console.timeEnd('claim game');
|
|
}
|
|
|
|
if (cfg.time) console.timeEnd('claim all games');
|
|
} catch (error) {
|
|
process.exitCode ||= 1;
|
|
console.error('--- Exception:');
|
|
console.error(error);
|
|
if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`);
|
|
} finally {
|
|
await db.write();
|
|
|
|
// Save cookies
|
|
const cookies = await context.cookies();
|
|
// Convert cookies to EpicCookie format for setPuppeteerCookies
|
|
const epicCookies = cookies.map(c => ({
|
|
domain: c.domain,
|
|
hostOnly: !c.domain.startsWith('.'),
|
|
httpOnly: c.httpOnly,
|
|
name: c.name,
|
|
path: c.path,
|
|
sameSite: c.sameSite === 'Lax' ? 'no_restriction' : 'unspecified',
|
|
secure: c.secure,
|
|
session: !c.expires,
|
|
storeId: '0',
|
|
value: c.value,
|
|
id: 0,
|
|
expirationDate: c.expires ? Math.floor(c.expires) : undefined,
|
|
}));
|
|
await setPuppeteerCookies(cfg.eg_email || 'default', epicCookies);
|
|
|
|
if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) {
|
|
notify(`epic-games (${user}):<br>${html_game_list(notify_games)}`);
|
|
}
|
|
}
|
|
|
|
if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies()));
|
|
if (page.video()) console.log('Recorded video:', await page.video().path());
|
|
await context.close();
|