This commit is contained in:
Dawit A_Abraham 2024-08-21 19:45:32 -04:00
commit 02460a586e
12 changed files with 635 additions and 610 deletions

View file

@ -27,12 +27,13 @@ jobs:
- -
name: Set environment variables name: Set environment variables
run: | run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV BRANCH="${GITHUB_REF#refs/heads/}"
echo "BRANCH=$BRANCH" >> $GITHUB_ENV
echo "NOW=$(date -R)" >> $GITHUB_ENV # date -Iseconds; date +'%Y-%m-%dT%H:%M:%S' echo "NOW=$(date -R)" >> $GITHUB_ENV # date -Iseconds; date +'%Y-%m-%dT%H:%M:%S'
if [[ "${{ env.BRANCH }}" == "main" ]]; then if [[ "$BRANCH" == "main" ]]; then
echo "IMAGE_TAG=latest" >> $GITHUB_ENV echo "IMAGE_TAG=latest" >> $GITHUB_ENV
else else
echo "IMAGE_TAG=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV echo "IMAGE_TAG=$BRANCH" >> $GITHUB_ENV
fi fi
- -
name: Set up QEMU name: Set up QEMU

View file

@ -92,6 +92,7 @@ Available options/variables and their default values:
| GOG_EMAIL | | GOG email for login. Overrides EMAIL. | | GOG_EMAIL | | GOG email for login. Overrides EMAIL. |
| GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. | | GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. |
| GOG_NEWSLETTER | 0 | Do not unsubscribe from newsletter after claiming a game if 1. | | GOG_NEWSLETTER | 0 | Do not unsubscribe from newsletter after claiming a game if 1. |
| LG_EMAIL | | Legacy Games: email to use for redeeming (if not set, defaults to PG_EMAIL) |
See `src/config.js` for all options. See `src/config.js` for all options.
@ -103,7 +104,7 @@ You can pass variables using `-e VAR=VAL`, for example `docker run -e EMAIL=foo@
If you are using [docker compose](https://docs.docker.com/compose/environment-variables/) (or Portainer etc.), you can put options in the `environment:` section. If you are using [docker compose](https://docs.docker.com/compose/environment-variables/) (or Portainer etc.), you can put options in the `environment:` section.
##### Without Docker ##### Without Docker
On Linux/macOS you can prefix the variables you want to set, for example `EMAIL=foo@bar.baz SHOW=1 node epic-games` will show the browser and skip asking you for your login email. On Linux/macOS you can prefix the variables you want to set, for example `EMAIL=foo@bar.baz SHOW=1 node epic-games` will show the browser and skip asking you for your login email. On Windows you have to use `set`, [example](https://github.com/vogler/free-games-claimer/issues/314).
You can also put options in `data/config.env` which will be loaded by [dotenv](https://github.com/motdotla/dotenv). You can also put options in `data/config.env` which will be loaded by [dotenv](https://github.com/motdotla/dotenv).
### Notifications ### Notifications
@ -156,7 +157,7 @@ If you want it to run regularly, you have to schedule the runs yourself:
- Linux/macOS: `crontab -e` ([example](https://github.com/vogler/free-games-claimer/discussions/56)) - Linux/macOS: `crontab -e` ([example](https://github.com/vogler/free-games-claimer/discussions/56))
- macOS: [launchd](https://stackoverflow.com/questions/132955/how-do-i-set-a-task-to-run-every-so-often) - macOS: [launchd](https://stackoverflow.com/questions/132955/how-do-i-set-a-task-to-run-every-so-often)
- Windows: [task scheduler](https://active-directory-wp.com/docs/Usage/How_to_add_a_cron_job_on_Windows/Scheduled_tasks_and_cron_jobs_on_Windows/index.html), [other options](https://stackoverflow.com/questions/132971/what-is-the-windows-version-of-cron), or just put the command in a `.bat` file in Autostart if you restart often... - Windows: [task scheduler](https://active-directory-wp.com/docs/Usage/How_to_add_a_cron_job_on_Windows/Scheduled_tasks_and_cron_jobs_on_Windows/index.html) ([example](https://github.com/vogler/free-games-claimer/wiki/%5BHowTo%5D-Schedule-runs-on-Windows)), [other options](https://stackoverflow.com/questions/132971/what-is-the-windows-version-of-cron), or just put the command in a `.bat` file in Autostart if you restart often...
- any OS: use a process manager like [pm2](https://pm2.keymetrics.io/docs/usage/restart-strategies/) - any OS: use a process manager like [pm2](https://pm2.keymetrics.io/docs/usage/restart-strategies/)
- Docker Compose `command: bash -c "node epic-games; node prime-gaming; node gog; echo sleeping; sleep 1d"` additionally add `restart: unless-stopped` to it. - Docker Compose `command: bash -c "node epic-games; node prime-gaming; node gog; echo sleeping; sleep 1d"` additionally add `restart: unless-stopped` to it.

105
aliexpress.js Normal file
View file

@ -0,0 +1,105 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { datetime, filenamify, prompt, handleSIGINT, stealth } from './src/util.js';
import { cfg } from './src/config.js';
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
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-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
});
handleSIGINT(context);
await stealth(context);
context.setDefaultTimeout(cfg.debug ? 0 : cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
const auth = async (url) => {
console.log('auth', url);
await page.goto(url, { waitUntil: 'domcontentloaded' });
// redirects to https://login.aliexpress.com/?return_url=https%3A%2F%2Fwww.aliexpress.com%2Fp%2Fcoin-pc-index%2Findex.html
await Promise.any([page.waitForURL(/.*login.aliexpress.com.*/).then(async () => {
// manual login
console.error('Not logged in! Will wait for 120s for you to login...');
// await page.waitForTimeout(120*1000);
// or try automated
page.locator('span:has-text("Switch account")').click().catch(_ => {}); // sometimes no longer logged in, but previous user/email is pre-selected -> in this case we want to go back to the classic login
const login = page.locator('.login-container');
const email = cfg.ae_email || await prompt({ message: 'Enter email' });
const emailInput = login.locator('input[label="Email or phone number"]');
await emailInput.fill(email);
await emailInput.blur(); // otherwise Continue button stays disabled
const continueButton = login.locator('button:has-text("Continue")');
await continueButton.click({ force: true }); // normal click waits for button to no longer be covered by their suggestion menu, so we have to force click somewhere for the menu to close and then click
await continueButton.click();
const password = email && (cfg.ae_password || await prompt({ type: 'password', message: 'Enter password' }));
await login.locator('input[label="Password"]').fill(password);
await login.locator('button:has-text("Sign in")').click();
const error = login.locator('.error-text');
error.waitFor().then(async _ => console.error('Login error:', await error.innerText()));
await page.waitForURL(url);
// await page.addLocatorHandler(page.getByRole('button', { name: 'Accept cookies' }), btn => btn.click());
page.getByRole('button', { name: 'Accept cookies' }).click().then(_ => console.log('Accepted cookies')).catch(_ => { });
}), page.locator('#nav-user-account').waitFor()]).catch(_ => {});
// await page.locator('#nav-user-account').hover();
// console.log('Logged in as:', await page.locator('.welcome-name').innerText());
};
// copied URLs from AliExpress app on tablet which has menu for the used webview
const urls = {
// works with desktop view, but stuck at 100% loading in mobile view:
coins: 'https://www.aliexpress.com/p/coin-pc-index/index.html',
// only work with mobile view:
grow: 'https://m.aliexpress.com/p/ae_fruit/index.html', // firefox: stuck at 60% loading, chrome: loads, but canvas
gogo: 'https://m.aliexpress.com/p/gogo-match-cc/index.html', // closes firefox?!
// only show notification to install the app
euro: 'https://m.aliexpress.com/p/european-cup/index.html', // doesn't load
merge: 'https://m.aliexpress.com/p/merge-market/index.html',
};
const coins = async () => {
// await auth(urls.coins);
await Promise.any([page.locator('.checkin-button').click(), page.locator('.addcoin').waitFor()]);
console.log('Coins:', await page.locator('.mycoin-content-right-money').innerText());
console.log('Streak:', await page.locator('.title-box').innerText());
console.log('Tomorrow:', await page.locator('.addcoin').innerText());
};
const grow = async () => {
await page.pause();
};
const gogo = async () => {
await page.pause();
};
const euro = async () => {
await page.pause();
};
const merge = async () => {
await page.pause();
};
try {
// await coins();
await [
coins,
// grow,
// gogo,
// euro,
// merge,
].reduce((a, f) => a.then(async _ => { await auth(urls[f.name]); await f(); console.log() }), Promise.resolve());
// await page.pause();
} catch (error) {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
}
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close();

View file

@ -1,7 +1,7 @@
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 path from 'path'; import path from 'path';
import { existsSync, writeFileSync } from 'fs'; import { existsSync, writeFileSync, appendFileSync } from 'fs';
import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js'; import { cfg } from './src/config.js';
@ -16,16 +16,24 @@ const db = await jsonDb('epic-games.json', {});
if (cfg.time) console.time('startup'); if (cfg.time) console.time('startup');
const browserPrefs = path.join(cfg.dir.browser, 'prefs.js');
if (existsSync(browserPrefs)) {
console.log('Adding webgl.disabled to', browserPrefs);
appendFileSync(browserPrefs, 'user_pref("webgl.disabled", true);'); // apparently Firefox removes duplicates (and sorts), so no problem appending every time
} else {
console.log(browserPrefs, 'does not exist yet, will patch it on next run. Restart the script if you get a captcha.');
}
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
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 },
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; rv:127.0) Gecko/20100101 Firefox/127.0', // 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-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
// user settings for firefox have to be put in $BROWSER_DIR/user.js // user settings for firefox have to be put in $BROWSER_DIR/user.js
args: [ // https://wiki.mozilla.org/Firefox/CommandLineOptions args: [ // https://wiki.mozilla.org/Firefox/CommandLineOptions
@ -90,19 +98,26 @@ try {
if (!email) await notifyBrowserLogin(); if (!email) await notifyBrowserLogin();
else { else {
// await page.click('text=Sign in with Epic Games'); // await page.click('text=Sign in with Epic Games');
await page.fill('#email', email);
await page.click('button[type="submit"]');
page.waitForSelector('.h_captcha_challenge iframe').then(async () => { page.waitForSelector('.h_captcha_challenge iframe').then(async () => {
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.'); 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.');
await notify('epic-games: got captcha during login. Please check.'); await notify('epic-games: got captcha during login. Please check.');
}).catch(_ => { }); }).catch(_ => { });
page.waitForSelector('p:has-text("Incorrect response.")').then(async () => { page.waitForSelector('p:has-text("Incorrect response.")').then(async () => {
console.error('Incorrect repsonse for captcha!'); console.error('Incorrect response for captcha!');
}).catch(_ => { }); }).catch(_ => { });
await page.fill('#email', email);
// await page.click('button[type="submit"]'); login was split in two steps for some time, now email and password are on the same form again
const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' })); const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (!password) await notifyBrowserLogin(); if (!password) await notifyBrowserLogin();
else {
await page.fill('#password', password); await page.fill('#password', password);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
}
const error = page.locator('#form-error-message');
error.waitFor().then(async () => {
console.error('Login error:', await error.innerText());
console.log('Please login in the browser!');
}).catch(_ => { });
// handle MFA, but don't await it // handle MFA, but don't await it
page.waitForURL('**/id/login/mfa**').then(async () => { 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 ...'); 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 ...');
@ -141,7 +156,8 @@ try {
for (const url of urls) { for (const url of urls) {
if (cfg.time) console.time('claim game'); if (cfg.time) console.time('claim game');
await page.goto(url); // , { waitUntil: 'domcontentloaded' }); 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 const purcahseBtn = page.locator('aside button').first();
const btnText = (await purcahseBtn.innerText()).toLowerCase(); // barrier to block until page is loaded
// click Continue if 'This game contains mature content recommended only for ages 18+' // click Continue if 'This game contains mature content recommended only for ages 18+'
if (await page.locator('button:has-text("Continue")').count() > 0) { if (await page.locator('button:has-text("Continue")').count() > 0) {
@ -159,19 +175,25 @@ try {
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
} }
const title = await page.locator('h1').first().innerText(); let title;
if (await page.locator('span:text-is("About Bundle")').count()) {
// console.log(' This is a bundle containing: TODO');
title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', '');
} else {
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
if (btnText.toLowerCase() == 'in library') { if (btnText == 'in library') {
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
if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed 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') { } else if (btnText == 'requires base game') {
console.log(' Requires base game! Nothing to claim.'); console.log(' Requires base game! Nothing to claim.');
notify_game.status = 'requires base game'; notify_game.status = 'requires base game';
db.data[user][game_id].status ||= 'failed:requires-base-game'; db.data[user][game_id].status ||= 'failed:requires-base-game';
@ -179,9 +201,12 @@ try {
const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href'); const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href');
console.log(' Base game:', baseUrl); console.log(' Base game:', baseUrl);
// await page.click('a:has-text("Overview")'); // await page.click('a:has-text("Overview")');
// TODO handle this via function call for base game above since this will never terminate if DRYRUN=1
urls.push(baseUrl); // add base game to the list of games to claim
urls.push(url); // add add-on itself again
} else { // GET } else { // GET
console.log(' Not in library yet! Click GET.'); console.log(' Not in library yet! Click GET.');
await page.click('[data-testid="purchase-cta-button"]', { delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough await purcahseBtn.click({ delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough
// click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent? // 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? page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox?
@ -192,7 +217,7 @@ try {
// Accept End User License Agreement (only needed once) // Accept End User License Agreement (only needed once)
page.locator('input#agree').waitFor().then(async () => { page.locator('input#agree').waitFor().then(async () => {
console.log(' Accept End User License Agreement (only needed once)'); console.log(' Accept End User License Agreement (only needed once)');
await page.locator('input#agree').check(); await page.locator('input#agree').check(); // TODO Bundle: got stuck here
await page.locator('button:has-text("Accept")').click(); await page.locator('button:has-text("Accept")').click();
}).catch(_ => { }); }).catch(_ => { });
@ -228,7 +253,7 @@ try {
await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 });
// I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872
const btnAgree = iframe.locator('button:has-text("I Agree")'); const btnAgree = iframe.locator('button:has-text("I Accept")');
btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree' btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree'
try { try {
// context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s?
@ -243,7 +268,11 @@ try {
// console.info(' Saved a screenshot of hcaptcha challenge to', 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? // 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 }).catch(_ => { }); // may time out if not shown
await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor().then(async () => {
console.error(' Failed to challenge captcha, please try again later.');
await notify('epic-games: failed to challenge captcha. Please check.');
}).catch(_ => { });
await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); // TODO Bundle: got stuck here
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!');

14
gog.js
View file

@ -10,13 +10,18 @@ console.log(datetime(), 'started checking gog');
const db = await jsonDb('gog.json', {}); const db = await jsonDb('gog.json', {});
if (cfg.width < 1280) { // otherwise 'Sign in' and #menuUsername are hidden (but attached to DOM), see https://github.com/vogler/free-games-claimer/issues/335
console.error(`Window width is set to ${cfg.width} but needs to be at least 1280 for GOG!`);
process.exit(1);
}
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
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-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
}); });
@ -93,10 +98,9 @@ try {
if (!await banner.count()) { if (!await banner.count()) {
console.log('Currently no free giveaway!'); console.log('Currently no free giveaway!');
} else { } else {
const text = await page.locator('.giveaway-banner__title').innerText(); const text = await page.locator('.giveaway__content-header').innerText();
const title = text.match(/Claim (.*)/)[1]; const title = text.match(/Claim (.*) and don't miss the/)[1];
const slug = await banner.getAttribute('href'); const url = await banner.locator('a').first().getAttribute('href');
const url = `https://gog.com${slug}`;
console.log(`Current free game: ${title} - ${url}`); console.log(`Current free game: ${title} - ${url}`);
db.data[user][title] ||= { title, time: datetime(), url }; db.data[user][title] ||= { title, time: datetime(), url };
if (cfg.dryrun) process.exit(1); if (cfg.dryrun) process.exit(1);

