Compare commits

..

317 commits

Author SHA1 Message Date
nocci
67afeead60 chore: chown /fgc/data on start so fgc can write bind-mount
All checks were successful
build-and-push / lint (push) Successful in 5s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m13s
2025-12-30 17:36:31 +00:00
nocci
8ebb57a706 Add missing Playwright deps and clear Firefox locks on start
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m10s
2025-12-30 17:27:28 +00:00
nocci
949206dbf2 Ensure /tmp/.X11-unix exists with sane perms
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m16s
2025-12-30 17:21:53 +00:00
nocci
cfc1e0ee12 Harden entrypoint: skip user.js on RO volume and auto-install browsers
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 13s
build-and-push / docker (push) Successful in 1m13s
2025-12-30 17:10:53 +00:00
nocci
4d835013cc Skip browser prefs when volume is read-only
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m12s
2025-12-30 17:05:01 +00:00
nocci
6f2e1e5b22 Make browser prefs creation non-fatal when unwritable
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 11s
build-and-push / docker (push) Successful in 1m11s
2025-12-30 16:58:22 +00:00
nocci
e3e6a6f36c Handle unwritable browser volume in entrypoint
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m11s
2025-12-30 16:54:42 +00:00
nocci
a477bb3327 Fix lint: guard browser globals and normalize indentation
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m12s
2025-12-30 16:47:46 +00:00
nocci
7ffc454e47 Clean up Sonar issues and lint warnings
Some checks failed
build-and-push / lint (push) Failing after 5s
build-and-push / sonar (push) Has been skipped
build-and-push / docker (push) Has been skipped
2025-12-30 16:45:17 +00:00
nocci
b9aa6e0073 Use sonar.token instead of deprecated sonar.login
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 11s
build-and-push / docker (push) Successful in 1m14s
2025-12-30 16:19:53 +00:00
nocci
bab4359977 Fix indentation in unrealengine eslint
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m11s
2025-12-30 16:16:29 +00:00
nocci
52895fd991 Fix eslint no-unused-vars in async handlers
Some checks failed
build-and-push / lint (push) Failing after 4s
build-and-push / sonar (push) Has been skipped
build-and-push / docker (push) Has been skipped
2025-12-30 16:15:14 +00:00
nocci
3fd861f134 Fix eslint no-empty and clean tests
Some checks failed
build-and-push / lint (push) Failing after 4s
build-and-push / sonar (push) Has been skipped
build-and-push / docker (push) Has been skipped
2025-12-30 16:13:15 +00:00
nocci
5f919039ab Clean Sonar issues in store scripts
Some checks failed
build-and-push / lint (push) Failing after 4s
build-and-push / sonar (push) Has been skipped
build-and-push / docker (push) Has been skipped
2025-12-30 16:08:59 +00:00
nocci
397871b012 Further clean Sonar: merge base RUN, strip comments, node imports
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m13s
2025-12-30 15:47:28 +00:00
nocci
405e801851 Clean Sonar findings: merge RUNs, drop commented code, update node imports
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 11s
build-and-push / docker (push) Successful in 1m13s
2025-12-30 15:39:11 +00:00
nocci
37ffd09545 Refactor prompt timeout plugin to reduce nesting
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 11s
build-and-push / docker (push) Successful in 1m11s
2025-12-30 14:56:50 +00:00
nocci
69282c63d5 Address Sonar warnings and harden runtime
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m13s
2025-12-30 14:54:14 +00:00
nocci
9e2bc89ff2 chore: clean up util notify/prompt lint findings
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 12s
build-and-push / docker (push) Successful in 1m11s
2025-12-30 14:36:41 +00:00
nocci
e4b1f60a66 chore: use execFile for git commands in version check
All checks were successful
build-and-push / lint (push) Successful in 5s
build-and-push / sonar (push) Successful in 11s
build-and-push / docker (push) Successful in 1m11s
2025-12-30 14:31:17 +00:00
nocci
f82c158a6b ci: run sonar-scanner locally instead of docker
All checks were successful
build-and-push / lint (push) Successful in 5s
build-and-push / sonar (push) Successful in 16s
build-and-push / docker (push) Successful in 1m10s
2025-12-30 14:27:12 +00:00
nocci
b5e9111039 ci: mount workspace explicitly for sonar scanner
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / docker (push) Successful in 1m12s
build-and-push / sonar (push) Successful in 7s
2025-12-30 14:23:43 +00:00
nocci
94be980c95 ci: debug sonar scanner mount and remove extra inclusions
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 10s
build-and-push / docker (push) Successful in 1m10s
2025-12-30 14:22:07 +00:00
nocci
00b36a65b1 ci: quote sonar inclusions to avoid shell glob
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 7s
build-and-push / docker (push) Successful in 1m8s
2025-12-30 13:25:17 +00:00
nocci
2a4653062a ci: make sonar scan pick up sources
All checks were successful
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Successful in 6s
build-and-push / docker (push) Successful in 1m10s
2025-12-30 13:22:54 +00:00
nocci
25fb2d9838 chore: remove github workflows and configs
All checks were successful
build-and-push / lint (push) Successful in 5s
build-and-push / sonar (push) Successful in 7s
build-and-push / docker (push) Successful in 1m13s
2025-12-30 13:19:36 +00:00
nocci
6c4eb948fc alles jut 2025-12-30 13:08:44 +00:00
nocci
405660e0f2 🐛 fix(ci): adjust sonar scm configuration
All checks were successful
build-and-push / lint (push) Successful in 5s
build-and-push / sonar (push) Successful in 6s
build-and-push / docker (push) Successful in 1m11s
- change sonar.scm.provider to sonar.scm.disabled for compatibility
2025-12-30 13:06:49 +00:00
nocci
02972a04d4 🐛 fix(ci): handle missing sonar project key gracefully
Some checks failed
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Failing after 6s
build-and-push / docker (push) Has been skipped
- set default empty value for PROJECT_KEY variable
- add fallback to read sonar-project.properties if secret is not provided
- exit with error if PROJECT_KEY is still unset after checks
2025-12-30 13:05:40 +00:00
nocci
608b6b87cd 👷 ci(build): simplify sonar project key retrieval
Some checks failed
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Failing after 6s
build-and-push / docker (push) Has been skipped
- remove redundant conditional logic for project key extraction
- enforce mandatory SONAR_PROJECT_KEY secret for consistency
- change sonar.scm.provider to 'none' for improved configuration
2025-12-30 13:02:52 +00:00
nocci
e11d40bdda 👷 ci(build): enforce sonar host url secret presence
Some checks failed
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Failing after 6s
build-and-push / docker (push) Has been skipped
- update HOST_URL to require SONAR_HOST_URL secret for enhanced security configuration
- improve error handling by ensuring critical secrets are set
2025-12-30 13:01:14 +00:00
nocci
8626fa5a0f ♻️ refactor(build): enhance sonar scanner configuration
- set default host url for sonar scanner to ensure consistency
- add sonar.sources and sonar.scm.provider for improved analysis
2025-12-30 12:57:44 +00:00
nocci
56ca1f63d4 👷 ci(build): enhance sonar project key configuration
Some checks failed
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Failing after 6s
build-and-push / docker (push) Has been skipped
- add SONAR_PROJECT_KEY environment variable for flexibility
- fallback to default or file-based project key if not set
2025-12-30 12:42:36 +00:00
nocci
5b1d966c6e 👷 ci(build): enhance sonar scanner configuration
Some checks failed
build-and-push / lint (push) Successful in 4s
build-and-push / sonar (push) Failing after 7s
build-and-push / docker (push) Has been skipped
- extract project key from properties file for dynamic setup
- add projectBaseDir to sonar scanner command for accurate analysis
2025-12-30 12:40:06 +00:00
nocci
d40a577f47 👷 ci(build): add SonarQube scan to build workflow
Some checks failed
build-and-push / lint (push) Successful in 5s
build-and-push / sonar (push) Failing after 12s
build-and-push / docker (push) Has been skipped
- introduce SonarQube scanning step for code quality analysis
- update workflow dependencies and execution order

🐛 fix(auth): improve error handling and code formatting

- remove unused imports and fix code indentation
- enhance error handling with improved catch blocks

💄 style(general): standardize code formatting and style consistency

- update various files to ensure consistent code style
- adjust indentation and whitespace for readability
2025-12-30 12:38:03 +00:00
nocci
0e5303da62 👷 ci(workflow): add lint job to build workflow
Some checks failed
build-and-push / lint (push) Failing after 32s
build-and-push / docker (push) Has been skipped
- introduce lint job in build.yml for code quality checks
- ensure lint job runs before docker job
- setup Node.js and install dependencies for ESLint
2025-12-30 11:03:08 +00:00
e39cca93c2 better README.md
All checks were successful
build-and-push / docker (push) Successful in 1m10s
2025-12-29 17:19:39 +01:00
nocci
9d79f9ac78 📝 docs(README): update configuration and remove CI section
All checks were successful
build-and-push / docker (push) Successful in 1m8s
- remove outdated CI/build instructions
- add new environment variable options for configuration

🐛 fix(util): handle notification errors gracefully

- resolve promise instead of rejecting on notification errors
- prevent whole run from failing due to notification issues
2025-12-29 15:54:41 +00:00
nocci
0a729d0cbf 🔧 chore(workflow): simplify docker image tagging and pushing
All checks were successful
build-and-push / docker (push) Successful in 1m10s
- remove specific sha tag from build and push steps
- streamline workflow by focusing on latest tag
2025-12-29 15:08:00 +00:00
nocci
eb5b9bbb6e 👷 ci(build): enhance docker build process with buildx
All checks were successful
build-and-push / docker (push) Successful in 1m12s
- add Docker Buildx setup for advanced build capabilities
- update build step to use buildx for multi-platform support
2025-12-29 15:01:55 +00:00
nocci
eba07721ca 📝 docs(README): update instructions and clarify configurations
Some checks failed
build-and-push / docker (push) Failing after 1m55s
- update registry image references to point to `git.sky-net.it/nocci/free-games-claimer`
- improve clarity on Docker and Docker Compose examples
- translate German sections into English for wider accessibility
- add detailed explanations for environment variables and configurations
- enhance quickstart and CI instructions for better understanding
2025-12-29 14:38:54 +00:00
nocci
bde0f34a80 📝 docs(README): simplify and update documentation
Some checks failed
build-and-push / docker (push) Failing after 0s
- remove detailed setup instructions and history
- add quick-start guide for Docker and Docker Compose
- update CI and configuration sections
- streamline language and formatting for clarity
2025-12-29 14:36:18 +00:00
nocci
6f778d71ac 📝 docs(README): add instructions for building images in Forgejo
Some checks failed
build-and-push / docker (push) Failing after 0s
- include steps for building and pushing Docker images using Forgejo
- provide details on setting Forgejo secrets and using self-hosted runners
2025-12-29 14:33:14 +00:00
nocci
a21dced86e 📦 build(ci): add build-and-push workflow for docker images
Some checks failed
build-and-push / docker (push) Failing after 4s
- create a new CI workflow to automate image building and pushing
- trigger workflow on push to main branch
- include steps for checkout, login, build, and push docker images
2025-12-29 14:31:25 +00:00
nocci
76f578e2e6 feat(auth): enhance automatic login and MFA handling
Some checks failed
Build and push Docker image (amd64, arm64 to hub.docker.com and ghcr.io) / docker (push) Has been cancelled
Lint / Lint (push) Has been cancelled
Sonar / sonarcloud (push) Has been cancelled
- add handleMFA function for improved two-step verification
- implement direct login page handling with automatic sign-in

🐛 fix(claim): improve game claim process and duplicate prevention

- normalize claim URLs and deduplicate by URL
- fix various selectors for claim buttons and handle different languages
- prevent duplicate game claims by checking existing records

♻️ refactor(utils): improve code readability and maintainability

- extract normalizeClaimUrl function for URL handling
- restructure logic for claim and notification processes

🌐 i18n(claim): add support for game claim text in German

- handle German text for claim buttons and status checks
2025-12-29 14:15:22 +00:00
nocci
faf22aafb1 🐛 fix(prime-gaming): update URL and selectors for claims
Some checks are pending
Build and push Docker image (amd64, arm64 to hub.docker.com and ghcr.io) / docker (push) Waiting to run
Lint / Lint (push) Waiting to run
Sonar / sonarcloud (push) Waiting to run
- change URL_CLAIM to point to new Luna claims home
- update selectors for sign-in and user verification
- improve handling of cookies acceptance

 feat(prime-gaming): enhance game claiming logic

- add support for new layout and game list detection
- implement flexible scrolling for loading all game cards
- refine logic for internal and external game claims
- improve store identification for external claims

♻️ refactor(prime-gaming): modularize game tab and list location

- extract functions for opening games tab and locating games list
- enhance code readability and maintainability

🐛 fix(prime-gaming): handle dynamic selectors for availability dates

- support multiple selectors for availability date detection
- improve error handling and logging for missing elements
2025-12-29 11:41:09 +00:00
Ralf Vogler
99c1f05302 Merge branch 'dev' 2025-05-16 00:15:45 +02:00
Ralf Vogler
991e0a1449 docker: only build branches main, dev
Otherwise branches will accumulate in 'Recent tagged image version'
https://github.com/vogler/free-games-claimer/pkgs/container/free-games-claimer
2025-05-16 00:14:10 +02:00
Ralf Vogler
cea5b11c95 mv renovate.json .github/ 2025-05-16 00:06:39 +02:00
Ralf Vogler
6adc7e529a disable Renovate for now 2025-05-16 00:05:48 +02:00
Ralf Vogler
158caa6164
Merge pull request #469 from vogler/renovate/super-linter-super-linter-7.x 2025-05-16 00:01:31 +02:00
Ralf Vogler
0a4cac3571 ncu -u 2025-05-15 23:51:49 +02:00
Ralf Vogler
bf0c9032cc need 'packages: write' permission for ghcr.io 2025-05-14 01:59:37 +02:00
Ralf Vogler
4ce0d44b79 can't check secrets in if expr... 2025-05-14 01:44:14 +02:00
Ralf Vogler
7c8682ac91 build docker image for each branch/PR? 2025-05-14 01:31:04 +02:00
Ralf Vogler
a5ce5ec816 fix CodeQL alerts 5-7
https://github.com/vogler/free-games-claimer/security/code-scanning/5
https://github.com/vogler/free-games-claimer/security/code-scanning/6
https://github.com/vogler/free-games-claimer/security/code-scanning/7
2025-05-14 00:46:37 +02:00
Ralf Vogler
6aea18836d enquirer: esc is fine, but after first prompt SIGINT will be ignore; onRawSIGINT keeps process running -> switch to inquirer 2025-05-14 00:30:39 +02:00
Ralf Vogler
97ef14f514 uncommited steam-games.js from 2024-09-24 - WIP just saves all games and their stats 2025-05-14 00:18:22 +02:00
Ralf Vogler
5cfe57870a note on git describe for version 2025-05-13 23:43:29 +02:00
renovate[bot]
3e13e9fba8
chore(deps): update super-linter/super-linter action to v7.4.0 2025-05-13 20:29:21 +00:00
Ralf Vogler
dee47d2218 pg: redeem gog: detect captcha 2025-04-15 17:27:21 +02:00
Ralf Vogler
726db4527b Current free game title in blue 2025-04-15 17:26:27 +02:00
Ralf Vogler
04d1b7ea9e pg: fix auto-login; rememberMe was removed 2025-04-15 17:22:21 +02:00
Ralf Vogler
0fefcc47b7 pg: skipBasedOnTime if days left > PG_TIMELEFT 2025-04-15 17:08:34 +02:00
Ralf Vogler
92cb32ff2d ae: try fingerprint-injector 2025-04-03 15:33:00 +02:00
Ralf Vogler
90e3400072
readme: logos: use GitHub asset upload 2025-03-27 17:11:30 +01:00
Ralf Vogler
041a77d9f6
readme: logos: align: middle - CSS is stripped 2025-03-27 17:01:18 +01:00
Ralf Vogler
787522473f readme: logos: vertical-align: middle 2025-03-27 16:53:41 +01:00
Ralf Vogler
cfe42db805 pg: fix logo 2025-03-27 16:53:27 +01:00
Ralf Vogler
8b7018f54c fix actionlint errors (docker.yml)
Double quote to prevent globbing and word splitting
2025-03-27 16:26:40 +01:00
Ralf Vogler
ea69c76b50 eg: fix #449: only notify once if 'Game already in library' 2025-03-11 10:14:42 +01:00
Ralf Vogler
3626fc1762 fixup #417 2025-03-05 16:17:48 +01:00
Ralf Vogler
d2d85dee8c
Merge pull request #417 from NeoMod/patch-1
Fixed missing game link in notification for epic-games on captcha halt
2025-03-05 16:11:37 +01:00
Ralf Vogler
8d538290a8
Merge pull request #387 from srounce/patch-1
fix: docker-entrypoint.sh uses safe shebang
2025-03-05 15:57:54 +01:00
Ralf Vogler
daef12b562
Merge pull request #441 from vogler/renovate/docker-build-push-action-6.x
chore(deps): update docker/build-push-action action to v6
2025-03-05 15:54:29 +01:00
Ralf Vogler
acaf6691d4
Merge pull request #440 from vogler/renovate/major-eslint-stylistic-monorepo
chore(deps): update dependency @stylistic/eslint-plugin-js to v4
2025-03-05 15:54:12 +01:00
renovate[bot]
e75975b273
chore(deps): update docker/build-push-action action to v6 2025-03-05 14:54:01 +00:00
renovate[bot]
cb22abfb2f
chore(deps): update dependency @stylistic/eslint-plugin-js to v4 2025-03-05 14:53:57 +00:00
Ralf Vogler
67058850d5
Merge pull request #438 from vogler/renovate/chalk-5.x-lockfile
fix(deps): update dependency chalk to v5.4.1
2025-03-05 15:47:47 +01:00
Ralf Vogler
3ac05a5593
Merge pull request #434 from vogler/renovate/eslint-stylistic-monorepo
chore(deps): update dependency @stylistic/eslint-plugin-js to v2.13.0
2025-03-05 15:47:30 +01:00
renovate[bot]
d620ee2731
chore(deps): update dependency @stylistic/eslint-plugin-js to v2.13.0 2025-03-05 14:46:12 +00:00
Ralf Vogler
f47ffe0e9f
Merge pull request #436 from vogler/renovate/eslint-monorepo
chore(deps): update dependency eslint to v9.21.0
2025-03-05 15:45:33 +01:00
Ralf Vogler
253a3f7253
Merge pull request #433 from vogler/renovate/dotenv-16.x-lockfile
fix(deps): update dependency dotenv to v16.4.7
2025-03-05 15:44:52 +01:00
Ralf Vogler
5e5c257b83
Merge pull request #437 from vogler/super-linter
try super-linter
2025-03-05 15:41:35 +01:00
Ralf Vogler
6aa0f9b817 super-linter: problem matchers included? 2025-03-05 15:34:03 +01:00
Ralf Vogler
bf2216f731 super-linter: TODO fix-lint-issues 2025-03-05 15:27:00 +01:00
Ralf Vogler
d6666aed03 super-linter: use slim version 2025-03-05 15:17:34 +01:00
renovate[bot]
041605dcf7
fix(deps): update dependency chalk to v5.4.1 2025-03-05 14:08:35 +00:00
renovate[bot]
d76c03ed6a
chore(deps): update dependency eslint to v9.21.0 2025-03-05 14:08:28 +00:00
Ralf Vogler
fed03428f3 try super-linter
https://github.com/marketplace/actions/super-linter#get-started
2025-03-05 15:07:37 +01:00
renovate[bot]
4a018c20e9
fix(deps): update dependency dotenv to v16.4.7 2025-03-05 13:22:40 +00:00
Ralf Vogler
ceb873ea0b
Merge pull request #432 from vogler/renovate/configure
chore: Configure Renovate
2025-03-05 14:22:06 +01:00
renovate[bot]
9e87ce58ac
Add renovate.json 2025-03-05 13:17:13 +00:00
NeoMod
b3ab8f7830
Fixed missing game link in notification for epic-games on captcha halt
This fixes the "game link" missing from notification when checking Epic Games Store and encountering a captcha, as per #259 should have been but instead wasn't. Issue was identified in #402 comment by "vttc08" (https://github.com/vogler/free-games-claimer/issues/402#issuecomment-2510818082)

I also added the missing notification for "Game Already in Library" for Epic Games Store, since I felt it is useful because it provides an easy way to know if the script is working or not during scheduled usage.

This also provided the opportunity to check if the game url link was handled properly outside "Discord": I have added a commented option where one could activate the "Already in Library" notification that will also provide the game url link for easy verification.
2025-01-01 15:31:04 +01:00
Ralf Vogler
33d2493336
Merge pull request #411 from AgentTechnoman/main
Fix gog error per @jordyamc
2024-12-30 04:29:09 +01:00
AgentTechnoman
d5072a62c0
Fix gog error per @jordyamc
https://github.com/vogler/free-games-claimer/issues/398#issuecomment-2487274414
2024-12-19 16:46:18 -07:00
Samuel Rounce
c5a7a10ca3
fix: docker-entrypoint.sh uses safe shebang
Shebangs should use /usr/bin/env to locate the interpreter. Setting a path directly to the interpreter itself tends to be brittle and prone to breaking.
2024-09-28 18:34:39 +01:00
Ralf Vogler
c8cf7362fa eg: wait for purchaseBtn to have some text, fixes #375 2024-09-01 01:20:45 +02:00
Ralf Vogler
fef5b97e3b eg: wait for purchaseBtn, without it didn't detect bundle as already claimed 2024-08-29 20:23:43 +02:00
Ralf Vogler
292aadae3c eg: list games included in a bundle 2024-08-29 20:06:59 +02:00
Ralf Vogler
7279ba06e8 eg: change back to purchase-cta-button from 'aside button', fixes #374 2024-08-29 19:48:28 +02:00
Ralf Vogler
1f4af79e0e pg: fix loading all games by 2*PageDown + checking height instead of End + checking count..., #357 2024-08-29 13:23:45 +02:00
Ralf Vogler
c8e0640490 eg: #371: dump HTML in case of EULA popup to find new locator
https://github.com/vogler/free-games-claimer/issues/371
2024-08-29 12:46:28 +02:00
Ralf Vogler
eafb2316f7 pg: login now has Continue button after email 2024-08-28 02:31:47 +02:00
Ralf Vogler
c87deb393e pg: DLC: forgot to await scrollUntilStable, closes #356 2024-08-02 14:02:33 +02:00
Ralf Vogler
23a611a2f1 ci/docker: fix: tag main as latest 2024-08-02 13:19:39 +02:00
Ralf Vogler
eac11e8949 ci/docker: force tag main as latest since many people pull it 2024-08-02 13:06:28 +02:00
Ralf Vogler
8605b70377 pg: fix internal/external locators, closes #355, waitUntilStable 2s -> 5s 2024-08-02 12:57:37 +02:00
Ralf Vogler
d9e91d22c9 eg: fix changed button locators 2024-08-01 15:46:41 +02:00
Ralf Vogler
a39d737999 aliexpress: collect daily coins via desktop website
All load in webview in android app.
Other games have no desktop version and mobile version uses canvas
and only refers to download app with both stealth and fingerprint-injector.
Also stuck on loading screen when using firefox instead of chrome.
2024-07-19 14:35:40 +02:00
Ralf Vogler
b8f7068a87 pg: scrollUntilStable to wait in loop to load all games since one scroll may not be enough 2024-07-10 13:27:48 +02:00
Ralf Vogler
17f1ee41c9 eg: don't exit on login error since it may be 'Incorrect response' for captcha
https://github.com/vogler/free-games-claimer/issues/183#issuecomment-2195691372
2024-06-27 23:32:15 +02:00
Ralf Vogler
aee72327ea eg: disable webgl since it leaks running virtualized, #183 2024-06-27 15:41:58 +02:00
Ralf Vogler
00275d825b eg: forgot to catch timeout for captcha detection 2024-06-27 15:40:45 +02:00
Ralf Vogler
e154b74e4d eg: TODO handle base game for add-ons via function 2024-06-27 14:09:37 +02:00
Ralf Vogler
d85cd8d20c rm xbox.js, no more games with gold, closes #286, closes #339
TODO integrate login code for microsoft games in prime-gaming
2024-06-27 10:54:46 +02:00
Ralf Vogler
9af36e902c eg: fix login: password now together with email again, detect login error, closes #338, closes #337 2024-06-26 17:16:38 +02:00
Ralf Vogler
920fc4d186
Merge pull request #318 from Targunitoth/main
Escape the apprise -b parameter
2024-06-25 18:52:13 +02:00
Ralf Vogler
c4be7ece21 xbox: fixup #307 2024-06-25 18:45:21 +02:00
Ralf Vogler
b8071d442c
Merge pull request #307 from Rexexe/main
Resolve error with no default data
2024-06-25 18:44:25 +02:00
Ralf Vogler
9cf5d2f7f2 pg: include code in redeem_url for gog, closes #330 2024-06-25 15:50:02 +02:00
Ralf Vogler
55226933c0 filenamify datetime recordHar for Windows : -> ., fix #336 2024-06-25 15:30:02 +02:00
Ralf Vogler
357a780aa9
Merge pull request #325 from Tymec/main
Ability to use a different email for Legacy Games
2024-06-25 13:31:06 +02:00
Ralf Vogler
afceb0aad9
Merge branch 'main' into main 2024-06-25 13:30:42 +02:00
Ralf Vogler
c0eb6dbb0b ncu -u: playwright 1.44.1 -> 1.45.0 2024-06-25 13:24:55 +02:00
Ralf Vogler
076738e301 gog: fail if WIDTH<1280 due to hidden username, closes #335 2024-06-25 13:09:10 +02:00
Ralf Vogler
601e893714 README: link example how to set env vars on Windows, closes #314 2024-06-23 20:50:00 +02:00
Ralf Vogler
43bef9b23c pg: redeem microsoft store and xbox, WIP, #315, #5 2024-06-23 20:44:03 +02:00
Ralf Vogler
f920aa26d0 pg: checkTimeLeft via PG_TIMELEFT=1 2024-06-23 19:18:30 +02:00
Ralf Vogler
30b1835bdb pg: reverse games: oldest/bottom to newest/top 2024-06-23 19:16:01 +02:00
Ralf Vogler
f0f142733e ncu -u: lowdb 6 -> 7 dropped support for Node 16 2024-06-23 19:14:13 +02:00
Ralf Vogler
cf59da5d9e gog: new locators, fixes #326, #334 2024-06-23 18:03:38 +02:00
Ralf Vogler
2d81ed2145
Merge pull request #329 from nett00n/patch-1
Update README.md
2024-06-21 18:12:04 +02:00
Vladimir Budylnikov
f7c23569c7
Update README.md
I've made i howto for windows users
2024-06-02 13:33:51 +04:00
Ralf Vogler
9ef9798626 eg: detect 'Failed to challenge captcha, please try again later.' 2024-05-23 15:55:07 +02:00
Ralf Vogler
e49fa930e0 eg: claim base game before add-on 2024-05-23 15:54:23 +02:00
Tymec
c4ae3505b3
Update README.md 2024-05-18 19:50:13 +02:00
Tymec
8c535f48fa
Update prime-gaming.js 2024-05-18 19:47:18 +02:00
Tymec
075106da11
Update config.js 2024-05-18 19:45:59 +02:00
Ralf Vogler
3338d08a1d eg: fix title for Bundles, TODO got stuck after
happened for 'LISA: The Definitive Edition' - https://store.epicgames.com/en-US/bundles/lisa-the-definitive-edition
2024-05-02 16:58:31 +02:00
Targunitoth
da6964c19a
Escape the apprise -b parameter 2024-04-25 13:51:40 +02:00
Ralf Vogler
6f06fccd46 eslint: prefer-const 2024-03-07 13:47:58 +01:00
Rex
61fdf566c9 Resolve error with no default data 2024-03-01 20:57:00 +08:00
Ralf Vogler
285c7a44fd README.md: comment alternative starchart.cc 2024-02-29 16:35:02 +01:00
Ralf Vogler
2ea0c611e4 pg: fix claiming external games, detect store early in description, delete old code 2024-02-13 23:48:48 +01:00
Ralf Vogler
e4e4ce703d pg: also get and store url for internal games 2024-02-13 23:45:36 +01:00
Ralf Vogler
fc810af80e
Update README.md: add Repobeats insights 2024-01-11 15:58:56 +01:00
Ralf Vogler
28bcceb285 eg: enter date of birth if age confirmation pops up, #275 2023-12-31 16:58:56 +01:00
Ralf Vogler
0ec24ef062 fix import paths for test/ 2023-12-30 13:37:04 +01:00
Ralf Vogler
c3657f05ac workaround for cropped viewport (Playwright regression), fixes #277 2023-12-28 17:56:09 +01:00
Ralf Vogler
7ca79bd413 eg: debug window.screen 2023-12-28 17:31:23 +01:00
Ralf Vogler
56cfab6e21
Update README.md 2023-12-28 11:28:23 +01:00
Ralf Vogler
fb8f38706a
mention pipx, #276 2023-12-27 10:22:54 +01:00
Ralf Vogler
a9e20928ee pg: fix util.js -> src/util.js
64676795d1 (r135743209)
2023-12-26 18:56:36 +01:00
Ralf Vogler
b68021b8e8 docker: firefox: overwrite user.js instead of appending on every run, #261 2023-12-26 18:01:32 +01:00
Ralf Vogler
baeaaa64f8 docker: firefox: privacy.resistFingerprinting fixes #261
https://github.com/vogler/free-games-claimer/issues/261#issuecomment-1868385830

