run eslint --fix .

This commit is contained in:
Ralf Vogler 2023-11-08 01:16:49 +01:00
parent 011eddf97a
commit 0832ae57f5
10 changed files with 259 additions and 263 deletions

View file

@ -11,7 +11,9 @@ export const cfg = {
dryrun: process.env.DRYRUN == '1', // don't claim anything
interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip
show: process.env.SHOW == '1', // run non-headless
get headless() { return !this.debug && !this.show },
get headless() {
return !this.debug && !this.show;
},
width: Number(process.env.WIDTH) || 1920, // width of the opened browser
height: Number(process.env.HEIGHT) || 1080, // height of the opened browser
timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s
@ -23,7 +25,7 @@ export const cfg = {
return {
browser: process.env.BROWSER_DIR || dataDir('browser'), // for multiple accounts or testing
screenshots: process.env.SCREENSHOTS_DIR || dataDir('screenshots'), // set to 0 to disable screenshots
}
};
},
// auth epic-games
eg_email: process.env.EG_EMAIL || process.env.EMAIL,

View file

@ -28,7 +28,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
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 firefox (macOS): Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0
// userAgent firefox (docker): Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0
locale: "en-US", // ignore OS locale to be sure to have english text for locators
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
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -64,7 +64,7 @@ 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 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
@ -77,12 +77,12 @@ try {
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!`);
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'}));
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);
@ -100,7 +100,7 @@ try {
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
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.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
await page.click('button[type="submit"]');
}).catch(_ => { });
@ -134,7 +134,7 @@ try {
// 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
// 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
// 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);
@ -235,7 +235,7 @@ try {
// 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.locator('text=Thanks for your order!').waitFor({state: 'attached'});
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
console.log(' Claimed successfully!');
@ -260,8 +260,7 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
if (error.message && process.exitCode != 130)
notify(`epic-games failed: ${error.message.split('\n')[0]}`);
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'

25
gog.js
View file

@ -14,7 +14,7 @@ const db = await jsonDb('gog.json', {});
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height },
locale: "en-US", // ignore OS locale to be sure to have english text for locators -> done via /en in URL
locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL
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/gog-${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
@ -31,7 +31,7 @@ const notify_games = [];
let user;
try {
await context.addCookies([{name: 'CookieConsent', value: '{stamp:%274oR8MJL+bxVlG6g+kl2we5+suMJ+Tv7I4C5d4k+YY4vrnhCD+P23RQ==%27%2Cnecessary:true%2Cpreferences:true%2Cstatistics:true%2Cmarketing:true%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1672331618201%2Cregion:%27de%27}', domain: 'www.gog.com', path: '/'}]); // to not waste screen space when non-headless
await context.addCookies([{ name: 'CookieConsent', value: '{stamp:%274oR8MJL+bxVlG6g+kl2we5+suMJ+Tv7I4C5d4k+YY4vrnhCD+P23RQ==%27%2Cnecessary:true%2Cpreferences:true%2Cstatistics:true%2Cmarketing:true%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1672331618201%2Cregion:%27de%27}', domain: 'www.gog.com', path: '/' }]); // to not waste screen space when non-headless
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
@ -45,11 +45,11 @@ try {
await page.waitForSelector('#GalaxyAccountsFrameContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#GalaxyAccountsFrameContainer iframe');
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!`);
console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
if (cfg.gog_email && cfg.gog_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.gog_email || await prompt({message: 'Enter email'});
const password = email && (cfg.gog_password || await prompt({type: 'password', message: 'Enter password'}));
const email = cfg.gog_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.gog_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
iframe.locator('a[href="/logout"]').click().catch(_ => { }); // Click 'Change account' (email from previous login is set in some cookie)
await iframe.locator('#login_username').fill(email);
@ -58,9 +58,9 @@ try {
// handle MFA, but don't await it
iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => {
console.log('Two-Step Verification - Enter security code');
console.log(await iframe.locator('.form__description').innerText())
const otp = await prompt({type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 4 || 'The code must be 4 digits!'}); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), {delay: 10});
console.log(await iframe.locator('.form__description').innerText());
const otp = await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 4 || 'The code must be 4 digits!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), { delay: 10 });
await iframe.locator('#second_step_authentication_send').click();
await page.waitForTimeout(1000); // TODO still needed with wait for username below?
}).catch(_ => { });
@ -71,7 +71,7 @@ try {
notify('gog: got captcha during login. Please check.');
// TODO solve reCAPTCHA?
}).catch(_ => { });
await page.waitForSelector('#menuUsername')
await page.waitForSelector('#menuUsername');
} else {
console.log('Waiting for you to login in the browser.');
await notify('gog: no longer signed in and not enough options set for automatic login.');
@ -129,7 +129,7 @@ try {
notify_games.push({ title, url, status });
if (status == 'claimed' && !cfg.gog_newsletter) {
console.log("Unsubscribe from 'Promotions and hot deals' newsletter");
console.log('Unsubscribe from \'Promotions and hot deals\' newsletter');
await page.goto('https://www.gog.com/en/account/settings/subscriptions');
await page.locator('li:has-text("Marketing communications through Trusted Partners") label').uncheck();
await page.locator('li:has-text("Promotions and hot deals") label').uncheck();
@ -139,13 +139,12 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
if (error.message && process.exitCode != 130)
notify(`gog failed: ${error.message.split('\n')[0]}`);
if (error.message && process.exitCode != 130) notify(`gog failed: ${error.message.split('\n')[0]}`);
} finally {
await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed
notify(`gog (${user}):<br>${html_game_list(notify_games)}`);
}
}
if (page.video()) console.log('Recorded video:', await page.video().path())
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close();

View file

@ -4,8 +4,7 @@ import { JSONFile } from 'lowdb/node';
import { datetime } from './util.js';
const datetime_UTCtoLocalTimezone = async file => {
if (!existsSync(file))
return console.error('File does not exist:', file);
if (!existsSync(file)) return console.error('File does not exist:', file);
const db = new Low(new JSONFile(file));
await db.read();
db.data ||= {};

View file

@ -1,5 +1,5 @@
/* eslint-disable no-constant-condition */
import { delay, html_game_list, notify } from "./util.js";
import { delay, html_game_list, notify } from './util.js';
const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL

View file

@ -16,7 +16,7 @@ const db = await jsonDb('prime-gaming.json', {});
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height },
locale: "en-US", // ignore OS locale to be sure to have english text for locators
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/pg-${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
@ -44,11 +44,11 @@ try {
console.error('Not signed in anymore.');
await page.click('button:has-text("Sign in")');
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!`);
console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
if (cfg.pg_email && cfg.pg_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.pg_email || await prompt({message: 'Enter email'});
const password = email && (cfg.pg_password || await prompt({type: 'password', message: 'Enter password'}));
const email = cfg.pg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
await page.fill('[name=email]', email);
await page.fill('[name=password]', password);
@ -66,7 +66,7 @@ try {
page.waitForURL('**/ap/mfa**').then(async () => {
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App');
await page.check('[name=rememberDevice]');
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_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
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_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.locator('input[name=otpCode]').pressSequentially(otp.toString());
await page.click('input[type="submit"]');
}).catch(_ => { });
@ -128,10 +128,10 @@ try {
const slug = await card.locator('a:has-text("Claim")').first().getAttribute('href');
const url = 'https://gaming.amazon.com' + slug.split('?')[0];
// await (await card.$('text=Claim')).click(); // goes to URL of game, no need to wait
external_info.push({title, url});
external_info.push({ title, url });
}
for (const {title, url} of external_info) {
console.log('Current free game:', title); //, url);
for (const { title, url } of external_info) {
console.log('Current free game:', title); // , url);
await page.goto(url, { waitUntil: 'domcontentloaded' });
if (cfg.debug) await page.pause();
if (cfg.dryrun) continue;
@ -298,7 +298,7 @@ try {
await page.keyboard.press('End'); // scroll to bottom to show all games
await page.waitForTimeout(1000); // wait for fade in animation
const viewportSize = page.viewportSize(); // current viewport size
await page.setViewportSize({...viewportSize, height: 3000}); // increase height, otherwise element screenshot is cut off at the top and bottom
await page.setViewportSize({ ...viewportSize, height: 3000 }); // increase height, otherwise element screenshot is cut off at the top and bottom
await games.screenshot({ path: p }); // screenshot of all claimed games
}
@ -357,7 +357,7 @@ try {
console.debug(' LinkAccountButton label:', unlinked_store);
const match = unlinked_store.match(/Link (.*) account/);
if (match && match.length == 2) unlinked_store = match[1];
} else if(await page.locator('text=Link game account').count()) { // epic-games only?
} else if (await page.locator('text=Link game account').count()) { // epic-games only?
console.error(' Missing account linking (epic-games specific button?):', await page.locator('button[data-a-target="gms-cta"]').innerText()); // TODO needed?
unlinked_store = 'epic-games';
}
@ -386,8 +386,7 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
if (error.message && process.exitCode != 130)
notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
if (error.message && process.exitCode != 130) notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
} finally {
await db.write(); // write out json db
if (notify_games.length) { // list should only include claimed games

View file

@ -23,7 +23,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
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
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/ue-${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
@ -42,7 +42,7 @@ 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 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
@ -52,12 +52,12 @@ try {
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!`);
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'}));
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);
@ -71,7 +71,7 @@ try {
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
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.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
await page.click('button[type="submit"]');
}).catch(_ => { });
@ -105,7 +105,7 @@ try {
const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below
// if (await p.locator('.btn .add-review-btn').count()) { // did not work
if((await p.getAttribute('class')).includes('asset--owned')) {
if ((await p.getAttribute('class')).includes('asset--owned')) {
console.log(' ↳ Already claimed');
if (db.data[user][id].status != 'claimed') {
db.data[user][id].status = 'existed';
@ -128,7 +128,7 @@ try {
const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' ');
console.log('Price: ', price[1], 'instead of', price[0]);
if (price[1] != '0') {
const err = 'Price is not 0! Exit! Please <a href="https://github.com/vogler/free-games-claimer/issues/44">report</a>.'
const err = 'Price is not 0! Exit! Please <a href="https://github.com/vogler/free-games-claimer/issues/44">report</a>.';
console.error(err);
notify('unrealengine: ' + err);
process.exit(1);
@ -142,7 +142,7 @@ try {
// maybe: Accept End User License Agreement
page.locator('[name=accept-label]').check().then(() => {
console.log('Accept End User License Agreement');
page.locator('span:text-is("Accept")').click() // otherwise matches 'Accept All Cookies'
page.locator('span:text-is("Accept")').click(); // otherwise matches 'Accept All Cookies'
}).catch(_ => { });
await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#webPurchaseContainer iframe');
@ -165,7 +165,7 @@ try {
const captcha = iframe.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.')
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.');
}).catch(_ => { }); // may time out if not shown
await page.waitForSelector('text=Thank you');
for (const id of ids) {
@ -192,8 +192,7 @@ try {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
if (error.message && process.exitCode != 130)
notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
if (error.message && process.exitCode != 130) notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
} finally {
await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed
@ -201,5 +200,5 @@ try {
}
}
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())
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close();

19
util.js
View file

@ -27,7 +27,7 @@ export const handleSIGINT = (context = null) => process.on('SIGINT', async () =>
if (context) await context.close(); // in order to save recordings also on SIGINT, we need to disable Playwright's handleSIGINT and close the context ourselves
});
export const stealth = async (context) => {
export const stealth = async context => {
// stealth with playwright: https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212
// https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
const enabledEvasions = [
@ -47,13 +47,13 @@ export const stealth = async (context) => {
'sourceurl',
// 'user-agent-override', // doesn't work since playwright has no page.browser()
'webgl.vendor',
'window.outerdimensions'
'window.outerdimensions',
];
const stealth = {
callbacks: [],
async evaluateOnNewDocument(...args) {
this.callbacks.push({ cb: args[0], a: args[1] });
}
},
};
for (const e of enabledEvasions) {
const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`);
@ -70,7 +70,10 @@ export const stealth = async (context) => {
import Enquirer from 'enquirer'; const enquirer = new Enquirer();
const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
enquirer.on('prompt', prompt => {
const t = setTimeout(() => { prompt.hint = () => 'timeout'; prompt.cancel(); }, timeout);
const t = setTimeout(() => {
prompt.hint = () => 'timeout';
prompt.cancel();
}, timeout);
prompt.on('submit', _ => clearTimeout(t));
prompt.on('cancel', _ => clearTimeout(t));
});
@ -78,14 +81,14 @@ const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
enquirer.use(timeoutPlugin(cfg.login_timeout)); // TODO may not want to have this timeout for all prompts; better extend Prompt and add a timeout prompt option
// single prompt that just returns the non-empty value instead of an object
// @ts-ignore
export const prompt = o => enquirer.prompt({name: 'name', type: 'input', message: 'Enter value', ...o}).then(r => r.name).catch(_ => {});
export const confirm = o => prompt({type: 'confirm', message: 'Continue?', ...o});
export const prompt = o => enquirer.prompt({ name: 'name', type: 'input', message: 'Enter value', ...o }).then(r => r.name).catch(_ => {});
export const confirm = o => prompt({ type: 'confirm', message: 'Continue?', ...o });
// notifications via apprise CLI
import { exec } from 'child_process';
import { cfg } from './config.js';
export const notify = (html) => new Promise((resolve, reject) => {
export const notify = html => new Promise((resolve, reject) => {
if (!cfg.notify) return resolve();
const title = cfg.notify_title ? `-t ${cfg.notify_title}` : '';
exec(`apprise ${cfg.notify} -i html '${title}' -b '${html}'`, (error, stdout, stderr) => {
@ -102,6 +105,6 @@ export const notify = (html) => new Promise((resolve, reject) => {
});
});
export const escapeHtml = (unsafe) => unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
export const escapeHtml = unsafe => unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('\'', '&#039;');
export const html_game_list = games => games.map(g => `- <a href="${g.url}">${escapeHtml(g.title)}</a> (${g.status})`).join('<br>');

View file

@ -1,9 +1,9 @@
// check if running the latest version
import {log} from 'console';
import { log } from 'console';
import { exec } from 'child_process';
const execp = (cmd) => new Promise((resolve, reject) => {
const execp = cmd => new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
if (stderr) console.error(`stderr: ${stderr}`);
// if (stdout) console.log(`stdout: ${stdout}`);
@ -36,7 +36,7 @@ if (process.env.NOVNC_PORT) {
const gh = await (await fetch('https://api.github.com/repos/vogler/free-games-claimer/commits/main', {
// headers: { accept: 'application/vnd.github.VERSION.sha' }
})).json();
})).json();
// log(gh);
log('Local commit:', sha, new Date(date));

148
xbox.js
View file

@ -1,5 +1,5 @@
import { firefox } from "playwright-firefox"; // stealth plugin needs no outdated playwright-extra
import { authenticator } from "otplib";
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib';
import {
datetime,
handleSIGINT,
@ -7,15 +7,15 @@ import {
jsonDb,
notify,
prompt,
} from "./util.js";
import { cfg } from "./config.js";
} from './util.js';
import { cfg } from './config.js';
// ### SETUP
const URL_CLAIM = "https://www.xbox.com/en-US/live/gold"; // #gameswithgold";
const URL_CLAIM = 'https://www.xbox.com/en-US/live/gold'; // #gameswithgold";
console.log(datetime(), "started checking xbox");
console.log(datetime(), 'started checking xbox');
const db = await jsonDb("xbox.json");
const db = await jsonDb('xbox.json');
db.data ||= {};
handleSIGINT();
@ -24,7 +24,7 @@ handleSIGINT();
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height },
locale: "en-US", // ignore OS locale to be sure to have english text for locators -> done via /en in URL
locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL
});
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
@ -46,11 +46,10 @@ async function main() {
} catch (error) {
console.error(error);
process.exitCode ||= 1;
if (error.message && process.exitCode != 130)
notify(`xbox failed: ${error.message.split("\n")[0]}`);
if (error.message && process.exitCode != 130) notify(`xbox failed: ${error.message.split('\n')[0]}`);
} finally {
await db.write(); // write out json db
if (notify_games.filter((g) => g.status != "existed").length) {
if (notify_games.filter(g => g.status != 'existed').length) {
// don't notify if all were already claimed
notify(`xbox (${user}):<br>${html_game_list(notify_games)}`);
}
@ -59,16 +58,16 @@ async function main() {
}
async function performLogin() {
await page.goto(URL_CLAIM, { waitUntil: "domcontentloaded" }); // default 'load' takes forever
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
const signInLocator = page
.getByRole("link", {
name: "Sign in to your account",
.getByRole('link', {
name: 'Sign in to your account',
})
.first();
const usernameLocator = page
.getByRole("button", {
name: "Account manager for",
.getByRole('button', {
name: 'Account manager for',
})
.first();
@ -77,40 +76,38 @@ async function performLogin() {
if (await usernameLocator.isVisible()) {
return; // logged in using saved cookie
} else if (await signInLocator.isVisible()) {
console.error("Not signed in anymore.");
console.error('Not signed in anymore.');
await signInLocator.click();
await signInToXbox();
} else {
console.error("lost! where am i?");
console.error('lost! where am i?');
}
}
async function signInToXbox() {
page.waitForLoadState("domcontentloaded");
page.waitForLoadState('domcontentloaded');
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!`);
// ### FETCH EMAIL/PASS
if (cfg.xbox_email && cfg.xbox_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)."
if (cfg.xbox_email && cfg.xbox_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.xbox_email || (await prompt({ message: "Enter email" }));
const email = cfg.xbox_email || await prompt({ message: 'Enter email' });
const password =
email &&
(cfg.xbox_password ||
(await prompt({
type: "password",
message: "Enter password",
})));
await prompt({
type: 'password',
message: 'Enter password',
}));
// ### FILL IN EMAIL/PASS
if (email && password) {
const usernameLocator = page
.getByPlaceholder("Email, phone, or Skype")
.getByPlaceholder('Email, phone, or Skype')
.first();
const passwordLocator = page.getByPlaceholder("Password").first();
const passwordLocator = page.getByPlaceholder('Password').first();
await Promise.any([
usernameLocator.waitFor(),
@ -118,58 +115,57 @@ async function signInToXbox() {
]);
// username may already be saved from before, if so, skip to filling in password
if (await page.getByPlaceholder("Email, phone, or Skype").isVisible()) {
if (await page.getByPlaceholder('Email, phone, or Skype').isVisible()) {
await usernameLocator.fill(email);
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole('button', { name: 'Next' }).click();
}
await passwordLocator.fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByRole('button', { name: 'Sign in' }).click();
// handle MFA, but don't await it
page.locator('input[name="otc"]')
.waitFor()
.then(async () => {
console.log("Two-Step Verification - Enter security code");
console.log('Two-Step Verification - Enter security code');
console.log(
await page
.locator('div[data-bind="text: description"]')
.innerText()
.innerText(),
);
const otp =
(cfg.xbox_otpkey &&
authenticator.generate(cfg.xbox_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
cfg.xbox_otpkey &&
authenticator.generate(cfg.xbox_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="otc"]', otp.toString());
await page
.getByLabel("Don't ask me again on this device")
.getByLabel('Don\'t ask me again on this device')
.check(); // Trust this Browser
await page.getByRole("button", { name: "Verify" }).click();
await page.getByRole('button', { name: 'Verify' }).click();
})
.catch((_) => {});
.catch(_ => {});
// Trust this browser, but don't await it
page.getByLabel("Don't show this again")
page.getByLabel('Don\'t show this again')
.waitFor()
.then(async () => {
await page.getByLabel("Don't show this again").check();
await page.getByRole("button", { name: "Yes" }).click();
await page.getByLabel('Don\'t show this again').check();
await page.getByRole('button', { name: 'Yes' }).click();
})
.catch((_) => {});
.catch(_ => {});
} else {
console.log("Waiting for you to login in the browser.");
console.log('Waiting for you to login in the browser.');
await notify(
"xbox: no longer signed in and not enough options set for automatic login."
'xbox: no longer signed in and not enough options set for automatic login.',
);
if (cfg.headless) {
console.log(
"Run `SHOW=1 node xbox` to login in the opened browser."
'Run `SHOW=1 node xbox` to login in the opened browser.',
);
await context.close();
process.exit(1);
@ -183,35 +179,35 @@ async function signInToXbox() {
}
async function getAndSaveUser() {
user = await page.locator("#mectrl_currentAccount_primary").innerHTML();
user = await page.locator('#mectrl_currentAccount_primary').innerHTML();
console.log(`Signed in as '${user}'`);
db.data[user] ||= {};
}
async function redeemFreeGames() {
const monthlyGamesLocator = await page.locator(".f-size-large").all();
const monthlyGamesLocator = await page.locator('.f-size-large').all();
const monthlyGamesPageLinks = await Promise.all(
monthlyGamesLocator.map(
async (el) => await el.locator("a").getAttribute("href")
)
async el => await el.locator('a').getAttribute('href'),
),
);
console.log("Free games:", monthlyGamesPageLinks);
console.log('Free games:', monthlyGamesPageLinks);
for (const url of monthlyGamesPageLinks) {
await page.goto(url);
const title = await page.locator("h1").first().innerText();
const game_id = page.url().split("/").pop();
const title = await page.locator('h1').first().innerText();
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" };
console.log('Current free game:', title);
const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below
// SELECTORS
const getBtnLocator = page.getByText("GET", { exact: true }).first();
const getBtnLocator = page.getByText('GET', { exact: true }).first();
const installToLocator = page
.getByText("INSTALL TO", { exact: true })
.getByText('INSTALL TO', { exact: true })
.first();
await Promise.any([
@ -220,11 +216,11 @@ async function redeemFreeGames() {
]);
if (await installToLocator.isVisible()) {
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
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
} else if (await getBtnLocator.isVisible()) {
console.log(" Not in library yet! Click GET.");
console.log(' Not in library yet! Click GET.');
await getBtnLocator.click();
// wait for popup
@ -232,18 +228,18 @@ async function redeemFreeGames() {
.locator('iframe[name="purchase-sdk-hosted-iframe"]')
.waitFor();
const popupLocator = page.frameLocator(
"[name=purchase-sdk-hosted-iframe]"
'[name=purchase-sdk-hosted-iframe]',
);
const finalGetBtnLocator = popupLocator.getByText("GET");
const finalGetBtnLocator = popupLocator.getByText('GET');
await finalGetBtnLocator.waitFor();
await finalGetBtnLocator.click();
await page.getByText("Thank you for your purchase.").waitFor();
notify_game.status = "claimed";
db.data[user][game_id].status = "claimed";
await page.getByText('Thank you for your purchase.').waitFor();
notify_game.status = 'claimed';
db.data[user][game_id].status = 'claimed';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(" Claimed successfully!");
console.log(' Claimed successfully!');
}
// notify_game.status = db.data[user][game_id].status; // claimed or failed