Merge branch 'main' into working-steam
This commit is contained in:
commit
e1e9986847
25 changed files with 3142 additions and 950 deletions
13
.github/FUNDING.yml
vendored
Normal file
13
.github/FUNDING.yml
vendored
Normal 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
7
.github/renovate.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"enabled": false,
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
40
.github/workflows/docker.yml
vendored
40
.github/workflows/docker.yml
vendored
|
|
@ -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
36
.github/workflows/lint.yml
vendored
Normal 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
|
||||||
9
.github/workflows/sonar.yml
vendored
9
.github/workflows/sonar.yml
vendored
|
|
@ -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'
|
||||||
-
|
-
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -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,
|
||||||
}
|
}
|
||||||
23
README.md
23
README.md
|
|
@ -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>
|
||||||
|
|
||||||
[](https://star-history.com/#vogler/free-games-claimer&Date)
|
[](https://star-history.com/#vogler/free-games-claimer&Date)
|
||||||
|
<!-- [](https://starchart.cc/vogler/free-games-claimer) -->
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
124
aliexpress.js
Normal file
124
aliexpress.js
Normal 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();
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
150
epic-games.js
150
epic-games.js
|
|
@ -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!');
|
||||||
|
|
|
||||||
|
|
@ -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
23
gog.js
|
|
@ -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
3015
package-lock.json
generated
File diff suppressed because it is too large
Load diff
40
package.json
40
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
194
prime-gaming.js
194
prime-gaming.js
|
|
@ -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}`;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
21
test/sigint-enquirer-raw-keeps-running.js
Normal file
21
test/sigint-enquirer-raw-keeps-running.js
Normal 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...
|
||||||
41
test/sigint-enquirer-raw.js
Normal file
41
test/sigint-enquirer-raw.js
Normal 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);
|
||||||
20
test/sigint-enquirer-simple.js
Normal file
20
test/sigint-enquirer-simple.js
Normal 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?',
|
||||||
|
});
|
||||||
|
|
@ -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
250
xbox.js
|
|
@ -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...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue