From 2c4d85f7324f779946c7da30b1144b305e5bd7c0 Mon Sep 17 00:00:00 2001 From: TheNetsky <56271887+TheNetsky@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:16:32 +0100 Subject: [PATCH] v3 init Based of v3.0.0b10. --- .eslintrc.js | 28 + .gitignore | 2 + .prettierrc | 11 + Dockerfile | 89 + README.md | 1 + compose.yaml | 42 + flake.lock | 61 + flake.nix | 40 + package-lock.json | 2974 +++++++++++++++++ package.json | 59 + scripts/clearSessions.js | 104 + scripts/docker/entrypoint.sh | 50 + scripts/docker/run_daily.sh | 155 + scripts/nix/run.sh | 3 + src/accounts.example.json | 28 + src/browser/Browser.ts | 123 + src/browser/BrowserFunc.ts | 433 +++ src/browser/BrowserUtils.ts | 273 ++ src/browser/UserAgent.ts | 164 + src/browser/auth/Login.ts | 509 +++ src/browser/auth/methods/EmailLogin.ts | 86 + src/browser/auth/methods/MobileAccessLogin.ts | 88 + src/browser/auth/methods/PasswordlessLogin.ts | 110 + src/browser/auth/methods/Totp2FALogin.ts | 163 + src/config.example.json | 71 + src/crontab.template | 7 + src/functions/Activities.ts | 91 + src/functions/QueryEngine.ts | 191 ++ src/functions/SearchManager.ts | 618 ++++ src/functions/Workers.ts | 199 ++ src/functions/activities/api/FindClippy.ts | 130 + src/functions/activities/api/Quiz.ts | 173 + src/functions/activities/api/UrlReward.ts | 129 + src/functions/activities/app/AppReward.ts | 119 + src/functions/activities/app/DailyCheckIn.ts | 161 + src/functions/activities/app/ReadToEarn.ts | 131 + src/functions/activities/browser/Search.ts | 426 +++ .../activities/browser/SearchOnBing.ts | 330 ++ src/index.ts | 494 +++ src/interface/Account.ts | 15 + src/interface/AppDashBoardData.ts | 105 + src/interface/AppUserData.ts | 225 ++ src/interface/Config.ts | 81 + src/interface/DashboardData.ts | 803 +++++ src/interface/OAuth.ts | 9 + src/interface/Points.ts | 20 + src/interface/QuizData.ts | 50 + src/interface/Search.ts | 96 + src/interface/UserAgentUtil.ts | 62 + src/interface/XboxDashboardData.ts | 43 + src/logging/Discord.ts | 50 + src/logging/Logger.ts | 189 ++ src/logging/Ntfy.ts | 61 + src/util/Axios.ts | 67 + src/util/ErrorDiagnostic.ts | 46 + src/util/Load.ts | 123 + src/util/Utils.ts | 81 + tsconfig.json | 70 + 58 files changed, 11062 insertions(+) create mode 100644 .eslintrc.js create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/clearSessions.js create mode 100644 scripts/docker/entrypoint.sh create mode 100644 scripts/docker/run_daily.sh create mode 100644 scripts/nix/run.sh create mode 100644 src/accounts.example.json create mode 100644 src/browser/Browser.ts create mode 100644 src/browser/BrowserFunc.ts create mode 100644 src/browser/BrowserUtils.ts create mode 100644 src/browser/UserAgent.ts create mode 100644 src/browser/auth/Login.ts create mode 100644 src/browser/auth/methods/EmailLogin.ts create mode 100644 src/browser/auth/methods/MobileAccessLogin.ts create mode 100644 src/browser/auth/methods/PasswordlessLogin.ts create mode 100644 src/browser/auth/methods/Totp2FALogin.ts create mode 100644 src/config.example.json create mode 100644 src/crontab.template create mode 100644 src/functions/Activities.ts create mode 100644 src/functions/QueryEngine.ts create mode 100644 src/functions/SearchManager.ts create mode 100644 src/functions/Workers.ts create mode 100644 src/functions/activities/api/FindClippy.ts create mode 100644 src/functions/activities/api/Quiz.ts create mode 100644 src/functions/activities/api/UrlReward.ts create mode 100644 src/functions/activities/app/AppReward.ts create mode 100644 src/functions/activities/app/DailyCheckIn.ts create mode 100644 src/functions/activities/app/ReadToEarn.ts create mode 100644 src/functions/activities/browser/Search.ts create mode 100644 src/functions/activities/browser/SearchOnBing.ts create mode 100644 src/index.ts create mode 100644 src/interface/Account.ts create mode 100644 src/interface/AppDashBoardData.ts create mode 100644 src/interface/AppUserData.ts create mode 100644 src/interface/Config.ts create mode 100644 src/interface/DashboardData.ts create mode 100644 src/interface/OAuth.ts create mode 100644 src/interface/Points.ts create mode 100644 src/interface/QuizData.ts create mode 100644 src/interface/Search.ts create mode 100644 src/interface/UserAgentUtil.ts create mode 100644 src/interface/XboxDashboardData.ts create mode 100644 src/logging/Discord.ts create mode 100644 src/logging/Logger.ts create mode 100644 src/logging/Ntfy.ts create mode 100644 src/util/Axios.ts create mode 100644 src/util/ErrorDiagnostic.ts create mode 100644 src/util/Load.ts create mode 100644 src/util/Utils.ts create mode 100644 tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..11d76d8 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + env: { + es2021: true, + node: true + }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 12, + sourceType: 'module' + }, + plugins: ['@typescript-eslint'], + rules: { + 'linebreak-style': ['error', 'unix'], + quotes: ['error', 'single'], + semi: ['error', 'never'], + '@typescript-eslint/no-explicit-any': [ + 'warn', + { + fixToUnknown: false + } + ], + 'comma-dangle': 'off', + '@typescript-eslint/comma-dangle': 'error', + 'prefer-arrow-callback': 'error', + 'no-empty': 'off' + } +} diff --git a/.gitignore b/.gitignore index faaa9b8..e1fc54d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist/ node_modules/ src/accounts.json src/config.json +/.vscode +/diagnostics note accounts.dev.json accounts.main.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6cf93e5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "tabWidth": 4, + "useTabs": false, + "printWidth": 120, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..057252b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,89 @@ +############################################################################### +# Stage 1: Builder +############################################################################### +FROM node:22-slim AS builder + +WORKDIR /usr/src/microsoft-rewards-script + +ENV PLAYWRIGHT_BROWSERS_PATH=0 + +# Copy package files +COPY package.json package-lock.json tsconfig.json ./ + +# Install all dependencies required to build the script +RUN npm ci --ignore-scripts + +# Copy source and build +COPY . . +RUN npm run build + +# Remove build dependencies, and reinstall only runtime dependencies +RUN rm -rf node_modules \ + && npm ci --omit=dev --ignore-scripts \ + && npm cache clean --force + +# Install Chromium Headless Shell, and cleanup +RUN npx patchright install --with-deps --only-shell chromium \ + && rm -rf /root/.cache /tmp/* /var/tmp/* + +############################################################################### +# Stage 2: Runtime +############################################################################### +FROM node:22-slim AS runtime + +WORKDIR /usr/src/microsoft-rewards-script + +# Set production environment variables +ENV NODE_ENV=production \ + TZ=UTC \ + PLAYWRIGHT_BROWSERS_PATH=0 \ + FORCE_HEADLESS=1 + +# Install minimal system libraries required for Chromium headless to run +RUN apt-get update && apt-get install -y --no-install-recommends \ + cron \ + gettext-base \ + tzdata \ + ca-certificates \ + libglib2.0-0 \ + libdbus-1-3 \ + libexpat1 \ + libfontconfig1 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libasound2 \ + libflac12 \ + libatk1.0-0 \ + libatspi2.0-0 \ + libdrm2 \ + libgbm1 \ + libdav1d6 \ + libx11-6 \ + libx11-xcb1 \ + libxcomposite1 \ + libxcursor1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + libxss1 \ + libxtst6 \ + libdouble-conversion3 \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +# Copy compiled application and dependencies from builder stage +COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist +COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./ +COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules + +# Copy runtime scripts with proper permissions from the start +COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh +COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template +COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh + +# Entrypoint handles TZ, initial run toggle, cron templating & launch +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["sh", "-c", "echo 'Container started; cron is running.'"] diff --git a/README.md b/README.md index 8c95f80..7068227 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Discord](https://img.shields.io/badge/Join%20Our%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/8BxYbV4pkj) --- + TODO ## Disclaimer diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4085972 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,42 @@ +services: + microsoft-rewards-script: + build: . + container_name: microsoft-rewards-script + restart: unless-stopped + + # Volume mounts: Specify a location where you want to save the files on your local machine. + volumes: + - ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro + - ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro + - ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session + + environment: + TZ: 'America/Toronto' # Set your timezone for proper scheduling + NODE_ENV: 'production' + CRON_SCHEDULE: '0 7 * * *' # Customize your schedule, use crontab.guru for formatting + RUN_ON_START: 'true' # Runs the script immediately on container startup + + # Add scheduled start-time randomization (uncomment to customize or disable, default: enabled) + #MIN_SLEEP_MINUTES: "5" + #MAX_SLEEP_MINUTES: "50" + SKIP_RANDOM_SLEEP: 'false' + + # Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours) + #STUCK_PROCESS_TIMEOUT_HOURS: "8" + + # Optional resource limits for the container + mem_limit: 4g + cpus: 2 + + # Health check - monitors if cron daemon is running to ensure scheduled jobs can execute + # Container marked unhealthy if cron process dies + healthcheck: + test: ['CMD', 'sh', '-c', 'pgrep cron > /dev/null || exit 1'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + # Security hardening + security_opt: + - no-new-privileges:true diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..27bbe28 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1749727998, + "narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e327534 --- /dev/null +++ b/flake.nix @@ -0,0 +1,40 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils = { + url = "github:numtide/flake-utils"; + }; + }; + + outputs = + { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShell = pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + nodejs + playwright-driver.browsers + typescript + playwright-test + + # fixes "waiting until load" issue compared to + # setting headless in config.json + xvfb-run + ]; + + shellHook = '' + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + npm i + npm run build + ''; + }; + } + ); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ae160fc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2974 @@ +{ + "name": "microsoft-rewards-script", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "microsoft-rewards-script", + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.13.2", + "axios-retry": "^4.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0", + "fingerprint-generator": "^2.1.77", + "fingerprint-injector": "^2.1.77", + "ghost-cursor-playwright-port": "^1.4.3", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "ms": "^2.1.3", + "otpauth": "^9.4.1", + "p-queue": "^9.0.1", + "patchright": "^1.57.0", + "ts-node": "^10.9.2" + }, + "devDependencies": { + "@types/ms": "^2.1.0", + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.48.0", + "eslint": "^9.39.1", + "eslint-plugin-modules-newline": "^0.0.6", + "prettier": "^3.7.1", + "rimraf": "^6.1.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT" + }, + "node_modules/@types/bezier-js": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/bezier-js/-/bezier-js-4.1.3.tgz", + "integrity": "sha512-FNVVCu5mx/rJCWBxLTcL7oOajmGtWtBTDjq6DSUWUI12GeePivrZZXz+UgE0D6VYsLEjvExRO03z4hVtu3pTEQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "license": "ISC" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-modules-newline": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-modules-newline/-/eslint-plugin-modules-newline-0.0.6.tgz", + "integrity": "sha512-69NpBr68U6pmXL+y+KHl/64PwRarceC3/sCNUVxRbe0gPI32SIw8AtdpkqNiJYCa2yMd4lRrkrnU09Yio7KVzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "requireindex": "~1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fingerprint-generator": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.77.tgz", + "integrity": "sha512-wR15VUEZnwozFiSDRV+40zxlEt3ZV3JNYvLx0CSF9D9smov4pUC6MJZJnlxtDr+Ir4oppU8vn1JXApLk/Qr5Uw==", + "license": "Apache-2.0", + "dependencies": { + "generative-bayesian-network": "^2.1.77", + "header-generator": "^2.1.77", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fingerprint-injector": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/fingerprint-injector/-/fingerprint-injector-2.1.77.tgz", + "integrity": "sha512-R778SIyrqgWO0P+UWKzIFWUWZz13EGu6UmV7CX3vuFDbsYIL1xiH+s+/nzPSOqFdhXyLo7d8aTOjbGbRLULoQQ==", + "license": "Apache-2.0", + "dependencies": { + "fingerprint-generator": "^2.1.77", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "playwright": "^1.22.2", + "puppeteer": ">= 9.x" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "puppeteer": { + "optional": true + } + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generative-bayesian-network": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.77.tgz", + "integrity": "sha512-viU4CRPsmgiklR94LhvdMndaY73BkCH1pGjmOjWbLR/ZwcUd06gKF3TCcsS3npRl74o33YSInSixxm16wIukcA==", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ghost-cursor-playwright-port": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ghost-cursor-playwright-port/-/ghost-cursor-playwright-port-1.4.3.tgz", + "integrity": "sha512-+h1skiBy3S6ItlrW91JgmexcS9qH8Vhk9XBkC20RW+jTjfiZvg0RWt73iy4AzJhUWZCy7pZWZqSFNBK4bdIahw==", + "license": "ISC", + "dependencies": { + "@types/bezier-js": "4", + "bezier-js": "^6.1.3", + "debug": "^4.3.4" + }, + "peerDependencies": { + "@playwright/test": "^1.54.0", + "playwright": "^1.54.0", + "playwright-core": "^1.54.0" + } + }, + "node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-generator": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.77.tgz", + "integrity": "sha512-ggSG/mfkFMu8CO7xP591G8kp1IJCBvgXu7M8oxTjC9u914JsIzE6zIfoFsXzA+pf0utWJhUsdqU0oV/DtQ4DFQ==", + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.77", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/otpauth": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz", + "integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz", + "integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/patchright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/patchright/-/patchright-1.57.0.tgz", + "integrity": "sha512-pxbI/D65QiFuCY3qUXKQONRhplR3rkYFhry5ieimEbzLNxu/xfOYizQRyuMgc6F5ZoZ37QNIwZz9PWEfn6aC1Q==", + "license": "Apache-2.0", + "dependencies": { + "patchright-core": "1.57.0" + }, + "bin": { + "patchright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/patchright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/patchright-core/-/patchright-core-1.57.0.tgz", + "integrity": "sha512-um/9Wue7IFAa9UDLacjNgDn62ub5GJe1b1qouvYpELIF9rsFVMNhRo/rRXYajupLwp5xKJ0sSjOV6sw8/HarBQ==", + "license": "Apache-2.0", + "bin": { + "patchright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.1.tgz", + "integrity": "sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.5" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "license": "MIT" + }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..712b111 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "microsoft-rewards-script", + "version": "3.0.0", + "description": "Automatically do tasks for Microsoft Rewards but in TS!", + "author": "Netsky", + "license": "GPL-3.0-or-later", + "main": "dist/index.js", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "pre-build": "npm i && rimraf dist && npx patchright install chromium", + "build": "rimraf dist && tsc", + "start": "node ./dist/index.js", + "ts-start": "ts-node ./src/index.ts", + "dev": "ts-node ./src/index.ts -dev", + "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", + "create-docker": "docker build -t microsoft-rewards-script-docker .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "clear-sessions": "node ./scripts/clearSessions.js", + "clear-diagnostics": "rimraf diagnostics" + }, + "keywords": [ + "Bing Rewards", + "Microsoft Rewards", + "Bot", + "Script", + "TypeScript", + "Playwright", + "Cheerio" + ], + "devDependencies": { + "@types/ms": "^2.1.0", + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.48.0", + "eslint": "^9.39.1", + "eslint-plugin-modules-newline": "^0.0.6", + "prettier": "^3.7.1", + "rimraf": "^6.1.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "axios": "^1.13.2", + "axios-retry": "^4.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0", + "fingerprint-generator": "^2.1.77", + "fingerprint-injector": "^2.1.77", + "ghost-cursor-playwright-port": "^1.4.3", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "ms": "^2.1.3", + "otpauth": "^9.4.1", + "p-queue": "^9.0.1", + "patchright": "^1.57.0", + "ts-node": "^10.9.2" + } +} diff --git a/scripts/clearSessions.js b/scripts/clearSessions.js new file mode 100644 index 0000000..bc0a658 --- /dev/null +++ b/scripts/clearSessions.js @@ -0,0 +1,104 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const projectRoot = path.resolve(__dirname, '..') + +const possibleConfigPaths = [ + path.join(projectRoot, 'config.json'), + path.join(projectRoot, 'src', 'config.json'), + path.join(projectRoot, 'dist', 'config.json') +] + +console.log('[DEBUG] Project root:', projectRoot) +console.log('[DEBUG] Searching for config.json...') + +let configPath = null +for (const p of possibleConfigPaths) { + console.log('[DEBUG] Checking:', p) + if (fs.existsSync(p)) { + configPath = p + console.log('[DEBUG] Found config at:', p) + break + } +} + +if (!configPath) { + console.error('[ERROR] config.json not found in any expected location!') + console.error('[ERROR] Searched:', possibleConfigPaths) + process.exit(1) +} + +console.log('[INFO] Using config:', configPath) +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) + +if (!config.sessionPath) { + console.error("[ERROR] config.json missing 'sessionPath' key!") + process.exit(1) +} + +console.log('[INFO] Session path from config:', config.sessionPath) + +const configDir = path.dirname(configPath) +const possibleSessionDirs = [ + path.resolve(configDir, config.sessionPath), + path.join(projectRoot, 'src/browser', config.sessionPath), + path.join(projectRoot, 'dist/browser', config.sessionPath) +] + +console.log('[DEBUG] Searching for session directory...') + +let sessionDir = null +for (const p of possibleSessionDirs) { + console.log('[DEBUG] Checking:', p) + if (fs.existsSync(p)) { + sessionDir = p + console.log('[DEBUG] Found session directory at:', p) + break + } +} + +if (!sessionDir) { + sessionDir = path.resolve(configDir, config.sessionPath) + console.log('[DEBUG] Using fallback session directory:', sessionDir) +} + +const normalizedSessionDir = path.normalize(sessionDir) +const normalizedProjectRoot = path.normalize(projectRoot) + +if (!normalizedSessionDir.startsWith(normalizedProjectRoot)) { + console.error('[ERROR] Session directory is outside project root!') + console.error('[ERROR] Project root:', normalizedProjectRoot) + console.error('[ERROR] Session directory:', normalizedSessionDir) + process.exit(1) +} + +if (normalizedSessionDir === normalizedProjectRoot) { + console.error('[ERROR] Session directory cannot be the project root!') + process.exit(1) +} + +const pathSegments = normalizedSessionDir.split(path.sep) +if (pathSegments.length < 3) { + console.error('[ERROR] Session path is too shallow (safety check failed)!') + console.error('[ERROR] Path:', normalizedSessionDir) + process.exit(1) +} + +if (fs.existsSync(sessionDir)) { + console.log('[INFO] Removing session folder:', sessionDir) + try { + fs.rmSync(sessionDir, { recursive: true, force: true }) + console.log('[SUCCESS] Session folder removed successfully') + } catch (error) { + console.error('[ERROR] Failed to remove session folder:', error.message) + process.exit(1) + } +} else { + console.log('[INFO] Session folder does not exist:', sessionDir) +} + +console.log('[INFO] Done.') diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh new file mode 100644 index 0000000..0954e77 --- /dev/null +++ b/scripts/docker/entrypoint.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure Playwright uses preinstalled browsers +export PLAYWRIGHT_BROWSERS_PATH=0 + +# 1. Timezone: default to UTC if not provided +: "${TZ:=UTC}" +ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime +echo "$TZ" > /etc/timezone +dpkg-reconfigure -f noninteractive tzdata + +# 2. Validate CRON_SCHEDULE +if [ -z "${CRON_SCHEDULE:-}" ]; then + echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2 + echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2 + exit 1 +fi + +# 3. Initial run without sleep if RUN_ON_START=true +if [ "${RUN_ON_START:-false}" = "true" ]; then + echo "[entrypoint] Starting initial run in background at $(date)" + ( + cd /usr/src/microsoft-rewards-script || { + echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2 + exit 1 + } + # Skip random sleep for initial run, but preserve setting for cron jobs + SKIP_RANDOM_SLEEP=true scripts/docker/run_daily.sh + echo "[entrypoint-bg] Initial run completed at $(date)" + ) & + echo "[entrypoint] Background process started (PID: $!)" +fi + +# 4. Template and register cron file with explicit timezone export +if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then + echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2 + exit 1 +fi + +# Export TZ for envsubst to use +export TZ +envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron +chmod 0644 /etc/cron.d/microsoft-rewards-cron +crontab /etc/cron.d/microsoft-rewards-cron + +echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)" + +# 5. Start cron in foreground (PID 1) +exec cron -f \ No newline at end of file diff --git a/scripts/docker/run_daily.sh b/scripts/docker/run_daily.sh new file mode 100644 index 0000000..7953334 --- /dev/null +++ b/scripts/docker/run_daily.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +export PLAYWRIGHT_BROWSERS_PATH=0 +export TZ="${TZ:-UTC}" + +cd /usr/src/microsoft-rewards-script + +LOCKFILE=/tmp/run_daily.lock + +# ------------------------------- +# Function: Check and fix lockfile integrity +# ------------------------------- +self_heal_lockfile() { + # If lockfile exists but is empty → remove it + if [ -f "$LOCKFILE" ]; then + local lock_content + lock_content=$(<"$LOCKFILE" || echo "") + + if [[ -z "$lock_content" ]]; then + echo "[$(date)] [run_daily.sh] Found empty lockfile → removing." + rm -f "$LOCKFILE" + return + fi + + # If lockfile contains non-numeric PID → remove it + if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then + echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') → removing." + rm -f "$LOCKFILE" + return + fi + + # If lockfile contains PID but process is dead → remove it + if ! kill -0 "$lock_content" 2>/dev/null; then + echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead → removing stale lock." + rm -f "$LOCKFILE" + return + fi + fi +} + +# ------------------------------- +# Function: Acquire lock +# ------------------------------- +acquire_lock() { + local max_attempts=5 + local attempt=0 + local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8} + local timeout_seconds=$((timeout_hours * 3600)) + + while [ $attempt -lt $max_attempts ]; do + # Try to create lock with current PID + if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then + echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)" + return 0 + fi + + # Lock exists, validate it + if [ -f "$LOCKFILE" ]; then + local existing_pid + existing_pid=$(<"$LOCKFILE" || echo "") + + echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'" + + # If lockfile content is invalid → delete and retry + if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then + echo "[$(date)] [run_daily.sh] Removing invalid lockfile → retrying..." + rm -f "$LOCKFILE" + continue + fi + + # If process is dead → delete and retry + if ! kill -0 "$existing_pid" 2>/dev/null; then + echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)" + rm -f "$LOCKFILE" + continue + fi + + # Check process runtime → kill if exceeded timeout + local process_age + if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then + if [ "$process_age" -gt "$timeout_seconds" ]; then + echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)" + kill -TERM "$existing_pid" 2>/dev/null || true + sleep 5 + kill -KILL "$existing_pid" 2>/dev/null || true + rm -f "$LOCKFILE" + continue + fi + fi + fi + + echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts" + sleep 2 + ((attempt++)) + done + + echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting." + return 1 +} + +# ------------------------------- +# Function: Release lock +# ------------------------------- +release_lock() { + if [ -f "$LOCKFILE" ]; then + local lock_pid + lock_pid=$(<"$LOCKFILE") + if [ "$lock_pid" = "$$" ]; then + rm -f "$LOCKFILE" + echo "[$(date)] [run_daily.sh] Lock released (PID: $$)" + fi + fi +} + +# Always release lock on exit — but only if we acquired it +trap 'release_lock' EXIT INT TERM + +# ------------------------------- +# MAIN EXECUTION FLOW +# ------------------------------- +echo "[$(date)] [run_daily.sh] Current process PID: $$" + +# Self-heal any broken or empty locks before proceeding +self_heal_lockfile + +# Attempt to acquire the lock safely +if ! acquire_lock; then + exit 0 +fi + +# Random sleep between MIN and MAX to spread execution +MINWAIT=${MIN_SLEEP_MINUTES:-5} +MAXWAIT=${MAX_SLEEP_MINUTES:-50} +MINWAIT_SEC=$((MINWAIT*60)) +MAXWAIT_SEC=$((MAXWAIT*60)) + +if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then + SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) )) + echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)" + sleep "$SLEEPTIME" +else + echo "[$(date)] [run_daily.sh] Skipping random sleep" +fi + +# Start the actual script +echo "[$(date)] [run_daily.sh] Starting script..." +if npm start; then + echo "[$(date)] [run_daily.sh] Script completed successfully." +else + echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2 +fi + +echo "[$(date)] [run_daily.sh] Script finished" +# Lock is released automatically via trap diff --git a/scripts/nix/run.sh b/scripts/nix/run.sh new file mode 100644 index 0000000..cffeb43 --- /dev/null +++ b/scripts/nix/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +nix develop --command bash -c "xvfb-run npm run start" diff --git a/src/accounts.example.json b/src/accounts.example.json new file mode 100644 index 0000000..051fb3a --- /dev/null +++ b/src/accounts.example.json @@ -0,0 +1,28 @@ +[ + { + "email": "email_1", + "password": "password_1", + "totp": "", + "geoLocale": "auto", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + }, + { + "email": "email_2", + "password": "password_2", + "totp": "", + "geoLocale": "auto", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + } +] diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts new file mode 100644 index 0000000..8223fe7 --- /dev/null +++ b/src/browser/Browser.ts @@ -0,0 +1,123 @@ +import rebrowser, { BrowserContext } from 'patchright' + +import { newInjectedContext } from 'fingerprint-injector' +import { BrowserFingerprintWithHeaders, FingerprintGenerator } from 'fingerprint-generator' + +import type { MicrosoftRewardsBot } from '../index' +import { loadSessionData, saveFingerprintData } from '../util/Load' +import { UserAgentManager } from './UserAgent' + +import type { AccountProxy } from '../interface/Account' + +/* Test Stuff +https://abrahamjuliot.github.io/creepjs/ +https://botcheck.luminati.io/ +https://fv.pro/ +https://pixelscan.net/ +https://www.browserscan.net/ +*/ + +class Browser { + private bot: MicrosoftRewardsBot + + constructor(bot: MicrosoftRewardsBot) { + this.bot = bot + } + + async createBrowser( + proxy: AccountProxy, + email: string + ): Promise<{ + context: BrowserContext + fingerprint: BrowserFingerprintWithHeaders + }> { + let browser: rebrowser.Browser + try { + browser = await rebrowser.chromium.launch({ + headless: this.bot.config.headless, + ...(proxy.url && { + proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } + }), + args: [ + '--no-sandbox', + '--mute-audio', + '--disable-setuid-sandbox', + '--ignore-certificate-errors', + '--ignore-certificate-errors-spki-list', + '--ignore-ssl-errors', + '--no-first-run', + '--no-default-browser-check', + '--disable-user-media-security=true', + '--disable-blink-features=Attestation', + '--disable-features=WebAuthentication,PasswordManagerOnboarding,PasswordManager,EnablePasswordsAccountStorage,Passkeys', + '--disable-save-password-bubble' + ] + }) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'BROWSER', + `Launch failed: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + + const sessionData = await loadSessionData( + this.bot.config.sessionPath, + email, + this.bot.config.saveFingerprint, + this.bot.isMobile + ) + + const fingerprint = sessionData.fingerprint + ? sessionData.fingerprint + : await this.generateFingerprint(this.bot.isMobile) + + const context = await newInjectedContext(browser as any, { fingerprint: fingerprint }) + + await context.addInitScript(() => { + Object.defineProperty(navigator, 'credentials', { + value: { + create: () => Promise.reject(new Error('WebAuthn disabled')), + get: () => Promise.reject(new Error('WebAuthn disabled')) + } + }) + }) + + context.setDefaultTimeout(this.bot.utils.stringToNumber(this.bot.config?.globalTimeout ?? 30000)) + + await context.addCookies(sessionData.cookies) + + if (this.bot.config.saveFingerprint) { + await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint) + } + + this.bot.logger.info( + this.bot.isMobile, + 'BROWSER', + `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"` + ) + + this.bot.logger.debug(this.bot.isMobile, 'BROWSER-FINGERPRINT', JSON.stringify(fingerprint)) + + return { + context: context as unknown as BrowserContext, + fingerprint: fingerprint + } + } + + async generateFingerprint(isMobile: boolean) { + const fingerPrintData = new FingerprintGenerator().getFingerprint({ + devices: isMobile ? ['mobile'] : ['desktop'], + operatingSystems: isMobile ? ['android', 'ios'] : ['windows', 'linux'], + browsers: [{ name: 'edge' }] + }) + + const userAgentManager = new UserAgentManager(this.bot) + const updatedFingerPrintData = await userAgentManager.updateFingerprintUserAgent(fingerPrintData, isMobile) + + return updatedFingerPrintData + } +} + +export default Browser diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts new file mode 100644 index 0000000..32e6d0c --- /dev/null +++ b/src/browser/BrowserFunc.ts @@ -0,0 +1,433 @@ +import type { BrowserContext, Cookie } from 'patchright' +import type { AxiosRequestConfig, AxiosResponse } from 'axios' + +import type { MicrosoftRewardsBot } from '../index' +import { saveSessionData } from '../util/Load' + +import type { Counters, DashboardData } from './../interface/DashboardData' +import type { AppUserData } from '../interface/AppUserData' +import type { XboxDashboardData } from '../interface/XboxDashboardData' +import type { AppEarnablePoints, BrowserEarnablePoints, MissingSearchPoints } from '../interface/Points' +import type { AppDashboardData } from '../interface/AppDashBoardData' + +export default class BrowserFunc { + private bot: MicrosoftRewardsBot + + constructor(bot: MicrosoftRewardsBot) { + this.bot = bot + } + + /** + * Fetch user desktop dashboard data + * @returns {DashboardData} Object of user bing rewards dashboard data + */ + async getDashboardData(): Promise { + try { + const cookieHeader = this.bot.cookies.mobile + .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) + .join('; ') + + const request: AxiosRequestConfig = { + url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`, + method: 'GET', + headers: { + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: cookieHeader, + Referer: 'https://rewards.bing.com/', + Origin: 'https://rewards.bing.com' + } + } + + const response = await this.bot.axios.request(request) + return response.data.dashboard as DashboardData + } catch (error) { + this.bot.logger.info( + this.bot.isMobile, + 'GET-DASHBOARD-DATA', + `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + /** + * Fetch user app dashboard data + * @returns {AppDashboardData} Object of user bing rewards dashboard data + */ + async getAppDashboardData(): Promise { + try { + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAIOS&options=613', + method: 'GET', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2' + } + } + + const response = await this.bot.axios.request(request) + return response.data as AppDashboardData + } catch (error) { + this.bot.logger.info( + this.bot.isMobile, + 'GET-APP-DASHBOARD-DATA', + `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + /** + * Fetch user xbox dashboard data + * @returns {XboxDashboardData} Object of user bing rewards dashboard data + */ + async getXBoxDashboardData(): Promise { + try { + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=xboxapp&options=6', + method: 'GET', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One X) AppleWebKit/537.36 (KHTML, like Gecko) Edge/18.19041' + } + } + + const response = await this.bot.axios.request(request) + return response.data as XboxDashboardData + } catch (error) { + this.bot.logger.info( + this.bot.isMobile, + 'GET-XBOX-DASHBOARD-DATA', + `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + /** + * Get search point counters + */ + async getSearchPoints(): Promise { + const dashboardData = await this.getDashboardData() // Always fetch newest data + + return dashboardData.userStatus.counters + } + + missingSearchPoints(counters: Counters, isMobile: boolean): MissingSearchPoints { + const mobileData = counters.mobileSearch?.[0] + const desktopData = counters.pcSearch?.[0] + const edgeData = counters.pcSearch?.[1] + + const mobilePoints = mobileData ? Math.max(0, mobileData.pointProgressMax - mobileData.pointProgress) : 0 + const desktopPoints = desktopData ? Math.max(0, desktopData.pointProgressMax - desktopData.pointProgress) : 0 + const edgePoints = edgeData ? Math.max(0, edgeData.pointProgressMax - edgeData.pointProgress) : 0 + + const totalPoints = isMobile ? mobilePoints : desktopPoints + edgePoints + + return { mobilePoints, desktopPoints, edgePoints, totalPoints } + } + + /** + * Get total earnable points with web browser + */ + async getBrowserEarnablePoints(): Promise { + try { + const data = await this.getDashboardData() + + const desktopSearchPoints = + data.userStatus.counters.pcSearch?.reduce( + (sum, x) => sum + (x.pointProgressMax - x.pointProgress), + 0 + ) ?? 0 + + const mobileSearchPoints = + data.userStatus.counters.mobileSearch?.reduce( + (sum, x) => sum + (x.pointProgressMax - x.pointProgress), + 0 + ) ?? 0 + + const todayDate = this.bot.utils.getFormattedDate() + const dailySetPoints = + data.dailySetPromotions[todayDate]?.reduce( + (sum, x) => sum + (x.pointProgressMax - x.pointProgress), + 0 + ) ?? 0 + + const morePromotionsPoints = + data.morePromotions?.reduce((sum, x) => { + if ( + ['quiz', 'urlreward'].includes(x.promotionType) && + x.exclusiveLockedFeatureStatus !== 'locked' + ) { + return sum + (x.pointProgressMax - x.pointProgress) + } + return sum + }, 0) ?? 0 + + const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints + + return { + dailySetPoints, + morePromotionsPoints, + desktopSearchPoints, + mobileSearchPoints, + totalEarnablePoints + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'GET-BROWSER-EARNABLE-POINTS', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + /** + * Get total earnable points with mobile app + */ + async getAppEarnablePoints(): Promise { + try { + const eligibleOffers = ['ENUS_readarticle3_30points', 'Gamification_Sapphire_DailyCheckIn'] + + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613', + method: 'GET', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'X-Rewards-Country': this.bot.userData.geoLocale, + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' + } + } + + const response = await this.bot.axios.request(request) + const userData: AppUserData = response.data + const eligibleActivities = userData.response.promotions.filter(x => + eligibleOffers.includes(x.attributes.offerid ?? '') + ) + + let readToEarn = 0 + let checkIn = 0 + + for (const item of eligibleActivities) { + const attrs = item.attributes + + if (attrs.type === 'msnreadearn') { + const pointMax = parseInt(attrs.pointmax ?? '0') + const pointProgress = parseInt(attrs.pointprogress ?? '0') + readToEarn = Math.max(0, pointMax - pointProgress) + } else if (attrs.type === 'checkin') { + const progress = parseInt(attrs.progress ?? '0') + const checkInDay = progress % 7 + const lastUpdated = new Date(attrs.last_updated ?? '') + const today = new Date() + + if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) { + checkIn = parseInt(attrs[`day_${checkInDay + 1}_points`] ?? '0') + } + } + } + + const totalEarnablePoints = readToEarn + checkIn + + return { + readToEarn, + checkIn, + totalEarnablePoints + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'GET-APP-EARNABLE-POINTS', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + /** + * Get current point amount + * @returns {number} Current total point amount + */ + async getCurrentPoints(): Promise { + try { + const data = await this.getDashboardData() + + return data.userStatus.availablePoints + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'GET-CURRENT-POINTS', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + async closeBrowser(browser: BrowserContext, email: string) { + try { + const cookies = await browser.cookies() + + // Save cookies + this.bot.logger.debug( + this.bot.isMobile, + 'CLOSE-BROWSER', + `Saving ${cookies.length} cookies to session folder!` + ) + await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile) + + await this.bot.utils.wait(2000) + + // Close browser + await browser.close() + this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!') + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'CLOSE-BROWSER', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + mergeCookies(response: AxiosResponse, currentCookieHeader: string = '', whitelist?: string[]): string { + const cookieMap = new Map( + currentCookieHeader + .split(';') + .map(pair => pair.split('=').map(s => s.trim())) + .filter(([name, value]) => name && value) + .map(([name, value]) => [name, value] as [string, string]) + ) + + const setCookieList = [response.headers['set-cookie']].flat().filter(Boolean) as string[] + const cookiesByName = new Map(this.bot.cookies.mobile.map(c => [c.name, c])) + + for (const setCookie of setCookieList) { + const [nameValue, ...attributes] = setCookie.split(';').map(s => s.trim()) + if (!nameValue) continue + + const [name, value] = nameValue.split('=').map(s => s.trim()) + + if (!name) continue + + if (whitelist && !whitelist?.includes(name)) { + continue + } + + const attrs = this.parseAttributes(attributes) + const existing = cookiesByName.get(name) + + if (!value) { + if (existing) { + cookiesByName.delete(name) + this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name) + } + cookieMap.delete(name) + continue + } + + if (attrs.expires !== undefined && attrs.expires < Date.now() / 1000) { + if (existing) { + cookiesByName.delete(name) + this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name) + } + cookieMap.delete(name) + continue + } + + cookieMap.set(name, value) + + if (existing) { + this.updateCookie(existing, value, attrs) + } else { + this.bot.cookies.mobile.push(this.createCookie(name, value, attrs)) + } + } + + return Array.from(cookieMap, ([name, value]) => `${name}=${value}`).join('; ') + } + + private parseAttributes(attributes: string[]) { + const attrs: { + domain?: string + path?: string + expires?: number + httpOnly?: boolean + secure?: boolean + sameSite?: Cookie['sameSite'] + } = {} + + for (const attr of attributes) { + const [key, val] = attr.split('=').map(s => s?.trim()) + const lowerKey = key?.toLowerCase() + + switch (lowerKey) { + case 'domain': + case 'path': { + if (val) attrs[lowerKey] = val + break + } + case 'expires': { + if (val) { + const ts = Date.parse(val) + if (!isNaN(ts)) attrs.expires = Math.floor(ts / 1000) + } + break + } + case 'max-age': { + if (val) { + const maxAge = Number(val) + if (!isNaN(maxAge)) attrs.expires = Math.floor(Date.now() / 1000) + maxAge + } + break + } + case 'httponly': { + attrs.httpOnly = true + break + } + case 'secure': { + attrs.secure = true + break + } + case 'samesite': { + const normalized = val?.toLowerCase() + if (normalized && ['lax', 'strict', 'none'].includes(normalized)) { + attrs.sameSite = (normalized.charAt(0).toUpperCase() + + normalized.slice(1)) as Cookie['sameSite'] + } + break + } + } + } + + return attrs + } + + private updateCookie(cookie: Cookie, value: string, attrs: ReturnType) { + cookie.value = value + if (attrs.domain) cookie.domain = attrs.domain + if (attrs.path) cookie.path = attrs.path + //if (attrs.expires !== undefined) cookie.expires = attrs.expires + //if (attrs.httpOnly) cookie.httpOnly = true + //if (attrs.secure) cookie.secure = true + //if (attrs.sameSite) cookie.sameSite = attrs.sameSite + } + + private createCookie(name: string, value: string, attrs: ReturnType): Cookie { + return { + name, + value, + domain: attrs.domain || '.bing.com', + path: attrs.path || '/' + /* + ...(attrs.expires !== undefined && { expires: attrs.expires }), + ...(attrs.httpOnly && { httpOnly: true }), + ...(attrs.secure && { secure: true }), + ...(attrs.sameSite && { sameSite: attrs.sameSite }) + */ + } as Cookie + } +} diff --git a/src/browser/BrowserUtils.ts b/src/browser/BrowserUtils.ts new file mode 100644 index 0000000..ecd82a2 --- /dev/null +++ b/src/browser/BrowserUtils.ts @@ -0,0 +1,273 @@ +import { type Page, type BrowserContext } from 'patchright' +import { CheerioAPI, load } from 'cheerio' +import { ClickOptions, createCursor } from 'ghost-cursor-playwright-port' + +import type { MicrosoftRewardsBot } from '../index' + +export default class BrowserUtils { + private bot: MicrosoftRewardsBot + + constructor(bot: MicrosoftRewardsBot) { + this.bot = bot + } + + async tryDismissAllMessages(page: Page): Promise { + try { + const buttons = [ + { selector: '#acceptButton', label: 'AcceptButton' }, + { selector: '#wcpConsentBannerCtrl > * > button:first-child', label: 'Bing Cookies Accept' }, + { selector: '.ext-secondary.ext-button', label: '"Skip for now" Button' }, + { selector: '#iLandingViewAction', label: 'iLandingViewAction' }, + { selector: '#iShowSkip', label: 'iShowSkip' }, + { selector: '#iNext', label: 'iNext' }, + { selector: '#iLooksGood', label: 'iLooksGood' }, + { selector: '#idSIButton9', label: 'idSIButton9' }, + { selector: '.ms-Button.ms-Button--primary', label: 'Primary Button' }, + { selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Button' }, + { selector: '.maybe-later', label: 'Mobile Rewards App Banner' }, + { selector: '#bnp_btn_accept', label: 'Bing Cookie Banner' }, + { selector: '#reward_pivot_earn', label: 'Reward Coupon Accept' } + ] + + const checkVisible = await Promise.allSettled( + buttons.map(async b => ({ + ...b, + isVisible: await page + .locator(b.selector) + .isVisible() + .catch(() => false) + })) + ) + + const visibleButtons = checkVisible + .filter(r => r.status === 'fulfilled' && r.value.isVisible) + .map(r => (r.status === 'fulfilled' ? r.value : null)) + .filter(Boolean) + + if (visibleButtons.length > 0) { + await Promise.allSettled( + visibleButtons.map(async b => { + if (b) { + const clicked = await this.ghostClick(page, b.selector) + if (clicked) { + this.bot.logger.debug( + this.bot.isMobile, + 'DISMISS-ALL-MESSAGES', + `Dismissed: ${b.label}` + ) + } + } + }) + ) + await this.bot.utils.wait(300) + } + + // Overlay + const overlay = await page.$('#bnp_overlay_wrapper') + if (overlay) { + const rejected = await this.ghostClick(page, '#bnp_btn_reject, button[aria-label*="Reject" i]') + if (rejected) { + this.bot.logger.debug(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject') + } else { + const accepted = await this.ghostClick(page, '#bnp_btn_accept') + if (accepted) { + this.bot.logger.debug( + this.bot.isMobile, + 'DISMISS-ALL-MESSAGES', + 'Dismissed: Bing Overlay Accept' + ) + } + } + await this.bot.utils.wait(250) + } + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'DISMISS-ALL-MESSAGES', + `Handler error: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + async getLatestTab(page: Page): Promise { + try { + const browser: BrowserContext = page.context() + const pages = browser.pages() + + const newTab = pages[pages.length - 1] + if (!newTab) { + throw this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', 'No tabs could be found!') + } + + return newTab + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'GET-NEW-TAB', + `Unable to get latest tab: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + async reloadBadPage(page: Page): Promise { + try { + const html = await page.content().catch(() => '') + const $ = load(html) + + if ($('body.neterror').length) { + this.bot.logger.info(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!') + try { + await page.reload({ waitUntil: 'load' }) + } catch { + await page.reload().catch(() => {}) + } + return true + } else { + return false + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'RELOAD-BAD-PAGE', + `Reload check failed: ${error instanceof Error ? error.message : String(error)}` + ) + return true + } + } + + async closeTabs(page: Page, config = { minTabs: 1, maxTabs: 1 }): Promise { + try { + const browser = page.context() + const tabs = browser.pages() + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-CLOSE-TABS', + `Found ${tabs.length} tab(s) open (min: ${config.minTabs}, max: ${config.maxTabs})` + ) + + // Check if valid + if (config.minTabs < 1 || config.maxTabs < config.minTabs) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'Invalid config, using defaults') + config = { minTabs: 1, maxTabs: 1 } + } + + // Close if more than max config + if (tabs.length > config.maxTabs) { + const tabsToClose = tabs.slice(config.maxTabs) + + const closeResults = await Promise.allSettled(tabsToClose.map(tab => tab.close())) + + const closedCount = closeResults.filter(r => r.status === 'fulfilled').length + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-CLOSE-TABS', + `Closed ${closedCount}/${tabsToClose.length} excess tab(s) to reach max of ${config.maxTabs}` + ) + + // Open more tabs + } else if (tabs.length < config.minTabs) { + const tabsNeeded = config.minTabs - tabs.length + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-CLOSE-TABS', + `Opening ${tabsNeeded} tab(s) to reach min of ${config.minTabs}` + ) + + const newTabPromises = Array.from({ length: tabsNeeded }, async () => { + try { + const newPage = await browser.newPage() + await newPage.goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 15000 }) + return newPage + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-CLOSE-TABS', + `Failed to create new tab: ${error instanceof Error ? error.message : String(error)}` + ) + return null + } + }) + + await Promise.allSettled(newTabPromises) + } + + const latestTab = await this.getLatestTab(page) + return latestTab + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-CLOSE-TABS', + `Error: ${error instanceof Error ? error.message : String(error)}` + ) + return page + } + } + + async loadInCheerio(data: Page | string): Promise { + const html: string = typeof data === 'string' ? data : await data.content() + const $ = load(html) + return $ + } + + async ghostClick(page: Page, selector: string, options?: ClickOptions): Promise { + try { + this.bot.logger.debug( + this.bot.isMobile, + 'GHOST-CLICK', + `Trying to click selector: ${selector}, options: ${JSON.stringify(options)}` + ) + + // Wait for selector to exist before clicking + await page.waitForSelector(selector, { timeout: 10000 }) + + const cursor = createCursor(page as any) + await cursor.click(selector, options) + + return true + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'GHOST-CLICK', + `Failed for ${selector}: ${error instanceof Error ? error.message : String(error)}` + ) + return false + } + } + + async disableFido(page: Page) { + const routePattern = '**/GetCredentialType.srf*' + await page.route(routePattern, route => { + try { + const request = route.request() + const postData = request.postData() + + const body = postData ? JSON.parse(postData) : {} + + body.isFidoSupported = false + + this.bot.logger.debug( + this.bot.isMobile, + 'DISABLE-FIDO', + `Modified request body: isFidoSupported set to ${body.isFidoSupported}` + ) + + route.continue({ + postData: JSON.stringify(body), + headers: { + ...request.headers(), + 'Content-Type': 'application/json' + } + }) + } catch (error) { + this.bot.logger.debug( + this.bot.isMobile, + 'DISABLE-FIDO', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + route.continue() + } + }) + } +} diff --git a/src/browser/UserAgent.ts b/src/browser/UserAgent.ts new file mode 100644 index 0000000..3bec3f0 --- /dev/null +++ b/src/browser/UserAgent.ts @@ -0,0 +1,164 @@ +import axios from 'axios' +import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator' + +import type { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil' +import type { MicrosoftRewardsBot } from '../index' + +export class UserAgentManager { + private static readonly NOT_A_BRAND_VERSION = '99' + + constructor(private bot: MicrosoftRewardsBot) {} + + async getUserAgent(isMobile: boolean) { + const system = this.getSystemComponents(isMobile) + const app = await this.getAppComponents(isMobile) + + const uaTemplate = isMobile + ? `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Mobile Safari/537.36 EdgA/${app.edge_version}` + : `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Safari/537.36 Edg/${app.edge_version}` + + const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0` + + const uaMetadata = { + isMobile, + platform: isMobile ? 'Android' : 'Windows', + fullVersionList: [ + { brand: 'Not/A)Brand', version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0` }, + { brand: 'Microsoft Edge', version: app['edge_version'] }, + { brand: 'Chromium', version: app['chrome_version'] } + ], + brands: [ + { brand: 'Not/A)Brand', version: UserAgentManager.NOT_A_BRAND_VERSION }, + { brand: 'Microsoft Edge', version: app['edge_major_version'] }, + { brand: 'Chromium', version: app['chrome_major_version'] } + ], + platformVersion, + architecture: isMobile ? '' : 'x86', + bitness: isMobile ? '' : '64', + model: '' + } + + return { userAgent: uaTemplate, userAgentMetadata: uaMetadata } + } + + async getChromeVersion(isMobile: boolean): Promise { + try { + const request = { + url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json', + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + + const response = await axios(request) + const data: ChromeVersion = response.data + return data.channels.Stable.version + } catch (error) { + this.bot.logger.error( + isMobile, + 'USERAGENT-CHROME-VERSION', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + async getEdgeVersions(isMobile: boolean) { + try { + const request = { + url: 'https://edgeupdates.microsoft.com/api/products', + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + + const response = await axios(request) + const data: EdgeVersion[] = response.data + const stable = data.find(x => x.Product == 'Stable') as EdgeVersion + return { + android: stable.Releases.find(x => x.Platform == 'Android')?.ProductVersion, + windows: stable.Releases.find(x => x.Platform == 'Windows' && x.Architecture == 'x64')?.ProductVersion + } + } catch (error) { + this.bot.logger.error( + isMobile, + 'USERAGENT-EDGE-VERSION', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + getSystemComponents(mobile: boolean): string { + if (mobile) { + const androidVersion = 10 + Math.floor(Math.random() * 5) + return `Linux; Android ${androidVersion}; K` + } + + return 'Windows NT 10.0; Win64; x64' + } + + async getAppComponents(isMobile: boolean) { + const versions = await this.getEdgeVersions(isMobile) + const edgeVersion = isMobile ? versions.android : (versions.windows as string) + const edgeMajorVersion = edgeVersion?.split('.')[0] + + const chromeVersion = await this.getChromeVersion(isMobile) + const chromeMajorVersion = chromeVersion?.split('.')[0] + const chromeReducedVersion = `${chromeMajorVersion}.0.0.0` + + return { + not_a_brand_version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0`, + not_a_brand_major_version: UserAgentManager.NOT_A_BRAND_VERSION, + edge_version: edgeVersion as string, + edge_major_version: edgeMajorVersion as string, + chrome_version: chromeVersion as string, + chrome_major_version: chromeMajorVersion as string, + chrome_reduced_version: chromeReducedVersion as string + } + } + + async updateFingerprintUserAgent( + fingerprint: BrowserFingerprintWithHeaders, + isMobile: boolean + ): Promise { + try { + const userAgentData = await this.getUserAgent(isMobile) + const componentData = await this.getAppComponents(isMobile) + + //@ts-expect-error Errors due it not exactly matching + fingerprint.fingerprint.navigator.userAgentData = userAgentData.userAgentMetadata + fingerprint.fingerprint.navigator.userAgent = userAgentData.userAgent + fingerprint.fingerprint.navigator.appVersion = userAgentData.userAgent.replace( + `${fingerprint.fingerprint.navigator.appCodeName}/`, + '' + ) + + fingerprint.headers['user-agent'] = userAgentData.userAgent + fingerprint.headers['sec-ch-ua'] = + `"Microsoft Edge";v="${componentData.edge_major_version}", "Not=A?Brand";v="${componentData.not_a_brand_major_version}", "Chromium";v="${componentData.chrome_major_version}"` + fingerprint.headers['sec-ch-ua-full-version-list'] = + `"Microsoft Edge";v="${componentData.edge_version}", "Not=A?Brand";v="${componentData.not_a_brand_version}", "Chromium";v="${componentData.chrome_version}"` + + /* + Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0 + sec-ch-ua-full-version-list: "Microsoft Edge";v="129.0.2792.84", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90" + sec-ch-ua: "Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129" + + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 + "Google Chrome";v="129.0.6668.90", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90" + */ + + return fingerprint + } catch (error) { + this.bot.logger.error( + isMobile, + 'USER-AGENT-UPDATE', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } +} diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts new file mode 100644 index 0000000..e9f999c --- /dev/null +++ b/src/browser/auth/Login.ts @@ -0,0 +1,509 @@ +import type { Page } from 'patchright' +import type { MicrosoftRewardsBot } from '../../index' +import { saveSessionData } from '../../util/Load' + +// Methods +import { MobileAccessLogin } from './methods/MobileAccessLogin' +import { EmailLogin } from './methods/EmailLogin' +import { PasswordlessLogin } from './methods/PasswordlessLogin' +import { TotpLogin } from './methods/Totp2FALogin' + +type LoginState = + | 'EMAIL_INPUT' + | 'PASSWORD_INPUT' + | 'SIGN_IN_ANOTHER_WAY' + | 'PASSKEY_ERROR' + | 'PASSKEY_VIDEO' + | 'KMSI_PROMPT' + | 'LOGGED_IN' + | 'ACCOUNT_LOCKED' + | 'ERROR_ALERT' + | '2FA_TOTP' + | 'LOGIN_PASSWORDLESS' + | 'GET_A_CODE' + | 'UNKNOWN' + | 'CHROMEWEBDATA_ERROR' + +export class Login { + emailLogin: EmailLogin + passwordlessLogin: PasswordlessLogin + totp2FALogin: TotpLogin + constructor(private bot: MicrosoftRewardsBot) { + this.emailLogin = new EmailLogin(this.bot) + this.passwordlessLogin = new PasswordlessLogin(this.bot) + this.totp2FALogin = new TotpLogin(this.bot) + } + + private readonly primaryButtonSelector = 'button[data-testid="primaryButton"]' + private readonly secondaryButtonSelector = 'button[data-testid="secondaryButton"]' + + async login(page: Page, email: string, password: string, totpSecret?: string) { + try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process') + + await page.goto('https://www.bing.com/rewards/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {}) + await this.bot.utils.wait(2000) + await this.bot.browser.utils.reloadBadPage(page) + + await this.bot.browser.utils.disableFido(page) + + const maxIterations = 25 + let iteration = 0 + + let previousState: LoginState = 'UNKNOWN' + let sameStateCount = 0 + + while (iteration < maxIterations) { + if (page.isClosed()) throw new Error('Page closed unexpectedly') + + iteration++ + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `State check iteration ${iteration}/${maxIterations}`) + + const state = await this.detectCurrentState(page) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Current state: ${state}`) + + if (state !== previousState && previousState !== 'UNKNOWN') { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', `State transition: ${previousState} → ${state}`) + } + + if (state === previousState && state !== 'LOGGED_IN' && state !== 'UNKNOWN') { + sameStateCount++ + if (sameStateCount >= 4) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + `Stuck in state "${state}" for 4 loops. Refreshing page...` + ) + await page.reload({ waitUntil: 'domcontentloaded' }) + await this.bot.utils.wait(3000) + sameStateCount = 0 + previousState = 'UNKNOWN' + continue + } + } else { + sameStateCount = 0 + } + previousState = state + + if (state === 'LOGGED_IN') { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Successfully logged in') + break + } + + const shouldContinue = await this.handleState(state, page, email, password, totpSecret) + + if (!shouldContinue) { + throw new Error(`Login failed or aborted at state: ${state}`) + } + + await this.bot.utils.wait(1000) + } + + if (iteration >= maxIterations) { + throw new Error('Login timeout: exceeded maximum iterations') + } + + await this.finalizeLogin(page, email) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + private async detectCurrentState(page: Page): Promise { + // Make sure we settled before getting a URL + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + + const url = new URL(page.url()) + + this.bot.logger.debug(this.bot.isMobile, 'DETECT-CURRENT-STATE', `Current URL: ${url}`) + + if (url.hostname === 'chromewebdata') { + this.bot.logger.warn(this.bot.isMobile, 'DETECT-CURRENT-STATE', 'Detected chromewebdata error page') + return 'CHROMEWEBDATA_ERROR' + } + + const isLocked = await page + .waitForSelector('#serviceAbuseLandingTitle', { state: 'visible', timeout: 200 }) + .then(() => true) + .catch(() => false) + if (isLocked) { + return 'ACCOUNT_LOCKED' + } + + // If instantly loading rewards dash, logged in + if (url.hostname === 'rewards.bing.com') { + return 'LOGGED_IN' + } + + // If account dash, logged in + if (url.hostname === 'account.microsoft.com') { + return 'LOGGED_IN' + } + + const check = async (selector: string, state: LoginState): Promise => { + return page + .waitForSelector(selector, { state: 'visible', timeout: 200 }) + .then(visible => (visible ? state : null)) + .catch(() => null) + } + + const results = await Promise.all([ + check('div[role="alert"]', 'ERROR_ALERT'), + check('[data-testid="passwordEntry"]', 'PASSWORD_INPUT'), + check('input#usernameEntry', 'EMAIL_INPUT'), + check('[data-testid="kmsiVideo"]', 'KMSI_PROMPT'), + check('[data-testid="biometricVideo"]', 'PASSKEY_VIDEO'), + check('[data-testid="registrationImg"]', 'PASSKEY_ERROR'), + check('[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])', 'SIGN_IN_ANOTHER_WAY'), + check('[data-testid="deviceShieldCheckmarkVideo"]', 'LOGIN_PASSWORDLESS'), + check('input[name="otc"]', '2FA_TOTP'), + check('form[name="OneTimeCodeViewForm"]', '2FA_TOTP') + ]) + + // Get a code + const identityBanner = await page + .waitForSelector('[data-testid="identityBanner"]', { state: 'visible', timeout: 200 }) + .then(() => true) + .catch(() => false) + + const primaryButton = await page + .waitForSelector(this.primaryButtonSelector, { state: 'visible', timeout: 200 }) + .then(() => true) + .catch(() => false) + + const passwordEntry = await page + .waitForSelector('[data-testid="passwordEntry"]', { state: 'visible', timeout: 200 }) + .then(() => true) + .catch(() => false) + + if (identityBanner && primaryButton && !passwordEntry && !results.includes('2FA_TOTP')) { + results.push('GET_A_CODE') // Lower prio + } + + // Final + let foundStates = results.filter((s): s is LoginState => s !== null) + + if (foundStates.length === 0) return 'UNKNOWN' + + if (foundStates.includes('ERROR_ALERT')) { + if (url.hostname !== 'login.live.com') { + // Remove ERROR_ALERT if not on login.live.com + foundStates = foundStates.filter(s => s !== 'ERROR_ALERT') + } + if (foundStates.includes('2FA_TOTP')) { + // Don't throw on TOTP if expired code is entered + foundStates = foundStates.filter(s => s !== 'ERROR_ALERT') + } + + // On login.live.com, keep it + return 'ERROR_ALERT' + } + + if (foundStates.includes('ERROR_ALERT')) return 'ERROR_ALERT' + if (foundStates.includes('ACCOUNT_LOCKED')) return 'ACCOUNT_LOCKED' + if (foundStates.includes('PASSKEY_VIDEO')) return 'PASSKEY_VIDEO' + if (foundStates.includes('PASSKEY_ERROR')) return 'PASSKEY_ERROR' + if (foundStates.includes('KMSI_PROMPT')) return 'KMSI_PROMPT' + if (foundStates.includes('PASSWORD_INPUT')) return 'PASSWORD_INPUT' + if (foundStates.includes('EMAIL_INPUT')) return 'EMAIL_INPUT' + if (foundStates.includes('SIGN_IN_ANOTHER_WAY')) return 'SIGN_IN_ANOTHER_WAY' + if (foundStates.includes('LOGIN_PASSWORDLESS')) return 'LOGIN_PASSWORDLESS' + if (foundStates.includes('2FA_TOTP')) return '2FA_TOTP' + + const mainState = foundStates[0] as LoginState + + return mainState + } + + private async handleState( + state: LoginState, + page: Page, + email: string, + password: string, + totpSecret?: string + ): Promise { + switch (state) { + case 'ACCOUNT_LOCKED': { + const msg = 'This account has been locked! Remove from config and restart!' + this.bot.logger.error(this.bot.isMobile, 'CHECK-LOCKED', msg) + throw new Error(msg) + } + + case 'ERROR_ALERT': { + const alertEl = page.locator('div[role="alert"]') + const errorMsg = await alertEl.innerText().catch(() => 'Unknown Error') + this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error: ${errorMsg}`) + throw new Error(`Microsoft login error message: ${errorMsg}`) + } + + case 'LOGGED_IN': + return true + + case 'EMAIL_INPUT': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering email') + await this.emailLogin.enterEmail(page, email) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'PASSWORD_INPUT': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering password') + await this.emailLogin.enterPassword(page, password) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'GET_A_CODE': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code"') + // Select sign in other way + await this.bot.browser.utils.ghostClick(page, '[data-testid="viewFooter"] span[role="button"]') + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'CHROMEWEBDATA_ERROR': { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + 'chromewebdata error page detected, attempting to recover to Rewards home' + ) + // Try go to Rewards dashboard + try { + await page + .goto(this.bot.config.baseURL, { + waitUntil: 'domcontentloaded', + timeout: 10000 + }) + .catch(() => {}) + + await this.bot.utils.wait(3000) + return true + } catch { + // If even that fails, fall back to login.live.com + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + 'Failed to navigate to baseURL from chromewebdata, retrying login.live.com' + ) + + await page + .goto('https://login.live.com/', { + waitUntil: 'domcontentloaded', + timeout: 10000 + }) + .catch(() => {}) + + await this.bot.utils.wait(3000) + return true + } + } + + case '2FA_TOTP': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA required') + await this.totp2FALogin.handle(page, totpSecret) + return true + } + + case 'SIGN_IN_ANOTHER_WAY': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Use my password"') + const passwordOption = '[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])' + await this.bot.browser.utils.ghostClick(page, passwordOption) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'KMSI_PROMPT': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Accepting KMSI prompt') + await this.bot.browser.utils.ghostClick(page, this.primaryButtonSelector) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'PASSKEY_VIDEO': + case 'PASSKEY_ERROR': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Skipping Passkey prompt') + await this.bot.browser.utils.ghostClick(page, this.secondaryButtonSelector) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'LOGIN_PASSWORDLESS': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling passwordless authentication') + await this.passwordlessLogin.handle(page) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + return true + } + + case 'UNKNOWN': { + const url = new URL(page.url()) + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + `Unknown state at host:${url.hostname} path:${url.pathname}. Waiting...` + ) + return true + } + + default: + return true + } + } + + private async finalizeLogin(page: Page, email: string) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Finalizing login') + + await page.goto(this.bot.config.baseURL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}) + + const loginRewardsSuccess = new URL(page.url()).hostname === 'rewards.bing.com' + if (loginRewardsSuccess) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Logged into Microsoft Rewards successfully') + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + 'Could not verify Rewards Dashboard. Assuming login valid anyway.' + ) + } + + await this.verifyBingSession(page) + await this.getRewardsSession(page) + + const browser = page.context() + const cookies = await browser.cookies() + await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile) + + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed! Session saved!') + } + + async verifyBingSession(page: Page) { + const url = + 'https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F' + const loopMax = 5 + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session') + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}) + + for (let i = 0; i < loopMax; i++) { + if (page.isClosed()) break + + // Rare error state + const state = await this.detectCurrentState(page) + if (state === 'PASSKEY_ERROR') { + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN-BING', + 'Verification landed on Passkey error state! Trying to dismiss.' + ) + await this.bot.browser.utils.ghostClick(page, this.secondaryButtonSelector) + } + + const u = new URL(page.url()) + const atBingHome = u.hostname === 'www.bing.com' && u.pathname === '/' + + if (atBingHome) { + await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {}) + + const signedIn = await page + .waitForSelector('#id_n', { timeout: 3000 }) + .then(() => true) + .catch(() => false) + + if (signedIn || this.bot.isMobile) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session established') + return + } + } + + await this.bot.utils.wait(1000) + } + + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-BING', + 'Could not confirm Bing session after retries; continuing' + ) + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-BING', + `Bing verification error: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async getRewardsSession(page: Page) { + const loopMax = 5 + + this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Fetching request token') + + try { + await page + .goto(`${this.bot.config.baseURL}?_=${Date.now()}`, { waitUntil: 'networkidle', timeout: 10000 }) + .catch(() => {}) + + for (let i = 0; i < loopMax; i++) { + if (page.isClosed()) break + + this.bot.logger.debug( + this.bot.isMobile, + 'GET-REWARD-SESSION', + `Loop ${i + 1}/${loopMax} | URL=${page.url()}` + ) + + const u = new URL(page.url()) + const atRewardHome = u.hostname === 'rewards.bing.com' && u.pathname === '/' + + if (atRewardHome) { + await this.bot.browser.utils.tryDismissAllMessages(page) + + const html = await page.content() + const $ = await this.bot.browser.utils.loadInCheerio(html) + + const token = + $('input[name="__RequestVerificationToken"]').attr('value') ?? + $('meta[name="__RequestVerificationToken"]').attr('content') ?? + null + + if (token) { + this.bot.requestToken = token + this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Request token has been set!') + + this.bot.logger.debug( + this.bot.isMobile, + 'GET-REWARD-SESSION', + `Token extracted: ${token.substring(0, 10)}...` + ) + return + } + + this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token NOT found on page') + } + + await this.bot.utils.wait(1000) + } + + this.bot.logger.warn( + this.bot.isMobile, + 'GET-REQUEST-TOKEN', + 'No RequestVerificationToken found — some activities may not work' + ) + } catch (error) { + throw this.bot.logger.error( + this.bot.isMobile, + 'GET-REQUEST-TOKEN', + `Reward session error: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + async getAppAccessToken(page: Page, email: string) { + return await new MobileAccessLogin(this.bot, page).get(email) + } +} diff --git a/src/browser/auth/methods/EmailLogin.ts b/src/browser/auth/methods/EmailLogin.ts new file mode 100644 index 0000000..5198523 --- /dev/null +++ b/src/browser/auth/methods/EmailLogin.ts @@ -0,0 +1,86 @@ +import type { Page } from 'patchright' +import type { MicrosoftRewardsBot } from '../../../index' + +export class EmailLogin { + private submitButton = 'button[type="submit"]' + + constructor(private bot: MicrosoftRewardsBot) {} + + async enterEmail(page: Page, email: string): Promise<'ok' | 'error'> { + try { + const emailInputSelector = 'input[type="email"]' + const emailField = await page + .waitForSelector(emailInputSelector, { state: 'visible', timeout: 1000 }) + .catch(() => {}) + if (!emailField) { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email field not found') + return 'error' + } + + await this.bot.utils.wait(1000) + + const prefilledEmail = await page + .waitForSelector('#userDisplayName', { state: 'visible', timeout: 1000 }) + .catch(() => {}) + if (!prefilledEmail) { + await page.fill(emailInputSelector, '').catch(() => {}) + await this.bot.utils.wait(500) + await page.fill(emailInputSelector, email).catch(() => {}) + await this.bot.utils.wait(1000) + } else { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email prefilled') + } + + await page.waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }).catch(() => {}) + + await this.bot.browser.utils.ghostClick(page, this.submitButton) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email submitted') + + return 'ok' + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-ENTER-EMAIL', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + return 'error' + } + } + + async enterPassword(page: Page, password: string): Promise<'ok' | 'needs-2fa' | 'error'> { + try { + const passwordInputSelector = 'input[type="password"]' + const passwordField = await page + .waitForSelector(passwordInputSelector, { state: 'visible', timeout: 1000 }) + .catch(() => {}) + if (!passwordField) { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password field not found') + return 'error' + } + + await this.bot.utils.wait(1000) + await page.fill(passwordInputSelector, '').catch(() => {}) + await this.bot.utils.wait(500) + await page.fill(passwordInputSelector, password).catch(() => {}) + await this.bot.utils.wait(1000) + + const submitButton = await page + .waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }) + .catch(() => null) + + if (submitButton) { + await this.bot.browser.utils.ghostClick(page, this.submitButton) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password submitted') + } + + return 'ok' + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-ENTER-PASSWORD', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + return 'error' + } + } +} diff --git a/src/browser/auth/methods/MobileAccessLogin.ts b/src/browser/auth/methods/MobileAccessLogin.ts new file mode 100644 index 0000000..8b71807 --- /dev/null +++ b/src/browser/auth/methods/MobileAccessLogin.ts @@ -0,0 +1,88 @@ +import type { Page } from 'patchright' +import { randomBytes } from 'crypto' +import { URLSearchParams } from 'url' +import type { AxiosRequestConfig } from 'axios' + +import type { MicrosoftRewardsBot } from '../../../index' + +export class MobileAccessLogin { + private clientId = '0000000040170455' + private authUrl = 'https://login.live.com/oauth20_authorize.srf' + private redirectUrl = 'https://login.live.com/oauth20_desktop.srf' + private tokenUrl = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token' + private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL' + private maxTimeout = 180_000 // 3min + + constructor( + private bot: MicrosoftRewardsBot, + private page: Page + ) {} + + async get(email: string): Promise { + try { + const authorizeUrl = new URL(this.authUrl) + authorizeUrl.searchParams.append('response_type', 'code') + authorizeUrl.searchParams.append('client_id', this.clientId) + authorizeUrl.searchParams.append('redirect_uri', this.redirectUrl) + authorizeUrl.searchParams.append('scope', this.scope) + authorizeUrl.searchParams.append('state', randomBytes(16).toString('hex')) + authorizeUrl.searchParams.append('access_type', 'offline_access') + authorizeUrl.searchParams.append('login_hint', email) + + await this.bot.browser.utils.disableFido(this.page) + + await this.page.goto(authorizeUrl.href).catch(() => {}) + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...') + const start = Date.now() + let code = '' + + while (Date.now() - start < this.maxTimeout) { + const url = new URL(this.page.url()) + if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') { + code = url.searchParams.get('code') || '' + if (code) break + } + await this.bot.utils.wait(1000) + } + + if (!code) { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'Timed out waiting for OAuth code') + return '' + } + + const data = new URLSearchParams() + data.append('grant_type', 'authorization_code') + data.append('client_id', this.clientId) + data.append('code', code) + data.append('redirect_uri', this.redirectUrl) + + const request: AxiosRequestConfig = { + url: this.tokenUrl, + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: data.toString() + } + + const response = await this.bot.axios.request(request) + const token = (response?.data?.access_token as string) ?? '' + + if (!token) { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'No access_token in token response') + return '' + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Mobile access token received') + return token + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-APP', + `MobileAccess error: ${error instanceof Error ? error.message : String(error)}` + ) + return '' + } finally { + await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {}) + } + } +} diff --git a/src/browser/auth/methods/PasswordlessLogin.ts b/src/browser/auth/methods/PasswordlessLogin.ts new file mode 100644 index 0000000..b2b4110 --- /dev/null +++ b/src/browser/auth/methods/PasswordlessLogin.ts @@ -0,0 +1,110 @@ +import type { Page } from 'patchright' +import type { MicrosoftRewardsBot } from '../../../index' + +export class PasswordlessLogin { + private readonly maxAttempts = 60 + private readonly numberDisplaySelector = 'div[data-testid="displaySign"]' + private readonly approvalPath = '/ppsecure/post.srf' + + constructor(private bot: MicrosoftRewardsBot) {} + + private async getDisplayedNumber(page: Page): Promise { + try { + const numberElement = await page + .waitForSelector(this.numberDisplaySelector, { + timeout: 5000 + }) + .catch(() => null) + + if (numberElement) { + const number = await numberElement.textContent() + return number?.trim() || null + } + } catch (error) { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Could not retrieve displayed number') + } + return null + } + + private async waitForApproval(page: Page): Promise { + try { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + `Waiting for approval... (timeout after ${this.maxAttempts} seconds)` + ) + + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + const currentUrl = new URL(page.url()) + if (currentUrl.pathname === this.approvalPath) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Approval detected') + return true + } + + // Every 5 seconds to show it's still waiting + if (attempt % 5 === 0) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + `Still waiting... (${attempt}/${this.maxAttempts} seconds elapsed)` + ) + } + + await this.bot.utils.wait(1000) + } + + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + `Approval timeout after ${this.maxAttempts} seconds!` + ) + return false + } catch (error: any) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + `Approval failed, an error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + async handle(page: Page): Promise { + try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Passwordless authentication requested') + + const displayedNumber = await this.getDisplayedNumber(page) + + if (displayedNumber) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + `Please approve login and select number: ${displayedNumber}` + ) + } else { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + 'Please approve login on your authenticator app' + ) + } + + const approved = await this.waitForApproval(page) + + if (approved) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approved successfully') + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + } else { + this.bot.logger.error(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approval failed or timed out') + throw new Error('Passwordless authentication timeout') + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-PASSWORDLESS', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } +} diff --git a/src/browser/auth/methods/Totp2FALogin.ts b/src/browser/auth/methods/Totp2FALogin.ts new file mode 100644 index 0000000..21d1689 --- /dev/null +++ b/src/browser/auth/methods/Totp2FALogin.ts @@ -0,0 +1,163 @@ +import type { Page } from 'patchright' +import * as OTPAuth from 'otpauth' +import readline from 'readline' +import type { MicrosoftRewardsBot } from '../../../index' + +export class TotpLogin { + private readonly textInputSelector = + 'form[name="OneTimeCodeViewForm"] input[type="text"], input#floatingLabelInput5' + private readonly hiddenInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]' + private readonly submitButtonSelector = 'button[type="submit"]' + private readonly maxManualSeconds = 60 + private readonly maxManualAttempts = 5 + + constructor(private bot: MicrosoftRewardsBot) {} + + private generateTotpCode(secret: string): string { + return new OTPAuth.TOTP({ secret, digits: 6 }).generate() + } + + private async promptManualCode(): Promise { + return await new Promise(resolve => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }) + + let resolved = false + + const cleanup = (result: string | null) => { + if (resolved) return + resolved = true + clearTimeout(timer) + rl.close() + resolve(result) + } + + const timer = setTimeout(() => cleanup(null), this.maxManualSeconds * 1000) + + rl.question(`Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `, answer => { + cleanup(answer.trim()) + }) + }) + } + + private async fillCode(page: Page, code: string): Promise { + try { + const visibleInput = await page + .waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 }) + .catch(() => null) + + if (visibleInput) { + await visibleInput.fill(code) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled visible TOTP text input') + return true + } + + const hiddenInput = await page.$(this.hiddenInputSelector) + + if (hiddenInput) { + await hiddenInput.fill(code) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled hidden TOTP input') + return true + } + + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found (visible or hidden)') + return false + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-TOTP', + `Failed to fill TOTP input: ${error instanceof Error ? error.message : String(error)}` + ) + return false + } + } + + async handle(page: Page, totpSecret?: string): Promise { + try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP 2FA authentication requested') + + if (totpSecret) { + const code = this.generateTotpCode(totpSecret) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret') + + const filled = await this.fillCode(page, code) + + if (!filled) { + this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to locate or fill TOTP input field') + throw new Error('TOTP input field not found') + } + + await this.bot.utils.wait(500) + await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') + return + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input') + + for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { + const code = await this.promptManualCode() + + if (!code || !/^\d{6}$/.test(code)) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-TOTP', + `Invalid or missing TOTP code (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('Manual TOTP input failed or timed out') + } + + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-TOTP', + 'Retrying manual TOTP input due to invalid code' + ) + continue + } + + const filled = await this.fillCode(page, code) + + if (!filled) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-TOTP', + `Unable to locate or fill TOTP input field (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('TOTP input field not found') + } + + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-TOTP', + 'Retrying manual TOTP input due to fill failure' + ) + continue + } + + await this.bot.utils.wait(500) + await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') + return + } + + throw new Error(`Manual TOTP input failed after ${this.maxManualAttempts} attempts`) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-TOTP', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } +} diff --git a/src/config.example.json b/src/config.example.json new file mode 100644 index 0000000..c06226e --- /dev/null +++ b/src/config.example.json @@ -0,0 +1,71 @@ +{ + "baseURL": "https://rewards.bing.com", + "sessionPath": "sessions", + "headless": false, + "runOnZeroPoints": false, + "clusters": 1, + "errorDiagnostics": true, + "saveFingerprint": { + "mobile": false, + "desktop": false + }, + "workers": { + "doDailySet": true, + "doMorePromotions": true, + "doPunchCards": true, + "doAppPromotions": true, + "doDesktopSearch": true, + "doMobileSearch": true, + "doDailyCheckIn": true, + "doReadToEarn": true + }, + "searchOnBingLocalQueries": false, + "globalTimeout": "30sec", + "searchSettings": { + "scrollRandomResults": false, + "clickRandomResults": false, + "parallelSearching": true, + "searchResultVisitTime": "10sec", + "searchDelay": { + "min": "30sec", + "max": "1min" + }, + "readDelay": { + "min": "30sec", + "max": "1min" + } + }, + "debugLogs": false, + "consoleLogFilter": { + "enabled": false, + "mode": "whitelist", + "levels": ["error", "warn"], + "keywords": ["starting account"], + "regexPatterns": [] + }, + "proxy": { + "queryEngine": true + }, + "webhook": { + "discord": { + "enabled": false, + "url": "" + }, + "ntfy": { + "enabled": false, + "url": "", + "topic": "", + "token": "", + "title": "Microsoft-Rewards-Script", + "tags": ["bot", "notify"], + "priority": 3 + }, + "webhookLogFilter": { + "enabled": false, + "mode": "whitelist", + "levels": ["error"], + "keywords": ["starting account", "select number", "collected"], + "regexPatterns": [] + } + } +} diff --git a/src/crontab.template b/src/crontab.template new file mode 100644 index 0000000..e538055 --- /dev/null +++ b/src/crontab.template @@ -0,0 +1,7 @@ +# Set PATH so cron jobs can find node/npm +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# Set timezone for cron jobs +TZ=${TZ} + +# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs +${CRON_SCHEDULE} /bin/bash /usr/src/microsoft-rewards-script/scripts/docker/run_daily.sh >> /proc/1/fd/1 2>&1 diff --git a/src/functions/Activities.ts b/src/functions/Activities.ts new file mode 100644 index 0000000..ee2793c --- /dev/null +++ b/src/functions/Activities.ts @@ -0,0 +1,91 @@ +import type { MicrosoftRewardsBot } from '../index' +import type { Page } from 'patchright' + +// App +import { DailyCheckIn } from './activities/app/DailyCheckIn' +import { ReadToEarn } from './activities/app/ReadToEarn' +import { AppReward } from './activities/app/AppReward' + +// API +import { UrlReward } from './activities/api/UrlReward' +import { Quiz } from './activities/api/Quiz' +import { FindClippy } from './activities/api/FindClippy' + +// Browser +import { SearchOnBing } from './activities/browser/SearchOnBing' +import { Search } from './activities/browser/Search' + +import type { BasePromotion, DashboardData, FindClippyPromotion } from '../interface/DashboardData' +import type { Promotion } from '../interface/AppDashBoardData' + +export default class Activities { + private bot: MicrosoftRewardsBot + + constructor(bot: MicrosoftRewardsBot) { + this.bot = bot + } + + // Browser Activities + doSearch = async (data: DashboardData, page: Page, isMobile: boolean): Promise => { + const search = new Search(this.bot) + return await search.doSearch(data, page, isMobile) + } + + doSearchOnBing = async (promotion: BasePromotion, page: Page): Promise => { + const searchOnBing = new SearchOnBing(this.bot) + await searchOnBing.doSearchOnBing(promotion, page) + } + + /* + doABC = async (page: Page): Promise => { + const abc = new ABC(this.bot) + await abc.doABC(page) + } + */ + + /* + doPoll = async (page: Page): Promise => { + const poll = new Poll(this.bot) + await poll.doPoll(page) + } + */ + + /* + doThisOrThat = async (page: Page): Promise => { + const thisOrThat = new ThisOrThat(this.bot) + await thisOrThat.doThisOrThat(page) + } + */ + + // API Activities + doUrlReward = async (promotion: BasePromotion): Promise => { + const urlReward = new UrlReward(this.bot) + await urlReward.doUrlReward(promotion) + } + + doQuiz = async (promotion: BasePromotion): Promise => { + const quiz = new Quiz(this.bot) + await quiz.doQuiz(promotion) + } + + doFindClippy = async (promotions: FindClippyPromotion): Promise => { + const urlReward = new FindClippy(this.bot) + await urlReward.doFindClippy(promotions) + } + + // App Activities + doAppReward = async (promotion: Promotion): Promise => { + const urlReward = new AppReward(this.bot) + await urlReward.doAppReward(promotion) + } + + doReadToEarn = async (): Promise => { + const readToEarn = new ReadToEarn(this.bot) + await readToEarn.doReadToEarn() + } + + doDailyCheckIn = async (): Promise => { + const dailyCheckIn = new DailyCheckIn(this.bot) + await dailyCheckIn.doDailyCheckIn() + } +} diff --git a/src/functions/QueryEngine.ts b/src/functions/QueryEngine.ts new file mode 100644 index 0000000..d012241 --- /dev/null +++ b/src/functions/QueryEngine.ts @@ -0,0 +1,191 @@ +import type { AxiosRequestConfig } from 'axios' +import type { + BingSuggestionResponse, + BingTrendingTopicsResponse, + GoogleSearch, + GoogleTrendsResponse +} from '../interface/Search' +import type { MicrosoftRewardsBot } from '../index' + +export class QueryCore { + constructor(private bot: MicrosoftRewardsBot) {} + + async getGoogleTrends(geoLocale: string): Promise { + const queryTerms: GoogleSearch[] = [] + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-GOOGLE-TRENDS', + `Generating search queries, can take a while! | GeoLocale: ${geoLocale}` + ) + + try { + const request: AxiosRequestConfig = { + url: 'https://trends.google.com/_/TrendsUi/data/batchexecute', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' + }, + data: `f.req=[[[i0OFE,"[null, null, \\"${geoLocale.toUpperCase()}\\", 0, null, 48]"]]]` + } + + const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) + const rawData = response.data + + const trendsData = this.extractJsonFromResponse(rawData) + if (!trendsData) { + throw this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-GOOGLE-TRENDS', + 'Failed to parse Google Trends response' + ) + } + + const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)]) + if (mappedTrendsData.length < 90) { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-GOOGLE-TRENDS', + 'Insufficient search queries, falling back to US' + ) + return this.getGoogleTrends(geoLocale) + } + + for (const [topic, relatedQueries] of mappedTrendsData) { + queryTerms.push({ + topic: topic as string, + related: relatedQueries as string[] + }) + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-GOOGLE-TRENDS', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + } + + const queries = queryTerms.flatMap(x => [x.topic, ...x.related]) + + return queries + } + + private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null { + const lines = text.split('\n') + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + return JSON.parse(JSON.parse(trimmed)[0][2])[1] + } catch { + continue + } + } + } + + return null + } + + async getBingSuggestions(query: string = '', langCode: string = 'en'): Promise { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-BING-SUGGESTIONS', + `Generating bing suggestions! | LangCode: ${langCode}` + ) + + try { + const request: AxiosRequestConfig = { + url: `https://www.bingapis.com/api/v7/suggestions?q=${encodeURIComponent(query)}&appid=6D0A9B8C5100E9ECC7E11A104ADD76C10219804B&cc=xl&setlang=${langCode}`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' + } + } + + const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) + const rawData: BingSuggestionResponse = response.data + + const searchSuggestions = rawData.suggestionGroups[0]?.searchSuggestions + + if (!searchSuggestions?.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'API returned no results') + return [] + } + + return searchSuggestions.map(x => x.query) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-GOOGLE-TRENDS', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + } + + return [] + } + + async getBingRelatedTerms(term: string): Promise { + try { + const request = { + url: `https://api.bing.com/osjson.aspx?query=${term}`, + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + + const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) + const rawData = response.data + + const relatedTerms = rawData[1] + + if (!relatedTerms?.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'API returned no results') + return [] + } + + return relatedTerms + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-BING-RELATED', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + } + + return [] + } + + async getBingTendingTopics(langCode: string = 'en'): Promise { + try { + const request = { + url: `https://www.bing.com/api/v7/news/trendingtopics?appid=91B36E34F9D1B900E54E85A77CF11FB3BE5279E6&cc=xl&setlang=${langCode}`, + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + + const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) + const rawData: BingTrendingTopicsResponse = response.data + + const trendingTopics = rawData.value + + if (!trendingTopics?.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'API returned no results') + return [] + } + + const queries = trendingTopics.map(x => x.query?.text?.trim() || x.name.trim()) + + return queries + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-BING-TRENDING', + `An error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + } + + return [] + } +} diff --git a/src/functions/SearchManager.ts b/src/functions/SearchManager.ts new file mode 100644 index 0000000..2aa63f9 --- /dev/null +++ b/src/functions/SearchManager.ts @@ -0,0 +1,618 @@ +import type { BrowserContext } from 'patchright' +import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator' +import { MicrosoftRewardsBot, executionContext } from '../index' +import type { DashboardData } from '../interface/DashboardData' +import type { Account } from '../interface/Account' + +interface BrowserSession { + context: BrowserContext + fingerprint: BrowserFingerprintWithHeaders +} + +interface MissingSearchPoints { + mobilePoints: number + desktopPoints: number +} + +interface SearchResults { + mobilePoints: number + desktopPoints: number +} + +export class SearchManager { + constructor(private bot: MicrosoftRewardsBot) {} + + async doSearches( + data: DashboardData, + missingSearchPoints: MissingSearchPoints, + mobileSession: BrowserSession, + account: Account, + accountEmail: string + ): Promise { + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Start | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}` + ) + + const doMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0 + const doDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0 + + const mobileStatus = this.bot.config.workers.doMobileSearch + ? missingSearchPoints.mobilePoints > 0 + ? 'run' + : 'skip-no-points' + : 'skip-disabled' + const desktopStatus = this.bot.config.workers.doDesktopSearch + ? missingSearchPoints.desktopPoints > 0 + ? 'run' + : 'skip-no-points' + : 'skip-disabled' + + this.bot.logger.info( + 'main', + 'SEARCH-MANAGER', + `Mobile: ${mobileStatus} (enabled=${this.bot.config.workers.doMobileSearch}, missing=${missingSearchPoints.mobilePoints})` + ) + this.bot.logger.info( + 'main', + 'SEARCH-MANAGER', + `Desktop: ${desktopStatus} (enabled=${this.bot.config.workers.doDesktopSearch}, missing=${missingSearchPoints.desktopPoints})` + ) + + if (!doMobile && !doDesktop) { + const bothWorkersEnabled = this.bot.config.workers.doMobileSearch && this.bot.config.workers.doDesktopSearch + const bothNoPoints = missingSearchPoints.mobilePoints <= 0 && missingSearchPoints.desktopPoints <= 0 + + if (bothWorkersEnabled && bothNoPoints) { + this.bot.logger.info( + 'main', + 'SEARCH-MANAGER', + 'All searches skipped: no mobile or desktop points left.' + ) + } else { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'No searches scheduled (disabled or no points).') + } + + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Closing mobile session') + try { + await executionContext.run({ isMobile: true, accountEmail }, async () => { + await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail) + }) + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed') + } catch (error) { + this.bot.logger.warn( + 'main', + 'SEARCH-MANAGER', + `Failed to close mobile session: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-MANAGER', `Mobile close stack: ${error.stack}`) + } + } + return { mobilePoints: 0, desktopPoints: 0 } + } + + const useParallel = this.bot.config.searchSettings.parallelSearching + this.bot.logger.info('main', 'SEARCH-MANAGER', `Mode: ${useParallel ? 'parallel' : 'sequential'}`) + this.bot.logger.debug('main', 'SEARCH-MANAGER', `parallelSearching=${useParallel} | account=${accountEmail}`) + + if (useParallel) { + return await this.doParallelSearches( + data, + missingSearchPoints, + mobileSession, + account, + accountEmail, + executionContext + ) + } else { + return await this.doSequentialSearches( + data, + missingSearchPoints, + mobileSession, + account, + accountEmail, + executionContext + ) + } + } + + private async doParallelSearches( + data: DashboardData, + missingSearchPoints: MissingSearchPoints, + mobileSession: BrowserSession, + account: Account, + accountEmail: string, + executionContext: any + ): Promise { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Parallel start') + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Parallel config | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}` + ) + + const shouldDoMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0 + const shouldDoDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0 + + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Parallel flags | mobile=${shouldDoMobile} | desktop=${shouldDoDesktop}` + ) + + let desktopSession: BrowserSession | null = null + let mobileContextClosed = false + + try { + const promises: Promise[] = [] + const searchTypes: string[] = [] + + if (shouldDoMobile) { + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Schedule mobile | target=${missingSearchPoints.mobilePoints}` + ) + searchTypes.push('Mobile') + promises.push( + this.doMobileSearch(data, missingSearchPoints, mobileSession, accountEmail, executionContext).then( + points => { + mobileContextClosed = true + this.bot.logger.info('main', 'SEARCH-MANAGER', `Mobile done | earned=${points}`) + return points + } + ) + ) + } else { + const reason = !this.bot.config.workers.doMobileSearch ? 'disabled' : 'no-points' + this.bot.logger.info('main', 'SEARCH-MANAGER', `Skip mobile (${reason}); closing mobile session`) + await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail) + mobileContextClosed = true + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed (no mobile search)') + } + + if (shouldDoDesktop) { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Desktop login start') + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Desktop login | account=${accountEmail} | proxy=${account.proxy ?? 'none'}` + ) + desktopSession = await executionContext.run({ isMobile: false, accountEmail }, async () => + this.createDesktopSession(account, accountEmail) + ) + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Desktop login done') + } else { + const reason = !this.bot.config.workers.doDesktopSearch ? 'disabled' : 'no-points' + this.bot.logger.info('main', 'SEARCH-MANAGER', `Skip desktop login (${reason})`) + } + + if (shouldDoDesktop && desktopSession) { + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Schedule desktop | target=${missingSearchPoints.desktopPoints}` + ) + searchTypes.push('Desktop') + promises.push( + this.doDesktopSearch( + data, + missingSearchPoints, + desktopSession, + accountEmail, + executionContext + ).then(points => { + this.bot.logger.info('main', 'SEARCH-MANAGER', `Desktop done | earned=${points}`) + return points + }) + ) + } + + this.bot.logger.info('main', 'SEARCH-MANAGER', `Running parallel: ${searchTypes.join(' + ') || 'none'}`) + + const results = await Promise.all(promises) + + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Parallel results | account=${accountEmail} | results=${JSON.stringify(results)}` + ) + + const mobilePoints = shouldDoMobile ? (results[0] ?? 0) : 0 + const desktopPoints = shouldDoDesktop ? (results[shouldDoMobile ? 1 : 0] ?? 0) : 0 + + this.bot.logger.info( + 'main', + 'SEARCH-MANAGER', + `Parallel summary | mobile=${mobilePoints} | desktop=${desktopPoints} | total=${ + mobilePoints + desktopPoints + }` + ) + + return { mobilePoints, desktopPoints } + } catch (error) { + this.bot.logger.error( + 'main', + 'SEARCH-MANAGER', + `Parallel failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-MANAGER', `Parallel stack: ${error.stack}`) + } + throw error + } finally { + if (!mobileContextClosed && mobileSession) { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Cleanup: closing mobile session') + this.bot.logger.debug('main', 'SEARCH-MANAGER', `Cleanup mobile | account=${accountEmail}`) + try { + await executionContext.run({ isMobile: true, accountEmail }, async () => { + await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail) + }) + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Cleanup: mobile session closed') + } catch (error) { + this.bot.logger.warn( + 'main', + 'SEARCH-MANAGER', + `Cleanup: mobile close failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-MANAGER', `Cleanup mobile stack: ${error.stack}`) + } + } + } + } + } + + private async doSequentialSearches( + data: DashboardData, + missingSearchPoints: MissingSearchPoints, + mobileSession: BrowserSession, + account: Account, + accountEmail: string, + executionContext: any + ): Promise { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Sequential start') + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Sequential config | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}` + ) + + const shouldDoMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0 + const shouldDoDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0 + + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Sequential flags | mobile=${shouldDoMobile} | desktop=${shouldDoDesktop}` + ) + + let mobilePoints = 0 + let desktopPoints = 0 + + if (shouldDoMobile) { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Step 1: mobile') + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Sequential mobile | target=${missingSearchPoints.mobilePoints}` + ) + mobilePoints = await this.doMobileSearch( + data, + missingSearchPoints, + mobileSession, + accountEmail, + executionContext + ) + this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 1: mobile done | earned=${mobilePoints}`) + } else { + const reason = !this.bot.config.workers.doMobileSearch ? 'disabled' : 'no-points' + this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 1: skip mobile (${reason}); closing mobile session`) + this.bot.logger.debug('main', 'SEARCH-MANAGER', 'Closing unused mobile context') + try { + await executionContext.run({ isMobile: true, accountEmail }, async () => { + await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail) + }) + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Unused mobile session closed') + } catch (error) { + this.bot.logger.warn( + 'main', + 'SEARCH-MANAGER', + `Unused mobile close failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-MANAGER', `Unused mobile stack: ${error.stack}`) + } + } + } + + if (shouldDoDesktop) { + this.bot.logger.info('main', 'SEARCH-MANAGER', 'Step 2: desktop') + this.bot.logger.debug( + 'main', + 'SEARCH-MANAGER', + `Sequential desktop | target=${missingSearchPoints.desktopPoints}` + ) + desktopPoints = await this.doDesktopSearchSequential( + data, + missingSearchPoints, + account, + accountEmail, + executionContext + ) + this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 2: desktop done | earned=${desktopPoints}`) + } else { + const reason = !this.bot.config.workers.doDesktopSearch ? 'disabled' : 'no-points' + this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 2: skip desktop (${reason})`) + } + + this.bot.logger.info( + 'main', + 'SEARCH-MANAGER', + `Sequential summary | mobile=${mobilePoints} | desktop=${desktopPoints} | total=${ + mobilePoints + desktopPoints + }` + ) + this.bot.logger.debug('main', 'SEARCH-MANAGER', `Sequential done | account=${accountEmail}`) + + return { mobilePoints, desktopPoints } + } + + private async createDesktopSession(account: Account, accountEmail: string): Promise { + this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Init desktop session') + this.bot.logger.debug( + 'main', + 'SEARCH-DESKTOP-LOGIN', + `Init | account=${accountEmail} | proxy=${account.proxy ?? 'none'}` + ) + + const session = await this.bot['browserFactory'].createBrowser(account.proxy, accountEmail) + this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Browser created, new page') + + this.bot.mainDesktopPage = await session.context.newPage() + + this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', `Browser ready | account=${accountEmail}`) + this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login start') + this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Calling login handler') + + await this.bot['login'].login(this.bot.mainDesktopPage, accountEmail, account.password, account.totp) + + this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login passed, verifying') + this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'verifyBingSession') + + await this.bot['login'].verifyBingSession(this.bot.mainDesktopPage) + this.bot.cookies.desktop = await session.context.cookies() + + this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Cookies stored') + this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Desktop session ready') + + return session + } + + private async doMobileSearch( + data: DashboardData, + missingSearchPoints: MissingSearchPoints, + mobileSession: BrowserSession, + accountEmail: string, + executionContext: any + ): Promise { + this.bot.logger.debug( + 'main', + 'SEARCH-MOBILE-SEARCH', + `Start | account=${accountEmail} | target=${missingSearchPoints.mobilePoints}` + ) + + return await executionContext.run({ isMobile: true, accountEmail }, async () => { + try { + if (!this.bot.config.workers.doMobileSearch) { + this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Skip: worker disabled in config') + return 0 + } + + if (missingSearchPoints.mobilePoints === 0) { + this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Skip: no points left') + return 0 + } + + this.bot.logger.info( + 'main', + 'SEARCH-MOBILE-SEARCH', + `Search start | target=${missingSearchPoints.mobilePoints}` + ) + this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', 'activities.doSearch (mobile)') + + const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainMobilePage, true) + + this.bot.logger.info( + 'main', + 'SEARCH-MOBILE-SEARCH', + `Search done | earned=${pointsEarned}/${missingSearchPoints.mobilePoints}` + ) + this.bot.logger.debug( + 'main', + 'SEARCH-MOBILE-SEARCH', + `Result | account=${accountEmail} | earned=${pointsEarned}` + ) + + return pointsEarned + } catch (error) { + this.bot.logger.error( + 'main', + 'SEARCH-MOBILE-SEARCH', + `Failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Stack: ${error.stack}`) + } + return 0 + } finally { + this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Closing mobile session') + this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Closing context | account=${accountEmail}`) + try { + await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail) + this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Mobile browser closed') + } catch (error) { + this.bot.logger.warn( + 'main', + 'SEARCH-MOBILE-SEARCH', + `Close failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Close stack: ${error.stack}`) + } + } + } + }) + } + + private async doDesktopSearch( + data: DashboardData, + missingSearchPoints: MissingSearchPoints, + desktopSession: BrowserSession, + accountEmail: string, + executionContext: any + ): Promise { + this.bot.logger.debug( + 'main', + 'SEARCH-DESKTOP-PARALLEL', + `Start | account=${accountEmail} | target=${missingSearchPoints.desktopPoints}` + ) + + return await executionContext.run({ isMobile: false, accountEmail }, async () => { + try { + this.bot.logger.info( + 'main', + 'SEARCH-DESKTOP-PARALLEL', + `Search start | target=${missingSearchPoints.desktopPoints}` + ) + const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainDesktopPage, false) + + this.bot.logger.info( + 'main', + 'SEARCH-DESKTOP-PARALLEL', + `Search done | earned=${pointsEarned}/${missingSearchPoints.desktopPoints}` + ) + this.bot.logger.debug( + 'main', + 'SEARCH-DESKTOP-PARALLEL', + `Result | account=${accountEmail} | earned=${pointsEarned}` + ) + + return pointsEarned + } catch (error) { + this.bot.logger.error( + 'main', + 'SEARCH-DESKTOP-PARALLEL', + `Failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Stack: ${error.stack}`) + } + return 0 + } finally { + this.bot.logger.info('main', 'SEARCH-DESKTOP-PARALLEL', 'Closing desktop session') + this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Closing context | account=${accountEmail}`) + try { + await this.bot.browser.func.closeBrowser(desktopSession.context, accountEmail) + this.bot.logger.info('main', 'SEARCH-DESKTOP-PARALLEL', 'Desktop browser closed') + } catch (error) { + this.bot.logger.warn( + 'main', + 'SEARCH-DESKTOP-PARALLEL', + `Close failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Close stack: ${error.stack}`) + } + } + } + }) + } + + private async doDesktopSearchSequential( + data: DashboardData, + missingSearchPoints: MissingSearchPoints, + account: Account, + accountEmail: string, + executionContext: any + ): Promise { + this.bot.logger.debug( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Start | account=${accountEmail} | target=${missingSearchPoints.desktopPoints}` + ) + + return await executionContext.run({ isMobile: false, accountEmail }, async () => { + if (!this.bot.config.workers.doDesktopSearch) { + this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Skip: worker disabled in config') + return 0 + } + + if (missingSearchPoints.desktopPoints === 0) { + this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Skip: no points left') + return 0 + } + + let desktopSession: BrowserSession | null = null + try { + this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Init desktop session') + desktopSession = await this.createDesktopSession(account, accountEmail) + + this.bot.logger.info( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Search start | target=${missingSearchPoints.desktopPoints}` + ) + + const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainDesktopPage, false) + + this.bot.logger.info( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Search done | earned=${pointsEarned}/${missingSearchPoints.desktopPoints}` + ) + this.bot.logger.debug( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Result | account=${accountEmail} | earned=${pointsEarned}` + ) + + return pointsEarned + } catch (error) { + this.bot.logger.error( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-DESKTOP-SEQUENTIAL', `Stack: ${error.stack}`) + } + return 0 + } finally { + if (desktopSession) { + this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Closing desktop session') + this.bot.logger.debug( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Closing context | account=${accountEmail}` + ) + try { + await this.bot.browser.func.closeBrowser(desktopSession.context, accountEmail) + this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Desktop browser closed') + } catch (error) { + this.bot.logger.warn( + 'main', + 'SEARCH-DESKTOP-SEQUENTIAL', + `Close failed: ${error instanceof Error ? error.message : String(error)}` + ) + if (error instanceof Error && error.stack) { + this.bot.logger.debug('main', 'SEARCH-DESKTOP-SEQUENTIAL', `Close stack: ${error.stack}`) + } + } + } + } + }) + } +} diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts new file mode 100644 index 0000000..48e79e8 --- /dev/null +++ b/src/functions/Workers.ts @@ -0,0 +1,199 @@ +import type { Page } from 'patchright' +import type { MicrosoftRewardsBot } from '../index' +import type { DashboardData, PunchCard, BasePromotion, FindClippyPromotion } from '../interface/DashboardData' +import type { AppDashboardData } from '../interface/AppDashBoardData' + +export class Workers { + public bot: MicrosoftRewardsBot + + constructor(bot: MicrosoftRewardsBot) { + this.bot = bot + } + + public async doDailySet(data: DashboardData, page: Page) { + const todayKey = this.bot.utils.getFormattedDate() + const todayData = data.dailySetPromotions[todayKey] + + const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [] + + if (!activitiesUncompleted.length) { + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed') + return + } + + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items') + + await this.solveActivities(activitiesUncompleted, page) + + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed') + } + + public async doMorePromotions(data: DashboardData, page: Page) { + const morePromotions: BasePromotion[] = [ + ...new Map( + [...(data.morePromotions ?? []), ...(data.morePromotionsWithoutPromotionalItems ?? [])] + .filter(Boolean) + .map(p => [p.offerId, p as BasePromotion] as const) + ).values() + ] + + const activitiesUncompleted: BasePromotion[] = + morePromotions?.filter( + x => + !x.complete && + x.pointProgressMax > 0 && + x.exclusiveLockedFeatureStatus !== 'locked' && + x.promotionType + ) ?? [] + + if (!activitiesUncompleted.length) { + this.bot.logger.info( + this.bot.isMobile, + 'MORE-PROMOTIONS', + 'All "More Promotion" items have already been completed' + ) + return + } + + this.bot.logger.info( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Started solving ${activitiesUncompleted.length} "More Promotions" items` + ) + + await this.solveActivities(activitiesUncompleted, page) + + this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed') + } + + public async doAppPromotions(data: AppDashboardData) { + const appRewards = data.response.promotions.filter( + x => + x.attributes['complete']?.toLowerCase() === 'false' && + x.attributes['offerid'] && + x.attributes['type'] && + x.attributes['type'] === 'sapphire' + ) + + if (!appRewards.length) { + this.bot.logger.info( + this.bot.isMobile, + 'APP-PROMOTIONS', + 'All "App Promotions" items have already been completed' + ) + return + } + + for (const reward of appRewards) { + await this.bot.activities.doAppReward(reward) + // A delay between completing each activity + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) + } + + this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'All "App Promotions" items have been completed') + } + + private async solveActivities(activities: BasePromotion[], page: Page, punchCard?: PunchCard) { + for (const activity of activities) { + try { + const type = activity.promotionType?.toLowerCase() ?? '' + const name = activity.name?.toLowerCase() ?? '' + const offerId = (activity as BasePromotion).offerId + const destinationUrl = activity.destinationUrl?.toLowerCase() ?? '' + + this.bot.logger.debug( + this.bot.isMobile, + 'ACTIVITY', + `Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type} | punchCard="${punchCard?.parentPromotion?.title ?? 'none'}"` + ) + + switch (type) { + // Quiz-like activities (Poll / regular quiz variants) + case 'quiz': { + const basePromotion = activity as BasePromotion + + // Poll (usually 10 points, pollscenarioid in URL) + if (activity.pointProgressMax === 10 && destinationUrl.includes('pollscenarioid')) { + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Found activity type "Poll" | title="${activity.title}" | offerId=${offerId}` + ) + + //await this.bot.activities.doPoll(basePromotion) + break + } + + // All other quizzes handled via Quiz API + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Found activity type "Quiz" | title="${activity.title}" | offerId=${offerId}` + ) + + await this.bot.activities.doQuiz(basePromotion) + break + } + + // UrlReward + case 'urlreward': { + const basePromotion = activity as BasePromotion + + // Search on Bing are subtypes of "urlreward" + if (name.includes('exploreonbing')) { + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Found activity type "SearchOnBing" | title="${activity.title}" | offerId=${offerId}` + ) + + await this.bot.activities.doSearchOnBing(basePromotion, page) + } else { + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Found activity type "UrlReward" | title="${activity.title}" | offerId=${offerId}` + ) + + await this.bot.activities.doUrlReward(basePromotion) + } + break + } + + // Find Clippy specific promotion type + case 'findclippy': { + const clippyPromotion = activity as unknown as FindClippyPromotion + + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Found activity type "FindClippy" | title="${activity.title}" | offerId=${offerId}` + ) + + await this.bot.activities.doFindClippy(clippyPromotion) + break + } + + // Unsupported types + default: { + this.bot.logger.warn( + this.bot.isMobile, + 'ACTIVITY', + `Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"` + ) + break + } + } + + // Cooldown + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'ACTIVITY', + `Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } + } +} diff --git a/src/functions/activities/api/FindClippy.ts b/src/functions/activities/api/FindClippy.ts new file mode 100644 index 0000000..1e46ae6 --- /dev/null +++ b/src/functions/activities/api/FindClippy.ts @@ -0,0 +1,130 @@ +import type { AxiosRequestConfig } from 'axios' +import type { FindClippyPromotion } from '../../../interface/DashboardData' +import { Workers } from '../../Workers' + +export class FindClippy extends Workers { + private cookieHeader: string = '' + + private fingerprintHeader: { [x: string]: string } = {} + + private gainedPoints: number = 0 + + private oldBalance: number = this.bot.userData.currentPoints + + public async doFindClippy(promotion: FindClippyPromotion) { + const offerId = promotion.offerId + const activityType = promotion.activityType + + try { + if (!this.bot.requestToken) { + this.bot.logger.warn( + this.bot.isMobile, + 'FIND-CLIPPY', + 'Skipping: Request token not available, this activity requires it!' + ) + return + } + + this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) + .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) + .join('; ') + + const fingerprintHeaders = { ...this.bot.fingerprint.headers } + delete fingerprintHeaders['Cookie'] + delete fingerprintHeaders['cookie'] + this.fingerprintHeader = fingerprintHeaders + + this.bot.logger.info( + this.bot.isMobile, + 'FIND-CLIPPY', + `Starting Find Clippy | offerId=${offerId} | activityType=${activityType} | oldBalance=${this.oldBalance}` + ) + + this.bot.logger.debug( + this.bot.isMobile, + 'FIND-CLIPPY', + `Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` + ) + + const formData = new URLSearchParams({ + id: offerId, + hash: promotion.hash, + timeZone: '60', + activityAmount: '1', + dbs: '0', + form: '', + type: activityType, + __RequestVerificationToken: this.bot.requestToken + }) + + this.bot.logger.debug( + this.bot.isMobile, + 'FIND-CLIPPY', + `Prepared Find Clippy form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}` + ) + + const request: AxiosRequestConfig = { + url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + method: 'POST', + headers: { + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: this.cookieHeader, + Referer: 'https://rewards.bing.com/', + Origin: 'https://rewards.bing.com' + }, + data: formData + } + + this.bot.logger.debug( + this.bot.isMobile, + 'FIND-CLIPPY', + `Sending Find Clippy request | offerId=${offerId} | url=${request.url}` + ) + + const response = await this.bot.axios.request(request) + + this.bot.logger.debug( + this.bot.isMobile, + 'FIND-CLIPPY', + `Received Find Clippy response | offerId=${offerId} | status=${response.status}` + ) + + const newBalance = await this.bot.browser.func.getCurrentPoints() + this.gainedPoints = newBalance - this.oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'FIND-CLIPPY', + `Balance delta after Find Clippy | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` + ) + + if (this.gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'FIND-CLIPPY', + `Found Clippy | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`, + 'green' + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'FIND-CLIPPY', + `Found Clippy but no points were gained | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}` + ) + } + + this.bot.logger.debug(this.bot.isMobile, 'FIND-CLIPPY', `Waiting after Find Clippy | offerId=${offerId}`) + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'FIND-CLIPPY', + `Error in doFindClippy | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/api/Quiz.ts b/src/functions/activities/api/Quiz.ts new file mode 100644 index 0000000..70394ed --- /dev/null +++ b/src/functions/activities/api/Quiz.ts @@ -0,0 +1,173 @@ +import type { AxiosRequestConfig } from 'axios' +import type { BasePromotion } from '../../../interface/DashboardData' +import { Workers } from '../../Workers' + +export class Quiz extends Workers { + private cookieHeader: string = '' + + private fingerprintHeader: { [x: string]: string } = {} + + private gainedPoints: number = 0 + + private oldBalance: number = this.bot.userData.currentPoints + + async doQuiz(promotion: BasePromotion) { + const offerId = promotion.offerId + this.oldBalance = Number(this.bot.userData.currentPoints ?? 0) + const startBalance = this.oldBalance + + this.bot.logger.info( + this.bot.isMobile, + 'QUIZ', + `Starting quiz | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax} | currentPoints=${startBalance}` + ) + + try { + this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) + .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) + .join('; ') + + const fingerprintHeaders = { ...this.bot.fingerprint.headers } + delete fingerprintHeaders['Cookie'] + delete fingerprintHeaders['cookie'] + this.fingerprintHeader = fingerprintHeaders + + this.bot.logger.debug( + this.bot.isMobile, + 'QUIZ', + `Prepared quiz headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` + ) + + // 8-question quiz + if (promotion.activityProgressMax === 80) { + this.bot.logger.warn( + this.bot.isMobile, + 'QUIZ', + `Detected 8-question quiz (activityProgressMax=80), marking as completed | offerId=${offerId}` + ) + + // Not implemented + return + } + + //Standard points quizzes (20/30/40/50 max) + if ([20, 30, 40, 50].includes(promotion.pointProgressMax)) { + let oldBalance = startBalance + let gainedPoints = 0 + const maxAttempts = 20 + let totalGained = 0 + let attempts = 0 + + this.bot.logger.debug( + this.bot.isMobile, + 'QUIZ', + `Starting ReportActivity loop | offerId=${offerId} | maxAttempts=${maxAttempts} | startingBalance=${oldBalance}` + ) + + for (let i = 0; i < maxAttempts; i++) { + try { + const jsonData = { + UserId: null, + TimeZoneOffset: -60, + OfferId: offerId, + ActivityCount: 1, + QuestionIndex: '-1' + } + + const request: AxiosRequestConfig = { + url: 'https://www.bing.com/bingqa/ReportActivity?ajaxreq=1', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + cookie: this.cookieHeader, + ...this.fingerprintHeader + }, + data: JSON.stringify(jsonData) + } + + this.bot.logger.debug( + this.bot.isMobile, + 'QUIZ', + `Sending ReportActivity request | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | url=${request.url}` + ) + + const response = await this.bot.axios.request(request) + + this.bot.logger.debug( + this.bot.isMobile, + 'QUIZ', + `Received ReportActivity response | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | status=${response.status}` + ) + + const newBalance = await this.bot.browser.func.getCurrentPoints() + gainedPoints = newBalance - oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'QUIZ', + `Balance delta after ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}` + ) + + attempts = i + 1 + + if (gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints + + oldBalance = newBalance + totalGained += gainedPoints + this.gainedPoints += gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'QUIZ', + `ReportActivity ${i + 1} → ${response.status} | offerId=${offerId} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`, + 'green' + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'QUIZ', + `ReportActivity ${i + 1} | offerId=${offerId} | no more points gained, ending quiz | lastBalance=${newBalance}` + ) + break + } + + this.bot.logger.debug( + this.bot.isMobile, + 'QUIZ', + `Waiting between ReportActivity attempts | attempt=${i + 1}/${maxAttempts} | offerId=${offerId}` + ) + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000)) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'QUIZ', + `Error during ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + break + } + } + + this.bot.logger.info( + this.bot.isMobile, + 'QUIZ', + `Completed the quiz successfully | offerId=${offerId} | attempts=${attempts} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${this.bot.userData.currentPoints}` + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'QUIZ', + `Unsupported quiz configuration | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax}` + ) + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'QUIZ', + `Error in doQuiz | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/api/UrlReward.ts b/src/functions/activities/api/UrlReward.ts new file mode 100644 index 0000000..abb1a82 --- /dev/null +++ b/src/functions/activities/api/UrlReward.ts @@ -0,0 +1,129 @@ +import type { AxiosRequestConfig } from 'axios' +import type { BasePromotion } from '../../../interface/DashboardData' +import { Workers } from '../../Workers' + +export class UrlReward extends Workers { + private cookieHeader: string = '' + + private fingerprintHeader: { [x: string]: string } = {} + + private gainedPoints: number = 0 + + private oldBalance: number = this.bot.userData.currentPoints + + public async doUrlReward(promotion: BasePromotion) { + if (!this.bot.requestToken) { + this.bot.logger.warn( + this.bot.isMobile, + 'URL-REWARD', + 'Skipping: Request token not available, this activity requires it!' + ) + return + } + + const offerId = promotion.offerId + + this.bot.logger.info( + this.bot.isMobile, + 'URL-REWARD', + `Starting UrlReward | offerId=${offerId} | geo=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}` + ) + + try { + this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) + .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) + .join('; ') + + const fingerprintHeaders = { ...this.bot.fingerprint.headers } + delete fingerprintHeaders['Cookie'] + delete fingerprintHeaders['cookie'] + this.fingerprintHeader = fingerprintHeaders + + this.bot.logger.debug( + this.bot.isMobile, + 'URL-REWARD', + `Prepared UrlReward headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` + ) + + const formData = new URLSearchParams({ + id: offerId, + hash: promotion.hash, + timeZone: '60', + activityAmount: '1', + dbs: '0', + form: '', + type: '', + __RequestVerificationToken: this.bot.requestToken + }) + + this.bot.logger.debug( + this.bot.isMobile, + 'URL-REWARD', + `Prepared UrlReward form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1` + ) + + const request: AxiosRequestConfig = { + url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + method: 'POST', + headers: { + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: this.cookieHeader, + Referer: 'https://rewards.bing.com/', + Origin: 'https://rewards.bing.com' + }, + data: formData + } + + this.bot.logger.debug( + this.bot.isMobile, + 'URL-REWARD', + `Sending UrlReward request | offerId=${offerId} | url=${request.url}` + ) + + const response = await this.bot.axios.request(request) + + this.bot.logger.debug( + this.bot.isMobile, + 'URL-REWARD', + `Received UrlReward response | offerId=${offerId} | status=${response.status}` + ) + + const newBalance = await this.bot.browser.func.getCurrentPoints() + this.gainedPoints = newBalance - this.oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'URL-REWARD', + `Balance delta after UrlReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` + ) + + if (this.gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'URL-REWARD', + `Completed UrlReward | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`, + 'green' + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'URL-REWARD', + `Failed UrlReward with no points | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}` + ) + } + + this.bot.logger.debug(this.bot.isMobile, 'URL-REWARD', `Waiting after UrlReward | offerId=${offerId}`) + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'URL-REWARD', + `Error in doUrlReward | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/app/AppReward.ts b/src/functions/activities/app/AppReward.ts new file mode 100644 index 0000000..5422eea --- /dev/null +++ b/src/functions/activities/app/AppReward.ts @@ -0,0 +1,119 @@ +import type { AxiosRequestConfig } from 'axios' +import { randomUUID } from 'crypto' +import type { Promotion } from '../../../interface/AppDashBoardData' +import { Workers } from '../../Workers' + +export class AppReward extends Workers { + private gainedPoints: number = 0 + + private oldBalance: number = this.bot.userData.currentPoints + + public async doAppReward(promotion: Promotion) { + if (!this.bot.accessToken) { + this.bot.logger.warn( + this.bot.isMobile, + 'APP-REWARD', + 'Skipping: App access token not available, this activity requires it!' + ) + return + } + + const offerId = promotion.attributes['offerid'] + + this.bot.logger.info( + this.bot.isMobile, + 'APP-REWARD', + `Starting AppReward | offerId=${offerId} | country=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}` + ) + + try { + const jsonData = { + id: randomUUID(), + amount: 1, + type: 101, + attributes: { + offerid: offerId + }, + country: this.bot.userData.geoLocale + } + + this.bot.logger.debug( + this.bot.isMobile, + 'APP-REWARD', + `Prepared activity payload | offerId=${offerId} | id=${jsonData.id} | amount=${jsonData.amount} | type=${jsonData.type} | country=${jsonData.country}` + ) + + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', + method: 'POST', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', + 'Content-Type': 'application/json', + 'X-Rewards-Country': this.bot.userData.geoLocale, + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' + }, + data: JSON.stringify(jsonData) + } + + this.bot.logger.debug( + this.bot.isMobile, + 'APP-REWARD', + `Sending activity request | offerId=${offerId} | url=${request.url}` + ) + + const response = await this.bot.axios.request(request) + + this.bot.logger.debug( + this.bot.isMobile, + 'APP-REWARD', + `Received activity response | offerId=${offerId} | status=${response.status}` + ) + + const newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) + this.gainedPoints = newBalance - this.oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'APP-REWARD', + `Balance delta after AppReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` + ) + + if (this.gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'APP-REWARD', + `Completed AppReward | offerId=${offerId} | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, + 'green' + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'APP-REWARD', + `Completed AppReward with no points | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance}` + ) + } + + this.bot.logger.debug(this.bot.isMobile, 'APP-REWARD', `Waiting after AppReward | offerId=${offerId}`) + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) + + this.bot.logger.info( + this.bot.isMobile, + 'APP-REWARD', + `Finished AppReward | offerId=${offerId} | finalBalance=${this.bot.userData.currentPoints}` + ) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'APP-REWARD', + `Error in doAppReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/app/DailyCheckIn.ts b/src/functions/activities/app/DailyCheckIn.ts new file mode 100644 index 0000000..9f88c9d --- /dev/null +++ b/src/functions/activities/app/DailyCheckIn.ts @@ -0,0 +1,161 @@ +import type { AxiosRequestConfig } from 'axios' +import { randomUUID } from 'crypto' +import { Workers } from '../../Workers' + +export class DailyCheckIn extends Workers { + private gainedPoints: number = 0 + + private oldBalance: number = this.bot.userData.currentPoints + + public async doDailyCheckIn() { + if (!this.bot.accessToken) { + this.bot.logger.warn( + this.bot.isMobile, + 'DAILY-CHECK-IN', + 'Skipping: App access token not available, this activity requires it!' + ) + return + } + + this.oldBalance = Number(this.bot.userData.currentPoints ?? 0) + + this.bot.logger.info( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Starting Daily Check-In | geo=${this.bot.userData.geoLocale} | currentPoints=${this.oldBalance}` + ) + + try { + // Try type 101 first + this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=101') + + let response = await this.submitDaily(101) // Try using 101 (EU Variant?) + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Received Daily Check-In response | type=101 | status=${response?.status ?? 'unknown'}` + ) + + let newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) + this.gainedPoints = newBalance - this.oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Balance delta after Daily Check-In | type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` + ) + + if (this.gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Completed Daily Check-In | type=101 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, + 'green' + ) + return + } + + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `No points gained with type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | retryingWithType=103` + ) + + // Fallback to type 103 + this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=103') + + response = await this.submitDaily(103) // Try using 103 (USA Variant?) + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Received Daily Check-In response | type=103 | status=${response?.status ?? 'unknown'}` + ) + + newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) + this.gainedPoints = newBalance - this.oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Balance delta after Daily Check-In | type=103 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` + ) + + if (this.gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Completed Daily Check-In | type=103 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, + 'green' + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Daily Check-In completed but no points gained | typesTried=101,103 | oldBalance=${this.oldBalance} | finalBalance=${newBalance}` + ) + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Error during Daily Check-In | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async submitDaily(type: number) { + try { + const jsonData = { + id: randomUUID(), + amount: 1, + type: type, + attributes: { + offerid: 'Gamification_Sapphire_DailyCheckIn' + }, + country: this.bot.userData.geoLocale + } + + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Preparing Daily Check-In payload | type=${type} | id=${jsonData.id} | amount=${jsonData.amount} | country=${jsonData.country}` + ) + + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', + method: 'POST', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', + 'Content-Type': 'application/json', + 'X-Rewards-Country': this.bot.userData.geoLocale, + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' + }, + data: JSON.stringify(jsonData) + } + + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Sending Daily Check-In request | type=${type} | url=${request.url}` + ) + + return this.bot.axios.request(request) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'DAILY-CHECK-IN', + `Error in submitDaily | type=${type} | message=${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } +} diff --git a/src/functions/activities/app/ReadToEarn.ts b/src/functions/activities/app/ReadToEarn.ts new file mode 100644 index 0000000..20f88cb --- /dev/null +++ b/src/functions/activities/app/ReadToEarn.ts @@ -0,0 +1,131 @@ +import type { AxiosRequestConfig } from 'axios' +import { randomBytes } from 'crypto' +import { Workers } from '../../Workers' + +export class ReadToEarn extends Workers { + public async doReadToEarn() { + if (!this.bot.accessToken) { + this.bot.logger.warn( + this.bot.isMobile, + 'READ-TO-EARN', + 'Skipping: App access token not available, this activity requires it!' + ) + return + } + + const delayMin = this.bot.config.searchSettings.readDelay.min + const delayMax = this.bot.config.searchSettings.readDelay.max + const startBalance = Number(this.bot.userData.currentPoints ?? 0) + + this.bot.logger.info( + this.bot.isMobile, + 'READ-TO-EARN', + `Starting Read to Earn | geo=${this.bot.userData.geoLocale} | delayRange=${delayMin}-${delayMax} | currentPoints=${startBalance}` + ) + + try { + const jsonData = { + amount: 1, + id: '1', + type: 101, + attributes: { + offerid: 'ENUS_readarticle3_30points' + }, + country: this.bot.userData.geoLocale + } + + const articleCount = 10 + let totalGained = 0 + let articlesRead = 0 + let oldBalance = startBalance + + for (let i = 0; i < articleCount; ++i) { + jsonData.id = randomBytes(64).toString('hex') + + this.bot.logger.debug( + this.bot.isMobile, + 'READ-TO-EARN', + `Submitting Read to Earn activity | article=${i + 1}/${articleCount} | id=${jsonData.id} | country=${jsonData.country}` + ) + + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', + method: 'POST', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', + 'Content-Type': 'application/json', + 'X-Rewards-Country': this.bot.userData.geoLocale, + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' + }, + data: JSON.stringify(jsonData) + } + + const response = await this.bot.axios.request(request) + + this.bot.logger.debug( + this.bot.isMobile, + 'READ-TO-EARN', + `Received Read to Earn response | article=${i + 1}/${articleCount} | status=${response?.status ?? 'unknown'}` + ) + + const newBalance = Number(response?.data?.response?.balance ?? oldBalance) + const gainedPoints = newBalance - oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'READ-TO-EARN', + `Balance delta after article | article=${i + 1}/${articleCount} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}` + ) + + if (gainedPoints <= 0) { + this.bot.logger.info( + this.bot.isMobile, + 'READ-TO-EARN', + `No points gained, stopping Read to Earn | article=${i + 1}/${articleCount} | status=${response.status} | oldBalance=${oldBalance} | newBalance=${newBalance}` + ) + break + } + + // Update point tracking + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints + totalGained += gainedPoints + articlesRead = i + 1 + oldBalance = newBalance + + this.bot.logger.info( + this.bot.isMobile, + 'READ-TO-EARN', + `Read article ${i + 1}/${articleCount} | status=${response.status} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`, + 'green' + ) + + // Wait random delay between articles + this.bot.logger.debug( + this.bot.isMobile, + 'READ-TO-EARN', + `Waiting between articles | article=${i + 1}/${articleCount} | delayRange=${delayMin}-${delayMax}` + ) + + await this.bot.utils.wait(this.bot.utils.randomDelay(delayMin, delayMax)) + } + + const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance) + + this.bot.logger.info( + this.bot.isMobile, + 'READ-TO-EARN', + `Completed Read to Earn | articlesRead=${articlesRead} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${finalBalance}` + ) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'READ-TO-EARN', + `Error during Read to Earn | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/browser/Search.ts b/src/functions/activities/browser/Search.ts new file mode 100644 index 0000000..6e3cc6e --- /dev/null +++ b/src/functions/activities/browser/Search.ts @@ -0,0 +1,426 @@ +import type { Page } from 'patchright' +import type { Counters, DashboardData } from '../../../interface/DashboardData' + +import { QueryCore } from '../../QueryEngine' +import { Workers } from '../../Workers' + +export class Search extends Workers { + private bingHome = 'https://bing.com' + private searchPageURL = '' + private searchCount = 0 + + public async doSearch(data: DashboardData, page: Page, isMobile: boolean): Promise { + const startBalance = Number(this.bot.userData.currentPoints ?? 0) + + this.bot.logger.info(isMobile, 'SEARCH-BING', `Starting Bing searches | currentPoints=${startBalance}`) + + let totalGainedPoints = 0 + + try { + let searchCounters: Counters = await this.bot.browser.func.getSearchPoints() + const missingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile) + let missingPointsTotal = missingPoints.totalPoints + + this.bot.logger.debug( + isMobile, + 'SEARCH-BING', + `Initial search counters | mobile=${missingPoints.mobilePoints} | desktop=${missingPoints.desktopPoints} | edge=${missingPoints.edgePoints}` + ) + + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `Search points remaining | Edge=${missingPoints.edgePoints} | Desktop=${missingPoints.desktopPoints} | Mobile=${missingPoints.mobilePoints}` + ) + + let queries: string[] = [] + + const queryCore = new QueryCore(this.bot) + + const locale = this.bot.userData.geoLocale.toUpperCase() + + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Resolving search queries | locale=${locale}`) + + // Set Google search queries + queries = await queryCore.getGoogleTrends(locale) + + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Fetched base queries | count=${queries.length}`) + + // Deduplicate queries + queries = [...new Set(queries)] + + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Deduplicated queries | count=${queries.length}`) + + // Shuffle + queries = this.bot.utils.shuffleArray(queries) + + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Shuffled queries | count=${queries.length}`) + + // Go to bing + const targetUrl = this.searchPageURL ? this.searchPageURL : this.bingHome + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Navigating to search page | url=${targetUrl}`) + + await page.goto(targetUrl) + + // Wait until page loaded + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}) + + await this.bot.browser.utils.tryDismissAllMessages(page) + + let stagnantLoop = 0 + const stagnantLoopMax = 10 + + for (let i = 0; i < queries.length; i++) { + const query = queries[i] as string + + searchCounters = await this.bingSearch(page, query, isMobile) + const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile) + const newMissingPointsTotal = newMissingPoints.totalPoints + + // Points gained for THIS query only + const rawGained = missingPointsTotal - newMissingPointsTotal + const gainedPoints = Math.max(0, rawGained) + + if (gainedPoints === 0) { + stagnantLoop++ + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `No points gained ${stagnantLoop}/${stagnantLoopMax} | query="${query}" | remaining=${newMissingPointsTotal}` + ) + } else { + stagnantLoop = 0 + + // Update global user data + const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints + + // Track for return value + totalGainedPoints += gainedPoints + + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `gainedPoints=${gainedPoints} points | query="${query}" | remaining=${newMissingPointsTotal}`, + 'green' + ) + } + + // Update loop state + missingPointsTotal = newMissingPointsTotal + + // Completed + if (missingPointsTotal === 0) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + 'All required search points earned, stopping main search loop' + ) + break + } + + // Stuck + if (stagnantLoop > stagnantLoopMax) { + this.bot.logger.warn( + isMobile, + 'SEARCH-BING', + `Search did not gain points for ${stagnantLoopMax} iterations, aborting main search loop` + ) + stagnantLoop = 0 + break + } + } + + if (missingPointsTotal > 0) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `Search completed but still missing points, generating extra searches | remaining=${missingPointsTotal}` + ) + + let i = 0 + let stagnantLoop = 0 + const stagnantLoopMax = 5 + + while (missingPointsTotal > 0) { + const query = queries[i++] as string + + this.bot.logger.debug( + isMobile, + 'SEARCH-BING-EXTRA', + `Fetching related terms for extra searches | baseQuery="${query}"` + ) + + const relatedTerms = await queryCore.getBingRelatedTerms(query) + this.bot.logger.debug( + isMobile, + 'SEARCH-BING-EXTRA', + `Related terms resolved | baseQuery="${query}" | count=${relatedTerms.length}` + ) + + if (relatedTerms.length > 3) { + for (const term of relatedTerms.slice(1, 3)) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + `Extra search | remaining=${missingPointsTotal} | query="${term}"` + ) + + searchCounters = await this.bingSearch(page, term, isMobile) + const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile) + const newMissingPointsTotal = newMissingPoints.totalPoints + + // Points gained for THIS extra query only + const rawGained = missingPointsTotal - newMissingPointsTotal + const gainedPoints = Math.max(0, rawGained) + + if (gainedPoints === 0) { + stagnantLoop++ + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + `No points gained for extra query ${stagnantLoop}/${stagnantLoopMax} | query="${term}" | remaining=${newMissingPointsTotal}` + ) + } else { + stagnantLoop = 0 + + // Update global user data + const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints + + // Track for return value + totalGainedPoints += gainedPoints + + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + `gainedPoints=${gainedPoints} points | query="${term}" | remaining=${newMissingPointsTotal}`, + 'green' + ) + } + + // Update loop state + missingPointsTotal = newMissingPointsTotal + + // Completed + if (missingPointsTotal === 0) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + 'All required search points earned during extra searches' + ) + break + } + + // Stuck again + if (stagnantLoop > stagnantLoopMax) { + this.bot.logger.warn( + isMobile, + 'SEARCH-BING-EXTRA', + `Search did not gain points for ${stagnantLoopMax} extra iterations, aborting extra searches` + ) + const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance) + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `Aborted extra searches | startBalance=${startBalance} | finalBalance=${finalBalance}` + ) + return totalGainedPoints + } + } + } + } + } + + const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance) + + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `Completed Bing searches | startBalance=${startBalance} | newBalance=${finalBalance}` + ) + + return totalGainedPoints + } catch (error) { + this.bot.logger.error( + isMobile, + 'SEARCH-BING', + `Error in doSearch | message=${error instanceof Error ? error.message : String(error)}` + ) + return totalGainedPoints + } + } + + private async bingSearch(searchPage: Page, query: string, isMobile: boolean) { + const maxAttempts = 5 + const refreshThreshold = 10 // Page gets sluggish after x searches? + + this.searchCount++ + + // Page fill seems to get more sluggish over time + if (this.searchCount % refreshThreshold === 0) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING', + `Returning to home page to clear accumulated page context | count=${this.searchCount} | threshold=${refreshThreshold}` + ) + + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Returning home to refresh state | url=${this.bingHome}`) + + await searchPage.goto(this.bingHome) + await searchPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}) + await this.bot.browser.utils.tryDismissAllMessages(searchPage) // Not always the case but possible for new cookie headers + } + + this.bot.logger.debug( + isMobile, + 'SEARCH-BING', + `Starting bingSearch | query="${query}" | maxAttempts=${maxAttempts} | searchCount=${this.searchCount} | refreshEvery=${refreshThreshold} | scrollRandomResults=${this.bot.config.searchSettings.scrollRandomResults} | clickRandomResults=${this.bot.config.searchSettings.clickRandomResults}` + ) + + for (let i = 0; i < maxAttempts; i++) { + try { + const searchBar = '#sb_form_q' + const searchBox = searchPage.locator(searchBar) + + await searchPage.evaluate(() => { + window.scrollTo({ left: 0, top: 0, behavior: 'auto' }) + }) + + await searchPage.keyboard.press('Home') + await searchBox.waitFor({ state: 'visible', timeout: 15000 }) + + await this.bot.utils.wait(1000) + await this.bot.browser.utils.ghostClick(searchPage, searchBar, { clickCount: 3 }) + await searchBox.fill('') + + await searchPage.keyboard.type(query, { delay: 50 }) + await searchPage.keyboard.press('Enter') + + this.bot.logger.debug( + isMobile, + 'SEARCH-BING', + `Submitted query to Bing | attempt=${i + 1}/${maxAttempts} | query="${query}"` + ) + + await this.bot.utils.wait(3000) + + if (this.bot.config.searchSettings.scrollRandomResults) { + await this.bot.utils.wait(2000) + await this.randomScroll(searchPage, isMobile) + } + + if (this.bot.config.searchSettings.clickRandomResults) { + await this.bot.utils.wait(2000) + await this.clickRandomLink(searchPage, isMobile) + } + + await this.bot.utils.wait( + this.bot.utils.randomDelay( + this.bot.config.searchSettings.searchDelay.min, + this.bot.config.searchSettings.searchDelay.max + ) + ) + + const counters = await this.bot.browser.func.getSearchPoints() + + this.bot.logger.debug( + isMobile, + 'SEARCH-BING', + `Search counters after query | attempt=${i + 1}/${maxAttempts} | query="${query}"` + ) + + return counters + } catch (error) { + if (i >= 5) { + this.bot.logger.error( + isMobile, + 'SEARCH-BING', + `Failed after 5 retries | query="${query}" | message=${error instanceof Error ? error.message : String(error)}` + ) + break + } + + this.bot.logger.error( + isMobile, + 'SEARCH-BING', + `Search attempt failed | attempt=${i + 1}/${maxAttempts} | query="${query}" | message=${error instanceof Error ? error.message : String(error)}` + ) + + this.bot.logger.warn( + isMobile, + 'SEARCH-BING', + `Retrying search | attempt=${i + 1}/${maxAttempts} | query="${query}"` + ) + + await this.bot.utils.wait(2000) + } + } + + this.bot.logger.debug( + isMobile, + 'SEARCH-BING', + `Returning current search counters after failed retries | query="${query}"` + ) + + return await this.bot.browser.func.getSearchPoints() + } + + private async randomScroll(page: Page, isMobile: boolean) { + try { + const viewportHeight = await page.evaluate(() => window.innerHeight) + const totalHeight = await page.evaluate(() => document.body.scrollHeight) + const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight)) + + this.bot.logger.debug( + isMobile, + 'SEARCH-RANDOM-SCROLL', + `Random scroll | viewportHeight=${viewportHeight} | totalHeight=${totalHeight} | scrollPos=${randomScrollPosition}` + ) + + await page.evaluate((scrollPos: number) => { + window.scrollTo({ left: 0, top: scrollPos, behavior: 'auto' }) + }, randomScrollPosition) + } catch (error) { + this.bot.logger.error( + isMobile, + 'SEARCH-RANDOM-SCROLL', + `An error occurred during random scroll | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async clickRandomLink(page: Page, isMobile: boolean) { + try { + this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Attempting to click a random search result link') + + const searchPageUrl = page.url() + + await this.bot.browser.utils.ghostClick(page, '#b_results .b_algo h2') + await this.bot.utils.wait(this.bot.config.searchSettings.searchResultVisitTime) + + if (isMobile) { + // Mobile + await page.goto(searchPageUrl) + this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Navigated back to search page') + } else { + // Desktop + const newTab = await this.bot.browser.utils.getLatestTab(page) + const newTabUrl = newTab.url() + + this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', `Visited result tab | url=${newTabUrl}`) + + await this.bot.browser.utils.closeTabs(newTab) + this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Closed result tab') + } + } catch (error) { + this.bot.logger.error( + isMobile, + 'SEARCH-RANDOM-CLICK', + `An error occurred during random click | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/browser/SearchOnBing.ts b/src/functions/activities/browser/SearchOnBing.ts new file mode 100644 index 0000000..c092738 --- /dev/null +++ b/src/functions/activities/browser/SearchOnBing.ts @@ -0,0 +1,330 @@ +import type { AxiosRequestConfig } from 'axios' +import type { Page } from 'patchright' +import * as fs from 'fs' +import path from 'path' + +import { Workers } from '../../Workers' +import { QueryCore } from '../../QueryEngine' + +import type { BasePromotion } from '../../../interface/DashboardData' + +export class SearchOnBing extends Workers { + private bingHome = 'https://bing.com' + + private cookieHeader: string = '' + + private fingerprintHeader: { [x: string]: string } = {} + + private gainedPoints: number = 0 + + private success: boolean = false + + private oldBalance: number = this.bot.userData.currentPoints + + public async doSearchOnBing(promotion: BasePromotion, page: Page) { + const offerId = promotion.offerId + this.oldBalance = Number(this.bot.userData.currentPoints ?? 0) + + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING', + `Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentPoints=${this.oldBalance}` + ) + + try { + this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop) + .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) + .join('; ') + + const fingerprintHeaders = { ...this.bot.fingerprint.headers } + delete fingerprintHeaders['Cookie'] + delete fingerprintHeaders['cookie'] + this.fingerprintHeader = fingerprintHeaders + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING', + `Prepared headers for SearchOnBing | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` + ) + + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING', `Activating search task | offerId=${offerId}`) + + const activated = await this.activateSearchTask(promotion) + if (!activated) { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-ON-BING', + `Search activity couldn't be activated, aborting | offerId=${offerId}` + ) + return + } + + // Do the bing search here + const queries = await this.getSearchQueries(promotion) + + // Run through the queries + await this.searchBing(page, queries) + + if (this.success) { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING', + `Completed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}` + ) + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-ON-BING', + `Failed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}` + ) + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-ON-BING', + `Error in doSearchOnBing | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async searchBing(page: Page, queries: string[]) { + queries = [...new Set(queries)] + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-SEARCH', + `Starting search loop | queriesCount=${queries.length} | oldBalance=${this.oldBalance}` + ) + + let i = 0 + for (const query of queries) { + try { + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Processing query | query="${query}"`) + + await this.bot.mainMobilePage.goto(this.bingHome) + + // Wait until page loaded + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}) + + await this.bot.browser.utils.tryDismissAllMessages(page) + + const searchBar = '#sb_form_q' + + const searchBox = page.locator(searchBar) + await searchBox.waitFor({ state: 'attached', timeout: 15000 }) + + await this.bot.utils.wait(500) + await this.bot.browser.utils.ghostClick(page, searchBar, { clickCount: 3 }) + await searchBox.fill('') + + await page.keyboard.type(query, { delay: 50 }) + await page.keyboard.press('Enter') + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000)) + + // Check for point updates + const newBalance = await this.bot.browser.func.getCurrentPoints() + this.gainedPoints = newBalance - this.oldBalance + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-SEARCH', + `Balance check after query | query="${query}" | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}` + ) + + if (this.gainedPoints > 0) { + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints + + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-SEARCH', + `SearchOnBing query completed | query="${query}" | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`, + 'green' + ) + + this.success = true + return + } else { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-ON-BING-SEARCH', + `${++i}/${queries.length} | noPoints=1 | query="${query}"` + ) + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-ON-BING-SEARCH', + `Error during search loop | query="${query}" | message=${error instanceof Error ? error.message : String(error)}` + ) + } finally { + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) + await page.goto(this.bot.config.baseURL, { timeout: 5000 }).catch(() => {}) + } + } + + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-ON-BING-SEARCH', + `Finished all queries with no points gained | queriesTried=${queries.length} | oldBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}` + ) + } + + // The task needs to be activated before being able to complete it + private async activateSearchTask(promotion: BasePromotion): Promise { + try { + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-ACTIVATE', + `Preparing activation request | offerId=${promotion.offerId} | hash=${promotion.hash}` + ) + + const formData = new URLSearchParams({ + id: promotion.offerId, + hash: promotion.hash, + timeZone: '60', + activityAmount: '1', + dbs: '0', + form: '', + type: '', + __RequestVerificationToken: this.bot.requestToken + }) + + const request: AxiosRequestConfig = { + url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + method: 'POST', + headers: { + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: this.cookieHeader, + Referer: 'https://rewards.bing.com/', + Origin: 'https://rewards.bing.com' + }, + data: formData + } + + const response = await this.bot.axios.request(request) + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-ACTIVATE', + `Successfully activated activity | status=${response.status} | offerId=${promotion.offerId}` + ) + return true + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-ON-BING-ACTIVATE', + `Activation failed | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + return false + } + } + + private async getSearchQueries(promotion: BasePromotion): Promise { + interface Queries { + title: string + queries: string[] + } + + let queries: Queries[] = [] + + try { + if (this.bot.config.searchOnBingLocalQueries) { + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'Using local queries config file') + + const data = fs.readFileSync(path.join(__dirname, '../queries.json'), 'utf8') + queries = JSON.parse(data) + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Loaded queries config | source=local | entries=${queries.length}` + ) + } else { + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + 'Fetching queries config from remote repository' + ) + + // Fetch from the repo directly so the user doesn't need to redownload the script for the new activities + const response = await this.bot.axios.request({ + method: 'GET', + url: 'https://raw.githubusercontent.com/TheNetsky/Microsoft-Rewards-Script/refs/heads/main/src/functions/queries.json' + }) + queries = response.data + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Loaded queries config | source=remote | entries=${queries.length}` + ) + } + + const answers = queries.find( + x => this.bot.utils.normalizeString(x.title) === this.bot.utils.normalizeString(promotion.title) + ) + + if (answers && answers.queries.length > 0) { + const answer = this.bot.utils.shuffleArray(answers.queries) + + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Found answers for activity title | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}" | answersCount=${answer.length} | firstQuery="${answer[0]}"` + ) + + return answer + } else { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `No matching title in queries config | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}"` + ) + + const queryCore = new QueryCore(this.bot) + + const promotionDescription = promotion.description.toLowerCase().trim() + const queryDescription = promotionDescription.replace('search on bing', '').trim() + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Requesting Bing suggestions | queryDescription="${queryDescription}"` + ) + + const bingSuggestions = await queryCore.getBingSuggestions(queryDescription) + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Bing suggestions result | count=${bingSuggestions.length} | title="${promotion.title}"` + ) + + // If no suggestions found + if (!bingSuggestions.length) { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `No suggestions found, falling back to activity title | title="${promotion.title}"` + ) + return [promotion.title] + } else { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Using Bing suggestions as search queries | count=${bingSuggestions.length} | title="${promotion.title}"` + ) + return bingSuggestions + } + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Error while resolving search queries | title="${promotion.title}" | message=${error instanceof Error ? error.message : String(error)} | fallback=promotionTitle` + ) + return [promotion.title] + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ac26e0c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,494 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import cluster, { Worker } from 'cluster' +import type { BrowserContext, Cookie, Page } from 'patchright' +import pkg from '../package.json' + +import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator' + +import Browser from './browser/Browser' +import BrowserFunc from './browser/BrowserFunc' +import BrowserUtils from './browser/BrowserUtils' + +import { IpcLog, Logger } from './logging/Logger' +import Utils from './util/Utils' +import { loadAccounts, loadConfig } from './util/Load' + +import { Login } from './browser/auth/Login' +import { Workers } from './functions/Workers' +import Activities from './functions/Activities' +import { SearchManager } from './functions/SearchManager' + +import type { Account } from './interface/Account' +import AxiosClient from './util/Axios' +import { sendDiscord, flushDiscordQueue } from './logging/Discord' +import { sendNtfy, flushNtfyQueue } from './logging/Ntfy' +import type { DashboardData } from './interface/DashboardData' +import type { AppDashboardData } from './interface/AppDashBoardData' + +interface ExecutionContext { + isMobile: boolean + accountEmail: string +} + +interface BrowserSession { + context: BrowserContext + fingerprint: BrowserFingerprintWithHeaders +} + +interface AccountStats { + email: string + initialPoints: number + finalPoints: number + collectedPoints: number + duration: number + success: boolean + error?: string +} + +const executionContext = new AsyncLocalStorage() + +export function getCurrentContext(): ExecutionContext { + const context = executionContext.getStore() + if (!context) { + return { isMobile: false, accountEmail: 'unknown' } + } + return context +} + +async function flushAllWebhooks(timeoutMs = 5000): Promise { + await Promise.allSettled([flushDiscordQueue(timeoutMs), flushNtfyQueue(timeoutMs)]) +} + +interface UserData { + userName: string + geoLocale: string + initialPoints: number + currentPoints: number + gainedPoints: number +} + +export class MicrosoftRewardsBot { + public logger: Logger + public config + public utils: Utils + public activities: Activities = new Activities(this) + public browser: { func: BrowserFunc; utils: BrowserUtils } + + public mainMobilePage!: Page + public mainDesktopPage!: Page + + public userData: UserData + + public accessToken = '' + public requestToken = '' + public cookies: { mobile: Cookie[]; desktop: Cookie[] } + public fingerprint!: BrowserFingerprintWithHeaders + + private pointsCanCollect = 0 + + private activeWorkers: number + private browserFactory: Browser = new Browser(this) + private accounts: Account[] + private workers: Workers + private login = new Login(this) + private searchManager: SearchManager + + public axios!: AxiosClient + + constructor() { + this.userData = { + userName: '', + geoLocale: '', + initialPoints: 0, + currentPoints: 0, + gainedPoints: 0 + } + this.logger = new Logger(this) + this.accounts = [] + this.cookies = { mobile: [], desktop: [] } + this.utils = new Utils() + this.workers = new Workers(this) + this.searchManager = new SearchManager(this) + this.browser = { + func: new BrowserFunc(this), + utils: new BrowserUtils(this) + } + this.config = loadConfig() + this.activeWorkers = this.config.clusters + } + + get isMobile(): boolean { + return getCurrentContext().isMobile + } + + async initialize(): Promise { + this.accounts = loadAccounts() + } + + async run(): Promise { + const totalAccounts = this.accounts.length + const runStartTime = Date.now() + + this.logger.info( + 'main', + 'RUN-START', + `Starting Microsoft Rewards bot| v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}` + ) + + if (this.config.clusters > 1) { + if (cluster.isPrimary) { + this.runMaster(runStartTime) + } else { + this.runWorker(runStartTime) + } + } else { + await this.runTasks(this.accounts, runStartTime) + } + } + + private runMaster(runStartTime: number): void { + void this.logger.info('main', 'CLUSTER-PRIMARY', `Primary process started | PID: ${process.pid}`) + + const rawChunks = this.utils.chunkArray(this.accounts, this.config.clusters) + const accountChunks = rawChunks.filter(c => c && c.length > 0) + this.activeWorkers = accountChunks.length + + const allAccountStats: AccountStats[] = [] + + for (const chunk of accountChunks) { + const worker = cluster.fork() + worker.send?.({ chunk, runStartTime }) + + worker.on('message', (msg: { __ipcLog?: IpcLog; __stats?: AccountStats[] }) => { + if (msg.__stats) { + allAccountStats.push(...msg.__stats) + } + + const log = msg.__ipcLog + + if (log && typeof log.content === 'string') { + const config = this.config + const webhook = config.webhook + const content = log.content + const level = log.level + if (webhook.discord?.enabled && webhook.discord.url) { + sendDiscord(webhook.discord.url, content, level) + } + if (webhook.ntfy?.enabled && webhook.ntfy.url) { + sendNtfy(webhook.ntfy, content, level) + } + } + }) + } + + const onWorkerDone = async (label: 'exit' | 'disconnect', worker: Worker, code?: number): Promise => { + this.activeWorkers -= 1 + this.logger.warn( + 'main', + `CLUSTER-WORKER-${label.toUpperCase()}`, + `Worker ${worker.process?.pid ?? '?'} ${label} | Code: ${code ?? 'n/a'} | Active workers: ${this.activeWorkers}` + ) + if (this.activeWorkers <= 0) { + const totalCollectedPoints = allAccountStats.reduce((sum, s) => sum + s.collectedPoints, 0) + const totalInitialPoints = allAccountStats.reduce((sum, s) => sum + s.initialPoints, 0) + const totalFinalPoints = allAccountStats.reduce((sum, s) => sum + s.finalPoints, 0) + const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1) + + this.logger.info( + 'main', + 'RUN-END', + `Completed all accounts | Accounts processed: ${allAccountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`, + 'green' + ) + await flushAllWebhooks() + process.exit(code ?? 0) + } + } + + cluster.on('exit', (worker, code) => { + void onWorkerDone('exit', worker, code) + }) + cluster.on('disconnect', worker => { + void onWorkerDone('disconnect', worker, undefined) + }) + } + + private runWorker(runStartTimeFromMaster?: number): void { + void this.logger.info('main', 'CLUSTER-WORKER-START', `Worker spawned | PID: ${process.pid}`) + process.on('message', async ({ chunk, runStartTime }: { chunk: Account[]; runStartTime: number }) => { + void this.logger.info( + 'main', + 'CLUSTER-WORKER-TASK', + `Worker ${process.pid} received ${chunk.length} accounts.` + ) + try { + const stats = await this.runTasks(chunk, runStartTime ?? runStartTimeFromMaster ?? Date.now()) + if (process.send) { + process.send({ __stats: stats }) + } + } catch (error) { + this.logger.error( + 'main', + 'CLUSTER-WORKER-ERROR', + `Worker task crash: ${error instanceof Error ? error.message : String(error)}` + ) + await flushAllWebhooks() + process.exit(1) + } + }) + } + + private async runTasks(accounts: Account[], runStartTime: number): Promise { + const accountStats: AccountStats[] = [] + + for (const account of accounts) { + const accountStartTime = Date.now() + const accountEmail = account.email + this.userData.userName = this.utils.getEmailUsername(accountEmail) + + try { + this.logger.info( + 'main', + 'ACCOUNT-START', + `Starting account: ${accountEmail} | geoLocale: ${account.geoLocale}` + ) + + this.axios = new AxiosClient(account.proxy) + + const result: { initialPoints: number; collectedPoints: number } | undefined = await this.Main( + account + ).catch(error => { + void this.logger.error( + true, + 'FLOW', + `Mobile flow failed for ${accountEmail}: ${error instanceof Error ? error.message : String(error)}` + ) + return undefined + }) + + const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1) + + if (result) { + const collectedPoints = result.collectedPoints ?? 0 + const accountInitialPoints = result.initialPoints ?? 0 + const accountFinalPoints = accountInitialPoints + collectedPoints + + accountStats.push({ + email: accountEmail, + initialPoints: accountInitialPoints, + finalPoints: accountFinalPoints, + collectedPoints: collectedPoints, + duration: parseFloat(durationSeconds), + success: true + }) + + this.logger.info( + 'main', + 'ACCOUNT-END', + `Completed account: ${accountEmail} | Total: +${collectedPoints} | Old: ${accountInitialPoints} → New: ${accountFinalPoints} | Duration: ${durationSeconds}s`, + 'green' + ) + } else { + accountStats.push({ + email: accountEmail, + initialPoints: 0, + finalPoints: 0, + collectedPoints: 0, + duration: parseFloat(durationSeconds), + success: false, + error: 'Flow failed' + }) + } + } catch (error) { + const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1) + this.logger.error( + 'main', + 'ACCOUNT-ERROR', + `${accountEmail}: ${error instanceof Error ? error.message : String(error)}` + ) + + accountStats.push({ + email: accountEmail, + initialPoints: 0, + finalPoints: 0, + collectedPoints: 0, + duration: parseFloat(durationSeconds), + success: false, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + if (this.config.clusters <= 1 && !cluster.isWorker) { + const totalCollectedPoints = accountStats.reduce((sum, s) => sum + s.collectedPoints, 0) + const totalInitialPoints = accountStats.reduce((sum, s) => sum + s.initialPoints, 0) + const totalFinalPoints = accountStats.reduce((sum, s) => sum + s.finalPoints, 0) + const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1) + + this.logger.info( + 'main', + 'RUN-END', + `Completed all accounts | Accounts processed: ${accountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`, + 'green' + ) + + await flushAllWebhooks() + process.exit() + } + + return accountStats + } + + async Main(account: Account): Promise<{ initialPoints: number; collectedPoints: number }> { + const accountEmail = account.email + this.logger.info('main', 'FLOW', `Starting session for ${accountEmail}`) + + let mobileSession: BrowserSession | null = null + let mobileContextClosed = false + + try { + return await executionContext.run({ isMobile: true, accountEmail }, async () => { + mobileSession = await this.browserFactory.createBrowser(account.proxy, accountEmail) + const initialContext: BrowserContext = mobileSession.context + this.mainMobilePage = await initialContext.newPage() + + this.logger.info('main', 'BROWSER', `Mobile Browser started | ${accountEmail}`) + + await this.login.login(this.mainMobilePage, accountEmail, account.password, account.totp) + + try { + this.accessToken = await this.login.getAppAccessToken(this.mainMobilePage, accountEmail) + } catch (error) { + this.logger.error( + 'main', + 'FLOW', + `Failed to get mobile access token: ${error instanceof Error ? error.message : String(error)}` + ) + } + + this.cookies.mobile = await initialContext.cookies() + this.fingerprint = mobileSession.fingerprint + + const data: DashboardData = await this.browser.func.getDashboardData() + const appData: AppDashboardData = await this.browser.func.getAppDashboardData() + + // Set geo + this.userData.geoLocale = + account.geoLocale === 'auto' ? data.userProfile.attributes.country : account.geoLocale.toLowerCase() + if (this.userData.geoLocale.length > 2) { + this.logger.warn( + 'main', + 'GEO-LOCALE', + `The provided geoLocale is longer than 2 (${this.userData.geoLocale} | auto=${account.geoLocale === 'auto'}), this is likely invalid and can cause errors!` + ) + } + + this.userData.initialPoints = data.userStatus.availablePoints + this.userData.currentPoints = data.userStatus.availablePoints + const initialPoints = this.userData.initialPoints ?? 0 + + const browserEarnable = await this.browser.func.getBrowserEarnablePoints() + const appEarnable = await this.browser.func.getAppEarnablePoints() + + this.pointsCanCollect = browserEarnable.mobileSearchPoints + (appEarnable?.totalEarnablePoints ?? 0) + + this.logger.info( + 'main', + 'POINTS', + `Earnable today | Mobile: ${this.pointsCanCollect} | Browser: ${ + browserEarnable.mobileSearchPoints + } | App: ${appEarnable?.totalEarnablePoints ?? 0} | ${accountEmail} | locale: ${this.userData.geoLocale}` + ) + + if (this.config.workers.doAppPromotions) await this.workers.doAppPromotions(appData) + if (this.config.workers.doDailySet) await this.workers.doDailySet(data, this.mainMobilePage) + if (this.config.workers.doMorePromotions) await this.workers.doMorePromotions(data, this.mainMobilePage) + if (this.config.workers.doDailyCheckIn) await this.activities.doDailyCheckIn() + if (this.config.workers.doReadToEarn) await this.activities.doReadToEarn() + + const searchPoints = await this.browser.func.getSearchPoints() + const missingSearchPoints = this.browser.func.missingSearchPoints(searchPoints, true) + + this.cookies.mobile = await initialContext.cookies() + + const { mobilePoints, desktopPoints } = await this.searchManager.doSearches( + data, + missingSearchPoints, + mobileSession, + account, + accountEmail + ) + + mobileContextClosed = true + + this.userData.gainedPoints = mobilePoints + desktopPoints + + const finalPoints = await this.browser.func.getCurrentPoints() + const collectedPoints = finalPoints - initialPoints + + this.logger.info( + 'main', + 'FLOW', + `Collected: +${collectedPoints} | Mobile: +${mobilePoints} | Desktop: +${desktopPoints} | ${accountEmail}` + ) + + return { + initialPoints, + collectedPoints: collectedPoints || 0 + } + }) + } finally { + if (mobileSession && !mobileContextClosed) { + try { + await executionContext.run({ isMobile: true, accountEmail }, async () => { + await this.browser.func.closeBrowser(mobileSession!.context, accountEmail) + }) + } catch {} + } + } + } +} + +export { executionContext } + +async function main(): Promise { + const rewardsBot = new MicrosoftRewardsBot() + + process.on('beforeExit', () => { + void flushAllWebhooks() + }) + process.on('SIGINT', async () => { + rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...') + await flushAllWebhooks() + process.exit(130) + }) + process.on('SIGTERM', async () => { + rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...') + await flushAllWebhooks() + process.exit(143) + }) + process.on('uncaughtException', async error => { + rewardsBot.logger.error('main', 'UNCAUGHT-EXCEPTION', error) + await flushAllWebhooks() + process.exit(1) + }) + process.on('unhandledRejection', async reason => { + rewardsBot.logger.error('main', 'UNHANDLED-REJECTION', reason as Error) + await flushAllWebhooks() + process.exit(1) + }) + + try { + await rewardsBot.initialize() + await rewardsBot.run() + } catch (error) { + rewardsBot.logger.error('main', 'MAIN-ERROR', error as Error) + } +} + +main().catch(async error => { + const tmpBot = new MicrosoftRewardsBot() + tmpBot.logger.error('main', 'MAIN-ERROR', error as Error) + await flushAllWebhooks() + process.exit(1) +}) diff --git a/src/interface/Account.ts b/src/interface/Account.ts new file mode 100644 index 0000000..2177dfa --- /dev/null +++ b/src/interface/Account.ts @@ -0,0 +1,15 @@ +export interface Account { + email: string + password: string + totp?: string + geoLocale: 'auto' | string + proxy: AccountProxy +} + +export interface AccountProxy { + proxyAxios: boolean + url: string + port: number + password: string + username: string +} diff --git a/src/interface/AppDashBoardData.ts b/src/interface/AppDashBoardData.ts new file mode 100644 index 0000000..38af029 --- /dev/null +++ b/src/interface/AppDashBoardData.ts @@ -0,0 +1,105 @@ +export interface AppDashboardData { + response: Response + correlationId: string + code: number +} + +export interface Response { + profile: Profile + balance: number + counters: null + promotions: Promotion[] + catalog: null + goal_item: GoalItem + activities: null + cashback: null + orders: unknown[] + rebateProfile: null + rebatePayouts: null + giveProfile: null + autoRedeemProfile: null + autoRedeemItem: null + thirdPartyProfile: null + notifications: null + waitlist: null + autoOpenFlyout: null + coupons: null + recommendedAffordableCatalog: null + generativeAICreditsBalance: null + requestCountryCatalog: null + donationCatalog: null +} + +export interface GoalItem { + name: string + provider: string + price: number + attributes: GoalItemAttributes + config: Config +} + +export interface GoalItemAttributes { + category: string + CategoryDescription: string + 'desc.group_text': string + 'desc.legal_text': string + 'desc.sc_description': string + 'desc.sc_title': string + display_order: string + ExtraLargeImage: string + group: string + group_image: string + group_sc_image: string + group_title: string + hidden: string + large_image: string + large_sc_image: string + medium_image: string + MobileImage: string + original_price: string + points_destination: string + points_source: string + Remarks: string + ShortText: string + showcase: string + small_image: string + title: string + cimsid: string + user_defined_goal: string +} + +export interface Config { + isHidden: string +} + +export interface Profile { + ruid: string + attributes: ProfileAttributes + offline_attributes: OfflineAttributes +} + +export interface ProfileAttributes { + ismsaautojoined: string + created: Date + creative: string + publisher: string + program: string + country: string + target: string + epuid: string + level: string + level_upd: Date + iris_segmentation: string + iris_segmentation_upd: Date + waitlistattributes: string + waitlistattributes_upd: Date +} + +export interface OfflineAttributes {} + +export interface Promotion { + name: string + priority: number + attributes: { [key: string]: string } + tags: string[] +} diff --git a/src/interface/AppUserData.ts b/src/interface/AppUserData.ts new file mode 100644 index 0000000..ad18805 --- /dev/null +++ b/src/interface/AppUserData.ts @@ -0,0 +1,225 @@ +export interface AppUserData { + response: Response + correlationId: string + code: number +} + +export interface Response { + profile: Profile + balance: number + counters: null + promotions: Promotion[] + catalog: null + goal_item: GoalItem + activities: null + cashback: null + orders: Order[] + rebateProfile: null + rebatePayouts: null + giveProfile: GiveProfile + autoRedeemProfile: null + autoRedeemItem: null + thirdPartyProfile: null + notifications: null + waitlist: null + autoOpenFlyout: null + coupons: null + recommendedAffordableCatalog: null +} + +export interface GiveProfile { + give_user: string + give_organization: { [key: string]: GiveOrganization | null } + first_give_optin: string + last_give_optout: string + give_lifetime_balance: string + give_lifetime_donation_balance: string + give_balance: string + form: null +} + +export interface GiveOrganization { + give_organization_donation_points: number + give_organization_donation_point_to_currency_ratio: number + give_organization_donation_currency: number +} + +export interface GoalItem { + name: string + provider: string + price: number + attributes: GoalItemAttributes + config: GoalItemConfig +} + +export interface GoalItemAttributes { + category: string + CategoryDescription: string + 'desc.group_text': string + 'desc.legal_text'?: string + 'desc.sc_description': string + 'desc.sc_title': string + display_order: string + ExtraLargeImage: string + group: string + group_image: string + group_sc_image: string + group_title: string + hidden?: string + large_image: string + large_sc_image: string + medium_image: string + MobileImage: string + original_price: string + Remarks?: string + ShortText?: string + showcase?: string + small_image: string + title: string + cimsid: string + user_defined_goal?: string + disable_bot_redemptions?: string + 'desc.large_text'?: string + english_title?: string + etid?: string + sku?: string + coupon_discount?: string +} + +export interface GoalItemConfig { + amount: string + currencyCode: string + isHidden: string + PointToCurrencyConversionRatio: string +} + +export interface Order { + id: string + t: Date + sku: string + item_snapshot: ItemSnapshot + p: number + s: S + a: A + child_redemption: null + third_party_partner: null + log: Log[] +} + +export interface A { + form?: string + OrderId: string + CorrelationId: string + Channel: string + Language: string + Country: string + EvaluationId: string + provider?: string + referenceOrderID?: string + externalRefID?: string + denomination?: string + rewardName?: string + sendEmail?: string + status?: string + createdAt?: Date + bal_before_deduct?: string + bal_after_deduct?: string +} + +export interface ItemSnapshot { + name: string + provider: string + price: number + attributes: GoalItemAttributes + config: ItemSnapshotConfig +} + +export interface ItemSnapshotConfig { + amount: string + countryCode: string + currencyCode: string + sku: string +} + +export interface Log { + time: Date + from: From + to: S + reason: string +} + +export enum From { + Created = 'Created', + RiskApproved = 'RiskApproved', + RiskReview = 'RiskReview' +} + +export enum S { + Cancelled = 'Cancelled', + RiskApproved = 'RiskApproved', + RiskReview = 'RiskReview', + Shipped = 'Shipped' +} + +export interface Profile { + ruid: string + attributes: ProfileAttributes + offline_attributes: OfflineAttributes +} + +export interface ProfileAttributes { + publisher: string + publisher_upd: Date + creative: string + creative_upd: Date + program: string + program_upd: Date + country: string + country_upd: Date + referrerhash: string + referrerhash_upd: Date + optout_upd: Date + language: string + language_upd: Date + target: string + target_upd: Date + created: Date + created_upd: Date + epuid: string + epuid_upd: Date + goal: string + goal_upd: Date + waitlistattributes: string + waitlistattributes_upd: Date + serpbotscore_upd: Date + iscashbackeligible: string + cbedc: string + rlscpct_upd: Date + give_user: string + rebcpc_upd: Date + SerpBotScore_upd: Date + AdsBotScore_upd: Date + dbs_upd: Date + rbs: string + rbs_upd: Date + iris_segmentation: string + iris_segmentation_upd: Date +} + +export interface OfflineAttributes {} + +export interface Promotion { + name: string + priority: number + attributes: { [key: string]: string } + tags: Tag[] +} + +export enum Tag { + AllowTrialUser = 'allow_trial_user', + ExcludeGivePcparent = 'exclude_give_pcparent', + ExcludeGlobalConfig = 'exclude_global_config', + ExcludeHidden = 'exclude_hidden', + LOCString = 'locString', + NonGlobalConfig = 'non_global_config' +} diff --git a/src/interface/Config.ts b/src/interface/Config.ts new file mode 100644 index 0000000..f862e68 --- /dev/null +++ b/src/interface/Config.ts @@ -0,0 +1,81 @@ +export interface Config { + baseURL: string + sessionPath: string + headless: boolean + runOnZeroPoints: boolean + clusters: number + errorDiagnostics: boolean + saveFingerprint: ConfigSaveFingerprint + workers: ConfigWorkers + searchOnBingLocalQueries: boolean + globalTimeout: number | string + searchSettings: ConfigSearchSettings + debugLogs: boolean + proxy: ConfigProxy + consoleLogFilter: LogFilter + webhook: ConfigWebhook +} + +export interface ConfigSaveFingerprint { + mobile: boolean + desktop: boolean +} + +export interface ConfigSearchSettings { + scrollRandomResults: boolean + clickRandomResults: boolean + parallelSearching: boolean + searchResultVisitTime: number | string + searchDelay: ConfigDelay + readDelay: ConfigDelay +} + +export interface ConfigDelay { + min: number | string + max: number | string +} + +export interface ConfigProxy { + queryEngine: boolean +} + +export interface ConfigWorkers { + doDailySet: boolean + doMorePromotions: boolean + doPunchCards: boolean + doAppPromotions: boolean + doDesktopSearch: boolean + doMobileSearch: boolean + doDailyCheckIn: boolean + doReadToEarn: boolean +} + +// Webhooks +export interface ConfigWebhook { + discord?: WebhookDiscordConfig + ntfy?: WebhookNtfyConfig + webhookLogFilter: LogFilter +} + +export interface LogFilter { + enabled: boolean + mode: 'whitelist' | 'blacklist' + levels?: Array<'debug' | 'info' | 'warn' | 'error'> + keywords?: string[] + regexPatterns?: string[] +} + +export interface WebhookDiscordConfig { + enabled: boolean + url: string +} + +export interface WebhookNtfyConfig { + enabled?: boolean + url: string + topic?: string + token?: string + title?: string + tags?: string[] + priority?: 1 | 2 | 3 | 4 | 5 // 5 highest (important) +} diff --git a/src/interface/DashboardData.ts b/src/interface/DashboardData.ts new file mode 100644 index 0000000..8d9f367 --- /dev/null +++ b/src/interface/DashboardData.ts @@ -0,0 +1,803 @@ +export interface DashboardData { + userStatus: UserStatus + userWarnings: unknown[] + promotionalItem: PromotionalItem + promotionalItems: PurplePromotionalItem[] + dailySetPromotions: { [key: string]: PromotionalItem[] } + streakPromotion: StreakPromotion + streakBonusPromotions: StreakBonusPromotion[] + punchCards: PunchCard[] + dashboardFlights: DashboardFlights + morePromotions: MorePromotion[] + morePromotionsWithoutPromotionalItems: MorePromotion[] + suggestedRewards: AutoRedeemItem[] + coachMarks: CoachMarks + welcomeTour: WelcomeTour + userInterests: UserInterests + isVisualParityTest: boolean + mbingFlight: null + componentImpressionPromotions: ComponentImpressionPromotion[] + machineTranslationPromo: BingUfMachineTranslationPromo + bingUfMachineTranslationPromo: BingUfMachineTranslationPromo + streakProtectionPromo: StreakProtectionPromo + autoRedeemItem: AutoRedeemItem + isAutoRedeemEligible: boolean + autoRedeemSubscriptions: unknown[] + userProfile: UserProfile + coupons: unknown[] + couponBannerPromotion: null + popUpPromotions: BingUfMachineTranslationPromo + pointClaimBannerPromotion: null + highValueSweepstakesPromotions: HighValueSweepstakesPromotion[] + revIpCountryName: null + shareAndWinPromotion: null + referAndEarnPromotion: ReferAndEarnPromotion + giveWithBingNoticePromotion: null + levelUpHeroBannerPromotion: null + monthlyBonusHeroBannerPromotion: null + starBonusWeeklyBannerPromotion: null + userGeneratedContentPromotion: null + created: Date + findClippyPromotion: FindClippyPromotion +} + +export enum ExclusiveLockedFeature { + Locked = 'locked', + Notsupported = 'notsupported', + Unlocked = 'unlocked' +} + +export enum GiveEligible { + False = 'False', + True = 'True' +} + +export enum State { + Complete = 'Complete', + Default = 'Default' +} + +export enum Type { + Empty = '', + Quiz = 'quiz', + Urlreward = 'urlreward' +} + +export enum Style { + ColorBlack = 'color:black', + Empty = '' +} + +export enum LegalLinkText { + ContinueToMicrosoftEdge = 'Continue to Microsoft Edge', + Empty = '' +} + +export enum Title { + Empty = '', + SetAGoal = 'Set a goal' +} + +export interface CloseLink { + text: null | string + url: null | string +} + +export interface SupportedLevels { + level1?: string + level2: string + level2XBoxGold: string +} + +export interface Benefit { + key: string + text: string + url: null | string + helpText: null | string + supportedLevels: SupportedLevels +} + +export interface BasePromotion< + TAttributes = Record, + TTitleStyle = string, + TDescriptionStyle = string, + TLegalLinkText = string, + TExclusiveLockedFeatureCategory = ExclusiveLockedFeature, + TPromotionType = string +> { + name: string + priority: number + attributes: TAttributes + offerId: string + complete: boolean + counter: number + activityProgress: number + activityProgressMax: number + pointProgressMax: number + pointProgress: number + promotionType: TPromotionType + promotionSubtype: string + title: string + extBannerTitle: string + titleStyle: TTitleStyle + theme: string + description: string + extBannerDescription: string + descriptionStyle: TDescriptionStyle + showcaseTitle: string + showcaseDescription: string + imageUrl: string + dynamicImage: string + smallImageUrl: string + backgroundImageUrl: string + showcaseBackgroundImageUrl: string + showcaseBackgroundLargeImageUrl: string + promotionBackgroundLeft: string + promotionBackgroundRight: string + iconUrl: string + animatedIconUrl: string + animatedLargeBackgroundImageUrl: string + destinationUrl: string + linkText: string + hash: string + activityType: string + isRecurring: boolean + isHidden: boolean + isTestOnly: boolean + isGiveEligible: boolean + level: string + levelUpActionsProgress: number + levelUpActivityDefaultSearchEngineDays: number + levelUpActivityDefaultSearchEngineCompletedAmount: number + levelUpActivityDailySetStreakDays: number + levelUpActivityDailySetCompletedAmount: number + levelUpActivityDailyStreaksCompletedAmount: number + levelUpActivityXboxGamePassCompleted: boolean + bingSearchDailyPoints: number + bingStarMonthlyBonusProgress: number + bingStarMonthlyBonusMaximum: number + bingStarBonusWeeklyProgress: number + bingStarBonusWeeklyState: string + defaultSearchEngineMonthlyBonusProgress: number + defaultSearchEngineMonthlyBonusMaximum: number + defaultSearchEngineMonthlyBonusState: string + monthlyLevelBonusMaximum: number + monthlyDistributionChartSrc: string + monthlyLevelBonusProgress: number + monthlyLevelBonusState: string + slidesCount: number + legalText: string + legalLinkText: TLegalLinkText + deviceType: string + exclusiveLockedFeatureCategory: TExclusiveLockedFeatureCategory + exclusiveLockedFeatureStatus: ExclusiveLockedFeature + exclusiveLockedFeatureDestinationUrl: string + lockedImage: string + pointsPerSearch: number + pointsPerSearchNewLevels: number + lastMonthLevel: string + sectionalOrdering: number + isAnimatedRewardEnabled: boolean + hvaLevelUpActivityDailySetCompletedAmount_V2: string + hvaLevelUpActivityDailySetCompletedMax_V2: string + hvaLevelUpActivityDailySetDays_V2: string + hvaLevelUpActivityDailySetDaysMax_V2: string + hvaLevelUpActivityDailySetProgress_V2: boolean + hvaLevelUpActivityDailySetDisplay_V2: boolean + hvaLevelUpActivityDailyStreaksBingCompletedAmount_V2: string + hvaLevelUpActivityDailyStreaksBingCompletedMax_V2: string + hvaLevelUpActivityDailyStreaksBingProgress_V2: boolean + hvaLevelUpActivityDailyStreaksBingDisplay_V2: boolean + hvaLevelUpActivityDailyStreaksMobileCompletedAmount_V2: string + hvaLevelUpActivityDailyStreaksMobileCompletedMax_V2: string + hvaLevelUpActivityDailyStreaksMobileProgress_V2: boolean + hvaLevelUpActivityDailyStreaksMobileDisplay_V2: boolean + hvaLevelUpDefaultSearchEngineCompletedAmount_V2: string + hvaLevelUpActivityDefaultSearchEngineCompletedMax_V2: string + hvaLevelUpActivityDefaultSearchEngineDays_V2: string + hvaLevelUpActivityDefaultSearchEngineDaysMax_V2: string + hvaLevelUpActivityDefaultSearchEngineProgress_V2: boolean + hvaLevelUpActivityDefaultSearchEngineDisplay_V2: boolean + hvaLevelUpActivityXboxGamePassCompletedAmount_V2: string + hvaLevelUpActivityXboxGamePassCompletedMax_V2: string + hvaLevelUpActivityXboxGamePassProgress_V2: boolean + hvaLevelUpActivityXboxGamePassDisplay_V2: boolean + programRestructureWave2HvaFlight: string + programRestructureHvaSevenDayLink: string +} + +export type AnyPromotion = BasePromotion + +export interface AutoRedeemItem { + name: null | string + price: number + provider: null | string + disabled: boolean + category: string + title: string + variableGoalSpecificTitle: string + smallImageUrl: string + mediumImageUrl: string + largeImageUrl: string + largeShowcaseImageUrl: string + description: Description + showcase: boolean + showcaseInAllCategory: boolean + originalPrice: number + discountedPrice: number + couponDiscount: number + popular: boolean + isTestOnly: boolean + groupId: string + inGroup: boolean + isDefaultItemInGroup: boolean + groupTitle: string + groupImageUrl: string + groupShowcaseImageUrl: string + isEligibleForOneClickRedemption: boolean + instantWinGameId: string + instantWinPlayAgainSku: string + isLowInStock: boolean + isOutOfStock: boolean + getCodeMessage: string + disableEmail: boolean + stockMessage: string + comingSoonFlag: boolean + onSaleFlag: boolean + onSaleText: string + isGenericDonation: boolean + shouldDisableButton: boolean + highValueSweepstakesCatalogItemId: string + isHighValueSweepstakesRedeemCatalogSKU: boolean + isVariableRedemptionItem: boolean + variableRedemptionItemCurrencySymbol: null + variableRedemptionItemMin: number + variableRedemptionItemMax: number + variableItemConfigPointsToCurrencyConversionRatio: number + isRecommendedAffordableItem: boolean + recommendedAffordableOrder: number + isAutoRedeem: boolean + isAutoDonate: boolean + isAutoDonateAllPointsItem: boolean + isOneTimeDonateAllPointsItem: boolean + isAutoDonateAllGivePointsItem: boolean + isAutoDonateSetPointsItem: boolean + products: null + isDiscontinuedAutoRedeem: boolean + discontinuedAutoRedeemDate: null + isSubscriptionToggleDisabled: boolean +} + +export interface Description { + itemGroupText: string + smallText: string + largeText: string + legalText: string + showcaseTitle: string + showcaseDescription: string + pageTitleTag: string + metaDescription: string +} + +export interface BingUfMachineTranslationPromo {} + +export interface CoachMarks { + streaks: WelcomeTour +} + +export interface WelcomeTour { + promotion: DashboardImpression | null + slides: Slide[] +} + +export type DashboardImpression = BasePromotion<{ [key: string]: string } | null> & { + benefits?: Benefit[] + levelRequirements?: null + supportedLevelKeys?: string[] + supportedLevelTitles?: string[] + supportedLevelTitlesMobile?: string[] + activeLevel?: string + showShopAndEarnBenefits?: boolean + showXboxBenefits?: boolean + isLevelRedesignEnabled?: boolean + hvaDailySetDays?: string + hvaDseDays?: string + hvaGamepassCompleted?: string + hvaPuzzlePiecesCompletedAmount?: string +} + +export interface Slide { + slideType: null + slideShowTourId: string + id: number + title: string + subtitle: null + subtitle1: null + description: string + description1: null + imageTitle: null + image2Title: null + image3Title: null + image4Title: null + imageDescription: null + image2Description: null + image3Description: null + image4Description: null + imageUrl: null + darkImageUrl: null + image2Url: null + image3Url: null + image4Url: null + layout: null + actionButtonText: null + actionButtonUrl: null + foregroundImageUrl: null + backLink: null + nextLink: CloseLink + closeLink: CloseLink + footnote: null + termsText: null + termsUrl: null + privacyText: null + privacyUrl: null + taggedItem: string + slideVisited: boolean + aboutPageLinkText: null + aboutPageLink: null + redeemLink: null + rewardsLink: null + labelText: null +} + +export interface ComponentImpressionPromotionAttributes { + red_dot_form_code?: string + hidden: GiveEligible + type: string + offerid: string + give_eligible: GiveEligible + destination: string + progress?: string + max?: string + complete?: GiveEligible + activity_progress?: string +} + +export type ComponentImpressionPromotion = BasePromotion + +export interface DailySetPromotionAttributes { + animated_icon?: string + bg_image: string + complete: GiveEligible + daily_set_date?: string + description: string + destination: string + icon: string + image: string + link_text: string + max: string + modern_image?: string + offerid: string + progress: string + sc_bg_image: string + sc_bg_large_image: string + small_image: string + state: State + title: string + type: Type + give_eligible: GiveEligible + ariaLabel?: string + promotional?: GiveEligible + parentPunchcards?: string + is_unlocked?: GiveEligible + translation_prompt?: string +} + +// Daily set "tile" promotion +export type PromotionalItem = BasePromotion< + DailySetPromotionAttributes, + string, + string, + string, + ExclusiveLockedFeature, + Type +> + +export interface DashboardFlights { + dashboardbannernav: string + togglegiveuser: string + spotifyRedirect: string + give_eligible: GiveEligible + destination: string +} + +export interface FindClippyPromotionAttributes { + enabled: GiveEligible + points: string + activity_type: string + hidden: GiveEligible + give_eligible: GiveEligible + progress: string + max: string + complete: GiveEligible + offerid: string + destination: string +} + +export type FindClippyPromotion = BasePromotion + +export type HighValueSweepstakesPromotion = BasePromotion<{ [key: string]: string }> + +export interface MorePromotionAttributes { + animated_icon: string + bg_image: string + complete: GiveEligible + description: string + description_style?: Style + destination: string + icon: string + image: string + link_text: string + max: string + offerid: string + progress: string + promotional?: GiveEligible + sc_bg_image: string + sc_bg_large_image: string + small_image: string + state: State + title: string + title_style?: Style + type?: Type + give_eligible: GiveEligible + cardHeader?: string + enable_hva_card?: string + hvA_BG_static?: string + hvA_BG_type?: string + hvA_primary_asset?: string + hvA_text_color?: string + isHvaV2Compatible?: GiveEligible + link_text_style?: Style + isExploreOnBingTask?: GiveEligible + isInProgress?: GiveEligible + modern_image?: string + is_unlocked?: GiveEligible + locked_category_criteria?: string + translationprompt?: string + legal_link_text?: LegalLinkText + legal_text?: string + description_comment?: string + query_comment?: string + title_comment?: string + layout?: string + sc_description?: string + sc_title?: Title + schemaName?: string + daily_set_date?: string +} + +export type MorePromotion = BasePromotion + +export interface PurpleAttributes { + animated_icon: string + ariaLabel?: string + bg_image: string + complete: GiveEligible + description: string + destination: string + icon: string + image: string + link_text: string + max: string + offerid: string + progress: string + promotional: GiveEligible + sc_bg_image: string + sc_bg_large_image: string + small_image: string + state: State + title: string + type: Type + give_eligible: GiveEligible + description_style?: Style + title_style?: Style + cardHeader?: string + enable_hva_card?: string + hvA_BG_static?: string + hvA_BG_type?: string + hvA_primary_asset?: string + hvA_text_color?: string + isHvaV2Compatible?: GiveEligible + link_text_style?: Style +} + +export type PurplePromotionalItem = BasePromotion + +export interface ParentPromotionAttributes { + bg_image: string + 'classification.DescriptionText': string + 'classification.PunchcardChildrenCount': string + 'classification.PunchcardEndDate': Date + 'classification.Template': string + 'classification.TitleText': string + complete: GiveEligible + description: string + destination: string + icon: string + image: string + legal_text?: string + link_text: string + max: string + offerid: string + progress: string + sc_bg_image: string + sc_bg_large_image: string + small_image: string + state: State + title: string + type: string + give_eligible: GiveEligible + modern_image?: string + translation_prompt?: string +} + +export type ParentPromotion = BasePromotion + +export interface PunchCard { + name: string + parentPromotion: ParentPromotion + childPromotions: PromotionalItem[] +} + +export interface ReferAndEarnPromotionAttributes { + bannerImpressionOffer: string + claimedPointsFrom1stLayer: string + claimedPointsFrom2ndLayer: string + dailyDirectDepositPoints: string + eduBannerEnabled: GiveEligible + eventEndDate: Date + eventStartDate: Date + firstLayerDailySearchCount: string + firstLayerDailySearchUser: string + firstLayerRefereeCount: string + hidden: GiveEligible + isBigBlueBtn: GiveEligible + isNewString: GiveEligible + isOneLayer: GiveEligible + isRafStatusBanner: GiveEligible + isTwoLayer: GiveEligible + offerid: string + pendingPointsFrom1stLayer: string + pendingPointsFrom2ndLayer: string + rafBannerTreatment: string + secondLayerDailySearchCount: string + secondLayerDailySearchUser: string + secondLayerRefereeCount: string + showRedDot: GiveEligible + showTopBanner: GiveEligible + showUnusualActivityBanner: GiveEligible + totalClaimedPoints: string + totalDeclinedPoints: string + totalPendingPoints: string + type: string + give_eligible: GiveEligible + destination: string +} + +export type ReferAndEarnPromotion = BasePromotion + +export interface StreakBonusPromotionAttributes { + activity_max: string + activity_progress: string + animated_icon: string + bonus_earned?: string + break_description?: string + description: string + description_localizedkey: string + hidden: GiveEligible + image: string + title: string + type: string + give_eligible: GiveEligible + destination: string +} + +export type StreakBonusPromotion = BasePromotion + +export interface StreakPromotionAttributes { + hidden: GiveEligible + type: string + title: string + image: string + activity_progress: string + last_updated: Date + break_image: string + lifetime_max: string + bonus_points: string + give_eligible: GiveEligible + destination: string +} + +export type StreakPromotion = BasePromotion & { + lastUpdatedDate: Date + breakImageUrl: string + lifetimeMaxValue: number + bonusPointsEarned: number +} + +export interface StreakProtectionPromo { + type: string + offerid: string + isStreakProtectionOnEligible: GiveEligible + streakProtectionStatus: GiveEligible + remainingDays: string + isFirstTime: GiveEligible + streakCount: string + isTodayStreakComplete: GiveEligible + autoTurnOn: GiveEligible + give_eligible: GiveEligible + destination: string +} + +export interface UserInterestsAttributes { + hidden: GiveEligible + give_eligible: GiveEligible + destination: string +} + +export type UserInterests = BasePromotion + +export interface UserProfile { + ruid: string + attributes: UserProfileAttributes +} + +export interface UserProfileAttributes { + ismsaautojoined: GiveEligible + created: Date + creative: string + publisher: string + program: string + country: string + target: string + epuid: string + level: string + level_upd: Date + iris_segmentation: string + iris_segmentation_upd: Date + waitlistattributes: string + waitlistattributes_upd: Date + iscashbackeligible: GiveEligible + serpbotscore: string + serpbotscore_upd: Date +} + +export interface UserStatus { + levelInfo: LevelInfo + availablePoints: number + lifetimePoints: number + lifetimePointsRedeemed: number + migratedGiveBalance: number + ePuid: string + redeemGoal: AutoRedeemItem + counters: Counters + lastOrder: LastOrder + dashboardImpression: DashboardImpression + highvalueSweepstakesHVAImpression: DashboardImpression + highvalueSweepstakesWinnerImpression: DashboardImpression + referrerProgressInfo: ReferrerProgressInfo + isAutoDonateFlightEnabled: boolean + isGiveModeOn: boolean + giveBalance: number + firstTimeGiveModeOptIn: null + giveOrganizationName: null + lifetimeGivingPoints: number + isRewardsUser: boolean + isMuidTrialUser: boolean + isUserEligibleForOneClickRedemption: boolean + primaryEarningCountryName: null +} + +export interface Counters { + pcSearch: DashboardImpression[] + mobileSearch: DashboardImpression[] + activityAndQuiz: ActivityAndQuiz[] + dailyPoint: DashboardImpression[] +} + +export interface ActivityAndQuizAttributes { + type: string + title: string + link_text: string + description: string + foreground_color: string + image: string + recurring: string + destination: string + 'classification.ShowProgress': GiveEligible + hidden: GiveEligible + give_eligible: GiveEligible +} + +export type ActivityAndQuiz = BasePromotion + +export interface LastOrder { + id: null + price: number + status: null + sku: null + timestamp: Date + catalogItem: null +} + +export interface LevelInfo { + isNewLevelsFeatureAvailable: boolean + lastMonthLevel: string + activeLevel: string + activeLevelName: string + progress: number + progressMax: number + levels: Level[] + benefitsPromotion: DashboardImpression + levelUpActivitiesProgress: number + levelUpActivitiesMax: number + levelUpActivityDefaultSearchEngineDays: number + levelUpActivityDefaultSearchEngineCompletedAmount: number + levelUpActivityDailySetStreakDays: number + levelUpActivityDailySetCompletedAmount: number + levelUpActivityDailyStreaksCompletedAmount: number + levelUpActivityXboxGamePassCompleted: boolean + bingStarMonthlyBonusProgress: number + bingStarMonthlyBonusMaximum: number + bingStarBonusWeeklyProgress: number + bingStarBonusWeeklyState: string + defaultSearchEngineMonthlyBonusProgress: number + defaultSearchEngineMonthlyBonusMaximum: number + defaultSearchEngineMonthlyBonusState: string + monthlyLevelBonusProgress: number + monthlyLevelBonusMaximum: number + monthlyLevelBonusState: string + monthlyDistributionChartSrc: string + bingSearchDailyPoints: number + pointsPerSearch: number + hvaLevelUpActivityDailySetCompletedAmount_V2: string + hvaLevelUpActivityDailySetCompletedMax_V2: string + hvaLevelUpActivityDailySetDays_V2: string + hvaLevelUpActivityDailySetDaysMax_V2: string + hvaLevelUpActivityDailySetProgress_V2: boolean + hvaLevelUpActivityDailySetDisplay_V2: boolean + hvaLevelUpActivityDailyStreaksBingCompletedAmount_V2: string + hvaLevelUpActivityDailyStreaksBingCompletedMax_V2: string + hvaLevelUpActivityDailyStreaksBingProgress_V2: boolean + hvaLevelUpActivityDailyStreaksBingDisplay_V2: boolean + hvaLevelUpActivityDailyStreaksMobileCompletedAmount_V2: string + hvaLevelUpActivityDailyStreaksMobileCompletedMax_V2: string + hvaLevelUpActivityDailyStreaksMobileProgress_V2: boolean + hvaLevelUpActivityDailyStreaksMobileDisplay_V2: boolean + hvaLevelUpDefaultSearchEngineCompletedAmount_V2: string + hvaLevelUpActivityDefaultSearchEngineCompletedMax_V2: string + hvaLevelUpActivityDefaultSearchEngineDays_V2: string + hvaLevelUpActivityDefaultSearchEngineDaysMax_V2: string + hvaLevelUpActivityDefaultSearchEngineProgress_V2: boolean + hvaLevelUpActivityDefaultSearchEngineDisplay_V2: boolean + hvaLevelUpActivityXboxGamePassCompletedAmount_V2: string + hvaLevelUpActivityXboxGamePassCompletedMax_V2: string + hvaLevelUpActivityXboxGamePassProgress_V2: boolean + hvaLevelUpActivityXboxGamePassDisplay_V2: boolean + programRestructureWave2HvaFlight: string + programRestructureHvaSevenDayLink: string +} + +export interface Level { + key: string + active: boolean + name: string + tasks: CloseLink[] + privileges: CloseLink[] +} + +export interface ReferrerProgressInfo { + pointsEarned: number + pointsMax: number + isComplete: boolean + promotions: unknown[] +} diff --git a/src/interface/OAuth.ts b/src/interface/OAuth.ts new file mode 100644 index 0000000..d9d5408 --- /dev/null +++ b/src/interface/OAuth.ts @@ -0,0 +1,9 @@ +export interface OAuth { + access_token: string + refresh_token: string + scope: string + expires_in: number + ext_expires_in: number + foci: string + token_type: string +} diff --git a/src/interface/Points.ts b/src/interface/Points.ts new file mode 100644 index 0000000..e502056 --- /dev/null +++ b/src/interface/Points.ts @@ -0,0 +1,20 @@ +export interface BrowserEarnablePoints { + desktopSearchPoints: number + mobileSearchPoints: number + dailySetPoints: number + morePromotionsPoints: number + totalEarnablePoints: number +} + +export interface AppEarnablePoints { + readToEarn: number + checkIn: number + totalEarnablePoints: number +} + +export interface MissingSearchPoints { + mobilePoints: number + desktopPoints: number + edgePoints: number + totalPoints: number +} diff --git a/src/interface/QuizData.ts b/src/interface/QuizData.ts new file mode 100644 index 0000000..ff14dce --- /dev/null +++ b/src/interface/QuizData.ts @@ -0,0 +1,50 @@ +export interface QuizData { + offerId: string + quizId: string + quizCategory: string + IsCurrentQuestionCompleted: boolean + quizRenderSummaryPage: boolean + resetQuiz: boolean + userClickedOnHint: boolean + isDemoEnabled: boolean + correctAnswer: string + isMultiChoiceQuizType: boolean + isPutInOrderQuizType: boolean + isListicleQuizType: boolean + isWOTQuizType: boolean + isBugsForRewardsQuizType: boolean + currentQuestionNumber: number + maxQuestions: number + resetTrackingCounters: boolean + showWelcomePanel: boolean + isAjaxCall: boolean + showHint: boolean + numberOfOptions: number + isMobile: boolean + inRewardsMode: boolean + enableDailySetWelcomePane: boolean + enableDailySetNonWelcomePane: boolean + isDailySetUrlOffer: boolean + isDailySetFlightEnabled: boolean + dailySetUrlOfferId: string + earnedCredits: number + maxCredits: number + creditsPerQuestion: number + userAlreadyClickedOptions: number + hasUserClickedOnOption: boolean + recentAnswerChoice: string + sessionTimerSeconds: string + isOverlayMinimized: number + ScreenReaderMsgOnMove: string + ScreenReaderMsgOnDrop: string + IsPartialPointsEnabled: boolean + PrioritizeUrlOverCookies: boolean + UseNewReportActivityAPI: boolean + CorrectlyAnsweredQuestionCount: number + showJoinRewardsPage: boolean + CorrectOptionAnswer_WOT: string + WrongOptionAnswer_WOT: string + enableSlideAnimation: boolean + ariaLoggingEnabled: boolean + UseQuestionIndexInActivityId: boolean +} diff --git a/src/interface/Search.ts b/src/interface/Search.ts new file mode 100644 index 0000000..d507246 --- /dev/null +++ b/src/interface/Search.ts @@ -0,0 +1,96 @@ +// Google Trends +export type GoogleTrendsResponse = [string, [string, ...null[], [string, ...string[]]][]] + +export interface GoogleSearch { + topic: string + related: string[] +} + +// Bing Suggestions +export interface BingSuggestionResponse { + _type: string + instrumentation: BingInstrumentation + queryContext: BingQueryContext + suggestionGroups: BingSuggestionGroup[] +} + +export interface BingInstrumentation { + _type: string + pingUrlBase: string + pageLoadPingUrl: string + llmPingUrlBase: string + llmLogPingUrlBase: string +} + +export interface BingQueryContext { + originalQuery: string +} + +export interface BingSuggestionGroup { + name: string + searchSuggestions: BingSearchSuggestion[] +} + +export interface BingSearchSuggestion { + url: string + urlPingSuffix: string + displayText: string + query: string + result?: BingResult[] + searchKind?: string +} + +export interface BingResult { + id: string + readLink: string + readLinkPingSuffix: string + webSearchUrl: string + webSearchUrlPingSuffix: string + name: string + image: BingSuggestionImage + description: string + entityPresentationInfo: BingEntityPresentationInfo + bingId: string +} + +export interface BingEntityPresentationInfo { + entityScenario: string + entityTypeDisplayHint: string + query: string +} + +export interface BingSuggestionImage { + thumbnailUrl: string + hostPageUrl: string + hostPageUrlPingSuffix: string + width: number + height: number + sourceWidth: number + sourceHeight: number +} + +// Bing Tending Topics +export interface BingTrendingTopicsResponse { + _type: string + instrumentation: BingInstrumentation + value: BingValue[] +} + +export interface BingValue { + webSearchUrl: string + webSearchUrlPingSuffix: string + name: string + image: BingTrendingImage + isBreakingNews: boolean + query: BingTrendingQuery + newsSearchUrl: string + newsSearchUrlPingSuffix: string +} + +export interface BingTrendingImage { + url: string +} + +export interface BingTrendingQuery { + text: string +} diff --git a/src/interface/UserAgentUtil.ts b/src/interface/UserAgentUtil.ts new file mode 100644 index 0000000..d6e8360 --- /dev/null +++ b/src/interface/UserAgentUtil.ts @@ -0,0 +1,62 @@ +// Chrome Product Data +export interface ChromeVersion { + timestamp: Date + channels: Channels +} + +export interface Channels { + Stable: Beta + Beta: Beta + Dev: Beta + Canary: Beta +} + +export interface Beta { + channel: string + version: string + revision: string +} + +// Edge Product Data +export interface EdgeVersion { + Product: string + Releases: Release[] +} + +export interface Release { + ReleaseId: number + Platform: Platform + Architecture: Architecture + CVEs: string[] + ProductVersion: string + Artifacts: Artifact[] + PublishedTime: Date + ExpectedExpiryDate: Date +} + +export enum Architecture { + Arm64 = 'arm64', + Universal = 'universal', + X64 = 'x64', + X86 = 'x86' +} + +export interface Artifact { + ArtifactName: string + Location: string + Hash: string + HashAlgorithm: HashAlgorithm + SizeInBytes: number +} + +export enum HashAlgorithm { + Sha256 = 'SHA256' +} + +export enum Platform { + Android = 'Android', + IOS = 'iOS', + Linux = 'Linux', + MACOS = 'MacOS', + Windows = 'Windows' +} diff --git a/src/interface/XboxDashboardData.ts b/src/interface/XboxDashboardData.ts new file mode 100644 index 0000000..5153e54 --- /dev/null +++ b/src/interface/XboxDashboardData.ts @@ -0,0 +1,43 @@ +export interface XboxDashboardData { + response: Response + correlationId: string + code: number +} + +export interface Response { + profile: null + balance: number + counters: { [key: string]: string } + promotions: Promotion[] + catalog: null + goal_item: null + activities: null + cashback: null + orders: null + rebateProfile: null + rebatePayouts: null + giveProfile: null + autoRedeemProfile: null + autoRedeemItem: null + thirdPartyProfile: null + notifications: null + waitlist: null + autoOpenFlyout: null + coupons: null + recommendedAffordableCatalog: null + generativeAICreditsBalance: null + requestCountryCatalog: null + donationCatalog: null +} + +export interface Promotion { + name: string + priority: number + attributes: { [key: string]: string } + tags: Tag[] +} + +export enum Tag { + ExcludeHidden = 'exclude_hidden', + NonGlobalConfig = 'non_global_config' +} diff --git a/src/logging/Discord.ts b/src/logging/Discord.ts new file mode 100644 index 0000000..0dad5e2 --- /dev/null +++ b/src/logging/Discord.ts @@ -0,0 +1,50 @@ +import axios, { AxiosRequestConfig } from 'axios' +import PQueue from 'p-queue' +import type { LogLevel } from './Logger' + +const DISCORD_LIMIT = 2000 + +export interface DiscordConfig { + enabled?: boolean + url: string +} + +const discordQueue = new PQueue({ + interval: 1000, + intervalCap: 2, + carryoverConcurrencyCount: true +}) + +function truncate(text: string) { + return text.length <= DISCORD_LIMIT ? text : text.slice(0, DISCORD_LIMIT - 14) + ' …(truncated)' +} + +export async function sendDiscord(discordUrl: string, content: string, level: LogLevel): Promise { + if (!discordUrl) return + + const request: AxiosRequestConfig = { + method: 'POST', + url: discordUrl, + headers: { 'Content-Type': 'application/json' }, + data: { content: truncate(content), allowed_mentions: { parse: [] } }, + timeout: 10000 + } + + await discordQueue.add(async () => { + try { + await axios(request) + } catch (err: any) { + const status = err?.response?.status + if (status === 429) return + } + }) +} + +export async function flushDiscordQueue(timeoutMs = 5000): Promise { + await Promise.race([ + (async () => { + await discordQueue.onIdle() + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error('discord flush timeout')), timeoutMs)) + ]).catch(() => {}) +} diff --git a/src/logging/Logger.ts b/src/logging/Logger.ts new file mode 100644 index 0000000..60a6234 --- /dev/null +++ b/src/logging/Logger.ts @@ -0,0 +1,189 @@ +import chalk from 'chalk' +import cluster from 'cluster' +import { sendDiscord } from './Discord' +import { sendNtfy } from './Ntfy' +import type { MicrosoftRewardsBot } from '../index' +import { errorDiagnostic } from '../util/ErrorDiagnostic' +import type { LogFilter } from '../interface/Config' + +export type Platform = boolean | 'main' +export type LogLevel = 'info' | 'warn' | 'error' | 'debug' +export type ColorKey = keyof typeof chalk +export interface IpcLog { + content: string + level: LogLevel +} + +type ChalkFn = (msg: string) => string + +function platformText(platform: Platform): string { + return platform === 'main' ? 'MAIN' : platform ? 'MOBILE' : 'DESKTOP' +} + +function platformBadge(platform: Platform): string { + return platform === 'main' ? chalk.bgCyan('MAIN') : platform ? chalk.bgBlue('MOBILE') : chalk.bgMagenta('DESKTOP') +} + +function getColorFn(color?: ColorKey): ChalkFn | null { + return color && typeof chalk[color] === 'function' ? (chalk[color] as ChalkFn) : null +} + +function consoleOut(level: LogLevel, msg: string, chalkFn: ChalkFn | null): void { + const out = chalkFn ? chalkFn(msg) : msg + switch (level) { + case 'warn': + return console.warn(out) + case 'error': + return console.error(out) + default: + return console.log(out) + } +} + +function formatMessage(message: string | Error): string { + return message instanceof Error ? `${message.message}\n${message.stack || ''}` : message +} + +export class Logger { + constructor(private bot: MicrosoftRewardsBot) {} + + info(isMobile: Platform, title: string, message: string, color?: ColorKey) { + return this.baseLog('info', isMobile, title, message, color) + } + + warn(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) { + return this.baseLog('warn', isMobile, title, message, color) + } + + error(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) { + return this.baseLog('error', isMobile, title, message, color) + } + + debug(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) { + return this.baseLog('debug', isMobile, title, message, color) + } + + private baseLog( + level: LogLevel, + isMobile: Platform, + title: string, + message: string | Error, + color?: ColorKey + ): void { + const now = new Date().toLocaleString() + const formatted = formatMessage(message) + + const levelTag = level.toUpperCase() + const cleanMsg = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${platformText( + isMobile + )} [${title}] ${formatted}` + + const config = this.bot.config + + if (level === 'debug' && !config.debugLogs && !process.argv.includes('-dev')) { + return + } + + const badge = platformBadge(isMobile) + const consoleStr = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${badge} [${title}] ${formatted}` + + let logColor: ColorKey | undefined = color + + if (!logColor) { + switch (level) { + case 'error': + logColor = 'red' + break + case 'warn': + logColor = 'yellow' + break + case 'debug': + logColor = 'magenta' + break + default: + break + } + } + + if (level === 'error' && config.errorDiagnostics) { + const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage + const error = message instanceof Error ? message : new Error(String(message)) + errorDiagnostic(page, error) + } + + const consoleAllowed = this.shouldPassFilter(config.consoleLogFilter, level, cleanMsg) + const webhookAllowed = this.shouldPassFilter(config.webhook.webhookLogFilter, level, cleanMsg) + + if (consoleAllowed) { + consoleOut(level, consoleStr, getColorFn(logColor)) + } + + if (!webhookAllowed) { + return + } + + if (cluster.isPrimary) { + if (config.webhook.discord?.enabled && config.webhook.discord.url) { + if (level === 'debug') return + sendDiscord(config.webhook.discord.url, cleanMsg, level) + } + + if (config.webhook.ntfy?.enabled && config.webhook.ntfy.url) { + if (level === 'debug') return + sendNtfy(config.webhook.ntfy, cleanMsg, level) + } + } else { + process.send?.({ __ipcLog: { content: cleanMsg, level } }) + } + } + + private shouldPassFilter(filter: LogFilter | undefined, level: LogLevel, message: string): boolean { + // If disabled or not, let all logs pass + if (!filter || !filter.enabled) { + return true + } + + // Always log error levelo logs, remove these lines to disable this! + if (level === 'error') { + return true + } + + const { mode, levels, keywords, regexPatterns } = filter + + const hasLevelRule = Array.isArray(levels) && levels.length > 0 + const hasKeywordRule = Array.isArray(keywords) && keywords.length > 0 + const hasPatternRule = Array.isArray(regexPatterns) && regexPatterns.length > 0 + + if (!hasLevelRule && !hasKeywordRule && !hasPatternRule) { + return mode === 'blacklist' + } + + const lowerMessage = message.toLowerCase() + let isMatch = false + + if (hasLevelRule && levels!.includes(level)) { + isMatch = true + } + + if (!isMatch && hasKeywordRule) { + if (keywords!.some(k => lowerMessage.includes(k.toLowerCase()))) { + isMatch = true + } + } + + // Fancy regex filtering if set! + if (!isMatch && hasPatternRule) { + for (const pattern of regexPatterns!) { + try { + const regex = new RegExp(pattern, 'i') + if (regex.test(message)) { + isMatch = true + break + } + } catch {} + } + } + + return mode === 'whitelist' ? isMatch : !isMatch + } +} diff --git a/src/logging/Ntfy.ts b/src/logging/Ntfy.ts new file mode 100644 index 0000000..931c5fa --- /dev/null +++ b/src/logging/Ntfy.ts @@ -0,0 +1,61 @@ +import axios, { AxiosRequestConfig } from 'axios' +import PQueue from 'p-queue' +import type { WebhookNtfyConfig } from '../interface/Config' +import type { LogLevel } from './Logger' + +const ntfyQueue = new PQueue({ + interval: 1000, + intervalCap: 2, + carryoverConcurrencyCount: true +}) + +export async function sendNtfy(config: WebhookNtfyConfig, content: string, level: LogLevel): Promise { + if (!config?.url) return + + switch (level) { + case 'error': + config.priority = 5 // Highest + break + + case 'warn': + config.priority = 4 + break + + default: + break + } + + const headers: Record = { 'Content-Type': 'text/plain' } + if (config.title) headers['Title'] = config.title + if (config.tags?.length) headers['Tags'] = config.tags.join(',') + if (config.priority) headers['Priority'] = String(config.priority) + if (config.token) headers['Authorization'] = `Bearer ${config.token}` + + const url = config.topic ? `${config.url}/${config.topic}` : config.url + + const request: AxiosRequestConfig = { + method: 'POST', + url: url, + headers, + data: content, + timeout: 10000 + } + + await ntfyQueue.add(async () => { + try { + await axios(request) + } catch (err: any) { + const status = err?.response?.status + if (status === 429) return + } + }) +} + +export async function flushNtfyQueue(timeoutMs = 5000): Promise { + await Promise.race([ + (async () => { + await ntfyQueue.onIdle() + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error('ntfy flush timeout')), timeoutMs)) + ]).catch(() => {}) +} diff --git a/src/util/Axios.ts b/src/util/Axios.ts new file mode 100644 index 0000000..20b6c76 --- /dev/null +++ b/src/util/Axios.ts @@ -0,0 +1,67 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import axiosRetry from 'axios-retry' +import { HttpProxyAgent } from 'http-proxy-agent' +import { HttpsProxyAgent } from 'https-proxy-agent' +import type { AccountProxy } from '../interface/Account' + +class AxiosClient { + private instance: AxiosInstance + private account: AccountProxy + + constructor(account: AccountProxy) { + this.account = account + + this.instance = axios.create({ + timeout: 20000 + }) + + // Configure proxy agent if available + if (this.account.url && this.account.proxyAxios) { + const agent = this.getAgentForProxy(this.account) + this.instance.defaults.httpAgent = agent + this.instance.defaults.httpsAgent = agent + } + + axiosRetry(this.instance, { + retries: 5, // Retry 5 times + retryDelay: axiosRetry.exponentialDelay, + shouldResetTimeout: true, + retryCondition: error => { + // Retry on: Network errors, 429 (Too Many Requests), 5xx server errors + if (axiosRetry.isNetworkError(error)) return true + if (!error.response) return true + + const status = error.response.status + return status === 429 || (status >= 500 && status <= 599) + } + }) + } + + private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent | HttpsProxyAgent { + const { url, port } = proxyConfig + + switch (true) { + case url.startsWith('http://'): + return new HttpProxyAgent(`${url}:${port}`) + case url.startsWith('https://'): + return new HttpsProxyAgent(`${url}:${port}`) + default: + throw new Error(`Unsupported proxy protocol: ${url}, only HTTP(S) is supported!`) + } + } + + public async request(config: AxiosRequestConfig, bypassProxy = false): Promise { + if (bypassProxy) { + const bypassInstance = axios.create() + axiosRetry(bypassInstance, { + retries: 3, + retryDelay: axiosRetry.exponentialDelay + }) + return bypassInstance.request(config) + } + + return this.instance.request(config) + } +} + +export default AxiosClient diff --git a/src/util/ErrorDiagnostic.ts b/src/util/ErrorDiagnostic.ts new file mode 100644 index 0000000..94372d4 --- /dev/null +++ b/src/util/ErrorDiagnostic.ts @@ -0,0 +1,46 @@ +import fs from 'fs/promises' +import path from 'path' +import type { Page } from 'patchright' + +export async function errorDiagnostic(page: Page, error: Error): Promise { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const folderName = `error-${timestamp}` + const outputDir = path.join(process.cwd(), 'diagnostics', folderName) + + if (!page) { + return + } + + if (page.isClosed()) { + return + } + + // Error log content + const errorLog = ` +Name: ${error.name} +Message: ${error.message} +Timestamp: ${new Date().toISOString()} +--------------------------------------------------- +Stack Trace: +${error.stack || 'No stack trace available'} + `.trim() + + const [htmlContent, screenshotBuffer] = await Promise.all([ + page.content(), + page.screenshot({ fullPage: true, type: 'png' }) + ]) + + await fs.mkdir(outputDir, { recursive: true }) + + await Promise.all([ + fs.writeFile(path.join(outputDir, 'dump.html'), htmlContent), + fs.writeFile(path.join(outputDir, 'screenshot.png'), screenshotBuffer), + fs.writeFile(path.join(outputDir, 'error.txt'), errorLog) + ]) + + console.log(`Diagnostics saved to: ${outputDir}`) + } catch (error) { + console.error('Unable to create error diagnostics:', error) + } +} diff --git a/src/util/Load.ts b/src/util/Load.ts new file mode 100644 index 0000000..3c37cf3 --- /dev/null +++ b/src/util/Load.ts @@ -0,0 +1,123 @@ +import type { Cookie } from 'patchright' +import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator' +import fs from 'fs' +import path from 'path' + +import type { Account } from '../interface/Account' +import type { Config, ConfigSaveFingerprint } from '../interface/Config' + +let configCache: Config + +export function loadAccounts(): Account[] { + try { + let file = 'accounts.json' + + if (process.argv.includes('-dev')) { + file = 'accounts.dev.json' + } + + const accountDir = path.join(__dirname, '../', file) + const accounts = fs.readFileSync(accountDir, 'utf-8') + + return JSON.parse(accounts) + } catch (error) { + throw new Error(error as string) + } +} + +export function loadConfig(): Config { + try { + if (configCache) { + return configCache + } + + const configDir = path.join(__dirname, '../', 'config.json') + const config = fs.readFileSync(configDir, 'utf-8') + + const configData = JSON.parse(config) + configCache = configData + + return configData + } catch (error) { + throw new Error(error as string) + } +} + +export async function loadSessionData( + sessionPath: string, + email: string, + saveFingerprint: ConfigSaveFingerprint, + isMobile: boolean +) { + try { + const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json' + const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, cookiesFileName) + + let cookies: Cookie[] = [] + if (fs.existsSync(cookieFile)) { + const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8') + cookies = JSON.parse(cookiesData) + } + + const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json' + const fingerprintFile = path.join(__dirname, '../browser/', sessionPath, email, fingerprintFileName) + + let fingerprint!: BrowserFingerprintWithHeaders + const shouldLoadFingerprint = isMobile ? saveFingerprint.mobile : saveFingerprint.desktop + if (shouldLoadFingerprint && fs.existsSync(fingerprintFile)) { + const fingerprintData = await fs.promises.readFile(fingerprintFile, 'utf-8') + fingerprint = JSON.parse(fingerprintData) + } + + return { + cookies: cookies, + fingerprint: fingerprint + } + } catch (error) { + throw new Error(error as string) + } +} + +export async function saveSessionData( + sessionPath: string, + cookies: Cookie[], + email: string, + isMobile: boolean +): Promise { + try { + const sessionDir = path.join(__dirname, '../browser/', sessionPath, email) + const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json' + + if (!fs.existsSync(sessionDir)) { + await fs.promises.mkdir(sessionDir, { recursive: true }) + } + + await fs.promises.writeFile(path.join(sessionDir, cookiesFileName), JSON.stringify(cookies)) + + return sessionDir + } catch (error) { + throw new Error(error as string) + } +} + +export async function saveFingerprintData( + sessionPath: string, + email: string, + isMobile: boolean, + fingerpint: BrowserFingerprintWithHeaders +): Promise { + try { + const sessionDir = path.join(__dirname, '../browser/', sessionPath, email) + const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json' + + if (!fs.existsSync(sessionDir)) { + await fs.promises.mkdir(sessionDir, { recursive: true }) + } + + await fs.promises.writeFile(path.join(sessionDir, fingerprintFileName), JSON.stringify(fingerpint)) + + return sessionDir + } catch (error) { + throw new Error(error as string) + } +} diff --git a/src/util/Utils.ts b/src/util/Utils.ts new file mode 100644 index 0000000..23972c2 --- /dev/null +++ b/src/util/Utils.ts @@ -0,0 +1,81 @@ +import ms, { StringValue } from 'ms' + +export default class Util { + async wait(time: number | string): Promise { + if (typeof time === 'string') { + time = this.stringToNumber(time) + } + + return new Promise(resolve => { + setTimeout(resolve, time) + }) + } + + getFormattedDate(ms = Date.now()): string { + const today = new Date(ms) + const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0 + const day = String(today.getDate()).padStart(2, '0') + const year = today.getFullYear() + + return `${month}/${day}/${year}` + } + + shuffleArray(array: T[]): T[] { + return array + .map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) + } + + randomNumber(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min + } + + chunkArray(arr: T[], numChunks: number): T[][] { + const chunkSize = Math.ceil(arr.length / numChunks) + const chunks: T[][] = [] + + for (let i = 0; i < arr.length; i += chunkSize) { + const chunk = arr.slice(i, i + chunkSize) + chunks.push(chunk) + } + + return chunks + } + + stringToNumber(input: string | number): number { + if (typeof input === 'number') { + return input + } + const value = input.trim() + + const milisec = ms(value as StringValue) + + if (milisec === undefined) { + throw new Error( + `The input provided (${input}) cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"` + ) + } + + return milisec + } + + normalizeString(string: string): string { + return string + .normalize('NFD') + .trim() + .toLowerCase() + .replace(/[^\x20-\x7E]/g, '') + .replace(/[?!]/g, '') + } + + getEmailUsername(email: string): string { + return email.split('@')[0] ?? 'Unknown' + } + + randomDelay(min: string | number, max: string | number): number { + const minMs = typeof min === 'number' ? min : this.stringToNumber(min) + const maxMs = typeof max === 'number' ? max : this.stringToNumber(max) + return Math.floor(this.randomNumber(minMs, maxMs)) + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ddd7a23 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,70 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ + "declaration": true /* Generates corresponding '.d.ts' file. */, + "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, + "sourceMap": true /* Generates corresponding '.map' file. */, + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./dist" /* Redirect output structure to the directory. */, + "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, + "strictNullChecks": true /* Enable strict null checks. */, + "strictFunctionTypes": true /* Enable strict checking of function types. */, + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, + "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, + /* Additional Checks */ + "noUnusedLocals": true /* Report errors on unused locals. */, + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, + "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, + "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */, + "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an 'override' modifier. */, + // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ + /* Module Resolution Options */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + "types": ["node"], + "typeRoots": ["./node_modules/@types"], + // Keep explicit typeRoots to ensure resolution in environments that don't auto-detect before full install. + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts", "src/accounts.json", "src/config.json", "src/functions/queries.json"], + "exclude": ["node_modules"] +}