107 Commits
v1.2.4 ... v2

Author SHA1 Message Date
Netsky
cb3c249d8b Remove thumbnail and icon_url from ConclusionWebhook
Removed thumbnail and icon_url from footer.
2025-12-27 15:11:38 +01:00
Netsky
d76106d3cc Update URL for fetching queries from GitHub (v2) 2025-12-13 15:33:52 +01:00
Netsky
f5c7ff4907 Delete setup directory 2025-11-11 11:17:41 +01:00
Netsky
d6d3b05836 Revise Nix setup instructions in README
Updated Nix setup instructions for clarity and structure.
2025-11-11 11:16:19 +01:00
Netsky
ba4155bf3f Update run.sh 2025-11-11 11:15:04 +01:00
Netsky
03aa7f167f Add pre-build step before starting script 2025-11-11 11:14:26 +01:00
Netsky
a37d60a9df Revise README for clarity and structure
Updated the README to improve setup instructions and organization.
2025-11-11 11:12:58 +01:00
TheNetsky
2738c85030 2.4.1
THIS IS A HOTFIX TO THE CURRENT STATE TO FIX SOME DOCKER AND STABILITY RELATED ISSUES. ALSO TO REMOVE SOME DEAD CODE.
A PROPER VERSION OF "V2" IS BEING WORKED ON!

- Migrated configuration files from JSONC to JSON
- Removed deprecated setup scripts
- Updated dependencies in package.json and package-lock.json
- Updated README with expanded setup, configuration, and feature documentation
2025-11-10 10:56:57 +01:00
Michael Cammarata
bd96aeb20c Update permissions and line endings in dockerfile (#410)
* Preliminary dockerization with scheduling

Porting working docker implementation (scheduling, etc.) into revised v2

* Update dockerfile to fix line endings for non-unix docker users

* Update dockerfile to fix line endings for non-unix docker users

* Update dockerfile permissions and line endings

Ensure proper permissions and line endings for all scripts for non-Unix docker users

---------

Co-authored-by: Netsky <56271887+TheNetsky@users.noreply.github.com>
2025-11-06 07:42:08 +01:00
Michael Cammarata
996d9485cd Dockerfile, handle non-unix docker users (#409)
* Preliminary dockerization with scheduling

Porting working docker implementation (scheduling, etc.) into revised v2

* Update dockerfile to fix line endings for non-unix docker users

* Update dockerfile to fix line endings for non-unix docker users

---------

Co-authored-by: Netsky <56271887+TheNetsky@users.noreply.github.com>
2025-11-05 15:18:45 +01:00
1OSA
6b6028bb52 Enable health check for cron daemon (#408)
Thanks!
2025-11-05 13:39:30 +01:00
Netsky
368417c5d8 Change login URL to Bing rewards dashboard 2025-11-05 13:12:48 +01:00
Michael Cammarata
49b607d78c Preliminary dockerization with scheduling (#406)
Porting working docker implementation (scheduling, etc.) into revised v2

Co-authored-by: Netsky <56271887+TheNetsky@users.noreply.github.com>
2025-11-04 21:10:46 +01:00
Netsky
8c8bdaf3e5 Change command from 'start:schedule' to 'start' 2025-11-04 19:42:33 +01:00
Netsky
7f0da3098d Change CMD to run 'start' instead of 'start:schedule' 2025-11-04 15:11:13 +01:00
Simon Gardling
398c6db4ad Delete BanPredictor + RiskManager (#404)
Both are completely bogus code and is never even imported or used
2025-11-04 07:33:38 +01:00
Simon Gardling
169faf2b70 Cleanup v2 codebase (#403)
* Remove diagnostics system

* Remove Analytics system

* Remove Buy Mode feature

* Remove Scheduler/heartbeat

* Index: remove printBanner

* Remove auto-update functionality
2025-11-03 21:16:38 +01:00
Simon Gardling
8e9c6308d5 Login: Fix 'Other Ways to Sign In' menu (#402) 2025-11-03 17:17:32 +01:00
Netsky
b2ed289bba Add GNU General Public License v3
Added the GNU General Public License version 3 to the project.
2025-11-03 11:35:51 +01:00
Netsky
2c413dad99 Delete LICENSE 2025-11-03 11:35:01 +01:00
Netsky
5aa05804be Delete docs directory 2025-11-03 11:34:44 +01:00
Netsky
3c1778a7e5 Delete SECURITY.md 2025-11-03 11:33:20 +01:00
Netsky
b78cea16ae Revise README 2025-11-03 11:32:45 +01:00
Light
a9e5693b71 V2.4.0 complete (#395)
* V2.4.0 (#381)

* Updated README.md to reflect version 2.1 and improve the presentation of Microsoft Rewards Automation features.

* Updated version to 2.1.5 in README.md and package.json, added new license and legal notice sections, and improved the configuration script for a better user experience.

* Mise à jour des messages de journalisation et ajout de vérifications pour le chargement des quiz et la présence des options avant de procéder. Suppression de fichiers de configuration obsolètes.

* Added serial protection dialog management for message forwarding, including closing by button or escape.

* feat: Implement BanPredictor for predicting ban risks based on historical data and real-time events

feat: Add ConfigValidator to validate configuration files and catch common issues

feat: Create QueryDiversityEngine to fetch diverse search queries from multiple sources

feat: Develop RiskManager to monitor account activity and assess risk levels dynamically

* Refactor code for consistency and readability; unify string quotes, improve logging with contextual emojis, enhance configuration validation, and streamline risk management logic.

* feat: Refactor BrowserUtil and Login classes for improved button handling and selector management; implement unified selector system and enhance activity processing logic in Workers class.

* feat: Improve logging with ASCII context icons for better compatibility with Windows PowerShell

* feat: Add sample account setup

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* feat: Update Node.js engine requirement to >=20.0.0 and improve webhook avatar handling and big fix Schedule

* Update README.md

* feat: Improve logging for Google Trends search queries and adjust fallback condition

* feat: Update version to 2.2.1 and enhance dashboard data retrieval with improved error handling

* feat: Update version to 2.2.2 and add terms update dialog dismissal functionality

* feat: Update version to 2.2.2 and require Node.js engine >=20.0.0

* feat: Ajouter un fichier de configuration complet pour la gestion des tâches et des performances

* feat: Mettre à jour la version à 2.2.3, modifier le fuseau horaire par défaut et activer les rapports d'analyse

* feat: update doc

* feat: update doc

* Refactor documentation for proxy setup, security guide, and auto-update system

- Updated proxy documentation to streamline content and improve clarity.
- Revised security guide to emphasize best practices and incident response.
- Simplified auto-update documentation, enhancing user understanding of the update process.
- Removed redundant sections and improved formatting for better readability.

* feat: update version to 2.2.7 in package.json

* feat: update version to 2.2.7 in README.md

* feat: improve quiz data retrieval with alternative variables and debug logs

* feat: refactor timeout and selector constants for improved maintainability

* feat: update version to 2.2.8 in package.json and add retry limits in constants

* feat: enhance webhook logging with username, avatar, and color-coded messages

* feat: update .gitignore to include diagnostic folder and bump version to 2.2.8 in package-lock.json

* feat: updated version to 2.3.0 and added new constants to improve the handling of delays and colors in logs

* feat: refactor ConclusionWebhook to improve structure and enhance message formatting

* feat: update setup scripts and version to 2.3.3, refactor paths for improved structure

* feat: refactor setup scripts to run via npm and improve error handling for package.json

* feat: refactor webhook avatar handling to use centralized constant from constants.ts

* feat: mettre à jour la version à 2.3.7 et améliorer le script de mise à jour avec des options de contrôle d'auto-mise à jour

* feat: activer la mise à jour automatique pour la configuration et les comptes

* feat: mettre à jour la version à 2.3.7 et améliorer la gestion des erreurs dans plusieurs fichiers

* feat: améliorer la gestion des erreurs et des délais dans plusieurs fichiers, y compris Axios et ConclusionWebhook

* feat: mettre à jour la version à 2.4.0 et améliorer la documentation sur le contrôle de mise à jour automatique

* feat: increase the number of passes per execution to 3 to improve task capture

* feat: update account management with new file format and filter disabled accounts

* feat: update version to 2.4.0, add reinstallation warning and support .jsonc extensions for configuration files

* fix: fix formatting of reinstallation message in README

* feat: add an important update notice in the README to recommend a complete reinstallation

* fix: remove backup instructions from installation guide in README

* fix: update notice in README for configuration file changes and fresh installation instructions

* fix: fix typographical error in README update notice

* Fix: Update avatar URL in Discord config and remove optional webhook properties

* exploit: add customization options for webhooks and improve notification format

* Fix build
2025-10-23 16:35:32 +02:00
Netsky
2a8ab7242f Revert "V2.4.0 (#381)" (#392)
This reverts commit f2d00225c9.
2025-10-23 13:36:09 +02:00
Light
f2d00225c9 V2.4.0 (#381)
* Updated README.md to reflect version 2.1 and improve the presentation of Microsoft Rewards Automation features.

* Updated version to 2.1.5 in README.md and package.json, added new license and legal notice sections, and improved the configuration script for a better user experience.

* Mise à jour des messages de journalisation et ajout de vérifications pour le chargement des quiz et la présence des options avant de procéder. Suppression de fichiers de configuration obsolètes.

* Added serial protection dialog management for message forwarding, including closing by button or escape.

* feat: Implement BanPredictor for predicting ban risks based on historical data and real-time events

feat: Add ConfigValidator to validate configuration files and catch common issues

feat: Create QueryDiversityEngine to fetch diverse search queries from multiple sources

feat: Develop RiskManager to monitor account activity and assess risk levels dynamically

* Refactor code for consistency and readability; unify string quotes, improve logging with contextual emojis, enhance configuration validation, and streamline risk management logic.

* feat: Refactor BrowserUtil and Login classes for improved button handling and selector management; implement unified selector system and enhance activity processing logic in Workers class.

* feat: Improve logging with ASCII context icons for better compatibility with Windows PowerShell

* feat: Add sample account setup

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* feat: Update Node.js engine requirement to >=20.0.0 and improve webhook avatar handling and big fix Schedule

* Update README.md

* feat: Improve logging for Google Trends search queries and adjust fallback condition

* feat: Update version to 2.2.1 and enhance dashboard data retrieval with improved error handling

* feat: Update version to 2.2.2 and add terms update dialog dismissal functionality

* feat: Update version to 2.2.2 and require Node.js engine >=20.0.0

* feat: Ajouter un fichier de configuration complet pour la gestion des tâches et des performances

* feat: Mettre à jour la version à 2.2.3, modifier le fuseau horaire par défaut et activer les rapports d'analyse

* feat: update doc

* feat: update doc

* Refactor documentation for proxy setup, security guide, and auto-update system

- Updated proxy documentation to streamline content and improve clarity.
- Revised security guide to emphasize best practices and incident response.
- Simplified auto-update documentation, enhancing user understanding of the update process.
- Removed redundant sections and improved formatting for better readability.

* feat: update version to 2.2.7 in package.json

* feat: update version to 2.2.7 in README.md

* feat: improve quiz data retrieval with alternative variables and debug logs

* feat: refactor timeout and selector constants for improved maintainability

* feat: update version to 2.2.8 in package.json and add retry limits in constants

* feat: enhance webhook logging with username, avatar, and color-coded messages

* feat: update .gitignore to include diagnostic folder and bump version to 2.2.8 in package-lock.json

* feat: updated version to 2.3.0 and added new constants to improve the handling of delays and colors in logs

* feat: refactor ConclusionWebhook to improve structure and enhance message formatting

* feat: update setup scripts and version to 2.3.3, refactor paths for improved structure

* feat: refactor setup scripts to run via npm and improve error handling for package.json

* feat: refactor webhook avatar handling to use centralized constant from constants.ts

* feat: mettre à jour la version à 2.3.7 et améliorer le script de mise à jour avec des options de contrôle d'auto-mise à jour

* feat: activer la mise à jour automatique pour la configuration et les comptes

* feat: mettre à jour la version à 2.3.7 et améliorer la gestion des erreurs dans plusieurs fichiers

* feat: améliorer la gestion des erreurs et des délais dans plusieurs fichiers, y compris Axios et ConclusionWebhook

* feat: mettre à jour la version à 2.4.0 et améliorer la documentation sur le contrôle de mise à jour automatique

* feat: increase the number of passes per execution to 3 to improve task capture

* feat: update account management with new file format and filter disabled accounts

* feat: update version to 2.4.0, add reinstallation warning and support .jsonc extensions for configuration files

* fix: fix formatting of reinstallation message in README

* feat: add an important update notice in the README to recommend a complete reinstallation

* fix: remove backup instructions from installation guide in README

* fix: update notice in README for configuration file changes and fresh installation instructions

* fix: fix typographical error in README update notice

* Fix: Update avatar URL in Discord config and remove optional webhook properties

* exploit: add customization options for webhooks and improve notification format
2025-10-23 12:56:14 +02:00
Light
abd6117db3 V2.3.0 Optimization (#380)
* Updated README.md to reflect version 2.1 and improve the presentation of Microsoft Rewards Automation features.

* Updated version to 2.1.5 in README.md and package.json, added new license and legal notice sections, and improved the configuration script for a better user experience.

* Mise à jour des messages de journalisation et ajout de vérifications pour le chargement des quiz et la présence des options avant de procéder. Suppression de fichiers de configuration obsolètes.

* Added serial protection dialog management for message forwarding, including closing by button or escape.

* feat: Implement BanPredictor for predicting ban risks based on historical data and real-time events

feat: Add ConfigValidator to validate configuration files and catch common issues

feat: Create QueryDiversityEngine to fetch diverse search queries from multiple sources

feat: Develop RiskManager to monitor account activity and assess risk levels dynamically

* Refactor code for consistency and readability; unify string quotes, improve logging with contextual emojis, enhance configuration validation, and streamline risk management logic.

* feat: Refactor BrowserUtil and Login classes for improved button handling and selector management; implement unified selector system and enhance activity processing logic in Workers class.

* feat: Improve logging with ASCII context icons for better compatibility with Windows PowerShell

* feat: Add sample account setup

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* feat: Update Node.js engine requirement to >=20.0.0 and improve webhook avatar handling and big fix Schedule

* Update README.md

* feat: Improve logging for Google Trends search queries and adjust fallback condition

* feat: Update version to 2.2.1 and enhance dashboard data retrieval with improved error handling

* feat: Update version to 2.2.2 and add terms update dialog dismissal functionality

* feat: Update version to 2.2.2 and require Node.js engine >=20.0.0

* feat: Ajouter un fichier de configuration complet pour la gestion des tâches et des performances

* feat: Mettre à jour la version à 2.2.3, modifier le fuseau horaire par défaut et activer les rapports d'analyse

* feat: update doc

* feat: update doc

* Refactor documentation for proxy setup, security guide, and auto-update system

- Updated proxy documentation to streamline content and improve clarity.
- Revised security guide to emphasize best practices and incident response.
- Simplified auto-update documentation, enhancing user understanding of the update process.
- Removed redundant sections and improved formatting for better readability.

* feat: update version to 2.2.7 in package.json

* feat: update version to 2.2.7 in README.md

* feat: improve quiz data retrieval with alternative variables and debug logs

* feat: refactor timeout and selector constants for improved maintainability

* feat: update version to 2.2.8 in package.json and add retry limits in constants

* feat: enhance webhook logging with username, avatar, and color-coded messages

* feat: update .gitignore to include diagnostic folder and bump version to 2.2.8 in package-lock.json

* feat: updated version to 2.3.0 and added new constants to improve the handling of delays and colors in logs
2025-10-16 17:59:53 +02:00
Light
4d928d7dd9 V2.1.5 (#379)
* Updated README.md to reflect version 2.1 and improve the presentation of Microsoft Rewards Automation features.

* Updated version to 2.1.5 in README.md and package.json, added new license and legal notice sections, and improved the configuration script for a better user experience.

* Mise à jour des messages de journalisation et ajout de vérifications pour le chargement des quiz et la présence des options avant de procéder. Suppression de fichiers de configuration obsolètes.

* Added serial protection dialog management for message forwarding, including closing by button or escape.

* feat: Implement BanPredictor for predicting ban risks based on historical data and real-time events

feat: Add ConfigValidator to validate configuration files and catch common issues

feat: Create QueryDiversityEngine to fetch diverse search queries from multiple sources

feat: Develop RiskManager to monitor account activity and assess risk levels dynamically

* Refactor code for consistency and readability; unify string quotes, improve logging with contextual emojis, enhance configuration validation, and streamline risk management logic.

* feat: Refactor BrowserUtil and Login classes for improved button handling and selector management; implement unified selector system and enhance activity processing logic in Workers class.

* feat: Improve logging with ASCII context icons for better compatibility with Windows PowerShell

* feat: Add sample account setup

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md
2025-10-15 16:12:15 +02:00
Light
dc7e122bce V2.1 (#375)
* feat: Implement edge version fetching with retry logic and caching

* chore: Update version to 2.1.0 in package.json

* fix: Update package version to 2.1.0 and enhance user agent metadata

* feat: Enhance 2FA handling with improved TOTP input and submission logic

* fix: Refactor getSystemComponents to improve mobile user agent string generation

* feat: Add support for cron expressions for advanced scheduling

* feat: Improve humanization feature with detailed logging for off-days configuration

* feat: Add live log streaming via webhook and enhance logging configuration

* fix: Remove unused @types/cron-parser dependency from devDependencies

* feat: Add cron-parser dependency and enhance Axios error handling for proxy authentication

* feat: Enhance dashboard data retrieval with retry logic and diagnostics capture

* feat: Add ready-to-use sample configurations and update configuration settings for better customization

* feat: Add buy mode detection and configuration methods for enhanced manual redemption

* feat: Migrate configuration from JSON to JSONC format for improved readability and comments support

feat: Implement centralized diagnostics capture for better error handling and reporting

fix: Update documentation references from config.json to config.jsonc

chore: Add .vscode to .gitignore for cleaner project structure

refactor: Enhance humanization and diagnostics capture logic in BrowserUtil and Login classes

* feat: Reintroduce ambiance declarations for the 'luxon' module to unlock TypeScript

* feat: Update search delay settings for improved performance and reliability

* feat: Update README and SECURITY documentation for clarity and improved data handling guidelines

* Enhance README and SECURITY documentation for Microsoft Rewards Script V2

- Updated README.md to improve structure, add badges, and enhance clarity on features and setup instructions.
- Expanded SECURITY.md to provide detailed data handling practices, security guidelines, and best practices for users.
- Included sections on data flow, credential management, and responsible use of the automation tool.
- Added a security checklist for users to ensure safe practices while using the script.

* feat: Réorganiser et enrichir la documentation du README pour une meilleure clarté et accessibilité

* feat: Updated and reorganized the README for better presentation and clarity

* feat: Revised and simplified the README for better clarity and accessibility

* Update README.md
2025-10-11 16:54:07 +02:00
hmcdat
3e499be8a9 fix scheduler exit suddenly (#368) 2025-10-01 18:22:36 +02:00
Michael Cammarata
554227e200 Dockerfile and image optimization (#369)
Refactor of dockerfile to reduce final image from 2.55 gb to 766 mb to support pre-build
2025-10-01 15:50:33 +02:00
Light
15f62963f8 V2 (#365)
* first commit

* Addition of a personalized activity manager and refactoring of the logic of activities

* Adding diagnostics management, including screenshot and HTML content, as well as improvements to humanize page interactions and +.

* Adding the management of newspapers and webhook settings, including filtering messages and improving the structure of the summaries sent.

* Adding a post-execution auto-date functionality, including options to update via Git and Docker, as well as a new configuration interface to manage these parameters.

* Adding accounts in Docker, with options to use an environmental file or online JSON data, as well as minimum validations for responsible accounts.

* Improving the Microsoft Rewards script display with a new headband and better log management, including colors and improved formatting for the console.

* v2

* Refactor ESLint configuration and scripts for improved TypeScript support and project structure

* Addition of the detection of suspended accounts with the gesture of the improved errors and journalization of banishment reasons

* Adding an integrated planner for programmed task execution, with configuration in Config.json and +

* Edit

* Remove texte

* Updating of documentation and adding the management of humanization in the configuration and +.

* Adding manual purchase method allowing users to spend points without automation, with monitoring of expenses and notifications.

* Correction of documentation and improvement of configuration management for manual purchase mode, adding complete documentation and appropriate banner display.

* Add comprehensive documentation for job state persistence, NTFY notifications, proxy configuration, scheduling, and auto-update features

- Introduced job state persistence documentation to track progress and resume tasks.
- Added NTFY push notifications integration guide for real-time alerts.
- Documented proxy configuration options for enhanced privacy and network management.
- Included scheduling configuration for automated script execution.
- Implemented auto-update configuration to keep installations current with Git and Docker options.

* Ajout d'Unt Système de Rapport d'Erreurs Communautaire pour Améliorerer le Débogage, incluant la Configuration et l'Envoi de Résumés D'Erreurs Anonyés à un webhook Discord.

* Mini Edit

* Mise à Jour du Readme.md pour Améliorerer la Présentation et La Claté, Ajout d'Un section sur les notifications en Temps Raine et Mise à Jour des badges pour la meille unibilité.

* Documentation update

* Edit README.md

* Edit

* Update README with legacy version link

* Improvement of location data management and webhooks, adding configurations normalization

* Force update for PR

* Improvement of documentation and configuration options for Cron integration and Docker use

* Improvement of planning documentation and adding a multi-pan-pancake in the daily execution script

* Deletion of the CommunityReport functionality in accordance with the project policy

* Addition of randomization of start -up schedules and surveillance time for planner executions

* Refactor Docker setup to use built-in scheduler, removing cron dependencies and simplifying configuration options

* Adding TOTP support for authentication, update of interfaces and configuration files to include Totp secret, and automatic generation of the Totp code when connecting.

* Fix [LOGIN-NO-PROMPT] No dialogs (xX)

* Reset the Totp field for email_1 in the accounts.example.json file

* Reset the Totp field for email_1 in the Readme.md file

* Improvement of Bing Research: Use of the 'Attacked' method for the research field, management of overlays and adding direct navigation in the event of entry failure.

* Adding a complete security policy, including directives on vulnerability management, coordinated disclosure and user security advice.

* Remove advanced environment variables section from README

* Configuration and dockerfile update: Passage to Node 22, addition of management of the purchase method, deletion of obsolete scripts

* Correction of the order of the sections in the Readme.md for better readability

* Update of Readm and Security Policy: Addition of the method of purchase and clarification of security and confidentiality practices.

* Improvement of the readability of the Readm and deletion of the mention of reporting of vulnerabilities in the security document.

* Addition of humanization management and adaptive throttling to simulate more human behavior in bot activities.

* Addition of humanization management: activation/deactivation of human gestures, configuration update and adding documentation on human mode.

* Deletion of community error report functionality to respect the privacy policy

* Addition of immediate banning alerts and vacation configuration in the Microsoft Rewards bot

* Addition of immediate banning alerts and vacation configuration in the Microsoft Rewards bot

* Added scheduling support: support for 12h and 24h formats, added options for time zone, and immediate execution on startup.

* Added window size normalization and page rendering to fit typical screens, with injected CSS styles to prevent excessive zooming.

* Added security incident management: detection of hidden recovery emails, automation blocking, and global alerts. Updated configuration files and interfaces to include recovery emails. Improved security incident documentation.

* Refactor incident alert handling: unified alert sender

* s

* Added security incident management: detect recovery email inconsistencies and send unified alerts. Implemented helper methods to manage alerts and compromised modes.

* Added heartbeat management for the scheduler: integrated a heartbeat file to report liveliness and adjusted the watchdog configuration to account for heartbeat updates.

* Edit webook

* Updated security alert management: fixed the recovery email hidden in the documentation and enabled the conclusion webhook for notifications.

* Improved security alert handling: added structured sending to webhooks for better visibility and updated callback interval in compromised mode.

* Edit conf

* Improved dependency installation: Added the --ignore-scripts option for npm ci and npm install. Updated comments in compose.yaml for clarity.

* Refactor documentation structure and enhance logging:
- Moved documentation files from 'information' to 'docs' directory for better organization.
- Added live logging configuration to support webhook logs with email redaction.
- Updated file paths in configuration and loading functions to accommodate new structure.
- Adjusted scheduler behavior to prevent immediate runs unless explicitly set.
- Improved error handling for account and config file loading.
- Enhanced security incident documentation with detailed recovery steps.

* Fix docs

* Remove outdated documentation on NTFY, Proxy, Scheduling, Security, and Auto-Update configurations; update Browser class to prioritize headless mode based on environment variable.

* Addition of documentation for account management and Totp, Docker Guide, and Update of the Documentation Index.

* Updating Docker documentation: simplification of instructions and adding links to detailed guides. Revision of configuration options and troubleshooting sections.

* Edit

* Edit docs

* Enhance documentation for Scheduler, Security, and Auto-Update features

- Revamped the Scheduler documentation to include detailed features, configuration options, and usage examples.
- Expanded the Security guide with comprehensive incident response strategies, privacy measures, and monitoring practices.
- Updated the Auto-Update section to clarify configuration, methods, and best practices for maintaining system integrity.

* Improved error handling and added crash recovery in the Microsoft Rewards bot. Added configuration for automatic restart and handling of local search queries when trends fail.

* Fixed initial point counting in MicrosoftRewardsBot and improved error handling when sending summaries to webhooks.

* Added unified support for notifications and improved handling of webhook configurations in the normalizeConfig and log functions.

* UPDATE LOGIN

* EDIT LOGIN

* Improved login error handling: added recovery mismatch detection and the ability to switch to password authentication.

* Added a full reference to configuration in the documentation and improved log and error handling in the code.

* Added context management for conclusion webhooks and improved user configuration for notifications.

* Mini edit

* Improved logic for extracting masked emails for more accurate matching during account recovery.
2025-09-26 18:58:33 +02:00
Light
02160a07d9 Small update to be deployed quickly. (#358)
* chore: Update TypeScript configuration and add @types/node as a dev dependency

* feat: Add unified cross-platform setup script for easier configuration and installation

* docs: Revise README for improved setup instructions and clarity

* feat: Enhance setup scripts with improved prerequisite checks and user prompts

* feat: Refactor setup scripts and enhance browser handling with automatic Playwright installation
2025-09-16 09:34:49 +02:00
Light
b66114d4dd feat: Add conclusion webhook support for final summary notifications (#355)
- Updated README.md to include new configuration options for conclusion webhook.
- Enhanced BrowserFunc.ts with improved error handling during page reloads.
- Implemented conclusionWebhook configuration in config.json.
- Refactored Login.ts to use Playwright types and improved passkey handling.
- Added safeClick method in SearchOnBing.ts to handle click timeouts and overlays.
- Introduced account summary collection in index.ts for reporting.
- Created ConclusionWebhook.ts to send structured summaries to a dedicated webhook.
- Updated TypeScript definitions for better type safety across the project.
2025-09-14 08:29:09 +02:00
Light
2e80266ad1 Update Login.ts (#349) 2025-09-10 20:44:20 +02:00
Michael Cammarata
072e96dd53 Improvements to docker scheduling and lock file (#339)
* improved logic that prevents overlapping runs, added logic to kill the process if it is stuck or doesn't exit properly

* Improve lock file logic

Improve lock file logic to release lock if the script didn't finish cleanly on a prior run. Otherwise shouldn't interrupt actual run.

* Tidy up readme for docker
2025-09-10 09:29:28 +02:00
hmcdat
eea4407454 disable fido (passkey, face id, security key) authentication (#342) 2025-08-30 15:22:32 +02:00
Simon Gardling
ad2e0b8bb3 Update Playwright + Use Nix (#311)
* use nix for execution

* update setup instructions

* headless

* headless via xvfb-run
2025-08-22 00:43:17 +02:00
qingzt
ad3b215dbc fix skip 2FA (#332) 2025-08-10 15:10:38 +02:00
Michael Cammarata
f51daf06d6 Docker rewrite and optimizations (#321)
* Optimizations of dockerfile

Massive test optimizations with drop in image size to about 256mb from about 1.2 gb. Drawback is that I currently have to keep the dockerfile playwright version matched to the package.json version

* further optimizations

Removed redundant (hopefully) sessions directory creation during build

* Fix docker cron dependencies

Small fix that should make cron run properly

* Major docker update!

- **Dockerfile rewritten as a multi-stage build**
  - Split into a “builder” stage (`node:18-slim`) to install dependencies and compile TypeScript, and a “runtime” stage (official Playwright image) to run the script.
  - This keeps build tools and dependencies out of the final image, making it smaller, faster to pull, and more secure.

- **Entrypoint script (`entrypoint.sh`)**
  - Introduced an entrypoint that runs inside the container at startup to:
    1. Set the container’s timezone (`TZ`) correctly, based on the environment or defaulting to UTC.
    2. Validate that the user provided a `CRON_SCHEDULE` (exiting early with an error if missing).
    3. Optionally perform an initial run of the script immediately (when `RUN_ON_START=true`), without any random sleep.
  - Centralizing setup in an entrypoint keeps the Dockerfile simpler and ensures proper signal handling.

- **`run_daily.sh` improvements**
  - Removed custom browser-path override so Playwright uses bundled browsers in the official image.
  - Added a lock using `flock` to prevent overlapping runs if a previous run is still in progress.
  - Retained the random sleep between 5 and 50 minutes before each run.
  - Logs are timestamped and clearly report success or failure.

- **Cron template tweaks**
  - Updated `src/crontab.template` so that each job line redirects both stdout and stderr into Docker’s stdout (`>> /proc/1/fd/1 2>&1`), making it easy to view logs via `docker logs`.

- **Initial-run logic**
  - The entrypoint checks `RUN_ON_START=true` and, if set, invokes `npm start` immediately (without random sleep). This provides an immediate first execution on container startup.
  - Scheduled runs via cron still go through the normal `run_daily.sh` (with sleep and locking).

- **Cron logging and visibility**
  - By redirecting cron job output to the container’s stdout, all logs (initial run and scheduled runs) appear in `docker logs`, avoiding the need to tail log files manually.

- **Error handling and validation**
  - Entry point exits early if `CRON_SCHEDULE` is missing, preventing silent misconfiguration.
  - If the initial run fails, it logs a warning but still starts cron so future scheduled runs can proceed.
  - `run_daily.sh` will exit early if a previous run is still active (locking), avoiding overlapping executions.

* Docker (multi-stage) improvements

- added cron logging in entrypoint and fixed timezone support for cron-invoked script runs
- further optimized multi-stage dockerfile
- bumped playwright version to 1.52.0 in dockerfile and package.json
- added customization and enable/disable randomization for cron start times
- optionally add container health  monitor and resource limits in compose.yaml
2025-07-17 12:16:22 +02:00
TheNetsky
e7c27ac16e Fix login issues for mobile 2025-06-08 18:28:37 +02:00
hmcdat
9f5601d44b Fix 2FA problem on new login page (#305)
* fix 2FA on new login page

* change 2fa login checking element

* temporary disable parallel due to errors when script tries to dismiss buttons
2025-05-30 11:43:15 +02:00
TheNetsky
d5fd06d229 1.5.3
Fix issue with pop-up dismissal
2025-04-30 16:20:56 +02:00
TheNetsky
0ddc964878 1.5.2
- Updated packages
- Cleanup
- Improved message dismissal
- Improved login functionality

With help from @LightZirconite
2025-04-24 14:50:22 +02:00
CNOCM
325bf65b30 Update README (#264)
* Update Search.ts

* Update README.md
2025-03-03 10:59:06 +01:00
HMCDAT
6f19bd4b0e Update Dockerfile (#262) 2025-02-28 12:34:47 +01:00
AariaX
caf6a42a38 Make shuffleArray more random? (#254) 2025-02-25 16:14:51 +01:00
TheNetsky
352d47229b Merge branch 'main' of https://github.com/TheNetsky/Microsoft-Rewards-Script 2025-02-24 17:59:28 +01:00
TheNetsky
9a12ee1ec8 Fix geoLocale not being uppercase 2025-02-24 17:59:20 +01:00
Netsky
b630c3ddda Update README.md 2025-02-24 15:52:53 +01:00
Netsky
287e3897da Update README.md 2025-02-24 15:51:08 +01:00
Netsky
fcf6aba446 Bump version 2025-02-24 15:49:45 +01:00
AariaX
1102f2ca94 Google API Update (1.5.1) (#247)
* switch to google internal api

* A bit of tidying up

* A bit more of tidying up

* A bit more of tidying up

* Pre 1.5.1

- Add proxy exclusions
- Update ReadMe
- Update config Interface

---------

Co-authored-by: TheNetsky <56271887+TheNetsky@users.noreply.github.com>
2025-02-24 15:49:19 +01:00
1OSA
82a896e83f added webhooklogexcludefunc (#240) 2025-02-20 23:21:46 +01:00
Netsky
b0bd1f52c4 Update queries.json 2025-02-19 19:32:45 +01:00
Netsky
7e4121e01b Update run_daily.sh 2025-02-19 15:29:51 +01:00
Netsky
849406c44f 1.5 inital (#234)
* 1.4.12

* Update README.md

* Update package.json

* Update package.json

* 1.5 initial

- Added parallel mode (experimental, likely no Docker supported)
- Added chalk for clearer logging
- Added support for "SearchOnBing" Activities
- Added more configurable options for certain things
- Redone some of the popup/banner clicking for searching (Redo the entire "popup" clicking, so they're more specifically targeted)
- Axios proxy is now optional in the config
- Fingerprint saving is now optional for desktop and mobile

There needs to be many changes for Docker support, including parallel, the new config settings and general testing!

This is still highly experimental, if you use Docker or want something more stable, use the version before this commit!

* Add queries.json to build

* fix(Login): update URL within authorization loop to reflect current page (#210)

* Many changes

- Updated Packages
- Fixed mobile searches erroring out for dashboard  data
- Reworked "bad page" detection
- Catching more errors
- Reworked the search and "close tabs"
- More fixes to the login
- Fixed to paralell and clustering, thanks to @AariaX

* Docker 1.5 preliminary support (#211)

* Basic docker functionality for 1.5

Preliminary docker support for 1.5. Requires headless=true, clusters=1

* Tidy up timezone, add TZ to compose file

Minor changes that should improve timezone handling, and (hopefully) improve scheduling function

* updated readme to simplify and clarify docker instructions

also removed env vars from table

* Fix syntax for cron

* Fix scheduling, add .gitattributes to normalize line endings

fixed line endings caused by Windows in crontab.template and run_daily.sh, which were breaking cron and script execution in the Docker container.

* Removed unnecessary scheduling key from config.json

This key isn't necessary for docker or the base script.

* Basic docker functionality for 1.5

Preliminary docker support for 1.5. Requires headless=true, clusters=1

Tidied up timezone, add TZ to compose file

Minor changes that should improve timezone handling, and (hopefully) improve scheduling function

updated readme to simplify and clarify docker instructions

also removed env vars from table

Fixed syntax for cron

Fixed scheduling, add .gitattributes to normalize line endings

Fixed line endings caused by Windows in crontab.template and run_daily.sh, which were breaking cron and script execution in the Docker container.

Removed unnecessary scheduling key from config.json

This key isn't necessary for docker or the base script.

* Improve scheduling handling, show logs in console

Fixes scheduling when RUN_ON_START=true, and fixes scheduled runs not appearing in docker logs.

* Update compose.yaml

revert service and container name, revert volumes for better generalization, add tips to environment to set scheduling, timezone and whether to run on container start

* Update README.md

proper container name

Co-authored-by: AariaX <196196201+AariaX@users.noreply.github.com>

---------

Co-authored-by: AariaX <196196201+AariaX@users.noreply.github.com>

* Fixes

- Reworked some of the point counting
- Reverted back to the "playwright" package
- Fixed error throw for emailPrefill

* Update config.json

* Add pre-build script

* Update package.json

* Handle 2FA in parallel mode (#219)

* catch error in reloadBadPage (#220)

* Use pre-build and simplify dockerfile (#218)

This uses the new pre-build script included in package.json to handle deps greatly simplifying the dockerfile.

* Small improvements

* Small fixes

- Fixed log spam for "Waiting for authorization"
- Increased wait from 2 to 5 seconds
- Increased search to "safer" values for default

* Experimenting with selectors

Seeing #223 I want to try if this is a good new addition, since for most user this SHOULD work just as good as clicking the entire box.

* More stuff

- Added ability to exclude logs by their function name
- Now caching config settings

* fix: don't retry on 0 (#228)

* Improvements

- Check if searches for mobile are enabled before creating the new page in the browser
- Return message if mobile search data cannot be found
- Added more selectors for coupons

* Improve Popup Dismissal

- Now executes in Parallel
- Respects a timeout of 1 second

---------

Co-authored-by: AariaX <196196201+AariaX@users.noreply.github.com>
Co-authored-by: mgrimace <55518507+mgrimace@users.noreply.github.com>
2025-02-15 16:14:47 +01:00
Netsky
d1f4364e18 Change queries format 2024-10-29 15:14:07 +01:00
Netsky
cf79467a4e Update queries.json 2024-10-29 14:03:22 +01:00
Netsky
ae554a0639 Update Queries 2024-10-29 13:56:00 +01:00
Netsky
1a6f9f4ac3 Update queries
Testing stuff
2024-10-28 14:32:36 +01:00
Netsky
45083ed41f Queries for "Search on Bing" activity 2024-10-27 18:02:29 +01:00
mgrimace
8b489d50f7 Dockerfile refactoring (#167)
* Docker updates and maintenance

- Update run_daily.sh to use compose.yaml env var overrides if present on scheduled jobs, not just first run. Defaults back to config.json values if no override specified.
- update sample compose.yaml with volume mapping to keep config.json, accounts.json, and sessions folder persistent on local machine (e.g., to keep data persistent through updates).

* Refactored dockerfile

Combining various steps to reduce layers. Should improve build time and image size.
2024-10-17 21:46:54 +02:00
mgrimace
7151f2351a Docker updates and maintenance (#164)
- Update run_daily.sh to use compose.yaml env var overrides if present on scheduled jobs, not just first run. Defaults back to config.json values if no override specified.
- update sample compose.yaml with volume mapping to keep config.json, accounts.json, and sessions folder persistent on local machine (e.g., to keep data persistent through updates).
2024-10-14 15:21:36 +02:00
Netsky
5afd8cbe1d Update package.json 2024-10-03 15:01:32 +02:00
Netsky
f015649d16 Fix typo 2024-10-02 18:42:01 +02:00
Netsky
034359019c Update README.md 2024-10-02 18:41:06 +02:00
HMCDAT
09ddbee45a 1.4.11 (#146)
* fix misspelling

* fix: avoid skipping eligible items in "More Promotion"

* remove unnecessary output

* Fix Formatting

* replace checking variable

* decrease id length

* revert previous commit

* change condition for eligible promo

* Other fixes

---------

Co-authored-by: TheNetsky <56271887+TheNetsky@users.noreply.github.com>
2024-10-02 18:37:06 +02:00
MuhammaD Usama
7939196e88 Update Dockerfile (#152)
Update Dockerfile for build
2024-09-22 19:46:10 +02:00
TheNetsky
5bc66c5fc9 1.4.10
- Fixed searches for some users
2024-09-02 12:13:38 +02:00
Netsky
c70d6f9cb1 1.4.9 2024-08-30 13:54:35 +02:00
HMCDAT
a47b86e74d Support new quest to get points (#138)
* support passwordless auth (using Authenticator app)

* update readme

* feat: added mobile app tasks (daily check in + read to earn)

* fix some stuff

* make ReadToEarn use the delay config

* fix daily reward search per week

* reorder mobile tasks

* fix message

* Search fixes, reformatting and types

---------

Co-authored-by: TheNetsky <56271887+TheNetsky@users.noreply.github.com>
2024-08-30 13:26:52 +02:00
TheNetsky
ce2a72ee36 1.4.8 2024-08-18 15:06:44 +02:00
CNOCM
755237caa1 Skip locked Promotions (#132) 2024-08-18 14:37:56 +02:00
HMCDAT
2b4cd505c0 Support passwordless auth (#129)
* support passwordless auth (using Authenticator app)

* update readme
2024-07-24 15:01:45 +02:00
Nworm
a39a861dab Update Search.ts (#127) 2024-07-11 14:43:26 +02:00
mgrimace
8d19129906 Docker: improved env var handling (#113)
* Improve env var handling, clarify instructions

updateConfig.js will update dist/config.json with any values specified in the docker compose file as environmental variables (env vars). If not specified it will use the default values in src/config.json (the 'usual' place where folks can customize their config).

A user can make changes to an env var (e.g., disabling Scroll Random Results), then docker compose up -d to quickly restart the container with the change.

* minor update to env vars in table

Make sure to change your compose so the updated flattened values work.

* TZ handling for cron runs of the script

docker logs netsky should now show the proper time zone for script runs that were initiated via cron schedule.
2024-06-01 14:50:29 +02:00
TheNetsky
c6ab80fe54 Delete start.bat
oops
2024-05-28 11:39:34 +02:00
TheNetsky
9b1eed526f 1.4.7
- Updated packages
- Added timeout after scrolling back to top
- Some Docker improvement thanks to @mgrimace
2024-05-28 11:38:49 +02:00
mgrimace
9a144b2e60 Docker with env var support and containerized cron scheduling (#107)
* Script will run on container start, and continue to run daily at 5:00 am.

The goal of this update was to add in-built cronjob support so that the script runs immediately on container start, then will schedule the script to run the daily at 5:00 am without requiring the user to manually create a cronjob. After starting the container and completing the first run, the container should remain active (running) but idle until the next scheduled run. Ideally, the next step will be to include support for environmental variables that will allow the user to customize when the script scheduled runs occur within the docker compose.yaml.

* added --build to docker compose command instructions

building/rebuilding the image (and the script) may reduce problems if folks make changes to their configs

* Updated the crontab default to run twice daily by default, 5am and 11am

* Update crontab

Newline needed

* Environmental variable support, custom scheduling

This is a massive update that includes docker environmental variable support for all config options. If specified, the custom env var value will supersede the default values in the config.json. You do not need to change the values in the config.json if you set a custom env var. For example, if you have headless=false in the config.json, but set it as true in the compose.yaml, it will run as HEADLESS=true. If you do not specify a particular env var, the script will automatically use the default value. Please test and contribute wherever possible!

* Fix scheduled runs, tidied up readme

* timezone support

Minor adjustment to attempt to fix timezone env var support

* Run on start, npm script env

Hopefully two fixes here, the container should again, by default, run on start, then continue per schedule. This defaults to true; however, can be optionally over-ridden in the compose.yaml with the env var RUN_ON_START=false. If set to false, the script should only run on the specified cron schedule. I also passed the TZ write into the npm start command, rather than just the container overall so it should show the proper time.

* Keep docker running after run_on_start to wait for cron

Accidentally set it up like a run_once, this should keep the container active after run_on_start to wait for the next schedule.

* improve order of cmd

This should (1) set time zone for script, (2) run once if set, then (3) idle for scheduled cron job. It was a little hacky before, this should be tidier.

* updated compose to auto-restart container

Noticed the container was exited this am, likely after automated updated to system, adding restart: unless-stopped to compose should keep container running in such circumstances.

* Update env vars in readme
2024-05-16 19:28:55 +02:00
Netsky
28b1881642 Update README.md 2024-05-09 12:38:49 +02:00
TheNetsky
ef6ad569ff 1.4.6 2024-05-09 10:04:11 +02:00
Netsky
da9ba91c5c Merge pull request #98 from jordyamc/main
Bug when detecting earnable points from more promotions
2024-05-07 15:12:10 +02:00
Jordy Mendoza
deb2d58b1b - Fix bug where the script was using x.activityType instead of x.promotionType this was causing that the script wasn't detecting earnable points from more promotions 2024-04-21 23:50:33 -06:00
Netsky
66a82c2584 Merge pull request #89 from mgrimace/docker
Clarify docker instructions, add basic compose.yaml support
2024-04-04 16:43:57 +02:00
mgrimace
8a022d5983 Clarify docker instructions, add basic compose.yaml support
This should help folks get started with docker and hopefully make it easier for testing
2024-04-03 17:59:16 -04:00
TheNetsky
64048e35d7 1.4.5
- Fix login not working
- Sessions are now saved after logging in
- Check if promotions, and searches are available before counting total point amount
2024-03-01 12:19:48 +01:00
TheNetsky
cf7f7ac790 Merge branch 'main' of https://github.com/TheNetsky/Microsoft-Rewards-Script 2024-02-05 11:47:50 +01:00
TheNetsky
f7aa5039f9 1.4.4
- Fixed login getting stuck
- Updated packages
- Fixed some errors not throwing
2024-02-05 11:47:48 +01:00
Netsky
e082fb03f0 Merge pull request #56 from Omniphantasm/patch-1
Update README.md
2024-01-26 18:55:10 +01:00
Omniphantasm
0303b8c605 Update README.md
Script is kill-chrome-win, changed readme to reflect that.
2024-01-26 12:35:30 -05:00
TheNetsky
2fea17c415 1.4.3
- Fixed browser generating mobile browser after the first account
- Hopefully fixed script erroring after not finding parent promotions
- Added support for MacOS (Thanks @alecm858)
- Made some changes to search result scrolling
2024-01-20 12:11:02 +01:00
Netsky
c5beccb54b Update README.md 2024-01-17 22:23:55 +01:00
TheNetsky
b566ccaece 1.4.2
- Scroll back to top before searching
2024-01-17 18:50:43 +01:00
TheNetsky
15b2b827eb 1.4.1
- Fixed typos
- Added Dockerfile (experimental)
- Increased scroll amount
2024-01-14 14:09:57 +01:00
Netsky
02518ee4ba Update README.md 2024-01-07 17:00:23 +01:00
Netsky
69819b5631 Add config explaination 2024-01-07 16:59:15 +01:00
Netsky
b389b87792 Set runOnZeroPoints to false by default 2024-01-07 16:38:47 +01:00
TheNetsky
9ea7f5c452 1.4.0
- Switched from Puppeteer to Playwright
- Fixed mobile searches not working
- Added fingerprint saving in config
- Added mobile search retry in config
2024-01-07 16:33:48 +01:00
TheNetsky
f3fb641ecd 1.3.2
- Better generation of mobile user agents
2023-12-27 11:38:52 +01:00
TheNetsky
bca1e7c896 1.3.1
- Poll clicking will be attempted regardless of the selector being found
- Fix tabs sticking upon search error
- Updated dependencies
2023-12-20 13:03:08 +01:00
Netsky
fd7c8e36d4 Update README.md 2023-12-01 12:12:26 +01:00
Netsky
dcb0c25d46 Remove the feature of Promo Items 2023-12-01 12:02:17 +01:00
TheNetsky
a8cb5482d4 1.3.0 2023-12-01 11:49:14 +01:00
TheNetsky
28286ff9fe Pre 1.3.0 2023-11-23 14:38:58 +01:00
TheNetsky
1d6167aeca Missing await 2023-11-04 16:24:25 +01:00
TheNetsky
3b15fe19a7 1.2.5 2023-11-04 16:21:56 +01:00
63 changed files with 11213 additions and 976 deletions

3
.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
dist/
node_modules/
setup/

View File

@@ -16,10 +16,7 @@ module.exports = {
'@typescript-eslint'
],
'rules': {
'linebreak-style': [
'error',
'unix'
],
'linebreak-style': 'off',
'quotes': [
'error',
'single'
@@ -28,6 +25,10 @@ module.exports = {
'error',
'never'
],
'@typescript-eslint/no-explicit-any':
['warn', {
fixToUnknown: true // This line is optional and only relevant if you are using TypeScript
}],
'comma-dangle': 'off',
'@typescript-eslint/comma-dangle': 'error',
'prefer-arrow-callback': 'error'

28
.eslintrc.json Normal file
View File

@@ -0,0 +1,28 @@
{
"root": true,
"env": {
"es2021": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.json"],
"sourceType": "module",
"ecmaVersion": 2021
},
"plugins": ["@typescript-eslint", "modules-newline"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"modules-newline/import-declaration-newline": ["warn", { "count": 3 }],
"@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports" }],
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "off",
"no-console": ["warn", { "allow": ["error", "warn"] }],
"quotes": ["error", "double", { "avoidEscape": true }],
"linebreak-style": "off"
},
"ignorePatterns": ["dist/**", "node_modules/**", "setup/**"]
}

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.sh text eol=lf
*.template text eol=lf

8
.gitignore vendored
View File

@@ -1,8 +1,12 @@
sessions/
dist/
node_modules/
package-lock.json
.vscode/
.github/
diagnostic/
report/
accounts.json
notes
accounts.dev.json
accounts.main.json
.DS_Store
.playwright-chromium-installed

20
.vscode/launch.json vendored
View File

@@ -1,20 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${file}",
"outFiles": [
"${workspaceFolder}/**/*.js"
]
}
]
}

91
Dockerfile Normal file
View File

@@ -0,0 +1,91 @@
###############################################################################
# Stage 1: Builder
###############################################################################
FROM node:22-slim AS builder
WORKDIR /usr/src/microsoft-rewards-script
ENV PLAYWRIGHT_BROWSERS_PATH=0
# Copy package files
COPY package.json package-lock.json tsconfig.json ./
# Install all dependencies required to build the script
RUN npm ci --ignore-scripts
# Copy source and build
COPY . .
RUN npm run build
# Remove build dependencies, and reinstall only runtime dependencies
RUN rm -rf node_modules \
&& npm ci --omit=dev --ignore-scripts \
&& npm cache clean --force
# Install Chromium Headless Shell, and cleanup
RUN npx playwright install --with-deps --only-shell chromium \
&& rm -rf /root/.cache /tmp/* /var/tmp/*
###############################################################################
# Stage 2: Runtime
###############################################################################
FROM node:22-slim AS runtime
WORKDIR /usr/src/microsoft-rewards-script
# Set production environment variables
ENV NODE_ENV=production \
TZ=UTC \
PLAYWRIGHT_BROWSERS_PATH=0 \
FORCE_HEADLESS=1
# Install minimal system libraries required for Chromium headless to run
RUN apt-get update && apt-get install -y --no-install-recommends \
cron \
gettext-base \
tzdata \
ca-certificates \
libglib2.0-0 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libasound2 \
libflac12 \
libatk1.0-0 \
libatspi2.0-0 \
libdrm2 \
libgbm1 \
libdav1d6 \
libx11-6 \
libx11-xcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
libdouble-conversion3 \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Copy compiled application and dependencies from builder stage
COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist
COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./
COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules
# Copy runtime scripts with proper permissions and normalize line endings for non-Unix users
COPY --chmod=755 src/run_daily.sh ./src/run_daily.sh
COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh \
&& sed -i 's/\r$//' ./src/run_daily.sh
# Entrypoint handles TZ, initial run toggle, cron templating & launch
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["sh", "-c", "echo 'Container started; cron is running.'"]

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 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 General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is 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. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
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.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
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 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. Use with the GNU Affero General Public License.
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 Affero 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 special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU 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 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 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 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 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 General Public License for more details.
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
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 GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

390
README.md
View File

@@ -1,45 +1,353 @@
# Microsoft-Rewards-Script
Automated Microsoft Rewards script, however this time using TypeScript, Cheerio and Puppeteer.
[![Discord](https://img.shields.io/badge/Join%20Our%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/8BxYbV4pkj)
Under development, however mainly for personal use!
---
## How to setup ##
1. Download or clone source code
2. Run `npm i` to install the packages
3. Change `accounts.example.json` to `accounts.json` and add your account details
4. Change `config.json` to your liking
5. Run `npm run build` to build the script
6. Run `npm run start` to start the built script
## Table of Contents
- [Setup](#setup)
- [1. Clone the Repository](#1-clone-the-repository)
- [2. Copy Configuration Files](#2-copy-configuration-files)
- [3. Install Dependencies and Prepare the Browser](#3-install-dependencies-and-prepare-the-browser)
- [4. Build and Run](#4-build-and-run)
- [Nix Users](#nix-setup)
- [Docker Setup](#docker-setup)
- [Before Starting](#before-starting)
- [Quick Start](#quick-start)
- [Example compose.yaml](#example-composeyaml)
- [Configuration Reference](#configuration-reference)
- [Account Configuration](#account-configuration)
- [Features Overview](#features-overview)
- [Disclaimer](#disclaimer)
## Features ##
- [x] Multi-Account Support
- [x] Session Storing
- [x] 2FA Support
- [x] Headless Support
- [x] Discord Webhook Support
- [x] Desktop Searches
- [x] Configurable Tasks
- [x] Microsoft Edge Searches
- [x] Mobile Searches
- [x] Emulated Scrolling Support
- [x] Emulated Link Clicking Support
- [x] Geo Locale Search Queries
- [x] Completing Daily Set
- [x] Completing More Promotions
- [x] Solving Quiz (10 point variant)
- [x] Solving Quiz (30-40 point variant)
- [x] Completing Click Rewards
- [x] Completing Polls
- [x] Completing Punchcards
- [ ] Solving This Or That Quiz
- [x] Clicking Promotional Items
- [x] Solving ABC Quiz
- [ ] Completing Shopping Game
- [ ] Completing Gaming Tab
- [x] Clustering Support
- [x] Proxy Support
---
## Disclaimer ##
Your account may be at risk of getting banned or suspended using this script, you've been warned!
<br />
Use this script at your own risk!
## Setup
**Requirements:** Node.js ≥ 20 and Git
Works on Windows, Linux, macOS, and WSL.
---
### 1. Clone the Repository
**All systems:**
```bash
git clone https://github.com/TheNetsky/Microsoft-Rewards-Script.git
cd Microsoft-Rewards-Script
```
Or download the latest release ZIP and extract it.
---
### 2. Copy Configuration Files
**Windows:**
Rename manually:
```
src/accounts.example.json → src/accounts.json
```
**Linux / macOS / WSL:**
```bash
cp src/accounts.example.json src/accounts.json
```
Then edit:
- `src/accounts.json` — fill in your Microsoft account credentials.
- `src/config.json` — review or customize options.
---
### 3. Install Dependencies and Prepare the Browser
**All systems:**
```bash
npm run pre-build
```
This command:
- Installs all dependencies
- Clears old builds (`dist/`)
- Installs Playwright Chromium (required browser)
---
### 4. Build and Run
**All systems:**
```bash
npm run build
npm run start
```
---
## Nix Setup
If using Nix:
1. Run the pre-build step first:
```bash
npm run pre-build
```
2. Then start the script:
```bash
./run.sh
```
This will launch the script headlessly using `xvfb-run`.
## Docker Setup
### Before Starting
- Remove local `/node_modules` and `/dist` if previously built.
- Remove old Docker volumes if upgrading from older versions.
- You can reuse your existing `accounts.json`.
---
### Quick Start
1. Clone the repository and configure your `accounts.json`.
2. Ensure `config.json` has `"headless": true`.
3. Edit `compose.yaml`:
- Set your timezone (`TZ`)
- Set the cron schedule (`CRON_SCHEDULE`)
- Optionally enable `RUN_ON_START=true`
4. Start the container:
```bash
docker compose up -d
```
5. Monitor logs:
```bash
docker logs microsoft-rewards-script
```
The container includes a randomized delay (about 550 minutes by default)
before each scheduled run to appear more natural. This can be configured or disabled via environment variables.
---
### Example compose.yaml
```yaml
services:
microsoft-rewards-script:
image: ghcr.io/your-org/microsoft-rewards-script:latest
container_name: microsoft-rewards-script
restart: unless-stopped
volumes:
- ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro
- ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro
- ./sessions:/usr/src/microsoft-rewards-script/dist/sessions
# - ./jobstate:/usr/src/microsoft-rewards-script/dist/jobstate
environment:
TZ: "Europe/Amsterdam"
NODE_ENV: "production"
CRON_SCHEDULE: "0 7,16,20 * * *"
RUN_ON_START: "true"
# MIN_SLEEP_MINUTES: "5"
# MAX_SLEEP_MINUTES: "50"
# SKIP_RANDOM: "true"
deploy:
resources:
limits:
cpus: "1.0"
memory: "1g"
```
#### compose.yaml Notes
- **volumes**
- `accounts.json` and `config.json` are mounted read-only to prevent accidental edits.
- `sessions` persists login sessions and fingerprints across runs.
- If `jobState.enabled` is used, mount its directory as a volume.
- **CRON_SCHEDULE**
- Uses standard crontab syntax (e.g., via [crontab.guru](https://crontab.guru/)).
- Schedule is evaluated inside the container using the configured `TZ`.
- **RUN_ON_START**
- Runs the script once immediately on startup, then continues on schedule.
- **Randomization**
- Default delay: 550 minutes.
- Adjustable via `MIN_SLEEP_MINUTES` and `MAX_SLEEP_MINUTES`, or disable with `SKIP_RANDOM`.
---
## Configuration Reference
Edit `src/config.json` to customize behavior.
Below is a summary of key configuration sections.
### Core
| Setting | Description | Default |
|----------|-------------|----------|
| `baseURL` | Microsoft Rewards base URL | `https://rewards.bing.com` |
| `sessionPath` | Folder to store browser sessions | `sessions` |
| `dryRun` | Simulate execution without running tasks | `false` |
### Browser
| Setting | Description | Default |
|----------|-------------|----------|
| `browser.headless` | Run browser invisibly | `false` |
| `browser.globalTimeout` | Timeout for actions | `"30s"` |
### Fingerprinting
| Setting | Description | Default |
|----------|-------------|----------|
| `fingerprinting.saveFingerprint.mobile` | Reuse mobile fingerprint | `true` |
| `fingerprinting.saveFingerprint.desktop` | Reuse desktop fingerprint | `true` |
### Execution
| Setting | Description | Default |
|----------|-------------|----------|
| `execution.parallel` | Run desktop and mobile simultaneously | `false` |
| `execution.runOnZeroPoints` | Run even with zero points | `false` |
| `execution.clusters` | Number of concurrent account clusters | `1` |
### Job State
| Setting | Description | Default |
|----------|-------------|----------|
| `jobState.enabled` | Save last job state | `true` |
| `jobState.dir` | Directory for job data | `""` |
### Workers (Tasks)
| Setting | Description | Default |
|----------|-------------|----------|
| `doDailySet` | Complete daily set | `true` |
| `doMorePromotions` | Complete more promotions | `true` |
| `doPunchCards` | Complete punchcards | `true` |
| `doDesktopSearch` | Perform desktop searches | `true` |
| `doMobileSearch` | Perform mobile searches | `true` |
| `doDailyCheckIn` | Complete daily check-in | `true` |
| `doReadToEarn` | Complete Read-to-Earn | `true` |
| `bundleDailySetWithSearch` | Combine daily set and searches | `true` |
### Search
| Setting | Description | Default |
|----------|-------------|----------|
| `search.useLocalQueries` | Use local query list | `true` |
| `search.settings.useGeoLocaleQueries` | Use region-based queries | `true` |
| `search.settings.scrollRandomResults` | Random scrolling | `true` |
| `search.settings.clickRandomResults` | Random link clicking | `true` |
| `search.settings.retryMobileSearchAmount` | Retry mobile searches | `2` |
| `search.settings.delay.min` | Minimum delay between searches | `1min` |
| `search.settings.delay.max` | Maximum delay between searches | `5min` |
### Query Diversity
| Setting | Description | Default |
|----------|-------------|----------|
| `queryDiversity.enabled` | Enable multiple query sources | `true` |
| `queryDiversity.sources` | Query providers | `["google-trends", "reddit", "local-fallback"]` |
| `queryDiversity.maxQueriesPerSource` | Limit per source | `10` |
| `queryDiversity.cacheMinutes` | Cache lifetime | `30` |
### Humanization
| Setting | Description | Default |
|----------|-------------|----------|
| `humanization.enabled` | Enable human behavior | `true` |
| `stopOnBan` | Stop immediately on ban | `true` |
| `immediateBanAlert` | Alert instantly if banned | `true` |
| `actionDelay.min` | Minimum delay per action (ms) | `500` |
| `actionDelay.max` | Maximum delay per action (ms) | `2200` |
| `gestureMoveProb` | Chance of random mouse movement | `0.65` |
| `gestureScrollProb` | Chance of random scrolls | `0.4` |
### Vacation Mode
| Setting | Description | Default |
|----------|-------------|----------|
| `vacation.enabled` | Enable random pauses | `true` |
| `minDays` | Minimum days off | `2` |
| `maxDays` | Maximum days off | `4` |
### Risk Management
| Setting | Description | Default |
|----------|-------------|----------|
| `enabled` | Enable risk-based adjustments | `true` |
| `autoAdjustDelays` | Adapt delays dynamically | `true` |
| `stopOnCritical` | Stop on critical warning | `false` |
| `banPrediction` | Predict bans based on signals | `true` |
| `riskThreshold` | Risk tolerance level | `75` |
### Retry Policy
| Setting | Description | Default |
|----------|-------------|----------|
| `maxAttempts` | Maximum retry attempts | `3` |
| `baseDelay` | Initial retry delay | `1000` |
| `maxDelay` | Maximum retry delay | `30s` |
| `multiplier` | Backoff multiplier | `2` |
| `jitter` | Random jitter factor | `0.2` |
### Proxy
| Setting | Description | Default |
|----------|-------------|----------|
| `proxy.proxyGoogleTrends` | Proxy Google Trends requests | `true` |
| `proxy.proxyBingTerms` | Proxy Bing terms requests | `true` |
### Notifications
| Setting | Description | Default |
|----------|-------------|----------|
| `notifications.webhook.enabled` | Enable Discord webhook | `false` |
| `notifications.webhook.url` | Discord webhook URL | `""` |
| `notifications.conclusionWebhook.enabled` | Enable summary webhook | `false` |
| `notifications.conclusionWebhook.url` | Summary webhook URL | `""` |
| `notifications.ntfy.enabled` | Enable Ntfy push alerts | `false` |
| `notifications.ntfy.url` | Ntfy server URL | `""` |
| `notifications.ntfy.topic` | Ntfy topic name | `"rewards"` |
### Logging
| Setting | Description | Default |
|----------|-------------|----------|
| `excludeFunc` | Exclude from console logs | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` |
| `webhookExcludeFunc` | Exclude from webhook logs | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` |
| `redactEmails` | Hide emails in logs | `true` |
---
## Account Configuration
Edit `src/accounts.json`:
```json
{
"accounts": [
{
"enabled": true,
"email": "email_1@outlook.com",
"password": "password_1",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
}
]
}
```
---
## Features Overview
- Multi-account and session handling
- Persistent browser fingerprints
- Parallel task execution
- Proxy and retry support
- Human-like behavior simulation
- Full daily set automation
- Mobile and desktop search support
- Vacation and risk protection
- Webhook and Ntfy notifications
- Docker scheduling support
---
## Disclaimer
Use at your own risk.
Automation of Microsoft Rewards may lead to account suspension or bans.
This software is provided for educational purposes only.
The authors are not responsible for any actions taken by Microsoft.

42
compose.yaml Normal file
View File

@@ -0,0 +1,42 @@
services:
microsoft-rewards-script:
build: .
container_name: microsoft-rewards-script
restart: unless-stopped
# Volume mounts: Specify a location where you want to save the files on your local machine.
volumes:
- ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro
- ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro
- ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session
environment:
TZ: "America/Toronto" # Set your timezone for proper scheduling
NODE_ENV: "production"
CRON_SCHEDULE: "0 7,16,20 * * *" # Customize your schedule, use crontab.guru for formatting
RUN_ON_START: "true" # Runs the script immediately on container startup
# Add scheduled start-time randomization (uncomment to customize or disable, default: enabled)
#MIN_SLEEP_MINUTES: "5"
#MAX_SLEEP_MINUTES: "50"
SKIP_RANDOM_SLEEP: "false"
# Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours)
#STUCK_PROCESS_TIMEOUT_HOURS: "8"
# Optional resource limits for the container
mem_limit: 4g
cpus: 2
# Health check - monitors if cron daemon is running to ensure scheduled jobs can execute
# Container marked unhealthy if cron process dies
healthcheck:
test: ["CMD", "sh", "-c", "pgrep cron > /dev/null || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
# Security hardening
security_opt:
- no-new-privileges:true

50
entrypoint.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure Playwright uses preinstalled browsers
export PLAYWRIGHT_BROWSERS_PATH=0
# 1. Timezone: default to UTC if not provided
: "${TZ:=UTC}"
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
dpkg-reconfigure -f noninteractive tzdata
# 2. Validate CRON_SCHEDULE
if [ -z "${CRON_SCHEDULE:-}" ]; then
echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2
echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2
exit 1
fi
# 3. Initial run without sleep if RUN_ON_START=true
if [ "${RUN_ON_START:-false}" = "true" ]; then
echo "[entrypoint] Starting initial run in background at $(date)"
(
cd /usr/src/microsoft-rewards-script || {
echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2
exit 1
}
# Skip random sleep for initial run, but preserve setting for cron jobs
SKIP_RANDOM_SLEEP=true src/run_daily.sh
echo "[entrypoint-bg] Initial run completed at $(date)"
) &
echo "[entrypoint] Background process started (PID: $!)"
fi
# 4. Template and register cron file with explicit timezone export
if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then
echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2
exit 1
fi
# Export TZ for envsubst to use
export TZ
envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron
chmod 0644 /etc/cron.d/microsoft-rewards-cron
crontab /etc/cron.d/microsoft-rewards-cron
echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)"
# 5. Start cron in foreground (PID 1)
exec cron -f

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1749727998,
"narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

40
flake.nix Normal file
View File

@@ -0,0 +1,40 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-utils = {
url = "github:numtide/flake-utils";
};
};
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
};
in
{
devShell = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
nodejs
playwright-driver.browsers
typescript
playwright-test
# fixes "waiting until load" issue compared to
# setting headless in config.json
xvfb-run
];
shellHook = ''
export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
npm i
npm run build
'';
};
}
);
}

3174
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,32 @@
{
"name": "microsoft-rewards-script",
"version": "1.2.4",
"version": "2.4.1",
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
"private": true,
"main": "index.js",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/TheNetsky/Microsoft-Rewards-Script.git"
},
"bugs": {
"url": "https://github.com/TheNetsky/Microsoft-Rewards-Script/issues"
},
"homepage": "https://github.com/TheNetsky/Microsoft-Rewards-Script#readme",
"scripts": {
"clean": "rimraf dist",
"pre-build": "npm i && npm run clean && node -e \"process.exit(process.env.SKIP_PLAYWRIGHT_INSTALL?0:1)\" || npx playwright install chromium",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node ./dist/index.js",
"ts-start": "ts-node ./src/index.ts",
"dev": "ts-node ./src/index.ts -dev"
"start": "node --enable-source-maps ./dist/index.js",
"ts-start": "node --loader ts-node/esm ./src/index.ts",
"dev": "ts-node ./src/index.ts -dev",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"prepare": "npm run build",
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"",
"create-docker": "docker build -t microsoft-rewards-script-docker ."
},
"keywords": [
"Bing Rewards",
@@ -18,24 +34,34 @@
"Bot",
"Script",
"TypeScript",
"Puppeteer",
"Playwright",
"Cheerio"
],
"author": "Netsky",
"license": "ISC",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.7.0",
"typescript": "^5.2.2"
"@types/ms": "^0.7.34",
"@types/node": "^20.14.11",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"eslint": "^8.57.0",
"eslint-plugin-modules-newline": "^0.0.6",
"rimraf": "^6.0.1",
"typescript": "^5.5.4"
},
"dependencies": {
"axios": "^1.5.1",
"cheerio": "^1.0.0-rc.12",
"eslint": "^8.49.0",
"eslint-plugin-modules-newline": "^0.0.6",
"fingerprint-generator": "^2.1.42",
"fingerprint-injector": "^2.1.42",
"puppeteer": "^21.4.1",
"puppeteer-extra": "^3.3.6",
"ts-node": "^10.9.1"
"axios": "^1.13.2",
"chalk": "^4.1.2",
"cheerio": "^1.0.0",
"cron-parser": "^5.4.0",
"fingerprint-generator": "^2.1.76",
"fingerprint-injector": "^2.1.76",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"luxon": "^3.5.0",
"ms": "^2.1.3",
"playwright": "1.52.0",
"rebrowser-playwright": "1.52.0",
"socks-proxy-agent": "^8.0.5",
"ts-node": "^10.9.2"
}
}
}

3
run.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
nix develop --command bash -c "xvfb-run npm run start"

View File

@@ -1,22 +1,32 @@
[
{
"accounts": [
{
"email": "email_1",
"password": "password_1",
"proxy": {
"url": "",
"port": 0,
"username": "",
"password": ""
}
"enabled": true,
"email": "email_1@outlook.com",
"password": "password_1",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
"email": "email_2",
"password": "password_2",
"proxy": {
"url": "",
"port": 0,
"username": "",
"password": ""
}
"enabled": false,
"email": "email_2@outlook.com",
"password": "password_2",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
}
]
]
}

View File

@@ -1,18 +1,22 @@
import puppeteer from 'puppeteer-extra'
import { FingerprintInjector } from 'fingerprint-injector'
import playwright, { BrowserContext } from 'rebrowser-playwright'
import { newInjectedContext } from 'fingerprint-injector'
import { FingerprintGenerator } from 'fingerprint-generator'
import { MicrosoftRewardsBot } from '../index'
import { loadSessionData, saveFingerprintData } from '../util/Load'
import { updateFingerprintUserAgent } from '../util/UserAgent'
import { AccountProxy } from '../interface/Account'
/* Test Stuff
https://abrahamjuliot.github.io/creepjs/
https://botcheck.luminati.io/
http://f.vision/
https://fv.pro/
https://pixelscan.net/
https://www.browserscan.net/
*/
class Browser {
private bot: MicrosoftRewardsBot
@@ -20,38 +24,117 @@ class Browser {
this.bot = bot
}
async createBrowser(email: string, proxy: AccountProxy, isMobile: boolean) {
// const userAgent = await getUserAgent(isMobile)
async createBrowser(proxy: AccountProxy, email: string): Promise<BrowserContext> {
let browser: playwright.Browser
try {
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
// Support legacy config.headless OR nested config.browser.headless
const legacyHeadless = (this.bot.config as { headless?: boolean }).headless
const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless
const headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false)
const headless: boolean = Boolean(headlessValue)
const browser = await puppeteer.launch({
headless: this.bot.config.headless,
userDataDir: await this.bot.browser.func.loadSesion(email),
args: [
'--no-sandbox',
'--mute-audio',
'--disable-setuid-sandbox',
proxy.url ? `--proxy-server=${proxy.url}:${proxy.port}` : ''
]
})
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
devices: isMobile ? ['mobile'] : ['desktop'],
operatingSystems: isMobile ? ['android'] : ['windows'],
browsers: ['edge'],
browserListQuery: 'last 2 Edge versions'
})
// Modify the newPage function to attach the fingerprint
const originalNewPage = browser.newPage
browser.newPage = async function () {
const page = await originalNewPage.apply(browser)
await new FingerprintInjector().attachFingerprintToPuppeteer(page, { fingerprint, headers })
return page
const engineName = 'chromium' // current hard-coded engine
this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
browser = await playwright.chromium.launch({
// Optional: uncomment to use Edge instead of Chromium
// channel: 'msedge',
headless,
...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }),
args: [
'--no-sandbox',
'--mute-audio',
'--disable-setuid-sandbox',
'--ignore-certificate-errors',
'--ignore-certificate-errors-spki-list',
'--ignore-ssl-errors'
]
})
} catch (e: unknown) {
const msg = (e instanceof Error ? e.message : String(e))
// Common missing browser executable guidance
if (/Executable doesn't exist/i.test(msg)) {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies', 'error')
} else {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
}
throw e
}
return browser
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint
const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint
const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false }
const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint)
const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint()
const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint })
// Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout
const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout
const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout))
// Normalize viewport and page rendering so content fits typical screens
try {
const desktopViewport = { width: 1280, height: 800 }
const mobileViewport = { width: 390, height: 844 }
context.on('page', async (page) => {
try {
// Set a reasonable viewport size depending on device type
if (this.bot.isMobile) {
await page.setViewportSize(mobileViewport)
} else {
await page.setViewportSize(desktopViewport)
}
// Inject a tiny CSS to avoid gigantic scaling on some environments
await page.addInitScript(() => {
try {
const style = document.createElement('style')
style.id = '__mrs_fit_style'
style.textContent = `
html, body { overscroll-behavior: contain; }
/* Mild downscale to keep content within window on very large DPI */
@media (min-width: 1000px) {
html { zoom: 0.9 !important; }
}
`
document.documentElement.appendChild(style)
} catch { /* ignore */ }
})
} catch { /* ignore */ }
})
} catch { /* ignore */ }
await context.addCookies(sessionData.cookies)
// Persist fingerprint when feature is configured
if (saveFingerprint.mobile || saveFingerprint.desktop) {
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
}
this.bot.log(this.bot.isMobile, 'BROWSER', `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`)
return context as BrowserContext
}
async generateFingerprint() {
const fingerPrintData = new FingerprintGenerator().getFingerprint({
devices: this.bot.isMobile ? ['mobile'] : ['desktop'],
operatingSystems: this.bot.isMobile ? ['android'] : ['windows'],
browsers: [{ name: 'edge' }]
})
const updatedFingerPrintData = await updateFingerprintUserAgent(fingerPrintData, this.bot.isMobile)
return updatedFingerPrintData
}
}
export default Browser

View File

@@ -1,12 +1,15 @@
import { Page } from 'puppeteer'
import { BrowserContext, Page } from 'rebrowser-playwright'
import { CheerioAPI, load } from 'cheerio'
import fs from 'fs'
import path from 'path'
import { AxiosRequestConfig } from 'axios'
import { MicrosoftRewardsBot } from '../index'
import { saveSessionData } from '../util/Load'
import { TIMEOUTS, RETRY_LIMITS, SELECTORS, URLS } from '../constants'
import { Counters, DashboardData, MorePromotion, PromotionalItem } from './../interface/DashboardData'
import { QuizData } from './../interface/QuizData'
import { Counters, DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
import { QuizData } from '../interface/QuizData'
import { AppUserData } from '../interface/AppUserData'
import { EarnablePoints } from '../interface/Points'
export default class BrowserFunc {
@@ -16,210 +19,492 @@ export default class BrowserFunc {
this.bot = bot
}
async goHome(page: Page): Promise<boolean> {
/**
* Navigate the provided page to rewards homepage
* @param {Page} page Playwright page
*/
async goHome(page: Page) {
const navigateHome = async () => {
try {
await page.goto(this.bot.config.baseURL, {
waitUntil: 'domcontentloaded',
timeout: 30000
})
} catch (e: any) {
if (typeof e?.message === 'string' && e.message.includes('ERR_ABORTED')) {
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Navigation aborted, retrying...', 'warn')
await this.bot.utils.wait(1500)
await page.goto(this.bot.config.baseURL, {
waitUntil: 'domcontentloaded',
timeout: 30000
})
} else {
throw e
}
}
}
try {
const dashboardURL = new URL(this.bot.config.baseURL)
await page.goto(this.bot.config.baseURL)
if (new URL(page.url()).hostname !== dashboardURL.hostname) {
await navigateHome()
}
const maxIterations = 5 // Maximum iterations set to 5
let success = false
for (let iteration = 1; iteration <= maxIterations; iteration++) {
await this.bot.utils.wait(3000)
await this.bot.browser.utils.tryDismissCookieBanner(page)
// Check if account is suspended
const isSuspended = await page.waitForSelector('#suspendedAccountHeader', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
if (isSuspended) {
this.bot.log('GO-HOME', 'This account is suspended!', 'error')
throw new Error('Account has been suspended!')
}
for (let iteration = 1; iteration <= RETRY_LIMITS.GO_HOME_MAX; iteration++) {
await this.bot.utils.wait(TIMEOUTS.LONG)
await this.bot.browser.utils.tryDismissAllMessages(page)
try {
// If activities are found, exit the loop
await page.waitForSelector('#more-activities', { timeout: 1000 })
await page.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: 1000 })
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
success = true
break
} catch {
const suspendedByHeader = await page
.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 })
.then(() => true)
.catch(() => false)
} catch (error) {
// Continue if element is not found
if (suspendedByHeader) {
this.bot.log(
this.bot.isMobile,
'GO-HOME',
`Account suspension detected by header selector (iteration ${iteration})`,
'error'
)
throw new Error('Account has been suspended!')
}
try {
const mainContent =
(await page
.locator('#contentContainer, #main, .main-content')
.first()
.textContent({ timeout: 500 })
.catch(() => '')) || ''
const suspensionPatterns = [
/account\s+has\s+been\s+suspended/i,
/suspended\s+due\s+to\s+unusual\s+activity/i,
/your\s+account\s+is\s+temporarily\s+suspended/i
]
const isSuspended = suspensionPatterns.some((p) => p.test(mainContent))
if (isSuspended) {
this.bot.log(
this.bot.isMobile,
'GO-HOME',
`Account suspension detected by content text (iteration ${iteration})`,
'error'
)
throw new Error('Account has been suspended!')
}
} catch (e) {
this.bot.log(
this.bot.isMobile,
'GO-HOME',
`Suspension text check skipped: ${e instanceof Error ? e.message : String(e)}`,
'warn'
)
}
const currentURL = new URL(page.url())
if (currentURL.hostname !== dashboardURL.hostname) {
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
try {
await navigateHome()
} catch (e: any) {
if (typeof e?.message === 'string' && e.message.includes('ERR_ABORTED')) {
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Navigation aborted again; continuing...', 'warn')
} else {
throw e
}
}
} else {
this.bot.log(
this.bot.isMobile,
'GO-HOME',
`Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`,
'warn'
)
}
}
const currentURL = new URL(page.url())
if (currentURL.hostname !== dashboardURL.hostname) {
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(2000)
await page.goto(this.bot.config.baseURL)
}
await this.bot.utils.wait(5000)
this.bot.log('GO-HOME', 'Visited homepage successfully')
const backoff = Math.min(TIMEOUTS.VERY_LONG, 1000 + iteration * 500)
await this.bot.utils.wait(backoff)
}
if (!success) {
throw new Error('Failed to reach homepage or find activities within retry limit')
}
} catch (error) {
console.error('An error occurred:', error)
return false
throw this.bot.log(
this.bot.isMobile,
'GO-HOME',
'An error occurred:' + (error instanceof Error ? ` ${error.message}` : ` ${String(error)}`),
'error'
)
}
return true
}
async getDashboardData(page: Page): Promise<DashboardData> {
/**
* Fetch user dashboard data
* @returns {DashboardData} Object of user bing rewards dashboard data
*/
async getDashboardData(page?: Page): Promise<DashboardData> {
const target = page ?? this.bot.homePage
const dashboardURL = new URL(this.bot.config.baseURL)
const currentURL = new URL(page.url())
const currentURL = new URL(target.url())
// Should never happen since tasks are opened in a new tab!
if (currentURL.hostname !== dashboardURL.hostname) {
this.bot.log('DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
await this.goHome(page)
}
// Reload the page to get new data
await page.reload({ waitUntil: 'networkidle2' })
const scriptContent = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
if (targetScript) {
return targetScript.innerText
} else {
throw this.bot.log('GET-DASHBOARD-DATA', 'Script containing dashboard data not found', 'error')
}
})
// Extract the dashboard object from the script content
const dashboardData = await page.evaluate(scriptContent => {
// Extract the dashboard object using regex
const regex = /var dashboard = (\{.*?\});/s
const match = regex.exec(scriptContent)
if (match && match[1]) {
return JSON.parse(match[1])
} else {
throw this.bot.log('GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
}
}, scriptContent)
return dashboardData
}
async getQuizData(page: Page): Promise<QuizData> {
try {
const html = await page.content()
const $ = load(html)
const scriptContent = $('script').filter((index, element) => {
return $(element).text().includes('_w.rewardsQuizRenderInfo')
}).text()
if (scriptContent) {
const regex = /_w\.rewardsQuizRenderInfo\s*=\s*({.*?});/s
const match = regex.exec(scriptContent)
if (match && match[1]) {
const quizData = JSON.parse(match[1])
return quizData
} else {
throw this.bot.log('GET-QUIZ-DATA', 'Quiz data not found within script', 'error')
}
} else {
throw this.bot.log('GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
// Should never happen since tasks are opened in a new tab!
if (currentURL.hostname !== dashboardURL.hostname) {
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
await this.goHome(target)
}
let lastError: unknown = null
for (let attempt = 1; attempt <= 2; attempt++) {
try {
// Reload the page to get new data
await target.reload({ waitUntil: 'domcontentloaded' })
lastError = null
break
} catch (re) {
lastError = re
const msg = (re instanceof Error ? re.message : String(re))
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn')
// If page/context closed => bail early after first retry
if (msg.includes('has been closed')) {
if (attempt === 1) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
try {
await this.goHome(target)
} catch {/* ignore */ }
} else {
break
}
}
if (attempt === 2 && lastError) throw lastError
await this.bot.utils.wait(1000)
}
}
// Wait a bit longer for scripts to load, especially on mobile
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
// Wait for the more-activities element to ensure page is fully loaded
await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Activities element not found, continuing anyway', 'warn')
})
let scriptContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
return targetScript?.innerText ? targetScript.innerText : null
})
if (!scriptContent) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn')
// Force a navigation retry once before failing hard
try {
await this.goHome(target)
await target.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.VERY_LONG }).catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Wait for load state failed: ${e}`, 'warn')
})
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
} catch (e) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Recovery navigation failed: ${e}`, 'warn')
}
const retryContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
return targetScript?.innerText ? targetScript.innerText : null
}).catch(() => null)
if (!retryContent) {
// Log additional debug info
const scriptsDebug = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
return scripts.map(s => s.innerText.substring(0, 100)).join(' | ')
}).catch(() => 'Unable to get script debug info')
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Available scripts preview: ${scriptsDebug}`, 'warn')
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
}
scriptContent = retryContent
}
// Extract the dashboard object from the script content
const dashboardData = await target.evaluate((scriptContent: string) => {
// Try multiple regex patterns for better compatibility
const patterns = [
/var dashboard = (\{.*?\});/s, // Original pattern
/var dashboard=(\{.*?\});/s, // No spaces
/var\s+dashboard\s*=\s*(\{.*?\});/s, // Flexible whitespace
/dashboard\s*=\s*(\{[\s\S]*?\});/ // More permissive
]
for (const regex of patterns) {
const match = regex.exec(scriptContent)
if (match && match[1]) {
try {
return JSON.parse(match[1])
} catch (e) {
// Try next pattern if JSON parsing fails
continue
}
}
}
return null
}, scriptContent)
if (!dashboardData) {
// Log a snippet of the script content for debugging
const scriptPreview = scriptContent.substring(0, 200)
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Script preview: ${scriptPreview}`, 'warn')
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
}
return dashboardData
} catch (error) {
throw this.bot.log('GET-QUIZ-DATA', 'An error occurred:' + error, 'error')
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Error fetching dashboard data: ${error}`, 'error')
}
}
async getSearchPoints(page: Page): Promise<Counters> {
const dashboardData = await this.getDashboardData(page) // Always fetch newest data
/**
* Get search point counters
* @returns {Counters} Object of search counter data
*/
async getSearchPoints(): Promise<Counters> {
const dashboardData = await this.getDashboardData() // Always fetch newest data
return dashboardData.userStatus.counters
}
async getEarnablePoints(data: DashboardData, page: null | Page = null): Promise<number> {
/**
* Get total earnable points with web browser
* @returns {number} Total earnable points
*/
async getBrowserEarnablePoints(): Promise<EarnablePoints> {
try {
// Fetch new data if page is provided
if (page) {
data = await this.getDashboardData(page)
}
let desktopSearchPoints = 0
let mobileSearchPoints = 0
let dailySetPoints = 0
let morePromotionsPoints = 0
// These only include the points from tasks that the script can complete!
let totalEarnablePoints = 0
const data = await this.getDashboardData()
// Desktop Search Points
data.userStatus.counters.pcSearch.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
if (data.userStatus.counters.pcSearch?.length) {
data.userStatus.counters.pcSearch.forEach(x => desktopSearchPoints += (x.pointProgressMax - x.pointProgress))
}
// Mobile Search Points
if (data.userStatus.counters.mobileSearch?.length) {
data.userStatus.counters.mobileSearch.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
data.userStatus.counters.mobileSearch.forEach(x => mobileSearchPoints += (x.pointProgressMax - x.pointProgress))
}
// Daily Set
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => dailySetPoints += (x.pointProgressMax - x.pointProgress))
// More Promotions
data.morePromotions.forEach(x => {
// Only count points from supported activities
if (['quiz', 'urlreward'].includes(x.activityType)) {
totalEarnablePoints += (x.pointProgressMax - x.pointProgress)
}
})
if (data.morePromotions?.length) {
data.morePromotions.forEach(x => {
// Only count points from supported activities
if (['quiz', 'urlreward'].includes(x.promotionType) && x.exclusiveLockedFeatureStatus !== 'locked') {
morePromotionsPoints += (x.pointProgressMax - x.pointProgress)
}
})
}
return totalEarnablePoints
const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints
return {
dailySetPoints,
morePromotionsPoints,
desktopSearchPoints,
mobileSearchPoints,
totalEarnablePoints
}
} catch (error) {
throw this.bot.log('GET-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
throw this.bot.log(this.bot.isMobile, 'GET-BROWSER-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
}
}
async getCurrentPoints(data: DashboardData, page: null | Page = null): Promise<number> {
/**
* Get total earnable points with mobile app
* @returns {number} Total earnable points
*/
async getAppEarnablePoints(accessToken: string) {
try {
// Fetch new data if page is provided
if (page) {
data = await this.getDashboardData(page)
const points = {
readToEarn: 0,
checkIn: 0,
totalEarnablePoints: 0
}
const eligibleOffers = [
'ENUS_readarticle3_30points',
'Gamification_Sapphire_DailyCheckIn'
]
const data = await this.getDashboardData()
// Guard against missing profile/attributes and undefined settings
let geoLocale = data?.userProfile?.attributes?.country || 'US'
const useGeo = !!(this.bot?.config?.searchSettings?.useGeoLocaleQueries)
geoLocale = (useGeo && typeof geoLocale === 'string' && geoLocale.length === 2)
? geoLocale.toLowerCase()
: 'us'
const userDataRequest: AxiosRequestConfig = {
url: URLS.APP_USER_DATA,
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en'
}
}
const userDataResponse: AppUserData = (await this.bot.axios.request(userDataRequest)).data
const userData = userDataResponse.response
const eligibleActivities = userData.promotions.filter((x) => eligibleOffers.includes(x.attributes.offerid ?? ''))
for (const item of eligibleActivities) {
if (item.attributes.type === 'msnreadearn') {
points.readToEarn = parseInt(item.attributes.pointmax ?? '') - parseInt(item.attributes.pointprogress ?? '')
break
} else if (item.attributes.type === 'checkin') {
const checkInDay = parseInt(item.attributes.progress ?? '') % 7
if (checkInDay < 6 && (new Date()).getDate() != (new Date(item.attributes.last_updated ?? '')).getDate()) {
points.checkIn = parseInt(item.attributes['day_' + (checkInDay + 1) + '_points'] ?? '')
}
break
}
}
points.totalEarnablePoints = points.readToEarn + points.checkIn
return points
} catch (error) {
throw this.bot.log(this.bot.isMobile, 'GET-APP-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
}
}
/**
* Get current point amount
* @returns {number} Current total point amount
*/
async getCurrentPoints(): Promise<number> {
try {
const data = await this.getDashboardData()
return data.userStatus.availablePoints
} catch (error) {
throw this.bot.log('GET-CURRENT-POINTS', 'An error occurred:' + error, 'error')
throw this.bot.log(this.bot.isMobile, 'GET-CURRENT-POINTS', 'An error occurred:' + error, 'error')
}
}
async loadSesion(email: string): Promise<string> {
const sessionDir = path.join(__dirname, this.bot.config.sessionPath, email)
/**
* Parse quiz data from provided page
* @param {Page} page Playwright page
* @returns {QuizData} Quiz data object
*/
async getQuizData(page: Page): Promise<QuizData> {
try {
// Create session dir
if (!fs.existsSync(sessionDir)) {
await fs.promises.mkdir(sessionDir, { recursive: true })
// Wait for page to be fully loaded
await page.waitForLoadState('domcontentloaded')
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
const html = await page.content()
const $ = load(html)
// Try multiple possible variable names
const possibleVariables = [
'_w.rewardsQuizRenderInfo',
'rewardsQuizRenderInfo',
'_w.quizRenderInfo',
'quizRenderInfo'
]
let scriptContent = ''
let foundVariable = ''
for (const varName of possibleVariables) {
scriptContent = $('script')
.toArray()
.map(el => $(el).text())
.find(t => t.includes(varName)) || ''
if (scriptContent) {
foundVariable = varName
break
}
}
if (scriptContent && foundVariable) {
// Escape dots in variable name for regex
const escapedVar = foundVariable.replace(/\./g, '\\.')
const regex = new RegExp(`${escapedVar}\\s*=\\s*({.*?});`, 's')
const match = regex.exec(scriptContent)
if (match && match[1]) {
const quizData = JSON.parse(match[1])
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found quiz data using variable: ${foundVariable}`, 'log')
return quizData
} else {
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Variable ${foundVariable} found but could not extract JSON data`, 'error')
}
} else {
// Log available scripts for debugging
const allScripts = $('script')
.toArray()
.map(el => $(el).text())
.filter(t => t.length > 0)
.map(t => t.substring(0, 100))
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error')
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn')
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
}
return sessionDir
} catch (error) {
throw new Error(error as string)
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + error, 'error')
}
}
async waitForQuizRefresh(page: Page): Promise<boolean> {
try {
await page.waitForSelector('#rqHeaderCredits', { visible: true, timeout: 10_000 })
await this.bot.utils.wait(2000)
await page.waitForSelector(SELECTORS.QUIZ_CREDITS, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
return true
} catch (error) {
this.bot.log('QUIZ-REFRESH', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', 'An error occurred:' + error, 'error')
return false
}
}
async checkQuizCompleted(page: Page): Promise<boolean> {
try {
await page.waitForSelector('#quizCompleteContainer', { visible: true, timeout: 2000 })
await this.bot.utils.wait(2000)
await page.waitForSelector(SELECTORS.QUIZ_COMPLETE, { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
return true
} catch (error) {
@@ -227,7 +512,7 @@ export default class BrowserFunc {
}
}
async refreshCheerio(page: Page): Promise<CheerioAPI> {
async loadInCheerio(page: Page): Promise<CheerioAPI> {
const html = await page.content()
const $ = load(html)
@@ -240,15 +525,32 @@ export default class BrowserFunc {
const html = await page.content()
const $ = load(html)
const element = $('.offer-cta').toArray().find(x => x.attribs.href?.includes(activity.offerId))
const element = $('.offer-cta').toArray().find((x: unknown) => {
const el = x as { attribs?: { href?: string } }
return !!el.attribs?.href?.includes(activity.offerId)
})
if (element) {
selector = `a[href*="${element.attribs.href}"]`
}
} catch (error) {
this.bot.log('GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
}
return selector
}
async closeBrowser(browser: BrowserContext, email: string) {
try {
// Save cookies
await saveSessionData(this.bot.config.sessionPath, browser, email, this.bot.isMobile)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
// Close browser
await browser.close()
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
} catch (error) {
throw this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -1,90 +1,219 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { load } from 'cheerio'
import { MicrosoftRewardsBot } from '../index'
type DismissButton = { selector: string; label: string; isXPath?: boolean }
export default class BrowserUtil {
private bot: MicrosoftRewardsBot
private static readonly DISMISS_BUTTONS: readonly DismissButton[] = [
{ selector: '#acceptButton', label: 'AcceptButton' },
{ selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
{ selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
{ selector: '#iLandingViewAction', label: 'Landing Continue' },
{ selector: '#iShowSkip', label: 'Show Skip' },
{ selector: '#iNext', label: 'Next' },
{ selector: '#iLooksGood', label: 'LooksGood' },
{ selector: '#idSIButton9', label: 'PrimaryLoginButton' },
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
{ selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
{ selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
{ selector: '#bnp_close_link', label: 'Bing Cookie Close' },
{ selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
{ selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
]
private static readonly OVERLAY_SELECTORS = {
container: '#bnp_overlay_wrapper',
reject: '#bnp_btn_reject, button[aria-label*="Reject" i]',
accept: '#bnp_btn_accept'
} as const
private static readonly STREAK_DIALOG_SELECTORS = {
container: '[role="dialog"], div[role="alert"], div.ms-Dialog',
textFilter: /streak protection has run out/i,
closeButtons: 'button[aria-label*="close" i], button:has-text("Close"), button:has-text("Dismiss"), button:has-text("Got it"), button:has-text("OK"), button:has-text("Ok")'
} as const
private static readonly TERMS_UPDATE_SELECTORS = {
titleId: '#iTOUTitle',
titleText: /we're updating our terms/i,
nextButton: 'button[data-testid="primaryButton"]:has-text("Next"), button[type="submit"]:has-text("Next")'
} as const
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
async tryDismissAllMessages(page: Page): Promise<boolean> {
const buttons = [
{ selector: '#iLandingViewAction', label: 'iLandingViewAction' },
{ selector: '#iShowSkip', label: 'iShowSkip' },
{ selector: '#iNext', label: 'iNext' },
{ selector: '#iLooksGood', label: 'iLooksGood' },
{ selector: '#idSIButton9', label: 'idSIButton9' },
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Button' }
]
let result = false
for (const button of buttons) {
try {
const element = await page.waitForSelector(button.selector, { visible: true, timeout: 1000 })
if (element) {
await element.click()
result = true
}
} catch (error) {
continue
}
}
return result
}
async tryDismissCookieBanner(page: Page): Promise<void> {
try {
await page.waitForSelector('#cookieConsentContainer', { timeout: 1000 })
const cookieBanner = await page.$('#cookieConsentContainer')
if (cookieBanner) {
const button = await cookieBanner.$('button')
if (button) {
await button.click()
await this.bot.utils.wait(2000)
}
}
} catch (error) {
// Continue if element is not found or other error occurs
async tryDismissAllMessages(page: Page): Promise<void> {
const maxRounds = 3
for (let round = 0; round < maxRounds; round++) {
const dismissCount = await this.dismissRound(page)
if (dismissCount === 0) break
}
}
async tryDismissBingCookieBanner(page: Page): Promise<void> {
try {
await page.waitForSelector('#bnp_btn_accept', { timeout: 1000 })
const cookieBanner = await page.$('#bnp_btn_accept')
private async dismissRound(page: Page): Promise<number> {
let count = 0
count += await this.dismissStandardButtons(page)
count += await this.dismissOverlayButtons(page)
count += await this.dismissStreakDialog(page)
count += await this.dismissTermsUpdateDialog(page)
return count
}
if (cookieBanner) {
await cookieBanner.click()
private async dismissStandardButtons(page: Page): Promise<number> {
let count = 0
for (const btn of BrowserUtil.DISMISS_BUTTONS) {
const dismissed = await this.tryClickButton(page, btn)
if (dismissed) {
count++
await page.waitForTimeout(150)
}
} catch (error) {
// Continue if element is not found or other error occurs
}
return count
}
private async tryClickButton(page: Page, btn: DismissButton): Promise<boolean> {
try {
const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
const visible = await loc.first().isVisible({ timeout: 200 }).catch(() => false)
if (!visible) return false
await loc.first().click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
return true
} catch {
return false
}
}
private async dismissOverlayButtons(page: Page): Promise<number> {
try {
const { container, reject, accept } = BrowserUtil.OVERLAY_SELECTORS
const overlay = page.locator(container)
const visible = await overlay.isVisible({ timeout: 200 }).catch(() => false)
if (!visible) return 0
const rejectBtn = overlay.locator(reject)
if (await rejectBtn.first().isVisible().catch(() => false)) {
await rejectBtn.first().click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
return 1
}
const acceptBtn = overlay.locator(accept)
if (await acceptBtn.first().isVisible().catch(() => false)) {
await acceptBtn.first().click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
return 1
}
return 0
} catch {
return 0
}
}
private async dismissStreakDialog(page: Page): Promise<number> {
try {
const { container, textFilter, closeButtons } = BrowserUtil.STREAK_DIALOG_SELECTORS
const dialog = page.locator(container).filter({ hasText: textFilter })
const visible = await dialog.first().isVisible({ timeout: 200 }).catch(() => false)
if (!visible) return 0
const closeBtn = dialog.locator(closeButtons).first()
if (await closeBtn.isVisible({ timeout: 200 }).catch(() => false)) {
await closeBtn.click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Button')
return 1
}
await page.keyboard.press('Escape').catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Escape')
return 1
} catch {
return 0
}
}
private async dismissTermsUpdateDialog(page: Page): Promise<number> {
try {
const { titleId, titleText, nextButton } = BrowserUtil.TERMS_UPDATE_SELECTORS
// Check if terms update page is present
const titleById = page.locator(titleId)
const titleByText = page.locator('h1').filter({ hasText: titleText })
const hasTitle = await titleById.isVisible({ timeout: 200 }).catch(() => false) ||
await titleByText.first().isVisible({ timeout: 200 }).catch(() => false)
if (!hasTitle) return 0
// Click the Next button
const nextBtn = page.locator(nextButton).first()
if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await nextBtn.click({ timeout: 1000 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)')
// Wait a bit for navigation
await page.waitForTimeout(1000)
return 1
}
return 0
} catch {
return 0
}
}
async getLatestTab(page: Page): Promise<Page> {
try {
await this.bot.utils.wait(500)
await this.bot.utils.wait(1000)
const browser = page.browser()
const pages = await browser.pages()
const browser = page.context()
const pages = browser.pages()
const newTab = pages[pages.length - 1]
if (newTab) {
return newTab
}
throw this.bot.log('GET-NEW-TAB', 'Unable to get latest tab', 'error')
throw this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Unable to get latest tab', 'error')
} catch (error) {
throw this.bot.log('GET-NEW-TAB', 'An error occurred:' + error, 'error')
throw this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'An error occurred:' + error, 'error')
}
}
async reloadBadPage(page: Page): Promise<void> {
try {
const html = await page.content().catch(() => '')
const $ = load(html)
const isNetworkError = $('body.neterror').length
if (isNetworkError) {
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
await page.reload()
}
} catch (error) {
throw this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'An error occurred:' + error, 'error')
}
}
/**
* Perform small human-like gestures: short waits, minor mouse moves and occasional scrolls.
* This should be called sparingly between actions to avoid a fixed cadence.
*/
async humanizePage(page: Page): Promise<void> {
try {
await this.bot.humanizer.microGestures(page)
await this.bot.humanizer.actionPause()
} catch { /* swallow */ }
}
}

View File

@@ -1,27 +1,121 @@
{
"baseURL": "https://rewards.bing.com",
"sessionPath": "sessions",
"headless": false,
"runOnZeroPoints": false,
"clusters": 1,
"dryRun": false,
"browser": {
"headless": false,
"globalTimeout": "30s"
},
"fingerprinting": {
"saveFingerprint": {
"mobile": true,
"desktop": true
}
},
"execution": {
"parallel": false,
"runOnZeroPoints": false,
"clusters": 1
},
"jobState": {
"enabled": true,
"dir": ""
},
"workers": {
"doDailySet": true,
"doMorePromotions": true,
"doPunchCards": true,
"doDesktopSearch": true,
"doMobileSearch": true
"doMobileSearch": true,
"doDailyCheckIn": true,
"doReadToEarn": true,
"bundleDailySetWithSearch": true
},
"searchSettings": {
"useGeoLocaleQueries": false,
"scrollRandomResults": true,
"clickRandomResults": true,
"searchDelay": {
"min": 10000,
"max": 20000
"search": {
"useLocalQueries": true,
"settings": {
"useGeoLocaleQueries": true,
"scrollRandomResults": true,
"clickRandomResults": true,
"retryMobileSearchAmount": 2,
"delay": {
"min": "2min",
"max": "5min"
}
}
},
"webhook": {
"enabled": false,
"url": ""
"queryDiversity": {
"enabled": true,
"sources": [
"google-trends",
"reddit",
"local-fallback"
],
"maxQueriesPerSource": 10,
"cacheMinutes": 30
},
"humanization": {
"enabled": true,
"stopOnBan": true,
"immediateBanAlert": true,
"actionDelay": {
"min": 500,
"max": 2200
},
"gestureMoveProb": 0.65,
"gestureScrollProb": 0.4,
"allowedWindows": []
},
"vacation": {
"enabled": true,
"minDays": 2,
"maxDays": 4
},
"riskManagement": {
"enabled": true,
"autoAdjustDelays": true,
"stopOnCritical": false,
"banPrediction": true,
"riskThreshold": 75
},
"retryPolicy": {
"maxAttempts": 3,
"baseDelay": 1000,
"maxDelay": "30s",
"multiplier": 2,
"jitter": 0.2
},
"proxy": {
"proxyGoogleTrends": true,
"proxyBingTerms": true
},
"notifications": {
"webhook": {
"enabled": false,
"url": ""
},
"conclusionWebhook": {
"enabled": false,
"url": ""
},
"ntfy": {
"enabled": false,
"url": "",
"topic": "rewards",
"authToken": ""
}
},
"logging": {
"excludeFunc": [
"SEARCH-CLOSE-TABS",
"LOGIN-NO-PROMPT",
"FLOW"
],
"webhookExcludeFunc": [
"SEARCH-CLOSE-TABS",
"LOGIN-NO-PROMPT",
"FLOW"
],
"redactEmails": true
}
}

74
src/constants.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Central constants file for the Microsoft Rewards Script
* Defines timeouts, retry limits, and other magic numbers used throughout the application
*/
export const TIMEOUTS = {
SHORT: 500,
MEDIUM: 1500,
MEDIUM_LONG: 2000,
LONG: 3000,
VERY_LONG: 5000,
EXTRA_LONG: 10000,
DASHBOARD_WAIT: 10000,
LOGIN_MAX: 180000, // 3 minutes
NETWORK_IDLE: 5000
} as const
export const RETRY_LIMITS = {
MAX_ITERATIONS: 5,
DASHBOARD_RELOAD: 2,
MOBILE_SEARCH: 3,
ABC_MAX: 15,
POLL_MAX: 15,
QUIZ_MAX: 15,
QUIZ_ANSWER_TIMEOUT: 10000,
GO_HOME_MAX: 5
} as const
export const DELAYS = {
ACTION_MIN: 1000,
ACTION_MAX: 3000,
SEARCH_DEFAULT_MIN: 2000,
SEARCH_DEFAULT_MAX: 5000,
BROWSER_CLOSE: 2000,
TYPING_DELAY: 20,
SEARCH_ON_BING_WAIT: 5000,
SEARCH_ON_BING_COMPLETE: 3000,
SEARCH_ON_BING_FOCUS: 200,
SEARCH_BAR_TIMEOUT: 15000,
QUIZ_ANSWER_WAIT: 2000,
THIS_OR_THAT_START: 2000
} as const
export const SELECTORS = {
MORE_ACTIVITIES: '#more-activities',
SUSPENDED_ACCOUNT: '#suspendedAccountHeader',
QUIZ_COMPLETE: '#quizCompleteContainer',
QUIZ_CREDITS: 'span.rqMCredits'
} as const
export const URLS = {
REWARDS_BASE: 'https://rewards.bing.com',
REWARDS_SIGNIN: 'https://rewards.bing.com/signin',
APP_USER_DATA: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613'
} as const
export const DISCORD = {
MAX_EMBED_LENGTH: 1900,
RATE_LIMIT_DELAY: 500,
WEBHOOK_TIMEOUT: 10000,
DEBOUNCE_DELAY: 750,
COLOR_RED: 0xFF0000,
COLOR_CRIMSON: 0xDC143C,
COLOR_ORANGE: 0xFFA500,
COLOR_BLUE: 0x3498DB,
COLOR_GREEN: 0x00D26A,
AVATAR_URL: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
} as const
export const META = {
C: 'aHR0cHM6Ly9kaXNjb3JkLmdnL2tuMzY5NUt4MzI=',
R: 'aHR0cHM6Ly9naXRodWIuY29tL0xpZ2h0NjAtMS9NaWNyb3NvZnQtUmV3YXJkcy1SZXdp'
} as const

2
src/crontab.template Normal file
View File

@@ -0,0 +1,2 @@
# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs
${CRON_SCHEDULE} TZ=${TZ} /bin/bash /usr/src/microsoft-rewards-script/src/run_daily.sh >> /proc/1/fd/1 2>&1

View File

@@ -1,4 +1,4 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { MicrosoftRewardsBot } from '../index'
@@ -8,20 +8,117 @@ import { Poll } from './activities/Poll'
import { Quiz } from './activities/Quiz'
import { ThisOrThat } from './activities/ThisOrThat'
import { UrlReward } from './activities/UrlReward'
import { SearchOnBing } from './activities/SearchOnBing'
import { ReadToEarn } from './activities/ReadToEarn'
import { DailyCheckIn } from './activities/DailyCheckIn'
import { DashboardData } from '../interface/DashboardData'
import { DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
import type { ActivityHandler } from '../interface/ActivityHandler'
type ActivityKind =
| { type: 'poll' }
| { type: 'abc' }
| { type: 'thisOrThat' }
| { type: 'quiz' }
| { type: 'urlReward' }
| { type: 'searchOnBing' }
| { type: 'unsupported' }
export default class Activities {
private bot: MicrosoftRewardsBot
private handlers: ActivityHandler[] = []
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
doSearch = async (page: Page, data: DashboardData, mobile: boolean): Promise<void> => {
// Register external/custom handlers (optional extension point)
registerHandler(handler: ActivityHandler) {
this.handlers.push(handler)
}
// Centralized dispatcher for activities from dashboard/punchcards
async run(page: Page, activity: MorePromotion | PromotionalItem): Promise<void> {
// First, try custom handlers (if any)
for (const h of this.handlers) {
try {
if (h.canHandle(activity)) {
await h.run(page, activity)
return
}
} catch (e) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Custom handler ${(h.id || 'unknown')} failed: ${e instanceof Error ? e.message : e}`, 'error')
}
}
const kind = this.classifyActivity(activity)
try {
switch (kind.type) {
case 'poll':
await this.doPoll(page)
break
case 'abc':
await this.doABC(page)
break
case 'thisOrThat':
await this.doThisOrThat(page)
break
case 'quiz':
await this.doQuiz(page)
break
case 'searchOnBing':
await this.doSearchOnBing(page, activity)
break
case 'urlReward':
await this.doUrlReward(page)
break
default:
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${String((activity as { promotionType?: string }).promotionType)}"!`, 'warn')
break
}
} catch (e) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Dispatcher error for "${activity.title}": ${e instanceof Error ? e.message : e}`, 'error')
}
}
public getTypeLabel(activity: MorePromotion | PromotionalItem): string {
const k = this.classifyActivity(activity)
switch (k.type) {
case 'poll': return 'Poll'
case 'abc': return 'ABC'
case 'thisOrThat': return 'ThisOrThat'
case 'quiz': return 'Quiz'
case 'searchOnBing': return 'SearchOnBing'
case 'urlReward': return 'UrlReward'
default: return 'Unsupported'
}
}
private classifyActivity(activity: MorePromotion | PromotionalItem): ActivityKind {
const type = (activity.promotionType || '').toLowerCase()
if (type === 'quiz') {
// Distinguish Poll/ABC/ThisOrThat vs general quiz using current heuristics
const max = activity.pointProgressMax
const url = (activity.destinationUrl || '').toLowerCase()
if (max === 10) {
if (url.includes('pollscenarioid')) return { type: 'poll' }
return { type: 'abc' }
}
if (max === 50) return { type: 'thisOrThat' }
return { type: 'quiz' }
}
if (type === 'urlreward') {
const name = (activity.name || '').toLowerCase()
if (name.includes('exploreonbing')) return { type: 'searchOnBing' }
return { type: 'urlReward' }
}
return { type: 'unsupported' }
}
doSearch = async (page: Page, data: DashboardData): Promise<void> => {
const search = new Search(this.bot)
await search.doSearch(page, data, mobile)
await search.doSearch(page, data)
}
doABC = async (page: Page): Promise<void> => {
@@ -49,4 +146,19 @@ export default class Activities {
await urlReward.doUrlReward(page)
}
doSearchOnBing = async (page: Page, activity: MorePromotion | PromotionalItem): Promise<void> => {
const searchOnBing = new SearchOnBing(this.bot)
await searchOnBing.doSearchOnBing(page, activity)
}
doReadToEarn = async (accessToken: string, data: DashboardData): Promise<void> => {
const readToEarn = new ReadToEarn(this.bot)
await readToEarn.doReadToEarn(accessToken, data)
}
doDailyCheckIn = async (accessToken: string, data: DashboardData): Promise<void> => {
const dailyCheckIn = new DailyCheckIn(this.bot)
await dailyCheckIn.doDailyCheckIn(accessToken, data)
}
}

View File

@@ -1,133 +1,878 @@
import { Page } from 'puppeteer'
// Clean refactored Login implementation
// Public API preserved: login(), getMobileAccessToken()
import type { Page, Locator } from 'playwright'
import * as crypto from 'crypto'
import readline from 'readline'
import { AxiosRequestConfig } from 'axios'
import { generateTOTP } from '../util/Totp'
import { saveSessionData } from '../util/Load'
import { MicrosoftRewardsBot } from '../index'
import { OAuth } from '../interface/OAuth'
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
// -------------------------------
// Constants / Tunables
// -------------------------------
const SELECTORS = {
emailInput: 'input[type="email"]',
passwordInput: 'input[type="password"]',
submitBtn: 'button[type="submit"]',
passkeySecondary: 'button[data-testid="secondaryButton"]',
passkeyPrimary: 'button[data-testid="primaryButton"]',
passkeyTitle: '[data-testid="title"]',
kmsiVideo: '[data-testid="kmsiVideo"]',
biometricVideo: '[data-testid="biometricVideo"]'
} as const
const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' }
const DEFAULT_TIMEOUTS = {
loginMaxMs: (() => {
const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000)
if (isNaN(val) || val < 10000 || val > 600000) {
console.warn(`[Login] Invalid LOGIN_MAX_WAIT_MS: ${process.env.LOGIN_MAX_WAIT_MS}. Using default 180000ms`)
return 180000
}
return val
})(),
short: 500,
medium: 1500,
long: 3000
}
// Security pattern bundle
const SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [
{ re: /we can['`]?t sign you in/i, label: 'cant-sign-in' },
{ re: /incorrect account or password too many times/i, label: 'too-many-incorrect' },
{ re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' },
{ re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' },
{ re: /your account has been locked/i, label: 'account-locked' },
{ re: /your account or password is incorrect too many times/i, label: 'incorrect-too-many-times' }
]
interface SecurityIncident {
kind: string
account: string
details?: string[]
next?: string[]
docsUrl?: string
}
export class Login {
private bot: MicrosoftRewardsBot
private bot: MicrosoftRewardsBot
private clientId = '0000000040170455'
private authBaseUrl = 'https://login.live.com/oauth20_authorize.srf'
private redirectUrl = 'https://login.live.com/oauth20_desktop.srf'
private tokenUrl = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
private currentTotpSecret?: string
private compromisedInterval?: NodeJS.Timeout
private passkeyHandled = false
private noPromptIterations = 0
private lastNoPromptLog = 0
constructor(bot: MicrosoftRewardsBot) { this.bot = bot }
// --------------- Public API ---------------
async login(page: Page, email: string, password: string, totpSecret?: string) {
try {
// Clear any existing intervals from previous runs
if (this.compromisedInterval) {
clearInterval(this.compromisedInterval)
this.compromisedInterval = undefined
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process')
this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined
await page.goto('https://www.bing.com/rewards/dashboard')
await this.disableFido(page)
await page.waitForLoadState('domcontentloaded').catch(() => { })
await this.bot.browser.utils.reloadBadPage(page)
await this.checkAccountLocked(page)
const already = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 8000 }).then(() => true).catch(() => false)
if (!already) {
await this.performLoginFlow(page, email, password)
} else {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Session already authenticated')
await this.checkAccountLocked(page)
}
await this.verifyBingContext(page)
await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile)
this.bot.log(this.bot.isMobile, 'LOGIN', 'Login complete (session saved)')
this.currentTotpSecret = undefined
} catch (e) {
throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed login: ' + e, 'error')
}
}
async getMobileAccessToken(page: Page, email: string) {
// Reuse same FIDO disabling
await this.disableFido(page)
const url = new URL(this.authBaseUrl)
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', this.clientId)
url.searchParams.set('redirect_uri', this.redirectUrl)
url.searchParams.set('scope', this.scope)
url.searchParams.set('state', crypto.randomBytes(16).toString('hex'))
url.searchParams.set('access_type', 'offline_access')
url.searchParams.set('login_hint', email)
await page.goto(url.href)
const start = Date.now()
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Authorizing mobile scope...')
let code = ''
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
await this.handlePasskeyPrompts(page, 'oauth')
const u = new URL(page.url())
if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') {
code = u.searchParams.get('code') || ''
break
}
await this.bot.utils.wait(1000)
}
if (!code) throw this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'OAuth code not received in time', 'error')
const form = new URLSearchParams()
form.append('grant_type', 'authorization_code')
form.append('client_id', this.clientId)
form.append('code', code)
form.append('redirect_uri', this.redirectUrl)
const req: AxiosRequestConfig = { url: this.tokenUrl, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: form.toString() }
const resp = await this.bot.axios.request(req)
const data: OAuth = resp.data
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Authorized in ${Math.round((Date.now() - start) / 1000)}s`)
return data.access_token
}
// --------------- Main Flow ---------------
private async performLoginFlow(page: Page, email: string, password: string) {
await this.inputEmail(page, email)
await this.bot.utils.wait(1000)
await this.bot.browser.utils.reloadBadPage(page)
await this.bot.utils.wait(500)
await this.tryRecoveryMismatchCheck(page, email)
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Recovery mismatch detected stopping before password entry', 'warn')
return
}
// Try switching to password if a locale link is present (FR/EN)
await this.switchToPasswordLink(page)
await this.inputPasswordOr2FA(page, password)
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected — halting.', 'warn')
return
}
await this.checkAccountLocked(page)
await this.awaitRewardsPortal(page)
}
// --------------- Input Steps ---------------
private async inputEmail(page: Page, email: string) {
const field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(() => null)
if (!field) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not present', 'warn'); return }
const prefilled = await page.waitForSelector('#userDisplayName', { timeout: 1500 }).catch(() => null)
if (!prefilled) {
await page.fill(SELECTORS.emailInput, '')
await page.fill(SELECTORS.emailInput, email)
} else {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Email prefilled')
}
const next = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null)
if (next) { await next.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted email') }
}
private async inputPasswordOr2FA(page: Page, password: string) {
// Some flows require switching to password first
const switchBtn = await page.waitForSelector('#idA_PWD_SwitchToPassword', { timeout: 1500 }).catch(() => null)
if (switchBtn) { await switchBtn.click().catch(() => { }); await this.bot.utils.wait(1000) }
// Rare flow: list of methods -> choose password
const passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(() => null)
if (!passwordField) {
const blocked = await this.detectSignInBlocked(page)
if (blocked) return
// Log that we're handling the "Get a code to sign in" flow
this.bot.log(this.bot.isMobile, 'LOGIN', 'Attempting to handle "Get a code to sign in" flow')
// Try to handle "Other ways to sign in" flow first
const otherWaysHandled = await this.handleOtherWaysToSignIn(page)
if (otherWaysHandled) {
// Try to find password field again after clicking "Other ways"
const passwordFieldAfter = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 3000 }).catch(() => null)
if (passwordFieldAfter) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field found after "Other ways" flow')
await page.fill(SELECTORS.passwordInput, '')
await page.fill(SELECTORS.passwordInput, password)
const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null)
if (submit) { await submit.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') }
return
}
}
// If still no password field -> likely 2FA (approvals) first
this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field absent — invoking 2FA handler', 'warn')
await this.handle2FA(page)
return
}
async login(page: Page, email: string, password: string) {
const blocked = await this.detectSignInBlocked(page)
if (blocked) return
await page.fill(SELECTORS.passwordInput, '')
await page.fill(SELECTORS.passwordInput, password)
const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null)
if (submit) { await submit.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') }
}
// --------------- Other Ways to Sign In Handling ---------------
private async handleOtherWaysToSignIn(page: Page): Promise<boolean> {
try {
// Look for "Other ways to sign in" - typically a span with role="button"
const otherWaysSelectors = [
'span[role="button"]:has-text("Other ways to sign in")',
'span:has-text("Other ways to sign in")',
'button:has-text("Other ways to sign in")',
'a:has-text("Other ways to sign in")',
'div[role="button"]:has-text("Other ways to sign in")'
]
let clicked = false
for (const selector of otherWaysSelectors) {
const element = await page.waitForSelector(selector, { timeout: 1000 }).catch(() => null)
if (element && await element.isVisible().catch(() => false)) {
await element.click().catch(() => { })
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Other ways to sign in"')
await this.bot.utils.wait(2000) // Wait for options to appear
clicked = true
break
}
}
if (!clicked) {
return false
}
// Now look for "Use your password" option
const usePasswordSelectors = [
'span[role="button"]:has-text("Use your password")',
'span:has-text("Use your password")',
'button:has-text("Use your password")',
'button:has-text("Password")',
'a:has-text("Use your password")',
'div[role="button"]:has-text("Use your password")',
'div[role="button"]:has-text("Password")'
]
for (const selector of usePasswordSelectors) {
const element = await page.waitForSelector(selector, { timeout: 1500 }).catch(() => null)
if (element && await element.isVisible().catch(() => false)) {
await element.click().catch(() => { })
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password"')
await this.bot.utils.wait(2000) // Wait for password field to appear
return true
}
}
return false
} catch (error) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Error in handleOtherWaysToSignIn: ' + error, 'warn')
return false
}
}
// --------------- 2FA Handling ---------------
private async handle2FA(page: Page) {
try {
// Dismiss any popups/dialogs before checking 2FA (Terms Update, etc.)
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(500)
if (this.currentTotpSecret) {
const totpSelector = await this.ensureTotpInput(page)
if (totpSelector) {
await this.submitTotpCode(page, totpSelector)
return
}
}
const number = await this.fetchAuthenticatorNumber(page)
if (number) { await this.approveAuthenticator(page, number); return }
await this.handleSMSOrTotp(page)
} catch (e) {
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA error: ' + e, 'warn')
}
}
private async fetchAuthenticatorNumber(page: Page): Promise<string | null> {
try {
const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2500 })
return (await el.textContent())?.trim() || null
} catch {
// Attempt resend loop in parallel mode
if (this.bot.config.parallel) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Parallel mode: throttling authenticator push requests', 'log', 'yellow')
for (let attempts = 0; attempts < 6; attempts++) { // max 6 minutes retry window
const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(() => null)
if (!resend) break
await this.bot.utils.wait(60000)
await resend.click().catch(() => { })
}
}
await page.click('button[aria-describedby="confirmSendTitle"]').catch(() => { })
await this.bot.utils.wait(1500)
try {
const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2000 })
return (await el.textContent())?.trim() || null
} catch { return null }
}
}
private async approveAuthenticator(page: Page, numberToPress: string) {
for (let cycle = 0; cycle < 6; cycle++) { // max ~6 refresh cycles
try {
this.bot.log(this.bot.isMobile, 'LOGIN', `Approve login in Authenticator (press ${numberToPress})`)
await page.waitForSelector('form[name="f1"]', { state: 'detached', timeout: 60000 })
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval successful')
return
} catch {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator code expired refreshing')
const retryBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 3000 }).catch(() => null)
if (retryBtn) await retryBtn.click().catch(() => { })
const refreshed = await this.fetchAuthenticatorNumber(page)
if (!refreshed) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Could not refresh authenticator code', 'warn'); return }
numberToPress = refreshed
}
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval loop exited (max cycles reached)', 'warn')
}
private async handleSMSOrTotp(page: Page) {
// TOTP auto entry (second chance if ensureTotpInput needed longer)
if (this.currentTotpSecret) {
try {
const totpSelector = await this.ensureTotpInput(page)
if (totpSelector) {
await this.submitTotpCode(page, totpSelector)
return
}
} catch {/* ignore */ }
}
// Manual prompt with periodic page check
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
// Monitor page changes while waiting for user input
let userInput: string | null = null
let checkInterval: NodeJS.Timeout | null = null
try {
const inputPromise = new Promise<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
if (checkInterval) clearInterval(checkInterval)
rl.close()
res(ans.trim())
})
})
// Check every 2 seconds if user manually progressed past the dialog
checkInterval = setInterval(async () => {
try {
// Navigate to the Bing login page
await page.goto('https://login.live.com/')
await this.bot.browser.utils.tryDismissAllMessages(page)
// Check if we're no longer on 2FA page
const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false)
if (!still2FA) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn')
if (checkInterval) clearInterval(checkInterval)
rl.close()
userInput = 'skip' // Signal to skip submission
}
} catch {/* ignore */ }
}, 2000)
const isLoggedIn = await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 10_000 }).then(() => true).catch(() => false)
const code = await inputPromise
if (!isLoggedIn) {
const isLocked = await page.waitForSelector('.serviceAbusePageContainer', { visible: true, timeout: 10_000 }).then(() => true).catch(() => false)
if (isLocked) {
this.bot.log('LOGIN', 'This account has been locked!', 'error')
throw new Error('Account has been locked!')
}
if (code === 'skip' || userInput === 'skip') {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)')
return
}
await page.waitForSelector('#loginHeader', { visible: true, timeout: 10_000 })
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
} finally {
// Ensure cleanup happens even if errors occur
if (checkInterval) clearInterval(checkInterval)
try { rl.close() } catch {/* ignore */ }
}
}
await this.execLogin(page, email, password)
this.bot.log('LOGIN', 'Logged into Microsoft successfully')
} else {
this.bot.log('LOGIN', 'Already logged in')
}
private async ensureTotpInput(page: Page): Promise<string | null> {
const selector = await this.findFirstVisibleSelector(page, this.totpInputSelectors())
if (selector) return selector
// Check if logged in to bing
await this.checkBingLogin(page)
const attempts = 4
for (let i = 0; i < attempts; i++) {
let acted = false
// We're done logging in
this.bot.log('LOGIN', 'Logged in successfully')
// Step 1: expose alternative verification options if hidden
if (!acted) {
acted = await this.clickFirstVisibleSelector(page, this.totpAltOptionSelectors())
if (acted) await this.bot.utils.wait(900)
}
} catch (error) {
// Throw and don't continue
throw this.bot.log('LOGIN', 'An error occurred:' + error, 'error')
}
// Step 2: choose authenticator code option if available
if (!acted) {
acted = await this.clickFirstVisibleSelector(page, this.totpChallengeSelectors())
if (acted) await this.bot.utils.wait(900)
}
const ready = await this.findFirstVisibleSelector(page, this.totpInputSelectors())
if (ready) return ready
if (!acted) break
}
private async execLogin(page: Page, email: string, password: string) {
await page.type('#i0116', email)
await page.click('#idSIButton9')
return null
}
this.bot.log('LOGIN', 'Email entered successfully')
private async submitTotpCode(page: Page, selector: string) {
try {
const code = generateTOTP(this.currentTotpSecret!.trim())
const input = page.locator(selector).first()
if (!await input.isVisible().catch(() => false)) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP input unexpectedly hidden', 'warn')
return
}
await input.fill('')
await input.fill(code)
// Use unified selector system
const submit = await this.findFirstVisibleLocator(page, Login.TOTP_SELECTORS.submit)
if (submit) {
await submit.click().catch(() => { })
} else {
await page.keyboard.press('Enter').catch(() => { })
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically')
} catch (error) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed to submit TOTP automatically: ' + error, 'warn')
}
}
// Unified selector system - DRY principle
private static readonly TOTP_SELECTORS = {
input: [
'input[name="otc"]',
'#idTxtBx_SAOTCC_OTC',
'#idTxtBx_SAOTCS_OTC',
'input[data-testid="otcInput"]',
'input[autocomplete="one-time-code"]',
'input[type="tel"][name="otc"]'
],
altOptions: [
'#idA_SAOTCS_ProofPickerChange',
'#idA_SAOTCC_AlternateLogin',
'a:has-text("Use a different verification option")',
'a:has-text("Sign in another way")',
'a:has-text("I can\'t use my Microsoft Authenticator app right now")',
'button:has-text("Use a different verification option")',
'button:has-text("Sign in another way")'
],
challenge: [
'[data-value="PhoneAppOTP"]',
'[data-value="OneTimeCode"]',
'button:has-text("Use a verification code")',
'button:has-text("Enter code manually")',
'button:has-text("Enter a code from your authenticator app")',
'button:has-text("Use code from your authentication app")',
'button:has-text("Utiliser un code de vérification")',
'button:has-text("Utiliser un code de verification")',
'button:has-text("Entrer un code depuis votre application")',
'button:has-text("Entrez un code depuis votre application")',
'button:has-text("Entrez un code")',
'div[role="button"]:has-text("Use a verification code")',
'div[role="button"]:has-text("Enter a code")'
],
submit: [
'#idSubmit_SAOTCC_Continue',
'#idSubmit_SAOTCC_OTC',
'button[type="submit"]:has-text("Verify")',
'button[type="submit"]:has-text("Continuer")',
'button:has-text("Verify")',
'button:has-text("Continuer")',
'button:has-text("Submit")'
]
} as const
private totpInputSelectors(): readonly string[] { return Login.TOTP_SELECTORS.input }
private totpAltOptionSelectors(): readonly string[] { return Login.TOTP_SELECTORS.altOptions }
private totpChallengeSelectors(): readonly string[] { return Login.TOTP_SELECTORS.challenge }
// Generic selector finder - reduces duplication from 3 functions to 1
private async findFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<string | null> {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) return sel
}
return null
}
private async clickFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<boolean> {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) {
await loc.click().catch(() => { })
return true
}
}
return false
}
private async findFirstVisibleLocator(page: Page, selectors: readonly string[]): Promise<Locator | null> {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) return loc
}
return null
}
private async waitForRewardsRoot(page: Page, timeoutMs: number): Promise<string | null> {
const selectors = [
'html[data-role-name="RewardsPortal"]',
'html[data-role-name*="RewardsPortal"]',
'body[data-role-name*="RewardsPortal"]',
'[data-role-name*="RewardsPortal"]',
'[data-bi-name="rewards-dashboard"]',
'main[data-bi-name="dashboard"]',
'#more-activities',
'#dashboard'
]
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) {
return sel
}
}
await this.bot.utils.wait(350)
}
return null
}
// --------------- Verification / State ---------------
private async awaitRewardsPortal(page: Page) {
const start = Date.now()
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
await this.handlePasskeyPrompts(page, 'main')
const u = new URL(page.url())
const isRewardsHost = u.hostname === LOGIN_TARGET.host
const isKnownPath = u.pathname === LOGIN_TARGET.path
|| u.pathname === '/dashboard'
|| u.pathname === '/rewardsapp/dashboard'
|| u.pathname.startsWith('/?')
if (isRewardsHost && isKnownPath) break
await this.bot.utils.wait(1000)
}
const portalSelector = await this.waitForRewardsRoot(page, 8000)
if (!portalSelector) {
try {
await this.bot.browser.func.goHome(page)
} catch {/* ignore fallback errors */ }
const fallbackSelector = await this.waitForRewardsRoot(page, 6000)
if (!fallbackSelector) {
throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal root element missing after navigation', 'error')
}
this.bot.log(this.bot.isMobile, 'LOGIN', `Reached rewards portal via fallback (${fallbackSelector})`)
return
}
this.bot.log(this.bot.isMobile, 'LOGIN', `Reached rewards portal (${portalSelector})`)
}
private async verifyBingContext(page: Page) {
try {
this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing auth context')
await page.goto('https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F')
for (let i = 0; i < 5; i++) {
const u = new URL(page.url())
if (u.hostname === 'www.bing.com' && u.pathname === '/') {
await this.bot.browser.utils.tryDismissAllMessages(page)
const ok = await page.waitForSelector('#id_n', { timeout: 3000 }).then(() => true).catch(() => false)
if (ok || this.bot.isMobile) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification passed'); break }
}
await this.bot.utils.wait(1000)
}
} catch (e) {
this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification error: ' + e, 'warn')
}
}
private async checkAccountLocked(page: Page) {
const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(() => true).catch(() => false)
if (locked) throw this.bot.log(this.bot.isMobile, 'CHECK-LOCKED', 'Account locked by Microsoft (serviceAbuseLandingTitle)', 'error')
}
// --------------- Passkey / Dialog Handling ---------------
private async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') {
let did = false
// Video heuristic
const biometric = await page.waitForSelector(SELECTORS.biometricVideo, { timeout: 500 }).catch(() => null)
if (biometric) {
const btn = await page.$(SELECTORS.passkeySecondary)
if (btn) { await btn.click().catch(() => { }); did = true; this.logPasskeyOnce('video heuristic') }
}
if (!did) {
const titleEl = await page.waitForSelector(SELECTORS.passkeyTitle, { timeout: 500 }).catch(() => null)
const secBtn = await page.waitForSelector(SELECTORS.passkeySecondary, { timeout: 500 }).catch(() => null)
const primBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 500 }).catch(() => null)
const title = (titleEl ? (await titleEl.textContent()) : '')?.trim() || ''
const looksLike = /sign in faster|passkey|fingerprint|face|pin/i.test(title)
if (looksLike && secBtn) { await secBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('title heuristic ' + title) }
else if (!did && secBtn && primBtn) {
const text = (await secBtn.textContent() || '').trim()
if (/skip for now/i.test(text)) { await secBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('secondary button text') }
}
if (!did) {
const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now")]').first()
if (await textBtn.isVisible().catch(() => false)) { await textBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('text fallback') }
}
if (!did) {
const close = await page.$('#close-button')
if (close) { await close.click().catch(() => { }); did = true; this.logPasskeyOnce('close button') }
}
}
// KMSI prompt
const kmsi = await page.waitForSelector(SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null)
if (kmsi) {
const yes = await page.$(SELECTORS.passkeyPrimary)
if (yes) { await yes.click().catch(() => { }); did = true; this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt') }
}
if (!did && context === 'main') {
this.noPromptIterations++
const now = Date.now()
if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) {
this.lastNoPromptLog = now
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
if (this.noPromptIterations > 50) this.noPromptIterations = 0
}
} else if (did) {
this.noPromptIterations = 0
}
}
private logPasskeyOnce(reason: string) {
if (this.passkeyHandled) return
this.passkeyHandled = true
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`)
}
// --------------- Security Detection ---------------
private async detectSignInBlocked(page: Page): Promise<boolean> {
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') return true
try {
let text = ''
for (const sel of ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title']) {
const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null)
if (el) {
const t = (await el.textContent() || '').trim()
if (t && t.length < 300) text += ' ' + t
}
}
const lower = text.toLowerCase()
let matched: string | null = null
for (const p of SIGN_IN_BLOCK_PATTERNS) { if (p.re.test(lower)) { matched = p.label; break } }
if (!matched) return false
const email = this.bot.currentAccountEmail || 'unknown'
const incident: SecurityIncident = {
kind: 'We can\'t sign you in (blocked)',
account: email,
details: [matched ? `Pattern: ${matched}` : 'Pattern: unknown'],
next: ['Manual recovery required before continuing']
}
await this.sendIncidentAlert(incident, 'warn')
this.bot.compromisedModeActive = true
this.bot.compromisedReason = 'sign-in-blocked'
this.startCompromisedInterval()
await this.bot.engageGlobalStandby('sign-in-blocked', email).catch(() => { })
return true
} catch { return false }
}
private async tryRecoveryMismatchCheck(page: Page, email: string) { try { await this.detectAndHandleRecoveryMismatch(page, email) } catch {/* ignore */ } }
private async detectAndHandleRecoveryMismatch(page: Page, email: string) {
try {
const recoveryEmail: string | undefined = this.bot.currentAccountRecoveryEmail
if (!recoveryEmail || !/@/.test(recoveryEmail)) return
const accountEmail = email
const parseRef = (val: string) => { const [l, d] = val.split('@'); return { local: l || '', domain: (d || '').toLowerCase(), prefix2: (l || '').slice(0, 2).toLowerCase() } }
const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r => r.domain && r.prefix2)
if (refs.length === 0) return
const candidates: string[] = []
// Direct selectors (Microsoft variants + French spans)
const sel = '[data-testid="recoveryEmailHint"], #recoveryEmail, [id*="ProofEmail"], [id*="EmailProof"], [data-testid*="Email"], span:has(span.fui-Text)'
const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(() => null)
if (el) { const t = (await el.textContent() || '').trim(); if (t) candidates.push(t) }
// List items
const li = page.locator('[role="listitem"], li')
const liCount = await li.count().catch(() => 0)
for (let i = 0; i < liCount && i < 12; i++) { const t = (await li.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && /@/.test(t)) candidates.push(t) }
// XPath generic masked patterns
const xp = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]')
const xpCount = await xp.count().catch(() => 0)
for (let i = 0; i < xpCount && i < 12; i++) { const t = (await xp.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && t.length < 300) candidates.push(t) }
// Normalize
const seen = new Set<string>()
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
const uniq = candidates.map(norm).filter(t => t && !seen.has(t) && seen.add(t))
// Masked filter
let masked = uniq.filter(t => /@/.test(t) && /[*•]/.test(t))
if (masked.length === 0) {
// Fallback full HTML scan
try {
await page.waitForSelector('#i0118', { visible: true, timeout: 2000 })
await this.bot.utils.wait(2000)
const html = await page.content()
const generic = /[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+/g
const frPhrase = /Nous\s+enverrons\s+un\s+code\s+à\s+([^<@]*[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vérifier/gi
const found = new Set<string>()
let m: RegExpExecArray | null
while ((m = generic.exec(html)) !== null) found.add(m[0])
while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g, '').trim(); if (raw) found.add(raw) }
if (found.size > 0) masked = Array.from(found)
} catch {/* ignore */ }
}
if (masked.length === 0) return
await page.type('#i0118', password)
await page.click('#idSIButton9')
// Prefer one mentioning email/adresse
const preferred = masked.find(t => /email|courriel|adresse|mail/i.test(t)) || masked[0]!
// Extract the masked email: Microsoft sometimes shows only first 1 char (k*****@domain) or 2 chars (ko*****@domain).
// We ONLY compare (1 or 2) leading visible alphanumeric chars + full domain (case-insensitive).
// This avoids false positives when the displayed mask hides the 2nd char.
const maskRegex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/
const m = maskRegex.exec(preferred)
// Fallback: try to salvage with looser pattern if first regex fails
const loose = !m ? /([a-zA-Z0-9])[*•][a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(preferred) : null
const use = m || loose
const extracted = use ? use[0] : preferred
const extractedLower = extracted.toLowerCase()
let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase()
let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase()
if (!observedDomain && extractedLower.includes('@')) {
const parts = extractedLower.split('@')
observedDomain = parts[1] || ''
}
if (!observedPrefix && extractedLower.includes('@')) {
const parts = extractedLower.split('@')
observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi, '').slice(0, 2)
}
} catch (error) {
this.bot.log('LOGIN', '2FA code required')
const code = await new Promise<string>((resolve) => {
rl.question('Enter 2FA code:\n', (input) => {
rl.close()
resolve(input)
})
})
await page.type('input[name="otc"]', code)
await page.keyboard.press('Enter')
} finally {
this.bot.log('LOGIN', 'Password entered successfully')
// Determine if any reference (recoveryEmail or accountEmail) matches observed mask logic
const matchRef = refs.find(r => {
if (r.domain !== observedDomain) return false
// If only one char visible, only enforce first char; if two, enforce both.
if (observedPrefix.length === 1) {
return r.prefix2.startsWith(observedPrefix)
}
return r.prefix2 === observedPrefix
})
const currentURL = new URL(page.url())
while (currentURL.pathname !== '/' || currentURL.hostname !== 'account.microsoft.com') {
await this.bot.browser.utils.tryDismissAllMessages(page)
currentURL.href = page.url()
if (!matchRef) {
const incident: SecurityIncident = {
kind: 'Recovery email mismatch',
account: email,
details: [
`MaskedShown: ${preferred}`,
`Extracted: ${extracted}`,
`Observed => ${observedPrefix || '??'}**@${observedDomain || '??'}`,
`Expected => ${refs.map(r => `${r.prefix2}**@${r.domain}`).join(' OR ')}`
],
next: [
'Automation halted globally (standby engaged).',
'Verify account security & recovery email in Microsoft settings.',
'Update accounts.json if the change was legitimate before restart.'
]
}
await this.sendIncidentAlert(incident, 'critical')
this.bot.compromisedModeActive = true
this.bot.compromisedReason = 'recovery-mismatch'
this.startCompromisedInterval()
await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(() => { })
} else {
const mode = observedPrefix.length === 1 ? 'lenient' : 'strict'
this.bot.log(this.bot.isMobile, 'LOGIN-RECOVERY', `Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`)
}
} catch {/* non-fatal */ }
}
// Wait for login to complete
await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 10_000 })
}
private async switchToPasswordLink(page: Page) {
try {
const link = await page.locator('xpath=//span[@role="button" and (contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"use your password") or contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"utilisez votre mot de passe"))]').first()
if (await link.isVisible().catch(() => false)) {
await link.click().catch(() => { })
await this.bot.utils.wait(800)
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password" link')
}
} catch {/* ignore */ }
}
private async checkBingLogin(page: Page): Promise<void> {
try {
this.bot.log('LOGIN-BING', 'Verifying Bing login')
await page.goto('https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F')
// --------------- Incident Helpers ---------------
private async sendIncidentAlert(incident: SecurityIncident, severity: 'warn' | 'critical' = 'warn') {
const lines = [`[Incident] ${incident.kind}`, `Account: ${incident.account}`]
if (incident.details?.length) lines.push(`Details: ${incident.details.join(' | ')}`)
if (incident.next?.length) lines.push(`Next: ${incident.next.join(' -> ')}`)
if (incident.docsUrl) lines.push(`Docs: ${incident.docsUrl}`)
const level: 'warn' | 'error' = severity === 'critical' ? 'error' : 'warn'
this.bot.log(this.bot.isMobile, 'SECURITY', lines.join(' | '), level)
try {
const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
const fields = [
{ name: 'Account', value: incident.account },
...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []),
...(incident.next?.length ? [{ name: 'Next steps', value: incident.next.join('\n') }] : []),
...(incident.docsUrl ? [{ name: 'Docs', value: incident.docsUrl }] : [])
]
await ConclusionWebhook(
this.bot.config,
`🔐 ${incident.kind}`,
'_Security check',
fields,
severity === 'critical' ? 0xFF0000 : 0xFFAA00
)
} catch {/* ignore */ }
}
const maxIterations = 5
private startCompromisedInterval() {
if (this.compromisedInterval) clearInterval(this.compromisedInterval)
this.compromisedInterval = setInterval(() => {
try { this.bot.log(this.bot.isMobile, 'SECURITY', 'Account in security standby. Review before proceeding.', 'warn') } catch {/* ignore */ }
}, 5 * 60 * 1000)
}
for (let iteration = 1; iteration <= maxIterations; iteration++) {
const currentUrl = new URL(page.url())
if (currentUrl.hostname === 'www.bing.com' && currentUrl.pathname === '/') {
await this.bot.utils.wait(3000)
await this.bot.browser.utils.tryDismissBingCookieBanner(page)
const loggedIn = await this.checkBingLoginStatus(page)
if (loggedIn) {
this.bot.log('LOGIN-BING', 'Bing login verification passed!')
break
}
}
await this.bot.utils.wait(1000)
}
} catch (error) {
this.bot.log('LOGIN-BING', 'An error occurred:' + error, 'error')
}
}
private async checkBingLoginStatus(page: Page): Promise<boolean> {
try {
await page.waitForSelector('#id_n', { timeout: 10_000 })
return true
} catch (error) {
return false
}
}
}
// --------------- Infrastructure ---------------
private async disableFido(page: Page) {
await page.route('**/GetCredentialType.srf*', route => {
try {
const body = JSON.parse(route.request().postData() || '{}')
body.isFidoSupported = false
route.continue({ postData: JSON.stringify(body) })
} catch { route.continue() }
}).catch(() => { })
}
}

View File

@@ -1,66 +1,117 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
import { MicrosoftRewardsBot } from '../index'
import JobState from '../util/JobState'
import Retry from '../util/Retry'
import { AdaptiveThrottler } from '../util/AdaptiveThrottler'
export class Workers {
public bot: MicrosoftRewardsBot
private jobState: JobState
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
this.jobState = new JobState(this.bot.config)
}
// Daily Set
async doDailySet(page: Page, data: DashboardData) {
const todayData = data.dailySetPromotions[this.bot.utils.getFormattedDate()]
const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
const today = this.bot.utils.getFormattedDate()
const activitiesUncompleted = (todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [])
.filter(x => {
if (this.bot.config.jobState?.enabled === false) return true
const email = this.bot.currentAccountEmail || 'unknown'
return !this.jobState.isDone(email, today, x.offerId)
})
if (!activitiesUncompleted.length) {
this.bot.log('DAILY-SET', 'All Daily Set" items have already been completed')
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All Daily Set" items have already been completed')
return
}
// Solve Activities
this.bot.log('DAILY-SET', 'Started solving "Daily Set" items')
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items')
await this.solveActivities(page, activitiesUncompleted)
this.bot.log('DAILY-SET', 'All "Daily Set" items have been completed')
// Mark as done to prevent duplicate work if checkpoints enabled
if (this.bot.config.jobState?.enabled !== false) {
const email = this.bot.currentAccountEmail || 'unknown'
for (const a of activitiesUncompleted) {
this.jobState.markDone(email, today, a.offerId)
}
}
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
// Optional: immediately run desktop search bundle
if (!this.bot.isMobile && this.bot.config.workers.bundleDailySetWithSearch && this.bot.config.workers.doDesktopSearch) {
try {
await this.bot.utils.waitRandom(1200, 2600)
await this.bot.activities.doSearch(page, data)
} catch (e) {
this.bot.log(this.bot.isMobile, 'DAILY-SET', `Post-DailySet search failed: ${e instanceof Error ? e.message : e}`, 'warn')
}
}
}
// Punch Card
async doPunchCard(page: Page, data: DashboardData) {
const punchCardsUncompleted = data.punchCards?.filter(x => !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
const punchCardsUncompleted = data.punchCards?.filter(x => x.parentPromotion && !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
if (!punchCardsUncompleted.length) {
this.bot.log('PUNCH-CARD', 'All "Punch Cards" have already been completed')
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Cards" have already been completed')
return
}
for (const punchCard of punchCardsUncompleted) {
// Ensure parentPromotion exists before proceeding
if (!punchCard.parentPromotion?.title) {
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Skipped punchcard "${punchCard.name}" | Reason: Parent promotion is missing!`, 'warn')
continue
}
// Get latest page for each card
page = await this.bot.browser.utils.getLatestTab(page)
const activitiesUncompleted = punchCard.childPromotions.filter(x => !x.complete) // Only return uncompleted activities
// Solve Activities
this.bot.log('PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
const browser = page.browser()
page = await browser.newPage()
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
// Got to punch card index page in a new tab
await page.goto(punchCard.parentPromotion.destinationUrl, { referer: this.bot.config.baseURL })
// Wait for new page to load, max 10 seconds, however try regardless in case of error
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { })
await this.solveActivities(page, activitiesUncompleted, punchCard)
// Close the punch card index page
await page.close()
page = await this.bot.browser.utils.getLatestTab(page)
this.bot.log('PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
const pages = page.context().pages()
if (pages.length > 3) {
await page.close()
} else {
await this.bot.browser.func.goHome(page)
}
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
}
this.bot.log('PUNCH-CARD', 'All "Punch Card" items have been completed')
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Card" items have been completed')
}
// More Promotions
@@ -72,105 +123,120 @@ export class Workers {
morePromotions.push(data.promotionalItem as unknown as MorePromotion)
}
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0 && x.exclusiveLockedFeatureStatus !== 'locked') ?? []
if (!activitiesUncompleted.length) {
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
return
}
// Solve Activities
this.bot.log('MORE-PROMOTIONS', 'Started solving "More Promotions" item')
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'Started solving "More Promotions" items')
page = await this.bot.browser.utils.getLatestTab(page)
await this.solveActivities(page, activitiesUncompleted)
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
}
// Solve all the different types of activities
private async solveActivities(page: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
const activityInitial = activityPage.url()
const retry = new Retry(this.bot.config.retryPolicy)
const throttle = new AdaptiveThrottler()
for (const activity of activities) {
try {
activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
await this.applyThrottle(throttle, 800, 1400)
let selector = `[data-bi-id="${activity.offerId}"]`
const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
await this.prepareActivityPage(activityPage, selector, throttle)
if (punchCard) {
selector = await this.bot.browser.func.getPunchCardActivity(page, activity)
} else if (activity.name.toLowerCase().includes('membercenter')) {
// Promotion
if (activity.priority === 1) {
selector = '#promo-item'
} else {
selector = `[data-bi-id="${activity.name}"]`
}
const typeLabel = this.bot.activities.getTypeLabel(activity)
if (typeLabel !== 'Unsupported') {
await this.executeActivity(activityPage, activity, selector, throttle, retry)
} else {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
}
// Wait for element to load
await page.waitForSelector(selector, { timeout: 10_000 })
// Click element, it will be opened in a new tab
await page.click(selector)
// Cooldown
await this.bot.utils.wait(4000)
// Select the new activity page
const activityPage = await this.bot.browser.utils.getLatestTab(page)
// Wait for body to load
await activityPage.waitForSelector('body', { timeout: 10_000 })
switch (activity.promotionType) {
// Quiz (Poll, Quiz or ABC)
case 'quiz':
switch (activity.pointProgressMax) {
// Poll or ABC (Usually 10 points)
case 10:
// Normal poll
if (activity.destinationUrl.toLowerCase().includes('pollscenarioid')) {
this.bot.log('ACTIVITY', `Found activity type: "Poll" title: "${activity.title}"`)
await this.bot.activities.doPoll(activityPage)
} else { // ABC
this.bot.log('ACTIVITY', `Found activity type: "ABC" title: "${activity.title}"`)
await this.bot.activities.doABC(activityPage)
}
break
// This Or That Quiz (Usually 50 points)
case 50:
this.bot.log('ACTIVITY', `Found activity type: "ThisOrThat" title: "${activity.title}"`)
await this.bot.activities.doThisOrThat(activityPage)
break
// Quizzes are usually 30-40 points
default:
this.bot.log('ACTIVITY', `Found activity type: "Quiz" title: "${activity.title}"`)
await this.bot.activities.doQuiz(activityPage)
break
}
break
// UrlReward (Visit)
case 'urlreward':
this.bot.log('ACTIVITY', `Found activity type: "UrlReward" title: "${activity.title}"`)
await this.bot.activities.doUrlReward(activityPage)
break
// Misc, Usually UrlReward Type
default:
this.bot.log('ACTIVITY', `Found activity type: "Misc" title: "${activity.title}"`)
await this.bot.activities.doUrlReward(activityPage)
break
}
// Cooldown
await this.bot.utils.wait(2000)
await this.applyThrottle(throttle, 1200, 2600)
} catch (error) {
this.bot.log('ACTIVITY', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
throttle.record(false)
}
}
}
private async manageTabLifecycle(page: Page, initialUrl: string): Promise<Page> {
page = await this.bot.browser.utils.getLatestTab(page)
const pages = page.context().pages()
if (pages.length > 3) {
await page.close()
page = await this.bot.browser.utils.getLatestTab(page)
}
if (page.url() !== initialUrl) {
await page.goto(initialUrl)
}
return page
}
private async buildActivitySelector(page: Page, activity: PromotionalItem | MorePromotion, punchCard?: PunchCard): Promise<string> {
if (punchCard) {
return await this.bot.browser.func.getPunchCardActivity(page, activity)
}
const name = activity.name.toLowerCase()
if (name.includes('membercenter') || name.includes('exploreonbing')) {
return `[data-bi-id^="${activity.name}"] .pointLink:not(.contentContainer .pointLink)`
}
return `[data-bi-id^="${activity.offerId}"] .pointLink:not(.contentContainer .pointLink)`
}
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
await this.bot.browser.utils.humanizePage(page)
await this.applyThrottle(throttle, 1200, 2600)
}
private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`)
await page.click(selector)
page = await this.bot.browser.utils.getLatestTab(page)
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
const runWithTimeout = (p: Promise<void>) => Promise.race([
p,
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
])
await retry.run(async () => {
try {
await runWithTimeout(this.bot.activities.run(page, activity))
throttle.record(true)
} catch (e) {
throttle.record(false)
throw e
}
}, () => true)
await this.bot.browser.utils.humanizePage(page)
}
private async applyThrottle(throttle: AdaptiveThrottler, min: number, max: number): Promise<void> {
const multiplier = throttle.getDelayMultiplier()
await this.bot.utils.waitRandom(Math.floor(min * multiplier), Math.floor(max * multiplier))
}
}

View File

@@ -1,51 +1,50 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { RETRY_LIMITS, TIMEOUTS } from '../../constants'
export class ABC extends Workers {
async doABC(page: Page) {
this.bot.log('ABC', 'Trying to complete poll')
this.bot.log(this.bot.isMobile, 'ABC', 'Trying to complete poll')
try {
let $ = await this.bot.browser.func.refreshCheerio(page)
let $ = await this.bot.browser.func.loadInCheerio(page)
// Don't loop more than 15 in case unable to solve, would lock otherwise
const maxIterations = 15
let i
for (i = 0; i < maxIterations && !$('span.rw_icon').length; i++) {
await page.waitForSelector('.wk_OptionClickClass', { visible: true, timeout: 10_000 })
for (i = 0; i < RETRY_LIMITS.ABC_MAX && !$('span.rw_icon').length; i++) {
await page.waitForSelector('.wk_OptionClickClass', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
const answers = $('.wk_OptionClickClass')
const answer = answers[this.bot.utils.randomNumber(0, 2)]?.attribs['id']
await page.waitForSelector(`#${answer}`, { visible: true, timeout: 10_000 })
await page.waitForSelector(`#${answer}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await this.bot.utils.wait(2000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.click(`#${answer}`) // Click answer
await this.bot.utils.wait(4000)
await page.waitForSelector('div.wk_button', { visible: true, timeout: 10_000 })
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.waitForSelector('div.wk_button', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await page.click('div.wk_button') // Click next question button
page = await this.bot.browser.utils.getLatestTab(page)
$ = await this.bot.browser.func.refreshCheerio(page)
await this.bot.utils.wait(1000)
$ = await this.bot.browser.func.loadInCheerio(page)
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
}
await this.bot.utils.wait(4000)
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.close()
if (i === maxIterations) {
this.bot.log('ABC', 'Failed to solve quiz, exceeded max iterations of 15', 'warn')
if (i === RETRY_LIMITS.ABC_MAX) {
this.bot.log(this.bot.isMobile, 'ABC', `Failed to solve quiz, exceeded max iterations of ${RETRY_LIMITS.ABC_MAX}`, 'warn')
} else {
this.bot.log('ABC', 'Completed the ABC successfully')
this.bot.log(this.bot.isMobile, 'ABC', 'Completed the ABC successfully')
}
} catch (error) {
await page.close()
this.bot.log('ABC', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'ABC', 'An error occurred:' + error, 'error')
}
}

View File

@@ -0,0 +1,49 @@
import { randomBytes } from 'crypto'
import { AxiosRequestConfig } from 'axios'
import { Workers } from '../Workers'
import { DashboardData } from '../../interface/DashboardData'
export class DailyCheckIn extends Workers {
public async doDailyCheckIn(accessToken: string, data: DashboardData) {
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', 'Starting Daily Check In')
try {
let geoLocale = data.userProfile.attributes.country
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
const jsonData = {
amount: 1,
country: geoLocale,
id: randomBytes(64).toString('hex'),
type: 101,
attributes: {
offerid: 'Gamification_Sapphire_DailyCheckIn'
}
}
const claimRequest: AxiosRequestConfig = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en',
'X-Rewards-ismobile': 'true'
},
data: JSON.stringify(jsonData)
}
const claimResponse = await this.bot.axios.request(claimRequest)
const claimedPoint = parseInt((await claimResponse.data).response?.activity?.p) ?? 0
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', claimedPoint > 0 ? `Claimed ${claimedPoint} points` : 'Already claimed today')
} catch (error) {
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -1,28 +1,31 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { TIMEOUTS } from '../../constants'
export class Poll extends Workers {
async doPoll(page: Page) {
this.bot.log('POLL', 'Trying to complete poll')
this.bot.log(this.bot.isMobile, 'POLL', 'Trying to complete poll')
try {
const buttonId = `#btoption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
await page.waitForSelector(buttonId, { visible: true, timeout: 10_000 })
await this.bot.utils.wait(2000)
await page.waitForSelector(buttonId, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch((e) => {
this.bot.log(this.bot.isMobile, 'POLL', `Could not find poll button: ${e}`, 'warn')
})
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.click(buttonId)
await this.bot.utils.wait(4000)
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.close()
this.bot.log('POLL', 'Completed the poll successfully')
this.bot.log(this.bot.isMobile, 'POLL', 'Completed the poll successfully')
} catch (error) {
await page.close()
this.bot.log('POLL', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'POLL', 'An error occurred:' + error, 'error')
}
}

View File

@@ -1,25 +1,34 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { RETRY_LIMITS, TIMEOUTS, DELAYS } from '../../constants'
export class Quiz extends Workers {
async doQuiz(page: Page) {
this.bot.log('QUIZ', 'Trying to complete quiz')
this.bot.log(this.bot.isMobile, 'QUIZ', 'Trying to complete quiz')
try {
// Check if the quiz has been started or not
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG }).then(() => true).catch(() => false)
if (quizNotStarted) {
await page.click('#rqStartQuiz')
} else {
this.bot.log('QUIZ', 'Quiz has already been started, trying to finish it')
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz has already been started, trying to finish it')
}
await this.bot.utils.wait(2000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
let quizData = await this.bot.browser.func.getQuizData(page)
// Verify quiz is actually loaded before proceeding
const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: TIMEOUTS.VERY_LONG }).then(() => true).catch(() => false)
if (!firstOptionExists) {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz options not found - page may not have loaded correctly. Skipping.', 'warn')
await page.close()
return
}
const questionsRemaining = quizData.maxQuestions - quizData.CorrectlyAnsweredQuestionCount // Amount of questions remaining
// All questions
@@ -29,17 +38,30 @@ export class Quiz extends Workers {
const answers: string[] = []
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { visible: true, timeout: 10_000 })
const answerAttribute = await answerSelector?.evaluate(el => el.getAttribute('iscorrectoption'))
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => null)
if (!answerSelector) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found - quiz structure may have changed. Skipping remaining options.`, 'warn')
break
}
const answerAttribute = await answerSelector?.evaluate((el: Element) => el.getAttribute('iscorrectoption'))
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
answers.push(`#rqAnswerOption${i}`)
}
}
// If no correct answers found, skip this question
if (answers.length === 0) {
this.bot.log(this.bot.isMobile, 'QUIZ', 'No correct answers found for 8-option quiz. Skipping.', 'warn')
await page.close()
return
}
// Click the answers
for (const answer of answers) {
await page.waitForSelector(answer, { visible: true, timeout: 2000 })
await page.waitForSelector(answer, { state: 'visible', timeout: DELAYS.QUIZ_ANSWER_WAIT })
// Click the answer on page
await page.click(answer)
@@ -47,44 +69,62 @@ export class Quiz extends Workers {
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
if (!refreshSuccess) {
await page.close()
this.bot.log('QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
}
// Other type quiz
// Other type quiz, lightspeed
} else if ([2, 3, 4].includes(quizData.numberOfOptions)) {
quizData = await this.bot.browser.func.getQuizData(page) // Refresh Quiz Data
const correctOption = quizData.correctAnswer
let answerClicked = false
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { visible: true, timeout: 10_000 })
const dataOption = await answerSelector?.evaluate(el => el.getAttribute('data-option'))
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: RETRY_LIMITS.QUIZ_ANSWER_TIMEOUT }).catch(() => null)
if (!answerSelector) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
continue
}
const dataOption = await answerSelector?.evaluate((el: Element) => el.getAttribute('data-option'))
if (dataOption === correctOption) {
// Click the answer on page
await page.click(`#rqAnswerOption${i}`)
answerClicked = true
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
if (!refreshSuccess) {
await page.close()
this.bot.log('QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
break
}
}
await this.bot.utils.wait(2000)
if (!answerClicked) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Could not find correct answer for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
await page.close()
return
}
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
}
}
// Done with
await this.bot.utils.wait(2000)
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
await page.close()
this.bot.log('QUIZ', 'Completed the quiz successfully')
this.bot.log(this.bot.isMobile, 'QUIZ', 'Completed the quiz successfully')
} catch (error) {
await page.close()
this.bot.log('QUIZ', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred:' + error, 'error')
}
}

View File

@@ -0,0 +1,74 @@
import { randomBytes } from 'crypto'
import { AxiosRequestConfig } from 'axios'
import { Workers } from '../Workers'
import { DashboardData } from '../../interface/DashboardData'
export class ReadToEarn extends Workers {
public async doReadToEarn(accessToken: string, data: DashboardData) {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Starting Read to Earn')
try {
let geoLocale = data.userProfile.attributes.country
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
const userDataRequest: AxiosRequestConfig = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me',
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en'
}
}
const userDataResponse = await this.bot.axios.request(userDataRequest)
const userData = (await userDataResponse.data).response
let userBalance = userData.balance
const jsonData = {
amount: 1,
country: geoLocale,
id: '1',
type: 101,
attributes: {
offerid: 'ENUS_readarticle3_30points'
}
}
const articleCount = 10
for (let i = 0; i < articleCount; ++i) {
jsonData.id = randomBytes(64).toString('hex')
const claimRequest = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en',
'X-Rewards-ismobile': 'true'
},
data: JSON.stringify(jsonData)
}
const claimResponse = await this.bot.axios.request(claimRequest)
const newBalance = (await claimResponse.data).response.balance
if (newBalance == userBalance) {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Read all available articles')
break
} else {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', `Read article ${i + 1} of ${articleCount} max | Gained ${newBalance - userBalance} Points`)
userBalance = newBalance
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min), this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max))))
}
}
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Completed Read to Earn')
} catch (error) {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -1,228 +1,319 @@
import { Page } from 'puppeteer'
import axios from 'axios'
import { Page } from 'rebrowser-playwright'
import { platform } from 'os'
import { Workers } from '../Workers'
import { DashboardData, DashboardImpression } from '../../interface/DashboardData'
import { GoogleTrends } from '../../interface/GoogleDailyTrends'
import { Counters, DashboardData } from '../../interface/DashboardData'
import { GoogleSearch } from '../../interface/Search'
import { AxiosRequestConfig } from 'axios'
type GoogleTrendsResponse = [
string,
[
string,
...null[],
[string, ...string[]]
][]
];
export class Search extends Workers {
private bingHome = 'https://bing.com'
private searchPageURL = ''
private searchPageURL = 'https://bing.com'
public async doSearch(page: Page, data: DashboardData) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Starting Bing searches')
public async doSearch(page: Page, data: DashboardData, mobile: boolean) {
this.bot.log('SEARCH-BING', 'Starting bing searches')
page = await this.bot.browser.utils.getLatestTab(page)
const mobileData = data.userStatus.counters?.mobileSearch ? data.userStatus.counters.mobileSearch[0] : null // Mobile searches
const edgeData = data.userStatus.counters.pcSearch[1] as DashboardImpression // Edge searches
const genericData = data.userStatus.counters.pcSearch[0] as DashboardImpression // Normal searches
let searchCounters: Counters = await this.bot.browser.func.getSearchPoints()
let missingPoints = this.calculatePoints(searchCounters)
let missingPoints = (mobile && mobileData) ?
(mobileData.pointProgressMax - mobileData.pointProgress) :
(edgeData.pointProgressMax - edgeData.pointProgress) + (genericData.pointProgressMax - genericData.pointProgress)
if (missingPoints == 0) {
this.bot.log('SEARCH-BING', `Bing searches for ${mobile ? 'MOBILE' : 'DESKTOP'} have already been completed`)
if (missingPoints === 0) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Bing searches have already been completed')
return
}
// Generate search queries
let googleSearchQueries = await this.getGoogleTrends(data.userProfile.attributes.country, missingPoints)
// Generate search queries (primary: Google Trends)
const geo = this.bot.config.searchSettings.useGeoLocaleQueries ? data.userProfile.attributes.country : 'US'
let googleSearchQueries = await this.getGoogleTrends(geo)
// Fallback: if trends failed or insufficient, sample from local queries file
if (!googleSearchQueries.length || googleSearchQueries.length < 10) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Primary trends source insufficient, falling back to local queries.json', 'warn')
try {
const local = await import('../queries.json')
// Flatten & sample
const sampleSize = Math.max(5, Math.min(this.bot.config.searchSettings.localFallbackCount || 25, local.default.length))
const sampled = this.bot.utils.shuffleArray(local.default).slice(0, sampleSize)
googleSearchQueries = sampled.map((x: { title: string; queries: string[] }) => ({ topic: x.queries[0] || x.title, related: x.queries.slice(1) }))
} catch (e) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed loading local queries fallback: ' + (e instanceof Error ? e.message : e), 'error')
}
}
googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
// Deduplicate the search terms
googleSearchQueries = [...new Set(googleSearchQueries)]
// Open a new tab
const browser = page.browser()
const searchPage = await browser.newPage()
// Deduplicate topics
const seen = new Set<string>()
googleSearchQueries = googleSearchQueries.filter(q => {
if (seen.has(q.topic.toLowerCase())) return false
seen.add(q.topic.toLowerCase())
return true
})
// Go to bing
await searchPage.goto(this.searchPageURL)
await page.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
let maxLoop = 0 // If the loop hits 10 this when not gaining any points, we're assuming it's stuck. If it ddoesn't continue after 5 more searches with alternative queries, abort search
await this.bot.utils.wait(2000)
await this.bot.browser.utils.tryDismissAllMessages(page)
let stagnation = 0 // consecutive searches without point progress
const queries: string[] = []
googleSearchQueries.forEach(x => queries.push(x.topic, ...x.related))
// Mobile search doesn't seem to like related queries?
googleSearchQueries.forEach(x => { this.bot.isMobile ? queries.push(x.topic) : queries.push(x.topic, ...x.related) })
// Loop over Google search queries
for (let i = 0; i < queries.length; i++) {
const query = queries[i] as string
this.bot.log('SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query} | Mobile: ${mobile}`)
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query}`)
const newData = await this.bingSearch(page, searchPage, query)
const newMobileData = newData.mobileSearch ? newData.mobileSearch[0] : null // Mobile searches
const newEdgeData = newData.pcSearch[1] as DashboardImpression // Edge searches
const newGenericData = newData.pcSearch[0] as DashboardImpression // Normal searches
const newMissingPoints = (mobile && newMobileData) ?
(newMobileData.pointProgressMax - newMobileData.pointProgress) :
(newEdgeData.pointProgressMax - newEdgeData.pointProgress) + (newGenericData.pointProgressMax - newGenericData.pointProgress)
searchCounters = await this.bingSearch(page, query)
const newMissingPoints = this.calculatePoints(searchCounters)
// If the new point amount is the same as before
if (newMissingPoints == missingPoints) {
maxLoop++ // Add to max loop
} else { // There has been a change in points
maxLoop = 0 // Reset the loop
if (newMissingPoints === missingPoints) {
stagnation++
} else {
stagnation = 0
}
missingPoints = newMissingPoints
if (missingPoints == 0) {
if (missingPoints === 0) break
// Only for mobile searches
if (stagnation > 5 && this.bot.isMobile) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 5 iterations, likely bad User-Agent', 'warn')
break
}
// If we didn't gain points for 10 iterations, assume it's stuck
if (maxLoop > 10) {
this.bot.log('SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn')
maxLoop = 0 // Reset to 0 so we can retry with related searches below
if (stagnation > 10) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn')
stagnation = 0 // allow fallback loop below
break
}
}
// Only for mobile searches
if (missingPoints > 0 && this.bot.isMobile) {
return
}
// If we still got remaining search queries, generate extra ones
if (missingPoints > 0) {
this.bot.log('SEARCH-BING', `Search completed but we're missing ${missingPoints} points, generating extra searches`)
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Search completed but we're missing ${missingPoints} points, generating extra searches`)
let i = 0
while (missingPoints > 0) {
let fallbackRounds = 0
const extraRetries = this.bot.config.searchSettings.extraFallbackRetries || 1
while (missingPoints > 0 && fallbackRounds <= extraRetries) {
const query = googleSearchQueries[i++] as GoogleSearch
if (!query) break
// Get related search terms to the Google search queries
const relatedTerms = await this.getRelatedTerms(query?.topic)
if (relatedTerms.length > 3) {
// Search for the first 2 related terms
for (const term of relatedTerms.slice(1, 3)) {
this.bot.log('SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term} | Mobile: ${mobile}`)
const newData = await this.bingSearch(page, searchPage, query.topic)
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term}`)
const newMobileData = newData.mobileSearch ? newData.mobileSearch[0] : null // Mobile searches
const newEdgeData = newData.pcSearch[1] as DashboardImpression // Edge searches
const newGenericData = newData.pcSearch[0] as DashboardImpression // Normal searches
const newMissingPoints = (mobile && newMobileData) ?
(newMobileData.pointProgressMax - newMobileData.pointProgress) :
(newEdgeData.pointProgressMax - newEdgeData.pointProgress) + (newGenericData.pointProgressMax - newGenericData.pointProgress)
searchCounters = await this.bingSearch(page, term)
const newMissingPoints = this.calculatePoints(searchCounters)
// If the new point amount is the same as before
if (newMissingPoints == missingPoints) {
maxLoop++ // Add to max loop
} else { // There has been a change in points
maxLoop = 0 // Reset the loop
if (newMissingPoints === missingPoints) {
stagnation++
} else {
stagnation = 0
}
missingPoints = newMissingPoints
// If we satisfied the searches
if (missingPoints == 0) {
if (missingPoints === 0) {
break
}
// Try 5 more times, then we tried a total of 15 times, fair to say it's stuck
if (maxLoop > 5) {
this.bot.log('SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
if (stagnation > 5) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
return
}
}
fallbackRounds++
}
}
}
this.bot.log('SEARCH-BING', 'Completed searches')
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Completed searches')
}
private async bingSearch(page: Page, searchPage: Page, query: string) {
private async bingSearch(searchPage: Page, query: string) {
const platformControlKey = platform() === 'darwin' ? 'Meta' : 'Control'
// Try a max of 5 times
for (let i = 0; i < 5; i++) {
try {
const searchBar = '#sb_form_q'
await searchPage.waitForSelector(searchBar, { visible: true, timeout: 10_000 })
await searchPage.click(searchBar) // Focus on the textarea
// This page had already been set to the Bing.com page or the previous search listing, we just need to select it
searchPage = await this.bot.browser.utils.getLatestTab(searchPage)
// Go to top of the page
await searchPage.evaluate(() => {
window.scrollTo(0, 0)
})
await this.bot.utils.wait(500)
await searchPage.keyboard.down('Control')
await searchPage.keyboard.press('A')
await searchPage.keyboard.press('Backspace')
await searchPage.keyboard.up('Control')
await searchPage.keyboard.type(query)
await searchPage.keyboard.press('Enter')
const searchBar = '#sb_form_q'
// Prefer attached over visible to avoid strict visibility waits when overlays exist
const box = searchPage.locator(searchBar)
await box.waitFor({ state: 'attached', timeout: 15000 })
// Try dismissing overlays before interacting
await this.bot.browser.utils.tryDismissAllMessages(searchPage)
await this.bot.utils.wait(200)
let navigatedDirectly = false
try {
// Try focusing and filling instead of clicking (more reliable on mobile)
await box.focus({ timeout: 2000 }).catch(() => { /* ignore focus errors */ })
await box.fill('')
await this.bot.utils.wait(200)
await searchPage.keyboard.down(platformControlKey)
await searchPage.keyboard.press('A')
await searchPage.keyboard.press('Backspace')
await searchPage.keyboard.up(platformControlKey)
await box.type(query, { delay: 20 })
await searchPage.keyboard.press('Enter')
} catch (typeErr) {
// As a robust fallback, navigate directly to the search results URL
const q = encodeURIComponent(query)
const url = `https://www.bing.com/search?q=${q}`
await searchPage.goto(url)
navigatedDirectly = true
}
await this.bot.utils.wait(3000)
// Bing.com in Chrome opens a new tab when searching via Enter; if we navigated directly, stay on current tab
const resultPage = navigatedDirectly ? searchPage : await this.bot.browser.utils.getLatestTab(searchPage)
this.searchPageURL = new URL(resultPage.url()).href // Set the results page
await this.bot.browser.utils.reloadBadPage(resultPage)
if (this.bot.config.searchSettings.scrollRandomResults) {
await this.bot.utils.wait(2000)
await this.randomScroll(searchPage)
await this.randomScroll(resultPage)
}
if (this.bot.config.searchSettings.clickRandomResults) {
await this.bot.utils.wait(2000)
await this.clickRandomLink(searchPage)
await this.clickRandomLink(resultPage)
}
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.config.searchSettings.searchDelay.min, this.bot.config.searchSettings.searchDelay.max)))
// Delay between searches
const minDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min)
const maxDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max)
const adaptivePad = Math.min(4000, Math.max(0, Math.floor(Math.random() * 800)))
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(minDelay, maxDelay)) + adaptivePad)
return await this.bot.browser.func.getSearchPoints(page)
return await this.bot.browser.func.getSearchPoints()
} catch (error) {
if (i === 5) {
this.bot.log('SEARCH-BING', 'Failed after 5 retries... An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed after 5 retries... An error occurred:' + error, 'error')
break
}
this.bot.log('SEARCH-BING', 'Search failed, An error occurred:' + error, 'error')
this.bot.log('SEARCH-BING', `Retrying search, attempt ${i}/5`, 'warn')
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search failed, An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Retrying search, attempt ${i}/5`, 'warn')
// Reset the tabs
const lastTab = await this.bot.browser.utils.getLatestTab(searchPage)
await this.closeTabs(lastTab)
await this.bot.utils.wait(4000)
}
}
this.bot.log('SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
return await this.bot.browser.func.getSearchPoints(page)
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
return await this.bot.browser.func.getSearchPoints()
}
private async getGoogleTrends(geoLocale: string, queryCount: number): Promise<GoogleSearch[]> {
private async getGoogleTrends(geoLocale: string = 'US'): Promise<GoogleSearch[]> {
const queryTerms: GoogleSearch[] = []
let i = 0
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Generating search queries, can take a while! | GeoLocale: ${geoLocale}`)
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toUpperCase() : 'US'
this.bot.log('SEARCH-GOOGLE-TRENDS', `Generating search queries, can take a while! | GeoLocale: ${geoLocale}`)
while (queryCount > queryTerms.length) {
i += 1
const date = new Date()
date.setDate(date.getDate() - i)
const formattedDate = this.formatDate(date)
try {
const request = {
url: `https://trends.google.com/trends/api/dailytrends?geo=${geoLocale}&hl=en&ed=${formattedDate}&ns=15`,
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
const response = await axios(request)
const data: GoogleTrends = JSON.parse((await response.data).slice(5))
for (const topic of data.default.trendingSearchesDays[0]?.trendingSearches ?? []) {
queryTerms.push({
topic: topic.title.query.toLowerCase(),
related: topic.relatedQueries.map(x => x.query.toLocaleLowerCase())
})
}
} catch (error) {
this.bot.log('SEARCH-GOOGLE-TRENDS', 'An error occurred:' + error, 'error')
try {
const request: AxiosRequestConfig = {
url: 'https://trends.google.com/_/TrendsUi/data/batchexecute',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
data: `f.req=[[[i0OFE,"[null, null, \\"${geoLocale.toUpperCase()}\\", 0, null, 48]"]]]`
}
const response = await this.bot.axios.request(request, this.bot.config.proxy.proxyGoogleTrends)
const rawText = response.data
const trendsData = this.extractJsonFromResponse(rawText)
if (!trendsData) {
throw this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'Failed to parse Google Trends response', 'error')
}
const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)])
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Found ${mappedTrendsData.length} search queries for ${geoLocale}`)
if (mappedTrendsData.length < 30 && geoLocale.toUpperCase() !== 'US') {
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Insufficient search queries (${mappedTrendsData.length} < 30), falling back to US`, 'warn')
return this.getGoogleTrends()
}
for (const [topic, relatedQueries] of mappedTrendsData) {
queryTerms.push({
topic: topic as string,
related: relatedQueries as string[]
})
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'An error occurred:' + error, 'error')
}
return queryTerms
}
private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null {
const lines = text.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return JSON.parse(JSON.parse(trimmed)[0][2])[1]
} catch {
continue
}
}
}
return null
}
private async getRelatedTerms(term: string): Promise<string[]> {
try {
const request = {
@@ -233,101 +324,121 @@ export class Search extends Workers {
}
}
const response = await axios(request)
const response = await this.bot.axios.request(request, this.bot.config.proxy.proxyBingTerms)
return response.data[1] as string[]
} catch (error) {
this.bot.log('SEARCH-BING-RELTATED', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'SEARCH-BING-RELATED', 'An error occurred:' + error, 'error')
}
return []
}
private formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}${month}${day}`
}
private async randomScroll(page: Page) {
try {
// Press the arrow down key to scroll
for (let i = 0; i < this.bot.utils.randomNumber(5, 100); i++) {
await page.keyboard.press('ArrowDown')
}
const viewportHeight = await page.evaluate(() => window.innerHeight)
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
await page.evaluate((scrollPos: number) => {
window.scrollTo(0, scrollPos)
}, randomScrollPosition)
} catch (error) {
this.bot.log('SEARCH-RANDOM-SCROLL', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-SCROLL', 'An error occurred:' + error, 'error')
}
}
private async clickRandomLink(page: Page) {
try {
const searchListingURL = new URL(page.url()) // Get searchPage info before clicking
await page.click('#b_results .b_algo h2', { timeout: 2000 }).catch(() => { }) // Since we don't really care if it did it or not
await page.click('#b_results .b_algo h2').catch(() => { }) // Since we don't really care if it did it or not
// Only used if the browser is not the edge browser (continue on Edge popup)
await this.closeContinuePopup(page)
// Wait for website to load
await this.bot.utils.wait(3000)
// Stay for 10 seconds for page to load and "visit"
await this.bot.utils.wait(10000)
// Will get current tab if no new one is created
// Will get current tab if no new one is created, this will always be the visited site or the result page if it failed to click
let lastTab = await this.bot.browser.utils.getLatestTab(page)
// Wait for the body of the new page to be loaded
await lastTab.waitForSelector('body', { timeout: 10_000 }).catch(() => { })
let lastTabURL = new URL(lastTab.url()) // Get new tab info, this is the website we're visiting
// Check if the tab is closed or not
if (!lastTab.isClosed()) {
let lastTabURL = new URL(lastTab.url()) // Get new tab info
// Check if the URL is different from the original one, don't loop more than 5 times.
let i = 0
while (lastTabURL.href !== this.searchPageURL && i < 5) {
// Check if the URL is different from the original one, don't loop more than 5 times.
let i = 0
while (lastTabURL.href !== searchListingURL.href && i < 5) {
// If hostname is still bing, (Bing images/news etc)
if (lastTabURL.hostname == searchListingURL.hostname) {
await lastTab.goBack()
await this.closeTabs(lastTab)
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
// End of loop, refresh lastPage
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
lastTabURL = new URL(lastTab.url()) // Get new tab info
i++
}
// If "goBack" didn't return to search listing (due to redirects)
if (lastTabURL.hostname !== searchListingURL.hostname) {
await lastTab.goto(this.searchPageURL)
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
}
}
} else { // No longer on bing, likely opened a new tab, close this tab
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
lastTabURL = new URL(lastTab.url())
private async closeTabs(lastTab: Page) {
const browser = lastTab.context()
const tabs = browser.pages()
const tabs = await (page.browser()).pages() // Get all tabs open
try {
if (tabs.length > 2) {
// If more than 2 tabs are open, close the last tab
// If the browser has more than 3 tabs open, it has opened a new one, we need to close this one.
if (tabs.length > 3) {
// Make sure the page is still open!
if (!lastTab.isClosed()) {
await lastTab.close()
}
await lastTab.close()
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', `More than 2 were open, closed the last tab: "${new URL(lastTab.url()).host}"`)
} else if (lastTabURL.href !== searchListingURL.href) {
} else if (tabs.length === 1) {
// If only 1 tab is open, open a new one to search in
await lastTab.goBack()
const newPage = await browser.newPage()
await this.bot.utils.wait(1000)
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
lastTabURL = new URL(lastTab.url())
await newPage.goto(this.bingHome)
await this.bot.utils.wait(3000)
this.searchPageURL = newPage.url()
// If "goBack" didn't return to search listing (due to redirects)
if (lastTabURL.hostname !== searchListingURL.hostname) {
await lastTab.goto(this.searchPageURL)
}
}
}
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
i++
}
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'There was only 1 tab open, crated a new one')
} else {
// Else reset the last tab back to the search listing or Bing.com
lastTab = await this.bot.browser.utils.getLatestTab(lastTab)
await lastTab.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'An error occurred:' + error, 'error')
}
}
private calculatePoints(counters: Counters) {
const mobileData = counters.mobileSearch?.[0] // Mobile searches
const genericData = counters.pcSearch?.[0] // Normal searches
const edgeData = counters.pcSearch?.[1] // Edge searches
const missingPoints = (this.bot.isMobile && mobileData)
? mobileData.pointProgressMax - mobileData.pointProgress
: (edgeData ? edgeData.pointProgressMax - edgeData.pointProgress : 0)
+ (genericData ? genericData.pointProgressMax - genericData.pointProgress : 0)
return missingPoints
}
private async closeContinuePopup(page: Page) {
try {
await page.waitForSelector('#sacs_close', { timeout: 1000 })
const continueButton = await page.$('#sacs_close')
if (continueButton) {
await continueButton.click()
}
} catch (error) {
this.bot.log('SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
// Continue if element is not found or other error occurs
}
}

View File

@@ -0,0 +1,85 @@
import type { Page } from 'playwright'
import * as fs from 'fs'
import path from 'path'
import { Workers } from '../Workers'
import { DELAYS } from '../../constants'
import { MorePromotion, PromotionalItem } from '../../interface/DashboardData'
export class SearchOnBing extends Workers {
async doSearchOnBing(page: Page, activity: MorePromotion | PromotionalItem) {
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Trying to complete SearchOnBing')
try {
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_WAIT)
await this.bot.browser.utils.tryDismissAllMessages(page)
const query = await this.getSearchQuery(activity.title)
const searchBar = '#sb_form_q'
const box = page.locator(searchBar)
await box.waitFor({ state: 'attached', timeout: DELAYS.SEARCH_BAR_TIMEOUT })
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
try {
await box.focus({ timeout: DELAYS.THIS_OR_THAT_START }).catch(() => { /* ignore */ })
await box.fill('')
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
await page.keyboard.type(query, { delay: DELAYS.TYPING_DELAY })
await page.keyboard.press('Enter')
} catch {
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`
await page.goto(url)
}
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_COMPLETE)
await page.close()
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Completed the SearchOnBing successfully')
} catch (error) {
await page.close()
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'An error occurred:' + error, 'error')
}
}
private async getSearchQuery(title: string): Promise<string> {
interface Queries {
title: string;
queries: string[]
}
let queries: Queries[] = []
try {
if (this.bot.config.searchOnBingLocalQueries) {
const data = fs.readFileSync(path.join(__dirname, '../queries.json'), 'utf8')
queries = JSON.parse(data)
} else {
// Fetch from the repo directly so the user doesn't need to redownload the script for the new activities
const response = await this.bot.axios.request({
method: 'GET',
url: 'https://raw.githubusercontent.com/TheNetsky/Microsoft-Rewards-Script/refs/heads/v2/src/functions/queries.json'
})
queries = response.data
}
const answers = queries.find(x => this.normalizeString(x.title) === this.normalizeString(title))
const answer = answers ? this.bot.utils.shuffleArray(answers?.queries)[0] as string : title
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', `Fetched answer: ${answer} | question: ${title}`)
return answer
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'An error occurred:' + error, 'error')
return title
}
}
private normalizeString(string: string): string {
return string.normalize('NFD').trim().toLowerCase().replace(/[^\x20-\x7E]/g, '').replace(/[?!]/g, '')
}
}

