refactor(epic): switch from external API calls to in-page browser-based fetching
-removed axios dependency and replaced server-side API calls with in-page fetch() execution -migrated from OAuth device flow to browser-based authentication using persistent context -simplified claim flow by removing manual token exchange and cookie management
This commit is contained in:
parent
bceb642bcb
commit
1ddcf1d8af
1 changed files with 76 additions and 223 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
import axios from 'axios';
|
|
||||||
import { firefox } from 'playwright-firefox';
|
import { firefox } from 'playwright-firefox';
|
||||||
import { authenticator } from 'otplib';
|
import { authenticator } from 'otplib';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
@ -14,22 +13,18 @@ import {
|
||||||
handleSIGINT,
|
handleSIGINT,
|
||||||
} from './src/util.js';
|
} from './src/util.js';
|
||||||
import { cfg } from './src/config.js';
|
import { cfg } from './src/config.js';
|
||||||
import { getDeviceAuths, setAccountAuth } from './src/device-auths.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';
|
||||||
|
|
||||||
|
// Fetch Free Games from API using page.evaluate (browser context)
|
||||||
const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games';
|
const fetchFreeGamesAPI = async page => {
|
||||||
const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json');
|
const response = await page.evaluate(async () => {
|
||||||
const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN';
|
const resp = await fetch(FREE_GAMES_PROMOTIONS_ENDPOINT + '?locale=en-US&country=US&allowCountries=US,DE,AT,CH,GB');
|
||||||
|
return await resp.json();
|
||||||
// Screenshot Helper Function
|
|
||||||
|
|
||||||
|
|
||||||
// Fetch Free Games from API
|
|
||||||
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
|
|
||||||
|
return response?.Catalog?.searchStore?.elements
|
||||||
?.filter(g => g.promotions?.promotionalOffers?.[0])
|
?.filter(g => g.promotions?.promotionalOffers?.[0])
|
||||||
?.map(g => {
|
?.map(g => {
|
||||||
const offer = g.promotions.promotionalOffers[0].promotionalOffers[0];
|
const offer = g.promotions.promotionalOffers[0].promotionalOffers[0];
|
||||||
|
|
@ -43,147 +38,64 @@ const fetchFreeGamesAPI = async () => {
|
||||||
}) || [];
|
}) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll for OAuth tokens
|
const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games';
|
||||||
const pollForTokens = async (deviceCode, maxAttempts = 30) => {
|
const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM;
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json');
|
||||||
try {
|
const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN';
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
|
|
||||||
params.append('device_code', deviceCode);
|
|
||||||
|
|
||||||
const response = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token', params.toString(), {
|
// Claim game function
|
||||||
headers: {
|
const claimGame = async (page, game) => {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
const purchaseUrl = `https://store.epicgames.com/${game.pageSlug}`;
|
||||||
Authorization: 'Basic OThmN2U0MmMyZTNhNGY4NmE3NGViNDNmYmI0MWVkMzk6MGEyNDQ5YTItMDEwYS00NTFlLWFmZWMtM2U4MTI5MDFjNGQ3',
|
console.log(`🎮 ${game.title} → ${purchaseUrl}`);
|
||||||
},
|
const notify_game = { title: game.title, url: purchaseUrl, status: 'failed' };
|
||||||
});
|
|
||||||
if (response.data?.access_token) {
|
await page.goto(purchaseUrl, { waitUntil: 'networkidle' });
|
||||||
console.log('✅ OAuth successful');
|
|
||||||
return response.data;
|
const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first();
|
||||||
}
|
await purchaseBtn.waitFor({ timeout: cfg.timeout });
|
||||||
} catch (error) {
|
const btnText = (await purchaseBtn.textContent() || '').toLowerCase();
|
||||||
if (error.response?.data?.error === 'authorization_pending') {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
if (btnText.includes('library') || btnText.includes('owned')) {
|
||||||
continue;
|
notify_game.status = 'existed';
|
||||||
}
|
return notify_game;
|
||||||
throw error;
|
}
|
||||||
}
|
if (cfg.dryrun) {
|
||||||
|
notify_game.status = 'skipped';
|
||||||
|
return notify_game;
|
||||||
}
|
}
|
||||||
throw new Error('OAuth timeout');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exchange token for cookies
|
await purchaseBtn.click({ delay: 50 });
|
||||||
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: '/' };
|
|
||||||
}) || [];
|
|
||||||
cookies.push({ name: BEARER_TOKEN_NAME, value: accessToken, domain: '.epicgames.com', path: '/' });
|
|
||||||
return cookies;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get client credentials token (first step of OAuth flow)
|
|
||||||
const getClientCredentialsToken = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token', {
|
await page.waitForSelector('#webPurchaseContainer iframe', { timeout: 15000 });
|
||||||
grant_type: 'client_credentials',
|
const iframe = page.frameLocator('#webPurchaseContainer iframe');
|
||||||
}, {
|
|
||||||
auth: {
|
|
||||||
username: '98f7e42c2e3a4f86a74eb43fbb41ed39',
|
|
||||||
password: '0a2449a2-001a-451e-afec-3e812901c4d7',
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data.access_token;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get client credentials token:', error.response?.status || error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get device authorization code (second step of OAuth flow)
|
if (cfg.eg_parentalpin) {
|
||||||
const getDeviceAuthorizationCode = async clientCredentialsToken => {
|
try {
|
||||||
try {
|
await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 });
|
||||||
const params = new URLSearchParams();
|
await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin);
|
||||||
params.append('prompt', 'login');
|
await iframe.locator('button:has-text("Continue")').click({ delay: 11 });
|
||||||
|
} catch {
|
||||||
const response = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization', params.toString(), {
|
// no PIN needed
|
||||||
headers: {
|
}
|
||||||
Authorization: `Bearer ${clientCredentialsToken}`,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('Device authorization response:', response.data);
|
|
||||||
// Return the correct field names (device_code vs deviceCode)
|
|
||||||
return {
|
|
||||||
deviceCode: response.data.device_code,
|
|
||||||
userCode: response.data.user_code,
|
|
||||||
verificationUriComplete: response.data.verification_uri_complete,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get device authorization code:', error.response?.status || error.message);
|
|
||||||
console.error('Error response data:', error.response?.data);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get valid authentication
|
|
||||||
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)...');
|
await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 });
|
||||||
|
|
||||||
// Step 1: Get client credentials token
|
|
||||||
const clientCredentialsToken = await getClientCredentialsToken();
|
|
||||||
console.log('✅ Got client credentials token');
|
|
||||||
|
|
||||||
// Step 2: Get device authorization code
|
|
||||||
const { deviceCode, userCode, verificationUriComplete } = await getDeviceAuthorizationCode(clientCredentialsToken);
|
|
||||||
console.log(`📱 Open: ${verificationUriComplete}`);
|
|
||||||
console.log(`💳 Code: ${userCode}`);
|
|
||||||
|
|
||||||
const tokens = await pollForTokens(deviceCode);
|
|
||||||
|
|
||||||
if (otpKey) {
|
|
||||||
const totpCode = authenticator.generate(otpKey);
|
|
||||||
console.log(`🔑 TOTP Code (generated): ${totpCode}`);
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
await iframe.locator('button:has-text("I Accept")').click({ timeout: 5000 });
|
||||||
params.append('grant_type', 'refresh_token');
|
|
||||||
params.append('refresh_token', tokens.refresh_token);
|
|
||||||
params.append('client_id', '98f7e42c2e3a4f86a74eb43fbb41ed39');
|
|
||||||
params.append('client_secret', '0a2449a2-001a-451e-afec-3e812901c4d7');
|
|
||||||
|
|
||||||
const refreshed = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token', params.toString(), {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tokens.access_token = refreshed.data.access_token;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if refresh fails; use original token
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = await exchangeTokenForCookies(tokens.access_token);
|
return notify_game;
|
||||||
writeFileSync(cookiesPath, JSON.stringify(cookies, null, 2));
|
|
||||||
console.log('💾 Cookies saved to', cookiesPath);
|
|
||||||
return { bearerToken: tokens.access_token, cookies };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure user is logged in
|
// Ensure user is logged in
|
||||||
|
|
@ -194,7 +106,7 @@ const ensureLoggedIn = async (page, context) => {
|
||||||
if (!cfg.eg_email || !cfg.eg_password) return false;
|
if (!cfg.eg_email || !cfg.eg_password) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto('https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM, {
|
await page.goto(URL_LOGIN, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: cfg.login_timeout,
|
timeout: cfg.login_timeout,
|
||||||
});
|
});
|
||||||
|
|
@ -302,70 +214,12 @@ const ensureLoggedIn = async (page, context) => {
|
||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main function to claim Epic Games
|
// Main function to claim Epic Games
|
||||||
export const claimEpicGamesNew = async () => {
|
export const claimEpicGamesNew = async () => {
|
||||||
console.log('Starting Epic Games claimer (new mode, cookies + API)');
|
console.log('Starting Epic Games claimer (new mode, browser-based)');
|
||||||
const db = await jsonDb('epic-games.json', {});
|
const db = await jsonDb('epic-games.json', {});
|
||||||
const notify_games = [];
|
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, {
|
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
|
||||||
headless: cfg.headless,
|
headless: cfg.headless,
|
||||||
viewport: { width: cfg.width, height: cfg.height },
|
viewport: { width: cfg.width, height: cfg.height },
|
||||||
|
|
@ -382,8 +236,19 @@ export const claimEpicGamesNew = async () => {
|
||||||
const page = context.pages().length ? context.pages()[0] : await context.newPage();
|
const page = context.pages().length ? context.pages()[0] : await context.newPage();
|
||||||
await page.setViewportSize({ width: cfg.width, height: cfg.height });
|
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)
|
// Use device auths if available (from legacy mode)
|
||||||
const deviceAuths = await getDeviceAuths();
|
const deviceAuths = await getAccountAuth();
|
||||||
if (deviceAuths && cfg.eg_email) {
|
if (deviceAuths && cfg.eg_email) {
|
||||||
const accountAuth = deviceAuths[cfg.eg_email];
|
const accountAuth = deviceAuths[cfg.eg_email];
|
||||||
if (accountAuth) {
|
if (accountAuth) {
|
||||||
|
|
@ -401,23 +266,13 @@ export const claimEpicGamesNew = async () => {
|
||||||
let user;
|
let user;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await getValidAuth({
|
|
||||||
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' });
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
||||||
user = await ensureLoggedIn(page, context);
|
user = await ensureLoggedIn(page, context);
|
||||||
db.data[user] ||= {};
|
db.data[user] ||= {};
|
||||||
|
|
||||||
|
const freeGames = await fetchFreeGamesAPI(page);
|
||||||
|
console.log('Free games via API:', freeGames.map(g => g.pageSlug));
|
||||||
|
|
||||||
for (const game of freeGames) {
|
for (const game of freeGames) {
|
||||||
const result = await claimGame(page, game);
|
const result = await claimGame(page, game);
|
||||||
notify_games.push(result);
|
notify_games.push(result);
|
||||||
|
|
@ -429,12 +284,14 @@ export const claimEpicGamesNew = async () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeFileSync(COOKIES_PATH, JSON.stringify(await context.cookies(), null, 2));
|
// Save cookies to file
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
writeFileSync(COOKIES_PATH, JSON.stringify(cookies, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.exitCode ||= 1;
|
process.exitCode ||= 1;
|
||||||
console.error('--- Exception (new epic):');
|
console.error('--- Exception (new epic):');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (error.message && process.exitCode !== 130) notify(`epic-games (new) failed: ${error.message.split('\\n')[0]}`);
|
if (error.message && process.exitCode !== 130) notify(`epic-games (new) failed: ${error.message.split('\n')[0]}`);
|
||||||
} finally {
|
} finally {
|
||||||
await db.write();
|
await db.write();
|
||||||
if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) {
|
if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) {
|
||||||
|
|
@ -447,7 +304,3 @@ export const claimEpicGamesNew = async () => {
|
||||||
}
|
}
|
||||||
await context.close();
|
await context.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
export default claimEpicGamesNew;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue