├── .gitattributes ├── scripts ├── nix │ └── run.sh ├── docker │ ├── entrypoint.sh │ └── run_daily.sh └── clearSessions.js ├── .gitignore ├── src ├── interface │ ├── OAuth.ts │ ├── Account.ts │ ├── Points.ts │ ├── XboxDashboardData.ts │ ├── UserAgentUtil.ts │ ├── QuizData.ts │ ├── Config.ts │ ├── Search.ts │ ├── AppDashBoardData.ts │ └── AppUserData.ts ├── crontab.template ├── accounts.example.json ├── util │ ├── ErrorDiagnostic.ts │ ├── Utils.ts │ ├── Axios.ts │ └── Load.ts ├── logging │ ├── Discord.ts │ ├── Ntfy.ts │ └── Logger.ts ├── config.example.json ├── functions │ ├── Activities.ts │ ├── activities │ │ ├── app │ │ │ ├── AppReward.ts │ │ │ ├── ReadToEarn.ts │ │ │ └── DailyCheckIn.ts │ │ ├── api │ │ │ ├── UrlReward.ts │ │ │ ├── FindClippy.ts │ │ │ └── Quiz.ts │ │ └── browser │ │ │ └── SearchOnBing.ts │ ├── QueryEngine.ts │ ├── Workers.ts │ └── queries.json └── browser │ ├── auth │ └── methods │ │ ├── EmailLogin.ts │ │ ├── MobileAccessLogin.ts │ │ ├── PasswordlessLogin.ts │ │ └── Totp2FALogin.ts │ ├── Browser.ts │ ├── UserAgent.ts │ ├── BrowserUtils.ts │ └── BrowserFunc.ts ├── .prettierrc ├── README.md ├── .eslintrc.js ├── flake.nix ├── flake.lock ├── compose.yaml ├── package.json ├── Dockerfile └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf 2 | *.template text eol=lf 3 | -------------------------------------------------------------------------------- /scripts/nix/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | nix develop --command bash -c "xvfb-run npm run start" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sessions/ 2 | dist/ 3 | .dev/ 4 | node_modules/ 5 | src/accounts.json 6 | src/config.json 7 | /.vscode 8 | /diagnostics 9 | note 10 | accounts.dev.json 11 | accounts.main.json 12 | .DS_Store 13 | .playwright-chromium-installed -------------------------------------------------------------------------------- /src/interface/OAuth.ts: -------------------------------------------------------------------------------- 1 | export interface OAuth { 2 | access_token: string 3 | refresh_token: string 4 | scope: string 5 | expires_in: number 6 | ext_expires_in: number 7 | foci: string 8 | token_type: string 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "tabWidth": 4, 6 | "useTabs": false, 7 | "printWidth": 120, 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid", 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /src/interface/Account.ts: -------------------------------------------------------------------------------- 1 | export interface Account { 2 | email: string 3 | password: string 4 | totp?: string 5 | geoLocale: 'auto' | string 6 | proxy: AccountProxy 7 | } 8 | 9 | export interface AccountProxy { 10 | proxyAxios: boolean 11 | url: string 12 | port: number 13 | password: string 14 | username: string 15 | } 16 | -------------------------------------------------------------------------------- /src/crontab.template: -------------------------------------------------------------------------------- 1 | # Set PATH so cron jobs can find node/npm 2 | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 3 | # Set timezone for cron jobs 4 | TZ=${TZ} 5 | 6 | # Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs 7 | ${CRON_SCHEDULE} /bin/bash /usr/src/microsoft-rewards-script/scripts/docker/run_daily.sh >> /proc/1/fd/1 2>&1 8 | -------------------------------------------------------------------------------- /src/interface/Points.ts: -------------------------------------------------------------------------------- 1 | export interface BrowserEarnablePoints { 2 | desktopSearchPoints: number 3 | mobileSearchPoints: number 4 | dailySetPoints: number 5 | morePromotionsPoints: number 6 | totalEarnablePoints: number 7 | } 8 | 9 | export interface AppEarnablePoints { 10 | readToEarn: number 11 | checkIn: number 12 | totalEarnablePoints: number 13 | } 14 | 15 | export interface MissingSearchPoints { 16 | mobilePoints: number 17 | desktopPoints: number 18 | edgePoints: number 19 | totalPoints: number 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Discord](https://img.shields.io/badge/Join%20Our%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/8BxYbV4pkj) 2 | 3 | --- 4 | 5 | TODO 6 | 7 | [For installation see the main (v1) or v2 branch (mostly the same)](https://github.com/TheNetsky/Microsoft-Rewards-Script/tree/main?tab=readme-ov-file#setup) 8 | 9 | ## Disclaimer 10 | 11 | Use at your own risk. 12 | Automation of Microsoft Rewards may lead to account suspension or bans. 13 | This software is provided for educational purposes only. 14 | The authors are not responsible for any actions taken by Microsoft. 15 | -------------------------------------------------------------------------------- /src/accounts.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "email": "email_1", 4 | "password": "password_1", 5 | "totp": "", 6 | "geoLocale": "auto", 7 | "proxy": { 8 | "proxyAxios": true, 9 | "url": "", 10 | "port": 0, 11 | "username": "", 12 | "password": "" 13 | } 14 | }, 15 | { 16 | "email": "email_2", 17 | "password": "password_2", 18 | "totp": "", 19 | "geoLocale": "auto", 20 | "proxy": { 21 | "proxyAxios": true, 22 | "url": "", 23 | "port": 0, 24 | "username": "", 25 | "password": "" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 12, 10 | sourceType: 'module' 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: { 14 | 'linebreak-style': ['error', 'unix'], 15 | quotes: ['error', 'single'], 16 | semi: ['error', 'never'], 17 | '@typescript-eslint/no-explicit-any': [ 18 | 'warn', 19 | { 20 | fixToUnknown: false 21 | } 22 | ], 23 | 'comma-dangle': 'off', 24 | '@typescript-eslint/comma-dangle': 'error', 25 | 'prefer-arrow-callback': 'error', 26 | 'no-empty': 'off' 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; 4 | flake-utils = { 5 | url = "github:numtide/flake-utils"; 6 | }; 7 | }; 8 | 9 | outputs = 10 | { nixpkgs, flake-utils, ... }: 11 | flake-utils.lib.eachDefaultSystem ( 12 | system: 13 | let 14 | pkgs = import nixpkgs { 15 | inherit system; 16 | }; 17 | in 18 | { 19 | devShell = pkgs.mkShell { 20 | nativeBuildInputs = with pkgs; [ 21 | nodejs 22 | playwright-driver.browsers 23 | typescript 24 | playwright-test 25 | 26 | # fixes "waiting until load" issue compared to 27 | # setting headless in config.json 28 | xvfb-run 29 | ]; 30 | 31 | shellHook = '' 32 | export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} 33 | export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true 34 | npm i 35 | npm run build 36 | ''; 37 | }; 38 | } 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/interface/XboxDashboardData.ts: -------------------------------------------------------------------------------- 1 | export interface XboxDashboardData { 2 | response: Response 3 | correlationId: string 4 | code: number 5 | } 6 | 7 | export interface Response { 8 | profile: null 9 | balance: number 10 | counters: { [key: string]: string } 11 | promotions: Promotion[] 12 | catalog: null 13 | goal_item: null 14 | activities: null 15 | cashback: null 16 | orders: null 17 | rebateProfile: null 18 | rebatePayouts: null 19 | giveProfile: null 20 | autoRedeemProfile: null 21 | autoRedeemItem: null 22 | thirdPartyProfile: null 23 | notifications: null 24 | waitlist: null 25 | autoOpenFlyout: null 26 | coupons: null 27 | recommendedAffordableCatalog: null 28 | generativeAICreditsBalance: null 29 | requestCountryCatalog: null 30 | donationCatalog: null 31 | } 32 | 33 | export interface Promotion { 34 | name: string 35 | priority: number 36 | attributes: { [key: string]: string } 37 | tags: Tag[] 38 | } 39 | 40 | export enum Tag { 41 | ExcludeHidden = 'exclude_hidden', 42 | NonGlobalConfig = 'non_global_config' 43 | } 44 | -------------------------------------------------------------------------------- /src/interface/UserAgentUtil.ts: -------------------------------------------------------------------------------- 1 | // Chrome Product Data 2 | export interface ChromeVersion { 3 | timestamp: Date 4 | channels: Channels 5 | } 6 | 7 | export interface Channels { 8 | Stable: Beta 9 | Beta: Beta 10 | Dev: Beta 11 | Canary: Beta 12 | } 13 | 14 | export interface Beta { 15 | channel: string 16 | version: string 17 | revision: string 18 | } 19 | 20 | // Edge Product Data 21 | export interface EdgeVersion { 22 | Product: string 23 | Releases: Release[] 24 | } 25 | 26 | export interface Release { 27 | ReleaseId: number 28 | Platform: Platform 29 | Architecture: Architecture 30 | CVEs: string[] 31 | ProductVersion: string 32 | Artifacts: Artifact[] 33 | PublishedTime: Date 34 | ExpectedExpiryDate: Date 35 | } 36 | 37 | export enum Architecture { 38 | Arm64 = 'arm64', 39 | Universal = 'universal', 40 | X64 = 'x64', 41 | X86 = 'x86' 42 | } 43 | 44 | export interface Artifact { 45 | ArtifactName: string 46 | Location: string 47 | Hash: string 48 | HashAlgorithm: HashAlgorithm 49 | SizeInBytes: number 50 | } 51 | 52 | export enum HashAlgorithm { 53 | Sha256 = 'SHA256' 54 | } 55 | 56 | export enum Platform { 57 | Android = 'Android', 58 | IOS = 'iOS', 59 | Linux = 'Linux', 60 | MACOS = 'MacOS', 61 | Windows = 'Windows' 62 | } 63 | -------------------------------------------------------------------------------- /src/util/ErrorDiagnostic.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import path from 'path' 3 | import type { Page } from 'patchright' 4 | 5 | export async function errorDiagnostic(page: Page, error: Error): Promise { 6 | try { 7 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-') 8 | const folderName = `error-${timestamp}` 9 | const outputDir = path.join(process.cwd(), 'diagnostics', folderName) 10 | 11 | if (!page) { 12 | return 13 | } 14 | 15 | if (page.isClosed()) { 16 | return 17 | } 18 | 19 | // Error log content 20 | const errorLog = ` 21 | Name: ${error.name} 22 | Message: ${error.message} 23 | Timestamp: ${new Date().toISOString()} 24 | --------------------------------------------------- 25 | Stack Trace: 26 | ${error.stack || 'No stack trace available'} 27 | `.trim() 28 | 29 | const [htmlContent, screenshotBuffer] = await Promise.all([ 30 | page.content(), 31 | page.screenshot({ fullPage: true, type: 'png' }) 32 | ]) 33 | 34 | await fs.mkdir(outputDir, { recursive: true }) 35 | 36 | await Promise.all([ 37 | fs.writeFile(path.join(outputDir, 'dump.html'), htmlContent), 38 | fs.writeFile(path.join(outputDir, 'screenshot.png'), screenshotBuffer), 39 | fs.writeFile(path.join(outputDir, 'error.txt'), errorLog) 40 | ]) 41 | 42 | console.log(`Diagnostics saved to: ${outputDir}`) 43 | } catch (error) { 44 | console.error('Unable to create error diagnostics:', error) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/logging/Discord.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios' 2 | import PQueue from 'p-queue' 3 | import type { LogLevel } from './Logger' 4 | 5 | const DISCORD_LIMIT = 2000 6 | 7 | export interface DiscordConfig { 8 | enabled?: boolean 9 | url: string 10 | } 11 | 12 | const discordQueue = new PQueue({ 13 | interval: 1000, 14 | intervalCap: 2, 15 | carryoverConcurrencyCount: true 16 | }) 17 | 18 | function truncate(text: string) { 19 | return text.length <= DISCORD_LIMIT ? text : text.slice(0, DISCORD_LIMIT - 14) + ' …(truncated)' 20 | } 21 | 22 | export async function sendDiscord(discordUrl: string, content: string, level: LogLevel): Promise { 23 | if (!discordUrl) return 24 | 25 | const request: AxiosRequestConfig = { 26 | method: 'POST', 27 | url: discordUrl, 28 | headers: { 'Content-Type': 'application/json' }, 29 | data: { content: truncate(content), allowed_mentions: { parse: [] } }, 30 | timeout: 10000 31 | } 32 | 33 | await discordQueue.add(async () => { 34 | try { 35 | await axios(request) 36 | } catch (err: any) { 37 | const status = err?.response?.status 38 | if (status === 429) return 39 | } 40 | }) 41 | } 42 | 43 | export async function flushDiscordQueue(timeoutMs = 5000): Promise { 44 | await Promise.race([ 45 | (async () => { 46 | await discordQueue.onIdle() 47 | })(), 48 | new Promise((_, reject) => setTimeout(() => reject(new Error('discord flush timeout')), timeoutMs)) 49 | ]).catch(() => {}) 50 | } 51 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1749727998, 24 | "narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-25.05", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /src/interface/QuizData.ts: -------------------------------------------------------------------------------- 1 | export interface QuizData { 2 | offerId: string 3 | quizId: string 4 | quizCategory: string 5 | IsCurrentQuestionCompleted: boolean 6 | quizRenderSummaryPage: boolean 7 | resetQuiz: boolean 8 | userClickedOnHint: boolean 9 | isDemoEnabled: boolean 10 | correctAnswer: string 11 | isMultiChoiceQuizType: boolean 12 | isPutInOrderQuizType: boolean 13 | isListicleQuizType: boolean 14 | isWOTQuizType: boolean 15 | isBugsForRewardsQuizType: boolean 16 | currentQuestionNumber: number 17 | maxQuestions: number 18 | resetTrackingCounters: boolean 19 | showWelcomePanel: boolean 20 | isAjaxCall: boolean 21 | showHint: boolean 22 | numberOfOptions: number 23 | isMobile: boolean 24 | inRewardsMode: boolean 25 | enableDailySetWelcomePane: boolean 26 | enableDailySetNonWelcomePane: boolean 27 | isDailySetUrlOffer: boolean 28 | isDailySetFlightEnabled: boolean 29 | dailySetUrlOfferId: string 30 | earnedCredits: number 31 | maxCredits: number 32 | creditsPerQuestion: number 33 | userAlreadyClickedOptions: number 34 | hasUserClickedOnOption: boolean 35 | recentAnswerChoice: string 36 | sessionTimerSeconds: string 37 | isOverlayMinimized: number 38 | ScreenReaderMsgOnMove: string 39 | ScreenReaderMsgOnDrop: string 40 | IsPartialPointsEnabled: boolean 41 | PrioritizeUrlOverCookies: boolean 42 | UseNewReportActivityAPI: boolean 43 | CorrectlyAnsweredQuestionCount: number 44 | showJoinRewardsPage: boolean 45 | CorrectOptionAnswer_WOT: string 46 | WrongOptionAnswer_WOT: string 47 | enableSlideAnimation: boolean 48 | ariaLoggingEnabled: boolean 49 | UseQuestionIndexInActivityId: boolean 50 | } 51 | -------------------------------------------------------------------------------- /scripts/docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Ensure Playwright uses preinstalled browsers 5 | export PLAYWRIGHT_BROWSERS_PATH=0 6 | 7 | # 1. Timezone: default to UTC if not provided 8 | : "${TZ:=UTC}" 9 | ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime 10 | echo "$TZ" > /etc/timezone 11 | dpkg-reconfigure -f noninteractive tzdata 12 | 13 | # 2. Validate CRON_SCHEDULE 14 | if [ -z "${CRON_SCHEDULE:-}" ]; then 15 | echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2 16 | echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2 17 | exit 1 18 | fi 19 | 20 | # 3. Initial run without sleep if RUN_ON_START=true 21 | if [ "${RUN_ON_START:-false}" = "true" ]; then 22 | echo "[entrypoint] Starting initial run in background at $(date)" 23 | ( 24 | cd /usr/src/microsoft-rewards-script || { 25 | echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2 26 | exit 1 27 | } 28 | # Skip random sleep for initial run, but preserve setting for cron jobs 29 | SKIP_RANDOM_SLEEP=true scripts/docker/run_daily.sh 30 | echo "[entrypoint-bg] Initial run completed at $(date)" 31 | ) & 32 | echo "[entrypoint] Background process started (PID: $!)" 33 | fi 34 | 35 | # 4. Template and register cron file with explicit timezone export 36 | if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then 37 | echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2 38 | exit 1 39 | fi 40 | 41 | # Export TZ for envsubst to use 42 | export TZ 43 | envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron 44 | chmod 0644 /etc/cron.d/microsoft-rewards-cron 45 | crontab /etc/cron.d/microsoft-rewards-cron 46 | 47 | echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)" 48 | 49 | # 5. Start cron in foreground (PID 1) 50 | exec cron -f -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | microsoft-rewards-script: 3 | build: . 4 | container_name: microsoft-rewards-script 5 | restart: unless-stopped 6 | 7 | # Volume mounts: Specify a location where you want to save the files on your local machine. 8 | volumes: 9 | - ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro 10 | - ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro 11 | - ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session 12 | 13 | environment: 14 | TZ: 'America/Toronto' # Set your timezone for proper scheduling 15 | NODE_ENV: 'production' 16 | CRON_SCHEDULE: '0 7 * * *' # Customize your schedule, use crontab.guru for formatting 17 | RUN_ON_START: 'true' # Runs the script immediately on container startup 18 | 19 | # Add scheduled start-time randomization (uncomment to customize or disable, default: enabled) 20 | #MIN_SLEEP_MINUTES: "5" 21 | #MAX_SLEEP_MINUTES: "50" 22 | SKIP_RANDOM_SLEEP: 'false' 23 | 24 | # Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours) 25 | #STUCK_PROCESS_TIMEOUT_HOURS: "8" 26 | 27 | # Optional resource limits for the container 28 | mem_limit: 4g 29 | cpus: 2 30 | 31 | # Health check - monitors if cron daemon is running to ensure scheduled jobs can execute 32 | # Container marked unhealthy if cron process dies 33 | healthcheck: 34 | test: ['CMD', 'sh', '-c', 'pgrep cron > /dev/null || exit 1'] 35 | interval: 60s 36 | timeout: 10s 37 | retries: 3 38 | start_period: 30s 39 | 40 | # Security hardening 41 | security_opt: 42 | - no-new-privileges:true 43 | -------------------------------------------------------------------------------- /src/logging/Ntfy.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios' 2 | import PQueue from 'p-queue' 3 | import type { WebhookNtfyConfig } from '../interface/Config' 4 | import type { LogLevel } from './Logger' 5 | 6 | const ntfyQueue = new PQueue({ 7 | interval: 1000, 8 | intervalCap: 2, 9 | carryoverConcurrencyCount: true 10 | }) 11 | 12 | export async function sendNtfy(config: WebhookNtfyConfig, content: string, level: LogLevel): Promise { 13 | if (!config?.url) return 14 | 15 | switch (level) { 16 | case 'error': 17 | config.priority = 5 // Highest 18 | break 19 | 20 | case 'warn': 21 | config.priority = 4 22 | break 23 | 24 | default: 25 | break 26 | } 27 | 28 | const headers: Record = { 'Content-Type': 'text/plain' } 29 | if (config.title) headers['Title'] = config.title 30 | if (config.tags?.length) headers['Tags'] = config.tags.join(',') 31 | if (config.priority) headers['Priority'] = String(config.priority) 32 | if (config.token) headers['Authorization'] = `Bearer ${config.token}` 33 | 34 | const url = config.topic ? `${config.url}/${config.topic}` : config.url 35 | 36 | const request: AxiosRequestConfig = { 37 | method: 'POST', 38 | url: url, 39 | headers, 40 | data: content, 41 | timeout: 10000 42 | } 43 | 44 | await ntfyQueue.add(async () => { 45 | try { 46 | await axios(request) 47 | } catch (err: any) { 48 | const status = err?.response?.status 49 | if (status === 429) return 50 | } 51 | }) 52 | } 53 | 54 | export async function flushNtfyQueue(timeoutMs = 5000): Promise { 55 | await Promise.race([ 56 | (async () => { 57 | await ntfyQueue.onIdle() 58 | })(), 59 | new Promise((_, reject) => setTimeout(() => reject(new Error('ntfy flush timeout')), timeoutMs)) 60 | ]).catch(() => {}) 61 | } 62 | -------------------------------------------------------------------------------- /src/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseURL": "https://rewards.bing.com", 3 | "sessionPath": "sessions", 4 | "headless": false, 5 | "runOnZeroPoints": false, 6 | "clusters": 1, 7 | "errorDiagnostics": true, 8 | "saveFingerprint": { 9 | "mobile": false, 10 | "desktop": false 11 | }, 12 | "workers": { 13 | "doDailySet": true, 14 | "doMorePromotions": true, 15 | "doPunchCards": true, 16 | "doAppPromotions": true, 17 | "doDesktopSearch": true, 18 | "doMobileSearch": true, 19 | "doDailyCheckIn": true, 20 | "doReadToEarn": true 21 | }, 22 | "searchOnBingLocalQueries": false, 23 | "globalTimeout": "30sec", 24 | "searchSettings": { 25 | "scrollRandomResults": false, 26 | "clickRandomResults": false, 27 | "parallelSearching": true, 28 | "searchResultVisitTime": "10sec", 29 | "searchDelay": { 30 | "min": "30sec", 31 | "max": "1min" 32 | }, 33 | "readDelay": { 34 | "min": "30sec", 35 | "max": "1min" 36 | } 37 | }, 38 | "debugLogs": false, 39 | "consoleLogFilter": { 40 | "enabled": false, 41 | "mode": "whitelist", 42 | "levels": ["error", "warn"], 43 | "keywords": ["starting account"], 44 | "regexPatterns": [] 45 | }, 46 | "proxy": { 47 | "queryEngine": true 48 | }, 49 | "webhook": { 50 | "discord": { 51 | "enabled": false, 52 | "url": "" 53 | }, 54 | "ntfy": { 55 | "enabled": false, 56 | "url": "", 57 | "topic": "", 58 | "token": "", 59 | "title": "Microsoft-Rewards-Script", 60 | "tags": ["bot", "notify"], 61 | "priority": 3 62 | }, 63 | "webhookLogFilter": { 64 | "enabled": false, 65 | "mode": "whitelist", 66 | "levels": ["error"], 67 | "keywords": ["starting account", "select number", "collected"], 68 | "regexPatterns": [] 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/interface/Config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | baseURL: string 3 | sessionPath: string 4 | headless: boolean 5 | runOnZeroPoints: boolean 6 | clusters: number 7 | errorDiagnostics: boolean 8 | saveFingerprint: ConfigSaveFingerprint 9 | workers: ConfigWorkers 10 | searchOnBingLocalQueries: boolean 11 | globalTimeout: number | string 12 | searchSettings: ConfigSearchSettings 13 | debugLogs: boolean 14 | proxy: ConfigProxy 15 | consoleLogFilter: LogFilter 16 | webhook: ConfigWebhook 17 | } 18 | 19 | export interface ConfigSaveFingerprint { 20 | mobile: boolean 21 | desktop: boolean 22 | } 23 | 24 | export interface ConfigSearchSettings { 25 | scrollRandomResults: boolean 26 | clickRandomResults: boolean 27 | parallelSearching: boolean 28 | searchResultVisitTime: number | string 29 | searchDelay: ConfigDelay 30 | readDelay: ConfigDelay 31 | } 32 | 33 | export interface ConfigDelay { 34 | min: number | string 35 | max: number | string 36 | } 37 | 38 | export interface ConfigProxy { 39 | queryEngine: boolean 40 | } 41 | 42 | export interface ConfigWorkers { 43 | doDailySet: boolean 44 | doMorePromotions: boolean 45 | doPunchCards: boolean 46 | doAppPromotions: boolean 47 | doDesktopSearch: boolean 48 | doMobileSearch: boolean 49 | doDailyCheckIn: boolean 50 | doReadToEarn: boolean 51 | } 52 | 53 | // Webhooks 54 | export interface ConfigWebhook { 55 | discord?: WebhookDiscordConfig 56 | ntfy?: WebhookNtfyConfig 57 | webhookLogFilter: LogFilter 58 | } 59 | 60 | export interface LogFilter { 61 | enabled: boolean 62 | mode: 'whitelist' | 'blacklist' 63 | levels?: Array<'debug' | 'info' | 'warn' | 'error'> 64 | keywords?: string[] 65 | regexPatterns?: string[] 66 | } 67 | 68 | export interface WebhookDiscordConfig { 69 | enabled: boolean 70 | url: string 71 | } 72 | 73 | export interface WebhookNtfyConfig { 74 | enabled?: boolean 75 | url: string 76 | topic?: string 77 | token?: string 78 | title?: string 79 | tags?: string[] 80 | priority?: 1 | 2 | 3 | 4 | 5 // 5 highest (important) 81 | } 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "microsoft-rewards-script", 3 | "version": "3.0.1", 4 | "description": "Automatically do tasks for Microsoft Rewards but in TS!", 5 | "author": "Netsky", 6 | "license": "GPL-3.0-or-later", 7 | "main": "dist/index.js", 8 | "engines": { 9 | "node": ">=18.0.0" 10 | }, 11 | "scripts": { 12 | "pre-build": "npm i && rimraf dist && npx patchright install chromium", 13 | "build": "rimraf dist && tsc", 14 | "start": "node ./dist/index.js", 15 | "ts-start": "ts-node ./src/index.ts", 16 | "dev": "ts-node ./src/index.ts -dev", 17 | "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", 18 | "create-docker": "docker build -t microsoft-rewards-script-docker .", 19 | "format": "prettier --write .", 20 | "format:check": "prettier --check .", 21 | "clear-sessions": "node ./scripts/clearSessions.js", 22 | "clear-diagnostics": "rimraf diagnostics" 23 | }, 24 | "keywords": [ 25 | "Bing Rewards", 26 | "Microsoft Rewards", 27 | "Bot", 28 | "Script", 29 | "TypeScript", 30 | "Playwright", 31 | "Cheerio" 32 | ], 33 | "devDependencies": { 34 | "@types/ms": "^2.1.0", 35 | "@types/node": "^24.10.1", 36 | "@typescript-eslint/eslint-plugin": "^8.48.0", 37 | "eslint": "^9.39.1", 38 | "eslint-plugin-modules-newline": "^0.0.6", 39 | "prettier": "^3.7.1", 40 | "rimraf": "^6.1.2", 41 | "typescript": "^5.9.3" 42 | }, 43 | "dependencies": { 44 | "axios": "^1.13.2", 45 | "axios-retry": "^4.5.0", 46 | "chalk": "^4.1.2", 47 | "cheerio": "^1.0.0", 48 | "fingerprint-generator": "^2.1.77", 49 | "fingerprint-injector": "^2.1.77", 50 | "ghost-cursor-playwright-port": "^1.4.3", 51 | "http-proxy-agent": "^7.0.2", 52 | "https-proxy-agent": "^7.0.6", 53 | "ms": "^2.1.3", 54 | "otpauth": "^9.4.1", 55 | "p-queue": "^9.0.1", 56 | "patchright": "^1.57.0", 57 | "ts-node": "^10.9.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/interface/Search.ts: -------------------------------------------------------------------------------- 1 | // Google Trends 2 | export type GoogleTrendsResponse = [string, [string, ...null[], [string, ...string[]]][]] 3 | 4 | export interface GoogleSearch { 5 | topic: string 6 | related: string[] 7 | } 8 | 9 | // Bing Suggestions 10 | export interface BingSuggestionResponse { 11 | _type: string 12 | instrumentation: BingInstrumentation 13 | queryContext: BingQueryContext 14 | suggestionGroups: BingSuggestionGroup[] 15 | } 16 | 17 | export interface BingInstrumentation { 18 | _type: string 19 | pingUrlBase: string 20 | pageLoadPingUrl: string 21 | llmPingUrlBase: string 22 | llmLogPingUrlBase: string 23 | } 24 | 25 | export interface BingQueryContext { 26 | originalQuery: string 27 | } 28 | 29 | export interface BingSuggestionGroup { 30 | name: string 31 | searchSuggestions: BingSearchSuggestion[] 32 | } 33 | 34 | export interface BingSearchSuggestion { 35 | url: string 36 | urlPingSuffix: string 37 | displayText: string 38 | query: string 39 | result?: BingResult[] 40 | searchKind?: string 41 | } 42 | 43 | export interface BingResult { 44 | id: string 45 | readLink: string 46 | readLinkPingSuffix: string 47 | webSearchUrl: string 48 | webSearchUrlPingSuffix: string 49 | name: string 50 | image: BingSuggestionImage 51 | description: string 52 | entityPresentationInfo: BingEntityPresentationInfo 53 | bingId: string 54 | } 55 | 56 | export interface BingEntityPresentationInfo { 57 | entityScenario: string 58 | entityTypeDisplayHint: string 59 | query: string 60 | } 61 | 62 | export interface BingSuggestionImage { 63 | thumbnailUrl: string 64 | hostPageUrl: string 65 | hostPageUrlPingSuffix: string 66 | width: number 67 | height: number 68 | sourceWidth: number 69 | sourceHeight: number 70 | } 71 | 72 | // Bing Tending Topics 73 | export interface BingTrendingTopicsResponse { 74 | _type: string 75 | instrumentation: BingInstrumentation 76 | value: BingValue[] 77 | } 78 | 79 | export interface BingValue { 80 | webSearchUrl: string 81 | webSearchUrlPingSuffix: string 82 | name: string 83 | image: BingTrendingImage 84 | isBreakingNews: boolean 85 | query: BingTrendingQuery 86 | newsSearchUrl: string 87 | newsSearchUrlPingSuffix: string 88 | } 89 | 90 | export interface BingTrendingImage { 91 | url: string 92 | } 93 | 94 | export interface BingTrendingQuery { 95 | text: string 96 | } 97 | -------------------------------------------------------------------------------- /src/util/Utils.ts: -------------------------------------------------------------------------------- 1 | import ms, { StringValue } from 'ms' 2 | 3 | export default class Util { 4 | async wait(time: number | string): Promise { 5 | if (typeof time === 'string') { 6 | time = this.stringToNumber(time) 7 | } 8 | 9 | return new Promise(resolve => { 10 | setTimeout(resolve, time) 11 | }) 12 | } 13 | 14 | getFormattedDate(ms = Date.now()): string { 15 | const today = new Date(ms) 16 | const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0 17 | const day = String(today.getDate()).padStart(2, '0') 18 | const year = today.getFullYear() 19 | 20 | return `${month}/${day}/${year}` 21 | } 22 | 23 | shuffleArray(array: T[]): T[] { 24 | return array 25 | .map(value => ({ value, sort: Math.random() })) 26 | .sort((a, b) => a.sort - b.sort) 27 | .map(({ value }) => value) 28 | } 29 | 30 | randomNumber(min: number, max: number): number { 31 | return Math.floor(Math.random() * (max - min + 1)) + min 32 | } 33 | 34 | chunkArray(arr: T[], numChunks: number): T[][] { 35 | const chunkSize = Math.ceil(arr.length / numChunks) 36 | const chunks: T[][] = [] 37 | 38 | for (let i = 0; i < arr.length; i += chunkSize) { 39 | const chunk = arr.slice(i, i + chunkSize) 40 | chunks.push(chunk) 41 | } 42 | 43 | return chunks 44 | } 45 | 46 | stringToNumber(input: string | number): number { 47 | if (typeof input === 'number') { 48 | return input 49 | } 50 | const value = input.trim() 51 | 52 | const milisec = ms(value as StringValue) 53 | 54 | if (milisec === undefined) { 55 | throw new Error( 56 | `The input provided (${input}) cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"` 57 | ) 58 | } 59 | 60 | return milisec 61 | } 62 | 63 | normalizeString(string: string): string { 64 | return string 65 | .normalize('NFD') 66 | .trim() 67 | .toLowerCase() 68 | .replace(/[^\x20-\x7E]/g, '') 69 | .replace(/[?!]/g, '') 70 | } 71 | 72 | getEmailUsername(email: string): string { 73 | return email.split('@')[0] ?? 'Unknown' 74 | } 75 | 76 | randomDelay(min: string | number, max: string | number): number { 77 | const minMs = typeof min === 'number' ? min : this.stringToNumber(min) 78 | const maxMs = typeof max === 'number' ? max : this.stringToNumber(max) 79 | return Math.floor(this.randomNumber(minMs, maxMs)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/interface/AppDashBoardData.ts: -------------------------------------------------------------------------------- 1 | export interface AppDashboardData { 2 | response: Response 3 | correlationId: string 4 | code: number 5 | } 6 | 7 | export interface Response { 8 | profile: Profile 9 | balance: number 10 | counters: null 11 | promotions: Promotion[] 12 | catalog: null 13 | goal_item: GoalItem 14 | activities: null 15 | cashback: null 16 | orders: unknown[] 17 | rebateProfile: null 18 | rebatePayouts: null 19 | giveProfile: null 20 | autoRedeemProfile: null 21 | autoRedeemItem: null 22 | thirdPartyProfile: null 23 | notifications: null 24 | waitlist: null 25 | autoOpenFlyout: null 26 | coupons: null 27 | recommendedAffordableCatalog: null 28 | generativeAICreditsBalance: null 29 | requestCountryCatalog: null 30 | donationCatalog: null 31 | } 32 | 33 | export interface GoalItem { 34 | name: string 35 | provider: string 36 | price: number 37 | attributes: GoalItemAttributes 38 | config: Config 39 | } 40 | 41 | export interface GoalItemAttributes { 42 | category: string 43 | CategoryDescription: string 44 | 'desc.group_text': string 45 | 'desc.legal_text': string 46 | 'desc.sc_description': string 47 | 'desc.sc_title': string 48 | display_order: string 49 | ExtraLargeImage: string 50 | group: string 51 | group_image: string 52 | group_sc_image: string 53 | group_title: string 54 | hidden: string 55 | large_image: string 56 | large_sc_image: string 57 | medium_image: string 58 | MobileImage: string 59 | original_price: string 60 | points_destination: string 61 | points_source: string 62 | Remarks: string 63 | ShortText: string 64 | showcase: string 65 | small_image: string 66 | title: string 67 | cimsid: string 68 | user_defined_goal: string 69 | } 70 | 71 | export interface Config { 72 | isHidden: string 73 | } 74 | 75 | export interface Profile { 76 | ruid: string 77 | attributes: ProfileAttributes 78 | offline_attributes: OfflineAttributes 79 | } 80 | 81 | export interface ProfileAttributes { 82 | ismsaautojoined: string 83 | created: Date 84 | creative: string 85 | publisher: string 86 | program: string 87 | country: string 88 | target: string 89 | epuid: string 90 | level: string 91 | level_upd: Date 92 | iris_segmentation: string 93 | iris_segmentation_upd: Date 94 | waitlistattributes: string 95 | waitlistattributes_upd: Date 96 | } 97 | 98 | export interface OfflineAttributes {} 99 | 100 | export interface Promotion { 101 | name: string 102 | priority: number 103 | attributes: { [key: string]: string } 104 | tags: string[] 105 | } 106 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Stage 1: Builder 3 | ############################################################################### 4 | FROM node:22-slim AS builder 5 | 6 | WORKDIR /usr/src/microsoft-rewards-script 7 | 8 | ENV PLAYWRIGHT_BROWSERS_PATH=0 9 | 10 | # Copy package files 11 | COPY package.json package-lock.json tsconfig.json ./ 12 | 13 | # Install all dependencies required to build the script 14 | RUN npm ci --ignore-scripts 15 | 16 | # Copy source and build 17 | COPY . . 18 | RUN npm run build 19 | 20 | # Remove build dependencies, and reinstall only runtime dependencies 21 | RUN rm -rf node_modules \ 22 | && npm ci --omit=dev --ignore-scripts \ 23 | && npm cache clean --force 24 | 25 | # Install Chromium Headless Shell, and cleanup 26 | RUN npx patchright install --with-deps --only-shell chromium \ 27 | && rm -rf /root/.cache /tmp/* /var/tmp/* 28 | 29 | ############################################################################### 30 | # Stage 2: Runtime 31 | ############################################################################### 32 | FROM node:22-slim AS runtime 33 | 34 | WORKDIR /usr/src/microsoft-rewards-script 35 | 36 | # Set production environment variables 37 | ENV NODE_ENV=production \ 38 | TZ=UTC \ 39 | PLAYWRIGHT_BROWSERS_PATH=0 \ 40 | FORCE_HEADLESS=1 41 | 42 | # Install minimal system libraries required for Chromium headless to run 43 | RUN apt-get update && apt-get install -y --no-install-recommends \ 44 | cron \ 45 | gettext-base \ 46 | tzdata \ 47 | ca-certificates \ 48 | libglib2.0-0 \ 49 | libdbus-1-3 \ 50 | libexpat1 \ 51 | libfontconfig1 \ 52 | libgtk-3-0 \ 53 | libnspr4 \ 54 | libnss3 \ 55 | libasound2 \ 56 | libflac12 \ 57 | libatk1.0-0 \ 58 | libatspi2.0-0 \ 59 | libdrm2 \ 60 | libgbm1 \ 61 | libdav1d6 \ 62 | libx11-6 \ 63 | libx11-xcb1 \ 64 | libxcomposite1 \ 65 | libxcursor1 \ 66 | libxdamage1 \ 67 | libxext6 \ 68 | libxfixes3 \ 69 | libxi6 \ 70 | libxrandr2 \ 71 | libxrender1 \ 72 | libxss1 \ 73 | libxtst6 \ 74 | libdouble-conversion3 \ 75 | && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* 76 | 77 | # Copy compiled application and dependencies from builder stage 78 | COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist 79 | COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./ 80 | COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules 81 | 82 | # Copy runtime scripts with proper permissions from the start 83 | COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh 84 | COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template 85 | COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh 86 | 87 | # Entrypoint handles TZ, initial run toggle, cron templating & launch 88 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 89 | CMD ["sh", "-c", "echo 'Container started; cron is running.'"] 90 | -------------------------------------------------------------------------------- /src/functions/Activities.ts: -------------------------------------------------------------------------------- 1 | import type { MicrosoftRewardsBot } from '../index' 2 | import type { Page } from 'patchright' 3 | 4 | // App 5 | import { DailyCheckIn } from './activities/app/DailyCheckIn' 6 | import { ReadToEarn } from './activities/app/ReadToEarn' 7 | import { AppReward } from './activities/app/AppReward' 8 | 9 | // API 10 | import { UrlReward } from './activities/api/UrlReward' 11 | import { Quiz } from './activities/api/Quiz' 12 | import { FindClippy } from './activities/api/FindClippy' 13 | 14 | // Browser 15 | import { SearchOnBing } from './activities/browser/SearchOnBing' 16 | import { Search } from './activities/browser/Search' 17 | 18 | import type { BasePromotion, DashboardData, FindClippyPromotion } from '../interface/DashboardData' 19 | import type { Promotion } from '../interface/AppDashBoardData' 20 | 21 | export default class Activities { 22 | private bot: MicrosoftRewardsBot 23 | 24 | constructor(bot: MicrosoftRewardsBot) { 25 | this.bot = bot 26 | } 27 | 28 | // Browser Activities 29 | doSearch = async (data: DashboardData, page: Page, isMobile: boolean): Promise => { 30 | const search = new Search(this.bot) 31 | return await search.doSearch(data, page, isMobile) 32 | } 33 | 34 | doSearchOnBing = async (promotion: BasePromotion, page: Page): Promise => { 35 | const searchOnBing = new SearchOnBing(this.bot) 36 | await searchOnBing.doSearchOnBing(promotion, page) 37 | } 38 | 39 | /* 40 | doABC = async (page: Page): Promise => { 41 | const abc = new ABC(this.bot) 42 | await abc.doABC(page) 43 | } 44 | */ 45 | 46 | /* 47 | doPoll = async (page: Page): Promise => { 48 | const poll = new Poll(this.bot) 49 | await poll.doPoll(page) 50 | } 51 | */ 52 | 53 | /* 54 | doThisOrThat = async (page: Page): Promise => { 55 | const thisOrThat = new ThisOrThat(this.bot) 56 | await thisOrThat.doThisOrThat(page) 57 | } 58 | */ 59 | 60 | // API Activities 61 | doUrlReward = async (promotion: BasePromotion): Promise => { 62 | const urlReward = new UrlReward(this.bot) 63 | await urlReward.doUrlReward(promotion) 64 | } 65 | 66 | doQuiz = async (promotion: BasePromotion): Promise => { 67 | const quiz = new Quiz(this.bot) 68 | await quiz.doQuiz(promotion) 69 | } 70 | 71 | doFindClippy = async (promotions: FindClippyPromotion): Promise => { 72 | const urlReward = new FindClippy(this.bot) 73 | await urlReward.doFindClippy(promotions) 74 | } 75 | 76 | // App Activities 77 | doAppReward = async (promotion: Promotion): Promise => { 78 | const urlReward = new AppReward(this.bot) 79 | await urlReward.doAppReward(promotion) 80 | } 81 | 82 | doReadToEarn = async (): Promise => { 83 | const readToEarn = new ReadToEarn(this.bot) 84 | await readToEarn.doReadToEarn() 85 | } 86 | 87 | doDailyCheckIn = async (): Promise => { 88 | const dailyCheckIn = new DailyCheckIn(this.bot) 89 | await dailyCheckIn.doDailyCheckIn() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/util/Axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' 2 | import axiosRetry from 'axios-retry' 3 | import { HttpProxyAgent } from 'http-proxy-agent' 4 | import { HttpsProxyAgent } from 'https-proxy-agent' 5 | import { URL } from 'url' 6 | import type { AccountProxy } from '../interface/Account' 7 | 8 | class AxiosClient { 9 | private instance: AxiosInstance 10 | private account: AccountProxy 11 | 12 | constructor(account: AccountProxy) { 13 | this.account = account 14 | 15 | this.instance = axios.create({ 16 | timeout: 20000 17 | }) 18 | 19 | if (this.account.url && this.account.proxyAxios) { 20 | const agent = this.getAgentForProxy(this.account) 21 | this.instance.defaults.httpAgent = agent 22 | this.instance.defaults.httpsAgent = agent 23 | } 24 | 25 | axiosRetry(this.instance, { 26 | retries: 5, 27 | retryDelay: axiosRetry.exponentialDelay, 28 | shouldResetTimeout: true, 29 | retryCondition: error => { 30 | if (axiosRetry.isNetworkError(error)) return true 31 | if (!error.response) return true 32 | 33 | const status = error.response.status 34 | return status === 429 || (status >= 500 && status <= 599) 35 | } 36 | }) 37 | } 38 | 39 | private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent | HttpsProxyAgent { 40 | const { url: baseUrl, port, username, password } = proxyConfig 41 | 42 | let urlObj: URL 43 | try { 44 | urlObj = new URL(baseUrl) 45 | } catch (e) { 46 | try { 47 | urlObj = new URL(`http://${baseUrl}`) 48 | } catch (error) { 49 | throw new Error(`Invalid proxy URL format: ${baseUrl}`) 50 | } 51 | } 52 | 53 | const protocol = urlObj.protocol.toLowerCase() 54 | let proxyUrl: string 55 | 56 | if (username && password) { 57 | urlObj.username = encodeURIComponent(username) 58 | urlObj.password = encodeURIComponent(password) 59 | urlObj.port = port.toString() 60 | proxyUrl = urlObj.toString() 61 | } else { 62 | proxyUrl = `${protocol}//${urlObj.hostname}:${port}` 63 | } 64 | 65 | switch (protocol) { 66 | case 'http:': 67 | return new HttpProxyAgent(proxyUrl) 68 | case 'https:': 69 | return new HttpsProxyAgent(proxyUrl) 70 | default: 71 | throw new Error(`Unsupported proxy protocol: ${protocol}. Only HTTP(S) is supported!`) 72 | } 73 | } 74 | 75 | public async request(config: AxiosRequestConfig, bypassProxy = false): Promise { 76 | if (bypassProxy) { 77 | const bypassInstance = axios.create() 78 | axiosRetry(bypassInstance, { 79 | retries: 3, 80 | retryDelay: axiosRetry.exponentialDelay 81 | }) 82 | return bypassInstance.request(config) 83 | } 84 | 85 | return this.instance.request(config) 86 | } 87 | } 88 | 89 | export default AxiosClient 90 | -------------------------------------------------------------------------------- /scripts/clearSessions.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = path.dirname(__filename) 7 | 8 | const projectRoot = path.resolve(__dirname, '..') 9 | 10 | const possibleConfigPaths = [ 11 | path.join(projectRoot, 'config.json'), 12 | path.join(projectRoot, 'src', 'config.json'), 13 | path.join(projectRoot, 'dist', 'config.json') 14 | ] 15 | 16 | console.log('[DEBUG] Project root:', projectRoot) 17 | console.log('[DEBUG] Searching for config.json...') 18 | 19 | let configPath = null 20 | for (const p of possibleConfigPaths) { 21 | console.log('[DEBUG] Checking:', p) 22 | if (fs.existsSync(p)) { 23 | configPath = p 24 | console.log('[DEBUG] Found config at:', p) 25 | break 26 | } 27 | } 28 | 29 | if (!configPath) { 30 | console.error('[ERROR] config.json not found in any expected location!') 31 | console.error('[ERROR] Searched:', possibleConfigPaths) 32 | process.exit(1) 33 | } 34 | 35 | console.log('[INFO] Using config:', configPath) 36 | const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) 37 | 38 | if (!config.sessionPath) { 39 | console.error("[ERROR] config.json missing 'sessionPath' key!") 40 | process.exit(1) 41 | } 42 | 43 | console.log('[INFO] Session path from config:', config.sessionPath) 44 | 45 | const configDir = path.dirname(configPath) 46 | const possibleSessionDirs = [ 47 | path.resolve(configDir, config.sessionPath), 48 | path.join(projectRoot, 'src/browser', config.sessionPath), 49 | path.join(projectRoot, 'dist/browser', config.sessionPath) 50 | ] 51 | 52 | console.log('[DEBUG] Searching for session directory...') 53 | 54 | let sessionDir = null 55 | for (const p of possibleSessionDirs) { 56 | console.log('[DEBUG] Checking:', p) 57 | if (fs.existsSync(p)) { 58 | sessionDir = p 59 | console.log('[DEBUG] Found session directory at:', p) 60 | break 61 | } 62 | } 63 | 64 | if (!sessionDir) { 65 | sessionDir = path.resolve(configDir, config.sessionPath) 66 | console.log('[DEBUG] Using fallback session directory:', sessionDir) 67 | } 68 | 69 | const normalizedSessionDir = path.normalize(sessionDir) 70 | const normalizedProjectRoot = path.normalize(projectRoot) 71 | 72 | if (!normalizedSessionDir.startsWith(normalizedProjectRoot)) { 73 | console.error('[ERROR] Session directory is outside project root!') 74 | console.error('[ERROR] Project root:', normalizedProjectRoot) 75 | console.error('[ERROR] Session directory:', normalizedSessionDir) 76 | process.exit(1) 77 | } 78 | 79 | if (normalizedSessionDir === normalizedProjectRoot) { 80 | console.error('[ERROR] Session directory cannot be the project root!') 81 | process.exit(1) 82 | } 83 | 84 | const pathSegments = normalizedSessionDir.split(path.sep) 85 | if (pathSegments.length < 3) { 86 | console.error('[ERROR] Session path is too shallow (safety check failed)!') 87 | console.error('[ERROR] Path:', normalizedSessionDir) 88 | process.exit(1) 89 | } 90 | 91 | if (fs.existsSync(sessionDir)) { 92 | console.log('[INFO] Removing session folder:', sessionDir) 93 | try { 94 | fs.rmSync(sessionDir, { recursive: true, force: true }) 95 | console.log('[SUCCESS] Session folder removed successfully') 96 | } catch (error) { 97 | console.error('[ERROR] Failed to remove session folder:', error.message) 98 | process.exit(1) 99 | } 100 | } else { 101 | console.log('[INFO] Session folder does not exist:', sessionDir) 102 | } 103 | 104 | console.log('[INFO] Done.') 105 | -------------------------------------------------------------------------------- /src/browser/auth/methods/EmailLogin.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'patchright' 2 | import type { MicrosoftRewardsBot } from '../../../index' 3 | 4 | export class EmailLogin { 5 | private submitButton = 'button[type="submit"]' 6 | 7 | constructor(private bot: MicrosoftRewardsBot) {} 8 | 9 | async enterEmail(page: Page, email: string): Promise<'ok' | 'error'> { 10 | try { 11 | const emailInputSelector = 'input[type="email"]' 12 | const emailField = await page 13 | .waitForSelector(emailInputSelector, { state: 'visible', timeout: 1000 }) 14 | .catch(() => {}) 15 | if (!emailField) { 16 | this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email field not found') 17 | return 'error' 18 | } 19 | 20 | await this.bot.utils.wait(1000) 21 | 22 | const prefilledEmail = await page 23 | .waitForSelector('#userDisplayName', { state: 'visible', timeout: 1000 }) 24 | .catch(() => {}) 25 | if (!prefilledEmail) { 26 | await page.fill(emailInputSelector, '').catch(() => {}) 27 | await this.bot.utils.wait(500) 28 | await page.fill(emailInputSelector, email).catch(() => {}) 29 | await this.bot.utils.wait(1000) 30 | } else { 31 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email prefilled') 32 | } 33 | 34 | await page.waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }).catch(() => {}) 35 | 36 | await this.bot.browser.utils.ghostClick(page, this.submitButton) 37 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email submitted') 38 | 39 | return 'ok' 40 | } catch (error) { 41 | this.bot.logger.error( 42 | this.bot.isMobile, 43 | 'LOGIN-ENTER-EMAIL', 44 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 45 | ) 46 | return 'error' 47 | } 48 | } 49 | 50 | async enterPassword(page: Page, password: string): Promise<'ok' | 'needs-2fa' | 'error'> { 51 | try { 52 | const passwordInputSelector = 'input[type="password"]' 53 | const passwordField = await page 54 | .waitForSelector(passwordInputSelector, { state: 'visible', timeout: 1000 }) 55 | .catch(() => {}) 56 | if (!passwordField) { 57 | this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password field not found') 58 | return 'error' 59 | } 60 | 61 | await this.bot.utils.wait(1000) 62 | await page.fill(passwordInputSelector, '').catch(() => {}) 63 | await this.bot.utils.wait(500) 64 | await page.fill(passwordInputSelector, password).catch(() => {}) 65 | await this.bot.utils.wait(1000) 66 | 67 | const submitButton = await page 68 | .waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }) 69 | .catch(() => null) 70 | 71 | if (submitButton) { 72 | await this.bot.browser.utils.ghostClick(page, this.submitButton) 73 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password submitted') 74 | } 75 | 76 | return 'ok' 77 | } catch (error) { 78 | this.bot.logger.error( 79 | this.bot.isMobile, 80 | 'LOGIN-ENTER-PASSWORD', 81 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 82 | ) 83 | return 'error' 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/browser/auth/methods/MobileAccessLogin.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'patchright' 2 | import { randomBytes } from 'crypto' 3 | import { URLSearchParams } from 'url' 4 | import type { AxiosRequestConfig } from 'axios' 5 | 6 | import type { MicrosoftRewardsBot } from '../../../index' 7 | 8 | export class MobileAccessLogin { 9 | private clientId = '0000000040170455' 10 | private authUrl = 'https://login.live.com/oauth20_authorize.srf' 11 | private redirectUrl = 'https://login.live.com/oauth20_desktop.srf' 12 | private tokenUrl = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token' 13 | private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL' 14 | private maxTimeout = 180_000 // 3min 15 | 16 | constructor( 17 | private bot: MicrosoftRewardsBot, 18 | private page: Page 19 | ) {} 20 | 21 | async get(email: string): Promise { 22 | try { 23 | const authorizeUrl = new URL(this.authUrl) 24 | authorizeUrl.searchParams.append('response_type', 'code') 25 | authorizeUrl.searchParams.append('client_id', this.clientId) 26 | authorizeUrl.searchParams.append('redirect_uri', this.redirectUrl) 27 | authorizeUrl.searchParams.append('scope', this.scope) 28 | authorizeUrl.searchParams.append('state', randomBytes(16).toString('hex')) 29 | authorizeUrl.searchParams.append('access_type', 'offline_access') 30 | authorizeUrl.searchParams.append('login_hint', email) 31 | 32 | await this.bot.browser.utils.disableFido(this.page) 33 | 34 | await this.page.goto(authorizeUrl.href).catch(() => {}) 35 | 36 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...') 37 | const start = Date.now() 38 | let code = '' 39 | 40 | while (Date.now() - start < this.maxTimeout) { 41 | const url = new URL(this.page.url()) 42 | if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') { 43 | code = url.searchParams.get('code') || '' 44 | if (code) break 45 | } 46 | await this.bot.utils.wait(1000) 47 | } 48 | 49 | if (!code) { 50 | this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'Timed out waiting for OAuth code') 51 | return '' 52 | } 53 | 54 | const data = new URLSearchParams() 55 | data.append('grant_type', 'authorization_code') 56 | data.append('client_id', this.clientId) 57 | data.append('code', code) 58 | data.append('redirect_uri', this.redirectUrl) 59 | 60 | const request: AxiosRequestConfig = { 61 | url: this.tokenUrl, 62 | method: 'POST', 63 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 64 | data: data.toString() 65 | } 66 | 67 | const response = await this.bot.axios.request(request) 68 | const token = (response?.data?.access_token as string) ?? '' 69 | 70 | if (!token) { 71 | this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'No access_token in token response') 72 | return '' 73 | } 74 | 75 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Mobile access token received') 76 | return token 77 | } catch (error) { 78 | this.bot.logger.error( 79 | this.bot.isMobile, 80 | 'LOGIN-APP', 81 | `MobileAccess error: ${error instanceof Error ? error.message : String(error)}` 82 | ) 83 | return '' 84 | } finally { 85 | await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {}) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/util/Load.ts: -------------------------------------------------------------------------------- 1 | import type { Cookie } from 'patchright' 2 | import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator' 3 | import fs from 'fs' 4 | import path from 'path' 5 | 6 | import type { Account } from '../interface/Account' 7 | import type { Config, ConfigSaveFingerprint } from '../interface/Config' 8 | 9 | let configCache: Config 10 | 11 | export function loadAccounts(): Account[] { 12 | try { 13 | let file = 'accounts.json' 14 | 15 | if (process.argv.includes('-dev')) { 16 | file = 'accounts.dev.json' 17 | } 18 | 19 | const accountDir = path.join(__dirname, '../', file) 20 | const accounts = fs.readFileSync(accountDir, 'utf-8') 21 | 22 | return JSON.parse(accounts) 23 | } catch (error) { 24 | throw new Error(error as string) 25 | } 26 | } 27 | 28 | export function loadConfig(): Config { 29 | try { 30 | if (configCache) { 31 | return configCache 32 | } 33 | 34 | const configDir = path.join(__dirname, '../', 'config.json') 35 | const config = fs.readFileSync(configDir, 'utf-8') 36 | 37 | const configData = JSON.parse(config) 38 | configCache = configData 39 | 40 | return configData 41 | } catch (error) { 42 | throw new Error(error as string) 43 | } 44 | } 45 | 46 | export async function loadSessionData( 47 | sessionPath: string, 48 | email: string, 49 | saveFingerprint: ConfigSaveFingerprint, 50 | isMobile: boolean 51 | ) { 52 | try { 53 | const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json' 54 | const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, cookiesFileName) 55 | 56 | let cookies: Cookie[] = [] 57 | if (fs.existsSync(cookieFile)) { 58 | const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8') 59 | cookies = JSON.parse(cookiesData) 60 | } 61 | 62 | const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json' 63 | const fingerprintFile = path.join(__dirname, '../browser/', sessionPath, email, fingerprintFileName) 64 | 65 | let fingerprint!: BrowserFingerprintWithHeaders 66 | const shouldLoadFingerprint = isMobile ? saveFingerprint.mobile : saveFingerprint.desktop 67 | if (shouldLoadFingerprint && fs.existsSync(fingerprintFile)) { 68 | const fingerprintData = await fs.promises.readFile(fingerprintFile, 'utf-8') 69 | fingerprint = JSON.parse(fingerprintData) 70 | } 71 | 72 | return { 73 | cookies: cookies, 74 | fingerprint: fingerprint 75 | } 76 | } catch (error) { 77 | throw new Error(error as string) 78 | } 79 | } 80 | 81 | export async function saveSessionData( 82 | sessionPath: string, 83 | cookies: Cookie[], 84 | email: string, 85 | isMobile: boolean 86 | ): Promise { 87 | try { 88 | const sessionDir = path.join(__dirname, '../browser/', sessionPath, email) 89 | const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json' 90 | 91 | if (!fs.existsSync(sessionDir)) { 92 | await fs.promises.mkdir(sessionDir, { recursive: true }) 93 | } 94 | 95 | await fs.promises.writeFile(path.join(sessionDir, cookiesFileName), JSON.stringify(cookies)) 96 | 97 | return sessionDir 98 | } catch (error) { 99 | throw new Error(error as string) 100 | } 101 | } 102 | 103 | export async function saveFingerprintData( 104 | sessionPath: string, 105 | email: string, 106 | isMobile: boolean, 107 | fingerpint: BrowserFingerprintWithHeaders 108 | ): Promise { 109 | try { 110 | const sessionDir = path.join(__dirname, '../browser/', sessionPath, email) 111 | const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json' 112 | 113 | if (!fs.existsSync(sessionDir)) { 114 | await fs.promises.mkdir(sessionDir, { recursive: true }) 115 | } 116 | 117 | await fs.promises.writeFile(path.join(sessionDir, fingerprintFileName), JSON.stringify(fingerpint)) 118 | 119 | return sessionDir 120 | } catch (error) { 121 | throw new Error(error as string) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/browser/auth/methods/PasswordlessLogin.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'patchright' 2 | import type { MicrosoftRewardsBot } from '../../../index' 3 | 4 | export class PasswordlessLogin { 5 | private readonly maxAttempts = 60 6 | private readonly numberDisplaySelector = 'div[data-testid="displaySign"]' 7 | private readonly approvalPath = '/ppsecure/post.srf' 8 | 9 | constructor(private bot: MicrosoftRewardsBot) {} 10 | 11 | private async getDisplayedNumber(page: Page): Promise { 12 | try { 13 | const numberElement = await page 14 | .waitForSelector(this.numberDisplaySelector, { 15 | timeout: 5000 16 | }) 17 | .catch(() => null) 18 | 19 | if (numberElement) { 20 | const number = await numberElement.textContent() 21 | return number?.trim() || null 22 | } 23 | } catch (error) { 24 | this.bot.logger.warn(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Could not retrieve displayed number') 25 | } 26 | return null 27 | } 28 | 29 | private async waitForApproval(page: Page): Promise { 30 | try { 31 | this.bot.logger.info( 32 | this.bot.isMobile, 33 | 'LOGIN-PASSWORDLESS', 34 | `Waiting for approval... (timeout after ${this.maxAttempts} seconds)` 35 | ) 36 | 37 | for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { 38 | const currentUrl = new URL(page.url()) 39 | if (currentUrl.pathname === this.approvalPath) { 40 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Approval detected') 41 | return true 42 | } 43 | 44 | // Every 5 seconds to show it's still waiting 45 | if (attempt % 5 === 0) { 46 | this.bot.logger.info( 47 | this.bot.isMobile, 48 | 'LOGIN-PASSWORDLESS', 49 | `Still waiting... (${attempt}/${this.maxAttempts} seconds elapsed)` 50 | ) 51 | } 52 | 53 | await this.bot.utils.wait(1000) 54 | } 55 | 56 | this.bot.logger.warn( 57 | this.bot.isMobile, 58 | 'LOGIN-PASSWORDLESS', 59 | `Approval timeout after ${this.maxAttempts} seconds!` 60 | ) 61 | return false 62 | } catch (error: any) { 63 | this.bot.logger.error( 64 | this.bot.isMobile, 65 | 'LOGIN-PASSWORDLESS', 66 | `Approval failed, an error occurred: ${error instanceof Error ? error.message : String(error)}` 67 | ) 68 | throw error 69 | } 70 | } 71 | 72 | async handle(page: Page): Promise { 73 | try { 74 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Passwordless authentication requested') 75 | 76 | const displayedNumber = await this.getDisplayedNumber(page) 77 | 78 | if (displayedNumber) { 79 | this.bot.logger.info( 80 | this.bot.isMobile, 81 | 'LOGIN-PASSWORDLESS', 82 | `Please approve login and select number: ${displayedNumber}` 83 | ) 84 | } else { 85 | this.bot.logger.info( 86 | this.bot.isMobile, 87 | 'LOGIN-PASSWORDLESS', 88 | 'Please approve login on your authenticator app' 89 | ) 90 | } 91 | 92 | const approved = await this.waitForApproval(page) 93 | 94 | if (approved) { 95 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approved successfully') 96 | await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) 97 | } else { 98 | this.bot.logger.error(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approval failed or timed out') 99 | throw new Error('Passwordless authentication timeout') 100 | } 101 | } catch (error) { 102 | this.bot.logger.error( 103 | this.bot.isMobile, 104 | 'LOGIN-PASSWORDLESS', 105 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 106 | ) 107 | throw error 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/browser/Browser.ts: -------------------------------------------------------------------------------- 1 | import rebrowser, { BrowserContext } from 'patchright' 2 | 3 | import { newInjectedContext } from 'fingerprint-injector' 4 | import { BrowserFingerprintWithHeaders, FingerprintGenerator } from 'fingerprint-generator' 5 | 6 | import type { MicrosoftRewardsBot } from '../index' 7 | import { loadSessionData, saveFingerprintData } from '../util/Load' 8 | import { UserAgentManager } from './UserAgent' 9 | 10 | import type { AccountProxy } from '../interface/Account' 11 | 12 | /* Test Stuff 13 | https://abrahamjuliot.github.io/creepjs/ 14 | https://botcheck.luminati.io/ 15 | https://fv.pro/ 16 | https://pixelscan.net/ 17 | https://www.browserscan.net/ 18 | */ 19 | 20 | class Browser { 21 | private bot: MicrosoftRewardsBot 22 | 23 | constructor(bot: MicrosoftRewardsBot) { 24 | this.bot = bot 25 | } 26 | 27 | async createBrowser( 28 | proxy: AccountProxy, 29 | email: string 30 | ): Promise<{ 31 | context: BrowserContext 32 | fingerprint: BrowserFingerprintWithHeaders 33 | }> { 34 | let browser: rebrowser.Browser 35 | try { 36 | browser = await rebrowser.chromium.launch({ 37 | headless: this.bot.config.headless, 38 | ...(proxy.url && { 39 | proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } 40 | }), 41 | args: [ 42 | '--no-sandbox', 43 | '--mute-audio', 44 | '--disable-setuid-sandbox', 45 | '--ignore-certificate-errors', 46 | '--ignore-certificate-errors-spki-list', 47 | '--ignore-ssl-errors', 48 | '--no-first-run', 49 | '--no-default-browser-check', 50 | '--disable-user-media-security=true', 51 | '--disable-blink-features=Attestation', 52 | '--disable-features=WebAuthentication,PasswordManagerOnboarding,PasswordManager,EnablePasswordsAccountStorage,Passkeys', 53 | '--disable-save-password-bubble' 54 | ] 55 | }) 56 | } catch (error) { 57 | this.bot.logger.error( 58 | this.bot.isMobile, 59 | 'BROWSER', 60 | `Launch failed: ${error instanceof Error ? error.message : String(error)}` 61 | ) 62 | throw error 63 | } 64 | 65 | const sessionData = await loadSessionData( 66 | this.bot.config.sessionPath, 67 | email, 68 | this.bot.config.saveFingerprint, 69 | this.bot.isMobile 70 | ) 71 | 72 | const fingerprint = sessionData.fingerprint 73 | ? sessionData.fingerprint 74 | : await this.generateFingerprint(this.bot.isMobile) 75 | 76 | const context = await newInjectedContext(browser as any, { fingerprint: fingerprint }) 77 | 78 | await context.addInitScript(() => { 79 | Object.defineProperty(navigator, 'credentials', { 80 | value: { 81 | create: () => Promise.reject(new Error('WebAuthn disabled')), 82 | get: () => Promise.reject(new Error('WebAuthn disabled')) 83 | } 84 | }) 85 | }) 86 | 87 | context.setDefaultTimeout(this.bot.utils.stringToNumber(this.bot.config?.globalTimeout ?? 30000)) 88 | 89 | await context.addCookies(sessionData.cookies) 90 | 91 | if (this.bot.config.saveFingerprint) { 92 | await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint) 93 | } 94 | 95 | this.bot.logger.info( 96 | this.bot.isMobile, 97 | 'BROWSER', 98 | `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"` 99 | ) 100 | 101 | this.bot.logger.debug(this.bot.isMobile, 'BROWSER-FINGERPRINT', JSON.stringify(fingerprint)) 102 | 103 | return { 104 | context: context as unknown as BrowserContext, 105 | fingerprint: fingerprint 106 | } 107 | } 108 | 109 | async generateFingerprint(isMobile: boolean) { 110 | const fingerPrintData = new FingerprintGenerator().getFingerprint({ 111 | devices: isMobile ? ['mobile'] : ['desktop'], 112 | operatingSystems: isMobile ? ['android', 'ios'] : ['windows', 'linux'], 113 | browsers: [{ name: 'edge' }] 114 | }) 115 | 116 | const userAgentManager = new UserAgentManager(this.bot) 117 | const updatedFingerPrintData = await userAgentManager.updateFingerprintUserAgent(fingerPrintData, isMobile) 118 | 119 | return updatedFingerPrintData 120 | } 121 | } 122 | 123 | export default Browser 124 | -------------------------------------------------------------------------------- /src/functions/activities/app/AppReward.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import { randomUUID } from 'crypto' 3 | import type { Promotion } from '../../../interface/AppDashBoardData' 4 | import { Workers } from '../../Workers' 5 | 6 | export class AppReward extends Workers { 7 | private gainedPoints: number = 0 8 | 9 | private oldBalance: number = this.bot.userData.currentPoints 10 | 11 | public async doAppReward(promotion: Promotion) { 12 | if (!this.bot.accessToken) { 13 | this.bot.logger.warn( 14 | this.bot.isMobile, 15 | 'APP-REWARD', 16 | 'Skipping: App access token not available, this activity requires it!' 17 | ) 18 | return 19 | } 20 | 21 | const offerId = promotion.attributes['offerid'] 22 | 23 | this.bot.logger.info( 24 | this.bot.isMobile, 25 | 'APP-REWARD', 26 | `Starting AppReward | offerId=${offerId} | country=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}` 27 | ) 28 | 29 | try { 30 | const jsonData = { 31 | id: randomUUID(), 32 | amount: 1, 33 | type: 101, 34 | attributes: { 35 | offerid: offerId 36 | }, 37 | country: this.bot.userData.geoLocale 38 | } 39 | 40 | this.bot.logger.debug( 41 | this.bot.isMobile, 42 | 'APP-REWARD', 43 | `Prepared activity payload | offerId=${offerId} | id=${jsonData.id} | amount=${jsonData.amount} | type=${jsonData.type} | country=${jsonData.country}` 44 | ) 45 | 46 | const request: AxiosRequestConfig = { 47 | url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', 48 | method: 'POST', 49 | headers: { 50 | Authorization: `Bearer ${this.bot.accessToken}`, 51 | 'User-Agent': 52 | 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', 53 | 'Content-Type': 'application/json', 54 | 'X-Rewards-Country': this.bot.userData.geoLocale, 55 | 'X-Rewards-Language': 'en', 56 | 'X-Rewards-ismobile': 'true' 57 | }, 58 | data: JSON.stringify(jsonData) 59 | } 60 | 61 | this.bot.logger.debug( 62 | this.bot.isMobile, 63 | 'APP-REWARD', 64 | `Sending activity request | offerId=${offerId} | url=${request.url}` 65 | ) 66 | 67 | const response = await this.bot.axios.request(request) 68 | 69 | this.bot.logger.debug( 70 | this.bot.isMobile, 71 | 'APP-REWARD', 72 | `Received activity response | offerId=${offerId} | status=${response.status}` 73 | ) 74 | 75 | const newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) 76 | this.gainedPoints = newBalance - this.oldBalance 77 | 78 | this.bot.logger.debug( 79 | this.bot.isMobile, 80 | 'APP-REWARD', 81 | `Balance delta after AppReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` 82 | ) 83 | 84 | if (this.gainedPoints > 0) { 85 | this.bot.userData.currentPoints = newBalance 86 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints 87 | 88 | this.bot.logger.info( 89 | this.bot.isMobile, 90 | 'APP-REWARD', 91 | `Completed AppReward | offerId=${offerId} | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, 92 | 'green' 93 | ) 94 | } else { 95 | this.bot.logger.warn( 96 | this.bot.isMobile, 97 | 'APP-REWARD', 98 | `Completed AppReward with no points | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance}` 99 | ) 100 | } 101 | 102 | this.bot.logger.debug(this.bot.isMobile, 'APP-REWARD', `Waiting after AppReward | offerId=${offerId}`) 103 | 104 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) 105 | 106 | this.bot.logger.info( 107 | this.bot.isMobile, 108 | 'APP-REWARD', 109 | `Finished AppReward | offerId=${offerId} | finalBalance=${this.bot.userData.currentPoints}` 110 | ) 111 | } catch (error) { 112 | this.bot.logger.error( 113 | this.bot.isMobile, 114 | 'APP-REWARD', 115 | `Error in doAppReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` 116 | ) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/functions/activities/api/UrlReward.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import type { BasePromotion } from '../../../interface/DashboardData' 3 | import { Workers } from '../../Workers' 4 | 5 | export class UrlReward extends Workers { 6 | private cookieHeader: string = '' 7 | 8 | private fingerprintHeader: { [x: string]: string } = {} 9 | 10 | private gainedPoints: number = 0 11 | 12 | private oldBalance: number = this.bot.userData.currentPoints 13 | 14 | public async doUrlReward(promotion: BasePromotion) { 15 | if (!this.bot.requestToken) { 16 | this.bot.logger.warn( 17 | this.bot.isMobile, 18 | 'URL-REWARD', 19 | 'Skipping: Request token not available, this activity requires it!' 20 | ) 21 | return 22 | } 23 | 24 | const offerId = promotion.offerId 25 | 26 | this.bot.logger.info( 27 | this.bot.isMobile, 28 | 'URL-REWARD', 29 | `Starting UrlReward | offerId=${offerId} | geo=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}` 30 | ) 31 | 32 | try { 33 | this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) 34 | .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) 35 | .join('; ') 36 | 37 | const fingerprintHeaders = { ...this.bot.fingerprint.headers } 38 | delete fingerprintHeaders['Cookie'] 39 | delete fingerprintHeaders['cookie'] 40 | this.fingerprintHeader = fingerprintHeaders 41 | 42 | this.bot.logger.debug( 43 | this.bot.isMobile, 44 | 'URL-REWARD', 45 | `Prepared UrlReward headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` 46 | ) 47 | 48 | const formData = new URLSearchParams({ 49 | id: offerId, 50 | hash: promotion.hash, 51 | timeZone: '60', 52 | activityAmount: '1', 53 | dbs: '0', 54 | form: '', 55 | type: '', 56 | __RequestVerificationToken: this.bot.requestToken 57 | }) 58 | 59 | this.bot.logger.debug( 60 | this.bot.isMobile, 61 | 'URL-REWARD', 62 | `Prepared UrlReward form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1` 63 | ) 64 | 65 | const request: AxiosRequestConfig = { 66 | url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', 67 | method: 'POST', 68 | headers: { 69 | ...(this.bot.fingerprint?.headers ?? {}), 70 | Cookie: this.cookieHeader, 71 | Referer: 'https://rewards.bing.com/', 72 | Origin: 'https://rewards.bing.com' 73 | }, 74 | data: formData 75 | } 76 | 77 | this.bot.logger.debug( 78 | this.bot.isMobile, 79 | 'URL-REWARD', 80 | `Sending UrlReward request | offerId=${offerId} | url=${request.url}` 81 | ) 82 | 83 | const response = await this.bot.axios.request(request) 84 | 85 | this.bot.logger.debug( 86 | this.bot.isMobile, 87 | 'URL-REWARD', 88 | `Received UrlReward response | offerId=${offerId} | status=${response.status}` 89 | ) 90 | 91 | const newBalance = await this.bot.browser.func.getCurrentPoints() 92 | this.gainedPoints = newBalance - this.oldBalance 93 | 94 | this.bot.logger.debug( 95 | this.bot.isMobile, 96 | 'URL-REWARD', 97 | `Balance delta after UrlReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` 98 | ) 99 | 100 | if (this.gainedPoints > 0) { 101 | this.bot.userData.currentPoints = newBalance 102 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints 103 | 104 | this.bot.logger.info( 105 | this.bot.isMobile, 106 | 'URL-REWARD', 107 | `Completed UrlReward | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`, 108 | 'green' 109 | ) 110 | } else { 111 | this.bot.logger.warn( 112 | this.bot.isMobile, 113 | 'URL-REWARD', 114 | `Failed UrlReward with no points | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}` 115 | ) 116 | } 117 | 118 | this.bot.logger.debug(this.bot.isMobile, 'URL-REWARD', `Waiting after UrlReward | offerId=${offerId}`) 119 | 120 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) 121 | } catch (error) { 122 | this.bot.logger.error( 123 | this.bot.isMobile, 124 | 'URL-REWARD', 125 | `Error in doUrlReward | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` 126 | ) 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/functions/activities/api/FindClippy.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import type { FindClippyPromotion } from '../../../interface/DashboardData' 3 | import { Workers } from '../../Workers' 4 | 5 | export class FindClippy extends Workers { 6 | private cookieHeader: string = '' 7 | 8 | private fingerprintHeader: { [x: string]: string } = {} 9 | 10 | private gainedPoints: number = 0 11 | 12 | private oldBalance: number = this.bot.userData.currentPoints 13 | 14 | public async doFindClippy(promotion: FindClippyPromotion) { 15 | const offerId = promotion.offerId 16 | const activityType = promotion.activityType 17 | 18 | try { 19 | if (!this.bot.requestToken) { 20 | this.bot.logger.warn( 21 | this.bot.isMobile, 22 | 'FIND-CLIPPY', 23 | 'Skipping: Request token not available, this activity requires it!' 24 | ) 25 | return 26 | } 27 | 28 | this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) 29 | .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) 30 | .join('; ') 31 | 32 | const fingerprintHeaders = { ...this.bot.fingerprint.headers } 33 | delete fingerprintHeaders['Cookie'] 34 | delete fingerprintHeaders['cookie'] 35 | this.fingerprintHeader = fingerprintHeaders 36 | 37 | this.bot.logger.info( 38 | this.bot.isMobile, 39 | 'FIND-CLIPPY', 40 | `Starting Find Clippy | offerId=${offerId} | activityType=${activityType} | oldBalance=${this.oldBalance}` 41 | ) 42 | 43 | this.bot.logger.debug( 44 | this.bot.isMobile, 45 | 'FIND-CLIPPY', 46 | `Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` 47 | ) 48 | 49 | const formData = new URLSearchParams({ 50 | id: offerId, 51 | hash: promotion.hash, 52 | timeZone: '60', 53 | activityAmount: '1', 54 | dbs: '0', 55 | form: '', 56 | type: activityType, 57 | __RequestVerificationToken: this.bot.requestToken 58 | }) 59 | 60 | this.bot.logger.debug( 61 | this.bot.isMobile, 62 | 'FIND-CLIPPY', 63 | `Prepared Find Clippy form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}` 64 | ) 65 | 66 | const request: AxiosRequestConfig = { 67 | url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', 68 | method: 'POST', 69 | headers: { 70 | ...(this.bot.fingerprint?.headers ?? {}), 71 | Cookie: this.cookieHeader, 72 | Referer: 'https://rewards.bing.com/', 73 | Origin: 'https://rewards.bing.com' 74 | }, 75 | data: formData 76 | } 77 | 78 | this.bot.logger.debug( 79 | this.bot.isMobile, 80 | 'FIND-CLIPPY', 81 | `Sending Find Clippy request | offerId=${offerId} | url=${request.url}` 82 | ) 83 | 84 | const response = await this.bot.axios.request(request) 85 | 86 | this.bot.logger.debug( 87 | this.bot.isMobile, 88 | 'FIND-CLIPPY', 89 | `Received Find Clippy response | offerId=${offerId} | status=${response.status}` 90 | ) 91 | 92 | const newBalance = await this.bot.browser.func.getCurrentPoints() 93 | this.gainedPoints = newBalance - this.oldBalance 94 | 95 | this.bot.logger.debug( 96 | this.bot.isMobile, 97 | 'FIND-CLIPPY', 98 | `Balance delta after Find Clippy | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` 99 | ) 100 | 101 | if (this.gainedPoints > 0) { 102 | this.bot.userData.currentPoints = newBalance 103 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints 104 | 105 | this.bot.logger.info( 106 | this.bot.isMobile, 107 | 'FIND-CLIPPY', 108 | `Found Clippy | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`, 109 | 'green' 110 | ) 111 | } else { 112 | this.bot.logger.warn( 113 | this.bot.isMobile, 114 | 'FIND-CLIPPY', 115 | `Found Clippy but no points were gained | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}` 116 | ) 117 | } 118 | 119 | this.bot.logger.debug(this.bot.isMobile, 'FIND-CLIPPY', `Waiting after Find Clippy | offerId=${offerId}`) 120 | 121 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) 122 | } catch (error) { 123 | this.bot.logger.error( 124 | this.bot.isMobile, 125 | 'FIND-CLIPPY', 126 | `Error in doFindClippy | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` 127 | ) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /scripts/docker/run_daily.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | export PLAYWRIGHT_BROWSERS_PATH=0 5 | export TZ="${TZ:-UTC}" 6 | 7 | cd /usr/src/microsoft-rewards-script 8 | 9 | LOCKFILE=/tmp/run_daily.lock 10 | 11 | # ------------------------------- 12 | # Function: Check and fix lockfile integrity 13 | # ------------------------------- 14 | self_heal_lockfile() { 15 | # If lockfile exists but is empty → remove it 16 | if [ -f "$LOCKFILE" ]; then 17 | local lock_content 18 | lock_content=$(<"$LOCKFILE" || echo "") 19 | 20 | if [[ -z "$lock_content" ]]; then 21 | echo "[$(date)] [run_daily.sh] Found empty lockfile → removing." 22 | rm -f "$LOCKFILE" 23 | return 24 | fi 25 | 26 | # If lockfile contains non-numeric PID → remove it 27 | if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then 28 | echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') → removing." 29 | rm -f "$LOCKFILE" 30 | return 31 | fi 32 | 33 | # If lockfile contains PID but process is dead → remove it 34 | if ! kill -0 "$lock_content" 2>/dev/null; then 35 | echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead → removing stale lock." 36 | rm -f "$LOCKFILE" 37 | return 38 | fi 39 | fi 40 | } 41 | 42 | # ------------------------------- 43 | # Function: Acquire lock 44 | # ------------------------------- 45 | acquire_lock() { 46 | local max_attempts=5 47 | local attempt=0 48 | local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8} 49 | local timeout_seconds=$((timeout_hours * 3600)) 50 | 51 | while [ $attempt -lt $max_attempts ]; do 52 | # Try to create lock with current PID 53 | if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then 54 | echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)" 55 | return 0 56 | fi 57 | 58 | # Lock exists, validate it 59 | if [ -f "$LOCKFILE" ]; then 60 | local existing_pid 61 | existing_pid=$(<"$LOCKFILE" || echo "") 62 | 63 | echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'" 64 | 65 | # If lockfile content is invalid → delete and retry 66 | if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then 67 | echo "[$(date)] [run_daily.sh] Removing invalid lockfile → retrying..." 68 | rm -f "$LOCKFILE" 69 | continue 70 | fi 71 | 72 | # If process is dead → delete and retry 73 | if ! kill -0 "$existing_pid" 2>/dev/null; then 74 | echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)" 75 | rm -f "$LOCKFILE" 76 | continue 77 | fi 78 | 79 | # Check process runtime → kill if exceeded timeout 80 | local process_age 81 | if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then 82 | if [ "$process_age" -gt "$timeout_seconds" ]; then 83 | echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)" 84 | kill -TERM "$existing_pid" 2>/dev/null || true 85 | sleep 5 86 | kill -KILL "$existing_pid" 2>/dev/null || true 87 | rm -f "$LOCKFILE" 88 | continue 89 | fi 90 | fi 91 | fi 92 | 93 | echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts" 94 | sleep 2 95 | ((attempt++)) 96 | done 97 | 98 | echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting." 99 | return 1 100 | } 101 | 102 | # ------------------------------- 103 | # Function: Release lock 104 | # ------------------------------- 105 | release_lock() { 106 | if [ -f "$LOCKFILE" ]; then 107 | local lock_pid 108 | lock_pid=$(<"$LOCKFILE") 109 | if [ "$lock_pid" = "$$" ]; then 110 | rm -f "$LOCKFILE" 111 | echo "[$(date)] [run_daily.sh] Lock released (PID: $$)" 112 | fi 113 | fi 114 | } 115 | 116 | # Always release lock on exit — but only if we acquired it 117 | trap 'release_lock' EXIT INT TERM 118 | 119 | # ------------------------------- 120 | # MAIN EXECUTION FLOW 121 | # ------------------------------- 122 | echo "[$(date)] [run_daily.sh] Current process PID: $$" 123 | 124 | # Self-heal any broken or empty locks before proceeding 125 | self_heal_lockfile 126 | 127 | # Attempt to acquire the lock safely 128 | if ! acquire_lock; then 129 | exit 0 130 | fi 131 | 132 | # Random sleep between MIN and MAX to spread execution 133 | MINWAIT=${MIN_SLEEP_MINUTES:-5} 134 | MAXWAIT=${MAX_SLEEP_MINUTES:-50} 135 | MINWAIT_SEC=$((MINWAIT*60)) 136 | MAXWAIT_SEC=$((MAXWAIT*60)) 137 | 138 | if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then 139 | SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) )) 140 | echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)" 141 | sleep "$SLEEPTIME" 142 | else 143 | echo "[$(date)] [run_daily.sh] Skipping random sleep" 144 | fi 145 | 146 | # Start the actual script 147 | echo "[$(date)] [run_daily.sh] Starting script..." 148 | if npm start; then 149 | echo "[$(date)] [run_daily.sh] Script completed successfully." 150 | else 151 | echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2 152 | fi 153 | 154 | echo "[$(date)] [run_daily.sh] Script finished" 155 | # Lock is released automatically via trap 156 | -------------------------------------------------------------------------------- /src/functions/activities/app/ReadToEarn.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import { randomBytes } from 'crypto' 3 | import { Workers } from '../../Workers' 4 | 5 | export class ReadToEarn extends Workers { 6 | public async doReadToEarn() { 7 | if (!this.bot.accessToken) { 8 | this.bot.logger.warn( 9 | this.bot.isMobile, 10 | 'READ-TO-EARN', 11 | 'Skipping: App access token not available, this activity requires it!' 12 | ) 13 | return 14 | } 15 | 16 | const delayMin = this.bot.config.searchSettings.readDelay.min 17 | const delayMax = this.bot.config.searchSettings.readDelay.max 18 | const startBalance = Number(this.bot.userData.currentPoints ?? 0) 19 | 20 | this.bot.logger.info( 21 | this.bot.isMobile, 22 | 'READ-TO-EARN', 23 | `Starting Read to Earn | geo=${this.bot.userData.geoLocale} | delayRange=${delayMin}-${delayMax} | currentPoints=${startBalance}` 24 | ) 25 | 26 | try { 27 | const jsonData = { 28 | amount: 1, 29 | id: '1', 30 | type: 101, 31 | attributes: { 32 | offerid: 'ENUS_readarticle3_30points' 33 | }, 34 | country: this.bot.userData.geoLocale 35 | } 36 | 37 | const articleCount = 10 38 | let totalGained = 0 39 | let articlesRead = 0 40 | let oldBalance = startBalance 41 | 42 | for (let i = 0; i < articleCount; ++i) { 43 | jsonData.id = randomBytes(64).toString('hex') 44 | 45 | this.bot.logger.debug( 46 | this.bot.isMobile, 47 | 'READ-TO-EARN', 48 | `Submitting Read to Earn activity | article=${i + 1}/${articleCount} | id=${jsonData.id} | country=${jsonData.country}` 49 | ) 50 | 51 | const request: AxiosRequestConfig = { 52 | url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', 53 | method: 'POST', 54 | headers: { 55 | Authorization: `Bearer ${this.bot.accessToken}`, 56 | 'User-Agent': 57 | 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', 58 | 'Content-Type': 'application/json', 59 | 'X-Rewards-Country': this.bot.userData.geoLocale, 60 | 'X-Rewards-Language': 'en', 61 | 'X-Rewards-ismobile': 'true' 62 | }, 63 | data: JSON.stringify(jsonData) 64 | } 65 | 66 | const response = await this.bot.axios.request(request) 67 | 68 | this.bot.logger.debug( 69 | this.bot.isMobile, 70 | 'READ-TO-EARN', 71 | `Received Read to Earn response | article=${i + 1}/${articleCount} | status=${response?.status ?? 'unknown'}` 72 | ) 73 | 74 | const newBalance = Number(response?.data?.response?.balance ?? oldBalance) 75 | const gainedPoints = newBalance - oldBalance 76 | 77 | this.bot.logger.debug( 78 | this.bot.isMobile, 79 | 'READ-TO-EARN', 80 | `Balance delta after article | article=${i + 1}/${articleCount} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}` 81 | ) 82 | 83 | if (gainedPoints <= 0) { 84 | this.bot.logger.info( 85 | this.bot.isMobile, 86 | 'READ-TO-EARN', 87 | `No points gained, stopping Read to Earn | article=${i + 1}/${articleCount} | status=${response.status} | oldBalance=${oldBalance} | newBalance=${newBalance}` 88 | ) 89 | break 90 | } 91 | 92 | // Update point tracking 93 | this.bot.userData.currentPoints = newBalance 94 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints 95 | totalGained += gainedPoints 96 | articlesRead = i + 1 97 | oldBalance = newBalance 98 | 99 | this.bot.logger.info( 100 | this.bot.isMobile, 101 | 'READ-TO-EARN', 102 | `Read article ${i + 1}/${articleCount} | status=${response.status} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`, 103 | 'green' 104 | ) 105 | 106 | // Wait random delay between articles 107 | this.bot.logger.debug( 108 | this.bot.isMobile, 109 | 'READ-TO-EARN', 110 | `Waiting between articles | article=${i + 1}/${articleCount} | delayRange=${delayMin}-${delayMax}` 111 | ) 112 | 113 | await this.bot.utils.wait(this.bot.utils.randomDelay(delayMin, delayMax)) 114 | } 115 | 116 | const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance) 117 | 118 | this.bot.logger.info( 119 | this.bot.isMobile, 120 | 'READ-TO-EARN', 121 | `Completed Read to Earn | articlesRead=${articlesRead} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${finalBalance}` 122 | ) 123 | } catch (error) { 124 | this.bot.logger.error( 125 | this.bot.isMobile, 126 | 'READ-TO-EARN', 127 | `Error during Read to Earn | message=${error instanceof Error ? error.message : String(error)}` 128 | ) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/interface/AppUserData.ts: -------------------------------------------------------------------------------- 1 | export interface AppUserData { 2 | response: Response 3 | correlationId: string 4 | code: number 5 | } 6 | 7 | export interface Response { 8 | profile: Profile 9 | balance: number 10 | counters: null 11 | promotions: Promotion[] 12 | catalog: null 13 | goal_item: GoalItem 14 | activities: null 15 | cashback: null 16 | orders: Order[] 17 | rebateProfile: null 18 | rebatePayouts: null 19 | giveProfile: GiveProfile 20 | autoRedeemProfile: null 21 | autoRedeemItem: null 22 | thirdPartyProfile: null 23 | notifications: null 24 | waitlist: null 25 | autoOpenFlyout: null 26 | coupons: null 27 | recommendedAffordableCatalog: null 28 | } 29 | 30 | export interface GiveProfile { 31 | give_user: string 32 | give_organization: { [key: string]: GiveOrganization | null } 33 | first_give_optin: string 34 | last_give_optout: string 35 | give_lifetime_balance: string 36 | give_lifetime_donation_balance: string 37 | give_balance: string 38 | form: null 39 | } 40 | 41 | export interface GiveOrganization { 42 | give_organization_donation_points: number 43 | give_organization_donation_point_to_currency_ratio: number 44 | give_organization_donation_currency: number 45 | } 46 | 47 | export interface GoalItem { 48 | name: string 49 | provider: string 50 | price: number 51 | attributes: GoalItemAttributes 52 | config: GoalItemConfig 53 | } 54 | 55 | export interface GoalItemAttributes { 56 | category: string 57 | CategoryDescription: string 58 | 'desc.group_text': string 59 | 'desc.legal_text'?: string 60 | 'desc.sc_description': string 61 | 'desc.sc_title': string 62 | display_order: string 63 | ExtraLargeImage: string 64 | group: string 65 | group_image: string 66 | group_sc_image: string 67 | group_title: string 68 | hidden?: string 69 | large_image: string 70 | large_sc_image: string 71 | medium_image: string 72 | MobileImage: string 73 | original_price: string 74 | Remarks?: string 75 | ShortText?: string 76 | showcase?: string 77 | small_image: string 78 | title: string 79 | cimsid: string 80 | user_defined_goal?: string 81 | disable_bot_redemptions?: string 82 | 'desc.large_text'?: string 83 | english_title?: string 84 | etid?: string 85 | sku?: string 86 | coupon_discount?: string 87 | } 88 | 89 | export interface GoalItemConfig { 90 | amount: string 91 | currencyCode: string 92 | isHidden: string 93 | PointToCurrencyConversionRatio: string 94 | } 95 | 96 | export interface Order { 97 | id: string 98 | t: Date 99 | sku: string 100 | item_snapshot: ItemSnapshot 101 | p: number 102 | s: S 103 | a: A 104 | child_redemption: null 105 | third_party_partner: null 106 | log: Log[] 107 | } 108 | 109 | export interface A { 110 | form?: string 111 | OrderId: string 112 | CorrelationId: string 113 | Channel: string 114 | Language: string 115 | Country: string 116 | EvaluationId: string 117 | provider?: string 118 | referenceOrderID?: string 119 | externalRefID?: string 120 | denomination?: string 121 | rewardName?: string 122 | sendEmail?: string 123 | status?: string 124 | createdAt?: Date 125 | bal_before_deduct?: string 126 | bal_after_deduct?: string 127 | } 128 | 129 | export interface ItemSnapshot { 130 | name: string 131 | provider: string 132 | price: number 133 | attributes: GoalItemAttributes 134 | config: ItemSnapshotConfig 135 | } 136 | 137 | export interface ItemSnapshotConfig { 138 | amount: string 139 | countryCode: string 140 | currencyCode: string 141 | sku: string 142 | } 143 | 144 | export interface Log { 145 | time: Date 146 | from: From 147 | to: S 148 | reason: string 149 | } 150 | 151 | export enum From { 152 | Created = 'Created', 153 | RiskApproved = 'RiskApproved', 154 | RiskReview = 'RiskReview' 155 | } 156 | 157 | export enum S { 158 | Cancelled = 'Cancelled', 159 | RiskApproved = 'RiskApproved', 160 | RiskReview = 'RiskReview', 161 | Shipped = 'Shipped' 162 | } 163 | 164 | export interface Profile { 165 | ruid: string 166 | attributes: ProfileAttributes 167 | offline_attributes: OfflineAttributes 168 | } 169 | 170 | export interface ProfileAttributes { 171 | publisher: string 172 | publisher_upd: Date 173 | creative: string 174 | creative_upd: Date 175 | program: string 176 | program_upd: Date 177 | country: string 178 | country_upd: Date 179 | referrerhash: string 180 | referrerhash_upd: Date 181 | optout_upd: Date 182 | language: string 183 | language_upd: Date 184 | target: string 185 | target_upd: Date 186 | created: Date 187 | created_upd: Date 188 | epuid: string 189 | epuid_upd: Date 190 | goal: string 191 | goal_upd: Date 192 | waitlistattributes: string 193 | waitlistattributes_upd: Date 194 | serpbotscore_upd: Date 195 | iscashbackeligible: string 196 | cbedc: string 197 | rlscpct_upd: Date 198 | give_user: string 199 | rebcpc_upd: Date 200 | SerpBotScore_upd: Date 201 | AdsBotScore_upd: Date 202 | dbs_upd: Date 203 | rbs: string 204 | rbs_upd: Date 205 | iris_segmentation: string 206 | iris_segmentation_upd: Date 207 | } 208 | 209 | export interface OfflineAttributes {} 210 | 211 | export interface Promotion { 212 | name: string 213 | priority: number 214 | attributes: { [key: string]: string } 215 | tags: Tag[] 216 | } 217 | 218 | export enum Tag { 219 | AllowTrialUser = 'allow_trial_user', 220 | ExcludeGivePcparent = 'exclude_give_pcparent', 221 | ExcludeGlobalConfig = 'exclude_global_config', 222 | ExcludeHidden = 'exclude_hidden', 223 | LOCString = 'locString', 224 | NonGlobalConfig = 'non_global_config' 225 | } 226 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 11 | "declaration": true /* Generates corresponding '.d.ts' file. */, 12 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 13 | "sourceMap": true /* Generates corresponding '.map' file. */, 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist" /* Redirect output structure to the directory. */, 16 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | /* Strict Type-Checking Options */ 25 | "strict": true /* Enable all strict type-checking options. */, 26 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 27 | "strictNullChecks": true /* Enable strict null checks. */, 28 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 32 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 33 | /* Additional Checks */ 34 | "noUnusedLocals": true /* Report errors on unused locals. */, 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 37 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 38 | "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */, 39 | "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an 'override' modifier. */, 40 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 43 | "types": ["node"], 44 | "typeRoots": ["./node_modules/@types"], 45 | // Keep explicit typeRoots to ensure resolution in environments that don't auto-detect before full install. 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | /* Advanced Options */ 64 | "skipLibCheck": true /* Skip type checking of declaration files. */, 65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 66 | "resolveJsonModule": true 67 | }, 68 | "include": ["src/**/*.ts", "src/accounts.json", "src/config.json", "src/functions/queries.json"], 69 | "exclude": ["node_modules"] 70 | } 71 | -------------------------------------------------------------------------------- /src/browser/auth/methods/Totp2FALogin.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'patchright' 2 | import * as OTPAuth from 'otpauth' 3 | import readline from 'readline' 4 | import type { MicrosoftRewardsBot } from '../../../index' 5 | 6 | export class TotpLogin { 7 | private readonly textInputSelector = 8 | 'form[name="OneTimeCodeViewForm"] input[type="text"], input#floatingLabelInput5' 9 | private readonly hiddenInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]' 10 | private readonly submitButtonSelector = 'button[type="submit"]' 11 | private readonly maxManualSeconds = 60 12 | private readonly maxManualAttempts = 5 13 | 14 | constructor(private bot: MicrosoftRewardsBot) {} 15 | 16 | private generateTotpCode(secret: string): string { 17 | return new OTPAuth.TOTP({ secret, digits: 6 }).generate() 18 | } 19 | 20 | private async promptManualCode(): Promise { 21 | return await new Promise(resolve => { 22 | const rl = readline.createInterface({ 23 | input: process.stdin, 24 | output: process.stdout 25 | }) 26 | 27 | let resolved = false 28 | 29 | const cleanup = (result: string | null) => { 30 | if (resolved) return 31 | resolved = true 32 | clearTimeout(timer) 33 | rl.close() 34 | resolve(result) 35 | } 36 | 37 | const timer = setTimeout(() => cleanup(null), this.maxManualSeconds * 1000) 38 | 39 | rl.question(`Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `, answer => { 40 | cleanup(answer.trim()) 41 | }) 42 | }) 43 | } 44 | 45 | private async fillCode(page: Page, code: string): Promise { 46 | try { 47 | const visibleInput = await page 48 | .waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 }) 49 | .catch(() => null) 50 | 51 | if (visibleInput) { 52 | await visibleInput.fill(code) 53 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled visible TOTP text input') 54 | return true 55 | } 56 | 57 | const hiddenInput = await page.$(this.hiddenInputSelector) 58 | 59 | if (hiddenInput) { 60 | await hiddenInput.fill(code) 61 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled hidden TOTP input') 62 | return true 63 | } 64 | 65 | this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found (visible or hidden)') 66 | return false 67 | } catch (error) { 68 | this.bot.logger.warn( 69 | this.bot.isMobile, 70 | 'LOGIN-TOTP', 71 | `Failed to fill TOTP input: ${error instanceof Error ? error.message : String(error)}` 72 | ) 73 | return false 74 | } 75 | } 76 | 77 | async handle(page: Page, totpSecret?: string): Promise { 78 | try { 79 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP 2FA authentication requested') 80 | 81 | if (totpSecret) { 82 | const code = this.generateTotpCode(totpSecret) 83 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret') 84 | 85 | const filled = await this.fillCode(page, code) 86 | 87 | if (!filled) { 88 | this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to locate or fill TOTP input field') 89 | throw new Error('TOTP input field not found') 90 | } 91 | 92 | await this.bot.utils.wait(500) 93 | await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector) 94 | await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) 95 | 96 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') 97 | return 98 | } 99 | 100 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input') 101 | 102 | for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { 103 | const code = await this.promptManualCode() 104 | 105 | if (!code || !/^\d{6}$/.test(code)) { 106 | this.bot.logger.warn( 107 | this.bot.isMobile, 108 | 'LOGIN-TOTP', 109 | `Invalid or missing TOTP code (attempt ${attempt}/${this.maxManualAttempts})` 110 | ) 111 | 112 | if (attempt === this.maxManualAttempts) { 113 | throw new Error('Manual TOTP input failed or timed out') 114 | } 115 | 116 | this.bot.logger.info( 117 | this.bot.isMobile, 118 | 'LOGIN-TOTP', 119 | 'Retrying manual TOTP input due to invalid code' 120 | ) 121 | continue 122 | } 123 | 124 | const filled = await this.fillCode(page, code) 125 | 126 | if (!filled) { 127 | this.bot.logger.error( 128 | this.bot.isMobile, 129 | 'LOGIN-TOTP', 130 | `Unable to locate or fill TOTP input field (attempt ${attempt}/${this.maxManualAttempts})` 131 | ) 132 | 133 | if (attempt === this.maxManualAttempts) { 134 | throw new Error('TOTP input field not found') 135 | } 136 | 137 | this.bot.logger.info( 138 | this.bot.isMobile, 139 | 'LOGIN-TOTP', 140 | 'Retrying manual TOTP input due to fill failure' 141 | ) 142 | continue 143 | } 144 | 145 | await this.bot.utils.wait(500) 146 | await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector) 147 | await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) 148 | 149 | this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') 150 | return 151 | } 152 | 153 | throw new Error(`Manual TOTP input failed after ${this.maxManualAttempts} attempts`) 154 | } catch (error) { 155 | this.bot.logger.error( 156 | this.bot.isMobile, 157 | 'LOGIN-TOTP', 158 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 159 | ) 160 | throw error 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/logging/Logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import cluster from 'cluster' 3 | import { sendDiscord } from './Discord' 4 | import { sendNtfy } from './Ntfy' 5 | import type { MicrosoftRewardsBot } from '../index' 6 | import { errorDiagnostic } from '../util/ErrorDiagnostic' 7 | import type { LogFilter } from '../interface/Config' 8 | 9 | export type Platform = boolean | 'main' 10 | export type LogLevel = 'info' | 'warn' | 'error' | 'debug' 11 | export type ColorKey = keyof typeof chalk 12 | export interface IpcLog { 13 | content: string 14 | level: LogLevel 15 | } 16 | 17 | type ChalkFn = (msg: string) => string 18 | 19 | function platformText(platform: Platform): string { 20 | return platform === 'main' ? 'MAIN' : platform ? 'MOBILE' : 'DESKTOP' 21 | } 22 | 23 | function platformBadge(platform: Platform): string { 24 | return platform === 'main' ? chalk.bgCyan('MAIN') : platform ? chalk.bgBlue('MOBILE') : chalk.bgMagenta('DESKTOP') 25 | } 26 | 27 | function getColorFn(color?: ColorKey): ChalkFn | null { 28 | return color && typeof chalk[color] === 'function' ? (chalk[color] as ChalkFn) : null 29 | } 30 | 31 | function consoleOut(level: LogLevel, msg: string, chalkFn: ChalkFn | null): void { 32 | const out = chalkFn ? chalkFn(msg) : msg 33 | switch (level) { 34 | case 'warn': 35 | return console.warn(out) 36 | case 'error': 37 | return console.error(out) 38 | default: 39 | return console.log(out) 40 | } 41 | } 42 | 43 | function formatMessage(message: string | Error): string { 44 | return message instanceof Error ? `${message.message}\n${message.stack || ''}` : message 45 | } 46 | 47 | export class Logger { 48 | constructor(private bot: MicrosoftRewardsBot) {} 49 | 50 | info(isMobile: Platform, title: string, message: string, color?: ColorKey) { 51 | return this.baseLog('info', isMobile, title, message, color) 52 | } 53 | 54 | warn(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) { 55 | return this.baseLog('warn', isMobile, title, message, color) 56 | } 57 | 58 | error(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) { 59 | return this.baseLog('error', isMobile, title, message, color) 60 | } 61 | 62 | debug(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) { 63 | return this.baseLog('debug', isMobile, title, message, color) 64 | } 65 | 66 | private baseLog( 67 | level: LogLevel, 68 | isMobile: Platform, 69 | title: string, 70 | message: string | Error, 71 | color?: ColorKey 72 | ): void { 73 | const now = new Date().toLocaleString() 74 | const formatted = formatMessage(message) 75 | 76 | const levelTag = level.toUpperCase() 77 | const cleanMsg = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${platformText( 78 | isMobile 79 | )} [${title}] ${formatted}` 80 | 81 | const config = this.bot.config 82 | 83 | if (level === 'debug' && !config.debugLogs && !process.argv.includes('-dev')) { 84 | return 85 | } 86 | 87 | const badge = platformBadge(isMobile) 88 | const consoleStr = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${badge} [${title}] ${formatted}` 89 | 90 | let logColor: ColorKey | undefined = color 91 | 92 | if (!logColor) { 93 | switch (level) { 94 | case 'error': 95 | logColor = 'red' 96 | break 97 | case 'warn': 98 | logColor = 'yellow' 99 | break 100 | case 'debug': 101 | logColor = 'magenta' 102 | break 103 | default: 104 | break 105 | } 106 | } 107 | 108 | if (level === 'error' && config.errorDiagnostics) { 109 | const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage 110 | const error = message instanceof Error ? message : new Error(String(message)) 111 | errorDiagnostic(page, error) 112 | } 113 | 114 | const consoleAllowed = this.shouldPassFilter(config.consoleLogFilter, level, cleanMsg) 115 | const webhookAllowed = this.shouldPassFilter(config.webhook.webhookLogFilter, level, cleanMsg) 116 | 117 | if (consoleAllowed) { 118 | consoleOut(level, consoleStr, getColorFn(logColor)) 119 | } 120 | 121 | if (!webhookAllowed) { 122 | return 123 | } 124 | 125 | if (cluster.isPrimary) { 126 | if (config.webhook.discord?.enabled && config.webhook.discord.url) { 127 | if (level === 'debug') return 128 | sendDiscord(config.webhook.discord.url, cleanMsg, level) 129 | } 130 | 131 | if (config.webhook.ntfy?.enabled && config.webhook.ntfy.url) { 132 | if (level === 'debug') return 133 | sendNtfy(config.webhook.ntfy, cleanMsg, level) 134 | } 135 | } else { 136 | process.send?.({ __ipcLog: { content: cleanMsg, level } }) 137 | } 138 | } 139 | 140 | private shouldPassFilter(filter: LogFilter | undefined, level: LogLevel, message: string): boolean { 141 | // If disabled or not, let all logs pass 142 | if (!filter || !filter.enabled) { 143 | return true 144 | } 145 | 146 | // Always log error levelo logs, remove these lines to disable this! 147 | if (level === 'error') { 148 | return true 149 | } 150 | 151 | const { mode, levels, keywords, regexPatterns } = filter 152 | 153 | const hasLevelRule = Array.isArray(levels) && levels.length > 0 154 | const hasKeywordRule = Array.isArray(keywords) && keywords.length > 0 155 | const hasPatternRule = Array.isArray(regexPatterns) && regexPatterns.length > 0 156 | 157 | if (!hasLevelRule && !hasKeywordRule && !hasPatternRule) { 158 | return mode === 'blacklist' 159 | } 160 | 161 | const lowerMessage = message.toLowerCase() 162 | let isMatch = false 163 | 164 | if (hasLevelRule && levels!.includes(level)) { 165 | isMatch = true 166 | } 167 | 168 | if (!isMatch && hasKeywordRule) { 169 | if (keywords!.some(k => lowerMessage.includes(k.toLowerCase()))) { 170 | isMatch = true 171 | } 172 | } 173 | 174 | // Fancy regex filtering if set! 175 | if (!isMatch && hasPatternRule) { 176 | for (const pattern of regexPatterns!) { 177 | try { 178 | const regex = new RegExp(pattern, 'i') 179 | if (regex.test(message)) { 180 | isMatch = true 181 | break 182 | } 183 | } catch {} 184 | } 185 | } 186 | 187 | return mode === 'whitelist' ? isMatch : !isMatch 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/functions/activities/app/DailyCheckIn.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import { randomUUID } from 'crypto' 3 | import { Workers } from '../../Workers' 4 | 5 | export class DailyCheckIn extends Workers { 6 | private gainedPoints: number = 0 7 | 8 | private oldBalance: number = this.bot.userData.currentPoints 9 | 10 | public async doDailyCheckIn() { 11 | if (!this.bot.accessToken) { 12 | this.bot.logger.warn( 13 | this.bot.isMobile, 14 | 'DAILY-CHECK-IN', 15 | 'Skipping: App access token not available, this activity requires it!' 16 | ) 17 | return 18 | } 19 | 20 | this.oldBalance = Number(this.bot.userData.currentPoints ?? 0) 21 | 22 | this.bot.logger.info( 23 | this.bot.isMobile, 24 | 'DAILY-CHECK-IN', 25 | `Starting Daily Check-In | geo=${this.bot.userData.geoLocale} | currentPoints=${this.oldBalance}` 26 | ) 27 | 28 | try { 29 | // Try type 101 first 30 | this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=101') 31 | 32 | let response = await this.submitDaily(101) // Try using 101 (EU Variant?) 33 | this.bot.logger.debug( 34 | this.bot.isMobile, 35 | 'DAILY-CHECK-IN', 36 | `Received Daily Check-In response | type=101 | status=${response?.status ?? 'unknown'}` 37 | ) 38 | 39 | let newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) 40 | this.gainedPoints = newBalance - this.oldBalance 41 | 42 | this.bot.logger.debug( 43 | this.bot.isMobile, 44 | 'DAILY-CHECK-IN', 45 | `Balance delta after Daily Check-In | type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` 46 | ) 47 | 48 | if (this.gainedPoints > 0) { 49 | this.bot.userData.currentPoints = newBalance 50 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints 51 | 52 | this.bot.logger.info( 53 | this.bot.isMobile, 54 | 'DAILY-CHECK-IN', 55 | `Completed Daily Check-In | type=101 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, 56 | 'green' 57 | ) 58 | return 59 | } 60 | 61 | this.bot.logger.debug( 62 | this.bot.isMobile, 63 | 'DAILY-CHECK-IN', 64 | `No points gained with type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | retryingWithType=103` 65 | ) 66 | 67 | // Fallback to type 103 68 | this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=103') 69 | 70 | response = await this.submitDaily(103) // Try using 103 (USA Variant?) 71 | this.bot.logger.debug( 72 | this.bot.isMobile, 73 | 'DAILY-CHECK-IN', 74 | `Received Daily Check-In response | type=103 | status=${response?.status ?? 'unknown'}` 75 | ) 76 | 77 | newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) 78 | this.gainedPoints = newBalance - this.oldBalance 79 | 80 | this.bot.logger.debug( 81 | this.bot.isMobile, 82 | 'DAILY-CHECK-IN', 83 | `Balance delta after Daily Check-In | type=103 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` 84 | ) 85 | 86 | if (this.gainedPoints > 0) { 87 | this.bot.userData.currentPoints = newBalance 88 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints 89 | 90 | this.bot.logger.info( 91 | this.bot.isMobile, 92 | 'DAILY-CHECK-IN', 93 | `Completed Daily Check-In | type=103 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, 94 | 'green' 95 | ) 96 | } else { 97 | this.bot.logger.warn( 98 | this.bot.isMobile, 99 | 'DAILY-CHECK-IN', 100 | `Daily Check-In completed but no points gained | typesTried=101,103 | oldBalance=${this.oldBalance} | finalBalance=${newBalance}` 101 | ) 102 | } 103 | } catch (error) { 104 | this.bot.logger.error( 105 | this.bot.isMobile, 106 | 'DAILY-CHECK-IN', 107 | `Error during Daily Check-In | message=${error instanceof Error ? error.message : String(error)}` 108 | ) 109 | } 110 | } 111 | 112 | private async submitDaily(type: number) { 113 | try { 114 | const jsonData = { 115 | id: randomUUID(), 116 | amount: 1, 117 | type: type, 118 | attributes: { 119 | offerid: 'Gamification_Sapphire_DailyCheckIn' 120 | }, 121 | country: this.bot.userData.geoLocale 122 | } 123 | 124 | this.bot.logger.debug( 125 | this.bot.isMobile, 126 | 'DAILY-CHECK-IN', 127 | `Preparing Daily Check-In payload | type=${type} | id=${jsonData.id} | amount=${jsonData.amount} | country=${jsonData.country}` 128 | ) 129 | 130 | const request: AxiosRequestConfig = { 131 | url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', 132 | method: 'POST', 133 | headers: { 134 | Authorization: `Bearer ${this.bot.accessToken}`, 135 | 'User-Agent': 136 | 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', 137 | 'Content-Type': 'application/json', 138 | 'X-Rewards-Country': this.bot.userData.geoLocale, 139 | 'X-Rewards-Language': 'en', 140 | 'X-Rewards-ismobile': 'true' 141 | }, 142 | data: JSON.stringify(jsonData) 143 | } 144 | 145 | this.bot.logger.debug( 146 | this.bot.isMobile, 147 | 'DAILY-CHECK-IN', 148 | `Sending Daily Check-In request | type=${type} | url=${request.url}` 149 | ) 150 | 151 | return this.bot.axios.request(request) 152 | } catch (error) { 153 | this.bot.logger.error( 154 | this.bot.isMobile, 155 | 'DAILY-CHECK-IN', 156 | `Error in submitDaily | type=${type} | message=${error instanceof Error ? error.message : String(error)}` 157 | ) 158 | throw error 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/functions/QueryEngine.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import type { 3 | BingSuggestionResponse, 4 | BingTrendingTopicsResponse, 5 | GoogleSearch, 6 | GoogleTrendsResponse 7 | } from '../interface/Search' 8 | import type { MicrosoftRewardsBot } from '../index' 9 | 10 | export class QueryCore { 11 | constructor(private bot: MicrosoftRewardsBot) {} 12 | 13 | async getGoogleTrends(geoLocale: string): Promise { 14 | const queryTerms: GoogleSearch[] = [] 15 | this.bot.logger.info( 16 | this.bot.isMobile, 17 | 'SEARCH-GOOGLE-TRENDS', 18 | `Generating search queries, can take a while! | GeoLocale: ${geoLocale}` 19 | ) 20 | 21 | try { 22 | const request: AxiosRequestConfig = { 23 | url: 'https://trends.google.com/_/TrendsUi/data/batchexecute', 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' 27 | }, 28 | data: `f.req=[[[i0OFE,"[null, null, \\"${geoLocale.toUpperCase()}\\", 0, null, 48]"]]]` 29 | } 30 | 31 | const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) 32 | const rawData = response.data 33 | 34 | const trendsData = this.extractJsonFromResponse(rawData) 35 | if (!trendsData) { 36 | throw this.bot.logger.error( 37 | this.bot.isMobile, 38 | 'SEARCH-GOOGLE-TRENDS', 39 | 'Failed to parse Google Trends response' 40 | ) 41 | } 42 | 43 | const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)]) 44 | if (mappedTrendsData.length < 90) { 45 | this.bot.logger.warn( 46 | this.bot.isMobile, 47 | 'SEARCH-GOOGLE-TRENDS', 48 | 'Insufficient search queries, falling back to US' 49 | ) 50 | return this.getGoogleTrends('US') 51 | } 52 | 53 | for (const [topic, relatedQueries] of mappedTrendsData) { 54 | queryTerms.push({ 55 | topic: topic as string, 56 | related: relatedQueries as string[] 57 | }) 58 | } 59 | } catch (error) { 60 | this.bot.logger.error( 61 | this.bot.isMobile, 62 | 'SEARCH-GOOGLE-TRENDS', 63 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 64 | ) 65 | } 66 | 67 | const queries = queryTerms.flatMap(x => [x.topic, ...x.related]) 68 | 69 | return queries 70 | } 71 | 72 | private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null { 73 | const lines = text.split('\n') 74 | for (const line of lines) { 75 | const trimmed = line.trim() 76 | if (trimmed.startsWith('[') && trimmed.endsWith(']')) { 77 | try { 78 | return JSON.parse(JSON.parse(trimmed)[0][2])[1] 79 | } catch { 80 | continue 81 | } 82 | } 83 | } 84 | 85 | return null 86 | } 87 | 88 | async getBingSuggestions(query: string = '', langCode: string = 'en'): Promise { 89 | this.bot.logger.info( 90 | this.bot.isMobile, 91 | 'SEARCH-BING-SUGGESTIONS', 92 | `Generating bing suggestions! | LangCode: ${langCode}` 93 | ) 94 | 95 | try { 96 | const request: AxiosRequestConfig = { 97 | url: `https://www.bingapis.com/api/v7/suggestions?q=${encodeURIComponent(query)}&appid=6D0A9B8C5100E9ECC7E11A104ADD76C10219804B&cc=xl&setlang=${langCode}`, 98 | method: 'POST', 99 | headers: { 100 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' 101 | } 102 | } 103 | 104 | const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) 105 | const rawData: BingSuggestionResponse = response.data 106 | 107 | const searchSuggestions = rawData.suggestionGroups[0]?.searchSuggestions 108 | 109 | if (!searchSuggestions?.length) { 110 | this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'API returned no results') 111 | return [] 112 | } 113 | 114 | return searchSuggestions.map(x => x.query) 115 | } catch (error) { 116 | this.bot.logger.error( 117 | this.bot.isMobile, 118 | 'SEARCH-GOOGLE-TRENDS', 119 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 120 | ) 121 | } 122 | 123 | return [] 124 | } 125 | 126 | async getBingRelatedTerms(term: string): Promise { 127 | try { 128 | const request = { 129 | url: `https://api.bing.com/osjson.aspx?query=${term}`, 130 | method: 'GET', 131 | headers: { 132 | 'Content-Type': 'application/json' 133 | } 134 | } 135 | 136 | const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) 137 | const rawData = response.data 138 | 139 | const relatedTerms = rawData[1] 140 | 141 | if (!relatedTerms?.length) { 142 | this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'API returned no results') 143 | return [] 144 | } 145 | 146 | return relatedTerms 147 | } catch (error) { 148 | this.bot.logger.error( 149 | this.bot.isMobile, 150 | 'SEARCH-BING-RELATED', 151 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 152 | ) 153 | } 154 | 155 | return [] 156 | } 157 | 158 | async getBingTendingTopics(langCode: string = 'en'): Promise { 159 | try { 160 | const request = { 161 | url: `https://www.bing.com/api/v7/news/trendingtopics?appid=91B36E34F9D1B900E54E85A77CF11FB3BE5279E6&cc=xl&setlang=${langCode}`, 162 | method: 'GET', 163 | headers: { 164 | 'Content-Type': 'application/json' 165 | } 166 | } 167 | 168 | const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) 169 | const rawData: BingTrendingTopicsResponse = response.data 170 | 171 | const trendingTopics = rawData.value 172 | 173 | if (!trendingTopics?.length) { 174 | this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'API returned no results') 175 | return [] 176 | } 177 | 178 | const queries = trendingTopics.map(x => x.query?.text?.trim() || x.name.trim()) 179 | 180 | return queries 181 | } catch (error) { 182 | this.bot.logger.error( 183 | this.bot.isMobile, 184 | 'SEARCH-BING-TRENDING', 185 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 186 | ) 187 | } 188 | 189 | return [] 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/browser/UserAgent.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator' 3 | 4 | import type { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil' 5 | import type { MicrosoftRewardsBot } from '../index' 6 | 7 | export class UserAgentManager { 8 | private static readonly NOT_A_BRAND_VERSION = '99' 9 | 10 | constructor(private bot: MicrosoftRewardsBot) {} 11 | 12 | async getUserAgent(isMobile: boolean) { 13 | const system = this.getSystemComponents(isMobile) 14 | const app = await this.getAppComponents(isMobile) 15 | 16 | const uaTemplate = isMobile 17 | ? `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Mobile Safari/537.36 EdgA/${app.edge_version}` 18 | : `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Safari/537.36 Edg/${app.edge_version}` 19 | 20 | const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0` 21 | 22 | const uaMetadata = { 23 | isMobile, 24 | platform: isMobile ? 'Android' : 'Windows', 25 | fullVersionList: [ 26 | { brand: 'Not/A)Brand', version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0` }, 27 | { brand: 'Microsoft Edge', version: app['edge_version'] }, 28 | { brand: 'Chromium', version: app['chrome_version'] } 29 | ], 30 | brands: [ 31 | { brand: 'Not/A)Brand', version: UserAgentManager.NOT_A_BRAND_VERSION }, 32 | { brand: 'Microsoft Edge', version: app['edge_major_version'] }, 33 | { brand: 'Chromium', version: app['chrome_major_version'] } 34 | ], 35 | platformVersion, 36 | architecture: isMobile ? '' : 'x86', 37 | bitness: isMobile ? '' : '64', 38 | model: '' 39 | } 40 | 41 | return { userAgent: uaTemplate, userAgentMetadata: uaMetadata } 42 | } 43 | 44 | async getChromeVersion(isMobile: boolean): Promise { 45 | try { 46 | const request = { 47 | url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json', 48 | method: 'GET', 49 | headers: { 50 | 'Content-Type': 'application/json' 51 | } 52 | } 53 | 54 | const response = await axios(request) 55 | const data: ChromeVersion = response.data 56 | return data.channels.Stable.version 57 | } catch (error) { 58 | this.bot.logger.error( 59 | isMobile, 60 | 'USERAGENT-CHROME-VERSION', 61 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 62 | ) 63 | throw error 64 | } 65 | } 66 | 67 | async getEdgeVersions(isMobile: boolean) { 68 | try { 69 | const request = { 70 | url: 'https://edgeupdates.microsoft.com/api/products', 71 | method: 'GET', 72 | headers: { 73 | 'Content-Type': 'application/json' 74 | } 75 | } 76 | 77 | const response = await axios(request) 78 | const data: EdgeVersion[] = response.data 79 | const stable = data.find(x => x.Product == 'Stable') as EdgeVersion 80 | return { 81 | android: stable.Releases.find(x => x.Platform == 'Android')?.ProductVersion, 82 | windows: stable.Releases.find(x => x.Platform == 'Windows' && x.Architecture == 'x64')?.ProductVersion 83 | } 84 | } catch (error) { 85 | this.bot.logger.error( 86 | isMobile, 87 | 'USERAGENT-EDGE-VERSION', 88 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 89 | ) 90 | throw error 91 | } 92 | } 93 | 94 | getSystemComponents(mobile: boolean): string { 95 | if (mobile) { 96 | const androidVersion = 10 + Math.floor(Math.random() * 5) 97 | return `Linux; Android ${androidVersion}; K` 98 | } 99 | 100 | return 'Windows NT 10.0; Win64; x64' 101 | } 102 | 103 | async getAppComponents(isMobile: boolean) { 104 | const versions = await this.getEdgeVersions(isMobile) 105 | const edgeVersion = isMobile ? versions.android : (versions.windows as string) 106 | const edgeMajorVersion = edgeVersion?.split('.')[0] 107 | 108 | const chromeVersion = await this.getChromeVersion(isMobile) 109 | const chromeMajorVersion = chromeVersion?.split('.')[0] 110 | const chromeReducedVersion = `${chromeMajorVersion}.0.0.0` 111 | 112 | return { 113 | not_a_brand_version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0`, 114 | not_a_brand_major_version: UserAgentManager.NOT_A_BRAND_VERSION, 115 | edge_version: edgeVersion as string, 116 | edge_major_version: edgeMajorVersion as string, 117 | chrome_version: chromeVersion as string, 118 | chrome_major_version: chromeMajorVersion as string, 119 | chrome_reduced_version: chromeReducedVersion as string 120 | } 121 | } 122 | 123 | async updateFingerprintUserAgent( 124 | fingerprint: BrowserFingerprintWithHeaders, 125 | isMobile: boolean 126 | ): Promise { 127 | try { 128 | const userAgentData = await this.getUserAgent(isMobile) 129 | const componentData = await this.getAppComponents(isMobile) 130 | 131 | //@ts-expect-error Errors due it not exactly matching 132 | fingerprint.fingerprint.navigator.userAgentData = userAgentData.userAgentMetadata 133 | fingerprint.fingerprint.navigator.userAgent = userAgentData.userAgent 134 | fingerprint.fingerprint.navigator.appVersion = userAgentData.userAgent.replace( 135 | `${fingerprint.fingerprint.navigator.appCodeName}/`, 136 | '' 137 | ) 138 | 139 | fingerprint.headers['user-agent'] = userAgentData.userAgent 140 | fingerprint.headers['sec-ch-ua'] = 141 | `"Microsoft Edge";v="${componentData.edge_major_version}", "Not=A?Brand";v="${componentData.not_a_brand_major_version}", "Chromium";v="${componentData.chrome_major_version}"` 142 | fingerprint.headers['sec-ch-ua-full-version-list'] = 143 | `"Microsoft Edge";v="${componentData.edge_version}", "Not=A?Brand";v="${componentData.not_a_brand_version}", "Chromium";v="${componentData.chrome_version}"` 144 | 145 | /* 146 | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0 147 | sec-ch-ua-full-version-list: "Microsoft Edge";v="129.0.2792.84", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90" 148 | sec-ch-ua: "Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129" 149 | 150 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 151 | "Google Chrome";v="129.0.6668.90", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90" 152 | */ 153 | 154 | return fingerprint 155 | } catch (error) { 156 | this.bot.logger.error( 157 | isMobile, 158 | 'USER-AGENT-UPDATE', 159 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 160 | ) 161 | throw error 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/functions/activities/api/Quiz.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import type { BasePromotion } from '../../../interface/DashboardData' 3 | import { Workers } from '../../Workers' 4 | 5 | export class Quiz extends Workers { 6 | private cookieHeader: string = '' 7 | 8 | private fingerprintHeader: { [x: string]: string } = {} 9 | 10 | private gainedPoints: number = 0 11 | 12 | private oldBalance: number = this.bot.userData.currentPoints 13 | 14 | async doQuiz(promotion: BasePromotion) { 15 | const offerId = promotion.offerId 16 | this.oldBalance = Number(this.bot.userData.currentPoints ?? 0) 17 | const startBalance = this.oldBalance 18 | 19 | this.bot.logger.info( 20 | this.bot.isMobile, 21 | 'QUIZ', 22 | `Starting quiz | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax} | currentPoints=${startBalance}` 23 | ) 24 | 25 | try { 26 | this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) 27 | .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) 28 | .join('; ') 29 | 30 | const fingerprintHeaders = { ...this.bot.fingerprint.headers } 31 | delete fingerprintHeaders['Cookie'] 32 | delete fingerprintHeaders['cookie'] 33 | this.fingerprintHeader = fingerprintHeaders 34 | 35 | this.bot.logger.debug( 36 | this.bot.isMobile, 37 | 'QUIZ', 38 | `Prepared quiz headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` 39 | ) 40 | 41 | // 8-question quiz 42 | if (promotion.activityProgressMax === 80) { 43 | this.bot.logger.warn( 44 | this.bot.isMobile, 45 | 'QUIZ', 46 | `Detected 8-question quiz (activityProgressMax=80), marking as completed | offerId=${offerId}` 47 | ) 48 | 49 | // Not implemented 50 | return 51 | } 52 | 53 | //Standard points quizzes (20/30/40/50 max) 54 | if ([20, 30, 40, 50].includes(promotion.pointProgressMax)) { 55 | let oldBalance = startBalance 56 | let gainedPoints = 0 57 | const maxAttempts = 20 58 | let totalGained = 0 59 | let attempts = 0 60 | 61 | this.bot.logger.debug( 62 | this.bot.isMobile, 63 | 'QUIZ', 64 | `Starting ReportActivity loop | offerId=${offerId} | maxAttempts=${maxAttempts} | startingBalance=${oldBalance}` 65 | ) 66 | 67 | for (let i = 0; i < maxAttempts; i++) { 68 | try { 69 | const jsonData = { 70 | UserId: null, 71 | TimeZoneOffset: -60, 72 | OfferId: offerId, 73 | ActivityCount: 1, 74 | QuestionIndex: '-1' 75 | } 76 | 77 | const request: AxiosRequestConfig = { 78 | url: 'https://www.bing.com/bingqa/ReportActivity?ajaxreq=1', 79 | method: 'POST', 80 | headers: { 81 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 82 | cookie: this.cookieHeader, 83 | ...this.fingerprintHeader 84 | }, 85 | data: JSON.stringify(jsonData) 86 | } 87 | 88 | this.bot.logger.debug( 89 | this.bot.isMobile, 90 | 'QUIZ', 91 | `Sending ReportActivity request | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | url=${request.url}` 92 | ) 93 | 94 | const response = await this.bot.axios.request(request) 95 | 96 | this.bot.logger.debug( 97 | this.bot.isMobile, 98 | 'QUIZ', 99 | `Received ReportActivity response | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | status=${response.status}` 100 | ) 101 | 102 | const newBalance = await this.bot.browser.func.getCurrentPoints() 103 | gainedPoints = newBalance - oldBalance 104 | 105 | this.bot.logger.debug( 106 | this.bot.isMobile, 107 | 'QUIZ', 108 | `Balance delta after ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}` 109 | ) 110 | 111 | attempts = i + 1 112 | 113 | if (gainedPoints > 0) { 114 | this.bot.userData.currentPoints = newBalance 115 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints 116 | 117 | oldBalance = newBalance 118 | totalGained += gainedPoints 119 | this.gainedPoints += gainedPoints 120 | 121 | this.bot.logger.info( 122 | this.bot.isMobile, 123 | 'QUIZ', 124 | `ReportActivity ${i + 1} → ${response.status} | offerId=${offerId} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`, 125 | 'green' 126 | ) 127 | } else { 128 | this.bot.logger.warn( 129 | this.bot.isMobile, 130 | 'QUIZ', 131 | `ReportActivity ${i + 1} | offerId=${offerId} | no more points gained, ending quiz | lastBalance=${newBalance}` 132 | ) 133 | break 134 | } 135 | 136 | this.bot.logger.debug( 137 | this.bot.isMobile, 138 | 'QUIZ', 139 | `Waiting between ReportActivity attempts | attempt=${i + 1}/${maxAttempts} | offerId=${offerId}` 140 | ) 141 | 142 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000)) 143 | } catch (error) { 144 | this.bot.logger.error( 145 | this.bot.isMobile, 146 | 'QUIZ', 147 | `Error during ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` 148 | ) 149 | break 150 | } 151 | } 152 | 153 | this.bot.logger.info( 154 | this.bot.isMobile, 155 | 'QUIZ', 156 | `Completed the quiz successfully | offerId=${offerId} | attempts=${attempts} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${this.bot.userData.currentPoints}` 157 | ) 158 | } else { 159 | this.bot.logger.warn( 160 | this.bot.isMobile, 161 | 'QUIZ', 162 | `Unsupported quiz configuration | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax}` 163 | ) 164 | } 165 | } catch (error) { 166 | this.bot.logger.error( 167 | this.bot.isMobile, 168 | 'QUIZ', 169 | `Error in doQuiz | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` 170 | ) 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/functions/Workers.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'patchright' 2 | import type { MicrosoftRewardsBot } from '../index' 3 | import type { DashboardData, PunchCard, BasePromotion, FindClippyPromotion } from '../interface/DashboardData' 4 | import type { AppDashboardData } from '../interface/AppDashBoardData' 5 | 6 | export class Workers { 7 | public bot: MicrosoftRewardsBot 8 | 9 | constructor(bot: MicrosoftRewardsBot) { 10 | this.bot = bot 11 | } 12 | 13 | public async doDailySet(data: DashboardData, page: Page) { 14 | const todayKey = this.bot.utils.getFormattedDate() 15 | const todayData = data.dailySetPromotions[todayKey] 16 | 17 | const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [] 18 | 19 | if (!activitiesUncompleted.length) { 20 | this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed') 21 | return 22 | } 23 | 24 | this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items') 25 | 26 | await this.solveActivities(activitiesUncompleted, page) 27 | 28 | this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed') 29 | } 30 | 31 | public async doMorePromotions(data: DashboardData, page: Page) { 32 | const morePromotions: BasePromotion[] = [ 33 | ...new Map( 34 | [...(data.morePromotions ?? []), ...(data.morePromotionsWithoutPromotionalItems ?? [])] 35 | .filter(Boolean) 36 | .map(p => [p.offerId, p as BasePromotion] as const) 37 | ).values() 38 | ] 39 | 40 | const activitiesUncompleted: BasePromotion[] = 41 | morePromotions?.filter( 42 | x => 43 | !x.complete && 44 | x.pointProgressMax > 0 && 45 | x.exclusiveLockedFeatureStatus !== 'locked' && 46 | x.promotionType 47 | ) ?? [] 48 | 49 | if (!activitiesUncompleted.length) { 50 | this.bot.logger.info( 51 | this.bot.isMobile, 52 | 'MORE-PROMOTIONS', 53 | 'All "More Promotion" items have already been completed' 54 | ) 55 | return 56 | } 57 | 58 | this.bot.logger.info( 59 | this.bot.isMobile, 60 | 'MORE-PROMOTIONS', 61 | `Started solving ${activitiesUncompleted.length} "More Promotions" items` 62 | ) 63 | 64 | await this.solveActivities(activitiesUncompleted, page) 65 | 66 | this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed') 67 | } 68 | 69 | public async doAppPromotions(data: AppDashboardData) { 70 | const appRewards = data.response.promotions.filter( 71 | x => 72 | x.attributes['complete']?.toLowerCase() === 'false' && 73 | x.attributes['offerid'] && 74 | x.attributes['type'] && 75 | x.attributes['type'] === 'sapphire' 76 | ) 77 | 78 | if (!appRewards.length) { 79 | this.bot.logger.info( 80 | this.bot.isMobile, 81 | 'APP-PROMOTIONS', 82 | 'All "App Promotions" items have already been completed' 83 | ) 84 | return 85 | } 86 | 87 | for (const reward of appRewards) { 88 | await this.bot.activities.doAppReward(reward) 89 | // A delay between completing each activity 90 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) 91 | } 92 | 93 | this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'All "App Promotions" items have been completed') 94 | } 95 | 96 | private async solveActivities(activities: BasePromotion[], page: Page, punchCard?: PunchCard) { 97 | for (const activity of activities) { 98 | try { 99 | const type = activity.promotionType?.toLowerCase() ?? '' 100 | const name = activity.name?.toLowerCase() ?? '' 101 | const offerId = (activity as BasePromotion).offerId 102 | const destinationUrl = activity.destinationUrl?.toLowerCase() ?? '' 103 | 104 | this.bot.logger.debug( 105 | this.bot.isMobile, 106 | 'ACTIVITY', 107 | `Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type} | punchCard="${punchCard?.parentPromotion?.title ?? 'none'}"` 108 | ) 109 | 110 | switch (type) { 111 | // Quiz-like activities (Poll / regular quiz variants) 112 | case 'quiz': { 113 | const basePromotion = activity as BasePromotion 114 | 115 | // Poll (usually 10 points, pollscenarioid in URL) 116 | if (activity.pointProgressMax === 10 && destinationUrl.includes('pollscenarioid')) { 117 | this.bot.logger.info( 118 | this.bot.isMobile, 119 | 'ACTIVITY', 120 | `Found activity type "Poll" | title="${activity.title}" | offerId=${offerId}` 121 | ) 122 | 123 | //await this.bot.activities.doPoll(basePromotion) 124 | break 125 | } 126 | 127 | // All other quizzes handled via Quiz API 128 | this.bot.logger.info( 129 | this.bot.isMobile, 130 | 'ACTIVITY', 131 | `Found activity type "Quiz" | title="${activity.title}" | offerId=${offerId}` 132 | ) 133 | 134 | await this.bot.activities.doQuiz(basePromotion) 135 | break 136 | } 137 | 138 | // UrlReward 139 | case 'urlreward': { 140 | const basePromotion = activity as BasePromotion 141 | 142 | // Search on Bing are subtypes of "urlreward" 143 | if (name.includes('exploreonbing')) { 144 | this.bot.logger.info( 145 | this.bot.isMobile, 146 | 'ACTIVITY', 147 | `Found activity type "SearchOnBing" | title="${activity.title}" | offerId=${offerId}` 148 | ) 149 | 150 | await this.bot.activities.doSearchOnBing(basePromotion, page) 151 | } else { 152 | this.bot.logger.info( 153 | this.bot.isMobile, 154 | 'ACTIVITY', 155 | `Found activity type "UrlReward" | title="${activity.title}" | offerId=${offerId}` 156 | ) 157 | 158 | await this.bot.activities.doUrlReward(basePromotion) 159 | } 160 | break 161 | } 162 | 163 | // Find Clippy specific promotion type 164 | case 'findclippy': { 165 | const clippyPromotion = activity as unknown as FindClippyPromotion 166 | 167 | this.bot.logger.info( 168 | this.bot.isMobile, 169 | 'ACTIVITY', 170 | `Found activity type "FindClippy" | title="${activity.title}" | offerId=${offerId}` 171 | ) 172 | 173 | await this.bot.activities.doFindClippy(clippyPromotion) 174 | break 175 | } 176 | 177 | // Unsupported types 178 | default: { 179 | this.bot.logger.warn( 180 | this.bot.isMobile, 181 | 'ACTIVITY', 182 | `Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"` 183 | ) 184 | break 185 | } 186 | } 187 | 188 | // Cooldown 189 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) 190 | } catch (error) { 191 | this.bot.logger.error( 192 | this.bot.isMobile, 193 | 'ACTIVITY', 194 | `Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}` 195 | ) 196 | } 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/browser/BrowserUtils.ts: -------------------------------------------------------------------------------- 1 | import { type Page, type BrowserContext } from 'patchright' 2 | import { CheerioAPI, load } from 'cheerio' 3 | import { ClickOptions, createCursor } from 'ghost-cursor-playwright-port' 4 | 5 | import type { MicrosoftRewardsBot } from '../index' 6 | 7 | export default class BrowserUtils { 8 | private bot: MicrosoftRewardsBot 9 | 10 | constructor(bot: MicrosoftRewardsBot) { 11 | this.bot = bot 12 | } 13 | 14 | async tryDismissAllMessages(page: Page): Promise { 15 | try { 16 | const buttons = [ 17 | { selector: '#acceptButton', label: 'AcceptButton' }, 18 | { selector: '#wcpConsentBannerCtrl > * > button:first-child', label: 'Bing Cookies Accept' }, 19 | { selector: '.ext-secondary.ext-button', label: '"Skip for now" Button' }, 20 | { selector: '#iLandingViewAction', label: 'iLandingViewAction' }, 21 | { selector: '#iShowSkip', label: 'iShowSkip' }, 22 | { selector: '#iNext', label: 'iNext' }, 23 | { selector: '#iLooksGood', label: 'iLooksGood' }, 24 | { selector: '#idSIButton9', label: 'idSIButton9' }, 25 | { selector: '.ms-Button.ms-Button--primary', label: 'Primary Button' }, 26 | { selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Button' }, 27 | { selector: '.maybe-later', label: 'Mobile Rewards App Banner' }, 28 | { selector: '#bnp_btn_accept', label: 'Bing Cookie Banner' }, 29 | { selector: '#reward_pivot_earn', label: 'Reward Coupon Accept' } 30 | ] 31 | 32 | const checkVisible = await Promise.allSettled( 33 | buttons.map(async b => ({ 34 | ...b, 35 | isVisible: await page 36 | .locator(b.selector) 37 | .isVisible() 38 | .catch(() => false) 39 | })) 40 | ) 41 | 42 | const visibleButtons = checkVisible 43 | .filter(r => r.status === 'fulfilled' && r.value.isVisible) 44 | .map(r => (r.status === 'fulfilled' ? r.value : null)) 45 | .filter(Boolean) 46 | 47 | if (visibleButtons.length > 0) { 48 | await Promise.allSettled( 49 | visibleButtons.map(async b => { 50 | if (b) { 51 | const clicked = await this.ghostClick(page, b.selector) 52 | if (clicked) { 53 | this.bot.logger.debug( 54 | this.bot.isMobile, 55 | 'DISMISS-ALL-MESSAGES', 56 | `Dismissed: ${b.label}` 57 | ) 58 | } 59 | } 60 | }) 61 | ) 62 | await this.bot.utils.wait(300) 63 | } 64 | 65 | // Overlay 66 | const overlay = await page.$('#bnp_overlay_wrapper') 67 | if (overlay) { 68 | const rejected = await this.ghostClick(page, '#bnp_btn_reject, button[aria-label*="Reject" i]') 69 | if (rejected) { 70 | this.bot.logger.debug(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject') 71 | } else { 72 | const accepted = await this.ghostClick(page, '#bnp_btn_accept') 73 | if (accepted) { 74 | this.bot.logger.debug( 75 | this.bot.isMobile, 76 | 'DISMISS-ALL-MESSAGES', 77 | 'Dismissed: Bing Overlay Accept' 78 | ) 79 | } 80 | } 81 | await this.bot.utils.wait(250) 82 | } 83 | } catch (error) { 84 | this.bot.logger.warn( 85 | this.bot.isMobile, 86 | 'DISMISS-ALL-MESSAGES', 87 | `Handler error: ${error instanceof Error ? error.message : String(error)}` 88 | ) 89 | } 90 | } 91 | 92 | async getLatestTab(page: Page): Promise { 93 | try { 94 | const browser: BrowserContext = page.context() 95 | const pages = browser.pages() 96 | 97 | const newTab = pages[pages.length - 1] 98 | if (!newTab) { 99 | throw this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', 'No tabs could be found!') 100 | } 101 | 102 | return newTab 103 | } catch (error) { 104 | this.bot.logger.error( 105 | this.bot.isMobile, 106 | 'GET-NEW-TAB', 107 | `Unable to get latest tab: ${error instanceof Error ? error.message : String(error)}` 108 | ) 109 | throw error 110 | } 111 | } 112 | 113 | async reloadBadPage(page: Page): Promise { 114 | try { 115 | const html = await page.content().catch(() => '') 116 | const $ = load(html) 117 | 118 | if ($('body.neterror').length) { 119 | this.bot.logger.info(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!') 120 | try { 121 | await page.reload({ waitUntil: 'load' }) 122 | } catch { 123 | await page.reload().catch(() => {}) 124 | } 125 | return true 126 | } else { 127 | return false 128 | } 129 | } catch (error) { 130 | this.bot.logger.error( 131 | this.bot.isMobile, 132 | 'RELOAD-BAD-PAGE', 133 | `Reload check failed: ${error instanceof Error ? error.message : String(error)}` 134 | ) 135 | return true 136 | } 137 | } 138 | 139 | async closeTabs(page: Page, config = { minTabs: 1, maxTabs: 1 }): Promise { 140 | try { 141 | const browser = page.context() 142 | const tabs = browser.pages() 143 | 144 | this.bot.logger.debug( 145 | this.bot.isMobile, 146 | 'SEARCH-CLOSE-TABS', 147 | `Found ${tabs.length} tab(s) open (min: ${config.minTabs}, max: ${config.maxTabs})` 148 | ) 149 | 150 | // Check if valid 151 | if (config.minTabs < 1 || config.maxTabs < config.minTabs) { 152 | this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'Invalid config, using defaults') 153 | config = { minTabs: 1, maxTabs: 1 } 154 | } 155 | 156 | // Close if more than max config 157 | if (tabs.length > config.maxTabs) { 158 | const tabsToClose = tabs.slice(config.maxTabs) 159 | 160 | const closeResults = await Promise.allSettled(tabsToClose.map(tab => tab.close())) 161 | 162 | const closedCount = closeResults.filter(r => r.status === 'fulfilled').length 163 | this.bot.logger.debug( 164 | this.bot.isMobile, 165 | 'SEARCH-CLOSE-TABS', 166 | `Closed ${closedCount}/${tabsToClose.length} excess tab(s) to reach max of ${config.maxTabs}` 167 | ) 168 | 169 | // Open more tabs 170 | } else if (tabs.length < config.minTabs) { 171 | const tabsNeeded = config.minTabs - tabs.length 172 | this.bot.logger.debug( 173 | this.bot.isMobile, 174 | 'SEARCH-CLOSE-TABS', 175 | `Opening ${tabsNeeded} tab(s) to reach min of ${config.minTabs}` 176 | ) 177 | 178 | const newTabPromises = Array.from({ length: tabsNeeded }, async () => { 179 | try { 180 | const newPage = await browser.newPage() 181 | await newPage.goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 15000 }) 182 | return newPage 183 | } catch (error) { 184 | this.bot.logger.warn( 185 | this.bot.isMobile, 186 | 'SEARCH-CLOSE-TABS', 187 | `Failed to create new tab: ${error instanceof Error ? error.message : String(error)}` 188 | ) 189 | return null 190 | } 191 | }) 192 | 193 | await Promise.allSettled(newTabPromises) 194 | } 195 | 196 | const latestTab = await this.getLatestTab(page) 197 | return latestTab 198 | } catch (error) { 199 | this.bot.logger.error( 200 | this.bot.isMobile, 201 | 'SEARCH-CLOSE-TABS', 202 | `Error: ${error instanceof Error ? error.message : String(error)}` 203 | ) 204 | return page 205 | } 206 | } 207 | 208 | async loadInCheerio(data: Page | string): Promise { 209 | const html: string = typeof data === 'string' ? data : await data.content() 210 | const $ = load(html) 211 | return $ 212 | } 213 | 214 | async ghostClick(page: Page, selector: string, options?: ClickOptions): Promise { 215 | try { 216 | this.bot.logger.debug( 217 | this.bot.isMobile, 218 | 'GHOST-CLICK', 219 | `Trying to click selector: ${selector}, options: ${JSON.stringify(options)}` 220 | ) 221 | 222 | // Wait for selector to exist before clicking 223 | await page.waitForSelector(selector, { timeout: 10000 }) 224 | 225 | const cursor = createCursor(page as any) 226 | await cursor.click(selector, options) 227 | 228 | return true 229 | } catch (error) { 230 | this.bot.logger.error( 231 | this.bot.isMobile, 232 | 'GHOST-CLICK', 233 | `Failed for ${selector}: ${error instanceof Error ? error.message : String(error)}` 234 | ) 235 | return false 236 | } 237 | } 238 | 239 | async disableFido(page: Page) { 240 | const routePattern = '**/GetCredentialType.srf*' 241 | await page.route(routePattern, route => { 242 | try { 243 | const request = route.request() 244 | const postData = request.postData() 245 | 246 | const body = postData ? JSON.parse(postData) : {} 247 | 248 | body.isFidoSupported = false 249 | 250 | this.bot.logger.debug( 251 | this.bot.isMobile, 252 | 'DISABLE-FIDO', 253 | `Modified request body: isFidoSupported set to ${body.isFidoSupported}` 254 | ) 255 | 256 | route.continue({ 257 | postData: JSON.stringify(body), 258 | headers: { 259 | ...request.headers(), 260 | 'Content-Type': 'application/json' 261 | } 262 | }) 263 | } catch (error) { 264 | this.bot.logger.debug( 265 | this.bot.isMobile, 266 | 'DISABLE-FIDO', 267 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 268 | ) 269 | route.continue() 270 | } 271 | }) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/functions/activities/browser/SearchOnBing.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | import type { Page } from 'patchright' 3 | import * as fs from 'fs' 4 | import path from 'path' 5 | 6 | import { Workers } from '../../Workers' 7 | import { QueryCore } from '../../QueryEngine' 8 | 9 | import type { BasePromotion } from '../../../interface/DashboardData' 10 | 11 | export class SearchOnBing extends Workers { 12 | private bingHome = 'https://bing.com' 13 | 14 | private cookieHeader: string = '' 15 | 16 | private fingerprintHeader: { [x: string]: string } = {} 17 | 18 | private gainedPoints: number = 0 19 | 20 | private success: boolean = false 21 | 22 | private oldBalance: number = this.bot.userData.currentPoints 23 | 24 | public async doSearchOnBing(promotion: BasePromotion, page: Page) { 25 | const offerId = promotion.offerId 26 | this.oldBalance = Number(this.bot.userData.currentPoints ?? 0) 27 | 28 | this.bot.logger.info( 29 | this.bot.isMobile, 30 | 'SEARCH-ON-BING', 31 | `Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentPoints=${this.oldBalance}` 32 | ) 33 | 34 | try { 35 | this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) 36 | .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) 37 | .join('; ') 38 | 39 | const fingerprintHeaders = { ...this.bot.fingerprint.headers } 40 | delete fingerprintHeaders['Cookie'] 41 | delete fingerprintHeaders['cookie'] 42 | this.fingerprintHeader = fingerprintHeaders 43 | 44 | this.bot.logger.debug( 45 | this.bot.isMobile, 46 | 'SEARCH-ON-BING', 47 | `Prepared headers for SearchOnBing | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` 48 | ) 49 | 50 | this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING', `Activating search task | offerId=${offerId}`) 51 | 52 | const activated = await this.activateSearchTask(promotion) 53 | if (!activated) { 54 | this.bot.logger.warn( 55 | this.bot.isMobile, 56 | 'SEARCH-ON-BING', 57 | `Search activity couldn't be activated, aborting | offerId=${offerId}` 58 | ) 59 | return 60 | } 61 | 62 | // Do the bing search here 63 | const queries = await this.getSearchQueries(promotion) 64 | 65 | // Run through the queries 66 | await this.searchBing(page, queries) 67 | 68 | if (this.success) { 69 | this.bot.logger.info( 70 | this.bot.isMobile, 71 | 'SEARCH-ON-BING', 72 | `Completed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}` 73 | ) 74 | } else { 75 | this.bot.logger.warn( 76 | this.bot.isMobile, 77 | 'SEARCH-ON-BING', 78 | `Failed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}` 79 | ) 80 | } 81 | } catch (error) { 82 | this.bot.logger.error( 83 | this.bot.isMobile, 84 | 'SEARCH-ON-BING', 85 | `Error in doSearchOnBing | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` 86 | ) 87 | } 88 | } 89 | 90 | private async searchBing(page: Page, queries: string[]) { 91 | queries = [...new Set(queries)] 92 | 93 | this.bot.logger.debug( 94 | this.bot.isMobile, 95 | 'SEARCH-ON-BING-SEARCH', 96 | `Starting search loop | queriesCount=${queries.length} | oldBalance=${this.oldBalance}` 97 | ) 98 | 99 | let i = 0 100 | for (const query of queries) { 101 | try { 102 | this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Processing query | query="${query}"`) 103 | 104 | await this.bot.mainMobilePage.goto(this.bingHome) 105 | 106 | // Wait until page loaded 107 | await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}) 108 | 109 | await this.bot.browser.utils.tryDismissAllMessages(page) 110 | 111 | const searchBar = '#sb_form_q' 112 | 113 | const searchBox = page.locator(searchBar) 114 | await searchBox.waitFor({ state: 'attached', timeout: 15000 }) 115 | 116 | await this.bot.utils.wait(500) 117 | await this.bot.browser.utils.ghostClick(page, searchBar, { clickCount: 3 }) 118 | await searchBox.fill('') 119 | 120 | await page.keyboard.type(query, { delay: 50 }) 121 | await page.keyboard.press('Enter') 122 | 123 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000)) 124 | 125 | // Check for point updates 126 | const newBalance = await this.bot.browser.func.getCurrentPoints() 127 | this.gainedPoints = newBalance - this.oldBalance 128 | 129 | this.bot.logger.debug( 130 | this.bot.isMobile, 131 | 'SEARCH-ON-BING-SEARCH', 132 | `Balance check after query | query="${query}" | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` 133 | ) 134 | 135 | if (this.gainedPoints > 0) { 136 | this.bot.userData.currentPoints = newBalance 137 | this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints 138 | 139 | this.bot.logger.info( 140 | this.bot.isMobile, 141 | 'SEARCH-ON-BING-SEARCH', 142 | `SearchOnBing query completed | query="${query}" | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, 143 | 'green' 144 | ) 145 | 146 | this.success = true 147 | return 148 | } else { 149 | this.bot.logger.warn( 150 | this.bot.isMobile, 151 | 'SEARCH-ON-BING-SEARCH', 152 | `${++i}/${queries.length} | noPoints=1 | query="${query}"` 153 | ) 154 | } 155 | } catch (error) { 156 | this.bot.logger.error( 157 | this.bot.isMobile, 158 | 'SEARCH-ON-BING-SEARCH', 159 | `Error during search loop | query="${query}" | message=${error instanceof Error ? error.message : String(error)}` 160 | ) 161 | } finally { 162 | await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) 163 | await page.goto(this.bot.config.baseURL, { timeout: 5000 }).catch(() => {}) 164 | } 165 | } 166 | 167 | this.bot.logger.warn( 168 | this.bot.isMobile, 169 | 'SEARCH-ON-BING-SEARCH', 170 | `Finished all queries with no points gained | queriesTried=${queries.length} | oldBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}` 171 | ) 172 | } 173 | 174 | // The task needs to be activated before being able to complete it 175 | private async activateSearchTask(promotion: BasePromotion): Promise { 176 | try { 177 | this.bot.logger.debug( 178 | this.bot.isMobile, 179 | 'SEARCH-ON-BING-ACTIVATE', 180 | `Preparing activation request | offerId=${promotion.offerId} | hash=${promotion.hash}` 181 | ) 182 | 183 | const formData = new URLSearchParams({ 184 | id: promotion.offerId, 185 | hash: promotion.hash, 186 | timeZone: '60', 187 | activityAmount: '1', 188 | dbs: '0', 189 | form: '', 190 | type: '', 191 | __RequestVerificationToken: this.bot.requestToken 192 | }) 193 | 194 | const request: AxiosRequestConfig = { 195 | url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', 196 | method: 'POST', 197 | headers: { 198 | ...(this.bot.fingerprint?.headers ?? {}), 199 | Cookie: this.cookieHeader, 200 | Referer: 'https://rewards.bing.com/', 201 | Origin: 'https://rewards.bing.com' 202 | }, 203 | data: formData 204 | } 205 | 206 | const response = await this.bot.axios.request(request) 207 | this.bot.logger.info( 208 | this.bot.isMobile, 209 | 'SEARCH-ON-BING-ACTIVATE', 210 | `Successfully activated activity | status=${response.status} | offerId=${promotion.offerId}` 211 | ) 212 | return true 213 | } catch (error) { 214 | this.bot.logger.error( 215 | this.bot.isMobile, 216 | 'SEARCH-ON-BING-ACTIVATE', 217 | `Activation failed | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` 218 | ) 219 | return false 220 | } 221 | } 222 | 223 | private async getSearchQueries(promotion: BasePromotion): Promise { 224 | interface Queries { 225 | title: string 226 | queries: string[] 227 | } 228 | 229 | let queries: Queries[] = [] 230 | 231 | try { 232 | if (this.bot.config.searchOnBingLocalQueries) { 233 | this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'Using local queries config file') 234 | 235 | const data = fs.readFileSync(path.join(__dirname, '../queries.json'), 'utf8') 236 | queries = JSON.parse(data) 237 | 238 | this.bot.logger.debug( 239 | this.bot.isMobile, 240 | 'SEARCH-ON-BING-QUERY', 241 | `Loaded queries config | source=local | entries=${queries.length}` 242 | ) 243 | } else { 244 | this.bot.logger.debug( 245 | this.bot.isMobile, 246 | 'SEARCH-ON-BING-QUERY', 247 | 'Fetching queries config from remote repository' 248 | ) 249 | 250 | // Fetch from the repo directly so the user doesn't need to redownload the script for the new activities 251 | const response = await this.bot.axios.request({ 252 | method: 'GET', 253 | url: 'https://raw.githubusercontent.com/TheNetsky/Microsoft-Rewards-Script/refs/heads/v3/src/functions/queries.json' 254 | }) 255 | queries = response.data 256 | 257 | this.bot.logger.debug( 258 | this.bot.isMobile, 259 | 'SEARCH-ON-BING-QUERY', 260 | `Loaded queries config | source=remote | entries=${queries.length}` 261 | ) 262 | } 263 | 264 | const answers = queries.find( 265 | x => this.bot.utils.normalizeString(x.title) === this.bot.utils.normalizeString(promotion.title) 266 | ) 267 | 268 | if (answers && answers.queries.length > 0) { 269 | const answer = this.bot.utils.shuffleArray(answers.queries) 270 | 271 | this.bot.logger.info( 272 | this.bot.isMobile, 273 | 'SEARCH-ON-BING-QUERY', 274 | `Found answers for activity title | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}" | answersCount=${answer.length} | firstQuery="${answer[0]}"` 275 | ) 276 | 277 | return answer 278 | } else { 279 | this.bot.logger.info( 280 | this.bot.isMobile, 281 | 'SEARCH-ON-BING-QUERY', 282 | `No matching title in queries config | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}"` 283 | ) 284 | 285 | const queryCore = new QueryCore(this.bot) 286 | 287 | const promotionDescription = promotion.description.toLowerCase().trim() 288 | const queryDescription = promotionDescription.replace('search on bing', '').trim() 289 | 290 | this.bot.logger.debug( 291 | this.bot.isMobile, 292 | 'SEARCH-ON-BING-QUERY', 293 | `Requesting Bing suggestions | queryDescription="${queryDescription}"` 294 | ) 295 | 296 | const bingSuggestions = await queryCore.getBingSuggestions(queryDescription) 297 | 298 | this.bot.logger.debug( 299 | this.bot.isMobile, 300 | 'SEARCH-ON-BING-QUERY', 301 | `Bing suggestions result | count=${bingSuggestions.length} | title="${promotion.title}"` 302 | ) 303 | 304 | // If no suggestions found 305 | if (!bingSuggestions.length) { 306 | this.bot.logger.info( 307 | this.bot.isMobile, 308 | 'SEARCH-ON-BING-QUERY', 309 | `No suggestions found, falling back to activity title | title="${promotion.title}"` 310 | ) 311 | return [promotion.title] 312 | } else { 313 | this.bot.logger.info( 314 | this.bot.isMobile, 315 | 'SEARCH-ON-BING-QUERY', 316 | `Using Bing suggestions as search queries | count=${bingSuggestions.length} | title="${promotion.title}"` 317 | ) 318 | return bingSuggestions 319 | } 320 | } 321 | } catch (error) { 322 | this.bot.logger.error( 323 | this.bot.isMobile, 324 | 'SEARCH-ON-BING-QUERY', 325 | `Error while resolving search queries | title="${promotion.title}" | message=${error instanceof Error ? error.message : String(error)} | fallback=promotionTitle` 326 | ) 327 | return [promotion.title] 328 | } 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/browser/BrowserFunc.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserContext, Cookie } from 'patchright' 2 | import type { AxiosRequestConfig, AxiosResponse } from 'axios' 3 | 4 | import type { MicrosoftRewardsBot } from '../index' 5 | import { saveSessionData } from '../util/Load' 6 | 7 | import type { Counters, DashboardData } from './../interface/DashboardData' 8 | import type { AppUserData } from '../interface/AppUserData' 9 | import type { XboxDashboardData } from '../interface/XboxDashboardData' 10 | import type { AppEarnablePoints, BrowserEarnablePoints, MissingSearchPoints } from '../interface/Points' 11 | import type { AppDashboardData } from '../interface/AppDashBoardData' 12 | 13 | export default class BrowserFunc { 14 | private bot: MicrosoftRewardsBot 15 | 16 | constructor(bot: MicrosoftRewardsBot) { 17 | this.bot = bot 18 | } 19 | 20 | /** 21 | * Fetch user desktop dashboard data 22 | * @returns {DashboardData} Object of user bing rewards dashboard data 23 | */ 24 | async getDashboardData(): Promise { 25 | try { 26 | const cookieHeader = this.bot.cookies.mobile 27 | .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) 28 | .join('; ') 29 | 30 | const request: AxiosRequestConfig = { 31 | url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`, 32 | method: 'GET', 33 | headers: { 34 | ...(this.bot.fingerprint?.headers ?? {}), 35 | Cookie: cookieHeader, 36 | Referer: 'https://rewards.bing.com/', 37 | Origin: 'https://rewards.bing.com' 38 | } 39 | } 40 | 41 | const response = await this.bot.axios.request(request) 42 | return response.data.dashboard as DashboardData 43 | } catch (error) { 44 | this.bot.logger.info( 45 | this.bot.isMobile, 46 | 'GET-DASHBOARD-DATA', 47 | `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` 48 | ) 49 | throw error 50 | } 51 | } 52 | 53 | /** 54 | * Fetch user app dashboard data 55 | * @returns {AppDashboardData} Object of user bing rewards dashboard data 56 | */ 57 | async getAppDashboardData(): Promise { 58 | try { 59 | const request: AxiosRequestConfig = { 60 | url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAIOS&options=613', 61 | method: 'GET', 62 | headers: { 63 | Authorization: `Bearer ${this.bot.accessToken}`, 64 | 'User-Agent': 65 | 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2' 66 | } 67 | } 68 | 69 | const response = await this.bot.axios.request(request) 70 | return response.data as AppDashboardData 71 | } catch (error) { 72 | this.bot.logger.info( 73 | this.bot.isMobile, 74 | 'GET-APP-DASHBOARD-DATA', 75 | `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` 76 | ) 77 | throw error 78 | } 79 | } 80 | 81 | /** 82 | * Fetch user xbox dashboard data 83 | * @returns {XboxDashboardData} Object of user bing rewards dashboard data 84 | */ 85 | async getXBoxDashboardData(): Promise { 86 | try { 87 | const request: AxiosRequestConfig = { 88 | url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=xboxapp&options=6', 89 | method: 'GET', 90 | headers: { 91 | Authorization: `Bearer ${this.bot.accessToken}`, 92 | 'User-Agent': 93 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One X) AppleWebKit/537.36 (KHTML, like Gecko) Edge/18.19041' 94 | } 95 | } 96 | 97 | const response = await this.bot.axios.request(request) 98 | return response.data as XboxDashboardData 99 | } catch (error) { 100 | this.bot.logger.info( 101 | this.bot.isMobile, 102 | 'GET-XBOX-DASHBOARD-DATA', 103 | `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` 104 | ) 105 | throw error 106 | } 107 | } 108 | 109 | /** 110 | * Get search point counters 111 | */ 112 | async getSearchPoints(): Promise { 113 | const dashboardData = await this.getDashboardData() // Always fetch newest data 114 | 115 | return dashboardData.userStatus.counters 116 | } 117 | 118 | missingSearchPoints(counters: Counters, isMobile: boolean): MissingSearchPoints { 119 | const mobileData = counters.mobileSearch?.[0] 120 | const desktopData = counters.pcSearch?.[0] 121 | const edgeData = counters.pcSearch?.[1] 122 | 123 | const mobilePoints = mobileData ? Math.max(0, mobileData.pointProgressMax - mobileData.pointProgress) : 0 124 | const desktopPoints = desktopData ? Math.max(0, desktopData.pointProgressMax - desktopData.pointProgress) : 0 125 | const edgePoints = edgeData ? Math.max(0, edgeData.pointProgressMax - edgeData.pointProgress) : 0 126 | 127 | const totalPoints = isMobile ? mobilePoints : desktopPoints + edgePoints 128 | 129 | return { mobilePoints, desktopPoints, edgePoints, totalPoints } 130 | } 131 | 132 | /** 133 | * Get total earnable points with web browser 134 | */ 135 | async getBrowserEarnablePoints(): Promise { 136 | try { 137 | const data = await this.getDashboardData() 138 | 139 | const desktopSearchPoints = 140 | data.userStatus.counters.pcSearch?.reduce( 141 | (sum, x) => sum + (x.pointProgressMax - x.pointProgress), 142 | 0 143 | ) ?? 0 144 | 145 | const mobileSearchPoints = 146 | data.userStatus.counters.mobileSearch?.reduce( 147 | (sum, x) => sum + (x.pointProgressMax - x.pointProgress), 148 | 0 149 | ) ?? 0 150 | 151 | const todayDate = this.bot.utils.getFormattedDate() 152 | const dailySetPoints = 153 | data.dailySetPromotions[todayDate]?.reduce( 154 | (sum, x) => sum + (x.pointProgressMax - x.pointProgress), 155 | 0 156 | ) ?? 0 157 | 158 | const morePromotionsPoints = 159 | data.morePromotions?.reduce((sum, x) => { 160 | if ( 161 | ['quiz', 'urlreward'].includes(x.promotionType) && 162 | x.exclusiveLockedFeatureStatus !== 'locked' 163 | ) { 164 | return sum + (x.pointProgressMax - x.pointProgress) 165 | } 166 | return sum 167 | }, 0) ?? 0 168 | 169 | const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints 170 | 171 | return { 172 | dailySetPoints, 173 | morePromotionsPoints, 174 | desktopSearchPoints, 175 | mobileSearchPoints, 176 | totalEarnablePoints 177 | } 178 | } catch (error) { 179 | this.bot.logger.error( 180 | this.bot.isMobile, 181 | 'GET-BROWSER-EARNABLE-POINTS', 182 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 183 | ) 184 | throw error 185 | } 186 | } 187 | 188 | /** 189 | * Get total earnable points with mobile app 190 | */ 191 | async getAppEarnablePoints(): Promise { 192 | try { 193 | const eligibleOffers = ['ENUS_readarticle3_30points', 'Gamification_Sapphire_DailyCheckIn'] 194 | 195 | const request: AxiosRequestConfig = { 196 | url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613', 197 | method: 'GET', 198 | headers: { 199 | Authorization: `Bearer ${this.bot.accessToken}`, 200 | 'X-Rewards-Country': this.bot.userData.geoLocale, 201 | 'X-Rewards-Language': 'en', 202 | 'X-Rewards-ismobile': 'true' 203 | } 204 | } 205 | 206 | const response = await this.bot.axios.request(request) 207 | const userData: AppUserData = response.data 208 | const eligibleActivities = userData.response.promotions.filter(x => 209 | eligibleOffers.includes(x.attributes.offerid ?? '') 210 | ) 211 | 212 | let readToEarn = 0 213 | let checkIn = 0 214 | 215 | for (const item of eligibleActivities) { 216 | const attrs = item.attributes 217 | 218 | if (attrs.type === 'msnreadearn') { 219 | const pointMax = parseInt(attrs.pointmax ?? '0') 220 | const pointProgress = parseInt(attrs.pointprogress ?? '0') 221 | readToEarn = Math.max(0, pointMax - pointProgress) 222 | } else if (attrs.type === 'checkin') { 223 | const progress = parseInt(attrs.progress ?? '0') 224 | const checkInDay = progress % 7 225 | const lastUpdated = new Date(attrs.last_updated ?? '') 226 | const today = new Date() 227 | 228 | if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) { 229 | checkIn = parseInt(attrs[`day_${checkInDay + 1}_points`] ?? '0') 230 | } 231 | } 232 | } 233 | 234 | const totalEarnablePoints = readToEarn + checkIn 235 | 236 | return { 237 | readToEarn, 238 | checkIn, 239 | totalEarnablePoints 240 | } 241 | } catch (error) { 242 | this.bot.logger.error( 243 | this.bot.isMobile, 244 | 'GET-APP-EARNABLE-POINTS', 245 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 246 | ) 247 | throw error 248 | } 249 | } 250 | /** 251 | * Get current point amount 252 | * @returns {number} Current total point amount 253 | */ 254 | async getCurrentPoints(): Promise { 255 | try { 256 | const data = await this.getDashboardData() 257 | 258 | return data.userStatus.availablePoints 259 | } catch (error) { 260 | this.bot.logger.error( 261 | this.bot.isMobile, 262 | 'GET-CURRENT-POINTS', 263 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 264 | ) 265 | throw error 266 | } 267 | } 268 | 269 | async closeBrowser(browser: BrowserContext, email: string) { 270 | try { 271 | const cookies = await browser.cookies() 272 | 273 | // Save cookies 274 | this.bot.logger.debug( 275 | this.bot.isMobile, 276 | 'CLOSE-BROWSER', 277 | `Saving ${cookies.length} cookies to session folder!` 278 | ) 279 | await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile) 280 | 281 | await this.bot.utils.wait(2000) 282 | 283 | // Close browser 284 | await browser.close() 285 | this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!') 286 | } catch (error) { 287 | this.bot.logger.error( 288 | this.bot.isMobile, 289 | 'CLOSE-BROWSER', 290 | `An error occurred: ${error instanceof Error ? error.message : String(error)}` 291 | ) 292 | throw error 293 | } 294 | } 295 | 296 | mergeCookies(response: AxiosResponse, currentCookieHeader: string = '', whitelist?: string[]): string { 297 | const cookieMap = new Map( 298 | currentCookieHeader 299 | .split(';') 300 | .map(pair => pair.split('=').map(s => s.trim())) 301 | .filter(([name, value]) => name && value) 302 | .map(([name, value]) => [name, value] as [string, string]) 303 | ) 304 | 305 | const setCookieList = [response.headers['set-cookie']].flat().filter(Boolean) as string[] 306 | const cookiesByName = new Map(this.bot.cookies.mobile.map(c => [c.name, c])) 307 | 308 | for (const setCookie of setCookieList) { 309 | const [nameValue, ...attributes] = setCookie.split(';').map(s => s.trim()) 310 | if (!nameValue) continue 311 | 312 | const [name, value] = nameValue.split('=').map(s => s.trim()) 313 | 314 | if (!name) continue 315 | 316 | if (whitelist && !whitelist?.includes(name)) { 317 | continue 318 | } 319 | 320 | const attrs = this.parseAttributes(attributes) 321 | const existing = cookiesByName.get(name) 322 | 323 | if (!value) { 324 | if (existing) { 325 | cookiesByName.delete(name) 326 | this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name) 327 | } 328 | cookieMap.delete(name) 329 | continue 330 | } 331 | 332 | if (attrs.expires !== undefined && attrs.expires < Date.now() / 1000) { 333 | if (existing) { 334 | cookiesByName.delete(name) 335 | this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name) 336 | } 337 | cookieMap.delete(name) 338 | continue 339 | } 340 | 341 | cookieMap.set(name, value) 342 | 343 | if (existing) { 344 | this.updateCookie(existing, value, attrs) 345 | } else { 346 | this.bot.cookies.mobile.push(this.createCookie(name, value, attrs)) 347 | } 348 | } 349 | 350 | return Array.from(cookieMap, ([name, value]) => `${name}=${value}`).join('; ') 351 | } 352 | 353 | private parseAttributes(attributes: string[]) { 354 | const attrs: { 355 | domain?: string 356 | path?: string 357 | expires?: number 358 | httpOnly?: boolean 359 | secure?: boolean 360 | sameSite?: Cookie['sameSite'] 361 | } = {} 362 | 363 | for (const attr of attributes) { 364 | const [key, val] = attr.split('=').map(s => s?.trim()) 365 | const lowerKey = key?.toLowerCase() 366 | 367 | switch (lowerKey) { 368 | case 'domain': 369 | case 'path': { 370 | if (val) attrs[lowerKey] = val 371 | break 372 | } 373 | case 'expires': { 374 | if (val) { 375 | const ts = Date.parse(val) 376 | if (!isNaN(ts)) attrs.expires = Math.floor(ts / 1000) 377 | } 378 | break 379 | } 380 | case 'max-age': { 381 | if (val) { 382 | const maxAge = Number(val) 383 | if (!isNaN(maxAge)) attrs.expires = Math.floor(Date.now() / 1000) + maxAge 384 | } 385 | break 386 | } 387 | case 'httponly': { 388 | attrs.httpOnly = true 389 | break 390 | } 391 | case 'secure': { 392 | attrs.secure = true 393 | break 394 | } 395 | case 'samesite': { 396 | const normalized = val?.toLowerCase() 397 | if (normalized && ['lax', 'strict', 'none'].includes(normalized)) { 398 | attrs.sameSite = (normalized.charAt(0).toUpperCase() + 399 | normalized.slice(1)) as Cookie['sameSite'] 400 | } 401 | break 402 | } 403 | } 404 | } 405 | 406 | return attrs 407 | } 408 | 409 | private updateCookie(cookie: Cookie, value: string, attrs: ReturnType) { 410 | cookie.value = value 411 | if (attrs.domain) cookie.domain = attrs.domain 412 | if (attrs.path) cookie.path = attrs.path 413 | //if (attrs.expires !== undefined) cookie.expires = attrs.expires 414 | //if (attrs.httpOnly) cookie.httpOnly = true 415 | //if (attrs.secure) cookie.secure = true 416 | //if (attrs.sameSite) cookie.sameSite = attrs.sameSite 417 | } 418 | 419 | private createCookie(name: string, value: string, attrs: ReturnType): Cookie { 420 | return { 421 | name, 422 | value, 423 | domain: attrs.domain || '.bing.com', 424 | path: attrs.path || '/' 425 | /* 426 | ...(attrs.expires !== undefined && { expires: attrs.expires }), 427 | ...(attrs.httpOnly && { httpOnly: true }), 428 | ...(attrs.secure && { secure: true }), 429 | ...(attrs.sameSite && { sameSite: attrs.sameSite }) 430 | */ 431 | } as Cookie 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /src/functions/queries.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Houses near you", 4 | "queries": [ 5 | "Houses near me", 6 | "Homes for sale near me", 7 | "Apartments near me", 8 | "Real estate listings near me", 9 | "Zillow homes near me", 10 | "houses for rent near me" 11 | ] 12 | }, 13 | { 14 | "title": "Feeling symptoms?", 15 | "queries": [ 16 | "Rash on forearm", 17 | "Stuffy nose", 18 | "Tickling cough", 19 | "sore throat remedies", 20 | "headache and nausea causes", 21 | "fever symptoms adults" 22 | ] 23 | }, 24 | { 25 | "title": "Get your shopping done faster", 26 | "queries": [ 27 | "Buy PS5", 28 | "Buy Xbox", 29 | "Chair deals", 30 | "wireless mouse deals", 31 | "best gaming headset price", 32 | "laptop deals", 33 | "buy office chair", 34 | "SSD deals" 35 | ] 36 | }, 37 | { 38 | "title": "Translate anything", 39 | "queries": [ 40 | "Translate welcome home to Korean", 41 | "Translate welcome home to Japanese", 42 | "Translate goodbye to Japanese", 43 | "Translate good morning to Spanish", 44 | "Translate thank you to French", 45 | "Translate see you later to Italian" 46 | ] 47 | }, 48 | { 49 | "title": "Search the lyrics of a song", 50 | "queries": [ 51 | "Debarge rhythm of the night lyrics", 52 | "bohemian rhapsody lyrics", 53 | "hotel california lyrics", 54 | "blinding lights lyrics", 55 | "lose yourself lyrics", 56 | "smells like teen spirit lyrics" 57 | ] 58 | }, 59 | { 60 | "title": "Let's watch that movie again!", 61 | "queries": [ 62 | "Alien movie", 63 | "Aliens movie", 64 | "Alien 3 movie", 65 | "Predator movie", 66 | "Terminator movie", 67 | "John Wick movie", 68 | "Interstellar movie", 69 | "The Matrix movie" 70 | ] 71 | }, 72 | { 73 | "title": "Plan a quick getaway", 74 | "queries": [ 75 | "Flights Amsterdam to Tokyo", 76 | "Flights New York to Tokyo", 77 | "cheap flights to paris", 78 | "flights amsterdam to rome", 79 | "last minute flight deals", 80 | "direct flights from amsterdam", 81 | "weekend getaway europe", 82 | "best time to visit tokyo" 83 | ] 84 | }, 85 | { 86 | "title": "Discover open job roles", 87 | "queries": [ 88 | "jobs at Microsoft", 89 | "Microsoft Job Openings", 90 | "Jobs near me", 91 | "jobs at Boeing worked", 92 | "software engineer jobs near me", 93 | "remote developer jobs", 94 | "IT jobs netherlands", 95 | "customer support jobs near me" 96 | ] 97 | }, 98 | { 99 | "title": "You can track your package", 100 | "queries": [ 101 | "USPS tracking", 102 | "UPS tracking", 103 | "DHL tracking", 104 | "FedEx tracking", 105 | "track my package", 106 | "international package tracking" 107 | ] 108 | }, 109 | { 110 | "title": "Find somewhere new to explore", 111 | "queries": [ 112 | "Directions to Berlin", 113 | "Directions to Tokyo", 114 | "Directions to New York", 115 | "things to do in berlin", 116 | "tourist attractions tokyo", 117 | "best places to visit in new york", 118 | "hidden gems near me", 119 | "day trips near me" 120 | ] 121 | }, 122 | { 123 | "title": "Too tired to cook tonight?", 124 | "queries": [ 125 | "KFC near me", 126 | "Burger King near me", 127 | "McDonalds near me", 128 | "pizza delivery near me", 129 | "restaurants open now", 130 | "best takeout near me", 131 | "quick dinner ideas", 132 | "easy dinner recipes" 133 | ] 134 | }, 135 | { 136 | "title": "Quickly convert your money", 137 | "queries": [ 138 | "convert 250 USD to yen", 139 | "convert 500 USD to yen", 140 | "usd to eur", 141 | "gbp to eur", 142 | "eur to jpy", 143 | "currency converter", 144 | "exchange rate today", 145 | "1000 yen to euro" 146 | ] 147 | }, 148 | { 149 | "title": "Learn to cook a new recipe", 150 | "queries": [ 151 | "How to cook ratatouille", 152 | "How to cook lasagna", 153 | "easy pasta recipe", 154 | "how to make pancakes", 155 | "how to make fried rice", 156 | "simple chicken recipe" 157 | ] 158 | }, 159 | { 160 | "title": "Find places to stay!", 161 | "queries": [ 162 | "Hotels Berlin Germany", 163 | "Hotels Amsterdam Netherlands", 164 | "hotels in paris", 165 | "best hotels in tokyo", 166 | "cheap hotels london", 167 | "places to stay in barcelona", 168 | "hotel deals", 169 | "booking hotels near me" 170 | ] 171 | }, 172 | { 173 | "title": "How's the economy?", 174 | "queries": [ 175 | "sp 500", 176 | "nasdaq", 177 | "dow jones today", 178 | "inflation rate europe", 179 | "interest rates today", 180 | "stock market today", 181 | "economic news", 182 | "recession forecast" 183 | ] 184 | }, 185 | { 186 | "title": "Who won?", 187 | "queries": [ 188 | "braves score", 189 | "champions league results", 190 | "premier league results", 191 | "nba score", 192 | "formula 1 winner", 193 | "latest football scores", 194 | "ucl final winner", 195 | "world cup final result" 196 | ] 197 | }, 198 | { 199 | "title": "Gaming time", 200 | "queries": [ 201 | "Overwatch video game", 202 | "Call of duty video game", 203 | "best games 2025", 204 | "top xbox games", 205 | "popular steam games", 206 | "new pc games", 207 | "game reviews", 208 | "best co-op games" 209 | ] 210 | }, 211 | { 212 | "title": "Expand your vocabulary", 213 | "queries": [ 214 | "definition definition", 215 | "meaning of serendipity", 216 | "define nostalgia", 217 | "synonym for happy", 218 | "define eloquent", 219 | "what does epiphany mean", 220 | "word of the day", 221 | "define immaculate" 222 | ] 223 | }, 224 | { 225 | "title": "What time is it?", 226 | "queries": [ 227 | "Japan time", 228 | "New York time", 229 | "time in london", 230 | "time in tokyo", 231 | "current time in amsterdam", 232 | "time in los angeles" 233 | ] 234 | }, 235 | { 236 | "title": "Find deals on Bing", 237 | "queries": [ 238 | "best laptop deals", 239 | "tech deals today", 240 | "wireless earbuds deals", 241 | "gaming chair deals", 242 | "discount codes electronics", 243 | "best amazon deals today", 244 | "smartphone deals", 245 | "ssd deals" 246 | ] 247 | }, 248 | { 249 | "title": "Prepare for the weather", 250 | "queries": [ 251 | "weather tomorrow", 252 | "weekly weather forecast", 253 | "rain forecast today", 254 | "weather in amsterdam", 255 | "storm forecast europe", 256 | "uv index today", 257 | "temperature this weekend", 258 | "snow forecast" 259 | ] 260 | }, 261 | { 262 | "title": "Track your delivery", 263 | "queries": [ 264 | "track my package", 265 | "postnl track and trace", 266 | "dhl parcel tracking", 267 | "ups tracking", 268 | "fedex tracking", 269 | "usps tracking", 270 | "parcel tracking", 271 | "international package tracking" 272 | ] 273 | }, 274 | { 275 | "title": "Explore a new spot today", 276 | "queries": [ 277 | "places to visit near me", 278 | "things to do near me", 279 | "hidden gems netherlands", 280 | "best museums near me", 281 | "parks near me", 282 | "tourist attractions nearby", 283 | "best cafes near me", 284 | "day trip ideas" 285 | ] 286 | }, 287 | { 288 | "title": "Maisons près de chez vous", 289 | "queries": [ 290 | "Maisons près de chez moi", 291 | "Maisons à vendre près de chez moi", 292 | "Appartements près de chez moi", 293 | "Annonces immobilières près de chez moi", 294 | "Maisons à louer près de chez moi" 295 | ] 296 | }, 297 | { 298 | "title": "Vous ressentez des symptômes ?", 299 | "queries": [ 300 | "Éruption cutanée sur l'avant-bras", 301 | "Nez bouché", 302 | "Toux chatouilleuse", 303 | "mal de gorge remèdes", 304 | "maux de tête causes", 305 | "symptômes de la grippe" 306 | ] 307 | }, 308 | { 309 | "title": "Faites vos achats plus vite", 310 | "queries": [ 311 | "Acheter une PS5", 312 | "Acheter une Xbox", 313 | "Offres sur les chaises", 314 | "offres ordinateur portable", 315 | "meilleures offres casque", 316 | "acheter souris sans fil", 317 | "promotions ssd", 318 | "bons plans tech" 319 | ] 320 | }, 321 | { 322 | "title": "Traduisez tout !", 323 | "queries": [ 324 | "Traduction bienvenue à la maison en coréen", 325 | "Traduction bienvenue à la maison en japonais", 326 | "Traduction au revoir en japonais", 327 | "Traduire bonjour en espagnol", 328 | "Traduire merci en anglais", 329 | "Traduire à plus tard en italien" 330 | ] 331 | }, 332 | { 333 | "title": "Rechercher paroles de chanson", 334 | "queries": [ 335 | "Paroles de Debarge rhythm of the night", 336 | "paroles bohemian rhapsody", 337 | "paroles hotel california", 338 | "paroles blinding lights", 339 | "paroles lose yourself", 340 | "paroles smells like teen spirit" 341 | ] 342 | }, 343 | { 344 | "title": "Et si nous regardions ce film une nouvelle fois?", 345 | "queries": [ 346 | "Alien film", 347 | "Film Aliens", 348 | "Film Alien 3", 349 | "Film Predator", 350 | "Film Terminator", 351 | "Film John Wick", 352 | "Film Interstellar", 353 | "Film Matrix" 354 | ] 355 | }, 356 | { 357 | "title": "Planifiez une petite escapade", 358 | "queries": [ 359 | "Vols Amsterdam-Tokyo", 360 | "Vols New York-Tokyo", 361 | "vols pas chers paris", 362 | "vols amsterdam rome", 363 | "offres vols dernière minute", 364 | "week-end en europe", 365 | "vols directs depuis amsterdam" 366 | ] 367 | }, 368 | { 369 | "title": "Consulter postes à pourvoir", 370 | "queries": [ 371 | "emplois chez Microsoft", 372 | "Offres d'emploi Microsoft", 373 | "Emplois près de chez moi", 374 | "emplois chez Boeing", 375 | "emplois développeur à distance", 376 | "emplois informatique pays-bas", 377 | "offres d'emploi près de chez moi" 378 | ] 379 | }, 380 | { 381 | "title": "Vous pouvez suivre votre colis", 382 | "queries": [ 383 | "Suivi Chronopost", 384 | "suivi colis", 385 | "suivi DHL", 386 | "suivi UPS", 387 | "suivi FedEx", 388 | "suivi international colis" 389 | ] 390 | }, 391 | { 392 | "title": "Trouver un endroit à découvrir", 393 | "queries": [ 394 | "Itinéraire vers Berlin", 395 | "Itinéraire vers Tokyo", 396 | "Itinéraire vers New York", 397 | "que faire à berlin", 398 | "attractions tokyo", 399 | "meilleurs endroits à visiter à new york", 400 | "endroits à visiter près de chez moi" 401 | ] 402 | }, 403 | { 404 | "title": "Trop fatigué pour cuisiner ce soir ?", 405 | "queries": [ 406 | "KFC près de chez moi", 407 | "Burger King près de chez moi", 408 | "McDonalds près de chez moi", 409 | "livraison pizza près de chez moi", 410 | "restaurants ouverts maintenant", 411 | "idées dîner rapide", 412 | "quoi manger ce soir" 413 | ] 414 | }, 415 | { 416 | "title": "Convertissez rapidement votre argent", 417 | "queries": [ 418 | "convertir 250 EUR en yen", 419 | "convertir 500 EUR en yen", 420 | "usd en eur", 421 | "gbp en eur", 422 | "eur en jpy", 423 | "convertisseur de devises", 424 | "taux de change aujourd'hui", 425 | "1000 yen en euro" 426 | ] 427 | }, 428 | { 429 | "title": "Apprenez à cuisiner une nouvelle recette", 430 | "queries": [ 431 | "Comment faire cuire la ratatouille", 432 | "Comment faire cuire les lasagnes", 433 | "recette pâtes facile", 434 | "comment faire des crêpes", 435 | "recette riz sauté", 436 | "recette poulet simple" 437 | ] 438 | }, 439 | { 440 | "title": "Trouvez des emplacements pour rester!", 441 | "queries": [ 442 | "Hôtels Berlin Allemagne", 443 | "Hôtels Amsterdam Pays-Bas", 444 | "hôtels paris", 445 | "meilleurs hôtels tokyo", 446 | "hôtels pas chers londres", 447 | "hébergement barcelone", 448 | "offres hôtels", 449 | "hôtels près de chez moi" 450 | ] 451 | }, 452 | { 453 | "title": "Comment se porte l'économie ?", 454 | "queries": [ 455 | "CAC 40", 456 | "indice dax", 457 | "dow jones aujourd'hui", 458 | "inflation europe", 459 | "taux d'intérêt aujourd'hui", 460 | "marché boursier aujourd'hui", 461 | "actualités économie", 462 | "prévisions récession" 463 | ] 464 | }, 465 | { 466 | "title": "Qui a gagné ?", 467 | "queries": [ 468 | "score du Paris Saint-Germain", 469 | "résultats ligue des champions", 470 | "résultats premier league", 471 | "score nba", 472 | "vainqueur formule 1", 473 | "derniers scores football", 474 | "vainqueur finale ldc" 475 | ] 476 | }, 477 | { 478 | "title": "Temps de jeu", 479 | "queries": [ 480 | "Jeu vidéo Overwatch", 481 | "Jeu vidéo Call of Duty", 482 | "meilleurs jeux 2025", 483 | "top jeux xbox", 484 | "jeux steam populaires", 485 | "nouveaux jeux pc", 486 | "avis jeux vidéo", 487 | "meilleurs jeux coop" 488 | ] 489 | }, 490 | { 491 | "title": "Enrichissez votre vocabulaire", 492 | "queries": [ 493 | "definition definition", 494 | "signification sérendipité", 495 | "définir nostalgie", 496 | "synonyme heureux", 497 | "définir éloquent", 498 | "mot du jour", 499 | "que veut dire épiphanie" 500 | ] 501 | }, 502 | { 503 | "title": "Quelle heure est-il ?", 504 | "queries": [ 505 | "Heure du Japon", 506 | "Heure de New York", 507 | "heure de londres", 508 | "heure de tokyo", 509 | "heure actuelle amsterdam", 510 | "heure de los angeles" 511 | ] 512 | }, 513 | { 514 | "title": "Vérifier la météo", 515 | "queries": [ 516 | "Météo de Paris", 517 | "Météo de la France", 518 | "météo demain", 519 | "prévisions météo semaine", 520 | "météo amsterdam", 521 | "risque de pluie aujourd'hui" 522 | ] 523 | }, 524 | { 525 | "title": "Tenez-vous informé des sujets d'actualité", 526 | "queries": [ 527 | "Augmentation Impots", 528 | "Mort célébrité", 529 | "actualités france", 530 | "actualité internationale", 531 | "dernières nouvelles économie", 532 | "news technologie" 533 | ] 534 | }, 535 | { 536 | "title": "Préparez-vous pour la météo", 537 | "queries": [ 538 | "météo demain", 539 | "prévisions météo semaine", 540 | "météo amsterdam", 541 | "risque de pluie aujourd'hui", 542 | "indice uv aujourd'hui", 543 | "température ce week-end", 544 | "alerte tempête" 545 | ] 546 | }, 547 | { 548 | "title": "Suivez votre livraison", 549 | "queries": [ 550 | "suivi colis", 551 | "postnl suivi colis", 552 | "suivi DHL colis", 553 | "suivi UPS", 554 | "suivi FedEx", 555 | "suivi international colis", 556 | "suivre ma livraison" 557 | ] 558 | }, 559 | { 560 | "title": "Trouvez des offres sur Bing", 561 | "queries": [ 562 | "meilleures offres ordinateur portable", 563 | "bons plans tech", 564 | "promotions écouteurs", 565 | "offres chaise gamer", 566 | "codes promo électronique", 567 | "meilleures offres amazon aujourd'hui" 568 | ] 569 | }, 570 | { 571 | "title": "Explorez un nouvel endroit aujourd'hui", 572 | "queries": [ 573 | "endroits à visiter près de chez moi", 574 | "que faire près de chez moi", 575 | "endroits insolites pays-bas", 576 | "meilleurs musées près de chez moi", 577 | "parcs près de chez moi", 578 | "attractions touristiques à proximité", 579 | "meilleurs cafés près de chez moi" 580 | ] 581 | } 582 | ] --------------------------------------------------------------------------------