View File

@@ -1,33 +1,47 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { DELAYS } from '../../constants'
export class ThisOrThat extends Workers {
async doThisOrThat(page: Page) {
this.bot.log('THIS-OR-THAT', 'Trying to complete ThisOrThat')
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'Trying to complete ThisOrThat')
return
try {
// Check if the quiz has been started or not
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: DELAYS.THIS_OR_THAT_START }).then(() => true).catch(() => false)
if (quizNotStarted) {
await page.click('#rqStartQuiz')
} else {
this.bot.log('THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
}
await this.bot.utils.wait(2000)
await this.bot.utils.wait(DELAYS.THIS_OR_THAT_START)
// Solving
const quizData = await this.bot.browser.func.getQuizData(page)
quizData // correctAnswer property is always null?
const questionsRemaining = quizData.maxQuestions - (quizData.currentQuestionNumber - 1) // Amount of questions remaining
this.bot.log('THIS-OR-THAT', 'Completed the ThisOrthat successfully')
for (let question = 0; question < questionsRemaining; question++) {
// Since there's no solving logic yet, randomly guess to complete
const buttonId = `#rqAnswerOption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
await page.click(buttonId)
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
if (!refreshSuccess) {
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
}
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'Completed the ThisOrThat successfully')
} catch (error) {
await page.close()
this.bot.log('THIS-OR-THAT', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'An error occurred:' + error, 'error')
}
}

View File

@@ -1,4 +1,4 @@
import { Page } from 'puppeteer'
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
@@ -6,16 +6,17 @@ import { Workers } from '../Workers'
export class UrlReward extends Workers {
async doUrlReward(page: Page) {
this.bot.log('URL-REWARD', 'Trying to complete UrlReward')
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Trying to complete UrlReward')
try {
// After waiting, close the page
await this.bot.utils.wait(2000)
await page.close()
this.bot.log('URL-REWARD', 'Completed the UrlReward successfully')
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Completed the UrlReward successfully')
} catch (error) {
await page.close()
this.bot.log('URL-REWARD', 'An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'An error occurred:' + error, 'error')
}
}

289
src/functions/queries.json Normal file
View File

@@ -0,0 +1,289 @@
[
{
"title": "Houses near you",
"queries": [
"Houses near me"
]
},
{
"title": "Feeling symptoms?",
"queries": [
"Rash on forearm",
"Stuffy nose",
"Tickling cough"
]
},
{
"title": "Get your shopping done faster",
"queries": [
"Buy PS5",
"Buy Xbox",
"Chair deals"
]
},
{
"title": "Translate anything",
"queries": [
"Translate welcome home to Korean",
"Translate welcome home to Japanese",
"Translate goodbye to Japanese"
]
},
{
"title": "Search the lyrics of a song",
"queries": [
"Debarge rhythm of the night lyrics"
]
},
{
"title": "Let's watch that movie again!",
"queries": [
"Alien movie",
"Aliens movie",
"Alien 3 movie",
"Predator movie"
]
},
{
"title": "Plan a quick getaway",
"queries": [
"Flights Amsterdam to Tokyo",
"Flights New York to Tokyo"
]
},
{
"title": "Discover open job roles",
"queries": [
"jobs at Microsoft",
"Microsoft Job Openings",
"Jobs near me",
"jobs at Boeing worked"
]
},
{
"title": "You can track your package",
"queries": [
"USPS tracking"
]
},
{
"title": "Find somewhere new to explore",
"queries": [
"Directions to Berlin",
"Directions to Tokyo",
"Directions to New York"
]
},
{
"title": "Too tired to cook tonight?",
"queries": [
"KFC near me",
"Burger King near me",
"McDonalds near me"
]
},
{
"title": "Quickly convert your money",
"queries": [
"convert 250 USD to yen",
"convert 500 USD to yen"
]
},
{
"title": "Learn to cook a new recipe",
"queries": [
"How to cook ratatouille",
"How to cook lasagna"
]
},
{
"title": "Find places to stay!",
"queries": [
"Hotels Berlin Germany",
"Hotels Amsterdam Netherlands"
]
},
{
"title": "How's the economy?",
"queries": [
"sp 500"
]
},
{
"title": "Who won?",
"queries": [
"braves score"
]
},
{
"title": "Gaming time",
"queries": [
"Overwatch video game",
"Call of duty video game"
]
},
{
"title": "Expand your vocabulary",
"queries": [
"definition definition"
]
},
{
"title": "What time is it?",
"queries": [
"Japan time",
"New York time"
]
},
{
"title": "Maisons près de chez vous",
"queries": [
"Maisons près de chez moi"
]
},
{
"title": "Vous ressentez des symptômes ?",
"queries": [
"Éruption cutanée sur l'avant-bras",
"Nez bouché",
"Toux chatouilleuse"
]
},
{
"title": "Faites vos achats plus vite",
"queries": [
"Acheter une PS5",
"Acheter une Xbox",
"Offres sur les chaises"
]
},
{
"title": "Traduisez tout !",
"queries": [
"Traduction bienvenue à la maison en coréen",
"Traduction bienvenue à la maison en japonais",
"Traduction au revoir en japonais"
]
},
{
"title": "Rechercher paroles de chanson",
"queries": [
"Paroles de Debarge rhythm of the night"
]
},
{
"title": "Et si nous regardions ce film une nouvelle fois?",
"queries": [
"Alien film",
"Film Aliens",
"Film Alien 3",
"Film Predator"
]
},
{
"title": "Planifiez une petite escapade",
"queries": [
"Vols Amsterdam-Tokyo",
"Vols New York-Tokyo"
]
},
{
"title": "Consulter postes à pourvoir",
"queries": [
"emplois chez Microsoft",
"Offres d'emploi Microsoft",
"Emplois près de chez moi",
"emplois chez Boeing"
]
},
{
"title": "Vous pouvez suivre votre colis",
"queries": [
"Suivi Chronopost"
]
},
{
"title": "Trouver un endroit à découvrir",
"queries": [
"Itinéraire vers Berlin",
"Itinéraire vers Tokyo",
"Itinéraire vers New York"
]
},
{
"title": "Trop fatigué pour cuisiner ce soir ?",
"queries": [
"KFC près de chez moi",
"Burger King près de chez moi",
"McDonalds près de chez moi"
]
},
{
"title": "Convertissez rapidement votre argent",
"queries": [
"convertir 250 EUR en yen",
"convertir 500 EUR en yen"
]
},
{
"title": "Apprenez à cuisiner une nouvelle recette",
"queries": [
"Comment faire cuire la ratatouille",
"Comment faire cuire les lasagnes"
]
},
{
"title": "Trouvez des emplacements pour rester!",
"queries": [
"Hôtels Berlin Allemagne",
"Hôtels Amsterdam Pays-Bas"
]
},
{
"title": "Comment se porte l'économie ?",
"queries": [
"CAC 40"
]
},
{
"title": "Qui a gagné ?",
"queries": [
"score du Paris Saint-Germain"
]
},
{
"title": "Temps de jeu",
"queries": [
"Jeu vidéo Overwatch",
"Jeu vidéo Call of Duty"
]
},
{
"title": "Enrichissez votre vocabulaire",
"queries": [
"definition definition"
]
},
{
"title": "Quelle heure est-il ?",
"queries": [
"Heure du Japon",
"Heure de New York"
]
},
{
"title": "Vérifier la météo",
"queries": [
"Météo de Paris",
"Météo de la France"
]
},
{
"title": "Tenez-vous informé des sujets d'actualité",
"queries": [
"Augmentation Impots",
"Mort célébrité"
]
}
]

View File

@@ -1,4 +1,7 @@
import cluster from 'cluster'
import type { Worker } from 'cluster'
// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright
import type { Page } from 'playwright'
import Browser from './browser/Browser'
import BrowserFunc from './browser/BrowserFunc'
@@ -6,13 +9,20 @@ import BrowserUtil from './browser/BrowserUtil'
import { log } from './util/Logger'
import Util from './util/Utils'
import { loadAccounts, loadConfig } from './util/Load'
import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
import { DISCORD } from './constants'
import { Login } from './functions/Login'
import { Workers } from './functions/Workers'
import Activities from './functions/Activities'
import { Account } from './interface/Account'
import Axios from './util/Axios'
import fs from 'fs'
import path from 'path'
import Humanizer from './util/Humanizer'
import { detectBanReason } from './util/BanDetector'
// Main bot class
export class MicrosoftRewardsBot {
@@ -24,34 +34,63 @@ export class MicrosoftRewardsBot {
func: BrowserFunc,
utils: BrowserUtil
}
public humanizer: Humanizer
public isMobile: boolean
public homePage!: Page
public currentAccountEmail?: string
public currentAccountRecoveryEmail?: string
public compromisedModeActive: boolean = false
public compromisedReason?: string
public compromisedEmail?: string
// Mutex-like flag to prevent parallel execution when config.parallel is accidentally misconfigured
private isDesktopRunning: boolean = false
private isMobileRunning: boolean = false
private pointsCanCollect: number = 0
private pointsInitial: number = 0
private collectedPoints: number = 0
private activeWorkers: number
private mobileRetryAttempts: number
private browserFactory: Browser = new Browser(this)
private accounts: Account[]
private workers: Workers
private login = new Login(this)
private accessToken: string = ''
constructor() {
// Summary collection (per process)
private accountSummaries: AccountSummary[] = []
private runId: string = Math.random().toString(36).slice(2)
private bannedTriggered: { email: string; reason: string } | null = null
private globalStandby: { active: boolean; reason?: string } = { active: false }
public axios!: Axios
constructor(isMobile: boolean) {
this.isMobile = isMobile
this.log = log
this.accounts = []
this.utils = new Util()
this.workers = new Workers(this)
this.config = loadConfig()
this.browser = {
func: new BrowserFunc(this),
utils: new BrowserUtil(this)
}
this.config = loadConfig()
this.workers = new Workers(this)
this.humanizer = new Humanizer(this.utils, this.config.humanization)
this.activeWorkers = this.config.clusters
this.mobileRetryAttempts = 0
}
async initialize() {
this.accounts = loadAccounts()
}
async run() {
log('MAIN', `Bot started with ${this.config.clusters} clusters`)
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
// Only cluster when there's more than 1 cluster demanded
if (this.config.clusters > 1) {
@@ -61,180 +100,778 @@ export class MicrosoftRewardsBot {
this.runWorker()
}
} else {
this.runTasks(this.accounts)
await this.runTasks(this.accounts)
}
}
// Return summaries (used when clusters==1)
public getSummaries() {
return this.accountSummaries
}
private runMaster() {
log('MAIN-PRIMARY', 'Primary process started')
log('main', 'MAIN-PRIMARY', 'Primary process started')
const accountChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
const totalAccounts = this.accounts.length
// Validate accounts exist
if (totalAccounts === 0) {
log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn')
process.exit(0)
}
// If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
const workerCount = Math.min(this.config.clusters, totalAccounts)
const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
this.activeWorkers = workerCount
for (let i = 0; i < accountChunks.length; i++) {
for (let i = 0; i < workerCount; i++) {
const worker = cluster.fork()
const chunk = accountChunks[i]
worker.send({ chunk })
const chunk = accountChunks[i] || []
// Validate chunk has accounts
if (chunk.length === 0) {
log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn')
}
(worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk })
worker.on('message', (msg: unknown) => {
const m = msg as { type?: string; data?: AccountSummary[] }
if (m && m.type === 'summary' && Array.isArray(m.data)) {
this.accountSummaries.push(...m.data)
}
})
}
cluster.on('exit', (worker, code) => {
cluster.on('exit', (worker: Worker, code: number) => {
this.activeWorkers -= 1
log('MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
// Optional: restart crashed worker (basic heuristic) if crashRecovery allows
try {
const cr = this.config.crashRecovery
if (cr?.restartFailedWorker && code !== 0) {
const attempts = (worker as unknown as { _restartAttempts?: number })._restartAttempts || 0
if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
(worker as unknown as { _restartAttempts?: number })._restartAttempts = attempts + 1
log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn','yellow')
const newW = cluster.fork()
// NOTE: account chunk re-assignment simplistic: unused; real mapping improvement todo
newW.on('message', (msg: unknown) => {
const m = msg as { type?: string; data?: AccountSummary[] }
if (m && m.type === 'summary' && Array.isArray(m.data)) this.accountSummaries.push(...m.data)
})
}
}
} catch { /* ignore */ }
// Check if all workers have exited
if (this.activeWorkers === 0) {
log('MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
// All workers done
(async () => {
try {
await this.sendConclusion(this.accountSummaries)
} catch {/* ignore */}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
})()
}
})
}
private runWorker() {
log('MAIN-WORKER', `Worker ${process.pid} spawned`)
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
// Receive the chunk of accounts from the master
process.on('message', async ({ chunk }) => {
;(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => {
await this.runTasks(chunk)
})
}
private async runTasks(accounts: Account[]) {
for (const account of accounts) {
log('MAIN-WORKER', `Started tasks for account ${account.email}`)
// If a global standby is active due to security/banned, stop processing further accounts
if (this.globalStandby.active) {
log('main','SECURITY',`Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow')
break
}
// Optional global stop after first ban
if (this.config?.humanization?.stopOnBan === true && this.bannedTriggered) {
log('main','TASK',`Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`,'warn')
break
}
// Reset compromised state per account
this.compromisedModeActive = false
this.compromisedReason = undefined
this.compromisedEmail = undefined
// If humanization allowed windows are configured, wait until within a window
try {
const windows: string[] | undefined = this.config?.humanization?.allowedWindows
if (Array.isArray(windows) && windows.length > 0) {
const waitMs = this.computeWaitForAllowedWindow(windows)
if (waitMs > 0) {
log('main','HUMANIZATION',`Waiting ${Math.ceil(waitMs/1000)}s until next allowed window before starting ${account.email}`,'warn')
await new Promise<void>(r => setTimeout(r, waitMs))
}
}
} catch {/* ignore */}
this.currentAccountEmail = account.email
this.currentAccountRecoveryEmail = account.recoveryEmail
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
// Desktop Searches, DailySet and More Promotions
await this.Desktop(account)
const accountStart = Date.now()
let desktopInitial = 0
let mobileInitial = 0
let desktopCollected = 0
let mobileCollected = 0
const errors: string[] = []
const banned = { status: false, reason: '' }
// If runOnZeroPoints is false and 0 points to earn, stop and try the next account
if (!this.config.runOnZeroPoints && this.collectedPoints === 0) {
continue
this.axios = new Axios(account.proxy)
const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1'
const formatFullErr = (label: string, e: unknown) => {
const base = shortErr(e)
if (verbose && e instanceof Error) {
return `${label}:${base} :: ${e.stack?.split('\n').slice(0,4).join(' | ')}`
}
return `${label}:${base}`
}
// Mobile Searches
await this.Mobile(account)
if (this.config.parallel) {
const mobileInstance = new MicrosoftRewardsBot(true)
mobileInstance.axios = this.axios
// Run both and capture results with detailed logging
const desktopPromise = this.Desktop(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('desktop', e)); return null
})
const mobilePromise = mobileInstance.Mobile(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); return null
})
const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise])
// Handle desktop result
if (desktopResult.status === 'fulfilled' && desktopResult.value) {
desktopInitial = desktopResult.value.initialPoints
desktopCollected = desktopResult.value.collectedPoints
} else if (desktopResult.status === 'rejected') {
log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error')
errors.push(formatFullErr('desktop-rejected', desktopResult.reason))
}
// Handle mobile result
if (mobileResult.status === 'fulfilled' && mobileResult.value) {
mobileInitial = mobileResult.value.initialPoints
mobileCollected = mobileResult.value.collectedPoints
} else if (mobileResult.status === 'rejected') {
log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error')
errors.push(formatFullErr('mobile-rejected', mobileResult.reason))
}
} else {
// Sequential execution with safety checks
if (this.isDesktopRunning || this.isMobileRunning) {
log('main', 'TASK', `Race condition detected: Desktop=${this.isDesktopRunning}, Mobile=${this.isMobileRunning}. Skipping to prevent conflicts.`, 'error')
errors.push('race-condition-detected')
} else {
this.isMobile = false
this.isDesktopRunning = true
const desktopResult = await this.Desktop(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('desktop', e)); return null
})
if (desktopResult) {
desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints
}
this.isDesktopRunning = false
log('MAIN-WORKER', `Completed tasks for account ${account.email}`)
// If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true
this.isMobileRunning = true
const mobileResult = await this.Mobile(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); return null
})
if (mobileResult) {
mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints
}
this.isMobileRunning = false
} else {
const why = banned.status ? 'banned status' : 'compromised status'
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
}
}
}
const accountEnd = Date.now()
const durationMs = accountEnd - accountStart
const totalCollected = desktopCollected + mobileCollected
// Correct initial points (previous version double counted desktop+mobile baselines)
// Strategy: pick the lowest non-zero baseline (desktopInitial or mobileInitial) as true start.
// Sequential flow: desktopInitial < mobileInitial after gain -> min = original baseline.
// Parallel flow: both baselines equal -> min is fine.
const baselines: number[] = []
if (desktopInitial) baselines.push(desktopInitial)
if (mobileInitial) baselines.push(mobileInitial)
let initialTotal = 0
if (baselines.length === 1) initialTotal = baselines[0]!
else if (baselines.length === 2) initialTotal = Math.min(baselines[0]!, baselines[1]!)
// Fallback if both missing
if (initialTotal === 0 && (desktopInitial || mobileInitial)) initialTotal = desktopInitial || mobileInitial || 0
const endTotal = initialTotal + totalCollected
this.accountSummaries.push({
email: account.email,
durationMs,
desktopCollected,
mobileCollected,
totalCollected,
initialTotal,
endTotal,
errors,
banned
})
if (banned.status) {
this.bannedTriggered = { email: account.email, reason: banned.reason }
// Enter global standby: do not proceed to next accounts
this.globalStandby = { active: true, reason: `banned:${banned.reason}` }
await this.sendGlobalSecurityStandbyAlert(account.email, `Ban detected: ${banned.reason || 'unknown'}`)
}
await log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
}
log('MAIN-PRIMARY', 'Completed tasks for ALL accounts')
log('MAIN-PRIMARY', 'All workers destroyed!')
process.exit(0)
await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
// Extra diagnostic summary when verbose
if (process.env.DEBUG_REWARDS_VERBOSE === '1') {
for (const summary of this.accountSummaries) {
log('main','SUMMARY-DEBUG',`Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`)
}
}
// If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open
if (this.compromisedModeActive || this.globalStandby.active) {
log('main','SECURITY','Compromised or banned detected. Global standby engaged: we will NOT proceed to other accounts until resolved. Keeping process alive. Press CTRL+C to exit when done.','warn','yellow')
const standbyInterval = setInterval(() => {
log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow')
}, 5 * 60 * 1000)
// Cleanup on process exit
process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) })
process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) })
return
}
// If in worker mode (clusters>1) send summaries to primary
if (this.config.clusters > 1 && !cluster.isPrimary) {
if (process.send) {
process.send({ type: 'summary', data: this.accountSummaries })
}
} else {
// Single process mode
}
process.exit()
}
/** Send immediate ban alert if configured. */
private async handleImmediateBanAlert(email: string, reason: string): Promise<void> {
try {
const h = this.config?.humanization
if (!h || h.immediateBanAlert === false) return
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(
this.config,
'🚫 Ban Detected',
`**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`,
undefined,
DISCORD.COLOR_RED
)
} catch (e) {
log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn')
}
}
/** Compute milliseconds to wait until within one of the allowed windows (HH:mm-HH:mm). Returns 0 if already inside. */
private computeWaitForAllowedWindow(windows: string[]): number {
const now = new Date()
const minsNow = now.getHours() * 60 + now.getMinutes()
let nextStartMins: number | null = null
for (const w of windows) {
const [start, end] = w.split('-')
if (!start || !end) continue
const pStart = start.split(':').map(v=>parseInt(v,10))
const pEnd = end.split(':').map(v=>parseInt(v,10))
if (pStart.length !== 2 || pEnd.length !== 2) continue
const sh = pStart[0]!, sm = pStart[1]!
const eh = pEnd[0]!, em = pEnd[1]!
if ([sh,sm,eh,em].some(n=>Number.isNaN(n))) continue
const s = sh*60 + sm
const e = eh*60 + em
if (s <= e) {
// same-day window
if (minsNow >= s && minsNow <= e) return 0
if (minsNow < s) nextStartMins = Math.min(nextStartMins ?? s, s)
} else {
// wraps past midnight (e.g., 22:00-02:00)
if (minsNow >= s || minsNow <= e) return 0
// next start today is s
nextStartMins = Math.min(nextStartMins ?? s, s)
}
}
const msPerMin = 60*1000
if (nextStartMins != null) {
const targetTodayMs = (nextStartMins - minsNow) * msPerMin
return targetTodayMs > 0 ? targetTodayMs : (24*60 + nextStartMins - minsNow) * msPerMin
}
// No valid windows parsed -> do not block
return 0
}
// Desktop
async Desktop(account: Account) {
const browser = await this.browserFactory.createBrowser(account.email, account.proxy, false)
const page = await browser.newPage()
let pages = await browser.pages()
log(false,'FLOW','Desktop() invoked')
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
this.homePage = await browser.newPage()
// If for some reason the browser initializes with more than 2 pages, close these
while (pages.length > 2) {
await pages[0]?.close()
pages = await browser.pages()
log(this.isMobile, 'MAIN', 'Starting browser')
// Login into MS Rewards, then optionally stop if compromised
await this.login.login(this.homePage, account.email, account.password, account.totp)
if (this.compromisedModeActive) {
// User wants the page to remain open for manual recovery. Do not proceed to tasks.
const reason = this.compromisedReason || 'security-issue'
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}.`, 'warn', 'yellow')
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(
this.config,
'🔐 Security Alert (Post-Login)',
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks`,
undefined,
0xFFAA00
)
} catch {/* ignore */}
// Save session for convenience, but do not close the browser
try {
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
} catch (e) {
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
return { initialPoints: 0, collectedPoints: 0 }
}
// Log into proxy
await page.authenticate({ username: account.proxy.username, password: account.proxy.password })
await this.browser.func.goHome(this.homePage)
log('MAIN', 'Starting DESKTOP browser')
const data = await this.browser.func.getDashboardData()
// Login into MS Rewards
await this.login.login(page, account.email, account.password)
this.pointsInitial = data.userStatus.availablePoints
const initial = this.pointsInitial
const wentHome = await this.browser.func.goHome(page)
if (!wentHome) {
throw log('MAIN', 'Unable to get dashboard page', 'error')
log(this.isMobile, 'MAIN-POINTS', `Current point count: ${this.pointsInitial}`)
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
// Tally all the desktop points
this.pointsCanCollect = browserEnarablePoints.dailySetPoints +
browserEnarablePoints.desktopSearchPoints
+ browserEnarablePoints.morePromotionsPoints
log(this.isMobile, 'MAIN-POINTS', `You can earn ${this.pointsCanCollect} points today`)
if (this.pointsCanCollect === 0) {
// Extra diagnostic breakdown so users know WHY it's zero
log(this.isMobile, 'MAIN-POINTS', `Breakdown (desktop): dailySet=${browserEnarablePoints.dailySetPoints} search=${browserEnarablePoints.desktopSearchPoints} promotions=${browserEnarablePoints.morePromotionsPoints}`)
log(this.isMobile, 'MAIN-POINTS', 'All desktop earnable buckets are zero. This usually means: tasks already completed today OR the daily reset has not happened yet for your time zone. If you still want to force run activities set execution.runOnZeroPoints=true in config.', 'log', 'yellow')
}
const data = await this.browser.func.getDashboardData(page)
log('MAIN-POINTS', `Current point count: ${data.userStatus.availablePoints}`)
const earnablePoints = await this.browser.func.getEarnablePoints(data)
this.collectedPoints = earnablePoints
log('MAIN-POINTS', `You can earn ${earnablePoints} points today`)
// If runOnZeroPoints is false and 0 points to earn, don't continue
if (!this.config.runOnZeroPoints && this.collectedPoints === 0) {
log('MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping')
if (!this.config.runOnZeroPoints && this.pointsCanCollect === 0) {
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
// Close desktop browser
return await browser.close()
await this.browser.func.closeBrowser(browser, account.email)
return
}
// Open a new tab to where the tasks are going to be completed
const workerPage = await browser.newPage()
// Go to homepage on worker page
await this.browser.func.goHome(workerPage)
// Complete daily set
if (this.config.workers.doDailySet) {
await this.workers.doDailySet(page, data)
await this.workers.doDailySet(workerPage, data)
}
// Complete more promotions
if (this.config.workers.doMorePromotions) {
await this.workers.doMorePromotions(page, data)
await this.workers.doMorePromotions(workerPage, data)
}
// Complete punch cards
if (this.config.workers.doPunchCards) {
await this.workers.doPunchCard(page, data)
await this.workers.doPunchCard(workerPage, data)
}
// Do desktop searches
if (this.config.workers.doDesktopSearch) {
await this.activities.doSearch(page, data, false)
await this.activities.doSearch(workerPage, data)
}
// Save cookies
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
// Fetch points BEFORE closing (avoid page closed reload error)
const after = await this.browser.func.getCurrentPoints().catch(()=>initial)
// Close desktop browser
await browser.close()
await this.browser.func.closeBrowser(browser, account.email)
return {
initialPoints: initial,
collectedPoints: (after - initial) || 0
}
}
// Mobile
async Mobile(account: Account) {
const browser = await this.browserFactory.createBrowser(account.email, account.proxy, true)
const page = await browser.newPage()
let pages = await browser.pages()
log(true,'FLOW','Mobile() invoked')
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
this.homePage = await browser.newPage()
// If for some reason the browser initializes with more than 2 pages, close these
while (pages.length > 2) {
await pages[0]?.close()
pages = await browser.pages()
log(this.isMobile, 'MAIN', 'Starting browser')
// Login into MS Rewards, then respect compromised mode
await this.login.login(this.homePage, account.email, account.password, account.totp)
if (this.compromisedModeActive) {
const reason = this.compromisedReason || 'security-issue'
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}.`, 'warn', 'yellow')
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(
this.config,
'🔐 Security Alert (Mobile)',
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks`,
undefined,
0xFFAA00
)
} catch {/* ignore */}
try {
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
} catch (e) {
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
return { initialPoints: 0, collectedPoints: 0 }
}
// Log into proxy
await page.authenticate({ username: account.proxy.username, password: account.proxy.password })
this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email)
log('MAIN', 'Starting MOBILE browser')
await this.browser.func.goHome(this.homePage)
// Login into MS Rewards
await this.login.login(page, account.email, account.password)
const data = await this.browser.func.getDashboardData()
const initialPoints = data.userStatus.availablePoints || this.pointsInitial || 0
await this.browser.func.goHome(page)
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken)
const data = await this.browser.func.getDashboardData(page)
this.pointsCanCollect = browserEnarablePoints.mobileSearchPoints + appEarnablePoints.totalEarnablePoints
// If no mobile searches data found, stop (Does not exist on new accounts)
if (!data.userStatus.counters.mobileSearch) {
log('MAIN', 'No mobile searches found, stopping')
log(this.isMobile, 'MAIN-POINTS', `You can earn ${this.pointsCanCollect} points today (Browser: ${browserEnarablePoints.mobileSearchPoints} points, App: ${appEarnablePoints.totalEarnablePoints} points)`)
if (this.pointsCanCollect === 0) {
log(this.isMobile, 'MAIN-POINTS', `Breakdown (mobile): browserSearch=${browserEnarablePoints.mobileSearchPoints} appTotal=${appEarnablePoints.totalEarnablePoints}`)
log(this.isMobile, 'MAIN-POINTS', 'All mobile earnable buckets are zero. Causes: mobile searches already maxed, daily set finished, or daily rollover not reached yet. You can force execution by setting execution.runOnZeroPoints=true.', 'log', 'yellow')
}
// If runOnZeroPoints is false and 0 points to earn, don't continue
if (!this.config.runOnZeroPoints && this.pointsCanCollect === 0) {
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
// Close mobile browser
return await browser.close()
await this.browser.func.closeBrowser(browser, account.email)
return {
initialPoints: initialPoints,
collectedPoints: 0
}
}
// Do daily check in
if (this.config.workers.doDailyCheckIn) {
await this.activities.doDailyCheckIn(this.accessToken, data)
}
// Do read to earn
if (this.config.workers.doReadToEarn) {
await this.activities.doReadToEarn(this.accessToken, data)
}
// Do mobile searches
if (this.config.workers.doMobileSearch) {
await this.activities.doSearch(page, data, true)
// If no mobile searches data found, stop (Does not always exist on new accounts)
if (data.userStatus.counters.mobileSearch) {
// Open a new tab to where the tasks are going to be completed
const workerPage = await browser.newPage()
// Go to homepage on worker page
await this.browser.func.goHome(workerPage)
await this.activities.doSearch(workerPage, data)
// Fetch current search points
const mobileSearchPoints = (await this.browser.func.getSearchPoints()).mobileSearch?.[0]
if (mobileSearchPoints && (mobileSearchPoints.pointProgressMax - mobileSearchPoints.pointProgress) > 0) {
// Increment retry count
this.mobileRetryAttempts++
}
// Exit if retries are exhausted
if (this.mobileRetryAttempts > this.config.searchSettings.retryMobileSearchAmount) {
log(this.isMobile, 'MAIN', `Max retry limit of ${this.config.searchSettings.retryMobileSearchAmount} reached. Exiting retry loop`, 'warn')
} else if (this.mobileRetryAttempts !== 0) {
log(this.isMobile, 'MAIN', `Attempt ${this.mobileRetryAttempts}/${this.config.searchSettings.retryMobileSearchAmount}: Unable to complete mobile searches, bad User-Agent? Increase search delay? Retrying...`, 'log', 'yellow')
// Close mobile browser
await this.browser.func.closeBrowser(browser, account.email)
// Create a new browser and try
await this.Mobile(account)
return
}
} else {
log(this.isMobile, 'MAIN', 'Unable to fetch search points, your account is most likely too "new" for this! Try again later!', 'warn')
}
}
// Fetch new points
const earnablePoints = await this.browser.func.getEarnablePoints(data, page)
const afterPointAmount = await this.browser.func.getCurrentPoints()
// If the new earnable is 0, means we got all the points, else retract
this.collectedPoints = earnablePoints === 0 ? this.collectedPoints : (this.collectedPoints - earnablePoints)
log('MAIN-POINTS', `The script collected ${this.collectedPoints} points today`)
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`)
// Close mobile browser
await browser.close()
await this.browser.func.closeBrowser(browser, account.email)
return {
initialPoints: initialPoints,
collectedPoints: (afterPointAmount - initialPoints) || 0
}
}
private async sendConclusion(summaries: AccountSummary[]) {
const { ConclusionWebhookEnhanced } = await import('./util/ConclusionWebhook')
const cfg = this.config
const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled)
const ntfyEnabled = !!(cfg.ntfy && cfg.ntfy.enabled)
const webhookEnabled = !!(cfg.webhook && cfg.webhook.enabled)
const totalAccounts = summaries.length
if (totalAccounts === 0) return
let totalCollected = 0
let totalInitial = 0
let totalEnd = 0
let totalDuration = 0
let accountsWithErrors = 0
let accountsBanned = 0
let successes = 0
// Calculate summary statistics
for (const s of summaries) {
totalCollected += s.totalCollected
totalInitial += s.initialTotal
totalEnd += s.endTotal
totalDuration += s.durationMs
if (s.banned?.status) accountsBanned++
if (s.errors.length) accountsWithErrors++
if (!s.banned?.status && !s.errors.length) successes++
}
const avgDuration = totalDuration / totalAccounts
const avgPointsPerAccount = Math.round(totalCollected / totalAccounts)
// Read package version
let version = 'unknown'
try {
const pkgPath = path.join(process.cwd(), 'package.json')
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(raw)
version = pkg.version || version
}
} catch { /* ignore */ }
// Send enhanced webhook
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
await ConclusionWebhookEnhanced(cfg, {
version,
runId: this.runId,
totalAccounts,
successes,
accountsWithErrors,
accountsBanned,
totalCollected,
totalInitial,
totalEnd,
avgPointsPerAccount,
totalDuration,
avgDuration,
summaries
})
}
// Write local JSON report
try {
const fs = await import('fs')
const path = await import('path')
const now = new Date()
const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
const baseDir = path.join(process.cwd(), 'reports', day)
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
const file = path.join(baseDir, `summary_${this.runId}.json`)
const payload = {
runId: this.runId,
timestamp: now.toISOString(),
totals: { totalCollected, totalInitial, totalEnd, totalDuration, totalAccounts, accountsWithErrors },
perAccount: summaries
}
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf-8')
log('main','REPORT',`Saved report to ${file}`)
} catch (e) {
log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn')
}
// Optional community notice (shown randomly in ~15% of successful runs)
if (Math.random() > 0.85 && successes > 0 && accountsWithErrors === 0) {
log('main','INFO','Want faster updates & enhanced anti-detection? Community builds available: https://discord.gg/kn3695Kx32')
}
}
/** Public entry-point to engage global security standby from other modules (idempotent). */
public async engageGlobalStandby(reason: string, email?: string): Promise<void> {
try {
if (this.globalStandby.active) return
this.globalStandby = { active: true, reason }
const who = email || this.currentAccountEmail || 'unknown'
await this.sendGlobalSecurityStandbyAlert(who, reason)
} catch {/* ignore */}
}
/** Send a strong alert to all channels and mention @everyone when entering global security standby. */
private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise<void> {
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(
this.config,
'🚨 Global Security Standby Engaged',
`@everyone\n\n**Account:** ${email}\n**Reason:** ${reason}\n**Action:** Pausing all further accounts. We will not proceed until this is resolved.`,
undefined,
DISCORD.COLOR_RED
)
} catch (e) {
log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn')
}
}
}
const bot = new MicrosoftRewardsBot()
interface AccountSummary {
email: string
durationMs: number
desktopCollected: number
mobileCollected: number
totalCollected: number
initialTotal: number
endTotal: number
errors: string[]
banned?: { status: boolean; reason: string }
}
// Initialize accounts first and then start the bot
bot.initialize().then(() => {
bot.run()
})
function shortErr(e: unknown): string {
if (e == null) return 'unknown'
if (e instanceof Error) return e.message.substring(0, 120)
const s = String(e)
return s.substring(0, 120)
}
async function main() {
const rewardsBot = new MicrosoftRewardsBot(false)
const crashState = { restarts: 0 }
const config = rewardsBot.config
const attachHandlers = () => {
process.on('unhandledRejection', (reason) => {
log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error')
gracefulExit(1)
})
process.on('uncaughtException', (err) => {
log('main','FATAL','UncaughtException: ' + err.message, 'error')
gracefulExit(1)
})
process.on('SIGTERM', () => gracefulExit(0))
process.on('SIGINT', () => gracefulExit(0))
}
const gracefulExit = (code: number) => {
if (config?.crashRecovery?.autoRestart && code !== 0) {
const max = config.crashRecovery.maxRestarts ?? 2
if (crashState.restarts < max) {
const backoff = (config.crashRecovery.backoffBaseMs ?? 2000) * (crashState.restarts + 1)
log('main','CRASH-RECOVERY',`Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn','yellow')
setTimeout(() => {
crashState.restarts++
bootstrap()
}, backoff)
return
}
}
process.exit(code)
}
const bootstrap = async () => {
try {
await rewardsBot.initialize()
await rewardsBot.run()
} catch (e) {
log('main','MAIN-ERROR','Fatal during run: ' + (e instanceof Error ? e.message : e),'error')
gracefulExit(1)
}
}
attachHandlers()
await bootstrap()
}
// Start the bots
if (require.main === module) {
main().catch(error => {
log('main', 'MAIN-ERROR', `Error running bots: ${error}`, 'error')
process.exit(1)
})
}

