feat: add optional new epic claimer mode
This commit is contained in:
parent
4ce50e2e43
commit
7a9f31df7c
6 changed files with 464 additions and 44 deletions
206
epic-claimer-new.js
Normal file
206
epic-claimer-new.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import axios from 'axios';
|
||||
import { firefox } from 'playwright-firefox';
|
||||
import { authenticator } from 'otplib';
|
||||
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 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?.pageSlug ? mapping.id : g.catalogNs?.mappings?.[0]?.id,
|
||||
pageSlug: mapping?.pageSlug || g.urlSlug,
|
||||
offerId: offer?.offerId,
|
||||
};
|
||||
}) || [];
|
||||
};
|
||||
|
||||
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.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);
|
||||
}
|
||||
const user = await page.locator('egs-navigation').getAttribute('displayname');
|
||||
console.log(`Signed in as ${user}`);
|
||||
return user;
|
||||
};
|
||||
|
||||
export const claimEpicGamesNew = async () => {
|
||||
console.log('Starting Epic Games claimer (new mode)');
|
||||
const db = await jsonDb('epic-games.json', {});
|
||||
|
||||
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 });
|
||||
|
||||
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: '/' },
|
||||
]);
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.exitCode ||= 1;
|
||||
console.error('--- Exception:');
|
||||
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}):<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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue