Merge branch 'main' into working-steam

This commit is contained in:
drklien 2025-07-25 11:21:51 +10:00 committed by GitHub
commit e1e9986847
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 3142 additions and 950 deletions

13
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,13 @@
# These are supported funding model platforms
github: vogler # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: fgc # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: vogler # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: vogler # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ["https://www.buymeacoffee.com/vogler", "https://paypal.me/voglerr"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

7
.github/renovate.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabled": false,
"extends": [
"config:recommended"
]
}

View file

@ -1,21 +1,21 @@
name: Build and push Docker image (amd64, arm64 to hub.docker.com and ghcr.io) name: Build and push Docker image (amd64, arm64 to hub.docker.com and ghcr.io)
on: on:
workflow_dispatch: # allow manual trigger workflow_dispatch: # allows manual trigger
# https://github.com/orgs/community/discussions/26276 push: # push on branch
push: branches: [main, dev]
branches: paths: # ignore changes to .md files
- "main"
- "v*"
tags:
- "v*"
paths: # ignore changes to certain files
- '**' - '**'
- '!*.md' - '!*.md'
# - '!.github/**' # - '!.github/**'
pull_request: # runs when opened/reopned or when the head branch is updated, see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request pull_request: # runs when opened/reopned or when the head branch is updated
branches:
- "main" # only PRs against main permissions:
contents: read
packages: write
env:
BRANCH: ${{ github.head_ref || github.ref_name }} # head_ref/base_ref are only set for PRs, for branches ref_name will be used
jobs: jobs:
docker: docker:
@ -27,12 +27,11 @@ jobs:
- -
name: Set environment variables name: Set environment variables
run: | run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $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
@ -43,7 +42,7 @@ jobs:
- -
name: Login to Docker Hub name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
if: github.event_name != 'pull_request' # TODO if DOCKERHUB_* are set? # if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} # does not work: Unrecognized named-value: 'secrets' - https://www.cloudtruth.com/blog/skipping-jobs-in-github-actions-when-secrets-are-unavailable-securely-inject-configuration-secrets-into-github
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -56,17 +55,16 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
# if: github.event_name != 'pull_request' # still want to build image if: ${{ env.IMAGE_TAG != '' }}
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} # TODO push for forks? push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
build-args: | build-args: |
COMMIT=${{ github.sha }} COMMIT=${{ github.sha }}
BRANCH=${{ env.BRANCH }} BRANCH=${{ env.BRANCH }}
NOW=${{ env.NOW }} NOW=${{ env.NOW }}
platforms: linux/amd64,linux/arm64 # ,linux/arm/v7 platforms: linux/amd64,linux/arm64
# TODO docker tag only if DOCKERHUB_* are set?
tags: | tags: |
${{ secrets.DOCKERHUB_USERNAME }}/free-games-claimer:${{env.IMAGE_TAG}} ${{ secrets.DOCKERHUB_USERNAME }}/free-games-claimer:${{env.IMAGE_TAG}}
ghcr.io/${{ github.actor }}/free-games-claimer:${{env.IMAGE_TAG}} ghcr.io/${{ github.actor }}/free-games-claimer:${{env.IMAGE_TAG}}

36
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,36 @@
# https://github.com/marketplace/actions/super-linter#get-started
name: Lint
on: # yamllint disable-line rule:truthy
push: null
pull_request: null
permissions: {}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
# To report GitHub Actions status checks
statuses: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
# super-linter needs the full git history to get the
# list of files that changed across commits
fetch-depth: 0
- name: Super-linter
uses: super-linter/super-linter/slim@v7.4.0 # x-release-please-version
# TODO need to create problem matchers for each linter? https://github.com/rhysd/actionlint/blob/v1.7.7/docs/usage.md#problem-matchers
env:
# To report GitHub Actions status checks
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TODO automatically fix linting issues and commit them for PRs
# fix-lint-issues: # https://github.com/marketplace/actions/super-linter#github-actions-workflow-example-pull-request

View file

@ -1,3 +1,5 @@
name: Sonar
on: on:
# Trigger analysis when pushing in main or pull requests, and when creating a pull request. # Trigger analysis when pushing in main or pull requests, and when creating a pull request.
push: push:
@ -5,7 +7,10 @@ on:
- main - main
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
name: Sonar
permissions:
contents: read
jobs: jobs:
sonarcloud: sonarcloud:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -16,7 +21,7 @@ jobs:
# Disabling shallow clone is recommended for improving relevancy of reporting. Otherwise sonarcloud will show a warning. # Disabling shallow clone is recommended for improving relevancy of reporting. Otherwise sonarcloud will show a warning.
fetch-depth: 0 fetch-depth: 0
- -
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
cache: 'npm' cache: 'npm'
- -

View file

@ -3,7 +3,8 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnSaveMode": "modifications", "editor.formatOnSaveMode": "modifications",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"eslint.experimental.useFlatConfig": true, "eslint.experimental.useFlatConfig": true,
"eslint.codeActionsOnSave.rules": null,
} }

View file