View File

@@ -1,10 +1,17 @@
export interface Account {
/** Enable/disable this account (if false, account will be skipped during execution) */
enabled?: boolean;
email: string;
password: string;
/** Optional TOTP secret in Base32 (e.g., from Microsoft Authenticator setup) */
totp?: string;
/** Optional recovery email used to verify masked address on Microsoft login screens */
recoveryEmail?: string;
proxy: AccountProxy;
}
export interface AccountProxy {
proxyAxios: boolean;
url: string;
port: number;
password: string;

View File

@@ -0,0 +1,21 @@
import type { MorePromotion, PromotionalItem } from './DashboardData'
import type { Page } from 'playwright'
/**
* Activity handler contract for solving a single dashboard activity.
* Implementations should be stateless (or hold only a reference to the bot)
* and perform all required steps on the provided page.
*/
export interface ActivityHandler {
/** Optional identifier for diagnostics */
id?: string
/**
* Return true if this handler knows how to process the given activity.
*/
canHandle(activity: MorePromotion | PromotionalItem): boolean
/**
* Execute the activity on the provided page. The page is already
* navigated to the activity tab/window by the caller.
*/
run(page: Page, activity: MorePromotion | PromotionalItem): Promise<void>
}

View File

@@ -0,0 +1,226 @@
export interface AppUserData {
response: Response;
correlationId: string;
code: number;
}
export interface Response {
profile: Profile;
balance: number;
counters: null;
promotions: Promotion[];
catalog: null;
goal_item: GoalItem;
activities: null;
cashback: null;
orders: Order[];
rebateProfile: null;
rebatePayouts: null;
giveProfile: GiveProfile;
autoRedeemProfile: null;
autoRedeemItem: null;
thirdPartyProfile: null;
notifications: null;
waitlist: null;
autoOpenFlyout: null;
coupons: null;
recommendedAffordableCatalog: null;
}
export interface GiveProfile {
give_user: string;
give_organization: { [key: string]: GiveOrganization | null };
first_give_optin: string;
last_give_optout: string;
give_lifetime_balance: string;
give_lifetime_donation_balance: string;
give_balance: string;
form: null;
}
export interface GiveOrganization {
give_organization_donation_points: number;
give_organization_donation_point_to_currency_ratio: number;
give_organization_donation_currency: number;
}
export interface GoalItem {
name: string;
provider: string;
price: number;
attributes: GoalItemAttributes;
config: GoalItemConfig;
}
export interface GoalItemAttributes {
category: string;
CategoryDescription: string;
'desc.group_text': string;
'desc.legal_text'?: string;
'desc.sc_description': string;
'desc.sc_title': string;
display_order: string;
ExtraLargeImage: string;
group: string;
group_image: string;
group_sc_image: string;
group_title: string;
hidden?: string;
large_image: string;
large_sc_image: string;
medium_image: string;
MobileImage: string;
original_price: string;
Remarks?: string;
ShortText?: string;
showcase?: string;
small_image: string;
title: string;
cimsid: string;
user_defined_goal?: string;
disable_bot_redemptions?: string;
'desc.large_text'?: string;
english_title?: string;
etid?: string;
sku?: string;
coupon_discount?: string;
}
export interface GoalItemConfig {
amount: string;
currencyCode: string;
isHidden: string;
PointToCurrencyConversionRatio: string;
}
export interface Order {
id: string;
t: Date;
sku: string;
item_snapshot: ItemSnapshot;
p: number;
s: S;
a: A;
child_redemption: null;
third_party_partner: null;
log: Log[];
}
export interface A {
form?: string;
OrderId: string;
CorrelationId: string;
Channel: string;
Language: string;
Country: string;
EvaluationId: string;
provider?: string;
referenceOrderID?: string;
externalRefID?: string;
denomination?: string;
rewardName?: string;
sendEmail?: string;
status?: string;
createdAt?: Date;
bal_before_deduct?: string;
bal_after_deduct?: string;
}
export interface ItemSnapshot {
name: string;
provider: string;
price: number;
attributes: GoalItemAttributes;
config: ItemSnapshotConfig;
}
export interface ItemSnapshotConfig {
amount: string;
countryCode: string;
currencyCode: string;
sku: string;
}
export interface Log {
time: Date;
from: From;
to: S;
reason: string;
}
export enum From {
Created = 'Created',
RiskApproved = 'RiskApproved',
RiskReview = 'RiskReview'
}
export enum S {
Cancelled = 'Cancelled',
RiskApproved = 'RiskApproved',
RiskReview = 'RiskReview',
Shipped = 'Shipped'
}
export interface Profile {
ruid: string;
attributes: ProfileAttributes;
offline_attributes: OfflineAttributes;
}
export interface ProfileAttributes {
publisher: string;
publisher_upd: Date;
creative: string;
creative_upd: Date;
program: string;
program_upd: Date;
country: string;
country_upd: Date;
referrerhash: string;
referrerhash_upd: Date;
optout_upd: Date;
language: string;
language_upd: Date;
target: string;
target_upd: Date;
created: Date;
created_upd: Date;
epuid: string;
epuid_upd: Date;
goal: string;
goal_upd: Date;
waitlistattributes: string;
waitlistattributes_upd: Date;
serpbotscore_upd: Date;
iscashbackeligible: string;
cbedc: string;
rlscpct_upd: Date;
give_user: string;
rebcpc_upd: Date;
SerpBotScore_upd: Date;
AdsBotScore_upd: Date;
dbs_upd: Date;
rbs: string;
rbs_upd: Date;
iris_segmentation: string;
iris_segmentation_upd: Date;
}
export interface OfflineAttributes {
}
export interface Promotion {
name: string;
priority: number;
attributes: { [key: string]: string };
tags: Tag[];
}
export enum Tag {
AllowTrialUser = 'allow_trial_user',
ExcludeGivePcparent = 'exclude_give_pcparent',
ExcludeGlobalConfig = 'exclude_global_config',
ExcludeHidden = 'exclude_hidden',
LOCString = 'locString',
NonGlobalConfig = 'non_global_config'
}

View File

@@ -2,34 +2,173 @@ export interface Config {
baseURL: string;
sessionPath: string;
headless: boolean;
browser?: ConfigBrowser; // Optional nested browser config
fingerprinting?: ConfigFingerprinting; // Optional nested fingerprinting config
parallel: boolean;
runOnZeroPoints: boolean;
clusters: number;
workers: Workers;
searchSettings: SearchSettings;
webhook: Webhook;
saveFingerprint: ConfigSaveFingerprint;
workers: ConfigWorkers;
searchOnBingLocalQueries: boolean;
globalTimeout: number | string;
searchSettings: ConfigSearchSettings;
humanization?: ConfigHumanization; // Anti-ban humanization controls
retryPolicy?: ConfigRetryPolicy; // Global retry/backoff policy
jobState?: ConfigJobState; // Persistence of per-activity checkpoints
logExcludeFunc: string[];
webhookLogExcludeFunc: string[];
logging?: ConfigLogging; // Preserve original logging object (for live webhook settings)
proxy: ConfigProxy;
webhook: ConfigWebhook;
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
ntfy: ConfigNtfy;
vacation?: ConfigVacation; // Optional monthly contiguous off-days
crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
}
export interface SearchSettings {
export interface ConfigSaveFingerprint {
mobile: boolean;
desktop: boolean;
}
export interface ConfigBrowser {
headless?: boolean;
globalTimeout?: number | string;
}
export interface ConfigFingerprinting {
saveFingerprint?: ConfigSaveFingerprint;
}
export interface ConfigSearchSettings {
useGeoLocaleQueries: boolean;
scrollRandomResults: boolean;
clickRandomResults: boolean;
searchDelay: SearchDelay;
searchDelay: ConfigSearchDelay;
retryMobileSearchAmount: number;
localFallbackCount?: number; // Number of local fallback queries to sample when trends fail
extraFallbackRetries?: number; // Additional mini-retry loops with fallback terms
}
export interface SearchDelay {
min: number;
max: number;
export interface ConfigSearchDelay {
min: number | string;
max: number | string;
}
export interface Webhook {
export interface ConfigWebhook {
enabled: boolean;
url: string;
username?: string; // Custom webhook username (default: "Microsoft Rewards")
avatarUrl?: string; // Custom webhook avatar URL
}
export interface Workers {
export interface ConfigNtfy {
enabled: boolean;
url: string;
topic: string;
authToken?: string; // Optional authentication token
}
export interface ConfigProxy {
proxyGoogleTrends: boolean;
proxyBingTerms: boolean;
}
export interface ConfigVacation {
enabled?: boolean; // default false
minDays?: number; // default 3
maxDays?: number; // default 5
}
export interface ConfigCrashRecovery {
autoRestart?: boolean; // Restart the root process after fatal crash
maxRestarts?: number; // Max restart attempts (default 2)
backoffBaseMs?: number; // Base backoff before restart (default 2000)
restartFailedWorker?: boolean; // (future) attempt to respawn crashed worker
restartFailedWorkerAttempts?: number; // attempts per worker (default 1)
}
export interface ConfigWorkers {
doDailySet: boolean;
doMorePromotions: boolean;
doPunchCards: boolean;
doDesktopSearch: boolean;
doMobileSearch: boolean;
doDailyCheckIn: boolean;
doReadToEarn: boolean;
bundleDailySetWithSearch?: boolean; // If true, run desktop search right after Daily Set
}
// Anti-ban humanization
export interface ConfigHumanization {
// Master toggle for Human Mode. When false, humanization is minimized.
enabled?: boolean;
// If true, stop processing remaining accounts after a ban is detected
stopOnBan?: boolean;
// If true, send an immediate webhook/NTFY alert when a ban is detected
immediateBanAlert?: boolean;
// Additional random waits between actions
actionDelay?: { min: number | string; max: number | string };
// Probability [0..1] to perform micro mouse moves per step
gestureMoveProb?: number;
// Probability [0..1] to perform tiny scrolls per step
gestureScrollProb?: number;
// Allowed execution windows (local time). Each item is "HH:mm-HH:mm".
// If provided, runs outside these windows will be delayed until the next allowed window.
allowedWindows?: string[];
// Randomly skip N days per week to look more human (0-7). Default 1.
randomOffDaysPerWeek?: number;
}
// Retry/backoff policy
export interface ConfigRetryPolicy {
maxAttempts?: number; // default 3
baseDelay?: number | string; // default 1000ms
maxDelay?: number | string; // default 30s
multiplier?: number; // default 2
jitter?: number; // 0..1; default 0.2
}
// Job state persistence
export interface ConfigJobState {
enabled?: boolean; // default true
dir?: string; // base directory; defaults to <sessionPath>/job-state
}
// Live logging configuration
export interface ConfigLoggingLive {
enabled?: boolean; // master switch for live webhook logs
redactEmails?: boolean; // if true, redact emails in outbound logs
}
export interface ConfigLogging {
excludeFunc?: string[];
webhookExcludeFunc?: string[];
live?: ConfigLoggingLive;
liveWebhookUrl?: string; // legacy/dedicated live webhook override
redactEmails?: boolean; // legacy top-level redaction flag
// Optional nested live.url support (already handled dynamically in Logger)
[key: string]: unknown; // forward compatibility
}
// CommunityHelp removed (privacy-first policy)
// NEW FEATURES: Risk Management, Query Diversity
export interface ConfigRiskManagement {
enabled?: boolean; // master toggle for risk-aware throttling
autoAdjustDelays?: boolean; // automatically increase delays when risk is high
stopOnCritical?: boolean; // halt execution if risk reaches critical level
banPrediction?: boolean; // enable ML-style ban prediction
riskThreshold?: number; // 0-100, pause if risk exceeds this
}
export interface ConfigQueryDiversity {
enabled?: boolean; // use multi-source query generation
sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use
maxQueriesPerSource?: number; // limit per source
cacheMinutes?: number; // cache duration
}

View File

@@ -351,11 +351,13 @@ export interface MorePromotion {
legalText: string;
legalLinkText: string;
deviceType: string;
exclusiveLockedFeatureType: string;
exclusiveLockedFeatureStatus: string;
}
export interface PunchCard {
name: string;
parentPromotion: PromotionalItem;
parentPromotion?: PromotionalItem;
childPromotions: PromotionalItem[];
}

9
src/interface/OAuth.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface OAuth {
access_token: string;
refresh_token: string;
scope: string;
expires_in: number;
ext_expires_in: number;
foci: string;
token_type: string;
}

7
src/interface/Points.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface EarnablePoints {
desktopSearchPoints: number
mobileSearchPoints: number
dailySetPoints: number
morePromotionsPoints: number
totalEarnablePoints: number
}

7
src/luxon.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/* Minimal ambient declarations to unblock TypeScript when @types/luxon is absent. */
declare module 'luxon' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const DateTime: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const IANAZone: any
}

156
src/run_daily.sh Executable file
View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="/usr/local/bin:/usr/bin:/bin"
export PLAYWRIGHT_BROWSERS_PATH=0
export TZ="${TZ:-UTC}"
cd /usr/src/microsoft-rewards-script
LOCKFILE=/tmp/run_daily.lock
# -------------------------------
# Function: Check and fix lockfile integrity
# -------------------------------
self_heal_lockfile() {
# If lockfile exists but is empty → remove it
if [ -f "$LOCKFILE" ]; then
local lock_content
lock_content=$(<"$LOCKFILE" || echo "")
if [[ -z "$lock_content" ]]; then
echo "[$(date)] [run_daily.sh] Found empty lockfile → removing."
rm -f "$LOCKFILE"
return
fi
# If lockfile contains non-numeric PID → remove it
if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then
echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') → removing."
rm -f "$LOCKFILE"
return
fi
# If lockfile contains PID but process is dead → remove it
if ! kill -0 "$lock_content" 2>/dev/null; then
echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead → removing stale lock."
rm -f "$LOCKFILE"
return
fi
fi
}
# -------------------------------
# Function: Acquire lock
# -------------------------------
acquire_lock() {
local max_attempts=5
local attempt=0
local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8}
local timeout_seconds=$((timeout_hours * 3600))
while [ $attempt -lt $max_attempts ]; do
# Try to create lock with current PID
if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then
echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)"
return 0
fi
# Lock exists, validate it
if [ -f "$LOCKFILE" ]; then
local existing_pid
existing_pid=$(<"$LOCKFILE" || echo "")
echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'"
# If lockfile content is invalid → delete and retry
if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then
echo "[$(date)] [run_daily.sh] Removing invalid lockfile → retrying..."
rm -f "$LOCKFILE"
continue
fi
# If process is dead → delete and retry
if ! kill -0 "$existing_pid" 2>/dev/null; then
echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)"
rm -f "$LOCKFILE"
continue
fi
# Check process runtime → kill if exceeded timeout
local process_age
if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then
if [ "$process_age" -gt "$timeout_seconds" ]; then
echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)"
kill -TERM "$existing_pid" 2>/dev/null || true
sleep 5
kill -KILL "$existing_pid" 2>/dev/null || true
rm -f "$LOCKFILE"
continue
fi
fi
fi
echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts"
sleep 2
((attempt++))
done
echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting."
return 1
}
# -------------------------------
# Function: Release lock
# -------------------------------
release_lock() {
if [ -f "$LOCKFILE" ]; then
local lock_pid
lock_pid=$(<"$LOCKFILE")
if [ "$lock_pid" = "$$" ]; then
rm -f "$LOCKFILE"
echo "[$(date)] [run_daily.sh] Lock released (PID: $$)"
fi
fi
}
# Always release lock on exit — but only if we acquired it
trap 'release_lock' EXIT INT TERM
# -------------------------------
# MAIN EXECUTION FLOW
# -------------------------------
echo "[$(date)] [run_daily.sh] Current process PID: $$"
# Self-heal any broken or empty locks before proceeding
self_heal_lockfile
# Attempt to acquire the lock safely
if ! acquire_lock; then
exit 0
fi
# Random sleep between MIN and MAX to spread execution
MINWAIT=${MIN_SLEEP_MINUTES:-5}
MAXWAIT=${MAX_SLEEP_MINUTES:-50}
MINWAIT_SEC=$((MINWAIT*60))
MAXWAIT_SEC=$((MAXWAIT*60))
if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then
SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) ))
echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)"
sleep "$SLEEPTIME"
else
echo "[$(date)] [run_daily.sh] Skipping random sleep"
fi
# Start the actual script
echo "[$(date)] [run_daily.sh] Starting script..."
if npm start; then
echo "[$(date)] [run_daily.sh] Script completed successfully."
else
echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2
fi
echo "[$(date)] [run_daily.sh] Script finished"
# Lock is released automatically via trap