Docker container will ask/fill email, challenge captcha, ask/fill password, challenge captcha again.
2023-12-26 17:52:18 +01:00
Ralf Vogler
105f6f414a eg: only ask for password after email submit -> notice captcha before 2023-12-26 17:49:51 +01:00
Ralf Vogler
64676795d1 mv {config,migrate,util,version}.js src/ 2023-12-26 17:09:14 +01:00
Ralf Vogler
6b9420804b test to show enquirer's sigint issue 2023-12-26 16:58:14 +01:00
Ralf Vogler
5a4f07ce70 mv notify-test.js test/notify.js 2023-12-26 16:57:47 +01:00
Ralf Vogler
09a364c67d extract chrome-specific comments/args into util.js/launchChromium 2023-12-26 16:48:25 +01:00
Ralf Vogler
4518914bfd
Update FUNDING.yml 2023-12-24 17:04:37 +01:00
Ralf Vogler
377ee736cb
Update FUNDING.yml 2023-12-24 16:29:05 +01:00
Ralf Vogler
2dec0080d9
Update FUNDING.yml 2023-12-24 16:23:01 +01:00
Ralf Vogler
a2af1ccd09
Update FUNDING.yml 2023-12-24 16:22:35 +01:00
Ralf Vogler
e66d10574d
Update FUNDING.yml 2023-12-24 16:21:15 +01:00
Ralf Vogler
68b66444ca eg: set cookie to void 'please provide your date of birth', closes #275 2023-12-24 15:52:59 +01:00
Ralf Vogler
2a19100601
Update FUNDING.yml 2023-12-24 15:39:38 +01:00
Ralf Vogler
5343535429
Create FUNDING.yml 2023-12-24 01:10:17 +01:00
Ralf Vogler
ace4ef8303 pg: DLC: ignore timeout if there is no code
https://github.com/vogler/free-games-claimer/issues/208#issuecomment-1868317276
2023-12-23 18:16:30 +01:00
Ralf Vogler
e078a1540a
Merge pull request #274 from Primajin/#208-prime-gaming
Use `.tw-button` css class over `button` html element selector
2023-12-23 15:23:33 +01:00
Jannis Hell
093ed813c6
Update prime-gaming.js
account for more than one account link button
2023-12-23 11:55:27 +01:00
Jannis Hell
e06ad3a27f
use tw-button css class over button html element selector 2023-12-22 16:40:02 +01:00
Ralf Vogler
9f97805517 debug navigator.{userAgent, platform} 2023-12-21 16:38:12 +01:00
Ralf Vogler
30957d63d0 DEBUG_NETWORK=1 to log network requests and responses instead of DEBUG && RECORD 2023-12-21 16:16:48 +01:00
Ralf Vogler
19e9bb9cfc eg: changed: move password fill and fix captcha locators, #260 2023-12-21 01:55:54 +01:00
Ralf Vogler
bdc305aa83 reorder package.json 2023-12-21 01:53:47 +01:00
Ralf Vogler
e18de7e640
Merge pull request #249 from Primajin/patch-1
prime-gaming: 'Claim now' -> 'Claim' for external/DLC
2023-12-21 01:19:20 +01:00
Jannis Hell
f4270e176f Update prime-gaming.js
The button was renamed to "Claim" instead of "Claim now" this causes / fixes #208
2023-12-20 20:55:14 +01:00
Ralf Vogler
8bf36a2158 specify engines.node >=15 for ||=, #264 2023-12-19 12:27:55 +01:00
Ralf Vogler
3c15252d8b pg: use chalk to color game codes blue, closes #250 2023-12-19 12:06:15 +01:00
Ralf Vogler
76597f4315 eg: include link to game in captcha notification, closes #259
TODO use purchaseURL from https://github.com/vogler/free-games-claimer/pull/130
2023-12-19 11:52:35 +01:00
Ralf Vogler
9c89bf06a4 npm run lint = npx eslint . 2023-12-19 11:34:00 +01:00
Ralf Vogler
36fe60a2b1 vscode update changed config 2023-12-19 11:33:38 +01:00
Ralf Vogler
0f549285b9
Merge pull request #253 from vogler/dependabot/github_actions/actions/setup-node-4
build(deps): bump actions/setup-node from 3 to 4
2023-12-19 11:30:59 +01:00
Ralf Vogler
544eff8a23 ncu -u: playwright-firefox 1.40.0 -> 1.40.1, eslint 2023-12-19 11:25:21 +01:00
Ralf Vogler
5919d37efa ncu -u: playwright-firefox 1.39.0 -> 1.40.0, eslint 2023-11-23 16:16:26 +01:00
dependabot[bot]
324f6bffe7
build(deps): bump actions/setup-node from 3 to 4
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 06:01:59 +00:00
Ralf Vogler
4231b7dd28 gog: don't wait for screenshot to not be loading, closes #240 2023-11-09 02:43:43 +01:00
Ralf Vogler
3ddf1720bb notify: use execFile with arg array instead of exec to avoid shell-escape, fixes #239
Also proper fix for https://github.com/vogler/free-games-claimer/pull/167

https://www.npmjs.com/package/shell-escape
https://stackoverflow.com/questions/1779858/how-do-i-escape-a-string-for-a-shell-command-in-node
https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
2023-11-09 02:34:17 +01:00
Ralf Vogler
a7cc68b6db ue: login: fix #248, similar to #236
Commits for #236:
d73a523fe7
a374d48345
2023-11-09 01:41:01 +01:00
Ralf Vogler
739fb0dbfe vscode workspace settings for formatOnSave with eslint --fix using flat config
https://eslint.style/guide/faq#vs-code
2023-11-08 02:30:21 +01:00
Ralf Vogler
110b2ea4c6 eslint: migrate to new flat config (.eslintrc.cjs -> eslint.config.js)
https://eslint.org/docs/latest/use/configure/configuration-files-new
https://eslint.org/docs/latest/use/configure/migration-guide
2023-11-08 02:28:34 +01:00
Ralf Vogler
c5f75e06c8 gha: sonar: disable shallow clone (irrelevant) to get rid of warning 2023-11-08 01:36:35 +01:00
Ralf Vogler
d4685ff370 gha: sonar: install dev deps for eslint plugins 2023-11-08 01:32:20 +01:00
Ralf Vogler
0832ae57f5 run eslint --fix . 2023-11-08 01:16:49 +01:00
Ralf Vogler
011eddf97a eslint: enable most stylistic rules
https://eslint.style/packages/js
2023-11-08 01:02:43 +01:00
Ralf Vogler
efe4faab3e
add sonarcloud badge for code smells 2023-11-07 18:24:32 +01:00
Ralf Vogler
e4e4f2eafa fix eslint errors 2023-11-07 18:10:22 +01:00
Ralf Vogler
6a34cb541d format sonar.yml 2023-11-07 18:03:54 +01:00
Ralf Vogler
5083ea408d ncu -u: eslint 8.50.0 -> 8.53.0 2023-11-07 17:58:36 +01:00
Ralf Vogler
2eaf6f0598 .eslintignore -> .gitignore, no-unused-vars: ignore args starting with _
https://eslint.org/docs/latest/rules/no-unused-vars#argsignorepattern
https://eslint.org/docs/latest/rules/no-undef
2023-11-07 17:57:10 +01:00
Ralf Vogler
dec0b519a5
Merge pull request #228 from 4n4n4s/ci
Sonarqube support with ESLint
2023-11-07 17:36:20 +01:00
Ralf Vogler
6113ea4b8d
Merge branch 'main' into ci 2023-11-07 17:32:00 +01:00
Ralf Vogler
798b130c92 fix vscode problem in jsconfig with module vs. moduleResolution
Option 'module' must be set to 'NodeNext' when option 'moduleResolution' is set to 'NodeNext'.ts
Specify what module code is generated.

See more: https://www.typescriptlang.org/tsconfig#module
2023-11-07 16:45:17 +01:00
Ralf Vogler
e5935faa13 sonarcloud fixes 2023-11-07 15:20:58 +01:00
Ralf Vogler
b5ef699f4f sort .dockerignore 2023-11-07 12:03:15 +01:00
Ralf Vogler
a6b9ec9609 sonarcloud fix reject(error)
https://sonarcloud.io/project/issues?cleanCodeAttributeCategories=CONSISTENT&resolved=false&id=vogler_free-games-claimer&open=AYupZi4O_aoWVkCdISRb&tab=code
2023-11-07 12:02:33 +01:00
Ralf Vogler
a62aa8c0c8 sonarcloud fix docker: Remove cache after installing packages
https://sonarcloud.io/project/issues?resolved=false&types=CODE_SMELL&id=vogler_free-games-claimer&open=AYupZi3__aoWVkCdISRI
https://sonarcloud.io/organizations/vogler/rules?open=docker%3AS6587&rule_key=docker%3AS6587&tab=how_to_fix
https://askubuntu.com/questions/3167/what-is-difference-between-the-options-autoclean-autoremove-and-clean
2023-11-07 11:53:29 +01:00
Ralf Vogler
e192365b48 can't have comment in yml list? 2023-11-07 11:39:08 +01:00
Ralf Vogler
92ce3d405d run docker workflow if its defition changed 2023-11-07 11:36:17 +01:00
Ralf Vogler
584130f5d1 edits for #229, build image for PRs from forks? 2023-11-07 11:31:14 +01:00
Ralf Vogler
5fda138d8d
Merge pull request #229 from 4n4n4s/docker
Allow forks to publish docker images
2023-11-07 11:16:24 +01:00
Ralf Vogler
11db31ea46 Merge branch 'xbox-gold'
https://github.com/vogler/free-games-claimer/pull/160
2023-11-06 19:40:18 +01:00
Ralf Vogler
0ab9935fb5 eg: catch timeout in case there are no free games available, #210 2023-11-06 19:22:43 +01:00
Ralf Vogler
28a1e42cc4 npm package*.json license MIT -> AGPL-3.0-only 2023-11-06 19:15:09 +01:00
Ralf Vogler
32cd0d8990
Create LICENSE - AGPL-3.0
https://choosealicense.com/licenses/agpl-3.0/
2023-11-06 19:12:16 +01:00
Ralf Vogler
b99a154267 Revert "workaround for recordVideo broken in Playwright 1.36"
This reverts commit 13b2917dd0.