653
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,20 +17,20 @@
}, },
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=15" "node": ">=17"
}, },
"dependencies": { "dependencies": {
"chalk": "^5.3.0", "chalk": "^5.3.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.3.1", "dotenv": "^16.4.5",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"lowdb": "^6.1.1", "lowdb": "^7.0.1",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"playwright-firefox": "^1.40.1", "playwright-firefox": "^1.45.0",
"puppeteer-extra-plugin-stealth": "^2.11.2" "puppeteer-extra-plugin-stealth": "^2.11.2"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin-js": "^1.5.1", "@stylistic/eslint-plugin-js": "^2.2.2",
"eslint": "^8.56.0" "eslint": "^9.5.0"
} }
} }

View file

@ -19,7 +19,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
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-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
}); });
@ -97,16 +97,50 @@ try {
process.exit(1); process.exit(1);
} }
await page.click('button[data-type="Game"]'); const waitUntilStable = async (f, act) => {
let v;
while (true) {
const v2 = await f();
console.log('waitUntilStable', v2);
if (v == v2) break;
v = v2;
await act();
}
};
const scrollUntilStable = async f => waitUntilStable(f, async () => {
await page.keyboard.press('End'); // scroll to bottom to show all games await page.keyboard.press('End'); // scroll to bottom to show all games
await page.waitForLoadState('networkidle'); // wait for all games to be loaded await page.waitForLoadState('networkidle'); // wait for all games to be loaded
await page.waitForTimeout(2000); // TODO networkidle wasn't enough to load all already collected games await page.waitForTimeout(5000); // TODO networkidle wasn't enough to load all already collected games
});
await page.click('button[data-type="Game"]');
const games = page.locator('div[data-a-target="offer-list-FGWP_FULL"]'); const games = page.locator('div[data-a-target="offer-list-FGWP_FULL"]');
await games.waitFor(); await games.waitFor();
await scrollUntilStable(() => games.locator('.item-card__action').count());
console.log('Number of already claimed games (total):', await games.locator('p:has-text("Collected")').count()); console.log('Number of already claimed games (total):', await games.locator('p:has-text("Collected")').count());
// can't use .all() since the list of elements via locator will change after click while we iterate over it // can't use .all() since the list of elements via locator will change after click while we iterate over it
const internal = await games.locator('.item-card__action:has([data-a-target="FGWPOffer"])').elementHandles(); const internal = await games.locator('.item-card__action:has(button[data-a-target="FGWPOffer"])').elementHandles();
const external = await games.locator('.item-card__action:has([data-a-target="ExternalOfferClaim"])').all(); const external = await games.locator('.item-card__action:has(a[data-a-target="FGWPOffer"])').all();
// bottom to top: oldest to newest games
internal.reverse();
external.reverse();
const checkTimeLeft = async url => {
// console.log(' Checking time left for game:', url);
const check = async p => {
console.log(' ', await p.locator('.availability-date').innerText());
const dueDateOrg = await p.locator('.availability-date .tw-bold').innerText();
const dueDate = datetime(new Date(Date.parse(dueDateOrg + ' 17:00')));
console.log(' Due date:', dueDate);
};
if (page.url() == url) {
await check(page);
} else {
const p = await context.newPage();
await p.goto(url, { waitUntil: 'domcontentloaded' });
await check(p);
p.close();
}
};
console.log('Number of free unclaimed games (Prime Gaming):', internal.length); console.log('Number of free unclaimed games (Prime Gaming):', internal.length);
// claim games in internal store // claim games in internal store
for (const card of internal) { for (const card of internal) {
@ -115,6 +149,7 @@ try {
const slug = await (await card.$('a')).getAttribute('href'); const slug = await (await card.$('a')).getAttribute('href');
const url = 'https://gaming.amazon.com' + slug.split('?')[0]; const url = 'https://gaming.amazon.com' + slug.split('?')[0];
console.log('Current free game:', title); console.log('Current free game:', title);
if (cfg.pg_timeLeft) await checkTimeLeft(url);
if (cfg.dryrun) continue; if (cfg.dryrun) continue;
if (cfg.interactive && !await confirm()) continue; if (cfg.interactive && !await confirm()) continue;
await (await card.$('.tw-button:has-text("Claim")')).click(); await (await card.$('.tw-button:has-text("Claim")')).click();
@ -134,6 +169,7 @@ try {
// await (await card.$('text=Claim')).click(); // goes to URL of game, no need to wait // 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 });
} }
// external_info = [ { title: 'Fallout 76 (XBOX)', url: 'https://gaming.amazon.com/fallout-76-xbox-fgwp/dp/amzn1.pg.item.9fe17d7b-b6c2-4f58-b494-cc4e79528d0b?ingress=amzn&ref_=SM_Fallout76XBOX_S01_FGWP_CRWN' } ];
for (const { title, url } of external_info) { for (const { title, url } of external_info) {
console.log('Current free game:', title); // , url); console.log('Current free game:', title); // , url);
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
@ -141,6 +177,7 @@ try {
const item_text = await page.innerText('[data-a-target="DescriptionItemDetails"]'); const item_text = await page.innerText('[data-a-target="DescriptionItemDetails"]');
const store = item_text.toLowerCase().replace(/.* on /, '').slice(0, -1); const store = item_text.toLowerCase().replace(/.* on /, '').slice(0, -1);
console.log(' External store:', store); console.log(' External store:', store);
if (cfg.pg_timeLeft) await checkTimeLeft(url);
if (cfg.dryrun) continue; if (cfg.dryrun) continue;
if (cfg.interactive && !await confirm()) continue; if (cfg.interactive && !await confirm()) continue;
await Promise.any([page.click('[data-a-target="buy-box"] .tw-button:has-text("Get game")'), page.click('[data-a-target="buy-box"] .tw-button:has-text("Claim")'), page.click('.tw-button:has-text("Complete Claim")'), page.waitForSelector('div:has-text("Link game account")'), page.waitForSelector('.thank-you-title:has-text("Success")')]); // waits for navigation await Promise.any([page.click('[data-a-target="buy-box"] .tw-button:has-text("Get game")'), page.click('[data-a-target="buy-box"] .tw-button:has-text("Claim")'), page.click('.tw-button:has-text("Complete Claim")'), page.waitForSelector('div:has-text("Link game account")'), page.waitForSelector('.thank-you-title:has-text("Success")')]); // waits for navigation
@ -163,7 +200,8 @@ try {
const redeem = { const redeem = {
// 'origin': 'https://www.origin.com/redeem', // TODO still needed or now only via account linking? // 'origin': 'https://www.origin.com/redeem', // TODO still needed or now only via account linking?
'gog.com': 'https://www.gog.com/redeem', 'gog.com': 'https://www.gog.com/redeem',
'microsoft games': 'https://redeem.microsoft.com', 'microsoft store': 'https://account.microsoft.com/billing/redeem',
xbox: 'https://account.microsoft.com/billing/redeem',
'legacy games': 'https://www.legacygames.com/primedeal', 'legacy games': 'https://www.legacygames.com/primedeal',
}; };
if (store in redeem) { // did not work for linked origin: && !await page.locator('div:has-text("Successfully Claimed")').count() if (store in redeem) { // did not work for linked origin: && !await page.locator('div:has-text("Successfully Claimed")').count()
@ -172,7 +210,9 @@ try {
if (store == 'legacy games') { // may be different URL like https://legacygames.com/primeday/puzzleoftheyear/ if (store == 'legacy games') { // may be different URL like https://legacygames.com/primeday/puzzleoftheyear/
redeem[store] = await (await page.$('li:has-text("Click here") a')).getAttribute('href'); // full text: Click here to enter your redemption code. redeem[store] = await (await page.$('li:has-text("Click here") a')).getAttribute('href'); // full text: Click here to enter your redemption code.
} }
console.log(' URL to redeem game:', redeem[store]); let redeem_url = redeem[store];
if (store == 'gog.com') redeem_url += '/' + code; // to log and notify, but can't use for goto below (captcha)
console.log(' URL to redeem game:', redeem_url);
db.data[user][title].code = code; db.data[user][title].code = code;
let redeem_action = 'redeem'; let redeem_action = 'redeem';
if (cfg.pg_redeem) { // try to redeem keys on external stores if (cfg.pg_redeem) { // try to redeem keys on external stores
@ -218,33 +258,50 @@ try {
console.log(' Unknown Response 2 - please report in https://github.com/vogler/free-games-claimer/issues/5'); console.log(' Unknown Response 2 - please report in https://github.com/vogler/free-games-claimer/issues/5');
} }
} }
} else if (store == 'microsoft games') { } else if (store == 'microsoft store' || store == 'xbox') {
console.error(` Redeem on ${store} not yet implemented!`); console.error(` Redeem on ${store} is experimental!`);
// await page2.pause();
if (page2.url().startsWith('https://login.')) { if (page2.url().startsWith('https://login.')) {
console.error(' Not logged in! Use the browser to login manually.'); console.error(' Not logged in! Use the browser to login manually. Waiting for 60s.');
await page2.waitForTimeout(60 * 1000);
redeem_action = 'redeem (login)'; redeem_action = 'redeem (login)';
} else { } else {
const r = page2.waitForResponse(r => r.url().startsWith('https://purchase.mp.microsoft.com/')); const iframe = page2.frameLocator('#redeem-iframe');
await page2.fill('[name=tokenString]', code); const input = iframe.locator('[name=tokenString]');
await input.waitFor();
await input.fill(code);
const r = page2.waitForResponse(r => r.url().startsWith('https://cart.production.store-web.dynamics.com/v1.0/Redeem/PrepareRedeem'));
// console.log(await page2.locator('.redeem_code_error').innerText()); // console.log(await page2.locator('.redeem_code_error').innerText());
const rt = await (await r).text(); const rt = await (await r).text();
console.debug(` Response: ${rt}`);
// {"code":"NotFound","data":[],"details":[],"innererror":{"code":"TokenNotFound",... // {"code":"NotFound","data":[],"details":[],"innererror":{"code":"TokenNotFound",...
const reason = JSON.parse(rt).code; const j = JSON.parse(rt);
if (reason == 'NotFound') { const reason = j?.events?.cart.length && j.events.cart[0]?.data?.reason;
if (reason == 'TokenNotFound') {
redeem_action = 'redeem (not found)'; redeem_action = 'redeem (not found)';
console.error(' Code was not found!'); console.error(' Code was not found!');
} else { // TODO find out other responses } else if (j?.productInfos?.length && j.productInfos[0]?.redeemable) {
await page2.click('#nextButton'); await iframe.locator('button:has-text("Next")').click();
redeem_action = 'redeemed?'; await iframe.locator('button:has-text("Confirm")').click();
console.log(' Redeemed successfully? Please report your Response from above (if it is new) in https://github.com/vogler/free-games-claimer/issues/5'); const r = page2.waitForResponse(r => r.url().startsWith('https://cart.production.store-web.dynamics.com/v1.0/Redeem/RedeemToken'));
const j = JSON.parse(await (await r).text());
if (j?.events?.cart.length && j.events.cart[0]?.data?.reason == 'UserAlreadyOwnsContent') {
redeem_action = 'already redeemed';
console.error(' error: UserAlreadyOwnsContent');
} else if (true) { // TODO what's returned on success?
redeem_action = 'redeemed';
db.data[user][title].status = 'claimed and redeemed?'; db.data[user][title].status = 'claimed and redeemed?';
console.log(' Redeemed successfully? Please report if not in https://github.com/vogler/free-games-claimer/issues/5');
}
} else { // TODO find out other responses
redeem_action = 'unknown';
console.debug(` Response: ${rt}`);
console.log(' Redeemed successfully? Please report your Response from above (if it is new) in https://github.com/vogler/free-games-claimer/issues/5');
} }
} }
} else if (store == 'legacy games') { } else if (store == 'legacy games') {
await page2.fill('[name=coupon_code]', code); await page2.fill('[name=coupon_code]', code);
await page2.fill('[name=email]', cfg.pg_email); // TODO option for sep. email? await page2.fill('[name=email]', cfg.lg_email);
await page2.fill('[name=email_validate]', cfg.pg_email); await page2.fill('[name=email_validate]', cfg.lg_email);
await page2.uncheck('[name=newsletter_sub]'); await page2.uncheck('[name=newsletter_sub]');
await page2.click('[type="submit"]'); await page2.click('[type="submit"]');
try { try {
@ -264,7 +321,7 @@ try {
if (cfg.debug) await page2.pause(); if (cfg.debug) await page2.pause();
await page2.close(); await page2.close();
} }
notify_game.status = `<a href="${redeem[store]}">${redeem_action}</a> ${code} on ${store}`; notify_game.status = `<a href="${redeem_url}">${redeem_action}</a> ${code} on ${store}`;
} else { } else {
notify_game.status = `claimed on ${store}`; notify_game.status = `claimed on ${store}`;
db.data[user][title].status = 'claimed'; db.data[user][title].status = 'claimed';
@ -281,8 +338,7 @@ try {
if (notify_games.length) { // make screenshot of all games if something was claimed if (notify_games.length) { // make screenshot of all games if something was claimed
const p = screenshot(`${filenamify(datetime())}.png`); const p = screenshot(`${filenamify(datetime())}.png`);
// await page.screenshot({ path: p, fullPage: true }); // fullPage does not make a difference since scroll not on body but on some element // await page.screenshot({ path: p, fullPage: true }); // fullPage does not make a difference since scroll not on body but on some element
await page.keyboard.press('End'); // scroll to bottom to show all games await scrollUntilStable(() => games.locator('.item-card__action').count());
await page.waitForTimeout(1000); // wait for fade in animation
const viewportSize = page.viewportSize(); // current viewport size 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 await games.screenshot({ path: p }); // screenshot of all claimed games
@ -296,17 +352,7 @@ try {
await loot.waitFor(); await loot.waitFor();
process.stdout.write('Loading all DLCs on page...'); process.stdout.write('Loading all DLCs on page...');
let n1 = 0; await scrollUntilStable(() => loot.locator('[data-a-target="item-card"]').count())
let n2 = 0;
do {
n1 = n2;
n2 = await loot.locator('[data-a-target="item-card"]').count();
// console.log(n2);
process.stdout.write(` ${n2}`);
await page.keyboard.press('End'); // scroll to bottom to show all dlcs
await page.waitForLoadState('networkidle'); // did not wait for dlcs to be loaded
await page.waitForTimeout(1000);
} while (n2 > n1);
console.log('\nNumber of already claimed DLC:', await loot.locator('p:has-text("Collected")').count()); console.log('\nNumber of already claimed DLC:', await loot.locator('p:has-text("Collected")').count());

View file

@ -41,12 +41,13 @@ export const cfg = {
gog_email: process.env.GOG_EMAIL || process.env.EMAIL, gog_email: process.env.GOG_EMAIL || process.env.EMAIL,
gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD, gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD,
gog_newsletter: process.env.GOG_NEWSLETTER == '1', // do not unsubscribe from newsletter after claiming a game gog_newsletter: process.env.GOG_NEWSLETTER == '1', // do not unsubscribe from newsletter after claiming a game
// auth AliExpress
ae_email: process.env.AE_EMAIL || process.env.EMAIL,
ae_password: process.env.AE_PASSWORD || process.env.PASSWORD,
// OTP only via GOG_EMAIL, can't add app... // OTP only via GOG_EMAIL, can't add app...
// auth xbox // experimmental
xbox_email: process.env.XBOX_EMAIL || process.env.EMAIL,
xbox_password: process.env.XBOX_PASSWORD || process.env.PASSWORD,
xbox_otpkey: process.env.XBOX_OTPKEY,
// experimmental - likely to change
pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores
lg_email: process.env.LG_EMAIL || process.env.PG_EMAIL || process.env.EMAIL, // prime-gaming: external: legacy-games: email to use for redeeming
pg_claimdlc: process.env.PG_CLAIMDLC == '1', // prime-gaming: claim in-game content pg_claimdlc: process.env.PG_CLAIMDLC == '1', // prime-gaming: claim in-game content
pg_timeLeft: process.env.PG_TIMELEFT == '1', // prime-gaming: list time left to claim
}; };

View file

@ -11,8 +11,8 @@ export const dataDir = s => path.resolve(__dirname, '..', 'data', s);
export const resolve = (...a) => a.length && a[0] == '0' ? null : path.resolve(...a); export const resolve = (...a) => a.length && a[0] == '0' ? null : path.resolve(...a);
// json database // json database
import { JSONPreset } from 'lowdb/node'; import { JSONFilePreset } from 'lowdb/node';
export const jsonDb = (file, defaultData) => JSONPreset(dataDir(file), defaultData); export const jsonDb = (file, defaultData) => JSONFilePreset(dataDir(file), defaultData);
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// date and time as UTC (no timezone offset) in nicely readable and sortable format, e.g., 2022-10-06 12:05:27.313 // date and time as UTC (no timezone offset) in nicely readable and sortable format, e.g., 2022-10-06 12:05:27.313
@ -116,7 +116,7 @@ export const notify = html => new Promise((resolve, reject) => {
return resolve(); return resolve();
} }
// const cmd = `apprise '${cfg.notify}' ${title} -i html -b '${html}'`; // this had problems if e.g. ' was used in arg; could have `npm i shell-escape`, but instead using safer execFile which takes args as array instead of exec which spawned a shell to execute the command // const cmd = `apprise '${cfg.notify}' ${title} -i html -b '${html}'`; // this had problems if e.g. ' was used in arg; could have `npm i shell-escape`, but instead using safer execFile which takes args as array instead of exec which spawned a shell to execute the command
const args = [cfg.notify, '-i', 'html', '-b', html]; const args = [cfg.notify, '-i', 'html', '-b', `'${html}'`];
if (cfg.notify_title) args.push(...['-t', cfg.notify_title]); if (cfg.notify_title) args.push(...['-t', cfg.notify_title]);
if (cfg.debug) console.debug(`apprise ${args.map(a => `'${a}'`).join(' ')}`); // this also doesn't escape, but it's just for info if (cfg.debug) console.debug(`apprise ${args.map(a => `'${a}'`).join(' ')}`); // this also doesn't escape, but it's just for info
execFile('apprise', args, (error, stdout, stderr) => { execFile('apprise', args, (error, stdout, stderr) => {

View file

@ -25,7 +25,7 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, {
// 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-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
}); });

251
xbox.js
View file

@ -1,251 +0,0 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib';
import {
datetime,
handleSIGINT,
html_game_list,
jsonDb,
notify,
prompt,
} from './src/util.js';
import { cfg } from './src/config.js';
// ### SETUP
const URL_CLAIM = 'https://www.xbox.com/en-US/live/gold'; // #gameswithgold";
console.log(datetime(), 'started checking xbox');
const db = await jsonDb('xbox.json');
db.data ||= {};
handleSIGINT();
// https://playwright.dev/docs/auth#multi-factor-authentication
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
});
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length
? context.pages()[0]
: await context.newPage(); // should always exist
await page.setViewportSize({ width: cfg.width, height: cfg.height }); // TODO workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it
const notify_games = [];
let user;
main();
async function main() {
try {
await performLogin();
await getAndSaveUser();
await redeemFreeGames();
} catch (error) {
console.error(error);
process.exitCode ||= 1;
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) {
// don't notify if all were already claimed
notify(`xbox (${user}):<br>${html_game_list(notify_games)}`);
}
await context.close();
}
}
async function performLogin() {
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
const signInLocator = page
.getByRole('link', {
name: 'Sign in to your account',
})
.first();
const usernameLocator = page
.getByRole('button', {
name: 'Account manager for',
})
.first();
await Promise.any([signInLocator.waitFor(), usernameLocator.waitFor()]);
if (await usernameLocator.isVisible()) {
return; // logged in using saved cookie
} else if (await signInLocator.isVisible()) {
console.error('Not signed in anymore.');
await signInLocator.click();
await signInToXbox();
} else {
console.error('lost! where am i?');
}
}
async function signInToXbox() {
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).',
);
const email = cfg.xbox_email || await prompt({ message: 'Enter email' });
const password =
email &&
(cfg.xbox_password ||
await prompt({
type: 'password',
message: 'Enter password',
}));
// ### FILL IN EMAIL/PASS
if (email && password) {
const usernameLocator = page
.getByPlaceholder('Email, phone, or Skype')
.first();
const passwordLocator = page.getByPlaceholder('Password').first();
await Promise.any([
usernameLocator.waitFor(),
passwordLocator.waitFor(),
]);
// username may already be saved from before, if so, skip to filling in password
if (await page.getByPlaceholder('Email, phone, or Skype').isVisible()) {
await usernameLocator.fill(email);
await page.getByRole('button', { name: 'Next' }).click();
}
await passwordLocator.fill(password);
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(
await page
.locator('div[data-bind="text: description"]')
.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
await page.type('input[name="otc"]', otp.toString());
await page
.getByLabel('Don\'t ask me again on this device')
.check(); // Trust this Browser
await page.getByRole('button', { name: 'Verify' }).click();
})
.catch(_ => {});
// Trust this browser, but don't await it
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();
})
.catch(_ => {});
} else {
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.',
);
if (cfg.headless) {
console.log(
'Run `SHOW=1 node xbox` to login in the opened browser.',
);
await context.close();
process.exit(1);
}
}
// ### VERIFY SIGNED IN
await page.waitForURL(`${URL_CLAIM}**`);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
}
async function getAndSaveUser() {
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 monthlyGamesPageLinks = await Promise.all(
monthlyGamesLocator.map(
async el => await el.locator('a').getAttribute('href'),
),
);
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();
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
// SELECTORS
const getBtnLocator = page.getByText('GET', { exact: true }).first();
const installToLocator = page
.getByText('INSTALL TO', { exact: true })
.first();
await Promise.any([
getBtnLocator.waitFor(),
installToLocator.waitFor(),
]);
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
} else if (await getBtnLocator.isVisible()) {
console.log(' Not in library yet! Click GET.');
await getBtnLocator.click();
// wait for popup
await page
.locator('iframe[name="purchase-sdk-hosted-iframe"]')
.waitFor();
const popupLocator = page.frameLocator(
'[name=purchase-sdk-hosted-iframe]',
);
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';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(' Claimed successfully!');
}
// notify_game.status = db.data[user][game_id].status; // claimed or failed
// const p = path.resolve(cfg.dir.screenshots, playstation-plus', `${game_id}.png`);
// if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
}
}