mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-17 21:43:59 +00:00
V2.1 (#375)
* feat: Implement edge version fetching with retry logic and caching * chore: Update version to 2.1.0 in package.json * fix: Update package version to 2.1.0 and enhance user agent metadata * feat: Enhance 2FA handling with improved TOTP input and submission logic * fix: Refactor getSystemComponents to improve mobile user agent string generation * feat: Add support for cron expressions for advanced scheduling * feat: Improve humanization feature with detailed logging for off-days configuration * feat: Add live log streaming via webhook and enhance logging configuration * fix: Remove unused @types/cron-parser dependency from devDependencies * feat: Add cron-parser dependency and enhance Axios error handling for proxy authentication * feat: Enhance dashboard data retrieval with retry logic and diagnostics capture * feat: Add ready-to-use sample configurations and update configuration settings for better customization * feat: Add buy mode detection and configuration methods for enhanced manual redemption * feat: Migrate configuration from JSON to JSONC format for improved readability and comments support feat: Implement centralized diagnostics capture for better error handling and reporting fix: Update documentation references from config.json to config.jsonc chore: Add .vscode to .gitignore for cleaner project structure refactor: Enhance humanization and diagnostics capture logic in BrowserUtil and Login classes * feat: Reintroduce ambiance declarations for the 'luxon' module to unlock TypeScript * feat: Update search delay settings for improved performance and reliability * feat: Update README and SECURITY documentation for clarity and improved data handling guidelines * Enhance README and SECURITY documentation for Microsoft Rewards Script V2 - Updated README.md to improve structure, add badges, and enhance clarity on features and setup instructions. - Expanded SECURITY.md to provide detailed data handling practices, security guidelines, and best practices for users. - Included sections on data flow, credential management, and responsible use of the automation tool. - Added a security checklist for users to ensure safe practices while using the script. * feat: Réorganiser et enrichir la documentation du README pour une meilleure clarté et accessibilité * feat: Updated and reorganized the README for better presentation and clarity * feat: Revised and simplified the README for better clarity and accessibility * Update README.md
This commit is contained in:
@@ -40,7 +40,14 @@ class Browser {
|
||||
try {
|
||||
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
|
||||
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
|
||||
const headlessValue = envForceHeadless ? true : ((cfgAny['headless'] as boolean | undefined) ?? (cfgAny['browser'] && (cfgAny['browser'] as Record<string, unknown>)['headless'] as boolean | undefined) ?? false)
|
||||
let headlessValue = envForceHeadless ? true : ((cfgAny['headless'] as boolean | undefined) ?? (cfgAny['browser'] && (cfgAny['browser'] as Record<string, unknown>)['headless'] as boolean | undefined) ?? false)
|
||||
if (this.bot.isBuyModeEnabled() && !envForceHeadless) {
|
||||
if (headlessValue !== false) {
|
||||
const target = this.bot.getBuyModeTarget()
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Buy mode detected${target ? ` for ${target}` : ''}; forcing headless=false so captchas and manual flows remain interactive.`, 'warn')
|
||||
}
|
||||
headlessValue = false
|
||||
}
|
||||
const headless: boolean = Boolean(headlessValue)
|
||||
|
||||
const engineName = 'chromium' // current hard-coded engine
|
||||
|
||||
@@ -127,7 +127,7 @@ export default class BrowserFunc {
|
||||
}
|
||||
}
|
||||
|
||||
const scriptContent = await target.evaluate(() => {
|
||||
let scriptContent = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
|
||||
|
||||
@@ -135,7 +135,21 @@ export default class BrowserFunc {
|
||||
})
|
||||
|
||||
if (!scriptContent) {
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
|
||||
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch(()=>{})
|
||||
// Force a navigation retry once before failing hard
|
||||
try {
|
||||
await this.goHome(target)
|
||||
await target.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(()=>{})
|
||||
} catch {/* ignore */}
|
||||
const retryContent = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
|
||||
return targetScript?.innerText ? targetScript.innerText : null
|
||||
}).catch(()=>null)
|
||||
if (!retryContent) {
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
|
||||
}
|
||||
scriptContent = retryContent
|
||||
}
|
||||
|
||||
// Extract the dashboard object from the script content
|
||||
@@ -151,6 +165,7 @@ export default class BrowserFunc {
|
||||
}, scriptContent)
|
||||
|
||||
if (!dashboardData) {
|
||||
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch(()=>{})
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Page } from 'rebrowser-playwright'
|
||||
import { load } from 'cheerio'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics'
|
||||
|
||||
|
||||
export default class BrowserUtil {
|
||||
@@ -106,39 +107,8 @@ export default class BrowserUtil {
|
||||
*/
|
||||
async humanizePage(page: Page): Promise<void> {
|
||||
try {
|
||||
const h = this.bot.config?.humanization || {}
|
||||
if (h.enabled === false) return
|
||||
const moveProb = typeof h.gestureMoveProb === 'number' ? h.gestureMoveProb : 0.4
|
||||
const scrollProb = typeof h.gestureScrollProb === 'number' ? h.gestureScrollProb : 0.2
|
||||
// minor mouse move
|
||||
if (Math.random() < moveProb) {
|
||||
const x = Math.floor(Math.random() * 30) + 5
|
||||
const y = Math.floor(Math.random() * 20) + 3
|
||||
await page.mouse.move(x, y, { steps: 2 }).catch(() => { })
|
||||
}
|
||||
// tiny scroll
|
||||
if (Math.random() < scrollProb) {
|
||||
const dy = (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 150) + 50)
|
||||
await page.mouse.wheel(0, dy).catch(() => { })
|
||||
}
|
||||
// Random short wait; override via humanization.actionDelay
|
||||
const range = h.actionDelay
|
||||
if (range && typeof range.min !== 'undefined' && typeof range.max !== 'undefined') {
|
||||
try {
|
||||
const ms = (await import('ms')).default
|
||||
const min = typeof range.min === 'number' ? range.min : ms(String(range.min))
|
||||
const max = typeof range.max === 'number' ? range.max : ms(String(range.max))
|
||||
if (typeof min === 'number' && typeof max === 'number' && max >= min) {
|
||||
await this.bot.utils.wait(this.bot.utils.randomNumber(Math.max(0, min), Math.min(max, 5000)))
|
||||
} else {
|
||||
await this.bot.utils.wait(this.bot.utils.randomNumber(150, 450))
|
||||
}
|
||||
} catch {
|
||||
await this.bot.utils.wait(this.bot.utils.randomNumber(150, 450))
|
||||
}
|
||||
} else {
|
||||
await this.bot.utils.wait(this.bot.utils.randomNumber(150, 450))
|
||||
}
|
||||
await this.bot.humanizer.microGestures(page)
|
||||
await this.bot.humanizer.actionPause()
|
||||
} catch { /* swallow */ }
|
||||
}
|
||||
|
||||
@@ -147,33 +117,7 @@ export default class BrowserUtil {
|
||||
* Files are written under ./reports/<date>/ with a safe label.
|
||||
*/
|
||||
async captureDiagnostics(page: Page, label: string): Promise<void> {
|
||||
try {
|
||||
const cfg = this.bot.config?.diagnostics || {}
|
||||
if (cfg.enabled === false) return
|
||||
const maxPerRun = typeof cfg.maxPerRun === 'number' ? cfg.maxPerRun : 8
|
||||
if (!this.bot.tryReserveDiagSlot(maxPerRun)) return
|
||||
|
||||
const safe = label.replace(/[^a-z0-9-_]/gi, '_').slice(0, 64)
|
||||
const now = new Date()
|
||||
const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||||
const baseDir = `${process.cwd()}/reports/${day}`
|
||||
const fs = await import('fs')
|
||||
const path = await import('path')
|
||||
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
|
||||
const ts = `${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}${String(now.getSeconds()).padStart(2,'0')}`
|
||||
const shot = path.join(baseDir, `${ts}_${safe}.png`)
|
||||
const htmlPath = path.join(baseDir, `${ts}_${safe}.html`)
|
||||
if (cfg.saveScreenshot !== false) {
|
||||
await page.screenshot({ path: shot }).catch(()=>{})
|
||||
}
|
||||
if (cfg.saveHtml !== false) {
|
||||
const html = await page.content().catch(()=> '<html></html>')
|
||||
fs.writeFileSync(htmlPath, html)
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'DIAG', `Saved diagnostics to ${shot} and ${htmlPath}`)
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'DIAG', `Failed to capture diagnostics: ${e instanceof Error ? e.message : e}`, 'warn')
|
||||
}
|
||||
await captureSharedDiagnostics(this.bot, page, label)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"sessionPath": "sessions",
|
||||
|
||||
"browser": {
|
||||
// Run browser without UI (true=headless, false=visible). Visible can help with stability.
|
||||
// Keep headless=false so the browser window stays visible by default
|
||||
"headless": false,
|
||||
// Max time to wait for common operations (supports ms/s/min: e.g. 30000, "30s", "2min")
|
||||
"globalTimeout": "30s"
|
||||
@@ -31,17 +31,17 @@
|
||||
"fingerprinting": {
|
||||
// Persist browser fingerprints per device type to improve consistency across runs
|
||||
"saveFingerprint": {
|
||||
"mobile": false,
|
||||
"desktop": false
|
||||
"mobile": true,
|
||||
"desktop": true
|
||||
}
|
||||
},
|
||||
|
||||
"search": {
|
||||
// Use locale-specific query sources
|
||||
"useLocalQueries": false,
|
||||
"useLocalQueries": true,
|
||||
"settings": {
|
||||
// Add geo/locale signal into query selection
|
||||
"useGeoLocaleQueries": false,
|
||||
"useGeoLocaleQueries": true,
|
||||
// Randomly scroll search result pages to look more natural
|
||||
"scrollRandomResults": true,
|
||||
// Occasionally click a result (safe targets only)
|
||||
@@ -50,7 +50,7 @@
|
||||
"retryMobileSearchAmount": 2,
|
||||
// Delay between searches (supports numbers in ms or time strings)
|
||||
"delay": {
|
||||
"min": "3min",
|
||||
"min": "1min",
|
||||
"max": "5min"
|
||||
}
|
||||
}
|
||||
@@ -66,13 +66,13 @@
|
||||
"immediateBanAlert": true,
|
||||
// Extra random pause between actions (ms or time string e.g., "300ms", "1s")
|
||||
"actionDelay": {
|
||||
"min": 150,
|
||||
"max": 450
|
||||
"min": 500,
|
||||
"max": 2200
|
||||
},
|
||||
// Probability (0..1) to move mouse a tiny bit in between actions
|
||||
"gestureMoveProb": 0.4,
|
||||
"gestureMoveProb": 0.65,
|
||||
// Probability (0..1) to perform a very small scroll
|
||||
"gestureScrollProb": 0.2,
|
||||
"gestureScrollProb": 0.4,
|
||||
// Optional local-time windows for execution (e.g., ["08:30-11:00", "19:00-22:00"]).
|
||||
// If provided, runs will wait until inside a window before starting.
|
||||
"allowedWindows": []
|
||||
@@ -80,12 +80,12 @@
|
||||
|
||||
// Optional monthly "vacation" block: skip a contiguous range of days to look more human.
|
||||
// This is independent of weekly random off-days. When enabled, each month a random
|
||||
// block between minDays and maxDays is selected (e.g., 3–5 days) and all runs within
|
||||
// block between minDays and maxDays is selected (e.g., 2–4 days) and all runs within
|
||||
// that date range are skipped. The chosen block is logged at the start of the month.
|
||||
"vacation": {
|
||||
"enabled": false,
|
||||
"minDays": 3,
|
||||
"maxDays": 5
|
||||
"enabled": true,
|
||||
"minDays": 2,
|
||||
"maxDays": 4
|
||||
},
|
||||
|
||||
"retryPolicy": {
|
||||
@@ -107,7 +107,7 @@
|
||||
"doDailyCheckIn": true,
|
||||
"doReadToEarn": true,
|
||||
// If true, run a desktop search bundle right after Daily Set
|
||||
"bundleDailySetWithSearch": false
|
||||
"bundleDailySetWithSearch": true
|
||||
},
|
||||
|
||||
"proxy": {
|
||||
@@ -194,4 +194,4 @@
|
||||
// Custom updater script path (relative to repo root)
|
||||
"scriptPath": "setup/update/update.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
// Clean refactored Login implementation
|
||||
// Public API preserved: login(), getMobileAccessToken()
|
||||
|
||||
import type { Page } from 'playwright'
|
||||
import type { Page, Locator } from 'playwright'
|
||||
import * as crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import readline from 'readline'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { generateTOTP } from '../util/Totp'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { captureDiagnostics } from '../util/Diagnostics'
|
||||
import { OAuth } from '../interface/OAuth'
|
||||
|
||||
// -------------------------------
|
||||
@@ -203,6 +202,14 @@ export class Login {
|
||||
// --------------- 2FA Handling ---------------
|
||||
private async handle2FA(page: Page) {
|
||||
try {
|
||||
if (this.currentTotpSecret) {
|
||||
const totpSelector = await this.ensureTotpInput(page)
|
||||
if (totpSelector) {
|
||||
await this.submitTotpCode(page, totpSelector)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const number = await this.fetchAuthenticatorNumber(page)
|
||||
if (number) { await this.approveAuthenticator(page, number); return }
|
||||
await this.handleSMSOrTotp(page)
|
||||
@@ -255,16 +262,16 @@ export class Login {
|
||||
}
|
||||
|
||||
private async handleSMSOrTotp(page: Page) {
|
||||
// TOTP auto entry
|
||||
try {
|
||||
if (this.currentTotpSecret) {
|
||||
const code = generateTOTP(this.currentTotpSecret.trim())
|
||||
await page.fill('input[name="otc"]', code)
|
||||
await page.keyboard.press('Enter')
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically')
|
||||
return
|
||||
}
|
||||
} catch {/* ignore */}
|
||||
// TOTP auto entry (second chance if ensureTotpInput needed longer)
|
||||
if (this.currentTotpSecret) {
|
||||
try {
|
||||
const totpSelector = await this.ensureTotpInput(page)
|
||||
if (totpSelector) {
|
||||
await this.submitTotpCode(page, totpSelector)
|
||||
return
|
||||
}
|
||||
} catch {/* ignore */}
|
||||
}
|
||||
|
||||
// Manual prompt
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
|
||||
@@ -275,18 +282,194 @@ export class Login {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
|
||||
}
|
||||
|
||||
private async ensureTotpInput(page: Page): Promise<string | null> {
|
||||
const selector = await this.findFirstVisibleSelector(page, this.totpInputSelectors())
|
||||
if (selector) return selector
|
||||
|
||||
const attempts = 4
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
let acted = false
|
||||
|
||||
// Step 1: expose alternative verification options if hidden
|
||||
if (!acted) {
|
||||
acted = await this.clickFirstVisibleSelector(page, this.totpAltOptionSelectors())
|
||||
if (acted) await this.bot.utils.wait(900)
|
||||
}
|
||||
|
||||
// Step 2: choose authenticator code option if available
|
||||
if (!acted) {
|
||||
acted = await this.clickFirstVisibleSelector(page, this.totpChallengeSelectors())
|
||||
if (acted) await this.bot.utils.wait(900)
|
||||
}
|
||||
|
||||
const ready = await this.findFirstVisibleSelector(page, this.totpInputSelectors())
|
||||
if (ready) return ready
|
||||
|
||||
if (!acted) break
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async submitTotpCode(page: Page, selector: string) {
|
||||
try {
|
||||
const code = generateTOTP(this.currentTotpSecret!.trim())
|
||||
const input = page.locator(selector).first()
|
||||
if (!await input.isVisible().catch(()=>false)) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP input unexpectedly hidden', 'warn')
|
||||
return
|
||||
}
|
||||
await input.fill('')
|
||||
await input.fill(code)
|
||||
const submitSelectors = [
|
||||
'#idSubmit_SAOTCC_Continue',
|
||||
'#idSubmit_SAOTCC_OTC',
|
||||
'button[type="submit"]:has-text("Verify")',
|
||||
'button[type="submit"]:has-text("Continuer")',
|
||||
'button:has-text("Verify")',
|
||||
'button:has-text("Continuer")',
|
||||
'button:has-text("Submit")'
|
||||
]
|
||||
const submit = await this.findFirstVisibleLocator(page, submitSelectors)
|
||||
if (submit) {
|
||||
await submit.click().catch(()=>{})
|
||||
} else {
|
||||
await page.keyboard.press('Enter').catch(()=>{})
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically')
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed to submit TOTP automatically: ' + error, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
private totpInputSelectors(): string[] {
|
||||
return [
|
||||
'input[name="otc"]',
|
||||
'#idTxtBx_SAOTCC_OTC',
|
||||
'#idTxtBx_SAOTCS_OTC',
|
||||
'input[data-testid="otcInput"]',
|
||||
'input[autocomplete="one-time-code"]',
|
||||
'input[type="tel"][name="otc"]'
|
||||
]
|
||||
}
|
||||
|
||||
private totpAltOptionSelectors(): string[] {
|
||||
return [
|
||||
'#idA_SAOTCS_ProofPickerChange',
|
||||
'#idA_SAOTCC_AlternateLogin',
|
||||
'a:has-text("Use a different verification option")',
|
||||
'a:has-text("Sign in another way")',
|
||||
'a:has-text("I can\'t use my Microsoft Authenticator app right now")',
|
||||
'button:has-text("Use a different verification option")',
|
||||
'button:has-text("Sign in another way")'
|
||||
]
|
||||
}
|
||||
|
||||
private totpChallengeSelectors(): string[] {
|
||||
return [
|
||||
'[data-value="PhoneAppOTP"]',
|
||||
'[data-value="OneTimeCode"]',
|
||||
'button:has-text("Use a verification code")',
|
||||
'button:has-text("Enter code manually")',
|
||||
'button:has-text("Enter a code from your authenticator app")',
|
||||
'button:has-text("Use code from your authentication app")',
|
||||
'button:has-text("Utiliser un code de vérification")',
|
||||
'button:has-text("Utiliser un code de verification")',
|
||||
'button:has-text("Entrer un code depuis votre application")',
|
||||
'button:has-text("Entrez un code depuis votre application")',
|
||||
'button:has-text("Entrez un code")',
|
||||
'div[role="button"]:has-text("Use a verification code")',
|
||||
'div[role="button"]:has-text("Enter a code")'
|
||||
]
|
||||
}
|
||||
|
||||
private async findFirstVisibleSelector(page: Page, selectors: string[]): Promise<string | null> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
return sel
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async clickFirstVisibleSelector(page: Page, selectors: string[]): Promise<boolean> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
await loc.click().catch(()=>{})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async findFirstVisibleLocator(page: Page, selectors: string[]): Promise<Locator | null> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
return loc
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async waitForRewardsRoot(page: Page, timeoutMs: number): Promise<string | null> {
|
||||
const selectors = [
|
||||
'html[data-role-name="RewardsPortal"]',
|
||||
'html[data-role-name*="RewardsPortal"]',
|
||||
'body[data-role-name*="RewardsPortal"]',
|
||||
'[data-role-name*="RewardsPortal"]',
|
||||
'[data-bi-name="rewards-dashboard"]',
|
||||
'main[data-bi-name="dashboard"]',
|
||||
'#more-activities',
|
||||
'#dashboard'
|
||||
]
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(()=>false)) {
|
||||
return sel
|
||||
}
|
||||
}
|
||||
await this.bot.utils.wait(350)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// --------------- Verification / State ---------------
|
||||
private async awaitRewardsPortal(page: Page) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
|
||||
await this.handlePasskeyPrompts(page, 'main')
|
||||
const u = new URL(page.url())
|
||||
if (u.hostname === LOGIN_TARGET.host && u.pathname === LOGIN_TARGET.path) break
|
||||
const isRewardsHost = u.hostname === LOGIN_TARGET.host
|
||||
const isKnownPath = u.pathname === LOGIN_TARGET.path
|
||||
|| u.pathname === '/dashboard'
|
||||
|| u.pathname === '/rewardsapp/dashboard'
|
||||
|| u.pathname.startsWith('/?')
|
||||
if (isRewardsHost && isKnownPath) break
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
const portal = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 8000 }).catch(()=>null)
|
||||
if (!portal) throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal root element missing after navigation', 'error')
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Reached rewards portal')
|
||||
|
||||
const portalSelector = await this.waitForRewardsRoot(page, 8000)
|
||||
if (!portalSelector) {
|
||||
try {
|
||||
await this.bot.browser.func.goHome(page)
|
||||
} catch {/* ignore fallback errors */}
|
||||
|
||||
const fallbackSelector = await this.waitForRewardsRoot(page, 6000)
|
||||
if (!fallbackSelector) {
|
||||
await this.bot.browser.utils.captureDiagnostics(page, 'login-portal-missing').catch(()=>{})
|
||||
throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal root element missing after navigation (saved diagnostics to reports/)', 'error')
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', `Reached rewards portal via fallback (${fallbackSelector})`)
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', `Reached rewards portal (${portalSelector})`)
|
||||
}
|
||||
|
||||
private async verifyBingContext(page: Page) {
|
||||
@@ -565,16 +748,7 @@ export class Login {
|
||||
}
|
||||
|
||||
private async saveIncidentArtifacts(page: Page, slug: string) {
|
||||
try {
|
||||
const base = path.join(process.cwd(),'diagnostics','security-incidents')
|
||||
await fs.promises.mkdir(base,{ recursive:true })
|
||||
const ts = new Date().toISOString().replace(/[:.]/g,'-')
|
||||
const dir = path.join(base, `${ts}-${slug}`)
|
||||
await fs.promises.mkdir(dir,{ recursive:true })
|
||||
try { await page.screenshot({ path: path.join(dir,'page.png'), fullPage:false }) } catch {/* ignore */}
|
||||
try { const html = await page.content(); await fs.promises.writeFile(path.join(dir,'page.html'), html) } catch {/* ignore */}
|
||||
this.bot.log(this.bot.isMobile,'SECURITY',`Saved incident artifacts: ${dir}`)
|
||||
} catch {/* ignore */}
|
||||
await captureDiagnostics(this.bot, page, slug, { scope: 'security', skipSlot: true, force: true })
|
||||
}
|
||||
|
||||
private async openDocsTab(page: Page, url: string) {
|
||||
|
||||
@@ -102,6 +102,14 @@ export class MicrosoftRewardsBot {
|
||||
}
|
||||
}
|
||||
|
||||
public isBuyModeEnabled(): boolean {
|
||||
return this.buyMode.enabled === true
|
||||
}
|
||||
|
||||
public getBuyModeTarget(): string | undefined {
|
||||
return this.buyMode.email
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.accounts = loadAccounts()
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface ConfigSchedule {
|
||||
timeZone?: string; // IANA TZ e.g., "America/New_York"
|
||||
useAmPm?: boolean; // If true, prefer time12 + AM/PM style; if false, prefer time24. If undefined, back-compat behavior.
|
||||
runImmediatelyOnStart?: boolean; // if true, run once immediately when process starts
|
||||
cron?: string | string[]; // Optional cron expression(s) (standard 5-field or 6-field) for advanced scheduling
|
||||
}
|
||||
|
||||
export interface ConfigVacation {
|
||||
|
||||
2
src/types/luxon.d.ts → src/luxon.d.ts
vendored
2
src/types/luxon.d.ts → src/luxon.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
/* Minimal ambient declarations to unblock TypeScript when @types/luxon not present. */
|
||||
/* Minimal ambient declarations to unblock TypeScript when @types/luxon is absent. */
|
||||
declare module 'luxon' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const DateTime: any
|
||||
103
src/scheduler.ts
103
src/scheduler.ts
@@ -1,4 +1,5 @@
|
||||
import { DateTime, IANAZone } from 'luxon'
|
||||
import cronParser from 'cron-parser'
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
@@ -7,6 +8,9 @@ import { loadConfig } from './util/Load'
|
||||
import { log } from './util/Logger'
|
||||
import type { Config } from './interface/Config'
|
||||
|
||||
type CronExpressionInfo = { expression: string; tz: string }
|
||||
type DateTimeInstance = ReturnType<typeof DateTime.fromJSDate>
|
||||
|
||||
function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } {
|
||||
const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
|
||||
// Determine source string
|
||||
@@ -47,6 +51,55 @@ function parseTargetToday(now: Date, schedule: Config['schedule'] | undefined) {
|
||||
return dtn.set({ hour, minute, second: 0, millisecond: 0 })
|
||||
}
|
||||
|
||||
function normalizeCronExpressions(schedule: Config['schedule'] | undefined, fallbackTz: string): CronExpressionInfo[] {
|
||||
if (!schedule) return []
|
||||
const raw = schedule.cron
|
||||
if (!raw) return []
|
||||
const expressions = Array.isArray(raw) ? raw : [raw]
|
||||
return expressions
|
||||
.map(expr => (typeof expr === 'string' ? expr.trim() : ''))
|
||||
.filter(expr => expr.length > 0)
|
||||
.map(expr => ({ expression: expr, tz: (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : fallbackTz }))
|
||||
}
|
||||
|
||||
function getNextCronOccurrence(after: DateTimeInstance, items: CronExpressionInfo[]): { next: DateTimeInstance; source: string } | null {
|
||||
let soonest: { next: DateTimeInstance; source: string } | null = null
|
||||
for (const item of items) {
|
||||
try {
|
||||
const iterator = cronParser.parseExpression(item.expression, {
|
||||
currentDate: after.toJSDate(),
|
||||
tz: item.tz
|
||||
})
|
||||
const nextDate = iterator.next().toDate()
|
||||
const nextDt = DateTime.fromJSDate(nextDate, { zone: item.tz })
|
||||
if (!soonest || nextDt < soonest.next) {
|
||||
soonest = { next: nextDt, source: item.expression }
|
||||
}
|
||||
} catch (error) {
|
||||
void log('main', 'SCHEDULER', `Invalid cron expression "${item.expression}": ${error instanceof Error ? error.message : String(error)}`, 'warn')
|
||||
}
|
||||
}
|
||||
return soonest
|
||||
}
|
||||
|
||||
function getNextDailyOccurrence(after: DateTimeInstance, schedule: Config['schedule'] | undefined): DateTimeInstance {
|
||||
const todayTarget = parseTargetToday(after.toJSDate(), schedule)
|
||||
const target = after >= todayTarget ? todayTarget.plus({ days: 1 }) : todayTarget
|
||||
return target
|
||||
}
|
||||
|
||||
function computeNextRun(after: DateTimeInstance, schedule: Config['schedule'] | undefined, cronItems: CronExpressionInfo[]): { next: DateTimeInstance; source: 'cron' | 'daily'; detail?: string } {
|
||||
if (cronItems.length > 0) {
|
||||
const cronNext = getNextCronOccurrence(after, cronItems)
|
||||
if (cronNext) {
|
||||
return { next: cronNext.next, source: 'cron', detail: cronNext.source }
|
||||
}
|
||||
void log('main', 'SCHEDULER', 'All cron expressions invalid; falling back to daily schedule', 'warn')
|
||||
}
|
||||
|
||||
return { next: getNextDailyOccurrence(after, schedule), source: 'daily' }
|
||||
}
|
||||
|
||||
async function runOnePass(): Promise<void> {
|
||||
const bot = new MicrosoftRewardsBot(false)
|
||||
await bot.initialize()
|
||||
@@ -195,7 +248,8 @@ async function main() {
|
||||
}
|
||||
offDays = chosen.sort((a,b)=>a-b)
|
||||
offWeek = week
|
||||
await log('main','SCHEDULER',`Selected random off-days this week (ISO): ${offDays.join(', ')}`,'warn')
|
||||
const msg = offDays.length ? offDays.join(', ') : 'none'
|
||||
await log('main','SCHEDULER',`Weekly humanization off-day sample (ISO weekday): ${msg} | adjust via config.humanization.randomOffDaysPerWeek`,'warn')
|
||||
}
|
||||
|
||||
const chooseVacationRange = async (now: typeof DateTime.prototype) => {
|
||||
@@ -226,6 +280,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const tz = (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
|
||||
const cronExpressions = normalizeCronExpressions(schedule, tz)
|
||||
// Default to false to avoid unexpected immediate runs
|
||||
const runImmediate = schedule.runImmediatelyOnStart === true
|
||||
let running = false
|
||||
@@ -256,7 +311,7 @@ async function main() {
|
||||
if (isVacationToday) {
|
||||
await log('main','SCHEDULER',`Skipping immediate run: vacation day (${todayIso})`,'warn')
|
||||
} else if (offDays.includes(nowDT.weekday)) {
|
||||
await log('main','SCHEDULER',`Skipping immediate run: off-day (weekday ${nowDT.weekday})`,'warn')
|
||||
await log('main','SCHEDULER',`Skipping immediate run: humanization off-day (ISO weekday ${nowDT.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn')
|
||||
} else {
|
||||
await runPasses(passes)
|
||||
}
|
||||
@@ -264,38 +319,32 @@ async function main() {
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const now = new Date()
|
||||
const targetToday = parseTargetToday(now, schedule)
|
||||
let next = targetToday
|
||||
const nowDT = DateTime.fromJSDate(now, { zone: targetToday.zone })
|
||||
|
||||
if (nowDT >= targetToday) {
|
||||
next = targetToday.plus({ days: 1 })
|
||||
}
|
||||
|
||||
const nowDT = DateTime.local().setZone(tz)
|
||||
const nextInfo = computeNextRun(nowDT, schedule, cronExpressions)
|
||||
const next = nextInfo.next
|
||||
let ms = Math.max(0, next.toMillis() - nowDT.toMillis())
|
||||
|
||||
// Optional daily jitter to further randomize the exact start time each day
|
||||
const dailyJitterMin = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MIN || process.env.SCHEDULER_DAILY_JITTER_MIN || 0)
|
||||
const dailyJitterMax = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MAX || process.env.SCHEDULER_DAILY_JITTER_MAX || 0)
|
||||
const djMin = isFinite(dailyJitterMin) ? dailyJitterMin : 0
|
||||
const djMax = isFinite(dailyJitterMax) ? dailyJitterMax : 0
|
||||
let extraMs = 0
|
||||
if (djMin > 0 || djMax > 0) {
|
||||
const mn = Math.max(0, Math.min(djMin, djMax))
|
||||
const mx = Math.max(0, Math.max(djMin, djMax))
|
||||
const jitterSec = (mn === mx) ? mn * 60 : (mn * 60 + Math.floor(Math.random() * ((mx - mn) * 60)))
|
||||
extraMs = jitterSec * 1000
|
||||
ms += extraMs
|
||||
if (cronExpressions.length === 0) {
|
||||
const dailyJitterMin = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MIN || process.env.SCHEDULER_DAILY_JITTER_MIN || 0)
|
||||
const dailyJitterMax = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MAX || process.env.SCHEDULER_DAILY_JITTER_MAX || 0)
|
||||
const djMin = isFinite(dailyJitterMin) ? dailyJitterMin : 0
|
||||
const djMax = isFinite(dailyJitterMax) ? dailyJitterMax : 0
|
||||
if (djMin > 0 || djMax > 0) {
|
||||
const mn = Math.max(0, Math.min(djMin, djMax))
|
||||
const mx = Math.max(0, Math.max(djMin, djMax))
|
||||
const jitterSec = (mn === mx) ? mn * 60 : (mn * 60 + Math.floor(Math.random() * ((mx - mn) * 60)))
|
||||
extraMs = jitterSec * 1000
|
||||
ms += extraMs
|
||||
}
|
||||
}
|
||||
|
||||
const human = next.toFormat('yyyy-LL-dd HH:mm ZZZZ')
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
if (extraMs > 0) {
|
||||
await log('main', 'SCHEDULER', `Next run at ${human} plus daily jitter (+${Math.round(extraMs/60000)}m) → in ${totalSec}s`)
|
||||
} else {
|
||||
await log('main', 'SCHEDULER', `Next run at ${human} (in ${totalSec}s)`)
|
||||
}
|
||||
const jitterMsg = extraMs > 0 ? ` plus daily jitter (+${Math.round(extraMs/60000)}m)` : ''
|
||||
const sourceMsg = nextInfo.source === 'cron' ? ` [cron: ${nextInfo.detail}]` : ''
|
||||
await log('main', 'SCHEDULER', `Next run at ${human}${jitterMsg}${sourceMsg} (in ${totalSec}s)`)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -310,7 +359,7 @@ async function main() {
|
||||
continue
|
||||
}
|
||||
if (offDays.includes(nowRun.weekday)) {
|
||||
await log('main','SCHEDULER',`Skipping scheduled run: off-day (weekday ${nowRun.weekday})`,'warn')
|
||||
await log('main','SCHEDULER',`Skipping scheduled run: humanization off-day (ISO weekday ${nowRun.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn')
|
||||
continue
|
||||
}
|
||||
if (!running) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { HttpProxyAgent } from 'http-proxy-agent'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent'
|
||||
@@ -45,6 +45,14 @@ class AxiosClient {
|
||||
try {
|
||||
return await this.instance.request(config)
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as AxiosError | undefined
|
||||
|
||||
// Detect HTTP proxy auth failures (status 407) and retry without proxy once.
|
||||
if (!bypassProxy && axiosErr && axiosErr.response && axiosErr.response.status === 407) {
|
||||
const bypassInstance = axios.create()
|
||||
return bypassInstance.request(config)
|
||||
}
|
||||
|
||||
// If proxied request fails with common proxy/network errors, retry once without proxy
|
||||
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
|
||||
const code = e?.code || e?.cause?.code
|
||||
|
||||
74
src/util/Diagnostics.ts
Normal file
74
src/util/Diagnostics.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import type { Page } from 'rebrowser-playwright'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
export type DiagnosticsScope = 'default' | 'security'
|
||||
|
||||
export interface DiagnosticsOptions {
|
||||
scope?: DiagnosticsScope
|
||||
skipSlot?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export async function captureDiagnostics(bot: MicrosoftRewardsBot, page: Page, rawLabel: string, options?: DiagnosticsOptions): Promise<void> {
|
||||
try {
|
||||
const scope: DiagnosticsScope = options?.scope ?? 'default'
|
||||
const cfg = bot.config?.diagnostics ?? {}
|
||||
const forceCapture = options?.force === true || scope === 'security'
|
||||
if (!forceCapture && cfg.enabled === false) return
|
||||
|
||||
if (scope === 'default') {
|
||||
const maxPerRun = typeof cfg.maxPerRun === 'number' ? cfg.maxPerRun : 8
|
||||
if (!options?.skipSlot && !bot.tryReserveDiagSlot(maxPerRun)) return
|
||||
}
|
||||
|
||||
const saveScreenshot = scope === 'security' ? true : cfg.saveScreenshot !== false
|
||||
const saveHtml = scope === 'security' ? true : cfg.saveHtml !== false
|
||||
if (!saveScreenshot && !saveHtml) return
|
||||
|
||||
const safeLabel = rawLabel.replace(/[^a-z0-9-_]/gi, '_').slice(0, 64) || 'capture'
|
||||
const now = new Date()
|
||||
const timestamp = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
|
||||
|
||||
let dir: string
|
||||
if (scope === 'security') {
|
||||
const base = path.join(process.cwd(), 'diagnostics', 'security-incidents')
|
||||
fs.mkdirSync(base, { recursive: true })
|
||||
const sub = `${now.toISOString().replace(/[:.]/g, '-')}-${safeLabel}`
|
||||
dir = path.join(base, sub)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} else {
|
||||
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
dir = path.join(process.cwd(), 'reports', day)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
if (saveScreenshot) {
|
||||
const shotName = scope === 'security' ? 'page.png' : `${timestamp}_${safeLabel}.png`
|
||||
const shotPath = path.join(dir, shotName)
|
||||
await page.screenshot({ path: shotPath }).catch(() => {})
|
||||
if (scope === 'security') {
|
||||
bot.log(bot.isMobile, 'DIAG', `Saved security screenshot to ${shotPath}`)
|
||||
} else {
|
||||
bot.log(bot.isMobile, 'DIAG', `Saved diagnostics screenshot to ${shotPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (saveHtml) {
|
||||
const htmlName = scope === 'security' ? 'page.html' : `${timestamp}_${safeLabel}.html`
|
||||
const htmlPath = path.join(dir, htmlName)
|
||||
try {
|
||||
const html = await page.content()
|
||||
await fs.promises.writeFile(htmlPath, html, 'utf-8')
|
||||
if (scope === 'security') {
|
||||
bot.log(bot.isMobile, 'DIAG', `Saved security HTML to ${htmlPath}`)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
bot.log(bot.isMobile, 'DIAG', `Failed to capture diagnostics: ${error instanceof Error ? error.message : error}`, 'warn')
|
||||
}
|
||||
}
|
||||
@@ -255,14 +255,21 @@ export function loadConfig(): Config {
|
||||
return configCache
|
||||
}
|
||||
|
||||
// Resolve config.json from common locations
|
||||
const candidates = [
|
||||
path.join(__dirname, '../', 'config.json'), // root/config.json when compiled (expected primary)
|
||||
path.join(__dirname, '../src', 'config.json'), // fallback: running compiled dist but file still in src/
|
||||
path.join(process.cwd(), 'config.json'), // cwd root
|
||||
path.join(process.cwd(), 'src', 'config.json'), // running from repo root but config left in src/
|
||||
path.join(__dirname, 'config.json') // last resort: dist/util/config.json
|
||||
// Resolve configuration file from common locations (supports .jsonc and .json)
|
||||
const names = ['config.jsonc', 'config.json']
|
||||
const bases = [
|
||||
path.join(__dirname, '../'), // dist root when compiled
|
||||
path.join(__dirname, '../src'), // fallback: running dist but config still in src
|
||||
process.cwd(), // repo root
|
||||
path.join(process.cwd(), 'src'), // repo/src when running ts-node
|
||||
__dirname // dist/util
|
||||
]
|
||||
const candidates: string[] = []
|
||||
for (const base of bases) {
|
||||
for (const name of names) {
|
||||
candidates.push(path.join(base, name))
|
||||
}
|
||||
}
|
||||
let cfgPath: string | null = null
|
||||
for (const p of candidates) {
|
||||
try { if (fs.existsSync(p)) { cfgPath = p; break } } catch { /* ignore */ }
|
||||
|
||||
@@ -1,8 +1,70 @@
|
||||
import axios from 'axios'
|
||||
import chalk from 'chalk'
|
||||
|
||||
import { Ntfy } from './Ntfy'
|
||||
import { loadConfig } from './Load'
|
||||
|
||||
type WebhookBuffer = {
|
||||
lines: string[]
|
||||
sending: boolean
|
||||
timer?: NodeJS.Timeout
|
||||
}
|
||||
|
||||
const webhookBuffers = new Map<string, WebhookBuffer>()
|
||||
|
||||
function getBuffer(url: string): WebhookBuffer {
|
||||
let buf = webhookBuffers.get(url)
|
||||
if (!buf) {
|
||||
buf = { lines: [], sending: false }
|
||||
webhookBuffers.set(url, buf)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
async function sendBatch(url: string, buf: WebhookBuffer) {
|
||||
if (buf.sending) return
|
||||
buf.sending = true
|
||||
while (buf.lines.length > 0) {
|
||||
const chunk: string[] = []
|
||||
let currentLength = 0
|
||||
while (buf.lines.length > 0) {
|
||||
const next = buf.lines[0]!
|
||||
const projected = currentLength + next.length + (chunk.length > 0 ? 1 : 0)
|
||||
if (projected > 1900 && chunk.length > 0) break
|
||||
buf.lines.shift()
|
||||
chunk.push(next)
|
||||
currentLength = projected
|
||||
}
|
||||
|
||||
const content = chunk.join('\n').slice(0, 1900)
|
||||
if (!content) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(url, { content }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 })
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
} catch (error) {
|
||||
// Re-queue failed batch at front and exit loop
|
||||
buf.lines = chunk.concat(buf.lines)
|
||||
console.error('[Webhook] live log delivery failed:', error)
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.sending = false
|
||||
}
|
||||
|
||||
function enqueueWebhookLog(url: string, line: string) {
|
||||
const buf = getBuffer(url)
|
||||
buf.lines.push(line)
|
||||
if (!buf.timer) {
|
||||
buf.timer = setTimeout(() => {
|
||||
buf.timer = undefined
|
||||
void sendBatch(url, buf)
|
||||
}, 750)
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronous logger that returns an Error when type === 'error' so callers can `throw log(...)` safely.
|
||||
export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): Error | void {
|
||||
const configData = loadConfig()
|
||||
@@ -84,6 +146,21 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
|
||||
break
|
||||
}
|
||||
|
||||
// Webhook streaming (live logs)
|
||||
try {
|
||||
const loggingCfg: Record<string, unknown> = (configAny.logging || {}) as Record<string, unknown>
|
||||
const webhookCfg = configData.webhook
|
||||
const liveUrlRaw = typeof loggingCfg.liveWebhookUrl === 'string' ? loggingCfg.liveWebhookUrl.trim() : ''
|
||||
const liveUrl = liveUrlRaw || (webhookCfg?.enabled && webhookCfg.url ? webhookCfg.url : '')
|
||||
const webhookExclude = Array.isArray(loggingCfg.webhookExcludeFunc) ? loggingCfg.webhookExcludeFunc : configData.webhookLogExcludeFunc || []
|
||||
const webhookExcluded = Array.isArray(webhookExclude) && webhookExclude.some((x: string) => x.toLowerCase() === title.toLowerCase())
|
||||
if (liveUrl && !webhookExcluded) {
|
||||
enqueueWebhookLog(liveUrl, cleanStr)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Logger] Failed to enqueue webhook log:', error)
|
||||
}
|
||||
|
||||
// Return an Error when logging an error so callers can `throw log(...)`
|
||||
if (type === 'error') {
|
||||
// CommunityReporter disabled per project policy
|
||||
|
||||
@@ -2,10 +2,21 @@ import axios from 'axios'
|
||||
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
|
||||
import { log } from './Logger'
|
||||
import Retry from './Retry'
|
||||
|
||||
import { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil'
|
||||
import { ChromeVersion, EdgeVersion, Architecture, Platform } from '../interface/UserAgentUtil'
|
||||
|
||||
const NOT_A_BRAND_VERSION = '99'
|
||||
const EDGE_VERSION_URL = 'https://edgeupdates.microsoft.com/api/products'
|
||||
const EDGE_VERSION_CACHE_TTL_MS = 1000 * 60 * 60
|
||||
|
||||
type EdgeVersionResult = {
|
||||
android?: string
|
||||
windows?: string
|
||||
}
|
||||
|
||||
let edgeVersionCache: { data: EdgeVersionResult; expiresAt: number } | null = null
|
||||
let edgeVersionInFlight: Promise<EdgeVersionResult> | null = null
|
||||
|
||||
export async function getUserAgent(isMobile: boolean) {
|
||||
const system = getSystemComponents(isMobile)
|
||||
@@ -18,6 +29,7 @@ export async function getUserAgent(isMobile: boolean) {
|
||||
const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
|
||||
|
||||
const uaMetadata = {
|
||||
mobile: isMobile,
|
||||
isMobile,
|
||||
platform: isMobile ? 'Android' : 'Windows',
|
||||
fullVersionList: [
|
||||
@@ -33,7 +45,8 @@ export async function getUserAgent(isMobile: boolean) {
|
||||
platformVersion,
|
||||
architecture: isMobile ? '' : 'x86',
|
||||
bitness: isMobile ? '' : '64',
|
||||
model: ''
|
||||
model: '',
|
||||
uaFullVersion: app['chrome_version']
|
||||
}
|
||||
|
||||
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
|
||||
@@ -59,38 +72,49 @@ export async function getChromeVersion(isMobile: boolean): Promise<string> {
|
||||
}
|
||||
|
||||
export async function 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) {
|
||||
throw log(isMobile, 'USERAGENT-EDGE-VERSION', 'An error occurred:' + error, 'error')
|
||||
const now = Date.now()
|
||||
if (edgeVersionCache && edgeVersionCache.expiresAt > now) {
|
||||
return edgeVersionCache.data
|
||||
}
|
||||
|
||||
if (edgeVersionInFlight) {
|
||||
try {
|
||||
return await edgeVersionInFlight
|
||||
} catch (error) {
|
||||
if (edgeVersionCache) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using cached Edge versions after in-flight failure: ' + formatEdgeError(error), 'warn')
|
||||
return edgeVersionCache.data
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPromise = fetchEdgeVersionsWithRetry(isMobile)
|
||||
.then(result => {
|
||||
edgeVersionCache = { data: result, expiresAt: Date.now() + EDGE_VERSION_CACHE_TTL_MS }
|
||||
edgeVersionInFlight = null
|
||||
return result
|
||||
})
|
||||
.catch(error => {
|
||||
edgeVersionInFlight = null
|
||||
if (edgeVersionCache) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Falling back to cached Edge versions: ' + formatEdgeError(error), 'warn')
|
||||
return edgeVersionCache.data
|
||||
}
|
||||
throw log(isMobile, 'USERAGENT-EDGE-VERSION', 'Failed to fetch Edge versions: ' + formatEdgeError(error), 'error')
|
||||
})
|
||||
|
||||
edgeVersionInFlight = fetchPromise
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
export function getSystemComponents(mobile: boolean): string {
|
||||
const osId: string = mobile ? 'Linux' : 'Windows NT 10.0'
|
||||
const uaPlatform: string = mobile ? `Android 1${Math.floor(Math.random() * 5)}` : 'Win64; x64'
|
||||
|
||||
if (mobile) {
|
||||
return `${uaPlatform}; ${osId}; K`
|
||||
const androidVersion = 10 + Math.floor(Math.random() * 5)
|
||||
return `Linux; Android ${androidVersion}; K`
|
||||
}
|
||||
|
||||
return `${uaPlatform}; ${osId}`
|
||||
return 'Windows NT 10.0; Win64; x64'
|
||||
}
|
||||
|
||||
export async function getAppComponents(isMobile: boolean) {
|
||||
@@ -113,12 +137,124 @@ export async function getAppComponents(isMobile: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEdgeVersionsWithRetry(isMobile: boolean): Promise<EdgeVersionResult> {
|
||||
const retry = new Retry()
|
||||
return retry.run(async () => {
|
||||
const versions = await fetchEdgeVersionsOnce(isMobile)
|
||||
if (!versions.android && !versions.windows) {
|
||||
throw new Error('Stable Edge releases did not include Android or Windows versions')
|
||||
}
|
||||
return versions
|
||||
}, () => true)
|
||||
}
|
||||
|
||||
async function fetchEdgeVersionsOnce(isMobile: boolean): Promise<EdgeVersionResult> {
|
||||
try {
|
||||
const response = await axios<EdgeVersion[]>({
|
||||
url: EDGE_VERSION_URL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; rewards-bot/2.1)' // Provide UA to avoid stricter servers
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
return mapEdgeVersions(response.data)
|
||||
|
||||
} catch (primaryError) {
|
||||
const fallback = await tryNativeFetchFallback(isMobile)
|
||||
if (fallback) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Axios failed, native fetch succeeded: ' + formatEdgeError(primaryError), 'warn')
|
||||
return fallback
|
||||
}
|
||||
throw primaryError
|
||||
}
|
||||
}
|
||||
|
||||
async function tryNativeFetchFallback(isMobile: boolean): Promise<EdgeVersionResult | null> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
const response = await fetch(EDGE_VERSION_URL, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; rewards-bot/2.1)'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP ' + response.status)
|
||||
}
|
||||
const data = await response.json() as EdgeVersion[]
|
||||
return mapEdgeVersions(data)
|
||||
} catch (error) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Native fetch fallback failed: ' + formatEdgeError(error), 'warn')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mapEdgeVersions(data: EdgeVersion[]): EdgeVersionResult {
|
||||
const stable = data.find(entry => entry.Product.toLowerCase() === 'stable')
|
||||
?? data.find(entry => /stable/i.test(entry.Product))
|
||||
if (!stable) {
|
||||
throw new Error('Stable Edge channel not found in response payload')
|
||||
}
|
||||
|
||||
const androidRelease = stable.Releases.find(release => release.Platform === Platform.Android)
|
||||
const windowsRelease = stable.Releases.find(release => release.Platform === Platform.Windows && release.Architecture === Architecture.X64)
|
||||
?? stable.Releases.find(release => release.Platform === Platform.Windows)
|
||||
|
||||
return {
|
||||
android: androidRelease?.ProductVersion,
|
||||
windows: windowsRelease?.ProductVersion
|
||||
}
|
||||
}
|
||||
|
||||
function formatEdgeError(error: unknown): string {
|
||||
if (isAggregateErrorLike(error)) {
|
||||
const inner = error.errors
|
||||
.map(innerErr => formatEdgeError(innerErr))
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
const message = error.message || 'AggregateError'
|
||||
return inner ? `${message} | causes: ${inner}` : message
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const parts = [`${error.name}: ${error.message}`]
|
||||
const cause = getErrorCause(error)
|
||||
if (cause) {
|
||||
parts.push('cause => ' + formatEdgeError(cause))
|
||||
}
|
||||
return parts.join(' | ')
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
type AggregateErrorLike = { message?: string; errors: unknown[] }
|
||||
|
||||
function isAggregateErrorLike(error: unknown): error is AggregateErrorLike {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidate = error as { errors?: unknown }
|
||||
return Array.isArray(candidate.errors)
|
||||
}
|
||||
|
||||
function getErrorCause(error: { cause?: unknown } | Error): unknown {
|
||||
if (typeof (error as { cause?: unknown }).cause === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
return (error as { cause?: unknown }).cause
|
||||
}
|
||||
|
||||
export async function updateFingerprintUserAgent(fingerprint: BrowserFingerprintWithHeaders, isMobile: boolean): Promise<BrowserFingerprintWithHeaders> {
|
||||
try {
|
||||
const userAgentData = await getUserAgent(isMobile)
|
||||
const componentData = await 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}/`, '')
|
||||
|
||||
Reference in New Issue
Block a user