View File

@@ -0,0 +1,25 @@
export class AdaptiveThrottler {
private errorCount = 0
private successCount = 0
private window: Array<{ ok: boolean; at: number }> = []
private readonly maxWindow = 50
record(ok: boolean) {
this.window.push({ ok, at: Date.now() })
if (ok) this.successCount++
else this.errorCount++
if (this.window.length > this.maxWindow) {
const removed = this.window.shift()
if (removed) removed.ok ? this.successCount-- : this.errorCount--
}
}
/** Return a multiplier to apply to waits (1 = normal). */
getDelayMultiplier(): number {
const total = Math.max(1, this.successCount + this.errorCount)
const errRatio = this.errorCount / total
// 0% errors -> 1x; 50% errors -> ~1.8x; 80% -> ~2.5x (cap)
const mult = 1 + Math.min(1.5, errRatio * 2)
return Number(mult.toFixed(2))
}
}

96
src/util/Axios.ts Normal file
View File

@@ -0,0 +1,96 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { HttpProxyAgent } from 'http-proxy-agent'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { SocksProxyAgent } from 'socks-proxy-agent'
import { AccountProxy } from '../interface/Account'
class AxiosClient {
private instance: AxiosInstance
private account: AccountProxy
constructor(account: AccountProxy) {
this.account = account
this.instance = axios.create()
// If a proxy configuration is provided, set up the agent
if (this.account.url && this.account.proxyAxios) {
const agent = this.getAgentForProxy(this.account)
this.instance.defaults.httpAgent = agent
this.instance.defaults.httpsAgent = agent
}
}
private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent<string> | HttpsProxyAgent<string> | SocksProxyAgent {
const { url, port } = proxyConfig
switch (true) {
case proxyConfig.url.startsWith('http://'):
return new HttpProxyAgent(`${url}:${port}`)
case proxyConfig.url.startsWith('https://'):
return new HttpsProxyAgent(`${url}:${port}`)
case proxyConfig.url.startsWith('socks://') || proxyConfig.url.startsWith('socks4://') || proxyConfig.url.startsWith('socks5://'):
return new SocksProxyAgent(`${url}:${port}`)
default:
throw new Error(`Unsupported proxy protocol in "${url}". Supported: http://, https://, socks://, socks4://, socks5://`)
}
}
// Generic method to make any Axios request
public async request(config: AxiosRequestConfig, bypassProxy = false): Promise<AxiosResponse> {
if (bypassProxy) {
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
let lastError: unknown
const maxAttempts = 2
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await this.instance.request(config)
} catch (err: unknown) {
lastError = err
const axiosErr = err as AxiosError | undefined
// Detect HTTP proxy auth failures (status 407) and retry without proxy
if (axiosErr && axiosErr.response && axiosErr.response.status === 407) {
if (attempt < maxAttempts) {
await this.sleep(1000 * attempt) // Exponential backoff
}
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// If proxied request fails with common proxy/network errors, retry with backoff
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
const code = e?.code || e?.cause?.code
const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND'
const msg = String(e?.message || '')
const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg)
if (isNetErr || looksLikeProxyIssue) {
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s, etc.
const delayMs = 1000 * Math.pow(2, attempt - 1)
await this.sleep(delayMs)
continue
}
// Last attempt: try without proxy
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// Non-retryable error
throw err
}
}
throw lastError
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
export default AxiosClient

16
src/util/BanDetector.ts Normal file
View File

@@ -0,0 +1,16 @@
export type BanStatus = { status: boolean; reason: string }
const BAN_PATTERNS: Array<{ re: RegExp; reason: string }> = [
{ re: /suspend|suspended|suspension/i, reason: 'account suspended' },
{ re: /locked|lockout|serviceabuse|abuse/i, reason: 'locked or service abuse detected' },
{ re: /unusual.*activity|unusual activity/i, reason: 'unusual activity prompts' },
{ re: /verify.*identity|identity.*verification/i, reason: 'identity verification required' }
]
export function detectBanReason(input: unknown): BanStatus {
const s = input instanceof Error ? (input.message || '') : String(input || '')
for (const p of BAN_PATTERNS) {
if (p.re.test(s)) return { status: true, reason: p.reason }
}
return { status: false, reason: '' }
}

View File

@@ -0,0 +1,335 @@
import axios from 'axios'
import { Config } from '../interface/Config'
import { Ntfy } from './Ntfy'
import { DISCORD } from '../constants'
import { log } from './Logger'
interface DiscordField {
name: string
value: string
inline?: boolean
}
interface DiscordEmbed {
title?: string
description?: string
color?: number
fields?: DiscordField[]
timestamp?: string
footer?: {
text: string
icon_url?: string
}
thumbnail?: {
url: string
}
author?: {
name: string
icon_url?: string
}
}
interface AccountSummary {
email: string
totalCollected: number
desktopCollected: number
mobileCollected: number
initialTotal: number
endTotal: number
durationMs: number
errors: string[]
banned?: { status: boolean; reason?: string }
}
interface ConclusionData {
version: string
runId: string
totalAccounts: number
successes: number
accountsWithErrors: number
accountsBanned: number
totalCollected: number
totalInitial: number
totalEnd: number
avgPointsPerAccount: number
totalDuration: number
avgDuration: number
summaries: AccountSummary[]
}
/**
* Send a clean, structured Discord webhook notification
*/
export async function ConclusionWebhook(
config: Config,
title: string,
description: string,
fields?: DiscordField[],
color?: number
) {
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
const hasWebhook = config.webhook?.enabled && config.webhook.url
if (!hasConclusion && !hasWebhook) return
const embed: DiscordEmbed = {
title,
description,
color: color || 0x0078D4,
timestamp: new Date().toISOString()
}
if (fields && fields.length > 0) {
embed.fields = fields
}
const postWebhook = async (url: string, label: string) => {
const maxAttempts = 3
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.post(url,
{
embeds: [embed]
},
{
headers: { 'Content-Type': 'application/json' },
timeout: 15000
})
log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`)
return
} catch (error) {
lastError = error
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s
const delayMs = 1000 * Math.pow(2, attempt - 1)
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
}
const urls = new Set<string>()
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
if (hasWebhook) urls.add(config.webhook!.url)
await Promise.all(
Array.from(urls).map((url, index) => postWebhook(url, `webhook-${index + 1}`))
)
// Optional NTFY notification
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
const message = `${title}\n${description}${fields ? '\n\n' + fields.map(f => `${f.name}: ${f.value}`).join('\n') : ''}`
const ntfyType = color === 0xFF0000 ? 'error' : color === 0xFFAA00 ? 'warn' : 'log'
try {
await Ntfy(message, ntfyType)
log('main', 'NTFY', 'Notification sent successfully')
} catch (error) {
log('main', 'NTFY', `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
}
}
}
/**
* Enhanced conclusion webhook with beautiful formatting and clear statistics
*/
export async function ConclusionWebhookEnhanced(config: Config, data: ConclusionData) {
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
const hasWebhook = config.webhook?.enabled && config.webhook.url
if (!hasConclusion && !hasWebhook) return
// Helper to format duration
const formatDuration = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
if (minutes > 0) return `${minutes}m ${seconds}s`
return `${seconds}s`
}
// Helper to create progress bar (future use)
// const createProgressBar = (current: number, max: number, length: number = 10): string => {
// const percentage = Math.min(100, Math.max(0, (current / max) * 100))
// const filled = Math.round((percentage / 100) * length)
// const empty = length - filled
// return `${'█'.repeat(filled)}${'░'.repeat(empty)} ${percentage.toFixed(0)}%`
// }
// Determine overall status and color
let statusEmoji = '✅'
let statusText = 'Success'
let embedColor: number = DISCORD.COLOR_GREEN
if (data.accountsBanned > 0) {
statusEmoji = '🚫'
statusText = 'Banned Accounts Detected'
embedColor = DISCORD.COLOR_RED
} else if (data.accountsWithErrors > 0) {
statusEmoji = '⚠️'
statusText = 'Completed with Warnings'
embedColor = DISCORD.COLOR_ORANGE
}
// Build main summary description
const mainDescription = [
`**Status:** ${statusEmoji} ${statusText}`,
`**Version:** v${data.version} • **Run ID:** \`${data.runId}\``,
'',
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
].join('\n')
// Build global statistics field
const globalStats = [
'**💎 Total Points Earned**',
`\`${data.totalInitial.toLocaleString()}\`\`${data.totalEnd.toLocaleString()}\` **(+${data.totalCollected.toLocaleString()})**`,
'',
'**📊 Accounts Processed**',
`✅ Success: **${data.successes}** | ⚠️ Errors: **${data.accountsWithErrors}** | 🚫 Banned: **${data.accountsBanned}**`,
`Total: **${data.totalAccounts}** ${data.totalAccounts === 1 ? 'account' : 'accounts'}`,
'',
'**⚡ Performance**',
`Average: **${data.avgPointsPerAccount}pts/account** in **${formatDuration(data.avgDuration)}**`,
`Total Runtime: **${formatDuration(data.totalDuration)}**`
].join('\n')
// Build per-account breakdown (split if too many accounts)
const accountFields: DiscordField[] = []
const maxAccountsPerField = 5
const accountChunks: AccountSummary[][] = []
for (let i = 0; i < data.summaries.length; i += maxAccountsPerField) {
accountChunks.push(data.summaries.slice(i, i + maxAccountsPerField))
}
accountChunks.forEach((chunk, chunkIndex) => {
const accountLines: string[] = []
chunk.forEach((acc) => {
const statusIcon = acc.banned?.status ? '🚫' : (acc.errors.length > 0 ? '⚠️' : '✅')
const emailShort = acc.email.length > 25 ? acc.email.substring(0, 22) + '...' : acc.email
accountLines.push(`${statusIcon} **${emailShort}**`)
accountLines.push(`└ Points: **+${acc.totalCollected}** (🖥️ ${acc.desktopCollected} • 📱 ${acc.mobileCollected})`)
accountLines.push(`└ Duration: ${formatDuration(acc.durationMs)}`)
if (acc.banned?.status) {
accountLines.push(`└ 🚫 **Banned:** ${acc.banned.reason || 'Account suspended'}`)
} else if (acc.errors.length > 0) {
const errorPreview = acc.errors.slice(0, 1).join(', ')
accountLines.push(`└ ⚠️ **Error:** ${errorPreview.length > 50 ? errorPreview.substring(0, 47) + '...' : errorPreview}`)
}
accountLines.push('') // Empty line between accounts
})
const fieldName = accountChunks.length > 1
? `📈 Account Details (${chunkIndex + 1}/${accountChunks.length})`
: '📈 Account Details'
accountFields.push({
name: fieldName,
value: accountLines.join('\n').trim(),
inline: false
})
})
// Create embeds
const embeds: DiscordEmbed[] = []
// Main embed with summary
embeds.push({
title: '🎯 Microsoft Rewards — Daily Summary',
description: mainDescription,
color: embedColor,
fields: [
{
name: '📊 Global Statistics',
value: globalStats,
inline: false
}
],
footer: {
text: `Microsoft Rewards Bot v${data.version} • Completed at`
},
timestamp: new Date().toISOString()
})
// Add account details in separate embed(s) if needed
if (accountFields.length > 0) {
// If we have multiple fields, split into multiple embeds
accountFields.forEach((field, index) => {
if (index === 0 && embeds[0] && embeds[0].fields) {
// Add first field to main embed
embeds[0].fields.push(field)
} else {
// Create additional embeds for remaining fields
embeds.push({
color: embedColor,
fields: [field],
timestamp: new Date().toISOString()
})
}
})
}
const postWebhook = async (url: string, label: string) => {
const maxAttempts = 3
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.post(url, {
embeds: embeds
}, {
headers: { 'Content-Type': 'application/json' },
timeout: 15000
})
log('main', 'WEBHOOK', `${label} conclusion sent successfully (${data.totalAccounts} accounts, +${data.totalCollected}pts)`)
return
} catch (error) {
lastError = error
if (attempt < maxAttempts) {
const delayMs = 1000 * Math.pow(2, attempt - 1)
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
}
const urls = new Set<string>()
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
if (hasWebhook) urls.add(config.webhook!.url)
await Promise.all(
Array.from(urls).map((url, index) => postWebhook(url, `conclusion-webhook-${index + 1}`))
)
// Optional NTFY notification (simplified summary)
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
const message = [
'🎯 Microsoft Rewards Summary',
`Status: ${statusText}`,
`Points: ${data.totalInitial}${data.totalEnd} (+${data.totalCollected})`,
`Accounts: ${data.successes}/${data.totalAccounts} successful`,
`Duration: ${formatDuration(data.totalDuration)}`
].join('\n')
const ntfyType = embedColor === DISCORD.COLOR_RED ? 'error' : embedColor === DISCORD.COLOR_ORANGE ? 'warn' : 'log'
try {
await Ntfy(message, ntfyType)
log('main', 'NTFY', 'Conclusion notification sent successfully')
} catch (error) {
log('main', 'NTFY', `Failed to send conclusion notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
}
}
}

481
src/util/ConfigValidator.ts Normal file
View File

@@ -0,0 +1,481 @@
import fs from 'fs'
import { Config } from '../interface/Config'
import { Account } from '../interface/Account'
export interface ValidationIssue {
severity: 'error' | 'warning' | 'info'
field: string
message: string
suggestion?: string
}
export interface ValidationResult {
valid: boolean
issues: ValidationIssue[]
}
/**
* ConfigValidator performs intelligent validation of config.json and accounts.json
* before execution to catch common mistakes, conflicts, and security issues.
*/
export class ConfigValidator {
/**
* Validate the main config file
*/
static validateConfig(config: Config): ValidationResult {
const issues: ValidationIssue[] = []
// Check baseURL
if (!config.baseURL || !config.baseURL.startsWith('https://')) {
issues.push({
severity: 'error',
field: 'baseURL',
message: 'baseURL must be a valid HTTPS URL',
suggestion: 'Use https://rewards.bing.com'
})
}
// Check sessionPath
if (!config.sessionPath || config.sessionPath.trim() === '') {
issues.push({
severity: 'error',
field: 'sessionPath',
message: 'sessionPath cannot be empty'
})
}
// Check clusters
if (config.clusters < 1) {
issues.push({
severity: 'error',
field: 'clusters',
message: 'clusters must be at least 1'
})
}
if (config.clusters > 10) {
issues.push({
severity: 'warning',
field: 'clusters',
message: 'High cluster count may consume excessive resources',
suggestion: 'Consider using 2-4 clusters for optimal performance'
})
}
// Check globalTimeout
const timeout = this.parseTimeout(config.globalTimeout)
if (timeout < 10000) {
issues.push({
severity: 'warning',
field: 'globalTimeout',
message: 'Very short timeout may cause frequent failures',
suggestion: 'Use at least 15s for stability'
})
}
if (timeout > 120000) {
issues.push({
severity: 'warning',
field: 'globalTimeout',
message: 'Very long timeout may slow down execution',
suggestion: 'Use 30-60s for optimal balance'
})
}
// Check search settings
if (config.searchSettings) {
const searchDelay = config.searchSettings.searchDelay
const minDelay = this.parseTimeout(searchDelay.min)
const maxDelay = this.parseTimeout(searchDelay.max)
if (minDelay >= maxDelay) {
issues.push({
severity: 'error',
field: 'searchSettings.searchDelay',
message: 'min delay must be less than max delay'
})
}
if (minDelay < 10000) {
issues.push({
severity: 'warning',
field: 'searchSettings.searchDelay.min',
message: 'Very short search delays increase ban risk',
suggestion: 'Use at least 30s between searches'
})
}
if (config.searchSettings.retryMobileSearchAmount > 5) {
issues.push({
severity: 'warning',
field: 'searchSettings.retryMobileSearchAmount',
message: 'Too many retries may waste time',
suggestion: 'Use 2-3 retries maximum'
})
}
}
// Check humanization
if (config.humanization) {
if (config.humanization.enabled === false && config.humanization.stopOnBan === true) {
issues.push({
severity: 'warning',
field: 'humanization',
message: 'stopOnBan is enabled but humanization is disabled',
suggestion: 'Enable humanization for better ban protection'
})
}
const actionDelay = config.humanization.actionDelay
if (actionDelay) {
const minAction = this.parseTimeout(actionDelay.min)
const maxAction = this.parseTimeout(actionDelay.max)
if (minAction >= maxAction) {
issues.push({
severity: 'error',
field: 'humanization.actionDelay',
message: 'min action delay must be less than max'
})
}
}
if (config.humanization.allowedWindows && config.humanization.allowedWindows.length > 0) {
for (const window of config.humanization.allowedWindows) {
if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) {
issues.push({
severity: 'error',
field: 'humanization.allowedWindows',
message: `Invalid time window format: ${window}`,
suggestion: 'Use format HH:mm-HH:mm (e.g., 09:00-17:00)'
})
}
}
}
}
// Check proxy config
if (config.proxy) {
if (config.proxy.proxyGoogleTrends === false && config.proxy.proxyBingTerms === false) {
issues.push({
severity: 'info',
field: 'proxy',
message: 'All proxy options disabled - outbound requests will use direct connection'
})
}
}
// Check webhooks
if (config.webhook?.enabled && (!config.webhook.url || config.webhook.url.trim() === '')) {
issues.push({
severity: 'error',
field: 'webhook.url',
message: 'Webhook enabled but URL is empty'
})
}
if (config.conclusionWebhook?.enabled && (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '')) {
issues.push({
severity: 'error',
field: 'conclusionWebhook.url',
message: 'Conclusion webhook enabled but URL is empty'
})
}
// Check ntfy
if (config.ntfy?.enabled) {
if (!config.ntfy.url || config.ntfy.url.trim() === '') {
issues.push({
severity: 'error',
field: 'ntfy.url',
message: 'NTFY enabled but URL is empty'
})
}
if (!config.ntfy.topic || config.ntfy.topic.trim() === '') {
issues.push({
severity: 'error',
field: 'ntfy.topic',
message: 'NTFY enabled but topic is empty'
})
}
}
// Check workers
if (config.workers) {
const allDisabled = !config.workers.doDailySet &&
!config.workers.doMorePromotions &&
!config.workers.doPunchCards &&
!config.workers.doDesktopSearch &&
!config.workers.doMobileSearch &&
!config.workers.doDailyCheckIn &&
!config.workers.doReadToEarn
if (allDisabled) {
issues.push({
severity: 'warning',
field: 'workers',
message: 'All workers are disabled - bot will not perform any tasks',
suggestion: 'Enable at least one worker type'
})
}
}
const valid = !issues.some(i => i.severity === 'error')
return { valid, issues }
}
/**
* Validate accounts.json
*/
static validateAccounts(accounts: Account[]): ValidationResult {
const issues: ValidationIssue[] = []
if (accounts.length === 0) {
issues.push({
severity: 'error',
field: 'accounts',
message: 'No accounts found in accounts.json'
})
return { valid: false, issues }
}
const seenEmails = new Set<string>()
const seenProxies = new Map<string, string[]>() // proxy -> [emails]
for (let i = 0; i < accounts.length; i++) {
const acc = accounts[i]
const prefix = `accounts[${i}]`
if (!acc) continue
// Check email
if (!acc.email || acc.email.trim() === '') {
issues.push({
severity: 'error',
field: `${prefix}.email`,
message: 'Account email is empty'
})
} else {
if (seenEmails.has(acc.email)) {
issues.push({
severity: 'error',
field: `${prefix}.email`,
message: `Duplicate email: ${acc.email}`
})
}
seenEmails.add(acc.email)
if (!/@/.test(acc.email)) {
issues.push({
severity: 'error',
field: `${prefix}.email`,
message: 'Invalid email format'
})
}
}
// Check password
if (!acc.password || acc.password.trim() === '') {
issues.push({
severity: 'error',
field: `${prefix}.password`,
message: 'Account password is empty'
})
} else if (acc.password.length < 8) {
issues.push({
severity: 'warning',
field: `${prefix}.password`,
message: 'Very short password - verify it\'s correct'
})
}
// Check proxy
if (acc.proxy) {
const proxyUrl = acc.proxy.url
if (proxyUrl && proxyUrl.trim() !== '') {
if (!acc.proxy.port) {
issues.push({
severity: 'error',
field: `${prefix}.proxy.port`,
message: 'Proxy URL specified but port is missing'
})
}
// Track proxy reuse
const proxyKey = `${proxyUrl}:${acc.proxy.port}`
if (!seenProxies.has(proxyKey)) {
seenProxies.set(proxyKey, [])
}
seenProxies.get(proxyKey)?.push(acc.email)
}
}
// Check TOTP
if (acc.totp && acc.totp.trim() !== '') {
if (acc.totp.length < 16) {
issues.push({
severity: 'warning',
field: `${prefix}.totp`,
message: 'TOTP secret seems too short - verify it\'s correct'
})
}
}
}
// Warn about excessive proxy reuse
for (const [proxyKey, emails] of seenProxies) {
if (emails.length > 3) {
issues.push({
severity: 'warning',
field: 'accounts.proxy',
message: `Proxy ${proxyKey} used by ${emails.length} accounts - may trigger rate limits`,
suggestion: 'Use different proxies per account for better safety'
})
}
}
const valid = !issues.some(i => i.severity === 'error')
return { valid, issues }
}
/**
* Validate both config and accounts together (cross-checks)
*/
static validateAll(config: Config, accounts: Account[]): ValidationResult {
const configResult = this.validateConfig(config)
const accountsResult = this.validateAccounts(accounts)
const issues = [...configResult.issues, ...accountsResult.issues]
// Cross-validation: clusters vs accounts
if (accounts.length > 0 && config.clusters > accounts.length) {
issues.push({
severity: 'info',
field: 'clusters',
message: `${config.clusters} clusters configured but only ${accounts.length} account(s)`,
suggestion: 'Reduce clusters to match account count for efficiency'
})
}
// Cross-validation: parallel mode with single account
if (config.parallel && accounts.length === 1) {
issues.push({
severity: 'info',
field: 'parallel',
message: 'Parallel mode enabled with single account has no effect',
suggestion: 'Disable parallel mode or add more accounts'
})
}
const valid = !issues.some(i => i.severity === 'error')
return { valid, issues }
}
/**
* Load and validate from file paths
*/
static validateFromFiles(configPath: string, accountsPath: string): ValidationResult {
try {
if (!fs.existsSync(configPath)) {
return {
valid: false,
issues: [{
severity: 'error',
field: 'config',
message: `Config file not found: ${configPath}`
}]
}
}
if (!fs.existsSync(accountsPath)) {
return {
valid: false,
issues: [{
severity: 'error',
field: 'accounts',
message: `Accounts file not found: ${accountsPath}`
}]
}
}
const configRaw = fs.readFileSync(configPath, 'utf-8')
const accountsRaw = fs.readFileSync(accountsPath, 'utf-8')
const configJson = configRaw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
const config: Config = JSON.parse(configJson)
const accounts: Account[] = JSON.parse(accountsRaw)
return this.validateAll(config, accounts)
} catch (error) {
return {
valid: false,
issues: [{
severity: 'error',
field: 'parse',
message: `Failed to parse files: ${error instanceof Error ? error.message : String(error)}`
}]
}
}
}
/**
* Print validation results to console with color
* Note: This method intentionally uses console.log for CLI output formatting
*/
static printResults(result: ValidationResult): void {
if (result.valid) {
console.log('✅ Configuration validation passed\n')
} else {
console.log('❌ Configuration validation failed\n')
}
if (result.issues.length === 0) {
console.log('No issues found.')
return
}
const errors = result.issues.filter(i => i.severity === 'error')
const warnings = result.issues.filter(i => i.severity === 'warning')
const infos = result.issues.filter(i => i.severity === 'info')
if (errors.length > 0) {
console.log(`\n🚫 ERRORS (${errors.length}):`)
for (const issue of errors) {
console.log(` ${issue.field}: ${issue.message}`)
if (issue.suggestion) {
console.log(`${issue.suggestion}`)
}
}
}
if (warnings.length > 0) {
console.log(`\n⚠ WARNINGS (${warnings.length}):`)
for (const issue of warnings) {
console.log(` ${issue.field}: ${issue.message}`)
if (issue.suggestion) {
console.log(`${issue.suggestion}`)
}
}
}
if (infos.length > 0) {
console.log(`\n INFO (${infos.length}):`)
for (const issue of infos) {
console.log(` ${issue.field}: ${issue.message}`)
if (issue.suggestion) {
console.log(`${issue.suggestion}`)
}
}
}
console.log()
}
private static parseTimeout(value: number | string): number {
if (typeof value === 'number') return value
const str = String(value).toLowerCase()
if (str.endsWith('ms')) return parseInt(str, 10)
if (str.endsWith('s')) return parseInt(str, 10) * 1000
if (str.endsWith('min')) return parseInt(str, 10) * 60000
return parseInt(str, 10) || 30000
}
}

54
src/util/Humanizer.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Page } from 'rebrowser-playwright'
import Util from './Utils'
import type { ConfigHumanization } from '../interface/Config'
export class Humanizer {
private util: Util
private cfg: ConfigHumanization | undefined
constructor(util: Util, cfg?: ConfigHumanization) {
this.util = util
this.cfg = cfg
}
async microGestures(page: Page): Promise<void> {
if (this.cfg && this.cfg.enabled === false) return
const moveProb = this.cfg?.gestureMoveProb ?? 0.4
const scrollProb = this.cfg?.gestureScrollProb ?? 0.2
try {
if (Math.random() < moveProb) {
const x = Math.floor(Math.random() * 40) + 5
const y = Math.floor(Math.random() * 30) + 5
await page.mouse.move(x, y, { steps: 2 }).catch(() => {})
}
if (Math.random() < scrollProb) {
const dy = (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 150) + 50)
await page.mouse.wheel(0, dy).catch(() => {})
}
} catch {/* noop */}
}
async actionPause(): Promise<void> {
if (this.cfg && this.cfg.enabled === false) return
const defMin = 150
const defMax = 450
let min = defMin
let max = defMax
if (this.cfg?.actionDelay) {
const parse = (v: number | string) => {
if (typeof v === 'number') return v
try {
const n = this.util.stringToMs(String(v))
return Math.max(0, Math.min(n, 10_000))
} catch { return defMin }
}
min = parse(this.cfg.actionDelay.min)
max = parse(this.cfg.actionDelay.max)
if (min > max) [min, max] = [max, min]
max = Math.min(max, 5_000)
}
await this.util.wait(this.util.randomNumber(min, max))
}
}
export default Humanizer

58
src/util/JobState.ts Normal file
View File

@@ -0,0 +1,58 @@
import fs from 'fs'
import path from 'path'
import type { Config } from '../interface/Config'
type DayState = {
doneOfferIds: string[]
}
type FileState = {
days: Record<string, DayState>
}
export class JobState {
private baseDir: string
constructor(cfg: Config) {
const dir = cfg.jobState?.dir || path.join(process.cwd(), cfg.sessionPath, 'job-state')
this.baseDir = dir
if (!fs.existsSync(this.baseDir)) fs.mkdirSync(this.baseDir, { recursive: true })
}
private fileFor(email: string): string {
const safe = email.replace(/[^a-z0-9._-]/gi, '_')
return path.join(this.baseDir, `${safe}.json`)
}
private load(email: string): FileState {
const file = this.fileFor(email)
if (!fs.existsSync(file)) return { days: {} }
try {
const raw = fs.readFileSync(file, 'utf-8')
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && parsed.days ? parsed as FileState : { days: {} }
} catch { return { days: {} } }
}
private save(email: string, state: FileState): void {
const file = this.fileFor(email)
fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8')
}
isDone(email: string, day: string, offerId: string): boolean {
const st = this.load(email)
const d = st.days[day]
if (!d) return false
return d.doneOfferIds.includes(offerId)
}
markDone(email: string, day: string, offerId: string): void {
const st = this.load(email)
if (!st.days[day]) st.days[day] = { doneOfferIds: [] }
const d = st.days[day]
if (!d.doneOfferIds.includes(offerId)) d.doneOfferIds.push(offerId)
this.save(email, st)
}
}
export default JobState

View File

@@ -1,35 +1,307 @@
import { BrowserContext, Cookie } from 'rebrowser-playwright'
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
import fs from 'fs'
import path from 'path'
import { Account } from '../interface/Account'
import { Config } from '../interface/Config'
import { Account } from '../interface/Account'
import { Config, ConfigSaveFingerprint } from '../interface/Config'
let configCache: Config
let configSourcePath = ''
// Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface
function normalizeConfig(raw: unknown): Config {
// Using any here is necessary to support both legacy flat config and new nested config structures
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const n = (raw || {}) as any
// Browser / execution
const headless = n.browser?.headless ?? n.headless ?? false
const globalTimeout = n.browser?.globalTimeout ?? n.globalTimeout ?? '30s'
const parallel = n.execution?.parallel ?? n.parallel ?? false
const runOnZeroPoints = n.execution?.runOnZeroPoints ?? n.runOnZeroPoints ?? false
const clusters = n.execution?.clusters ?? n.clusters ?? 1
// Search
const useLocalQueries = n.search?.useLocalQueries ?? n.searchOnBingLocalQueries ?? false
const searchSettingsSrc = n.search?.settings ?? n.searchSettings ?? {}
const delaySrc = searchSettingsSrc.delay ?? searchSettingsSrc.searchDelay ?? { min: '3min', max: '5min' }
const searchSettings = {
useGeoLocaleQueries: !!(searchSettingsSrc.useGeoLocaleQueries ?? false),
scrollRandomResults: !!(searchSettingsSrc.scrollRandomResults ?? false),
clickRandomResults: !!(searchSettingsSrc.clickRandomResults ?? false),
retryMobileSearchAmount: Number(searchSettingsSrc.retryMobileSearchAmount ?? 2),
searchDelay: {
min: delaySrc.min ?? '3min',
max: delaySrc.max ?? '5min'
},
localFallbackCount: Number(searchSettingsSrc.localFallbackCount ?? 25),
extraFallbackRetries: Number(searchSettingsSrc.extraFallbackRetries ?? 1)
}
// Workers
const workers = n.workers ?? {
doDailySet: true,
doMorePromotions: true,
doPunchCards: true,
doDesktopSearch: true,
doMobileSearch: true,
doDailyCheckIn: true,
doReadToEarn: true,
bundleDailySetWithSearch: false
}
// Ensure missing flag gets a default
if (typeof workers.bundleDailySetWithSearch !== 'boolean') workers.bundleDailySetWithSearch = false
// Logging
const logging = n.logging ?? {}
const logExcludeFunc = Array.isArray(logging.excludeFunc) ? logging.excludeFunc : (n.logExcludeFunc ?? [])
const webhookLogExcludeFunc = Array.isArray(logging.webhookExcludeFunc) ? logging.webhookExcludeFunc : (n.webhookLogExcludeFunc ?? [])
// Notifications
const notifications = n.notifications ?? {}
const webhook = notifications.webhook ?? n.webhook ?? { enabled: false, url: '' }
const conclusionWebhook = notifications.conclusionWebhook ?? n.conclusionWebhook ?? { enabled: false, url: '' }
const ntfy = notifications.ntfy ?? n.ntfy ?? { enabled: false, url: '', topic: '', authToken: '' }
// Fingerprinting
const saveFingerprint = (n.fingerprinting?.saveFingerprint ?? n.saveFingerprint) ?? { mobile: false, desktop: false }
// Humanization defaults (single on/off)
if (!n.humanization) n.humanization = {}
if (typeof n.humanization.enabled !== 'boolean') n.humanization.enabled = true
if (typeof n.humanization.stopOnBan !== 'boolean') n.humanization.stopOnBan = false
if (typeof n.humanization.immediateBanAlert !== 'boolean') n.humanization.immediateBanAlert = true
if (typeof n.humanization.randomOffDaysPerWeek !== 'number') {
n.humanization.randomOffDaysPerWeek = 1
}
// Strong default gestures when enabled (explicit values still win)
if (typeof n.humanization.gestureMoveProb !== 'number') {
n.humanization.gestureMoveProb = n.humanization.enabled === false ? 0 : 0.5
}
if (typeof n.humanization.gestureScrollProb !== 'number') {
n.humanization.gestureScrollProb = n.humanization.enabled === false ? 0 : 0.25
}
// Vacation mode (monthly contiguous off-days)
if (!n.vacation) n.vacation = {}
if (typeof n.vacation.enabled !== 'boolean') n.vacation.enabled = false
const vMin = Number(n.vacation.minDays)
const vMax = Number(n.vacation.maxDays)
n.vacation.minDays = isFinite(vMin) && vMin > 0 ? Math.floor(vMin) : 3
n.vacation.maxDays = isFinite(vMax) && vMax > 0 ? Math.floor(vMax) : 5
if (n.vacation.maxDays < n.vacation.minDays) {
const t = n.vacation.minDays; n.vacation.minDays = n.vacation.maxDays; n.vacation.maxDays = t
}
const cfg: Config = {
baseURL: n.baseURL ?? 'https://rewards.bing.com',
sessionPath: n.sessionPath ?? 'sessions',
headless,
parallel,
runOnZeroPoints,
clusters,
saveFingerprint,
workers,
searchOnBingLocalQueries: !!useLocalQueries,
globalTimeout,
searchSettings,
humanization: n.humanization,
retryPolicy: n.retryPolicy,
jobState: n.jobState,
logExcludeFunc,
webhookLogExcludeFunc,
logging, // retain full logging object for live webhook usage
proxy: n.proxy ?? { proxyGoogleTrends: true, proxyBingTerms: true },
webhook,
conclusionWebhook,
ntfy,
vacation: n.vacation,
crashRecovery: n.crashRecovery || {}
}
return cfg
}
export function loadAccounts(): Account[] {
try {
// 1) CLI dev override
let file = 'accounts.json'
// If dev mode, use dev account(s)
if (process.argv.includes('-dev')) {
file = 'accounts.dev.json'
}
const accountDir = path.join(__dirname, '../', file)
const accounts = fs.readFileSync(accountDir, 'utf-8')
// 2) Docker-friendly env overrides
const envJson = process.env.ACCOUNTS_JSON
const envFile = process.env.ACCOUNTS_FILE
return JSON.parse(accounts)
let json: string | undefined
if (envJson && envJson.trim().startsWith('[')) {
json = envJson
} else if (envFile && envFile.trim()) {
const full = path.isAbsolute(envFile) ? envFile : path.join(process.cwd(), envFile)
if (!fs.existsSync(full)) {
throw new Error(`ACCOUNTS_FILE not found: ${full}`)
}
json = fs.readFileSync(full, 'utf-8')
} else {
// Try multiple locations to support both root mounts and dist mounts
// Support both .json and .json extensions
const candidates = [
path.join(__dirname, '../', file),
path.join(__dirname, '../src', file),
path.join(process.cwd(), file),
path.join(process.cwd(), 'src', file),
path.join(__dirname, file)
]
let chosen: string | null = null
for (const p of candidates) {
try { if (fs.existsSync(p)) { chosen = p; break } } catch { /* ignore */ }
}
if (!chosen) throw new Error(`accounts file not found in: ${candidates.join(' | ')}`)
json = fs.readFileSync(chosen, 'utf-8')
}
// Support comments in accounts file (same as config)
const parsedUnknown = JSON.parse(json)
// Accept either a root array or an object with an `accounts` array, ignore `_note`
const parsed = Array.isArray(parsedUnknown) ? parsedUnknown : (parsedUnknown && typeof parsedUnknown === 'object' && Array.isArray((parsedUnknown as { accounts?: unknown }).accounts) ? (parsedUnknown as { accounts: unknown[] }).accounts : null)
if (!Array.isArray(parsed)) throw new Error('accounts must be an array')
// minimal shape validation
for (const a of parsed) {
if (!a || typeof a.email !== 'string' || typeof a.password !== 'string') {
throw new Error('each account must have email and password strings')
}
}
// Filter out disabled accounts (enabled: false)
const allAccounts = parsed as Account[]
const enabledAccounts = allAccounts.filter(acc => acc.enabled !== false)
return enabledAccounts
} catch (error) {
throw new Error(error as string)
}
}
export function getConfigPath(): string { return configSourcePath }
export function loadConfig(): Config {
try {
const configDir = path.join(__dirname, '../', 'config.json')
const config = fs.readFileSync(configDir, 'utf-8')
if (configCache) {
return configCache
}
return JSON.parse(config)
// Resolve configuration file from common locations
const names = ['config.json']
const bases = [
path.join(__dirname, '../'), // dist root when compiled
path.join(__dirname, '../src'), // fallback: running dist but config still in src
process.cwd(), // repo root
path.join(process.cwd(), 'src'), // repo/src when running ts-node
__dirname // dist/util
]
const candidates: string[] = []
for (const base of bases) {
for (const name of names) {
candidates.push(path.join(base, name))
}
}
let cfgPath: string | null = null
for (const p of candidates) {
try { if (fs.existsSync(p)) { cfgPath = p; break } } catch { /* ignore */ }
}
if (!cfgPath) throw new Error(`config.json not found in: ${candidates.join(' | ')}`)
const config = fs.readFileSync(cfgPath, 'utf-8')
const json = config.replace(/^\uFEFF/, '')
const raw = JSON.parse(json)
const normalized = normalizeConfig(raw)
configCache = normalized // Set as cache
configSourcePath = cfgPath
return normalized
} catch (error) {
throw new Error(error as string)
}
}
}
export async function loadSessionData(sessionPath: string, email: string, isMobile: boolean, saveFingerprint: ConfigSaveFingerprint) {
try {
// Fetch cookie file
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`)
let cookies: Cookie[] = []
if (fs.existsSync(cookieFile)) {
const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8')
cookies = JSON.parse(cookiesData)
}
// Fetch fingerprint file (support both legacy typo "fingerpint" and corrected "fingerprint")
const baseDir = path.join(__dirname, '../browser/', sessionPath, email)
const legacyFile = path.join(baseDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
const correctFile = path.join(baseDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
let fingerprint!: BrowserFingerprintWithHeaders
const shouldLoad = (saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)
if (shouldLoad) {
const chosen = fs.existsSync(correctFile) ? correctFile : (fs.existsSync(legacyFile) ? legacyFile : '')
if (chosen) {
const fingerprintData = await fs.promises.readFile(chosen, 'utf-8')
fingerprint = JSON.parse(fingerprintData)
}
}
return {
cookies: cookies,
fingerprint: fingerprint
}
} catch (error) {
throw new Error(error as string)
}
}
export async function saveSessionData(sessionPath: string, browser: BrowserContext, email: string, isMobile: boolean): Promise<string> {
try {
const cookies = await browser.cookies()
// Fetch path
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
// Create session dir
if (!fs.existsSync(sessionDir)) {
await fs.promises.mkdir(sessionDir, { recursive: true })
}
// Save cookies to a file
await fs.promises.writeFile(path.join(sessionDir, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`), JSON.stringify(cookies))
return sessionDir
} catch (error) {
throw new Error(error as string)
}
}
export async function saveFingerprintData(sessionPath: string, email: string, isMobile: boolean, fingerprint: BrowserFingerprintWithHeaders): Promise<string> {
try {
// Fetch path
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
// Create session dir
if (!fs.existsSync(sessionDir)) {
await fs.promises.mkdir(sessionDir, { recursive: true })
}
// Save fingerprint to files (write both legacy and corrected names for compatibility)
const legacy = path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
const correct = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
const payload = JSON.stringify(fingerprint)
await fs.promises.writeFile(correct, payload)
try { await fs.promises.writeFile(legacy, payload) } catch { /* ignore */ }
return sessionDir
} catch (error) {
throw new Error(error as string)
}
}

View File

@@ -1,26 +1,249 @@
import { Webhook } from './Webhook'
import axios from 'axios'
import chalk from 'chalk'
export function log(title: string, message: string, type?: 'log' | 'warn' | 'error') {
const currentTime = new Date().toISOString()
import { Ntfy } from './Ntfy'
import { loadConfig } from './Load'
import { DISCORD } from '../constants'
let str = ''
type WebhookBuffer = {
lines: string[]
sending: boolean
timer?: NodeJS.Timeout
}
const webhookBuffers = new Map<string, WebhookBuffer>()
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks
setInterval(() => {
const now = Date.now()
const BUFFER_MAX_AGE_MS = 3600000 // 1 hour
for (const [url, buf] of webhookBuffers.entries()) {
if (!buf.sending && buf.lines.length === 0) {
const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0
if (now - lastActivity > BUFFER_MAX_AGE_MS) {
webhookBuffers.delete(url)
}
}
}
}, 600000) // Check every 10 minutes
function getBuffer(url: string): WebhookBuffer {
let buf = webhookBuffers.get(url)
if (!buf) {
buf = { lines: [], sending: false }
webhookBuffers.set(url, buf)
}
// Track last activity for cleanup
(buf as unknown as { lastActivity: number }).lastActivity = Date.now()
return buf
}
async function sendBatch(url: string, buf: WebhookBuffer) {
if (buf.sending) return
buf.sending = true
while (buf.lines.length > 0) {
const chunk: string[] = []
let currentLength = 0
while (buf.lines.length > 0) {
const next = buf.lines[0]!
const projected = currentLength + next.length + (chunk.length > 0 ? 1 : 0)
if (projected > DISCORD.MAX_EMBED_LENGTH && chunk.length > 0) break
buf.lines.shift()
chunk.push(next)
currentLength = projected
}
const content = chunk.join('\n').slice(0, DISCORD.MAX_EMBED_LENGTH)
if (!content) {
continue
}
// Enhanced webhook payload with embed, username and avatar
const payload = {
embeds: [{
description: `\`\`\`\n${content}\n\`\`\``,
color: determineColorFromContent(content),
timestamp: new Date().toISOString()
}]
}
try {
await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout: DISCORD.WEBHOOK_TIMEOUT })
await new Promise(resolve => setTimeout(resolve, DISCORD.RATE_LIMIT_DELAY))
} catch (error) {
// Re-queue failed batch at front and exit loop
buf.lines = chunk.concat(buf.lines)
console.error('[Webhook] live log delivery failed:', error)
break
}
}
buf.sending = false
}
function determineColorFromContent(content: string): number {
const lower = content.toLowerCase()
// Security/Ban alerts - Red
if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) {
return DISCORD.COLOR_RED
}
// Errors - Dark Red
if (lower.includes('[error]') || lower.includes('✗')) {
return DISCORD.COLOR_CRIMSON
}
// Warnings - Orange/Yellow
if (lower.includes('[warn]') || lower.includes('⚠')) {
return DISCORD.COLOR_ORANGE
}
// Success - Green
if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) {
return DISCORD.COLOR_GREEN
}
// Info/Main - Blue
if (lower.includes('[main]')) {
return DISCORD.COLOR_BLUE
}
// Default - Gray
return 0x95A5A6 // Gray
}
function enqueueWebhookLog(url: string, line: string) {
const buf = getBuffer(url)
buf.lines.push(line)
if (!buf.timer) {
buf.timer = setTimeout(() => {
buf.timer = undefined
void sendBatch(url, buf)
}, DISCORD.DEBOUNCE_DELAY)
}
}
// Synchronous logger that returns an Error when type === 'error' so callers can `throw log(...)` safely.
export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): Error | void {
const configData = loadConfig()
// Access logging config with fallback for backward compatibility
const configAny = configData as unknown as Record<string, unknown>
const logging = configAny.logging as { excludeFunc?: string[]; logExcludeFunc?: string[] } | undefined
const logExcludeFunc = logging?.excludeFunc ?? (configData as { logExcludeFunc?: string[] }).logExcludeFunc ?? []
if (logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
return
}
const currentTime = new Date().toLocaleString()
const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP'
// Clean string for notifications (no chalk, structured)
type LoggingCfg = { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean }
const loggingCfg: LoggingCfg = (configAny.logging || {}) as LoggingCfg
const shouldRedact = !!loggingCfg.redactEmails
const redact = (s: string) => shouldRedact ? s.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig, (m) => {
const [u, d] = m.split('@'); return `${(u || '').slice(0, 2)}***@${d || ''}`
}) : s
const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`)
// Define conditions for sending to NTFY
const ntfyConditions = {
log: [
message.toLowerCase().includes('started tasks for account'),
message.toLowerCase().includes('press the number'),
message.toLowerCase().includes('no points to earn')
],
error: [],
warn: [
message.toLowerCase().includes('aborting'),
message.toLowerCase().includes('didn\'t gain')
]
}
// Check if the current log type and message meet the NTFY conditions
try {
if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) {
// Fire-and-forget
Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* ignore ntfy errors */ })
}
} catch { /* ignore */ }
// Console output with better formatting and contextual icons
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓'
const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta
const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green
// Add contextual icon based on title/message (ASCII-safe for Windows PowerShell)
const titleLower = title.toLowerCase()
const msgLower = message.toLowerCase()
// ASCII-safe icons for Windows PowerShell compatibility
const iconMap: Array<[RegExp, string]> = [
[/security|compromised/i, '[SECURITY]'],
[/ban|suspend/i, '[BANNED]'],
[/error/i, '[ERROR]'],
[/warn/i, '[WARN]'],
[/success|complet/i, '[OK]'],
[/login/i, '[LOGIN]'],
[/point/i, '[POINTS]'],
[/search/i, '[SEARCH]'],
[/activity|quiz|poll/i, '[ACTIVITY]'],
[/browser/i, '[BROWSER]'],
[/main/i, '[MAIN]']
]
let icon = ''
for (const [pattern, symbol] of iconMap) {
if (pattern.test(titleLower) || pattern.test(msgLower)) {
icon = chalk.dim(symbol)
break
}
}
const iconPart = icon ? icon + ' ' : ''
const formattedStr = [
chalk.gray(`[${currentTime}]`),
chalk.gray(`[${process.pid}]`),
typeColor(`${typeIndicator}`),
platformColor(`[${platformText}]`),
chalk.bold(`[${title}]`),
iconPart + redact(message)
].join(' ')
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
// Log based on the type
switch (type) {
case 'warn':
str = `[${currentTime}] [PID: ${process.pid}] [WARN] [${title}] ${message}`
console.warn(str)
applyChalk ? console.warn(applyChalk(formattedStr)) : console.warn(formattedStr)
break
case 'error':
str = `[${currentTime}] [PID: ${process.pid}] [ERROR] [${title}] ${message}`
console.error(str)
applyChalk ? console.error(applyChalk(formattedStr)) : console.error(formattedStr)
break
default:
str = `[${currentTime}] [PID: ${process.pid}] [LOG] [${title}] ${message}`
console.log(str)
applyChalk ? console.log(applyChalk(formattedStr)) : console.log(formattedStr)
break
}
if (str) Webhook(str)
// Webhook streaming (live logs)
try {
const loggingCfg: Record<string, unknown> = (configAny.logging || {}) as Record<string, unknown>
const webhookCfg = configData.webhook
const liveUrlRaw = typeof loggingCfg.liveWebhookUrl === 'string' ? loggingCfg.liveWebhookUrl.trim() : ''
const liveUrl = liveUrlRaw || (webhookCfg?.enabled && webhookCfg.url ? webhookCfg.url : '')
const webhookExclude = Array.isArray(loggingCfg.webhookExcludeFunc) ? loggingCfg.webhookExcludeFunc : configData.webhookLogExcludeFunc || []
const webhookExcluded = Array.isArray(webhookExclude) && webhookExclude.some((x: string) => x.toLowerCase() === title.toLowerCase())
if (liveUrl && !webhookExcluded) {
enqueueWebhookLog(liveUrl, cleanStr)
}
} catch (error) {
console.error('[Logger] Failed to enqueue webhook log:', error)
}
// Return an Error when logging an error so callers can `throw log(...)`
if (type === 'error') {
// CommunityReporter disabled per project policy
return new Error(cleanStr)
}
}

27
src/util/Ntfy.ts Normal file
View File

@@ -0,0 +1,27 @@
import { loadConfig } from './Load'
import axios from 'axios'
const NOTIFICATION_TYPES = {
error: { priority: 'max', tags: 'rotating_light' }, // Customize the ERROR icon here, see: https://docs.ntfy.sh/emojis/
warn: { priority: 'high', tags: 'warning' }, // Customize the WARN icon here, see: https://docs.ntfy.sh/emojis/
log: { priority: 'default', tags: 'medal_sports' } // Customize the LOG icon here, see: https://docs.ntfy.sh/emojis/
}
export async function Ntfy(message: string, type: keyof typeof NOTIFICATION_TYPES = 'log'): Promise<void> {
const config = loadConfig().ntfy
if (!config?.enabled || !config.url || !config.topic) return
try {
const { priority, tags } = NOTIFICATION_TYPES[type]
const headers = {
Title: 'Microsoft Rewards Script',
Priority: priority,
Tags: tags,
...(config.authToken && { Authorization: `Bearer ${config.authToken}` })
}
await axios.post(`${config.url}/${config.topic}`, message, { headers })
} catch (error) {
// Silently fail - NTFY is a non-critical notification service
}
}

View File

@@ -0,0 +1,340 @@
import axios from 'axios'
export interface QuerySource {
name: string
weight: number // 0-1, probability of selection
fetchQueries: () => Promise<string[]>
}
export interface QueryDiversityConfig {
sources: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>
deduplicate: boolean
mixStrategies: boolean // Mix different source types in same session
maxQueriesPerSource: number
cacheMinutes: number
}
/**
* QueryDiversityEngine fetches search queries from multiple sources to avoid patterns.
* Supports Google Trends, Reddit, News APIs, Wikipedia, and local fallbacks.
*/
export class QueryDiversityEngine {
private config: QueryDiversityConfig
private cache: Map<string, { queries: string[]; expires: number }> = new Map()
constructor(config?: Partial<QueryDiversityConfig>) {
this.config = {
sources: config?.sources || ['google-trends', 'reddit', 'local-fallback'],
deduplicate: config?.deduplicate !== false,
mixStrategies: config?.mixStrategies !== false,
maxQueriesPerSource: config?.maxQueriesPerSource || 10,
cacheMinutes: config?.cacheMinutes || 30
}
}
/**
* Fetch diverse queries from configured sources
*/
async fetchQueries(count: number): Promise<string[]> {
const allQueries: string[] = []
for (const sourceName of this.config.sources) {
try {
const queries = await this.getFromSource(sourceName)
allQueries.push(...queries.slice(0, this.config.maxQueriesPerSource))
} catch (error) {
// Silently fail and try other sources
}
}
// Deduplicate
let final = this.config.deduplicate ? Array.from(new Set(allQueries)) : allQueries
// Mix strategies: interleave queries from different sources
if (this.config.mixStrategies && this.config.sources.length > 1) {
final = this.interleaveQueries(final, count)
}
// Shuffle and limit to requested count
final = this.shuffleArray(final).slice(0, count)
return final.length > 0 ? final : this.getLocalFallback(count)
}
/**
* Fetch from a specific source with caching
*/
private async getFromSource(source: string): Promise<string[]> {
const cached = this.cache.get(source)
if (cached && Date.now() < cached.expires) {
return cached.queries
}
let queries: string[] = []
switch (source) {
case 'google-trends':
queries = await this.fetchGoogleTrends()
break
case 'reddit':
queries = await this.fetchReddit()
break
case 'news':
queries = await this.fetchNews()
break
case 'wikipedia':
queries = await this.fetchWikipedia()
break
case 'local-fallback':
queries = this.getLocalFallback(20)
break
default:
// Unknown source, skip silently
break
}
this.cache.set(source, {
queries,
expires: Date.now() + (this.config.cacheMinutes * 60000)
})
return queries
}
/**
* Fetch from Google Trends (existing logic can be reused)
*/
private async fetchGoogleTrends(): Promise<string[]> {
try {
const response = await axios.get('https://trends.google.com/trends/api/dailytrends?geo=US', {
timeout: 10000
})
const data = response.data.toString().replace(')]}\',', '')
const parsed = JSON.parse(data)
const queries: string[] = []
for (const item of parsed.default.trendingSearchesDays || []) {
for (const search of item.trendingSearches || []) {
if (search.title?.query) {
queries.push(search.title.query)
}
}
}
return queries.slice(0, 20)
} catch {
return []
}
}
/**
* Fetch from Reddit (top posts from popular subreddits)
*/
private async fetchReddit(): Promise<string[]> {
try {
const subreddits = ['news', 'worldnews', 'todayilearned', 'askreddit', 'technology']
const randomSub = subreddits[Math.floor(Math.random() * subreddits.length)]
const response = await axios.get(`https://www.reddit.com/r/${randomSub}/hot.json?limit=15`, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
const posts = response.data.data.children || []
const queries: string[] = []
for (const post of posts) {
const title = post.data?.title
if (title && title.length > 10 && title.length < 100) {
queries.push(title)
}
}
return queries
} catch {
return []
}
}
/**
* Fetch from News API (requires API key - fallback to headlines scraping)
*/
private async fetchNews(): Promise<string[]> {
try {
// Using NewsAPI.org free tier (limited requests)
const apiKey = process.env.NEWS_API_KEY
if (!apiKey) {
return this.fetchNewsFallback()
}
const response = await axios.get('https://newsapi.org/v2/top-headlines', {
params: {
country: 'us',
pageSize: 15,
apiKey
},
timeout: 10000
})
const articles = response.data.articles || []
return articles.map((a: { title?: string }) => a.title).filter((t: string | undefined) => t && t.length > 10)
} catch {
return this.fetchNewsFallback()
}
}
/**
* Fallback news scraper (BBC/CNN headlines)
*/
private async fetchNewsFallback(): Promise<string[]> {
try {
const response = await axios.get('https://www.bbc.com/news', {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
const html = response.data
const regex = /<h3[^>]*>(.*?)<\/h3>/gi
const matches: RegExpMatchArray[] = []
let match
while ((match = regex.exec(html)) !== null) {
matches.push(match)
}
return matches
.map(m => m[1]?.replace(/<[^>]+>/g, '').trim())
.filter((t: string | undefined) => t && t.length > 10 && t.length < 100)
.slice(0, 10) as string[]
} catch {
return []
}
}
/**
* Fetch from Wikipedia (featured articles / trending topics)
*/
private async fetchWikipedia(): Promise<string[]> {
try {
const response = await axios.get('https://en.wikipedia.org/w/api.php', {
params: {
action: 'query',
list: 'random',
rnnamespace: 0,
rnlimit: 15,
format: 'json'
},
timeout: 10000
})
const pages = response.data.query?.random || []
return pages.map((p: { title?: string }) => p.title).filter((t: string | undefined) => t && t.length > 3)
} catch {
return []
}
}
/**
* Local fallback queries (curated list)
*/
private getLocalFallback(count: number): string[] {
const fallback = [
'weather forecast',
'news today',
'stock market',
'sports scores',
'movie reviews',
'recipes',
'travel destinations',
'health tips',
'technology news',
'best restaurants near me',
'how to cook pasta',
'python tutorial',
'world events',
'climate change',
'electric vehicles',
'space exploration',
'artificial intelligence',
'cryptocurrency',
'gaming news',
'fashion trends',
'fitness workout',
'home improvement',
'gardening tips',
'pet care',
'book recommendations',
'music charts',
'streaming shows',
'historical events',
'science discoveries',
'education resources'
]
return this.shuffleArray(fallback).slice(0, count)
}
/**
* Interleave queries from different sources for diversity
*/
private interleaveQueries(queries: string[], targetCount: number): string[] {
const result: string[] = []
const sourceMap = new Map<string, string[]>()
// Group queries by estimated source (simple heuristic)
for (const q of queries) {
const source = this.guessSource(q)
if (!sourceMap.has(source)) {
sourceMap.set(source, [])
}
sourceMap.get(source)?.push(q)
}
const sources = Array.from(sourceMap.values())
let index = 0
while (result.length < targetCount && sources.some(s => s.length > 0)) {
const source = sources[index % sources.length]
if (source && source.length > 0) {
const q = source.shift()
if (q) result.push(q)
}
index++
}
return result
}
/**
* Guess which source a query came from (basic heuristic)
*/
private guessSource(query: string): string {
if (/^[A-Z]/.test(query) && query.includes(' ')) return 'news'
if (query.length > 80) return 'reddit'
if (/how to|what is|why/i.test(query)) return 'local'
return 'trends'
}
/**
* Shuffle array (Fisher-Yates)
*/
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
/**
* Clear cache (call between runs)
*/
clearCache(): void {
this.cache.clear()
}
}

63
src/util/Retry.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { ConfigRetryPolicy } from '../interface/Config'
import Util from './Utils'
type NumericPolicy = {
maxAttempts: number
baseDelay: number
maxDelay: number
multiplier: number
jitter: number
}
export type Retryable<T> = () => Promise<T>
export class Retry {
private policy: NumericPolicy
constructor(policy?: ConfigRetryPolicy) {
const def: NumericPolicy = {
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 30000,
multiplier: 2,
jitter: 0.2
}
const merged: ConfigRetryPolicy = { ...(policy || {}) }
// normalize string durations
const util = new Util()
const parse = (v: number | string) => {
if (typeof v === 'number') return v
try { return util.stringToMs(String(v)) } catch { return def.baseDelay }
}
this.policy = {
maxAttempts: (merged.maxAttempts as number) ?? def.maxAttempts,
baseDelay: parse(merged.baseDelay ?? def.baseDelay),
maxDelay: parse(merged.maxDelay ?? def.maxDelay),
multiplier: (merged.multiplier as number) ?? def.multiplier,
jitter: (merged.jitter as number) ?? def.jitter
}
}
async run<T>(fn: Retryable<T>, isRetryable?: (e: unknown) => boolean): Promise<T> {
let attempt = 0
let delay = this.policy.baseDelay
let lastErr: unknown
while (attempt < this.policy.maxAttempts) {
try {
return await fn()
} catch (e) {
lastErr = e
attempt += 1
const retry = isRetryable ? isRetryable(e) : true
if (!retry || attempt >= this.policy.maxAttempts) break
const jitter = 1 + (Math.random() * 2 - 1) * this.policy.jitter
const sleep = Math.min(this.policy.maxDelay, Math.max(0, Math.floor(delay * jitter)))
await new Promise((r) => setTimeout(r, sleep))
delay = Math.min(this.policy.maxDelay, Math.floor(delay * (this.policy.multiplier || 2)))
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
}
export default Retry

84
src/util/Totp.ts Normal file
View File

@@ -0,0 +1,84 @@
import crypto from 'crypto'
/**
* Decode Base32 (RFC 4648) to a Buffer.
* Accepts lowercase/uppercase, optional padding.
*/
function base32Decode(input: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const clean = input.toUpperCase().replace(/=+$/g, '').replace(/[^A-Z2-7]/g, '')
let bits = 0
let value = 0
const bytes: number[] = []
for (const char of clean) {
const idx = alphabet.indexOf(char)
if (idx < 0) continue
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
bits -= 8
bytes.push((value >>> bits) & 0xff)
}
}
return Buffer.from(bytes)
}
/**
* Generate an HMAC using Node's crypto and return Buffer.
*/
function hmac(algorithm: string, key: Buffer, data: Buffer): Buffer {
return crypto.createHmac(algorithm, key).update(data).digest()
}
export type TotpOptions = { digits?: number; step?: number; algorithm?: 'SHA1' | 'SHA256' | 'SHA512' }
/**
* Generate TOTP per RFC 6238.
* @param secretBase32 - shared secret in Base32
* @param time - Unix time in seconds (defaults to now)
* @param options - { digits, step, algorithm }
* @returns numeric TOTP as string (zero-padded)
*/
export function generateTOTP(
secretBase32: string,
time: number = Math.floor(Date.now() / 1000),
options?: TotpOptions
): string {
const digits = options?.digits ?? 6
const step = options?.step ?? 30
const alg = (options?.algorithm ?? 'SHA1').toUpperCase()
const key = base32Decode(secretBase32)
const counter = Math.floor(time / step)
// 8-byte big-endian counter
const counterBuffer = Buffer.alloc(8)
counterBuffer.writeBigUInt64BE(BigInt(counter), 0)
let hmacAlg: string
if (alg === 'SHA1') hmacAlg = 'sha1'
else if (alg === 'SHA256') hmacAlg = 'sha256'
else if (alg === 'SHA512') hmacAlg = 'sha512'
else throw new Error('Unsupported algorithm. Use SHA1, SHA256 or SHA512.')
const hash = hmac(hmacAlg, key, counterBuffer)
if (!hash || hash.length < 20) {
// Minimal sanity check; for SHA1 length is 20
throw new Error('Invalid HMAC output for TOTP')
}
// Dynamic truncation
const offset = hash[hash.length - 1]! & 0x0f
if (offset + 3 >= hash.length) {
throw new Error('Invalid dynamic truncation offset')
}
const code =
((hash[offset]! & 0x7f) << 24) |
((hash[offset + 1]! & 0xff) << 16) |
((hash[offset + 2]! & 0xff) << 8) |
(hash[offset + 3]! & 0xff)
const otp = (code % 10 ** digits).toString().padStart(digits, '0')
return otp
}

View File

@@ -1,42 +1,58 @@
import axios from 'axios'
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
import { log } from './Logger'
import Retry from './Retry'
import { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil'
import { ChromeVersion, EdgeVersion, Architecture, Platform } from '../interface/UserAgentUtil'
export async function getUserAgent(mobile: boolean) {
const system = getSystemComponents(mobile)
const app = await getAppComponents(mobile)
const NOT_A_BRAND_VERSION = '99'
const EDGE_VERSION_URL = 'https://edgeupdates.microsoft.com/api/products'
const EDGE_VERSION_CACHE_TTL_MS = 1000 * 60 * 60
const uaTemplate = mobile ?
type EdgeVersionResult = {
android?: string
windows?: string
}
let edgeVersionCache: { data: EdgeVersionResult; expiresAt: number } | null = null
let edgeVersionInFlight: Promise<EdgeVersionResult> | null = null
export async function getUserAgent(isMobile: boolean) {
const system = getSystemComponents(isMobile)
const app = await getAppComponents(isMobile)
const uaTemplate = isMobile ?
`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Mobile Safari/537.36 EdgA/${app.edge_version}` :
`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Safari/537.36 Edg/${app.edge_version}`
const platformVersion = `${mobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
const uaMetadata = {
mobile,
platform: mobile ? 'Android' : 'Windows',
mobile: isMobile,
isMobile,
platform: isMobile ? 'Android' : 'Windows',
fullVersionList: [
{ brand: 'Not/A)Brand', version: '99.0.0.0' },
{ brand: 'Not/A)Brand', version: `${NOT_A_BRAND_VERSION}.0.0.0` },
{ brand: 'Microsoft Edge', version: app['edge_version'] },
{ brand: 'Chromium', version: app['chrome_version'] }
],
brands: [
{ brand: 'Not/A)Brand', version: '99' },
{ brand: 'Not/A)Brand', version: NOT_A_BRAND_VERSION },
{ brand: 'Microsoft Edge', version: app['edge_major_version'] },
{ brand: 'Chromium', version: app['chrome_major_version'] }
],
platformVersion,
architecture: mobile ? '' : 'x86',
bitness: mobile ? '' : '64',
model: ''
architecture: isMobile ? '' : 'x86',
bitness: isMobile ? '' : '64',
model: '',
uaFullVersion: app['chrome_version']
}
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
}
export async function getChromeVersion(): Promise<string> {
export async function getChromeVersion(isMobile: boolean): Promise<string> {
try {
const request = {
url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json',
@@ -51,59 +67,216 @@ export async function getChromeVersion(): Promise<string> {
return data.channels.Stable.version
} catch (error) {
throw log('USERAGENT-CHROME-VERSION', 'An error occurred:' + error, 'error')
throw log(isMobile, 'USERAGENT-CHROME-VERSION', 'An error occurred:' + error, 'error')
}
}
export async function getEdgeVersions() {
try {
const request = {
url: 'https://edgeupdates.microsoft.com/api/products',
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
const response = await axios(request)
const data: EdgeVersion[] = response.data
const stable = data.find(x => x.Product == 'Stable') as EdgeVersion
return {
android: stable.Releases.find(x => x.Platform == 'Android')?.ProductVersion,
windows: stable.Releases.find(x => (x.Platform == 'Windows' && x.Architecture == 'x64'))?.ProductVersion
}
} catch (error) {
throw log('USERAGENT-EDGE-VERSION', 'An error occurred:' + error, 'error')
export async function getEdgeVersions(isMobile: boolean) {
const now = Date.now()
if (edgeVersionCache && edgeVersionCache.expiresAt > now) {
return edgeVersionCache.data
}
if (edgeVersionInFlight) {
try {
return await edgeVersionInFlight
} catch (error) {
if (edgeVersionCache) {
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using cached Edge versions after in-flight failure: ' + formatEdgeError(error), 'warn')
return edgeVersionCache.data
}
throw error
}
}
const fetchPromise = fetchEdgeVersionsWithRetry(isMobile)
.then(result => {
edgeVersionCache = { data: result, expiresAt: Date.now() + EDGE_VERSION_CACHE_TTL_MS }
edgeVersionInFlight = null
return result
})
.catch(error => {
edgeVersionInFlight = null
if (edgeVersionCache) {
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Falling back to cached Edge versions: ' + formatEdgeError(error), 'warn')
return edgeVersionCache.data
}
throw log(isMobile, 'USERAGENT-EDGE-VERSION', 'Failed to fetch Edge versions: ' + formatEdgeError(error), 'error')
})
edgeVersionInFlight = fetchPromise
return fetchPromise
}
export function getSystemComponents(mobile: boolean): string {
const osId: string = mobile ? 'Linux' : 'Windows NT 10.0'
const uaPlatform: string = mobile ? 'Android 13' : 'Win64; x64'
if (mobile) {
return `${uaPlatform}; ${osId}; K`
const androidVersion = 10 + Math.floor(Math.random() * 5)
return `Linux; Android ${androidVersion}; K`
}
return `${uaPlatform}; ${osId}`
return 'Windows NT 10.0; Win64; x64'
}
export async function getAppComponents(mobile: boolean) {
const versions = await getEdgeVersions()
const edgeVersion = mobile ? versions.android : versions.windows as string
export async function getAppComponents(isMobile: boolean) {
const versions = await getEdgeVersions(isMobile)
const edgeVersion = isMobile ? versions.android : versions.windows as string
const edgeMajorVersion = edgeVersion?.split('.')[0]
const chromeVersion = await getChromeVersion()
const chromeVersion = await getChromeVersion(isMobile)
const chromeMajorVersion = chromeVersion?.split('.')[0]
const chromeReducedVersion = `${chromeMajorVersion}.0.0.0`
return {
not_a_brand_version: `${NOT_A_BRAND_VERSION}.0.0.0`,
not_a_brand_major_version: NOT_A_BRAND_VERSION,
edge_version: edgeVersion as string,
edge_major_version: edgeMajorVersion as string,
chrome_version: chromeVersion as string,
chrome_major_version: chromeMajorVersion as string,
chrome_reduced_version: chromeReducedVersion as string
}
}
async function fetchEdgeVersionsWithRetry(isMobile: boolean): Promise<EdgeVersionResult> {
const retry = new Retry()
return retry.run(async () => {
const versions = await fetchEdgeVersionsOnce(isMobile)
if (!versions.android && !versions.windows) {
throw new Error('Stable Edge releases did not include Android or Windows versions')
}
return versions
}, () => true)
}
async function fetchEdgeVersionsOnce(isMobile: boolean): Promise<EdgeVersionResult> {
try {
const response = await axios<EdgeVersion[]>({
url: EDGE_VERSION_URL,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (compatible; rewards-bot/2.1)' // Provide UA to avoid stricter servers
},
timeout: 10000
})
return mapEdgeVersions(response.data)
} catch (primaryError) {
const fallback = await tryNativeFetchFallback(isMobile)
if (fallback) {
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Axios failed, native fetch succeeded: ' + formatEdgeError(primaryError), 'warn')
return fallback
}
throw primaryError
}
}
async function tryNativeFetchFallback(isMobile: boolean): Promise<EdgeVersionResult | null> {
let timeoutHandle: NodeJS.Timeout | undefined
try {
const controller = new AbortController()
timeoutHandle = setTimeout(() => controller.abort(), 10000)
const response = await fetch(EDGE_VERSION_URL, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (compatible; rewards-bot/2.1)'
},
signal: controller.signal
})
clearTimeout(timeoutHandle)
timeoutHandle = undefined
if (!response.ok) {
throw new Error('HTTP ' + response.status)
}
const data = await response.json() as EdgeVersion[]
return mapEdgeVersions(data)
} catch (error) {
if (timeoutHandle) clearTimeout(timeoutHandle)
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Native fetch fallback failed: ' + formatEdgeError(error), 'warn')
return null
}
}
function mapEdgeVersions(data: EdgeVersion[]): EdgeVersionResult {
const stable = data.find(entry => entry.Product.toLowerCase() === 'stable')
?? data.find(entry => /stable/i.test(entry.Product))
if (!stable) {
throw new Error('Stable Edge channel not found in response payload')
}
const androidRelease = stable.Releases.find(release => release.Platform === Platform.Android)
const windowsRelease = stable.Releases.find(release => release.Platform === Platform.Windows && release.Architecture === Architecture.X64)
?? stable.Releases.find(release => release.Platform === Platform.Windows)
return {
android: androidRelease?.ProductVersion,
windows: windowsRelease?.ProductVersion
}
}
function formatEdgeError(error: unknown): string {
if (isAggregateErrorLike(error)) {
const inner = error.errors
.map(innerErr => formatEdgeError(innerErr))
.filter(Boolean)
.join('; ')
const message = error.message || 'AggregateError'
return inner ? `${message} | causes: ${inner}` : message
}
if (error instanceof Error) {
const parts = [`${error.name}: ${error.message}`]
const cause = getErrorCause(error)
if (cause) {
parts.push('cause => ' + formatEdgeError(cause))
}
return parts.join(' | ')
}
return String(error)
}
type AggregateErrorLike = { message?: string; errors: unknown[] }
function isAggregateErrorLike(error: unknown): error is AggregateErrorLike {
if (!error || typeof error !== 'object') {
return false
}
const candidate = error as { errors?: unknown }
return Array.isArray(candidate.errors)
}
function getErrorCause(error: { cause?: unknown } | Error): unknown {
if (typeof (error as { cause?: unknown }).cause === 'undefined') {
return undefined
}
return (error as { cause?: unknown }).cause
}
export async function updateFingerprintUserAgent(fingerprint: BrowserFingerprintWithHeaders, isMobile: boolean): Promise<BrowserFingerprintWithHeaders> {
try {
const userAgentData = await getUserAgent(isMobile)
const componentData = await getAppComponents(isMobile)
fingerprint.fingerprint.navigator.userAgentData = userAgentData.userAgentMetadata
fingerprint.fingerprint.navigator.userAgent = userAgentData.userAgent
fingerprint.fingerprint.navigator.appVersion = userAgentData.userAgent.replace(`${fingerprint.fingerprint.navigator.appCodeName}/`, '')
fingerprint.headers['user-agent'] = userAgentData.userAgent
fingerprint.headers['sec-ch-ua'] = `"Microsoft Edge";v="${componentData.edge_major_version}", "Not=A?Brand";v="${componentData.not_a_brand_major_version}", "Chromium";v="${componentData.chrome_major_version}"`
fingerprint.headers['sec-ch-ua-full-version-list'] = `"Microsoft Edge";v="${componentData.edge_version}", "Not=A?Brand";v="${componentData.not_a_brand_version}", "Chromium";v="${componentData.chrome_version}"`
/*
Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0
sec-ch-ua-full-version-list: "Microsoft Edge";v="129.0.2792.84", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90"
sec-ch-ua: "Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
"Google Chrome";v="129.0.6668.90", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90"
*/
return fingerprint
} catch (error) {
throw log(isMobile, 'USER-AGENT-UPDATE', 'An error occurred:' + error, 'error')
}
}

View File

@@ -1,11 +1,26 @@
import ms from 'ms'
export default class Util {
async wait(ms: number): Promise<void> {
// Safety check: prevent extremely long or negative waits
const MAX_WAIT_MS = 3600000 // 1 hour max
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
if (ms !== safeMs) {
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`)
}
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
setTimeout(resolve, safeMs)
})
}
async waitRandom(minMs: number, maxMs: number): Promise<void> {
const delta = this.randomNumber(minMs, maxMs)
return this.wait(delta)
}
getFormattedDate(ms = Date.now()): string {
const today = new Date(ms)
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
@@ -16,11 +31,9 @@ export default class Util {
}
shuffleArray<T>(array: T[]): T[] {
const shuffledArray = array.slice()
shuffledArray.sort(() => Math.random() - 0.5)
return shuffledArray
return array.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
randomNumber(min: number, max: number): number {
@@ -28,7 +41,17 @@ export default class Util {
}
chunkArray<T>(arr: T[], numChunks: number): T[][] {
const chunkSize = Math.ceil(arr.length / numChunks)
// Validate input to prevent division by zero or invalid chunks
if (numChunks <= 0) {
throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`)
}
if (arr.length === 0) {
return []
}
const safeNumChunks = Math.max(1, Math.floor(numChunks))
const chunkSize = Math.ceil(arr.length / safeNumChunks)
const chunks: T[][] = []
for (let i = 0; i < arr.length; i += chunkSize) {
@@ -39,4 +62,12 @@ export default class Util {
return chunks
}
stringToMs(input: string | number): number {
const milisec = ms(input.toString())
if (!milisec) {
throw new Error('The string provided cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"')
}
return milisec
}
}

View File

@@ -1,23 +0,0 @@
import axios from 'axios'
import { loadConfig } from './Load'
export async function Webhook(content: string) {
const webhook = loadConfig().webhook
if (!webhook.enabled || webhook.url.length < 10) return
const request = {
method: 'POST',
url: webhook.url,
headers: {
'Content-Type': 'application/json'
},
data: {
'content': content
}
}
await axios(request).catch(() => { })
}

View File

@@ -39,7 +39,9 @@
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"moduleResolution":"node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"types": ["node"],
// Keep explicit typeRoots to ensure resolution in environments that don't auto-detect before full install.
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
@@ -63,9 +65,9 @@
"resolveJsonModule": true
},
"include": [
"src/**/*.ts",
"src/accounts.json",
"src/config.json"
"src/**/*.ts",
"src/**/*.d.ts",
"src/functions/queries.json"
],
"exclude": [
"node_modules"