Fine to do after upgrade to 1.39 in 75f7d77445.
which included https://github.com/microsoft/playwright/issues/27086
2023-11-02 16:26:45 +01:00
Ralf Vogler
75f7d77445 upgrade playwright-firefox 1.38.1 -> 1.39.0 2023-11-02 16:23:46 +01:00
Ralf Vogler
bf870919a6 upgrade to lowdb 6.1.1 and use JSONPreset
See example in https://github.com/typicode/lowdb/releases/tag/v6.1.0
2023-11-02 16:18:48 +01:00
Ralf Vogler
280ab70975
README.md: recommend to run without docker until #183 is fixed 2023-11-02 16:01:08 +01:00
4n4n4s
1dbe2f1457 Allow forks to create builds and fix failing build 2023-10-27 11:37:32 +00:00
Ralf Vogler
4137bb5569 DEBUG=1 as alternative to PWDEBUG=1 (also shows Playwright debugger) 2023-10-26 15:01:04 +02:00
Ralf Vogler
a374d48345 eg: fix login (email/password split), closes #236 2023-10-25 19:48:58 +02:00
Ralf Vogler
d73a523fe7 eg: fix sign in, user displayname, #236 2023-10-25 19:44:52 +02:00
Ralf Vogler
04787909c7 eg: waitFor order confirmation to be attached instead of visible, #233 2023-10-13 00:03:11 +02:00
Ralf Vogler
ad2301c3fd pg: eg: fix detecting successful claim 2023-10-10 22:42:38 +02:00
Ralf Vogler
a8ab989a7f pg: external: check for 'Link account' besides 'Link game account' 2023-10-10 22:12:59 +02:00
Ralf Vogler
9fc68b881f pg: legacygames: don't wait for response, just text 2023-10-10 22:01:23 +02:00
4n4n4s
0040d9d96c Sonarqube support with ESLint 2023-09-26 19:12:09 +00:00
Ralf Vogler
d318a57be1 ncu -u: playwright-firefox ^1.38.0 → ^1.38.1 2023-09-25 09:35:30 +02:00
Ralf Vogler
9ebd15f204 fix log text if $VNC_PASSWORD is set, fixes #223 2023-09-23 11:02:08 +02:00
Ralf Vogler
41f1f95509 PW: replace deprecated type() with pressSequentially() for OTPs 2023-09-18 14:02:15 +02:00
Ralf Vogler
840d35c2d2 ncu -u: playwright-firefox ^1.37.1 → ^1.38.0 2023-09-18 13:54:40 +02:00
Ralf Vogler
13b2917dd0 workaround for recordVideo broken in Playwright 1.36
Recording videos with relative path as in docs was broken for Firefox.
Issue: https://github.com/microsoft/playwright/issues/27086
Can be reverted after PR is available in release: https://github.com/microsoft/playwright/pull/27099
2023-09-18 13:03:04 +02:00
Ralf Vogler
b748460b73 disable Playwright's handleSIGINT and close context ourselves to save recordings also on SIGINT 2023-09-18 12:58:20 +02:00
Ralf Vogler
1fbabbc0f2 log path of recorded video 2023-09-18 12:49:23 +02:00
dependabot[bot]
2a4da3f49c
build(deps): bump docker/build-push-action from 4 to 5 (#212)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 09:51:53 +02:00
dependabot[bot]
0ba693eeb6
build(deps): bump docker/login-action from 2 to 3 (#214)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 09:51:23 +02:00
dependabot[bot]
6cdef56c09
build(deps): bump docker/setup-buildx-action from 2 to 3 (#213)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 09:50:04 +02:00
dependabot[bot]
30db49d9b9
build(deps): bump docker/setup-qemu-action from 2 to 3 (#215)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 09:49:00 +02:00
dependabot[bot]
44d712e333
build(deps): bump actions/checkout from 3 to 4 (#207)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-11 09:41:42 +02:00
Ralf Vogler
d51f7310d9 gog: fix #200, uncheck 'Marketing communications through Trusted Partners' 2023-09-07 13:56:37 +02:00
Ralf Vogler
e30216c9b8 eg: default resolution 1280x1280 -> 1920x1080, #183 2023-09-07 13:30:49 +02:00
Ralf Vogler
c79f4cc40f docker: migrate to new nodesource repo; node 19 -> 20
old node setup script had a warning to migrate with 60s pause during docker build

https://github.com/nodesource/distributions/wiki/How-to-migrate-to-the-new-repository
2023-09-07 13:27:18 +02:00
Ralf Vogler
97882c76c3 eg: debug: window.screen 2023-09-07 13:25:14 +02:00
Ralf Vogler
b449ee4d7f pg: redeem: legacygames success/error 2023-09-05 16:12:20 +02:00
Ralf Vogler
d4ebdd091a pg: log current game in claim loop, not data loop 2023-09-05 15:59:17 +02:00
Ralf Vogler
d8e2093a0d pg: INTERACTIVE=1 to confirm each claim or skip it 2023-09-05 15:56:24 +02:00
Ralf Vogler
38975e811b eg: error: Incorrect repsonse for captcha! 2023-08-31 23:08:29 +02:00
Ralf Vogler
59004cd4bb README: no docker updates: git pull; npm install 2023-08-31 23:04:42 +02:00
Ralf Vogler
5ae4ce5666 eg: option TIME=1 to log duration of steps, #183 2023-08-31 18:59:10 +02:00
Ralf Vogler
93d082e700 eg: comment userAgent firefox (docker) 2023-08-31 17:05:12 +02:00
Ralf Vogler
621032e459 eg: notify about captcha before claim, closes #191 2023-08-30 19:21:24 +02:00
KevinMatt
6bb1dca934
epic-games: captcha on login: await notify (#195)
Adding async handle in captcha notify
2023-08-28 09:16:30 +02:00
Ralf Vogler
9261be690c '/.dockerenv' did not exist in container... 2023-08-28 01:25:09 +02:00
Ralf Vogler
6560afa2b5 version.js to check if running the latest version 2023-08-28 01:17:57 +02:00
Ralf Vogler
d4d7ee3281 fix indent 2023-08-28 00:47:52 +02:00
Ralf Vogler
c211472d0c docker: try GitHub Actions cache
https://docs.docker.com/build/ci/github-actions/cache/#github-cache
2023-08-27 23:59:30 +02:00
Ralf Vogler
f528fbfd30 docker: more human-readable format for date $NOW 2023-08-27 23:41:19 +02:00
Ralf Vogler
d7949fb9dd can't use secrets in if of workflow step? 2023-08-27 23:39:52 +02:00
Ralf Vogler
8a010dbcc7 docker: pass in build-args, add $NOW 2023-08-27 23:32:56 +02:00
Ralf Vogler
2eb17a1419 docker: show version via $COMMIT_SHA (and $BRANCH if not "main") 2023-08-27 18:33:27 +02:00
Ralf Vogler
24a9f23211
Merge pull request #193 from 4n4n4s/main-1
Update Readme for restart with sleep in docker compose and Apprise with Telegram
2023-08-27 15:44:38 +02:00
Ralf Vogler
682c651224 docker: use OCI labels
https://github.com/opencontainers/image-spec/blob/main/annotations.md
https://docs.docker.com/engine/reference/builder/#label

Before:
```console
$ docker image inspect --format='{{json .Config.Labels}}' ghcr.io/vogler/free-games-claimer | jq
{
  "org.opencontainers.image.ref.name": "ubuntu",
  "org.opencontainers.image.version": "22.04"
}
```

After:
```console
$ docker image inspect --format='{{json .Config.Labels}}' ghcr.io/vogler/free-games-claimer | jq
{
  "org.opencontainers.image.base.name": "ubuntu:jammy",
  "org.opencontainers.image.description": "Automatically claims free games on the Epic Games Store, Amazon Prime Gaming and GOG",
  "org.opencontainers.image.name": "free-games-claimer",
  "org.opencontainers.image.ref.name": "",
  "org.opencontainers.image.revision": "",
  "org.opencontainers.image.source": "https://github.com/vogler/free-games-claimer",
  "org.opencontainers.image.title": "free-games-claimer",
  "org.opencontainers.image.url": "https://github.com/vogler/free-games-claimer",
  "org.opencontainers.image.version": "latest"
}
```
2023-08-26 10:47:18 +02:00
Ralf Vogler
c09d20766c dependabot daily -> weekly, may want to customize commit-message 2023-08-25 21:50:17 +02:00
Ralf Vogler
70b5182bed same as before, just ignore .github/** and then include docker.yml 2023-08-25 21:43:57 +02:00
Ralf Vogler
a7d045f0e2 ignore dependabot.yml in workflows/docker.yml 2023-08-25 21:40:28 +02:00
Ralf Vogler
56d8c2d188
Merge pull request #194 from 4n4n4s/main
Create dependabot.yml
2023-08-25 21:32:06 +02:00
4n4n4s
a3adaae99f Fixed README.md 2023-08-25 16:30:04 +00:00
4n4n4s
66c12b8ea2
Create dependabot.yml 2023-08-25 18:06:31 +02:00
4n4n4s
da23bc2f92
Update README.md
Update documentation
2023-08-25 17:42:52 +02:00
Ralf Vogler
6a7cca31a4 update dependencies via ncu -u, lowdb: pass defaultData
dotenv                          ^16.0.3  →  ^16.3.1
  enquirer                         ^2.3.6  →   ^2.4.1
  lowdb                            ^5.1.0  →   ^6.0.1
  playwright-firefox              ^1.31.0  →  ^1.37.1
  puppeteer-extra-plugin-stealth  ^2.11.1  →  ^2.11.2

https://github.com/typicode/lowdb/releases/tag/v6.0.0
> Require defaultData parameter for Low and LowSync constructors to improve TypeScript experience
2023-08-24 13:14:39 +02:00
Ralf Vogler
3d1168f653 pg: external: split loop for URLs to avoid issue with changed elementHandles (Page.adoptNode) 2023-08-23 00:51:19 +02:00
Ralf Vogler
40bcf1c8a2 pg: status 'claimed' (not just 'claimed and redeemed') for external stores 2023-08-23 00:09:27 +02:00
Ralf Vogler
7551a6ca96 pg: fixup e2e28301e6? 2023-08-19 17:04:33 +02:00
Ralf Vogler
e2e28301e6 pg: fix #185? 2023-08-19 16:35:58 +02:00
Ralf Vogler
e794a3306f pg: dlc: grouped list of dlcs per unlinked store, #180 2023-08-17 17:02:47 +02:00
Ralf Vogler
5c8f8fc3fd pg: dlc: log url of dlc if account not linked, #180 2023-08-17 16:44:59 +02:00
Ralf Vogler
86832e7fe9 pg: dlc: log url of dlc if account not linked, #180 2023-08-17 16:43:47 +02:00
Ralf Vogler
d0761dea26 pg: dlc: account linking: mention store, close #180 2023-08-17 16:37:05 +02:00
Ralf Vogler
490c4c0788 don't ignore .github, only issue templates 2023-08-17 16:31:43 +02:00
Ralf Vogler
13def8ba18 build on every push or PR to main 2023-08-17 16:30:12 +02:00
Ralf Vogler
08fc59520c update docker/build-push-action v3 -> v4 2023-08-17 16:29:08 +02:00
Ralf Vogler
f89b4a6a36 ue: mention new assets to claim every first Tuesday of a month 2023-08-10 14:37:00 +02:00
Ralf Vogler
b5ead8ea21 SCREENSHOTS_DIR=0 to disable screenshots, fixes #172 2023-07-20 16:10:50 +02:00
Ralf Vogler
22f673282b pg: fix a90f90afc0 2023-07-13 11:30:48 +02:00
Ralf Vogler
a90f90afc0 pg: PG_REDEEM: only pause if DEBUG=1
Fixes https://github.com/vogler/free-games-claimer/issues/5#issuecomment-1527063819
2023-07-13 11:28:18 +02:00
Ralf Vogler
2e8a731573 pg: update selectors for internal/external games, fixes #164 2023-07-13 10:56:12 +02:00
Ralf Vogler
04bd56cd77 pg: legacy games: status = claimed and redeemed 2023-07-06 16:48:07 +02:00
Omair
8c6ff57054 xbox: update readme and dockerfile with xbox info/scripts 2023-06-26 15:49:27 -04:00
Omair
a7dcfe72cc xbox: add implementation for xbox games with gold 2023-06-26 15:48:58 -04:00
Omair
acc337a4f9 xbox: add config values 2023-06-26 15:48:21 -04:00
Ralf Vogler
f0e15b5c7c pg: fix #158 - page for gog (and ?) changed (button, store, code)
The claim page for Legacy Games is still unchanged and works with the
old code.
2023-06-23 01:24:06 +02:00
Ralf Vogler
85513031f2 pg: external: fix lost elementHandle error due to navigation
elementHandle.$: Protocol error (Page.adoptNode)
2023-06-23 01:21:42 +02:00
Ralf Vogler
0b9d5d0b63 pg: comments 2023-06-22 17:16:35 +02:00
Ralf Vogler
7e4770b846 ue: notify on 'Price is not 0!' + ask to report, #44 2023-06-02 00:52:11 +02:00
Ralf Vogler
d3d22b1582 ue: wait 2s before checking cart, #44 2023-06-02 00:47:48 +02:00
Ralf Vogler
2ceccdae6b
ue: add as experimental to README.md, #44 2023-06-02 00:30:31 +02:00
Ralf Vogler
f906bef2a8 ue: fix 'Sign In'/user detection, #44 2023-06-02 00:14:21 +02:00
Ralf Vogler
00e213cf50 ue: support RECORD=1 2023-06-02 00:11:10 +02:00
Ralf Vogler
feadfc5acf pg: redeem: gog: status = 'claimed and redeemed' 2023-05-18 18:02:11 +02:00
Ralf Vogler
7fb872608d pg: dlc: try/catch for each dlc, #55 2023-05-18 17:32:02 +02:00
Ralf Vogler
0d074d0397 pg: dlc: scroll to end of page until all are loaded, #55 2023-05-18 16:03:24 +02:00
Ralf Vogler
368229be09 pg: screenshot prep only if needed 2023-05-17 11:35:10 +02:00
Ralf Vogler
8ee63c7a35 pg: support RECORD=1 2023-05-17 11:29:45 +02:00
Ralf Vogler
819e4cc57e pg: PG_REDEEM tested legacy games successfully, #5 2023-05-17 11:28:31 +02:00
Ralf Vogler
e4ebae3744 pg: PG_REDEEM, fixes for gog, #5 2023-05-17 11:15:07 +02:00
Ralf Vogler
240e64ae4c pg: also use elementHandles() for external 2023-05-17 10:20:17 +02:00
Ralf Vogler
98e4145708 Merge branch 'main' into refactor/prime-gaming 2023-05-17 00:59:44 +02:00
Ralf Vogler
60acf747d0 pg: scroll to bottom to load all games
There may be so many unclaimed games that not all of them are loaded
initially.
Also relevant to show the correct number of collected games.
2023-05-17 00:55:58 +02:00
Ralf Vogler
c4eb1e03ce pg: need elementHandles() instead of all() for internal since it changes 2023-05-17 00:41:37 +02:00
Ralf Vogler
590b01aba2 pg: refactor: use more locators & all() instead of $ 2023-05-17 00:11:35 +02:00
Ralf Vogler
290fe289d4 pg: fix #142: PG_CLAIMDLC locator 'a' resolved to 2 elements 2023-05-16 13:31:53 +02:00
Ralf Vogler
491ee899a5 pg: #126 change selector to handle potential button text 'Claim' (instead of 'Claim game') for internal games 2023-05-11 19:00:43 +02:00
Ralf Vogler
af935d4889 gog: no quotes around user, as for {eg, pg} 2023-05-11 18:50:54 +02:00
Ralf Vogler
6b13287b61 eg: click 'Yes, buy now' if 'This edition contains something you already have. Still interested?'
Happened for add-on https://store.epicgames.com/en-US/p/the-sims-4--the-daring-lifestyle-bundle
2023-05-11 18:38:44 +02:00
30 changed files with 6266 additions and 1001 deletions

View file

@ -1,6 +1,10 @@
node_modules node_modules/
data data/
*.env
.gitignore .gitignore
.github/
**Dockerfile** **Dockerfile**
.dockerignore .dockerignore

View file

@ -0,0 +1,87 @@
name: build-and-push
on:
push:
branches:
- main
jobs:
lint:
runs-on: self-hosted
steps:
- 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
steps:
- 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 }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_PROJECT_KEY: ${{ secrets.SONAR_PROJECT_KEY }}
run: |
WORKDIR=${GITHUB_WORKSPACE:-$PWD}
HOST_URL=${SONAR_HOST_URL:?SONAR_HOST_URL secret not set}
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')
fi
if [ -z "$PROJECT_KEY" ]; then
echo "SONAR_PROJECT_KEY secret not set and no sonar-project.properties entry found" >&2
exit 1
fi
echo "Sonar project key: $PROJECT_KEY"
echo "Listing workspace:"
ls -la
echo "Sample files:"
find . -maxdepth 2 -type f | head -n 20
echo "Running local sonar-scanner..."
sonar-scanner \
-Dsonar.host.url="$HOST_URL" \
-Dsonar.token="$SONAR_TOKEN" \
-Dsonar.projectKey="$PROJECT_KEY" \
-Dsonar.sources=. \
-Dsonar.scm.disabled=true \
-Dsonar.projectBaseDir="$WORKDIR"
docker:
needs: [lint, sonar]
runs-on: self-hosted
steps:
- 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 }}:latest" .
- name: Push image
run: |
docker push "${{ secrets.REGISTRY_IMAGE }}:latest"

View file

@ -1,48 +0,0 @@
name: Build and push Docker image (amd64, arm64 to hub.docker.com and ghcr.io)
on:
workflow_dispatch:
push:
branches:
- "main"
paths-ignore:
- ".github/**"
- ".gitignore"
- "README.md"
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64 # ,linux/arm/v7
push: true
tags: |
voglerr/free-games-claimer:latest
ghcr.io/vogler/free-games-claimer:latest

10
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
// https://eslint.style/guide/faq#vs-code
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "modifications",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.experimental.useFlatConfig": true,
"eslint.codeActionsOnSave.rules": null,
}

6
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,6 @@
# Contribute
## Building and publishing docker images
Setup the secrets for DOCKERHUB_USERNAME and [DOCKERHUB_TOKEN](https://hub.docker.com/settings/security) in https://github.com/YOUR_USERNAME/free-games-claimer/settings/secrets/actions to be able to run the docker.yml workflows.
Check if under Workflow Permissions in https://github.com/YOUR_USERNAME/free-games-claimer/settings/actions the radio button is set to "Read and write permissions". In case that's not set the push to ghcr.io will fail.

View file

@ -10,8 +10,11 @@ ARG DEBIAN_FRONTEND=noninteractive
# Install up-to-date node & npm, deps for virtual screen & noVNC, firefox, pip for apprise. # Install up-to-date node & npm, deps for virtual screen & noVNC, firefox, pip for apprise.
RUN apt-get update \ RUN apt-get update \
&& apt-get install --no-install-recommends -y curl ca-certificates \ && apt-get install --no-install-recommends -y curl ca-certificates gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_19.x | bash - \ && mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install --no-install-recommends -y \ && apt-get install --no-install-recommends -y \
nodejs \ nodejs \
xvfb \ xvfb \
@ -20,8 +23,6 @@ RUN apt-get update \
novnc websockify \ novnc websockify \
dos2unix \ dos2unix \
python3-pip \ python3-pip \
# && npx playwright install-deps firefox \
&& apt-get install --no-install-recommends -y \
libgtk-3-0 \ libgtk-3-0 \
libasound2 \ libasound2 \
libxcomposite1 \ libxcomposite1 \
@ -33,47 +34,76 @@ RUN apt-get update \
libgdk-pixbuf-2.0-0 \ libgdk-pixbuf-2.0-0 \
libdbus-glib-1-2 \ libdbus-glib-1-2 \
libxcursor1 \ libxcursor1 \
&& apt-get autoclean -y \ libnss3 \
libnspr4 \
libgbm1 \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean \
&& rm -rf \ && rm -rf \
/tmp/* \ /tmp/* \
/usr/share/doc/* \ /usr/share/doc/* \
/var/cache/* \ /var/cache/* \
/var/lib/apt/lists/* \ /var/lib/apt/lists/* \
/var/tmp/* /var/tmp/* \
&& useradd -ms /bin/bash fgc \
RUN ln -s /usr/share/novnc/vnc_auto.html /usr/share/novnc/index.html && ln -s /usr/share/novnc/vnc_auto.html /usr/share/novnc/index.html \
RUN pip install apprise && pip install apprise
WORKDIR /fgc WORKDIR /fgc
COPY package*.json ./ COPY package*.json ./
# Playwright installs patched firefox to ~/.cache/ms-playwright/firefox-* # Playwright installs patched firefox to ~/.cache/ms-playwright/firefox-*
# Requires some system deps to run (see install-deps above). # Requires some system deps to run (see inlined install-deps above).
RUN npm install RUN npm install
# Old: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD + install firefox (had to be done after `npm install` to get the correct version). Now: playwright-firefox as npm dep and `npm install` will only install that. # Old: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD + install firefox (had to be done after `npm install` to get the correct version). Now: playwright-firefox as npm dep and `npm install` will only install that.
# From 1.38 Playwright will no longer install browser automatically for playwright, but apparently still for playwright-firefox: https://github.com/microsoft/playwright/releases/tag/v1.38.0
# RUN npx playwright install firefox # RUN npx playwright install firefox
COPY . . # Only copy the files we actually need in the image to avoid accidentally adding secrets.
COPY *.js ./
COPY eslint.config.js jsconfig.json sonar-project.properties ./
COPY src ./src
COPY test ./test
COPY docker-entrypoint.sh ./
# Shell scripts need Linux line endings. On Windows, git might be configured to check out dos/CRLF line endings, so we convert them for those people in case they want to build the image. They could also use --config core.autocrlf=input # Shell scripts need Linux line endings. On Windows, git might be configured to check out dos/CRLF line endings, so we convert them for those people in case they want to build the image. They could also use --config core.autocrlf=input
RUN dos2unix *.sh && chmod +x *.sh RUN dos2unix ./*.sh && chmod +x ./*.sh && chown -R fgc:fgc /fgc
COPY docker-entrypoint.sh /usr/local/bin/ COPY docker-entrypoint.sh /usr/local/bin/
ARG COMMIT=""
ARG BRANCH=""
ARG NOW=""
ENV COMMIT=${COMMIT}
ENV BRANCH=${BRANCH}
ENV NOW=${NOW}
LABEL org.opencontainers.image.title="free-games-claimer" \
org.opencontainers.image.name="free-games-claimer" \
org.opencontainers.image.description="Automatically claims free games on the Epic Games Store, Amazon Prime Gaming and GOG" \
org.opencontainers.image.url="https://github.com/vogler/free-games-claimer" \
org.opencontainers.image.source="https://github.com/vogler/free-games-claimer" \
org.opencontainers.image.revision=${COMMIT} \
org.opencontainers.image.ref.name=${BRANCH} \
org.opencontainers.image.base.name="ubuntu:jammy" \
org.opencontainers.image.version="latest"
# Configure VNC via environment variables: # Configure VNC via environment variables:
ENV VNC_PORT 5900 ENV VNC_PORT 5900
ENV NOVNC_PORT 6080 ENV NOVNC_PORT 6080
EXPOSE 5900 # Ports are not exposed by default; publish explicitly with -p when you really need GUI access.
EXPOSE 6080 # EXPOSE 5900
# EXPOSE 6080
# Configure Xvfb via environment variables: # Configure Xvfb via environment variables:
ENV WIDTH 1280 ENV WIDTH 1920
ENV HEIGHT 1280 ENV HEIGHT 1080
ENV DEPTH 24 ENV DEPTH 24
# Show browser instead of running headless # Show browser instead of running headless
ENV SHOW 1 ENV SHOW 1
USER fgc
# Script to setup display server & VNC is always executed. # Script to setup display server & VNC is always executed.
ENTRYPOINT ["docker-entrypoint.sh"] 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. # Default command to run. This is replaced by appending own command, e.g. `docker run ... node prime-gaming` to only run this script.

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

271
README.md
View file

@ -1,207 +1,88 @@
<p align="center"> Free Games Claimer (Fork)
<img alt="logo-free-games-claimer" src="https://user-images.githubusercontent.com/493741/214588518-a4c89998-127e-4a8c-9b1e-ee4a9d075715.png" /> ==========================
</p>
# free-games-claimer [![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)
Claims free games periodically on Automates claiming of free games for:
- <img src="https://upload.wikimedia.org/wikipedia/commons/3/31/Epic_Games_logo.svg" width="32"/> [Epic Games Store](https://www.epicgames.com/store/free-games) - Amazon Luna Gaming / Luna claims (including external stores like GOG, Epic Games, Legacy Games )
- <img src="https://seeklogo.com/images/P/prime-gaming-logo-61A701B3F5-seeklogo.com.png" width="32"/> [Amazon Prime Gaming](https://gaming.amazon.com) - GOG giveaways
- <img src="https://static.wikia.nocookie.net/this-war-of-mine/images/1/1a/Logo_GoG.png/revision/latest?cb=20160711062658" width="32"/> [GOG](https://www.gog.com) - Optional extras: Steam stats, AliExpress dailies (not implemated yet)
- <img src="https://www.freepnglogos.com/uploads/xbox-logo-picture-png-14.png" width="32"/> [Xbox Live Games with Gold](https://www.xbox.com/en-US/live/gold#gameswithgold) - planned
Pull requests welcome :) Requirements
------------
- Docker or Podman (recommended), or Node.js ≥ 20 for local runs
- Optional notifications: `pip install apprise`
- Playwright dependencies are baked into the container; locally, `npm install` downloads Firefox.
![Telegram Screenshot](https://user-images.githubusercontent.com/493741/214667078-eb5c1877-2bdd-40c1-b94e-4a50d6852c06.png) Quickstart (Docker Run)
-----------------------
_Works on Windows/macOS/Linux._
Raspberry Pi (3, 4, Zero 2): [requires 64-bit OS](https://github.com/vogler/free-games-claimer/issues/3) like Raspberry Pi OS or Ubuntu (Raspbian won't work since it's 32-bit).
## How to run
Easy option: [install Docker](https://docs.docker.com/get-docker/) (or [podman](https://podman-desktop.io/)) and run this command in a terminal (Windows: `cmd`, `.bat` file):
``` ```
docker run --rm -it -p 6080:6080 -v fgc:/fgc/data --pull=always ghcr.io/vogler/free-games-claimer docker run --rm -it \
-p 6080:6080 \
-v fgc:/fgc/data \
-e SHOW=1 \
git.sky-net.it/nocci/free-games-claimer:latest \
node prime-gaming.js
``` ```
This will run `node epic-games; node prime-gaming; node gog` - if you only want to claim games for one of the stores, you can override the default command by appending e.g. `node epic-games` at the end of the `docker run` command, or if you want several `bash -c "node epic-games.js; node gog.js"`. - Ports 6080/5900: noVNC/VNC (only needed with `SHOW=1`)
Data (including json files with claimed games, codes to redeem, screenshots) is stored in the Docker volume `fgc`. - Data/configs are stored in volume `fgc` under `/fgc/data`
<details> Docker Compose Example
<summary>I want to run without Docker or develop locally.</summary> ----------------------
```yaml
services:
fgc:
image: git.sky-net.it/nocci/free-games-claimer:latest
container_name: fgc
environment:
- SHOW=1 # show browser via VNC/noVNC
# - PG_EMAIL=...
# - PG_PASSWORD=...
# - PG_OTPKEY=...
volumes:
- fgc:/fgc/data
ports:
- "6080:6080" # noVNC
# - "5900:5900" # VNC optional
command: bash -c "node epic-games; node prime-gaming; node gog"
volumes:
fgc:
```
1. [Install Node.js](https://nodejs.org/en/download) Configuration (Environment Variables)
2. Clone/download this repository and `cd` into it in a terminal -------------------------------------
3. Run `npm install` Common options:
4. Run `pip install apprise` to install [apprise](https://github.com/caronc/apprise) if you want notifications - `SHOW=0/1` (0 = headless, 1 = UI)
- `WIDTH`, `HEIGHT` (browser size)
- `TIMEOUT`, `LOGIN_TIMEOUT` (seconds)
- 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=<days>` to skip long-remaining offers
- Screenshots: `SCREENSHOTS_DIR` (default `data/screenshots`)
- Notifications: `NOTIFY='...'` (Apprise URL), optional `NOTIFY_TITLE`
- Browser profile: `BROWSER_DIR` (default `data/browser`)
- Recording: `RECORD=1` to save videos/HAR to `data/record/`
- Debugging: `DEBUG=1` (opens Playwright inspector), `DEBUG_NETWORK=1` (logs requests), `TIME=1` (prints timings)
- Dry run / Interaction: `DRYRUN=1` (do not claim), `INTERACTIVE=1` (ask before claiming), `HEADLESS` is derived from `SHOW`/`DEBUG`
- 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)
During `npm install` Playwright will download its Firefox to a cache in home ([doc](https://playwright.dev/docs/browsers#managing-browser-binaries)). You can place a `data/config.env`; it is loaded via dotenv and is overridden by explicitly set environment variables.
If you are missing some dependencies for the browser on your system, you can use `sudo npx playwright install firefox --with-deps`.
If you don't want to use Docker for quasi-headless mode, you could run inside a virtual machine, on a server, or you wake your PC at night to avoid being interrupted. Local Run Without Docker
</details> ------------------------
```
npm install
SHOW=0 PG_EMAIL=... PG_PASSWORD=... PG_OTPKEY=... node prime-gaming.js
```
- Playwright downloads Firefox to `~/.cache/ms-playwright`.
- Use `SHOW=1` for a visible browser (requires GUI/Xvfb).
## Usage Persistence & Outputs
All scripts start an automated Firefox instance, either with the browser GUI shown or hidden (*headless mode*). By default, you won't see any browser open on your host system. ---------------------
- Data/status: `data/*.json` (per store)
- Browser profile: `data/browser`
- Screenshots: `data/screenshots/<store>/`
- Optional videos/HAR: `RECORD=1``data/record/`
- When running inside Docker, the browser will be shown only inside the container. You can open http://localhost:6080 to interact with the browser running inside the container via noVNC (or use other VNC clients on port 5900). 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).
- When running the scripts outside of Docker, the browser will be hidden by default; you can use `SHOW=1 ...` to show the UI (see options below).
When running the first time, you have to login for each store you want to claim games on.
You can login indirectly via the terminal or directly in the browser. The scripts will wait until you are successfully logged in.
There will be prompts in the terminal asking you to enter email, password, and afterwards some OTP (one time password / security code) if you have 2FA/MFA (two-/multi-factor authentication) enabled. If you want to login yourself via the browser, you can press escape in the terminal to skip the prompts.
After login, the script will continue claiming the current games. If it still waits after you are already logged in, you can restart it (and open an issue). If you run the scripts regularly, you should not have to login again.
### Configuration / Options
Options are set via [environment variables](https://kinsta.com/knowledgebase/what-is-an-environment-variable/) which allow for flexible configuration.
TODO: ~~On the first run, the script will guide you through configuration and save all settings to `data/config.env`. You can edit this file directly or run `node fgc config` to run the configuration assistant again.~~
Available options/variables and their default values:
| Option | Default | Description |
|--------------- |--------- |------------------------------------------------------------------------ |
| SHOW | 1 | Show browser if 1. Default for Docker, not shown when running outside. |
| WIDTH | 1280 | Width of the opened browser (and of screen for VNC in Docker). |
| HEIGHT | 1280 | Height of the opened browser (and of screen for VNC in Docker). |
| VNC_PASSWORD | | VNC password for Docker. No password used by default! |
| NOTIFY | | Notification services to use (Pushover, Slack, Telegram...), see below. |
| NOTIFY_TITLE | | Optional title for notifications, e.g. for Pushover. |
| BROWSER_DIR | data/browser | Directory for browser profile, e.g. for multiple accounts. |
| TIMEOUT | 60 | Timeout for any page action. Should be fine even on slow machines. |
| LOGIN_TIMEOUT | 180 | Timeout for login in seconds. Will wait twice (prompt + manual login). |
| EMAIL | | Default email for any login. |
| PASSWORD | | Default password for any login. |
| EG_EMAIL | | Epic Games email for login. Overrides EMAIL. |
| EG_PASSWORD | | Epic Games password for login. Overrides PASSWORD. |
| EG_OTPKEY | | Epic Games MFA OTP key. |
| EG_PARENTALPIN | | Epic Games Parental Controls PIN. |
| EG_COUNTRY | US | Epic Games [country of account](https://www.epicgames.com/account/personal). Set to avoid unavailable-in-region. |
| PG_EMAIL | | Prime Gaming email for login. Overrides EMAIL. |
| PG_PASSWORD | | Prime Gaming password for login. Overrides PASSWORD. |
| PG_OTPKEY | | Prime Gaming MFA OTP key. |
| PG_REDEEM | 0 | Prime Gaming: try to redeem keys on external stores ([experimental](https://github.com/vogler/free-games-claimer/issues/5)). |
| PG_CLAIMDLC | 0 | Prime Gaming: try to claim DLCs ([experimental](https://github.com/vogler/free-games-claimer/issues/55)). |
| GOG_EMAIL | | GOG email for login. Overrides EMAIL. |
| GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. |
| GOG_NEWSLETTER | 0 | Do not unsubscribe from newsletter after claiming a game if 1. |
See `config.js` for all options.
#### How to set options
You can add options directly in the command or put them in a file to load.
##### Docker
You can pass variables using `-e VAR=VAL`, for example `docker run -e EMAIL=foo@bar.baz -e NOTIFY='tgram://...' ...` or using `--env-file fgc.env` where `fgc.env` is a file on your host system (see [docs](https://docs.docker.com/engine/reference/commandline/run/#env)). You can also `docker cp` your configuration file to `/fgc/data/config.env` in the `fgc` volume to store it with the rest of the data instead of on the host ([example](https://github.com/moby/moby/issues/25245#issuecomment-365980572)).
If you are using [docker compose](https://docs.docker.com/compose/environment-variables/) (or Portainer etc.), you can put options in the `environment:` section.
##### Without Docker
On Linux/macOS you can prefix the variables you want to set, for example `EMAIL=foo@bar.baz SHOW=1 node epic-games` will show the browser and skip asking you for your login email.
You can also put options in `data/config.env` which will be loaded by [dotenv](https://github.com/motdotla/dotenv).
### Notifications
The scripts will try to send notifications for successfully claimed games and any errors like needing to log in or encountered captchas (should not happen).
[apprise](https://github.com/caronc/apprise) is used for notifications and offers many services including Pushover, Slack, Telegram, SMS, Email, desktop and custom notifications.
You just need to set `NOTIFY` to the notification services you want to use, e.g. `NOTIFY='mailto://myemail:mypass@gmail.com' 'pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b'` - refer to their list of services and [examples](https://github.com/caronc/apprise#command-line-usage).
### Automatic login, two-factor authentication
If you set the options for email, password and OTP key, there will be no prompts and logins should happen automatically. This is optional since all stores should stay logged in since cookies are refreshed.
To get the OTP key, it is easiest to follow the store's guide for adding an authenticator app. You should also scan the shown QR code with your favorite app to have an alternative method for 2FA.
- **Epic Games**: visit [password & security](https://www.epicgames.com/account/password), enable 'third-party authenticator app', copy the 'Manual Entry Key' and use it to set `EG_OTPKEY`.
- **Prime Gaming**: visit Amazon 'Your Account Login & security', 2-step verification Manage Add new app Can't scan the barcode, copy the bold key and use it to set `PG_OTPKEY`
- **GOG**: only offers OTP via email
Beware that storing passwords and OTP keys as clear text may be a security risk. Use a unique/generated password! TODO: maybe at least offer to base64 encode for storage.
### Epic Games Store
Run `node epic-games` (locally or in Docker).
### Amazon Prime Gaming
Run `node prime-gaming` (locally or in Docker).
Claiming the Amazon Games works out-of-the-box, however, for games on external stores you need to either link your account or redeem a key.
- Stores that require account linking: Epic Games, Battle.net, Origin.
- Stores that require redeeming a key: GOG.com, Microsoft Games, Legacy Games.
Keys and URLs are printed to the console, included in notifications and saved in `data/prime-gaming.json`. A screenshot of the page with the key is also saved to `data/screenshots`.
[TODO](https://github.com/vogler/free-games-claimer/issues/5): ~~redeem keys on external stores.~~
### Run periodically
#### How often?
Epic Games usually has two free games *every week*, before Christmas every day.
Prime Gaming has new games *every month* or more often during Prime days.
GOG usually has one new game every couples of weeks.
It is save to run the scripts every day.
#### How to schedule?
The container/scripts will claim currently available games and then exit.
If you want it to run regularly, you have to schedule the runs yourself:
- Linux/macOS: `crontab -e` ([example](https://github.com/vogler/free-games-claimer/discussions/56))
- macOS: [launchd](https://stackoverflow.com/questions/132955/how-do-i-set-a-task-to-run-every-so-often)
- Windows: [task scheduler](https://active-directory-wp.com/docs/Usage/How_to_add_a_cron_job_on_Windows/Scheduled_tasks_and_cron_jobs_on_Windows/index.html), [other options](https://stackoverflow.com/questions/132971/what-is-the-windows-version-of-cron)
- any OS: use a process manager like [pm2](https://pm2.keymetrics.io/docs/usage/restart-strategies/)
TODO: ~~add some server-mode where the script just keeps running and claims games e.g. every day.~~
### Problems?
Check the open [issues](https://github.com/vogler/free-games-claimer/issues) and comment there or open a new issue.
If you're a developer, you can use `PWDEBUG=1 ...` to [inspect](https://playwright.dev/docs/inspector) which opens a debugger where you can step through the script.
## History/DevLog
<details>
<summary>Click to expand</summary>
Tried [epicgames-freebies-claimer](https://github.com/Revadike/epicgames-freebies-claimer), but had problems since epicgames introduced hcaptcha (see [issue](https://github.com/Revadike/epicgames-freebies-claimer/issues/172)).
Played around with puppeteer before, now trying newer https://playwright.dev which is pretty similar.
Playwright Inspector and `codegen` to generate scripts are nice, but failed to generate the right code for clicking a button in an iframe.
Added [main.spec.ts](https://github.com/vogler/epicgames-claimer/commit/e5ce7916ab6329cfc7134677c4d89c2b3fa3ba97#diff-d18d03e9c407a20e05fbf03cbd6f9299857740544fb6b50d6a70b9c6fbc35831) which was the test script generated by `npx playwright codegen` with manual fix for clicking buttons in the created iframe. Can be executed by `npx playwright test`. The test runner has options `--debug` and `--timeout` and can execute typescript which is nice. However, this only worked up to the button 'I Agree', and then showed an hcaptcha.
Added [main.captcha.js](https://github.com/vogler/epicgames-claimer/commit/e5ce7916ab6329cfc7134677c4d89c2b3fa3ba97#diff-d18d03e9c407a20e05fbf03cbd6f9299857740544fb6b50d6a70b9c6fbc35831) which uses beta of `playwright-extra@next` and `@extra/recaptcha@next` (from [comment on puppeteer-extra](https://github.com/berstend/puppeteer-extra/pull/303#issuecomment-775277480)).
However, `playwright-extra` seems to be old and missing `:has-text` selector (fixed [here](https://github.com/vogler/epicgames-claimer/commit/ba97a0e840b65f4476cca18e28d8461b0c703420)) and `page.frameLocator`, so the script did not run without adjustments.
Also, solving via [2captcha](https://2captcha.com?from=13225256) is a paid service which takes time and may be unreliable.
<!-- Alternative: https://anti-captcha.com -->
Added [main.stealth.js](https://github.com/vogler/epicgames-claimer/commit/64d0ba8ce71baec3947d1b64acd567befcb39340#diff-f70d3bd29df4a343f11062a97063953173491ce30fe34f69a0fc52517adbf342) which uses the stealth plugin without `playwright-extra` wrapper but up-to-date `playwright` (from [comment](https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212)).
The listed evasions are enough to not show an hcaptcha. Script claimed game successfully in non-headless mode.
Removed `main.captcha.js`.
Using Playwright Test (`main.spec.ts`) instead of Library (`main.stealth.js`) has the advantage of free CLI like `--debug` and `--timeout`.
<!-- TODO: check if stealth plugin can be setup with `contextOptions` ([doc](https://playwright.dev/docs/test-configuration#more-browser-and-context-options)). -->
Button selectors should preferably use text in order to be more stable against changes in the DOM.
Renamed repository from epicgames-claimer to free-games-claimer since a script for Amazon Prime Gaming was also added. Removed all old scripts in favor of just `epic-games.js` and `prime-gaming.js`.
epic games: `headless` mode gets hcaptcha challenge. More details/references in [issue](https://github.com/vogler/free-games-claimer/issues/2).
https://github.com/vogler/free-games-claimer/pull/11 introduced a Dockerfile for running non-headless inside the container via xvfb which makes it headless for the host running the container.
v1.0 Standalone scripts node epic-games and node prime-gaming using Chromium.
Changed to Firefox for all scripts since Chromium led to captchas. Claiming then also worked in headless mode without Docker.
Added options via env vars, configurable in `data/config.env`.
Added OTP generation via otplib for automatic login, even with 2FA.
Added notifications via [apprise](https://github.com/caronc/apprise).
</details>
[![Star History Chart](https://api.star-history.com/svg?repos=vogler/free-games-claimer&type=Date)](https://star-history.com/#vogler/free-games-claimer&Date)
---
Logo with smaller aspect ratio (for Telegram bot etc.): 👾 - [emojipedia](https://emojipedia.org/alien-monster/)
![logo-fgc](https://user-images.githubusercontent.com/493741/214589922-093d6557-6393-421c-b577-da58ff3671bc.png)

113
aliexpress.js Normal file
View file

@ -0,0 +1,113 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { datetime, filenamify, prompt, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js';
// using https://github.com/apify/fingerprint-suite worked, but has no launchPersistentContext...
// from https://github.com/apify/fingerprint-suite/issues/162
import { FingerprintInjector } from 'fingerprint-injector';
import { FingerprintGenerator } from 'fingerprint-generator';
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
devices: ['mobile'],
operatingSystems: ['android'],
});
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless,
locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL
recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
recordHar: cfg.record ? { path: `data/record/aliexpress-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
userAgent: fingerprint.navigator.userAgent,
viewport: {
width: fingerprint.screen.width,
height: fingerprint.screen.height,
},
extraHTTPHeaders: {
'accept-language': headers['accept-language'],
},
});
handleSIGINT(context);
await new FingerprintInjector().attachFingerprintToPlaywright(context, { fingerprint, headers });
context.setDefaultTimeout(cfg.debug ? 0 : cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
const auth = async url => {
console.log('auth', url);
await page.goto(url, { waitUntil: 'domcontentloaded' });
// redirects to https://login.aliexpress.com/?return_url=https%3A%2F%2Fwww.aliexpress.com%2Fp%2Fcoin-pc-index%2Findex.html
await Promise.any([page.waitForURL(url => url.includes('login.aliexpress.com')).then(async () => {
console.error('Not logged in! Will wait for 120s for you to login...');
page.locator('span:has-text("Switch account")').click().catch(() => {}); // sometimes no longer logged in, but previous user/email is pre-selected -> in this case we want to go back to the classic login
const login = page.locator('.login-container');
const email = cfg.ae_email || await prompt({ message: 'Enter email' });
const emailInput = login.locator('input[label="Email or phone number"]');
await emailInput.fill(email);
await emailInput.blur(); // otherwise Continue button stays disabled
const continueButton = login.locator('button:has-text("Continue")');
await continueButton.click({ force: true }); // normal click waits for button to no longer be covered by their suggestion menu, so we have to force click somewhere for the menu to close and then click
await continueButton.click();
const password = email && (cfg.ae_password || await prompt({ type: 'password', message: 'Enter password' }));
await login.locator('input[label="Password"]').fill(password);
await login.locator('button:has-text("Sign in")').click();
const error = login.locator('.error-text');
error.waitFor().then(async () => console.error('Login error:', await error.innerText()));
await page.waitForURL(url);
page.getByRole('button', { name: 'Accept cookies' }).click().then(() => console.log('Accepted cookies')).catch(() => { });
}), page.locator('#nav-user-account').waitFor()]).catch(() => {});
};
// copied URLs from AliExpress app on tablet which has menu for the used webview
const urls = {
// works with desktop view, but stuck at 100% loading in mobile view:
coins: 'https://www.aliexpress.com/p/coin-pc-index/index.html',
// only work with mobile view:
grow: 'https://m.aliexpress.com/p/ae_fruit/index.html', // firefox: stuck at 60% loading, chrome: loads, but canvas
gogo: 'https://m.aliexpress.com/p/gogo-match-cc/index.html', // closes firefox?!
// only show notification to install the app
euro: 'https://m.aliexpress.com/p/european-cup/index.html', // doesn't load
merge: 'https://m.aliexpress.com/p/merge-market/index.html',
};
/* eslint-disable no-unused-vars */
const coins = async () => {
await Promise.any([page.locator('.checkin-button').click(), page.locator('.addcoin').waitFor()]);
console.log('Coins:', await page.locator('.mycoin-content-right-money').innerText());
console.log('Streak:', await page.locator('.title-box').innerText());
console.log('Tomorrow:', await page.locator('.addcoin').innerText());
};
const grow = async () => {
await page.pause();
};
const gogo = async () => {
await page.pause();
};
const euro = async () => {
await page.pause();
};
const merge = async () => {
await page.pause();
};
/* eslint-enable no-unused-vars */
try {
await [
merge,
].reduce((a, f) => a.then(async () => {
await auth(urls[f.name]);
await f();
console.log();
}), Promise.resolve());
} catch (error) {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
}
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close();

View file

@ -1,18 +1,52 @@
#!/bin/bash #!/usr/bin/env bash
set -eo pipefail # exit on error, error on any fail in pipe (not just last cmd); add -x to print each cmd; see gist bash_strict_mode.md set -eo pipefail # exit on error, error on any fail in pipe (not just last cmd); add -x to print each cmd; see gist bash_strict_mode.md
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.
if [ "$(id -u)" -eq 0 ]; then
chown -R 1000:1000 /fgc/data 2>/dev/null || true
fi
# Remove chromium profile lock. # Remove chromium profile lock.
# When running in docker and then killing it, on the next run chromium displayed a dialog to unlock the profile which made the script time out. # When running in docker and then killing it, on the next run chromium displayed a dialog to unlock the profile which made the script time out.
# Maybe due to changed hostname of container or due to how the docker container kills playwright - didn't check. # Maybe due to changed hostname of container or due to how the docker container kills playwright - didn't check.
# https://bugs.chromium.org/p/chromium/issues/detail?id=367048 # https://bugs.chromium.org/p/chromium/issues/detail?id=367048
rm -f /fgc/data/browser/SingletonLock rm -f /fgc/data/browser/SingletonLock 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 /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: /fgc/data/browser not writable; skipping user.js creation."
fi
# TODO disable session restore message?
# Remove X server display lock, fix for `docker compose up` which reuses container which made it fail after initial run, https://github.com/vogler/free-games-claimer/issues/31 # Remove X server display lock, fix for `docker compose up` which reuses container which made it fail after initial run, https://github.com/vogler/free-games-claimer/issues/31
# echo $DISPLAY # echo $DISPLAY
# ls -l /tmp/.X11-unix/ # ls -l /tmp/.X11-unix/
rm -f /tmp/.X1-lock rm -f /tmp/.X1-lock
# Ensure X11 socket dir exists with sane ownership/permissions.
mkdir -p /tmp/.X11-unix
if [ "$(stat -c %U /tmp/.X11-unix 2>/dev/null)" != "root" ]; then
chown root:root /tmp/.X11-unix 2>/dev/null || chmod 1777 /tmp/.X11-unix
fi
# 6000+SERVERNUM is the TCP port Xvfb is listening on: # 6000+SERVERNUM is the TCP port Xvfb is listening on:
# SERVERNUM=$(echo "$DISPLAY" | sed 's/:\([0-9][0-9]*\).*/\1/') # SERVERNUM=$(echo "$DISPLAY" | sed 's/:\([0-9][0-9]*\).*/\1/')
@ -23,11 +57,23 @@ rm -f /tmp/.X1-lock
export DISPLAY=:1 # need to export this, otherwise playwright complains with 'Looks like you launched a headed browser without having a XServer running.' export DISPLAY=:1 # need to export this, otherwise playwright complains with 'Looks like you launched a headed browser without having a XServer running.'
Xvfb $DISPLAY -ac -screen 0 "${WIDTH}x${HEIGHT}x${DEPTH}" & Xvfb $DISPLAY -ac -screen 0 "${WIDTH}x${HEIGHT}x${DEPTH}" &
echo "Xvfb display server created screen with resolution ${WIDTH}x${HEIGHT}" echo "Xvfb display server created screen with resolution ${WIDTH}x${HEIGHT}"
pw="-nopw" if [ -z "$VNC_PASSWORD" ]; then
[ -z "$VNC_PASSWORD" ] || pw="-passwd $VNC_PASSWORD" pw="-nopw"
pwt="no password!"
else
pw="-passwd $VNC_PASSWORD"
pwt="with password"
fi
x11vnc -display $DISPLAY -forever -shared -rfbport $VNC_PORT -bg $pw 2>/dev/null 1>&2 x11vnc -display $DISPLAY -forever -shared -rfbport $VNC_PORT -bg $pw 2>/dev/null 1>&2
echo "VNC is running on port $VNC_PORT (no password!)" echo "VNC is running on port $VNC_PORT ($pwt)"
websockify -D --web "/usr/share/novnc/" $NOVNC_PORT "localhost:$VNC_PORT" 2>/dev/null 1>&2 & websockify -D --web "/usr/share/novnc/" $NOVNC_PORT "localhost:$VNC_PORT" 2>/dev/null 1>&2 &
echo "noVNC (VNC via browser) is running on http://localhost:$NOVNC_PORT" echo "noVNC (VNC via browser) is running on http://localhost:$NOVNC_PORT"
echo echo
# Ensure Playwright browsers are available (chromium + firefox).
if [ ! -x /home/fgc/.cache/ms-playwright/firefox-1482/firefox/firefox ] || ! ls /home/fgc/.cache/ms-playwright/chromium-*/*/chrome >/dev/null 2>&1; then
echo "Playwright browsers missing; installing..."
npx playwright install chromium firefox || echo "Warning: failed to install Playwright browsers" >&2
fi
exec tini -g -- "$@" # https://github.com/krallin/tini/issues/8 node/playwright respond to signals like ctrl-c, but unsure about zombie processes exec tini -g -- "$@" # https://github.com/krallin/tini/issues/8 node/playwright respond to signals like ctrl-c, but unsure about zombie processes

View file

@ -1,65 +1,66 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib'; import { authenticator } from 'otplib';
import path from 'path'; import chalk from 'chalk';
import { existsSync, writeFileSync } from 'fs'; import path from 'node:path';
import { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js'; import { existsSync, writeFileSync, appendFileSync } from 'node:fs';
import { cfg } from './config.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'epic-games', ...a);
const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; 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; const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM;
console.log(datetime(), 'started checking epic-games'); console.log(datetime(), 'started checking epic-games');
const db = await jsonDb('epic-games.json'); const db = await jsonDb('epic-games.json', {});
db.data ||= {};
handleSIGINT(); if (cfg.time) console.time('startup');
// get current promotionalOffers from json instead of checking the website const browserPrefs = path.join(cfg.dir.browser, 'prefs.js');
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // otherwise got UNABLE_TO_GET_ISSUER_CERT_LOCALLY if (existsSync(browserPrefs)) {
const promoJson = await (await fetch(`https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?country=${cfg.eg_country}`)).json(); // ?locale=en-US console.log('Adding webgl.disabled to', browserPrefs);
const currentGames = promoJson.data.Catalog.searchStore.elements.filter(e => e.promotions?.promotionalOffers?.length); appendFileSync(browserPrefs, 'user_pref("webgl.disabled", true);'); // apparently Firefox removes duplicates (and sorts), so no problem appending every time
const gameURL = e => `https://store.epicgames.com/p/${e.productSlug || e.offerMappings[0].pageSlug}`; // e.urlSlug may be wrong and lead to 404, e.catalogNs.mappings[0].pageSlug leads to base game for add-ons! } else {
console.log('Free games:', currentGames.map(e => `${e.title} - ${gameURL(e)}`)); console.log(browserPrefs, 'does not exist yet, will patch it on next run. Restart the script if you get a captcha.');
}
// TODO check if there are new games to claim before launching browser? https://github.com/vogler/free-games-claimer/issues/29
// Options:
// 1. Check order history (https://www.epicgames.com/account/v2/payment/ajaxGetOrderHistory) - only contains the last 10 orders
// 2. Check epic-games.json - would need to know the logged in user for `cfg.dir.browser`
// However, this may not always speed up the process since a game may have already been claimed before.
// https://www.nopecha.com extension source from https://github.com/NopeCHA/NopeCHA/releases/tag/0.1.16
// const ext = path.resolve('nopecha'); // used in Chromium, currently not needed in Firefox
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
// chrome will not work in linux arm64, only chromium
// channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated? userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0', // Windows UA avoids "device not supported"; update when browser version changes
// userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0 locale: 'en-US', // ignore OS locale to be sure to have english text for locators
locale: "en-US", // ignore OS locale to be sure to have english text for locators
recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800 recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools recordHar: cfg.record ? { path: `data/record/eg-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
args: [ // https://peter.sh/experiments/chromium-command-line-switches handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
// don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.' // user settings for firefox have to be put in $BROWSER_DIR/user.js
// '--restore-last-session', // does not apply for crash/killed args: [], // https://wiki.mozilla.org/Firefox/CommandLineOptions
'--hide-crash-restore-bubble',
// `--disable-extensions-except=${ext}`,
// `--load-extension=${ext}`,
],
// ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
}); });
handleSIGINT(context);
// 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. // 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); await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); await page.setViewportSize({ width: cfg.width, height: cfg.height }); // workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it
if (cfg.record && cfg.debug) {
// 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 {
screen: { width, height, availWidth, availHeight },
userAgent: navigator.userAgent,
};
});
console.debug(debugInfo);
}
if (cfg.debug_network) {
// const filter = _ => true; // const filter = _ => true;
const filter = r => r.url().includes('store.epicgames.com'); const filter = r => r.url().includes('store.epicgames.com');
page.on('request', request => filter(request) && console.log('>>', request.method(), request.url())); page.on('request', request => filter(request) && console.log('>>', request.method(), request.url()));
@ -70,40 +71,25 @@ const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5*24*60*60*1000).toISOString(), domain: '.epicgames.com', path: '/'}]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago. await context.addCookies([
{ name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }, // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago.
{ name: 'HasAcceptedAgeGates', value: 'USK:9007199254740991,general:18,EPIC SUGGESTED RATING:18', domain: 'store.epicgames.com', path: '/' }, // gets rid of 'To continue, please provide your date of birth', https://github.com/vogler/free-games-claimer/issues/275, USK number doesn't seem to matter, cookie from 'Fallout 3: Game of the Year Edition'
]);
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
// page.click('button:has-text("Accept All Cookies")').catch(_ => { }); // Not needed anymore since we set the cookie above. Clicking this did not always work since the message was animated in too slowly. if (cfg.time) console.timeEnd('startup');
if (cfg.time) console.time('login');
while (await page.locator('a[role="button"]:has-text("Sign In")').count() > 0) { while (await page.locator('egs-navigation').getAttribute('isloggedin') != 'true') {
console.error('Not signed in anymore. 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.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`);
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
console.info(`Login timeout is ${cfg.login_timeout/1000} seconds!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' });
if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.');
else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).');
const email = cfg.eg_email || await prompt({message: 'Enter email'}); const notifyBrowserLogin = async () => {
const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'}));
if (email && password) {
await page.click('text=Sign in with Epic Games');
await page.fill('#email', email);
await page.fill('#password', password);
await page.click('button[type="submit"]');
page.waitForSelector('#h_captcha_challenge_login_prod iframe').then(() => {
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.');
notify('epic-games: got captcha during login. Please check.');
}).catch(_ => { });
// handle MFA, but don't await it
page.waitForURL('**/id/login/mfa**').then(async () => {
console.log('Enter the security code to continue - This appears to be a new device, browser or location. A security code has been sent to your email address at ...');
// TODO locator for text (email or app?)
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.type('input[name="code-input-0"]', otp.toString());
await page.click('button[type="submit"]');
}).catch(_ => { });
} else {
console.log('Waiting for you to login in the browser.'); 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.'); await notify('epic-games: no longer signed in and not enough options set for automatic login.');
if (cfg.headless) { if (cfg.headless) {
@ -111,124 +97,233 @@ try {
await context.close(); // finishes potential recording await context.close(); // finishes potential recording
process.exit(1); process.exit(1);
} }
} };
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 {
await error.waitFor({ timeout: 15000 });
console.error('Login error:', await error.innerText());
console.log('Please login in the browser!');
} catch {
return;
}
};
const watchMfaStep = async () => {
try {
await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout });
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); await page.waitForURL(URL_CLAIM);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
} }
user = await page.locator('#user span').first().innerHTML(); user = await page.locator('egs-navigation').getAttribute('displayname'); // 'null' if !isloggedin
console.log(`Signed in as ${user}`); console.log(`Signed in as ${user}`);
db.data[user] ||= {}; db.data[user] ||= {};
if (cfg.time) console.timeEnd('login');
if (cfg.time) console.time('claim all games');
// This URL will order all free games, but it will fail if some games have already been claimed: // Detect free games
// const purchaseURL = 'https://store.epicgames.com/purchase?' + currentGames.map(e => `offers=1-${e.namespace}-${e.id}`).join('&'); const game_loc = page.locator('a:has(span:text-is("Free Now"))');
for (const game of currentGames) { await game_loc.last().waitFor().catch(_ => {
const url = gameURL(game); // 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);
for (const url of urls) {
if (cfg.time) console.time('claim game');
await page.goto(url); // , { waitUntil: 'domcontentloaded' }); await page.goto(url); // , { waitUntil: 'domcontentloaded' });
const btnText = await page.locator('//button[@data-testid="purchase-cta-button"][not(contains(.,"Loading"))]').first().innerText(); // barrier to block until page is loaded const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"] >> :has-text("e"), :has-text("i")').first(); // when loading, the button text is empty -> need to wait for some text {'get', 'in library', 'requires base game'} -> just wait for e or i to not be too specific; :text-matches("\w+") somehow didn't work - https://github.com/vogler/free-games-claimer/issues/375
await purchaseBtn.waitFor();
const btnText = (await purchaseBtn.innerText()).toLowerCase(); // barrier to block until page is loaded
// click Continue if 'This game contains mature content recommended only for ages 18+' // click Continue if 'This game contains mature content recommended only for ages 18+'
if (await page.locator('button:has-text("Continue")').count() > 0) { if (await page.locator('button:has-text("Continue")').count() > 0) {
console.log(' This game contains mature content recommended only for ages 18+'); console.log(' This game contains mature content recommended only for ages 18+');
if (await page.locator('[data-testid="AgeSelect"]').count()) {
console.error(' Got "To continue, please provide your date of birth" - This shouldn\'t happen due to cookie set above. Please report to https://github.com/vogler/free-games-claimer/issues/275');
await page.locator('#month_toggle').click();
await page.locator('#month_menu li:has-text("01")').click();
await page.locator('#day_toggle').click();
await page.locator('#day_menu li:has-text("01")').click();
await page.locator('#year_toggle').click();
await page.locator('#year_menu li:has-text("1987")').click();
}
await page.click('button:has-text("Continue")', { delay: 111 }); await page.click('button:has-text("Continue")', { delay: 111 });
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
} }
// const title = await page.locator('h1').first().innerText(); let title;
const title = game.title; let bundle_includes;
if (await page.locator('span:text-is("About Bundle")').count()) {
title = (await page.locator('span:has-text("Buy"):left-of([data-testid="purchase-cta-button"])').first().innerText()).replace('Buy ', '');
// h1 first didn't exist for bundles but now it does... However h1 would e.g. be 'Fallout® Classic Collection' instead of 'Fallout Classic Collection'
try {
bundle_includes = await Promise.all((await page.locator('.product-card-top-row h5').all()).map(b => b.innerText()));
} catch (e) {
console.error('Failed to get "Bundle Includes":', e);
}
} else {
title = await page.locator('h1').first().innerText();
}
const game_id = page.url().split('/').pop(); const game_id = page.url().split('/').pop();
const existedInDb = db.data[user][game_id];
db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only! db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only!
console.log('Current free game:', title); console.log('Current free game:', chalk.blue(title));
if (bundle_includes) console.log(' This bundle includes:', bundle_includes);
const notify_game = { title, url, status: 'failed' }; const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
const p = path.resolve(cfg.dir.screenshots, 'epic-games', `${game_id}.png`); if (btnText == 'in library') {
if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
if (btnText.toLowerCase() == 'in library') {
console.log(' Already in library! Nothing to claim.'); console.log(' Already in library! Nothing to claim.');
if (!existedInDb) await notify(`Game already in library: ${url}`);
notify_game.status = 'existed'; notify_game.status = 'existed';
db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed
if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed if (db.data[user][game_id].status.startsWith('failed')) db.data[user][game_id].status = 'manual'; // was failed but now it's claimed
} else if (btnText.toLowerCase() == 'requires base game') { } else if (btnText == 'requires base game') {
console.log(' Requires base game! Nothing to claim.'); console.log(' Requires base game! Nothing to claim.');
notify_game.status = 'requires base game'; notify_game.status = 'requires base game';
db.data[user][game_id].status ||= 'failed:requires-base-game'; db.data[user][game_id].status ||= 'failed:requires-base-game';
// TODO claim base game if it is free // 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'); const baseUrl = 'https://store.epicgames.com' + await page.locator('a:has-text("Overview")').getAttribute('href');
console.log(' Base game:', baseUrl); console.log(' Base game:', baseUrl);
// await page.click('a:has-text("Overview")'); // await page.click('a:has-text("Overview")');
// 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 } else { // GET
console.log(' Not in library yet! Claim!'); console.log(' Not in library yet! Click', btnText);
// go to purchase of unclaimed game - https://github.com/vogler/free-games-claimer/issues/127 await purchaseBtn.click({ delay: 11 }); // got stuck here without delay (or mouse move), see #75, 1ms was also enough
const purchaseURL = `https://store.epicgames.com/purchase?offers=1-${game.namespace}-${game.id}`;
console.log(' purchaseURL:', purchaseURL);
await page.goto(purchaseURL);
// click Continue if 'Device not supported. This product is not compatible with your current device.' - avoided by Windows userAgent?
page.click('button:has-text("Continue")').catch(_ => { }); // needed since change from Chromium to Firefox?
// Accept End User License Agreement (only needed once) // Accept End User License Agreement (only needed once)
page.locator('input#agree').waitFor().then(async () => { const acceptEulaIfShown = async () => {
console.log('Accept End User License Agreement (only needed once)'); try {
await page.locator('input#agree').check(); await page.locator(':has-text("end user license agreement")').waitFor({ timeout: 10000 });
await page.locator('button:has-text("Accept")').click(); console.log(' Accept End User License Agreement (only needed once)');
}).catch(_ => { }); await page.locator('input#agree').check();
await page.locator('button:has-text("Accept")').click();
} catch {
return;
}
};
acceptEulaIfShown();
// skip game if unavailable in region, https://github.com/vogler/free-games-claimer/issues/46 TODO check games for account's region // it then creates an iframe for the purchase
if (await page.locator(':has-text("unavailable in your region")').count() > 0) { 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!'); console.error(' This product is unavailable in your region!');
db.data[user][game_id].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; continue;
} }
page.locator('.payment-pin-code').waitFor().then(async () => { const enterParentalPinIfNeeded = async () => {
if (!cfg.eg_parentalpin) { try {
console.error(' EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 });
notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.'); if (!cfg.eg_parentalpin) {
console.error(' EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.');
notify('epic-games: EG_PARENTALPIN not set. Need to enter Parental Control PIN manually.');
}
await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin);
await iframe.locator('button:has-text("Continue")').click({ delay: 11 });
} catch {
return;
} }
await page.locator('input.payment-pin-code__input').first().type(cfg.eg_parentalpin); };
await page.locator('button:has-text("Continue")').click({ delay: 11 }); enterParentalPinIfNeeded();
}).catch(_ => { });
if (cfg.debug) await page.pause(); if (cfg.debug) await page.pause();
if (cfg.dryrun) { if (cfg.dryrun) {
console.log(' DRYRUN=1 -> Skip order!'); console.log(' DRYRUN=1 -> Skip order!');
notify_game.status = 'skipped'; notify_game.status = 'skipped';
if (cfg.time) console.timeEnd('claim game');
continue; continue;
} }
// After successful order using the `purchaseURL`-method, the page is just empty, without any 'Thanks for your order', so we wait for the response of their API. Note: no await, and start waiting before final click to 'Place Order'.
const r = page.waitForResponse(r => r.url().startsWith('https://payment-website-pci.ol.epicgames.com/purchase/confirm-order'));
// Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591 // Playwright clicked before button was ready to handle event, https://github.com/vogler/free-games-claimer/issues/84#issuecomment-1474346591
await page.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 }); await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 });
// I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872
const btnAgree = page.locator('button:has-text("I Agree")'); const btnAgree = iframe.locator('button:has-text("I Accept")');
btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree' const acceptIfRequired = async () => {
try {
// May fail if game is already claimed with text 'Sorry, there is an error with your cart and we cannot complete the purchase. Please close this window and check your cart list.' await btnAgree.waitFor({ timeout: 10000 });
await btnAgree.click();
} catch {
return;
}
}; // EU: wait for and click 'I Agree'
acceptIfRequired();
try { try {
// context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s?
const captcha = page.locator('#h_captcha_challenge_checkout_free_prod iframe'); const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
captcha.waitFor().then(async () => { // don't await, since element may not be shown const watchCaptchaChallenge = async () => {
// console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.') try {
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 captcha.waitFor({ timeout: 10000 });
// await page.waitForTimeout(2000); 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.');
// const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'captcha', `${filenamify(datetime())}.png`); await notify(`epic-games: got captcha challenge for.\nGame link: ${url}`);
// await captcha.screenshot({ path: p }); } catch {
// console.info(' Saved a screenshot of hcaptcha challenge to', p); return;
// console.error(' Got hcaptcha challenge. To avoid it, get a link from https://www.hcaptcha.com/accessibility'); // TODO save this link in config and visit it daily to set accessibility cookie to avoid captcha challenge? }
}).catch(_ => { }); // may time out if not shown }; // may time out if not shown
// await page.waitForSelector('text=Thanks for your order!'); // not shown for order via `purchaseURL` const watchCaptchaFailure = async () => {
const rt = await (await r).text(); // TODO blocks if not claimed? try {
const rj = JSON.parse(rt); await iframe.locator('.payment__errors:has-text("Failed to challenge captcha, please try again later.")').waitFor({ timeout: 10000 });
if (rj?.receiptResponse?.orderStatus != 'COMPLETED') { console.error(' Failed to challenge captcha, please try again later.');
console.error('Unexpected confirm-order response. Message:', rj.message); await notify('epic-games: failed to challenge captcha. Please check.');
console.log(rj); } catch {
continue; 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].status = 'claimed';
db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time
console.log(' Claimed successfully!'); console.log(' Claimed successfully!');
@ -237,18 +332,23 @@ try {
console.log(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! 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.'); console.error(' Failed to claim! To avoid captchas try to get a new IP address.');
const p = path.resolve(cfg.dir.screenshots, 'epic-games', 'failed', `${game_id}_${filenamify(datetime())}.png`); const p = screenshot('failed', `${game_id}_${filenamify(datetime())}.png`);
await page.screenshot({ path: p, fullPage: true }); await page.screenshot({ path: p, fullPage: true });
db.data[user][game_id].status = 'failed'; db.data[user][game_id].status = 'failed';
} }
notify_game.status = db.data[user][game_id].status; // claimed or failed notify_game.status = db.data[user][game_id].status; // claimed or failed
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) { } catch (error) {
console.error(error); // .toString()?
process.exitCode ||= 1; process.exitCode ||= 1;
if (error.message && process.exitCode != 130) console.error('--- Exception:');
notify(`epic-games failed: ${error.message.split('\n')[0]}`); console.error(error); // .toString()?
if (error.message && process.exitCode != 130) notify(`epic-games failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { // don't notify if all have status 'existed', 'manual', 'requires base game', 'unavailable-in-region', 'skipped' if (notify_games.filter(g => g.status == 'claimed' || g.status == 'failed').length) { // don't notify if all have status 'existed', 'manual', 'requires base game', 'unavailable-in-region', 'skipped'
@ -256,4 +356,5 @@ try {
} }
} }
if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies())); if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies()));
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close(); await context.close();

76
eslint.config.js Normal file
View file

@ -0,0 +1,76 @@
// https://eslint.org/docs/latest/use/configure/configuration-files-new
// https://eslint.org/docs/latest/use/configure/migration-guide
import js from '@eslint/js';
import globals from 'globals';
import stylistic from '@stylistic/eslint-plugin-js';
export default [
// https://eslint.org/docs/latest/use/configure/configuration-files-new#globally-ignoring-files-with-ignores
// object with just `ignores` applies to all configuration objects
// had `ln -s .gitignore .eslintignore` before, but .eslintignore no longer supported
{
ignores: ['data/**'],
},
js.configs.recommended,
{
// files: ['*.js'],
languageOptions: {
globals: globals.node,
},
plugins: {
'@stylistic/js': stylistic,
},
// https://eslint.org/docs/latest/rules/
// https://eslint.style/packages/js
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prefer-const': 'error',
'@stylistic/js/array-bracket-newline': ['error', 'consistent'],
'@stylistic/js/array-bracket-spacing': 'error',
'@stylistic/js/array-element-newline': ['error', 'consistent'],
'@stylistic/js/arrow-parens': ['error', 'as-needed'],
'@stylistic/js/arrow-spacing': 'error',
'@stylistic/js/block-spacing': 'error',
'@stylistic/js/brace-style': 'error',
'@stylistic/js/comma-dangle': ['error', 'always-multiline'],
'@stylistic/js/comma-spacing': 'error',
'@stylistic/js/comma-style': 'error',
'@stylistic/js/eol-last': 'error',
'@stylistic/js/func-call-spacing': 'error',
'@stylistic/js/function-paren-newline': ['error', 'consistent'],
'@stylistic/js/implicit-arrow-linebreak': 'error',
'@stylistic/js/indent': ['error', 2],
'@stylistic/js/key-spacing': 'error',
'@stylistic/js/keyword-spacing': 'error',
'@stylistic/js/linebreak-style': 'error',
'@stylistic/js/no-extra-parens': 'error',
'@stylistic/js/no-extra-semi': 'error',
'@stylistic/js/no-mixed-spaces-and-tabs': 'error',
'@stylistic/js/no-multi-spaces': 'error',
'@stylistic/js/no-multiple-empty-lines': 'error',
'@stylistic/js/no-tabs': 'error',
'@stylistic/js/no-trailing-spaces': 'error',
'@stylistic/js/no-whitespace-before-property': 'error',
'@stylistic/js/nonblock-statement-body-position': 'error',
'@stylistic/js/object-curly-newline': 'error',
'@stylistic/js/object-curly-spacing': ['error', 'always'],
'@stylistic/js/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
'@stylistic/js/quote-props': ['error', 'as-needed'],
'@stylistic/js/quotes': ['error', 'single'],
'@stylistic/js/rest-spread-spacing': 'error',
'@stylistic/js/semi': 'error',
'@stylistic/js/semi-spacing': 'error',
'@stylistic/js/semi-style': 'error',
'@stylistic/js/space-before-blocks': 'error',
'@stylistic/js/space-before-function-paren': ['error', { anonymous: 'never', named: 'never', asyncArrow: 'always' }],
'@stylistic/js/space-in-parens': 'error',
'@stylistic/js/space-infix-ops': 'error',
'@stylistic/js/space-unary-ops': 'error',
'@stylistic/js/spaced-comment': 'error',
'@stylistic/js/switch-colon-spacing': 'error',
'@stylistic/js/template-curly-spacing': 'error',
'@stylistic/js/template-tag-spacing': 'error',
'@stylistic/js/wrap-regex': 'error',
},
},
];

112
gog.js
View file

@ -1,76 +1,90 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import path from 'path'; import chalk from 'chalk';
import { jsonDb, datetime, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js'; import { resolve, jsonDb, datetime, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './config.js'; import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'gog', ...a);
const URL_CLAIM = 'https://www.gog.com/en'; const URL_CLAIM = 'https://www.gog.com/en';
console.log(datetime(), 'started checking gog'); console.log(datetime(), 'started checking gog');
const db = await jsonDb('gog.json'); const db = await jsonDb('gog.json', {});
db.data ||= {};
handleSIGINT(); if (cfg.width < 1280) { // otherwise 'Sign in' and #menuUsername are hidden (but attached to DOM), see https://github.com/vogler/free-games-claimer/issues/335
console.error(`Window width is set to ${cfg.width} but needs to be at least 1280 for GOG!`);
process.exit(1);
}
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
locale: "en-US", // ignore OS locale to be sure to have english text for locators -> done via /en in URL locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL
// recordHar: { path: './data/gog.har' }, // https://toolbox.googleapps.com/apps/har_analyzer/ recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
// recordVideo: { dir: './data/videos' }, // console.log(await page.video().path()); recordHar: cfg.record ? { path: `data/record/gog-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
}); });
handleSIGINT(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); await page.setViewportSize({ width: cfg.width, height: cfg.height }); // workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it
const notify_games = []; const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{name: 'CookieConsent', value: '{stamp:%274oR8MJL+bxVlG6g+kl2we5+suMJ+Tv7I4C5d4k+YY4vrnhCD+P23RQ==%27%2Cnecessary:true%2Cpreferences:true%2Cstatistics:true%2Cmarketing:true%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1672331618201%2Cregion:%27de%27}', domain: 'www.gog.com', path: '/'}]); // to not waste screen space when non-headless await context.addCookies([{ name: 'CookieConsent', value: '{stamp:%274oR8MJL+bxVlG6g+kl2we5+suMJ+Tv7I4C5d4k+YY4vrnhCD+P23RQ==%27%2Cnecessary:true%2Cpreferences:true%2Cstatistics:true%2Cmarketing:true%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1672331618201%2Cregion:%27de%27}', domain: 'www.gog.com', path: '/' }]); // to not waste screen space when non-headless
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
// page.click('#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll').catch(_ => { }); // does not work reliably, solved by setting CookieConsent above
const signIn = page.locator('a:has-text("Sign in")').first(); const signIn = page.locator('a:has-text("Sign in")').first();
await Promise.any([signIn.waitFor(), page.waitForSelector('#menuUsername')]); await Promise.any([signIn.waitFor(), page.waitForSelector('#menuUsername')]);
while (await signIn.isVisible()) { while (await signIn.isVisible()) {
console.error('Not signed in anymore.'); console.error('Not signed in anymore.');
await signIn.click(); await signIn.click();
// it then creates an iframe for the login // it then creates an iframe for the login
await page.waitForSelector('#GalaxyAccountsFrameContainer iframe'); // TODO needed? await page.waitForSelector('#GalaxyAccountsFrameContainer iframe');
const iframe = page.frameLocator('#GalaxyAccountsFrameContainer iframe'); const iframe = page.frameLocator('#GalaxyAccountsFrameContainer iframe');
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
console.info(`Login timeout is ${cfg.login_timeout/1000} seconds!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
if (cfg.gog_email && cfg.gog_password) console.info('Using email and password from environment.'); if (cfg.gog_email && cfg.gog_password) console.info('Using email and password from environment.');
else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).');
const email = cfg.gog_email || await prompt({message: 'Enter email'}); const email = cfg.gog_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.gog_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.gog_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
iframe.locator('a[href="/logout"]').click().catch(_ => { }); // Click 'Change account' (email from previous login is set in some cookie) 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_username').fill(email);
await iframe.locator('#login_password').fill(password); await iframe.locator('#login_password').fill(password);
await iframe.locator('#login_login').click(); await iframe.locator('#login_login').click();
// handle MFA, but don't await it const handleTwoFactor = async () => {
iframe.locator('form[name=second_step_authentication]').waitFor().then(async () => { try {
console.log('Two-Step Verification - Enter security code'); await iframe.locator('form[name=second_step_authentication]').waitFor({ timeout: 15000 });
console.log(await iframe.locator('.form__description').innerText()) console.log('Two-Step Verification - Enter security code');
const otp = await prompt({type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 4 || 'The code must be 4 digits!'}); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them console.log(await iframe.locator('.form__description').innerText());
await iframe.locator('#second_step_authentication_token_letter_1').type(otp.toString(), {delay: 10}); const otp = await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 4 || 'The code must be 4 digits!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
await iframe.locator('#second_step_authentication_send').click(); await iframe.locator('#second_step_authentication_token_letter_1').pressSequentially(otp.toString(), { delay: 10 });
await page.waitForTimeout(1000); // TODO still needed with wait for username below? await iframe.locator('#second_step_authentication_send').click();
}).catch(_ => { }); await page.waitForTimeout(1000);
// iframe.locator('iframe[title=reCAPTCHA]').waitFor().then(() => { } catch {
// iframe.locator('.g-recaptcha').waitFor().then(() => { return;
iframe.locator('text=Invalid captcha').waitFor().then(() => { }
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.'); };
notify('gog: got captcha during login. Please check.'); const watchInvalidCaptcha = async () => {
// TODO solve reCAPTCHA? try {
}).catch(_ => { }); await iframe.locator('text=Invalid captcha').waitFor({ timeout: 15000 });
await page.waitForSelector('#menuUsername') 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.');
notify('gog: got captcha during login. Please check.');
} catch {
return;
}
};
handleTwoFactor();
watchInvalidCaptcha();
await page.waitForSelector('#menuUsername');
} else { } else {
console.log('Waiting for you to login in the browser.'); console.log('Waiting for you to login in the browser.');
await notify('gog: no longer signed in and not enough options set for automatic login.'); await notify('gog: no longer signed in and not enough options set for automatic login.');
@ -84,26 +98,24 @@ try {
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
} }
user = await page.locator('#menuUsername').first().textContent(); // innerText is uppercase due to styling! user = await page.locator('#menuUsername').first().textContent(); // innerText is uppercase due to styling!
console.log(`Signed in as '${user}'`); console.log(`Signed in as ${user}`);
db.data[user] ||= {}; db.data[user] ||= {};
const banner = page.locator('#giveaway'); const banner = page.locator('#giveaway');
if (!await banner.count()) { const hasGiveaway = await banner.count();
if (!hasGiveaway) {
console.log('Currently no free giveaway!'); console.log('Currently no free giveaway!');
} else { } else {
const text = await page.locator('.giveaway-banner__title').innerText(); const text = await page.locator('.giveaway__content-header').innerText();
const title = text.match(/Claim (.*)/)[1]; const match_all = text.match(/Claim (.*) and don't miss the|Success! (.*) was added to/);
const slug = await banner.getAttribute('href'); const title = match_all[1] ? match_all[1] : match_all[2];
const url = `https://gog.com${slug}`; const url = await banner.locator('a').first().getAttribute('href');
console.log(`Current free game: ${title} - ${url}`); console.log(`Current free game: ${chalk.blue(title)} - ${url}`);
db.data[user][title] ||= { title, time: datetime(), url }; db.data[user][title] ||= { title, time: datetime(), url };
if (cfg.dryrun) process.exit(1); if (cfg.dryrun) process.exit(1);
await page.locator('#giveaway:not(.is-loading)').waitFor(); // otherwise screenshot is sometimes with loading indicator instead of game title await banner.screenshot({ path: screenshot(`${filenamify(title)}.png`) }); // overwrites every time - only keep first?
const p = path.resolve(cfg.dir.screenshots, 'gog', `${filenamify(title)}.png`);
await banner.screenshot({ path: p }); // overwrites every time - only keep first?
// await banner.getByRole('button', { name: 'Add to library' }).click(); // instead of clicking the button, visit the auto-claim URL which gives a JSON response
// instead of clicking the button, we visit the auto-claim URL which gives as a JSON response which is easier than checking the state of a button
await page.goto('https://www.gog.com/giveaway/claim'); await page.goto('https://www.gog.com/giveaway/claim');
const response = await page.innerText('body'); const response = await page.innerText('body');
// console.log(response); // console.log(response);
@ -129,20 +141,22 @@ try {
notify_games.push({ title, url, status }); notify_games.push({ title, url, status });
if (status == 'claimed' && !cfg.gog_newsletter) { if (status == 'claimed' && !cfg.gog_newsletter) {
console.log("Unsubscribe from 'Promotions and hot deals' newsletter"); console.log('Unsubscribe from \'Promotions and hot deals\' newsletter');
await page.goto('https://www.gog.com/en/account/settings/subscriptions'); await page.goto('https://www.gog.com/en/account/settings/subscriptions');
await page.locator('li:has-text("Marketing communications through Trusted Partners") label').uncheck();
await page.locator('li:has-text("Promotions and hot deals") label').uncheck(); await page.locator('li:has-text("Promotions and hot deals") label').uncheck();
} }
} }
} catch (error) { } catch (error) {
console.error(error); // .toString()?
process.exitCode ||= 1; process.exitCode ||= 1;
if (error.message && process.exitCode != 130) console.error('--- Exception:');
notify(`gog failed: ${error.message.split('\n')[0]}`); console.error(error); // .toString()?
if (error.message && process.exitCode != 130) notify(`gog failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed
notify(`gog (${user}):<br>${html_game_list(notify_games)}`); notify(`gog (${user}):<br>${html_game_list(notify_games)}`);
} }
} }
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close(); await context.close();

View file

@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"checkJs": true, "checkJs": true,
"target": "es2021", "target": "es2021",
"module": "esnext", "module": "NodeNext",
"moduleResolution": "NodeNext", // https://github.com/typicode/lowdb/issues/554 "moduleResolution": "NodeNext", // https://github.com/typicode/lowdb/issues/554
}, },
"exclude": ["node_modules", "**/node_modules"] "exclude": ["node_modules", "**/node_modules"]

View file

@ -1,34 +0,0 @@
import { delay, html_game_list, notify } from "./util.js";
const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL
if (true) {
const notify_games = [
// { title: 'Kerbal Space Program', status: 'claimed', url: URL_CLAIM },
// { title: "Shadow Tactics - Aiko's Choice", status: 'claimed', url: URL_CLAIM },
{ title: 'Epistory - Typing Chronicles', status: 'claimed', url: URL_CLAIM },
];
notify(`epic-games:<br>${html_game_list(notify_games)}`);
}
if (false) {
await delay(1000);
const notify_games = [
{ title: 'Faraway 2: Jungle Escape', status: 'claimed', url: URL_CLAIM },
{ title: 'Chicken Police - Paint it RED!', status: 'claimed', url: URL_CLAIM },
{ title: 'Lawn Mowing Simulator', status: 'claimed', url: URL_CLAIM },
{ title: 'Breathedge', status: 'claimed', url: URL_CLAIM },
{ title: 'The Evil Within 2', status: `<a href="${URL_CLAIM}">redeem</a> H97S6FB38FA6D09DEA on gog.com`, url: URL_CLAIM },
{ title: 'Beat Cop', status: `<a href="${URL_CLAIM}">redeem</a> BMKM8558EC55F7B38F on gog.com`, url: URL_CLAIM },
{ title: 'Dishonored 2', status: `<a href="${URL_CLAIM}">redeem</a> NNEK0987AB20DFBF8F on gog.com`, url: URL_CLAIM },
];
notify(`prime-gaming:<br>${html_game_list(notify_games)}`);
}
if (false) {
await delay(1000);
const notify_games = [
{ title: 'Haven Park', status: 'claimed', url: URL_CLAIM },
];
notify(`gog:<br>${html_game_list(notify_games)}`);
}

3901
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,72 +1,133 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib'; import { authenticator } from 'otplib';
import path from 'path'; import chalk from 'chalk';
import { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, confirm, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './config.js'; import { cfg } from './src/config.js';
// const URL_LOGIN = 'https://www.amazon.de/ap/signin'; // wrong. needs some session args to be valid? const screenshot = (...a) => resolve(cfg.dir.screenshots, 'prime-gaming', ...a);
const URL_CLAIM = 'https://gaming.amazon.com/home';
const URL_CLAIM = 'https://luna.amazon.com/claims/home';
console.log(datetime(), 'started checking prime-gaming'); console.log(datetime(), 'started checking prime-gaming');
const db = await jsonDb('prime-gaming.json'); const db = await jsonDb('prime-gaming.json', {});
db.data ||= {};
handleSIGINT();
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
locale: "en-US", // ignore OS locale to be sure to have english text for locators 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/pg-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
}); });
// TODO test if needed handleSIGINT(context);
await stealth(context); await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); await page.setViewportSize({ width: cfg.width, height: cfg.height }); // workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it
const notify_games = []; const notify_games = [];
let user; let user;
const handleMFA = async p => {
const otpField = p.locator('#auth-mfa-otpcode, input[name=otpCode]');
if (!await otpField.count()) return false;
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App');
await p.locator('#auth-mfa-remember-device, [name=rememberDevice]').check().catch(() => {});
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_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 otpField.first().pressSequentially(otp.toString());
await p.locator('input[type="submit"], button[type="submit"]').first().click();
return true;
};
try { try {
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
const handleDirectLoginPage = async () => {
if (!page.url().includes('/ap/signin')) return false;
console.log('On Amazon login page (redirect). Trying to sign in automatically.');
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout);
const email = cfg.pg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) {
await page.fill('[name=email]', email);
await page.click('input[type="submit"]');
await page.fill('[name=password]', password);
await page.click('input[type="submit"]');
await handleMFA(page).catch(() => {});
try {
await page.waitForURL('**/ap/signin**');
const error = await page.locator('.a-alert-content').first().innerText();
if (error.trim().length) {
console.error('Login error:', error);
await notify(`prime-gaming: login: ${error}`);
await context.close(); // finishes potential recording
process.exit(1);
}
} catch {
// if navigation succeeded, continue
}
await page.waitForURL(/luna\.amazon\.com\/claims\/.*signedIn=true/);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
return true;
} else {
console.log('Waiting for manual login on redirect page.');
if (cfg.headless) {
console.log('Run `SHOW=1 node prime-gaming` to login in the opened browser.');
await context.close(); // finishes potential recording
process.exit(1);
}
return true;
}
};
await handleDirectLoginPage();
// need to wait for some elements to exist before checking if signed in or accepting cookies: // need to wait for some elements to exist before checking if signed in or accepting cookies:
await Promise.any(['button:has-text("Sign in")', '[data-a-target="user-dropdown-first-name-text"]'].map(s => page.waitForSelector(s))); await Promise.any([
page.click('[aria-label="Cookies usage disclaimer banner"] button:has-text("Accept Cookies")').catch(_ => { }); // to not waste screen space when non-headless, TODO does not work reliably, need to wait for something else first? 'button:has-text("Sign in")',
while (await page.locator('button:has-text("Sign in")').count() > 0) { 'button:has-text("Anmelden")',
'[data-a-target="user-dropdown-first-name-text"]',
'[data-testid="user-dropdown-first-name-text"]',
].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 {
// ignore if banner not present
}
while (await page.locator('button:has-text("Sign in"), button:has-text("Anmelden")').count() > 0) {
console.error('Not signed in anymore.'); console.error('Not signed in anymore.');
await page.click('button:has-text("Sign in")'); await page.click('button:has-text("Sign in")');
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
console.info(`Login timeout is ${cfg.login_timeout/1000} seconds!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
if (cfg.pg_email && cfg.pg_password) console.info('Using email and password from environment.'); if (cfg.pg_email && cfg.pg_password) console.info('Using email and password from environment.');
else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).');
const email = cfg.pg_email || await prompt({message: 'Enter email'}); const email = cfg.pg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.pg_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
await page.fill('[name=email]', email); await page.fill('[name=email]', email);
await page.fill('[name=password]', password);
await page.check('[name=rememberMe]');
await page.click('input[type="submit"]'); await page.click('input[type="submit"]');
page.waitForURL('**/ap/signin**').then(async () => { // check for wrong credentials await page.fill('[name=password]', password);
await page.click('input[type="submit"]');
try {
await page.waitForURL('**/ap/signin**');
const error = await page.locator('.a-alert-content').first().innerText(); const error = await page.locator('.a-alert-content').first().innerText();
if (!error.trim.length) return; if (error.trim().length) {
console.error('Login error:', error); console.error('Login error:', error);
await notify(`prime-gaming: login: ${error}`); await notify(`prime-gaming: login: ${error}`);
await context.close(); // finishes potential recording await context.close(); // finishes potential recording
process.exit(1); process.exit(1);
}); }
// handle MFA, but don't await it } catch {
page.waitForURL('**/ap/mfa**').then(async () => { // navigation ok
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App'); }
await page.check('[name=rememberDevice]'); try {
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_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 handleMFA(page);
await page.type('input[name=otpCode]', otp.toString()); } catch {
await page.click('input[type="submit"]'); // ignore MFA watcher errors
}).catch(_ => { }); }
} else { } else {
console.log('Waiting for you to login in the browser.'); console.log('Waiting for you to login in the browser.');
await notify('prime-gaming: no longer signed in and not enough options set for automatic login.'); await notify('prime-gaming: no longer signed in and not enough options set for automatic login.');
@ -79,11 +140,8 @@ try {
await page.waitForURL('https://gaming.amazon.com/home?signedIn=true'); await page.waitForURL('https://gaming.amazon.com/home?signedIn=true');
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
} }
user = await page.locator('[data-a-target="user-dropdown-first-name-text"]').first().innerText(); user = await page.locator('[data-a-target="user-dropdown-first-name-text"], [data-testid="user-dropdown-first-name-text"]').first().innerText();
console.log(`Signed in as ${user}`); console.log(`Signed in as ${user}`);
// await page.click('button[aria-label="User dropdown and more options"]');
// const twitch = await page.locator('[data-a-target="TwitchDisplayName"]').first().innerText();
// console.log(`Twitch user name is ${twitch}`);
db.data[user] ||= {}; db.data[user] ||= {};
if (await page.getByRole('button', { name: 'Try Prime' }).count()) { if (await page.getByRole('button', { name: 'Try Prime' }).count()) {
@ -92,69 +150,359 @@ try {
process.exit(1); process.exit(1);
} }
await page.click('button[data-type="Game"]'); const waitUntilStable = async (f, act) => {
const games_sel = 'div[data-a-target="offer-list-FGWP_FULL"]'; let v;
await page.waitForSelector(games_sel); while (true) {
console.log('Number of already claimed games (total):', await page.locator(`${games_sel} p:has-text("Collected")`).count()); const v2 = await f();
const game_sel = `${games_sel} [data-a-target="item-card"]:has-text("Claim game")`; console.log('waitUntilStable', v2);
console.log('Number of free unclaimed games (Prime Gaming):', await page.locator(game_sel).count()); if (v == v2) break;
const games = await page.$$(game_sel); v = v2;
// for (let i=1; i<=n; i++) { await act();
for (const card of games) { }
// const card = page.locator(`:nth-match(${game_sel}, ${i})`); // this will reevaluate after games are claimed and index will be wrong };
// const title = await card.locator('h3').first().innerText(); const scrollUntilStable = async f => await waitUntilStable(f, async () => {
const title = await (await card.$('.item-card-details__body__primary')).innerText(); // await page.keyboard.press('End'); // scroll to bottom to show all games
console.log('Current free game:', title); // loading all games became flaky; see https://github.com/vogler/free-games-claimer/issues/357
if (cfg.dryrun) continue; await page.keyboard.press('PageDown'); // scrolling to straight to the bottom started to skip loading some games
await (await card.$('button:has-text("Claim game")')).click(); await page.waitForLoadState('networkidle'); // wait for all games to be loaded
db.data[user][title] ||= { title, time: datetime(), store: 'internal' }; await page.waitForTimeout(3000); // extra wait to load all already collected games
notify_games.push({ title, status: 'claimed', url: URL_CLAIM }); // do it again since once wasn't enough...
// const img = await (await card.$('img.tw-image')).getAttribute('src'); await page.keyboard.press('PageDown');
// console.log('Image:', img); await page.waitForTimeout(3000);
const p = path.resolve(cfg.dir.screenshots, 'prime-gaming', 'internal', `${filenamify(title)}.png`); });
await card.screenshot({ path: p });
const openGamesTab = async () => {
const selectors = [
'button[data-type="Game"]', // old layout
'button:has-text("Games")',
'button:has-text("Games einlösen")',
'[data-test-selector="category-picker"] button:has-text("Games")',
'[data-testid="category-picker"] button:has-text("Games")',
];
for (const sel of selectors) {
const btn = page.locator(sel).first();
if (await btn.count()) {
await btn.click();
return;
}
}
// New Luna claims home: try the filter/CTA button with embedded <p title="Games einlösen">
const gamesTitle = page.locator('p.offer-filters__button__title:has-text("Games"), p.offer-filters__button__title:has-text("einlösen")');
if (await gamesTitle.count()) {
const btn = gamesTitle.first().locator('xpath=ancestor::button[1]');
if (await btn.count()) {
await btn.click();
return;
}
}
// New Luna claims home already shows games list
};
await openGamesTab();
const locateGamesList = async () => {
const selectors = [
'div[data-a-target="offer-list-FGWP_FULL"]', // old layout
'[data-testid="offer-list"]',
'[data-test-selector="offer-list"]',
'section:has(h2:has-text("Games with Prime"))',
'section:has(h2:has-text("Games"))',
];
for (const sel of selectors) {
const loc = page.locator(sel).first();
if (await loc.count()) return loc;
}
return null;
};
const games = await locateGamesList();
// Load all cards (old and new layout) by scrolling the container or the page
if (games) await scrollUntilStable(() => games.evaluate(el => el.scrollHeight).catch(() => 0));
await scrollUntilStable(() => page.evaluate(() => globalThis.document?.scrollingElement?.scrollHeight ?? 0));
const normalizeClaimUrl = url => {
if (!url) return { url, key: null };
const m = url.match(/(https?:\/\/[^/]+)?(\/claims\/[^?#]+)/);
if (!m) return { url, key: url };
const path = m[2];
const slug = path.split('/')[2];
return { url: 'https://luna.amazon.com' + path, key: slug || path };
};
const cards = [];
// New layout: direct claim buttons on cards (FGWPOffer) with "Spiel aktivieren"/"Claim"
const fgwps = page.locator('[data-a-target="FGWPOffer"]');
if (await fgwps.count()) {
for (const handle of await fgwps.elementHandles()) {
const href = await handle.getAttribute('href');
const { url, key } = normalizeClaimUrl(href?.startsWith('/') ? href : href || '');
const title =
await handle.$eval('p[title], span[title]', el => el.getAttribute('title')).catch(() => null) ||
await handle.$eval('p, span', el => el.textContent).catch(() => null) ||
key ||
'Unknown title';
cards.push({ kind: 'external', title, url, key });
}
} }
const anchorClaims = page.locator('a[href*="/claims/"][href*="amzn1.pg.item"]');
if (await anchorClaims.count()) {
const hrefs = [...new Set(await anchorClaims.evaluateAll(anchors => anchors.map(a => a.getAttribute('href')).filter(Boolean)))];
for (const href of hrefs) {
const { url, key } = normalizeClaimUrl(href);
const title = key || await anchorClaims.first().innerText() || 'Unknown title';
cards.push({ kind: 'external', title, url, key });
}
}
if (!cards.length && games) {
const cardLocator = games.locator([
'[data-testid="offer-card"]',
'[data-test-selector="offer-card"]',
'.item-card__action',
].join(','));
if (await cardLocator.count() === 0) {
console.log('No games found in list.');
} else {
for (const handle of await cardLocator.elementHandles()) {
const text = (await handle.textContent() || '').toLowerCase();
if (text.includes('collected')) continue; // skip already claimed
const title = await (await handle.$('h3, h4, [data-testid="item-card-title"], [data-test-selector="item-card-title"], .item-card-details__body__primary'))?.innerText() || 'Unknown title';
const linkEl = await handle.$('a[href]');
let url = linkEl && await linkEl.getAttribute('href');
if (url?.startsWith('/')) url = 'https://luna.amazon.com' + url;
const { url: normUrl, key } = normalizeClaimUrl(url);
const hasLinkClaim = await handle.$('a:has-text("Claim"), a:has-text("Get"), a:has-text("Details")');
const hasButtonClaim = await handle.$('button:has-text("Claim"), button:has-text("Get"), button:has-text("Get game"), button:has-text("Play")');
const hasGermanCTA = await handle.$(':is(button,p,a):has-text("Spiel aktivieren"), :is(button,p,a):has-text("Spiel holen")');
if (hasLinkClaim || hasGermanCTA) cards.push({ kind: 'external', title, url: normUrl, key });
else if (hasButtonClaim) cards.push({ kind: 'internal', title, url: normUrl, key, handle });
}
}
}
// dedup by URL to avoid duplicates from multiple selectors
const seenUrl = new Set();
const uniq = cards.filter(c => {
const key = c.key || c.url || c.title;
if (seenUrl.has(key)) return false;
seenUrl.add(key);
return true;
});
const internal = uniq.filter(c => c.kind == 'internal');
const external = uniq.filter(c => c.kind == 'external');
// bottom to top: oldest to newest games
internal.reverse();
external.reverse();
const sameOrNewPage = async url => {
const isNew = page.url() != url;
let p = page;
if (isNew) {
p = await context.newPage();
await p.goto(url, { waitUntil: 'domcontentloaded' });
}
return [p, isNew];
};
const skipBasedOnTime = async url => {
const [p, isNew] = await sameOrNewPage(url);
const dueDateLoc = p.locator('.availability-date .tw-bold, [data-testid="availability-end-date"], [data-test-selector="availability-end-date"]');
if (!await dueDateLoc.count()) {
if (isNew) await p.close();
return false;
}
const dueDateOrg = await dueDateLoc.first().innerText();
const dueDate = new Date(Date.parse(dueDateOrg + ' 17:00'));
const daysLeft = (dueDate.getTime() - Date.now()) / 1000 / 60 / 60 / 24;
const availabilityText = await p.locator('.availability-date, [data-testid="availability-end-date"], [data-test-selector="availability-end-date"]').first().innerText().catch(() => dueDateOrg);
console.log(' ', availabilityText, '->', daysLeft.toFixed(2));
if (isNew) await p.close();
return daysLeft > cfg.pg_timeLeft;
};
console.log('\nNumber of free unclaimed games (Prime Gaming):', internal.length);
// claim games in internal store
for (const card of internal) {
await card.handle.scrollIntoViewIfNeeded();
const title = card.title;
const url = card.url;
console.log('Current free game:', chalk.blue(title));
if (cfg.pg_timeLeft && url && await skipBasedOnTime(url)) continue;
if (cfg.dryrun) continue;
if (cfg.interactive) {
const confirmed = await confirm();
if (!confirmed) continue;
}
await card.handle.locator('.tw-button:has-text("Claim"), .tw-button:has-text("Get"), button:has-text("Claim"), button:has-text("Get")').first().click();
db.data[user][title] ||= { title, time: datetime(), url, store: 'internal' };
notify_games.push({ title, status: 'claimed', url });
await card.handle.screenshot({ path: screenshot('internal', `${filenamify(title)}.png`) });
}
console.log('\nNumber of free unclaimed games (external stores):', external.length);
// claim games in external/linked stores. Linked: origin.com, epicgames.com; Redeem-key: gog.com, legacygames.com, microsoft // claim games in external/linked stores. Linked: origin.com, epicgames.com; Redeem-key: gog.com, legacygames.com, microsoft
let n; const external_info = [];
const game_sel_ext = `${games_sel} [data-a-target="item-card"]:has(p:text-is("Claim"))`; for (const card of external) { // need to get data incl. URLs in this loop and then navigate in another, otherwise .all() would update after coming back and .elementHandles() like above would lead to error due to page navigation: elementHandle.$: Protocol error (Page.adoptNode)
do { const title = card.title;
n = await page.locator(game_sel_ext).count(); const url = card.url ? card.url.split('?')[0] : undefined;
console.log('Number of free unclaimed games (external stores):', n); if (!url) continue;
const card = await page.$(game_sel_ext); external_info.push({ title, url });
if (!card) break; }
const title = await (await card.$('.item-card-details__body__primary')).innerText(); const clickCTA = async p => {
console.log('Current free game:', title); const candidates = [
if (cfg.dryrun) break; // TODO change back to continue, but need different iteration scheme p.locator('button[data-a-target="buy-box_call-to-action"]').first(),
await (await card.$('text=Claim')).click(); // goes to URL of game, no need to wait p.locator('[data-a-target="buy-box_call-to-action"]').first(),
await Promise.any([page.click('button:has-text("Claim now")'), page.click('button:has-text("Complete Claim")'), page.waitForSelector('div:has-text("Link game account")')]); // waits for navigation p.locator('[data-a-target="buy-box"] .tw-button:has-text("Get game")').first(),
const store_text = await (await page.$('[data-a-target="hero-header-subtitle"]')).innerText(); p.locator('[data-a-target="buy-box"] .tw-button:has-text("Claim")').first(),
// Full game for PC [and MAC] on: gog.com, Origin, Legacy Games, EPIC GAMES, Battle.net p.locator('.tw-button:has-text("Complete Claim")').first(),
// 3 Full PC Games on Legacy Games p.locator('[data-a-target="buy-box_call-to-action-text"]').first().locator('xpath=ancestor::button[1]'),
const store = store_text.toLowerCase().replace(/.* on /, ''); p.locator('.tw-button:has-text("Spiel holen"), .tw-button:has-text("Spiel aktivieren")').first(),
p.locator('p:has-text("Spiel holen"), p:has-text("Spiel aktivieren")').first().locator('xpath=ancestor::button[1]'),
];
for (const c of candidates) {
if (await c.count()) {
try {
await c.waitFor({ state: 'visible', timeout: 5000 });
const enabled = await c.isEnabled();
if (enabled) await c.click();
else {
await c.evaluate(el => {
el.disabled = false;
el.removeAttribute('disabled');
el.click();
});
}
return true;
} catch {
// try next candidate
}
}
}
return false;
};
for (const { title, url } of external_info) {
console.log('Current free game:', chalk.blue(title)); // , url);
const existingStatus = db.data[user]?.[title]?.status;
if (existingStatus && !existingStatus.startsWith('failed')) {
console.log(` Already recorded as ${existingStatus}, skipping.`);
notify_games.push({ title, url, status: 'existed' });
continue;
}
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-a-target="buy-box"]', { timeout: 10000 }).catch(() => {});
if (cfg.debug) await page.pause();
let store = 'unknown';
const detailLoc = page.locator('[data-a-target="DescriptionItemDetails"], [data-testid="DescriptionItemDetails"]');
if (await detailLoc.count()) {
const item_text = await detailLoc.first().innerText();
const lower = item_text.toLowerCase();
const onPos = lower.lastIndexOf(' on ');
if (onPos >= 0) store = lower.slice(onPos + 4).replace(/[.!]$/, '');
} else if (url.includes('/claims/')) {
const slug = url.split('/claims/')[1]?.split('/')[0] || '';
if (slug.includes('gog')) store = 'gog.com';
else if (slug.includes('epic')) store = 'epic-games';
else if (slug.includes('origin')) store = 'origin';
else if (slug.includes('xbox') || slug.includes('microsoft')) store = 'microsoft store';
else if (slug.includes('legacy')) store = 'legacy games';
const lunaPlay = await page.locator('[data-a-target="LunaOffer"], button[data-a-target="LunaOffer"], button:has-text("Spielen")').count();
if (store == 'unknown' && lunaPlay) store = 'luna';
}
console.log(' External store:', store); console.log(' External store:', store);
const url = page.url().split('?')[0];
db.data[user][title] ||= { title, time: datetime(), url, store };
const notify_game = { title, url }; const notify_game = { title, url };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
if (await page.locator('div:has-text("Link game account")').count()) { // Already collected? skip
const collectedLoc = page.locator('[data-a-target="ClaimStateQuantityAndDateContent"], [data-a-target="ClaimStateClaimCodeContent"]:has-text("Collected"), [data-a-target="ClaimStateVendorContent"], [data-a-target="ClaimStateViewDetailsAndInstructions"]');
const collectedText = page.getByText(/You collected this on/i);
const collectedEpic = page.getByText(/Sent to your Epic Games Store library/i);
const collectedBanner = page.locator('p.tw-c-text-alert-success:has-text("Collected"), p.tw-c-text-alert-success:has-text("Collected this")');
const collectedSuccessIcon = page.locator('[data-a-target="ItemCardDetailSuccessStatus"], .claim-state__success-icon');
const disabledCTA = page.locator('[data-a-target="buy-box_call-to-action"][disabled], button[disabled]:has-text("Get game")');
const collectedAny = await Promise.all([
collectedLoc.count(),
collectedBanner.count(),
collectedText.count(),
collectedEpic.count(),
collectedSuccessIcon.count(),
disabledCTA.count(),
]).then(([a, b, c, d, e, f]) => a + b + c + d + e + f > 0);
if (collectedAny) {
console.log(' Already collected, skipping.');
notify_game.status = 'existed';
db.data[user][title] ||= { title, time: datetime(), url, store, status: 'existed' };
continue;
}
// Disabled CTA (e.g., needs linking or not available)
if (await disabledCTA.count()) {
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') {
console.log(' Luna cloud title detected, skipping code redemption.');
notify_game.status = 'luna (play)';
db.data[user][title] ||= { title, time: datetime(), url, store: 'luna', status: 'luna (play)' };
continue;
}
if (cfg.pg_timeLeft && await skipBasedOnTime(url)) continue;
if (cfg.dryrun) continue;
if (cfg.interactive) {
const confirmed = await confirm();
if (!confirmed) continue;
}
await clickCTA(page);
await Promise.any([
page.waitForSelector('.thank-you-title:has-text("Success")', { timeout: cfg.timeout }).catch(() => {}),
page.waitForSelector('div:has-text("Link game account")', { timeout: cfg.timeout }).catch(() => {}),
]).catch(() => {});
db.data[user][title] ||= { title, time: datetime(), url, store };
if (await page.locator('div:has-text("Link game account")').count() // epic games store also shows "Link account"
|| await page.locator('div:has-text("Link account")').count()) {
console.error(' Account linking is required to claim this offer!'); console.error(' Account linking is required to claim this offer!');
notify_game.status = `failed: need account linking for ${store}`; notify_game.status = `failed: need account linking for ${store}`;
db.data[user][title].status = 'failed: need account linking'; db.data[user][title].status = 'failed: need account linking';
// await page.pause();
// await page.click('[data-a-target="LinkAccountModal"] [data-a-target="LinkAccountButton"]');
// login for epic games also needed if already logged in
// wait for https://www.epicgames.com/id/authorize?redirect_uri=https%3A%2F%2Fservice.link.amazon.gg...
// await page.click('button[aria-label="Allow"]');
} else { } else {
db.data[user][title].status = 'claimed';
// print code if there is one // print code if there is one
const redeem = { const redeem = {
// 'origin': 'https://www.origin.com/redeem', // TODO still needed or now only via account linking? // 'origin': 'https://www.origin.com/redeem', // kept for legacy flows; current path uses account linking
'gog.com': 'https://www.gog.com/redeem', 'gog.com': 'https://www.gog.com/redeem',
'microsoft games': 'https://redeem.microsoft.com', 'microsoft store': 'https://account.microsoft.com/billing/redeem',
xbox: 'https://account.microsoft.com/billing/redeem',
'legacy games': 'https://www.legacygames.com/primedeal', 'legacy games': 'https://www.legacygames.com/primedeal',
}; };
if (store in redeem) { // did not work for linked origin: && !await page.locator('div:has-text("Successfully Claimed")').count() if (store in redeem) { // did not work for linked origin: && !await page.locator('div:has-text("Successfully Claimed")').count()
const code = await page.inputValue('input[type="text"]'); let code;
console.log(' Code to redeem game:', code); try {
if (store == 'legacy games') { // may be different URL like https://legacygames.com/primeday/puzzleoftheyear/ // ensure CTA was clicked in case code is behind it
redeem[store] = await (await page.$('li:has-text("Click here") a')).getAttribute('href'); await clickCTA(page).catch(() => {});
code = await Promise.any([
page.inputValue('input[type="text"]'),
page.textContent('[data-a-target="ClaimStateClaimCodeContent"]').then(s => s.replace('Your code: ', '')),
]);
} catch {
console.error(' Could not find claim code on page (timeout). Please check manually.');
db.data[user][title].status = 'claimed (code not found)';
notify_game.status = 'claimed (code not found)';
await page.screenshot({ path: screenshot('external', `${filenamify(title)}_nocode.png`), fullPage: true }).catch(() => {});
continue;
} }
console.log(' URL to redeem game:', redeem[store]); console.log(' Code to redeem game:', chalk.blue(code));
if (store == 'legacy games') { // may be different URL like https://legacygames.com/primeday/puzzleoftheyear/
redeem[store] = await (await page.$('li:has-text("Click here") a')).getAttribute('href'); // full text: Click here to enter your redemption code.
}
let redeem_url = redeem[store];
if (store == 'gog.com') redeem_url += '/' + code; // to log and notify, but can't use for goto below (captcha)
console.log(' URL to redeem game:', redeem_url);
db.data[user][title].code = code; db.data[user][title].code = code;
let redeem_action = 'redeem'; let redeem_action = 'redeem';
if (cfg.pg_redeem) { // try to redeem keys on external stores if (cfg.pg_redeem) { // try to redeem keys on external stores
@ -162,18 +510,15 @@ try {
const page2 = await context.newPage(); const page2 = await context.newPage();
await page2.goto(redeem[store], { waitUntil: 'domcontentloaded' }); await page2.goto(redeem[store], { waitUntil: 'domcontentloaded' });
if (store == 'gog.com') { if (store == 'gog.com') {
// await page.goto(`https://redeem.gog.com/v1/bonusCodes/${code}`); // {"reason":"Invalid or no captcha"}
await page2.fill('#codeInput', code); await page2.fill('#codeInput', code);
const r = page2.waitForResponse(r => r.url().startsWith('https://redeem.gog.com/')); const r1 = page2.waitForResponse(r => r.request().method() == 'GET' && r.url().startsWith('https://redeem.gog.com/'));
await page2.click('[type="submit"]'); await page2.click('[type="submit"]'); // click Continue
// console.log(await page2.locator('.warning-message').innerText()); const r1t = await (await r1).text();
const rt = await (await r).text(); const reason = JSON.parse(r1t).reason;
console.debug(` Response: ${rt}`);
// {"reason":"Invalid or no captcha"} // {"reason":"Invalid or no captcha"}
// {"reason":"code_used"} // {"reason":"code_used"}
// {"reason":"code_not_found"} // {"reason":"code_not_found"}
const reason = JSON.parse(rt).reason; if (reason?.includes('captcha')) {
if (reason && reason.includes('captcha')) {
redeem_action = 'redeem (got captcha)'; redeem_action = 'redeem (got captcha)';
console.error(' Got captcha; could not redeem!'); console.error(' Got captcha; could not redeem!');
} else if (reason == 'code_used') { } else if (reason == 'code_used') {
@ -182,127 +527,198 @@ try {
} else if (reason == 'code_not_found') { } else if (reason == 'code_not_found') {
redeem_action = 'redeem (not found)'; redeem_action = 'redeem (not found)';
console.error(' Code was not found!'); console.error(' Code was not found!');
} else { // TODO not logged in? need valid unused code to test. } else { // unknown state; keep info log for later analysis
redeem_action = 'redeemed?'; redeem_action = 'redeemed?';
console.log(' Redeemed successfully? Please report your Response from above (if it is new) in https://github.com/vogler/free-games-claimer/issues/5'); console.debug(` Response 1: ${r1t}`);
// db.data[user][title].status = 'claimed and redeemed'; const r2 = page2.waitForResponse(r => r.request().method() == 'POST' && r.url().startsWith('https://redeem.gog.com/'));
await page2.click('[type="submit"]'); // click Redeem
const r2t = await (await r2).text();
const reason2 = JSON.parse(r2t).reason;
if (r2t == '{}') {
redeem_action = 'redeemed';
console.log(' Redeemed successfully.');
db.data[user][title].status = 'claimed and redeemed';
} else if (reason2?.includes('captcha')) {
redeem_action = 'redeem (got captcha)';
console.error(' Got captcha; could not redeem!');
} else {
console.debug(` Response 2: ${r2t}`);
console.log(' Unknown Response 2 - please report in https://github.com/vogler/free-games-claimer/issues/5');
}
} }
} else if (store == 'microsoft games') { } else if (store == 'microsoft store' || store == 'xbox') {
console.error(` Redeem on ${store} not yet implemented!`); console.error(` Redeem on ${store} is experimental!`);
if (page2.url().startsWith('https://login.')) { if (page2.url().startsWith('https://login.')) {
console.error(' Not logged in! Use the browser to login manually.'); console.error(' Not logged in! Please redeem the code above manually. You can now login in the browser for next time. Waiting for 60s.');
await page2.waitForTimeout(60 * 1000);
redeem_action = 'redeem (login)'; redeem_action = 'redeem (login)';
} else { } else {
const r = page2.waitForResponse(r => r.url().startsWith('https://purchase.mp.microsoft.com/')); const iframe = page2.frameLocator('#redeem-iframe');
await page2.fill('[name=tokenString]', code); const input = iframe.locator('[name=tokenString]');
// console.log(await page2.locator('.redeem_code_error').innerText()); await input.waitFor();
await input.fill(code);
const r = page2.waitForResponse(r => r.url().startsWith('https://cart.production.store-web.dynamics.com/v1.0/Redeem/PrepareRedeem'));
const rt = await (await r).text(); const rt = await (await r).text();
console.debug(` Response: ${rt}`); const j = JSON.parse(rt);
// {"code":"NotFound","data":[],"details":[],"innererror":{"code":"TokenNotFound",... const reason = j?.events?.cart.length && j.events.cart[0]?.data?.reason;
const reason = JSON.parse(rt).code; if (reason == 'TokenNotFound') {
if (reason == 'NotFound') {
redeem_action = 'redeem (not found)'; redeem_action = 'redeem (not found)';
console.error(' Code was not found!'); console.error(' Code was not found!');
} else { // TODO find out other responses } else if (j?.productInfos?.length && j.productInfos[0]?.redeemable) {
await page2.click('#nextButton'); await iframe.locator('button:has-text("Next")').click();
redeem_action = 'redeemed?'; await iframe.locator('button:has-text("Confirm")').click();
const r = page2.waitForResponse(r => r.url().startsWith('https://cart.production.store-web.dynamics.com/v1.0/Redeem/RedeemToken'));
const j = JSON.parse(await (await r).text());
if (j?.events?.cart.length && j.events.cart[0]?.data?.reason == 'UserAlreadyOwnsContent') {
redeem_action = 'already redeemed';
console.error(' error: UserAlreadyOwnsContent');
} else { // success path not seen yet; log below if needed
redeem_action = 'redeemed';
db.data[user][title].status = 'claimed and redeemed?';
console.log(' Redeemed successfully? Please report if not in https://github.com/vogler/free-games-claimer/issues/5');
}
} else { // other responses; keep info log for analysis
redeem_action = 'unknown';
console.debug(` Response: ${rt}`);
console.log(' Redeemed successfully? Please report your Response from above (if it is new) in https://github.com/vogler/free-games-claimer/issues/5'); console.log(' Redeemed successfully? Please report your Response from above (if it is new) in https://github.com/vogler/free-games-claimer/issues/5');
// db.data[user][title].status = 'claimed and redeemed';
} }
} }
} else if (store == 'legacy games') { } else if (store == 'legacy games') {
console.error(` Redeem on ${store} not yet tested!`);
await page2.fill('[name=coupon_code]', code); await page2.fill('[name=coupon_code]', code);
await page2.fill('[name=email]', cfg.pg_email); // TODO option for sep. email? await page2.fill('[name=email]', cfg.lg_email);
await page2.fill('[name=email_validate]', cfg.pg_email); await page2.fill('[name=email_validate]', cfg.lg_email);
await page2.uncheck('[name=newsletter_sub]'); await page2.uncheck('[name=newsletter_sub]');
await page2.click('[type="submit"]'); await page2.click('[type="submit"]');
redeem_action = 'redeemed?'; try {
console.log(' Redeemed successfully? Please report problems in https://github.com/vogler/free-games-claimer/issues/5'); await page2.waitForSelector('h2:has-text("Thanks for redeeming")');
redeem_action = 'redeemed';
db.data[user][title].status = 'claimed and redeemed';
} catch (error) {
console.error(' Got error', error);
redeem_action = 'redeemed?';
db.data[user][title].status = 'claimed and redeemed?';
console.log(' Redeemed successfully? Please report problems in https://github.com/vogler/free-games-claimer/issues/5');
}
} else { } else {
console.error(` Redeem on ${store} not yet implemented!`); console.error(` Redeem on ${store} not yet implemented!`);
} }
await page2.pause(); if (cfg.debug) await page2.pause();
await page2.close(); await page2.close();
} }
notify_game.status = `<a href="${redeem[store]}">${redeem_action}</a> ${code} on ${store}`; notify_game.status = `<a href="${redeem_url}">${redeem_action}</a> ${code} on ${store}`;
} else { } else {
notify_game.status = `claimed on ${store}`; notify_game.status = `claimed on ${store}`;
db.data[user][title].status = 'claimed'; db.data[user][title].status = 'claimed';
} }
// save screenshot of potential code just in case await page.screenshot({ path: screenshot('external', `${filenamify(title)}.png`), fullPage: true });
const p = path.resolve(cfg.dir.screenshots, 'prime-gaming', 'external', `${filenamify(title)}.png`);
await page.screenshot({ path: p, fullPage: true });
// console.info(' Saved a screenshot of page to', p);
} }
// await page.pause(); }
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
try {
await page.click('button[data-type="Game"]'); await page.click('button[data-type="Game"]');
} while (n); } catch {
const p = path.resolve(cfg.dir.screenshots, 'prime-gaming', `${filenamify(datetime())}.png`); // ignore if filter already selected
// await page.screenshot({ path: p, fullPage: true }); // fullPage does not make a difference since scroll not on body but on some element }
await page.keyboard.press('End'); // scroll to bottom to show all games
await page.waitForTimeout(1000); // wait for fade in animation
const viewportSize = page.viewportSize(); // current viewport size
await page.setViewportSize({...viewportSize, height: 3000}); // increase height, otherwise element screenshot is cut off at the top and bottom
if (notify_games.length) await page.locator(games_sel).screenshot({ path: p }); // screenshot of all claimed games
if (notify_games.length && games) { // make screenshot of all games if something was claimed and list exists
const p = screenshot(`${filenamify(datetime())}.png`);
await scrollUntilStable(() => games.locator('.item-card__action').count());
const viewportSize = page.viewportSize(); // current viewport size
await page.setViewportSize({ ...viewportSize, height: 3000 }); // increase height, otherwise element screenshot is cut off at the top and bottom
await games.screenshot({ path: p }); // screenshot of all claimed games
}
// https://github.com/vogler/free-games-claimer/issues/55
if (cfg.pg_claimdlc) { if (cfg.pg_claimdlc) {
console.log('Trying to claim in-game content...'); console.log('Trying to claim in-game content...');
await page.click('button[data-type="InGameLoot"]'); await page.click('button[data-type="InGameLoot"]');
const loot_sel = 'div[data-a-target="offer-list-IN_GAME_LOOT"]'; const loot = page.locator('div[data-a-target="offer-list-IN_GAME_LOOT"]');
await page.waitForSelector(loot_sel); await loot.waitFor();
console.log('Number of already claimed DLC:', await page.locator(`${loot_sel} p:has-text("Collected")`).count());
const cards = await page.locator(`${loot_sel} [data-a-target="item-card"]:has(p:text-is("Claim"))`).all(); process.stdout.write('Loading all DLCs on page...');
await scrollUntilStable(() => loot.locator('[data-a-target="item-card"]').count());
console.log('\nNumber of already claimed DLC:', await loot.locator('p:has-text("Collected")').count());
const cards = await loot.locator('[data-a-target="item-card"]:has(p:text-is("Claim"))').all();
console.log('Number of unclaimed DLC:', cards.length); console.log('Number of unclaimed DLC:', cards.length);
const dlcs = await Promise.all(cards.map(async card => ({ const dlcs = await Promise.all(cards.map(async card => ({
game: await card.locator('.item-card-details__body p').innerText(), game: await card.locator('.item-card-details__body p').innerText(),
title: await card.locator('.item-card-details__body__primary').innerText(), title: await card.locator('.item-card-details__body__primary').innerText(),
url: 'https://gaming.amazon.com' + await card.locator('a').getAttribute('href'), url: 'https://gaming.amazon.com' + await card.locator('a').first().getAttribute('href'),
}))); })));
// console.log(dlcs); // console.log(dlcs);
const dlc_unlinked = {};
for (const dlc of dlcs) { for (const dlc of dlcs) {
const title = `${dlc.game} - ${dlc.title}`; const title = `${dlc.game} - ${dlc.title}`;
const url = dlc.url; const url = dlc.url;
console.log('Current DLC:', title); console.log('Current DLC:', title);
// if (cfg.dryrun) continue; if (cfg.debug) await page.pause();
if (cfg.dryrun) continue;
if (cfg.interactive && !await confirm()) continue;
db.data[user][title] ||= { title, time: datetime(), store: 'DLC', status: 'failed: need account linking' }; db.data[user][title] ||= { title, time: datetime(), store: 'DLC', status: 'failed: need account linking' };
const notify_game = { title, url }; const notify_game = { title, url };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
await page.goto(url, { waitUntil: 'domcontentloaded' }); try {
// most games have a button 'Get in-game content' await page.goto(url, { waitUntil: 'domcontentloaded' });
// epic-games: Fall Guys: Claim now -> Continue -> Go to Epic Games (despite account linked and logged into epic-games) -> not tied to account but via some cookie? // most games have a button 'Get in-game content'
await Promise.any([page.click('button:has-text("Get in-game content")'), page.click('button:has-text("Claim your gift")'), page.click('button:has-text("Claim now")').then(() => page.click('button:has-text("Continue")'))]); // 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?
page.click('button:has-text("Continue")').catch(_ => { }); const claimOptions = [
const linkAccountModal = page.locator('[data-a-target="LinkAccountModal"]'); page.click('.tw-button:has-text("Get in-game content")'),
const linkAccountButton = linkAccountModal.locator('[data-a-target="LinkAccountButton"]'); page.click('.tw-button:has-text("Claim your gift")'),
if (await linkAccountButton.count()) { (async () => {
console.error(' Missing account linking:', await linkAccountButton.innerText()); await page.click('.tw-button:has-text("Claim")');
} else if(await page.locator('text=Link game account').count()) { // epic-games only? await page.click('button:has-text("Continue")').catch(() => {});
console.error(' Missing account linking:', await page.locator('button[data-a-target="gms-cta"]').innerText()); })(),
} else { ];
const code = await page.inputValue('input[type="text"]'); await Promise.any(claimOptions);
console.log(' Code to redeem game:', code); try {
db.data[user][title].code = code; await page.click('button:has-text("Continue")');
db.data[user][title].status = 'claimed'; } catch {
// notify_game.status = `<a href="${redeem[store]}">${redeem_action}</a> ${code} on ${store}`; // continue button not always present
}
const linkAccountButton = page.locator('[data-a-target="LinkAccountButton"]');
let unlinked_store;
if (await linkAccountButton.count()) {
unlinked_store = await linkAccountButton.first().getAttribute('aria-label');
console.debug(' LinkAccountButton label:', unlinked_store);
const match = unlinked_store?.match(/Link (.*) account/);
const extracted = match?.[1];
if (extracted) unlinked_store = extracted;
} else if (await page.locator('text=Link game account').count()) { // epic-games only?
console.error(' Missing account linking (epic-games specific button?):', await page.locator('button[data-a-target="gms-cta"]').innerText()); // track account-linking UI drift
unlinked_store = 'epic-games';
}
if (unlinked_store) {
console.error(' Missing account linking:', unlinked_store, url);
dlc_unlinked[unlinked_store] ??= [];
dlc_unlinked[unlinked_store].push(title);
} else {
const code = await page.inputValue('input[type="text"]').catch(() => undefined);
console.log(' Code to redeem game:', chalk.blue(code));
db.data[user][title].code = code;
db.data[user][title].status = 'claimed';
}
} catch (error) {
console.error(error);
} finally {
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
await page.click('button[data-type="InGameLoot"]');
} }
// await page.pause();
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
await page.click('button[data-type="InGameLoot"]');
} }
console.log('DLC: Unlinked accounts:', dlc_unlinked);
} }
} catch (error) { } catch (error) {
console.error(error); // .toString()?
process.exitCode ||= 1; process.exitCode ||= 1;
if (error.message && process.exitCode != 130) console.error('--- Exception:');
notify(`prime-gaming failed: ${error.message.split('\n')[0]}`); console.error(error); // .toString()?
if (error.message && process.exitCode != 130) notify(`prime-gaming failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.length) { // list should only include claimed games if (notify_games.length) { // list should only include claimed games
notify(`prime-gaming (${user}):<br>${html_game_list(notify_games)}`); notify(`prime-gaming (${user}):<br>${html_game_list(notify_games)}`);
} }
} }
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close(); await context.close();

9
sonar-project.properties Normal file
View file

@ -0,0 +1,9 @@
sonar.organization=vogler
sonar.projectKey=vogler_free-games-claimer
# relative paths to source directories. More details and properties are described
# in https://sonarcloud.io/documentation/project-administration/narrowing-the-focus/
sonar.sources=.
#Eslint issues
sonar.eslint.reportPaths = eslint_report.json

View file

@ -5,13 +5,18 @@ dotenv.config({ path: 'data/config.env' }); // loads env vars from file - will n
// Options - also see table in README.md // Options - also see table in README.md
export const cfg = { export const cfg = {
debug: process.env.PWDEBUG == '1', // runs non-headless and opens https://playwright.dev/docs/inspector debug: process.env.DEBUG == '1' || process.env.PWDEBUG == '1', // runs non-headless and opens https://playwright.dev/docs/inspector
debug_network: process.env.DEBUG_NETWORK == '1', // log network requests and responses
record: process.env.RECORD == '1', // `recordHar` (network) + `recordVideo` record: process.env.RECORD == '1', // `recordHar` (network) + `recordVideo`
time: process.env.TIME == '1', // log duration of each step
dryrun: process.env.DRYRUN == '1', // don't claim anything dryrun: process.env.DRYRUN == '1', // don't claim anything
interactive: process.env.INTERACTIVE == '1', // confirm to claim, default skip
show: process.env.SHOW == '1', // run non-headless show: process.env.SHOW == '1', // run non-headless
get headless() { return !this.debug && !this.show }, get headless() {
width: Number(process.env.WIDTH) || 1280, // width of the opened browser return !this.debug && !this.show;
height: Number(process.env.HEIGHT) || 1280, // height of the opened browser },
width: Number(process.env.WIDTH) || 1920, // width of the opened browser
height: Number(process.env.HEIGHT) || 1080, // height of the opened browser
timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s timeout: (Number(process.env.TIMEOUT) || 60) * 1000, // default timeout for playwright is 30s
login_timeout: (Number(process.env.LOGIN_TIMEOUT) || 180) * 1000, // higher timeout for login, will wait twice: prompt + wait for manual login login_timeout: (Number(process.env.LOGIN_TIMEOUT) || 180) * 1000, // higher timeout for login, will wait twice: prompt + wait for manual login
novnc_port: process.env.NOVNC_PORT, // running in docker if set novnc_port: process.env.NOVNC_PORT, // running in docker if set
@ -20,15 +25,14 @@ export const cfg = {
get dir() { // avoids ReferenceError: Cannot access 'dataDir' before initialization get dir() { // avoids ReferenceError: Cannot access 'dataDir' before initialization
return { return {
browser: process.env.BROWSER_DIR || dataDir('browser'), // for multiple accounts or testing browser: process.env.BROWSER_DIR || dataDir('browser'), // for multiple accounts or testing
screenshots: process.env.SCREENSHOTS_DIR || dataDir('screenshots'), // if not wanted: /dev/null screenshots: process.env.SCREENSHOTS_DIR || dataDir('screenshots'), // set to 0 to disable screenshots
} };
}, },
// auth epic-games // auth epic-games
eg_email: process.env.EG_EMAIL || process.env.EMAIL, eg_email: process.env.EG_EMAIL || process.env.EMAIL,
eg_password: process.env.EG_PASSWORD || process.env.PASSWORD, eg_password: process.env.EG_PASSWORD || process.env.PASSWORD,
eg_otpkey: process.env.EG_OTPKEY, eg_otpkey: process.env.EG_OTPKEY,
eg_parentalpin: process.env.EG_PARENTALPIN, eg_parentalpin: process.env.EG_PARENTALPIN,
eg_country: process.env.EG_COUNTRY || 'US', // This should fit your account's country since sometimes there are replacements for games that are unavailable-in-region. See country/region under https://www.epicgames.com/account/personal and use its two-letter country code.
// auth prime-gaming // auth prime-gaming
pg_email: process.env.PG_EMAIL || process.env.EMAIL, pg_email: process.env.PG_EMAIL || process.env.EMAIL,
pg_password: process.env.PG_PASSWORD || process.env.PASSWORD, pg_password: process.env.PG_PASSWORD || process.env.PASSWORD,
@ -37,9 +41,13 @@ export const cfg = {
gog_email: process.env.GOG_EMAIL || process.env.EMAIL, gog_email: process.env.GOG_EMAIL || process.env.EMAIL,
gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD, gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD,
gog_newsletter: process.env.GOG_NEWSLETTER == '1', // do not unsubscribe from newsletter after claiming a game gog_newsletter: process.env.GOG_NEWSLETTER == '1', // do not unsubscribe from newsletter after claiming a game
// auth AliExpress
ae_email: process.env.AE_EMAIL || process.env.EMAIL,
ae_password: process.env.AE_PASSWORD || process.env.PASSWORD,
// OTP only via GOG_EMAIL, can't add app... // OTP only via GOG_EMAIL, can't add app...
// experimmental
// experimmental - likely to change
pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores
lg_email: process.env.LG_EMAIL || process.env.PG_EMAIL || process.env.EMAIL, // prime-gaming: external: legacy-games: email to use for redeeming
pg_claimdlc: process.env.PG_CLAIMDLC == '1', // prime-gaming: claim in-game content pg_claimdlc: process.env.PG_CLAIMDLC == '1', // prime-gaming: claim in-game content
pg_timeLeft: Number(process.env.PG_TIMELEFT), // prime-gaming: check time left to claim and skip game if there are more than PG_TIMELEFT days left to claim it
}; };

View file

@ -1,11 +1,10 @@
import { existsSync } from 'fs'; import { existsSync } from 'node:fs';
import { Low } from 'lowdb'; import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node'; import { JSONFile } from 'lowdb/node';
import { datetime } from './util.js'; import { datetime } from './util.js';
const datetime_UTCtoLocalTimezone = async file => { const datetime_UTCtoLocalTimezone = async file => {
if (!existsSync(file)) if (!existsSync(file)) return console.error('File does not exist:', file);
return console.error('File does not exist:', file);
const db = new Low(new JSONFile(file)); const db = new Low(new JSONFile(file));
await db.read(); await db.read();
db.data ||= {}; db.data ||= {};
@ -19,7 +18,6 @@ const datetime_UTCtoLocalTimezone = async file => {
db.data[user][game].time = time2; db.data[user][game].time = time2;
} }
} }
// console.log(db.data);
await db.write(); // write out json db await db.write(); // write out json db
}; };

148
src/util.js Normal file
View file

@ -0,0 +1,148 @@
// https://stackoverflow.com/questions/46745014/alternative-for-dirname-in-node-js-when-using-es6-modules
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// not the same since these will give the absolute paths for this file instead of for the file using them
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// explicit object instead of Object.fromEntries since the built-in type would loose the keys, better type: https://dev.to/svehla/typescript-object-fromentries-389c
export const dataDir = s => path.resolve(__dirname, '..', 'data', s);
// modified path.resolve to return null if first argument is '0', used to disable screenshots
export const resolve = (...a) => a.length && a[0] == '0' ? null : path.resolve(...a);
// json database
import { JSONFilePreset } from 'lowdb/node';
export const jsonDb = (file, defaultData) => JSONFilePreset(dataDir(file), defaultData);
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// date and time as UTC (no timezone offset) in nicely readable and sortable format, e.g., 2022-10-06 12:05:27.313
export const datetimeUTC = (d = new Date()) => d.toISOString().replace('T', ' ').replace('Z', '');
// same as datetimeUTC() but for local timezone, e.g., UTC + 2h for the above in DE
export const datetime = (d = new Date()) => datetimeUTC(new Date(d.getTime() - d.getTimezoneOffset() * 60000));
export const filenamify = s => s
.replaceAll(':', '.')
.replaceAll(/[^a-z0-9 _\-.]/gi, '_'); // alternative: https://www.npmjs.com/package/filenamify - On Unix-like systems, / is reserved. On Windows, <>:"/\|?* along with trailing periods are reserved.
export const handleSIGINT = (context = null) => process.on('SIGINT', async () => { // e.g. when killed by Ctrl-C
console.error('\nInterrupted by SIGINT. Exit!'); // Exception shows where the script was:\n'); // killed before catch in docker...
process.exitCode = 130; // 128+SIGINT to indicate to parent that process was killed
if (context) await context.close(); // in order to save recordings also on SIGINT, we need to disable Playwright's handleSIGINT and close the context ourselves
});
export const launchChromium = async options => {
const { chromium } = await import('playwright-chromium'); // stealth plugin needs no outdated playwright-extra
// https://www.nopecha.com extension source from https://github.com/NopeCHA/NopeCHA/releases/tag/0.1.16
// const ext = path.resolve('nopecha'); // used in Chromium, currently not needed in Firefox
const context = chromium.launchPersistentContext(cfg.dir.browser, {
// chrome will not work in linux arm64, only chromium
// channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
args: [ // https://peter.sh/experiments/chromium-command-line-switches
// don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.'
// '--restore-last-session', // does not apply for crash/killed
'--hide-crash-restore-bubble',
// `--disable-extensions-except=${ext}`,
// `--load-extension=${ext}`,
],
// ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'.
...options,
});
return context;
};
export const stealth = async context => {
// stealth with playwright: https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212
// https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
const enabledEvasions = [
'chrome.app',
'chrome.csi',
'chrome.loadTimes',
'chrome.runtime',
// 'defaultArgs',
'iframe.contentWindow',
'media.codecs',
'navigator.hardwareConcurrency',
'navigator.languages',
'navigator.permissions',
'navigator.plugins',
// 'navigator.vendor',
'navigator.webdriver',
'sourceurl',
// 'user-agent-override', // doesn't work since playwright has no page.browser()
'webgl.vendor',
'window.outerdimensions',
];
const stealth = {
callbacks: [],
async evaluateOnNewDocument(...args) {
this.callbacks.push({ cb: args[0], a: args[1] });
},
};
for (const e of enabledEvasions) {
const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`);
evasion.default().onPageCreated(stealth);
}
for (const evasion of stealth.callbacks) {
await context.addInitScript(evasion.cb, evasion.a);
}
};
// used prompts before, but couldn't cancel prompt
// alternative inquirer is big (node_modules 29MB, enquirer 9.7MB, prompts 9.8MB, none 9.4MB) and slower
// open issue: prevents handleSIGINT() to work if prompt is cancelled with Ctrl-C instead of Escape: https://github.com/enquirer/enquirer/issues/372
import Enquirer from 'enquirer'; const enquirer = new Enquirer();
const timeoutHint = () => 'timeout';
const cancelPromptWithHint = prompt => {
prompt.hint = timeoutHint;
prompt.cancel();
};
const applyPromptTimeout = (prompt, timeout) => {
if (!timeout) return;
const timer = setTimeout(cancelPromptWithHint, timeout, prompt);
const clearTimer = () => clearTimeout(timer);
prompt.on('submit', clearTimer);
prompt.on('cancel', clearTimer);
};
// cancel prompt after timeout ms; can be disabled per prompt via options.timeout = 0
const timeoutPlugin = defaultTimeout => enquirerInstance => {
const onPrompt = prompt => applyPromptTimeout(prompt, prompt.options?.timeout ?? defaultTimeout);
enquirerInstance.on('prompt', onPrompt);
};
enquirer.use(timeoutPlugin(cfg.login_timeout));
// single prompt that just returns the non-empty value instead of an object
// @ts-ignore
export const prompt = o => enquirer.prompt({ name: 'name', type: 'input', message: 'Enter value', ...o }).then(r => r.name).catch(() => {});
export const confirm = o => prompt({ type: 'confirm', message: 'Continue?', ...o });
// notifications via apprise CLI
import { execFile } from 'node:child_process';
import { cfg } from './config.js';
export const notify = html => new Promise(resolve => {
if (!cfg.notify) {
if (cfg.debug) console.debug('notify: NOTIFY is not set!');
return resolve();
}
const appriseBin = process.env.APPRISE_BIN || '/usr/local/bin/apprise';
const args = [cfg.notify, '-i', 'html', '-b', `'${html}'`];
if (cfg.notify_title) args.push('-t', cfg.notify_title);
if (cfg.debug) console.debug(`${appriseBin} ${args.join(' ')}`); // this also doesn't escape, but it's just for info
execFile(appriseBin, args, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
if (error.message.includes('command not found')) {
console.info('Run `pip install apprise`. See https://github.com/vogler/free-games-claimer#notifications');
}
// don't fail the whole run on notification errors
return resolve();
}
if (stderr) console.error(`stderr: ${stderr}`);
if (stdout) console.log(`stdout: ${stdout}`);
resolve();
});
});
export const escapeHtml = unsafe => unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('\'', '&#039;');
export const html_game_list = games => games.map(g => `- <a href="${g.url}">${escapeHtml(g.title)}</a> (${g.status})`).join('<br>');

41
src/version.js Normal file
View file

@ -0,0 +1,41 @@
import { log } from 'node:console';
import { execFile } from 'node:child_process';
const gitBin = process.env.GIT_BIN || '/usr/bin/git';
const runGit = (...args) => new Promise((resolve, reject) => {
execFile(gitBin, args, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (stderr) console.error(`stderr: ${stderr}`);
if (error) {
console.log(`error: ${error.message}`);
if (error.code === 'ENOENT' || error.message.includes('command not found')) {
console.info('Install git to check for updates!');
}
return reject(error);
}
resolve(stdout.trim());
});
});
let sha, date;
if (process.env.NOVNC_PORT) {
log('Running inside Docker.');
['COMMIT', 'BRANCH', 'NOW'].forEach(v => log(` ${v}:`, process.env[v]));
sha = process.env.COMMIT;
date = process.env.NOW;
} else {
log('Not running inside Docker.');
sha = await runGit('rev-parse', 'HEAD');
date = await runGit('show', '-s', '--format=%cD'); // same as format as `date -R` (RFC2822)
}
const gh = await (await fetch('https://api.github.com/repos/vogler/free-games-claimer/commits/main')).json();
log('Local commit:', sha, new Date(date));
log('Online commit:', gh.sha, new Date(gh.commit.committer.date));
if (sha == gh.sha) {
log('Running the latest version!');
} else {
log('Not running the latest version!');
}

70
steam-games.js Normal file
View file

@ -0,0 +1,70 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { jsonDb, prompt } from './src/util.js';
import { cfg } from './src/config.js';
const db = await jsonDb('steam-games.json', {});
const user = cfg.steam_id || await prompt({ message: 'Enter Steam community id ("View my profile", then copy from URL)' });
// using https://github.com/apify/fingerprint-suite worked, but has no launchPersistentContext...
// from https://github.com/apify/fingerprint-suite/issues/162
import { FingerprintInjector } from 'fingerprint-injector';
import { FingerprintGenerator } from 'fingerprint-generator';
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
devices: ['desktop'],
operatingSystems: ['windows'],
});
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless,
locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL
userAgent: fingerprint.navigator.userAgent,
viewport: {
width: fingerprint.screen.width,
height: fingerprint.screen.height,
},
extraHTTPHeaders: {
'accept-language': headers['accept-language'],
},
});
await new FingerprintInjector().attachFingerprintToPlaywright(context, { fingerprint, headers });
context.setDefaultTimeout(cfg.debug ? 0 : cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
try {
await page.goto(`https://steamcommunity.com/id/${user}/games?tab=all`);
const games = page.locator('div[data-featuretarget="gameslist-root"] > div.Panel > div.Panel > div');
await games.last().waitFor();
await page.keyboard.press('End');
await page.waitForLoadState('networkidle');
console.log('All Games:', await games.count());
for (const game of await games.all()) {
const title = await game.locator('span a').innerText();
let time, last, achievements, size;
const ltime = game.locator('span:has-text("total played")');
if (await ltime.count()) time = (await ltime.first().innerText()).split('\n')[1];
const llast = game.locator('span:has-text("last played")');
if (await llast.count()) last = (await llast.first().innerText()).split('\n')[1];
const lachievements = game.locator('a:has-text("achievements") + span');
if (await lachievements.count()) achievements = (await lachievements.first().innerText()).split('\n');
const lsize = game.locator('span:has(+ button)');
if (await lsize.count()) size = await lsize.first().innerText();
const url = await game.locator('a').first().getAttribute('href');
const img = await game.locator('img').first().getAttribute('src');
const stat = { title, time, last, achievements, size, url, img };
console.log(stat);
db.data[title] = stat;
}
} catch (error) {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()?
} finally {
await db.write(); // write out json db
}
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close();

42
test/notify.js Normal file
View file

@ -0,0 +1,42 @@
import { delay, html_game_list, notify } from '../src/util.js';
import { cfg } from '../src/config.js';
const URL_CLAIM = 'https://gaming.amazon.com/home'; // dummy URL
console.debug('NOTIFY:', cfg.notify);
const scenarios = [
{
enabled: process.env.TEST_NOTIFY_EPIC === '1',
title: 'epic-games',
games: [
{ title: 'Epistory - Typing Chronicles', status: 'claimed', url: URL_CLAIM },
],
},
{
enabled: process.env.TEST_NOTIFY_PG === '1',
delayMs: 1000,
title: 'prime-gaming',
games: [
{ title: 'Faraway 2: Jungle Escape', status: 'claimed', url: URL_CLAIM },
{ title: 'Chicken Police - Paint it RED!', status: 'claimed', url: URL_CLAIM },
{ title: 'Lawn Mowing Simulator', status: 'claimed', url: URL_CLAIM },
{ title: 'Breathedge', status: 'claimed', url: URL_CLAIM },
{ title: 'The Evil Within 2', status: `<a href="${URL_CLAIM}">redeem</a> H97S6FB38FA6D09DEA on gog.com`, url: URL_CLAIM },
{ title: 'Beat Cop', status: `<a href="${URL_CLAIM}">redeem</a> BMKM8558EC55F7B38F on gog.com`, url: URL_CLAIM },
{ title: 'Dishonored 2', status: `<a href="${URL_CLAIM}">redeem</a> NNEK0987AB20DFBF8F on gog.com`, url: URL_CLAIM },
],
},
{
enabled: process.env.TEST_NOTIFY_GOG === '1',
delayMs: 1000,
title: 'gog',
games: [{ title: 'Haven Park', status: 'claimed', url: URL_CLAIM }],
},
];
for (const scenario of scenarios) {
if (!scenario.enabled) continue;
if (scenario.delayMs) await delay(scenario.delayMs);
await notify(`${scenario.title}:<br>${html_game_list(scenario.games)}`);
}

View file

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

View file

@ -0,0 +1,18 @@
// https://github.com/enquirer/enquirer/issues/372
import { prompt, handleSIGINT } from '../src/util.js';
handleSIGINT();
console.log('hello');
console.error('hello error');
try {
const first = await prompt(); // SIGINT no longer handled if this is executed
const second = await prompt(); // SIGINT no longer handled if this is executed
console.log('values:', first, second);
setTimeout(() => console.log('timeout 3s'), 3000);
} catch (e) {
process.exitCode ||= 1;
console.log('catch. exitCode:', process.exitCode);
console.error(e);
}
console.log('end. exitCode:', process.exitCode);

View file

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

View file

@ -1,73 +1,86 @@
// TODO This is mostly a copy of epic-games.js
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra
import { authenticator } from 'otplib'; import { authenticator } from 'otplib';
import path from 'path'; import path from 'node:path';
import { existsSync, writeFileSync } from 'fs'; import { writeFileSync } from 'node:fs';
import { jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './util.js'; import { resolve, jsonDb, datetime, stealth, filenamify, prompt, notify, html_game_list, handleSIGINT } from './src/util.js';
import { cfg } from './config.js'; import { cfg } from './src/config.js';
const screenshot = (...a) => resolve(cfg.dir.screenshots, 'unrealengine', ...a);
const URL_CLAIM = 'https://www.unrealengine.com/marketplace/en-US/assets?count=20&sortBy=effectiveDate&sortDir=DESC&start=0&tag=4910'; const URL_CLAIM = 'https://www.unrealengine.com/marketplace/en-US/assets?count=20&sortBy=effectiveDate&sortDir=DESC&start=0&tag=4910';
const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM; const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM;
console.log(datetime(), 'started checking unrealengine'); console.log(datetime(), 'started checking unrealengine');
const db = await jsonDb('unrealengine.json'); const db = await jsonDb('unrealengine.json', {});
db.data ||= {};
handleSIGINT();
// https://playwright.dev/docs/auth#multi-factor-authentication // https://playwright.dev/docs/auth#multi-factor-authentication
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height }, viewport: { width: cfg.width, height: cfg.height },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated? userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // Windows UA avoids "device not supported"; update when browser version changes
// userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0 locale: 'en-US', // ignore OS locale to be sure to have english text for locators
locale: "en-US", // ignore OS locale to be sure to have english text for locators recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800
recordHar: cfg.record ? { path: `data/record/ue-${filenamify(datetime())}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools
handleSIGINT: false, // have to handle ourselves and call context.close(), otherwise recordings from above won't be saved
}); });
handleSIGINT(context);
await stealth(context); await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); await page.setViewportSize({ width: cfg.width, height: cfg.height }); // workaround for https://github.com/vogler/free-games-claimer/issues/277 until Playwright fixes it
const notify_games = []; const notify_games = [];
let user; let user;
try { try {
await context.addCookies([{name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5*24*60*60*1000).toISOString(), domain: '.epicgames.com', path: '/'}]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago. await context.addCookies([{ name: 'OptanonAlertBoxClosed', value: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), domain: '.epicgames.com', path: '/' }]); // Accept cookies to get rid of banner to save space on screen. Set accept time to 5 days ago.
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // 'domcontentloaded' faster than default 'load' https://playwright.dev/docs/api/class-page#page-goto
while (await page.locator('a[role="button"]:has-text("Sign In")').count() > 0) { await page.waitForResponse(r => r.request().method() == 'POST' && r.url().startsWith('https://graphql.unrealengine.com/ue/graphql'));
while (await page.locator('unrealengine-navigation').getAttribute('isloggedin') != 'true') {
console.error('Not signed in anymore. 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.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`);
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout); // give user some extra time to log in
console.info(`Login timeout is ${cfg.login_timeout/1000} seconds!`); console.info(`Login timeout is ${cfg.login_timeout / 1000} seconds!`);
await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' });
if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.'); if (cfg.eg_email && cfg.eg_password) console.info('Using email and password from environment.');
else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); else console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).');
const email = cfg.eg_email || await prompt({message: 'Enter email'}); const email = cfg.eg_email || await prompt({ message: 'Enter email' });
const password = email && (cfg.eg_password || await prompt({type: 'password', message: 'Enter password'})); const password = email && (cfg.eg_password || await prompt({ type: 'password', message: 'Enter password' }));
if (email && password) { if (email && password) {
await page.click('text=Sign in with Epic Games');
await page.fill('#email', email); await page.fill('#email', email);
await page.click('button[type="submit"]');
await page.fill('#password', password); await page.fill('#password', password);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
page.waitForSelector('#h_captcha_challenge_login_prod iframe').then(() => { const watchCaptchaDuringLogin = async () => {
console.error('Got a captcha during login (likely due to too many attempts)! You may solve it in the browser, get a new IP or try again in a few hours.'); try {
notify('unrealengine: got captcha during login. Please check.'); await page.waitForSelector('#h_captcha_challenge_login_prod iframe', { timeout: 15000 });
}).catch(_ => { }); 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.');
// handle MFA, but don't await it notify('unrealengine: got captcha during login. Please check.');
page.waitForURL('**/id/login/mfa**').then(async () => { } catch {
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 ...'); return;
// TODO locator for text (email or app?) }
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.type('input[name="code-input-0"]', otp.toString()); const watchMfa = async () => {
await page.click('button[type="submit"]'); try {
}).catch(_ => { }); await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout });
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;
}
};
watchCaptchaDuringLogin();
watchMfa();
} else { } else {
console.log('Waiting for you to login in the browser.'); console.log('Waiting for you to login in the browser.');
await notify('unrealengine: no longer signed in and not enough options set for automatic login.'); await notify('unrealengine: no longer signed in and not enough options set for automatic login.');
@ -77,11 +90,11 @@ try {
process.exit(1); process.exit(1);
} }
} }
await page.waitForURL(URL_CLAIM); await page.waitForURL('**unrealengine.com/marketplace/**');
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
} }
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
user = await page.locator('.user-label').first().innerHTML(); user = await page.locator('unrealengine-navigation').getAttribute('displayname'); // 'null' if !isloggedin
console.log(`Signed in as ${user}`); console.log(`Signed in as ${user}`);
db.data[user] ||= {}; db.data[user] ||= {};
@ -98,7 +111,7 @@ try {
const notify_game = { title, url, status: 'failed' }; const notify_game = { title, url, status: 'failed' };
notify_games.push(notify_game); // status is updated below notify_games.push(notify_game); // status is updated below
// if (await p.locator('.btn .add-review-btn').count()) { // did not work // if (await p.locator('.btn .add-review-btn').count()) { // did not work
if((await p.getAttribute('class')).includes('asset--owned')) { if ((await p.getAttribute('class')).includes('asset--owned')) {
console.log(' ↳ Already claimed'); console.log(' ↳ Already claimed');
if (db.data[user][id].status != 'claimed') { if (db.data[user][id].status != 'claimed') {
db.data[user][id].status = 'existed'; db.data[user][id].status = 'existed';
@ -108,33 +121,40 @@ try {
} }
if (await p.locator('.btn .in-cart').count()) { if (await p.locator('.btn .in-cart').count()) {
console.log(' ↳ Already in cart'); console.log(' ↳ Already in cart');
continue; } else {
await p.locator('.btn .add').click();
console.log(' ↳ Added to cart');
} }
await p.locator('.btn .add').click();
console.log(' ↳ Added to cart');
ids.push(id); ids.push(id);
} }
if (!ids.length) { if (ids.length === 0) {
console.log('Nothing to claim'); console.log('Nothing to claim');
} else { } else {
await page.waitForTimeout(2000);
const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' '); const price = (await page.locator('.shopping-cart .total .price').innerText()).split(' ');
console.log('Price: ', price[1], 'instead of', price[0]); console.log('Price: ', price[1], 'instead of', price[0]);
if (price[1] != '0') { if (price[1] != '0') {
console.error('Price is not 0! Exit!'); const err = 'Price is not 0! Exit! Please <a href="https://github.com/vogler/free-games-claimer/issues/44">report</a>.';
console.error(err);
notify('unrealengine: ' + err);
process.exit(1); process.exit(1);
} }
// await page.pause();
console.log('Click shopping cart'); console.log('Click shopping cart');
await page.locator('.shopping-cart').click(); await page.locator('.shopping-cart').click();
// await page.waitForTimeout(2000);
await page.locator('button.checkout').click(); await page.locator('button.checkout').click();
console.log('Click checkout'); console.log('Click checkout');
// maybe: Accept End User License Agreement // maybe: Accept End User License Agreement
page.locator('[name=accept-label]').check().then(() => { const acceptEulaIfPresent = async () => {
console.log('Accept End User License Agreement'); try {
page.locator('span:text-is("Accept")').click() // otherwise matches 'Accept All Cookies' await page.locator('[name=accept-label]').check({ timeout: 10000 });
}).catch(_ => { }); console.log('Accept End User License Agreement');
await page.waitForSelector('#webPurchaseContainer iframe'); // TODO needed? await page.locator('span:text-is("Accept")').click(); // otherwise matches 'Accept All Cookies'
} catch {
return;
}
};
acceptEulaIfPresent();
await page.waitForSelector('#webPurchaseContainer iframe');
const iframe = page.frameLocator('#webPurchaseContainer iframe'); const iframe = page.frameLocator('#webPurchaseContainer iframe');
if (cfg.debug) await page.pause(); if (cfg.debug) await page.pause();
@ -149,14 +169,27 @@ try {
// I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872 // I Agree button is only shown for EU accounts! https://github.com/vogler/free-games-claimer/pull/7#issuecomment-1038964872
const btnAgree = iframe.locator('button:has-text("I Agree")'); const btnAgree = iframe.locator('button:has-text("I Agree")');
btnAgree.waitFor().then(() => btnAgree.click()).catch(_ => { }); // EU: wait for and click 'I Agree' const acceptIfRequired = async () => {
try {
await btnAgree.waitFor({ timeout: 10000 });
await btnAgree.click();
} catch {
return;
}
}; // EU: wait for and click 'I Agree'
acceptIfRequired();
try { try {
// context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s? // context.setDefaultTimeout(100 * 1000); // give time to solve captcha, iframe goes blank after 60s?
const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe'); const captcha = iframe.locator('#h_captcha_challenge_checkout_free_prod iframe');
captcha.waitFor().then(async () => { // don't await, since element may not be shown const watchCaptchaChallenge = async () => {
// console.info(' Got hcaptcha challenge! NopeCHA extension will likely solve it.') try {
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 captcha.waitFor({ timeout: 10000 });
}).catch(_ => { }); // may time out if not shown console.error(' Got hcaptcha challenge! Lost trust due to too many login attempts? You can solve the captcha in the browser or get a new IP address.');
} catch {
return;
}
}; // may time out if not shown
watchCaptchaChallenge();
await page.waitForSelector('text=Thank you'); await page.waitForSelector('text=Thank you');
for (const id of ids) { for (const id of ids) {
db.data[user][id].status = 'claimed'; db.data[user][id].status = 'claimed';
@ -164,27 +197,21 @@ try {
} }
notify_games.forEach(g => g.status == 'failed' && (g.status = 'claimed')); notify_games.forEach(g => g.status == 'failed' && (g.status = 'claimed'));
console.log('Claimed successfully!'); console.log('Claimed successfully!');
// context.setDefaultTimeout(cfg.timeout);
} catch (e) { } catch (e) {
console.log(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.'); console.error(' Failed to claim! To avoid captchas try to get a new IP address.');
const p = path.resolve(cfg.dir.screenshots, 'unrealengine', 'failed', `${filenamify(datetime())}.png`); await page.screenshot({ path: screenshot('failed', `${filenamify(datetime())}.png`), fullPage: true });
await page.screenshot({ path: p, fullPage: true });
// db.data[user][id].status = 'failed';
notify_games.forEach(g => g.status = 'failed'); notify_games.forEach(g => g.status = 'failed');
} }
// notify_game.status = db.data[user][game_id].status; // claimed or failed
const p = path.resolve(cfg.dir.screenshots, 'unrealengine', `${filenamify(datetime())}.png`); if (notify_games.length) await page.screenshot({ path: screenshot(`${filenamify(datetime())}.png`), fullPage: false }); // fullPage is quite long...
if (notify_games.length) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long...
console.log('Done'); console.log('Done');
} }
} catch (error) { } catch (error) {
console.error(error); // .toString()?
process.exitCode ||= 1; process.exitCode ||= 1;
if (error.message && process.exitCode != 130) console.error('--- Exception:');
notify(`unrealengine failed: ${error.message.split('\n')[0]}`); console.error(error); // .toString()?
if (error.message && process.exitCode != 130) notify(`unrealengine failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db await db.write(); // write out json db
if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed
@ -192,4 +219,5 @@ try {
} }
} }
if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies())); if (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies()));
if (page.video()) console.log('Recorded video:', await page.video().path());
await context.close(); await context.close();

122
util.js
View file

@ -1,122 +0,0 @@
// https://stackoverflow.com/questions/46745014/alternative-for-dirname-in-node-js-when-using-es6-modules
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// not the same since these will give the absolute paths for this file instead of for the file using them
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// explicit object instead of Object.fromEntries since the built-in type would loose the keys, better type: https://dev.to/svehla/typescript-object-fromentries-389c
export const dataDir = s => path.resolve(__dirname, 'data', s);
// json database
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
export const jsonDb = async file => {
const db = new Low(new JSONFile(dataDir(file)));
await db.read();
return db;
};
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// date and time as UTC (no timezone offset) in nicely readable and sortable format, e.g., 2022-10-06 12:05:27.313
export const datetimeUTC = (d = new Date()) => d.toISOString().replace('T', ' ').replace('Z', '');
// same as datetimeUTC() but for local timezone, e.g., UTC + 2h for the above in DE
export const datetime = (d = new Date()) => datetimeUTC(new Date(d.getTime() - d.getTimezoneOffset() * 60000));
export const filenamify = s => s.replaceAll(':', '.').replace(/[^a-z0-9 _\-.]/gi, '_'); // alternative: https://www.npmjs.com/package/filenamify - On Unix-like systems, / is reserved. On Windows, <>:"/\|?* along with trailing periods are reserved.
export const handleSIGINT = () => process.on('SIGINT', () => { // e.g. when killed by Ctrl-C
console.error('\nInterrupted by SIGINT. Exit!'); // Exception shows where the script was:\n'); // killed before catch in docker...
process.exitCode = 130; // 128+SIGINT to indicate to parent that process was killed
});
// stealth with playwright: https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212
// gets userAgent and then removes "Headless" from it
const newStealthContext = async (browser, contextOptions = {}, debug = false) => {
if (!debug) { // only need to fix userAgent in headless mode
const dummyContext = await browser.newContext();
const originalUserAgent = await (await dummyContext.newPage()).evaluate(() => navigator.userAgent);
await dummyContext.close();
// console.log('originalUserAgent:', originalUserAgent); // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/96.0.4664.110 Safari/537.36
contextOptions = {
...contextOptions,
userAgent: originalUserAgent.replace("Headless", ""), // HeadlessChrome -> Chrome, TODO needed?
};
}
};
export const stealth = async (context) => {
// stealth with playwright: https://github.com/berstend/puppeteer-extra/issues/454#issuecomment-917437212
// https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
const enabledEvasions = [
'chrome.app',
'chrome.csi',
'chrome.loadTimes',
'chrome.runtime',
// 'defaultArgs',
'iframe.contentWindow',
'media.codecs',
'navigator.hardwareConcurrency',
'navigator.languages',
'navigator.permissions',
'navigator.plugins',
// 'navigator.vendor',
'navigator.webdriver',
'sourceurl',
// 'user-agent-override', // doesn't work since playwright has no page.browser()
'webgl.vendor',
'window.outerdimensions'
];
const stealth = {
callbacks: [],
async evaluateOnNewDocument(...args) {
this.callbacks.push({ cb: args[0], a: args[1] });
}
};
for (const e of enabledEvasions) {
const evasion = await import(`puppeteer-extra-plugin-stealth/evasions/${e}/index.js`);
evasion.default().onPageCreated(stealth);
}
for (let evasion of stealth.callbacks) {
await context.addInitScript(evasion.cb, evasion.a);
}
};
// used prompts before, but couldn't cancel prompt
// alternative inquirer is big (node_modules 29MB, enquirer 9.7MB, prompts 9.8MB, none 9.4MB) and slower
// open issue: prevents handleSIGINT() to work if prompt is cancelled with Ctrl-C instead of Escape: https://github.com/enquirer/enquirer/issues/372
import Enquirer from 'enquirer'; const enquirer = new Enquirer();
const timeoutPlugin = timeout => enquirer => { // cancel prompt after timeout ms
enquirer.on('prompt', prompt => {
const t = setTimeout(() => { prompt.hint = () => 'timeout'; prompt.cancel(); }, timeout);
prompt.on('submit', _ => clearTimeout(t));
prompt.on('cancel', _ => clearTimeout(t));
});
}
enquirer.use(timeoutPlugin(cfg.login_timeout)); // TODO may not want to have this timeout for all prompts; better extend Prompt and add a timeout prompt option
// single prompt that just returns the non-empty value instead of an object
export const prompt = o => enquirer.prompt({name: 'name', type: 'input', message: 'Enter value', ...o}).then(r => r.name).catch(_ => {});
// notifications via apprise CLI
import { exec } from 'child_process';
import { cfg } from './config.js';
export const notify = (html) => new Promise((resolve, reject) => {
if (!cfg.notify) return resolve();
const title = cfg.notify_title ? `-t ${cfg.notify_title}` : '';
exec(`apprise ${cfg.notify} -i html '${title}' -b '${html}'`, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
if (error.message.includes('command not found')) {
console.info('Run `pip install apprise`. See https://github.com/vogler/free-games-claimer#notifications');
}
return resolve();
}
if (stderr) console.error(`stderr: ${stderr}`);
if (stdout) console.log(`stdout: ${stdout}`);
resolve();
});
});
export const escapeHtml = (unsafe) => unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
export const html_game_list = games => games.map(g => `- <a href="${g.url}">${escapeHtml(g.title)}</a> (${g.status})`).join('<br>');