mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-11 02:46:17 +00:00
v3 init
Based of v3.0.0b10.
This commit is contained in:
28
.eslintrc.js
Normal file
28
.eslintrc.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
es2021: true,
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 12,
|
||||||
|
sourceType: 'module'
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
rules: {
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
|
quotes: ['error', 'single'],
|
||||||
|
semi: ['error', 'never'],
|
||||||
|
'@typescript-eslint/no-explicit-any': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
fixToUnknown: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'comma-dangle': 'off',
|
||||||
|
'@typescript-eslint/comma-dangle': 'error',
|
||||||
|
'prefer-arrow-callback': 'error',
|
||||||
|
'no-empty': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
src/accounts.json
|
src/accounts.json
|
||||||
src/config.json
|
src/config.json
|
||||||
|
/.vscode
|
||||||
|
/diagnostics
|
||||||
note
|
note
|
||||||
accounts.dev.json
|
accounts.dev.json
|
||||||
accounts.main.json
|
accounts.main.json
|
||||||
|
|||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
89
Dockerfile
Normal file
89
Dockerfile
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
###############################################################################
|
||||||
|
# Stage 1: Builder
|
||||||
|
###############################################################################
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/microsoft-rewards-script
|
||||||
|
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=0
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json tsconfig.json ./
|
||||||
|
|
||||||
|
# Install all dependencies required to build the script
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove build dependencies, and reinstall only runtime dependencies
|
||||||
|
RUN rm -rf node_modules \
|
||||||
|
&& npm ci --omit=dev --ignore-scripts \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# Install Chromium Headless Shell, and cleanup
|
||||||
|
RUN npx patchright install --with-deps --only-shell chromium \
|
||||||
|
&& rm -rf /root/.cache /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Stage 2: Runtime
|
||||||
|
###############################################################################
|
||||||
|
FROM node:22-slim AS runtime
|
||||||
|
|
||||||
|
WORKDIR /usr/src/microsoft-rewards-script
|
||||||
|
|
||||||
|
# Set production environment variables
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
TZ=UTC \
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=0 \
|
||||||
|
FORCE_HEADLESS=1
|
||||||
|
|
||||||
|
# Install minimal system libraries required for Chromium headless to run
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
cron \
|
||||||
|
gettext-base \
|
||||||
|
tzdata \
|
||||||
|
ca-certificates \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libexpat1 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libasound2 \
|
||||||
|
libflac12 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libdav1d6 \
|
||||||
|
libx11-6 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxcursor1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxi6 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxrender1 \
|
||||||
|
libxss1 \
|
||||||
|
libxtst6 \
|
||||||
|
libdouble-conversion3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||||
|
|
||||||
|
# Copy compiled application and dependencies from builder stage
|
||||||
|
COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist
|
||||||
|
COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./
|
||||||
|
COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy runtime scripts with proper permissions from the start
|
||||||
|
COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh
|
||||||
|
COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
|
||||||
|
COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Entrypoint handles TZ, initial run toggle, cron templating & launch
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["sh", "-c", "echo 'Container started; cron is running.'"]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[](https://discord.gg/8BxYbV4pkj)
|
[](https://discord.gg/8BxYbV4pkj)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|||||||
42
compose.yaml
Normal file
42
compose.yaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
microsoft-rewards-script:
|
||||||
|
build: .
|
||||||
|
container_name: microsoft-rewards-script
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Volume mounts: Specify a location where you want to save the files on your local machine.
|
||||||
|
volumes:
|
||||||
|
- ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro
|
||||||
|
- ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro
|
||||||
|
- ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TZ: 'America/Toronto' # Set your timezone for proper scheduling
|
||||||
|
NODE_ENV: 'production'
|
||||||
|
CRON_SCHEDULE: '0 7 * * *' # Customize your schedule, use crontab.guru for formatting
|
||||||
|
RUN_ON_START: 'true' # Runs the script immediately on container startup
|
||||||
|
|
||||||
|
# Add scheduled start-time randomization (uncomment to customize or disable, default: enabled)
|
||||||
|
#MIN_SLEEP_MINUTES: "5"
|
||||||
|
#MAX_SLEEP_MINUTES: "50"
|
||||||
|
SKIP_RANDOM_SLEEP: 'false'
|
||||||
|
|
||||||
|
# Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours)
|
||||||
|
#STUCK_PROCESS_TIMEOUT_HOURS: "8"
|
||||||
|
|
||||||
|
# Optional resource limits for the container
|
||||||
|
mem_limit: 4g
|
||||||
|
cpus: 2
|
||||||
|
|
||||||
|
# Health check - monitors if cron daemon is running to ensure scheduled jobs can execute
|
||||||
|
# Container marked unhealthy if cron process dies
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'sh', '-c', 'pgrep cron > /dev/null || exit 1']
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1749727998,
|
||||||
|
"narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
40
flake.nix
Normal file
40
flake.nix
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||||
|
flake-utils = {
|
||||||
|
url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, flake-utils, ... }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShell = pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
nodejs
|
||||||
|
playwright-driver.browsers
|
||||||
|
typescript
|
||||||
|
playwright-test
|
||||||
|
|
||||||
|
# fixes "waiting until load" issue compared to
|
||||||
|
# setting headless in config.json
|
||||||
|
xvfb-run
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}
|
||||||
|
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
|
||||||
|
npm i
|
||||||
|
npm run build
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
2974
package-lock.json
generated
Normal file
2974
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "microsoft-rewards-script",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
|
||||||
|
"author": "Netsky",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"pre-build": "npm i && rimraf dist && npx patchright install chromium",
|
||||||
|
"build": "rimraf dist && tsc",
|
||||||
|
"start": "node ./dist/index.js",
|
||||||
|
"ts-start": "ts-node ./src/index.ts",
|
||||||
|
"dev": "ts-node ./src/index.ts -dev",
|
||||||
|
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"",
|
||||||
|
"create-docker": "docker build -t microsoft-rewards-script-docker .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"clear-sessions": "node ./scripts/clearSessions.js",
|
||||||
|
"clear-diagnostics": "rimraf diagnostics"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"Bing Rewards",
|
||||||
|
"Microsoft Rewards",
|
||||||
|
"Bot",
|
||||||
|
"Script",
|
||||||
|
"TypeScript",
|
||||||
|
"Playwright",
|
||||||
|
"Cheerio"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-modules-newline": "^0.0.6",
|
||||||
|
"prettier": "^3.7.1",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"axios-retry": "^4.5.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"fingerprint-generator": "^2.1.77",
|
||||||
|
"fingerprint-injector": "^2.1.77",
|
||||||
|
"ghost-cursor-playwright-port": "^1.4.3",
|
||||||
|
"http-proxy-agent": "^7.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"otpauth": "^9.4.1",
|
||||||
|
"p-queue": "^9.0.1",
|
||||||
|
"patchright": "^1.57.0",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
104
scripts/clearSessions.js
Normal file
104
scripts/clearSessions.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const possibleConfigPaths = [
|
||||||
|
path.join(projectRoot, 'config.json'),
|
||||||
|
path.join(projectRoot, 'src', 'config.json'),
|
||||||
|
path.join(projectRoot, 'dist', 'config.json')
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log('[DEBUG] Project root:', projectRoot)
|
||||||
|
console.log('[DEBUG] Searching for config.json...')
|
||||||
|
|
||||||
|
let configPath = null
|
||||||
|
for (const p of possibleConfigPaths) {
|
||||||
|
console.log('[DEBUG] Checking:', p)
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
configPath = p
|
||||||
|
console.log('[DEBUG] Found config at:', p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configPath) {
|
||||||
|
console.error('[ERROR] config.json not found in any expected location!')
|
||||||
|
console.error('[ERROR] Searched:', possibleConfigPaths)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[INFO] Using config:', configPath)
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||||
|
|
||||||
|
if (!config.sessionPath) {
|
||||||
|
console.error("[ERROR] config.json missing 'sessionPath' key!")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[INFO] Session path from config:', config.sessionPath)
|
||||||
|
|
||||||
|
const configDir = path.dirname(configPath)
|
||||||
|
const possibleSessionDirs = [
|
||||||
|
path.resolve(configDir, config.sessionPath),
|
||||||
|
path.join(projectRoot, 'src/browser', config.sessionPath),
|
||||||
|
path.join(projectRoot, 'dist/browser', config.sessionPath)
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log('[DEBUG] Searching for session directory...')
|
||||||
|
|
||||||
|
let sessionDir = null
|
||||||
|
for (const p of possibleSessionDirs) {
|
||||||
|
console.log('[DEBUG] Checking:', p)
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
sessionDir = p
|
||||||
|
console.log('[DEBUG] Found session directory at:', p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionDir) {
|
||||||
|
sessionDir = path.resolve(configDir, config.sessionPath)
|
||||||
|
console.log('[DEBUG] Using fallback session directory:', sessionDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSessionDir = path.normalize(sessionDir)
|
||||||
|
const normalizedProjectRoot = path.normalize(projectRoot)
|
||||||
|
|
||||||
|
if (!normalizedSessionDir.startsWith(normalizedProjectRoot)) {
|
||||||
|
console.error('[ERROR] Session directory is outside project root!')
|
||||||
|
console.error('[ERROR] Project root:', normalizedProjectRoot)
|
||||||
|
console.error('[ERROR] Session directory:', normalizedSessionDir)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionDir === normalizedProjectRoot) {
|
||||||
|
console.error('[ERROR] Session directory cannot be the project root!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = normalizedSessionDir.split(path.sep)
|
||||||
|
if (pathSegments.length < 3) {
|
||||||
|
console.error('[ERROR] Session path is too shallow (safety check failed)!')
|
||||||
|
console.error('[ERROR] Path:', normalizedSessionDir)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(sessionDir)) {
|
||||||
|
console.log('[INFO] Removing session folder:', sessionDir)
|
||||||
|
try {
|
||||||
|
fs.rmSync(sessionDir, { recursive: true, force: true })
|
||||||
|
console.log('[SUCCESS] Session folder removed successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] Failed to remove session folder:', error.message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[INFO] Session folder does not exist:', sessionDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[INFO] Done.')
|
||||||
50
scripts/docker/entrypoint.sh
Normal file
50
scripts/docker/entrypoint.sh
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Ensure Playwright uses preinstalled browsers
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=0
|
||||||
|
|
||||||
|
# 1. Timezone: default to UTC if not provided
|
||||||
|
: "${TZ:=UTC}"
|
||||||
|
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||||
|
echo "$TZ" > /etc/timezone
|
||||||
|
dpkg-reconfigure -f noninteractive tzdata
|
||||||
|
|
||||||
|
# 2. Validate CRON_SCHEDULE
|
||||||
|
if [ -z "${CRON_SCHEDULE:-}" ]; then
|
||||||
|
echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2
|
||||||
|
echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Initial run without sleep if RUN_ON_START=true
|
||||||
|
if [ "${RUN_ON_START:-false}" = "true" ]; then
|
||||||
|
echo "[entrypoint] Starting initial run in background at $(date)"
|
||||||
|
(
|
||||||
|
cd /usr/src/microsoft-rewards-script || {
|
||||||
|
echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Skip random sleep for initial run, but preserve setting for cron jobs
|
||||||
|
SKIP_RANDOM_SLEEP=true scripts/docker/run_daily.sh
|
||||||
|
echo "[entrypoint-bg] Initial run completed at $(date)"
|
||||||
|
) &
|
||||||
|
echo "[entrypoint] Background process started (PID: $!)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Template and register cron file with explicit timezone export
|
||||||
|
if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then
|
||||||
|
echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Export TZ for envsubst to use
|
||||||
|
export TZ
|
||||||
|
envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron
|
||||||
|
chmod 0644 /etc/cron.d/microsoft-rewards-cron
|
||||||
|
crontab /etc/cron.d/microsoft-rewards-cron
|
||||||
|
|
||||||
|
echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)"
|
||||||
|
|
||||||
|
# 5. Start cron in foreground (PID 1)
|
||||||
|
exec cron -f
|
||||||
155
scripts/docker/run_daily.sh
Normal file
155
scripts/docker/run_daily.sh
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=0
|
||||||
|
export TZ="${TZ:-UTC}"
|
||||||
|
|
||||||
|
cd /usr/src/microsoft-rewards-script
|
||||||
|
|
||||||
|
LOCKFILE=/tmp/run_daily.lock
|
||||||
|
|
||||||
|
# -------------------------------
|
||||||
|
# Function: Check and fix lockfile integrity
|
||||||
|
# -------------------------------
|
||||||
|
self_heal_lockfile() {
|
||||||
|
# If lockfile exists but is empty → remove it
|
||||||
|
if [ -f "$LOCKFILE" ]; then
|
||||||
|
local lock_content
|
||||||
|
lock_content=$(<"$LOCKFILE" || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$lock_content" ]]; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Found empty lockfile → removing."
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If lockfile contains non-numeric PID → remove it
|
||||||
|
if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') → removing."
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If lockfile contains PID but process is dead → remove it
|
||||||
|
if ! kill -0 "$lock_content" 2>/dev/null; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead → removing stale lock."
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------
|
||||||
|
# Function: Acquire lock
|
||||||
|
# -------------------------------
|
||||||
|
acquire_lock() {
|
||||||
|
local max_attempts=5
|
||||||
|
local attempt=0
|
||||||
|
local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8}
|
||||||
|
local timeout_seconds=$((timeout_hours * 3600))
|
||||||
|
|
||||||
|
while [ $attempt -lt $max_attempts ]; do
|
||||||
|
# Try to create lock with current PID
|
||||||
|
if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lock exists, validate it
|
||||||
|
if [ -f "$LOCKFILE" ]; then
|
||||||
|
local existing_pid
|
||||||
|
existing_pid=$(<"$LOCKFILE" || echo "")
|
||||||
|
|
||||||
|
echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'"
|
||||||
|
|
||||||
|
# If lockfile content is invalid → delete and retry
|
||||||
|
if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Removing invalid lockfile → retrying..."
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If process is dead → delete and retry
|
||||||
|
if ! kill -0 "$existing_pid" 2>/dev/null; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)"
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check process runtime → kill if exceeded timeout
|
||||||
|
local process_age
|
||||||
|
if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then
|
||||||
|
if [ "$process_age" -gt "$timeout_seconds" ]; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)"
|
||||||
|
kill -TERM "$existing_pid" 2>/dev/null || true
|
||||||
|
sleep 5
|
||||||
|
kill -KILL "$existing_pid" 2>/dev/null || true
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts"
|
||||||
|
sleep 2
|
||||||
|
((attempt++))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------
|
||||||
|
# Function: Release lock
|
||||||
|
# -------------------------------
|
||||||
|
release_lock() {
|
||||||
|
if [ -f "$LOCKFILE" ]; then
|
||||||
|
local lock_pid
|
||||||
|
lock_pid=$(<"$LOCKFILE")
|
||||||
|
if [ "$lock_pid" = "$$" ]; then
|
||||||
|
rm -f "$LOCKFILE"
|
||||||
|
echo "[$(date)] [run_daily.sh] Lock released (PID: $$)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Always release lock on exit — but only if we acquired it
|
||||||
|
trap 'release_lock' EXIT INT TERM
|
||||||
|
|
||||||
|
# -------------------------------
|
||||||
|
# MAIN EXECUTION FLOW
|
||||||
|
# -------------------------------
|
||||||
|
echo "[$(date)] [run_daily.sh] Current process PID: $$"
|
||||||
|
|
||||||
|
# Self-heal any broken or empty locks before proceeding
|
||||||
|
self_heal_lockfile
|
||||||
|
|
||||||
|
# Attempt to acquire the lock safely
|
||||||
|
if ! acquire_lock; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Random sleep between MIN and MAX to spread execution
|
||||||
|
MINWAIT=${MIN_SLEEP_MINUTES:-5}
|
||||||
|
MAXWAIT=${MAX_SLEEP_MINUTES:-50}
|
||||||
|
MINWAIT_SEC=$((MINWAIT*60))
|
||||||
|
MAXWAIT_SEC=$((MAXWAIT*60))
|
||||||
|
|
||||||
|
if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then
|
||||||
|
SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) ))
|
||||||
|
echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)"
|
||||||
|
sleep "$SLEEPTIME"
|
||||||
|
else
|
||||||
|
echo "[$(date)] [run_daily.sh] Skipping random sleep"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the actual script
|
||||||
|
echo "[$(date)] [run_daily.sh] Starting script..."
|
||||||
|
if npm start; then
|
||||||
|
echo "[$(date)] [run_daily.sh] Script completed successfully."
|
||||||
|
else
|
||||||
|
echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] [run_daily.sh] Script finished"
|
||||||
|
# Lock is released automatically via trap
|
||||||
3
scripts/nix/run.sh
Normal file
3
scripts/nix/run.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
nix develop --command bash -c "xvfb-run npm run start"
|
||||||
28
src/accounts.example.json
Normal file
28
src/accounts.example.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"email": "email_1",
|
||||||
|
"password": "password_1",
|
||||||
|
"totp": "",
|
||||||
|
"geoLocale": "auto",
|
||||||
|
"proxy": {
|
||||||
|
"proxyAxios": true,
|
||||||
|
"url": "",
|
||||||
|
"port": 0,
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "email_2",
|
||||||
|
"password": "password_2",
|
||||||
|
"totp": "",
|
||||||
|
"geoLocale": "auto",
|
||||||
|
"proxy": {
|
||||||
|
"proxyAxios": true,
|
||||||
|
"url": "",
|
||||||
|
"port": 0,
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
123
src/browser/Browser.ts
Normal file
123
src/browser/Browser.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import rebrowser, { BrowserContext } from 'patchright'
|
||||||
|
|
||||||
|
import { newInjectedContext } from 'fingerprint-injector'
|
||||||
|
import { BrowserFingerprintWithHeaders, FingerprintGenerator } from 'fingerprint-generator'
|
||||||
|
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
import { loadSessionData, saveFingerprintData } from '../util/Load'
|
||||||
|
import { UserAgentManager } from './UserAgent'
|
||||||
|
|
||||||
|
import type { AccountProxy } from '../interface/Account'
|
||||||
|
|
||||||
|
/* Test Stuff
|
||||||
|
https://abrahamjuliot.github.io/creepjs/
|
||||||
|
https://botcheck.luminati.io/
|
||||||
|
https://fv.pro/
|
||||||
|
https://pixelscan.net/
|
||||||
|
https://www.browserscan.net/
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Browser {
|
||||||
|
private bot: MicrosoftRewardsBot
|
||||||
|
|
||||||
|
constructor(bot: MicrosoftRewardsBot) {
|
||||||
|
this.bot = bot
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBrowser(
|
||||||
|
proxy: AccountProxy,
|
||||||
|
email: string
|
||||||
|
): Promise<{
|
||||||
|
context: BrowserContext
|
||||||
|
fingerprint: BrowserFingerprintWithHeaders
|
||||||
|
}> {
|
||||||
|
let browser: rebrowser.Browser
|
||||||
|
try {
|
||||||
|
browser = await rebrowser.chromium.launch({
|
||||||
|
headless: this.bot.config.headless,
|
||||||
|
...(proxy.url && {
|
||||||
|
proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` }
|
||||||
|
}),
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--mute-audio',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--ignore-certificate-errors',
|
||||||
|
'--ignore-certificate-errors-spki-list',
|
||||||
|
'--ignore-ssl-errors',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-user-media-security=true',
|
||||||
|
'--disable-blink-features=Attestation',
|
||||||
|
'--disable-features=WebAuthentication,PasswordManagerOnboarding,PasswordManager,EnablePasswordsAccountStorage,Passkeys',
|
||||||
|
'--disable-save-password-bubble'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'BROWSER',
|
||||||
|
`Launch failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await loadSessionData(
|
||||||
|
this.bot.config.sessionPath,
|
||||||
|
email,
|
||||||
|
this.bot.config.saveFingerprint,
|
||||||
|
this.bot.isMobile
|
||||||
|
)
|
||||||
|
|
||||||
|
const fingerprint = sessionData.fingerprint
|
||||||
|
? sessionData.fingerprint
|
||||||
|
: await this.generateFingerprint(this.bot.isMobile)
|
||||||
|
|
||||||
|
const context = await newInjectedContext(browser as any, { fingerprint: fingerprint })
|
||||||
|
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'credentials', {
|
||||||
|
value: {
|
||||||
|
create: () => Promise.reject(new Error('WebAuthn disabled')),
|
||||||
|
get: () => Promise.reject(new Error('WebAuthn disabled'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
context.setDefaultTimeout(this.bot.utils.stringToNumber(this.bot.config?.globalTimeout ?? 30000))
|
||||||
|
|
||||||
|
await context.addCookies(sessionData.cookies)
|
||||||
|
|
||||||
|
if (this.bot.config.saveFingerprint) {
|
||||||
|
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'BROWSER',
|
||||||
|
`Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'BROWSER-FINGERPRINT', JSON.stringify(fingerprint))
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: context as unknown as BrowserContext,
|
||||||
|
fingerprint: fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateFingerprint(isMobile: boolean) {
|
||||||
|
const fingerPrintData = new FingerprintGenerator().getFingerprint({
|
||||||
|
devices: isMobile ? ['mobile'] : ['desktop'],
|
||||||
|
operatingSystems: isMobile ? ['android', 'ios'] : ['windows', 'linux'],
|
||||||
|
browsers: [{ name: 'edge' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const userAgentManager = new UserAgentManager(this.bot)
|
||||||
|
const updatedFingerPrintData = await userAgentManager.updateFingerprintUserAgent(fingerPrintData, isMobile)
|
||||||
|
|
||||||
|
return updatedFingerPrintData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Browser
|
||||||
433
src/browser/BrowserFunc.ts
Normal file
433
src/browser/BrowserFunc.ts
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import type { BrowserContext, Cookie } from 'patchright'
|
||||||
|
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
import { saveSessionData } from '../util/Load'
|
||||||
|
|
||||||
|
import type { Counters, DashboardData } from './../interface/DashboardData'
|
||||||
|
import type { AppUserData } from '../interface/AppUserData'
|
||||||
|
import type { XboxDashboardData } from '../interface/XboxDashboardData'
|
||||||
|
import type { AppEarnablePoints, BrowserEarnablePoints, MissingSearchPoints } from '../interface/Points'
|
||||||
|
import type { AppDashboardData } from '../interface/AppDashBoardData'
|
||||||
|
|
||||||
|
export default class BrowserFunc {
|
||||||
|
private bot: MicrosoftRewardsBot
|
||||||
|
|
||||||
|
constructor(bot: MicrosoftRewardsBot) {
|
||||||
|
this.bot = bot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user desktop dashboard data
|
||||||
|
* @returns {DashboardData} Object of user bing rewards dashboard data
|
||||||
|
*/
|
||||||
|
async getDashboardData(): Promise<DashboardData> {
|
||||||
|
try {
|
||||||
|
const cookieHeader = this.bot.cookies.mobile
|
||||||
|
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(this.bot.fingerprint?.headers ?? {}),
|
||||||
|
Cookie: cookieHeader,
|
||||||
|
Referer: 'https://rewards.bing.com/',
|
||||||
|
Origin: 'https://rewards.bing.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
return response.data.dashboard as DashboardData
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-DASHBOARD-DATA',
|
||||||
|
`Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user app dashboard data
|
||||||
|
* @returns {AppDashboardData} Object of user bing rewards dashboard data
|
||||||
|
*/
|
||||||
|
async getAppDashboardData(): Promise<AppDashboardData> {
|
||||||
|
try {
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAIOS&options=613',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||||
|
'User-Agent':
|
||||||
|
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
return response.data as AppDashboardData
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-APP-DASHBOARD-DATA',
|
||||||
|
`Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user xbox dashboard data
|
||||||
|
* @returns {XboxDashboardData} Object of user bing rewards dashboard data
|
||||||
|
*/
|
||||||
|
async getXBoxDashboardData(): Promise<XboxDashboardData> {
|
||||||
|
try {
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=xboxapp&options=6',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One X) AppleWebKit/537.36 (KHTML, like Gecko) Edge/18.19041'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
return response.data as XboxDashboardData
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-XBOX-DASHBOARD-DATA',
|
||||||
|
`Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get search point counters
|
||||||
|
*/
|
||||||
|
async getSearchPoints(): Promise<Counters> {
|
||||||
|
const dashboardData = await this.getDashboardData() // Always fetch newest data
|
||||||
|
|
||||||
|
return dashboardData.userStatus.counters
|
||||||
|
}
|
||||||
|
|
||||||
|
missingSearchPoints(counters: Counters, isMobile: boolean): MissingSearchPoints {
|
||||||
|
const mobileData = counters.mobileSearch?.[0]
|
||||||
|
const desktopData = counters.pcSearch?.[0]
|
||||||
|
const edgeData = counters.pcSearch?.[1]
|
||||||
|
|
||||||
|
const mobilePoints = mobileData ? Math.max(0, mobileData.pointProgressMax - mobileData.pointProgress) : 0
|
||||||
|
const desktopPoints = desktopData ? Math.max(0, desktopData.pointProgressMax - desktopData.pointProgress) : 0
|
||||||
|
const edgePoints = edgeData ? Math.max(0, edgeData.pointProgressMax - edgeData.pointProgress) : 0
|
||||||
|
|
||||||
|
const totalPoints = isMobile ? mobilePoints : desktopPoints + edgePoints
|
||||||
|
|
||||||
|
return { mobilePoints, desktopPoints, edgePoints, totalPoints }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total earnable points with web browser
|
||||||
|
*/
|
||||||
|
async getBrowserEarnablePoints(): Promise<BrowserEarnablePoints> {
|
||||||
|
try {
|
||||||
|
const data = await this.getDashboardData()
|
||||||
|
|
||||||
|
const desktopSearchPoints =
|
||||||
|
data.userStatus.counters.pcSearch?.reduce(
|
||||||
|
(sum, x) => sum + (x.pointProgressMax - x.pointProgress),
|
||||||
|
0
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const mobileSearchPoints =
|
||||||
|
data.userStatus.counters.mobileSearch?.reduce(
|
||||||
|
(sum, x) => sum + (x.pointProgressMax - x.pointProgress),
|
||||||
|
0
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const todayDate = this.bot.utils.getFormattedDate()
|
||||||
|
const dailySetPoints =
|
||||||
|
data.dailySetPromotions[todayDate]?.reduce(
|
||||||
|
(sum, x) => sum + (x.pointProgressMax - x.pointProgress),
|
||||||
|
0
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const morePromotionsPoints =
|
||||||
|
data.morePromotions?.reduce((sum, x) => {
|
||||||
|
if (
|
||||||
|
['quiz', 'urlreward'].includes(x.promotionType) &&
|
||||||
|
x.exclusiveLockedFeatureStatus !== 'locked'
|
||||||
|
) {
|
||||||
|
return sum + (x.pointProgressMax - x.pointProgress)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}, 0) ?? 0
|
||||||
|
|
||||||
|
const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints
|
||||||
|
|
||||||
|
return {
|
||||||
|
dailySetPoints,
|
||||||
|
morePromotionsPoints,
|
||||||
|
desktopSearchPoints,
|
||||||
|
mobileSearchPoints,
|
||||||
|
totalEarnablePoints
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-BROWSER-EARNABLE-POINTS',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total earnable points with mobile app
|
||||||
|
*/
|
||||||
|
async getAppEarnablePoints(): Promise<AppEarnablePoints> {
|
||||||
|
try {
|
||||||
|
const eligibleOffers = ['ENUS_readarticle3_30points', 'Gamification_Sapphire_DailyCheckIn']
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||||
|
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||||
|
'X-Rewards-Language': 'en',
|
||||||
|
'X-Rewards-ismobile': 'true'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
const userData: AppUserData = response.data
|
||||||
|
const eligibleActivities = userData.response.promotions.filter(x =>
|
||||||
|
eligibleOffers.includes(x.attributes.offerid ?? '')
|
||||||
|
)
|
||||||
|
|
||||||
|
let readToEarn = 0
|
||||||
|
let checkIn = 0
|
||||||
|
|
||||||
|
for (const item of eligibleActivities) {
|
||||||
|
const attrs = item.attributes
|
||||||
|
|
||||||
|
if (attrs.type === 'msnreadearn') {
|
||||||
|
const pointMax = parseInt(attrs.pointmax ?? '0')
|
||||||
|
const pointProgress = parseInt(attrs.pointprogress ?? '0')
|
||||||
|
readToEarn = Math.max(0, pointMax - pointProgress)
|
||||||
|
} else if (attrs.type === 'checkin') {
|
||||||
|
const progress = parseInt(attrs.progress ?? '0')
|
||||||
|
const checkInDay = progress % 7
|
||||||
|
const lastUpdated = new Date(attrs.last_updated ?? '')
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) {
|
||||||
|
checkIn = parseInt(attrs[`day_${checkInDay + 1}_points`] ?? '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalEarnablePoints = readToEarn + checkIn
|
||||||
|
|
||||||
|
return {
|
||||||
|
readToEarn,
|
||||||
|
checkIn,
|
||||||
|
totalEarnablePoints
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-APP-EARNABLE-POINTS',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get current point amount
|
||||||
|
* @returns {number} Current total point amount
|
||||||
|
*/
|
||||||
|
async getCurrentPoints(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const data = await this.getDashboardData()
|
||||||
|
|
||||||
|
return data.userStatus.availablePoints
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-CURRENT-POINTS',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeBrowser(browser: BrowserContext, email: string) {
|
||||||
|
try {
|
||||||
|
const cookies = await browser.cookies()
|
||||||
|
|
||||||
|
// Save cookies
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'CLOSE-BROWSER',
|
||||||
|
`Saving ${cookies.length} cookies to session folder!`
|
||||||
|
)
|
||||||
|
await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(2000)
|
||||||
|
|
||||||
|
// Close browser
|
||||||
|
await browser.close()
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'CLOSE-BROWSER',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeCookies(response: AxiosResponse, currentCookieHeader: string = '', whitelist?: string[]): string {
|
||||||
|
const cookieMap = new Map<string, string>(
|
||||||
|
currentCookieHeader
|
||||||
|
.split(';')
|
||||||
|
.map(pair => pair.split('=').map(s => s.trim()))
|
||||||
|
.filter(([name, value]) => name && value)
|
||||||
|
.map(([name, value]) => [name, value] as [string, string])
|
||||||
|
)
|
||||||
|
|
||||||
|
const setCookieList = [response.headers['set-cookie']].flat().filter(Boolean) as string[]
|
||||||
|
const cookiesByName = new Map(this.bot.cookies.mobile.map(c => [c.name, c]))
|
||||||
|
|
||||||
|
for (const setCookie of setCookieList) {
|
||||||
|
const [nameValue, ...attributes] = setCookie.split(';').map(s => s.trim())
|
||||||
|
if (!nameValue) continue
|
||||||
|
|
||||||
|
const [name, value] = nameValue.split('=').map(s => s.trim())
|
||||||
|
|
||||||
|
if (!name) continue
|
||||||
|
|
||||||
|
if (whitelist && !whitelist?.includes(name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = this.parseAttributes(attributes)
|
||||||
|
const existing = cookiesByName.get(name)
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
if (existing) {
|
||||||
|
cookiesByName.delete(name)
|
||||||
|
this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name)
|
||||||
|
}
|
||||||
|
cookieMap.delete(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.expires !== undefined && attrs.expires < Date.now() / 1000) {
|
||||||
|
if (existing) {
|
||||||
|
cookiesByName.delete(name)
|
||||||
|
this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name)
|
||||||
|
}
|
||||||
|
cookieMap.delete(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieMap.set(name, value)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.updateCookie(existing, value, attrs)
|
||||||
|
} else {
|
||||||
|
this.bot.cookies.mobile.push(this.createCookie(name, value, attrs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(cookieMap, ([name, value]) => `${name}=${value}`).join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAttributes(attributes: string[]) {
|
||||||
|
const attrs: {
|
||||||
|
domain?: string
|
||||||
|
path?: string
|
||||||
|
expires?: number
|
||||||
|
httpOnly?: boolean
|
||||||
|
secure?: boolean
|
||||||
|
sameSite?: Cookie['sameSite']
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
for (const attr of attributes) {
|
||||||
|
const [key, val] = attr.split('=').map(s => s?.trim())
|
||||||
|
const lowerKey = key?.toLowerCase()
|
||||||
|
|
||||||
|
switch (lowerKey) {
|
||||||
|
case 'domain':
|
||||||
|
case 'path': {
|
||||||
|
if (val) attrs[lowerKey] = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'expires': {
|
||||||
|
if (val) {
|
||||||
|
const ts = Date.parse(val)
|
||||||
|
if (!isNaN(ts)) attrs.expires = Math.floor(ts / 1000)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'max-age': {
|
||||||
|
if (val) {
|
||||||
|
const maxAge = Number(val)
|
||||||
|
if (!isNaN(maxAge)) attrs.expires = Math.floor(Date.now() / 1000) + maxAge
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'httponly': {
|
||||||
|
attrs.httpOnly = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'secure': {
|
||||||
|
attrs.secure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'samesite': {
|
||||||
|
const normalized = val?.toLowerCase()
|
||||||
|
if (normalized && ['lax', 'strict', 'none'].includes(normalized)) {
|
||||||
|
attrs.sameSite = (normalized.charAt(0).toUpperCase() +
|
||||||
|
normalized.slice(1)) as Cookie['sameSite']
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCookie(cookie: Cookie, value: string, attrs: ReturnType<typeof this.parseAttributes>) {
|
||||||
|
cookie.value = value
|
||||||
|
if (attrs.domain) cookie.domain = attrs.domain
|
||||||
|
if (attrs.path) cookie.path = attrs.path
|
||||||
|
//if (attrs.expires !== undefined) cookie.expires = attrs.expires
|
||||||
|
//if (attrs.httpOnly) cookie.httpOnly = true
|
||||||
|
//if (attrs.secure) cookie.secure = true
|
||||||
|
//if (attrs.sameSite) cookie.sameSite = attrs.sameSite
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCookie(name: string, value: string, attrs: ReturnType<typeof this.parseAttributes>): Cookie {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
domain: attrs.domain || '.bing.com',
|
||||||
|
path: attrs.path || '/'
|
||||||
|
/*
|
||||||
|
...(attrs.expires !== undefined && { expires: attrs.expires }),
|
||||||
|
...(attrs.httpOnly && { httpOnly: true }),
|
||||||
|
...(attrs.secure && { secure: true }),
|
||||||
|
...(attrs.sameSite && { sameSite: attrs.sameSite })
|
||||||
|
*/
|
||||||
|
} as Cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/browser/BrowserUtils.ts
Normal file
273
src/browser/BrowserUtils.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { type Page, type BrowserContext } from 'patchright'
|
||||||
|
import { CheerioAPI, load } from 'cheerio'
|
||||||
|
import { ClickOptions, createCursor } from 'ghost-cursor-playwright-port'
|
||||||
|
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
|
||||||
|
export default class BrowserUtils {
|
||||||
|
private bot: MicrosoftRewardsBot
|
||||||
|
|
||||||
|
constructor(bot: MicrosoftRewardsBot) {
|
||||||
|
this.bot = bot
|
||||||
|
}
|
||||||
|
|
||||||
|
async tryDismissAllMessages(page: Page): Promise<void> {
|
||||||
|
try {
|
||||||
|
const buttons = [
|
||||||
|
{ selector: '#acceptButton', label: 'AcceptButton' },
|
||||||
|
{ selector: '#wcpConsentBannerCtrl > * > button:first-child', label: 'Bing Cookies Accept' },
|
||||||
|
{ selector: '.ext-secondary.ext-button', label: '"Skip for now" Button' },
|
||||||
|
{ selector: '#iLandingViewAction', label: 'iLandingViewAction' },
|
||||||
|
{ selector: '#iShowSkip', label: 'iShowSkip' },
|
||||||
|
{ selector: '#iNext', label: 'iNext' },
|
||||||
|
{ selector: '#iLooksGood', label: 'iLooksGood' },
|
||||||
|
{ selector: '#idSIButton9', label: 'idSIButton9' },
|
||||||
|
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Button' },
|
||||||
|
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Button' },
|
||||||
|
{ selector: '.maybe-later', label: 'Mobile Rewards App Banner' },
|
||||||
|
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Banner' },
|
||||||
|
{ selector: '#reward_pivot_earn', label: 'Reward Coupon Accept' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const checkVisible = await Promise.allSettled(
|
||||||
|
buttons.map(async b => ({
|
||||||
|
...b,
|
||||||
|
isVisible: await page
|
||||||
|
.locator(b.selector)
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleButtons = checkVisible
|
||||||
|
.filter(r => r.status === 'fulfilled' && r.value.isVisible)
|
||||||
|
.map(r => (r.status === 'fulfilled' ? r.value : null))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (visibleButtons.length > 0) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
visibleButtons.map(async b => {
|
||||||
|
if (b) {
|
||||||
|
const clicked = await this.ghostClick(page, b.selector)
|
||||||
|
if (clicked) {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DISMISS-ALL-MESSAGES',
|
||||||
|
`Dismissed: ${b.label}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await this.bot.utils.wait(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay
|
||||||
|
const overlay = await page.$('#bnp_overlay_wrapper')
|
||||||
|
if (overlay) {
|
||||||
|
const rejected = await this.ghostClick(page, '#bnp_btn_reject, button[aria-label*="Reject" i]')
|
||||||
|
if (rejected) {
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject')
|
||||||
|
} else {
|
||||||
|
const accepted = await this.ghostClick(page, '#bnp_btn_accept')
|
||||||
|
if (accepted) {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DISMISS-ALL-MESSAGES',
|
||||||
|
'Dismissed: Bing Overlay Accept'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.bot.utils.wait(250)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DISMISS-ALL-MESSAGES',
|
||||||
|
`Handler error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestTab(page: Page): Promise<Page> {
|
||||||
|
try {
|
||||||
|
const browser: BrowserContext = page.context()
|
||||||
|
const pages = browser.pages()
|
||||||
|
|
||||||
|
const newTab = pages[pages.length - 1]
|
||||||
|
if (!newTab) {
|
||||||
|
throw this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', 'No tabs could be found!')
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTab
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-NEW-TAB',
|
||||||
|
`Unable to get latest tab: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadBadPage(page: Page): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const html = await page.content().catch(() => '')
|
||||||
|
const $ = load(html)
|
||||||
|
|
||||||
|
if ($('body.neterror').length) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
|
||||||
|
try {
|
||||||
|
await page.reload({ waitUntil: 'load' })
|
||||||
|
} catch {
|
||||||
|
await page.reload().catch(() => {})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'RELOAD-BAD-PAGE',
|
||||||
|
`Reload check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTabs(page: Page, config = { minTabs: 1, maxTabs: 1 }): Promise<Page> {
|
||||||
|
try {
|
||||||
|
const browser = page.context()
|
||||||
|
const tabs = browser.pages()
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-CLOSE-TABS',
|
||||||
|
`Found ${tabs.length} tab(s) open (min: ${config.minTabs}, max: ${config.maxTabs})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if valid
|
||||||
|
if (config.minTabs < 1 || config.maxTabs < config.minTabs) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'Invalid config, using defaults')
|
||||||
|
config = { minTabs: 1, maxTabs: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close if more than max config
|
||||||
|
if (tabs.length > config.maxTabs) {
|
||||||
|
const tabsToClose = tabs.slice(config.maxTabs)
|
||||||
|
|
||||||
|
const closeResults = await Promise.allSettled(tabsToClose.map(tab => tab.close()))
|
||||||
|
|
||||||
|
const closedCount = closeResults.filter(r => r.status === 'fulfilled').length
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-CLOSE-TABS',
|
||||||
|
`Closed ${closedCount}/${tabsToClose.length} excess tab(s) to reach max of ${config.maxTabs}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open more tabs
|
||||||
|
} else if (tabs.length < config.minTabs) {
|
||||||
|
const tabsNeeded = config.minTabs - tabs.length
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-CLOSE-TABS',
|
||||||
|
`Opening ${tabsNeeded} tab(s) to reach min of ${config.minTabs}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const newTabPromises = Array.from({ length: tabsNeeded }, async () => {
|
||||||
|
try {
|
||||||
|
const newPage = await browser.newPage()
|
||||||
|
await newPage.goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 15000 })
|
||||||
|
return newPage
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-CLOSE-TABS',
|
||||||
|
`Failed to create new tab: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.allSettled(newTabPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTab = await this.getLatestTab(page)
|
||||||
|
return latestTab
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-CLOSE-TABS',
|
||||||
|
`Error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadInCheerio(data: Page | string): Promise<CheerioAPI> {
|
||||||
|
const html: string = typeof data === 'string' ? data : await data.content()
|
||||||
|
const $ = load(html)
|
||||||
|
return $
|
||||||
|
}
|
||||||
|
|
||||||
|
async ghostClick(page: Page, selector: string, options?: ClickOptions): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GHOST-CLICK',
|
||||||
|
`Trying to click selector: ${selector}, options: ${JSON.stringify(options)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for selector to exist before clicking
|
||||||
|
await page.waitForSelector(selector, { timeout: 10000 })
|
||||||
|
|
||||||
|
const cursor = createCursor(page as any)
|
||||||
|
await cursor.click(selector, options)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GHOST-CLICK',
|
||||||
|
`Failed for ${selector}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableFido(page: Page) {
|
||||||
|
const routePattern = '**/GetCredentialType.srf*'
|
||||||
|
await page.route(routePattern, route => {
|
||||||
|
try {
|
||||||
|
const request = route.request()
|
||||||
|
const postData = request.postData()
|
||||||
|
|
||||||
|
const body = postData ? JSON.parse(postData) : {}
|
||||||
|
|
||||||
|
body.isFidoSupported = false
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DISABLE-FIDO',
|
||||||
|
`Modified request body: isFidoSupported set to ${body.isFidoSupported}`
|
||||||
|
)
|
||||||
|
|
||||||
|
route.continue({
|
||||||
|
postData: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
...request.headers(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DISABLE-FIDO',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
route.continue()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/browser/UserAgent.ts
Normal file
164
src/browser/UserAgent.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||||
|
|
||||||
|
import type { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil'
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
|
||||||
|
export class UserAgentManager {
|
||||||
|
private static readonly NOT_A_BRAND_VERSION = '99'
|
||||||
|
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
async getUserAgent(isMobile: boolean) {
|
||||||
|
const system = this.getSystemComponents(isMobile)
|
||||||
|
const app = await this.getAppComponents(isMobile)
|
||||||
|
|
||||||
|
const uaTemplate = isMobile
|
||||||
|
? `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Mobile Safari/537.36 EdgA/${app.edge_version}`
|
||||||
|
: `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Safari/537.36 Edg/${app.edge_version}`
|
||||||
|
|
||||||
|
const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
|
||||||
|
|
||||||
|
const uaMetadata = {
|
||||||
|
isMobile,
|
||||||
|
platform: isMobile ? 'Android' : 'Windows',
|
||||||
|
fullVersionList: [
|
||||||
|
{ brand: 'Not/A)Brand', version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0` },
|
||||||
|
{ brand: 'Microsoft Edge', version: app['edge_version'] },
|
||||||
|
{ brand: 'Chromium', version: app['chrome_version'] }
|
||||||
|
],
|
||||||
|
brands: [
|
||||||
|
{ brand: 'Not/A)Brand', version: UserAgentManager.NOT_A_BRAND_VERSION },
|
||||||
|
{ brand: 'Microsoft Edge', version: app['edge_major_version'] },
|
||||||
|
{ brand: 'Chromium', version: app['chrome_major_version'] }
|
||||||
|
],
|
||||||
|
platformVersion,
|
||||||
|
architecture: isMobile ? '' : 'x86',
|
||||||
|
bitness: isMobile ? '' : '64',
|
||||||
|
model: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChromeVersion(isMobile: boolean): Promise<string> {
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios(request)
|
||||||
|
const data: ChromeVersion = response.data
|
||||||
|
return data.channels.Stable.version
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'USERAGENT-CHROME-VERSION',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEdgeVersions(isMobile: boolean) {
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
url: 'https://edgeupdates.microsoft.com/api/products',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios(request)
|
||||||
|
const data: EdgeVersion[] = response.data
|
||||||
|
const stable = data.find(x => x.Product == 'Stable') as EdgeVersion
|
||||||
|
return {
|
||||||
|
android: stable.Releases.find(x => x.Platform == 'Android')?.ProductVersion,
|
||||||
|
windows: stable.Releases.find(x => x.Platform == 'Windows' && x.Architecture == 'x64')?.ProductVersion
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'USERAGENT-EDGE-VERSION',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSystemComponents(mobile: boolean): string {
|
||||||
|
if (mobile) {
|
||||||
|
const androidVersion = 10 + Math.floor(Math.random() * 5)
|
||||||
|
return `Linux; Android ${androidVersion}; K`
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Windows NT 10.0; Win64; x64'
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppComponents(isMobile: boolean) {
|
||||||
|
const versions = await this.getEdgeVersions(isMobile)
|
||||||
|
const edgeVersion = isMobile ? versions.android : (versions.windows as string)
|
||||||
|
const edgeMajorVersion = edgeVersion?.split('.')[0]
|
||||||
|
|
||||||
|
const chromeVersion = await this.getChromeVersion(isMobile)
|
||||||
|
const chromeMajorVersion = chromeVersion?.split('.')[0]
|
||||||
|
const chromeReducedVersion = `${chromeMajorVersion}.0.0.0`
|
||||||
|
|
||||||
|
return {
|
||||||
|
not_a_brand_version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0`,
|
||||||
|
not_a_brand_major_version: UserAgentManager.NOT_A_BRAND_VERSION,
|
||||||
|
edge_version: edgeVersion as string,
|
||||||
|
edge_major_version: edgeMajorVersion as string,
|
||||||
|
chrome_version: chromeVersion as string,
|
||||||
|
chrome_major_version: chromeMajorVersion as string,
|
||||||
|
chrome_reduced_version: chromeReducedVersion as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFingerprintUserAgent(
|
||||||
|
fingerprint: BrowserFingerprintWithHeaders,
|
||||||
|
isMobile: boolean
|
||||||
|
): Promise<BrowserFingerprintWithHeaders> {
|
||||||
|
try {
|
||||||
|
const userAgentData = await this.getUserAgent(isMobile)
|
||||||
|
const componentData = await this.getAppComponents(isMobile)
|
||||||
|
|
||||||
|
//@ts-expect-error Errors due it not exactly matching
|
||||||
|
fingerprint.fingerprint.navigator.userAgentData = userAgentData.userAgentMetadata
|
||||||
|
fingerprint.fingerprint.navigator.userAgent = userAgentData.userAgent
|
||||||
|
fingerprint.fingerprint.navigator.appVersion = userAgentData.userAgent.replace(
|
||||||
|
`${fingerprint.fingerprint.navigator.appCodeName}/`,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
fingerprint.headers['user-agent'] = userAgentData.userAgent
|
||||||
|
fingerprint.headers['sec-ch-ua'] =
|
||||||
|
`"Microsoft Edge";v="${componentData.edge_major_version}", "Not=A?Brand";v="${componentData.not_a_brand_major_version}", "Chromium";v="${componentData.chrome_major_version}"`
|
||||||
|
fingerprint.headers['sec-ch-ua-full-version-list'] =
|
||||||
|
`"Microsoft Edge";v="${componentData.edge_version}", "Not=A?Brand";v="${componentData.not_a_brand_version}", "Chromium";v="${componentData.chrome_version}"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0
|
||||||
|
sec-ch-ua-full-version-list: "Microsoft Edge";v="129.0.2792.84", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90"
|
||||||
|
sec-ch-ua: "Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"
|
||||||
|
|
||||||
|
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
|
||||||
|
"Google Chrome";v="129.0.6668.90", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90"
|
||||||
|
*/
|
||||||
|
|
||||||
|
return fingerprint
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'USER-AGENT-UPDATE',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
509
src/browser/auth/Login.ts
Normal file
509
src/browser/auth/Login.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import type { MicrosoftRewardsBot } from '../../index'
|
||||||
|
import { saveSessionData } from '../../util/Load'
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
import { MobileAccessLogin } from './methods/MobileAccessLogin'
|
||||||
|
import { EmailLogin } from './methods/EmailLogin'
|
||||||
|
import { PasswordlessLogin } from './methods/PasswordlessLogin'
|
||||||
|
import { TotpLogin } from './methods/Totp2FALogin'
|
||||||
|
|
||||||
|
type LoginState =
|
||||||
|
| 'EMAIL_INPUT'
|
||||||
|
| 'PASSWORD_INPUT'
|
||||||
|
| 'SIGN_IN_ANOTHER_WAY'
|
||||||
|
| 'PASSKEY_ERROR'
|
||||||
|
| 'PASSKEY_VIDEO'
|
||||||
|
| 'KMSI_PROMPT'
|
||||||
|
| 'LOGGED_IN'
|
||||||
|
| 'ACCOUNT_LOCKED'
|
||||||
|
| 'ERROR_ALERT'
|
||||||
|
| '2FA_TOTP'
|
||||||
|
| 'LOGIN_PASSWORDLESS'
|
||||||
|
| 'GET_A_CODE'
|
||||||
|
| 'UNKNOWN'
|
||||||
|
| 'CHROMEWEBDATA_ERROR'
|
||||||
|
|
||||||
|
export class Login {
|
||||||
|
emailLogin: EmailLogin
|
||||||
|
passwordlessLogin: PasswordlessLogin
|
||||||
|
totp2FALogin: TotpLogin
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {
|
||||||
|
this.emailLogin = new EmailLogin(this.bot)
|
||||||
|
this.passwordlessLogin = new PasswordlessLogin(this.bot)
|
||||||
|
this.totp2FALogin = new TotpLogin(this.bot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly primaryButtonSelector = 'button[data-testid="primaryButton"]'
|
||||||
|
private readonly secondaryButtonSelector = 'button[data-testid="secondaryButton"]'
|
||||||
|
|
||||||
|
async login(page: Page, email: string, password: string, totpSecret?: string) {
|
||||||
|
try {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process')
|
||||||
|
|
||||||
|
await page.goto('https://www.bing.com/rewards/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||||
|
await this.bot.utils.wait(2000)
|
||||||
|
await this.bot.browser.utils.reloadBadPage(page)
|
||||||
|
|
||||||
|
await this.bot.browser.utils.disableFido(page)
|
||||||
|
|
||||||
|
const maxIterations = 25
|
||||||
|
let iteration = 0
|
||||||
|
|
||||||
|
let previousState: LoginState = 'UNKNOWN'
|
||||||
|
let sameStateCount = 0
|
||||||
|
|
||||||
|
while (iteration < maxIterations) {
|
||||||
|
if (page.isClosed()) throw new Error('Page closed unexpectedly')
|
||||||
|
|
||||||
|
iteration++
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `State check iteration ${iteration}/${maxIterations}`)
|
||||||
|
|
||||||
|
const state = await this.detectCurrentState(page)
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Current state: ${state}`)
|
||||||
|
|
||||||
|
if (state !== previousState && previousState !== 'UNKNOWN') {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', `State transition: ${previousState} → ${state}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === previousState && state !== 'LOGGED_IN' && state !== 'UNKNOWN') {
|
||||||
|
sameStateCount++
|
||||||
|
if (sameStateCount >= 4) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN',
|
||||||
|
`Stuck in state "${state}" for 4 loops. Refreshing page...`
|
||||||
|
)
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||||
|
await this.bot.utils.wait(3000)
|
||||||
|
sameStateCount = 0
|
||||||
|
previousState = 'UNKNOWN'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sameStateCount = 0
|
||||||
|
}
|
||||||
|
previousState = state
|
||||||
|
|
||||||
|
if (state === 'LOGGED_IN') {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Successfully logged in')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldContinue = await this.handleState(state, page, email, password, totpSecret)
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
throw new Error(`Login failed or aborted at state: ${state}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iteration >= maxIterations) {
|
||||||
|
throw new Error('Login timeout: exceeded maximum iterations')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.finalizeLogin(page, email)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectCurrentState(page: Page): Promise<LoginState> {
|
||||||
|
// Make sure we settled before getting a URL
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
|
||||||
|
const url = new URL(page.url())
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'DETECT-CURRENT-STATE', `Current URL: ${url}`)
|
||||||
|
|
||||||
|
if (url.hostname === 'chromewebdata') {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'DETECT-CURRENT-STATE', 'Detected chromewebdata error page')
|
||||||
|
return 'CHROMEWEBDATA_ERROR'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocked = await page
|
||||||
|
.waitForSelector('#serviceAbuseLandingTitle', { state: 'visible', timeout: 200 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (isLocked) {
|
||||||
|
return 'ACCOUNT_LOCKED'
|
||||||
|
}
|
||||||
|
|
||||||
|
// If instantly loading rewards dash, logged in
|
||||||
|
if (url.hostname === 'rewards.bing.com') {
|
||||||
|
return 'LOGGED_IN'
|
||||||
|
}
|
||||||
|
|
||||||
|
// If account dash, logged in
|
||||||
|
if (url.hostname === 'account.microsoft.com') {
|
||||||
|
return 'LOGGED_IN'
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = async (selector: string, state: LoginState): Promise<LoginState | null> => {
|
||||||
|
return page
|
||||||
|
.waitForSelector(selector, { state: 'visible', timeout: 200 })
|
||||||
|
.then(visible => (visible ? state : null))
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all([
|
||||||
|
check('div[role="alert"]', 'ERROR_ALERT'),
|
||||||
|
check('[data-testid="passwordEntry"]', 'PASSWORD_INPUT'),
|
||||||
|
check('input#usernameEntry', 'EMAIL_INPUT'),
|
||||||
|
check('[data-testid="kmsiVideo"]', 'KMSI_PROMPT'),
|
||||||
|
check('[data-testid="biometricVideo"]', 'PASSKEY_VIDEO'),
|
||||||
|
check('[data-testid="registrationImg"]', 'PASSKEY_ERROR'),
|
||||||
|
check('[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])', 'SIGN_IN_ANOTHER_WAY'),
|
||||||
|
check('[data-testid="deviceShieldCheckmarkVideo"]', 'LOGIN_PASSWORDLESS'),
|
||||||
|
check('input[name="otc"]', '2FA_TOTP'),
|
||||||
|
check('form[name="OneTimeCodeViewForm"]', '2FA_TOTP')
|
||||||
|
])
|
||||||
|
|
||||||
|
// Get a code
|
||||||
|
const identityBanner = await page
|
||||||
|
.waitForSelector('[data-testid="identityBanner"]', { state: 'visible', timeout: 200 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
const primaryButton = await page
|
||||||
|
.waitForSelector(this.primaryButtonSelector, { state: 'visible', timeout: 200 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
const passwordEntry = await page
|
||||||
|
.waitForSelector('[data-testid="passwordEntry"]', { state: 'visible', timeout: 200 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (identityBanner && primaryButton && !passwordEntry && !results.includes('2FA_TOTP')) {
|
||||||
|
results.push('GET_A_CODE') // Lower prio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final
|
||||||
|
let foundStates = results.filter((s): s is LoginState => s !== null)
|
||||||
|
|
||||||
|
if (foundStates.length === 0) return 'UNKNOWN'
|
||||||
|
|
||||||
|
if (foundStates.includes('ERROR_ALERT')) {
|
||||||
|
if (url.hostname !== 'login.live.com') {
|
||||||
|
// Remove ERROR_ALERT if not on login.live.com
|
||||||
|
foundStates = foundStates.filter(s => s !== 'ERROR_ALERT')
|
||||||
|
}
|
||||||
|
if (foundStates.includes('2FA_TOTP')) {
|
||||||
|
// Don't throw on TOTP if expired code is entered
|
||||||
|
foundStates = foundStates.filter(s => s !== 'ERROR_ALERT')
|
||||||
|
}
|
||||||
|
|
||||||
|
// On login.live.com, keep it
|
||||||
|
return 'ERROR_ALERT'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundStates.includes('ERROR_ALERT')) return 'ERROR_ALERT'
|
||||||
|
if (foundStates.includes('ACCOUNT_LOCKED')) return 'ACCOUNT_LOCKED'
|
||||||
|
if (foundStates.includes('PASSKEY_VIDEO')) return 'PASSKEY_VIDEO'
|
||||||
|
if (foundStates.includes('PASSKEY_ERROR')) return 'PASSKEY_ERROR'
|
||||||
|
if (foundStates.includes('KMSI_PROMPT')) return 'KMSI_PROMPT'
|
||||||
|
if (foundStates.includes('PASSWORD_INPUT')) return 'PASSWORD_INPUT'
|
||||||
|
if (foundStates.includes('EMAIL_INPUT')) return 'EMAIL_INPUT'
|
||||||
|
if (foundStates.includes('SIGN_IN_ANOTHER_WAY')) return 'SIGN_IN_ANOTHER_WAY'
|
||||||
|
if (foundStates.includes('LOGIN_PASSWORDLESS')) return 'LOGIN_PASSWORDLESS'
|
||||||
|
if (foundStates.includes('2FA_TOTP')) return '2FA_TOTP'
|
||||||
|
|
||||||
|
const mainState = foundStates[0] as LoginState
|
||||||
|
|
||||||
|
return mainState
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleState(
|
||||||
|
state: LoginState,
|
||||||
|
page: Page,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
totpSecret?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
switch (state) {
|
||||||
|
case 'ACCOUNT_LOCKED': {
|
||||||
|
const msg = 'This account has been locked! Remove from config and restart!'
|
||||||
|
this.bot.logger.error(this.bot.isMobile, 'CHECK-LOCKED', msg)
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ERROR_ALERT': {
|
||||||
|
const alertEl = page.locator('div[role="alert"]')
|
||||||
|
const errorMsg = await alertEl.innerText().catch(() => 'Unknown Error')
|
||||||
|
this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error: ${errorMsg}`)
|
||||||
|
throw new Error(`Microsoft login error message: ${errorMsg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LOGGED_IN':
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'EMAIL_INPUT': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering email')
|
||||||
|
await this.emailLogin.enterEmail(page, email)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSWORD_INPUT': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering password')
|
||||||
|
await this.emailLogin.enterPassword(page, password)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET_A_CODE': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code"')
|
||||||
|
// Select sign in other way
|
||||||
|
await this.bot.browser.utils.ghostClick(page, '[data-testid="viewFooter"] span[role="button"]')
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CHROMEWEBDATA_ERROR': {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN',
|
||||||
|
'chromewebdata error page detected, attempting to recover to Rewards home'
|
||||||
|
)
|
||||||
|
// Try go to Rewards dashboard
|
||||||
|
try {
|
||||||
|
await page
|
||||||
|
.goto(this.bot.config.baseURL, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
await this.bot.utils.wait(3000)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// If even that fails, fall back to login.live.com
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN',
|
||||||
|
'Failed to navigate to baseURL from chromewebdata, retrying login.live.com'
|
||||||
|
)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.goto('https://login.live.com/', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
await this.bot.utils.wait(3000)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case '2FA_TOTP': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA required')
|
||||||
|
await this.totp2FALogin.handle(page, totpSecret)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SIGN_IN_ANOTHER_WAY': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Use my password"')
|
||||||
|
const passwordOption = '[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])'
|
||||||
|
await this.bot.browser.utils.ghostClick(page, passwordOption)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'KMSI_PROMPT': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Accepting KMSI prompt')
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.primaryButtonSelector)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSKEY_VIDEO':
|
||||||
|
case 'PASSKEY_ERROR': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Skipping Passkey prompt')
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.secondaryButtonSelector)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LOGIN_PASSWORDLESS': {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling passwordless authentication')
|
||||||
|
await this.passwordlessLogin.handle(page)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UNKNOWN': {
|
||||||
|
const url = new URL(page.url())
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN',
|
||||||
|
`Unknown state at host:${url.hostname} path:${url.pathname}. Waiting...`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeLogin(page: Page, email: string) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Finalizing login')
|
||||||
|
|
||||||
|
await page.goto(this.bot.config.baseURL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {})
|
||||||
|
|
||||||
|
const loginRewardsSuccess = new URL(page.url()).hostname === 'rewards.bing.com'
|
||||||
|
if (loginRewardsSuccess) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Logged into Microsoft Rewards successfully')
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN',
|
||||||
|
'Could not verify Rewards Dashboard. Assuming login valid anyway.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.verifyBingSession(page)
|
||||||
|
await this.getRewardsSession(page)
|
||||||
|
|
||||||
|
const browser = page.context()
|
||||||
|
const cookies = await browser.cookies()
|
||||||
|
await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile)
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed! Session saved!')
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyBingSession(page: Page) {
|
||||||
|
const url =
|
||||||
|
'https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F'
|
||||||
|
const loopMax = 5
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {})
|
||||||
|
|
||||||
|
for (let i = 0; i < loopMax; i++) {
|
||||||
|
if (page.isClosed()) break
|
||||||
|
|
||||||
|
// Rare error state
|
||||||
|
const state = await this.detectCurrentState(page)
|
||||||
|
if (state === 'PASSKEY_ERROR') {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-BING',
|
||||||
|
'Verification landed on Passkey error state! Trying to dismiss.'
|
||||||
|
)
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.secondaryButtonSelector)
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = new URL(page.url())
|
||||||
|
const atBingHome = u.hostname === 'www.bing.com' && u.pathname === '/'
|
||||||
|
|
||||||
|
if (atBingHome) {
|
||||||
|
await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {})
|
||||||
|
|
||||||
|
const signedIn = await page
|
||||||
|
.waitForSelector('#id_n', { timeout: 3000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (signedIn || this.bot.isMobile) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session established')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-BING',
|
||||||
|
'Could not confirm Bing session after retries; continuing'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-BING',
|
||||||
|
`Bing verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRewardsSession(page: Page) {
|
||||||
|
const loopMax = 5
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Fetching request token')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page
|
||||||
|
.goto(`${this.bot.config.baseURL}?_=${Date.now()}`, { waitUntil: 'networkidle', timeout: 10000 })
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
for (let i = 0; i < loopMax; i++) {
|
||||||
|
if (page.isClosed()) break
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-REWARD-SESSION',
|
||||||
|
`Loop ${i + 1}/${loopMax} | URL=${page.url()}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const u = new URL(page.url())
|
||||||
|
const atRewardHome = u.hostname === 'rewards.bing.com' && u.pathname === '/'
|
||||||
|
|
||||||
|
if (atRewardHome) {
|
||||||
|
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||||
|
|
||||||
|
const html = await page.content()
|
||||||
|
const $ = await this.bot.browser.utils.loadInCheerio(html)
|
||||||
|
|
||||||
|
const token =
|
||||||
|
$('input[name="__RequestVerificationToken"]').attr('value') ??
|
||||||
|
$('meta[name="__RequestVerificationToken"]').attr('content') ??
|
||||||
|
null
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
this.bot.requestToken = token
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Request token has been set!')
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-REWARD-SESSION',
|
||||||
|
`Token extracted: ${token.substring(0, 10)}...`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token NOT found on page')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-REQUEST-TOKEN',
|
||||||
|
'No RequestVerificationToken found — some activities may not work'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
throw this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'GET-REQUEST-TOKEN',
|
||||||
|
`Reward session error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppAccessToken(page: Page, email: string) {
|
||||||
|
return await new MobileAccessLogin(this.bot, page).get(email)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/browser/auth/methods/EmailLogin.ts
Normal file
86
src/browser/auth/methods/EmailLogin.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import type { MicrosoftRewardsBot } from '../../../index'
|
||||||
|
|
||||||
|
export class EmailLogin {
|
||||||
|
private submitButton = 'button[type="submit"]'
|
||||||
|
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
async enterEmail(page: Page, email: string): Promise<'ok' | 'error'> {
|
||||||
|
try {
|
||||||
|
const emailInputSelector = 'input[type="email"]'
|
||||||
|
const emailField = await page
|
||||||
|
.waitForSelector(emailInputSelector, { state: 'visible', timeout: 1000 })
|
||||||
|
.catch(() => {})
|
||||||
|
if (!emailField) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email field not found')
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
|
||||||
|
const prefilledEmail = await page
|
||||||
|
.waitForSelector('#userDisplayName', { state: 'visible', timeout: 1000 })
|
||||||
|
.catch(() => {})
|
||||||
|
if (!prefilledEmail) {
|
||||||
|
await page.fill(emailInputSelector, '').catch(() => {})
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
await page.fill(emailInputSelector, email).catch(() => {})
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email prefilled')
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }).catch(() => {})
|
||||||
|
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.submitButton)
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email submitted')
|
||||||
|
|
||||||
|
return 'ok'
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-ENTER-EMAIL',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterPassword(page: Page, password: string): Promise<'ok' | 'needs-2fa' | 'error'> {
|
||||||
|
try {
|
||||||
|
const passwordInputSelector = 'input[type="password"]'
|
||||||
|
const passwordField = await page
|
||||||
|
.waitForSelector(passwordInputSelector, { state: 'visible', timeout: 1000 })
|
||||||
|
.catch(() => {})
|
||||||
|
if (!passwordField) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password field not found')
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
await page.fill(passwordInputSelector, '').catch(() => {})
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
await page.fill(passwordInputSelector, password).catch(() => {})
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
|
||||||
|
const submitButton = await page
|
||||||
|
.waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 })
|
||||||
|
.catch(() => null)
|
||||||
|
|
||||||
|
if (submitButton) {
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.submitButton)
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password submitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok'
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-ENTER-PASSWORD',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/browser/auth/methods/MobileAccessLogin.ts
Normal file
88
src/browser/auth/methods/MobileAccessLogin.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { URLSearchParams } from 'url'
|
||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
|
import type { MicrosoftRewardsBot } from '../../../index'
|
||||||
|
|
||||||
|
export class MobileAccessLogin {
|
||||||
|
private clientId = '0000000040170455'
|
||||||
|
private authUrl = 'https://login.live.com/oauth20_authorize.srf'
|
||||||
|
private redirectUrl = 'https://login.live.com/oauth20_desktop.srf'
|
||||||
|
private tokenUrl = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
|
||||||
|
private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
|
||||||
|
private maxTimeout = 180_000 // 3min
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private bot: MicrosoftRewardsBot,
|
||||||
|
private page: Page
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(email: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const authorizeUrl = new URL(this.authUrl)
|
||||||
|
authorizeUrl.searchParams.append('response_type', 'code')
|
||||||
|
authorizeUrl.searchParams.append('client_id', this.clientId)
|
||||||
|
authorizeUrl.searchParams.append('redirect_uri', this.redirectUrl)
|
||||||
|
authorizeUrl.searchParams.append('scope', this.scope)
|
||||||
|
authorizeUrl.searchParams.append('state', randomBytes(16).toString('hex'))
|
||||||
|
authorizeUrl.searchParams.append('access_type', 'offline_access')
|
||||||
|
authorizeUrl.searchParams.append('login_hint', email)
|
||||||
|
|
||||||
|
await this.bot.browser.utils.disableFido(this.page)
|
||||||
|
|
||||||
|
await this.page.goto(authorizeUrl.href).catch(() => {})
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...')
|
||||||
|
const start = Date.now()
|
||||||
|
let code = ''
|
||||||
|
|
||||||
|
while (Date.now() - start < this.maxTimeout) {
|
||||||
|
const url = new URL(this.page.url())
|
||||||
|
if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') {
|
||||||
|
code = url.searchParams.get('code') || ''
|
||||||
|
if (code) break
|
||||||
|
}
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'Timed out waiting for OAuth code')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new URLSearchParams()
|
||||||
|
data.append('grant_type', 'authorization_code')
|
||||||
|
data.append('client_id', this.clientId)
|
||||||
|
data.append('code', code)
|
||||||
|
data.append('redirect_uri', this.redirectUrl)
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: this.tokenUrl,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
data: data.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
const token = (response?.data?.access_token as string) ?? ''
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'No access_token in token response')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Mobile access token received')
|
||||||
|
return token
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-APP',
|
||||||
|
`MobileAccess error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return ''
|
||||||
|
} finally {
|
||||||
|
await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/browser/auth/methods/PasswordlessLogin.ts
Normal file
110
src/browser/auth/methods/PasswordlessLogin.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import type { MicrosoftRewardsBot } from '../../../index'
|
||||||
|
|
||||||
|
export class PasswordlessLogin {
|
||||||
|
private readonly maxAttempts = 60
|
||||||
|
private readonly numberDisplaySelector = 'div[data-testid="displaySign"]'
|
||||||
|
private readonly approvalPath = '/ppsecure/post.srf'
|
||||||
|
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
private async getDisplayedNumber(page: Page): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const numberElement = await page
|
||||||
|
.waitForSelector(this.numberDisplaySelector, {
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
|
||||||
|
if (numberElement) {
|
||||||
|
const number = await numberElement.textContent()
|
||||||
|
return number?.trim() || null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Could not retrieve displayed number')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForApproval(page: Page): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
`Waiting for approval... (timeout after ${this.maxAttempts} seconds)`
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
||||||
|
const currentUrl = new URL(page.url())
|
||||||
|
if (currentUrl.pathname === this.approvalPath) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Approval detected')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every 5 seconds to show it's still waiting
|
||||||
|
if (attempt % 5 === 0) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
`Still waiting... (${attempt}/${this.maxAttempts} seconds elapsed)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
`Approval timeout after ${this.maxAttempts} seconds!`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
} catch (error: any) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
`Approval failed, an error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(page: Page): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Passwordless authentication requested')
|
||||||
|
|
||||||
|
const displayedNumber = await this.getDisplayedNumber(page)
|
||||||
|
|
||||||
|
if (displayedNumber) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
`Please approve login and select number: ${displayedNumber}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
'Please approve login on your authenticator app'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = await this.waitForApproval(page)
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approved successfully')
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
} else {
|
||||||
|
this.bot.logger.error(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approval failed or timed out')
|
||||||
|
throw new Error('Passwordless authentication timeout')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-PASSWORDLESS',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/browser/auth/methods/Totp2FALogin.ts
Normal file
163
src/browser/auth/methods/Totp2FALogin.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import * as OTPAuth from 'otpauth'
|
||||||
|
import readline from 'readline'
|
||||||
|
import type { MicrosoftRewardsBot } from '../../../index'
|
||||||
|
|
||||||
|
export class TotpLogin {
|
||||||
|
private readonly textInputSelector =
|
||||||
|
'form[name="OneTimeCodeViewForm"] input[type="text"], input#floatingLabelInput5'
|
||||||
|
private readonly hiddenInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
|
||||||
|
private readonly submitButtonSelector = 'button[type="submit"]'
|
||||||
|
private readonly maxManualSeconds = 60
|
||||||
|
private readonly maxManualAttempts = 5
|
||||||
|
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
private generateTotpCode(secret: string): string {
|
||||||
|
return new OTPAuth.TOTP({ secret, digits: 6 }).generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async promptManualCode(): Promise<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const visibleInput = await page
|
||||||
|
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
|
||||||
|
.catch(() => null)
|
||||||
|
|
||||||
|
if (visibleInput) {
|
||||||
|
await visibleInput.fill(code)
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled visible TOTP text input')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenInput = await page.$(this.hiddenInputSelector)
|
||||||
|
|
||||||
|
if (hiddenInput) {
|
||||||
|
await hiddenInput.fill(code)
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled hidden TOTP input')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found (visible or hidden)')
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-TOTP',
|
||||||
|
`Failed to fill TOTP input: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(page: Page, totpSecret?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP 2FA authentication requested')
|
||||||
|
|
||||||
|
if (totpSecret) {
|
||||||
|
const code = this.generateTotpCode(totpSecret)
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret')
|
||||||
|
|
||||||
|
const filled = await this.fillCode(page, code)
|
||||||
|
|
||||||
|
if (!filled) {
|
||||||
|
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to locate or fill TOTP input field')
|
||||||
|
throw new Error('TOTP input field not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input')
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||||
|
const code = await this.promptManualCode()
|
||||||
|
|
||||||
|
if (!code || !/^\d{6}$/.test(code)) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-TOTP',
|
||||||
|
`Invalid or missing TOTP code (attempt ${attempt}/${this.maxManualAttempts})`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (attempt === this.maxManualAttempts) {
|
||||||
|
throw new Error('Manual TOTP input failed or timed out')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-TOTP',
|
||||||
|
'Retrying manual TOTP input due to invalid code'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const filled = await this.fillCode(page, code)
|
||||||
|
|
||||||
|
if (!filled) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-TOTP',
|
||||||
|
`Unable to locate or fill TOTP input field (attempt ${attempt}/${this.maxManualAttempts})`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (attempt === this.maxManualAttempts) {
|
||||||
|
throw new Error('TOTP input field not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-TOTP',
|
||||||
|
'Retrying manual TOTP input due to fill failure'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Manual TOTP input failed after ${this.maxManualAttempts} attempts`)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'LOGIN-TOTP',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/config.example.json
Normal file
71
src/config.example.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"baseURL": "https://rewards.bing.com",
|
||||||
|
"sessionPath": "sessions",
|
||||||
|
"headless": false,
|
||||||
|
"runOnZeroPoints": false,
|
||||||
|
"clusters": 1,
|
||||||
|
"errorDiagnostics": true,
|
||||||
|
"saveFingerprint": {
|
||||||
|
"mobile": false,
|
||||||
|
"desktop": false
|
||||||
|
},
|
||||||
|
"workers": {
|
||||||
|
"doDailySet": true,
|
||||||
|
"doMorePromotions": true,
|
||||||
|
"doPunchCards": true,
|
||||||
|
"doAppPromotions": true,
|
||||||
|
"doDesktopSearch": true,
|
||||||
|
"doMobileSearch": true,
|
||||||
|
"doDailyCheckIn": true,
|
||||||
|
"doReadToEarn": true
|
||||||
|
},
|
||||||
|
"searchOnBingLocalQueries": false,
|
||||||
|
"globalTimeout": "30sec",
|
||||||
|
"searchSettings": {
|
||||||
|
"scrollRandomResults": false,
|
||||||
|
"clickRandomResults": false,
|
||||||
|
"parallelSearching": true,
|
||||||
|
"searchResultVisitTime": "10sec",
|
||||||
|
"searchDelay": {
|
||||||
|
"min": "30sec",
|
||||||
|
"max": "1min"
|
||||||
|
},
|
||||||
|
"readDelay": {
|
||||||
|
"min": "30sec",
|
||||||
|
"max": "1min"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debugLogs": false,
|
||||||
|
"consoleLogFilter": {
|
||||||
|
"enabled": false,
|
||||||
|
"mode": "whitelist",
|
||||||
|
"levels": ["error", "warn"],
|
||||||
|
"keywords": ["starting account"],
|
||||||
|
"regexPatterns": []
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"queryEngine": true
|
||||||
|
},
|
||||||
|
"webhook": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": false,
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"url": "",
|
||||||
|
"topic": "",
|
||||||
|
"token": "",
|
||||||
|
"title": "Microsoft-Rewards-Script",
|
||||||
|
"tags": ["bot", "notify"],
|
||||||
|
"priority": 3
|
||||||
|
},
|
||||||
|
"webhookLogFilter": {
|
||||||
|
"enabled": false,
|
||||||
|
"mode": "whitelist",
|
||||||
|
"levels": ["error"],
|
||||||
|
"keywords": ["starting account", "select number", "collected"],
|
||||||
|
"regexPatterns": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/crontab.template
Normal file
7
src/crontab.template
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Set PATH so cron jobs can find node/npm
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
# Set timezone for cron jobs
|
||||||
|
TZ=${TZ}
|
||||||
|
|
||||||
|
# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs
|
||||||
|
${CRON_SCHEDULE} /bin/bash /usr/src/microsoft-rewards-script/scripts/docker/run_daily.sh >> /proc/1/fd/1 2>&1
|
||||||
91
src/functions/Activities.ts
Normal file
91
src/functions/Activities.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
import type { Page } from 'patchright'
|
||||||
|
|
||||||
|
// App
|
||||||
|
import { DailyCheckIn } from './activities/app/DailyCheckIn'
|
||||||
|
import { ReadToEarn } from './activities/app/ReadToEarn'
|
||||||
|
import { AppReward } from './activities/app/AppReward'
|
||||||
|
|
||||||
|
// API
|
||||||
|
import { UrlReward } from './activities/api/UrlReward'
|
||||||
|
import { Quiz } from './activities/api/Quiz'
|
||||||
|
import { FindClippy } from './activities/api/FindClippy'
|
||||||
|
|
||||||
|
// Browser
|
||||||
|
import { SearchOnBing } from './activities/browser/SearchOnBing'
|
||||||
|
import { Search } from './activities/browser/Search'
|
||||||
|
|
||||||
|
import type { BasePromotion, DashboardData, FindClippyPromotion } from '../interface/DashboardData'
|
||||||
|
import type { Promotion } from '../interface/AppDashBoardData'
|
||||||
|
|
||||||
|
export default class Activities {
|
||||||
|
private bot: MicrosoftRewardsBot
|
||||||
|
|
||||||
|
constructor(bot: MicrosoftRewardsBot) {
|
||||||
|
this.bot = bot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser Activities
|
||||||
|
doSearch = async (data: DashboardData, page: Page, isMobile: boolean): Promise<number> => {
|
||||||
|
const search = new Search(this.bot)
|
||||||
|
return await search.doSearch(data, page, isMobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
doSearchOnBing = async (promotion: BasePromotion, page: Page): Promise<void> => {
|
||||||
|
const searchOnBing = new SearchOnBing(this.bot)
|
||||||
|
await searchOnBing.doSearchOnBing(promotion, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
doABC = async (page: Page): Promise<void> => {
|
||||||
|
const abc = new ABC(this.bot)
|
||||||
|
await abc.doABC(page)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
doPoll = async (page: Page): Promise<void> => {
|
||||||
|
const poll = new Poll(this.bot)
|
||||||
|
await poll.doPoll(page)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
doThisOrThat = async (page: Page): Promise<void> => {
|
||||||
|
const thisOrThat = new ThisOrThat(this.bot)
|
||||||
|
await thisOrThat.doThisOrThat(page)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API Activities
|
||||||
|
doUrlReward = async (promotion: BasePromotion): Promise<void> => {
|
||||||
|
const urlReward = new UrlReward(this.bot)
|
||||||
|
await urlReward.doUrlReward(promotion)
|
||||||
|
}
|
||||||
|
|
||||||
|
doQuiz = async (promotion: BasePromotion): Promise<void> => {
|
||||||
|
const quiz = new Quiz(this.bot)
|
||||||
|
await quiz.doQuiz(promotion)
|
||||||
|
}
|
||||||
|
|
||||||
|
doFindClippy = async (promotions: FindClippyPromotion): Promise<void> => {
|
||||||
|
const urlReward = new FindClippy(this.bot)
|
||||||
|
await urlReward.doFindClippy(promotions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App Activities
|
||||||
|
doAppReward = async (promotion: Promotion): Promise<void> => {
|
||||||
|
const urlReward = new AppReward(this.bot)
|
||||||
|
await urlReward.doAppReward(promotion)
|
||||||
|
}
|
||||||
|
|
||||||
|
doReadToEarn = async (): Promise<void> => {
|
||||||
|
const readToEarn = new ReadToEarn(this.bot)
|
||||||
|
await readToEarn.doReadToEarn()
|
||||||
|
}
|
||||||
|
|
||||||
|
doDailyCheckIn = async (): Promise<void> => {
|
||||||
|
const dailyCheckIn = new DailyCheckIn(this.bot)
|
||||||
|
await dailyCheckIn.doDailyCheckIn()
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/functions/QueryEngine.ts
Normal file
191
src/functions/QueryEngine.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type {
|
||||||
|
BingSuggestionResponse,
|
||||||
|
BingTrendingTopicsResponse,
|
||||||
|
GoogleSearch,
|
||||||
|
GoogleTrendsResponse
|
||||||
|
} from '../interface/Search'
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
|
||||||
|
export class QueryCore {
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
async getGoogleTrends(geoLocale: string): Promise<string[]> {
|
||||||
|
const queryTerms: GoogleSearch[] = []
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-GOOGLE-TRENDS',
|
||||||
|
`Generating search queries, can take a while! | GeoLocale: ${geoLocale}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://trends.google.com/_/TrendsUi/data/batchexecute',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||||
|
},
|
||||||
|
data: `f.req=[[[i0OFE,"[null, null, \\"${geoLocale.toUpperCase()}\\", 0, null, 48]"]]]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||||
|
const rawData = response.data
|
||||||
|
|
||||||
|
const trendsData = this.extractJsonFromResponse(rawData)
|
||||||
|
if (!trendsData) {
|
||||||
|
throw this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-GOOGLE-TRENDS',
|
||||||
|
'Failed to parse Google Trends response'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)])
|
||||||
|
if (mappedTrendsData.length < 90) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-GOOGLE-TRENDS',
|
||||||
|
'Insufficient search queries, falling back to US'
|
||||||
|
)
|
||||||
|
return this.getGoogleTrends(geoLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [topic, relatedQueries] of mappedTrendsData) {
|
||||||
|
queryTerms.push({
|
||||||
|
topic: topic as string,
|
||||||
|
related: relatedQueries as string[]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-GOOGLE-TRENDS',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = queryTerms.flatMap(x => [x.topic, ...x.related])
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.parse(trimmed)[0][2])[1]
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBingSuggestions(query: string = '', langCode: string = 'en'): Promise<string[]> {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-BING-SUGGESTIONS',
|
||||||
|
`Generating bing suggestions! | LangCode: ${langCode}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: `https://www.bingapis.com/api/v7/suggestions?q=${encodeURIComponent(query)}&appid=6D0A9B8C5100E9ECC7E11A104ADD76C10219804B&cc=xl&setlang=${langCode}`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||||
|
const rawData: BingSuggestionResponse = response.data
|
||||||
|
|
||||||
|
const searchSuggestions = rawData.suggestionGroups[0]?.searchSuggestions
|
||||||
|
|
||||||
|
if (!searchSuggestions?.length) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'API returned no results')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchSuggestions.map(x => x.query)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-GOOGLE-TRENDS',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBingRelatedTerms(term: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
url: `https://api.bing.com/osjson.aspx?query=${term}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||||
|
const rawData = response.data
|
||||||
|
|
||||||
|
const relatedTerms = rawData[1]
|
||||||
|
|
||||||
|
if (!relatedTerms?.length) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'API returned no results')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return relatedTerms
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-BING-RELATED',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBingTendingTopics(langCode: string = 'en'): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
url: `https://www.bing.com/api/v7/news/trendingtopics?appid=91B36E34F9D1B900E54E85A77CF11FB3BE5279E6&cc=xl&setlang=${langCode}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||||
|
const rawData: BingTrendingTopicsResponse = response.data
|
||||||
|
|
||||||
|
const trendingTopics = rawData.value
|
||||||
|
|
||||||
|
if (!trendingTopics?.length) {
|
||||||
|
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'API returned no results')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = trendingTopics.map(x => x.query?.text?.trim() || x.name.trim())
|
||||||
|
|
||||||
|
return queries
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-BING-TRENDING',
|
||||||
|
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
618
src/functions/SearchManager.ts
Normal file
618
src/functions/SearchManager.ts
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import type { BrowserContext } from 'patchright'
|
||||||
|
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||||
|
import { MicrosoftRewardsBot, executionContext } from '../index'
|
||||||
|
import type { DashboardData } from '../interface/DashboardData'
|
||||||
|
import type { Account } from '../interface/Account'
|
||||||
|
|
||||||
|
interface BrowserSession {
|
||||||
|
context: BrowserContext
|
||||||
|
fingerprint: BrowserFingerprintWithHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissingSearchPoints {
|
||||||
|
mobilePoints: number
|
||||||
|
desktopPoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResults {
|
||||||
|
mobilePoints: number
|
||||||
|
desktopPoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchManager {
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
async doSearches(
|
||||||
|
data: DashboardData,
|
||||||
|
missingSearchPoints: MissingSearchPoints,
|
||||||
|
mobileSession: BrowserSession,
|
||||||
|
account: Account,
|
||||||
|
accountEmail: string
|
||||||
|
): Promise<SearchResults> {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Start | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const doMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0
|
||||||
|
const doDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0
|
||||||
|
|
||||||
|
const mobileStatus = this.bot.config.workers.doMobileSearch
|
||||||
|
? missingSearchPoints.mobilePoints > 0
|
||||||
|
? 'run'
|
||||||
|
: 'skip-no-points'
|
||||||
|
: 'skip-disabled'
|
||||||
|
const desktopStatus = this.bot.config.workers.doDesktopSearch
|
||||||
|
? missingSearchPoints.desktopPoints > 0
|
||||||
|
? 'run'
|
||||||
|
: 'skip-no-points'
|
||||||
|
: 'skip-disabled'
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Mobile: ${mobileStatus} (enabled=${this.bot.config.workers.doMobileSearch}, missing=${missingSearchPoints.mobilePoints})`
|
||||||
|
)
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Desktop: ${desktopStatus} (enabled=${this.bot.config.workers.doDesktopSearch}, missing=${missingSearchPoints.desktopPoints})`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!doMobile && !doDesktop) {
|
||||||
|
const bothWorkersEnabled = this.bot.config.workers.doMobileSearch && this.bot.config.workers.doDesktopSearch
|
||||||
|
const bothNoPoints = missingSearchPoints.mobilePoints <= 0 && missingSearchPoints.desktopPoints <= 0
|
||||||
|
|
||||||
|
if (bothWorkersEnabled && bothNoPoints) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
'All searches skipped: no mobile or desktop points left.'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'No searches scheduled (disabled or no points).')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Closing mobile session')
|
||||||
|
try {
|
||||||
|
await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||||
|
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||||
|
})
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Failed to close mobile session: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Mobile close stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mobilePoints: 0, desktopPoints: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const useParallel = this.bot.config.searchSettings.parallelSearching
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Mode: ${useParallel ? 'parallel' : 'sequential'}`)
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `parallelSearching=${useParallel} | account=${accountEmail}`)
|
||||||
|
|
||||||
|
if (useParallel) {
|
||||||
|
return await this.doParallelSearches(
|
||||||
|
data,
|
||||||
|
missingSearchPoints,
|
||||||
|
mobileSession,
|
||||||
|
account,
|
||||||
|
accountEmail,
|
||||||
|
executionContext
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return await this.doSequentialSearches(
|
||||||
|
data,
|
||||||
|
missingSearchPoints,
|
||||||
|
mobileSession,
|
||||||
|
account,
|
||||||
|
accountEmail,
|
||||||
|
executionContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doParallelSearches(
|
||||||
|
data: DashboardData,
|
||||||
|
missingSearchPoints: MissingSearchPoints,
|
||||||
|
mobileSession: BrowserSession,
|
||||||
|
account: Account,
|
||||||
|
accountEmail: string,
|
||||||
|
executionContext: any
|
||||||
|
): Promise<SearchResults> {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Parallel start')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Parallel config | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const shouldDoMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0
|
||||||
|
const shouldDoDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Parallel flags | mobile=${shouldDoMobile} | desktop=${shouldDoDesktop}`
|
||||||
|
)
|
||||||
|
|
||||||
|
let desktopSession: BrowserSession | null = null
|
||||||
|
let mobileContextClosed = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises: Promise<number>[] = []
|
||||||
|
const searchTypes: string[] = []
|
||||||
|
|
||||||
|
if (shouldDoMobile) {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Schedule mobile | target=${missingSearchPoints.mobilePoints}`
|
||||||
|
)
|
||||||
|
searchTypes.push('Mobile')
|
||||||
|
promises.push(
|
||||||
|
this.doMobileSearch(data, missingSearchPoints, mobileSession, accountEmail, executionContext).then(
|
||||||
|
points => {
|
||||||
|
mobileContextClosed = true
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Mobile done | earned=${points}`)
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const reason = !this.bot.config.workers.doMobileSearch ? 'disabled' : 'no-points'
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Skip mobile (${reason}); closing mobile session`)
|
||||||
|
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||||
|
mobileContextClosed = true
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed (no mobile search)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDoDesktop) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Desktop login start')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Desktop login | account=${accountEmail} | proxy=${account.proxy ?? 'none'}`
|
||||||
|
)
|
||||||
|
desktopSession = await executionContext.run({ isMobile: false, accountEmail }, async () =>
|
||||||
|
this.createDesktopSession(account, accountEmail)
|
||||||
|
)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Desktop login done')
|
||||||
|
} else {
|
||||||
|
const reason = !this.bot.config.workers.doDesktopSearch ? 'disabled' : 'no-points'
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Skip desktop login (${reason})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDoDesktop && desktopSession) {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Schedule desktop | target=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
searchTypes.push('Desktop')
|
||||||
|
promises.push(
|
||||||
|
this.doDesktopSearch(
|
||||||
|
data,
|
||||||
|
missingSearchPoints,
|
||||||
|
desktopSession,
|
||||||
|
accountEmail,
|
||||||
|
executionContext
|
||||||
|
).then(points => {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Desktop done | earned=${points}`)
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Running parallel: ${searchTypes.join(' + ') || 'none'}`)
|
||||||
|
|
||||||
|
const results = await Promise.all(promises)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Parallel results | account=${accountEmail} | results=${JSON.stringify(results)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const mobilePoints = shouldDoMobile ? (results[0] ?? 0) : 0
|
||||||
|
const desktopPoints = shouldDoDesktop ? (results[shouldDoMobile ? 1 : 0] ?? 0) : 0
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Parallel summary | mobile=${mobilePoints} | desktop=${desktopPoints} | total=${
|
||||||
|
mobilePoints + desktopPoints
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mobilePoints, desktopPoints }
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Parallel failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Parallel stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (!mobileContextClosed && mobileSession) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Cleanup: closing mobile session')
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Cleanup mobile | account=${accountEmail}`)
|
||||||
|
try {
|
||||||
|
await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||||
|
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||||
|
})
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Cleanup: mobile session closed')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Cleanup: mobile close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Cleanup mobile stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSequentialSearches(
|
||||||
|
data: DashboardData,
|
||||||
|
missingSearchPoints: MissingSearchPoints,
|
||||||
|
mobileSession: BrowserSession,
|
||||||
|
account: Account,
|
||||||
|
accountEmail: string,
|
||||||
|
executionContext: any
|
||||||
|
): Promise<SearchResults> {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Sequential start')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Sequential config | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const shouldDoMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0
|
||||||
|
const shouldDoDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Sequential flags | mobile=${shouldDoMobile} | desktop=${shouldDoDesktop}`
|
||||||
|
)
|
||||||
|
|
||||||
|
let mobilePoints = 0
|
||||||
|
let desktopPoints = 0
|
||||||
|
|
||||||
|
if (shouldDoMobile) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Step 1: mobile')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Sequential mobile | target=${missingSearchPoints.mobilePoints}`
|
||||||
|
)
|
||||||
|
mobilePoints = await this.doMobileSearch(
|
||||||
|
data,
|
||||||
|
missingSearchPoints,
|
||||||
|
mobileSession,
|
||||||
|
accountEmail,
|
||||||
|
executionContext
|
||||||
|
)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 1: mobile done | earned=${mobilePoints}`)
|
||||||
|
} else {
|
||||||
|
const reason = !this.bot.config.workers.doMobileSearch ? 'disabled' : 'no-points'
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 1: skip mobile (${reason}); closing mobile session`)
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', 'Closing unused mobile context')
|
||||||
|
try {
|
||||||
|
await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||||
|
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||||
|
})
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Unused mobile session closed')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Unused mobile close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Unused mobile stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDoDesktop) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Step 2: desktop')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Sequential desktop | target=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
desktopPoints = await this.doDesktopSearchSequential(
|
||||||
|
data,
|
||||||
|
missingSearchPoints,
|
||||||
|
account,
|
||||||
|
accountEmail,
|
||||||
|
executionContext
|
||||||
|
)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 2: desktop done | earned=${desktopPoints}`)
|
||||||
|
} else {
|
||||||
|
const reason = !this.bot.config.workers.doDesktopSearch ? 'disabled' : 'no-points'
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 2: skip desktop (${reason})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MANAGER',
|
||||||
|
`Sequential summary | mobile=${mobilePoints} | desktop=${desktopPoints} | total=${
|
||||||
|
mobilePoints + desktopPoints
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Sequential done | account=${accountEmail}`)
|
||||||
|
|
||||||
|
return { mobilePoints, desktopPoints }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDesktopSession(account: Account, accountEmail: string): Promise<BrowserSession> {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Init desktop session')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-LOGIN',
|
||||||
|
`Init | account=${accountEmail} | proxy=${account.proxy ?? 'none'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await this.bot['browserFactory'].createBrowser(account.proxy, accountEmail)
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Browser created, new page')
|
||||||
|
|
||||||
|
this.bot.mainDesktopPage = await session.context.newPage()
|
||||||
|
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', `Browser ready | account=${accountEmail}`)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login start')
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Calling login handler')
|
||||||
|
|
||||||
|
await this.bot['login'].login(this.bot.mainDesktopPage, accountEmail, account.password, account.totp)
|
||||||
|
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login passed, verifying')
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'verifyBingSession')
|
||||||
|
|
||||||
|
await this.bot['login'].verifyBingSession(this.bot.mainDesktopPage)
|
||||||
|
this.bot.cookies.desktop = await session.context.cookies()
|
||||||
|
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Cookies stored')
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Desktop session ready')
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doMobileSearch(
|
||||||
|
data: DashboardData,
|
||||||
|
missingSearchPoints: MissingSearchPoints,
|
||||||
|
mobileSession: BrowserSession,
|
||||||
|
accountEmail: string,
|
||||||
|
executionContext: any
|
||||||
|
): Promise<number> {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MOBILE-SEARCH',
|
||||||
|
`Start | account=${accountEmail} | target=${missingSearchPoints.mobilePoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||||
|
try {
|
||||||
|
if (!this.bot.config.workers.doMobileSearch) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Skip: worker disabled in config')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingSearchPoints.mobilePoints === 0) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Skip: no points left')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MOBILE-SEARCH',
|
||||||
|
`Search start | target=${missingSearchPoints.mobilePoints}`
|
||||||
|
)
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', 'activities.doSearch (mobile)')
|
||||||
|
|
||||||
|
const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainMobilePage, true)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MOBILE-SEARCH',
|
||||||
|
`Search done | earned=${pointsEarned}/${missingSearchPoints.mobilePoints}`
|
||||||
|
)
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MOBILE-SEARCH',
|
||||||
|
`Result | account=${accountEmail} | earned=${pointsEarned}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return pointsEarned
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MOBILE-SEARCH',
|
||||||
|
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Closing mobile session')
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Closing context | account=${accountEmail}`)
|
||||||
|
try {
|
||||||
|
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Mobile browser closed')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
'main',
|
||||||
|
'SEARCH-MOBILE-SEARCH',
|
||||||
|
`Close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Close stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doDesktopSearch(
|
||||||
|
data: DashboardData,
|
||||||
|
missingSearchPoints: MissingSearchPoints,
|
||||||
|
desktopSession: BrowserSession,
|
||||||
|
accountEmail: string,
|
||||||
|
executionContext: any
|
||||||
|
): Promise<number> {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-PARALLEL',
|
||||||
|
`Start | account=${accountEmail} | target=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return await executionContext.run({ isMobile: false, accountEmail }, async () => {
|
||||||
|
try {
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-PARALLEL',
|
||||||
|
`Search start | target=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainDesktopPage, false)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-PARALLEL',
|
||||||
|
`Search done | earned=${pointsEarned}/${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-PARALLEL',
|
||||||
|
`Result | account=${accountEmail} | earned=${pointsEarned}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return pointsEarned
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-PARALLEL',
|
||||||
|
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-PARALLEL', 'Closing desktop session')
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Closing context | account=${accountEmail}`)
|
||||||
|
try {
|
||||||
|
await this.bot.browser.func.closeBrowser(desktopSession.context, accountEmail)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-PARALLEL', 'Desktop browser closed')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-PARALLEL',
|
||||||
|
`Close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Close stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doDesktopSearchSequential(
|
||||||
|
data: DashboardData,
|
||||||
|
missingSearchPoints: MissingSearchPoints,
|
||||||
|
account: Account,
|
||||||
|
accountEmail: string,
|
||||||
|
executionContext: any
|
||||||
|
): Promise<number> {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Start | account=${accountEmail} | target=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return await executionContext.run({ isMobile: false, accountEmail }, async () => {
|
||||||
|
if (!this.bot.config.workers.doDesktopSearch) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Skip: worker disabled in config')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingSearchPoints.desktopPoints === 0) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Skip: no points left')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let desktopSession: BrowserSession | null = null
|
||||||
|
try {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Init desktop session')
|
||||||
|
desktopSession = await this.createDesktopSession(account, accountEmail)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Search start | target=${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainDesktopPage, false)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Search done | earned=${pointsEarned}/${missingSearchPoints.desktopPoints}`
|
||||||
|
)
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Result | account=${accountEmail} | earned=${pointsEarned}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return pointsEarned
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-SEQUENTIAL', `Stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
if (desktopSession) {
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Closing desktop session')
|
||||||
|
this.bot.logger.debug(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Closing context | account=${accountEmail}`
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await this.bot.browser.func.closeBrowser(desktopSession.context, accountEmail)
|
||||||
|
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Desktop browser closed')
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
'main',
|
||||||
|
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||||
|
`Close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
this.bot.logger.debug('main', 'SEARCH-DESKTOP-SEQUENTIAL', `Close stack: ${error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/functions/Workers.ts
Normal file
199
src/functions/Workers.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
import type { DashboardData, PunchCard, BasePromotion, FindClippyPromotion } from '../interface/DashboardData'
|
||||||
|
import type { AppDashboardData } from '../interface/AppDashBoardData'
|
||||||
|
|
||||||
|
export class Workers {
|
||||||
|
public bot: MicrosoftRewardsBot
|
||||||
|
|
||||||
|
constructor(bot: MicrosoftRewardsBot) {
|
||||||
|
this.bot = bot
|
||||||
|
}
|
||||||
|
|
||||||
|
public async doDailySet(data: DashboardData, page: Page) {
|
||||||
|
const todayKey = this.bot.utils.getFormattedDate()
|
||||||
|
const todayData = data.dailySetPromotions[todayKey]
|
||||||
|
|
||||||
|
const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
|
||||||
|
|
||||||
|
if (!activitiesUncompleted.length) {
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items')
|
||||||
|
|
||||||
|
await this.solveActivities(activitiesUncompleted, page)
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async doMorePromotions(data: DashboardData, page: Page) {
|
||||||
|
const morePromotions: BasePromotion[] = [
|
||||||
|
...new Map(
|
||||||
|
[...(data.morePromotions ?? []), ...(data.morePromotionsWithoutPromotionalItems ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(p => [p.offerId, p as BasePromotion] as const)
|
||||||
|
).values()
|
||||||
|
]
|
||||||
|
|
||||||
|
const activitiesUncompleted: BasePromotion[] =
|
||||||
|
morePromotions?.filter(
|
||||||
|
x =>
|
||||||
|
!x.complete &&
|
||||||
|
x.pointProgressMax > 0 &&
|
||||||
|
x.exclusiveLockedFeatureStatus !== 'locked' &&
|
||||||
|
x.promotionType
|
||||||
|
) ?? []
|
||||||
|
|
||||||
|
if (!activitiesUncompleted.length) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'MORE-PROMOTIONS',
|
||||||
|
'All "More Promotion" items have already been completed'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'MORE-PROMOTIONS',
|
||||||
|
`Started solving ${activitiesUncompleted.length} "More Promotions" items`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.solveActivities(activitiesUncompleted, page)
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async doAppPromotions(data: AppDashboardData) {
|
||||||
|
const appRewards = data.response.promotions.filter(
|
||||||
|
x =>
|
||||||
|
x.attributes['complete']?.toLowerCase() === 'false' &&
|
||||||
|
x.attributes['offerid'] &&
|
||||||
|
x.attributes['type'] &&
|
||||||
|
x.attributes['type'] === 'sapphire'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!appRewards.length) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-PROMOTIONS',
|
||||||
|
'All "App Promotions" items have already been completed'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const reward of appRewards) {
|
||||||
|
await this.bot.activities.doAppReward(reward)
|
||||||
|
// A delay between completing each activity
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'All "App Promotions" items have been completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async solveActivities(activities: BasePromotion[], page: Page, punchCard?: PunchCard) {
|
||||||
|
for (const activity of activities) {
|
||||||
|
try {
|
||||||
|
const type = activity.promotionType?.toLowerCase() ?? ''
|
||||||
|
const name = activity.name?.toLowerCase() ?? ''
|
||||||
|
const offerId = (activity as BasePromotion).offerId
|
||||||
|
const destinationUrl = activity.destinationUrl?.toLowerCase() ?? ''
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type} | punchCard="${punchCard?.parentPromotion?.title ?? 'none'}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
// Quiz-like activities (Poll / regular quiz variants)
|
||||||
|
case 'quiz': {
|
||||||
|
const basePromotion = activity as BasePromotion
|
||||||
|
|
||||||
|
// Poll (usually 10 points, pollscenarioid in URL)
|
||||||
|
if (activity.pointProgressMax === 10 && destinationUrl.includes('pollscenarioid')) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Found activity type "Poll" | title="${activity.title}" | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
//await this.bot.activities.doPoll(basePromotion)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other quizzes handled via Quiz API
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Found activity type "Quiz" | title="${activity.title}" | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.activities.doQuiz(basePromotion)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// UrlReward
|
||||||
|
case 'urlreward': {
|
||||||
|
const basePromotion = activity as BasePromotion
|
||||||
|
|
||||||
|
// Search on Bing are subtypes of "urlreward"
|
||||||
|
if (name.includes('exploreonbing')) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Found activity type "SearchOnBing" | title="${activity.title}" | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.activities.doSearchOnBing(basePromotion, page)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Found activity type "UrlReward" | title="${activity.title}" | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.activities.doUrlReward(basePromotion)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Clippy specific promotion type
|
||||||
|
case 'findclippy': {
|
||||||
|
const clippyPromotion = activity as unknown as FindClippyPromotion
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Found activity type "FindClippy" | title="${activity.title}" | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.activities.doFindClippy(clippyPromotion)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported types
|
||||||
|
default: {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000))
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'ACTIVITY',
|
||||||
|
`Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/functions/activities/api/FindClippy.ts
Normal file
130
src/functions/activities/api/FindClippy.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type { FindClippyPromotion } from '../../../interface/DashboardData'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class FindClippy extends Workers {
|
||||||
|
private cookieHeader: string = ''
|
||||||
|
|
||||||
|
private fingerprintHeader: { [x: string]: string } = {}
|
||||||
|
|
||||||
|
private gainedPoints: number = 0
|
||||||
|
|
||||||
|
private oldBalance: number = this.bot.userData.currentPoints
|
||||||
|
|
||||||
|
public async doFindClippy(promotion: FindClippyPromotion) {
|
||||||
|
const offerId = promotion.offerId
|
||||||
|
const activityType = promotion.activityType
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.bot.requestToken) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
'Skipping: Request token not available, this activity requires it!'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||||
|
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||||
|
delete fingerprintHeaders['Cookie']
|
||||||
|
delete fingerprintHeaders['cookie']
|
||||||
|
this.fingerprintHeader = fingerprintHeaders
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Starting Find Clippy | offerId=${offerId} | activityType=${activityType} | oldBalance=${this.oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
id: offerId,
|
||||||
|
hash: promotion.hash,
|
||||||
|
timeZone: '60',
|
||||||
|
activityAmount: '1',
|
||||||
|
dbs: '0',
|
||||||
|
form: '',
|
||||||
|
type: activityType,
|
||||||
|
__RequestVerificationToken: this.bot.requestToken
|
||||||
|
})
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Prepared Find Clippy form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(this.bot.fingerprint?.headers ?? {}),
|
||||||
|
Cookie: this.cookieHeader,
|
||||||
|
Referer: 'https://rewards.bing.com/',
|
||||||
|
Origin: 'https://rewards.bing.com'
|
||||||
|
},
|
||||||
|
data: formData
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Sending Find Clippy request | offerId=${offerId} | url=${request.url}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Received Find Clippy response | offerId=${offerId} | status=${response.status}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||||
|
this.gainedPoints = newBalance - this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Balance delta after Find Clippy | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Found Clippy | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Found Clippy but no points were gained | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'FIND-CLIPPY', `Waiting after Find Clippy | offerId=${offerId}`)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'FIND-CLIPPY',
|
||||||
|
`Error in doFindClippy | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/functions/activities/api/Quiz.ts
Normal file
173
src/functions/activities/api/Quiz.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type { BasePromotion } from '../../../interface/DashboardData'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class Quiz extends Workers {
|
||||||
|
private cookieHeader: string = ''
|
||||||
|
|
||||||
|
private fingerprintHeader: { [x: string]: string } = {}
|
||||||
|
|
||||||
|
private gainedPoints: number = 0
|
||||||
|
|
||||||
|
private oldBalance: number = this.bot.userData.currentPoints
|
||||||
|
|
||||||
|
async doQuiz(promotion: BasePromotion) {
|
||||||
|
const offerId = promotion.offerId
|
||||||
|
this.oldBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||||
|
const startBalance = this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Starting quiz | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax} | currentPoints=${startBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||||
|
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||||
|
delete fingerprintHeaders['Cookie']
|
||||||
|
delete fingerprintHeaders['cookie']
|
||||||
|
this.fingerprintHeader = fingerprintHeaders
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Prepared quiz headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 8-question quiz
|
||||||
|
if (promotion.activityProgressMax === 80) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Detected 8-question quiz (activityProgressMax=80), marking as completed | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not implemented
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//Standard points quizzes (20/30/40/50 max)
|
||||||
|
if ([20, 30, 40, 50].includes(promotion.pointProgressMax)) {
|
||||||
|
let oldBalance = startBalance
|
||||||
|
let gainedPoints = 0
|
||||||
|
const maxAttempts = 20
|
||||||
|
let totalGained = 0
|
||||||
|
let attempts = 0
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Starting ReportActivity loop | offerId=${offerId} | maxAttempts=${maxAttempts} | startingBalance=${oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const jsonData = {
|
||||||
|
UserId: null,
|
||||||
|
TimeZoneOffset: -60,
|
||||||
|
OfferId: offerId,
|
||||||
|
ActivityCount: 1,
|
||||||
|
QuestionIndex: '-1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://www.bing.com/bingqa/ReportActivity?ajaxreq=1',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
cookie: this.cookieHeader,
|
||||||
|
...this.fingerprintHeader
|
||||||
|
},
|
||||||
|
data: JSON.stringify(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Sending ReportActivity request | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | url=${request.url}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Received ReportActivity response | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | status=${response.status}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||||
|
gainedPoints = newBalance - oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Balance delta after ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts = i + 1
|
||||||
|
|
||||||
|
if (gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||||
|
|
||||||
|
oldBalance = newBalance
|
||||||
|
totalGained += gainedPoints
|
||||||
|
this.gainedPoints += gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`ReportActivity ${i + 1} → ${response.status} | offerId=${offerId} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`ReportActivity ${i + 1} | offerId=${offerId} | no more points gained, ending quiz | lastBalance=${newBalance}`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Waiting between ReportActivity attempts | attempt=${i + 1}/${maxAttempts} | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000))
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Error during ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Completed the quiz successfully | offerId=${offerId} | attempts=${attempts} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Unsupported quiz configuration | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'QUIZ',
|
||||||
|
`Error in doQuiz | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/functions/activities/api/UrlReward.ts
Normal file
129
src/functions/activities/api/UrlReward.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type { BasePromotion } from '../../../interface/DashboardData'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class UrlReward extends Workers {
|
||||||
|
private cookieHeader: string = ''
|
||||||
|
|
||||||
|
private fingerprintHeader: { [x: string]: string } = {}
|
||||||
|
|
||||||
|
private gainedPoints: number = 0
|
||||||
|
|
||||||
|
private oldBalance: number = this.bot.userData.currentPoints
|
||||||
|
|
||||||
|
public async doUrlReward(promotion: BasePromotion) {
|
||||||
|
if (!this.bot.requestToken) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
'Skipping: Request token not available, this activity requires it!'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const offerId = promotion.offerId
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Starting UrlReward | offerId=${offerId} | geo=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||||
|
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||||
|
delete fingerprintHeaders['Cookie']
|
||||||
|
delete fingerprintHeaders['cookie']
|
||||||
|
this.fingerprintHeader = fingerprintHeaders
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Prepared UrlReward headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
id: offerId,
|
||||||
|
hash: promotion.hash,
|
||||||
|
timeZone: '60',
|
||||||
|
activityAmount: '1',
|
||||||
|
dbs: '0',
|
||||||
|
form: '',
|
||||||
|
type: '',
|
||||||
|
__RequestVerificationToken: this.bot.requestToken
|
||||||
|
})
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Prepared UrlReward form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1`
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(this.bot.fingerprint?.headers ?? {}),
|
||||||
|
Cookie: this.cookieHeader,
|
||||||
|
Referer: 'https://rewards.bing.com/',
|
||||||
|
Origin: 'https://rewards.bing.com'
|
||||||
|
},
|
||||||
|
data: formData
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Sending UrlReward request | offerId=${offerId} | url=${request.url}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Received UrlReward response | offerId=${offerId} | status=${response.status}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||||
|
this.gainedPoints = newBalance - this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Balance delta after UrlReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Completed UrlReward | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Failed UrlReward with no points | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'URL-REWARD', `Waiting after UrlReward | offerId=${offerId}`)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'URL-REWARD',
|
||||||
|
`Error in doUrlReward | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/functions/activities/app/AppReward.ts
Normal file
119
src/functions/activities/app/AppReward.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { Promotion } from '../../../interface/AppDashBoardData'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class AppReward extends Workers {
|
||||||
|
private gainedPoints: number = 0
|
||||||
|
|
||||||
|
private oldBalance: number = this.bot.userData.currentPoints
|
||||||
|
|
||||||
|
public async doAppReward(promotion: Promotion) {
|
||||||
|
if (!this.bot.accessToken) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
'Skipping: App access token not available, this activity requires it!'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const offerId = promotion.attributes['offerid']
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Starting AppReward | offerId=${offerId} | country=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonData = {
|
||||||
|
id: randomUUID(),
|
||||||
|
amount: 1,
|
||||||
|
type: 101,
|
||||||
|
attributes: {
|
||||||
|
offerid: offerId
|
||||||
|
},
|
||||||
|
country: this.bot.userData.geoLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Prepared activity payload | offerId=${offerId} | id=${jsonData.id} | amount=${jsonData.amount} | type=${jsonData.type} | country=${jsonData.country}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||||
|
'User-Agent':
|
||||||
|
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||||
|
'X-Rewards-Language': 'en',
|
||||||
|
'X-Rewards-ismobile': 'true'
|
||||||
|
},
|
||||||
|
data: JSON.stringify(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Sending activity request | offerId=${offerId} | url=${request.url}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Received activity response | offerId=${offerId} | status=${response.status}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const newBalance = Number(response?.data?.response?.balance ?? this.oldBalance)
|
||||||
|
this.gainedPoints = newBalance - this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Balance delta after AppReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Completed AppReward | offerId=${offerId} | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Completed AppReward with no points | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'APP-REWARD', `Waiting after AppReward | offerId=${offerId}`)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Finished AppReward | offerId=${offerId} | finalBalance=${this.bot.userData.currentPoints}`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'APP-REWARD',
|
||||||
|
`Error in doAppReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/functions/activities/app/DailyCheckIn.ts
Normal file
161
src/functions/activities/app/DailyCheckIn.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class DailyCheckIn extends Workers {
|
||||||
|
private gainedPoints: number = 0
|
||||||
|
|
||||||
|
private oldBalance: number = this.bot.userData.currentPoints
|
||||||
|
|
||||||
|
public async doDailyCheckIn() {
|
||||||
|
if (!this.bot.accessToken) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
'Skipping: App access token not available, this activity requires it!'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.oldBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Starting Daily Check-In | geo=${this.bot.userData.geoLocale} | currentPoints=${this.oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try type 101 first
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=101')
|
||||||
|
|
||||||
|
let response = await this.submitDaily(101) // Try using 101 (EU Variant?)
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Received Daily Check-In response | type=101 | status=${response?.status ?? 'unknown'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
let newBalance = Number(response?.data?.response?.balance ?? this.oldBalance)
|
||||||
|
this.gainedPoints = newBalance - this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Balance delta after Daily Check-In | type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Completed Daily Check-In | type=101 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`No points gained with type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | retryingWithType=103`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fallback to type 103
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=103')
|
||||||
|
|
||||||
|
response = await this.submitDaily(103) // Try using 103 (USA Variant?)
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Received Daily Check-In response | type=103 | status=${response?.status ?? 'unknown'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
newBalance = Number(response?.data?.response?.balance ?? this.oldBalance)
|
||||||
|
this.gainedPoints = newBalance - this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Balance delta after Daily Check-In | type=103 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Completed Daily Check-In | type=103 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Daily Check-In completed but no points gained | typesTried=101,103 | oldBalance=${this.oldBalance} | finalBalance=${newBalance}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Error during Daily Check-In | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submitDaily(type: number) {
|
||||||
|
try {
|
||||||
|
const jsonData = {
|
||||||
|
id: randomUUID(),
|
||||||
|
amount: 1,
|
||||||
|
type: type,
|
||||||
|
attributes: {
|
||||||
|
offerid: 'Gamification_Sapphire_DailyCheckIn'
|
||||||
|
},
|
||||||
|
country: this.bot.userData.geoLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Preparing Daily Check-In payload | type=${type} | id=${jsonData.id} | amount=${jsonData.amount} | country=${jsonData.country}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||||
|
'User-Agent':
|
||||||
|
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||||
|
'X-Rewards-Language': 'en',
|
||||||
|
'X-Rewards-ismobile': 'true'
|
||||||
|
},
|
||||||
|
data: JSON.stringify(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Sending Daily Check-In request | type=${type} | url=${request.url}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.bot.axios.request(request)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'DAILY-CHECK-IN',
|
||||||
|
`Error in submitDaily | type=${type} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/functions/activities/app/ReadToEarn.ts
Normal file
131
src/functions/activities/app/ReadToEarn.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class ReadToEarn extends Workers {
|
||||||
|
public async doReadToEarn() {
|
||||||
|
if (!this.bot.accessToken) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
'Skipping: App access token not available, this activity requires it!'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMin = this.bot.config.searchSettings.readDelay.min
|
||||||
|
const delayMax = this.bot.config.searchSettings.readDelay.max
|
||||||
|
const startBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Starting Read to Earn | geo=${this.bot.userData.geoLocale} | delayRange=${delayMin}-${delayMax} | currentPoints=${startBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonData = {
|
||||||
|
amount: 1,
|
||||||
|
id: '1',
|
||||||
|
type: 101,
|
||||||
|
attributes: {
|
||||||
|
offerid: 'ENUS_readarticle3_30points'
|
||||||
|
},
|
||||||
|
country: this.bot.userData.geoLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleCount = 10
|
||||||
|
let totalGained = 0
|
||||||
|
let articlesRead = 0
|
||||||
|
let oldBalance = startBalance
|
||||||
|
|
||||||
|
for (let i = 0; i < articleCount; ++i) {
|
||||||
|
jsonData.id = randomBytes(64).toString('hex')
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Submitting Read to Earn activity | article=${i + 1}/${articleCount} | id=${jsonData.id} | country=${jsonData.country}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||||
|
'User-Agent':
|
||||||
|
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||||
|
'X-Rewards-Language': 'en',
|
||||||
|
'X-Rewards-ismobile': 'true'
|
||||||
|
},
|
||||||
|
data: JSON.stringify(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Received Read to Earn response | article=${i + 1}/${articleCount} | status=${response?.status ?? 'unknown'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const newBalance = Number(response?.data?.response?.balance ?? oldBalance)
|
||||||
|
const gainedPoints = newBalance - oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Balance delta after article | article=${i + 1}/${articleCount} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (gainedPoints <= 0) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`No points gained, stopping Read to Earn | article=${i + 1}/${articleCount} | status=${response.status} | oldBalance=${oldBalance} | newBalance=${newBalance}`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update point tracking
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||||
|
totalGained += gainedPoints
|
||||||
|
articlesRead = i + 1
|
||||||
|
oldBalance = newBalance
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Read article ${i + 1}/${articleCount} | status=${response.status} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait random delay between articles
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Waiting between articles | article=${i + 1}/${articleCount} | delayRange=${delayMin}-${delayMax}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(delayMin, delayMax))
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Completed Read to Earn | articlesRead=${articlesRead} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${finalBalance}`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'READ-TO-EARN',
|
||||||
|
`Error during Read to Earn | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
426
src/functions/activities/browser/Search.ts
Normal file
426
src/functions/activities/browser/Search.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import type { Page } from 'patchright'
|
||||||
|
import type { Counters, DashboardData } from '../../../interface/DashboardData'
|
||||||
|
|
||||||
|
import { QueryCore } from '../../QueryEngine'
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
|
||||||
|
export class Search extends Workers {
|
||||||
|
private bingHome = 'https://bing.com'
|
||||||
|
private searchPageURL = ''
|
||||||
|
private searchCount = 0
|
||||||
|
|
||||||
|
public async doSearch(data: DashboardData, page: Page, isMobile: boolean): Promise<number> {
|
||||||
|
const startBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||||
|
|
||||||
|
this.bot.logger.info(isMobile, 'SEARCH-BING', `Starting Bing searches | currentPoints=${startBalance}`)
|
||||||
|
|
||||||
|
let totalGainedPoints = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
let searchCounters: Counters = await this.bot.browser.func.getSearchPoints()
|
||||||
|
const missingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile)
|
||||||
|
let missingPointsTotal = missingPoints.totalPoints
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Initial search counters | mobile=${missingPoints.mobilePoints} | desktop=${missingPoints.desktopPoints} | edge=${missingPoints.edgePoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Search points remaining | Edge=${missingPoints.edgePoints} | Desktop=${missingPoints.desktopPoints} | Mobile=${missingPoints.mobilePoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
let queries: string[] = []
|
||||||
|
|
||||||
|
const queryCore = new QueryCore(this.bot)
|
||||||
|
|
||||||
|
const locale = this.bot.userData.geoLocale.toUpperCase()
|
||||||
|
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Resolving search queries | locale=${locale}`)
|
||||||
|
|
||||||
|
// Set Google search queries
|
||||||
|
queries = await queryCore.getGoogleTrends(locale)
|
||||||
|
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Fetched base queries | count=${queries.length}`)
|
||||||
|
|
||||||
|
// Deduplicate queries
|
||||||
|
queries = [...new Set(queries)]
|
||||||
|
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Deduplicated queries | count=${queries.length}`)
|
||||||
|
|
||||||
|
// Shuffle
|
||||||
|
queries = this.bot.utils.shuffleArray(queries)
|
||||||
|
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Shuffled queries | count=${queries.length}`)
|
||||||
|
|
||||||
|
// Go to bing
|
||||||
|
const targetUrl = this.searchPageURL ? this.searchPageURL : this.bingHome
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Navigating to search page | url=${targetUrl}`)
|
||||||
|
|
||||||
|
await page.goto(targetUrl)
|
||||||
|
|
||||||
|
// Wait until page loaded
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||||
|
|
||||||
|
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||||
|
|
||||||
|
let stagnantLoop = 0
|
||||||
|
const stagnantLoopMax = 10
|
||||||
|
|
||||||
|
for (let i = 0; i < queries.length; i++) {
|
||||||
|
const query = queries[i] as string
|
||||||
|
|
||||||
|
searchCounters = await this.bingSearch(page, query, isMobile)
|
||||||
|
const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile)
|
||||||
|
const newMissingPointsTotal = newMissingPoints.totalPoints
|
||||||
|
|
||||||
|
// Points gained for THIS query only
|
||||||
|
const rawGained = missingPointsTotal - newMissingPointsTotal
|
||||||
|
const gainedPoints = Math.max(0, rawGained)
|
||||||
|
|
||||||
|
if (gainedPoints === 0) {
|
||||||
|
stagnantLoop++
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`No points gained ${stagnantLoop}/${stagnantLoopMax} | query="${query}" | remaining=${newMissingPointsTotal}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stagnantLoop = 0
|
||||||
|
|
||||||
|
// Update global user data
|
||||||
|
const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||||
|
|
||||||
|
// Track for return value
|
||||||
|
totalGainedPoints += gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`gainedPoints=${gainedPoints} points | query="${query}" | remaining=${newMissingPointsTotal}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loop state
|
||||||
|
missingPointsTotal = newMissingPointsTotal
|
||||||
|
|
||||||
|
// Completed
|
||||||
|
if (missingPointsTotal === 0) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
'All required search points earned, stopping main search loop'
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stuck
|
||||||
|
if (stagnantLoop > stagnantLoopMax) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Search did not gain points for ${stagnantLoopMax} iterations, aborting main search loop`
|
||||||
|
)
|
||||||
|
stagnantLoop = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingPointsTotal > 0) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Search completed but still missing points, generating extra searches | remaining=${missingPointsTotal}`
|
||||||
|
)
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
let stagnantLoop = 0
|
||||||
|
const stagnantLoopMax = 5
|
||||||
|
|
||||||
|
while (missingPointsTotal > 0) {
|
||||||
|
const query = queries[i++] as string
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
`Fetching related terms for extra searches | baseQuery="${query}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
const relatedTerms = await queryCore.getBingRelatedTerms(query)
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
`Related terms resolved | baseQuery="${query}" | count=${relatedTerms.length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (relatedTerms.length > 3) {
|
||||||
|
for (const term of relatedTerms.slice(1, 3)) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
`Extra search | remaining=${missingPointsTotal} | query="${term}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
searchCounters = await this.bingSearch(page, term, isMobile)
|
||||||
|
const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile)
|
||||||
|
const newMissingPointsTotal = newMissingPoints.totalPoints
|
||||||
|
|
||||||
|
// Points gained for THIS extra query only
|
||||||
|
const rawGained = missingPointsTotal - newMissingPointsTotal
|
||||||
|
const gainedPoints = Math.max(0, rawGained)
|
||||||
|
|
||||||
|
if (gainedPoints === 0) {
|
||||||
|
stagnantLoop++
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
`No points gained for extra query ${stagnantLoop}/${stagnantLoopMax} | query="${term}" | remaining=${newMissingPointsTotal}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stagnantLoop = 0
|
||||||
|
|
||||||
|
// Update global user data
|
||||||
|
const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||||
|
|
||||||
|
// Track for return value
|
||||||
|
totalGainedPoints += gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
`gainedPoints=${gainedPoints} points | query="${term}" | remaining=${newMissingPointsTotal}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loop state
|
||||||
|
missingPointsTotal = newMissingPointsTotal
|
||||||
|
|
||||||
|
// Completed
|
||||||
|
if (missingPointsTotal === 0) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
'All required search points earned during extra searches'
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stuck again
|
||||||
|
if (stagnantLoop > stagnantLoopMax) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING-EXTRA',
|
||||||
|
`Search did not gain points for ${stagnantLoopMax} extra iterations, aborting extra searches`
|
||||||
|
)
|
||||||
|
const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance)
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Aborted extra searches | startBalance=${startBalance} | finalBalance=${finalBalance}`
|
||||||
|
)
|
||||||
|
return totalGainedPoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Completed Bing searches | startBalance=${startBalance} | newBalance=${finalBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return totalGainedPoints
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Error in doSearch | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return totalGainedPoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bingSearch(searchPage: Page, query: string, isMobile: boolean) {
|
||||||
|
const maxAttempts = 5
|
||||||
|
const refreshThreshold = 10 // Page gets sluggish after x searches?
|
||||||
|
|
||||||
|
this.searchCount++
|
||||||
|
|
||||||
|
// Page fill seems to get more sluggish over time
|
||||||
|
if (this.searchCount % refreshThreshold === 0) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Returning to home page to clear accumulated page context | count=${this.searchCount} | threshold=${refreshThreshold}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Returning home to refresh state | url=${this.bingHome}`)
|
||||||
|
|
||||||
|
await searchPage.goto(this.bingHome)
|
||||||
|
await searchPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||||
|
await this.bot.browser.utils.tryDismissAllMessages(searchPage) // Not always the case but possible for new cookie headers
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Starting bingSearch | query="${query}" | maxAttempts=${maxAttempts} | searchCount=${this.searchCount} | refreshEvery=${refreshThreshold} | scrollRandomResults=${this.bot.config.searchSettings.scrollRandomResults} | clickRandomResults=${this.bot.config.searchSettings.clickRandomResults}`
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const searchBar = '#sb_form_q'
|
||||||
|
const searchBox = searchPage.locator(searchBar)
|
||||||
|
|
||||||
|
await searchPage.evaluate(() => {
|
||||||
|
window.scrollTo({ left: 0, top: 0, behavior: 'auto' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await searchPage.keyboard.press('Home')
|
||||||
|
await searchBox.waitFor({ state: 'visible', timeout: 15000 })
|
||||||
|
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
await this.bot.browser.utils.ghostClick(searchPage, searchBar, { clickCount: 3 })
|
||||||
|
await searchBox.fill('')
|
||||||
|
|
||||||
|
await searchPage.keyboard.type(query, { delay: 50 })
|
||||||
|
await searchPage.keyboard.press('Enter')
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Submitted query to Bing | attempt=${i + 1}/${maxAttempts} | query="${query}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(3000)
|
||||||
|
|
||||||
|
if (this.bot.config.searchSettings.scrollRandomResults) {
|
||||||
|
await this.bot.utils.wait(2000)
|
||||||
|
await this.randomScroll(searchPage, isMobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.bot.config.searchSettings.clickRandomResults) {
|
||||||
|
await this.bot.utils.wait(2000)
|
||||||
|
await this.clickRandomLink(searchPage, isMobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.utils.wait(
|
||||||
|
this.bot.utils.randomDelay(
|
||||||
|
this.bot.config.searchSettings.searchDelay.min,
|
||||||
|
this.bot.config.searchSettings.searchDelay.max
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const counters = await this.bot.browser.func.getSearchPoints()
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Search counters after query | attempt=${i + 1}/${maxAttempts} | query="${query}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
return counters
|
||||||
|
} catch (error) {
|
||||||
|
if (i >= 5) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Failed after 5 retries | query="${query}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Search attempt failed | attempt=${i + 1}/${maxAttempts} | query="${query}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bot.logger.warn(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Retrying search | attempt=${i + 1}/${maxAttempts} | query="${query}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.bot.utils.wait(2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-BING',
|
||||||
|
`Returning current search counters after failed retries | query="${query}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
return await this.bot.browser.func.getSearchPoints()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async randomScroll(page: Page, isMobile: boolean) {
|
||||||
|
try {
|
||||||
|
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
||||||
|
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
|
||||||
|
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-RANDOM-SCROLL',
|
||||||
|
`Random scroll | viewportHeight=${viewportHeight} | totalHeight=${totalHeight} | scrollPos=${randomScrollPosition}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.evaluate((scrollPos: number) => {
|
||||||
|
window.scrollTo({ left: 0, top: scrollPos, behavior: 'auto' })
|
||||||
|
}, randomScrollPosition)
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-RANDOM-SCROLL',
|
||||||
|
`An error occurred during random scroll | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clickRandomLink(page: Page, isMobile: boolean) {
|
||||||
|
try {
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Attempting to click a random search result link')
|
||||||
|
|
||||||
|
const searchPageUrl = page.url()
|
||||||
|
|
||||||
|
await this.bot.browser.utils.ghostClick(page, '#b_results .b_algo h2')
|
||||||
|
await this.bot.utils.wait(this.bot.config.searchSettings.searchResultVisitTime)
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Mobile
|
||||||
|
await page.goto(searchPageUrl)
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Navigated back to search page')
|
||||||
|
} else {
|
||||||
|
// Desktop
|
||||||
|
const newTab = await this.bot.browser.utils.getLatestTab(page)
|
||||||
|
const newTabUrl = newTab.url()
|
||||||
|
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', `Visited result tab | url=${newTabUrl}`)
|
||||||
|
|
||||||
|
await this.bot.browser.utils.closeTabs(newTab)
|
||||||
|
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Closed result tab')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
isMobile,
|
||||||
|
'SEARCH-RANDOM-CLICK',
|
||||||
|
`An error occurred during random click | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
330
src/functions/activities/browser/SearchOnBing.ts
Normal file
330
src/functions/activities/browser/SearchOnBing.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type { Page } from 'patchright'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import { Workers } from '../../Workers'
|
||||||
|
import { QueryCore } from '../../QueryEngine'
|
||||||
|
|
||||||
|
import type { BasePromotion } from '../../../interface/DashboardData'
|
||||||
|
|
||||||
|
export class SearchOnBing extends Workers {
|
||||||
|
private bingHome = 'https://bing.com'
|
||||||
|
|
||||||
|
private cookieHeader: string = ''
|
||||||
|
|
||||||
|
private fingerprintHeader: { [x: string]: string } = {}
|
||||||
|
|
||||||
|
private gainedPoints: number = 0
|
||||||
|
|
||||||
|
private success: boolean = false
|
||||||
|
|
||||||
|
private oldBalance: number = this.bot.userData.currentPoints
|
||||||
|
|
||||||
|
public async doSearchOnBing(promotion: BasePromotion, page: Page) {
|
||||||
|
const offerId = promotion.offerId
|
||||||
|
this.oldBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING',
|
||||||
|
`Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentPoints=${this.oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||||
|
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||||
|
delete fingerprintHeaders['Cookie']
|
||||||
|
delete fingerprintHeaders['cookie']
|
||||||
|
this.fingerprintHeader = fingerprintHeaders
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING',
|
||||||
|
`Prepared headers for SearchOnBing | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING', `Activating search task | offerId=${offerId}`)
|
||||||
|
|
||||||
|
const activated = await this.activateSearchTask(promotion)
|
||||||
|
if (!activated) {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING',
|
||||||
|
`Search activity couldn't be activated, aborting | offerId=${offerId}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the bing search here
|
||||||
|
const queries = await this.getSearchQueries(promotion)
|
||||||
|
|
||||||
|
// Run through the queries
|
||||||
|
await this.searchBing(page, queries)
|
||||||
|
|
||||||
|
if (this.success) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING',
|
||||||
|
`Completed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING',
|
||||||
|
`Failed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING',
|
||||||
|
`Error in doSearchOnBing | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchBing(page: Page, queries: string[]) {
|
||||||
|
queries = [...new Set(queries)]
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-SEARCH',
|
||||||
|
`Starting search loop | queriesCount=${queries.length} | oldBalance=${this.oldBalance}`
|
||||||
|
)
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
for (const query of queries) {
|
||||||
|
try {
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Processing query | query="${query}"`)
|
||||||
|
|
||||||
|
await this.bot.mainMobilePage.goto(this.bingHome)
|
||||||
|
|
||||||
|
// Wait until page loaded
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||||
|
|
||||||
|
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||||
|
|
||||||
|
const searchBar = '#sb_form_q'
|
||||||
|
|
||||||
|
const searchBox = page.locator(searchBar)
|
||||||
|
await searchBox.waitFor({ state: 'attached', timeout: 15000 })
|
||||||
|
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
await this.bot.browser.utils.ghostClick(page, searchBar, { clickCount: 3 })
|
||||||
|
await searchBox.fill('')
|
||||||
|
|
||||||
|
await page.keyboard.type(query, { delay: 50 })
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000))
|
||||||
|
|
||||||
|
// Check for point updates
|
||||||
|
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||||
|
this.gainedPoints = newBalance - this.oldBalance
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-SEARCH',
|
||||||
|
`Balance check after query | query="${query}" | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.gainedPoints > 0) {
|
||||||
|
this.bot.userData.currentPoints = newBalance
|
||||||
|
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-SEARCH',
|
||||||
|
`SearchOnBing query completed | query="${query}" | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
|
||||||
|
this.success = true
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-SEARCH',
|
||||||
|
`${++i}/${queries.length} | noPoints=1 | query="${query}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-SEARCH',
|
||||||
|
`Error during search loop | query="${query}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000))
|
||||||
|
await page.goto(this.bot.config.baseURL, { timeout: 5000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot.logger.warn(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-SEARCH',
|
||||||
|
`Finished all queries with no points gained | queriesTried=${queries.length} | oldBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The task needs to be activated before being able to complete it
|
||||||
|
private async activateSearchTask(promotion: BasePromotion): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-ACTIVATE',
|
||||||
|
`Preparing activation request | offerId=${promotion.offerId} | hash=${promotion.hash}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
id: promotion.offerId,
|
||||||
|
hash: promotion.hash,
|
||||||
|
timeZone: '60',
|
||||||
|
activityAmount: '1',
|
||||||
|
dbs: '0',
|
||||||
|
form: '',
|
||||||
|
type: '',
|
||||||
|
__RequestVerificationToken: this.bot.requestToken
|
||||||
|
})
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(this.bot.fingerprint?.headers ?? {}),
|
||||||
|
Cookie: this.cookieHeader,
|
||||||
|
Referer: 'https://rewards.bing.com/',
|
||||||
|
Origin: 'https://rewards.bing.com'
|
||||||
|
},
|
||||||
|
data: formData
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.bot.axios.request(request)
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-ACTIVATE',
|
||||||
|
`Successfully activated activity | status=${response.status} | offerId=${promotion.offerId}`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-ACTIVATE',
|
||||||
|
`Activation failed | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSearchQueries(promotion: BasePromotion): Promise<string[]> {
|
||||||
|
interface Queries {
|
||||||
|
title: string
|
||||||
|
queries: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let queries: Queries[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.bot.config.searchOnBingLocalQueries) {
|
||||||
|
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'Using local queries config file')
|
||||||
|
|
||||||
|
const data = fs.readFileSync(path.join(__dirname, '../queries.json'), 'utf8')
|
||||||
|
queries = JSON.parse(data)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Loaded queries config | source=local | entries=${queries.length}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
'Fetching queries config from remote repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch from the repo directly so the user doesn't need to redownload the script for the new activities
|
||||||
|
const response = await this.bot.axios.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://raw.githubusercontent.com/TheNetsky/Microsoft-Rewards-Script/refs/heads/main/src/functions/queries.json'
|
||||||
|
})
|
||||||
|
queries = response.data
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Loaded queries config | source=remote | entries=${queries.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = queries.find(
|
||||||
|
x => this.bot.utils.normalizeString(x.title) === this.bot.utils.normalizeString(promotion.title)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (answers && answers.queries.length > 0) {
|
||||||
|
const answer = this.bot.utils.shuffleArray(answers.queries)
|
||||||
|
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Found answers for activity title | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}" | answersCount=${answer.length} | firstQuery="${answer[0]}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
return answer
|
||||||
|
} else {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`No matching title in queries config | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
const queryCore = new QueryCore(this.bot)
|
||||||
|
|
||||||
|
const promotionDescription = promotion.description.toLowerCase().trim()
|
||||||
|
const queryDescription = promotionDescription.replace('search on bing', '').trim()
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Requesting Bing suggestions | queryDescription="${queryDescription}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
const bingSuggestions = await queryCore.getBingSuggestions(queryDescription)
|
||||||
|
|
||||||
|
this.bot.logger.debug(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Bing suggestions result | count=${bingSuggestions.length} | title="${promotion.title}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
// If no suggestions found
|
||||||
|
if (!bingSuggestions.length) {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`No suggestions found, falling back to activity title | title="${promotion.title}"`
|
||||||
|
)
|
||||||
|
return [promotion.title]
|
||||||
|
} else {
|
||||||
|
this.bot.logger.info(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Using Bing suggestions as search queries | count=${bingSuggestions.length} | title="${promotion.title}"`
|
||||||
|
)
|
||||||
|
return bingSuggestions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.bot.logger.error(
|
||||||
|
this.bot.isMobile,
|
||||||
|
'SEARCH-ON-BING-QUERY',
|
||||||
|
`Error while resolving search queries | title="${promotion.title}" | message=${error instanceof Error ? error.message : String(error)} | fallback=promotionTitle`
|
||||||
|
)
|
||||||
|
return [promotion.title]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
494
src/index.ts
Normal file
494
src/index.ts
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
import cluster, { Worker } from 'cluster'
|
||||||
|
import type { BrowserContext, Cookie, Page } from 'patchright'
|
||||||
|
import pkg from '../package.json'
|
||||||
|
|
||||||
|
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||||
|
|
||||||
|
import Browser from './browser/Browser'
|
||||||
|
import BrowserFunc from './browser/BrowserFunc'
|
||||||
|
import BrowserUtils from './browser/BrowserUtils'
|
||||||
|
|
||||||
|
import { IpcLog, Logger } from './logging/Logger'
|
||||||
|
import Utils from './util/Utils'
|
||||||
|
import { loadAccounts, loadConfig } from './util/Load'
|
||||||
|
|
||||||
|
import { Login } from './browser/auth/Login'
|
||||||
|
import { Workers } from './functions/Workers'
|
||||||
|
import Activities from './functions/Activities'
|
||||||
|
import { SearchManager } from './functions/SearchManager'
|
||||||
|
|
||||||
|
import type { Account } from './interface/Account'
|
||||||
|
import AxiosClient from './util/Axios'
|
||||||
|
import { sendDiscord, flushDiscordQueue } from './logging/Discord'
|
||||||
|
import { sendNtfy, flushNtfyQueue } from './logging/Ntfy'
|
||||||
|
import type { DashboardData } from './interface/DashboardData'
|
||||||
|
import type { AppDashboardData } from './interface/AppDashBoardData'
|
||||||
|
|
||||||
|
interface ExecutionContext {
|
||||||
|
isMobile: boolean
|
||||||
|
accountEmail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserSession {
|
||||||
|
context: BrowserContext
|
||||||
|
fingerprint: BrowserFingerprintWithHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountStats {
|
||||||
|
email: string
|
||||||
|
initialPoints: number
|
||||||
|
finalPoints: number
|
||||||
|
collectedPoints: number
|
||||||
|
duration: number
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionContext = new AsyncLocalStorage<ExecutionContext>()
|
||||||
|
|
||||||
|
export function getCurrentContext(): ExecutionContext {
|
||||||
|
const context = executionContext.getStore()
|
||||||
|
if (!context) {
|
||||||
|
return { isMobile: false, accountEmail: 'unknown' }
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAllWebhooks(timeoutMs = 5000): Promise<void> {
|
||||||
|
await Promise.allSettled([flushDiscordQueue(timeoutMs), flushNtfyQueue(timeoutMs)])
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
userName: string
|
||||||
|
geoLocale: string
|
||||||
|
initialPoints: number
|
||||||
|
currentPoints: number
|
||||||
|
gainedPoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MicrosoftRewardsBot {
|
||||||
|
public logger: Logger
|
||||||
|
public config
|
||||||
|
public utils: Utils
|
||||||
|
public activities: Activities = new Activities(this)
|
||||||
|
public browser: { func: BrowserFunc; utils: BrowserUtils }
|
||||||
|
|
||||||
|
public mainMobilePage!: Page
|
||||||
|
public mainDesktopPage!: Page
|
||||||
|
|
||||||
|
public userData: UserData
|
||||||
|
|
||||||
|
public accessToken = ''
|
||||||
|
public requestToken = ''
|
||||||
|
public cookies: { mobile: Cookie[]; desktop: Cookie[] }
|
||||||
|
public fingerprint!: BrowserFingerprintWithHeaders
|
||||||
|
|
||||||
|
private pointsCanCollect = 0
|
||||||
|
|
||||||
|
private activeWorkers: number
|
||||||
|
private browserFactory: Browser = new Browser(this)
|
||||||
|
private accounts: Account[]
|
||||||
|
private workers: Workers
|
||||||
|
private login = new Login(this)
|
||||||
|
private searchManager: SearchManager
|
||||||
|
|
||||||
|
public axios!: AxiosClient
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userData = {
|
||||||
|
userName: '',
|
||||||
|
geoLocale: '',
|
||||||
|
initialPoints: 0,
|
||||||
|
currentPoints: 0,
|
||||||
|
gainedPoints: 0
|
||||||
|
}
|
||||||
|
this.logger = new Logger(this)
|
||||||
|
this.accounts = []
|
||||||
|
this.cookies = { mobile: [], desktop: [] }
|
||||||
|
this.utils = new Utils()
|
||||||
|
this.workers = new Workers(this)
|
||||||
|
this.searchManager = new SearchManager(this)
|
||||||
|
this.browser = {
|
||||||
|
func: new BrowserFunc(this),
|
||||||
|
utils: new BrowserUtils(this)
|
||||||
|
}
|
||||||
|
this.config = loadConfig()
|
||||||
|
this.activeWorkers = this.config.clusters
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMobile(): boolean {
|
||||||
|
return getCurrentContext().isMobile
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
this.accounts = loadAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
const totalAccounts = this.accounts.length
|
||||||
|
const runStartTime = Date.now()
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'RUN-START',
|
||||||
|
`Starting Microsoft Rewards bot| v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.config.clusters > 1) {
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
this.runMaster(runStartTime)
|
||||||
|
} else {
|
||||||
|
this.runWorker(runStartTime)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.runTasks(this.accounts, runStartTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runMaster(runStartTime: number): void {
|
||||||
|
void this.logger.info('main', 'CLUSTER-PRIMARY', `Primary process started | PID: ${process.pid}`)
|
||||||
|
|
||||||
|
const rawChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
|
||||||
|
const accountChunks = rawChunks.filter(c => c && c.length > 0)
|
||||||
|
this.activeWorkers = accountChunks.length
|
||||||
|
|
||||||
|
const allAccountStats: AccountStats[] = []
|
||||||
|
|
||||||
|
for (const chunk of accountChunks) {
|
||||||
|
const worker = cluster.fork()
|
||||||
|
worker.send?.({ chunk, runStartTime })
|
||||||
|
|
||||||
|
worker.on('message', (msg: { __ipcLog?: IpcLog; __stats?: AccountStats[] }) => {
|
||||||
|
if (msg.__stats) {
|
||||||
|
allAccountStats.push(...msg.__stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = msg.__ipcLog
|
||||||
|
|
||||||
|
if (log && typeof log.content === 'string') {
|
||||||
|
const config = this.config
|
||||||
|
const webhook = config.webhook
|
||||||
|
const content = log.content
|
||||||
|
const level = log.level
|
||||||
|
if (webhook.discord?.enabled && webhook.discord.url) {
|
||||||
|
sendDiscord(webhook.discord.url, content, level)
|
||||||
|
}
|
||||||
|
if (webhook.ntfy?.enabled && webhook.ntfy.url) {
|
||||||
|
sendNtfy(webhook.ntfy, content, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWorkerDone = async (label: 'exit' | 'disconnect', worker: Worker, code?: number): Promise<void> => {
|
||||||
|
this.activeWorkers -= 1
|
||||||
|
this.logger.warn(
|
||||||
|
'main',
|
||||||
|
`CLUSTER-WORKER-${label.toUpperCase()}`,
|
||||||
|
`Worker ${worker.process?.pid ?? '?'} ${label} | Code: ${code ?? 'n/a'} | Active workers: ${this.activeWorkers}`
|
||||||
|
)
|
||||||
|
if (this.activeWorkers <= 0) {
|
||||||
|
const totalCollectedPoints = allAccountStats.reduce((sum, s) => sum + s.collectedPoints, 0)
|
||||||
|
const totalInitialPoints = allAccountStats.reduce((sum, s) => sum + s.initialPoints, 0)
|
||||||
|
const totalFinalPoints = allAccountStats.reduce((sum, s) => sum + s.finalPoints, 0)
|
||||||
|
const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1)
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'RUN-END',
|
||||||
|
`Completed all accounts | Accounts processed: ${allAccountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(code ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.on('exit', (worker, code) => {
|
||||||
|
void onWorkerDone('exit', worker, code)
|
||||||
|
})
|
||||||
|
cluster.on('disconnect', worker => {
|
||||||
|
void onWorkerDone('disconnect', worker, undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private runWorker(runStartTimeFromMaster?: number): void {
|
||||||
|
void this.logger.info('main', 'CLUSTER-WORKER-START', `Worker spawned | PID: ${process.pid}`)
|
||||||
|
process.on('message', async ({ chunk, runStartTime }: { chunk: Account[]; runStartTime: number }) => {
|
||||||
|
void this.logger.info(
|
||||||
|
'main',
|
||||||
|
'CLUSTER-WORKER-TASK',
|
||||||
|
`Worker ${process.pid} received ${chunk.length} accounts.`
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const stats = await this.runTasks(chunk, runStartTime ?? runStartTimeFromMaster ?? Date.now())
|
||||||
|
if (process.send) {
|
||||||
|
process.send({ __stats: stats })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
'main',
|
||||||
|
'CLUSTER-WORKER-ERROR',
|
||||||
|
`Worker task crash: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runTasks(accounts: Account[], runStartTime: number): Promise<AccountStats[]> {
|
||||||
|
const accountStats: AccountStats[] = []
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const accountStartTime = Date.now()
|
||||||
|
const accountEmail = account.email
|
||||||
|
this.userData.userName = this.utils.getEmailUsername(accountEmail)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'ACCOUNT-START',
|
||||||
|
`Starting account: ${accountEmail} | geoLocale: ${account.geoLocale}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.axios = new AxiosClient(account.proxy)
|
||||||
|
|
||||||
|
const result: { initialPoints: number; collectedPoints: number } | undefined = await this.Main(
|
||||||
|
account
|
||||||
|
).catch(error => {
|
||||||
|
void this.logger.error(
|
||||||
|
true,
|
||||||
|
'FLOW',
|
||||||
|
`Mobile flow failed for ${accountEmail}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const collectedPoints = result.collectedPoints ?? 0
|
||||||
|
const accountInitialPoints = result.initialPoints ?? 0
|
||||||
|
const accountFinalPoints = accountInitialPoints + collectedPoints
|
||||||
|
|
||||||
|
accountStats.push({
|
||||||
|
email: accountEmail,
|
||||||
|
initialPoints: accountInitialPoints,
|
||||||
|
finalPoints: accountFinalPoints,
|
||||||
|
collectedPoints: collectedPoints,
|
||||||
|
duration: parseFloat(durationSeconds),
|
||||||
|
success: true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'ACCOUNT-END',
|
||||||
|
`Completed account: ${accountEmail} | Total: +${collectedPoints} | Old: ${accountInitialPoints} → New: ${accountFinalPoints} | Duration: ${durationSeconds}s`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
accountStats.push({
|
||||||
|
email: accountEmail,
|
||||||
|
initialPoints: 0,
|
||||||
|
finalPoints: 0,
|
||||||
|
collectedPoints: 0,
|
||||||
|
duration: parseFloat(durationSeconds),
|
||||||
|
success: false,
|
||||||
|
error: 'Flow failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1)
|
||||||
|
this.logger.error(
|
||||||
|
'main',
|
||||||
|
'ACCOUNT-ERROR',
|
||||||
|
`${accountEmail}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
accountStats.push({
|
||||||
|
email: accountEmail,
|
||||||
|
initialPoints: 0,
|
||||||
|
finalPoints: 0,
|
||||||
|
collectedPoints: 0,
|
||||||
|
duration: parseFloat(durationSeconds),
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.clusters <= 1 && !cluster.isWorker) {
|
||||||
|
const totalCollectedPoints = accountStats.reduce((sum, s) => sum + s.collectedPoints, 0)
|
||||||
|
const totalInitialPoints = accountStats.reduce((sum, s) => sum + s.initialPoints, 0)
|
||||||
|
const totalFinalPoints = accountStats.reduce((sum, s) => sum + s.finalPoints, 0)
|
||||||
|
const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1)
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'RUN-END',
|
||||||
|
`Completed all accounts | Accounts processed: ${accountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`,
|
||||||
|
'green'
|
||||||
|
)
|
||||||
|
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountStats
|
||||||
|
}
|
||||||
|
|
||||||
|
async Main(account: Account): Promise<{ initialPoints: number; collectedPoints: number }> {
|
||||||
|
const accountEmail = account.email
|
||||||
|
this.logger.info('main', 'FLOW', `Starting session for ${accountEmail}`)
|
||||||
|
|
||||||
|
let mobileSession: BrowserSession | null = null
|
||||||
|
let mobileContextClosed = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||||
|
mobileSession = await this.browserFactory.createBrowser(account.proxy, accountEmail)
|
||||||
|
const initialContext: BrowserContext = mobileSession.context
|
||||||
|
this.mainMobilePage = await initialContext.newPage()
|
||||||
|
|
||||||
|
this.logger.info('main', 'BROWSER', `Mobile Browser started | ${accountEmail}`)
|
||||||
|
|
||||||
|
await this.login.login(this.mainMobilePage, accountEmail, account.password, account.totp)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.accessToken = await this.login.getAppAccessToken(this.mainMobilePage, accountEmail)
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
'main',
|
||||||
|
'FLOW',
|
||||||
|
`Failed to get mobile access token: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cookies.mobile = await initialContext.cookies()
|
||||||
|
this.fingerprint = mobileSession.fingerprint
|
||||||
|
|
||||||
|
const data: DashboardData = await this.browser.func.getDashboardData()
|
||||||
|
const appData: AppDashboardData = await this.browser.func.getAppDashboardData()
|
||||||
|
|
||||||
|
// Set geo
|
||||||
|
this.userData.geoLocale =
|
||||||
|
account.geoLocale === 'auto' ? data.userProfile.attributes.country : account.geoLocale.toLowerCase()
|
||||||
|
if (this.userData.geoLocale.length > 2) {
|
||||||
|
this.logger.warn(
|
||||||
|
'main',
|
||||||
|
'GEO-LOCALE',
|
||||||
|
`The provided geoLocale is longer than 2 (${this.userData.geoLocale} | auto=${account.geoLocale === 'auto'}), this is likely invalid and can cause errors!`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userData.initialPoints = data.userStatus.availablePoints
|
||||||
|
this.userData.currentPoints = data.userStatus.availablePoints
|
||||||
|
const initialPoints = this.userData.initialPoints ?? 0
|
||||||
|
|
||||||
|
const browserEarnable = await this.browser.func.getBrowserEarnablePoints()
|
||||||
|
const appEarnable = await this.browser.func.getAppEarnablePoints()
|
||||||
|
|
||||||
|
this.pointsCanCollect = browserEarnable.mobileSearchPoints + (appEarnable?.totalEarnablePoints ?? 0)
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'POINTS',
|
||||||
|
`Earnable today | Mobile: ${this.pointsCanCollect} | Browser: ${
|
||||||
|
browserEarnable.mobileSearchPoints
|
||||||
|
} | App: ${appEarnable?.totalEarnablePoints ?? 0} | ${accountEmail} | locale: ${this.userData.geoLocale}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.config.workers.doAppPromotions) await this.workers.doAppPromotions(appData)
|
||||||
|
if (this.config.workers.doDailySet) await this.workers.doDailySet(data, this.mainMobilePage)
|
||||||
|
if (this.config.workers.doMorePromotions) await this.workers.doMorePromotions(data, this.mainMobilePage)
|
||||||
|
if (this.config.workers.doDailyCheckIn) await this.activities.doDailyCheckIn()
|
||||||
|
if (this.config.workers.doReadToEarn) await this.activities.doReadToEarn()
|
||||||
|
|
||||||
|
const searchPoints = await this.browser.func.getSearchPoints()
|
||||||
|
const missingSearchPoints = this.browser.func.missingSearchPoints(searchPoints, true)
|
||||||
|
|
||||||
|
this.cookies.mobile = await initialContext.cookies()
|
||||||
|
|
||||||
|
const { mobilePoints, desktopPoints } = await this.searchManager.doSearches(
|
||||||
|
data,
|
||||||
|
missingSearchPoints,
|
||||||
|
mobileSession,
|
||||||
|
account,
|
||||||
|
accountEmail
|
||||||
|
)
|
||||||
|
|
||||||
|
mobileContextClosed = true
|
||||||
|
|
||||||
|
this.userData.gainedPoints = mobilePoints + desktopPoints
|
||||||
|
|
||||||
|
const finalPoints = await this.browser.func.getCurrentPoints()
|
||||||
|
const collectedPoints = finalPoints - initialPoints
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
'main',
|
||||||
|
'FLOW',
|
||||||
|
`Collected: +${collectedPoints} | Mobile: +${mobilePoints} | Desktop: +${desktopPoints} | ${accountEmail}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialPoints,
|
||||||
|
collectedPoints: collectedPoints || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (mobileSession && !mobileContextClosed) {
|
||||||
|
try {
|
||||||
|
await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||||
|
await this.browser.func.closeBrowser(mobileSession!.context, accountEmail)
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { executionContext }
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const rewardsBot = new MicrosoftRewardsBot()
|
||||||
|
|
||||||
|
process.on('beforeExit', () => {
|
||||||
|
void flushAllWebhooks()
|
||||||
|
})
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...')
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(130)
|
||||||
|
})
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...')
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(143)
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', async error => {
|
||||||
|
rewardsBot.logger.error('main', 'UNCAUGHT-EXCEPTION', error)
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', async reason => {
|
||||||
|
rewardsBot.logger.error('main', 'UNHANDLED-REJECTION', reason as Error)
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rewardsBot.initialize()
|
||||||
|
await rewardsBot.run()
|
||||||
|
} catch (error) {
|
||||||
|
rewardsBot.logger.error('main', 'MAIN-ERROR', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async error => {
|
||||||
|
const tmpBot = new MicrosoftRewardsBot()
|
||||||
|
tmpBot.logger.error('main', 'MAIN-ERROR', error as Error)
|
||||||
|
await flushAllWebhooks()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
15
src/interface/Account.ts
Normal file
15
src/interface/Account.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface Account {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
totp?: string
|
||||||
|
geoLocale: 'auto' | string
|
||||||
|
proxy: AccountProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountProxy {
|
||||||
|
proxyAxios: boolean
|
||||||
|
url: string
|
||||||
|
port: number
|
||||||
|
password: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
105
src/interface/AppDashBoardData.ts
Normal file
105
src/interface/AppDashBoardData.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
export interface AppDashboardData {
|
||||||
|
response: Response
|
||||||
|
correlationId: string
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Response {
|
||||||
|
profile: Profile
|
||||||
|
balance: number
|
||||||
|
counters: null
|
||||||
|
promotions: Promotion[]
|
||||||
|
catalog: null
|
||||||
|
goal_item: GoalItem
|
||||||
|
activities: null
|
||||||
|
cashback: null
|
||||||
|
orders: unknown[]
|
||||||
|
rebateProfile: null
|
||||||
|
rebatePayouts: null
|
||||||
|
giveProfile: null
|
||||||
|
autoRedeemProfile: null
|
||||||
|
autoRedeemItem: null
|
||||||
|
thirdPartyProfile: null
|
||||||
|
notifications: null
|
||||||
|
waitlist: null
|
||||||
|
autoOpenFlyout: null
|
||||||
|
coupons: null
|
||||||
|
recommendedAffordableCatalog: null
|
||||||
|
generativeAICreditsBalance: null
|
||||||
|
requestCountryCatalog: null
|
||||||
|
donationCatalog: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalItem {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
price: number
|
||||||
|
attributes: GoalItemAttributes
|
||||||
|
config: Config
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalItemAttributes {
|
||||||
|
category: string
|
||||||
|
CategoryDescription: string
|
||||||
|
'desc.group_text': string
|
||||||
|
'desc.legal_text': string
|
||||||
|
'desc.sc_description': string
|
||||||
|
'desc.sc_title': string
|
||||||
|
display_order: string
|
||||||
|
ExtraLargeImage: string
|
||||||
|
group: string
|
||||||
|
group_image: string
|
||||||
|
group_sc_image: string
|
||||||
|
group_title: string
|
||||||
|
hidden: string
|
||||||
|
large_image: string
|
||||||
|
large_sc_image: string
|
||||||
|
medium_image: string
|
||||||
|
MobileImage: string
|
||||||
|
original_price: string
|
||||||
|
points_destination: string
|
||||||
|
points_source: string
|
||||||
|
Remarks: string
|
||||||
|
ShortText: string
|
||||||
|
showcase: string
|
||||||
|
small_image: string
|
||||||
|
title: string
|
||||||
|
cimsid: string
|
||||||
|
user_defined_goal: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
isHidden: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
ruid: string
|
||||||
|
attributes: ProfileAttributes
|
||||||
|
offline_attributes: OfflineAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAttributes {
|
||||||
|
ismsaautojoined: string
|
||||||
|
created: Date
|
||||||
|
creative: string
|
||||||
|
publisher: string
|
||||||
|
program: string
|
||||||
|
country: string
|
||||||
|
target: string
|
||||||
|
epuid: string
|
||||||
|
level: string
|
||||||
|
level_upd: Date
|
||||||
|
iris_segmentation: string
|
||||||
|
iris_segmentation_upd: Date
|
||||||
|
waitlistattributes: string
|
||||||
|
waitlistattributes_upd: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineAttributes {}
|
||||||
|
|
||||||
|
export interface Promotion {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
attributes: { [key: string]: string }
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
225
src/interface/AppUserData.ts
Normal file
225
src/interface/AppUserData.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
export interface AppUserData {
|
||||||
|
response: Response
|
||||||
|
correlationId: string
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Response {
|
||||||
|
profile: Profile
|
||||||
|
balance: number
|
||||||
|
counters: null
|
||||||
|
promotions: Promotion[]
|
||||||
|
catalog: null
|
||||||
|
goal_item: GoalItem
|
||||||
|
activities: null
|
||||||
|
cashback: null
|
||||||
|
orders: Order[]
|
||||||
|
rebateProfile: null
|
||||||
|
rebatePayouts: null
|
||||||
|
giveProfile: GiveProfile
|
||||||
|
autoRedeemProfile: null
|
||||||
|
autoRedeemItem: null
|
||||||
|
thirdPartyProfile: null
|
||||||
|
notifications: null
|
||||||
|
waitlist: null
|
||||||
|
autoOpenFlyout: null
|
||||||
|
coupons: null
|
||||||
|
recommendedAffordableCatalog: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiveProfile {
|
||||||
|
give_user: string
|
||||||
|
give_organization: { [key: string]: GiveOrganization | null }
|
||||||
|
first_give_optin: string
|
||||||
|
last_give_optout: string
|
||||||
|
give_lifetime_balance: string
|
||||||
|
give_lifetime_donation_balance: string
|
||||||
|
give_balance: string
|
||||||
|
form: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiveOrganization {
|
||||||
|
give_organization_donation_points: number
|
||||||
|
give_organization_donation_point_to_currency_ratio: number
|
||||||
|
give_organization_donation_currency: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalItem {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
price: number
|
||||||
|
attributes: GoalItemAttributes
|
||||||
|
config: GoalItemConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalItemAttributes {
|
||||||
|
category: string
|
||||||
|
CategoryDescription: string
|
||||||
|
'desc.group_text': string
|
||||||
|
'desc.legal_text'?: string
|
||||||
|
'desc.sc_description': string
|
||||||
|
'desc.sc_title': string
|
||||||
|
display_order: string
|
||||||
|
ExtraLargeImage: string
|
||||||
|
group: string
|
||||||
|
group_image: string
|
||||||
|
group_sc_image: string
|
||||||
|
group_title: string
|
||||||
|
hidden?: string
|
||||||
|
large_image: string
|
||||||
|
large_sc_image: string
|
||||||
|
medium_image: string
|
||||||
|
MobileImage: string
|
||||||
|
original_price: string
|
||||||
|
Remarks?: string
|
||||||
|
ShortText?: string
|
||||||
|
showcase?: string
|
||||||
|
small_image: string
|
||||||
|
title: string
|
||||||
|
cimsid: string
|
||||||
|
user_defined_goal?: string
|
||||||
|
disable_bot_redemptions?: string
|
||||||
|
'desc.large_text'?: string
|
||||||
|
english_title?: string
|
||||||
|
etid?: string
|
||||||
|
sku?: string
|
||||||
|
coupon_discount?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalItemConfig {
|
||||||
|
amount: string
|
||||||
|
currencyCode: string
|
||||||
|
isHidden: string
|
||||||
|
PointToCurrencyConversionRatio: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string
|
||||||
|
t: Date
|
||||||
|
sku: string
|
||||||
|
item_snapshot: ItemSnapshot
|
||||||
|
p: number
|
||||||
|
s: S
|
||||||
|
a: A
|
||||||
|
child_redemption: null
|
||||||
|
third_party_partner: null
|
||||||
|
log: Log[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface A {
|
||||||
|
form?: string
|
||||||
|
OrderId: string
|
||||||
|
CorrelationId: string
|
||||||
|
Channel: string
|
||||||
|
Language: string
|
||||||
|
Country: string
|
||||||
|
EvaluationId: string
|
||||||
|
provider?: string
|
||||||
|
referenceOrderID?: string
|
||||||
|
externalRefID?: string
|
||||||
|
denomination?: string
|
||||||
|
rewardName?: string
|
||||||
|
sendEmail?: string
|
||||||
|
status?: string
|
||||||
|
createdAt?: Date
|
||||||
|
bal_before_deduct?: string
|
||||||
|
bal_after_deduct?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemSnapshot {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
price: number
|
||||||
|
attributes: GoalItemAttributes
|
||||||
|
config: ItemSnapshotConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemSnapshotConfig {
|
||||||
|
amount: string
|
||||||
|
countryCode: string
|
||||||
|
currencyCode: string
|
||||||
|
sku: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Log {
|
||||||
|
time: Date
|
||||||
|
from: From
|
||||||
|
to: S
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum From {
|
||||||
|
Created = 'Created',
|
||||||
|
RiskApproved = 'RiskApproved',
|
||||||
|
RiskReview = 'RiskReview'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum S {
|
||||||
|
Cancelled = 'Cancelled',
|
||||||
|
RiskApproved = 'RiskApproved',
|
||||||
|
RiskReview = 'RiskReview',
|
||||||
|
Shipped = 'Shipped'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
ruid: string
|
||||||
|
attributes: ProfileAttributes
|
||||||
|
offline_attributes: OfflineAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAttributes {
|
||||||
|
publisher: string
|
||||||
|
publisher_upd: Date
|
||||||
|
creative: string
|
||||||
|
creative_upd: Date
|
||||||
|
program: string
|
||||||
|
program_upd: Date
|
||||||
|
country: string
|
||||||
|
country_upd: Date
|
||||||
|
referrerhash: string
|
||||||
|
referrerhash_upd: Date
|
||||||
|
optout_upd: Date
|
||||||
|
language: string
|
||||||
|
language_upd: Date
|
||||||
|
target: string
|
||||||
|
target_upd: Date
|
||||||
|
created: Date
|
||||||
|
created_upd: Date
|
||||||
|
epuid: string
|
||||||
|
epuid_upd: Date
|
||||||
|
goal: string
|
||||||
|
goal_upd: Date
|
||||||
|
waitlistattributes: string
|
||||||
|
waitlistattributes_upd: Date
|
||||||
|
serpbotscore_upd: Date
|
||||||
|
iscashbackeligible: string
|
||||||
|
cbedc: string
|
||||||
|
rlscpct_upd: Date
|
||||||
|
give_user: string
|
||||||
|
rebcpc_upd: Date
|
||||||
|
SerpBotScore_upd: Date
|
||||||
|
AdsBotScore_upd: Date
|
||||||
|
dbs_upd: Date
|
||||||
|
rbs: string
|
||||||
|
rbs_upd: Date
|
||||||
|
iris_segmentation: string
|
||||||
|
iris_segmentation_upd: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineAttributes {}
|
||||||
|
|
||||||
|
export interface Promotion {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
attributes: { [key: string]: string }
|
||||||
|
tags: Tag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Tag {
|
||||||
|
AllowTrialUser = 'allow_trial_user',
|
||||||
|
ExcludeGivePcparent = 'exclude_give_pcparent',
|
||||||
|
ExcludeGlobalConfig = 'exclude_global_config',
|
||||||
|
ExcludeHidden = 'exclude_hidden',
|
||||||
|
LOCString = 'locString',
|
||||||
|
NonGlobalConfig = 'non_global_config'
|
||||||
|
}
|
||||||
81
src/interface/Config.ts
Normal file
81
src/interface/Config.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
export interface Config {
|
||||||
|
baseURL: string
|
||||||
|
sessionPath: string
|
||||||
|
headless: boolean
|
||||||
|
runOnZeroPoints: boolean
|
||||||
|
clusters: number
|
||||||
|
errorDiagnostics: boolean
|
||||||
|
saveFingerprint: ConfigSaveFingerprint
|
||||||
|
workers: ConfigWorkers
|
||||||
|
searchOnBingLocalQueries: boolean
|
||||||
|
globalTimeout: number | string
|
||||||
|
searchSettings: ConfigSearchSettings
|
||||||
|
debugLogs: boolean
|
||||||
|
proxy: ConfigProxy
|
||||||
|
consoleLogFilter: LogFilter
|
||||||
|
webhook: ConfigWebhook
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSaveFingerprint {
|
||||||
|
mobile: boolean
|
||||||
|
desktop: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSearchSettings {
|
||||||
|
scrollRandomResults: boolean
|
||||||
|
clickRandomResults: boolean
|
||||||
|
parallelSearching: boolean
|
||||||
|
searchResultVisitTime: number | string
|
||||||
|
searchDelay: ConfigDelay
|
||||||
|
readDelay: ConfigDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDelay {
|
||||||
|
min: number | string
|
||||||
|
max: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigProxy {
|
||||||
|
queryEngine: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigWorkers {
|
||||||
|
doDailySet: boolean
|
||||||
|
doMorePromotions: boolean
|
||||||
|
doPunchCards: boolean
|
||||||
|
doAppPromotions: boolean
|
||||||
|
doDesktopSearch: boolean
|
||||||
|
doMobileSearch: boolean
|
||||||
|
doDailyCheckIn: boolean
|
||||||
|
doReadToEarn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
export interface ConfigWebhook {
|
||||||
|
discord?: WebhookDiscordConfig
|
||||||
|
ntfy?: WebhookNtfyConfig
|
||||||
|
webhookLogFilter: LogFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilter {
|
||||||
|
enabled: boolean
|
||||||
|
mode: 'whitelist' | 'blacklist'
|
||||||
|
levels?: Array<'debug' | 'info' | 'warn' | 'error'>
|
||||||
|
keywords?: string[]
|
||||||
|
regexPatterns?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookDiscordConfig {
|
||||||
|
enabled: boolean
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookNtfyConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
url: string
|
||||||
|
topic?: string
|
||||||
|
token?: string
|
||||||
|
title?: string
|
||||||
|
tags?: string[]
|
||||||
|
priority?: 1 | 2 | 3 | 4 | 5 // 5 highest (important)
|
||||||
|
}
|
||||||
803
src/interface/DashboardData.ts
Normal file
803
src/interface/DashboardData.ts
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
export interface DashboardData {
|
||||||
|
userStatus: UserStatus
|
||||||
|
userWarnings: unknown[]
|
||||||
|
promotionalItem: PromotionalItem
|
||||||
|
promotionalItems: PurplePromotionalItem[]
|
||||||
|
dailySetPromotions: { [key: string]: PromotionalItem[] }
|
||||||
|
streakPromotion: StreakPromotion
|
||||||
|
streakBonusPromotions: StreakBonusPromotion[]
|
||||||
|
punchCards: PunchCard[]
|
||||||
|
dashboardFlights: DashboardFlights
|
||||||
|
morePromotions: MorePromotion[]
|
||||||
|
morePromotionsWithoutPromotionalItems: MorePromotion[]
|
||||||
|
suggestedRewards: AutoRedeemItem[]
|
||||||
|
coachMarks: CoachMarks
|
||||||
|
welcomeTour: WelcomeTour
|
||||||
|
userInterests: UserInterests
|
||||||
|
isVisualParityTest: boolean
|
||||||
|
mbingFlight: null
|
||||||
|
componentImpressionPromotions: ComponentImpressionPromotion[]
|
||||||
|
machineTranslationPromo: BingUfMachineTranslationPromo
|
||||||
|
bingUfMachineTranslationPromo: BingUfMachineTranslationPromo
|
||||||
|
streakProtectionPromo: StreakProtectionPromo
|
||||||
|
autoRedeemItem: AutoRedeemItem
|
||||||
|
isAutoRedeemEligible: boolean
|
||||||
|
autoRedeemSubscriptions: unknown[]
|
||||||
|
userProfile: UserProfile
|
||||||
|
coupons: unknown[]
|
||||||
|
couponBannerPromotion: null
|
||||||
|
popUpPromotions: BingUfMachineTranslationPromo
|
||||||
|
pointClaimBannerPromotion: null
|
||||||
|
highValueSweepstakesPromotions: HighValueSweepstakesPromotion[]
|
||||||
|
revIpCountryName: null
|
||||||
|
shareAndWinPromotion: null
|
||||||
|
referAndEarnPromotion: ReferAndEarnPromotion
|
||||||
|
giveWithBingNoticePromotion: null
|
||||||
|
levelUpHeroBannerPromotion: null
|
||||||
|
monthlyBonusHeroBannerPromotion: null
|
||||||
|
starBonusWeeklyBannerPromotion: null
|
||||||
|
userGeneratedContentPromotion: null
|
||||||
|
created: Date
|
||||||
|
findClippyPromotion: FindClippyPromotion
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExclusiveLockedFeature {
|
||||||
|
Locked = 'locked',
|
||||||
|
Notsupported = 'notsupported',
|
||||||
|
Unlocked = 'unlocked'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GiveEligible {
|
||||||
|
False = 'False',
|
||||||
|
True = 'True'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum State {
|
||||||
|
Complete = 'Complete',
|
||||||
|
Default = 'Default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Type {
|
||||||
|
Empty = '',
|
||||||
|
Quiz = 'quiz',
|
||||||
|
Urlreward = 'urlreward'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Style {
|
||||||
|
ColorBlack = 'color:black',
|
||||||
|
Empty = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LegalLinkText {
|
||||||
|
ContinueToMicrosoftEdge = 'Continue to Microsoft Edge',
|
||||||
|
Empty = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Title {
|
||||||
|
Empty = '',
|
||||||
|
SetAGoal = 'Set a goal'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloseLink {
|
||||||
|
text: null | string
|
||||||
|
url: null | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportedLevels {
|
||||||
|
level1?: string
|
||||||
|
level2: string
|
||||||
|
level2XBoxGold: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Benefit {
|
||||||
|
key: string
|
||||||
|
text: string
|
||||||
|
url: null | string
|
||||||
|
helpText: null | string
|
||||||
|
supportedLevels: SupportedLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BasePromotion<
|
||||||
|
TAttributes = Record<string, any>,
|
||||||
|
TTitleStyle = string,
|
||||||
|
TDescriptionStyle = string,
|
||||||
|
TLegalLinkText = string,
|
||||||
|
TExclusiveLockedFeatureCategory = ExclusiveLockedFeature,
|
||||||
|
TPromotionType = string
|
||||||
|
> {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
attributes: TAttributes
|
||||||
|
offerId: string
|
||||||
|
complete: boolean
|
||||||
|
counter: number
|
||||||
|
activityProgress: number
|
||||||
|
activityProgressMax: number
|
||||||
|
pointProgressMax: number
|
||||||
|
pointProgress: number
|
||||||
|
promotionType: TPromotionType
|
||||||
|
promotionSubtype: string
|
||||||
|
title: string
|
||||||
|
extBannerTitle: string
|
||||||
|
titleStyle: TTitleStyle
|
||||||
|
theme: string
|
||||||
|
description: string
|
||||||
|
extBannerDescription: string
|
||||||
|
descriptionStyle: TDescriptionStyle
|
||||||
|
showcaseTitle: string
|
||||||
|
showcaseDescription: string
|
||||||
|
imageUrl: string
|
||||||
|
dynamicImage: string
|
||||||
|
smallImageUrl: string
|
||||||
|
backgroundImageUrl: string
|
||||||
|
showcaseBackgroundImageUrl: string
|
||||||
|
showcaseBackgroundLargeImageUrl: string
|
||||||
|
promotionBackgroundLeft: string
|
||||||
|
promotionBackgroundRight: string
|
||||||
|
iconUrl: string
|
||||||
|
animatedIconUrl: string
|
||||||
|
animatedLargeBackgroundImageUrl: string
|
||||||
|
destinationUrl: string
|
||||||
|
linkText: string
|
||||||
|
hash: string
|
||||||
|
activityType: string
|
||||||
|
isRecurring: boolean
|
||||||
|
isHidden: boolean
|
||||||
|
isTestOnly: boolean
|
||||||
|
isGiveEligible: boolean
|
||||||
|
level: string
|
||||||
|
levelUpActionsProgress: number
|
||||||
|
levelUpActivityDefaultSearchEngineDays: number
|
||||||
|
levelUpActivityDefaultSearchEngineCompletedAmount: number
|
||||||
|
levelUpActivityDailySetStreakDays: number
|
||||||
|
levelUpActivityDailySetCompletedAmount: number
|
||||||
|
levelUpActivityDailyStreaksCompletedAmount: number
|
||||||
|
levelUpActivityXboxGamePassCompleted: boolean
|
||||||
|
bingSearchDailyPoints: number
|
||||||
|
bingStarMonthlyBonusProgress: number
|
||||||
|
bingStarMonthlyBonusMaximum: number
|
||||||
|
bingStarBonusWeeklyProgress: number
|
||||||
|
bingStarBonusWeeklyState: string
|
||||||
|
defaultSearchEngineMonthlyBonusProgress: number
|
||||||
|
defaultSearchEngineMonthlyBonusMaximum: number
|
||||||
|
defaultSearchEngineMonthlyBonusState: string
|
||||||
|
monthlyLevelBonusMaximum: number
|
||||||
|
monthlyDistributionChartSrc: string
|
||||||
|
monthlyLevelBonusProgress: number
|
||||||
|
monthlyLevelBonusState: string
|
||||||
|
slidesCount: number
|
||||||
|
legalText: string
|
||||||
|
legalLinkText: TLegalLinkText
|
||||||
|
deviceType: string
|
||||||
|
exclusiveLockedFeatureCategory: TExclusiveLockedFeatureCategory
|
||||||
|
exclusiveLockedFeatureStatus: ExclusiveLockedFeature
|
||||||
|
exclusiveLockedFeatureDestinationUrl: string
|
||||||
|
lockedImage: string
|
||||||
|
pointsPerSearch: number
|
||||||
|
pointsPerSearchNewLevels: number
|
||||||
|
lastMonthLevel: string
|
||||||
|
sectionalOrdering: number
|
||||||
|
isAnimatedRewardEnabled: boolean
|
||||||
|
hvaLevelUpActivityDailySetCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDailySetCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDailySetDays_V2: string
|
||||||
|
hvaLevelUpActivityDailySetDaysMax_V2: string
|
||||||
|
hvaLevelUpActivityDailySetProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDailySetDisplay_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksBingCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksBingCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksBingProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksBingDisplay_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileDisplay_V2: boolean
|
||||||
|
hvaLevelUpDefaultSearchEngineCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineDays_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineDaysMax_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineDisplay_V2: boolean
|
||||||
|
hvaLevelUpActivityXboxGamePassCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityXboxGamePassCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityXboxGamePassProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityXboxGamePassDisplay_V2: boolean
|
||||||
|
programRestructureWave2HvaFlight: string
|
||||||
|
programRestructureHvaSevenDayLink: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyPromotion = BasePromotion
|
||||||
|
|
||||||
|
export interface AutoRedeemItem {
|
||||||
|
name: null | string
|
||||||
|
price: number
|
||||||
|
provider: null | string
|
||||||
|
disabled: boolean
|
||||||
|
category: string
|
||||||
|
title: string
|
||||||
|
variableGoalSpecificTitle: string
|
||||||
|
smallImageUrl: string
|
||||||
|
mediumImageUrl: string
|
||||||
|
largeImageUrl: string
|
||||||
|
largeShowcaseImageUrl: string
|
||||||
|
description: Description
|
||||||
|
showcase: boolean
|
||||||
|
showcaseInAllCategory: boolean
|
||||||
|
originalPrice: number
|
||||||
|
discountedPrice: number
|
||||||
|
couponDiscount: number
|
||||||
|
popular: boolean
|
||||||
|
isTestOnly: boolean
|
||||||
|
groupId: string
|
||||||
|
inGroup: boolean
|
||||||
|
isDefaultItemInGroup: boolean
|
||||||
|
groupTitle: string
|
||||||
|
groupImageUrl: string
|
||||||
|
groupShowcaseImageUrl: string
|
||||||
|
isEligibleForOneClickRedemption: boolean
|
||||||
|
instantWinGameId: string
|
||||||
|
instantWinPlayAgainSku: string
|
||||||
|
isLowInStock: boolean
|
||||||
|
isOutOfStock: boolean
|
||||||
|
getCodeMessage: string
|
||||||
|
disableEmail: boolean
|
||||||
|
stockMessage: string
|
||||||
|
comingSoonFlag: boolean
|
||||||
|
onSaleFlag: boolean
|
||||||
|
onSaleText: string
|
||||||
|
isGenericDonation: boolean
|
||||||
|
shouldDisableButton: boolean
|
||||||
|
highValueSweepstakesCatalogItemId: string
|
||||||
|
isHighValueSweepstakesRedeemCatalogSKU: boolean
|
||||||
|
isVariableRedemptionItem: boolean
|
||||||
|
variableRedemptionItemCurrencySymbol: null
|
||||||
|
variableRedemptionItemMin: number
|
||||||
|
variableRedemptionItemMax: number
|
||||||
|
variableItemConfigPointsToCurrencyConversionRatio: number
|
||||||
|
isRecommendedAffordableItem: boolean
|
||||||
|
recommendedAffordableOrder: number
|
||||||
|
isAutoRedeem: boolean
|
||||||
|
isAutoDonate: boolean
|
||||||
|
isAutoDonateAllPointsItem: boolean
|
||||||
|
isOneTimeDonateAllPointsItem: boolean
|
||||||
|
isAutoDonateAllGivePointsItem: boolean
|
||||||
|
isAutoDonateSetPointsItem: boolean
|
||||||
|
products: null
|
||||||
|
isDiscontinuedAutoRedeem: boolean
|
||||||
|
discontinuedAutoRedeemDate: null
|
||||||
|
isSubscriptionToggleDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Description {
|
||||||
|
itemGroupText: string
|
||||||
|
smallText: string
|
||||||
|
largeText: string
|
||||||
|
legalText: string
|
||||||
|
showcaseTitle: string
|
||||||
|
showcaseDescription: string
|
||||||
|
pageTitleTag: string
|
||||||
|
metaDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingUfMachineTranslationPromo {}
|
||||||
|
|
||||||
|
export interface CoachMarks {
|
||||||
|
streaks: WelcomeTour
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelcomeTour {
|
||||||
|
promotion: DashboardImpression | null
|
||||||
|
slides: Slide[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardImpression = BasePromotion<{ [key: string]: string } | null> & {
|
||||||
|
benefits?: Benefit[]
|
||||||
|
levelRequirements?: null
|
||||||
|
supportedLevelKeys?: string[]
|
||||||
|
supportedLevelTitles?: string[]
|
||||||
|
supportedLevelTitlesMobile?: string[]
|
||||||
|
activeLevel?: string
|
||||||
|
showShopAndEarnBenefits?: boolean
|
||||||
|
showXboxBenefits?: boolean
|
||||||
|
isLevelRedesignEnabled?: boolean
|
||||||
|
hvaDailySetDays?: string
|
||||||
|
hvaDseDays?: string
|
||||||
|
hvaGamepassCompleted?: string
|
||||||
|
hvaPuzzlePiecesCompletedAmount?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Slide {
|
||||||
|
slideType: null
|
||||||
|
slideShowTourId: string
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subtitle: null
|
||||||
|
subtitle1: null
|
||||||
|
description: string
|
||||||
|
description1: null
|
||||||
|
imageTitle: null
|
||||||
|
image2Title: null
|
||||||
|
image3Title: null
|
||||||
|
image4Title: null
|
||||||
|
imageDescription: null
|
||||||
|
image2Description: null
|
||||||
|
image3Description: null
|
||||||
|
image4Description: null
|
||||||
|
imageUrl: null
|
||||||
|
darkImageUrl: null
|
||||||
|
image2Url: null
|
||||||
|
image3Url: null
|
||||||
|
image4Url: null
|
||||||
|
layout: null
|
||||||
|
actionButtonText: null
|
||||||
|
actionButtonUrl: null
|
||||||
|
foregroundImageUrl: null
|
||||||
|
backLink: null
|
||||||
|
nextLink: CloseLink
|
||||||
|
closeLink: CloseLink
|
||||||
|
footnote: null
|
||||||
|
termsText: null
|
||||||
|
termsUrl: null
|
||||||
|
privacyText: null
|
||||||
|
privacyUrl: null
|
||||||
|
taggedItem: string
|
||||||
|
slideVisited: boolean
|
||||||
|
aboutPageLinkText: null
|
||||||
|
aboutPageLink: null
|
||||||
|
redeemLink: null
|
||||||
|
rewardsLink: null
|
||||||
|
labelText: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentImpressionPromotionAttributes {
|
||||||
|
red_dot_form_code?: string
|
||||||
|
hidden: GiveEligible
|
||||||
|
type: string
|
||||||
|
offerid: string
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
progress?: string
|
||||||
|
max?: string
|
||||||
|
complete?: GiveEligible
|
||||||
|
activity_progress?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComponentImpressionPromotion = BasePromotion<ComponentImpressionPromotionAttributes>
|
||||||
|
|
||||||
|
export interface DailySetPromotionAttributes {
|
||||||
|
animated_icon?: string
|
||||||
|
bg_image: string
|
||||||
|
complete: GiveEligible
|
||||||
|
daily_set_date?: string
|
||||||
|
description: string
|
||||||
|
destination: string
|
||||||
|
icon: string
|
||||||
|
image: string
|
||||||
|
link_text: string
|
||||||
|
max: string
|
||||||
|
modern_image?: string
|
||||||
|
offerid: string
|
||||||
|
progress: string
|
||||||
|
sc_bg_image: string
|
||||||
|
sc_bg_large_image: string
|
||||||
|
small_image: string
|
||||||
|
state: State
|
||||||
|
title: string
|
||||||
|
type: Type
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
ariaLabel?: string
|
||||||
|
promotional?: GiveEligible
|
||||||
|
parentPunchcards?: string
|
||||||
|
is_unlocked?: GiveEligible
|
||||||
|
translation_prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily set "tile" promotion
|
||||||
|
export type PromotionalItem = BasePromotion<
|
||||||
|
DailySetPromotionAttributes,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
ExclusiveLockedFeature,
|
||||||
|
Type
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface DashboardFlights {
|
||||||
|
dashboardbannernav: string
|
||||||
|
togglegiveuser: string
|
||||||
|
spotifyRedirect: string
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindClippyPromotionAttributes {
|
||||||
|
enabled: GiveEligible
|
||||||
|
points: string
|
||||||
|
activity_type: string
|
||||||
|
hidden: GiveEligible
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
progress: string
|
||||||
|
max: string
|
||||||
|
complete: GiveEligible
|
||||||
|
offerid: string
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FindClippyPromotion = BasePromotion<FindClippyPromotionAttributes>
|
||||||
|
|
||||||
|
export type HighValueSweepstakesPromotion = BasePromotion<{ [key: string]: string }>
|
||||||
|
|
||||||
|
export interface MorePromotionAttributes {
|
||||||
|
animated_icon: string
|
||||||
|
bg_image: string
|
||||||
|
complete: GiveEligible
|
||||||
|
description: string
|
||||||
|
description_style?: Style
|
||||||
|
destination: string
|
||||||
|
icon: string
|
||||||
|
image: string
|
||||||
|
link_text: string
|
||||||
|
max: string
|
||||||
|
offerid: string
|
||||||
|
progress: string
|
||||||
|
promotional?: GiveEligible
|
||||||
|
sc_bg_image: string
|
||||||
|
sc_bg_large_image: string
|
||||||
|
small_image: string
|
||||||
|
state: State
|
||||||
|
title: string
|
||||||
|
title_style?: Style
|
||||||
|
type?: Type
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
cardHeader?: string
|
||||||
|
enable_hva_card?: string
|
||||||
|
hvA_BG_static?: string
|
||||||
|
hvA_BG_type?: string
|
||||||
|
hvA_primary_asset?: string
|
||||||
|
hvA_text_color?: string
|
||||||
|
isHvaV2Compatible?: GiveEligible
|
||||||
|
link_text_style?: Style
|
||||||
|
isExploreOnBingTask?: GiveEligible
|
||||||
|
isInProgress?: GiveEligible
|
||||||
|
modern_image?: string
|
||||||
|
is_unlocked?: GiveEligible
|
||||||
|
locked_category_criteria?: string
|
||||||
|
translationprompt?: string
|
||||||
|
legal_link_text?: LegalLinkText
|
||||||
|
legal_text?: string
|
||||||
|
description_comment?: string
|
||||||
|
query_comment?: string
|
||||||
|
title_comment?: string
|
||||||
|
layout?: string
|
||||||
|
sc_description?: string
|
||||||
|
sc_title?: Title
|
||||||
|
schemaName?: string
|
||||||
|
daily_set_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MorePromotion = BasePromotion<MorePromotionAttributes, Style, Style, LegalLinkText, string, Type>
|
||||||
|
|
||||||
|
export interface PurpleAttributes {
|
||||||
|
animated_icon: string
|
||||||
|
ariaLabel?: string
|
||||||
|
bg_image: string
|
||||||
|
complete: GiveEligible
|
||||||
|
description: string
|
||||||
|
destination: string
|
||||||
|
icon: string
|
||||||
|
image: string
|
||||||
|
link_text: string
|
||||||
|
max: string
|
||||||
|
offerid: string
|
||||||
|
progress: string
|
||||||
|
promotional: GiveEligible
|
||||||
|
sc_bg_image: string
|
||||||
|
sc_bg_large_image: string
|
||||||
|
small_image: string
|
||||||
|
state: State
|
||||||
|
title: string
|
||||||
|
type: Type
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
description_style?: Style
|
||||||
|
title_style?: Style
|
||||||
|
cardHeader?: string
|
||||||
|
enable_hva_card?: string
|
||||||
|
hvA_BG_static?: string
|
||||||
|
hvA_BG_type?: string
|
||||||
|
hvA_primary_asset?: string
|
||||||
|
hvA_text_color?: string
|
||||||
|
isHvaV2Compatible?: GiveEligible
|
||||||
|
link_text_style?: Style
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PurplePromotionalItem = BasePromotion<PurpleAttributes, Style, Style, string, ExclusiveLockedFeature, Type>
|
||||||
|
|
||||||
|
export interface ParentPromotionAttributes {
|
||||||
|
bg_image: string
|
||||||
|
'classification.DescriptionText': string
|
||||||
|
'classification.PunchcardChildrenCount': string
|
||||||
|
'classification.PunchcardEndDate': Date
|
||||||
|
'classification.Template': string
|
||||||
|
'classification.TitleText': string
|
||||||
|
complete: GiveEligible
|
||||||
|
description: string
|
||||||
|
destination: string
|
||||||
|
icon: string
|
||||||
|
image: string
|
||||||
|
legal_text?: string
|
||||||
|
link_text: string
|
||||||
|
max: string
|
||||||
|
offerid: string
|
||||||
|
progress: string
|
||||||
|
sc_bg_image: string
|
||||||
|
sc_bg_large_image: string
|
||||||
|
small_image: string
|
||||||
|
state: State
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
modern_image?: string
|
||||||
|
translation_prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentPromotion = BasePromotion<ParentPromotionAttributes>
|
||||||
|
|
||||||
|
export interface PunchCard {
|
||||||
|
name: string
|
||||||
|
parentPromotion: ParentPromotion
|
||||||
|
childPromotions: PromotionalItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferAndEarnPromotionAttributes {
|
||||||
|
bannerImpressionOffer: string
|
||||||
|
claimedPointsFrom1stLayer: string
|
||||||
|
claimedPointsFrom2ndLayer: string
|
||||||
|
dailyDirectDepositPoints: string
|
||||||
|
eduBannerEnabled: GiveEligible
|
||||||
|
eventEndDate: Date
|
||||||
|
eventStartDate: Date
|
||||||
|
firstLayerDailySearchCount: string
|
||||||
|
firstLayerDailySearchUser: string
|
||||||
|
firstLayerRefereeCount: string
|
||||||
|
hidden: GiveEligible
|
||||||
|
isBigBlueBtn: GiveEligible
|
||||||
|
isNewString: GiveEligible
|
||||||
|
isOneLayer: GiveEligible
|
||||||
|
isRafStatusBanner: GiveEligible
|
||||||
|
isTwoLayer: GiveEligible
|
||||||
|
offerid: string
|
||||||
|
pendingPointsFrom1stLayer: string
|
||||||
|
pendingPointsFrom2ndLayer: string
|
||||||
|
rafBannerTreatment: string
|
||||||
|
secondLayerDailySearchCount: string
|
||||||
|
secondLayerDailySearchUser: string
|
||||||
|
secondLayerRefereeCount: string
|
||||||
|
showRedDot: GiveEligible
|
||||||
|
showTopBanner: GiveEligible
|
||||||
|
showUnusualActivityBanner: GiveEligible
|
||||||
|
totalClaimedPoints: string
|
||||||
|
totalDeclinedPoints: string
|
||||||
|
totalPendingPoints: string
|
||||||
|
type: string
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReferAndEarnPromotion = BasePromotion<ReferAndEarnPromotionAttributes>
|
||||||
|
|
||||||
|
export interface StreakBonusPromotionAttributes {
|
||||||
|
activity_max: string
|
||||||
|
activity_progress: string
|
||||||
|
animated_icon: string
|
||||||
|
bonus_earned?: string
|
||||||
|
break_description?: string
|
||||||
|
description: string
|
||||||
|
description_localizedkey: string
|
||||||
|
hidden: GiveEligible
|
||||||
|
image: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreakBonusPromotion = BasePromotion<StreakBonusPromotionAttributes>
|
||||||
|
|
||||||
|
export interface StreakPromotionAttributes {
|
||||||
|
hidden: GiveEligible
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
image: string
|
||||||
|
activity_progress: string
|
||||||
|
last_updated: Date
|
||||||
|
break_image: string
|
||||||
|
lifetime_max: string
|
||||||
|
bonus_points: string
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreakPromotion = BasePromotion<StreakPromotionAttributes> & {
|
||||||
|
lastUpdatedDate: Date
|
||||||
|
breakImageUrl: string
|
||||||
|
lifetimeMaxValue: number
|
||||||
|
bonusPointsEarned: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreakProtectionPromo {
|
||||||
|
type: string
|
||||||
|
offerid: string
|
||||||
|
isStreakProtectionOnEligible: GiveEligible
|
||||||
|
streakProtectionStatus: GiveEligible
|
||||||
|
remainingDays: string
|
||||||
|
isFirstTime: GiveEligible
|
||||||
|
streakCount: string
|
||||||
|
isTodayStreakComplete: GiveEligible
|
||||||
|
autoTurnOn: GiveEligible
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInterestsAttributes {
|
||||||
|
hidden: GiveEligible
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserInterests = BasePromotion<UserInterestsAttributes>
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
ruid: string
|
||||||
|
attributes: UserProfileAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileAttributes {
|
||||||
|
ismsaautojoined: GiveEligible
|
||||||
|
created: Date
|
||||||
|
creative: string
|
||||||
|
publisher: string
|
||||||
|
program: string
|
||||||
|
country: string
|
||||||
|
target: string
|
||||||
|
epuid: string
|
||||||
|
level: string
|
||||||
|
level_upd: Date
|
||||||
|
iris_segmentation: string
|
||||||
|
iris_segmentation_upd: Date
|
||||||
|
waitlistattributes: string
|
||||||
|
waitlistattributes_upd: Date
|
||||||
|
iscashbackeligible: GiveEligible
|
||||||
|
serpbotscore: string
|
||||||
|
serpbotscore_upd: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStatus {
|
||||||
|
levelInfo: LevelInfo
|
||||||
|
availablePoints: number
|
||||||
|
lifetimePoints: number
|
||||||
|
lifetimePointsRedeemed: number
|
||||||
|
migratedGiveBalance: number
|
||||||
|
ePuid: string
|
||||||
|
redeemGoal: AutoRedeemItem
|
||||||
|
counters: Counters
|
||||||
|
lastOrder: LastOrder
|
||||||
|
dashboardImpression: DashboardImpression
|
||||||
|
highvalueSweepstakesHVAImpression: DashboardImpression
|
||||||
|
highvalueSweepstakesWinnerImpression: DashboardImpression
|
||||||
|
referrerProgressInfo: ReferrerProgressInfo
|
||||||
|
isAutoDonateFlightEnabled: boolean
|
||||||
|
isGiveModeOn: boolean
|
||||||
|
giveBalance: number
|
||||||
|
firstTimeGiveModeOptIn: null
|
||||||
|
giveOrganizationName: null
|
||||||
|
lifetimeGivingPoints: number
|
||||||
|
isRewardsUser: boolean
|
||||||
|
isMuidTrialUser: boolean
|
||||||
|
isUserEligibleForOneClickRedemption: boolean
|
||||||
|
primaryEarningCountryName: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Counters {
|
||||||
|
pcSearch: DashboardImpression[]
|
||||||
|
mobileSearch: DashboardImpression[]
|
||||||
|
activityAndQuiz: ActivityAndQuiz[]
|
||||||
|
dailyPoint: DashboardImpression[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityAndQuizAttributes {
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
link_text: string
|
||||||
|
description: string
|
||||||
|
foreground_color: string
|
||||||
|
image: string
|
||||||
|
recurring: string
|
||||||
|
destination: string
|
||||||
|
'classification.ShowProgress': GiveEligible
|
||||||
|
hidden: GiveEligible
|
||||||
|
give_eligible: GiveEligible
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityAndQuiz = BasePromotion<ActivityAndQuizAttributes>
|
||||||
|
|
||||||
|
export interface LastOrder {
|
||||||
|
id: null
|
||||||
|
price: number
|
||||||
|
status: null
|
||||||
|
sku: null
|
||||||
|
timestamp: Date
|
||||||
|
catalogItem: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelInfo {
|
||||||
|
isNewLevelsFeatureAvailable: boolean
|
||||||
|
lastMonthLevel: string
|
||||||
|
activeLevel: string
|
||||||
|
activeLevelName: string
|
||||||
|
progress: number
|
||||||
|
progressMax: number
|
||||||
|
levels: Level[]
|
||||||
|
benefitsPromotion: DashboardImpression
|
||||||
|
levelUpActivitiesProgress: number
|
||||||
|
levelUpActivitiesMax: number
|
||||||
|
levelUpActivityDefaultSearchEngineDays: number
|
||||||
|
levelUpActivityDefaultSearchEngineCompletedAmount: number
|
||||||
|
levelUpActivityDailySetStreakDays: number
|
||||||
|
levelUpActivityDailySetCompletedAmount: number
|
||||||
|
levelUpActivityDailyStreaksCompletedAmount: number
|
||||||
|
levelUpActivityXboxGamePassCompleted: boolean
|
||||||
|
bingStarMonthlyBonusProgress: number
|
||||||
|
bingStarMonthlyBonusMaximum: number
|
||||||
|
bingStarBonusWeeklyProgress: number
|
||||||
|
bingStarBonusWeeklyState: string
|
||||||
|
defaultSearchEngineMonthlyBonusProgress: number
|
||||||
|
defaultSearchEngineMonthlyBonusMaximum: number
|
||||||
|
defaultSearchEngineMonthlyBonusState: string
|
||||||
|
monthlyLevelBonusProgress: number
|
||||||
|
monthlyLevelBonusMaximum: number
|
||||||
|
monthlyLevelBonusState: string
|
||||||
|
monthlyDistributionChartSrc: string
|
||||||
|
bingSearchDailyPoints: number
|
||||||
|
pointsPerSearch: number
|
||||||
|
hvaLevelUpActivityDailySetCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDailySetCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDailySetDays_V2: string
|
||||||
|
hvaLevelUpActivityDailySetDaysMax_V2: string
|
||||||
|
hvaLevelUpActivityDailySetProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDailySetDisplay_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksBingCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksBingCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksBingProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksBingDisplay_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDailyStreaksMobileDisplay_V2: boolean
|
||||||
|
hvaLevelUpDefaultSearchEngineCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineDays_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineDaysMax_V2: string
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityDefaultSearchEngineDisplay_V2: boolean
|
||||||
|
hvaLevelUpActivityXboxGamePassCompletedAmount_V2: string
|
||||||
|
hvaLevelUpActivityXboxGamePassCompletedMax_V2: string
|
||||||
|
hvaLevelUpActivityXboxGamePassProgress_V2: boolean
|
||||||
|
hvaLevelUpActivityXboxGamePassDisplay_V2: boolean
|
||||||
|
programRestructureWave2HvaFlight: string
|
||||||
|
programRestructureHvaSevenDayLink: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Level {
|
||||||
|
key: string
|
||||||
|
active: boolean
|
||||||
|
name: string
|
||||||
|
tasks: CloseLink[]
|
||||||
|
privileges: CloseLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferrerProgressInfo {
|
||||||
|
pointsEarned: number
|
||||||
|
pointsMax: number
|
||||||
|
isComplete: boolean
|
||||||
|
promotions: unknown[]
|
||||||
|
}
|
||||||
9
src/interface/OAuth.ts
Normal file
9
src/interface/OAuth.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface OAuth {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
scope: string
|
||||||
|
expires_in: number
|
||||||
|
ext_expires_in: number
|
||||||
|
foci: string
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
20
src/interface/Points.ts
Normal file
20
src/interface/Points.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface BrowserEarnablePoints {
|
||||||
|
desktopSearchPoints: number
|
||||||
|
mobileSearchPoints: number
|
||||||
|
dailySetPoints: number
|
||||||
|
morePromotionsPoints: number
|
||||||
|
totalEarnablePoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppEarnablePoints {
|
||||||
|
readToEarn: number
|
||||||
|
checkIn: number
|
||||||
|
totalEarnablePoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissingSearchPoints {
|
||||||
|
mobilePoints: number
|
||||||
|
desktopPoints: number
|
||||||
|
edgePoints: number
|
||||||
|
totalPoints: number
|
||||||
|
}
|
||||||
50
src/interface/QuizData.ts
Normal file
50
src/interface/QuizData.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export interface QuizData {
|
||||||
|
offerId: string
|
||||||
|
quizId: string
|
||||||
|
quizCategory: string
|
||||||
|
IsCurrentQuestionCompleted: boolean
|
||||||
|
quizRenderSummaryPage: boolean
|
||||||
|
resetQuiz: boolean
|
||||||
|
userClickedOnHint: boolean
|
||||||
|
isDemoEnabled: boolean
|
||||||
|
correctAnswer: string
|
||||||
|
isMultiChoiceQuizType: boolean
|
||||||
|
isPutInOrderQuizType: boolean
|
||||||
|
isListicleQuizType: boolean
|
||||||
|
isWOTQuizType: boolean
|
||||||
|
isBugsForRewardsQuizType: boolean
|
||||||
|
currentQuestionNumber: number
|
||||||
|
maxQuestions: number
|
||||||
|
resetTrackingCounters: boolean
|
||||||
|
showWelcomePanel: boolean
|
||||||
|
isAjaxCall: boolean
|
||||||
|
showHint: boolean
|
||||||
|
numberOfOptions: number
|
||||||
|
isMobile: boolean
|
||||||
|
inRewardsMode: boolean
|
||||||
|
enableDailySetWelcomePane: boolean
|
||||||
|
enableDailySetNonWelcomePane: boolean
|
||||||
|
isDailySetUrlOffer: boolean
|
||||||
|
isDailySetFlightEnabled: boolean
|
||||||
|
dailySetUrlOfferId: string
|
||||||
|
earnedCredits: number
|
||||||
|
maxCredits: number
|
||||||
|
creditsPerQuestion: number
|
||||||
|
userAlreadyClickedOptions: number
|
||||||
|
hasUserClickedOnOption: boolean
|
||||||
|
recentAnswerChoice: string
|
||||||
|
sessionTimerSeconds: string
|
||||||
|
isOverlayMinimized: number
|
||||||
|
ScreenReaderMsgOnMove: string
|
||||||
|
ScreenReaderMsgOnDrop: string
|
||||||
|
IsPartialPointsEnabled: boolean
|
||||||
|
PrioritizeUrlOverCookies: boolean
|
||||||
|
UseNewReportActivityAPI: boolean
|
||||||
|
CorrectlyAnsweredQuestionCount: number
|
||||||
|
showJoinRewardsPage: boolean
|
||||||
|
CorrectOptionAnswer_WOT: string
|
||||||
|
WrongOptionAnswer_WOT: string
|
||||||
|
enableSlideAnimation: boolean
|
||||||
|
ariaLoggingEnabled: boolean
|
||||||
|
UseQuestionIndexInActivityId: boolean
|
||||||
|
}
|
||||||
96
src/interface/Search.ts
Normal file
96
src/interface/Search.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Google Trends
|
||||||
|
export type GoogleTrendsResponse = [string, [string, ...null[], [string, ...string[]]][]]
|
||||||
|
|
||||||
|
export interface GoogleSearch {
|
||||||
|
topic: string
|
||||||
|
related: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bing Suggestions
|
||||||
|
export interface BingSuggestionResponse {
|
||||||
|
_type: string
|
||||||
|
instrumentation: BingInstrumentation
|
||||||
|
queryContext: BingQueryContext
|
||||||
|
suggestionGroups: BingSuggestionGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingInstrumentation {
|
||||||
|
_type: string
|
||||||
|
pingUrlBase: string
|
||||||
|
pageLoadPingUrl: string
|
||||||
|
llmPingUrlBase: string
|
||||||
|
llmLogPingUrlBase: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingQueryContext {
|
||||||
|
originalQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingSuggestionGroup {
|
||||||
|
name: string
|
||||||
|
searchSuggestions: BingSearchSuggestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingSearchSuggestion {
|
||||||
|
url: string
|
||||||
|
urlPingSuffix: string
|
||||||
|
displayText: string
|
||||||
|
query: string
|
||||||
|
result?: BingResult[]
|
||||||
|
searchKind?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingResult {
|
||||||
|
id: string
|
||||||
|
readLink: string
|
||||||
|
readLinkPingSuffix: string
|
||||||
|
webSearchUrl: string
|
||||||
|
webSearchUrlPingSuffix: string
|
||||||
|
name: string
|
||||||
|
image: BingSuggestionImage
|
||||||
|
description: string
|
||||||
|
entityPresentationInfo: BingEntityPresentationInfo
|
||||||
|
bingId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingEntityPresentationInfo {
|
||||||
|
entityScenario: string
|
||||||
|
entityTypeDisplayHint: string
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingSuggestionImage {
|
||||||
|
thumbnailUrl: string
|
||||||
|
hostPageUrl: string
|
||||||
|
hostPageUrlPingSuffix: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
sourceWidth: number
|
||||||
|
sourceHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bing Tending Topics
|
||||||
|
export interface BingTrendingTopicsResponse {
|
||||||
|
_type: string
|
||||||
|
instrumentation: BingInstrumentation
|
||||||
|
value: BingValue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingValue {
|
||||||
|
webSearchUrl: string
|
||||||
|
webSearchUrlPingSuffix: string
|
||||||
|
name: string
|
||||||
|
image: BingTrendingImage
|
||||||
|
isBreakingNews: boolean
|
||||||
|
query: BingTrendingQuery
|
||||||
|
newsSearchUrl: string
|
||||||
|
newsSearchUrlPingSuffix: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingTrendingImage {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingTrendingQuery {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
62
src/interface/UserAgentUtil.ts
Normal file
62
src/interface/UserAgentUtil.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Chrome Product Data
|
||||||
|
export interface ChromeVersion {
|
||||||
|
timestamp: Date
|
||||||
|
channels: Channels
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Channels {
|
||||||
|
Stable: Beta
|
||||||
|
Beta: Beta
|
||||||
|
Dev: Beta
|
||||||
|
Canary: Beta
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Beta {
|
||||||
|
channel: string
|
||||||
|
version: string
|
||||||
|
revision: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge Product Data
|
||||||
|
export interface EdgeVersion {
|
||||||
|
Product: string
|
||||||
|
Releases: Release[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Release {
|
||||||
|
ReleaseId: number
|
||||||
|
Platform: Platform
|
||||||
|
Architecture: Architecture
|
||||||
|
CVEs: string[]
|
||||||
|
ProductVersion: string
|
||||||
|
Artifacts: Artifact[]
|
||||||
|
PublishedTime: Date
|
||||||
|
ExpectedExpiryDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Architecture {
|
||||||
|
Arm64 = 'arm64',
|
||||||
|
Universal = 'universal',
|
||||||
|
X64 = 'x64',
|
||||||
|
X86 = 'x86'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artifact {
|
||||||
|
ArtifactName: string
|
||||||
|
Location: string
|
||||||
|
Hash: string
|
||||||
|
HashAlgorithm: HashAlgorithm
|
||||||
|
SizeInBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HashAlgorithm {
|
||||||
|
Sha256 = 'SHA256'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Platform {
|
||||||
|
Android = 'Android',
|
||||||
|
IOS = 'iOS',
|
||||||
|
Linux = 'Linux',
|
||||||
|
MACOS = 'MacOS',
|
||||||
|
Windows = 'Windows'
|
||||||
|
}
|
||||||
43
src/interface/XboxDashboardData.ts
Normal file
43
src/interface/XboxDashboardData.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface XboxDashboardData {
|
||||||
|
response: Response
|
||||||
|
correlationId: string
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Response {
|
||||||
|
profile: null
|
||||||
|
balance: number
|
||||||
|
counters: { [key: string]: string }
|
||||||
|
promotions: Promotion[]
|
||||||
|
catalog: null
|
||||||
|
goal_item: null
|
||||||
|
activities: null
|
||||||
|
cashback: null
|
||||||
|
orders: null
|
||||||
|
rebateProfile: null
|
||||||
|
rebatePayouts: null
|
||||||
|
giveProfile: null
|
||||||
|
autoRedeemProfile: null
|
||||||
|
autoRedeemItem: null
|
||||||
|
thirdPartyProfile: null
|
||||||
|
notifications: null
|
||||||
|
waitlist: null
|
||||||
|
autoOpenFlyout: null
|
||||||
|
coupons: null
|
||||||
|
recommendedAffordableCatalog: null
|
||||||
|
generativeAICreditsBalance: null
|
||||||
|
requestCountryCatalog: null
|
||||||
|
donationCatalog: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Promotion {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
attributes: { [key: string]: string }
|
||||||
|
tags: Tag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Tag {
|
||||||
|
ExcludeHidden = 'exclude_hidden',
|
||||||
|
NonGlobalConfig = 'non_global_config'
|
||||||
|
}
|
||||||
50
src/logging/Discord.ts
Normal file
50
src/logging/Discord.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import axios, { AxiosRequestConfig } from 'axios'
|
||||||
|
import PQueue from 'p-queue'
|
||||||
|
import type { LogLevel } from './Logger'
|
||||||
|
|
||||||
|
const DISCORD_LIMIT = 2000
|
||||||
|
|
||||||
|
export interface DiscordConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordQueue = new PQueue({
|
||||||
|
interval: 1000,
|
||||||
|
intervalCap: 2,
|
||||||
|
carryoverConcurrencyCount: true
|
||||||
|
})
|
||||||
|
|
||||||
|
function truncate(text: string) {
|
||||||
|
return text.length <= DISCORD_LIMIT ? text : text.slice(0, DISCORD_LIMIT - 14) + ' …(truncated)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendDiscord(discordUrl: string, content: string, level: LogLevel): Promise<void> {
|
||||||
|
if (!discordUrl) return
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: discordUrl,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
data: { content: truncate(content), allowed_mentions: { parse: [] } },
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
await discordQueue.add(async () => {
|
||||||
|
try {
|
||||||
|
await axios(request)
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status
|
||||||
|
if (status === 429) return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushDiscordQueue(timeoutMs = 5000): Promise<void> {
|
||||||
|
await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
await discordQueue.onIdle()
|
||||||
|
})(),
|
||||||
|
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('discord flush timeout')), timeoutMs))
|
||||||
|
]).catch(() => {})
|
||||||
|
}
|
||||||
189
src/logging/Logger.ts
Normal file
189
src/logging/Logger.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import chalk from 'chalk'
|
||||||
|
import cluster from 'cluster'
|
||||||
|
import { sendDiscord } from './Discord'
|
||||||
|
import { sendNtfy } from './Ntfy'
|
||||||
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
import { errorDiagnostic } from '../util/ErrorDiagnostic'
|
||||||
|
import type { LogFilter } from '../interface/Config'
|
||||||
|
|
||||||
|
export type Platform = boolean | 'main'
|
||||||
|
export type LogLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
export type ColorKey = keyof typeof chalk
|
||||||
|
export interface IpcLog {
|
||||||
|
content: string
|
||||||
|
level: LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChalkFn = (msg: string) => string
|
||||||
|
|
||||||
|
function platformText(platform: Platform): string {
|
||||||
|
return platform === 'main' ? 'MAIN' : platform ? 'MOBILE' : 'DESKTOP'
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformBadge(platform: Platform): string {
|
||||||
|
return platform === 'main' ? chalk.bgCyan('MAIN') : platform ? chalk.bgBlue('MOBILE') : chalk.bgMagenta('DESKTOP')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorFn(color?: ColorKey): ChalkFn | null {
|
||||||
|
return color && typeof chalk[color] === 'function' ? (chalk[color] as ChalkFn) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function consoleOut(level: LogLevel, msg: string, chalkFn: ChalkFn | null): void {
|
||||||
|
const out = chalkFn ? chalkFn(msg) : msg
|
||||||
|
switch (level) {
|
||||||
|
case 'warn':
|
||||||
|
return console.warn(out)
|
||||||
|
case 'error':
|
||||||
|
return console.error(out)
|
||||||
|
default:
|
||||||
|
return console.log(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(message: string | Error): string {
|
||||||
|
return message instanceof Error ? `${message.message}\n${message.stack || ''}` : message
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
constructor(private bot: MicrosoftRewardsBot) {}
|
||||||
|
|
||||||
|
info(isMobile: Platform, title: string, message: string, color?: ColorKey) {
|
||||||
|
return this.baseLog('info', isMobile, title, message, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) {
|
||||||
|
return this.baseLog('warn', isMobile, title, message, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
error(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) {
|
||||||
|
return this.baseLog('error', isMobile, title, message, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) {
|
||||||
|
return this.baseLog('debug', isMobile, title, message, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseLog(
|
||||||
|
level: LogLevel,
|
||||||
|
isMobile: Platform,
|
||||||
|
title: string,
|
||||||
|
message: string | Error,
|
||||||
|
color?: ColorKey
|
||||||
|
): void {
|
||||||
|
const now = new Date().toLocaleString()
|
||||||
|
const formatted = formatMessage(message)
|
||||||
|
|
||||||
|
const levelTag = level.toUpperCase()
|
||||||
|
const cleanMsg = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${platformText(
|
||||||
|
isMobile
|
||||||
|
)} [${title}] ${formatted}`
|
||||||
|
|
||||||
|
const config = this.bot.config
|
||||||
|
|
||||||
|
if (level === 'debug' && !config.debugLogs && !process.argv.includes('-dev')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = platformBadge(isMobile)
|
||||||
|
const consoleStr = `[${now}] [${this.bot.userData.userName}] [${levelTag}] ${badge} [${title}] ${formatted}`
|
||||||
|
|
||||||
|
let logColor: ColorKey | undefined = color
|
||||||
|
|
||||||
|
if (!logColor) {
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
logColor = 'red'
|
||||||
|
break
|
||||||
|
case 'warn':
|
||||||
|
logColor = 'yellow'
|
||||||
|
break
|
||||||
|
case 'debug':
|
||||||
|
logColor = 'magenta'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 'error' && config.errorDiagnostics) {
|
||||||
|
const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage
|
||||||
|
const error = message instanceof Error ? message : new Error(String(message))
|
||||||
|
errorDiagnostic(page, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleAllowed = this.shouldPassFilter(config.consoleLogFilter, level, cleanMsg)
|
||||||
|
const webhookAllowed = this.shouldPassFilter(config.webhook.webhookLogFilter, level, cleanMsg)
|
||||||
|
|
||||||
|
if (consoleAllowed) {
|
||||||
|
consoleOut(level, consoleStr, getColorFn(logColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookAllowed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
if (config.webhook.discord?.enabled && config.webhook.discord.url) {
|
||||||
|
if (level === 'debug') return
|
||||||
|
sendDiscord(config.webhook.discord.url, cleanMsg, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.webhook.ntfy?.enabled && config.webhook.ntfy.url) {
|
||||||
|
if (level === 'debug') return
|
||||||
|
sendNtfy(config.webhook.ntfy, cleanMsg, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.send?.({ __ipcLog: { content: cleanMsg, level } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPassFilter(filter: LogFilter | undefined, level: LogLevel, message: string): boolean {
|
||||||
|
// If disabled or not, let all logs pass
|
||||||
|
if (!filter || !filter.enabled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always log error levelo logs, remove these lines to disable this!
|
||||||
|
if (level === 'error') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mode, levels, keywords, regexPatterns } = filter
|
||||||
|
|
||||||
|
const hasLevelRule = Array.isArray(levels) && levels.length > 0
|
||||||
|
const hasKeywordRule = Array.isArray(keywords) && keywords.length > 0
|
||||||
|
const hasPatternRule = Array.isArray(regexPatterns) && regexPatterns.length > 0
|
||||||
|
|
||||||
|
if (!hasLevelRule && !hasKeywordRule && !hasPatternRule) {
|
||||||
|
return mode === 'blacklist'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerMessage = message.toLowerCase()
|
||||||
|
let isMatch = false
|
||||||
|
|
||||||
|
if (hasLevelRule && levels!.includes(level)) {
|
||||||
|
isMatch = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMatch && hasKeywordRule) {
|
||||||
|
if (keywords!.some(k => lowerMessage.includes(k.toLowerCase()))) {
|
||||||
|
isMatch = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fancy regex filtering if set!
|
||||||
|
if (!isMatch && hasPatternRule) {
|
||||||
|
for (const pattern of regexPatterns!) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(pattern, 'i')
|
||||||
|
if (regex.test(message)) {
|
||||||
|
isMatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode === 'whitelist' ? isMatch : !isMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/logging/Ntfy.ts
Normal file
61
src/logging/Ntfy.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import axios, { AxiosRequestConfig } from 'axios'
|
||||||
|
import PQueue from 'p-queue'
|
||||||
|
import type { WebhookNtfyConfig } from '../interface/Config'
|
||||||
|
import type { LogLevel } from './Logger'
|
||||||
|
|
||||||
|
const ntfyQueue = new PQueue({
|
||||||
|
interval: 1000,
|
||||||
|
intervalCap: 2,
|
||||||
|
carryoverConcurrencyCount: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function sendNtfy(config: WebhookNtfyConfig, content: string, level: LogLevel): Promise<void> {
|
||||||
|
if (!config?.url) return
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
config.priority = 5 // Highest
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'warn':
|
||||||
|
config.priority = 4
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'text/plain' }
|
||||||
|
if (config.title) headers['Title'] = config.title
|
||||||
|
if (config.tags?.length) headers['Tags'] = config.tags.join(',')
|
||||||
|
if (config.priority) headers['Priority'] = String(config.priority)
|
||||||
|
if (config.token) headers['Authorization'] = `Bearer ${config.token}`
|
||||||
|
|
||||||
|
const url = config.topic ? `${config.url}/${config.topic}` : config.url
|
||||||
|
|
||||||
|
const request: AxiosRequestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: url,
|
||||||
|
headers,
|
||||||
|
data: content,
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
await ntfyQueue.add(async () => {
|
||||||
|
try {
|
||||||
|
await axios(request)
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status
|
||||||
|
if (status === 429) return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushNtfyQueue(timeoutMs = 5000): Promise<void> {
|
||||||
|
await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
await ntfyQueue.onIdle()
|
||||||
|
})(),
|
||||||
|
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('ntfy flush timeout')), timeoutMs))
|
||||||
|
]).catch(() => {})
|
||||||
|
}
|
||||||
67
src/util/Axios.ts
Normal file
67
src/util/Axios.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
|
import axiosRetry from 'axios-retry'
|
||||||
|
import { HttpProxyAgent } from 'http-proxy-agent'
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||||
|
import type { AccountProxy } from '../interface/Account'
|
||||||
|
|
||||||
|
class AxiosClient {
|
||||||
|
private instance: AxiosInstance
|
||||||
|
private account: AccountProxy
|
||||||
|
|
||||||
|
constructor(account: AccountProxy) {
|
||||||
|
this.account = account
|
||||||
|
|
||||||
|
this.instance = axios.create({
|
||||||
|
timeout: 20000
|
||||||
|
})
|
||||||
|
|
||||||
|
// Configure proxy agent if available
|
||||||
|
if (this.account.url && this.account.proxyAxios) {
|
||||||
|
const agent = this.getAgentForProxy(this.account)
|
||||||
|
this.instance.defaults.httpAgent = agent
|
||||||
|
this.instance.defaults.httpsAgent = agent
|
||||||
|
}
|
||||||
|
|
||||||
|
axiosRetry(this.instance, {
|
||||||
|
retries: 5, // Retry 5 times
|
||||||
|
retryDelay: axiosRetry.exponentialDelay,
|
||||||
|
shouldResetTimeout: true,
|
||||||
|
retryCondition: error => {
|
||||||
|
// Retry on: Network errors, 429 (Too Many Requests), 5xx server errors
|
||||||
|
if (axiosRetry.isNetworkError(error)) return true
|
||||||
|
if (!error.response) return true
|
||||||
|
|
||||||
|
const status = error.response.status
|
||||||
|
return status === 429 || (status >= 500 && status <= 599)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent<string> | HttpsProxyAgent<string> {
|
||||||
|
const { url, port } = proxyConfig
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case url.startsWith('http://'):
|
||||||
|
return new HttpProxyAgent(`${url}:${port}`)
|
||||||
|
case url.startsWith('https://'):
|
||||||
|
return new HttpsProxyAgent(`${url}:${port}`)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported proxy protocol: ${url}, only HTTP(S) is supported!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async request(config: AxiosRequestConfig, bypassProxy = false): Promise<AxiosResponse> {
|
||||||
|
if (bypassProxy) {
|
||||||
|
const bypassInstance = axios.create()
|
||||||
|
axiosRetry(bypassInstance, {
|
||||||
|
retries: 3,
|
||||||
|
retryDelay: axiosRetry.exponentialDelay
|
||||||
|
})
|
||||||
|
return bypassInstance.request(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.instance.request(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AxiosClient
|
||||||
46
src/util/ErrorDiagnostic.ts
Normal file
46
src/util/ErrorDiagnostic.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Page } from 'patchright'
|
||||||
|
|
||||||
|
export async function errorDiagnostic(page: Page, error: Error): Promise<void> {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const folderName = `error-${timestamp}`
|
||||||
|
const outputDir = path.join(process.cwd(), 'diagnostics', folderName)
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.isClosed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error log content
|
||||||
|
const errorLog = `
|
||||||
|
Name: ${error.name}
|
||||||
|
Message: ${error.message}
|
||||||
|
Timestamp: ${new Date().toISOString()}
|
||||||
|
---------------------------------------------------
|
||||||
|
Stack Trace:
|
||||||
|
${error.stack || 'No stack trace available'}
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
const [htmlContent, screenshotBuffer] = await Promise.all([
|
||||||
|
page.content(),
|
||||||
|
page.screenshot({ fullPage: true, type: 'png' })
|
||||||
|
])
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true })
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fs.writeFile(path.join(outputDir, 'dump.html'), htmlContent),
|
||||||
|
fs.writeFile(path.join(outputDir, 'screenshot.png'), screenshotBuffer),
|
||||||
|
fs.writeFile(path.join(outputDir, 'error.txt'), errorLog)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`Diagnostics saved to: ${outputDir}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to create error diagnostics:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/util/Load.ts
Normal file
123
src/util/Load.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Cookie } from 'patchright'
|
||||||
|
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import type { Account } from '../interface/Account'
|
||||||
|
import type { Config, ConfigSaveFingerprint } from '../interface/Config'
|
||||||
|
|
||||||
|
let configCache: Config
|
||||||
|
|
||||||
|
export function loadAccounts(): Account[] {
|
||||||
|
try {
|
||||||
|
let file = 'accounts.json'
|
||||||
|
|
||||||
|
if (process.argv.includes('-dev')) {
|
||||||
|
file = 'accounts.dev.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountDir = path.join(__dirname, '../', file)
|
||||||
|
const accounts = fs.readFileSync(accountDir, 'utf-8')
|
||||||
|
|
||||||
|
return JSON.parse(accounts)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
try {
|
||||||
|
if (configCache) {
|
||||||
|
return configCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const configDir = path.join(__dirname, '../', 'config.json')
|
||||||
|
const config = fs.readFileSync(configDir, 'utf-8')
|
||||||
|
|
||||||
|
const configData = JSON.parse(config)
|
||||||
|
configCache = configData
|
||||||
|
|
||||||
|
return configData
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSessionData(
|
||||||
|
sessionPath: string,
|
||||||
|
email: string,
|
||||||
|
saveFingerprint: ConfigSaveFingerprint,
|
||||||
|
isMobile: boolean
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json'
|
||||||
|
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, cookiesFileName)
|
||||||
|
|
||||||
|
let cookies: Cookie[] = []
|
||||||
|
if (fs.existsSync(cookieFile)) {
|
||||||
|
const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8')
|
||||||
|
cookies = JSON.parse(cookiesData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json'
|
||||||
|
const fingerprintFile = path.join(__dirname, '../browser/', sessionPath, email, fingerprintFileName)
|
||||||
|
|
||||||
|
let fingerprint!: BrowserFingerprintWithHeaders
|
||||||
|
const shouldLoadFingerprint = isMobile ? saveFingerprint.mobile : saveFingerprint.desktop
|
||||||
|
if (shouldLoadFingerprint && fs.existsSync(fingerprintFile)) {
|
||||||
|
const fingerprintData = await fs.promises.readFile(fingerprintFile, 'utf-8')
|
||||||
|
fingerprint = JSON.parse(fingerprintData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookies: cookies,
|
||||||
|
fingerprint: fingerprint
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSessionData(
|
||||||
|
sessionPath: string,
|
||||||
|
cookies: Cookie[],
|
||||||
|
email: string,
|
||||||
|
isMobile: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||||
|
const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json'
|
||||||
|
|
||||||
|
if (!fs.existsSync(sessionDir)) {
|
||||||
|
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.writeFile(path.join(sessionDir, cookiesFileName), JSON.stringify(cookies))
|
||||||
|
|
||||||
|
return sessionDir
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFingerprintData(
|
||||||
|
sessionPath: string,
|
||||||
|
email: string,
|
||||||
|
isMobile: boolean,
|
||||||
|
fingerpint: BrowserFingerprintWithHeaders
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||||
|
const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json'
|
||||||
|
|
||||||
|
if (!fs.existsSync(sessionDir)) {
|
||||||
|
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.writeFile(path.join(sessionDir, fingerprintFileName), JSON.stringify(fingerpint))
|
||||||
|
|
||||||
|
return sessionDir
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/util/Utils.ts
Normal file
81
src/util/Utils.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import ms, { StringValue } from 'ms'
|
||||||
|
|
||||||
|
export default class Util {
|
||||||
|
async wait(time: number | string): Promise<void> {
|
||||||
|
if (typeof time === 'string') {
|
||||||
|
time = this.stringToNumber(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
setTimeout(resolve, time)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormattedDate(ms = Date.now()): string {
|
||||||
|
const today = new Date(ms)
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
|
||||||
|
const day = String(today.getDate()).padStart(2, '0')
|
||||||
|
const year = today.getFullYear()
|
||||||
|
|
||||||
|
return `${month}/${day}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
shuffleArray<T>(array: T[]): T[] {
|
||||||
|
return array
|
||||||
|
.map(value => ({ value, sort: Math.random() }))
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
|
.map(({ value }) => value)
|
||||||
|
}
|
||||||
|
|
||||||
|
randomNumber(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkArray<T>(arr: T[], numChunks: number): T[][] {
|
||||||
|
const chunkSize = Math.ceil(arr.length / numChunks)
|
||||||
|
const chunks: T[][] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||||
|
const chunk = arr.slice(i, i + chunkSize)
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
stringToNumber(input: string | number): number {
|
||||||
|
if (typeof input === 'number') {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
const value = input.trim()
|
||||||
|
|
||||||
|
const milisec = ms(value as StringValue)
|
||||||
|
|
||||||
|
if (milisec === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`The input provided (${input}) cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return milisec
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeString(string: string): string {
|
||||||
|
return string
|
||||||
|
.normalize('NFD')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
.replace(/[?!]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmailUsername(email: string): string {
|
||||||
|
return email.split('@')[0] ?? 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
randomDelay(min: string | number, max: string | number): number {
|
||||||
|
const minMs = typeof min === 'number' ? min : this.stringToNumber(min)
|
||||||
|
const maxMs = typeof max === 'number' ? max : this.stringToNumber(max)
|
||||||
|
return Math.floor(this.randomNumber(minMs, maxMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tsconfig.json
Normal file
70
tsconfig.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||||
|
/* Basic Options */
|
||||||
|
"target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
|
||||||
|
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
|
||||||
|
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||||
|
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||||
|
// "checkJs": true, /* Report errors in .js files. */
|
||||||
|
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
|
||||||
|
"declaration": true /* Generates corresponding '.d.ts' file. */,
|
||||||
|
"declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
|
||||||
|
"sourceMap": true /* Generates corresponding '.map' file. */,
|
||||||
|
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||||
|
"outDir": "./dist" /* Redirect output structure to the directory. */,
|
||||||
|
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
|
||||||
|
// "composite": true, /* Enable project compilation */
|
||||||
|
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||||
|
// "removeComments": true, /* Do not emit comments to output. */
|
||||||
|
// "noEmit": true, /* Do not emit outputs. */
|
||||||
|
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||||
|
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||||
|
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||||
|
/* Strict Type-Checking Options */
|
||||||
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
|
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||||
|
"strictNullChecks": true /* Enable strict null checks. */,
|
||||||
|
"strictFunctionTypes": true /* Enable strict checking of function types. */,
|
||||||
|
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||||
|
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||||
|
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
|
||||||
|
"alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
|
||||||
|
/* Additional Checks */
|
||||||
|
"noUnusedLocals": true /* Report errors on unused locals. */,
|
||||||
|
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||||
|
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
|
||||||
|
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
|
||||||
|
"noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */,
|
||||||
|
"noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an 'override' modifier. */,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
||||||
|
/* Module Resolution Options */
|
||||||
|
"moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
|
||||||
|
"types": ["node"],
|
||||||
|
"typeRoots": ["./node_modules/@types"],
|
||||||
|
// Keep explicit typeRoots to ensure resolution in environments that don't auto-detect before full install.
|
||||||
|
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||||
|
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||||
|
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||||
|
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||||
|
// "types": [], /* Type declaration files to be included in compilation. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||||
|
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||||
|
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
/* Source Map Options */
|
||||||
|
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||||
|
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||||
|
/* Experimental Options */
|
||||||
|
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||||
|
/* Advanced Options */
|
||||||
|
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||||
|
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/accounts.json", "src/config.json", "src/functions/queries.json"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user