* 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:
Light
2025-10-11 16:54:07 +02:00
committed by GitHub
parent 3e499be8a9
commit dc7e122bce
34 changed files with 1571 additions and 356 deletions

View File

@@ -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

View File

@@ -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')
}

View File

@@ -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)
}
}

View File

@@ -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., 35 days) and all runs within
// block between minDays and maxDays is selected (e.g., 24 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"
}
}
}

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
View 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')
}
}

View File

@@ -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 */ }

View File

@@ -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

View File

@@ -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}/`, '')