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 dryrun: process.env.DRYRUN == '1', // don't claim anything
interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip
show: process.env.SHOW == '1', // run non-headless 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 width: Number(process.env.WIDTH) || 1920, // width of the opened browser
height: Number(process.env.HEIGHT) || 1080, // height 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 timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s
@ -23,7 +25,7 @@ export const cfg = {
return { return {
browser: process.env.BROWSER_DIR || dataDir('browser'), // for multiple accounts or testing 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 screenshots: process.env.SCREENSHOTS_DIR || dataDir('screenshots'), // set to 0 to disable screenshots
} };
}, },
// auth epic-games // auth epic-games
eg_email: process.env.EG_EMAIL || process.env.EMAIL, 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: '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 (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 // 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 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 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 handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -134,7 +134,7 @@ try {
// clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25 // 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. // 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 // 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 urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href')));
const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s); const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s);
console.log('Free games:', urls); console.log('Free games:', urls);
@ -260,8 +260,7 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`);
notify(`epic-games failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db 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' 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'

13
gog.js
View file

@ -14,7 +14,7 @@ const db = await jsonDb('gog.json', {});
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
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 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 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 handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -58,7 +58,7 @@ try {
// handle MFA, but don't await it // handle MFA, but don't await it
iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => { iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => {
console.log('Two-Step Verification - Enter security code'); console.log('Two-Step Verification - Enter security code');
console.log(await iframe.locator('.form__description').innerText()) 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 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_token_letter_1').pressSequentially(otp.toString(), { delay: 10 });
await iframe.locator('#second_step_authentication_send').click(); await iframe.locator('#second_step_authentication_send').click();
@ -71,7 +71,7 @@ try {
notify('gog: got captcha during login. Please check.'); notify('gog: got captcha during login. Please check.');
// TODO solve reCAPTCHA? // TODO solve reCAPTCHA?
}).catch(_ => { }); }).catch(_ => { });
await page.waitForSelector('#menuUsername') await page.waitForSelector('#menuUsername');
} else { } else {
console.log('Waiting for you to login in the browser.'); 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.'); 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 }); notify_games.push({ title, url, status });
if (status == 'claimed' && !cfg.gog_newsletter) { 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.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("Marketing communications through Trusted Partners") label').uncheck();
await page.locator('li:has-text("Promotions and hot deals") label').uncheck(); await page.locator('li:has-text("Promotions and hot deals") label').uncheck();
@ -139,13 +139,12 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`gog failed: ${error.message.split('\n')[0]}`);
notify(`gog failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed 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)}`); 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(); await context.close();

View file

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

View file

@ -1,5 +1,5 @@
/* eslint-disable no-constant-condition */ /* 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 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, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
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 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 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 handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -386,8 +386,7 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.length) { // list should only include claimed games 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 }, 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: '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 // 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 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 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 handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
@ -128,7 +128,7 @@ try {
const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' '); const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' ');
console.log('Price: ', price[1], 'instead of', price[0]); console.log('Price: ', price[1], 'instead of', price[0]);
if (price[1] != '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); console.error(err);
notify('unrealengine: ' + err); notify('unrealengine: ' + err);
process.exit(1); process.exit(1);
@ -142,7 +142,7 @@ try {
// maybe: Accept End User License Agreement // maybe: Accept End User License Agreement
page.locator('[name=accept-label]').check().then(() => { page.locator('[name=accept-label]').check().then(() => {
console.log('Accept End User License Agreement'); 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(_ => { }); }).catch(_ => { });
await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed? await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed?
const iframe = page.frameLocator('#webPurchaseContainer iframe'); const iframe = page.frameLocator('#webPurchaseContainer iframe');
@ -165,7 +165,7 @@ try {
const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe'); const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
captcha.waitFor().then(async () => { // don't await, since element may not be shown 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.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 }).catch(_ => { }); // may time out if not shown
await page.waitForSelector('text=Thank you'); await page.waitForSelector('text=Thank you');
for (const id of ids) { for (const id of ids) {
@ -192,8 +192,7 @@ try {
process.exitCode ||= 1; process.exitCode ||= 1;
console.error('--- Exception:'); console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed 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 (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(); await context.close();

15
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 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 // 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 // https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
const enabledEvasions = [ const enabledEvasions = [
@ -47,13 +47,13 @@ export const stealth = async (context) => {
'sourceurl', 'sourceurl',
// 'user-agent-override', // doesn't work since playwright has no page.browser() // 'user-agent-override', // doesn't work since playwright has no page.browser()
'webgl.vendor', 'webgl.vendor',
'window.outerdimensions' 'window.outerdimensions',
]; ];
const stealth = { const stealth = {
callbacks: [], callbacks: [],
async evaluateOnNewDocument(...args) { async evaluateOnNewDocument(...args) {
this.callbacks.push({ cb: args[0], a: args[1] }); this.callbacks.push({ cb: args[0], a: args[1] });
} },
}; };
for (const e of enabledEvasions) { for (const e of enabledEvasions) {
const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`); 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(); import Enquirer from 'enquirer'; const enquirer = new Enquirer();
const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
enquirer.on('prompt', prompt => { 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('submit', _ => clearTimeout(t));
prompt.on('cancel', _ => clearTimeout(t)); prompt.on('cancel', _ => clearTimeout(t));
}); });
@ -85,7 +88,7 @@ export const confirm = o => prompt({type: 'confirm', message: 'Continue?', ...o}
import { exec } from 'child_process'; import { exec } from 'child_process';
import { cfg } from './config.js'; 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(); if (!cfg.notify) return resolve();
const title = cfg.notify_title ? `-t ${cfg.notify_title}` : ''; const title = cfg.notify_title ? `-t ${cfg.notify_title}` : '';
exec(`apprise ${cfg.notify} -i html '${title}' -b '${html}'`, (error, stdout, stderr) => { 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>'); export const html_game_list = games => games.map(g => `- <a href="${g.url}">${escapeHtml(g.title)}</a> (${g.status})`).join('<br>');

View file

@ -3,7 +3,7 @@
import { log } from 'console'; import { log } from 'console';
import { exec } from 'child_process'; import { exec } from 'child_process';
const execp = (cmd) => new Promise((resolve, reject) => { const execp = cmd => new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout, stderr) => {
if (stderr) console.error(`stderr: ${stderr}`); if (stderr) console.error(`stderr: ${stderr}`);
// if (stdout) console.log(`stdout: ${stdout}`); // if (stdout) console.log(`stdout: ${stdout}`);

148
xbox.js
View file

@ -1,5 +1,5 @@
import { firefox } from "playwright-firefox"; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from "otplib"; import { authenticator } from 'otplib';
import { import {
datetime, datetime,
handleSIGINT, handleSIGINT,
@ -7,15 +7,15 @@ import {
jsonDb, jsonDb,
notify, notify,
prompt, prompt,
} from "./util.js"; } from './util.js';
import { cfg } from "./config.js"; import { cfg } from './config.js';
// ### SETUP // ### 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 ||= {}; db.data ||= {};
handleSIGINT(); handleSIGINT();
@ -24,7 +24,7 @@ handleSIGINT();
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
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); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
@ -46,11 +46,10 @@ async function main() {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
process.exitCode ||= 1; process.exitCode ||= 1;
if (error.message && process.exitCode != 130) if (error.message && process.exitCode != 130) notify(`xbox failed: ${error.message.split('\n')[0]}`);
notify(`xbox failed: ${error.message.split("\n")[0]}`);
} finally { } finally {
await db.write(); // write out json db 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 // don't notify if all were already claimed
notify(`xbox (${user}):<br>${html_game_list(notify_games)}`); notify(`xbox (${user}):<br>${html_game_list(notify_games)}`);
} }
@ -59,16 +58,16 @@ async function main() {
} }
async function performLogin() { 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 const signInLocator = page
.getByRole("link", { .getByRole('link', {
name: "Sign in to your account", name: 'Sign in to your account',
}) })
.first(); .first();
const usernameLocator = page const usernameLocator = page
.getByRole("button", { .getByRole('button', {
name: "Account manager for", name: 'Account manager for',
}) })
.first(); .first();
@ -77,40 +76,38 @@ async function performLogin() {
if (await usernameLocator.isVisible()) { if (await usernameLocator.isVisible()) {
return; // logged in using saved cookie return; // logged in using saved cookie
} else if (await signInLocator.isVisible()) { } else if (await signInLocator.isVisible()) {
console.error("Not signed in anymore."); console.error('Not signed in anymore.');
await signInLocator.click(); await signInLocator.click();
await signInToXbox(); await signInToXbox();
} else { } else {
console.error("lost! where am i?"); console.error('lost! where am i?');
} }
} }
async function signInToXbox() { 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 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!`);
// ### FETCH EMAIL/PASS // ### FETCH EMAIL/PASS
if (cfg.xbox_email && cfg.xbox_password) if (cfg.xbox_email && cfg.xbox_password) console.info('Using email and password from environment.');
console.info("Using email and password from environment."); else console.info(
else 'Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).',
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 = const password =
email && email &&
(cfg.xbox_password || (cfg.xbox_password ||
(await prompt({ await prompt({
type: "password", type: 'password',
message: "Enter password", message: 'Enter password',
}))); }));
// ### FILL IN EMAIL/PASS // ### FILL IN EMAIL/PASS
if (email && password) { if (email && password) {
const usernameLocator = page const usernameLocator = page
.getByPlaceholder("Email, phone, or Skype") .getByPlaceholder('Email, phone, or Skype')
.first(); .first();
const passwordLocator = page.getByPlaceholder("Password").first(); const passwordLocator = page.getByPlaceholder('Password').first();
await Promise.any([ await Promise.any([
usernameLocator.waitFor(), usernameLocator.waitFor(),
@ -118,58 +115,57 @@ async function signInToXbox() {
]); ]);
// username may already be saved from before, if so, skip to filling in password // 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 usernameLocator.fill(email);
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole('button', { name: 'Next' }).click();
} }
await passwordLocator.fill(password); 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 // handle MFA, but don't await it
page.locator('input[name="otc"]') page.locator('input[name="otc"]')
.waitFor() .waitFor()
.then(async () => { .then(async () => {
console.log("Two-Step Verification - Enter security code"); console.log('Two-Step Verification - Enter security code');
console.log( console.log(
await page await page
.locator('div[data-bind="text: description"]') .locator('div[data-bind="text: description"]')
.innerText() .innerText(),
); );
const otp = const otp =
(cfg.xbox_otpkey && cfg.xbox_otpkey &&
authenticator.generate(cfg.xbox_otpkey)) || authenticator.generate(cfg.xbox_otpkey) ||
(await prompt({ await prompt({
type: "text", type: 'text',
message: "Enter two-factor sign in code", message: 'Enter two-factor sign in code',
validate: (n) => validate: n => n.toString().length == 6 ||
n.toString().length == 6 || 'The code must be 6 digits!',
"The code must be 6 digits!", }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
})); // 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.type('input[name="otc"]', otp.toString());
await page await page
.getByLabel("Don't ask me again on this device") .getByLabel('Don\'t ask me again on this device')
.check(); // Trust this Browser .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 // Trust this browser, but don't await it
page.getByLabel("Don't show this again") page.getByLabel('Don\'t show this again')
.waitFor() .waitFor()
.then(async () => { .then(async () => {
await page.getByLabel("Don't show this again").check(); await page.getByLabel('Don\'t show this again').check();
await page.getByRole("button", { name: "Yes" }).click(); await page.getByRole('button', { name: 'Yes' }).click();
}) })
.catch((_) => {}); .catch(_ => {});
} else { } else {
console.log("Waiting for you to login in the browser."); console.log('Waiting for you to login in the browser.');
await notify( 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) { if (cfg.headless) {
console.log( 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(); await context.close();
process.exit(1); process.exit(1);
@ -183,35 +179,35 @@ async function signInToXbox() {
} }
async function getAndSaveUser() { 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}'`); console.log(`Signed in as '${user}'`);
db.data[user] ||= {}; db.data[user] ||= {};
} }
async function redeemFreeGames() { 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( const monthlyGamesPageLinks = await Promise.all(
monthlyGamesLocator.map( 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) { for (const url of monthlyGamesPageLinks) {
await page.goto(url); await page.goto(url);
const title = await page.locator("h1").first().innerText(); const title = await page.locator('h1').first().innerText();
const game_id = page.url().split("/").pop(); 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! 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); console.log('Current free game:', title);
const notify_game = { title, url, status: "failed" }; const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
// SELECTORS // SELECTORS
const getBtnLocator = page.getByText("GET", { exact: true }).first(); const getBtnLocator = page.getByText('GET', { exact: true }).first();
const installToLocator = page const installToLocator = page
.getByText("INSTALL TO", { exact: true }) .getByText('INSTALL TO', { exact: true })
.first(); .first();
await Promise.any([ await Promise.any([
@ -220,11 +216,11 @@ async function redeemFreeGames() {
]); ]);
if (await installToLocator.isVisible()) { if (await installToLocator.isVisible()) {
console.log(" Already in library! Nothing to claim."); console.log(' Already in library! Nothing to claim.');
notify_game.status = "existed"; notify_game.status = 'existed';
db.data[user][game_id].status ||= "existed"; // does not overwrite claimed or failed db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed
} else if (await getBtnLocator.isVisible()) { } else if (await getBtnLocator.isVisible()) {
console.log(" Not in library yet! Click GET."); console.log(' Not in library yet! Click GET.');
await getBtnLocator.click(); await getBtnLocator.click();
// wait for popup // wait for popup
@ -232,18 +228,18 @@ async function redeemFreeGames() {
.locator('iframe[name="purchase-sdk-hosted-iframe"]') .locator('iframe[name="purchase-sdk-hosted-iframe"]')
.waitFor(); .waitFor();
const popupLocator = page.frameLocator( 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.waitFor();
await finalGetBtnLocator.click(); await finalGetBtnLocator.click();
await page.getByText("Thank you for your purchase.").waitFor(); await page.getByText('Thank you for your purchase.').waitFor();
notify_game.status = "claimed"; notify_game.status = 'claimed';
db.data[user][game_id].status = "claimed"; db.data[user][game_id].status = 'claimed';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time 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 // notify_game.status = db.data[user][game_id].status; // claimed or failed