diff --git a/epic-games.js b/epic-games.js index beb3cd7..653a00e 100644 --- a/epic-games.js +++ b/epic-games.js @@ -1,17 +1,20 @@ -import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra +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 { getCookies, setPuppeteerCookies, userHasValidCookie, convertImportCookies } from './src/cookie.js'; +import { getAccountAuth, setAccountAuth, getDeviceAuths, writeDeviceAuths } 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'); +console.log(datetime(), 'started checking epic-games (GraphQL API mode)'); if (cfg.eg_mode === 'new') { const { claimEpicGamesNew } = await import('./epic-claimer-new.js'); @@ -26,7 +29,7 @@ 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);'); // apparently Firefox removes duplicates (and sorts), so no problem appending every time + 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.'); } @@ -35,28 +38,25 @@ if (existsSync(browserPrefs)) { 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', // Windows UA avoids "device not supported"; update when browser version changes - locale: 'en-US', // ignore OS locale to be sure to have english text for locators - recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800 - recordHar: cfg.record ? { path: `data/record/eg-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools - handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved - // user settings for firefox have to be put in $BROWSER_DIR/user.js - args: [], // https://wiki.mozilla.org/Firefox/CommandLineOptions + 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); -// Without stealth plugin, the website shows an hcaptcha on login with username/password and in the last step of claiming a game. It may have other heuristics like unsuccessful logins as well. After <6h (TBD) it resets to no captcha again. Getting a new IP also resets. await stealth(context); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); -const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist -await page.setViewportSize({ width: cfg.width, height: cfg.height }); // workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it +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 (screen dimensions, user agent) +// some debug info about the page if (cfg.debug) { - /* global window, navigator */ const debugInfo = await page.evaluate(() => { const { width, height, availWidth, availHeight } = window.screen; return { @@ -66,8 +66,8 @@ if (cfg.debug) { }); console.debug(debugInfo); } + if (cfg.debug_network) { - // const filter = _ => true; 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())); @@ -76,36 +76,249 @@ if (cfg.debug_network) { const notify_games = []; let user; +// GraphQL query for free games +const FREE_GAMES_QUERY = { + operationName: 'searchStoreQuery', + variables: { + allowCountries: 'US', + category: 'games/edition/base|software/edition/base|editors|bundles/games', + count: 1000, + country: 'US', + sortBy: 'relevancy', + sortDir: 'DESC', + start: 0, + withPrice: true, + }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: '7d58e12d9dd8cb14c84a3ff18d360bf9f0caa96bf218f2c5fda68ba88d68a437', + }, + }, +}; + +// 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 GraphQL API +const getFreeGamesFromGraphQL = async () => { + const items = []; + let start = 0; + const pageLimit = 1000; + + do { + const response = await page.evaluate(async (query, startOffset) => { + const variables = { ...query.variables, start: startOffset }; + const resp = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operationName: query.operationName, + variables: JSON.stringify(variables), + extensions: JSON.stringify(query.extensions), + }), + }); + return await resp.json(); + }, [FREE_GAMES_QUERY, start]); + + const elements = response.data?.Catalog?.searchStore?.elements; + if (!elements) break; + + items.push(...elements); + start += pageLimit; + } while (items.length < pageLimit); + + // Filter free games + const freeGames = items.filter(game => + game.price?.totalPrice?.discountPrice === 0 + ); + + // Deduplicate by productSlug + const uniqueGames = new Map(); + for (const game of freeGames) { + if (!uniqueGames.has(game.productSlug)) { + uniqueGames.set(game.productSlug, game); + } + } + + return Array.from(uniqueGames.values()).map(game => ({ + offerId: game.id, + offerNamespace: game.namespace, + productName: game.title, + productSlug: game.productSlug || game.urlSlug, + })); +}; + +// 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 + const bearerCookie = /** @type {import('playwright-firefox').Cookie} */ ({ + 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; +}; + +// Exchange token for cookies (alternative method) +const exchangeTokenForCookies = async (accessToken) => { + try { + const cookies = await page.evaluate(async (token) => { + const resp = await fetch('https://store.epicgames.com/', { + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + return await resp.headers.get('set-cookie'); + }, accessToken); + + return cookies; + } catch { + return null; + } +}; + +// 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: '/' }, // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago. - { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, // gets rid of 'To continue, please provide your date of birth', https://github.com/vogler/free-games-claimer/issues/275, USK number doesn't seem to matter, cookie from 'Fallout 3: Game of the Year Edition' + { 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' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); if (cfg.time) console.timeEnd('startup'); if (cfg.time) console.time('login'); + // Try device auth first + const deviceAuthLoginSuccess = await loginWithDeviceAuth(); + + // If device auth failed, try regular login 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.'); + 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); // give user some extra time to log in + 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(); // finishes potential recording + await context.close(); process.exit(1); } }; - // If captcha or "Incorrect response" is visible, do not auto-submit; wait for manual solve. const hasCaptcha = await page.locator('.h_captcha_challenge iframe, text=Incorrect response').count() > 0; if (hasCaptcha) { console.warn('Captcha/Incorrect response detected. Please solve manually in the browser.'); @@ -122,6 +335,7 @@ try { 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 { @@ -132,58 +346,74 @@ try { return; } }; + const watchMfaStep = async () => { try { await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); - console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...'); - 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!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them + 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'); // 'null' if !isloggedin + + 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'); - // Detect free games - const game_loc = page.locator('a:has(span:text-is("Free Now"))'); - await game_loc.last().waitFor().catch(_ => { - // rarely there are no free games available -> catch Timeout - // waiting for timeout; alternative would be waiting for "coming soon" - // see https://github.com/vogler/free-games-claimer/issues/210#issuecomment-1727420943 - console.error('Seems like currently there are no free games available in your region...'); - // urls below should then be an empty list - }); - // clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25 - // debug showed that in those cases the href was still correct, so we `goto` the urls instead of clicking. - // Alternative: parse the json loaded to build the page https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions - // i.e. filter data.Catalog.searchStore.elements for .promotions.promotionalOffers being set and build URL with .catalogNs.mappings[0].pageSlug or .urlSlug if not set to some wrong id like it was the case for spirit-of-the-north-f58a66 - this is also what's done here: https://github.com/claabs/epicgames-freegames-node/blob/938a9653ffd08b8284ea32cf01ac8727d25c5d4c/src/puppet/free-games.ts#L138-L213 - const urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href'))); - const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s); - console.log('Free games:', urls); + // Get free games + const freeGames = await getAllFreeGames(); + console.log('Free games:', freeGames.map(g => g.productName)); - for (const url of urls) { + // 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}):
Free games available!
Click here to claim: ${checkoutUrl}`); + } + + // 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'); - await page.goto(url); // , { waitUntil: 'domcontentloaded' }); - const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"] >> :has-text("e"), :has-text("i")').first(); // when loading, the button text is empty -> need to wait for some text {'get', 'in library', 'requires base game'} -> just wait for e or i to not be too specific; :text-matches("\w+") somehow didn't work - https://github.com/vogler/free-games-claimer/issues/375 + + 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(); // barrier to block until page is loaded + 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" - This shouldn\'t happen due to cookie set above. Please report to https://github.com/vogler/free-games-claimer/issues/275'); + 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(); @@ -196,66 +426,49 @@ try { } let title; - let bundle_includes; 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 ', ''); - // h1 first didn't exist for bundles but now it does... However h1 would e.g. be 'FalloutĀ® Classic Collection' instead of 'Fallout Classic Collection' - try { - bundle_includes = await Promise.all((await page.locator('.product-card-top-row h5').all()).map(b => b.innerText())); - } catch (e) { - console.error('Failed to get "Bundle Includes":', e); - } } else { title = await page.locator('h1').first().innerText(); } - const game_id = page.url().split('/').pop(); - const existedInDb = db.data[user][game_id]; - db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only! - console.log('Current free game:', chalk.blue(title)); - if (bundle_includes) console.log(' This bundle includes:', bundle_includes); - const notify_game = { title, url, status: 'failed' }; - notify_games.push(notify_game); // status is updated below - if (btnText == 'in library') { + 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: ${url}`); + if (!existedInDb) await notify(`Game already in library: ${purchaseUrl}`); notify_game.status = 'existed'; - db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed - if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed + 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_id].status ||= 'failed:requires-base-game'; - // if base game is free, add to queue as well - const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href'); - console.log(' Base game:', baseUrl); - // await page.click('a:has-text("Overview")'); - // re-add original add-on to queue after base game - urls.push(baseUrl, url); // add base game to the list of games to claim and re-add add-on itself - } else { // GET + db.data[user][game.offerId].status ||= 'failed:requires-base-game'; + } else { console.log(' Not in library yet! Click', btnText); - await purchaseBtn.click({ delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough + await purchaseBtn.click({ delay: 11 }); - // Accept End User License Agreement (only needed once) - const acceptEulaIfShown = async () => { - try { - await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 }); - console.log(' Accept End User License Agreement (only needed once)'); - await page.locator('input#agree').check(); - await page.locator('button:has-text("Accept")').click(); - } catch { - return; - } - }; - acceptEulaIfShown(); + // 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 + } - // it then creates an iframe for the purchase await page.waitForSelector('#webPurchaseContainer iframe'); const iframe = page.frameLocator('#webPurchaseContainer iframe'); - // skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 + 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_id].status = notify_game.status = 'unavailable-in-region'; + db.data[user][game.offerId].status = notify_game.status = 'unavailable-in-region'; if (cfg.time) console.timeEnd('claim game'); continue; } @@ -283,75 +496,77 @@ try { continue; } - // Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591 await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); - // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 const btnAgree = iframe.locator('button:has-text("I Accept")'); - const acceptIfRequired = async () => { - try { - await btnAgree.waitFor({ timeout: 10000 }); - await btnAgree.click(); - } catch { - return; - } - }; // EU: wait for and click 'I Agree' - acceptIfRequired(); try { - // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? - const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe'); - const watchCaptchaChallenge = async () => { - try { - await captcha.waitFor({ timeout: 10000 }); - console.error(' Got hcaptcha challenge! Lost trust due to too many login attempts? You can solve the captcha in the browser or get a new IP address.'); - await notify(`epic-games: got captcha challenge for.\nGame link: ${url}`); - } catch { - return; - } - }; // may time out if not shown - const watchCaptchaFailure = async () => { - try { - await iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor({ timeout: 10000 }); - console.error(' Failed to challenge captcha, please try again later.'); - await notify('epic-games: failed to challenge captcha. Please check.'); - } catch { - return; - } - }; - watchCaptchaChallenge(); - watchCaptchaFailure(); + 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_id].status = 'claimed'; - db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time + db.data[user][game.offerId].status = 'claimed'; + db.data[user][game.offerId].time = datetime(); console.log(' Claimed successfully!'); - // context.setDefaultTimeout(cfg.timeout); + + // 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! Try again if NopeCHA timed out. Click the extension to see if you ran out of credits (refill after 24h). To avoid captchas try to get a new IP or set a cookie from https://www.hcaptcha.com/accessibility'); console.error(' Failed to claim! To avoid captchas try to get a new IP address.'); - const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`); + const p = screenshot('failed', `${game.offerId}_${filenamify(datetime())}.png`); await page.screenshot({ path: p, fullPage: true }); - db.data[user][game_id].status = 'failed'; + db.data[user][game.offerId].status = 'failed'; } - notify_game.status = db.data[user][game_id].status; // claimed or failed + notify_game.status = db.data[user][game.offerId].status; - const p = screenshot(`${game_id}.png`); - if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long... + 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); // .toString()? + console.error(error); if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`); } finally { - await db.write(); // write out json db - if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { // don't notify if all have status 'existed', 'manual', 'requires base game', 'unavailable-in-region', 'skipped' + 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}):
${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(); diff --git a/package-lock.json b/package-lock.json index 1f14566..903bc52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ }, "devDependencies": { "@stylistic/eslint-plugin-js": "^4.2.0", - "eslint": "^9.26.0" + "eslint": "^9.26.0", + "typescript": "^5.9.3" }, "engines": { "node": ">=17" @@ -2876,6 +2877,20 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -4863,6 +4878,12 @@ "mime-types": "^3.0.0" } }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, "universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index cf2b41b..8944516 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ "node": ">=17" }, "dependencies": { + "axios": "^1.7.9", "chalk": "^5.4.1", "cross-env": "^7.0.3", "dotenv": "^16.5.0", "enquirer": "^2.4.1", "fingerprint-injector": "^2.1.66", - "axios": "^1.7.9", "lowdb": "^7.0.1", "otplib": "^12.0.1", "playwright-firefox": "^1.52.0", @@ -33,6 +33,7 @@ }, "devDependencies": { "@stylistic/eslint-plugin-js": "^4.2.0", - "eslint": "^9.26.0" + "eslint": "^9.26.0", + "typescript": "^5.9.3" } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..9985af6 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,43 @@ +// Epic Games API Constants +// Based on https://github.com/claabs/epicgames-freegames-node + +export const EPIC_CLIENT_ID = '875a3b57d3a640a6b7f9b4e883463ab4'; +export const CSRF_ENDPOINT = 'https://www.epicgames.com/id/api/csrf'; +export const ACCOUNT_CSRF_ENDPOINT = 'https://www.epicgames.com/account/v2/refresh-csrf'; +export const ACCOUNT_SESSION_ENDPOINT = 'https://www.epicgames.com/account/personal'; +export const LOGIN_ENDPOINT = 'https://www.epicgames.com/id/api/login'; +export const REDIRECT_ENDPOINT = 'https://www.epicgames.com/id/api/redirect'; +export const GRAPHQL_ENDPOINT = 'https://store.epicgames.com/graphql'; +export const ARKOSE_BASE_URL = 'https://epic-games-api.arkoselabs.com'; +export const CHANGE_EMAIL_ENDPOINT = 'https://www.epicgames.com/account/v2/api/email/change'; +export const USER_INFO_ENDPOINT = 'https://www.epicgames.com/account/v2/personal/ajaxGet'; +export const RESEND_VERIFICATION_ENDPOINT = 'https://www.epicgames.com/account/v2/resendEmailVerification'; +export const REPUTATION_ENDPOINT = 'https://www.epicgames.com/id/api/reputation'; +export const STORE_CONTENT = 'https://store-content-ipv4.ak.epicgames.com/api/en-US/content'; +export const EMAIL_VERIFY = 'https://www.epicgames.com/id/api/email/verify'; +export const SETUP_MFA = 'https://www.epicgames.com/account/v2/security/ajaxUpdateTwoFactorAuthSettings'; +export const FREE_GAMES_PROMOTIONS_ENDPOINT = 'https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions'; +export const STORE_HOMEPAGE = 'https://store.epicgames.com/'; +export const STORE_HOMEPAGE_EN = `${STORE_HOMEPAGE}en-US/`; +export const STORE_CART_EN = `${STORE_HOMEPAGE}en-US/cart`; +export const ORDER_CONFIRM_ENDPOINT = 'https://payment-website-pci.ol.epicgames.com/purchase/confirm-order'; +export const ORDER_PREVIEW_ENDPOINT = 'https://payment-website-pci.ol.epicgames.com/purchase/order-preview'; +export const EPIC_PURCHASE_ENDPOINT = 'https://www.epicgames.com/store/purchase'; +export const MFA_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/api/login/mfa'; +export const UNREAL_SET_SID_ENDPOINT = 'https://www.unrealengine.com/id/api/set-sid'; +export const TWINMOTION_SET_SID_ENDPOINT = 'https://www.twinmotion.com/id/api/set-sid'; +export const CLIENT_REDIRECT_ENDPOINT = `https://www.epicgames.com/id/api/client/${EPIC_CLIENT_ID}`; +export const AUTHENTICATE_ENDPOINT = `https://www.epicgames.com/id/api/authenticate`; +export const LOCATION_ENDPOINT = `https://www.epicgames.com/id/api/location`; +export const PHASER_F_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/phaser/f'; +export const PHASER_BATCH_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/phaser/batch'; +export const TALON_IP_ENDPOINT = 'https://talon-service-v4-prod.ak.epicgames.com/v1/init/ip'; +export const TALON_INIT_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/init'; +export const TALON_EXECUTE_ENDPOINT = 'https://talon-service-v4-prod.ak.epicgames.com/v1/init/execute'; +export const TALON_WEBSITE_BASE = 'https://talon-website-prod.ak.epicgames.com'; +export const TALON_REFERRER = 'https://talon-website-prod.ak.epicgames.com/challenge?env=prod&flow=login_prod&origin=https%3A%2F%2Fwww.epicgames.com'; +export const ACCOUNT_OAUTH_TOKEN = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token'; +export const ACCOUNT_OAUTH_DEVICE_AUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization'; +export const ID_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/login'; +export const EULA_AGREEMENTS_ENDPOINT = 'https://eulatracking-public-service-prod-m.ol.epicgames.com/eulatracking/api/public/agreements'; +export const REQUIRED_EULAS = ['epicgames_privacy_policy_no_table', 'egstore']; diff --git a/src/cookie.ts b/src/cookie.ts new file mode 100644 index 0000000..33e7f69 --- /dev/null +++ b/src/cookie.ts @@ -0,0 +1,171 @@ +// Cookie management for Epic Games +// Based on https://github.com/claabs/epicgames-freegames-node + +import fs from 'node:fs'; +import path from 'node:path'; +import tough from 'tough-cookie'; +import { filenamify } from './util.js'; +import { dataDir } from './util.js'; + +const CONFIG_DIR = dataDir('config'); +const DEFAULT_COOKIE_NAME = 'default'; + +// Ensure config directory exists +if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); +} + +function getCookiePath(username) { + const fileSafeUsername = filenamify(username); + const cookieFilename = path.join(CONFIG_DIR, `${fileSafeUsername}-cookies.json`); + return cookieFilename; +} + +// Cookie whitelist - only these cookies are stored +const COOKIE_WHITELIST = ['EPIC_SSO_RM', 'EPIC_SESSION_AP', 'EPIC_DEVICE']; + +// Cookie jar cache +const cookieJars = new Map(); + +function getCookieJar(username) { + let cookieJar = cookieJars.get(username); + if (cookieJar) { + return cookieJar; + } + const cookieFilename = getCookiePath(username); + cookieJar = new tough.CookieJar(); + cookieJars.set(username, cookieJar); + return cookieJar; +} + +// Convert EditThisCookie format to tough-cookie file store format +export function editThisCookieToToughCookieFileStore(etc) { + const tcfs = {}; + + etc.forEach((etcCookie) => { + const domain = etcCookie.domain.replace(/^\./, ''); + const expires = etcCookie.expirationDate + ? new Date(etcCookie.expirationDate * 1000).toISOString() + : undefined; + const { path: cookiePath, name } = etcCookie; + + if (COOKIE_WHITELIST.includes(name)) { + const temp = { + [domain]: { + [cookiePath]: { + [name]: { + key: name, + value: etcCookie.value, + expires, + domain, + path: cookiePath, + secure: etcCookie.secure, + httpOnly: etcCookie.httpOnly, + hostOnly: etcCookie.hostOnly, + }, + }, + }, + }; + Object.assign(tcfs, temp); + } + }); + + return tcfs; +} + +// Get cookies as simple object +export function getCookies(username) { + const cookieJar = getCookieJar(username); + const cookies = cookieJar.toJSON()?.cookies || []; + return cookies.reduce((accum, cookie) => { + if (cookie.key && cookie.value) { + return { ...accum, [cookie.key]: cookie.value }; + } + return accum; + }, {}); +} + +// Get raw cookies in tough-cookie file store format +export async function getCookiesRaw(username) { + const cookieFilename = getCookiePath(username); + try { + const existingCookies = JSON.parse(fs.readFileSync(cookieFilename, 'utf8')); + return existingCookies; + } catch { + return {}; + } +} + +// Set cookies from Playwright/Cookie format +export async function setPuppeteerCookies(username, newCookies) { + const cookieJar = getCookieJar(username); + + for (const cookie of newCookies) { + const domain = cookie.domain.replace(/^\./, ''); + const tcfsCookie = new tough.Cookie({ + key: cookie.name, + value: cookie.value, + expires: cookie.expires ? new Date(cookie.expires * 1000) : undefined, + domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + hostOnly: !cookie.domain.startsWith('.'), + }); + + try { + await cookieJar.setCookie(tcfsCookie, `https://${domain}`); + } catch (err) { + console.error('Error setting cookie:', err); + } + } +} + +// Delete cookies for a user +export async function deleteCookies(username) { + const cookieFilename = getCookiePath(username || DEFAULT_COOKIE_NAME); + try { + fs.unlinkSync(cookieFilename); + } catch { + // File doesn't exist, that's fine + } +} + +// Check if user has a valid cookie +export async function userHasValidCookie(username, cookieName) { + const cookieFilename = getCookiePath(username); + try { + const fileExists = fs.existsSync(cookieFilename); + if (!fileExists) return false; + + const cookieData = JSON.parse(fs.readFileSync(cookieFilename, 'utf8')); + const rememberCookieExpireDate = cookieData['epicgames.com']?.['/']?.[cookieName]?.expires; + if (!rememberCookieExpireDate) return false; + + return new Date(rememberCookieExpireDate) > new Date(); + } catch { + return false; + } +} + +// Convert imported cookies (EditThisCookie format) +export async function convertImportCookies(username) { + const cookieFilename = getCookiePath(username); + const fileExists = fs.existsSync(cookieFilename); + + if (fileExists) { + try { + const cookieData = fs.readFileSync(cookieFilename, 'utf8'); + const cookieTest = JSON.parse(cookieData); + + if (Array.isArray(cookieTest)) { + // Convert from EditThisCookie format + const tcfsCookies = editThisCookieToToughCookieFileStore(cookieTest); + fs.writeFileSync(cookieFilename, JSON.stringify(tcfsCookies, null, 2)); + } + } catch { + // Invalid format, delete file + fs.unlinkSync(cookieFilename); + } + } +} diff --git a/src/device-auths.ts b/src/device-auths.ts new file mode 100644 index 0000000..2fe0f88 --- /dev/null +++ b/src/device-auths.ts @@ -0,0 +1,38 @@ +// Device authentication management for Epic Games +// Based on https://github.com/claabs/epicgames-freegames-node + +import fs from 'node:fs'; +import path from 'node:path'; +import { dataDir } from './util.js'; + +const CONFIG_DIR = dataDir('config'); +const deviceAuthsFilename = path.join(CONFIG_DIR, 'device-auths.json'); + +// Ensure config directory exists +if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); +} + +export async function getDeviceAuths() { + try { + const deviceAuths = JSON.parse(fs.readFileSync(deviceAuthsFilename, 'utf-8')); + return deviceAuths; + } catch { + return undefined; + } +} + +export async function getAccountAuth(account) { + const deviceAuths = await getDeviceAuths(); + return deviceAuths?.[account]; +} + +export async function writeDeviceAuths(deviceAuths) { + fs.writeFileSync(deviceAuthsFilename, JSON.stringify(deviceAuths, null, 2)); +} + +export async function setAccountAuth(account, accountAuth) { + const existingDeviceAuths = (await getDeviceAuths()) ?? {}; + existingDeviceAuths[account] = accountAuth; + await writeDeviceAuths(existingDeviceAuths); +}