@ -6,10 +6,10 @@
# free-games-claimer # free-games-claimer
Claims free games periodically on Claims free games periodically on
- <img src="https://upload.wikimedia.org/wikipedia/commons/3/31/Epic_Games_logo.svg" width="32"/> [Epic Games Store](https://www.epicgames.com/store/free-games) - <img src="https://github.com/user-attachments/assets/82e9e9bf-b6ac-4f20-91db-36d2c8429cb6" width="32" align="middle" /> [Epic Games Store](https://www.epicgames.com/store/free-games)
- <img src="https://seeklogo.com/images/P/prime-gaming-logo-61A701B3F5-seeklogo.com.png" width="32"/> [Amazon Prime Gaming](https://gaming.amazon.com) - <img src="https://github.com/user-attachments/assets/7627a108-20c6-4525-a1d8-5d221ee89d6e" width="32" align="middle" /> [Amazon Prime Gaming](https://gaming.amazon.com)
- <img src="https://static.wikia.nocookie.net/this-war-of-mine/images/1/1a/Logo_GoG.png/revision/latest?cb=20160711062658" width="32"/> [GOG](https://www.gog.com) - <img src="https://github.com/user-attachments/assets/49040b50-ee14-4439-8e3c-e93cafd7c3a5" width="32" align="middle" /> [GOG](https://www.gog.com)
- <img src="https://cdn2.unrealengine.com/ue-logo-white-e34b6ba9383f.svg" width="32"/> [Unreal Engine (Assets)](https://www.unrealengine.com/marketplace/en-US/assets?count=20&sortBy=effectiveDate&sortDir=DESC&start=0&tag=4910) ([experimental](https://github.com/vogler/free-games-claimer/issues/44), same login as Epic Games) - <img src="https://github.com/user-attachments/assets/3582444b-f23b-448d-bf31-01668cd0313a" width="32" align="middle" /> [Unreal Engine (Assets)](https://www.unrealengine.com/marketplace/en-US/assets?count=20&sortBy=effectiveDate&sortDir=DESC&start=0&tag=4910) ([experimental](https://github.com/vogler/free-games-claimer/issues/44), same login as Epic Games)
<!-- - <img src="https://www.freepnglogos.com/uploads/xbox-logo-picture-png-14.png" width="32"/> [Xbox Live Games with Gold](https://www.xbox.com/en-US/live/gold#gameswithgold) ([experimental](https://github.com/vogler/free-games-claimer/issues/19)) --> <!-- - <img src="https://www.freepnglogos.com/uploads/xbox-logo-picture-png-14.png" width="32"/> [Xbox Live Games with Gold](https://www.xbox.com/en-US/live/gold#gameswithgold) ([experimental](https://github.com/vogler/free-games-claimer/issues/19)) -->
Pull requests welcome :) Pull requests welcome :)
@ -21,7 +21,7 @@ _Works on Windows/macOS/Linux._
Raspberry Pi (3, 4, Zero 2): [requires 64-bit OS](https://github.com/vogler/free-games-claimer/issues/3) like Raspberry Pi OS or Ubuntu (Raspbian won't work since it's 32-bit). Raspberry Pi (3, 4, Zero 2): [requires 64-bit OS](https://github.com/vogler/free-games-claimer/issues/3) like Raspberry Pi OS or Ubuntu (Raspbian won't work since it's 32-bit).
## How to run ## How to run
Easy option: [install Docker](https://docs.docker.com/get-docker/) (or [podman](https://podman-desktop.io/)) and run this command in a terminal (Windows: `cmd`, `.bat` file): Easy option: [install Docker](https://docs.docker.com/get-docker/) (or [podman](https://podman-desktop.io/)) and run this command in a terminal:
``` ```
docker run --rm -it -p 6080:6080 -v fgc:/fgc/data --pull=always ghcr.io/vogler/free-games-claimer docker run --rm -it -p 6080:6080 -v fgc:/fgc/data --pull=always ghcr.io/vogler/free-games-claimer
``` ```
@ -37,7 +37,7 @@ Data (including json files with claimed games, codes to redeem, screenshots) is
1. [Install Node.js](https://nodejs.org/en/download) 1. [Install Node.js](https://nodejs.org/en/download)
2. Clone/download this repository and `cd` into it in a terminal 2. Clone/download this repository and `cd` into it in a terminal
3. Run `npm install` 3. Run `npm install`
4. Run `pip install apprise` to install [apprise](https://github.com/caronc/apprise) if you want notifications 4. Run `pip install apprise` (or use [pipx](https://github.com/pypa/pipx) if you have [problems](https://stackoverflow.com/questions/75608323/how-do-i-solve-error-externally-managed-environment-every-time-i-use-pip-3)) to install [apprise](https://github.com/caronc/apprise) if you want notifications
5. To get updates: `git pull; npm install` 5. To get updates: `git pull; npm install`
6. Run `node epic-games`, `node prime-gaming`, `node gog`... 6. Run `node epic-games`, `node prime-gaming`, `node gog`...
@ -97,9 +97,9 @@ Available options/variables and their default values:
| STEAM_JSON | 0 | Claims steam games from json. STEAM_JSON_URL can be defined. | | STEAM_JSON | 0 | Claims steam games from json. STEAM_JSON_URL can be defined. |
| STEAM_JSON_URL | [steam-games.json](https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json) | A list of steam urls in json format to claim the games. | | STEAM_JSON_URL | [steam-games.json](https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json) | A list of steam urls in json format to claim the games. |
| STEAM_GAMERPOWER | 0 | Claims steam games using [gamerpower api](https://www.gamerpower.com/api/giveaways?platform=steam&type=game). | | STEAM_GAMERPOWER | 0 | Claims steam games using [gamerpower api](https://www.gamerpower.com/api/giveaways?platform=steam&type=game). |
| LG_EMAIL | | Legacy Games: email to use for redeeming (if not set, defaults to PG_EMAIL) |
See `src/config.js` for all options.
See `config.js` for all options.
#### How to set options #### How to set options
You can add options directly in the command or put them in a file to load. You can add options directly in the command or put them in a file to load.
@ -109,7 +109,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
@ -162,7 +162,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) - 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.
@ -218,6 +218,9 @@ Added notifications via [apprise](https://github.com/caronc/apprise).
</details> </details>
[![Star History Chart](https://api.star-history.com/svg?repos=vogler/free-games-claimer&type=Date)](https://star-history.com/#vogler/free-games-claimer&Date) [![Star History Chart](https://api.star-history.com/svg?repos=vogler/free-games-claimer&type=Date)](https://star-history.com/#vogler/free-games-claimer&Date)
<!-- [![Stargazers over time](https://starchart.cc/vogler/free-games-claimer.svg?variant=adaptive)](https://starchart.cc/vogler/free-games-claimer) -->
![Alt](https://repobeats.axiom.co/api/embed/a1c5e6e420d90e0d6b34c1285e92a69a44138faa.svg "Repobeats analytics image")
--- ---

124
aliexpress.js Normal file
View file

@ -0,0 +1,124 @@
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';
// using https://github.com/apify/fingerprint-suite worked, but has no launchPersistentContext...
// from https://github.com/apify/fingerprint-suite/issues/162
import { FingerprintInjector } from 'fingerprint-injector';
import { FingerprintGenerator } from 'fingerprint-generator';
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
devices: ["mobile"],
operatingSystems: ["android"],
});
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/aliexpress-${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
userAgent: fingerprint.navigator.userAgent,
viewport: {
width: fingerprint.screen.width,
height: fingerprint.screen.height,
},
extraHTTPHeaders: {
'accept-language': headers['accept-language'],
},
});
handleSIGINT(context);
// await stealth(context);
await new FingerprintInjector().attachFingerprintToPlaywright(context, { fingerprint, headers });
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,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
set -eo pipefail # exit on error, error on any fail in pipe (not just last cmd); add -x to print each cmd; see gist bash_strict_mode.md set -eo pipefail # exit on error, error on any fail in pipe (not just last cmd); add -x to print each cmd; see gist bash_strict_mode.md
@ -12,6 +12,19 @@ echo "Build: $NOW"
# https://bugs.chromium.org/p/chromium/issues/detail?id=367048 # https://bugs.chromium.org/p/chromium/issues/detail?id=367048
rm -f /fgc/data/browser/SingletonLock rm -f /fgc/data/browser/SingletonLock
# Firefox preferences are stored in $BROWSER_DIR/pref.js and can be overridden by a file user.js
# Since this file has to be in the volume (data/browser), we can't do this in Dockerfile.
mkdir -p /fgc/data/browser
# fix for 'Incorrect response' after solving a captcha correctly - https://github.com/vogler/free-games-claimer/issues/261#issuecomment-1868385830
# echo 'user_pref("privacy.resistFingerprinting", true);' > /fgc/data/browser/user.js
cat << EOT > /fgc/data/browser/user.js
user_pref("privacy.resistFingerprinting", true);
// user_pref("privacy.resistFingerprinting.letterboxing", true);
// user_pref("browser.contentblocking.category", "strict");
// user_pref("webgl.disabled", true);
EOT
# TODO disable session restore message?
# Remove X server display lock, fix for `docker compose up` which reuses container which made it fail after initial run, https://github.com/vogler/free-games-claimer/issues/31 # Remove X server display lock, fix for `docker compose up` which reuses container which made it fail after initial run, https://github.com/vogler/free-games-claimer/issues/31
# echo $DISPLAY # echo $DISPLAY
# ls -l /tmp/.X11-unix/ # ls -l /tmp/.X11-unix/

View file

@ -1,9 +1,10 @@
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 chalk from 'chalk';
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 './util.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './config.js'; import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a);
@ -16,30 +17,29 @@ const db = await jsonDb('epic-games.json', {});
if (cfg.time) console.time('startup'); if (cfg.time) console.time('startup');
// https://www.nopecha.com extension source from https://github.com/NopeCHA/NopeCHA/releases/tag/0.1.16 const browserPrefs = path.join(cfg.dir.browser, 'prefs.js');
// const ext = path.resolve('nopecha'); // used in Chromium, currently not needed in Firefox 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, {
// chrome will not work in linux arm64, only chromium
// channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
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
args: [ // https://peter.sh/experiments/chromium-command-line-switches // user settings for firefox have to be put in $BROWSER_DIR/user.js
// don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.' args: [ // https://wiki.mozilla.org/Firefox/CommandLineOptions
// '--restore-last-session', // does not apply for crash/killed // '-kiosk',
'--hide-crash-restore-bubble',
// `--disable-extensions-except=${ext}`,
// `--load-extension=${ext}`,
], ],
// ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
}); });
handleSIGINT(context); handleSIGINT(context);
@ -50,10 +50,12 @@ await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); 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
// some debug info about the page (screen dimensions, user agent, platform)
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
if (cfg.debug) console.debug(await page.evaluate(() => window.screen)); if (cfg.debug) console.debug(await page.evaluate(() => [(({ width, height, availWidth, availHeight }) => ({ width, height, availWidth, availHeight }))(window.screen), navigator.userAgent, navigator.platform, navigator.vendor])); // deconstruct screen needed since `window.screen` prints {}, `window.screen.toString()` '[object Screen]', and can't use some pick function without defining it on `page`
if (cfg.record && cfg.debug) { if (cfg.debug_network) {
// const filter = _ => true; // const filter = _ => true;
const filter = r => r.url().includes('store.epicgames.com'); const filter = r => r.url().includes('store.epicgames.com');
page.on('request', request => filter(request) && console.log('>>', request.method(), request.url())); page.on('request', request => filter(request) && console.log('>>', request.method(), request.url()));
@ -64,7 +66,10 @@ const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{ name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago. await context.addCookies([
{ name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }, // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago.
{ name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, // gets rid of 'To continue, please provide your date of birth', https://github.com/vogler/free-games-claimer/issues/275, USK number doesn't seem to matter, cookie from 'Fallout 3: Game of the Year Edition'
]);
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
@ -81,20 +86,38 @@ try {
await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' });
if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.');
else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).');
const notifyBrowserLogin = async () => {
console.log('Waiting for you to login in the browser.');
await notify('epic-games: no longer signed in and not enough options set for automatic login.');
if (cfg.headless) {
console.log('Run `SHOW=1 node epic-games` to login in the opened browser.');
await context.close(); // finishes potential recording
process.exit(1);
}
};
const email = cfg.eg_email || await prompt({ message: 'Enter email' }); const email = cfg.eg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' })); if (!email) await notifyBrowserLogin();
if (email && password) { else {
// await page.click('text=Sign in with Epic Games'); // await page.click('text=Sign in with Epic Games');
await page.fill('#email', email); page.waitForSelector('.h_captcha_challenge iframe').then(async () => {
await page.click('button[type="submit"]');
await page.fill('#password', password);
await page.click('button[type="submit"]');
page.waitForSelector('#h_captcha_challenge_login_prod 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('h6: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(_ => { });
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' }));
if (!password) await notifyBrowserLogin();
else {
await page.fill('#password', password);
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(_ => { }); }).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 () => {
@ -104,14 +127,6 @@ try {
await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
}).catch(_ => { }); }).catch(_ => { });
} else {
console.log('Waiting for you to login in the browser.');
await notify('epic-games: no longer signed in and not enough options set for automatic login.');
if (cfg.headless) {
console.log('Run `SHOW=1 node epic-games` to login in the opened browser.');
await context.close(); // finishes potential recording
process.exit(1);
}
} }
await page.waitForURL(URL_CLAIM); await page.waitForURL(URL_CLAIM);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
@ -142,28 +157,54 @@ 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 purchaseBtn = page.locator('button[data-testid="purchase-cta-button"] >> :has-text("e"), :has-text("i")').first(); // when loading, the button text is empty -> need to wait for some text {'get', 'in library', 'requires base game'} -> just wait for e or i to not be too specific; :text-matches("\w+") somehow didn't work - https://github.com/vogler/free-games-claimer/issues/375
await purchaseBtn.waitFor();
const btnText = (await purchaseBtn.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) {
console.log(' This game contains mature content recommended only for ages 18+'); console.log(' This game contains mature content recommended only for ages 18+');
if (await page.locator('[data-testid="AgeSelect"]').count()) {
console.error(' Got "To continue, please provide your date of birth" - This shouldn\'t happen due to cookie set above. Please report to https://github.com/vogler/free-games-claimer/issues/275');
await page.locator('#month_toggle').click();
await page.locator('#month_menu li:has-text("01")').click();
await page.locator('#day_toggle').click();
await page.locator('#day_menu li:has-text("01")').click();
await page.locator('#year_toggle').click();
await page.locator('#year_menu li:has-text("1987")').click();
}
await page.click('button:has-text("Continue")', { delay: 111 }); await page.click('button:has-text("Continue")', { delay: 111 });
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
} }
const title = await page.locator('h1').first().innerText(); let title;
let bundle_includes;
if (await page.locator('span:text-is("About Bundle")').count()) {
title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', '');
// h1 first didn't exist for bundles but now it does... However h1 would e.g. be 'Fallout® Classic Collection' instead of 'Fallout Classic Collection'
try {
bundle_includes = await Promise.all((await page.locator('.product-card-top-row h5').all()).map(b => b.innerText()));
} catch (e) {
console.error('Failed to get "Bundle Includes":', e);
}
} else {
title = await page.locator('h1').first().innerText();
}
const game_id = page.url().split('/').pop(); const game_id = page.url().split('/').pop();
const existedInDb = db.data[user][game_id];
db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only! 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:', chalk.blue(title));
if (bundle_includes) console.log(' This bundle includes:', bundle_includes);
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.');
if (!existedInDb) await notify(`Game already in library: ${url}`);
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';
@ -171,9 +212,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', btnText);
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 purchaseBtn.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?
@ -182,9 +226,11 @@ try {
page.click('button:has-text("Yes, buy now")').catch(_ => { }); page.click('button:has-text("Yes, buy now")').catch(_ => { });
// 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(':has-text("end user license agreement")').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(); console.log(page.innerHTML);
console.log('Please report the HTML above here: https://github.com/vogler/free-games-claimer/issues/371');
await page.locator('input#agree').check(); // TODO Bundle: got stuck here; likely unrelated to bundle and locator just changed: https://github.com/vogler/free-games-claimer/issues/371
await page.locator('button:has-text("Accept")').click(); await page.locator('button:has-text("Accept")').click();
}).catch(_ => { }); }).catch(_ => { });
@ -220,7 +266,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?
@ -228,14 +274,20 @@ try {
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.');
await notify('epic-games: got captcha challenge right before claim. Use VNC to solve it manually.'); // await notify(`epic-games: got captcha challenge right before claim of <a href="${url}">${title}</a>. Use VNC to solve it manually.`); // TODO not all apprise services understand HTML: https://github.com/vogler/free-games-claimer/pull/417
await notify(`epic-games: got captcha challenge for.\nGame link: ${url}`);
// TODO could even create purchase URL, see https://github.com/vogler/free-games-claimer/pull/130
// await page.waitForTimeout(2000); // await page.waitForTimeout(2000);
// const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`); // const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`);
// await captcha.screenshot({ path: p }); // await captcha.screenshot({ path: p });
// 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, but normal game now as well
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!');

View file

@ -24,6 +24,7 @@ export default [
// https://eslint.style/packages/js // https://eslint.style/packages/js
rules: { rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prefer-const': 'error',
'@stylistic/js/array-bracket-newline': ['error', 'consistent'], '@stylistic/js/array-bracket-newline': ['error', 'consistent'],
'@stylistic/js/array-bracket-spacing': 'error', '@stylistic/js/array-bracket-spacing': 'error',
'@stylistic/js/array-element-newline': ['error', 'consistent'], '@stylistic/js/array-element-newline': ['error', 'consistent'],

23
gog.js
View file

@ -1,6 +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 { resolve, jsonDb, datetime, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js'; import chalk from 'chalk';
import { cfg } from './config.js'; import { resolve, jsonDb, datetime, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'gog', ...a); const screenshot = (...a) => resolve(cfg.dir.screenshots, 'gog', ...a);
@ -10,13 +11,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
}); });
@ -25,6 +31,7 @@ handleSIGINT(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist 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
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); // console.debug('userAgent:', await page.evaluate(() => navigator.userAgent));
const notify_games = []; const notify_games = [];
@ -92,11 +99,11 @@ 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 match_all = text.match(/Claim (.*) and don't miss the|Success! (.*) was added to/);
const slug = await banner.getAttribute('href'); const title = match_all[1] ? match_all[1] : match_all[2];
const url = `https://gog.com${slug}`; const url = await banner.locator('a').first().getAttribute('href');
console.log(`Current free game: ${title} - ${url}`); console.log(`Current free game: ${chalk.blue(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);
// await page.locator('#giveaway:not(.is-loading)').waitFor(); // otherwise screenshot is sometimes with loading indicator instead of game title; #TODO fix, skipped due to timeout, see #240 // await page.locator('#giveaway:not(.is-loading)').waitFor(); // otherwise screenshot is sometimes with loading indicator instead of game title; #TODO fix, skipped due to timeout, see #240

3015
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,29 +3,35 @@
"version": "1.4.0", "version": "1.4.0",
"description": "Automatically claims free games on the Epic Games Store, Amazon Prime Gaming and GOG.", "description": "Automatically claims free games on the Epic Games Store, Amazon Prime Gaming and GOG.",
"homepage": "https://github.com/vogler/free-games-claimer", "homepage": "https://github.com/vogler/free-games-claimer",
"main": "index.js",
"scripts": {
"docker:build": "docker build . -t ghcr.io/vogler/free-games-claimer",
"docker": "cross-env-shell docker run --rm -it -p 5900:5900 -p 6080:6080 -v \\\"$INIT_CWD/data\\\":/fgc/data --name fgc ghcr.io/vogler/free-games-claimer"
},
"type": "module",
"dependencies": {
"cross-env": "^7.0.3",
"dotenv": "^16.3.1",
"enquirer": "^2.4.1",
"lowdb": "^6.1.1",
"otplib": "^12.0.1",
"playwright-firefox": "^1.39.0",
"puppeteer-extra-plugin-stealth": "^2.11.2"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/vogler/free-games-claimer.git" "url": "https://github.com/vogler/free-games-claimer.git"
}, },
"author": "Ralf Vogler", "author": "Ralf Vogler",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "index.js",
"scripts": {
"docker:build": "docker build . -t ghcr.io/vogler/free-games-claimer",
"docker": "cross-env-shell docker run --rm -it -p 5900:5900 -p 6080:6080 -v \\\"$INIT_CWD/data\\\":/fgc/data --name fgc ghcr.io/vogler/free-games-claimer",
"lint": "npx eslint ."
},
"type": "module",
"engines": {
"node": ">=17"
},
"dependencies": {
"chalk": "^5.4.1",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"enquirer": "^2.4.1",
"fingerprint-injector": "^2.1.66",
"lowdb": "^7.0.1",
"otplib": "^12.0.1",
"playwright-firefox": "^1.52.0",
"puppeteer-extra-plugin-stealth": "^2.11.2"
},
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin-js": "^1.0.1", "@stylistic/eslint-plugin-js": "^4.2.0",
"eslint": "^8.53.0" "eslint": "^9.26.0"
} }
} }

View file

@ -1,7 +1,8 @@
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 { resolve, jsonDb, datetime, stealth, filenamify, prompt, confirm, notify, html_game_list, handleSIGINT } from './util.js'; import chalk from 'chalk';
import { cfg } from './config.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, confirm, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'prime-gaming', ...a); const screenshot = (...a) => resolve(cfg.dir.screenshots, 'prime-gaming', ...a);
@ -18,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
}); });
@ -30,6 +31,7 @@ await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist 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
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); // console.debug('userAgent:', await page.evaluate(() => navigator.userAgent));
const notify_games = []; const notify_games = [];
@ -51,8 +53,9 @@ try {
const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' })); const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
await page.fill('[name=email]', email); await page.fill('[name=email]', email);
await page.click('input[type="submit"]');
await page.fill('[name=password]', password); await page.fill('[name=password]', password);
await page.check('[name=rememberMe]'); // await page.check('[name=rememberMe]'); // no longer exists
await page.click('input[type="submit"]'); await page.click('input[type="submit"]');
page.waitForURL('**/ap/signin**').then(async () => { // check for wrong credentials page.waitForURL('**/ap/signin**').then(async () => { // check for wrong credentials
const error = await page.locator('.a-alert-content').first().innerText(); const error = await page.locator('.a-alert-content').first().innerText();
@ -95,32 +98,77 @@ try {
process.exit(1); process.exit(1);
} }
await page.click('button[data-type="Game"]'); const waitUntilStable = async (f, act) => {
await page.keyboard.press('End'); // scroll to bottom to show all games let v;
while (true) {
const v2 = await f();
console.log('waitUntilStable', v2);
if (v == v2) break;
v = v2;
await act();
}
};
const scrollUntilStable = async f => await waitUntilStable(f, async () => {
// await page.keyboard.press('End'); // scroll to bottom to show all games
// loading all games became flaky; see https://github.com/vogler/free-games-claimer/issues/357
await page.keyboard.press('PageDown'); // scrolling to straight to the bottom started to skip loading some 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(3000); // TODO networkidle wasn't enough to load all already collected games
// do it again since once wasn't enough...
await page.keyboard.press('PageDown');
await page.waitForTimeout(3000);
});
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()); // number of games
await scrollUntilStable(() => page.evaluate(() => document.querySelector('.tw-full-width').scrollHeight)); // height may change during loading while number of games is still the same?
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();
console.log('Number of free unclaimed games (Prime Gaming):', internal.length); // bottom to top: oldest to newest games
internal.reverse();
external.reverse();
const sameOrNewPage = async url => new Promise(async (resolve, _reject) => {
const isNew = page.url() != url;
let p = page;
if (isNew) {
p = await context.newPage();
await p.goto(url, { waitUntil: 'domcontentloaded' });
}
resolve([p, isNew]);
});
const skipBasedOnTime = async url => {
// console.log(' Checking time left for game:', url);
const [p, isNew] = await sameOrNewPage(url);
const dueDateOrg = await p.locator('.availability-date .tw-bold').innerText();
const dueDate = new Date(Date.parse(dueDateOrg + ' 17:00'));
const daysLeft = (dueDate.getTime() - Date.now())/1000/60/60/24;
console.log(' ', await p.locator('.availability-date').innerText(), '->', daysLeft.toFixed(2));
if (isNew) await p.close();
return daysLeft > cfg.pg_timeLeft;
}
console.log('\nNumber 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) {
await card.scrollIntoViewIfNeeded(); await card.scrollIntoViewIfNeeded();
const title = await (await card.$('.item-card-details__body__primary')).innerText(); const title = await (await card.$('.item-card-details__body__primary')).innerText();
console.log('Current free game:', title); const slug = await (await card.$('a')).getAttribute('href');
const url = 'https://gaming.amazon.com' + slug.split('?')[0];
console.log('Current free game:', chalk.blue(title));
if (cfg.pg_timeLeft && await skipBasedOnTime(url)) continue;
if (cfg.dryrun) continue; if (cfg.dryrun) continue;
if (cfg.interactive && !await confirm()) continue; if (cfg.interactive && !await confirm()) continue;
await (await card.$('button:has-text("Claim")')).click(); await (await card.$('.tw-button:has-text("Claim")')).click();
db.data[user][title] ||= { title, time: datetime(), store: 'internal' }; db.data[user][title] ||= { title, time: datetime(), url, store: 'internal' };
notify_games.push({ title, status: 'claimed', url: URL_CLAIM }); notify_games.push({ title, status: 'claimed', url });
// const img = await (await card.$('img.tw-image')).getAttribute('src'); // const img = await (await card.$('img.tw-image')).getAttribute('src');
// console.log('Image:', img); // console.log('Image:', img);
await card.screenshot({ path: screenshot('internal', `${filenamify(title)}.png`) }); await card.screenshot({ path: screenshot('internal', `${filenamify(title)}.png`) });
} }
console.log('Number of free unclaimed games (external stores):', external.length); console.log('\nNumber of free unclaimed games (external stores):', external.length);
// claim games in external/linked stores. Linked: origin.com, epicgames.com; Redeem-key: gog.com, legacygames.com, microsoft // claim games in external/linked stores. Linked: origin.com, epicgames.com; Redeem-key: gog.com, legacygames.com, microsoft
const external_info = []; const external_info = [];
for (const card of external) { // need to get data incl. URLs in this loop and then navigate in another, otherwise .all() would update after coming back and .elementHandles() like above would lead to error due to page navigation: elementHandle.$: Protocol error (Page.adoptNode) for (const card of external) { // need to get data incl. URLs in this loop and then navigate in another, otherwise .all() would update after coming back and .elementHandles() like above would lead to error due to page navigation: elementHandle.$: Protocol error (Page.adoptNode)
@ -130,34 +178,18 @@ 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:', chalk.blue(title)); // , url);
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
if (cfg.debug) await page.pause(); if (cfg.debug) await page.pause();
const item_text = await page.innerText('[data-a-target="DescriptionItemDetails"]');
const store = item_text.toLowerCase().replace(/.* on /, '').slice(0, -1);
console.log(' External store:', store);
if (cfg.pg_timeLeft && await skipBasedOnTime(url)) continue;
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('button:has-text("Get game")'), page.click('button:has-text("Claim now")'), page.click('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
// TODO would be simpler than the below, but will block for linked stores without code
// const redeem_text = await page.textContent('text=/ code on /'); // FAQ: How do I redeem my code?
// console.log(' ', redeem_text);
// // Before July 29, 2023, redeem your offer code on GOG.com.
// // Before July 1, 2023, redeem your product code on Legacy Games.
// let store = redeem_text.toLowerCase().replace(/.* on /, '').slice(0, -1);
let store = '';
const store_text = await page.$('[data-a-target="hero-header-subtitle"]'); // worked fine for every store, but now no longer works for gog.com
if (store_text) { // legacy games, ?
const store_texts = await store_text.innerText();
// Full game for PC [and MAC] on: Legacy Games, Origin, EPIC GAMES, Battle.net; alt: 3 Full PC Games on Legacy Games
store = store_texts.toLowerCase().replace(/.* on /, '');
} else { // gog.com, ?
// $('[data-a-target="DescriptionItemDetails"]').innerText is e.g. 'Prey for PC on GOG.com.' but does not work for Legacy Games
const item_text = await page.innerText('[data-a-target="DescriptionItemDetails"]');
store = item_text.toLowerCase().replace(/.* on /, '').slice(0, -1);
}
console.log(' External store:', store);
db.data[user][title] ||= { title, time: datetime(), url, store }; db.data[user][title] ||= { title, time: datetime(), url, store };
const notify_game = { title, url }; const notify_game = { title, url };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
@ -177,16 +209,19 @@ 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()
const code = await Promise.any([page.inputValue('input[type="text"]'), page.textContent('[data-a-target="ClaimStateClaimCodeContent"]').then(s => s.replace('Your code: ', ''))]); // input: Legacy Games; text: gog.com const code = await Promise.any([page.inputValue('input[type="text"]'), page.textContent('[data-a-target="ClaimStateClaimCodeContent"]').then(s => s.replace('Your code: ', ''))]); // input: Legacy Games; text: gog.com
console.log(' Code to redeem game:', code); console.log(' Code to redeem game:', chalk.blue(code));
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
@ -223,42 +258,64 @@ try {
const r2 = page2.waitForResponse(r => r.request().method() == 'POST' && r.url().startsWith('https://redeem.gog.com/')); const r2 = page2.waitForResponse(r => r.request().method() == 'POST' && r.url().startsWith('https://redeem.gog.com/'));
await page2.click('[type="submit"]'); // click Redeem await page2.click('[type="submit"]'); // click Redeem
const r2t = await (await r2).text(); const r2t = await (await r2).text();
const reason2 = JSON.parse(r2t).reason;
if (r2t == '{}') { if (r2t == '{}') {
redeem_action = 'redeemed'; redeem_action = 'redeemed';
console.log(' Redeemed successfully.'); console.log(' Redeemed successfully.');
db.data[user][title].status = 'claimed and redeemed'; db.data[user][title].status = 'claimed and redeemed';
} else if (reason2?.includes('captcha')) {
redeem_action = 'redeem (got captcha)';
console.error(' Got captcha; could not redeem!');
} else { } else {
console.debug(` Response 2: ${r2t}`); console.debug(` Response 2: ${r2t}`);
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! Please redeem the code above manually. You can now login in the browser for next time. 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.pause();
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 {
@ -278,7 +335,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';
@ -295,8 +352,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
@ -310,17 +366,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());
@ -347,13 +393,13 @@ try {
try { try {
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
// most games have a button 'Get in-game content' // most games have a button 'Get in-game content'
// epic-games: Fall Guys: Claim now -> Continue -> Go to Epic Games (despite account linked and logged into epic-games) -> not tied to account but via some cookie? // epic-games: Fall Guys: Claim -> Continue -> Go to Epic Games (despite account linked and logged into epic-games) -> not tied to account but via some cookie?
await Promise.any([page.click('button:has-text("Get in-game content")'), page.click('button:has-text("Claim your gift")'), page.click('button:has-text("Claim now")').then(() => page.click('button:has-text("Continue")'))]); await Promise.any([page.click('.tw-button:has-text("Get in-game content")'), page.click('.tw-button:has-text("Claim your gift")'), page.click('.tw-button:has-text("Claim")').then(() => page.click('button:has-text("Continue")'))]);
page.click('button:has-text("Continue")').catch(_ => { }); page.click('button:has-text("Continue")').catch(_ => { });
const linkAccountButton = page.locator('[data-a-target="LinkAccountButton"]'); const linkAccountButton = page.locator('[data-a-target="LinkAccountButton"]');
let unlinked_store; let unlinked_store;
if (await linkAccountButton.count()) { if (await linkAccountButton.count()) {
unlinked_store = await linkAccountButton.getAttribute('aria-label'); unlinked_store = await linkAccountButton.first().getAttribute('aria-label');
console.debug(' LinkAccountButton label:', unlinked_store); console.debug(' LinkAccountButton label:', unlinked_store);
const match = unlinked_store.match(/Link (.*) account/); const match = unlinked_store.match(/Link (.*) account/);
if (match && match.length == 2) unlinked_store = match[1]; if (match && match.length == 2) unlinked_store = match[1];
@ -366,8 +412,8 @@ try {
dlc_unlinked[unlinked_store] ??= []; dlc_unlinked[unlinked_store] ??= [];
dlc_unlinked[unlinked_store].push(title); dlc_unlinked[unlinked_store].push(title);
} else { } else {
const code = await page.inputValue('input[type="text"]'); const code = await page.inputValue('input[type="text"]').catch(_ => undefined);
console.log(' Code to redeem game:', code); console.log(' Code to redeem game:', chalk.blue(code));
db.data[user][title].code = code; db.data[user][title].code = code;
db.data[user][title].status = 'claimed'; db.data[user][title].status = 'claimed';
// notify_game.status = `<a href="${redeem[store]}">${redeem_action}</a> ${code} on ${store}`; // notify_game.status = `<a href="${redeem[store]}">${redeem_action}</a> ${code} on ${store}`;

View file

@ -6,6 +6,7 @@ dotenv.config({ path: 'data/config.env' }); // loads env vars from file - will n
// Options - also see table in README.md // Options - also see table in README.md
export const cfg = { export const cfg = {
debug: process.env.DEBUG == '1' || process.env.PWDEBUG == '1', // runs non-headless and opens https://playwright.dev/docs/inspector debug: process.env.DEBUG == '1' || process.env.PWDEBUG == '1', // runs non-headless and opens https://playwright.dev/docs/inspector
debug_network: process.env.DEBUG_NETWORK == '1', // log network requests and responses
record: process.env.RECORD == '1', // `recordHar` (network) + `recordVideo` record: process.env.RECORD == '1', // `recordHar` (network) + `recordVideo`
time: process.env.TIME == '1', // log duration of each step time: process.env.TIME == '1', // log duration of each step
dryrun: process.env.DRYRUN == '1', // don't claim anything dryrun: process.env.DRYRUN == '1', // don't claim anything
@ -55,5 +56,13 @@ export const cfg = {
steam_json_url: process.env.STEAM_JSON_URL || 'https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json', steam_json_url: process.env.STEAM_JSON_URL || 'https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json',
steam_gamerpower: true, steam_gamerpower: true,
steam_gamerpower_url: process.env.STEAM_GAMERPOWER_URL || 'https://www.gamerpower.com/api/giveaways?platform=steam&type=game', steam_gamerpower_url: process.env.STEAM_GAMERPOWER_URL || 'https://www.gamerpower.com/api/giveaways?platform=steam&type=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...
// experimmental
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_timeLeft: Number(process.env.PG_TIMELEFT), // prime-gaming: check time left to claim and skip game if there are more than PG_TIMELEFT days left to claim it
}; };

View file

@ -5,14 +5,14 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// explicit object instead of Object.fromEntries since the built-in type would loose the keys, better type: https://dev.to/svehla/typescript-object-fromentries-389c // explicit object instead of Object.fromEntries since the built-in type would loose the keys, better type: https://dev.to/svehla/typescript-object-fromentries-389c
export const dataDir = s => path.resolve(__dirname, 'data', s); export const dataDir = s => path.resolve(__dirname, '..', 'data', s);
// modified path.resolve to return null if first argument is '0', used to disable screenshots // modified path.resolve to return null if first argument is '0', used to disable screenshots
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
@ -27,6 +27,28 @@ 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 launchChromium = async options => {
const { chromium } = await import('playwright-chromium'); // stealth plugin needs no outdated playwright-extra
// https://www.nopecha.com extension source from https://github.com/NopeCHA/NopeCHA/releases/tag/0.1.16
// const ext = path.resolve('nopecha'); // used in Chromium, currently not needed in Firefox
const context = chromium.launchPersistentContext(cfg.dir.browser, {
// chrome will not work in linux arm64, only chromium
// channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
args: [ // https://peter.sh/experiments/chromium-command-line-switches
// don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.'
// '--restore-last-session', // does not apply for crash/killed
'--hide-crash-restore-bubble',
// `--disable-extensions-except=${ext}`,
// `--load-extension=${ext}`,
],
// ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
...options,
});
return context;
};
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
@ -59,7 +81,7 @@ export const stealth = async context => {
const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`); const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`);
evasion.default().onPageCreated(stealth); evasion.default().onPageCreated(stealth);
} }
for (let evasion of stealth.callbacks) { for (const evasion of stealth.callbacks) {
await context.addInitScript(evasion.cb, evasion.a); await context.addInitScript(evasion.cb, evasion.a);
} }
}; };
@ -94,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

@ -42,6 +42,11 @@ const gh = await (await fetch('https://api.github.com/repos/vogler/free-games-cl
log('Local commit:', sha, new Date(date)); log('Local commit:', sha, new Date(date));
log('Online commit:', gh.sha, new Date(gh.commit.committer.date)); log('Online commit:', gh.sha, new Date(gh.commit.committer.date));
// git describe --all --long --dirty
// --> heads/main-0-gdee47d2-dirty
// git describe --tags --long --dirty
// --> v1.7-35-gdee47d2-dirty
if (sha == gh.sha) { if (sha == gh.sha) {
log('Running the latest version!'); log('Running the latest version!');
} else { } else {

View file

@ -1,6 +1,6 @@
/* 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 '../src/util.js';
import { cfg } from './config.js'; import { cfg } from '../src/config.js';
const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL

View file

@ -0,0 +1,21 @@
// open issue: prevents handleSIGINT() to work if prompt is cancelled with Ctrl-C instead of Escape: https://github.com/enquirer/enquirer/issues/372
function onRawSIGINT(fn) {
const { stdin, stdout } = process;
stdin.setRawMode(true);
stdin.resume();
stdin.on('data', data => {
const key = data.toString('utf-8');
if (key === '\u0003') { // ctrl + c
fn();
} else {
stdout.write(key);
}
});
}
console.log(1)
onRawSIGINT(() => {
console.log('raw'); process.exit(1);
});
console.log(2)
// onRawSIGINT workaround for enquirer keeps the process from exiting here...

View file

@ -0,0 +1,41 @@
// https://github.com/enquirer/enquirer/issues/372
import { prompt, handleSIGINT } from '../src/util.js';
// const handleSIGINT = () => process.on('SIGINT', () => { // e.g. when killed by Ctrl-C
// console.log('\nInterrupted by SIGINT. Exit!');
// process.exitCode = 130;
// });
handleSIGINT();
function onRawSIGINT(fn) {
const { stdin, stdout } = process;
stdin.setRawMode(true);
stdin.resume();
stdin.on('data', data => {
const key = data.toString('utf-8');
if (key === '\u0003') { // ctrl + c
fn();
} else {
stdout.write(key);
}
});
}
// onRawSIGINT(() => {
// console.log('raw'); process.exit(1);
// });
console.log('hello');
console.error('hello error');
try {
let i = 'foo';
i = await prompt(); // SIGINT no longer handled if this is executed
i = await prompt(); // SIGINT no longer handled if this is executed
// handleSIGINT();
console.log('value:', i);
setTimeout(() => console.log('timeout 3s'), 3000);
} catch (e) {
process.exitCode ||= 1;
console.log('catch. exitCode:', process.exitCode);
console.error(e);
}
console.log('end. exitCode:', process.exitCode);

View file

@ -0,0 +1,20 @@
// https://github.com/enquirer/enquirer/issues/372
import Enquirer from 'enquirer';
const enquirer = new Enquirer();
let interrupted = false;
process.on('SIGINT', () => {
if (interrupted) process.exit();
interrupted = true;
console.log('SIGINT');
});
await enquirer.prompt({
type: 'input',
name: 'username',
message: 'What is your username?',
});
await enquirer.prompt({
type: 'input',
name: 'username',
message: 'What is your username 2?',
});

View file

@ -5,8 +5,8 @@ import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdate
import { authenticator } from 'otplib'; import { authenticator } from 'otplib';
import path from 'path'; import path from 'path';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './config.js'; import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'unrealengine', ...a); const screenshot = (...a) => resolve(cfg.dir.screenshots, 'unrealengine', ...a);
@ -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
}); });
@ -36,6 +36,7 @@ await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist 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
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); // console.debug('userAgent:', await page.evaluate(() => navigator.userAgent));
const notify_games = []; const notify_games = [];

250
xbox.js
View file

@ -1,250 +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 './util.js';
import { cfg } from './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
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...
}
}