From 576899f39d662a8501a0c751310a0fdee49fc316 Mon Sep 17 00:00:00 2001 From: TheNetsky <56271887+TheNetsky@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:26:47 +0100 Subject: [PATCH] v3.1.0 initial --- .github/workflows/auto-release.yml | 71 +++ dockerignore | 13 + package-lock.json | 419 +++++++------ package.json | 23 +- scripts/clearSessions.js | 104 ---- scripts/main/browserSession.js | 168 +++++ scripts/main/clearSessions.js | 67 ++ scripts/utils.js | 269 ++++++++ src/accounts.example.json | 22 +- src/browser/Browser.ts | 145 +++-- src/browser/BrowserFunc.ts | 2 +- src/browser/auth/Login.ts | 436 ++++++++----- src/browser/auth/methods/GetACodeLogin.ts | 129 ++++ src/browser/auth/methods/LoginUtils.ts | 66 ++ src/browser/auth/methods/MobileAccessLogin.ts | 73 ++- .../auth/methods/RecoveryEmailLogin.ts | 187 ++++++ src/browser/auth/methods/Totp2FALogin.ts | 93 ++- src/config.example.json | 39 +- src/functions/Activities.ts | 19 +- src/functions/QueryEngine.ts | 495 ++++++++++++--- src/functions/SearchManager.ts | 6 +- src/functions/Workers.ts | 109 +++- .../activities/api/DoubleSearchPoints.ts | 124 ++++ src/functions/activities/browser/Search.ts | 205 +++--- .../activities/browser/SearchOnBing.ts | 4 +- .../bing-search-activity-queries.json | 582 ++++++++++++++++++ src/functions/search-queries.json | 116 ++++ src/index.ts | 34 +- src/interface/Account.ts | 10 +- src/interface/Config.ts | 8 +- src/interface/Search.ts | 20 + src/logging/Logger.ts | 8 +- src/util/Axios.ts | 10 +- src/util/Load.ts | 12 +- src/util/Utils.ts | 15 +- src/util/Validator.ts | 131 ++++ tsconfig.json | 22 +- 37 files changed, 3391 insertions(+), 865 deletions(-) create mode 100644 .github/workflows/auto-release.yml create mode 100644 dockerignore delete mode 100644 scripts/clearSessions.js create mode 100644 scripts/main/browserSession.js create mode 100644 scripts/main/clearSessions.js create mode 100644 scripts/utils.js create mode 100644 src/browser/auth/methods/GetACodeLogin.ts create mode 100644 src/browser/auth/methods/LoginUtils.ts create mode 100644 src/browser/auth/methods/RecoveryEmailLogin.ts create mode 100644 src/functions/activities/api/DoubleSearchPoints.ts create mode 100644 src/functions/bing-search-activity-queries.json create mode 100644 src/functions/search-queries.json create mode 100644 src/util/Validator.ts diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..69f3585 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,71 @@ +name: Auto Release on package.json version bump + +on: + push: + branches: [v3] + paths: + - package.json + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read current version + id: current + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Read previous version (from previous commit) + id: previous + run: | + PREV_VERSION=$(git show HEAD~1:package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" || echo "") + echo "version=$PREV_VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if version increased + id: check + run: | + CUR="${{ steps.current.outputs.version }}" + PREV="${{ steps.previous.outputs.version }}" + + echo "Current: $CUR" + echo "Previous: $PREV" + + # if previous doesn't exist (first commit containing package.json), skip + if [ -z "$PREV" ]; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Compare semver using node's semver package if available; fallback to simple inequality + node -e " + const cur='${CUR}'; + const prev='${PREV}'; + let should = cur !== prev; + try { + const semver = require('semver'); + should = semver.gt(cur, prev); + } catch (_) {} + console.log('should_release=' + should); + " >> "$GITHUB_OUTPUT" + + - name: Stop if no bump + if: steps.check.outputs.should_release != 'true' + run: echo "No version increase detected." + + - name: Create tag + GitHub Release + if: steps.check.outputs.should_release == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.current.outputs.version }} + name: v${{ steps.current.outputs.version }} + generate_release_notes: true diff --git a/dockerignore b/dockerignore new file mode 100644 index 0000000..ec4aa35 --- /dev/null +++ b/dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.git +.gitignore +.vscode +.DS_Store +sessions/ +.dev/ +diagnostics/ +note +accounts.dev.json +accounts.main.json +.playwright-chromium-installed \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ae160fc..1969e14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,14 @@ "": { "name": "microsoft-rewards-script", "version": "3.0.0", - "license": "ISC", + "license": "GPL-3.0-or-later", "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", + "fingerprint-generator": "^2.1.79", + "fingerprint-injector": "^2.1.79", "ghost-cursor-playwright-port": "^1.4.3", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", @@ -22,11 +22,15 @@ "otpauth": "^9.4.1", "p-queue": "^9.0.1", "patchright": "^1.57.0", - "ts-node": "^10.9.2" + "semver": "^7.7.3", + "socks-proxy-agent": "^8.0.5", + "ts-node": "^10.9.2", + "zod": "^4.3.5" }, "devDependencies": { "@types/ms": "^2.1.0", "@types/node": "^24.10.1", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.48.0", "eslint": "^9.39.1", "eslint-plugin-modules-newline": "^0.0.6", @@ -35,7 +39,7 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cspotcode/source-map-support": { @@ -51,9 +55,9 @@ } }, "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==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -145,9 +149,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -157,7 +161,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -179,6 +183,16 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -193,9 +207,9 @@ } }, "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==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -342,13 +356,13 @@ } }, "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==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "license": "Apache-2.0", "peer": true, "dependencies": { - "playwright": "1.56.1" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -357,25 +371,6 @@ "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", @@ -389,9 +384,9 @@ } }, "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==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "license": "MIT" }, "node_modules/@tsconfig/node12": { @@ -440,30 +435,36 @@ "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==", + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", + "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", "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", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/type-utils": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -473,33 +474,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", + "@typescript-eslint/parser": "^8.51.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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "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", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "engines": { @@ -515,14 +506,14 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", + "@typescript-eslint/tsconfig-utils": "^8.51.0", + "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "engines": { @@ -537,14 +528,14 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -555,9 +546,9 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", "dev": true, "license": "MIT", "engines": { @@ -572,17 +563,17 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -597,9 +588,9 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", "dev": true, "license": "MIT", "engines": { @@ -611,21 +602,21 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", "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", + "@typescript-eslint/project-service": "8.51.0", + "@typescript-eslint/tsconfig-utils": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -639,16 +630,16 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", "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" + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -663,13 +654,13 @@ } }, "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==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -827,9 +818,9 @@ "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==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -862,9 +853,9 @@ } }, "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==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -881,11 +872,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -917,9 +908,9 @@ } }, "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==", + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", "funding": [ { "type": "opencollective", @@ -1207,9 +1198,9 @@ } }, "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==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "license": "ISC" }, "node_modules/encoding-sniffer": { @@ -1305,9 +1296,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { @@ -1317,7 +1308,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -1431,6 +1422,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/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/eslint/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1476,9 +1477,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1597,13 +1598,13 @@ } }, "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==", + "version": "2.1.79", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.79.tgz", + "integrity": "sha512-0dr3kTgvRYHleRPp6OBDcPb8amJmOyFr9aOuwnpN6ooWJ5XyT+/aL/SZ6CU4ZrEtzV26EyJ2Lg7PT32a0NdrRA==", "license": "Apache-2.0", "dependencies": { - "generative-bayesian-network": "^2.1.77", - "header-generator": "^2.1.77", + "generative-bayesian-network": "^2.1.79", + "header-generator": "^2.1.79", "tslib": "^2.4.0" }, "engines": { @@ -1611,12 +1612,12 @@ } }, "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==", + "version": "2.1.79", + "resolved": "https://registry.npmjs.org/fingerprint-injector/-/fingerprint-injector-2.1.79.tgz", + "integrity": "sha512-0tKb3wCQ92ZlLLbxRfhoduCI+rIgpsUdb3Jp66TrwoMlSeVDlmoBZfkQW4FheQFau1kg0lIg4Zk4xetBubetQQ==", "license": "Apache-2.0", "dependencies": { - "fingerprint-generator": "^2.1.77", + "fingerprint-generator": "^2.1.79", "tslib": "^2.4.0" }, "engines": { @@ -1677,9 +1678,9 @@ } }, "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==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1716,9 +1717,9 @@ } }, "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==", + "version": "2.1.79", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.79.tgz", + "integrity": "sha512-aPH+V2wO+HE0BUX1LbsM8Ak99gmV43lgh+D7GDteM0zgnPqiAwcK9JZPxMPZa3aJUleFtFaL1lAei8g9zNrDIA==", "license": "Apache-2.0", "dependencies": { "adm-zip": "^0.5.9", @@ -1850,13 +1851,6 @@ "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", @@ -1906,13 +1900,13 @@ } }, "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==", + "version": "2.1.79", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.79.tgz", + "integrity": "sha512-YvHx8teq4QmV5mz7wdPMsj9n1OZBPnZxA4QE+EOrtx7xbmGvd1gBvDNKCb5XqS4GR/TL75MU5hqMqqqANdILRg==", "license": "Apache-2.0", "dependencies": { "browserslist": "^4.21.1", - "generative-bayesian-network": "^2.1.77", + "generative-bayesian-network": "^2.1.79", "ow": "^0.28.1", "tslib": "^2.4.0" }, @@ -1990,9 +1984,9 @@ } }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -2026,6 +2020,15 @@ "node": ">=0.8.19" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2166,11 +2169,11 @@ "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==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -2552,19 +2555,6 @@ } }, "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==", @@ -2588,9 +2578,9 @@ } }, "node_modules/prettier": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.1.tgz", - "integrity": "sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -2669,7 +2659,6 @@ "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" @@ -2701,6 +2690,44 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2744,9 +2771,9 @@ } }, "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==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -2847,9 +2874,9 @@ "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==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -2905,6 +2932,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -2969,6 +2997,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index d9c4e7a..c0adbe2 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,11 @@ { "name": "microsoft-rewards-script", - "version": "3.0.1", + "version": "3.0.2", "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" + "node": ">=22.0.0" }, "scripts": { "pre-build": "npm i && rimraf dist && npx patchright install chromium", @@ -18,8 +17,10 @@ "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" + "clear-diagnostics": "rimraf diagnostics", + "clear-sessions": "node ./scripts/main/clearSessions.js", + "open-session": "node ./scripts/main/browserSession.js -email", + "open-session:dev": "node ./scripts/main/browserSession.js -dev -email ItsNetsky@protonmail.com" }, "keywords": [ "Bing Rewards", @@ -33,6 +34,7 @@ "devDependencies": { "@types/ms": "^2.1.0", "@types/node": "^24.10.1", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.48.0", "eslint": "^9.39.1", "eslint-plugin-modules-newline": "^0.0.6", @@ -45,8 +47,8 @@ "axios-retry": "^4.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0", - "fingerprint-generator": "^2.1.77", - "fingerprint-injector": "^2.1.77", + "fingerprint-generator": "^2.1.79", + "fingerprint-injector": "^2.1.79", "ghost-cursor-playwright-port": "^1.4.3", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", @@ -54,6 +56,9 @@ "otpauth": "^9.4.1", "p-queue": "^9.0.1", "patchright": "^1.57.0", - "ts-node": "^10.9.2" + "semver": "^7.7.3", + "socks-proxy-agent": "^8.0.5", + "ts-node": "^10.9.2", + "zod": "^4.3.5" } -} +} \ No newline at end of file diff --git a/scripts/clearSessions.js b/scripts/clearSessions.js deleted file mode 100644 index bc0a658..0000000 --- a/scripts/clearSessions.js +++ /dev/null @@ -1,104 +0,0 @@ -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/main/browserSession.js b/scripts/main/browserSession.js new file mode 100644 index 0000000..1667d0e --- /dev/null +++ b/scripts/main/browserSession.js @@ -0,0 +1,168 @@ +import fs from 'fs' +import { chromium } from 'patchright' +import { newInjectedContext } from 'fingerprint-injector' +import { + getDirname, + getProjectRoot, + log, + parseArgs, + validateEmail, + loadConfig, + loadAccounts, + findAccountByEmail, + getRuntimeBase, + getSessionPath, + loadCookies, + loadFingerprint, + buildProxyConfig, + setupCleanupHandlers +} from '../utils.js' + +const __dirname = getDirname(import.meta.url) +const projectRoot = getProjectRoot(__dirname) + +const args = parseArgs() +args.dev = args.dev || false + +validateEmail(args.email) + +const { data: config } = loadConfig(projectRoot, args.dev) +const { data: accounts } = loadAccounts(projectRoot, args.dev) + +const account = findAccountByEmail(accounts, args.email) +if (!account) { + log('ERROR', `Account not found: ${args.email}`) + log('ERROR', 'Available accounts:') + accounts.forEach(acc => { + if (acc?.email) log('ERROR', ` - ${acc.email}`) + }) + process.exit(1) +} + +async function main() { + const runtimeBase = getRuntimeBase(projectRoot, args.dev) + const sessionBase = getSessionPath(runtimeBase, config.sessionPath, args.email) + + log('INFO', 'Validating session data...') + + if (!fs.existsSync(sessionBase)) { + log('ERROR', `Session directory does not exist: ${sessionBase}`) + log('ERROR', 'Please ensure the session has been created for this account') + process.exit(1) + } + + if (!config.baseURL) { + log('ERROR', 'baseURL is not set in config.json') + process.exit(1) + } + + let cookies = await loadCookies(sessionBase, 'desktop') + let sessionType = 'desktop' + + if (cookies.length === 0) { + log('WARN', 'No desktop session cookies found, trying mobile session...') + cookies = await loadCookies(sessionBase, 'mobile') + sessionType = 'mobile' + + if (cookies.length === 0) { + log('ERROR', 'No cookies found in desktop or mobile session') + log('ERROR', `Session directory: ${sessionBase}`) + log('ERROR', 'Please ensure a valid session exists for this account') + process.exit(1) + } + + log('INFO', `Using mobile session (${cookies.length} cookies)`) + } + + const isMobile = sessionType === 'mobile' + const fingerprintEnabled = isMobile ? account.saveFingerprint?.mobile : account.saveFingerprint?.desktop + + let fingerprint = null + if (fingerprintEnabled) { + fingerprint = await loadFingerprint(sessionBase, sessionType) + if (!fingerprint) { + log('ERROR', `Fingerprint is enabled for ${sessionType} but fingerprint file not found`) + log('ERROR', `Expected file: ${sessionBase}/session_fingerprint_${sessionType}.json`) + log('ERROR', 'Cannot start browser without fingerprint when it is explicitly enabled') + process.exit(1) + } + log('INFO', `Loaded ${sessionType} fingerprint`) + } + + const proxy = buildProxyConfig(account) + + if (account.proxy && account.proxy.url && (!proxy || !proxy.server)) { + log('ERROR', 'Proxy is configured in account but proxy data is invalid or incomplete') + log('ERROR', 'Account proxy config:', JSON.stringify(account.proxy, null, 2)) + log('ERROR', 'Required fields: proxy.url, proxy.port') + log('ERROR', 'Cannot start browser without proxy when it is explicitly configured') + process.exit(1) + } + + const userAgent = fingerprint?.fingerprint?.navigator?.userAgent || fingerprint?.fingerprint?.userAgent || null + + log('INFO', `Session: ${args.email} (${sessionType})`) + log('INFO', ` Cookies: ${cookies.length}`) + log('INFO', ` Fingerprint: ${fingerprint ? 'Yes' : 'No'}`) + log('INFO', ` User-Agent: ${userAgent || 'Default'}`) + log('INFO', ` Proxy: ${proxy ? 'Yes' : 'No'}`) + log('INFO', 'Launching browser...') + + const browser = await chromium.launch({ + headless: false, + ...(proxy ? { proxy } : {}), + 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' + ] + }) + + let context + if (fingerprint) { + context = await newInjectedContext(browser, { fingerprint }) + + await context.addInitScript(() => { + Object.defineProperty(navigator, 'credentials', { + value: { + create: () => Promise.reject(new Error('WebAuthn disabled')), + get: () => Promise.reject(new Error('WebAuthn disabled')) + } + }) + }) + + log('SUCCESS', 'Fingerprint injected into browser context') + } else { + context = await browser.newContext({ + viewport: isMobile ? { width: 375, height: 667 } : { width: 1366, height: 768 } + }) + } + + if (cookies.length) { + await context.addCookies(cookies) + log('INFO', `Added ${cookies.length} cookies to context`) + } + + const page = await context.newPage() + await page.goto(config.baseURL, { waitUntil: 'domcontentloaded' }) + + log('SUCCESS', 'Browser opened with session loaded') + log('INFO', `Navigated to: ${config.baseURL}`) + + setupCleanupHandlers(async () => { + if (browser?.isConnected?.()) { + await browser.close() + } + }) +} + +main() \ No newline at end of file diff --git a/scripts/main/clearSessions.js b/scripts/main/clearSessions.js new file mode 100644 index 0000000..9e37e0c --- /dev/null +++ b/scripts/main/clearSessions.js @@ -0,0 +1,67 @@ +import path from 'path' +import fs from 'fs' +import { + getDirname, + getProjectRoot, + log, + loadJsonFile, + safeRemoveDirectory +} from '../utils.js' + +const __dirname = getDirname(import.meta.url) +const projectRoot = getProjectRoot(__dirname) + +const possibleConfigPaths = [ + path.join(projectRoot, 'config.json'), + path.join(projectRoot, 'src', 'config.json'), + path.join(projectRoot, 'dist', 'config.json') +] + +log('DEBUG', 'Project root:', projectRoot) +log('DEBUG', 'Searching for config.json...') + +const configResult = loadJsonFile(possibleConfigPaths, true) +const config = configResult.data +const configPath = configResult.path + +log('INFO', 'Using config:', configPath) + +if (!config.sessionPath) { + log('ERROR', 'Invalid config.json - missing required field: sessionPath') + log('ERROR', `Config file: ${configPath}`) + process.exit(1) +} + +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) +] + +log('DEBUG', 'Searching for session directory...') + +let sessionDir = null +for (const p of possibleSessionDirs) { + log('DEBUG', 'Checking:', p) + if (fs.existsSync(p)) { + sessionDir = p + log('DEBUG', 'Found session directory at:', p) + break + } +} + +if (!sessionDir) { + sessionDir = path.resolve(configDir, config.sessionPath) + log('DEBUG', 'Using fallback session directory:', sessionDir) +} + +const success = safeRemoveDirectory(sessionDir, projectRoot) + +if (!success) { + process.exit(1) +} + +log('INFO', 'Done.') \ No newline at end of file diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 0000000..9776162 --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,269 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +export function getDirname(importMetaUrl) { + const __filename = fileURLToPath(importMetaUrl) + return path.dirname(__filename) +} + +export function getProjectRoot(currentDir) { + let dir = currentDir + while (dir !== path.parse(dir).root) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir + } + dir = path.dirname(dir) + } + throw new Error('Could not find project root (package.json not found)') +} + +export function log(level, ...args) { + console.log(`[${level}]`, ...args) +} + +export function parseArgs(argv = process.argv.slice(2)) { + const args = {} + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + + if (arg.startsWith('-')) { + const key = arg.substring(1) + + if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) { + args[key] = argv[i + 1] + i++ + } else { + args[key] = true + } + } + } + + return args +} + +export function validateEmail(email) { + if (!email) { + log('ERROR', 'Missing -email argument') + log('ERROR', 'Usage: node script.js -email you@example.com') + process.exit(1) + } + + if (typeof email !== 'string') { + log('ERROR', `Invalid email type: expected string, got ${typeof email}`) + log('ERROR', 'Usage: node script.js -email you@example.com') + process.exit(1) + } + + if (!email.includes('@')) { + log('ERROR', `Invalid email format: "${email}"`) + log('ERROR', 'Email must contain "@" symbol') + log('ERROR', 'Example: you@example.com') + process.exit(1) + } + + return email +} + +export function loadJsonFile(possiblePaths, required = true) { + for (const filePath of possiblePaths) { + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf8') + return { data: JSON.parse(content), path: filePath } + } catch (error) { + log('ERROR', `Failed to parse JSON file: ${filePath}`) + log('ERROR', `Parse error: ${error.message}`) + if (required) process.exit(1) + return null + } + } + } + + if (required) { + log('ERROR', 'Required file not found') + log('ERROR', 'Searched in the following locations:') + possiblePaths.forEach(p => log('ERROR', ` - ${p}`)) + process.exit(1) + } + + return null +} + +export function loadConfig(projectRoot, isDev = false) { + const possiblePaths = isDev + ? [path.join(projectRoot, 'src', 'config.json')] + : [ + path.join(projectRoot, 'dist', 'config.json'), + path.join(projectRoot, 'config.json') + ] + + const result = loadJsonFile(possiblePaths, true) + + const missingFields = [] + if (!result.data.baseURL) missingFields.push('baseURL') + if (!result.data.sessionPath) missingFields.push('sessionPath') + if (result.data.headless === undefined) missingFields.push('headless') + if (!result.data.workers) missingFields.push('workers') + + if (missingFields.length > 0) { + log('ERROR', 'Invalid config.json - missing required fields:') + missingFields.forEach(field => log('ERROR', ` - ${field}`)) + log('ERROR', `Config file: ${result.path}`) + process.exit(1) + } + + return result +} + +export function loadAccounts(projectRoot, isDev = false) { + const possiblePaths = isDev + ? [path.join(projectRoot, 'src', 'accounts.dev.json')] + : [ + path.join(projectRoot, 'dist', 'accounts.json'), + path.join(projectRoot, 'accounts.json'), + path.join(projectRoot, 'accounts.example.json') + ] + + return loadJsonFile(possiblePaths, true) +} + +export function findAccountByEmail(accounts, email) { + if (!email || typeof email !== 'string') return null + return accounts.find(a => a?.email && typeof a.email === 'string' && a.email.toLowerCase() === email.toLowerCase()) || null +} + +export function getRuntimeBase(projectRoot, isDev = false) { + return path.join(projectRoot, isDev ? 'src' : 'dist') +} + +export function getSessionPath(runtimeBase, sessionPath, email) { + return path.join(runtimeBase, 'browser', sessionPath, email) +} + +export async function loadCookies(sessionBase, type = 'desktop') { + const cookiesFile = path.join(sessionBase, `session_${type}.json`) + + if (!fs.existsSync(cookiesFile)) { + return [] + } + + try { + const content = await fs.promises.readFile(cookiesFile, 'utf8') + return JSON.parse(content) + } catch (error) { + log('WARN', `Failed to load cookies from: ${cookiesFile}`) + log('WARN', `Error: ${error.message}`) + return [] + } +} + +export async function loadFingerprint(sessionBase, type = 'desktop') { + const fpFile = path.join(sessionBase, `session_fingerprint_${type}.json`) + + if (!fs.existsSync(fpFile)) { + return null + } + + try { + const content = await fs.promises.readFile(fpFile, 'utf8') + return JSON.parse(content) + } catch (error) { + log('WARN', `Failed to load fingerprint from: ${fpFile}`) + log('WARN', `Error: ${error.message}`) + return null + } +} + +export function getUserAgent(fingerprint) { + if (!fingerprint) return null + return fingerprint?.fingerprint?.userAgent || fingerprint?.userAgent || null +} + +export function buildProxyConfig(account) { + if (!account.proxy || !account.proxy.url || !account.proxy.port) { + return null + } + + const proxy = { + server: `${account.proxy.url}:${account.proxy.port}` + } + + if (account.proxy.username && account.proxy.password) { + proxy.username = account.proxy.username + proxy.password = account.proxy.password + } + + return proxy +} + +export function setupCleanupHandlers(cleanupFn) { + const cleanup = async () => { + try { + await cleanupFn() + } catch (error) { + log('ERROR', 'Cleanup failed:', error.message) + } + process.exit(0) + } + + process.on('SIGINT', cleanup) + process.on('SIGTERM', cleanup) +} + +export function validateDeletionPath(targetPath, projectRoot) { + const normalizedTarget = path.normalize(targetPath) + const normalizedRoot = path.normalize(projectRoot) + + if (!normalizedTarget.startsWith(normalizedRoot)) { + return { + valid: false, + error: 'Path is outside project root' + } + } + + if (normalizedTarget === normalizedRoot) { + return { + valid: false, + error: 'Cannot delete project root' + } + } + + const pathSegments = normalizedTarget.split(path.sep) + if (pathSegments.length < 3) { + return { + valid: false, + error: 'Path is too shallow (safety check failed)' + } + } + + return { valid: true, error: null } +} + +export function safeRemoveDirectory(dirPath, projectRoot) { + const validation = validateDeletionPath(dirPath, projectRoot) + + if (!validation.valid) { + log('ERROR', 'Directory deletion failed - safety check:') + log('ERROR', ` Reason: ${validation.error}`) + log('ERROR', ` Target: ${dirPath}`) + log('ERROR', ` Project root: ${projectRoot}`) + return false + } + + if (!fs.existsSync(dirPath)) { + log('INFO', `Directory does not exist: ${dirPath}`) + return true + } + + try { + fs.rmSync(dirPath, { recursive: true, force: true }) + log('SUCCESS', `Directory removed: ${dirPath}`) + return true + } catch (error) { + log('ERROR', `Failed to remove directory: ${dirPath}`) + log('ERROR', `Error: ${error.message}`) + return false + } +} \ No newline at end of file diff --git a/src/accounts.example.json b/src/accounts.example.json index 051fb3a..a14c618 100644 --- a/src/accounts.example.json +++ b/src/accounts.example.json @@ -2,27 +2,39 @@ { "email": "email_1", "password": "password_1", - "totp": "", + "totpSecret": "", + "recoveryEmail": "", "geoLocale": "auto", + "langCode": "en", "proxy": { - "proxyAxios": true, + "proxyAxios": false, "url": "", "port": 0, "username": "", "password": "" + }, + "saveFingerprint": { + "mobile": false, + "desktop": false } }, { "email": "email_2", "password": "password_2", - "totp": "", + "totpSecret": "", + "recoveryEmail": "", "geoLocale": "auto", + "langCode": "en", "proxy": { - "proxyAxios": true, + "proxyAxios": false, "url": "", "port": 0, "username": "", "password": "" + }, + "saveFingerprint": { + "mobile": false, + "desktop": false } } -] +] \ No newline at end of file diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 8223fe7..1fa49fc 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -1,5 +1,4 @@ import rebrowser, { BrowserContext } from 'patchright' - import { newInjectedContext } from 'fingerprint-injector' import { BrowserFingerprintWithHeaders, FingerprintGenerator } from 'fingerprint-generator' @@ -7,7 +6,7 @@ import type { MicrosoftRewardsBot } from '../index' import { loadSessionData, saveFingerprintData } from '../util/Load' import { UserAgentManager } from './UserAgent' -import type { AccountProxy } from '../interface/Account' +import type { Account, AccountProxy } from '../interface/Account' /* Test Stuff https://abrahamjuliot.github.io/creepjs/ @@ -17,92 +16,110 @@ https://pixelscan.net/ https://www.browserscan.net/ */ +interface BrowserCreationResult { + context: BrowserContext + fingerprint: BrowserFingerprintWithHeaders +} + class Browser { - private bot: MicrosoftRewardsBot + private readonly bot: MicrosoftRewardsBot + private static readonly BROWSER_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' + ] as const constructor(bot: MicrosoftRewardsBot) { this.bot = bot } - async createBrowser( - proxy: AccountProxy, - email: string - ): Promise<{ - context: BrowserContext - fingerprint: BrowserFingerprintWithHeaders - }> { + async createBrowser(account: Account): Promise { let browser: rebrowser.Browser try { + const proxyConfig = account.proxy.url + ? { + server: this.formatProxyServer(account.proxy), + ...(account.proxy.username && + account.proxy.password && { + username: account.proxy.username, + password: account.proxy.password + }) + } + : undefined + 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' - ] + ...(proxyConfig && { proxy: proxyConfig }), + args: [...Browser.BROWSER_ARGS] }) } catch (error) { - this.bot.logger.error( - this.bot.isMobile, - 'BROWSER', - `Launch failed: ${error instanceof Error ? error.message : String(error)}` - ) + const errorMessage = error instanceof Error ? error.message : String(error) + this.bot.logger.error(this.bot.isMobile, 'BROWSER', `Launch failed: ${errorMessage}`) throw error } - const sessionData = await loadSessionData( - this.bot.config.sessionPath, - email, - this.bot.config.saveFingerprint, - this.bot.isMobile - ) + try { + const sessionData = await loadSessionData( + this.bot.config.sessionPath, + account.email, + account.saveFingerprint, + this.bot.isMobile + ) - const fingerprint = sessionData.fingerprint - ? sessionData.fingerprint - : await this.generateFingerprint(this.bot.isMobile) + const fingerprint = sessionData.fingerprint ?? (await this.generateFingerprint(this.bot.isMobile)) - const context = await newInjectedContext(browser as any, { fingerprint: fingerprint }) + const context = await newInjectedContext(browser as any, { fingerprint }) - await context.addInitScript(() => { - Object.defineProperty(navigator, 'credentials', { - value: { - create: () => Promise.reject(new Error('WebAuthn disabled')), - get: () => Promise.reject(new Error('WebAuthn disabled')) - } + 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)) + context.setDefaultTimeout(this.bot.utils.stringToNumber(this.bot.config?.globalTimeout ?? 30000)) - await context.addCookies(sessionData.cookies) + await context.addCookies(sessionData.cookies) - if (this.bot.config.saveFingerprint) { - await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint) + if ( + (account.saveFingerprint.mobile && this.bot.isMobile) || + (account.saveFingerprint.desktop && !this.bot.isMobile) + ) { + await saveFingerprintData(this.bot.config.sessionPath, account.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 } + } catch (error) { + await browser.close().catch(() => {}) + throw error } + } - 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 + private formatProxyServer(proxy: AccountProxy): string { + try { + const urlObj = new URL(proxy.url) + const protocol = urlObj.protocol.replace(':', '') + return `${protocol}://${urlObj.hostname}:${proxy.port}` + } catch { + return `${proxy.url}:${proxy.port}` } } diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 32e6d0c..afb400a 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -28,7 +28,7 @@ export default class BrowserFunc { .join('; ') const request: AxiosRequestConfig = { - url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`, + url: 'https://rewards.bing.com/api/getuserinfo?type=1', method: 'GET', headers: { ...(this.bot.fingerprint?.headers ?? {}), diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index e9f999c..532f4af 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -2,25 +2,31 @@ 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' +import { CodeLogin } from './methods/GetACodeLogin' +import { RecoveryLogin } from './methods/RecoveryEmailLogin' + +import type { Account } from '../../interface/Account' type LoginState = | 'EMAIL_INPUT' | 'PASSWORD_INPUT' | 'SIGN_IN_ANOTHER_WAY' + | 'SIGN_IN_ANOTHER_WAY_EMAIL' | 'PASSKEY_ERROR' | 'PASSKEY_VIDEO' | 'KMSI_PROMPT' | 'LOGGED_IN' + | 'RECOVERY_EMAIL_INPUT' | 'ACCOUNT_LOCKED' | 'ERROR_ALERT' | '2FA_TOTP' | 'LOGIN_PASSWORDLESS' | 'GET_A_CODE' + | 'GET_A_CODE_2' | 'UNKNOWN' | 'CHROMEWEBDATA_ERROR' @@ -28,28 +34,52 @@ export class Login { emailLogin: EmailLogin passwordlessLogin: PasswordlessLogin totp2FALogin: TotpLogin + codeLogin: CodeLogin + recoveryLogin: RecoveryLogin + + private readonly selectors = { + primaryButton: 'button[data-testid="primaryButton"]', + secondaryButton: 'button[data-testid="secondaryButton"]', + emailIcon: '[data-testid="tile"]:has(svg path[d*="M5.25 4h13.5a3.25"])', + emailIconOld: 'img[data-testid="accessibleImg"][src*="picker_verify_email"]', + recoveryEmail: '[data-testid="proof-confirmation"]', + passwordIcon: '[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])', + accountLocked: '#serviceAbuseLandingTitle', + errorAlert: 'div[role="alert"]', + passwordEntry: '[data-testid="passwordEntry"]', + emailEntry: 'input#usernameEntry', + kmsiVideo: '[data-testid="kmsiVideo"]', + passKeyVideo: '[data-testid="biometricVideo"]', + passKeyError: '[data-testid="registrationImg"]', + passwordlessCheck: '[data-testid="deviceShieldCheckmarkVideo"]', + totpInput: 'input[name="otc"]', + totpInputOld: 'form[name="OneTimeCodeViewForm"]', + identityBanner: '[data-testid="identityBanner"]', + viewFooter: '[data-testid="viewFooter"] span[role="button"]', + bingProfile: '#id_n', + requestToken: 'input[name="__RequestVerificationToken"]', + requestTokenMeta: 'meta[name="__RequestVerificationToken"]' + } as const + constructor(private bot: MicrosoftRewardsBot) { this.emailLogin = new EmailLogin(this.bot) this.passwordlessLogin = new PasswordlessLogin(this.bot) this.totp2FALogin = new TotpLogin(this.bot) + this.codeLogin = new CodeLogin(this.bot) + this.recoveryLogin = new RecoveryLogin(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) { + async login(page: Page, account: Account) { 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 @@ -59,7 +89,7 @@ export class Login { iteration++ this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `State check iteration ${iteration}/${maxIterations}`) - const state = await this.detectCurrentState(page) + const state = await this.detectCurrentState(page, account) this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Current state: ${state}`) if (state !== previousState && previousState !== 'UNKNOWN') { @@ -68,11 +98,16 @@ export class Login { if (state === previousState && state !== 'LOGGED_IN' && state !== 'UNKNOWN') { sameStateCount++ + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN', + `Same state count: ${sameStateCount}/4 for state "${state}"` + ) if (sameStateCount >= 4) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN', - `Stuck in state "${state}" for 4 loops. Refreshing page...` + `Stuck in state "${state}" for 4 loops, refreshing page` ) await page.reload({ waitUntil: 'domcontentloaded' }) await this.bot.utils.wait(3000) @@ -90,8 +125,7 @@ export class Login { break } - const shouldContinue = await this.handleState(state, page, email, password, totpSecret) - + const shouldContinue = await this.handleState(state, page, account) if (!shouldContinue) { throw new Error(`Login failed or aborted at state: ${state}`) } @@ -103,142 +137,151 @@ export class Login { throw new Error('Login timeout: exceeded maximum iterations') } - await this.finalizeLogin(page, email) + await this.finalizeLogin(page, account.email) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'LOGIN', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `Fatal error: ${error instanceof Error ? error.message : String(error)}` ) throw error } } - private async detectCurrentState(page: Page): Promise { - // Make sure we settled before getting a URL + private async detectCurrentState(page: Page, account?: Account): Promise { 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}`) + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${url.hostname}${url.pathname}`) if (url.hostname === 'chromewebdata') { - this.bot.logger.warn(this.bot.isMobile, 'DETECT-CURRENT-STATE', 'Detected chromewebdata error page') + this.bot.logger.warn(this.bot.isMobile, 'DETECT-STATE', 'Detected chromewebdata error page') return 'CHROMEWEBDATA_ERROR' } - const isLocked = await page - .waitForSelector('#serviceAbuseLandingTitle', { state: 'visible', timeout: 200 }) - .then(() => true) - .catch(() => false) + const isLocked = await this.checkSelector(page, this.selectors.accountLocked) if (isLocked) { + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Account locked selector found') return 'ACCOUNT_LOCKED' } - // If instantly loading rewards dash, logged in - if (url.hostname === 'rewards.bing.com') { + if (url.hostname === 'rewards.bing.com' || url.hostname === 'account.microsoft.com') { + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'On rewards/account page, assuming logged in') return 'LOGGED_IN' } - // If account dash, logged in - if (url.hostname === 'account.microsoft.com') { - return 'LOGGED_IN' + const stateChecks: Array<[string, LoginState]> = [ + [this.selectors.errorAlert, 'ERROR_ALERT'], + [this.selectors.passwordEntry, 'PASSWORD_INPUT'], + [this.selectors.emailEntry, 'EMAIL_INPUT'], + [this.selectors.recoveryEmail, 'RECOVERY_EMAIL_INPUT'], + [this.selectors.kmsiVideo, 'KMSI_PROMPT'], + [this.selectors.passKeyVideo, 'PASSKEY_VIDEO'], + [this.selectors.passKeyError, 'PASSKEY_ERROR'], + [this.selectors.passwordIcon, 'SIGN_IN_ANOTHER_WAY'], + [this.selectors.emailIcon, 'SIGN_IN_ANOTHER_WAY_EMAIL'], + [this.selectors.emailIconOld, 'SIGN_IN_ANOTHER_WAY_EMAIL'], + [this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'], + [this.selectors.totpInput, '2FA_TOTP'], + [this.selectors.totpInputOld, '2FA_TOTP'] + ] + + const results = await Promise.all( + stateChecks.map(async ([sel, state]) => { + const visible = await this.checkSelector(page, sel) + return visible ? state : null + }) + ) + + const visibleStates = results.filter((s): s is LoginState => s !== null) + if (visibleStates.length > 0) { + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Visible states: [${visibleStates.join(', ')}]`) } - 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') + const [identityBanner, primaryButton, passwordEntry] = await Promise.all([ + this.checkSelector(page, this.selectors.identityBanner), + this.checkSelector(page, this.selectors.primaryButton), + this.checkSelector(page, this.selectors.passwordEntry) ]) - // 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 + const codeState = account?.password ? 'GET_A_CODE' : 'GET_A_CODE_2' + this.bot.logger.debug( + this.bot.isMobile, + 'DETECT-STATE', + `Get code state detected: ${codeState} (has password: ${!!account?.password})` + ) + results.push(codeState) } - // Final let foundStates = results.filter((s): s is LoginState => s !== null) - if (foundStates.length === 0) return 'UNKNOWN' + if (foundStates.length === 0) { + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'No matching states found') + return 'UNKNOWN' + } if (foundStates.includes('ERROR_ALERT')) { + this.bot.logger.debug( + this.bot.isMobile, + 'DETECT-STATE', + `ERROR_ALERT found - hostname: ${url.hostname}, has 2FA: ${foundStates.includes('2FA_TOTP')}` + ) 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('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 priorities: LoginState[] = [ + 'ACCOUNT_LOCKED', + 'PASSKEY_VIDEO', + 'PASSKEY_ERROR', + 'KMSI_PROMPT', + 'PASSWORD_INPUT', + 'EMAIL_INPUT', + 'SIGN_IN_ANOTHER_WAY_EMAIL', + 'SIGN_IN_ANOTHER_WAY', + 'LOGIN_PASSWORDLESS', + '2FA_TOTP' + ] - const mainState = foundStates[0] as LoginState + for (const priority of priorities) { + if (foundStates.includes(priority)) { + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Selected state by priority: ${priority}`) + return priority + } + } - return mainState + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Returning first found state: ${foundStates[0]}`) + return foundStates[0] as LoginState } - private async handleState( - state: LoginState, - page: Page, - email: string, - password: string, - totpSecret?: string - ): Promise { + private async checkSelector(page: Page, selector: string): Promise { + return page + .waitForSelector(selector, { state: 'visible', timeout: 200 }) + .then(() => true) + .catch(() => false) + } + + private async handleState(state: LoginState, page: Page, account: Account): Promise { + this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Processing state: ${state}`) + 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) + this.bot.logger.error(this.bot.isMobile, 'LOGIN', msg) throw new Error(msg) } case 'ERROR_ALERT': { - const alertEl = page.locator('div[role="alert"]') + const alertEl = page.locator(this.selectors.errorAlert) 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}`) + throw new Error(`Microsoft login error: ${errorMsg}`) } case 'LOGGED_IN': @@ -246,96 +289,161 @@ export class Login { 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(() => {}) + await this.emailLogin.enterEmail(page, account.email) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after email entry') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Email entered successfully') 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(() => {}) + await this.emailLogin.enterPassword(page, account.password) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after password entry') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password entered successfully') 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(() => {}) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code" via footer') + await this.bot.browser.utils.ghostClick(page, this.selectors.viewFooter) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after footer click') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Footer clicked, proceeding') + return true + } + + case 'GET_A_CODE_2': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling "Get a code" flow') + await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after primary button click') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating code login handler') + await this.codeLogin.handle(page) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Code login handler completed successfully') + return true + } + + case 'SIGN_IN_ANOTHER_WAY_EMAIL': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Send a code to email"') + + const emailSelector = await Promise.race([ + this.checkSelector(page, this.selectors.emailIcon).then(found => + found ? this.selectors.emailIcon : null + ), + this.checkSelector(page, this.selectors.emailIconOld).then(found => + found ? this.selectors.emailIconOld : null + ) + ]) + + if (!emailSelector) { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Email icon not found') + return false + } + + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN', + `Using ${emailSelector === this.selectors.emailIcon ? 'new' : 'old'} email icon selector` + ) + await this.bot.browser.utils.ghostClick(page, emailSelector) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after email icon click') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating code login handler') + await this.codeLogin.handle(page) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Code login handler completed successfully') + return true + } + + case 'RECOVERY_EMAIL_INPUT': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email input detected') + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout on recovery page') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating recovery email handler') + await this.recoveryLogin.handle(page, account?.recoveryEmail) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email handler completed successfully') 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 + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'chromewebdata error detected, attempting recovery') try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', `Navigating to ${this.bot.config.baseURL}`) await page .goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 10000 }) .catch(() => {}) - await this.bot.utils.wait(3000) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful') 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' - ) - + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Fallback to login.live.com') await page .goto('https://login.live.com/', { waitUntil: 'domcontentloaded', timeout: 10000 }) .catch(() => {}) - await this.bot.utils.wait(3000) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful') return true } } case '2FA_TOTP': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA required') - await this.totp2FALogin.handle(page, totpSecret) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA authentication required') + await this.totp2FALogin.handle(page, account.totpSecret) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA handler completed successfully') 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(() => {}) + await this.bot.browser.utils.ghostClick(page, this.selectors.passwordIcon) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after password icon click') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password option selected') 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(() => {}) + await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after KMSI acceptance') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'KMSI prompt accepted') 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(() => {}) + await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after Passkey skip') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passkey prompt skipped') 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(() => {}) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after passwordless auth') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passwordless authentication completed successfully') return true } @@ -344,12 +452,13 @@ export class Login { this.bot.logger.warn( this.bot.isMobile, 'LOGIN', - `Unknown state at host:${url.hostname} path:${url.pathname}. Waiting...` + `Unknown state at ${url.hostname}${url.pathname}, waiting` ) return true } default: + this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Unhandled state: ${state}, continuing`) return true } } @@ -363,21 +472,21 @@ export class Login { 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.' - ) + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not verify Rewards Dashboard, assuming login valid') } + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting Bing session verification') await this.verifyBingSession(page) + + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting rewards session verification') await this.getRewardsSession(page) const browser = page.context() const cookies = await browser.cookies() + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Retrieved ${cookies.length} cookies`) await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed! Session saved!') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed, session saved') } async verifyBingSession(page: Page) { @@ -393,30 +502,34 @@ export class Login { for (let i = 0; i < loopMax; i++) { if (page.isClosed()) break - // Rare error state + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Verification loop ${i + 1}/${loopMax}`) + 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) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Dismissing Passkey error state') + await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton) } const u = new URL(page.url()) const atBingHome = u.hostname === 'www.bing.com' && u.pathname === '/' + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN-BING', + `At Bing home: ${atBingHome} (${u.hostname}${u.pathname})` + ) if (atBingHome) { await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {}) const signedIn = await page - .waitForSelector('#id_n', { timeout: 3000 }) + .waitForSelector(this.selectors.bingProfile, { timeout: 3000 }) .then(() => true) .catch(() => false) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Profile element found: ${signedIn}`) + if (signedIn || this.bot.isMobile) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session established') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session verified successfully') return } } @@ -424,16 +537,12 @@ export class Login { await this.bot.utils.wait(1000) } - this.bot.logger.warn( - this.bot.isMobile, - 'LOGIN-BING', - 'Could not confirm Bing session after retries; continuing' - ) + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-BING', 'Could not verify Bing session, continuing anyway') } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-BING', - `Bing verification error: ${error instanceof Error ? error.message : String(error)}` + `Verification error: ${error instanceof Error ? error.message : String(error)}` ) } } @@ -441,7 +550,7 @@ export class Login { private async getRewardsSession(page: Page) { const loopMax = 5 - this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Fetching request token') + this.bot.logger.info(this.bot.isMobile, 'GET-REWARD-SESSION', 'Fetching request token') try { await page @@ -451,11 +560,7 @@ export class Login { 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()}` - ) + this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', `Token fetch loop ${i + 1}/${loopMax}`) const u = new URL(page.url()) const atRewardHome = u.hostname === 'rewards.bing.com' && u.pathname === '/' @@ -467,23 +572,27 @@ export class Login { const $ = await this.bot.browser.utils.loadInCheerio(html) const token = - $('input[name="__RequestVerificationToken"]').attr('value') ?? - $('meta[name="__RequestVerificationToken"]').attr('content') ?? + $(this.selectors.requestToken).attr('value') ?? + $(this.selectors.requestTokenMeta).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.logger.info( this.bot.isMobile, 'GET-REWARD-SESSION', - `Token extracted: ${token.substring(0, 10)}...` + `Request token retrieved: ${token.substring(0, 10)}...` ) return } - this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token NOT found on page') + this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token not found on page') + } else { + this.bot.logger.debug( + this.bot.isMobile, + 'GET-REWARD-SESSION', + `Not at reward home: ${u.hostname}${u.pathname}` + ) } await this.bot.utils.wait(1000) @@ -491,19 +600,20 @@ export class Login { this.bot.logger.warn( this.bot.isMobile, - 'GET-REQUEST-TOKEN', - 'No RequestVerificationToken found — some activities may not work' + 'GET-REWARD-SESSION', + '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)}` + 'GET-REWARD-SESSION', + `Fatal error: ${error instanceof Error ? error.message : String(error)}` ) } } async getAppAccessToken(page: Page, email: string) { + this.bot.logger.info(this.bot.isMobile, 'GET-APP-TOKEN', 'Requesting mobile access token') return await new MobileAccessLogin(this.bot, page).get(email) } } diff --git a/src/browser/auth/methods/GetACodeLogin.ts b/src/browser/auth/methods/GetACodeLogin.ts new file mode 100644 index 0000000..9055c04 --- /dev/null +++ b/src/browser/auth/methods/GetACodeLogin.ts @@ -0,0 +1,129 @@ +import type { Page } from 'patchright' +import type { MicrosoftRewardsBot } from '../../../index' +import { getErrorMessage, getSubtitleMessage, promptInput } from './LoginUtils' + +export class CodeLogin { + private readonly textInputSelector = '[data-testid="codeInputWrapper"]' + private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]' + private readonly maxManualSeconds = 60 + private readonly maxManualAttempts = 5 + + constructor(private bot: MicrosoftRewardsBot) {} + + 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 page.keyboard.type(code, { delay: 50 }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input') + return true + } + + const secondairyInput = await page.$(this.secondairyInputSelector) + if (secondairyInput) { + await page.keyboard.type(code, { delay: 50 }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input') + return true + } + + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'No code input field found') + return false + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-CODE', + `Failed to fill code input: ${error instanceof Error ? error.message : String(error)}` + ) + return false + } + } + + async handle(page: Page): Promise { + try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code login authentication requested') + + const emailMessage = await getSubtitleMessage(page) + if (emailMessage) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', `Page message: "${emailMessage}"`) + } else { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Unable to retrieve email code destination') + } + + for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { + const code = await promptInput({ + question: `Enter the 6-digit code (waiting ${this.maxManualSeconds}s): `, + timeoutSeconds: this.maxManualSeconds, + validate: code => /^\d{6}$/.test(code) + }) + + if (!code || !/^\d{6}$/.test(code)) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-CODE', + `Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('Manual code input failed or timed out') + } + continue + } + + const filled = await this.fillCode(page, code) + if (!filled) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-CODE', + `Unable to fill code input (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('Code input field not found') + } + continue + } + + await this.bot.utils.wait(500) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + + // Check if wrong code was entered + const errorMessage = await getErrorMessage(page) + if (errorMessage) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-CODE', + `Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error(`Maximum attempts reached: ${errorMessage}`) + } + + // Clear the input field before retrying + const inputToClear = await page.$(this.textInputSelector).catch(() => null) + if (inputToClear) { + await inputToClear.click() + await page.keyboard.press('Control+A') + await page.keyboard.press('Backspace') + } + continue + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code authentication completed successfully') + return + } + + throw new Error(`Code input failed after ${this.maxManualAttempts} attempts`) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-CODE', + `Error occurred: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } +} diff --git a/src/browser/auth/methods/LoginUtils.ts b/src/browser/auth/methods/LoginUtils.ts new file mode 100644 index 0000000..23d4a7e --- /dev/null +++ b/src/browser/auth/methods/LoginUtils.ts @@ -0,0 +1,66 @@ +import type { Page } from 'patchright' +import readline from 'readline' + +export interface PromptOptions { + question: string + timeoutSeconds?: number + validate?: (input: string) => boolean + transform?: (input: string) => string +} + +export function promptInput(options: PromptOptions): Promise { + const { question, timeoutSeconds = 60, validate, transform } = options + + return 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), timeoutSeconds * 1000) + + rl.question(question, answer => { + let value = answer.trim() + if (transform) value = transform(value) + + if (validate && !validate(value)) { + cleanup(null) + return + } + + cleanup(value) + }) + }) +} + +export async function getSubtitleMessage(page: Page): Promise { + const message = await page + .waitForSelector('[data-testid="subtitle"]', { state: 'visible', timeout: 1000 }) + .catch(() => null) + + if (!message) return null + + const text = await message.innerText() + return text.trim() +} + +export async function getErrorMessage(page: Page): Promise { + const errorAlert = await page + .waitForSelector('div[role="alert"]', { state: 'visible', timeout: 1000 }) + .catch(() => null) + + if (!errorAlert) return null + + const text = await errorAlert.innerText() + return text.trim() +} diff --git a/src/browser/auth/methods/MobileAccessLogin.ts b/src/browser/auth/methods/MobileAccessLogin.ts index 8b71807..fdacf85 100644 --- a/src/browser/auth/methods/MobileAccessLogin.ts +++ b/src/browser/auth/methods/MobileAccessLogin.ts @@ -1,7 +1,6 @@ import type { Page } from 'patchright' import { randomBytes } from 'crypto' import { URLSearchParams } from 'url' -import type { AxiosRequestConfig } from 'axios' import type { MicrosoftRewardsBot } from '../../../index' @@ -29,25 +28,70 @@ export class MobileAccessLogin { authorizeUrl.searchParams.append('access_type', 'offline_access') authorizeUrl.searchParams.append('login_hint', email) + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN-APP', + `Auth URL constructed: ${authorizeUrl.origin}${authorizeUrl.pathname}` + ) + await this.bot.browser.utils.disableFido(this.page) - await this.page.goto(authorizeUrl.href).catch(() => {}) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Navigating to OAuth authorize URL') + + await this.page.goto(authorizeUrl.href).catch(err => { + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN-APP', + `page.goto() failed: ${err instanceof Error ? err.message : String(err)}` + ) + }) this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...') + const start = Date.now() let code = '' + let lastUrl = '' 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 + const currentUrl = this.page.url() + + // Log only when URL changes (high signal, no spam) + if (currentUrl !== lastUrl) { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `OAuth poll URL changed → ${currentUrl}`) + lastUrl = currentUrl } + + try { + const url = new URL(currentUrl) + + if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') { + code = url.searchParams.get('code') || '' + + if (code) { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'OAuth code detected in redirect URL') + break + } + } + } catch (err) { + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN-APP', + `Invalid URL while polling: ${String(currentUrl)}` + ) + } + await this.bot.utils.wait(1000) } if (!code) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'Timed out waiting for OAuth code') + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-APP', + `Timed out waiting for OAuth code after ${Math.round((Date.now() - start) / 1000)}s` + ) + + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `Final page URL: ${this.page.url()}`) + return '' } @@ -57,18 +101,24 @@ export class MobileAccessLogin { data.append('code', code) data.append('redirect_uri', this.redirectUrl) - const request: AxiosRequestConfig = { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Exchanging OAuth code for access token') + + const response = await this.bot.axios.request({ 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') + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN-APP', + `Token response payload: ${JSON.stringify(response?.data)}` + ) return '' } @@ -78,10 +128,11 @@ export class MobileAccessLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-APP', - `MobileAccess error: ${error instanceof Error ? error.message : String(error)}` + `MobileAccess error: ${error instanceof Error ? error.stack || error.message : String(error)}` ) return '' } finally { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Returning to base URL') await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {}) } } diff --git a/src/browser/auth/methods/RecoveryEmailLogin.ts b/src/browser/auth/methods/RecoveryEmailLogin.ts new file mode 100644 index 0000000..4528921 --- /dev/null +++ b/src/browser/auth/methods/RecoveryEmailLogin.ts @@ -0,0 +1,187 @@ +import type { Page } from 'patchright' +import type { MicrosoftRewardsBot } from '../../../index' +import { getErrorMessage, promptInput } from './LoginUtils' + +export class RecoveryLogin { + private readonly textInputSelector = '[data-testid="proof-confirmation"]' + private readonly maxManualSeconds = 60 + private readonly maxManualAttempts = 5 + + constructor(private bot: MicrosoftRewardsBot) {} + + private async fillEmail(page: Page, email: string): Promise { + try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Attempting to fill email: ${email}`) + + const visibleInput = await page + .waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 }) + .catch(() => null) + + if (visibleInput) { + await page.keyboard.type(email, { delay: 50 }) + await page.keyboard.press('Enter') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Successfully filled email input field') + return true + } + + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Email input field not found with selector: ${this.textInputSelector}` + ) + return false + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Failed to fill email input: ${error instanceof Error ? error.message : String(error)}` + ) + return false + } + } + + async handle(page: Page, recoveryEmail: string): Promise { + try { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email recovery authentication flow initiated') + + if (recoveryEmail) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Using provided recovery email: ${recoveryEmail}` + ) + + const filled = await this.fillEmail(page, recoveryEmail) + if (!filled) { + throw new Error('Email input field not found') + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response') + await this.bot.utils.wait(500) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached') + }) + + const errorMessage = await getErrorMessage(page) + if (errorMessage) { + throw new Error(`Email verification failed: ${errorMessage}`) + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully') + return + } + + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-RECOVERY', + 'No recovery email provided, will prompt user for input' + ) + + for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Starting attempt ${attempt}/${this.maxManualAttempts}` + ) + + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Prompting user for email input (timeout: ${this.maxManualSeconds}s)` + ) + + const email = await promptInput({ + question: `Recovery email (waiting ${this.maxManualSeconds}s): `, + timeoutSeconds: this.maxManualSeconds, + validate: email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + }) + + if (!email) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `No or invalid email input received (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('Manual email input failed: no input received') + } + continue + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Invalid email format received (attempt ${attempt}/${this.maxManualAttempts}) | length=${email.length}` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('Manual email input failed: invalid format') + } + continue + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Valid email received from user: ${email}`) + + const filled = await this.fillEmail(page, email) + if (!filled) { + this.bot.logger.error( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Failed to fill email input field (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error('Email input field not found after maximum attempts') + } + + await this.bot.utils.wait(1000) + continue + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response') + await this.bot.utils.wait(500) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached') + }) + + const errorMessage = await getErrorMessage(page) + if (errorMessage) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-RECOVERY', + `Error from page: "${errorMessage}" (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error(`Maximum attempts reached. Last error: ${errorMessage}`) + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Clearing input field for retry') + const inputToClear = await page.$(this.textInputSelector).catch(() => null) + if (inputToClear) { + await inputToClear.click() + await page.keyboard.press('Control+A') + await page.keyboard.press('Backspace') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Input field cleared') + } else { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-RECOVERY', 'Could not find input field to clear') + } + + await this.bot.utils.wait(1000) + continue + } + + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully') + return + } + + throw new Error(`Email input failed after ${this.maxManualAttempts} attempts`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + this.bot.logger.error(this.bot.isMobile, 'LOGIN-RECOVERY', `Fatal error: ${errorMsg}`) + throw error + } + } +} diff --git a/src/browser/auth/methods/Totp2FALogin.ts b/src/browser/auth/methods/Totp2FALogin.ts index 21d1689..64e5a31 100644 --- a/src/browser/auth/methods/Totp2FALogin.ts +++ b/src/browser/auth/methods/Totp2FALogin.ts @@ -1,12 +1,12 @@ import type { Page } from 'patchright' import * as OTPAuth from 'otpauth' -import readline from 'readline' import type { MicrosoftRewardsBot } from '../../../index' +import { getErrorMessage, promptInput } from './LoginUtils' 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 secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]' private readonly submitButtonSelector = 'button[type="submit"]' private readonly maxManualSeconds = 60 private readonly maxManualAttempts = 5 @@ -17,31 +17,6 @@ export class TotpLogin { 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 @@ -50,19 +25,18 @@ export class TotpLogin { if (visibleInput) { await visibleInput.fill(code) - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled visible TOTP text input') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP 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') + const secondairyInput = await page.$(this.secondairyInputSelector) + if (secondairyInput) { + await secondairyInput.fill(code) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input') return true } - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found (visible or hidden)') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found') return false } catch (error) { this.bot.logger.warn( @@ -83,9 +57,8 @@ export class TotpLogin { 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') + this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to fill TOTP input field') throw new Error('TOTP input field not found') } @@ -93,6 +66,12 @@ export class TotpLogin { await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector) await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + const errorMessage = await getErrorMessage(page) + if (errorMessage) { + this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', `TOTP failed: ${errorMessage}`) + throw new Error(`TOTP authentication failed: ${errorMessage}`) + } + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') return } @@ -100,45 +79,36 @@ export class TotpLogin { 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() + const code = await promptInput({ + question: `Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `, + timeoutSeconds: this.maxManualSeconds, + validate: code => /^\d{6}$/.test(code) + }) if (!code || !/^\d{6}$/.test(code)) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-TOTP', - `Invalid or missing TOTP code (attempt ${attempt}/${this.maxManualAttempts})` + `Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}` ) 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})` + `Unable to fill TOTP input (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 } @@ -146,16 +116,31 @@ export class TotpLogin { await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector) await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + // Check if wrong code was entered + const errorMessage = await getErrorMessage(page) + if (errorMessage) { + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN-TOTP', + `Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})` + ) + + if (attempt === this.maxManualAttempts) { + throw new Error(`Maximum attempts reached: ${errorMessage}`) + } + continue + } + 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`) + throw new Error(`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)}` + `Error occurred: ${error instanceof Error ? error.message : String(error)}` ) throw error } diff --git a/src/config.example.json b/src/config.example.json index c06226e..ab9a838 100644 --- a/src/config.example.json +++ b/src/config.example.json @@ -4,13 +4,10 @@ "headless": false, "runOnZeroPoints": false, "clusters": 1, - "errorDiagnostics": true, - "saveFingerprint": { - "mobile": false, - "desktop": false - }, + "errorDiagnostics": false, "workers": { "doDailySet": true, + "doSpecialPromotions": true, "doMorePromotions": true, "doPunchCards": true, "doAppPromotions": true, @@ -25,6 +22,12 @@ "scrollRandomResults": false, "clickRandomResults": false, "parallelSearching": true, + "queryEngines": [ + "google", + "wikipedia", + "reddit", + "local" + ], "searchResultVisitTime": "10sec", "searchDelay": { "min": "30sec", @@ -39,8 +42,13 @@ "consoleLogFilter": { "enabled": false, "mode": "whitelist", - "levels": ["error", "warn"], - "keywords": ["starting account"], + "levels": [ + "error", + "warn" + ], + "keywords": [ + "starting account" + ], "regexPatterns": [] }, "proxy": { @@ -57,15 +65,24 @@ "topic": "", "token": "", "title": "Microsoft-Rewards-Script", - "tags": ["bot", "notify"], + "tags": [ + "bot", + "notify" + ], "priority": 3 }, "webhookLogFilter": { "enabled": false, "mode": "whitelist", - "levels": ["error"], - "keywords": ["starting account", "select number", "collected"], + "levels": [ + "error" + ], + "keywords": [ + "starting account", + "select number", + "collected" + ], "regexPatterns": [] } } -} +} \ No newline at end of file diff --git a/src/functions/Activities.ts b/src/functions/Activities.ts index ee2793c..1486026 100644 --- a/src/functions/Activities.ts +++ b/src/functions/Activities.ts @@ -10,12 +10,18 @@ import { AppReward } from './activities/app/AppReward' import { UrlReward } from './activities/api/UrlReward' import { Quiz } from './activities/api/Quiz' import { FindClippy } from './activities/api/FindClippy' +import { DoubleSearchPoints } from './activities/api/DoubleSearchPoints' // Browser import { SearchOnBing } from './activities/browser/SearchOnBing' import { Search } from './activities/browser/Search' -import type { BasePromotion, DashboardData, FindClippyPromotion } from '../interface/DashboardData' +import type { + BasePromotion, + DashboardData, + FindClippyPromotion, + PurplePromotionalItem +} from '../interface/DashboardData' import type { Promotion } from '../interface/AppDashBoardData' export default class Activities { @@ -68,9 +74,14 @@ export default class Activities { await quiz.doQuiz(promotion) } - doFindClippy = async (promotions: FindClippyPromotion): Promise => { - const urlReward = new FindClippy(this.bot) - await urlReward.doFindClippy(promotions) + doFindClippy = async (promotion: FindClippyPromotion): Promise => { + const findClippy = new FindClippy(this.bot) + await findClippy.doFindClippy(promotion) + } + + doDoubleSearchPoints = async (promotion: PurplePromotionalItem): Promise => { + const doubleSearchPoints = new DoubleSearchPoints(this.bot) + await doubleSearchPoints.doDoubleSearchPoints(promotion) } // App Activities diff --git a/src/functions/QueryEngine.ts b/src/functions/QueryEngine.ts index 4eb7569..752cfd2 100644 --- a/src/functions/QueryEngine.ts +++ b/src/functions/QueryEngine.ts @@ -1,22 +1,207 @@ import type { AxiosRequestConfig } from 'axios' -import type { - BingSuggestionResponse, - BingTrendingTopicsResponse, - GoogleSearch, - GoogleTrendsResponse -} from '../interface/Search' +import * as fs from 'fs' +import path from 'path' +import type { GoogleSearch, GoogleTrendsResponse, RedditListing, WikipediaTopResponse } from '../interface/Search' import type { MicrosoftRewardsBot } from '../index' +import { QueryEngine } from '../interface/Config' export class QueryCore { constructor(private bot: MicrosoftRewardsBot) {} + async queryManager( + options: { + shuffle?: boolean + sourceOrder?: QueryEngine[] + related?: boolean + langCode?: string + geoLocale?: string + } = {} + ): Promise { + const { + shuffle = false, + sourceOrder = ['google', 'wikipedia', 'reddit', 'local'], + related = true, + langCode = 'en', + geoLocale = 'US' + } = options + + try { + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `start | shuffle=${shuffle}, related=${related}, lang=${langCode}, geo=${geoLocale}, sources=${sourceOrder.join(',')}` + ) + + const topicLists: string[][] = [] + + const sourceHandlers: Record< + 'google' | 'wikipedia' | 'reddit' | 'local', + (() => Promise) | (() => string[]) + > = { + google: async () => { + const topics = await this.getGoogleTrends(geoLocale.toUpperCase()).catch(() => []) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `google: ${topics.length}`) + return topics + }, + wikipedia: async () => { + const topics = await this.getWikipediaTrending(langCode).catch(() => []) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `wikipedia: ${topics.length}`) + return topics + }, + reddit: async () => { + const topics = await this.getRedditTopics().catch(() => []) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `reddit: ${topics.length}`) + return topics + }, + local: () => { + const topics = this.getLocalQueryList() + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `local: ${topics.length}`) + return topics + } + } + + for (const source of sourceOrder) { + const handler = sourceHandlers[source] + if (!handler) continue + + const topics = await Promise.resolve(handler()) + if (topics.length) topicLists.push(topics) + } + + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `sources combined | rawTotal=${topicLists.flat().length}` + ) + + const baseTopics = this.normalizeAndDedupe(topicLists.flat()) + + if (!baseTopics.length) { + this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No queries') + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'No base topics found (all sources empty)') + return [] + } + + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `baseTopics dedupe | before=${topicLists.flat().length} | after=${baseTopics.length}` + ) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `baseTopics: ${baseTopics.length}`) + + const clusters = related ? await this.buildRelatedClusters(baseTopics, langCode) : baseTopics.map(t => [t]) + + this.bot.utils.shuffleArray(clusters) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'clusters shuffled') + + let finalQueries = clusters.flat() + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `clusters flattened | total=${finalQueries.length}` + ) + + // Do not cluster searches and shuffle + if (shuffle) { + this.bot.utils.shuffleArray(finalQueries) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'finalQueries shuffled') + } + + finalQueries = this.normalizeAndDedupe(finalQueries) + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `finalQueries dedupe | after=${finalQueries.length}` + ) + + if (!finalQueries.length) { + this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No queries') + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'finalQueries deduped to 0') + return [] + } + + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `final queries: ${finalQueries.length}`) + + return finalQueries + } catch (error) { + this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `error: ${error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)}` + ) + return [] + } + } + + private async buildRelatedClusters(baseTopics: string[], langCode: string): Promise { + const clusters: string[][] = [] + + const LIMIT = 50 + const head = baseTopics.slice(0, LIMIT) + const tail = baseTopics.slice(LIMIT) + + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `related enabled | baseTopics=${baseTopics.length} | expand=${head.length} | passthrough=${tail.length} | lang=${langCode}` + ) + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `bing expansion enabled | limit=${LIMIT} | totalCalls=${head.length * 2}` + ) + + for (const topic of head) { + const suggestions = await this.getBingSuggestions(topic, langCode).catch(() => []) + const relatedTerms = await this.getBingRelatedTerms(topic).catch(() => []) + + const usedSuggestions = suggestions.slice(0, 6) + const usedRelated = relatedTerms.slice(0, 3) + + const cluster = this.normalizeAndDedupe([topic, ...usedSuggestions, ...usedRelated]) + + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `cluster expanded | topic="${topic}" | suggestions=${suggestions.length}->${usedSuggestions.length} | related=${relatedTerms.length}->${usedRelated.length} | clusterSize=${cluster.length}` + ) + + clusters.push(cluster) + } + + if (tail.length) { + this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `cluster passthrough | topics=${tail.length}`) + + for (const topic of tail) { + clusters.push([topic]) + } + } + + return clusters + } + + private normalizeAndDedupe(queries: string[]): string[] { + const seen = new Set() + const out: string[] = [] + + for (const q of queries) { + if (!q) continue + const trimmed = q.trim() + if (!trimmed) continue + + const norm = trimmed.replace(/\s+/g, ' ').toLowerCase() + if (seen.has(norm)) continue + + seen.add(norm) + out.push(trimmed) + } + + return out + } + 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 = { @@ -29,163 +214,287 @@ export class QueryCore { } const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) - const rawData = response.data - - const trendsData = this.extractJsonFromResponse(rawData) + const trendsData = this.extractJsonFromResponse(response.data) if (!trendsData) { - throw this.bot.logger.error( - this.bot.isMobile, - 'SEARCH-GOOGLE-TRENDS', - 'Failed to parse Google Trends response' - ) + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No queries') + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No trendsData parsed from response') + return [] } - 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' - ) + const mapped = trendsData.map(q => [q[0], q[9]!.slice(1)]) + + if (mapped.length < 90 && geoLocale !== 'US') { return this.getGoogleTrends('US') } - for (const [topic, relatedQueries] of mappedTrendsData) { + for (const [topic, related] of mapped) { queryTerms.push({ topic: topic as string, - related: relatedQueries as string[] + related: related as string[] }) } } catch (error) { - this.bot.logger.error( + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No queries') + this.bot.logger.debug( this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `request failed: ${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` ) + return [] } - const queries = queryTerms.flatMap(x => [x.topic, ...x.related]) - - return queries + return queryTerms.flatMap(x => [x.topic, ...x.related]) } private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null { - const lines = text.split('\n') - for (const line of lines) { + for (const line of text.split('\n')) { const trimmed = line.trim() - if (trimmed.startsWith('[') && trimmed.endsWith(']')) { - try { - return JSON.parse(JSON.parse(trimmed)[0][2])[1] - } catch { - continue - } - } + if (!trimmed.startsWith('[')) continue + try { + return JSON.parse(JSON.parse(trimmed)[0][2])[1] + } catch {} } - 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}` - ) - + async getBingSuggestions(query = '', langCode = 'en'): Promise { try { const request: AxiosRequestConfig = { - url: `https://www.bingapis.com/api/v7/suggestions?q=${encodeURIComponent(query)}&appid=6D0A9B8C5100E9ECC7E11A104ADD76C10219804B&cc=xl&setlang=${langCode}`, + url: `https://www.bingapis.com/api/v7/suggestions?q=${encodeURIComponent( + query + )}&appid=6D0A9B8C5100E9ECC7E11A104ADD76C10219804B&cc=xl&setlang=${langCode}`, method: 'POST', headers: { + ...(this.bot.fingerprint?.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 suggestions = + response.data.suggestionGroups?.[0]?.searchSuggestions?.map((x: { query: any }) => x.query) ?? [] - const searchSuggestions = rawData.suggestionGroups[0]?.searchSuggestions - - if (!searchSuggestions?.length) { - this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'API returned no results') - return [] + if (!suggestions.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-BING-SUGGESTIONS', + `empty suggestions | query="${query}" | lang=${langCode}` + ) } - return searchSuggestions.map(x => x.query) + return suggestions } catch (error) { - this.bot.logger.error( + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'No queries') + this.bot.logger.debug( this.bot.isMobile, - 'SEARCH-GOOGLE-TRENDS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + 'SEARCH-BING-SUGGESTIONS', + `request failed | query="${query}" | lang=${langCode} | error=${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` ) + return [] } - - return [] } - async getBingRelatedTerms(term: string): Promise { + async getBingRelatedTerms(query: string): Promise { try { - const request = { - url: `https://api.bing.com/osjson.aspx?query=${term}`, + const request: AxiosRequestConfig = { + url: `https://api.bing.com/osjson.aspx?query=${encodeURIComponent(query)}`, method: 'GET', headers: { - 'Content-Type': 'application/json' + ...(this.bot.fingerprint?.headers ?? {}) } } const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) - const rawData = response.data + const related = response.data?.[1] + const out = Array.isArray(related) ? related : [] - const relatedTerms = rawData[1] - - if (!relatedTerms?.length) { - this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'API returned no results') - return [] + if (!out.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-BING-RELATED', + `empty related terms | query="${query}"` + ) } - return relatedTerms + return out } catch (error) { - this.bot.logger.error( + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'No queries') + this.bot.logger.debug( this.bot.isMobile, 'SEARCH-BING-RELATED', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `request failed | query="${query}" | error=${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` ) + return [] } - - return [] } - async getBingTendingTopics(langCode: string = 'en'): Promise { + async getBingTrendingTopics(langCode = 'en'): Promise { try { - const request = { + const request: AxiosRequestConfig = { url: `https://www.bing.com/api/v7/news/trendingtopics?appid=91B36E34F9D1B900E54E85A77CF11FB3BE5279E6&cc=xl&setlang=${langCode}`, method: 'GET', headers: { - 'Content-Type': 'application/json' + 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' } } const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) - const rawData: BingTrendingTopicsResponse = response.data + const topics = + response.data.value?.map( + (x: { query: { text: string }; name: string }) => x.query?.text?.trim() || x.name.trim() + ) ?? [] - const trendingTopics = rawData.value - - if (!trendingTopics?.length) { - this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'API returned no results') - return [] + if (!topics.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-BING-TRENDING', + `empty trending topics | lang=${langCode}` + ) } - const queries = trendingTopics.map(x => x.query?.text?.trim() || x.name.trim()) - - return queries + return topics } catch (error) { - this.bot.logger.error( + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'No queries') + this.bot.logger.debug( this.bot.isMobile, 'SEARCH-BING-TRENDING', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `request failed | lang=${langCode} | error=${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` ) + return [] } + } - return [] + async getWikipediaTrending(langCode = 'en'): Promise { + try { + const date = new Date(Date.now() - 24 * 60 * 60 * 1000) + const yyyy = date.getUTCFullYear() + const mm = String(date.getUTCMonth() + 1).padStart(2, '0') + const dd = String(date.getUTCDate()).padStart(2, '0') + + const request: AxiosRequestConfig = { + url: `https://wikimedia.org/api/rest_v1/metrics/pageviews/top/${langCode}.wikipedia/all-access/${yyyy}/${mm}/${dd}`, + method: 'GET', + headers: { + ...(this.bot.fingerprint?.headers ?? {}) + } + } + + const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) + const articles = (response.data as WikipediaTopResponse).items?.[0]?.articles ?? [] + + const out = articles.slice(0, 50).map(a => a.article.replace(/_/g, ' ')) + + if (!out.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-WIKIPEDIA-TRENDING', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-WIKIPEDIA-TRENDING', + `empty wikipedia top | lang=${langCode}` + ) + } + + return out + } catch (error) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-WIKIPEDIA-TRENDING', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-WIKIPEDIA-TRENDING', + `request failed | lang=${langCode} | error=${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` + ) + return [] + } + } + + async getRedditTopics(subreddit = 'popular'): Promise { + try { + const safe = subreddit.replace(/[^a-zA-Z0-9_+]/g, '') + const request: AxiosRequestConfig = { + url: `https://www.reddit.com/r/${safe}.json?limit=50`, + method: 'GET', + headers: { + ...(this.bot.fingerprint?.headers ?? {}) + } + } + + const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine) + const posts = (response.data as RedditListing).data?.children ?? [] + + const out = posts.filter(p => !p.data.over_18).map(p => p.data.title) + + if (!out.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-REDDIT-TRENDING', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-REDDIT-TRENDING', + `empty reddit listing | subreddit=${safe}` + ) + } + + return out + } catch (error) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-REDDIT', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-REDDIT', + `request failed | subreddit=${subreddit} | error=${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` + ) + return [] + } + } + + getLocalQueryList(): string[] { + try { + const file = path.join(__dirname, './search-queries.json') + const queries = JSON.parse(fs.readFileSync(file, 'utf8')) as string[] + const out = Array.isArray(queries) ? queries : [] + + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-LOCAL-QUERY-LIST', + 'local queries loaded | file=search-queries.json' + ) + + if (!out.length) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-LOCAL-QUERY-LIST', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-LOCAL-QUERY-LIST', + 'search-queries.json parsed but empty or invalid' + ) + } + + return out + } catch (error) { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-LOCAL-QUERY-LIST', 'No queries') + this.bot.logger.debug( + this.bot.isMobile, + 'SEARCH-LOCAL-QUERY-LIST', + `read/parse failed | error=${ + error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error) + }` + ) + return [] + } } } diff --git a/src/functions/SearchManager.ts b/src/functions/SearchManager.ts index 2aa63f9..08e04fd 100644 --- a/src/functions/SearchManager.ts +++ b/src/functions/SearchManager.ts @@ -76,7 +76,7 @@ export class SearchManager { this.bot.logger.info('main', 'SEARCH-MANAGER', 'Closing mobile session') try { - await executionContext.run({ isMobile: true, accountEmail }, async () => { + await executionContext.run({ isMobile: true, account }, async () => { await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail) }) this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed') @@ -368,7 +368,7 @@ export class SearchManager { `Init | account=${accountEmail} | proxy=${account.proxy ?? 'none'}` ) - const session = await this.bot['browserFactory'].createBrowser(account.proxy, accountEmail) + const session = await this.bot['browserFactory'].createBrowser(account) this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Browser created, new page') this.bot.mainDesktopPage = await session.context.newPage() @@ -377,7 +377,7 @@ export class SearchManager { 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) + await this.bot['login'].login(this.bot.mainDesktopPage, account) this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login passed, verifying') this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'verifyBingSession') diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 48e79e8..70175b0 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -1,6 +1,12 @@ import type { Page } from 'patchright' import type { MicrosoftRewardsBot } from '../index' -import type { DashboardData, PunchCard, BasePromotion, FindClippyPromotion } from '../interface/DashboardData' +import type { + DashboardData, + PunchCard, + BasePromotion, + FindClippyPromotion, + PurplePromotionalItem +} from '../interface/DashboardData' import type { AppDashboardData } from '../interface/AppDashBoardData' export class Workers { @@ -38,13 +44,14 @@ export class Workers { ] const activitiesUncompleted: BasePromotion[] = - morePromotions?.filter( - x => - !x.complete && - x.pointProgressMax > 0 && - x.exclusiveLockedFeatureStatus !== 'locked' && - x.promotionType - ) ?? [] + morePromotions?.filter(x => { + if (x.complete) return false + if (x.pointProgressMax <= 0) return false + if (x.exclusiveLockedFeatureStatus === 'locked') return false + if (!x.promotionType) return false + + return true + }) ?? [] if (!activitiesUncompleted.length) { this.bot.logger.info( @@ -67,13 +74,14 @@ export class Workers { } 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' - ) + const appRewards = data.response.promotions.filter(x => { + if (x.attributes['complete']?.toLowerCase() !== 'false') return false + if (!x.attributes['offerid']) return false + if (!x.attributes['type']) return false + if (x.attributes['type'] !== 'sapphire') return false + + return true + }) if (!appRewards.length) { this.bot.logger.info( @@ -93,6 +101,77 @@ export class Workers { this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'All "App Promotions" items have been completed') } + public async doSpecialPromotions(data: DashboardData) { + const specialPromotions: PurplePromotionalItem[] = [ + ...new Map( + [...(data.promotionalItems ?? [])] + .filter(Boolean) + .map(p => [p.offerId, p as PurplePromotionalItem] as const) + ).values() + ] + + const supportedPromotions = ['ww_banner_optin_2x'] + + const specialPromotionsUncompleted: PurplePromotionalItem[] = + specialPromotions?.filter(x => { + if (x.complete) return false + if (x.exclusiveLockedFeatureStatus === 'locked') return false + if (!x.promotionType) return false + + const offerId = (x.offerId ?? '').toLowerCase() + return supportedPromotions.some(s => offerId.includes(s)) + }) ?? [] + + for (const activity of specialPromotionsUncompleted) { + try { + const type = activity.promotionType?.toLowerCase() ?? '' + const name = activity.name?.toLowerCase() ?? '' + const offerId = (activity as PurplePromotionalItem).offerId + + this.bot.logger.debug( + this.bot.isMobile, + 'SPECIAL-ACTIVITY', + `Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type}"` + ) + + switch (type) { + // UrlReward + case 'urlreward': { + // Special "Double Search Points" activation + if (name.includes('ww_banner_optin_2x')) { + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Found activity type "Double Search Points" | title="${activity.title}" | offerId=${offerId}` + ) + + await this.bot.activities.doDoubleSearchPoints(activity) + } + break + } + + // Unsupported types + default: { + this.bot.logger.warn( + this.bot.isMobile, + 'SPECIAL-ACTIVITY', + `Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"` + ) + break + } + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'SPECIAL-ACTIVITY', + `Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } + + this.bot.logger.info(this.bot.isMobile, 'SPECIAL-ACTIVITY', 'All "Special Activites" items have been completed') + } + private async solveActivities(activities: BasePromotion[], page: Page, punchCard?: PunchCard) { for (const activity of activities) { try { diff --git a/src/functions/activities/api/DoubleSearchPoints.ts b/src/functions/activities/api/DoubleSearchPoints.ts new file mode 100644 index 0000000..e3fcda2 --- /dev/null +++ b/src/functions/activities/api/DoubleSearchPoints.ts @@ -0,0 +1,124 @@ +import type { AxiosRequestConfig } from 'axios' +import { Workers } from '../../Workers' +import { PromotionalItem } from '../../../interface/DashboardData' + +export class DoubleSearchPoints extends Workers { + private cookieHeader: string = '' + + private fingerprintHeader: { [x: string]: string } = {} + + public async doDoubleSearchPoints(promotion: PromotionalItem) { + const offerId = promotion.offerId + const activityType = promotion.activityType + + try { + if (!this.bot.requestToken) { + this.bot.logger.warn( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + '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, + 'DOUBLE-SEARCH-POINTS', + `Starting Double Search Points | offerId=${offerId}` + ) + + this.bot.logger.debug( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + `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, + 'DOUBLE-SEARCH-POINTS', + `Prepared Double Search Points 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, + 'DOUBLE-SEARCH-POINTS', + `Sending Double Search Points request | offerId=${offerId} | url=${request.url}` + ) + + const response = await this.bot.axios.request(request) + + this.bot.logger.debug( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + `Received Double Search Points response | offerId=${offerId} | status=${response.status}` + ) + + const data = await this.bot.browser.func.getDashboardData() + const promotionalItem = data.promotionalItems.find(item => + item.name.toLowerCase().includes('ww_banner_optin_2x') + ) + + // If OK, should no longer be presernt in promotionalItems + if (promotionalItem) { + this.bot.logger.warn( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + `Unable to find or activate Double Search Points | offerId=${offerId} | status=${response.status}` + ) + } else { + this.bot.logger.info( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + `Activated Double Search Points | offerId=${offerId} | status=${response.status}`, + 'green' + ) + } + + this.bot.logger.debug( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + `Waiting after Double Search Points | offerId=${offerId}` + ) + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000)) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'DOUBLE-SEARCH-POINTS', + `Error in doDoubleSearchPoints | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/functions/activities/browser/Search.ts b/src/functions/activities/browser/Search.ts index 6e3cc6e..ada6f01 100644 --- a/src/functions/activities/browser/Search.ts +++ b/src/functions/activities/browser/Search.ts @@ -33,38 +33,34 @@ export class Search extends Workers { `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 ?? 'US').toUpperCase() + const langCode = (this.bot.userData.langCode ?? 'en').toLowerCase() - const locale = this.bot.userData.geoLocale.toUpperCase() + this.bot.logger.debug( + isMobile, + 'SEARCH-BING', + `Resolving search queries via QueryCore | locale=${locale} | lang=${langCode} | related=true` + ) - this.bot.logger.debug(isMobile, 'SEARCH-BING', `Resolving search queries | locale=${locale}`) + let queries = await queryCore.queryManager({ + shuffle: true, + related: true, + langCode, + geoLocale: locale, + sourceOrder: ['google', 'wikipedia', 'reddit', 'local'] + }) - // Set Google search queries - queries = await queryCore.getGoogleTrends(locale) + queries = [...new Set(queries.map(q => q.trim()).filter(Boolean))] - 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}`) + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Query pool ready | 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 @@ -77,7 +73,6 @@ export class Search extends Workers { 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) @@ -91,12 +86,10 @@ export class Search extends Workers { } 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( @@ -107,10 +100,8 @@ export class Search extends Workers { ) } - // Update loop state missingPointsTotal = newMissingPointsTotal - // Completed if (missingPointsTotal === 0) { this.bot.logger.info( isMobile, @@ -120,7 +111,6 @@ export class Search extends Workers { break } - // Stuck if (stagnantLoop > stagnantLoopMax) { this.bot.logger.warn( isMobile, @@ -130,105 +120,123 @@ export class Search extends Workers { stagnantLoop = 0 break } + + const remainingQueries = queries.length - (i + 1) + const minBuffer = 20 + if (missingPointsTotal > 0 && remainingQueries < minBuffer) { + this.bot.logger.warn( + isMobile, + 'SEARCH-BING', + `Low query buffer while still missing points, regenerating | remainingQueries=${remainingQueries} | missing=${missingPointsTotal}` + ) + + const extra = await queryCore.queryManager({ + shuffle: true, + related: true, + langCode, + geoLocale: locale, + sourceOrder: this.bot.config.searchSettings.queryEngines + }) + + const merged = [...queries, ...extra].map(q => q.trim()).filter(Boolean) + queries = [...new Set(merged)] + queries = this.bot.utils.shuffleArray(queries) + + this.bot.logger.debug(isMobile, 'SEARCH-BING', `Query pool regenerated | count=${queries.length}`) + } } if (missingPointsTotal > 0) { this.bot.logger.info( isMobile, 'SEARCH-BING', - `Search completed but still missing points, generating extra searches | remaining=${missingPointsTotal}` + `Search completed but still missing points, continuing with regenerated queries | remaining=${missingPointsTotal}` ) - let i = 0 let stagnantLoop = 0 const stagnantLoopMax = 5 while (missingPointsTotal > 0) { - const query = queries[i++] as string + const extra = await queryCore.queryManager({ + shuffle: true, + related: true, + langCode, + geoLocale: locale, + sourceOrder: this.bot.config.searchSettings.queryEngines + }) + + const merged = [...queries, ...extra].map(q => q.trim()).filter(Boolean) + const newPool = [...new Set(merged)] + queries = this.bot.utils.shuffleArray(newPool) this.bot.logger.debug( isMobile, 'SEARCH-BING-EXTRA', - `Fetching related terms for extra searches | baseQuery="${query}"` + `New query pool generated | count=${queries.length}` ) - const relatedTerms = await queryCore.getBingRelatedTerms(query) - this.bot.logger.debug( - isMobile, - 'SEARCH-BING-EXTRA', - `Related terms resolved | baseQuery="${query}" | count=${relatedTerms.length}` - ) + for (const query of queries) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + `Extra search | remaining=${missingPointsTotal} | query="${query}"` + ) - if (relatedTerms.length > 3) { - for (const term of relatedTerms.slice(1, 3)) { + searchCounters = await this.bingSearch(page, query, isMobile) + const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile) + const newMissingPointsTotal = newMissingPoints.totalPoints + + const rawGained = missingPointsTotal - newMissingPointsTotal + const gainedPoints = Math.max(0, rawGained) + + if (gainedPoints === 0) { + stagnantLoop++ this.bot.logger.info( isMobile, 'SEARCH-BING-EXTRA', - `Extra search | remaining=${missingPointsTotal} | query="${term}"` + `No points gained ${stagnantLoop}/${stagnantLoopMax} | query="${query}" | remaining=${newMissingPointsTotal}` ) + } else { + stagnantLoop = 0 - searchCounters = await this.bingSearch(page, term, isMobile) - const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile) - const newMissingPointsTotal = newMissingPoints.totalPoints + const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints + this.bot.userData.currentPoints = newBalance + this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints - // Points gained for THIS extra query only - const rawGained = missingPointsTotal - newMissingPointsTotal - const gainedPoints = Math.max(0, rawGained) + totalGainedPoints += gainedPoints - 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 + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + `gainedPoints=${gainedPoints} points | query="${query}" | remaining=${newMissingPointsTotal}`, + 'green' + ) + } - // 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 + missingPointsTotal = newMissingPointsTotal - // Track for return value - totalGainedPoints += gainedPoints + if (missingPointsTotal === 0) { + this.bot.logger.info( + isMobile, + 'SEARCH-BING-EXTRA', + 'All required search points earned during extra searches' + ) + break + } - 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 - } + if (stagnantLoop > stagnantLoopMax) { + this.bot.logger.warn( + isMobile, + 'SEARCH-BING-EXTRA', + `Search did not gain points for ${stagnantLoopMax} 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 } } } @@ -259,7 +267,6 @@ export class Search extends Workers { this.searchCount++ - // Page fill seems to get more sluggish over time if (this.searchCount % refreshThreshold === 0) { this.bot.logger.info( isMobile, @@ -271,7 +278,7 @@ export class Search extends Workers { 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 + await this.bot.browser.utils.tryDismissAllMessages(searchPage) } this.bot.logger.debug( @@ -402,11 +409,9 @@ export class Search extends Workers { 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() diff --git a/src/functions/activities/browser/SearchOnBing.ts b/src/functions/activities/browser/SearchOnBing.ts index 686c351..908fb43 100644 --- a/src/functions/activities/browser/SearchOnBing.ts +++ b/src/functions/activities/browser/SearchOnBing.ts @@ -232,7 +232,7 @@ export class SearchOnBing extends Workers { 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') + const data = fs.readFileSync(path.join(__dirname, '../bing-search-activity-queries.json'), 'utf8') queries = JSON.parse(data) this.bot.logger.debug( @@ -250,7 +250,7 @@ export class SearchOnBing extends Workers { // 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/v3/src/functions/queries.json' + url: 'https://raw.githubusercontent.com/TheNetsky/Microsoft-Rewards-Script/refs/heads/v3/src/functions/bing-search-activity-queries.json' }) queries = response.data diff --git a/src/functions/bing-search-activity-queries.json b/src/functions/bing-search-activity-queries.json new file mode 100644 index 0000000..54a56f0 --- /dev/null +++ b/src/functions/bing-search-activity-queries.json @@ -0,0 +1,582 @@ +[ + { + "title": "Houses near you", + "queries": [ + "Houses near me", + "Homes for sale near me", + "Apartments near me", + "Real estate listings near me", + "Zillow homes near me", + "houses for rent near me" + ] + }, + { + "title": "Feeling symptoms?", + "queries": [ + "Rash on forearm", + "Stuffy nose", + "Tickling cough", + "sore throat remedies", + "headache and nausea causes", + "fever symptoms adults" + ] + }, + { + "title": "Get your shopping done faster", + "queries": [ + "Buy PS5", + "Buy Xbox", + "Chair deals", + "wireless mouse deals", + "best gaming headset price", + "laptop deals", + "buy office chair", + "SSD deals" + ] + }, + { + "title": "Translate anything", + "queries": [ + "Translate welcome home to Korean", + "Translate welcome home to Japanese", + "Translate goodbye to Japanese", + "Translate good morning to Spanish", + "Translate thank you to French", + "Translate see you later to Italian" + ] + }, + { + "title": "Search the lyrics of a song", + "queries": [ + "Debarge rhythm of the night lyrics", + "bohemian rhapsody lyrics", + "hotel california lyrics", + "blinding lights lyrics", + "lose yourself lyrics", + "smells like teen spirit lyrics" + ] + }, + { + "title": "Let's watch that movie again!", + "queries": [ + "Alien movie", + "Aliens movie", + "Alien 3 movie", + "Predator movie", + "Terminator movie", + "John Wick movie", + "Interstellar movie", + "The Matrix movie" + ] + }, + { + "title": "Plan a quick getaway", + "queries": [ + "Flights Amsterdam to Tokyo", + "Flights New York to Tokyo", + "cheap flights to paris", + "flights amsterdam to rome", + "last minute flight deals", + "direct flights from amsterdam", + "weekend getaway europe", + "best time to visit tokyo" + ] + }, + { + "title": "Discover open job roles", + "queries": [ + "jobs at Microsoft", + "Microsoft Job Openings", + "Jobs near me", + "jobs at Boeing worked", + "software engineer jobs near me", + "remote developer jobs", + "IT jobs netherlands", + "customer support jobs near me" + ] + }, + { + "title": "You can track your package", + "queries": [ + "USPS tracking", + "UPS tracking", + "DHL tracking", + "FedEx tracking", + "track my package", + "international package tracking" + ] + }, + { + "title": "Find somewhere new to explore", + "queries": [ + "Directions to Berlin", + "Directions to Tokyo", + "Directions to New York", + "things to do in berlin", + "tourist attractions tokyo", + "best places to visit in new york", + "hidden gems near me", + "day trips near me" + ] + }, + { + "title": "Too tired to cook tonight?", + "queries": [ + "KFC near me", + "Burger King near me", + "McDonalds near me", + "pizza delivery near me", + "restaurants open now", + "best takeout near me", + "quick dinner ideas", + "easy dinner recipes" + ] + }, + { + "title": "Quickly convert your money", + "queries": [ + "convert 250 USD to yen", + "convert 500 USD to yen", + "usd to eur", + "gbp to eur", + "eur to jpy", + "currency converter", + "exchange rate today", + "1000 yen to euro" + ] + }, + { + "title": "Learn to cook a new recipe", + "queries": [ + "How to cook ratatouille", + "How to cook lasagna", + "easy pasta recipe", + "how to make pancakes", + "how to make fried rice", + "simple chicken recipe" + ] + }, + { + "title": "Find places to stay!", + "queries": [ + "Hotels Berlin Germany", + "Hotels Amsterdam Netherlands", + "hotels in paris", + "best hotels in tokyo", + "cheap hotels london", + "places to stay in barcelona", + "hotel deals", + "booking hotels near me" + ] + }, + { + "title": "How's the economy?", + "queries": [ + "sp 500", + "nasdaq", + "dow jones today", + "inflation rate europe", + "interest rates today", + "stock market today", + "economic news", + "recession forecast" + ] + }, + { + "title": "Who won?", + "queries": [ + "braves score", + "champions league results", + "premier league results", + "nba score", + "formula 1 winner", + "latest football scores", + "ucl final winner", + "world cup final result" + ] + }, + { + "title": "Gaming time", + "queries": [ + "Overwatch video game", + "Call of duty video game", + "best games 2025", + "top xbox games", + "popular steam games", + "new pc games", + "game reviews", + "best co-op games" + ] + }, + { + "title": "Expand your vocabulary", + "queries": [ + "definition definition", + "meaning of serendipity", + "define nostalgia", + "synonym for happy", + "define eloquent", + "what does epiphany mean", + "word of the day", + "define immaculate" + ] + }, + { + "title": "What time is it?", + "queries": [ + "Japan time", + "New York time", + "time in london", + "time in tokyo", + "current time in amsterdam", + "time in los angeles" + ] + }, + { + "title": "Find deals on Bing", + "queries": [ + "best laptop deals", + "tech deals today", + "wireless earbuds deals", + "gaming chair deals", + "discount codes electronics", + "best amazon deals today", + "smartphone deals", + "ssd deals" + ] + }, + { + "title": "Prepare for the weather", + "queries": [ + "weather tomorrow", + "weekly weather forecast", + "rain forecast today", + "weather in amsterdam", + "storm forecast europe", + "uv index today", + "temperature this weekend", + "snow forecast" + ] + }, + { + "title": "Track your delivery", + "queries": [ + "track my package", + "postnl track and trace", + "dhl parcel tracking", + "ups tracking", + "fedex tracking", + "usps tracking", + "parcel tracking", + "international package tracking" + ] + }, + { + "title": "Explore a new spot today", + "queries": [ + "places to visit near me", + "things to do near me", + "hidden gems netherlands", + "best museums near me", + "parks near me", + "tourist attractions nearby", + "best cafes near me", + "day trip ideas" + ] + }, + { + "title": "Maisons près de chez vous", + "queries": [ + "Maisons près de chez moi", + "Maisons à vendre près de chez moi", + "Appartements près de chez moi", + "Annonces immobilières près de chez moi", + "Maisons à louer près de chez moi" + ] + }, + { + "title": "Vous ressentez des symptômes ?", + "queries": [ + "Éruption cutanée sur l'avant-bras", + "Nez bouché", + "Toux chatouilleuse", + "mal de gorge remèdes", + "maux de tête causes", + "symptômes de la grippe" + ] + }, + { + "title": "Faites vos achats plus vite", + "queries": [ + "Acheter une PS5", + "Acheter une Xbox", + "Offres sur les chaises", + "offres ordinateur portable", + "meilleures offres casque", + "acheter souris sans fil", + "promotions ssd", + "bons plans tech" + ] + }, + { + "title": "Traduisez tout !", + "queries": [ + "Traduction bienvenue à la maison en coréen", + "Traduction bienvenue à la maison en japonais", + "Traduction au revoir en japonais", + "Traduire bonjour en espagnol", + "Traduire merci en anglais", + "Traduire à plus tard en italien" + ] + }, + { + "title": "Rechercher paroles de chanson", + "queries": [ + "Paroles de Debarge rhythm of the night", + "paroles bohemian rhapsody", + "paroles hotel california", + "paroles blinding lights", + "paroles lose yourself", + "paroles smells like teen spirit" + ] + }, + { + "title": "Et si nous regardions ce film une nouvelle fois?", + "queries": [ + "Alien film", + "Film Aliens", + "Film Alien 3", + "Film Predator", + "Film Terminator", + "Film John Wick", + "Film Interstellar", + "Film Matrix" + ] + }, + { + "title": "Planifiez une petite escapade", + "queries": [ + "Vols Amsterdam-Tokyo", + "Vols New York-Tokyo", + "vols pas chers paris", + "vols amsterdam rome", + "offres vols dernière minute", + "week-end en europe", + "vols directs depuis amsterdam" + ] + }, + { + "title": "Consulter postes à pourvoir", + "queries": [ + "emplois chez Microsoft", + "Offres d'emploi Microsoft", + "Emplois près de chez moi", + "emplois chez Boeing", + "emplois développeur à distance", + "emplois informatique pays-bas", + "offres d'emploi près de chez moi" + ] + }, + { + "title": "Vous pouvez suivre votre colis", + "queries": [ + "Suivi Chronopost", + "suivi colis", + "suivi DHL", + "suivi UPS", + "suivi FedEx", + "suivi international colis" + ] + }, + { + "title": "Trouver un endroit à découvrir", + "queries": [ + "Itinéraire vers Berlin", + "Itinéraire vers Tokyo", + "Itinéraire vers New York", + "que faire à berlin", + "attractions tokyo", + "meilleurs endroits à visiter à new york", + "endroits à visiter près de chez moi" + ] + }, + { + "title": "Trop fatigué pour cuisiner ce soir ?", + "queries": [ + "KFC près de chez moi", + "Burger King près de chez moi", + "McDonalds près de chez moi", + "livraison pizza près de chez moi", + "restaurants ouverts maintenant", + "idées dîner rapide", + "quoi manger ce soir" + ] + }, + { + "title": "Convertissez rapidement votre argent", + "queries": [ + "convertir 250 EUR en yen", + "convertir 500 EUR en yen", + "usd en eur", + "gbp en eur", + "eur en jpy", + "convertisseur de devises", + "taux de change aujourd'hui", + "1000 yen en euro" + ] + }, + { + "title": "Apprenez à cuisiner une nouvelle recette", + "queries": [ + "Comment faire cuire la ratatouille", + "Comment faire cuire les lasagnes", + "recette pâtes facile", + "comment faire des crêpes", + "recette riz sauté", + "recette poulet simple" + ] + }, + { + "title": "Trouvez des emplacements pour rester!", + "queries": [ + "Hôtels Berlin Allemagne", + "Hôtels Amsterdam Pays-Bas", + "hôtels paris", + "meilleurs hôtels tokyo", + "hôtels pas chers londres", + "hébergement barcelone", + "offres hôtels", + "hôtels près de chez moi" + ] + }, + { + "title": "Comment se porte l'économie ?", + "queries": [ + "CAC 40", + "indice dax", + "dow jones aujourd'hui", + "inflation europe", + "taux d'intérêt aujourd'hui", + "marché boursier aujourd'hui", + "actualités économie", + "prévisions récession" + ] + }, + { + "title": "Qui a gagné ?", + "queries": [ + "score du Paris Saint-Germain", + "résultats ligue des champions", + "résultats premier league", + "score nba", + "vainqueur formule 1", + "derniers scores football", + "vainqueur finale ldc" + ] + }, + { + "title": "Temps de jeu", + "queries": [ + "Jeu vidéo Overwatch", + "Jeu vidéo Call of Duty", + "meilleurs jeux 2025", + "top jeux xbox", + "jeux steam populaires", + "nouveaux jeux pc", + "avis jeux vidéo", + "meilleurs jeux coop" + ] + }, + { + "title": "Enrichissez votre vocabulaire", + "queries": [ + "definition definition", + "signification sérendipité", + "définir nostalgie", + "synonyme heureux", + "définir éloquent", + "mot du jour", + "que veut dire épiphanie" + ] + }, + { + "title": "Quelle heure est-il ?", + "queries": [ + "Heure du Japon", + "Heure de New York", + "heure de londres", + "heure de tokyo", + "heure actuelle amsterdam", + "heure de los angeles" + ] + }, + { + "title": "Vérifier la météo", + "queries": [ + "Météo de Paris", + "Météo de la France", + "météo demain", + "prévisions météo semaine", + "météo amsterdam", + "risque de pluie aujourd'hui" + ] + }, + { + "title": "Tenez-vous informé des sujets d'actualité", + "queries": [ + "Augmentation Impots", + "Mort célébrité", + "actualités france", + "actualité internationale", + "dernières nouvelles économie", + "news technologie" + ] + }, + { + "title": "Préparez-vous pour la météo", + "queries": [ + "météo demain", + "prévisions météo semaine", + "météo amsterdam", + "risque de pluie aujourd'hui", + "indice uv aujourd'hui", + "température ce week-end", + "alerte tempête" + ] + }, + { + "title": "Suivez votre livraison", + "queries": [ + "suivi colis", + "postnl suivi colis", + "suivi DHL colis", + "suivi UPS", + "suivi FedEx", + "suivi international colis", + "suivre ma livraison" + ] + }, + { + "title": "Trouvez des offres sur Bing", + "queries": [ + "meilleures offres ordinateur portable", + "bons plans tech", + "promotions écouteurs", + "offres chaise gamer", + "codes promo électronique", + "meilleures offres amazon aujourd'hui" + ] + }, + { + "title": "Explorez un nouvel endroit aujourd'hui", + "queries": [ + "endroits à visiter près de chez moi", + "que faire près de chez moi", + "endroits insolites pays-bas", + "meilleurs musées près de chez moi", + "parcs près de chez moi", + "attractions touristiques à proximité", + "meilleurs cafés près de chez moi" + ] + } +] \ No newline at end of file diff --git a/src/functions/search-queries.json b/src/functions/search-queries.json new file mode 100644 index 0000000..c73b6b1 --- /dev/null +++ b/src/functions/search-queries.json @@ -0,0 +1,116 @@ +[ + "weather tomorrow", + "how to cook pasta", + "best movies 2024", + "latest tech news", + "how tall is the eiffel tower", + "easy dinner recipes", + "what time is it in japan", + "how does photosynthesis work", + "best budget smartphones", + "coffee vs espresso difference", + "how to improve wifi signal", + "popular netflix series", + "how many calories in an apple", + "world population today", + "best free pc games", + "how to clean a keyboard", + "what is artificial intelligence", + "simple home workouts", + "how long do cats live", + "famous paintings in museums", + "how to boil eggs", + "latest windows updates", + "how to screenshot on windows", + "best travel destinations europe", + "what is cloud computing", + "how to save money monthly", + "best youtube channels", + "how fast is light", + "how to learn programming", + "popular board games", + "how to make pancakes", + "capital cities of europe", + "how does a vpn work", + "best productivity apps", + "how to grow plants indoors", + "difference between hdd and ssd", + "how to fix slow computer", + "most streamed songs", + "how to tie a tie", + "what causes rain", + "best laptops for students", + "how to reset router", + "healthy breakfast ideas", + "how many continents are there", + "latest smartphone features", + "how to meditate beginners", + "what is renewable energy", + "best pc accessories", + "how to clean glasses", + "famous landmarks worldwide", + "how to make coffee at home", + "what is machine learning", + "best programming languages", + "how to backup files", + "how does bluetooth work", + "top video games right now", + "how to improve sleep quality", + "what is cryptocurrency", + "easy lunch ideas", + "how to check internet speed", + "best noise cancelling headphones", + "how to take screenshots on mac", + "what is the milky way", + "how to organize files", + "popular mobile apps", + "how to learn faster", + "how does gps work", + "best free antivirus", + "how to clean a monitor", + "what is an electric car", + "simple math tricks", + "how to update drivers", + "famous scientists", + "how to cook rice", + "what is the tallest mountain", + "best tv shows all time", + "how to improve typing speed", + "how does solar power work", + "easy dessert recipes", + "how to fix bluetooth issues", + "what is the internet", + "best pc keyboards", + "how to stay focused", + "popular science facts", + "how to convert files to pdf", + "how long does it take to sleep", + "best travel tips", + "how to clean headphones", + "what is open source software", + "how to manage time better", + "latest gaming news", + "how to check laptop temperature", + "what is a firewall", + "easy meal prep ideas", + "how to reduce eye strain", + "best budget headphones", + "how does email work", + "what is virtual reality", + "how to compress files", + "popular programming tools", + "how to improve concentration", + "how to make smoothies", + "best desk setup ideas", + "how to block ads", + "what is 5g technology", + "how to clean a mouse", + "famous world wonders", + "how to improve battery life", + "best cloud storage services", + "how to learn a new language", + "what is dark mode", + "how to clear browser cache", + "popular tech podcasts", + "how to stay motivated" +] \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e158cb1..d606a3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import BrowserUtils from './browser/BrowserUtils' import { IpcLog, Logger } from './logging/Logger' import Utils from './util/Utils' import { loadAccounts, loadConfig } from './util/Load' +import { checkNodeVersion } from './util/Validator' import { Login } from './browser/auth/Login' import { Workers } from './functions/Workers' @@ -27,7 +28,7 @@ import type { AppDashboardData } from './interface/AppDashBoardData' interface ExecutionContext { isMobile: boolean - accountEmail: string + account: Account } interface BrowserSession { @@ -50,7 +51,7 @@ const executionContext = new AsyncLocalStorage() export function getCurrentContext(): ExecutionContext { const context = executionContext.getStore() if (!context) { - return { isMobile: false, accountEmail: 'unknown' } + return { isMobile: false, account: {} as any } } return context } @@ -62,6 +63,7 @@ async function flushAllWebhooks(timeoutMs = 5000): Promise { interface UserData { userName: string geoLocale: string + langCode: string initialPoints: number currentPoints: number gainedPoints: number @@ -99,7 +101,8 @@ export class MicrosoftRewardsBot { constructor() { this.userData = { userName: '', - geoLocale: '', + geoLocale: 'US', + langCode: 'en', initialPoints: 0, currentPoints: 0, gainedPoints: 0 @@ -134,7 +137,7 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'RUN-START', - `Starting Microsoft Rewards bot| v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}` + `Starting Microsoft Rewards Script | v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}` ) if (this.config.clusters > 1) { @@ -185,11 +188,14 @@ export class MicrosoftRewardsBot { const onWorkerDone = async (label: 'exit' | 'disconnect', worker: Worker, code?: number): Promise => { const { pid } = worker.process - - if (!pid || this.exitedWorkers.includes(pid)) return - else this.exitedWorkers.push(pid) - this.activeWorkers -= 1 + + if (!pid || this.exitedWorkers.includes(pid)) { + return + } else { + this.exitedWorkers.push(pid) + } + this.logger.warn( 'main', `CLUSTER-WORKER-${label.toUpperCase()}`, @@ -233,6 +239,7 @@ export class MicrosoftRewardsBot { if (process.send) { process.send({ __stats: stats }) } + process.disconnect() } catch (error) { this.logger.error( @@ -355,14 +362,14 @@ export class MicrosoftRewardsBot { let mobileContextClosed = false try { - return await executionContext.run({ isMobile: true, accountEmail }, async () => { - mobileSession = await this.browserFactory.createBrowser(account.proxy, accountEmail) + return await executionContext.run({ isMobile: true, account }, async () => { + mobileSession = await this.browserFactory.createBrowser(account) 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) + await this.login.login(this.mainMobilePage, account) try { this.accessToken = await this.login.getAppAccessToken(this.mainMobilePage, accountEmail) @@ -410,6 +417,7 @@ export class MicrosoftRewardsBot { 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.doSpecialPromotions) await this.workers.doSpecialPromotions(data) 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() @@ -448,7 +456,7 @@ export class MicrosoftRewardsBot { } finally { if (mobileSession && !mobileContextClosed) { try { - await executionContext.run({ isMobile: true, accountEmail }, async () => { + await executionContext.run({ isMobile: true, account }, async () => { await this.browser.func.closeBrowser(mobileSession!.context, accountEmail) }) } catch {} @@ -460,6 +468,8 @@ export class MicrosoftRewardsBot { export { executionContext } async function main(): Promise { + // Check before doing anything + checkNodeVersion() const rewardsBot = new MicrosoftRewardsBot() process.on('beforeExit', () => { diff --git a/src/interface/Account.ts b/src/interface/Account.ts index 2177dfa..a126b33 100644 --- a/src/interface/Account.ts +++ b/src/interface/Account.ts @@ -1,9 +1,12 @@ export interface Account { email: string password: string - totp?: string + totpSecret?: string + recoveryEmail: string geoLocale: 'auto' | string + langCode: 'en' | string proxy: AccountProxy + saveFingerprint: ConfigSaveFingerprint } export interface AccountProxy { @@ -13,3 +16,8 @@ export interface AccountProxy { password: string username: string } + +export interface ConfigSaveFingerprint { + mobile: boolean + desktop: boolean +} diff --git a/src/interface/Config.ts b/src/interface/Config.ts index f862e68..108b48f 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -5,7 +5,6 @@ export interface Config { runOnZeroPoints: boolean clusters: number errorDiagnostics: boolean - saveFingerprint: ConfigSaveFingerprint workers: ConfigWorkers searchOnBingLocalQueries: boolean globalTimeout: number | string @@ -16,15 +15,13 @@ export interface Config { webhook: ConfigWebhook } -export interface ConfigSaveFingerprint { - mobile: boolean - desktop: boolean -} +export type QueryEngine = 'google' | 'wikipedia' | 'reddit' | 'local' export interface ConfigSearchSettings { scrollRandomResults: boolean clickRandomResults: boolean parallelSearching: boolean + queryEngines: QueryEngine[] searchResultVisitTime: number | string searchDelay: ConfigDelay readDelay: ConfigDelay @@ -41,6 +38,7 @@ export interface ConfigProxy { export interface ConfigWorkers { doDailySet: boolean + doSpecialPromotions: boolean doMorePromotions: boolean doPunchCards: boolean doAppPromotions: boolean diff --git a/src/interface/Search.ts b/src/interface/Search.ts index d507246..5af3792 100644 --- a/src/interface/Search.ts +++ b/src/interface/Search.ts @@ -94,3 +94,23 @@ export interface BingTrendingImage { export interface BingTrendingQuery { text: string } + +export interface WikipediaTopResponse { + items: Array<{ + articles: Array<{ + article: string + views: number + }> + }> +} + +export interface RedditListing { + data: { + children: Array<{ + data: { + title: string + over_18: boolean + } + }> + } +} diff --git a/src/logging/Logger.ts b/src/logging/Logger.ts index 60a6234..2919a20 100644 --- a/src/logging/Logger.ts +++ b/src/logging/Logger.ts @@ -73,10 +73,10 @@ export class Logger { const now = new Date().toLocaleString() const formatted = formatMessage(message) + const userName = this.bot.userData.userName ? this.bot.userData.userName : 'MAIN' + const levelTag = level.toUpperCase() - const cleanMsg = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${platformText( - isMobile - )} [${title}] ${formatted}` + const cleanMsg = `[${now}] [${userName}] [${levelTag}] ${platformText(isMobile)} [${title}] ${formatted}` const config = this.bot.config @@ -85,7 +85,7 @@ export class Logger { } const badge = platformBadge(isMobile) - const consoleStr = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${badge} [${title}] ${formatted}` + const consoleStr = `[${now}] [${userName}] [${levelTag}] ${badge} [${title}] ${formatted}` let logColor: ColorKey | undefined = color diff --git a/src/util/Axios.ts b/src/util/Axios.ts index f00d27d..3a1f3c8 100644 --- a/src/util/Axios.ts +++ b/src/util/Axios.ts @@ -2,6 +2,7 @@ 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 { SocksProxyAgent } from 'socks-proxy-agent' import { URL } from 'url' import type { AccountProxy } from '../interface/Account' @@ -36,7 +37,9 @@ class AxiosClient { }) } - private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent | HttpsProxyAgent { + private getAgentForProxy( + proxyConfig: AccountProxy + ): HttpProxyAgent | HttpsProxyAgent | SocksProxyAgent { const { url: baseUrl, port, username, password } = proxyConfig let urlObj: URL @@ -67,8 +70,11 @@ class AxiosClient { return new HttpProxyAgent(proxyUrl) case 'https:': return new HttpsProxyAgent(proxyUrl) + case 'socks4:': + case 'socks5:': + return new SocksProxyAgent(proxyUrl) default: - throw new Error(`Unsupported proxy protocol: ${protocol}. Only HTTP(S) is supported!`) + throw new Error(`Unsupported proxy protocol: ${protocol}. Only HTTP(S) and SOCKS4/5 are supported!`) } } diff --git a/src/util/Load.ts b/src/util/Load.ts index 3c37cf3..3e75f99 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -3,8 +3,9 @@ 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' +import type { Account, ConfigSaveFingerprint } from '../interface/Account' +import type { Config } from '../interface/Config' +import { validateAccounts, validateConfig } from './Validator' let configCache: Config @@ -18,8 +19,11 @@ export function loadAccounts(): Account[] { const accountDir = path.join(__dirname, '../', file) const accounts = fs.readFileSync(accountDir, 'utf-8') + const accountsData = JSON.parse(accounts) - return JSON.parse(accounts) + validateAccounts(accountsData) + + return accountsData } catch (error) { throw new Error(error as string) } @@ -35,6 +39,8 @@ export function loadConfig(): Config { const config = fs.readFileSync(configDir, 'utf-8') const configData = JSON.parse(config) + validateConfig(configData) + configCache = configData return configData diff --git a/src/util/Utils.ts b/src/util/Utils.ts index 23972c2..b741a99 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -21,10 +21,19 @@ export default class Util { } shuffleArray(array: T[]): T[] { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + + const a = array[i] + const b = array[j] + + if (a === undefined || b === undefined) continue + + array[i] = b + array[j] = a + } + return array - .map(value => ({ value, sort: Math.random() })) - .sort((a, b) => a.sort - b.sort) - .map(({ value }) => value) } randomNumber(min: number, max: number): number { diff --git a/src/util/Validator.ts b/src/util/Validator.ts new file mode 100644 index 0000000..12d7c26 --- /dev/null +++ b/src/util/Validator.ts @@ -0,0 +1,131 @@ +import { z } from 'zod' +import semver from 'semver' +import pkg from '../../package.json' + +import { Config } from '../interface/Config' +import { Account } from '../interface/Account' + +const NumberOrString = z.union([z.number(), z.string()]) + +const LogFilterSchema = z.object({ + enabled: z.boolean(), + mode: z.enum(['whitelist', 'blacklist']), + levels: z.array(z.enum(['debug', 'info', 'warn', 'error'])).optional(), + keywords: z.array(z.string()).optional(), + regexPatterns: z.array(z.string()).optional() +}) + +const DelaySchema = z.object({ + min: NumberOrString, + max: NumberOrString +}) + +const QueryEngineSchema = z.enum(['google', 'wikipedia', 'reddit', 'local']) + +// Webhook +const WebhookSchema = z.object({ + discord: z + .object({ + enabled: z.boolean(), + url: z.string() + }) + .optional(), + ntfy: z + .object({ + enabled: z.boolean().optional(), + url: z.string(), + topic: z.string().optional(), + token: z.string().optional(), + title: z.string().optional(), + tags: z.array(z.string()).optional(), + priority: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional() + }) + .optional(), + webhookLogFilter: LogFilterSchema +}) + +// Config +export const ConfigSchema = z.object({ + baseURL: z.string(), + sessionPath: z.string(), + headless: z.boolean(), + runOnZeroPoints: z.boolean(), + clusters: z.number().int().nonnegative(), + errorDiagnostics: z.boolean(), + workers: z.object({ + doDailySet: z.boolean(), + doSpecialPromotions: z.boolean(), + doMorePromotions: z.boolean(), + doPunchCards: z.boolean(), + doAppPromotions: z.boolean(), + doDesktopSearch: z.boolean(), + doMobileSearch: z.boolean(), + doDailyCheckIn: z.boolean(), + doReadToEarn: z.boolean() + }), + searchOnBingLocalQueries: z.boolean(), + globalTimeout: NumberOrString, + searchSettings: z.object({ + scrollRandomResults: z.boolean(), + clickRandomResults: z.boolean(), + parallelSearching: z.boolean(), + queryEngines: z.array(QueryEngineSchema), + searchResultVisitTime: NumberOrString, + searchDelay: DelaySchema, + readDelay: DelaySchema + }), + debugLogs: z.boolean(), + proxy: z.object({ + queryEngine: z.boolean() + }), + consoleLogFilter: LogFilterSchema, + webhook: WebhookSchema +}) + +// Account +export const AccountSchema = z.object({ + email: z.string(), + password: z.string(), + totpSecret: z.string().optional(), + recoveryEmail: z.string(), + geoLocale: z.string(), + langCode: z.string(), + proxy: z.object({ + proxyAxios: z.boolean(), + url: z.string(), + port: z.number(), + password: z.string(), + username: z.string() + }), + saveFingerprint: z.object({ + mobile: z.boolean(), + desktop: z.boolean() + }) +}) + +export function validateConfig(data: unknown): Config { + return ConfigSchema.parse(data) as Config +} + +export function validateAccounts(data: unknown): Account[] { + return z.array(AccountSchema).parse(data) +} + +export function checkNodeVersion(): void { + try { + const requiredVersion = pkg.engines?.node + + if (!requiredVersion) { + console.warn('No Node.js version requirement found in package.json "engines" field.') + return + } + + if (!semver.satisfies(process.version, requiredVersion)) { + console.error(`Current Node.js version ${process.version} does not satisfy requirement: ${requiredVersion}`) + process.exit(1) + } + } catch (error) { + console.error('Failed to validate Node.js version:', error) + process.exit(1) + } +} diff --git a/tsconfig.json b/tsconfig.json index ddd7a23..a6fb589 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,8 +40,12 @@ // "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"], + "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'. */ @@ -65,6 +69,14 @@ "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"] -} + "include": [ + "src/**/*.ts", + "src/accounts.json", + "src/config.json", + "src/functions/bing-search-activity-queries.json", + "src/functions/search-queries.json" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file