-
-
Notifications
You must be signed in to change notification settings - Fork 156
/
epic-games.js
258 lines (227 loc) · 16.9 KB
/
epic-games.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib';
import path from 'path';
import { existsSync, writeFileSync } from 'fs';
import { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js';
import { cfg } from './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;
console.log(datetime(), 'started checking epic-games');
const db = await jsonDb('epic-games.json');
db.data ||= {};
handleSIGINT();
// get current promotionalOffers from json instead of checking the website
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // otherwise got UNABLE_TO_GET_ISSUER_CERT_LOCALLY
const promoJson = await (await fetch(`https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?country=${cfg.eg_country}`)).json(); // ?locale=en-US
const currentGames = promoJson.data.Catalog.searchStore.elements.filter(e => e.promotions?.promotionalOffers?.length);
const gameURL = e => `https://store.epicgames.com/p/${e.productSlug || e.offerMappings[0].pageSlug}`; // e.urlSlug may be wrong and lead to 404, e.catalogNs.mappings[0].pageSlug leads to base game for add-ons!
console.log('Free games:', currentGames.map(e => `${e.title} - ${gameURL(e)}`));
// TODO check if there are new games to claim before launching browser? https://github.com/vogler/free-games-claimer/issues/29
// Options:
// 1. Check order history (https://www.epicgames.com/account/v2/payment/ajaxGetOrderHistory) - only contains the last 10 orders
// 2. Check epic-games.json - would need to know the logged in user for `cfg.dir.browser`
// However, this may not always speed up the process since a game may have already been claimed before.
// https://www.nopecha.com extension source from https://github.com/NopeCHA/NopeCHA/releases/tag/0.1.16
// const ext = path.resolve('nopecha'); // used in Chromium, currently not needed in Firefox
// https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
// chrome will not work in linux arm64, only chromium
// channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated?
// userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
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-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
args: [ // https://peter.sh/experiments/chromium-command-line-switches
// don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.'
// '--restore-last-session', // does not apply for crash/killed
'--hide-crash-restore-bubble',
// `--disable-extensions-except=${ext}`,
// `--load-extension=${ext}`,
],
// ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
});
// 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
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent));
if (cfg.record && cfg.debug) {
// 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()));
}
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: '/'}]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago.
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
// page.click('button:has-text("Accept All Cookies")').catch(_ => { }); // Not needed anymore since we set the cookie above. Clicking this did not always work since the message was animated in too slowly.
while (await page.locator('a[role="button"]:has-text("Sign In")').count() > 0) {
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); // give user some extra time to log in
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 email = cfg.eg_email || await prompt({message: 'Enter email'});
const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'}));
if (email && password) {
await page.click('text=Sign in with Epic Games');
await page.fill('#email', email);
await page.fill('#password', password);
await page.click('button[type="submit"]');
page.waitForSelector('#h_captcha_challenge_login_prod iframe').then(() => {
console.error('Got a captcha during login (likely due to too many attempts)! You may solve it in the browser, get a new IP or try again in a few hours.');
notify('epic-games: got captcha during login. Please check.');
}).catch(_ => { });
// handle MFA, but don't await it
page.waitForURL('**/id/login/mfa**').then(async () => {
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 ...');
// TODO locator for text (email or app?)
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
await page.type('input[name="code-input-0"]', otp.toString());
await page.click('button[type="submit"]');
}).catch(_ => { });
} else {
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
process.exit(1);
}
}
await page.waitForURL(URL_CLAIM);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
}
user = await page.locator('#user span').first().innerHTML();
console.log(`Signed in as ${user}`);
db.data[user] ||= {};
// This URL will order all free games, but it will fail if some games have already been claimed:
// const purchaseURL = 'https://store.epicgames.com/purchase?' + currentGames.map(e => `offers=1-${e.namespace}-${e.id}`).join('&');
for (const game of currentGames) {
const url = gameURL(game);
await page.goto(url); // , { waitUntil: 'domcontentloaded' });
const btnText = await page.locator('//button[@data-testid="purchase-cta-button"][not(contains(.,"Loading"))]').first().innerText(); // barrier to block until page is loaded
// 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+');
await page.click('button:has-text("Continue")', { delay: 111 });
await page.waitForTimeout(2000);
}
// const title = await page.locator('h1').first().innerText();
const title = game.title;
const game_id = page.url().split('/').pop();
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:', title);
const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below
const p = path.resolve(cfg.dir.screenshots, 'epic-games', `${game_id}.png`);
if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
if (btnText.toLowerCase() == 'in library') {
console.log(' Already in library! Nothing to claim.');
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
} else if (btnText.toLowerCase() == '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';
// TODO claim base game if it is free
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")');
} else { // GET
console.log(' Not in library yet! Claim!');
// go to purchase of unclaimed game - https://github.com/vogler/free-games-claimer/issues/127
const purchaseURL = `https://store.epicgames.com/purchase?offers=1-${game.namespace}-${game.id}`;
await page.goto(purchaseURL);
// click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent?
page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox?
// Accept End User License Agreement (only needed once)
page.locator('input#agree').waitFor().then(async () => {
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(_ => { });
// skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region
if (await page.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';
continue;
}
page.locator('.payment-pin-code').waitFor().then(async () => {
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 page.locator('input.payment-pin-code__input').first().type(cfg.eg_parentalpin);
await page.locator('button:has-text("Continue")').click({ delay: 11 });
}).catch(_ => { });
if (cfg.debug) await page.pause();
if (cfg.dryrun) {
console.log(' DRYRUN=1 -> Skip order!');
notify_game.status = 'skipped';
continue;
}
// After successful order using the `purchaseURL`-method, the page is just empty, without any 'Thanks for your order', so we wait for the response of their API. Note: no await, and start waiting before final click to 'Place Order'.
const r = page.waitForResponse(r => r.url().startsWith('https://payment-website-pci.ol.epicgames.com/purchase/confirm-order'));
// Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591
await page.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 = page.locator('button:has-text("I Agree")');
btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree'
// May fail if game is already claimed with text 'Sorry, there is an error with your cart and we cannot complete the purchase. Please close this window and check your cart list.'
try {
// context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s?
const captcha = page.locator('#h_captcha_challenge_checkout_free_prod iframe');
captcha.waitFor().then(async () => { // don't await, since element may not be shown
// console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.')
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 page.waitForTimeout(2000);
// const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`);
// await captcha.screenshot({ path: p });
// console.info(' Saved a screenshot of hcaptcha challenge to', p);
// console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge?
}).catch(_ => { }); // may time out if not shown
// await page.waitForSelector('text=Thanks for your order!'); // not shown for order via `purchaseURL`
const rt = await (await r).text(); // TODO blocks if not claimed?
const rj = JSON.parse(rt);
if (rj?.receiptResponse?.orderStatus != 'COMPLETED') {
console.error('Unexpected confirm-order response. Message:', rj.message);
console.log(rj);
continue;
}
db.data[user][game_id].status = 'claimed';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(' Claimed successfully!');
// context.setDefaultTimeout(cfg.timeout);
} 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 = path.resolve(cfg.dir.screenshots, 'epic-games', 'failed', `${game_id}_${filenamify(datetime())}.png`);
await page.screenshot({ path: p, fullPage: true });
db.data[user][game_id].status = 'failed';
}
notify_game.status = db.data[user][game_id].status; // claimed or failed
}
}
} catch (error) {
console.error(error); // .toString()?
process.exitCode ||= 1;
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'
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()));
await context.close();