diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..cb3673f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2024-2025 TheNetsky and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..bbfd3d6
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,128 @@
+# IMPORTANT LEGAL NOTICES
+
+## šØ Terms of Service Violation Warning
+
+**Using this software violates Microsoft's Terms of Service.**
+
+Microsoft Rewards explicitly prohibits:
+- Automated point collection
+- Bot usage for completing tasks
+- Any form of automation on their platform
+
+### Potential Consequences:
+- ā **Immediate account suspension**
+- ā **Permanent ban from Microsoft Rewards**
+- ā **Forfeiture of all accumulated points**
+- ā **Loss of redemption history**
+- ā ļø Possible restrictions on other Microsoft services
+
+---
+
+## š¢ Commercial Use Prohibited
+
+This software is licensed for **PERSONAL, NON-COMMERCIAL USE ONLY**.
+
+### ā Prohibited Activities:
+- Selling this software or modifications
+- Offering it as a paid service
+- Using it for business purposes
+- Creating commercial automation services
+- Bulk account management for profit
+- Integration into commercial platforms
+
+### ā
Permitted Activities:
+- Personal use for your own accounts
+- Educational purposes and learning
+- Private modifications for personal use
+- Sharing with family/friends (non-commercial)
+
+---
+
+## āļø Legal Disclaimer
+
+### No Warranty
+This software is provided "AS IS" without any warranty of any kind.
+
+### No Liability
+The authors and contributors:
+- Are NOT responsible for account suspensions
+- Are NOT responsible for lost points or rewards
+- Are NOT responsible for any damages
+- Do NOT encourage ToS violations
+- Provide this for educational purposes ONLY
+
+### Your Responsibility
+You are solely responsible for:
+- Your use of this software
+- Compliance with Microsoft's policies
+- Any consequences from automation
+- Legal implications in your jurisdiction
+
+---
+
+## š Educational Purpose Statement
+
+This project is developed and maintained for **educational purposes**:
+- To demonstrate browser automation techniques
+- To showcase TypeScript and Playwright capabilities
+- To teach software architecture patterns
+- To explore anti-detection methodologies
+
+**The authors do not condone using this software in violation of any Terms of Service.**
+
+---
+
+## š Privacy & Security
+
+### Your Data:
+- This software stores credentials **locally only**
+- No data is sent to third parties
+- Sessions are stored in the `sessions/` folder
+- You can delete all data by removing local files
+
+### Third-Party Services:
+- Google Trends (for search queries)
+- Bing Search (for automation)
+- Discord/NTFY (optional, for notifications)
+
+### Your Responsibility:
+- Protect your `accounts.json` file
+- Use strong passwords
+- Enable 2FA where possible
+- Don't share your configuration publicly
+
+---
+
+## š Geographic Restrictions
+
+Microsoft Rewards availability and terms vary by region:
+- Available in select countries only
+- Region-specific earning rates
+- Local laws may apply
+- Check your local regulations
+
+**By using this software, you confirm:**
+1. Microsoft Rewards is available in your region
+2. You understand the risks of automation
+3. You accept full responsibility for your actions
+4. You will not use this for commercial purposes
+
+---
+
+## š Contact & Reporting
+
+**Questions about licensing?**
+Open an issue at: https://github.com/TheNetsky/Microsoft-Rewards-Script/issues
+
+**Found a security issue?**
+See: SECURITY.md
+
+**General discussion?**
+Join Discord: https://discord.gg/KRBFxxsU
+
+---
+
+**Last Updated:** October 2025
+**Applies to:** Microsoft Rewards Script V2.1.5 and later
+
+**BY USING THIS SOFTWARE, YOU ACKNOWLEDGE THAT YOU HAVE READ AND UNDERSTOOD THESE NOTICES.**
diff --git a/README.md b/README.md
index a641fcb..52b8815 100644
--- a/README.md
+++ b/README.md
@@ -1,238 +1,481 @@
-# šÆ Microsoft Rewards Script V2
-
-```
- āāāā āāāāāāāāāāāā āāāāāāā āāāāāāāāāāā āāā āāāāāā āāāāāāā āāāāāāā āāāāāāāā
- āāāāā āāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāā āāā āā āāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāā
- āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāā
- āāā āāā āāāāāāāāāāā āāā āāāāāāāāāāāāāāāāāāāāāāāā āāāāāā āāāāāāāāāāāāāāāāāāā
- āāā āāāāāāāāāāā āāā āāāāāāāāāāā āāāāāāāā āāā āāāāāā āāāāāāāāāā āāāāāāāā
-```
-
-**š¤ Intelligent automation meets Microsoft Rewards**
-*Earn points effortlessly while you sleep*
-[Legacy-1.5.3](https://github.com/LightZirconite/Microsoft-Rewards-Script-Private/tree/Legacy-1.5.3)
-
-[](https://www.typescriptlang.org/)
-[](https://nodejs.org/)
-[](https://www.docker.com/)
-[](https://playwright.dev/)
-
-
-
-
-

-

+
+
----
+
-## š **Big Update Alert ā V2 is here!**
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā WHAT DOES THIS DO? ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+
+
+**Automate your Microsoft Rewards daily activities with intelligent browser automation.**
+Complete searches, quizzes, and promotions automatically while mimicking natural human behavior.
+
+
+
+### **Daily Earnings Breakdown**
+
+| šÆ Activity | š Points | ā±ļø Time |
+|:-----------|:---------|:--------|
+| **Desktop Searches** | ~90 pts | 30 sec |
+| **Mobile Searches** | ~60 pts | 20 sec |
+| **Daily Set Tasks** | ~30-50 pts | 1-2 min |
+| **Promotions & Punch Cards** | Variable | 30s-2min |
+| **š TOTAL AVERAGE** | **150-300+ pts** | **3-5 min** |
+
+
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā QUICK START ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+### **š Automated Setup** (Recommended)
+
+```bash
+# Windows
+setup\setup.bat
+
+# Linux / macOS / WSL
+bash setup/setup.sh
+
+# Universal
+npm run setup
+```
+
+**The wizard handles everything:**
+- ā
Creates `accounts.json` with your credentials
+- ā
Installs dependencies & builds project
+- ā
Runs first automation (optional)
+
+
+
+### **š ļø Manual Setup**
+
+```bash
+# 1. Clone repository
+git clone -b v2 https://github.com/TheNetsky/Microsoft-Rewards-Script.git
+cd Microsoft-Rewards-Script
+
+# 2. Configure accounts
+cp src/accounts.example.json src/accounts.json
+# Edit accounts.json with your Microsoft credentials
+
+# 3. Install & build
+npm i
+
+# 4. Run automation
+npm start
+```
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā INTELLIGENT FEATURES ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
-
-
-TheNetsky š
-Foundation Architect
-Building the massive foundation
+ |
+
+### š”ļø **Risk-Aware System**
+```
+Real-time threat detection
+āā Monitors captchas & errors
+āā Dynamic delay adjustment (1xā4x)
+āā Automatic cool-down periods
+āā ML-based ban prediction
+```
+
+### š **Performance Analytics**
+```
+Track everything
+āā Points earned per day
+āā Success/failure rates
+āā Historical trends
+āā Account health monitoring
+```
+
|
-
-
-Mgrimace š„
-Active Developer
-Regular updates & NTFY mode
- |
-
-
-Light āØ
-V2 Mastermind
-Massive feature overhaul
+ |
+
+### š **Query Diversity Engine**
+```
+Natural search patterns
+āā Multi-source queries
+āā Pattern breaking algorithms
+āā Smart deduplication
+āā Reduced detection risk
+```
+
+### ā
**Config Validator**
+```
+Pre-flight checks
+āā Detects common mistakes
+āā Security warnings
+āā Optimization suggestions
+āā Dry-run test mode
+```
+
|
-**š” Welcome to V2 ā There are honestly so many changes that even I can't list them all!**
-*Trust me, you've got a **massive upgrade** in front of you. Enjoy the ride!* š¢
+
-
-
----
-
-## šÆ **What Does This Script Do?**
-
-
-
-**Automatically earn Microsoft Rewards points by completing daily tasks:**
-- š **Daily Searches** ā Desktop & Mobile Bing searches
-- š
**Daily Set** ā Complete daily quizzes and activities
-- š **Promotions** ā Bonus point opportunities
-- š **Punch Cards** ā Multi-day reward challenges
-- ā
**Daily Check-in** ā Simple daily login rewards
-- š **Read to Earn** ā News article reading points
-
-*All done automatically while you sleep! š¤*
-
-
-
----
-
-## ā” Quick Start
-
-```bash
-# šŖ Windows ā One command setup
-setup/setup.bat
-
-# š§ Linux/macOS/WSL
-bash setup/setup.sh
-
-# š Any platform
-npm run setup
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā USAGE COMMANDS ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
```
-**That's it!** The setup wizard configures accounts, installs dependencies, builds the project, and starts earning points.
-
-
-š Manual Setup
-
```bash
-# 1ļøā£ Configure your Microsoft accounts
-cp src/accounts.example.json src/accounts.json
-# Edit accounts.json with your credentials
-
-# 2ļøā£ Install & Build
-npm install && npm run build
-
-# 3ļøā£ Run once or start scheduler
-npm start # Single run
-npm run start:schedule # Automated daily runs
-```
-
-
-
----
-
-## š Documentation
-
-| Topic | Description |
-|-------|-------------|
-| **[š Getting Started](./docs/getting-started.md)** | Complete setup guide from zero to running |
-| **[š¤ Accounts & 2FA](./docs/accounts.md)** | Microsoft account setup + TOTP authentication |
-| **[š³ Docker](./docs/docker.md)** | Containerized deployment with slim headless image |
-| **[ā° Scheduling](./docs/schedule.md)** | Automated daily runs with built-in scheduler |
-| **[š ļø Diagnostics](./docs/diagnostics.md)** | Troubleshooting, error capture, and logs |
-| **[āļø Configuration](./docs/config.md)** | Full config.json reference |
-
-**[š Full Documentation Index ā](./docs/index.md)**
-
-## š® Commands
-
-```bash
-# š Run the automation once
+# Run automation once
npm start
-# ļæ½ Start automated daily scheduler
+# Daily automated scheduler
npm run start:schedule
-# š³ Manual points redemption mode
+# Manual redemption mode (monitor points while shopping)
npm start -- -buy your@email.com
-# ļæ½ Deploy with Docker
+# Docker deployment
docker compose up -d
-# ļæ½ Development mode
-npm run dev
+# Test configuration without executing
+npm start -- --dry-run
```
----
+
-## ⨠Key Features
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā CONFIGURATION ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+Edit `src/config.jsonc` to customize behavior:
+
+```jsonc
+{
+ "browser": {
+ "headless": false // Set true for background operation
+ },
+ "execution": {
+ "parallel": false, // Run desktop + mobile simultaneously
+ "runOnZeroPoints": false, // Skip when no points available
+ "clusters": 1 // Parallel account processes
+ },
+ "workers": {
+ "doDailySet": true,
+ "doDesktopSearch": true,
+ "doMobileSearch": true,
+ "doPunchCards": true
+ },
+ "humanization": {
+ "enabled": true, // Natural human-like delays
+ "actionDelay": { "min": 500, "max": 2200 },
+ "randomOffDaysPerWeek": 1 // Skip random days naturally
+ }
+}
+```
+
+**[š Complete Configuration Guide ā](./docs/config.md)**
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā CORE FEATURES ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
----
+
-## š Advanced Features
-
-**[š³ Buy Mode](./docs/buy-mode.md)** ā Manual redemption with live points monitoring
-**[š§ Humanization](./docs/humanization.md)** ā Advanced anti-detection patterns
-**[š Diagnostics](./docs/diagnostics.md)** ā Error capture with screenshots/HTML
-**[š Webhooks](./docs/conclusionwebhook.md)** ā Rich Discord notifications
-**[š± NTFY](./docs/ntfy.md)** ā Push notifications to your phone
-
----
-
-## š Documentation & Support
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā DOCUMENTATION ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
-**š [Complete Documentation Index](./docs/index.md)**
+| š Getting Started | āļø Configuration | š Monitoring |
+|:------------------|:----------------|:-------------|
+| [Installation & Setup](./docs/getting-started.md) | [Config Guide](./docs/config.md) | [Notifications](./docs/ntfy.md) |
+| [Accounts Setup](./docs/accounts.md) | [Scheduler](./docs/schedule.md) | [Diagnostics](./docs/diagnostics.md) |
+| [Docker Deployment](./docs/docker.md) | [Humanization](./docs/humanization.md) | [Buy Mode](./docs/buy-mode.md) |
+| | [Proxy Configuration](./docs/proxy.md) | |
+
+**[š Complete Documentation Index ā](./docs/index.md)**
-### Essential Guides
-- **[Getting Started](./docs/getting-started.md)** ā Zero to running in minutes
-- **[Accounts Setup](./docs/accounts.md)** ā Microsoft accounts + 2FA configuration
-- **[Docker Guide](./docs/docker.md)** ā Container deployment
-- **[Scheduling](./docs/schedule.md)** ā Automated daily runs
-- **[Troubleshooting](./docs/diagnostics.md)** ā Fix common issues
+
-### Advanced Topics
-- **[Humanization](./docs/humanization.md)** ā Anti-detection features
-- **[Notifications](./docs/ntfy.md)** ā Push alerts & Discord webhooks
-- **[Proxy Setup](./docs/proxy.md)** ā Network configuration
-- **[Buy Mode](./docs/buy-mode.md)** ā Manual redemption tracking
-
----
-
-## š¤ Community
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā TECHNICAL ARCHITECTURE ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
-[](https://discord.gg/KRBFxxsU)
-[](https://github.com/TheNetsky/Microsoft-Rewards-Script)
+**Built with Modern Technologies**
-**Found a bug?** [Report an issue](https://github.com/TheNetsky/Microsoft-Rewards-Script/issues)
-**Have suggestions?** [Start a discussion](https://github.com/TheNetsky/Microsoft-Rewards-Script/discussions)
+
+
+
----
+
+
+**Core Modules:**
+
+| Module | Purpose |
+|--------|---------|
+| `Login.ts` | Microsoft authentication flow with 2FA/TOTP support |
+| `Workers.ts` | Completes Daily Set, Promotions, and Punch Cards |
+| `Search.ts` | Desktop/mobile Bing searches with natural query variations |
+| `Activities.ts` | Routes to specific activity handlers (Quiz, Poll, etc.) |
+| `activities/*.ts` | Individual handlers for each reward type |
+
+**Key Technologies:**
+- [Playwright](https://playwright.dev/) ā Browser automation framework
+- [Rebrowser](https://github.com/rebrowser/rebrowser-playwright) ā Anti-fingerprinting extensions
+- [fingerprint-generator](https://www.npmjs.com/package/fingerprint-generator) ā Device consistency
+- [Cheerio](https://cheerio.js.org/) ā Fast HTML parsing
+- [Luxon](https://moment.github.io/luxon/) ā Modern date/time handling
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā IMPORTANT DISCLAIMERS ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
-## ā ļø Disclaimer
+### ā ļø **USE AT YOUR OWN RISK** ā ļø
-This project is for educational purposes only. Use at your own risk. Microsoft may suspend accounts that use automation tools. The authors are not responsible for any account actions taken by Microsoft.
+**Using automation violates Microsoft's Terms of Service.**
+Accounts may be **suspended or permanently banned**.
-**šÆ Contributors**
+This project is for **educational purposes only**.
+
+
+
+
+
+**Best Practices:**
+
+ā
**DO:**
+- Use 2FA/TOTP for security
+- Enable humanization features
+- Schedule 1-2x daily maximum
+- Set `runOnZeroPoints: false`
+- Test on secondary accounts first
+- Monitor diagnostics regularly
+
+ā **DON'T:**
+- Run on your main account
+- Schedule hourly runs
+- Ignore security warnings
+- Use shared proxies
+- Skip configuration validation
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā CONTRIBUTORS ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+
+
+### **Core Development Team**
+
+
+
+
+
+### **All Contributors**
-
+
-*Made with ā¤ļø by the community ⢠Happy automating! š*
-
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā COMMUNITY & SUPPORT ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+
+
+### **Need Help? Found a Bug?**
+
+**Join our Discord community ā we're here to help!**
+
+
+
+[](https://discord.gg/KRBFxxsU)
+
+
+
+**For bug reports and feature requests, please use Discord first.**
+GitHub Issues are also available for documentation and tracking.
+
+
+
+[](https://github.com/TheNetsky/Microsoft-Rewards-Script/issues)
+
+
+
+
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā LICENSE ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+
+
+**ISC License** ā Free and open source
+
+See [LICENSE](./LICENSE) for details ⢠[NOTICE](./NOTICE) for disclaimers
+
+
+
---
+
+
+**ā Star this repo if you found it useful! ā**
+
+
+
+
+
+
+
+**Made with ā¤ļø by the open source community**
+
+
+

+
+
+
+

+
+
diff --git a/docs/config-presets/balanced.json b/docs/config-presets/balanced.json
deleted file mode 100644
index 3048b5c..0000000
--- a/docs/config-presets/balanced.json
+++ /dev/null
@@ -1,147 +0,0 @@
-{
- // Base URL for Rewards dashboard and APIs
- "baseURL": "https://rewards.bing.com",
- // Where to store sessions (cookies, fingerprints)
- "sessionPath": "sessions",
-
- "browser": {
- // Headless mode is more stable on shared servers
- "headless": true,
- // Use short notation for readability
- "globalTimeout": "30s"
- },
-
- "execution": {
- "parallel": false,
- "runOnZeroPoints": false,
- "clusters": 1,
- "passesPerRun": 1
- },
-
- "buyMode": {
- "maxMinutes": 45
- },
-
- "fingerprinting": {
- "saveFingerprint": {
- "mobile": true,
- "desktop": true
- }
- },
-
- "search": {
- "useLocalQueries": true,
- "settings": {
- "useGeoLocaleQueries": true,
- "scrollRandomResults": true,
- "clickRandomResults": true,
- "retryMobileSearchAmount": 2,
- "delay": {
- "min": "8s",
- "max": "22s"
- }
- }
- },
-
- "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
- },
-
- "retryPolicy": {
- "maxAttempts": 3,
- "baseDelay": 1000,
- "maxDelay": "30s",
- "multiplier": 2,
- "jitter": 0.2
- },
-
- "workers": {
- "doDailySet": true,
- "doMorePromotions": true,
- "doPunchCards": true,
- "doDesktopSearch": true,
- "doMobileSearch": true,
- "doDailyCheckIn": true,
- "doReadToEarn": true,
- "bundleDailySetWithSearch": true
- },
-
- "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
- },
-
- "diagnostics": {
- "enabled": true,
- "saveScreenshot": true,
- "saveHtml": true,
- "maxPerRun": 2,
- "retentionDays": 7
- },
-
- "jobState": {
- "enabled": true,
- "dir": ""
- },
-
- "schedule": {
- "enabled": false,
- "useAmPm": false,
- "time12": "9:00 AM",
- "time24": "09:00",
- "timeZone": "America/New_York",
- "runImmediatelyOnStart": false
- },
-
- "update": {
- "git": true,
- "docker": false,
- "scriptPath": "setup/update/update.mjs"
- }
-}
diff --git a/docs/config-presets/balanced.jsonc b/docs/config-presets/balanced.jsonc
deleted file mode 100644
index 3048b5c..0000000
--- a/docs/config-presets/balanced.jsonc
+++ /dev/null
@@ -1,147 +0,0 @@
-{
- // Base URL for Rewards dashboard and APIs
- "baseURL": "https://rewards.bing.com",
- // Where to store sessions (cookies, fingerprints)
- "sessionPath": "sessions",
-
- "browser": {
- // Headless mode is more stable on shared servers
- "headless": true,
- // Use short notation for readability
- "globalTimeout": "30s"
- },
-
- "execution": {
- "parallel": false,
- "runOnZeroPoints": false,
- "clusters": 1,
- "passesPerRun": 1
- },
-
- "buyMode": {
- "maxMinutes": 45
- },
-
- "fingerprinting": {
- "saveFingerprint": {
- "mobile": true,
- "desktop": true
- }
- },
-
- "search": {
- "useLocalQueries": true,
- "settings": {
- "useGeoLocaleQueries": true,
- "scrollRandomResults": true,
- "clickRandomResults": true,
- "retryMobileSearchAmount": 2,
- "delay": {
- "min": "8s",
- "max": "22s"
- }
- }
- },
-
- "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
- },
-
- "retryPolicy": {
- "maxAttempts": 3,
- "baseDelay": 1000,
- "maxDelay": "30s",
- "multiplier": 2,
- "jitter": 0.2
- },
-
- "workers": {
- "doDailySet": true,
- "doMorePromotions": true,
- "doPunchCards": true,
- "doDesktopSearch": true,
- "doMobileSearch": true,
- "doDailyCheckIn": true,
- "doReadToEarn": true,
- "bundleDailySetWithSearch": true
- },
-
- "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
- },
-
- "diagnostics": {
- "enabled": true,
- "saveScreenshot": true,
- "saveHtml": true,
- "maxPerRun": 2,
- "retentionDays": 7
- },
-
- "jobState": {
- "enabled": true,
- "dir": ""
- },
-
- "schedule": {
- "enabled": false,
- "useAmPm": false,
- "time12": "9:00 AM",
- "time24": "09:00",
- "timeZone": "America/New_York",
- "runImmediatelyOnStart": false
- },
-
- "update": {
- "git": true,
- "docker": false,
- "scriptPath": "setup/update/update.mjs"
- }
-}
diff --git a/docs/config-presets/minimal.jsonc b/docs/config-presets/minimal.jsonc
deleted file mode 100644
index 42900d7..0000000
--- a/docs/config-presets/minimal.jsonc
+++ /dev/null
@@ -1,120 +0,0 @@
-{
- "baseURL": "https://rewards.bing.com",
- "sessionPath": "sessions",
- "browser": {
- "headless": true,
- "globalTimeout": "45s"
- },
- "execution": {
- "parallel": false,
- "runOnZeroPoints": true,
- "clusters": 1,
- "passesPerRun": 1
- },
- "fingerprinting": {
- "saveFingerprint": {
- "mobile": false,
- "desktop": false
- }
- },
- "search": {
- "useLocalQueries": false,
- "settings": {
- "useGeoLocaleQueries": false,
- "scrollRandomResults": true,
- "clickRandomResults": true,
- "retryMobileSearchAmount": 1,
- "delay": {
- "min": "6s",
- "max": "15s"
- }
- }
- },
- "humanization": {
- "enabled": true,
- "stopOnBan": true,
- "immediateBanAlert": true,
- "actionDelay": {
- "min": 200,
- "max": 750
- },
- "gestureMoveProb": 0.5,
- "gestureScrollProb": 0.25,
- "allowedWindows": []
- },
- "vacation": {
- "enabled": false,
- "minDays": 2,
- "maxDays": 3
- },
- "retryPolicy": {
- "maxAttempts": 3,
- "baseDelay": 1000,
- "maxDelay": "30s",
- "multiplier": 2,
- "jitter": 0.2
- },
- "workers": {
- "doDailySet": true,
- "doMorePromotions": true,
- "doPunchCards": true,
- "doDesktopSearch": true,
- "doMobileSearch": true,
- "doDailyCheckIn": true,
- "doReadToEarn": true,
- "bundleDailySetWithSearch": false
- },
- "proxy": {
- "proxyGoogleTrends": true,
- "proxyBingTerms": true
- },
- "notifications": {
- "webhook": {
- "enabled": false,
- "url": ""
- },
- "conclusionWebhook": {
- "enabled": false,
- "url": ""
- },
- "ntfy": {
- "enabled": false,
- "url": "",
- "topic": "rewards",
- "authToken": ""
- }
- },
- "logging": {
- "excludeFunc": [
- "FLOW"
- ],
- "webhookExcludeFunc": [
- "FLOW"
- ],
- "redactEmails": true
- },
- "diagnostics": {
- "enabled": true,
- "saveScreenshot": true,
- "saveHtml": false,
- "maxPerRun": 1,
- "retentionDays": 5
- },
- "jobState": {
- "enabled": true,
- "dir": ""
- },
- "schedule": {
- "enabled": false,
- "useAmPm": false,
- "time12": "8:30 AM",
- "time24": "08:30",
- "timeZone": "UTC",
- "runImmediatelyOnStart": true
- },
- "update": {
- "git": true,
- "docker": false,
- "scriptPath": "setup/update/update.mjs"
- }
-}
diff --git a/docs/config.md b/docs/config.md
index e77e550..5ba7044 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -222,8 +222,61 @@ Auto-update behavior after a run.
| Skip mobile searches | Set `workers.doMobileSearch=false`. |
| Use daily schedule | Set `schedule.enabled=true` and adjust `time24` + `timeZone`. |
+---
+## NEW INTELLIGENT FEATURES
+
+### riskManagement
+Dynamic risk assessment and ban prediction.
+
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| enabled | boolean | true | Enable risk-aware throttling. |
+| autoAdjustDelays | boolean | true | Automatically increase delays when captchas/errors are detected. |
+| stopOnCritical | boolean | false | Stop execution if risk score exceeds threshold. |
+| banPrediction | boolean | true | Enable ML-style pattern analysis to predict ban risk. |
+| riskThreshold | number | 75 | Risk score (0-100) above which bot pauses or alerts. |
+
+**How it works:** Monitors captchas, errors, timeouts, and account patterns. Dynamically adjusts delays (e.g., 1x ā 2.5x) and warns you before bans happen.
+
+---
+### analytics
+Performance dashboard and metrics tracking.
+
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| enabled | boolean | true | Track points earned, success rates, execution times. |
+| retentionDays | number | 30 | How long to keep analytics data. |
+| exportMarkdown | boolean | true | Generate human-readable markdown reports. |
+| webhookSummary | boolean | false | Send analytics summary via webhook. |
+
+**Output location:** `analytics/` folder (JSON files per account per day).
+
+---
+### queryDiversity
+Multi-source search query generation.
+
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| enabled | boolean | true | Use diverse sources instead of just Google Trends. |
+| sources | array | `["google-trends", "reddit", "local-fallback"]` | Which sources to query (google-trends, reddit, news, wikipedia, local-fallback). |
+| maxQueriesPerSource | number | 10 | Max queries to fetch per source. |
+| cacheMinutes | number | 30 | Cache duration to avoid hammering APIs. |
+
+**Why?** Reduces patterns by mixing Reddit posts, news headlines, Wikipedia topics instead of predictable Google Trends.
+
+---
+### dryRun
+Test mode: simulate execution without actually running tasks.
+
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| dryRun | boolean | false | When true, logs actions but doesn't execute (useful for testing config). |
+
+**Use case:** Validate new config changes, estimate execution time, debug issues without touching accounts.
+
---
## Changelog Notes
+- **v2.2.0**: Added risk-aware throttling, analytics dashboard, query diversity, ban prediction, dry-run mode.
- Removed live webhook streaming complexity; now simpler logging.
- Centralized redaction logic under `logging.redactEmails`.
diff --git a/package-lock.json b/package-lock.json
index ed53f3c..d825b5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "microsoft-rewards-script",
- "version": "2.1.0",
+ "version": "2.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "microsoft-rewards-script",
- "version": "2.1.0",
+ "version": "2.1.5",
"license": "ISC",
"dependencies": {
"axios": "^1.8.4",
@@ -35,6 +35,10 @@
},
"engines": {
"node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/TheNetsky"
}
},
"node_modules/@cspotcode/source-map-support": {
diff --git a/package.json b/package.json
index de3cc9c..93c0675 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,20 @@
{
"name": "microsoft-rewards-script",
- "version": "2.1.0",
+ "version": "2.1.5",
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
+ "private": true,
"main": "index.js",
"engines": {
"node": ">=18.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",
@@ -32,7 +41,17 @@
"Cheerio"
],
"author": "Netsky",
+ "contributors": [
+ "TheNetsky (https://github.com/TheNetsky)",
+ "LightZirconite (https://github.com/LightZirconite)",
+ "Mgrimace (https://github.com/mgrimace)",
+ "hmcdat (https://github.com/hmcdat)"
+ ],
"license": "ISC",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/TheNetsky"
+ },
"devDependencies": {
"@types/node": "^20.14.11",
"@types/ms": "^0.7.34",
diff --git a/setup/setup.mjs b/setup/setup.mjs
index 37d09c6..21165d6 100644
--- a/setup/setup.mjs
+++ b/setup/setup.mjs
@@ -1,12 +1,21 @@
#!/usr/bin/env node
/**
- * Unified cross-platform setup script for Microsoft Rewards Script.
- * Handles:
- * - Renaming accounts.example.json -> accounts.json (idempotent)
- * - Prompt loop to confirm passwords added
- * - Inform about config.jsonc and conclusionWebhook
- * - Run npm install + npm run build
- * - Optional start
+ * Unified cross-platform setup script for Microsoft Rewards Script V2.
+ *
+ * Features:
+ * - Renames accounts.example.json -> accounts.json (idempotent)
+ * - Guides user through account configuration (email, password, TOTP, proxy)
+ * - Explains config.jsonc structure and key settings
+ * - Installs dependencies (npm install)
+ * - Builds TypeScript project (npm run build)
+ * - Installs Playwright Chromium browser (idempotent with marker)
+ * - Optional immediate start or manual start instructions
+ *
+ * V2 Updates:
+ * - Enhanced prompts for new config.jsonc structure
+ * - Explains humanization, scheduling, notifications
+ * - References updated documentation (docs/config.md, docs/accounts.md)
+ * - Improved user guidance for first-time setup
*/
import fs from 'fs';
@@ -52,12 +61,19 @@ async function prompt(question) {
}
async function loopForAccountsConfirmation() {
+ log('\nš Please configure your Microsoft accounts:');
+ log(' - Open: src/accounts.json');
+ log(' - Add your email and password for each account');
+ log(' - Optional: Add TOTP secret for 2FA (see docs/accounts.md)');
+ log(' - Optional: Configure proxy settings per account');
+ log(' - Save the file (Ctrl+S or Cmd+S)\n');
+
// Keep asking until user says yes
for (;;) {
- const ans = (await prompt('Have you entered your passwords in accounts.json? (yes/no) : ')).toLowerCase();
+ const ans = (await prompt('Have you configured your accounts in accounts.json? (yes/no): ')).toLowerCase();
if (['yes', 'y'].includes(ans)) break;
if (['no', 'n'].includes(ans)) {
- log('Please enter your passwords in accounts.json and save the file (Ctrl+S), then answer yes.');
+ log('Please configure accounts.json and save the file, then answer yes.');
continue;
}
log('Please answer yes or no.');
@@ -102,17 +118,44 @@ async function startOnly() {
async function fullSetup() {
renameAccountsIfNeeded();
await loopForAccountsConfirmation();
- log('\nYou can now review config.jsonc (same folder) to adjust settings such as conclusionWebhook.');
- log('(How to enable it is documented in the repository README.)\n');
+
+ log('\nāļø Configuration Options (src/config.jsonc):');
+ log(' - browser.headless: Set to true for background operation');
+ log(' - execution.clusters: Number of parallel account processes');
+ log(' - workers: Enable/disable specific tasks (dailySet, searches, etc.)');
+ log(' - humanization: Add natural delays and behavior (recommended: enabled)');
+ log(' - schedule: Configure automated daily runs');
+ log(' - notifications: Discord webhooks, NTFY push alerts');
+ log(' š Full guide: docs/config.md\n');
+
+ const reviewConfig = (await prompt('Do you want to review config.jsonc now? (yes/no): ')).toLowerCase();
+ if (['yes', 'y'].includes(reviewConfig)) {
+ log('āøļø Setup paused. Please review src/config.jsonc, then re-run this setup.');
+ log(' Common settings to check:');
+ log(' - browser.headless (false = visible browser, true = background)');
+ log(' - execution.runOnZeroPoints (false = skip when no points available)');
+ log(' - humanization.enabled (true = natural behavior, recommended)');
+ log(' - schedule.enabled (false = manual runs, true = automated scheduling)');
+ log('\n After editing config.jsonc, run: npm run setup');
+ process.exit(0);
+ }
+
await ensureNpmAvailable();
await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install']);
await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build']);
await installPlaywrightBrowsers();
- const start = (await prompt('Do you want to start the program now? (yes/no) : ')).toLowerCase();
+
+ log('\nā
Setup complete!');
+ log(' - Accounts configured: src/accounts.json');
+ log(' - Configuration: src/config.jsonc');
+ log(' - Documentation: docs/index.md\n');
+
+ const start = (await prompt('Do you want to start the automation now? (yes/no): ')).toLowerCase();
if (['yes', 'y'].includes(start)) {
await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'start']);
} else {
- log('Finished setup without starting.');
+ log('\nFinished setup. To start later, run: npm start');
+ log('For automated scheduling, run: npm run start:schedule');
}
}
diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts
index b469d55..f83d21c 100644
--- a/src/browser/Browser.ts
+++ b/src/browser/Browser.ts
@@ -35,12 +35,13 @@ class Browser {
}
let browser: import('rebrowser-playwright').Browser
- // Support both legacy and new config structures (wider scope for later usage)
- const cfgAny = this.bot.config as unknown as Record
try {
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
- let headlessValue = envForceHeadless ? true : ((cfgAny['headless'] as boolean | undefined) ?? (cfgAny['browser'] && (cfgAny['browser'] as Record)['headless'] as boolean | undefined) ?? false)
+ // 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
+ let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false)
if (this.bot.isBuyModeEnabled() && !envForceHeadless) {
if (headlessValue !== false) {
const target = this.bot.getBuyModeTarget()
@@ -77,8 +78,9 @@ class Browser {
}
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
- const fpConfig = (cfgAny['saveFingerprint'] as unknown) || ((cfgAny['fingerprinting'] as Record | undefined)?.['saveFingerprint'] as unknown)
- const saveFingerprint: { mobile: boolean; desktop: boolean } = (fpConfig as { mobile: boolean; desktop: boolean }) || { mobile: false, desktop: false }
+ 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)
@@ -87,8 +89,10 @@ class Browser {
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 globalTimeout = (cfgAny['globalTimeout'] as unknown) ?? ((cfgAny['browser'] as Record | undefined)?.['globalTimeout'] as unknown) ?? 30000
- context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout as (number | string)))
+ 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 {
@@ -126,7 +130,7 @@ class Browser {
await context.addCookies(sessionData.cookies)
// Persist fingerprint when feature is configured
- if (fpConfig) {
+ if (saveFingerprint.mobile || saveFingerprint.desktop) {
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
}
diff --git a/src/browser/BrowserUtil.ts b/src/browser/BrowserUtil.ts
index adc9fb1..3525aa2 100644
--- a/src/browser/BrowserUtil.ts
+++ b/src/browser/BrowserUtil.ts
@@ -4,65 +4,134 @@ import { load } from 'cheerio'
import { MicrosoftRewardsBot } from '../index'
import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics'
+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
+
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
async tryDismissAllMessages(page: Page): Promise {
- const attempts = 3
- const buttonGroups: { selector: string; label: string; isXPath?: boolean }[] = [
- { 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 }
- ]
- for (let round = 0; round < attempts; round++) {
- let dismissedThisRound = 0
- for (const btn of buttonGroups) {
- try {
- const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
- if (await loc.first().isVisible({ timeout: 200 }).catch(()=>false)) {
- await loc.first().click({ timeout: 500 }).catch(()=>{})
- dismissedThisRound++
- this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
- await page.waitForTimeout(150)
- }
- } catch { /* ignore */ }
+ const maxRounds = 3
+ for (let round = 0; round < maxRounds; round++) {
+ const dismissCount = await this.dismissRound(page)
+ if (dismissCount === 0) break
+ }
+ }
+
+ private async dismissRound(page: Page): Promise {
+ let count = 0
+ count += await this.dismissStandardButtons(page)
+ count += await this.dismissOverlayButtons(page)
+ count += await this.dismissStreakDialog(page)
+ return count
+ }
+
+ private async dismissStandardButtons(page: Page): Promise {
+ let count = 0
+ for (const btn of BrowserUtil.DISMISS_BUTTONS) {
+ const dismissed = await this.tryClickButton(page, btn)
+ if (dismissed) {
+ count++
+ await page.waitForTimeout(150)
}
- // Special case: blocking overlay with inside buttons
- try {
- const overlay = page.locator('#bnp_overlay_wrapper')
- if (await overlay.isVisible({ timeout: 200 }).catch(()=>false)) {
- const reject = overlay.locator('#bnp_btn_reject, button[aria-label*="Reject" i]')
- const accept = overlay.locator('#bnp_btn_accept')
- if (await reject.first().isVisible().catch(()=>false)) {
- await reject.first().click({ timeout: 500 }).catch(()=>{})
- this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
- dismissedThisRound++
- } else if (await accept.first().isVisible().catch(()=>false)) {
- await accept.first().click({ timeout: 500 }).catch(()=>{})
- this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
- dismissedThisRound++
- }
- }
- } catch { /* ignore */ }
- if (dismissedThisRound === 0) break // nothing new dismissed -> stop early
+ }
+ return count
+ }
+
+ private async tryClickButton(page: Page, btn: DismissButton): Promise {
+ 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 {
+ 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 {
+ 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
}
}
diff --git a/src/config.jsonc b/src/config.jsonc
index 12974eb..ab360cc 100644
--- a/src/config.jsonc
+++ b/src/config.jsonc
@@ -193,5 +193,44 @@
"docker": false,
// Custom updater script path (relative to repo root)
"scriptPath": "setup/update/update.mjs"
- }
+ },
+
+ // NEW INTELLIGENT FEATURES
+ "riskManagement": {
+ // Risk-Aware Throttling: dynamically adjusts delays based on detected risk signals
+ "enabled": true,
+ // Automatically increase delays when captchas/errors are detected
+ "autoAdjustDelays": true,
+ // Stop execution if risk level reaches critical (score > riskThreshold)
+ "stopOnCritical": false,
+ // Enable ML-style ban prediction based on patterns
+ "banPrediction": true,
+ // Risk threshold (0-100). If exceeded, bot pauses or alerts you.
+ "riskThreshold": 75
+ },
+
+ "analytics": {
+ // Performance Dashboard: track points earned, success rates, execution times
+ "enabled": true,
+ // How long to keep analytics data (days)
+ "retentionDays": 30,
+ // Generate markdown summary reports
+ "exportMarkdown": true,
+ // Send analytics summary via webhook
+ "webhookSummary": false
+ },
+
+ "queryDiversity": {
+ // Multi-source query generation: use Reddit, News, Wikipedia instead of just Google Trends
+ "enabled": true,
+ // Which sources to use (google-trends, reddit, news, wikipedia, local-fallback)
+ "sources": ["google-trends", "reddit", "local-fallback"],
+ // Max queries to fetch per source
+ "maxQueriesPerSource": 10,
+ // Cache duration in minutes (avoids hammering APIs)
+ "cacheMinutes": 30
+ },
+
+ // Dry-run mode: simulate execution without actually running tasks (useful for testing config)
+ "dryRun": false
}
diff --git a/src/functions/Login.ts b/src/functions/Login.ts
index bda4a7a..1e3d2fd 100644
--- a/src/functions/Login.ts
+++ b/src/functions/Login.ts
@@ -321,16 +321,8 @@ export class Login {
}
await input.fill('')
await input.fill(code)
- const submitSelectors = [
- '#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")'
- ]
- const submit = await this.findFirstVisibleLocator(page, submitSelectors)
+ // Use unified selector system
+ const submit = await this.findFirstVisibleLocator(page, Login.TOTP_SELECTORS.submit)
if (submit) {
await submit.click().catch(()=>{})
} else {
@@ -342,19 +334,17 @@ export class Login {
}
}
- private totpInputSelectors(): string[] {
- return [
+ // 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"]'
- ]
- }
-
- private totpAltOptionSelectors(): string[] {
- return [
+ ],
+ altOptions: [
'#idA_SAOTCS_ProofPickerChange',
'#idA_SAOTCC_AlternateLogin',
'a:has-text("Use a different verification option")',
@@ -362,11 +352,8 @@ export class Login {
'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")'
- ]
- }
-
- private totpChallengeSelectors(): string[] {
- return [
+ ],
+ challenge: [
'[data-value="PhoneAppOTP"]',
'[data-value="OneTimeCode"]',
'button:has-text("Use a verification code")',
@@ -380,20 +367,32 @@ export class Login {
'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 async findFirstVisibleSelector(page: Page, selectors: string[]): Promise {
+ 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 {
for (const sel of selectors) {
const loc = page.locator(sel).first()
- if (await loc.isVisible().catch(() => false)) {
- return sel
- }
+ if (await loc.isVisible().catch(() => false)) return sel
}
return null
}
- private async clickFirstVisibleSelector(page: Page, selectors: string[]): Promise {
+ private async clickFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) {
@@ -404,12 +403,10 @@ export class Login {
return false
}
- private async findFirstVisibleLocator(page: Page, selectors: string[]): Promise {
+ private async findFirstVisibleLocator(page: Page, selectors: readonly string[]): Promise {
for (const sel of selectors) {
const loc = page.locator(sel).first()
- if (await loc.isVisible().catch(() => false)) {
- return loc
- }
+ if (await loc.isVisible().catch(() => false)) return loc
}
return null
}
diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts
index c9ff794..60b7742 100644
--- a/src/functions/Workers.ts
+++ b/src/functions/Workers.ts
@@ -147,95 +147,98 @@ export class Workers {
// Solve all the different types of activities
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
- const activityInitial = activityPage.url() // Homepage for Daily/More and Index for promotions
+ const activityInitial = activityPage.url()
+ const retry = new Retry(this.bot.config.retryPolicy)
+ const throttle = new AdaptiveThrottler()
- const retry = new Retry(this.bot.config.retryPolicy)
- const throttle = new AdaptiveThrottler()
- for (const activity of activities) {
+ for (const activity of activities) {
try {
- // Reselect the worker page
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
+ activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
+ await this.applyThrottle(throttle, 800, 1400)
- const pages = activityPage.context().pages()
- if (pages.length > 3) {
- await activityPage.close()
+ const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
+ await this.prepareActivityPage(activityPage, selector, throttle)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- }
-
- await this.bot.browser.utils.humanizePage(activityPage)
- {
- const m = throttle.getDelayMultiplier()
- await this.bot.utils.waitRandom(Math.floor(800*m), Math.floor(1400*m))
- }
-
- if (activityPage.url() !== activityInitial) {
- await activityPage.goto(activityInitial)
- }
-
-
- let selector = `[data-bi-id^="${activity.offerId}"] .pointLink:not(.contentContainer .pointLink)`
-
- if (punchCard) {
- selector = await this.bot.browser.func.getPunchCardActivity(activityPage, activity)
-
- } else if (activity.name.toLowerCase().includes('membercenter') || activity.name.toLowerCase().includes('exploreonbing')) {
- selector = `[data-bi-id^="${activity.name}"] .pointLink:not(.contentContainer .pointLink)`
- }
-
- // Wait for the new tab to fully load, ignore error.
- /*
- Due to common false timeout on this function, we're ignoring the error regardless, if it worked then it's faster,
- if it didn't then it gave enough time for the page to load.
- */
- await activityPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { })
- // Small human-like jitter before executing
- await this.bot.browser.utils.humanizePage(activityPage)
- {
- const m = throttle.getDelayMultiplier()
- await this.bot.utils.waitRandom(Math.floor(1200*m), Math.floor(2600*m))
- }
-
- // Log the detected type using the same heuristics as before
const typeLabel = this.bot.activities.getTypeLabel(activity)
if (typeLabel !== 'Unsupported') {
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${typeLabel}" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- // Watchdog: abort if the activity hangs too long
- const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
- const runWithTimeout = (p: Promise) => Promise.race([
- p,
- new Promise((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
- ])
- await retry.run(async () => {
- try {
- await runWithTimeout(this.bot.activities.run(activityPage, activity))
- throttle.record(true)
- } catch (e) {
- await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_timeout_${activity.title || activity.offerId}`)
- throttle.record(false)
- throw e
- }
- }, () => true)
+ 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')
}
- // Cooldown with jitter
- await this.bot.browser.utils.humanizePage(activityPage)
- {
- const m = throttle.getDelayMultiplier()
- await this.bot.utils.waitRandom(Math.floor(1200*m), Math.floor(2600*m))
- }
-
+ await this.applyThrottle(throttle, 1200, 2600)
} catch (error) {
await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_error_${activity.title || activity.offerId}`)
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
throttle.record(false)
}
-
}
}
+ private async manageTabLifecycle(page: Page, initialUrl: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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) => Promise.race([
+ p,
+ new Promise((_, 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) {
+ await this.bot.browser.utils.captureDiagnostics(page, `activity_timeout_${activity.title || activity.offerId}`)
+ throttle.record(false)
+ throw e
+ }
+ }, () => true)
+
+ await this.bot.browser.utils.humanizePage(page)
+ }
+
+ private async applyThrottle(throttle: AdaptiveThrottler, min: number, max: number): Promise {
+ const multiplier = throttle.getDelayMultiplier()
+ await this.bot.utils.waitRandom(Math.floor(min * multiplier), Math.floor(max * multiplier))
+ }
+
}
\ No newline at end of file
diff --git a/src/functions/activities/Quiz.ts b/src/functions/activities/Quiz.ts
index 4aa68d0..c858024 100644
--- a/src/functions/activities/Quiz.ts
+++ b/src/functions/activities/Quiz.ts
@@ -20,6 +20,14 @@ export class Quiz extends Workers {
await this.bot.utils.wait(2000)
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: 5000 }).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,13 +37,26 @@ export class Quiz extends Workers {
const answers: string[] = []
for (let i = 0; i < quizData.numberOfOptions; i++) {
- const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
+ const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 }).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) {
@@ -56,15 +77,24 @@ export class Quiz extends Workers {
} 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}`, { state: 'visible', timeout: 10000 })
+ const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 }).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) {
@@ -72,8 +102,16 @@ export class Quiz extends Workers {
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
+ break
}
}
+
+ 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(2000)
}
}
diff --git a/src/index.ts b/src/index.ts
index a743227..1b0b6b6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -84,20 +84,19 @@ export class MicrosoftRewardsBot {
this.humanizer = new Humanizer(this.utils, this.config.humanization)
this.activeWorkers = this.config.clusters
this.mobileRetryAttempts = 0
- // Base buy mode from config
- const cfgAny = this.config as unknown as { buyMode?: { enabled?: boolean } }
- if (cfgAny.buyMode?.enabled === true) {
- this.buyMode.enabled = true
- }
-
- // CLI: detect buy mode flag and target email (overrides config)
+
+ // Buy mode: CLI args take precedence over config
const idx = process.argv.indexOf('-buy')
if (idx >= 0) {
const target = process.argv[idx + 1]
- if (target && /@/.test(target)) {
- this.buyMode = { enabled: true, email: target }
- } else {
- this.buyMode = { enabled: true }
+ this.buyMode = target && /@/.test(target)
+ ? { enabled: true, email: target }
+ : { enabled: true }
+ } else {
+ // Fallback to config if no CLI flag
+ const buyModeConfig = this.config.buyMode as { enabled?: boolean } | undefined
+ if (buyModeConfig?.enabled === true) {
+ this.buyMode.enabled = true
}
}
}
@@ -221,10 +220,8 @@ export class MicrosoftRewardsBot {
let last = initial
let spent = 0
- const cfgAny = this.config as unknown as Record
- const buyModeConfig = cfgAny['buyMode'] as Record | undefined
- const maxMinutesRaw = buyModeConfig?.['maxMinutes'] ?? 45
- const maxMinutes = Math.max(10, Number(maxMinutesRaw))
+ const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined
+ const maxMinutes = Math.max(10, buyModeConfig?.maxMinutes ?? 45)
const endAt = start + maxMinutes * 60 * 1000
while (Date.now() < endAt) {
@@ -292,25 +289,33 @@ export class MicrosoftRewardsBot {
if (this.config.clusters > 1 && !cluster.isPrimary) return
const banner = `
- āāāā āāāāāāāāāāāā āāāāāāā āāāāāāāāāāā āāā āāāāāā āāāāāāā āāāāāāā āāāāāāāā
- āāāāā āāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāā āāā āā āāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāā
- āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāā
- āāā āāā āāāāāāāāāāā āāā āāāāāāāāāāāāāāāāāāāāāāāā āāāāāā āāāāāāāāāāāāāāāāāāā
- āāā āāāāāāāāāāā āāā āāāāāāāāāāā āāāāāāāā āāā āāāāāā āāāāāāāāāā āāāāāāāā
-
- TypeScript ⢠Playwright ⢠Automated Point Collection
+ āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ ā ā
+ ā āāāā āāāāāāāāāāāā āāāāāāā āāāāāāāāāāā āāā āāāāāā āāāāāāā āāāāāāā āāāāāāāā ā
+ ā āāāāā āāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā ā
+ ā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāā āāā āā āāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāā ā
+ ā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāā ā
+ ā āāā āāā āāāāāāāāāāā āāā āāāāāāāāāāāāāāāāāāāāāāāā āāāāāā āāāāāāāāāāāāāāāāāāā ā
+ ā āāā āāāāāāāāāāā āāā āāāāāāāāāāā āāāāāāāā āāā āāāāāā āāāāāāāāāā āāāāāāāā ā
+ ā ā
+ ā TypeScript ⢠Playwright ⢠Intelligent Automation ā
+ ā ā
+ āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
`
const buyModeBanner = `
- āāāā āāāāāāāāāāāā āāāāāāā āāā āāāāāā āāā
- āāāāā āāāāāāāāāāāāā āāāāāāāāāāā āāāāāāā āāāā
- āāāāāāāāāāāāāāāāāāā āāāāāāāāāāā āāā āāāāāāā
- āāāāāāāāāāāāāāāāāāā āāāāāāāāāāā āāā āāāāā
- āāā āāā āāāāāāāāāāā āāāāāāāāāāāāāāāāā āāā
- āāā āāāāāāāāāāā āāāāāāā āāāāāāā āāā
-
- By @Light ⢠Manual Purchase Mode ⢠Passive Monitoring
+ āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ ā ā
+ ā āāāā āāāāāāāāāāāā āāāāāāā āāā āāāāāā āāā ā
+ ā āāāāā āāāāāāāāāāāāā āāāāāāāāāāā āāāāāāā āāāā ā
+ ā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāā āāā āāāāāāā ā
+ ā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāā āāā āāāāā ā
+ ā āāā āāā āāāāāāāāāāā āāāāāāāāāāāāāāāāā āāā ā
+ ā āāā āāāāāāāāāāā āāāāāāā āāāāāāā āāā ā
+ ā ā
+ ā Manual Purchase Mode ⢠Passive Monitoring ā
+ ā ā
+ āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
`
try {
diff --git a/src/interface/Config.ts b/src/interface/Config.ts
index 499a698..6758aed 100644
--- a/src/interface/Config.ts
+++ b/src/interface/Config.ts
@@ -2,6 +2,8 @@ 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;
@@ -27,6 +29,10 @@ export interface Config {
buyMode?: ConfigBuyMode; // Optional manual spending mode
vacation?: ConfigVacation; // Optional monthly contiguous off-days
crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
+ riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
+ analytics?: ConfigAnalytics; // NEW: Performance dashboard and metrics tracking
+ dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
+ queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
}
export interface ConfigSaveFingerprint {
@@ -34,6 +40,15 @@ export interface ConfigSaveFingerprint {
desktop: boolean;
}
+export interface ConfigBrowser {
+ headless?: boolean;
+ globalTimeout?: number | string;
+}
+
+export interface ConfigFingerprinting {
+ saveFingerprint?: ConfigSaveFingerprint;
+}
+
export interface ConfigSearchSettings {
useGeoLocaleQueries: boolean;
scrollRandomResults: boolean;
@@ -178,3 +193,26 @@ export interface ConfigLogging {
// CommunityHelp removed (privacy-first policy)
+// NEW FEATURES: Risk Management, Analytics, 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 ConfigAnalytics {
+ enabled?: boolean; // track performance metrics
+ retentionDays?: number; // how long to keep analytics data
+ exportMarkdown?: boolean; // generate markdown reports
+ webhookSummary?: boolean; // send analytics via webhook
+}
+
+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
+}
+
diff --git a/src/util/Analytics.ts b/src/util/Analytics.ts
new file mode 100644
index 0000000..052e8c7
--- /dev/null
+++ b/src/util/Analytics.ts
@@ -0,0 +1,264 @@
+import fs from 'fs'
+import path from 'path'
+
+export interface DailyMetrics {
+ date: string // YYYY-MM-DD
+ email: string
+ pointsEarned: number
+ pointsInitial: number
+ pointsEnd: number
+ desktopPoints: number
+ mobilePoints: number
+ executionTimeMs: number
+ successRate: number // 0-1
+ errorsCount: number
+ banned: boolean
+ riskScore?: number
+}
+
+export interface AccountHistory {
+ email: string
+ totalRuns: number
+ totalPointsEarned: number
+ avgPointsPerDay: number
+ avgExecutionTime: number
+ successRate: number
+ lastRunDate: string
+ banHistory: Array<{ date: string; reason: string }>
+ riskTrend: number[] // last N risk scores
+}
+
+export interface AnalyticsSummary {
+ period: string // e.g., 'last-7-days', 'last-30-days', 'all-time'
+ accounts: AccountHistory[]
+ globalStats: {
+ totalPoints: number
+ avgSuccessRate: number
+ mostProductiveAccount: string
+ mostRiskyAccount: string
+ }
+}
+
+/**
+ * Analytics tracks performance metrics, point collection trends, and account health.
+ * Stores data in JSON files for lightweight persistence and easy analysis.
+ */
+export class Analytics {
+ private dataDir: string
+
+ constructor(baseDir: string = 'analytics') {
+ this.dataDir = path.join(process.cwd(), baseDir)
+ if (!fs.existsSync(this.dataDir)) {
+ fs.mkdirSync(this.dataDir, { recursive: true })
+ }
+ }
+
+ /**
+ * Record metrics for a completed account run
+ */
+ recordRun(metrics: DailyMetrics): void {
+ const date = metrics.date
+ const email = this.sanitizeEmail(metrics.email)
+ const fileName = `${email}_${date}.json`
+ const filePath = path.join(this.dataDir, fileName)
+
+ try {
+ fs.writeFileSync(filePath, JSON.stringify(metrics, null, 2), 'utf-8')
+ } catch (error) {
+ console.error(`Failed to save metrics for ${metrics.email}:`, error)
+ }
+ }
+
+ /**
+ * Get history for a specific account
+ */
+ getAccountHistory(email: string, days: number = 30): AccountHistory {
+ const sanitized = this.sanitizeEmail(email)
+ const files = this.getAccountFiles(sanitized, days)
+
+ if (files.length === 0) {
+ return {
+ email,
+ totalRuns: 0,
+ totalPointsEarned: 0,
+ avgPointsPerDay: 0,
+ avgExecutionTime: 0,
+ successRate: 1.0,
+ lastRunDate: 'never',
+ banHistory: [],
+ riskTrend: []
+ }
+ }
+
+ let totalPoints = 0
+ let totalTime = 0
+ let successCount = 0
+ const banHistory: Array<{ date: string; reason: string }> = []
+ const riskScores: number[] = []
+
+ for (const file of files) {
+ const filePath = path.join(this.dataDir, file)
+ try {
+ const data: DailyMetrics = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+ totalPoints += data.pointsEarned
+ totalTime += data.executionTimeMs
+ if (data.successRate > 0.5) successCount++
+ if (data.banned) {
+ banHistory.push({ date: data.date, reason: 'detected' })
+ }
+ if (typeof data.riskScore === 'number') {
+ riskScores.push(data.riskScore)
+ }
+ } catch {
+ continue
+ }
+ }
+
+ const totalRuns = files.length
+ const lastFile = files[files.length - 1]
+ const lastRunDate = lastFile ? lastFile.split('_')[1]?.replace('.json', '') || 'unknown' : 'unknown'
+
+ return {
+ email,
+ totalRuns,
+ totalPointsEarned: totalPoints,
+ avgPointsPerDay: Math.round(totalPoints / Math.max(1, totalRuns)),
+ avgExecutionTime: Math.round(totalTime / Math.max(1, totalRuns)),
+ successRate: successCount / Math.max(1, totalRuns),
+ lastRunDate,
+ banHistory,
+ riskTrend: riskScores.slice(-10) // last 10 risk scores
+ }
+ }
+
+ /**
+ * Generate a summary report for all accounts
+ */
+ generateSummary(days: number = 30): AnalyticsSummary {
+ const accountEmails = this.getAllAccounts()
+ const accounts: AccountHistory[] = []
+
+ for (const email of accountEmails) {
+ accounts.push(this.getAccountHistory(email, days))
+ }
+
+ const totalPoints = accounts.reduce((sum, a) => sum + a.totalPointsEarned, 0)
+ const avgSuccess = accounts.reduce((sum, a) => sum + a.successRate, 0) / Math.max(1, accounts.length)
+
+ let mostProductive = ''
+ let maxPoints = 0
+ let mostRisky = ''
+ let maxRisk = 0
+
+ for (const acc of accounts) {
+ if (acc.totalPointsEarned > maxPoints) {
+ maxPoints = acc.totalPointsEarned
+ mostProductive = acc.email
+ }
+ const avgRisk = acc.riskTrend.reduce((s, r) => s + r, 0) / Math.max(1, acc.riskTrend.length)
+ if (avgRisk > maxRisk) {
+ maxRisk = avgRisk
+ mostRisky = acc.email
+ }
+ }
+
+ return {
+ period: `last-${days}-days`,
+ accounts,
+ globalStats: {
+ totalPoints,
+ avgSuccessRate: Number(avgSuccess.toFixed(2)),
+ mostProductiveAccount: mostProductive || 'none',
+ mostRiskyAccount: mostRisky || 'none'
+ }
+ }
+ }
+
+ /**
+ * Export summary as markdown table (for human readability)
+ */
+ exportMarkdown(days: number = 30): string {
+ const summary = this.generateSummary(days)
+ const lines: string[] = []
+
+ lines.push(`# Analytics Summary (${summary.period})`)
+ lines.push('')
+ lines.push('## Global Stats')
+ lines.push(`- Total Points: ${summary.globalStats.totalPoints}`)
+ lines.push(`- Avg Success Rate: ${(summary.globalStats.avgSuccessRate * 100).toFixed(1)}%`)
+ lines.push(`- Most Productive: ${summary.globalStats.mostProductiveAccount}`)
+ lines.push(`- Most Risky: ${summary.globalStats.mostRiskyAccount}`)
+ lines.push('')
+ lines.push('## Per-Account Breakdown')
+ lines.push('')
+ lines.push('| Account | Runs | Total Points | Avg/Day | Success Rate | Last Run | Bans |')
+ lines.push('|---------|------|--------------|---------|--------------|----------|------|')
+
+ for (const acc of summary.accounts) {
+ const successPct = (acc.successRate * 100).toFixed(0)
+ const banCount = acc.banHistory.length
+ lines.push(
+ `| ${acc.email} | ${acc.totalRuns} | ${acc.totalPointsEarned} | ${acc.avgPointsPerDay} | ${successPct}% | ${acc.lastRunDate} | ${banCount} |`
+ )
+ }
+
+ return lines.join('\n')
+ }
+
+ /**
+ * Clean up old analytics files (retention policy)
+ */
+ cleanup(retentionDays: number): void {
+ const files = fs.readdirSync(this.dataDir)
+ const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000)
+
+ for (const file of files) {
+ if (!file.endsWith('.json')) continue
+ const filePath = path.join(this.dataDir, file)
+ try {
+ const stats = fs.statSync(filePath)
+ if (stats.mtimeMs < cutoff) {
+ fs.unlinkSync(filePath)
+ }
+ } catch {
+ continue
+ }
+ }
+ }
+
+ private sanitizeEmail(email: string): string {
+ return email.replace(/[^a-zA-Z0-9@._-]/g, '_')
+ }
+
+ private getAccountFiles(sanitizedEmail: string, days: number): string[] {
+ const files = fs.readdirSync(this.dataDir)
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - days)
+
+ return files
+ .filter((f: string) => f.startsWith(sanitizedEmail) && f.endsWith('.json'))
+ .filter((f: string) => {
+ const datePart = f.split('_')[1]?.replace('.json', '')
+ if (!datePart) return false
+ const fileDate = new Date(datePart)
+ return fileDate >= cutoffDate
+ })
+ .sort()
+ }
+
+ private getAllAccounts(): string[] {
+ const files = fs.readdirSync(this.dataDir)
+ const emailSet = new Set()
+
+ for (const file of files) {
+ if (!file.endsWith('.json')) continue
+ const parts = file.split('_')
+ if (parts.length >= 2) {
+ const email = parts[0]
+ if (email) emailSet.add(email)
+ }
+ }
+
+ return Array.from(emailSet)
+ }
+}
diff --git a/src/util/BanPredictor.ts b/src/util/BanPredictor.ts
new file mode 100644
index 0000000..ac051cd
--- /dev/null
+++ b/src/util/BanPredictor.ts
@@ -0,0 +1,394 @@
+import { RiskManager, RiskEvent } from './RiskManager'
+
+export interface BanPattern {
+ name: string
+ description: string
+ weight: number // 0-10
+ detected: boolean
+ evidence: string[]
+}
+
+export interface BanPrediction {
+ riskScore: number // 0-100
+ confidence: number // 0-1
+ likelihood: 'very-low' | 'low' | 'medium' | 'high' | 'critical'
+ patterns: BanPattern[]
+ recommendation: string
+ preventiveActions: string[]
+}
+
+export interface HistoricalData {
+ email: string
+ timestamp: number
+ banned: boolean
+ preBanEvents: RiskEvent[]
+ accountAge: number // days since first use
+ totalRuns: number
+}
+
+/**
+ * BanPredictor uses machine-learning-style pattern analysis to predict ban risk.
+ * Learns from historical data and real-time signals to calculate ban probability.
+ */
+export class BanPredictor {
+ private riskManager: RiskManager
+ private history: HistoricalData[] = []
+ private patterns: BanPattern[] = []
+
+ constructor(riskManager: RiskManager) {
+ this.riskManager = riskManager
+ this.initializePatterns()
+ }
+
+ /**
+ * Analyze current state and predict ban risk
+ */
+ predictBanRisk(accountEmail: string, accountAgeDays: number, totalRuns: number): BanPrediction {
+ const riskMetrics = this.riskManager.assessRisk()
+ const recentEvents = this.riskManager.getRecentEvents(60)
+
+ // Detect patterns
+ this.detectPatterns(recentEvents, accountAgeDays, totalRuns)
+
+ // Calculate base risk from RiskManager
+ const baseRisk = riskMetrics.score
+
+ // Apply ML-style feature weights
+ const featureScore = this.calculateFeatureScore(recentEvents, accountAgeDays, totalRuns)
+
+ // Pattern detection bonus
+ const detectedPatterns = this.patterns.filter(p => p.detected)
+ const patternPenalty = detectedPatterns.reduce((sum, p) => sum + p.weight, 0)
+
+ // Historical learning adjustment
+ const historicalAdjustment = this.getHistoricalAdjustment(accountEmail)
+
+ // Final risk score (capped at 100)
+ const finalScore = Math.min(100, baseRisk + featureScore + patternPenalty + historicalAdjustment)
+
+ // Calculate confidence (based on data availability)
+ const confidence = this.calculateConfidence(recentEvents.length, this.history.length)
+
+ // Determine likelihood tier
+ let likelihood: BanPrediction['likelihood']
+ if (finalScore < 20) likelihood = 'very-low'
+ else if (finalScore < 40) likelihood = 'low'
+ else if (finalScore < 60) likelihood = 'medium'
+ else if (finalScore < 80) likelihood = 'high'
+ else likelihood = 'critical'
+
+ // Generate recommendations
+ const recommendation = this.generateRecommendation(finalScore)
+ const preventiveActions = this.generatePreventiveActions(detectedPatterns)
+
+ return {
+ riskScore: Math.round(finalScore),
+ confidence: Number(confidence.toFixed(2)),
+ likelihood,
+ patterns: detectedPatterns,
+ recommendation,
+ preventiveActions
+ }
+ }
+
+ /**
+ * Record ban event for learning
+ */
+ recordBan(email: string, accountAgeDays: number, totalRuns: number): void {
+ const preBanEvents = this.riskManager.getRecentEvents(120)
+
+ this.history.push({
+ email,
+ timestamp: Date.now(),
+ banned: true,
+ preBanEvents,
+ accountAge: accountAgeDays,
+ totalRuns
+ })
+
+ // Keep history limited (last 100 bans)
+ if (this.history.length > 100) {
+ this.history.shift()
+ }
+ }
+
+ /**
+ * Record successful run (no ban) for learning
+ */
+ recordSuccess(email: string, accountAgeDays: number, totalRuns: number): void {
+ this.history.push({
+ email,
+ timestamp: Date.now(),
+ banned: false,
+ preBanEvents: [],
+ accountAge: accountAgeDays,
+ totalRuns
+ })
+
+ if (this.history.length > 100) {
+ this.history.shift()
+ }
+ }
+
+ /**
+ * Initialize known ban patterns
+ */
+ private initializePatterns(): void {
+ this.patterns = [
+ {
+ name: 'rapid-captcha-sequence',
+ description: 'Multiple captchas in short timespan',
+ weight: 8,
+ detected: false,
+ evidence: []
+ },
+ {
+ name: 'high-error-rate',
+ description: 'Excessive errors (>50% in last hour)',
+ weight: 6,
+ detected: false,
+ evidence: []
+ },
+ {
+ name: 'timeout-storm',
+ description: 'Many consecutive timeouts',
+ weight: 7,
+ detected: false,
+ evidence: []
+ },
+ {
+ name: 'suspicious-timing',
+ description: 'Activity at unusual hours or too consistent',
+ weight: 5,
+ detected: false,
+ evidence: []
+ },
+ {
+ name: 'new-account-aggressive',
+ description: 'Aggressive activity on young account',
+ weight: 9,
+ detected: false,
+ evidence: []
+ },
+ {
+ name: 'proxy-flagged',
+ description: 'Proxy showing signs of blacklisting',
+ weight: 7,
+ detected: false,
+ evidence: []
+ }
+ ]
+ }
+
+ /**
+ * Detect patterns in recent events
+ */
+ private detectPatterns(events: RiskEvent[], accountAgeDays: number, totalRuns: number): void {
+ // Reset detection
+ for (const p of this.patterns) {
+ p.detected = false
+ p.evidence = []
+ }
+
+ const captchaEvents = events.filter(e => e.type === 'captcha')
+ const errorEvents = events.filter(e => e.type === 'error')
+ const timeoutEvents = events.filter(e => e.type === 'timeout')
+
+ // Pattern 1: Rapid captcha sequence
+ if (captchaEvents.length >= 3) {
+ const timeSpan = (events[events.length - 1]?.timestamp || 0) - (events[0]?.timestamp || 0)
+ if (timeSpan < 1800000) { // 30 min
+ const p = this.patterns.find(pat => pat.name === 'rapid-captcha-sequence')
+ if (p) {
+ p.detected = true
+ p.evidence.push(`${captchaEvents.length} captchas in ${Math.round(timeSpan / 60000)}min`)
+ }
+ }
+ }
+
+ // Pattern 2: High error rate
+ const errorRate = errorEvents.length / Math.max(1, events.length)
+ if (errorRate > 0.5) {
+ const p = this.patterns.find(pat => pat.name === 'high-error-rate')
+ if (p) {
+ p.detected = true
+ p.evidence.push(`Error rate: ${(errorRate * 100).toFixed(1)}%`)
+ }
+ }
+
+ // Pattern 3: Timeout storm
+ if (timeoutEvents.length >= 5) {
+ const p = this.patterns.find(pat => pat.name === 'timeout-storm')
+ if (p) {
+ p.detected = true
+ p.evidence.push(`${timeoutEvents.length} timeouts detected`)
+ }
+ }
+
+ // Pattern 4: Suspicious timing (all events within same hour)
+ if (events.length > 5) {
+ const hours = new Set(events.map(e => new Date(e.timestamp).getHours()))
+ if (hours.size === 1) {
+ const p = this.patterns.find(pat => pat.name === 'suspicious-timing')
+ if (p) {
+ p.detected = true
+ p.evidence.push('All activity in same hour of day')
+ }
+ }
+ }
+
+ // Pattern 5: New account aggressive
+ if (accountAgeDays < 7 && totalRuns > 10) {
+ const p = this.patterns.find(pat => pat.name === 'new-account-aggressive')
+ if (p) {
+ p.detected = true
+ p.evidence.push(`Account ${accountAgeDays} days old with ${totalRuns} runs`)
+ }
+ }
+
+ // Pattern 6: Proxy flagged (heuristic: many ban hints)
+ const banHints = events.filter(e => e.type === 'ban_hint')
+ if (banHints.length >= 2) {
+ const p = this.patterns.find(pat => pat.name === 'proxy-flagged')
+ if (p) {
+ p.detected = true
+ p.evidence.push(`${banHints.length} ban hints detected`)
+ }
+ }
+ }
+
+ /**
+ * Calculate feature-based risk score (ML-style)
+ */
+ private calculateFeatureScore(events: RiskEvent[], accountAgeDays: number, totalRuns: number): number {
+ let score = 0
+
+ // Feature 1: Event density (events per minute)
+ const eventDensity = events.length / 60
+ if (eventDensity > 0.5) score += 10
+ else if (eventDensity > 0.2) score += 5
+
+ // Feature 2: Account age risk
+ if (accountAgeDays < 3) score += 15
+ else if (accountAgeDays < 7) score += 10
+ else if (accountAgeDays < 14) score += 5
+
+ // Feature 3: Run frequency risk
+ const runsPerDay = totalRuns / Math.max(1, accountAgeDays)
+ if (runsPerDay > 3) score += 12
+ else if (runsPerDay > 2) score += 6
+
+ // Feature 4: Severity distribution
+ const highSeverityEvents = events.filter(e => e.severity >= 7)
+ if (highSeverityEvents.length > 3) score += 15
+ else if (highSeverityEvents.length > 1) score += 8
+
+ return score
+ }
+
+ /**
+ * Learn from historical data
+ */
+ private getHistoricalAdjustment(email: string): number {
+ const accountHistory = this.history.filter(h => h.email === email)
+ if (accountHistory.length === 0) return 0
+
+ const bannedCount = accountHistory.filter(h => h.banned).length
+ const banRate = bannedCount / accountHistory.length
+
+ // If this account has high ban history, increase risk
+ if (banRate > 0.3) return 20
+ if (banRate > 0.1) return 10
+
+ // If clean history, slight bonus
+ if (accountHistory.length > 5 && banRate === 0) return -5
+
+ return 0
+ }
+
+ /**
+ * Calculate prediction confidence
+ */
+ private calculateConfidence(eventCount: number, historyCount: number): number {
+ let confidence = 0.5
+
+ // More events = higher confidence
+ if (eventCount > 20) confidence += 0.2
+ else if (eventCount > 10) confidence += 0.1
+
+ // More historical data = higher confidence
+ if (historyCount > 50) confidence += 0.2
+ else if (historyCount > 20) confidence += 0.1
+
+ return Math.min(1.0, confidence)
+ }
+
+ /**
+ * Generate human-readable recommendation
+ */
+ private generateRecommendation(score: number): string {
+ if (score < 20) {
+ return 'Safe to proceed. Risk is minimal.'
+ } else if (score < 40) {
+ return 'Low risk detected. Monitor for issues but safe to continue.'
+ } else if (score < 60) {
+ return 'Moderate risk. Consider increasing delays and reviewing patterns.'
+ } else if (score < 80) {
+ return 'High risk! Strongly recommend pausing automation for 24-48 hours.'
+ } else {
+ return 'CRITICAL RISK! Stop all automation immediately. Manual review required.'
+ }
+ }
+
+ /**
+ * Generate actionable preventive steps
+ */
+ private generatePreventiveActions(patterns: BanPattern[]): string[] {
+ const actions: string[] = []
+
+ if (patterns.some(p => p.name === 'rapid-captcha-sequence')) {
+ actions.push('Increase search delays to 3-5 minutes minimum')
+ actions.push('Enable longer cool-down periods between activities')
+ }
+
+ if (patterns.some(p => p.name === 'high-error-rate')) {
+ actions.push('Check proxy connectivity and health')
+ actions.push('Verify User-Agent and fingerprint configuration')
+ }
+
+ if (patterns.some(p => p.name === 'new-account-aggressive')) {
+ actions.push('Slow down activity on new accounts (max 1 run per day for first week)')
+ actions.push('Allow account to age naturally before heavy automation')
+ }
+
+ if (patterns.some(p => p.name === 'proxy-flagged')) {
+ actions.push('Rotate to different proxy immediately')
+ actions.push('Test proxy manually before resuming')
+ }
+
+ if (patterns.some(p => p.name === 'suspicious-timing')) {
+ actions.push('Randomize execution times across different hours')
+ actions.push('Enable humanization.allowedWindows with varied schedules')
+ }
+
+ if (actions.length === 0) {
+ actions.push('Continue monitoring but no immediate action needed')
+ }
+
+ return actions
+ }
+
+ /**
+ * Export historical data for analysis
+ */
+ exportHistory(): HistoricalData[] {
+ return [...this.history]
+ }
+
+ /**
+ * Import historical data (for persistence)
+ */
+ importHistory(data: HistoricalData[]): void {
+ this.history = data.slice(-100) // Keep last 100
+ }
+}
diff --git a/src/util/ConclusionWebhook.ts b/src/util/ConclusionWebhook.ts
index a0c9e7c..fe7f0ec 100644
--- a/src/util/ConclusionWebhook.ts
+++ b/src/util/ConclusionWebhook.ts
@@ -12,13 +12,13 @@ type WebhookContext = 'summary' | 'ban' | 'security' | 'compromised' | 'spend' |
function pickUsername(ctx: WebhookContext, fallbackColor?: number): string {
switch (ctx) {
- case 'summary': return 'Summary'
- case 'ban': return 'Ban'
- case 'security': return 'Security'
- case 'compromised': return 'Pirate'
- case 'spend': return 'Spend'
- case 'error': return 'Error'
- default: return fallbackColor === 0xFF0000 ? 'Error' : 'Rewards'
+ case 'summary': return 'š MS Rewards Summary'
+ case 'ban': return 'š« Ban Alert'
+ case 'security': return 'š Security Alert'
+ case 'compromised': return 'ā ļø Security Issue'
+ case 'spend': return 'š³ Spend Notice'
+ case 'error': return 'ā Error Report'
+ default: return fallbackColor === 0xFF0000 ? 'ā Error Report' : 'šÆ MS Rewards'
}
}
diff --git a/src/util/ConfigValidator.ts b/src/util/ConfigValidator.ts
new file mode 100644
index 0000000..0a21cd7
--- /dev/null
+++ b/src/util/ConfigValidator.ts
@@ -0,0 +1,531 @@
+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.jsonc 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 schedule
+ if (config.schedule?.enabled) {
+ if (!config.schedule.timeZone) {
+ issues.push({
+ severity: 'warning',
+ field: 'schedule.timeZone',
+ message: 'No timeZone specified, defaulting to UTC',
+ suggestion: 'Set your local timezone (e.g., America/New_York)'
+ })
+ }
+
+ const useAmPm = config.schedule.useAmPm
+ const time12 = (config.schedule as unknown as Record)['time12']
+ const time24 = (config.schedule as unknown as Record)['time24']
+
+ if (useAmPm === true && (!time12 || (typeof time12 === 'string' && time12.trim() === ''))) {
+ issues.push({
+ severity: 'error',
+ field: 'schedule.time12',
+ message: 'useAmPm is true but time12 is empty'
+ })
+ }
+ if (useAmPm === false && (!time24 || (typeof time24 === 'string' && time24.trim() === ''))) {
+ issues.push({
+ severity: 'error',
+ field: 'schedule.time24',
+ message: 'useAmPm is false but time24 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'
+ })
+ }
+ }
+
+ // Check diagnostics
+ if (config.diagnostics?.enabled) {
+ const maxPerRun = config.diagnostics.maxPerRun || 2
+ if (maxPerRun > 20) {
+ issues.push({
+ severity: 'warning',
+ field: 'diagnostics.maxPerRun',
+ message: 'Very high maxPerRun may fill disk quickly'
+ })
+ }
+
+ const retention = config.diagnostics.retentionDays || 7
+ if (retention > 90) {
+ issues.push({
+ severity: 'info',
+ field: 'diagnostics.retentionDays',
+ message: 'Long retention period - monitor disk usage'
+ })
+ }
+ }
+
+ 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()
+ const seenProxies = new Map() // 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')
+
+ // Remove JSONC comments (basic approach)
+ 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
+ */
+ 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
+ }
+}
diff --git a/src/util/Logger.ts b/src/util/Logger.ts
index c229a31..efb6b7b 100644
--- a/src/util/Logger.ts
+++ b/src/util/Logger.ts
@@ -71,13 +71,10 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
// Access logging config with fallback for backward compatibility
const configAny = configData as unknown as Record
- const loggingConfig = configAny.logging || configData
- const loggingConfigAny = loggingConfig as unknown as Record
-
- const logExcludeFunc = Array.isArray(loggingConfigAny.excludeFunc) ? loggingConfigAny.excludeFunc :
- Array.isArray(loggingConfigAny.logExcludeFunc) ? loggingConfigAny.logExcludeFunc : []
+ const logging = configAny.logging as { excludeFunc?: string[]; logExcludeFunc?: string[] } | undefined
+ const logExcludeFunc = logging?.excludeFunc ?? (configData as { logExcludeFunc?: string[] }).logExcludeFunc ?? []
- if (Array.isArray(logExcludeFunc) && logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
+ if (logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
return
}
@@ -115,18 +112,47 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
}
} catch { /* ignore */ }
- // Console output with better formatting
- const typeIndicator = type === 'error' ? 'ā' : type === 'warn' ? 'ā ' : 'ā'
+ // 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} ${type.toUpperCase()}`),
+ typeColor(`${typeIndicator}`),
platformColor(`[${platformText}]`),
chalk.bold(`[${title}]`),
- redact(message)
+ iconPart + redact(message)
].join(' ')
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
diff --git a/src/util/QueryDiversityEngine.ts b/src/util/QueryDiversityEngine.ts
new file mode 100644
index 0000000..a9a3ef5
--- /dev/null
+++ b/src/util/QueryDiversityEngine.ts
@@ -0,0 +1,339 @@
+import axios from 'axios'
+
+export interface QuerySource {
+ name: string
+ weight: number // 0-1, probability of selection
+ fetchQueries: () => Promise
+}
+
+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 = new Map()
+
+ constructor(config?: Partial) {
+ 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 {
+ 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) {
+ console.warn(`Failed to fetch from ${sourceName}:`, error instanceof Error ? error.message : error)
+ }
+ }
+
+ // 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 {
+ 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:
+ console.warn(`Unknown source: ${source}`)
+ }
+
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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>/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 {
+ 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()
+
+ // 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(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()
+ }
+}
diff --git a/src/util/RiskManager.ts b/src/util/RiskManager.ts
new file mode 100644
index 0000000..012435f
--- /dev/null
+++ b/src/util/RiskManager.ts
@@ -0,0 +1,177 @@
+import { AdaptiveThrottler } from './AdaptiveThrottler'
+
+export interface RiskEvent {
+ type: 'captcha' | 'error' | 'timeout' | 'ban_hint' | 'success'
+ timestamp: number
+ severity: number // 0-10, higher = worse
+ context?: string
+}
+
+export interface RiskMetrics {
+ score: number // 0-100, higher = riskier
+ level: 'safe' | 'elevated' | 'high' | 'critical'
+ recommendation: string
+ delayMultiplier: number
+}
+
+/**
+ * RiskManager monitors account activity patterns and detects early ban signals.
+ * Integrates with AdaptiveThrottler to dynamically adjust delays based on risk.
+ */
+export class RiskManager {
+ private events: RiskEvent[] = []
+ private readonly maxEvents = 100
+ private readonly timeWindowMs = 3600000 // 1 hour
+ private throttler: AdaptiveThrottler
+ private cooldownUntil: number = 0
+
+ constructor(throttler?: AdaptiveThrottler) {
+ this.throttler = throttler || new AdaptiveThrottler()
+ }
+
+ /**
+ * Record a risk event (captcha, error, success, etc.)
+ */
+ recordEvent(type: RiskEvent['type'], severity: number, context?: string): void {
+ const event: RiskEvent = {
+ type,
+ timestamp: Date.now(),
+ severity: Math.max(0, Math.min(10, severity)),
+ context
+ }
+
+ this.events.push(event)
+ if (this.events.length > this.maxEvents) {
+ this.events.shift()
+ }
+
+ // Feed success/error into adaptive throttler
+ if (type === 'success') {
+ this.throttler.record(true)
+ } else if (['error', 'captcha', 'timeout', 'ban_hint'].includes(type)) {
+ this.throttler.record(false)
+ }
+
+ // Auto cool-down on critical events
+ if (severity >= 8) {
+ const coolMs = Math.min(300000, severity * 30000) // max 5min
+ this.cooldownUntil = Date.now() + coolMs
+ }
+ }
+
+ /**
+ * Calculate current risk metrics based on recent events
+ */
+ assessRisk(): RiskMetrics {
+ const now = Date.now()
+ const recentEvents = this.events.filter(e => now - e.timestamp < this.timeWindowMs)
+
+ if (recentEvents.length === 0) {
+ return {
+ score: 0,
+ level: 'safe',
+ recommendation: 'Normal operation',
+ delayMultiplier: 1.0
+ }
+ }
+
+ // Calculate base risk score (weighted by recency and severity)
+ let weightedSum = 0
+ let totalWeight = 0
+
+ for (const event of recentEvents) {
+ const age = now - event.timestamp
+ const recencyFactor = 1 - (age / this.timeWindowMs) // newer = higher weight
+ const weight = recencyFactor * (event.severity / 10)
+
+ weightedSum += weight * event.severity
+ totalWeight += weight
+ }
+
+ const baseScore = totalWeight > 0 ? (weightedSum / totalWeight) * 10 : 0
+
+ // Penalty for rapid event frequency
+ const eventRate = recentEvents.length / (this.timeWindowMs / 60000) // events per minute
+ const frequencyPenalty = Math.min(30, eventRate * 5)
+
+ // Bonus penalty for specific patterns
+ const captchaCount = recentEvents.filter(e => e.type === 'captcha').length
+ const banHintCount = recentEvents.filter(e => e.type === 'ban_hint').length
+ const patternPenalty = (captchaCount * 15) + (banHintCount * 25)
+
+ const finalScore = Math.min(100, baseScore + frequencyPenalty + patternPenalty)
+
+ // Determine risk level
+ let level: RiskMetrics['level']
+ let recommendation: string
+ let delayMultiplier: number
+
+ if (finalScore < 20) {
+ level = 'safe'
+ recommendation = 'Normal operation'
+ delayMultiplier = 1.0
+ } else if (finalScore < 40) {
+ level = 'elevated'
+ recommendation = 'Minor issues detected. Increasing delays slightly.'
+ delayMultiplier = 1.5
+ } else if (finalScore < 70) {
+ level = 'high'
+ recommendation = 'Significant risk detected. Applying heavy throttling.'
+ delayMultiplier = 2.5
+ } else {
+ level = 'critical'
+ recommendation = 'CRITICAL: High ban risk. Consider stopping or manual review.'
+ delayMultiplier = 4.0
+ }
+
+ // Apply adaptive throttler multiplier on top
+ const adaptiveMultiplier = this.throttler.getDelayMultiplier()
+ delayMultiplier *= adaptiveMultiplier
+
+ return {
+ score: Math.round(finalScore),
+ level,
+ recommendation,
+ delayMultiplier: Number(delayMultiplier.toFixed(2))
+ }
+ }
+
+ /**
+ * Check if currently in forced cool-down period
+ */
+ isInCooldown(): boolean {
+ return Date.now() < this.cooldownUntil
+ }
+
+ /**
+ * Get remaining cool-down time in milliseconds
+ */
+ getCooldownRemaining(): number {
+ const remaining = this.cooldownUntil - Date.now()
+ return Math.max(0, remaining)
+ }
+
+ /**
+ * Get the adaptive throttler instance for advanced usage
+ */
+ getThrottler(): AdaptiveThrottler {
+ return this.throttler
+ }
+
+ /**
+ * Clear all events and reset state (use between accounts)
+ */
+ reset(): void {
+ this.events = []
+ this.cooldownUntil = 0
+ // Keep throttler state across resets for learning
+ }
+
+ /**
+ * Export events for analytics/logging
+ */
+ getRecentEvents(limitMinutes: number = 60): RiskEvent[] {
+ const cutoff = Date.now() - (limitMinutes * 60000)
+ return this.events.filter(e => e.timestamp >= cutoff)
+ }
+}