diff --git a/.forgejo/workflows/.eslintrc.cjs b/.forgejo/workflows/.eslintrc.cjs deleted file mode 100644 index bd4f055..0000000 --- a/.forgejo/workflows/.eslintrc.cjs +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = { - env: { - node: true, - es2021: true, - es6: true, - }, - extends: [ - 'eslint:recommended', - ], - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - rules: { - 'no-unused-vars': ['warn', { - varsIgnorePattern: '^_', - argsIgnorePattern: '^_', - }], - 'no-undef': 'error', - '@stylistic/js/comma-dangle': ['error', 'always-multiline'], - '@stylistic/js/arrow-parens': ['error', 'as-needed'], - }, - plugins: [ - '@stylistic/js', - ], - globals: { - screenshot: 'readonly', - cfg: 'readonly', - URL_CLAIM: 'readonly', - COOKIES_PATH: 'readonly', - BEARER_TOKEN_NAME: 'readonly', - notify: 'readonly', - authenticator: 'readonly', - prompt: 'readonly', - }, -}; diff --git a/.forgejo/workflows/.eslintrc.json b/.forgejo/workflows/.eslintrc.json deleted file mode 100644 index 346226c..0000000 --- a/.forgejo/workflows/.eslintrc.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "env": { - "node": true, - "es2021": true - }, - "extends": [ - "eslint:recommended" - ], - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "rules": { - "no-unused-vars": "warn", - "no-undef": "error" - }, - "globals": { - "cfg": "readonly", - "URL_CLAIM": "readonly", - "authenticator": "readonly", - "prompt": "readonly", - "notify": "readonly" - } -} - diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 60e07b7..f42dbff 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -4,60 +4,36 @@ on: push: branches: - main - - dev - -env: - IMAGE_TAG: ${{ github.ref == 'refs/heads/dev' && 'dev' || 'latest' }} - REPO_URL: https://git.sky-net.it jobs: lint: runs-on: self-hosted - container: - image: node:20-alpine steps: - - name: Manual Git Checkout - run: | - apk add --no-cache git - git init - git remote add origin ${{ env.REPO_URL }}/${{ github.repository }}.git - git fetch --depth 1 origin ${{ github.ref }} - git checkout FETCH_HEAD - + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 - name: Install dependencies run: npm ci - - name: Run ESLint run: npm run lint sonar: needs: lint runs-on: self-hosted - container: - image: node:20-alpine steps: - - name: Manual Git Checkout and Prepare - run: | - apk add --no-cache git curl bash - git init - git remote add origin ${{ env.REPO_URL }}/${{ github.repository }}.git - git fetch --depth 1 origin ${{ github.ref }} - git checkout FETCH_HEAD - - - name: Install Node.js and Sonar Scanner - run: | - npm install -g sonarqube-scanner - - - name: Install Node.js - run: | - apt-get update - apt-get install -y curl - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y nodejs - + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 - name: Install Sonar Scanner (npm) run: npm install -g sonarqube-scanner - - name: SonarQube Scan env: SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} @@ -66,7 +42,6 @@ jobs: run: | WORKDIR=${GITHUB_WORKSPACE:-$PWD} HOST_URL=${SONAR_HOST_URL:?SONAR_HOST_URL secret not set} - BRANCH_NAME=${GITHUB_REF#refs/heads/} PROJECT_KEY=${SONAR_PROJECT_KEY:-} if [ -z "$PROJECT_KEY" ] && [ -f sonar-project.properties ]; then PROJECT_KEY=$(grep -E '^sonar.projectKey=' sonar-project.properties | cut -d= -f2 | tr -d '\r') @@ -81,7 +56,7 @@ jobs: echo "Sample files:" find . -maxdepth 2 -type f | head -n 20 echo "Running local sonar-scanner..." - set -- \ + sonar-scanner \ -Dsonar.host.url="$HOST_URL" \ -Dsonar.token="$SONAR_TOKEN" \ -Dsonar.projectKey="$PROJECT_KEY" \ @@ -89,46 +64,24 @@ jobs: -Dsonar.scm.disabled=true \ -Dsonar.projectBaseDir="$WORKDIR" - if [ "${SONAR_ENABLE_BRANCH:-}" = "true" ]; then - set -- "$@" -Dsonar.branch.name="$BRANCH_NAME" - else - echo "Branch analysis disabled (requires SonarQube Developer Edition)" - fi - - sonar-scanner "$@" - docker: needs: [lint, sonar] runs-on: self-hosted steps: - - name: Network Debugging - run: | - cat /etc/resolv.conf - cat /etc/hosts - ping -c 4 server - getent hosts server - - - name: Manual Git Checkout - run: | - git init - git remote add origin ${{ env.REPO_URL }}/${{ github.repository }}.git - git fetch --depth 1 origin ${{ github.ref }} - git checkout FETCH_HEAD - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Checkout + uses: actions/checkout@v4 + - name: Login to registry run: echo "${{ secrets.REG_TOKEN }}" | docker login "${{ secrets.REGISTRY }}" -u "${{ secrets.REG_USER }}" --password-stdin - name: Build image run: | docker buildx build --load \ - -t "${{ secrets.REGISTRY_IMAGE }}:${{ env.IMAGE_TAG }}" . + -t "${{ secrets.REGISTRY_IMAGE }}:latest" . - name: Push image run: | - docker push "${{ secrets.REGISTRY_IMAGE }}:${{ env.IMAGE_TAG }}" - - - + docker push "${{ secrets.REGISTRY_IMAGE }}:latest" diff --git a/.gitignore b/.gitignore index cc33550..7983ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ node_modules/ data/ *.env -.continue diff --git a/.vscode/settings.json b/.vscode/settings.json index e6f2fe9..6106b4f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,3 @@ -### eslint style { // https://eslint.style/guide/faq#vs-code "editor.formatOnSave": true, diff --git a/Dockerfile b/Dockerfile index b0b8e01..cac91cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,6 +102,8 @@ ENV DEPTH 24 # Show browser instead of running headless ENV SHOW 1 +USER fgc + # Script to setup display server & VNC is always executed. ENTRYPOINT ["docker-entrypoint.sh"] # Default command to run. This is replaced by appending own command, e.g. `docker run ... node prime-gaming` to only run this script. diff --git a/README.md b/README.md index a56a7a5..a8fae1f 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Free Games Claimer (Fork) ========================== [![Quality Gate Status](https://sonata.cyber77.de/api/project_badges/measure?project=free-games-claimer&metric=alert_status&token=sqb_99c83edf82a1331f0c649f8a5b698b4ec8f9a965)](https://sonata.cyber77.de/dashboard?id=free-games-claimer) -- Optional notifications: `pip install apprise` + Automates claiming of free games for: - Amazon Luna Gaming / Luna claims (including external stores like GOG, Epic Games, Legacy Games ) - GOG giveaways - Optional extras: Steam stats, AliExpress dailies (not implemated yet) - -p 6080:6080 \ + Requirements ------------ - Docker or Podman (recommended), or Node.js ≥ 20 for local runs @@ -19,45 +19,35 @@ Quickstart (Docker Run) ``` docker run --rm -it \ -p 6080:6080 \ - -v fgc-data:/fgc/data \ - -v fgc-browser:/home/fgc/.cache/browser \ - -v fgc-playwright:/home/fgc/.cache/ms-playwright \ + -v fgc:/fgc/data \ -e SHOW=1 \ - git.sky-net.it/nocci/free-games-claimer:dev \ - bash -c "node prime-gaming; node gog; ./keep-alive.sh" + git.sky-net.it/nocci/free-games-claimer:latest \ + node prime-gaming.js ``` - Ports 6080/5900: noVNC/VNC (only needed with `SHOW=1`) -- Volumes persist profile + Playwright-Browser, damit Logins/Downloads bleiben. +- Data/configs are stored in volume `fgc` under `/fgc/data` -Docker Compose Example (persistent volumes) -------------------------------------------- +Docker Compose Example +---------------------- ```yaml services: - free-games-claimer: - image: git.sky-net.it/nocci/free-games-claimer:dev + fgc: + image: git.sky-net.it/nocci/free-games-claimer:latest container_name: fgc environment: - - SHOW=1 # show browser via VNC/noVNC + - SHOW=1 # show browser via VNC/noVNC # - PG_EMAIL=... # - PG_PASSWORD=... # - PG_OTPKEY=... - - BROWSER_DIR=/fgc/data/browser - - LOGIN_VISIBLE_TIMEOUT=20 # optional: faster login detection - - KEEP_ALIVE_SECONDS=86400 # optional: keep container alive after runs volumes: - - fgc-data:/fgc/data - - fgc-browser:/home/fgc/.cache/browser - - fgc-playwright:/home/fgc/.cache/ms-playwright + - fgc:/fgc/data ports: - - "6080:6080" # noVNC - # - "5900:5900" # VNC optional - command: bash -c "node prime-gaming; node gog; ./keep-alive.sh" + - "6080:6080" # noVNC + # - "5900:5900" # VNC optional + command: bash -c "node epic-games; node prime-gaming; node gog" volumes: - fgc-data: - fgc-browser: - fgc-playwright: + fgc: ``` -Hinweis: Das Image läuft auf `dev`; bei Bedarf `:latest` wählen. Configuration (Environment Variables) ------------------------------------- @@ -65,9 +55,6 @@ Common options: - `SHOW=0/1` (0 = headless, 1 = UI) - `WIDTH`, `HEIGHT` (browser size) - `TIMEOUT`, `LOGIN_TIMEOUT` (seconds) -- Epic: `EG_MODE=legacy|new` (legacy Playwright flow or neuer API-getriebener Claimer), `EG_PARENTALPIN`, `EG_EMAIL`, `EG_PASSWORD`, `EG_OTPKEY` -- Epic (new mode): Cookies werden unter `data/browser/epic-cookies.json` persistiert; OAuth Device Code Flow benötigt ggf. einmalige Freigabe im Browser. - - Falls Device-Code-Endpunkt nicht erreichbar ist (404/Bad Request), fällt der neue Modus automatisch auf manuellen Browser-Login zurück. - Login: `EMAIL`, `PASSWORD` global; per store `EG_EMAIL`, `EG_PASSWORD`, `EG_OTPKEY`, `PG_EMAIL`, `PG_PASSWORD`, `PG_OTPKEY`, `GOG_EMAIL`, `GOG_PASSWORD` - Prime Gaming: `PG_REDEEM=1` (auto-redeem keys, experimental), `PG_CLAIMDLC=1`, `PG_TIMELEFT=` to skip long-remaining offers - Screenshots: `SCREENSHOTS_DIR` (default `data/screenshots`) @@ -79,9 +66,6 @@ Common options: - Directories: `SCREENSHOTS_DIR`, `BROWSER_DIR`, `DATA_DIR` (prefix for data; default under `data/`) - VNC/noVNC: `VNC_PASSWORD` (for Docker entrypoint), `NOVNC_PORT`/`VNC_PORT` (Docker) - General timeouts: `TIMEOUT` (per action), `LOGIN_TIMEOUT` (extra time for login) -- Login detection: `LOGIN_VISIBLE_TIMEOUT` (ms) to abort sooner when login buttons not present -- Keep-alive: `KEEP_ALIVE_SECONDS` (default 86400) for `keep-alive.sh` -- Repo banner: `REPO_URL` for log output You can place a `data/config.env`; it is loaded via dotenv and is overridden by explicitly set environment variables. @@ -102,4 +86,3 @@ Persistence & Outputs - Optional videos/HAR: `RECORD=1` → `data/record/` Tip: For captchas or first-time login, run with `SHOW=1` and log in once; cookies stay in the profile. Notifications via `NOTIFY` help surface errors (e.g., captcha, login). - diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c97e5ac..e604e7c 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,13 +2,8 @@ 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 -REPO_URL=${REPO_URL:-https://git.sky-net.it/nocci/free-games-claimer} -if [ -n "$COMMIT" ]; then - echo "Version: ${REPO_URL}/tree/${COMMIT}" -else - echo "Version: ${REPO_URL}" -fi -[ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && echo "Branch: ${BRANCH}" +echo "Version: https://github.com/vogler/free-games-claimer/tree/${COMMIT}" +[ ! -z $BRANCH ] && [ $BRANCH != "main" ] && echo "Branch: ${BRANCH}" echo "Build: $NOW" # Ensure writable data dir for fgc when host bind-mount is owned by root. @@ -22,38 +17,23 @@ fi # https://bugs.chromium.org/p/chromium/issues/detail?id=367048 rm -f /fgc/data/browser/SingletonLock 2>/dev/null || true -# Firefox profile directory (persistent if writable; fallback to cache when bind-mount is read-only). -BROWSER_DIR=/fgc/data/browser -mkdir -p "$BROWSER_DIR" 2>/dev/null || true -if [ ! -w "$BROWSER_DIR" ]; then - echo "Warning: $BROWSER_DIR not writable; using fallback profile at /home/fgc/.cache/browser" - BROWSER_DIR=/home/fgc/.cache/browser - mkdir -p "$BROWSER_DIR" 2>/dev/null || true - chown 1000:1000 "$BROWSER_DIR" 2>/dev/null || true -fi -if [ ! -w "$BROWSER_DIR" ]; then - echo "Warning: $BROWSER_DIR not writable; using temp profile at /tmp/browser" - BROWSER_DIR=/tmp/browser - mkdir -p "$BROWSER_DIR" - chmod 777 "$BROWSER_DIR" 2>/dev/null || true -fi -# clean up stale firefox locks that can trigger "already running" -rm -f "$BROWSER_DIR"/parent.lock "$BROWSER_DIR"/lock "$BROWSER_DIR"/.parentlock 2>/dev/null || true # 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 +# clean up stale firefox locks that can trigger "already running" +rm -f /fgc/data/browser/parent.lock /fgc/data/browser/lock /fgc/data/browser/.parentlock 2>/dev/null || true # fix for 'Incorrect response' after solving a captcha correctly - https://github.com/vogler/free-games-claimer/issues/261#issuecomment-1868385830 # Only write the prefs file when the volume is writable (container runs as non-root). -if [ -w "$BROWSER_DIR" ] && { [ ! -e "$BROWSER_DIR/user.js" ] || [ -w "$BROWSER_DIR/user.js" ] || rm -f "$BROWSER_DIR/user.js" 2>/dev/null; }; then - cat << 'EOT' > "$BROWSER_DIR/user.js" +if [ -w /fgc/data/browser ] && { [ ! -e /fgc/data/browser/user.js ] || [ -w /fgc/data/browser/user.js ] || rm -f /fgc/data/browser/user.js 2>/dev/null; }; then + 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 else - echo "Warning: $BROWSER_DIR not writable; skipping user.js creation." + echo "Warning: /fgc/data/browser not writable; skipping user.js creation." fi -export BROWSER_DIR # 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 diff --git a/epic-claimer-new.js b/epic-claimer-new.js deleted file mode 100644 index c4335af..0000000 --- a/epic-claimer-new.js +++ /dev/null @@ -1,385 +0,0 @@ -import axios from 'axios'; -import { firefox } from 'playwright-firefox'; -import { authenticator } from 'otplib'; -import path from 'node:path'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { - jsonDb, - datetime, - stealth, - filenamify, - prompt, - notify, - html_game_list, - handleSIGINT, -} from './src/util.js'; -import { cfg } from './src/config.js'; - - -const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; -const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json'); -const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN'; - -// Screenshot Helper Function - - -// Fetch Free Games from API -const fetchFreeGamesAPI = async () => { - const resp = await axios.get('https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions', { - params: { locale: 'en-US', country: 'US', allowCountries: 'US,DE,AT,CH,GB' }, - }); - return resp.data?.Catalog?.searchStore?.elements - ?.filter(g => g.promotions?.promotionalOffers?.[0]) - ?.map(g => { - const offer = g.promotions.promotionalOffers[0].promotionalOffers[0]; - const mapping = g.catalogNs?.mappings?.[0]; - return { - title: g.title, - namespace: mapping?.id || g.productSlug, - pageSlug: mapping?.pageSlug || g.urlSlug, - offerId: offer?.offerId, - }; - }) || []; -}; - -// Poll for OAuth tokens -const pollForTokens = async (deviceCode, maxAttempts = 30) => { - for (let i = 0; i < maxAttempts; i++) { - try { - const response = await axios.post('https://api.epicgames.dev/epic/oauth/token', { - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - device_code: deviceCode, - client_id: '34a02cf8f4414e29b159cdd02e6184bd', - }); - if (response.data?.access_token) { - console.log('✅ OAuth successful'); - return response.data; - } - } catch (error) { - if (error.response?.data?.error === 'authorization_pending') { - await new Promise(resolve => setTimeout(resolve, 5000)); - continue; - } - throw error; - } - } - throw new Error('OAuth timeout'); -}; - -// Exchange token for cookies -const exchangeTokenForCookies = async accessToken => { - const response = await axios.get('https://store.epicgames.com/', { - headers: { - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - }, - }); - const cookies = response.headers['set-cookie']?.map(cookie => { - const [name, value] = cookie.split(';')[0].split('='); - return { name, value, domain: '.epicgames.com', path: '/' }; - }) || []; - cookies.push({ name: BEARER_TOKEN_NAME, value: accessToken, domain: '.epicgames.com', path: '/' }); - return cookies; -}; - -// Get valid authentication -const getValidAuth = async ({ otpKey, reuseCookies, cookiesPath }) => { - if (reuseCookies && existsSync(cookiesPath)) { - const cookies = JSON.parse(readFileSync(cookiesPath, 'utf8')); - const bearerCookie = cookies.find(c => c.name === BEARER_TOKEN_NAME); - if (bearerCookie?.value) { - console.log('🔄 Reusing existing bearer token from cookies'); - return { bearerToken: bearerCookie.value, cookies }; - } - } - - console.log('🔐 Starting fresh OAuth device flow (manual approval required)...'); - let deviceResponse; - - try { - deviceResponse = await axios.post('https://api.epicgames.dev/epic/oauth/deviceCode', { - client_id: '34a02cf8f4414e29b159cdd02e6184bd', - scope: 'account.basicprofile account.userentitlements', - }); - } catch (error) { - console.error('Device code flow failed (fallback to manual login):', error.response?.status || error.message); - return { bearerToken: null, cookies: [] }; - } - - const { device_code, user_code, verification_uri_complete } = deviceResponse.data; - console.log(`📱 Open: ${verification_uri_complete}`); - console.log(`💳 Code: ${user_code}`); - - const tokens = await pollForTokens(device_code); - - if (otpKey) { - const totpCode = authenticator.generate(otpKey); - console.log(`🔑 TOTP Code (generated): ${totpCode}`); - try { - const refreshed = await axios.post('https://api.epicgames.dev/epic/oauth/token', { - grant_type: 'refresh_token', - refresh_token: tokens.refresh_token, - code_verifier: totpCode, - }); - tokens.access_token = refreshed.data.access_token; - } catch { - // Ignore if refresh fails; use original token - } - } - - const cookies = await exchangeTokenForCookies(tokens.access_token); - writeFileSync(cookiesPath, JSON.stringify(cookies, null, 2)); - console.log('💾 Cookies saved to', cookiesPath); - return { bearerToken: tokens.access_token, cookies }; -}; - -// Ensure user is logged in -const ensureLoggedIn = async (page, context) => { - const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true'; - - const attemptAutoLogin = async () => { - if (!cfg.eg_email || !cfg.eg_password) return false; - - try { - await page.goto('https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM, { - waitUntil: 'domcontentloaded', - timeout: cfg.login_timeout, - }); - - const emailField = page.locator('input[name="email"], input#email, input[aria-label="Sign in with email"]').first(); - const passwordField = page.locator('input[name="password"], input#password').first(); - const continueBtn = page.locator('button:has-text("Continue"), button#continue, button[type="submit"]').first(); - - // Step 1: Email + continue - if (await emailField.count() > 0) { - await emailField.fill(cfg.eg_email); - await continueBtn.click(); - } - - // Step 2: Password + submit - await passwordField.waitFor({ timeout: cfg.login_visible_timeout }); - await passwordField.fill(cfg.eg_password); - - const rememberMe = page.locator('input[name="rememberMe"], #rememberMe').first(); - if (await rememberMe.count() > 0) await rememberMe.check(); - await continueBtn.click(); - - // MFA step - try { - await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); - const otp = cfg.eg_otpkey - ? authenticator.generate(cfg.eg_otpkey) - : await prompt({ - type: 'text', - message: 'Enter two-factor sign in code', - validate: n => n.toString().length === 6 || 'The code must be 6 digits!', - }); - - const codeInputs = page.locator('input[name^="code-input"]'); - if (await codeInputs.count() > 0) { - const digits = otp.toString().split(''); - for (let i = 0; i < digits.length; i++) { - const input = codeInputs.nth(i); - await input.fill(digits[i]); - } - } else { - await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); - } - await continueBtn.click(); - } catch { - // No MFA - } - - await page.waitForURL('**/free-games', { timeout: cfg.login_timeout }); - return await isLoggedIn(); - } catch (err) { - console.error('Auto login failed:', err); - return false; - } - }; - - const isChallenge = async () => { - const cfFrame = page.locator('iframe[title*="Cloudflare"], iframe[src*="challenges"]'); - const cfText = page.locator('text=Verify you are human'); - return await cfFrame.count() > 0 || await cfText.count() > 0; - }; - - let loginAttempts = 0; - const MAX_LOGIN_ATTEMPTS = 3; - - while (!await isLoggedIn() && loginAttempts < MAX_LOGIN_ATTEMPTS) { - loginAttempts++; - console.error(`Not signed in (Attempt ${loginAttempts}). Trying automatic login, otherwise please login in the browser.`); - if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`); - - if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); - await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); - - if (await isChallenge()) { - console.warn('Cloudflare challenge detected. Solve the captcha in the browser (no automation).'); - await notify('epic-games (new): Cloudflare challenge, please solve manually in browser.'); - await page.waitForTimeout(cfg.login_timeout); - continue; - } - - const logged = await attemptAutoLogin(); - if (logged) break; - - console.log('Waiting for manual login in the browser (cookies might be invalid).'); - await notify('epic-games (new): please login in browser; cookies invalid or expired.'); - - if (cfg.headless) { - console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); - await context.close(); - process.exit(1); - } - await page.waitForTimeout(cfg.login_timeout); - } - - if (loginAttempts >= MAX_LOGIN_ATTEMPTS) { - console.error('Maximum login attempts reached. Exiting.'); - await context.close(); - process.exit(1); - } - - const user = await page.locator('egs-navigation').getAttribute('displayname'); - console.log(`Signed in as ${user}`); - - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - return user; -}; - -// Claim game function -const claimGame = async (page, game) => { - const purchaseUrl = `https://store.epicgames.com/${game.pageSlug}`; - console.log(`🎮 ${game.title} → ${purchaseUrl}`); - const notify_game = { title: game.title, url: purchaseUrl, status: 'failed' }; - - await page.goto(purchaseUrl, { waitUntil: 'networkidle' }); - - const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first(); - await purchaseBtn.waitFor({ timeout: cfg.timeout }); - const btnText = (await purchaseBtn.textContent() || '').toLowerCase(); - - if (btnText.includes('library') || btnText.includes('owned')) { - notify_game.status = 'existed'; - return notify_game; - } - if (cfg.dryrun) { - notify_game.status = 'skipped'; - return notify_game; - } - - await purchaseBtn.click({ delay: 50 }); - - try { - await page.waitForSelector('#webPurchaseContainer iframe', { timeout: 15000 }); - const iframe = page.frameLocator('#webPurchaseContainer iframe'); - - if (cfg.eg_parentalpin) { - try { - await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 }); - await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin); - await iframe.locator('button:has-text("Continue")').click({ delay: 11 }); - } catch { - // no PIN needed - } - } - - await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); - try { - await iframe.locator('button:has-text("I Accept")').click({ timeout: 5000 }); - } catch { - // not required - } - await page.locator('text=Thanks for your order!').waitFor({ state: 'attached', timeout: cfg.timeout }); - notify_game.status = 'claimed'; - } catch (e) { - notify_game.status = 'failed'; - const screenshotPath = path.resolve(cfg.dir.screenshots, 'epic-games', 'failed', `${game.offerId || game.pageSlug}_${filenamify(datetime())}.png`); - await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => { }); - console.error(' Failed to claim:', e.message); - } - - return notify_game; -}; - -// Main function to claim Epic Games -export const claimEpicGamesNew = async () => { - console.log('Starting Epic Games claimer (new mode, cookies + API)'); - const db = await jsonDb('epic-games.json', {}); - const notify_games = []; - - const freeGames = await fetchFreeGamesAPI(); - console.log('Free games via API:', freeGames.map(g => g.pageSlug)); - - const context = await firefox.launchPersistentContext(cfg.dir.browser, { - headless: cfg.headless, - viewport: { width: cfg.width, height: cfg.height }, - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0', - locale: 'en-US', - recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, - recordHar: cfg.record ? { path: `data/record/eg-${filenamify(datetime())}.har` } : undefined, - handleSIGINT: false, - }); - handleSIGINT(context); - await stealth(context); - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - - const page = context.pages().length ? context.pages()[0] : await context.newPage(); - await page.setViewportSize({ width: cfg.width, height: cfg.height }); - - let user; - - try { - const auth = await getValidAuth({ - email: cfg.eg_email, - password: cfg.eg_password, - otpKey: cfg.eg_otpkey, - reuseCookies: true, - cookiesPath: COOKIES_PATH, - }); - - if (auth.cookies?.length) { - await context.addCookies(auth.cookies); - console.log('✅ Cookies loaded:', auth.cookies.length); - } else { - console.log('⚠️ No cookies loaded; using manual login via browser.'); - } - - await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); - user = await ensureLoggedIn(page, context); - db.data[user] ||= {}; - - for (const game of freeGames) { - const result = await claimGame(page, game); - notify_games.push(result); - db.data[user][game.offerId || game.pageSlug] = { - title: game.title, - time: datetime(), - url: `https://store.epicgames.com/${game.pageSlug}`, - status: result.status, - }; - } - - await writeFileSync(COOKIES_PATH, JSON.stringify(await context.cookies(), null, 2)); - } catch (error) { - process.exitCode ||= 1; - console.error('--- Exception (new epic):'); - console.error(error); - if (error.message && process.exitCode !== 130) notify(`epic-games (new) failed: ${error.message.split('\\n')[0]}`); - } finally { - await db.write(); - if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) { - notify(`epic-games (new ${user || 'unknown'}):
${html_game_list(notify_games)}`); - } - } - - if (cfg.debug && context) { - console.log(JSON.stringify(await context.cookies(), null, 2)); - } - await context.close(); -}; - -export default claimEpicGamesNew; - diff --git a/epic-games.js b/epic-games.js index 653a00e..941383f 100644 --- a/epic-games.js +++ b/epic-games.js @@ -1,26 +1,17 @@ -import { firefox } from 'playwright-firefox'; +import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { authenticator } from 'otplib'; import chalk from 'chalk'; import path from 'node:path'; import { existsSync, writeFileSync, appendFileSync } from 'node:fs'; -import { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; +import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; import { cfg } from './src/config.js'; -import { EPIC_CLIENT_ID, GRAPHQL_ENDPOINT, FREE_GAMES_PROMOTIONS_ENDPOINT, STORE_HOMEPAGE_EN, EPIC_PURCHASE_ENDPOINT, ID_LOGIN_ENDPOINT } from './src/constants.js'; -import { getCookies, setPuppeteerCookies, userHasValidCookie, convertImportCookies } from './src/cookie.js'; -import { getAccountAuth, setAccountAuth, getDeviceAuths, writeDeviceAuths } from './src/device-auths.js'; -const screenshot = (...a) => path.resolve(cfg.dir.screenshots, 'epic-games', ...a); +const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a); const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM; -console.log(datetime(), 'started checking epic-games (GraphQL API mode)'); - -if (cfg.eg_mode === 'new') { - const { claimEpicGamesNew } = await import('./epic-claimer-new.js'); - await claimEpicGamesNew(); - process.exit(0); -} +console.log(datetime(), 'started checking epic-games'); const db = await jsonDb('epic-games.json', {}); @@ -29,7 +20,7 @@ if (cfg.time) console.time('startup'); const browserPrefs = path.join(cfg.dir.browser, 'prefs.js'); if (existsSync(browserPrefs)) { console.log('Adding webgl.disabled to', browserPrefs); - appendFileSync(browserPrefs, 'user_pref("webgl.disabled", true);'); + 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.'); } @@ -38,25 +29,28 @@ if (existsSync(browserPrefs)) { const context = await firefox.launchPersistentContext(cfg.dir.browser, { headless: cfg.headless, viewport: { width: cfg.width, height: cfg.height }, - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0', - locale: 'en-US', - recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, - recordHar: cfg.record ? { path: `data/record/eg-${filenamify(datetime())}.har` } : undefined, - handleSIGINT: false, - args: [], + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0', // Windows UA avoids "device not supported"; update when browser version changes + locale: 'en-US', // ignore OS locale to be sure to have english text for locators + recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800 + recordHar: cfg.record ? { path: `data/record/eg-${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 + // user settings for firefox have to be put in $BROWSER_DIR/user.js + args: [], // https://wiki.mozilla.org/Firefox/CommandLineOptions }); handleSIGINT(context); +// Without stealth plugin, the website shows an hcaptcha on login with username/password and in the last step of claiming a game. It may have other heuristics like unsuccessful logins as well. After <6h (TBD) it resets to no captcha again. Getting a new IP also resets. await stealth(context); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); -const page = context.pages().length ? context.pages()[0] : await context.newPage(); -await page.setViewportSize({ width: cfg.width, height: cfg.height }); +const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist +await page.setViewportSize({ width: cfg.width, height: cfg.height }); // workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it -// some debug info about the page +// some debug info about the page (screen dimensions, user agent) if (cfg.debug) { + /* global window, navigator */ const debugInfo = await page.evaluate(() => { const { width, height, availWidth, availHeight } = window.screen; return { @@ -66,8 +60,8 @@ if (cfg.debug) { }); console.debug(debugInfo); } - if (cfg.debug_network) { + // const filter = _ => true; const filter = r => r.url().includes('store.epicgames.com'); page.on('request', request => filter(request) && console.log('>>', request.method(), request.url())); page.on('response', response => filter(response) && console.log('<<', response.status(), response.url())); @@ -76,266 +70,61 @@ if (cfg.debug_network) { const notify_games = []; let user; -// GraphQL query for free games -const FREE_GAMES_QUERY = { - operationName: 'searchStoreQuery', - variables: { - allowCountries: 'US', - category: 'games/edition/base|software/edition/base|editors|bundles/games', - count: 1000, - country: 'US', - sortBy: 'relevancy', - sortDir: 'DESC', - start: 0, - withPrice: true, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7d58e12d9dd8cb14c84a3ff18d360bf9f0caa96bf218f2c5fda68ba88d68a437', - }, - }, -}; - -// Generate login redirect URL -const generateLoginRedirect = (redirectUrl) => { - const loginRedirectUrl = new URL(ID_LOGIN_ENDPOINT); - loginRedirectUrl.searchParams.set('noHostRedirect', 'true'); - loginRedirectUrl.searchParams.set('redirectUrl', redirectUrl); - loginRedirectUrl.searchParams.set('client_id', EPIC_CLIENT_ID); - return loginRedirectUrl.toString(); -}; - -// Generate checkout URL with login redirect -const generateCheckoutUrl = (offers) => { - const offersParams = offers - .map((offer) => `&offers=1-${offer.offerNamespace}-${offer.offerId}`) - .join(''); - const checkoutUrl = `${EPIC_PURCHASE_ENDPOINT}?highlightColor=0078f2${offersParams}&orderId&purchaseToken&showNavigation=true`; - return generateLoginRedirect(checkoutUrl); -}; - -// Get free games from GraphQL API -const getFreeGamesFromGraphQL = async () => { - const items = []; - let start = 0; - const pageLimit = 1000; - - do { - const response = await page.evaluate(async (query, startOffset) => { - const variables = { ...query.variables, start: startOffset }; - const resp = await fetch(GRAPHQL_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - operationName: query.operationName, - variables: JSON.stringify(variables), - extensions: JSON.stringify(query.extensions), - }), - }); - return await resp.json(); - }, [FREE_GAMES_QUERY, start]); - - const elements = response.data?.Catalog?.searchStore?.elements; - if (!elements) break; - - items.push(...elements); - start += pageLimit; - } while (items.length < pageLimit); - - // Filter free games - const freeGames = items.filter(game => - game.price?.totalPrice?.discountPrice === 0 - ); - - // Deduplicate by productSlug - const uniqueGames = new Map(); - for (const game of freeGames) { - if (!uniqueGames.has(game.productSlug)) { - uniqueGames.set(game.productSlug, game); - } - } - - return Array.from(uniqueGames.values()).map(game => ({ - offerId: game.id, - offerNamespace: game.namespace, - productName: game.title, - productSlug: game.productSlug || game.urlSlug, - })); -}; - -// Get free games from promotions API (weekly free games) -const getFreeGamesFromPromotions = async () => { - const response = await page.evaluate(async () => { - const resp = await fetch(FREE_GAMES_PROMOTIONS_ENDPOINT + '?locale=en-US&country=US&allowCountries=US'); - return await resp.json(); - }); - - const nowDate = new Date(); - const elements = response.data?.Catalog?.searchStore?.elements || []; - - return elements.filter(offer => { - if (!offer.promotions) return false; - - return offer.promotions.promotionalOffers.some(innerOffers => - innerOffers.promotionalOffers.some(pOffer => { - const startDate = new Date(pOffer.startDate); - const endDate = new Date(pOffer.endDate); - const isFree = pOffer.discountSetting?.discountPercentage === 0; - return startDate <= nowDate && nowDate <= endDate && isFree; - }) - ); - }).map(game => ({ - offerId: game.id, - offerNamespace: game.namespace, - productName: game.title, - productSlug: game.productSlug || game.urlSlug, - })); -}; - -// Get all free games -const getAllFreeGames = async () => { - try { - const weeklyGames = await getFreeGamesFromPromotions(); - console.log('Found', weeklyGames.length, 'weekly free games'); - return weeklyGames; - } catch (e) { - console.error('Failed to get weekly free games:', e.message); - return []; - } -}; - -// Login with device auth - attempts to use stored auth token -const loginWithDeviceAuth = async () => { - const deviceAuth = await getAccountAuth(cfg.eg_email || 'default'); - - if (deviceAuth && deviceAuth.access_token) { - console.log('Using stored device auth'); - - // Set the bearer token cookie for authentication - const bearerCookie = /** @type {import('playwright-firefox').Cookie} */ ({ - name: 'EPIC_BEARER_TOKEN', - value: deviceAuth.access_token, - expires: new Date(deviceAuth.expires_at).getTime() / 1000, - domain: '.epicgames.com', - path: '/', - secure: true, - httpOnly: true, - sameSite: 'Lax', - }); - - await context.addCookies([bearerCookie]); - - // Visit store to get session cookies - await page.goto(STORE_HOMEPAGE_EN, { waitUntil: 'networkidle' }); - - // Check if login worked - const isLoggedIn = await page.locator('egs-navigation').getAttribute('isloggedin') === 'true'; - if (isLoggedIn) { - console.log('Successfully logged in with device auth'); - return true; - } - } - - return false; -}; - -// Exchange token for cookies (alternative method) -const exchangeTokenForCookies = async (accessToken) => { - try { - const cookies = await page.evaluate(async (token) => { - const resp = await fetch('https://store.epicgames.com/', { - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - }, - }); - return await resp.headers.get('set-cookie'); - }, accessToken); - - return cookies; - } catch { - return null; - } -}; - -// Save device auth -const saveDeviceAuth = async (accessToken, refreshToken, expiresAt) => { - const deviceAuth = { - access_token: accessToken, - refresh_token: refreshToken, - expires_at: expiresAt, - expires_in: 86400, - token_type: 'bearer', - account_id: 'unknown', - client_id: EPIC_CLIENT_ID, - internal_client: true, - client_service: 'account', - displayName: 'User', - app: 'epic-games', - in_app_id: 'unknown', - product_id: 'unknown', - refresh_expires: 604800, - refresh_expires_at: new Date(Date.now() + 604800000).toISOString(), - application_id: 'unknown', - }; - - await setAccountAuth(cfg.eg_email || 'default', deviceAuth); - console.log('Device auth saved'); -}; - try { await context.addCookies([ - { name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }, - { name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, + { 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' }); + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto if (cfg.time) console.timeEnd('startup'); if (cfg.time) console.time('login'); - // Try device auth first - const deviceAuthLoginSuccess = await loginWithDeviceAuth(); - - // If device auth failed, try regular login while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') { - console.error('Not signed in. Please login in the browser or here in the terminal.'); + console.error('Not signed in anymore. Please login in the browser or here in the terminal.'); if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`); - if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); + 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!`); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); - if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); - const 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(); + await context.close(); // finishes potential recording process.exit(1); } }; - - const hasCaptcha = await page.locator('.h_captcha_challenge iframe, text=Incorrect response').count() > 0; - if (hasCaptcha) { - console.warn('Captcha/Incorrect response detected. Please solve manually in the browser.'); - await notify('epic-games: captcha encountered; please solve manually in browser.'); - await page.waitForTimeout(cfg.login_timeout); - continue; - } - const email = cfg.eg_email || await prompt({ message: 'Enter email' }); if (email) { + const watchCaptchaChallenge = async () => { + try { + await page.waitForSelector('.h_captcha_challenge iframe', { timeout: 15000 }); + 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.'); + } catch { + return; + } + }; + const watchCaptchaIncorrect = async () => { + try { + await page.waitForSelector('p:has-text("Incorrect response.")', { timeout: 15000 }); + console.error('Incorrect response for captcha!'); + } catch { + return; + } + }; + watchCaptchaChallenge(); + watchCaptchaIncorrect(); await page.fill('#email', email); const password = cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }); if (password) { await page.fill('#password', password); await page.click('button[type="submit"]'); } else await notifyBrowserLogin(); - const error = page.locator('#form-error-message'); const watchLoginError = async () => { try { @@ -346,74 +135,58 @@ try { return; } }; - const watchMfaStep = async () => { try { await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); - console.log('Enter the security code to continue'); - const otp = cfg.eg_otpkey && authenticator.generate(cfg.eg_otpkey) || await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 6 || 'The code must be 6 digits!' }); + console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...'); + const otp = cfg.eg_otpkey && authenticator.generate(cfg.eg_otpkey) || await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 6 || 'The code must be 6 digits!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString()); await page.click('button[type="submit"]'); } catch { return; } }; - watchLoginError(); watchMfaStep(); } else await notifyBrowserLogin(); - await page.waitForURL(URL_CLAIM); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); } - - user = await page.locator('egs-navigation').getAttribute('displayname'); + user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin console.log(`Signed in as ${user}`); db.data[user] ||= {}; - if (cfg.time) console.timeEnd('login'); if (cfg.time) console.time('claim all games'); - // Get free games - const freeGames = await getAllFreeGames(); - console.log('Free games:', freeGames.map(g => g.productName)); - - // Generate checkout link for all free games (available for all games) - const checkoutUrl = freeGames.length > 0 ? generateCheckoutUrl(freeGames) : null; - if (checkoutUrl) { - console.log('Generated checkout URL:', checkoutUrl); - - // Send notification with checkout link - await notify(`epic-games (${user}):
Free games available!
Click here to claim: ${checkoutUrl}`); - } - - // Also save to database for reference - freeGames.forEach(game => { - const purchaseUrl = `https://store.epicgames.com/${game.productSlug}`; - db.data[user][game.offerId] ||= { - title: game.productName, - time: datetime(), - url: purchaseUrl, - checkoutUrl: checkoutUrl || purchaseUrl - }; + // Detect free games + const game_loc = page.locator('a:has(span:text-is("Free Now"))'); + await game_loc.last().waitFor().catch(_ => { + // rarely there are no free games available -> catch Timeout + // waiting for timeout; alternative would be waiting for "coming soon" + // see https://github.com/vogler/free-games-claimer/issues/210#issuecomment-1727420943 + console.error('Seems like currently there are no free games available in your region...'); + // urls below should then be an empty list }); + // clicking on `game_sel` sometimes led to a 404, see https://github.com/vogler/free-games-claimer/issues/25 + // debug showed that in those cases the href was still correct, so we `goto` the urls instead of clicking. + // Alternative: parse the json loaded to build the page https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions + // i.e. filter data.Catalog.searchStore.elements for .promotions.promotionalOffers being set and build URL with .catalogNs.mappings[0].pageSlug or .urlSlug if not set to some wrong id like it was the case for spirit-of-the-north-f58a66 - this is also what's done here: https://github.com/claabs/epicgames-freegames-node/blob/938a9653ffd08b8284ea32cf01ac8727d25c5d4c/src/puppet/free-games.ts#L138-L213 + const urlSlugs = await Promise.all((await game_loc.elementHandles()).map(a => a.getAttribute('href'))); + const urls = urlSlugs.map(s => 'https://store.epicgames.com' + s); + console.log('Free games:', urls); - // Claim each game individually (for detailed tracking) - for (const game of freeGames) { + for (const url of urls) { if (cfg.time) console.time('claim game'); - - const purchaseUrl = `https://store.epicgames.com/${game.productSlug}`; - await page.goto(purchaseUrl); - - const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first(); + await page.goto(url); // , { waitUntil: 'domcontentloaded' }); + 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(); + 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+' if (await page.locator('button:has-text("Continue")').count() > 0) { 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"'); + 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(); @@ -426,49 +199,66 @@ try { } 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 existedInDb = db.data[user][game.offerId]; - db.data[user][game.offerId] ||= { title, time: datetime(), url: purchaseUrl, checkoutUrl: checkoutUrl }; + 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! 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' }; + notify_games.push(notify_game); // status is updated below - const notify_game = { title, url: purchaseUrl, status: 'failed' }; - notify_games.push(notify_game); - - if (btnText == 'in library' || btnText == 'owned') { + if (btnText == 'in library') { console.log(' Already in library! Nothing to claim.'); - if (!existedInDb) await notify(`Game already in library: ${purchaseUrl}`); + if (!existedInDb) await notify(`Game already in library: ${url}`); notify_game.status = 'existed'; - db.data[user][game.offerId].status ||= 'existed'; - if (db.data[user][game.offerId].status.startsWith('failed')) db.data[user][game.offerId].status = 'manual'; + 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 } else if (btnText == 'requires base game') { console.log(' Requires base game! Nothing to claim.'); notify_game.status = 'requires base game'; - db.data[user][game.offerId].status ||= 'failed:requires-base-game'; - } else { + db.data[user][game_id].status ||= 'failed:requires-base-game'; + // if base game is free, add to queue as well + const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href'); + console.log(' Base game:', baseUrl); + // await page.click('a:has-text("Overview")'); + // re-add original add-on to queue after base game + urls.push(baseUrl, url); // add base game to the list of games to claim and re-add add-on itself + } else { // GET console.log(' Not in library yet! Click', btnText); - await purchaseBtn.click({ delay: 11 }); + await purchaseBtn.click({ delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough - // Accept EULA if shown - try { - await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 }); - console.log(' Accept End User License Agreement'); - await page.locator('input#agree').check(); - await page.locator('button:has-text("Accept")').click(); - } catch { - // EULA not shown - } + // Accept End User License Agreement (only needed once) + const acceptEulaIfShown = async () => { + try { + await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 }); + console.log(' Accept End User License Agreement (only needed once)'); + await page.locator('input#agree').check(); + await page.locator('button:has-text("Accept")').click(); + } catch { + return; + } + }; + acceptEulaIfShown(); + // it then creates an iframe for the purchase await page.waitForSelector('#webPurchaseContainer iframe'); const iframe = page.frameLocator('#webPurchaseContainer iframe'); - + // skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 if (await iframe.locator(':has-text("unavailable in your region")').count() > 0) { console.error(' This product is unavailable in your region!'); - db.data[user][game.offerId].status = notify_game.status = 'unavailable-in-region'; + db.data[user][game_id].status = notify_game.status = 'unavailable-in-region'; if (cfg.time) console.timeEnd('claim game'); continue; } @@ -496,77 +286,75 @@ try { continue; } + // Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591 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 const btnAgree = iframe.locator('button:has-text("I Accept")'); - try { - await btnAgree.waitFor({ timeout: 10000 }); - await btnAgree.click(); - } catch { - // EU: wait for and click 'I Agree' - } - - try { - await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); - db.data[user][game.offerId].status = 'claimed'; - db.data[user][game.offerId].time = datetime(); - console.log(' Claimed successfully!'); - - // Save device auth if we got a new token - const cookies = await context.cookies(); - const bearerCookie = cookies.find(c => c.name === 'EPIC_BEARER_TOKEN'); - if (bearerCookie?.value) { - await saveDeviceAuth(bearerCookie.value, 'refresh_token_placeholder', new Date(Date.now() + 86400000).toISOString()); + const acceptIfRequired = async () => { + try { + await btnAgree.waitFor({ timeout: 10000 }); + await btnAgree.click(); + } catch { + return; } + }; // EU: wait for and click 'I Agree' + acceptIfRequired(); + try { + // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? + const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe'); + const watchCaptchaChallenge = async () => { + try { + await captcha.waitFor({ timeout: 10000 }); + 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 for.\nGame link: ${url}`); + } catch { + return; + } + }; // may time out if not shown + const watchCaptchaFailure = async () => { + try { + await iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor({ timeout: 10000 }); + console.error(' Failed to challenge captcha, please try again later.'); + await notify('epic-games: failed to challenge captcha. Please check.'); + } catch { + return; + } + }; + watchCaptchaChallenge(); + watchCaptchaFailure(); + await page.locator('text=Thanks for your order!').waitFor({ state: 'attached' }); + db.data[user][game_id].status = 'claimed'; + db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time + console.log(' Claimed successfully!'); + // context.setDefaultTimeout(cfg.timeout); } catch (e) { console.log(e); + // console.error(' Failed to claim! Try again if NopeCHA timed out. Click the extension to see if you ran out of credits (refill after 24h). To avoid captchas try to get a new IP or set a cookie from https://www.hcaptcha.com/accessibility'); console.error(' Failed to claim! To avoid captchas try to get a new IP address.'); - const p = screenshot('failed', `${game.offerId}_${filenamify(datetime())}.png`); + const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`); await page.screenshot({ path: p, fullPage: true }); - db.data[user][game.offerId].status = 'failed'; + db.data[user][game_id].status = 'failed'; } - notify_game.status = db.data[user][game.offerId].status; + notify_game.status = db.data[user][game_id].status; // claimed or failed - const p = screenshot(`${game.offerId}.png`); - if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); + const p = screenshot(`${game_id}.png`); + if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long... } - if (cfg.time) console.timeEnd('claim game'); } - if (cfg.time) console.timeEnd('claim all games'); } catch (error) { process.exitCode ||= 1; console.error('--- Exception:'); - console.error(error); + console.error(error); // .toString()? if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`); } finally { - await db.write(); - - // Save cookies - const cookies = await context.cookies(); - // Convert cookies to EpicCookie format for setPuppeteerCookies - const epicCookies = cookies.map(c => ({ - domain: c.domain, - hostOnly: !c.domain.startsWith('.'), - httpOnly: c.httpOnly, - name: c.name, - path: c.path, - sameSite: c.sameSite === 'Lax' ? 'no_restriction' : 'unspecified', - secure: c.secure, - session: !c.expires, - storeId: '0', - value: c.value, - id: 0, - expirationDate: c.expires ? Math.floor(c.expires) : undefined, - })); - await setPuppeteerCookies(cfg.eg_email || 'default', epicCookies); - - if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { + await db.write(); // write out json db + if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { // don't notify if all have status 'existed', 'manual', 'requires base game', 'unavailable-in-region', 'skipped' notify(`epic-games (${user}):
${html_game_list(notify_games)}`); } } - if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies())); if (page.video()) console.log('Recorded video:', await page.video().path()); await context.close(); diff --git a/gog.js b/gog.js index 190fd7f..767fec1 100644 --- a/gog.js +++ b/gog.js @@ -42,10 +42,7 @@ try { await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever const signIn = page.locator('a:has-text("Sign in")').first(); - await Promise.any([ - signIn.waitFor({ timeout: cfg.login_visible_timeout }), - page.waitForSelector('#menuUsername', { timeout: cfg.login_visible_timeout }), - ]).catch(() => {}); + await Promise.any([signIn.waitFor(), page.waitForSelector('#menuUsername')]); while (await signIn.isVisible()) { console.error('Not signed in anymore.'); await signIn.click(); @@ -59,11 +56,7 @@ try { const email = cfg.gog_email || await prompt({ message: 'Enter email' }); const password = email && (cfg.gog_password || await prompt({ type: 'password', message: 'Enter password' })); if (email && password) { - try { - await iframe.locator('a[href="/logout"]').click(); // Click 'Change account' (email from previous login is set in some cookie) - } catch { - // link not present, continue with login flow - } + iframe.locator('a[href="/logout"]').click().catch(_ => { }); // Click 'Change account' (email from previous login is set in some cookie) await iframe.locator('#login_username').fill(email); await iframe.locator('#login_password').fill(password); await iframe.locator('#login_login').click(); @@ -110,7 +103,9 @@ try { const banner = page.locator('#giveaway'); const hasGiveaway = await banner.count(); - if (hasGiveaway) { + if (!hasGiveaway) { + console.log('Currently no free giveaway!'); + } else { const text = await page.locator('.giveaway__content-header').innerText(); const match_all = text.match(/Claim (.*) and don't miss the|Success! (.*) was added to/); const title = match_all[1] ? match_all[1] : match_all[2]; @@ -151,8 +146,6 @@ try { await page.locator('li:has-text("Marketing communications through Trusted Partners") label').uncheck(); await page.locator('li:has-text("Promotions and hot deals") label').uncheck(); } - } else { - console.log('Currently no free giveaway!'); } } catch (error) { process.exitCode ||= 1; diff --git a/keep-alive.sh b/keep-alive.sh deleted file mode 100755 index 7fc06e8..0000000 --- a/keep-alive.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -sleep_for=${KEEP_ALIVE_SECONDS:-86400} -echo "Keeping container alive (interval ${sleep_for}s). Press Ctrl+C to stop." - -trap 'exit 0' TERM INT -while true; do - sleep "$sleep_for" & - wait $! -done diff --git a/package-lock.json b/package-lock.json index 903bc52..9ab1fff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.4.0", "license": "AGPL-3.0-only", "dependencies": { - "axios": "^1.7.9", "chalk": "^5.4.1", "cross-env": "^7.0.3", "dotenv": "^16.5.0", @@ -22,8 +21,7 @@ }, "devDependencies": { "@stylistic/eslint-plugin-js": "^4.2.0", - "eslint": "^9.26.0", - "typescript": "^5.9.3" + "eslint": "^9.26.0" }, "engines": { "node": ">=17" @@ -448,23 +446,6 @@ "node": ">=0.10.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -546,6 +527,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -646,18 +628,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -782,15 +752,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -832,6 +793,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -881,6 +843,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -890,6 +853,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -899,6 +863,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -907,21 +872,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1375,26 +1325,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -1416,43 +1346,6 @@ "node": ">=0.10.0" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1497,6 +1390,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1516,6 +1410,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1540,6 +1435,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -1599,6 +1495,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1626,6 +1523,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1634,25 +1532,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1983,6 +1867,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2356,12 +2241,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2877,20 +2756,6 @@ "node": ">= 0.6" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -3315,21 +3180,6 @@ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==" }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "requires": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3382,6 +3232,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -3439,14 +3290,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3526,11 +3369,6 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3554,6 +3392,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -3589,32 +3428,24 @@ "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true }, "es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true }, "es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "requires": { "es-errors": "^1.3.0" } }, - "es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "requires": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, "escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3915,11 +3746,6 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, - "follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" - }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -3933,33 +3759,6 @@ "for-in": "^1.0.1" } }, - "form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "dependencies": { - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - } - } - }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3990,7 +3789,8 @@ "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true }, "generative-bayesian-network": { "version": "2.1.66", @@ -4005,6 +3805,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -4022,6 +3823,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "requires": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -4058,7 +3860,8 @@ "gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true }, "graceful-fs": { "version": "4.2.11", @@ -4074,20 +3877,14 @@ "has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "requires": { - "has-symbols": "^1.0.3" - } + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true }, "hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "requires": { "function-bind": "^1.1.2" } @@ -4320,7 +4117,8 @@ "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true }, "media-typer": { "version": "1.1.0", @@ -4565,11 +4363,6 @@ "ipaddr.js": "1.9.1" } }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4878,12 +4671,6 @@ "mime-types": "^3.0.0" } }, - "typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true - }, "universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index 8944516..0487277 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "node": ">=17" }, "dependencies": { - "axios": "^1.7.9", "chalk": "^5.4.1", "cross-env": "^7.0.3", "dotenv": "^16.5.0", @@ -33,7 +32,6 @@ }, "devDependencies": { "@stylistic/eslint-plugin-js": "^4.2.0", - "eslint": "^9.26.0", - "typescript": "^5.9.3" + "eslint": "^9.26.0" } } diff --git a/prime-gaming.js b/prime-gaming.js index dfb9d6c..a09cbca 100644 --- a/prime-gaming.js +++ b/prime-gaming.js @@ -45,26 +45,6 @@ const handleMFA = async p => { return true; }; -const waitForSignedInOrMFA = async p => { - const otpLocator = p.locator('#auth-mfa-otpcode, input[name=otpCode]'); - const waitSignedIn = p.waitForURL('**/claims/**signedIn=true', { timeout: cfg.login_timeout }).then(() => true).catch(() => false); - const waitMFA = (async () => { - try { - await otpLocator.waitFor({ timeout: cfg.login_timeout }); - } catch { - return false; - } - await handleMFA(p); - try { - await p.waitForURL('**/claims/**signedIn=true', { timeout: cfg.login_timeout }); - } catch { - // if it still fails, caller will handle via timeout - } - return true; - })(); - await Promise.race([waitSignedIn, waitMFA]); -}; - try { await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever const handleDirectLoginPage = async () => { @@ -78,7 +58,7 @@ try { await page.click('input[type="submit"]'); await page.fill('[name=password]', password); await page.click('input[type="submit"]'); - await waitForSignedInOrMFA(page); + await handleMFA(page).catch(() => {}); try { await page.waitForURL('**/ap/signin**'); const error = await page.locator('.a-alert-content').first().innerText(); @@ -111,7 +91,7 @@ try { 'button:has-text("Anmelden")', '[data-a-target="user-dropdown-first-name-text"]', '[data-testid="user-dropdown-first-name-text"]', - ].map(s => page.waitForSelector(s, { timeout: cfg.login_visible_timeout }))).catch(() => {}); + ].map(s => page.waitForSelector(s))); try { await page.click('[aria-label="Cookies usage disclaimer banner"] button:has-text("Accept Cookies")'); // to not waste screen space when non-headless; could be flaky } catch { @@ -131,7 +111,6 @@ try { await page.click('input[type="submit"]'); await page.fill('[name=password]', password); await page.click('input[type="submit"]'); - await waitForSignedInOrMFA(page); try { await page.waitForURL('**/ap/signin**'); const error = await page.locator('.a-alert-content').first().innerText(); @@ -144,6 +123,11 @@ try { } catch { // navigation ok } + try { + await handleMFA(page); + } catch { + // ignore MFA watcher errors + } } else { console.log('Waiting for you to login in the browser.'); await notify('prime-gaming: no longer signed in and not enough options set for automatic login.'); @@ -449,13 +433,13 @@ try { } // Disabled CTA (e.g., needs linking or not available) if (await disabledCTA.count()) { - if (store === 'epic-games') { - console.log(' CTA disabled for epic-games, will still try to link/claim.'); - } else { + if (store !== 'epic-games') { console.log(' CTA is disabled, skipping (likely needs linking/not available).'); notify_game.status = 'disabled'; db.data[user][title] ||= { title, time: datetime(), url, store, status: 'disabled' }; continue; + } else { + console.log(' CTA disabled for epic-games, will still try to link/claim.'); } } if (store == 'luna') { @@ -680,19 +664,13 @@ try { await page.goto(url, { waitUntil: 'domcontentloaded' }); // most games have a button 'Get in-game content' // 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? - const claimAndContinue = async () => { - await page.click('.tw-button:has-text("Claim")'); - try { - await page.click('button:has-text("Continue")'); - } catch { - // continue button not always present - } - }; - const claimOptions = [ page.click('.tw-button:has-text("Get in-game content")'), page.click('.tw-button:has-text("Claim your gift")'), - claimAndContinue(), + (async () => { + await page.click('.tw-button:has-text("Claim")'); + await page.click('button:has-text("Continue")').catch(() => {}); + })(), ]; await Promise.any(claimOptions); try { diff --git a/sonar-project.properties b/sonar-project.properties index 7e05b88..677d6b3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,11 +7,3 @@ sonar.sources=. #Eslint issues sonar.eslint.reportPaths = eslint_report.json - -# Ignore coverage and duplication requirements (community scan without reports) -sonar.coverage.exclusions=**/* -sonar.cpd.exclusions=**/* -# Ignore "commented-out code" findings (javascript:S125) across the project -sonar.issue.ignore.multicriteria=e1 -sonar.issue.ignore.multicriteria.e1.ruleKey=javascript:S125 -sonar.issue.ignore.multicriteria.e1.resourceKey=**/* diff --git a/src/config.js b/src/config.js index 7702984..a8b1817 100644 --- a/src/config.js +++ b/src/config.js @@ -15,12 +15,10 @@ export const cfg = { get headless() { return !this.debug && !this.show; }, - eg_mode: process.env.EG_MODE || 'legacy', // epic-games: legacy playwright flow or 'new' API-driven flow width: Number(process.env.WIDTH) || 1920, // width of the opened browser height: Number(process.env.HEIGHT) || 1080, // height of the opened browser timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s login_timeout: (Number(process.env.LOGIN_TIMEOUT) || 180) * 1000, // higher timeout for login, will wait twice: prompt + wait for manual login - login_visible_timeout: (Number(process.env.LOGIN_VISIBLE_TIMEOUT) || 20) * 1000, // how long to wait for login button/user indicator to appear novnc_port: process.env.NOVNC_PORT, // running in docker if set notify: process.env.NOTIFY, // apprise notification services notify_title: process.env.NOTIFY_TITLE, // apprise notification title diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 9985af6..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Epic Games API Constants -// Based on https://github.com/claabs/epicgames-freegames-node - -export const EPIC_CLIENT_ID = '875a3b57d3a640a6b7f9b4e883463ab4'; -export const CSRF_ENDPOINT = 'https://www.epicgames.com/id/api/csrf'; -export const ACCOUNT_CSRF_ENDPOINT = 'https://www.epicgames.com/account/v2/refresh-csrf'; -export const ACCOUNT_SESSION_ENDPOINT = 'https://www.epicgames.com/account/personal'; -export const LOGIN_ENDPOINT = 'https://www.epicgames.com/id/api/login'; -export const REDIRECT_ENDPOINT = 'https://www.epicgames.com/id/api/redirect'; -export const GRAPHQL_ENDPOINT = 'https://store.epicgames.com/graphql'; -export const ARKOSE_BASE_URL = 'https://epic-games-api.arkoselabs.com'; -export const CHANGE_EMAIL_ENDPOINT = 'https://www.epicgames.com/account/v2/api/email/change'; -export const USER_INFO_ENDPOINT = 'https://www.epicgames.com/account/v2/personal/ajaxGet'; -export const RESEND_VERIFICATION_ENDPOINT = 'https://www.epicgames.com/account/v2/resendEmailVerification'; -export const REPUTATION_ENDPOINT = 'https://www.epicgames.com/id/api/reputation'; -export const STORE_CONTENT = 'https://store-content-ipv4.ak.epicgames.com/api/en-US/content'; -export const EMAIL_VERIFY = 'https://www.epicgames.com/id/api/email/verify'; -export const SETUP_MFA = 'https://www.epicgames.com/account/v2/security/ajaxUpdateTwoFactorAuthSettings'; -export const FREE_GAMES_PROMOTIONS_ENDPOINT = 'https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions'; -export const STORE_HOMEPAGE = 'https://store.epicgames.com/'; -export const STORE_HOMEPAGE_EN = `${STORE_HOMEPAGE}en-US/`; -export const STORE_CART_EN = `${STORE_HOMEPAGE}en-US/cart`; -export const ORDER_CONFIRM_ENDPOINT = 'https://payment-website-pci.ol.epicgames.com/purchase/confirm-order'; -export const ORDER_PREVIEW_ENDPOINT = 'https://payment-website-pci.ol.epicgames.com/purchase/order-preview'; -export const EPIC_PURCHASE_ENDPOINT = 'https://www.epicgames.com/store/purchase'; -export const MFA_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/api/login/mfa'; -export const UNREAL_SET_SID_ENDPOINT = 'https://www.unrealengine.com/id/api/set-sid'; -export const TWINMOTION_SET_SID_ENDPOINT = 'https://www.twinmotion.com/id/api/set-sid'; -export const CLIENT_REDIRECT_ENDPOINT = `https://www.epicgames.com/id/api/client/${EPIC_CLIENT_ID}`; -export const AUTHENTICATE_ENDPOINT = `https://www.epicgames.com/id/api/authenticate`; -export const LOCATION_ENDPOINT = `https://www.epicgames.com/id/api/location`; -export const PHASER_F_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/phaser/f'; -export const PHASER_BATCH_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/phaser/batch'; -export const TALON_IP_ENDPOINT = 'https://talon-service-v4-prod.ak.epicgames.com/v1/init/ip'; -export const TALON_INIT_ENDPOINT = 'https://talon-service-prod.ak.epicgames.com/v1/init'; -export const TALON_EXECUTE_ENDPOINT = 'https://talon-service-v4-prod.ak.epicgames.com/v1/init/execute'; -export const TALON_WEBSITE_BASE = 'https://talon-website-prod.ak.epicgames.com'; -export const TALON_REFERRER = 'https://talon-website-prod.ak.epicgames.com/challenge?env=prod&flow=login_prod&origin=https%3A%2F%2Fwww.epicgames.com'; -export const ACCOUNT_OAUTH_TOKEN = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token'; -export const ACCOUNT_OAUTH_DEVICE_AUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization'; -export const ID_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/login'; -export const EULA_AGREEMENTS_ENDPOINT = 'https://eulatracking-public-service-prod-m.ol.epicgames.com/eulatracking/api/public/agreements'; -export const REQUIRED_EULAS = ['epicgames_privacy_policy_no_table', 'egstore']; diff --git a/src/cookie.ts b/src/cookie.ts deleted file mode 100644 index 33e7f69..0000000 --- a/src/cookie.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Cookie management for Epic Games -// Based on https://github.com/claabs/epicgames-freegames-node - -import fs from 'node:fs'; -import path from 'node:path'; -import tough from 'tough-cookie'; -import { filenamify } from './util.js'; -import { dataDir } from './util.js'; - -const CONFIG_DIR = dataDir('config'); -const DEFAULT_COOKIE_NAME = 'default'; - -// Ensure config directory exists -if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); -} - -function getCookiePath(username) { - const fileSafeUsername = filenamify(username); - const cookieFilename = path.join(CONFIG_DIR, `${fileSafeUsername}-cookies.json`); - return cookieFilename; -} - -// Cookie whitelist - only these cookies are stored -const COOKIE_WHITELIST = ['EPIC_SSO_RM', 'EPIC_SESSION_AP', 'EPIC_DEVICE']; - -// Cookie jar cache -const cookieJars = new Map(); - -function getCookieJar(username) { - let cookieJar = cookieJars.get(username); - if (cookieJar) { - return cookieJar; - } - const cookieFilename = getCookiePath(username); - cookieJar = new tough.CookieJar(); - cookieJars.set(username, cookieJar); - return cookieJar; -} - -// Convert EditThisCookie format to tough-cookie file store format -export function editThisCookieToToughCookieFileStore(etc) { - const tcfs = {}; - - etc.forEach((etcCookie) => { - const domain = etcCookie.domain.replace(/^\./, ''); - const expires = etcCookie.expirationDate - ? new Date(etcCookie.expirationDate * 1000).toISOString() - : undefined; - const { path: cookiePath, name } = etcCookie; - - if (COOKIE_WHITELIST.includes(name)) { - const temp = { - [domain]: { - [cookiePath]: { - [name]: { - key: name, - value: etcCookie.value, - expires, - domain, - path: cookiePath, - secure: etcCookie.secure, - httpOnly: etcCookie.httpOnly, - hostOnly: etcCookie.hostOnly, - }, - }, - }, - }; - Object.assign(tcfs, temp); - } - }); - - return tcfs; -} - -// Get cookies as simple object -export function getCookies(username) { - const cookieJar = getCookieJar(username); - const cookies = cookieJar.toJSON()?.cookies || []; - return cookies.reduce((accum, cookie) => { - if (cookie.key && cookie.value) { - return { ...accum, [cookie.key]: cookie.value }; - } - return accum; - }, {}); -} - -// Get raw cookies in tough-cookie file store format -export async function getCookiesRaw(username) { - const cookieFilename = getCookiePath(username); - try { - const existingCookies = JSON.parse(fs.readFileSync(cookieFilename, 'utf8')); - return existingCookies; - } catch { - return {}; - } -} - -// Set cookies from Playwright/Cookie format -export async function setPuppeteerCookies(username, newCookies) { - const cookieJar = getCookieJar(username); - - for (const cookie of newCookies) { - const domain = cookie.domain.replace(/^\./, ''); - const tcfsCookie = new tough.Cookie({ - key: cookie.name, - value: cookie.value, - expires: cookie.expires ? new Date(cookie.expires * 1000) : undefined, - domain, - path: cookie.path, - secure: cookie.secure, - httpOnly: cookie.httpOnly, - hostOnly: !cookie.domain.startsWith('.'), - }); - - try { - await cookieJar.setCookie(tcfsCookie, `https://${domain}`); - } catch (err) { - console.error('Error setting cookie:', err); - } - } -} - -// Delete cookies for a user -export async function deleteCookies(username) { - const cookieFilename = getCookiePath(username || DEFAULT_COOKIE_NAME); - try { - fs.unlinkSync(cookieFilename); - } catch { - // File doesn't exist, that's fine - } -} - -// Check if user has a valid cookie -export async function userHasValidCookie(username, cookieName) { - const cookieFilename = getCookiePath(username); - try { - const fileExists = fs.existsSync(cookieFilename); - if (!fileExists) return false; - - const cookieData = JSON.parse(fs.readFileSync(cookieFilename, 'utf8')); - const rememberCookieExpireDate = cookieData['epicgames.com']?.['/']?.[cookieName]?.expires; - if (!rememberCookieExpireDate) return false; - - return new Date(rememberCookieExpireDate) > new Date(); - } catch { - return false; - } -} - -// Convert imported cookies (EditThisCookie format) -export async function convertImportCookies(username) { - const cookieFilename = getCookiePath(username); - const fileExists = fs.existsSync(cookieFilename); - - if (fileExists) { - try { - const cookieData = fs.readFileSync(cookieFilename, 'utf8'); - const cookieTest = JSON.parse(cookieData); - - if (Array.isArray(cookieTest)) { - // Convert from EditThisCookie format - const tcfsCookies = editThisCookieToToughCookieFileStore(cookieTest); - fs.writeFileSync(cookieFilename, JSON.stringify(tcfsCookies, null, 2)); - } - } catch { - // Invalid format, delete file - fs.unlinkSync(cookieFilename); - } - } -} diff --git a/src/device-auths.ts b/src/device-auths.ts deleted file mode 100644 index 2fe0f88..0000000 --- a/src/device-auths.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Device authentication management for Epic Games -// Based on https://github.com/claabs/epicgames-freegames-node - -import fs from 'node:fs'; -import path from 'node:path'; -import { dataDir } from './util.js'; - -const CONFIG_DIR = dataDir('config'); -const deviceAuthsFilename = path.join(CONFIG_DIR, 'device-auths.json'); - -// Ensure config directory exists -if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); -} - -export async function getDeviceAuths() { - try { - const deviceAuths = JSON.parse(fs.readFileSync(deviceAuthsFilename, 'utf-8')); - return deviceAuths; - } catch { - return undefined; - } -} - -export async function getAccountAuth(account) { - const deviceAuths = await getDeviceAuths(); - return deviceAuths?.[account]; -} - -export async function writeDeviceAuths(deviceAuths) { - fs.writeFileSync(deviceAuthsFilename, JSON.stringify(deviceAuths, null, 2)); -} - -export async function setAccountAuth(account, accountAuth) { - const existingDeviceAuths = (await getDeviceAuths()) ?? {}; - existingDeviceAuths[account] = accountAuth; - await writeDeviceAuths(existingDeviceAuths); -} diff --git a/unrealengine.js b/unrealengine.js index 29d1064..6eb7d79 100644 --- a/unrealengine.js +++ b/unrealengine.js @@ -98,11 +98,7 @@ try { console.log(`Signed in as ${user}`); db.data[user] ||= {}; - try { - await page.locator('button:has-text("Accept All Cookies")').click(); - } catch { - // button may not be present - } + page.locator('button:has-text("Accept All Cookies")').click().catch(_ => { }); const ids = []; for (const p of await page.locator('article.asset').all()) {