Initial commit

This commit is contained in:
2025-11-01 20:44:31 +01:00
commit 6d549e6590
91 changed files with 20404 additions and 0 deletions

155
src/accounts.example.jsonc Normal file
View File

@@ -0,0 +1,155 @@
{
// ============================================================
// 📧 MICROSOFT ACCOUNTS CONFIGURATION
// ============================================================
// ⚠️ IMPORTANT SECURITY NOTICE
// This file contains sensitive credentials. Never commit the real accounts.jsonc to version control.
// The .gitignore is configured to exclude accounts.jsonc but you should verify it's not tracked.
// 📊 MICROSOFT ACCOUNT LIMITS (Unofficial Guidelines)
// - New accounts per IP per day: ~3 (official soft limit)
// - Recommended active accounts per household IP: ~5 (to avoid suspicion)
// - Creating too many accounts quickly may trigger verification (phone, OTP, captcha)
// - Unusual activity can result in temporary blocks or account restrictions
"accounts": [
{
// ============================================================
// 👤 ACCOUNT 1
// ============================================================
// Enable or disable this account (true = active, false = skip)
"enabled": true,
// Microsoft account email address
"email": "email_1@outlook.com",
// Account password
"password": "password_1",
// Two-Factor Authentication (2FA) TOTP secret (optional but HIGHLY recommended for security)
// Steps:
// 1. Visit https://account.live.com/proofs/Manage/additional and enable two-step verification.
// 2. When the wizard appears, click the blue link "Set up a different authenticator app".
// 3. On the next page click "I can't scan the bar code" to reveal the Base32 secret (letters + numbers).
// 4. Scan the QR with your authenticator (Google Authenticator recommended) AND copy the secret shown.
// 5. Paste the secret here. The same secret can stay in your app and power the bot simultaneously.
// Format: Base32 secret key (e.g., "JBSWY3DPEHPK3PXP"). Leave empty "" if 2FA is not enabled.
"totp": "",
// ⚠️ REQUIRED: Recovery email address associated with this Microsoft account
// During login, Microsoft shows the first 2 characters and the domain of the recovery email (e.g., "ab***@example.com")
// This field is MANDATORY to detect account compromise or bans:
// - The script compares what Microsoft displays with this configured recovery email
// - If they don't match, it alerts you that the account may be compromised or the recovery email was changed
// - This security check helps identify hijacked accounts before they cause issues
// Format: Full recovery email address (e.g., "backup@gmail.com")
"recoveryEmail": "your_email@domain.com",
// ============================================================
// 🌐 PROXY CONFIGURATION (Optional)
// ============================================================
"proxy": {
// Enable proxy for HTTP requests (axios/API calls)
// If false, proxy is only used for browser automation
"proxyAxios": true,
// Proxy server URL (protocol optional)
// Examples: "proxy.example.com", "http://proxy.example.com", "socks5://proxy.example.com"
// Leave empty "" to disable proxy for this account
"url": "",
// Proxy port number
"port": 0,
// Proxy authentication username (leave empty if no auth required)
"username": "",
// Proxy authentication password (leave empty if no auth required)
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 2
// ============================================================
"enabled": false,
"email": "email_2@outlook.com",
"password": "password_2",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 3
// ============================================================
"enabled": false,
"email": "email_3@outlook.com",
"password": "password_3",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 4
// ============================================================
"enabled": false,
"email": "email_4@outlook.com",
"password": "password_4",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 5
// ============================================================
"enabled": false,
"email": "email_5@outlook.com",
"password": "password_5",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
}
]
}

192
src/browser/Browser.ts Normal file
View File

@@ -0,0 +1,192 @@
import playwright, { BrowserContext } from 'rebrowser-playwright'
import { newInjectedContext } from 'fingerprint-injector'
import { FingerprintGenerator } from 'fingerprint-generator'
import { MicrosoftRewardsBot } from '../index'
import { loadSessionData, saveFingerprintData } from '../util/Load'
import { updateFingerprintUserAgent } from '../util/UserAgent'
import { AccountProxy } from '../interface/Account'
/* Test Stuff
https://abrahamjuliot.github.io/creepjs/
https://botcheck.luminati.io/
https://fv.pro/
https://pixelscan.net/
https://www.browserscan.net/
*/
class Browser {
private bot: MicrosoftRewardsBot
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
async createBrowser(proxy: AccountProxy, email: string): Promise<BrowserContext> {
// Optional automatic browser installation (set AUTO_INSTALL_BROWSERS=1)
if (process.env.AUTO_INSTALL_BROWSERS === '1') {
try {
// Dynamically import child_process to avoid overhead otherwise
const { execSync } = await import('child_process')
execSync('npx playwright install chromium', { stdio: 'ignore' })
} catch { /* silent */ }
}
let browser: import('rebrowser-playwright').Browser
try {
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
// Support legacy config.headless OR nested config.browser.headless
const legacyHeadless = (this.bot.config as { headless?: boolean }).headless
const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless
let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? 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
this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
const proxyConfig = this.buildPlaywrightProxy(proxy)
browser = await playwright.chromium.launch({
// Optional: uncomment to use Edge instead of Chromium
// channel: 'msedge',
headless,
...(proxyConfig && { proxy: proxyConfig }),
args: [
'--no-sandbox',
'--mute-audio',
'--disable-setuid-sandbox',
'--ignore-certificate-errors',
'--ignore-certificate-errors-spki-list',
'--ignore-ssl-errors'
]
})
} catch (e: unknown) {
const msg = (e instanceof Error ? e.message : String(e))
// Common missing browser executable guidance
if (/Executable doesn't exist/i.test(msg)) {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies (or set AUTO_INSTALL_BROWSERS=1 to auto-attempt).', 'error')
} else {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
}
throw e
}
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint
const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint
const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false }
const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint)
const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint()
const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint })
// Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout
const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout
const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout))
// Normalize viewport and page rendering so content fits typical screens
try {
const desktopViewport = { width: 1280, height: 800 }
const mobileViewport = { width: 390, height: 844 }
context.on('page', async (page) => {
try {
// Set a reasonable viewport size depending on device type
if (this.bot.isMobile) {
await page.setViewportSize(mobileViewport)
} else {
await page.setViewportSize(desktopViewport)
}
// Inject a tiny CSS to avoid gigantic scaling on some environments
await page.addInitScript(() => {
try {
const style = document.createElement('style')
style.id = '__mrs_fit_style'
style.textContent = `
html, body { overscroll-behavior: contain; }
/* Mild downscale to keep content within window on very large DPI */
@media (min-width: 1000px) {
html { zoom: 0.9 !important; }
}
`
document.documentElement.appendChild(style)
} catch { /* ignore */ }
})
} catch { /* ignore */ }
})
} catch { /* ignore */ }
await context.addCookies(sessionData.cookies)
// Persist fingerprint when feature is configured
if (saveFingerprint.mobile || saveFingerprint.desktop) {
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
}
this.bot.log(this.bot.isMobile, 'BROWSER', `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`)
return context as BrowserContext
}
private buildPlaywrightProxy(proxy: AccountProxy): { server: string; username?: string; password?: string } | undefined {
const { url, port, username, password } = proxy
if (!url) return undefined
const trimmed = url.trim()
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)
const candidate = hasScheme ? trimmed : `http://${trimmed}`
let parsed: URL
try {
parsed = new URL(candidate)
} catch (err) {
this.bot.log(this.bot.isMobile, 'BROWSER', `Invalid proxy URL "${url}": ${err instanceof Error ? err.message : String(err)}`, 'error')
return undefined
}
if (!parsed.port) {
if (port) {
parsed.port = String(port)
} else {
this.bot.log(this.bot.isMobile, 'BROWSER', `Proxy port missing for "${url}"`, 'error')
return undefined
}
}
const server = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`
const auth: { username?: string; password?: string } = {}
if (username) auth.username = username
if (password) auth.password = password
return { server, ...auth }
}
async generateFingerprint() {
const fingerPrintData = new FingerprintGenerator().getFingerprint({
devices: this.bot.isMobile ? ['mobile'] : ['desktop'],
operatingSystems: this.bot.isMobile ? ['android'] : ['windows'],
browsers: [{ name: 'edge' }]
})
const updatedFingerPrintData = await updateFingerprintUserAgent(fingerPrintData, this.bot.isMobile)
return updatedFingerPrintData
}
}
export default Browser

544
src/browser/BrowserFunc.ts Normal file
View File

@@ -0,0 +1,544 @@
import { BrowserContext, Page } from 'rebrowser-playwright'
import { CheerioAPI, load } from 'cheerio'
import { AxiosRequestConfig } from 'axios'
import { MicrosoftRewardsBot } from '../index'
import { saveSessionData } from '../util/Load'
import { TIMEOUTS, RETRY_LIMITS, SELECTORS, URLS } from '../constants'
import { Counters, DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
import { QuizData } from '../interface/QuizData'
import { AppUserData } from '../interface/AppUserData'
import { EarnablePoints } from '../interface/Points'
export default class BrowserFunc {
private bot: MicrosoftRewardsBot
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
/**
* Navigate the provided page to rewards homepage
* @param {Page} page Playwright page
*/
async goHome(page: Page) {
try {
const dashboardURL = new URL(this.bot.config.baseURL)
if (page.url() === dashboardURL.href) {
return
}
await page.goto(this.bot.config.baseURL)
for (let iteration = 1; iteration <= RETRY_LIMITS.GO_HOME_MAX; iteration++) {
await this.bot.utils.wait(TIMEOUTS.LONG)
await this.bot.browser.utils.tryDismissAllMessages(page)
try {
// If activities are found, exit the loop (SUCCESS - account is OK)
await page.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: 1000 })
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
break
} catch (error) {
// Activities not found yet - check if it's because account is suspended
// Only check suspension if we can't find activities (reduces false positives)
const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }).then(() => true).catch(() => false)
if (suspendedByHeader) {
this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error')
throw new Error('Account has been suspended!')
}
// Secondary check: look for suspension text in main content area only
try {
const mainContent = (await page.locator('#contentContainer, #main, .main-content').first().textContent({ timeout: 500 }).catch(() => '')) || ''
const suspensionPatterns = [
/account\s+has\s+been\s+suspended/i,
/suspended\s+due\s+to\s+unusual\s+activity/i,
/your\s+account\s+is\s+temporarily\s+suspended/i
]
const isSuspended = suspensionPatterns.some(pattern => pattern.test(mainContent))
if (isSuspended) {
this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by content text (iteration ${iteration})`, 'error')
throw new Error('Account has been suspended!')
}
} catch (e) {
// Ignore errors in text check - not critical
this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${e}`, 'warn')
}
// Not suspended, just activities not loaded yet - continue to next iteration
this.bot.log(this.bot.isMobile, 'GO-HOME', `Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`, 'warn')
}
// Below runs if the homepage was unable to be visited
const currentURL = new URL(page.url())
if (currentURL.hostname !== dashboardURL.hostname) {
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.goto(this.bot.config.baseURL)
} else {
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
break
}
await this.bot.utils.wait(TIMEOUTS.VERY_LONG)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GO-HOME', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Go home failed: ' + errorMessage)
}
}
/**
* Fetch user dashboard data
* @returns {DashboardData} Object of user bing rewards dashboard data
*/
async getDashboardData(page?: Page): Promise<DashboardData> {
const target = page ?? this.bot.homePage
const dashboardURL = new URL(this.bot.config.baseURL)
const currentURL = new URL(target.url())
try {
// Should never happen since tasks are opened in a new tab!
if (currentURL.hostname !== dashboardURL.hostname) {
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
await this.goHome(target)
}
let lastError: unknown = null
for (let attempt = 1; attempt <= 2; attempt++) {
try {
// Reload the page to get new data
await target.reload({ waitUntil: 'domcontentloaded' })
lastError = null
break
} catch (re) {
lastError = re
const msg = (re instanceof Error ? re.message : String(re))
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn')
// If page/context closed => bail early after first retry
if (msg.includes('has been closed')) {
if (attempt === 1) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
try {
await this.goHome(target)
} catch {/* ignore */}
} else {
break
}
}
if (attempt === 2) {
await this.bot.utils.wait(1000)
}
}
}
// If reload failed after all attempts, throw the last error
if (lastError) {
throw lastError
}
// Wait a bit longer for scripts to load, especially on mobile
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
// Wait for the more-activities element to ensure page is fully loaded
await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Activities element not found, continuing anyway', 'warn')
})
let scriptContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
// Try multiple patterns for better compatibility
const targetScript = scripts.find(script =>
script.innerText.includes('var dashboard') ||
script.innerText.includes('dashboard=') ||
script.innerText.includes('dashboard :')
)
return targetScript?.innerText ? targetScript.innerText : null
})
if (!scriptContent) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn')
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Failed to capture diagnostics: ${e}`, 'warn')
})
// Force a navigation retry once before failing hard
try {
await this.goHome(target)
await target.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.VERY_LONG }).catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Wait for load state failed: ${e}`, 'warn')
})
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
} catch (e) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Recovery navigation failed: ${e}`, 'warn')
}
const retryContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script =>
script.innerText.includes('var dashboard') ||
script.innerText.includes('dashboard=') ||
script.innerText.includes('dashboard :')
)
return targetScript?.innerText ? targetScript.innerText : null
}).catch(()=>null)
if (!retryContent) {
// Log additional debug info
const scriptsDebug = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
return scripts.map(s => s.innerText.substring(0, 100)).join(' | ')
}).catch(() => 'Unable to get script debug info')
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Available scripts preview: ${scriptsDebug}`, 'warn')
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
throw new Error('Dashboard data not found within script - check page structure')
}
scriptContent = retryContent
}
// Extract the dashboard object from the script content
const dashboardData = await target.evaluate((scriptContent: string) => {
// Try multiple regex patterns for better compatibility
const patterns = [
/var dashboard = (\{.*?\});/s, // Original pattern
/var dashboard=(\{.*?\});/s, // No spaces
/var\s+dashboard\s*=\s*(\{.*?\});/s, // Flexible whitespace
/dashboard\s*=\s*(\{[\s\S]*?\});/ // More permissive
]
for (const regex of patterns) {
const match = regex.exec(scriptContent)
if (match && match[1]) {
try {
return JSON.parse(match[1])
} catch (e) {
// Try next pattern if JSON parsing fails
continue
}
}
}
return null
}, scriptContent)
if (!dashboardData) {
// Log a snippet of the script content for debugging
const scriptPreview = scriptContent.substring(0, 200)
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Script preview: ${scriptPreview}`, 'warn')
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Failed to capture diagnostics: ${e}`, 'warn')
})
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
throw new Error('Unable to parse dashboard script - check diagnostics')
}
return dashboardData
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Error fetching dashboard data: ${errorMessage}`, 'error')
throw new Error('Get dashboard data failed: ' + errorMessage)
}
}
/**
* Get search point counters
* @returns {Counters} Object of search counter data
*/
async getSearchPoints(): Promise<Counters> {
const dashboardData = await this.getDashboardData() // Always fetch newest data
return dashboardData.userStatus.counters
}
/**
* Get total earnable points with web browser
* @returns {number} Total earnable points
*/
async getBrowserEarnablePoints(): Promise<EarnablePoints> {
try {
let desktopSearchPoints = 0
let mobileSearchPoints = 0
let dailySetPoints = 0
let morePromotionsPoints = 0
const data = await this.getDashboardData()
// Desktop Search Points
if (data.userStatus.counters.pcSearch?.length) {
data.userStatus.counters.pcSearch.forEach(x => desktopSearchPoints += (x.pointProgressMax - x.pointProgress))
}
// Mobile Search Points
if (data.userStatus.counters.mobileSearch?.length) {
data.userStatus.counters.mobileSearch.forEach(x => mobileSearchPoints += (x.pointProgressMax - x.pointProgress))
}
// Daily Set
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => dailySetPoints += (x.pointProgressMax - x.pointProgress))
// More Promotions
if (data.morePromotions?.length) {
data.morePromotions.forEach(x => {
// Only count points from supported activities
if (['quiz', 'urlreward'].includes(x.promotionType) && x.exclusiveLockedFeatureStatus !== 'locked') {
morePromotionsPoints += (x.pointProgressMax - x.pointProgress)
}
})
}
const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints
return {
dailySetPoints,
morePromotionsPoints,
desktopSearchPoints,
mobileSearchPoints,
totalEarnablePoints
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-BROWSER-EARNABLE-POINTS', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Get browser earnable points failed: ' + errorMessage)
}
}
/**
* Get total earnable points with mobile app
* @returns {number} Total earnable points
*/
async getAppEarnablePoints(accessToken: string) {
try {
const points = {
readToEarn: 0,
checkIn: 0,
totalEarnablePoints: 0
}
const eligibleOffers = [
'ENUS_readarticle3_30points',
'Gamification_Sapphire_DailyCheckIn'
]
const data = await this.getDashboardData()
// Guard against missing profile/attributes and undefined settings
let geoLocale = data?.userProfile?.attributes?.country || 'US'
const useGeo = !!(this.bot?.config?.searchSettings?.useGeoLocaleQueries)
geoLocale = (useGeo && typeof geoLocale === 'string' && geoLocale.length === 2)
? geoLocale.toLowerCase()
: 'us'
const userDataRequest: AxiosRequestConfig = {
url: URLS.APP_USER_DATA,
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en'
}
}
const userDataResponse: AppUserData = (await this.bot.axios.request(userDataRequest)).data
const userData = userDataResponse.response
const eligibleActivities = userData.promotions.filter((x) => eligibleOffers.includes(x.attributes.offerid ?? ''))
for (const item of eligibleActivities) {
if (item.attributes.type === 'msnreadearn') {
points.readToEarn = parseInt(item.attributes.pointmax ?? '', 10) - parseInt(item.attributes.pointprogress ?? '', 10)
break
} else if (item.attributes.type === 'checkin') {
const checkInDay = parseInt(item.attributes.progress ?? '', 10) % 7
const today = new Date()
const lastUpdated = new Date(item.attributes.last_updated ?? '')
if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) {
points.checkIn = parseInt(item.attributes['day_' + (checkInDay + 1) + '_points'] ?? '', 10)
}
break
}
}
points.totalEarnablePoints = points.readToEarn + points.checkIn
return points
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-APP-EARNABLE-POINTS', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Get app earnable points failed: ' + errorMessage)
}
}
/**
* Get current point amount
* @returns {number} Current total point amount
*/
async getCurrentPoints(): Promise<number> {
try {
const data = await this.getDashboardData()
return data.userStatus.availablePoints
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-CURRENT-POINTS', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Get current points failed: ' + errorMessage)
}
}
/**
* Parse quiz data from provided page
* @param {Page} page Playwright page
* @returns {QuizData} Quiz data object
*/
async getQuizData(page: Page): Promise<QuizData> {
try {
// Wait for page to be fully loaded
await page.waitForLoadState('domcontentloaded')
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
const html = await page.content()
const $ = load(html)
// Try multiple possible variable names
const possibleVariables = [
'_w.rewardsQuizRenderInfo',
'rewardsQuizRenderInfo',
'_w.quizRenderInfo',
'quizRenderInfo'
]
let scriptContent = ''
let foundVariable = ''
for (const varName of possibleVariables) {
scriptContent = $('script')
.toArray()
.map(el => $(el).text())
.find(t => t.includes(varName)) || ''
if (scriptContent) {
foundVariable = varName
break
}
}
if (scriptContent && foundVariable) {
// Escape dots in variable name for regex
const escapedVar = foundVariable.replace(/\./g, '\\.')
const regex = new RegExp(`${escapedVar}\\s*=\\s*({.*?});`, 's')
const match = regex.exec(scriptContent)
if (match && match[1]) {
const quizData = JSON.parse(match[1])
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found quiz data using variable: ${foundVariable}`, 'log')
return quizData
} else {
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Variable ${foundVariable} found but could not extract JSON data`, 'error')
throw new Error(`Quiz data variable ${foundVariable} found but JSON extraction failed`)
}
} else {
// Log available scripts for debugging
const allScripts = $('script')
.toArray()
.map(el => $(el).text())
.filter(t => t.length > 0)
.map(t => t.substring(0, 100))
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error')
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn')
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
throw new Error('Script containing quiz data not found - check page structure')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Get quiz data failed: ' + errorMessage)
}
}
async waitForQuizRefresh(page: Page): Promise<boolean> {
try {
await page.waitForSelector(SELECTORS.QUIZ_CREDITS, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
return true
} catch (error) {
this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', 'An error occurred:' + error, 'error')
return false
}
}
async checkQuizCompleted(page: Page): Promise<boolean> {
try {
await page.waitForSelector(SELECTORS.QUIZ_COMPLETE, { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
return true
} catch (error) {
return false
}
}
async loadInCheerio(page: Page): Promise<CheerioAPI> {
const html = await page.content()
const $ = load(html)
return $
}
async getPunchCardActivity(page: Page, activity: PromotionalItem | MorePromotion): Promise<string> {
let selector = ''
try {
const html = await page.content()
const $ = load(html)
const element = $('.offer-cta').toArray().find((x: unknown) => {
const el = x as { attribs?: { href?: string } }
return !!el.attribs?.href?.includes(activity.offerId)
})
if (element) {
selector = `a[href*="${element.attribs.href}"]`
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
}
return selector
}
async closeBrowser(browser: BrowserContext, email: string) {
try {
// Save cookies
await saveSessionData(this.bot.config.sessionPath, browser, email, this.bot.isMobile)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
// Close browser
await browser.close()
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Close browser failed: ' + errorMessage)
}
}
}

233
src/browser/BrowserUtil.ts Normal file
View File

@@ -0,0 +1,233 @@
import { Page } from 'rebrowser-playwright'
import { load } from 'cheerio'
import { MicrosoftRewardsBot } from '../index'
import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics'
type DismissButton = { selector: string; label: string; isXPath?: boolean }
export default class BrowserUtil {
private bot: MicrosoftRewardsBot
private static readonly DISMISS_BUTTONS: readonly DismissButton[] = [
{ selector: '#acceptButton', label: 'AcceptButton' },
{ selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
{ selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
{ selector: '#iLandingViewAction', label: 'Landing Continue' },
{ selector: '#iShowSkip', label: 'Show Skip' },
{ selector: '#iNext', label: 'Next' },
{ selector: '#iLooksGood', label: 'LooksGood' },
{ selector: '#idSIButton9', label: 'PrimaryLoginButton' },
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
{ selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
{ selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
{ selector: '#bnp_close_link', label: 'Bing Cookie Close' },
{ selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
{ selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
]
private static readonly OVERLAY_SELECTORS = {
container: '#bnp_overlay_wrapper',
reject: '#bnp_btn_reject, button[aria-label*="Reject" i]',
accept: '#bnp_btn_accept'
} as const
private static readonly STREAK_DIALOG_SELECTORS = {
container: '[role="dialog"], div[role="alert"], div.ms-Dialog',
textFilter: /streak protection has run out/i,
closeButtons: 'button[aria-label*="close" i], button:has-text("Close"), button:has-text("Dismiss"), button:has-text("Got it"), button:has-text("OK"), button:has-text("Ok")'
} as const
private static readonly TERMS_UPDATE_SELECTORS = {
titleId: '#iTOUTitle',
titleText: /we're updating our terms/i,
nextButton: 'button[data-testid="primaryButton"]:has-text("Next"), button[type="submit"]:has-text("Next")'
} as const
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
async tryDismissAllMessages(page: Page): Promise<void> {
const maxRounds = 3
for (let round = 0; round < maxRounds; round++) {
const dismissCount = await this.dismissRound(page)
if (dismissCount === 0) break
}
}
private async dismissRound(page: Page): Promise<number> {
let count = 0
count += await this.dismissStandardButtons(page)
count += await this.dismissOverlayButtons(page)
count += await this.dismissStreakDialog(page)
count += await this.dismissTermsUpdateDialog(page)
return count
}
private async dismissStandardButtons(page: Page): Promise<number> {
let count = 0
for (const btn of BrowserUtil.DISMISS_BUTTONS) {
const dismissed = await this.tryClickButton(page, btn)
if (dismissed) {
count++
await page.waitForTimeout(150)
}
}
return count
}
private async tryClickButton(page: Page, btn: DismissButton): Promise<boolean> {
try {
const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
const visible = await loc.first().isVisible({ timeout: 200 }).catch(() => false)
if (!visible) return false
await loc.first().click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
return true
} catch {
return false
}
}
private async dismissOverlayButtons(page: Page): Promise<number> {
try {
const { container, reject, accept } = BrowserUtil.OVERLAY_SELECTORS
const overlay = page.locator(container)
const visible = await overlay.isVisible({ timeout: 200 }).catch(() => false)
if (!visible) return 0
const rejectBtn = overlay.locator(reject)
if (await rejectBtn.first().isVisible().catch(() => false)) {
await rejectBtn.first().click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
return 1
}
const acceptBtn = overlay.locator(accept)
if (await acceptBtn.first().isVisible().catch(() => false)) {
await acceptBtn.first().click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
return 1
}
return 0
} catch {
return 0
}
}
private async dismissStreakDialog(page: Page): Promise<number> {
try {
const { container, textFilter, closeButtons } = BrowserUtil.STREAK_DIALOG_SELECTORS
const dialog = page.locator(container).filter({ hasText: textFilter })
const visible = await dialog.first().isVisible({ timeout: 200 }).catch(() => false)
if (!visible) return 0
const closeBtn = dialog.locator(closeButtons).first()
if (await closeBtn.isVisible({ timeout: 200 }).catch(() => false)) {
await closeBtn.click({ timeout: 500 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Button')
return 1
}
await page.keyboard.press('Escape').catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Escape')
return 1
} catch {
return 0
}
}
private async dismissTermsUpdateDialog(page: Page): Promise<number> {
try {
const { titleId, titleText, nextButton } = BrowserUtil.TERMS_UPDATE_SELECTORS
// Check if terms update page is present
const titleById = page.locator(titleId)
const titleByText = page.locator('h1').filter({ hasText: titleText })
const hasTitle = await titleById.isVisible({ timeout: 200 }).catch(() => false) ||
await titleByText.first().isVisible({ timeout: 200 }).catch(() => false)
if (!hasTitle) return 0
// Click the Next button
const nextBtn = page.locator(nextButton).first()
if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await nextBtn.click({ timeout: 1000 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)')
// Wait a bit for navigation
await page.waitForTimeout(1000)
return 1
}
return 0
} catch {
return 0
}
}
async getLatestTab(page: Page): Promise<Page> {
try {
await this.bot.utils.wait(1000)
const browser = page.context()
const pages = browser.pages()
const newTab = pages[pages.length - 1]
if (newTab) {
return newTab
}
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Unable to get latest tab', 'error')
throw new Error('Unable to get latest tab - no pages found in browser context')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Get new tab failed: ' + errorMessage)
}
}
async reloadBadPage(page: Page): Promise<void> {
try {
const html = await page.content().catch(() => '')
const $ = load(html)
const isNetworkError = $('body.neterror').length
if (isNetworkError) {
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
await page.reload()
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Reload bad page failed: ' + errorMessage)
}
}
/**
* Perform small human-like gestures: short waits, minor mouse moves and occasional scrolls.
* This should be called sparingly between actions to avoid a fixed cadence.
*/
async humanizePage(page: Page): Promise<void> {
try {
await this.bot.humanizer.microGestures(page)
await this.bot.humanizer.actionPause()
} catch { /* swallow */ }
}
/**
* Capture minimal diagnostics for a page: screenshot + HTML content.
* Files are written under ./reports/<date>/ with a safe label.
*/
async captureDiagnostics(page: Page, label: string): Promise<void> {
await captureSharedDiagnostics(this.bot, page, label)
}
}

337
src/config.jsonc Normal file
View File

@@ -0,0 +1,337 @@
{
// ============================================================
// 🌐 GENERAL CONFIGURATION
// ============================================================
// Base URL for Microsoft Rewards dashboard (do not change unless necessary)
"baseURL": "https://rewards.bing.com",
// Directory to store sessions (cookies, browser fingerprints)
"sessionPath": "sessions",
// Dry-run mode: simulate execution without actually running tasks (useful for testing)
"dryRun": false,
// ============================================================
// 🖥️ BROWSER CONFIGURATION
// ============================================================
"browser": {
// false = visible window | true = headless mode (invisible)
"headless": false,
// Max timeout for operations (supports: 30000, "30s", "2min")
"globalTimeout": "30s"
},
"fingerprinting": {
// Persist browser fingerprints to improve consistency across runs
"saveFingerprint": {
"mobile": true,
"desktop": true
}
},
// ============================================================
// ⚙️ EXECUTION & PERFORMANCE
// ============================================================
"execution": {
// true = Desktop + Mobile in parallel (faster, more resources)
// false = Sequential (slower, fewer resources)
"parallel": false,
// If false, skip execution when 0 points are available
"runOnZeroPoints": false,
// Number of account clusters (processes) to run concurrently
"clusters": 1,
// How many times to run through all accounts in sequence (1 = process each account once, 2 = twice, etc.)
// Higher values can catch missed tasks but increase detection risk
"passesPerRun": 3
},
"schedule": {
// Built-in scheduler (no cron needed in containers)
"enabled": false,
// Time format options:
// - US style with AM/PM → useAmPm: true and time12 (e.g., "9:00 AM")
// - 24-hour style → useAmPm: false and time24 (e.g., "09:00")
"useAmPm": false,
"time12": "9:00 AM",
"time24": "09:00",
// IANA timezone (e.g., "Europe/Paris", "America/New_York" check schedule.md)
"timeZone": "Europe/Paris",
// If true, run immediately on process start
"runImmediatelyOnStart": true
},
"jobState": {
// Save state to avoid duplicate work across restarts
"enabled": true,
// Custom state directory (empty = defaults to sessionPath/job-state)
"dir": ""
},
// ============================================================
// 🎯 TASKS & WORKERS
// ============================================================
"workers": {
// Select which tasks the bot should complete on desktop/mobile
"doDailySet": true, // Daily set tasks
"doMorePromotions": true, // More promotions section
"doPunchCards": true, // Punch cards
"doDesktopSearch": true, // Desktop searches
"doMobileSearch": true, // Mobile searches
"doDailyCheckIn": true, // Daily check-in
"doReadToEarn": true, // Read to earn
// If true, run desktop searches right after Daily Set
"bundleDailySetWithSearch": true
},
// ============================================================
// 🔍 SEARCH CONFIGURATION
// ============================================================
"search": {
// Use locale-specific query sources
"useLocalQueries": true,
"settings": {
// Use region-specific queries (at, fr, us, etc.)
"useGeoLocaleQueries": true,
// Randomly scroll search result pages (more natural behavior)
"scrollRandomResults": true,
// Occasionally click a result (safe targets only)
"clickRandomResults": true,
// Number of retries if mobile searches don't progress
"retryMobileSearchAmount": 2,
// Delay between searches
"delay": {
"min": "40sec",
"max": "1min"
}
}
},
"queryDiversity": {
// Multi-source query generation: Reddit, News, Wikipedia instead of only Google Trends
"enabled": true,
// Available sources: google-trends, reddit, news, wikipedia, local-fallback
"sources": ["google-trends", "reddit", "local-fallback"],
// Max queries to fetch per source
"maxQueriesPerSource": 10,
// Cache duration in minutes (avoids hammering APIs)
"cacheMinutes": 30
},
// ============================================================
// 🤖 HUMANIZATION & NATURAL BEHAVIOR
// ============================================================
"humanization": {
// Human Mode: adds subtle micro-gestures & pauses to mimic real users
"enabled": true,
// If a ban is detected on any account, stop processing remaining accounts
"stopOnBan": true,
// Immediately send an alert (webhook/NTFY) when a ban is detected
"immediateBanAlert": true,
// Extra random pause between actions
"actionDelay": {
"min": 500, // 0.5 seconds minimum
"max": 2200 // 2.2 seconds maximum
},
// Probability (0-1) to move mouse slightly between actions
"gestureMoveProb": 0.65,
// Probability (0-1) to perform a small scroll
"gestureScrollProb": 0.4,
// Optional execution time windows (e.g., ["08:30-11:00", "19:00-22:00"])
// If specified, waits until inside a window before starting
"allowedWindows": []
},
"vacation": {
// Monthly "vacation" block: skip a random range of days each month
// Each month, a random period between minDays and maxDays is selected
// and all runs within that date range are skipped (more human-like behavior)
"enabled": true,
"minDays": 2,
"maxDays": 4
},
// ============================================================
// 🛡️ RISK MANAGEMENT & SECURITY
// ============================================================
"riskManagement": {
// Dynamic delay adjustment based on detected risk signals
"enabled": true,
// Automatically increase delays when captchas/errors are detected
"autoAdjustDelays": true,
// Stop execution if risk level reaches critical threshold
"stopOnCritical": false,
// Enable ML-based ban prediction based on patterns
"banPrediction": true,
// Risk threshold (0-100). If exceeded, bot pauses or alerts you
"riskThreshold": 75
},
"retryPolicy": {
// Generic retry/backoff for transient failures
"maxAttempts": 3,
"baseDelay": 1000,
"maxDelay": "30s",
"multiplier": 2,
"jitter": 0.2
},
// ============================================================
// 🌐 PROXY
// ============================================================
"proxy": {
// Control which outbound calls go through your proxy
"proxyGoogleTrends": true,
"proxyBingTerms": true
},
// ============================================================
// 🔔 NOTIFICATIONS
// ============================================================
// Live logs webhook (Discord or similar). URL = your webhook endpoint
"webhook": {
"enabled": false,
"url": ""
},
// Rich end-of-run summary webhook (Discord or similar)
"conclusionWebhook": {
"enabled": false,
"url": ""
},
// NTFY push notifications (plain text)
"ntfy": {
"enabled": false,
"url": "",
"topic": "rewards",
"authToken": ""
},
// ============================================================
// 📊 LOGGING & DIAGNOSTICS
// ============================================================
"logging": {
// Logging controls (see docs/config.md)
// Filter out noisy log buckets locally and for webhook summaries
"excludeFunc": [
"SEARCH-CLOSE-TABS",
"LOGIN-NO-PROMPT",
"FLOW"
],
"webhookExcludeFunc": [
"SEARCH-CLOSE-TABS",
"LOGIN-NO-PROMPT",
"FLOW"
],
// Email redaction toggle (true = secure, false = full emails)
"redactEmails": true
},
"diagnostics": {
// Capture minimal evidence on failures (screenshots/HTML)
"enabled": true,
"saveScreenshot": true,
"saveHtml": true,
"maxPerRun": 2,
"retentionDays": 7
},
"analytics": {
// 📈 Performance Dashboard: tracks points earned, success rates, execution times
// Useful for monitoring your stats over time. Disable if you don't need it.
// WHAT IT DOES:
// - Collects daily/weekly/monthly statistics
// - Calculates success rates for each activity type
// - Tracks average execution times
// - Generates trend reports
// - Can export to Markdown or send via webhook
"enabled": true,
// How long to keep analytics data (days)
"retentionDays": 30,
// Generate markdown summary reports
"exportMarkdown": true,
// Send analytics summary via webhook
"webhookSummary": true
},
// ============================================================
// 🛒 BUY MODE
// ============================================================
"buyMode": {
// Manual purchase/redeem mode. Use CLI -buy to enable
// Session duration cap in minutes
"maxMinutes": 45
},
// ============================================================
// 🔄 UPDATES
// ============================================================
"update": {
// Post-run auto-update settings
"git": true,
"docker": false,
// Custom updater script path (relative to repo root)
"scriptPath": "setup/update/update.mjs",
// ⚠️ SMART UPDATE CONTROL - How It Really Works:
//
// BACKUP: Your files are ALWAYS backed up to .update-backup/ before any update
//
// UPDATE PROCESS:
// 1. Script checks if remote modified config.jsonc or accounts.json
// 2. Runs "git pull --rebase" to merge remote changes
// 3. Git intelligently merges:
// ✅ NEW FIELDS ADDED (new config options, new account properties)
// → Your existing values are PRESERVED, new fields are added alongside
// → This is 95% of updates - works perfectly without conflicts
//
// ⚠️ MAJOR RESTRUCTURING (fields renamed, sections reordered, format changed)
// → Git may choose one version over the other
// → Risk of losing your custom values in restructured sections
//
// WHAT THE OPTIONS DO:
// - true: ACCEPT git merge result (keeps new features + your settings in most cases)
// - false: REJECT remote changes, RESTORE your local file from backup (stay on old version)
//
// RECOMMENDED: Keep both TRUE
// Why? Because we rarely restructure files. Most updates just ADD new optional fields.
// Your passwords, emails, and custom settings survive addition-only updates.
// Only risk: major file restructuring (rare, usually announced in release notes).
//
// SAFETY NET: Check .update-backup/ folder after updates to compare if worried.
// Apply remote updates to config.jsonc via git merge
// true = accept new features + intelligent merge (RECOMMENDED for most users)
// false = always keep your local version (miss new config options)
"autoUpdateConfig": true,
// Apply remote updates to accounts.json via git merge
// true = accept new fields (like "region", "totpSecret") while keeping credentials (RECOMMENDED)
// false = always keep your local accounts file (safest but may miss new optional fields)
"autoUpdateAccounts": false
}
}

89
src/constants.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Central constants file for the Microsoft Rewards Script
* Defines timeouts, retry limits, and other magic numbers used throughout the application
*/
/**
* Safe environment variable parsing with validation
*/
function parseEnvNumber(key: string, defaultValue: number, min: number, max: number): number {
const raw = process.env[key]
if (!raw) return defaultValue
const parsed = Number(raw)
if (isNaN(parsed)) {
console.warn(`[Constants] Invalid ${key}="${raw}". Using default ${defaultValue}`)
return defaultValue
}
if (parsed < min || parsed > max) {
console.warn(`[Constants] ${key}=${parsed} out of range [${min}, ${max}]. Using default ${defaultValue}`)
return defaultValue
}
return parsed
}
export const TIMEOUTS = {
SHORT: 500,
MEDIUM: 1500,
MEDIUM_LONG: 2000,
LONG: 3000,
VERY_LONG: 5000,
EXTRA_LONG: 10000,
DASHBOARD_WAIT: 10000,
LOGIN_MAX: parseEnvNumber('LOGIN_MAX_WAIT_MS', 180000, 30000, 600000),
NETWORK_IDLE: 5000
} as const
export const RETRY_LIMITS = {
MAX_ITERATIONS: 5,
DASHBOARD_RELOAD: 2,
MOBILE_SEARCH: 3,
ABC_MAX: 15,
POLL_MAX: 15,
QUIZ_MAX: 15,
QUIZ_ANSWER_TIMEOUT: 10000,
GO_HOME_MAX: 5
} as const
export const DELAYS = {
ACTION_MIN: 1000,
ACTION_MAX: 3000,
SEARCH_DEFAULT_MIN: 2000,
SEARCH_DEFAULT_MAX: 5000,
BROWSER_CLOSE: 2000,
TYPING_DELAY: 20,
SEARCH_ON_BING_WAIT: 5000,
SEARCH_ON_BING_COMPLETE: 3000,
SEARCH_ON_BING_FOCUS: 200,
SEARCH_BAR_TIMEOUT: 15000,
QUIZ_ANSWER_WAIT: 2000,
THIS_OR_THAT_START: 2000
} as const
export const SELECTORS = {
MORE_ACTIVITIES: '#more-activities',
SUSPENDED_ACCOUNT: '#suspendedAccountHeader',
QUIZ_COMPLETE: '#quizCompleteContainer',
QUIZ_CREDITS: 'span.rqMCredits'
} as const
export const URLS = {
REWARDS_BASE: 'https://rewards.bing.com',
REWARDS_SIGNIN: 'https://rewards.bing.com/signin',
APP_USER_DATA: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613'
} as const
export const DISCORD = {
MAX_EMBED_LENGTH: 1900,
RATE_LIMIT_DELAY: 500,
WEBHOOK_TIMEOUT: 10000,
DEBOUNCE_DELAY: 750,
COLOR_RED: 0xFF0000,
COLOR_CRIMSON: 0xDC143C,
COLOR_ORANGE: 0xFFA500,
COLOR_BLUE: 0x3498DB,
COLOR_GREEN: 0x00D26A,
AVATAR_URL: 'https://media.discordapp.net/attachments/1430643658788438144/1430644205344133290/rewi-v1.png?ex=68fbd83e&is=68fa86be&hm=ccddee9430de1fff90c1c3750907c13a60d1da29f13617a5dbbdc642f243f5b9&=&format=png&quality=lossless&width=968&height=968'
} as const

164
src/functions/Activities.ts Normal file
View File

@@ -0,0 +1,164 @@
import { Page } from 'rebrowser-playwright'
import { MicrosoftRewardsBot } from '../index'
import { Search } from './activities/Search'
import { ABC } from './activities/ABC'
import { Poll } from './activities/Poll'
import { Quiz } from './activities/Quiz'
import { ThisOrThat } from './activities/ThisOrThat'
import { UrlReward } from './activities/UrlReward'
import { SearchOnBing } from './activities/SearchOnBing'
import { ReadToEarn } from './activities/ReadToEarn'
import { DailyCheckIn } from './activities/DailyCheckIn'
import { DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
import type { ActivityHandler } from '../interface/ActivityHandler'
type ActivityKind =
| { type: 'poll' }
| { type: 'abc' }
| { type: 'thisOrThat' }
| { type: 'quiz' }
| { type: 'urlReward' }
| { type: 'searchOnBing' }
| { type: 'unsupported' }
export default class Activities {
private bot: MicrosoftRewardsBot
private handlers: ActivityHandler[] = []
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
// Register external/custom handlers (optional extension point)
registerHandler(handler: ActivityHandler) {
this.handlers.push(handler)
}
// Centralized dispatcher for activities from dashboard/punchcards
async run(page: Page, activity: MorePromotion | PromotionalItem): Promise<void> {
// First, try custom handlers (if any)
for (const h of this.handlers) {
try {
if (h.canHandle(activity)) {
await h.run(page, activity)
return
}
} catch (e) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Custom handler ${(h.id || 'unknown')} failed: ${e instanceof Error ? e.message : e}`, 'error')
}
}
const kind = this.classifyActivity(activity)
try {
switch (kind.type) {
case 'poll':
await this.doPoll(page)
break
case 'abc':
await this.doABC(page)
break
case 'thisOrThat':
await this.doThisOrThat(page)
break
case 'quiz':
await this.doQuiz(page)
break
case 'searchOnBing':
await this.doSearchOnBing(page, activity)
break
case 'urlReward':
await this.doUrlReward(page)
break
default:
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${String((activity as { promotionType?: string }).promotionType)}"!`, 'warn')
break
}
} catch (e) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Dispatcher error for "${activity.title}": ${e instanceof Error ? e.message : e}`, 'error')
}
}
public getTypeLabel(activity: MorePromotion | PromotionalItem): string {
const k = this.classifyActivity(activity)
switch (k.type) {
case 'poll': return 'Poll'
case 'abc': return 'ABC'
case 'thisOrThat': return 'ThisOrThat'
case 'quiz': return 'Quiz'
case 'searchOnBing': return 'SearchOnBing'
case 'urlReward': return 'UrlReward'
default: return 'Unsupported'
}
}
private classifyActivity(activity: MorePromotion | PromotionalItem): ActivityKind {
const type = (activity.promotionType || '').toLowerCase()
if (type === 'quiz') {
// Distinguish Poll/ABC/ThisOrThat vs general quiz using current heuristics
const max = activity.pointProgressMax
const url = (activity.destinationUrl || '').toLowerCase()
if (max === 10) {
if (url.includes('pollscenarioid')) return { type: 'poll' }
return { type: 'abc' }
}
if (max === 50) return { type: 'thisOrThat' }
return { type: 'quiz' }
}
if (type === 'urlreward') {
const name = (activity.name || '').toLowerCase()
if (name.includes('exploreonbing')) return { type: 'searchOnBing' }
return { type: 'urlReward' }
}
return { type: 'unsupported' }
}
doSearch = async (page: Page, data: DashboardData): Promise<void> => {
const search = new Search(this.bot)
await search.doSearch(page, data)
}
doABC = async (page: Page): Promise<void> => {
const abc = new ABC(this.bot)
await abc.doABC(page)
}
doPoll = async (page: Page): Promise<void> => {
const poll = new Poll(this.bot)
await poll.doPoll(page)
}
doThisOrThat = async (page: Page): Promise<void> => {
const thisOrThat = new ThisOrThat(this.bot)
await thisOrThat.doThisOrThat(page)
}
doQuiz = async (page: Page): Promise<void> => {
const quiz = new Quiz(this.bot)
await quiz.doQuiz(page)
}
doUrlReward = async (page: Page): Promise<void> => {
const urlReward = new UrlReward(this.bot)
await urlReward.doUrlReward(page)
}
doSearchOnBing = async (page: Page, activity: MorePromotion | PromotionalItem): Promise<void> => {
const searchOnBing = new SearchOnBing(this.bot)
await searchOnBing.doSearchOnBing(page, activity)
}
doReadToEarn = async (accessToken: string, data: DashboardData): Promise<void> => {
const readToEarn = new ReadToEarn(this.bot)
await readToEarn.doReadToEarn(accessToken, data)
}
doDailyCheckIn = async (accessToken: string, data: DashboardData): Promise<void> => {
const dailyCheckIn = new DailyCheckIn(this.bot)
await dailyCheckIn.doDailyCheckIn(accessToken, data)
}
}

1303
src/functions/Login.ts Normal file

File diff suppressed because it is too large Load Diff

248
src/functions/Workers.ts Normal file
View File

@@ -0,0 +1,248 @@
import { Page } from 'rebrowser-playwright'
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
import { MicrosoftRewardsBot } from '../index'
import JobState from '../util/JobState'
import Retry from '../util/Retry'
import { AdaptiveThrottler } from '../util/AdaptiveThrottler'
export class Workers {
public bot: MicrosoftRewardsBot
private jobState: JobState
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
this.jobState = new JobState(this.bot.config)
}
// Daily Set
async doDailySet(page: Page, data: DashboardData) {
const todayData = data.dailySetPromotions[this.bot.utils.getFormattedDate()]
const today = this.bot.utils.getFormattedDate()
const activitiesUncompleted = (todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [])
.filter(x => {
if (this.bot.config.jobState?.enabled === false) return true
const email = this.bot.currentAccountEmail || 'unknown'
return !this.jobState.isDone(email, today, x.offerId)
})
if (!activitiesUncompleted.length) {
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All Daily Set" items have already been completed')
return
}
// Solve Activities
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items')
await this.solveActivities(page, activitiesUncompleted)
// Mark as done to prevent duplicate work if checkpoints enabled
if (this.bot.config.jobState?.enabled !== false) {
const email = this.bot.currentAccountEmail || 'unknown'
for (const a of activitiesUncompleted) {
this.jobState.markDone(email, today, a.offerId)
}
}
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
// Optional: immediately run desktop search bundle
if (!this.bot.isMobile && this.bot.config.workers.bundleDailySetWithSearch && this.bot.config.workers.doDesktopSearch) {
try {
await this.bot.utils.waitRandom(1200, 2600)
await this.bot.activities.doSearch(page, data)
} catch (e) {
this.bot.log(this.bot.isMobile, 'DAILY-SET', `Post-DailySet search failed: ${e instanceof Error ? e.message : e}`, 'warn')
}
}
}
// Punch Card
async doPunchCard(page: Page, data: DashboardData) {
const punchCardsUncompleted = data.punchCards?.filter(x => x.parentPromotion && !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
if (!punchCardsUncompleted.length) {
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Cards" have already been completed')
return
}
for (const punchCard of punchCardsUncompleted) {
// Ensure parentPromotion exists before proceeding
if (!punchCard.parentPromotion?.title) {
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Skipped punchcard "${punchCard.name}" | Reason: Parent promotion is missing!`, 'warn')
continue
}
// Get latest page for each card
page = await this.bot.browser.utils.getLatestTab(page)
const activitiesUncompleted = punchCard.childPromotions.filter(x => !x.complete) // Only return uncompleted activities
// Solve Activities
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
// Got to punch card index page in a new tab
await page.goto(punchCard.parentPromotion.destinationUrl, { referer: this.bot.config.baseURL })
// Wait for new page to load, max 10 seconds, however try regardless in case of error
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { })
await this.solveActivities(page, activitiesUncompleted, punchCard)
page = await this.bot.browser.utils.getLatestTab(page)
const pages = page.context().pages()
if (pages.length > 3) {
await page.close()
} else {
await this.bot.browser.func.goHome(page)
}
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
}
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Card" items have been completed')
}
// More Promotions
async doMorePromotions(page: Page, data: DashboardData) {
const morePromotions = data.morePromotions
// Check if there is a promotional item
if (data.promotionalItem) { // Convert and add the promotional item to the array
morePromotions.push(data.promotionalItem as unknown as MorePromotion)
}
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0 && x.exclusiveLockedFeatureStatus !== 'locked') ?? []
if (!activitiesUncompleted.length) {
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
return
}
// Solve Activities
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'Started solving "More Promotions" items')
page = await this.bot.browser.utils.getLatestTab(page)
await this.solveActivities(page, activitiesUncompleted)
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
}
// Solve all the different types of activities
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
const activityInitial = activityPage.url()
const retry = new Retry(this.bot.config.retryPolicy)
const throttle = new AdaptiveThrottler()
for (const activity of activities) {
try {
activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
await this.applyThrottle(throttle, 800, 1400)
const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
await this.prepareActivityPage(activityPage, selector, throttle)
const typeLabel = this.bot.activities.getTypeLabel(activity)
if (typeLabel !== 'Unsupported') {
await this.executeActivity(activityPage, activity, selector, throttle, retry)
} else {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
}
await this.applyThrottle(throttle, 1200, 2600)
} catch (error) {
await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_error_${activity.title || activity.offerId}`)
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
throttle.record(false)
}
}
}
private async manageTabLifecycle(page: Page, initialUrl: string): Promise<Page> {
page = await this.bot.browser.utils.getLatestTab(page)
const pages = page.context().pages()
if (pages.length > 3) {
await page.close()
page = await this.bot.browser.utils.getLatestTab(page)
}
if (page.url() !== initialUrl) {
await page.goto(initialUrl)
}
return page
}
private async buildActivitySelector(page: Page, activity: PromotionalItem | MorePromotion, punchCard?: PunchCard): Promise<string> {
if (punchCard) {
return await this.bot.browser.func.getPunchCardActivity(page, activity)
}
const name = activity.name.toLowerCase()
if (name.includes('membercenter') || name.includes('exploreonbing')) {
return `[data-bi-id^="${activity.name}"] .pointLink:not(.contentContainer .pointLink)`
}
return `[data-bi-id^="${activity.offerId}"] .pointLink:not(.contentContainer .pointLink)`
}
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
await this.bot.browser.utils.humanizePage(page)
await this.applyThrottle(throttle, 1200, 2600)
}
private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`)
await page.click(selector)
page = await this.bot.browser.utils.getLatestTab(page)
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
const runWithTimeout = (p: Promise<void>) => Promise.race([
p,
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
])
await retry.run(async () => {
try {
await runWithTimeout(this.bot.activities.run(page, activity))
throttle.record(true)
} catch (e) {
await this.bot.browser.utils.captureDiagnostics(page, `activity_timeout_${activity.title || activity.offerId}`)
throttle.record(false)
throw e
}
}, () => true)
await this.bot.browser.utils.humanizePage(page)
}
private async applyThrottle(throttle: AdaptiveThrottler, min: number, max: number): Promise<void> {
const multiplier = throttle.getDelayMultiplier()
const riskMultiplier = this.bot.getRiskDelayMultiplier()
await this.bot.utils.waitRandom(
Math.floor(min * multiplier * riskMultiplier),
Math.floor(max * multiplier * riskMultiplier)
)
}
}

View File

@@ -0,0 +1,51 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { RETRY_LIMITS, TIMEOUTS } from '../../constants'
export class ABC extends Workers {
async doABC(page: Page) {
this.bot.log(this.bot.isMobile, 'ABC', 'Trying to complete poll')
try {
let $ = await this.bot.browser.func.loadInCheerio(page)
let i
for (i = 0; i < RETRY_LIMITS.ABC_MAX && !$('span.rw_icon').length; i++) {
await page.waitForSelector('.wk_OptionClickClass', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
const answers = $('.wk_OptionClickClass')
const answer = answers[this.bot.utils.randomNumber(0, 2)]?.attribs['id']
await page.waitForSelector(`#${answer}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.click(`#${answer}`) // Click answer
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.waitForSelector('div.wk_button', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await page.click('div.wk_button') // Click next question button
page = await this.bot.browser.utils.getLatestTab(page)
$ = await this.bot.browser.func.loadInCheerio(page)
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
}
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.close()
if (i === RETRY_LIMITS.ABC_MAX) {
this.bot.log(this.bot.isMobile, 'ABC', `Failed to solve quiz, exceeded max iterations of ${RETRY_LIMITS.ABC_MAX}`, 'warn')
} else {
this.bot.log(this.bot.isMobile, 'ABC', 'Completed the ABC successfully')
}
} catch (error) {
await page.close()
this.bot.log(this.bot.isMobile, 'ABC', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -0,0 +1,48 @@
import { randomBytes } from 'crypto'
import { AxiosRequestConfig } from 'axios'
import { Workers } from '../Workers'
import { DashboardData } from '../../interface/DashboardData'
export class DailyCheckIn extends Workers {
public async doDailyCheckIn(accessToken: string, data: DashboardData) {
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', 'Starting Daily Check In')
try {
let geoLocale = data.userProfile.attributes.country
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
const jsonData = {
amount: 1,
country: geoLocale,
id: randomBytes(64).toString('hex'),
type: 101,
attributes: {
offerid: 'Gamification_Sapphire_DailyCheckIn'
}
}
const claimRequest: AxiosRequestConfig = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en'
},
data: JSON.stringify(jsonData)
}
const claimResponse = await this.bot.axios.request(claimRequest)
const claimedPoint = parseInt((await claimResponse.data).response?.activity?.p, 10) ?? 0
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', claimedPoint > 0 ? `Claimed ${claimedPoint} points` : 'Already claimed today')
} catch (error) {
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -0,0 +1,32 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { TIMEOUTS } from '../../constants'
export class Poll extends Workers {
async doPoll(page: Page) {
this.bot.log(this.bot.isMobile, 'POLL', 'Trying to complete poll')
try {
const buttonId = `#btoption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
await page.waitForSelector(buttonId, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch((e) => {
this.bot.log(this.bot.isMobile, 'POLL', `Could not find poll button: ${e}`, 'warn')
})
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.click(buttonId)
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.close()
this.bot.log(this.bot.isMobile, 'POLL', 'Completed the poll successfully')
} catch (error) {
await page.close()
this.bot.log(this.bot.isMobile, 'POLL', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -0,0 +1,132 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { RETRY_LIMITS, TIMEOUTS, DELAYS } from '../../constants'
export class Quiz extends Workers {
async doQuiz(page: Page) {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Trying to complete quiz')
try {
// Check if the quiz has been started or not
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG }).then(() => true).catch(() => false)
if (quizNotStarted) {
await page.click('#rqStartQuiz')
} else {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz has already been started, trying to finish it')
}
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
let quizData = await this.bot.browser.func.getQuizData(page)
// Verify quiz is actually loaded before proceeding
const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: TIMEOUTS.VERY_LONG }).then(() => true).catch(() => false)
if (!firstOptionExists) {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz options not found - page may not have loaded correctly. Skipping.', 'warn')
await page.close()
return
}
const questionsRemaining = quizData.maxQuestions - quizData.CorrectlyAnsweredQuestionCount // Amount of questions remaining
// All questions
for (let question = 0; question < questionsRemaining; question++) {
if (quizData.numberOfOptions === 8) {
const answers: string[] = []
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => null)
if (!answerSelector) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found - quiz structure may have changed. Skipping remaining options.`, 'warn')
break
}
const answerAttribute = await answerSelector?.evaluate((el: Element) => el.getAttribute('iscorrectoption'))
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
answers.push(`#rqAnswerOption${i}`)
}
}
// If no correct answers found, skip this question
if (answers.length === 0) {
this.bot.log(this.bot.isMobile, 'QUIZ', 'No correct answers found for 8-option quiz. Skipping.', 'warn')
await page.close()
return
}
// Click the answers
for (const answer of answers) {
await page.waitForSelector(answer, { state: 'visible', timeout: DELAYS.QUIZ_ANSWER_WAIT })
// Click the answer on page
await page.click(answer)
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
if (!refreshSuccess) {
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
}
// Other type quiz, lightspeed
} else if ([2, 3, 4].includes(quizData.numberOfOptions)) {
quizData = await this.bot.browser.func.getQuizData(page) // Refresh Quiz Data
const correctOption = quizData.correctAnswer
let answerClicked = false
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: RETRY_LIMITS.QUIZ_ANSWER_TIMEOUT }).catch(() => null)
if (!answerSelector) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
continue
}
const dataOption = await answerSelector?.evaluate((el: Element) => el.getAttribute('data-option'))
if (dataOption === correctOption) {
// Click the answer on page
await page.click(`#rqAnswerOption${i}`)
answerClicked = true
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
if (!refreshSuccess) {
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
break
}
}
if (!answerClicked) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Could not find correct answer for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
await page.close()
return
}
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
}
}
// Done with
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'Completed the quiz successfully')
} catch (error) {
await this.bot.browser.utils.captureDiagnostics(page, 'quiz_error')
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -0,0 +1,73 @@
import { randomBytes } from 'crypto'
import { AxiosRequestConfig } from 'axios'
import { Workers } from '../Workers'
import { DashboardData } from '../../interface/DashboardData'
export class ReadToEarn extends Workers {
public async doReadToEarn(accessToken: string, data: DashboardData) {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Starting Read to Earn')
try {
let geoLocale = data.userProfile.attributes.country
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
const userDataRequest: AxiosRequestConfig = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me',
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en'
}
}
const userDataResponse = await this.bot.axios.request(userDataRequest)
const userData = (await userDataResponse.data).response
let userBalance = userData.balance
const jsonData = {
amount: 1,
country: geoLocale,
id: '1',
type: 101,
attributes: {
offerid: 'ENUS_readarticle3_30points'
}
}
const articleCount = 10
for (let i = 0; i < articleCount; ++i) {
jsonData.id = randomBytes(64).toString('hex')
const claimRequest = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-Rewards-Country': geoLocale,
'X-Rewards-Language': 'en'
},
data: JSON.stringify(jsonData)
}
const claimResponse = await this.bot.axios.request(claimRequest)
const newBalance = (await claimResponse.data).response.balance
if (newBalance == userBalance) {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Read all available articles')
break
} else {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', `Read article ${i + 1} of ${articleCount} max | Gained ${newBalance - userBalance} Points`)
userBalance = newBalance
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min), this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max))))
}
}
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Completed Read to Earn')
} catch (error) {
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -0,0 +1,458 @@
import { Page } from 'rebrowser-playwright'
import { platform } from 'os'
import { Workers } from '../Workers'
import { Counters, DashboardData } from '../../interface/DashboardData'
import { GoogleSearch } from '../../interface/Search'
import { AxiosRequestConfig } from 'axios'
type GoogleTrendsResponse = [
string,
[
string,
...null[],
[string, ...string[]]
][]
];
export class Search extends Workers {
private bingHome = 'https://bing.com'
private searchPageURL = ''
public async doSearch(page: Page, data: DashboardData) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Starting Bing searches')
page = await this.bot.browser.utils.getLatestTab(page)
let searchCounters: Counters = await this.bot.browser.func.getSearchPoints()
let missingPoints = this.calculatePoints(searchCounters)
if (missingPoints === 0) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Bing searches have already been completed')
return
}
// Generate search queries (primary: Google Trends)
const geo = this.bot.config.searchSettings.useGeoLocaleQueries ? data.userProfile.attributes.country : 'US'
let googleSearchQueries = await this.getGoogleTrends(geo)
// Fallback: if trends failed or insufficient, sample from local queries file
if (!googleSearchQueries.length || googleSearchQueries.length < 10) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Primary trends source insufficient, falling back to local queries.json', 'warn')
try {
const local = await import('../queries.json')
// Flatten & sample
const sampleSize = Math.max(5, Math.min(this.bot.config.searchSettings.localFallbackCount || 25, local.default.length))
const sampled = this.bot.utils.shuffleArray(local.default).slice(0, sampleSize)
googleSearchQueries = sampled.map((x: { title: string; queries: string[] }) => ({ topic: x.queries[0] || x.title, related: x.queries.slice(1) }))
} catch (e) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed loading local queries fallback: ' + (e instanceof Error ? e.message : e), 'error')
}
}
if (this.bot.config.queryDiversity?.enabled && this.bot.queryEngine) {
try {
const targetCount = Math.max(20, missingPoints * 2)
const extraTerms = await this.bot.queryEngine.fetchQueries(targetCount)
if (extraTerms.length) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Query diversity enabled — adding ${extraTerms.length} mixed-source terms`)
googleSearchQueries.push(...extraTerms.map(term => ({ topic: term, related: [] })))
}
} catch (err) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Query diversity error: ${err instanceof Error ? err.message : err}`, 'warn')
}
}
googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
// Deduplicate topics
const seen = new Set<string>()
googleSearchQueries = googleSearchQueries.filter(q => {
if (seen.has(q.topic.toLowerCase())) return false
seen.add(q.topic.toLowerCase())
return true
})
// Go to bing
await page.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
await this.bot.utils.wait(2000)
await this.bot.browser.utils.tryDismissAllMessages(page)
let stagnation = 0 // consecutive searches without point progress
const queries: string[] = []
// Mobile search doesn't seem to like related queries?
googleSearchQueries.forEach(x => { this.bot.isMobile ? queries.push(x.topic) : queries.push(x.topic, ...x.related) })
// Loop over Google search queries
for (let i = 0; i < queries.length; i++) {
const query = queries[i] as string
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query}`)
searchCounters = await this.bingSearch(page, query)
const newMissingPoints = this.calculatePoints(searchCounters)
// If the new point amount is the same as before
if (newMissingPoints === missingPoints) {
stagnation++
} else {
stagnation = 0
}
missingPoints = newMissingPoints
if (missingPoints === 0) break
// Only for mobile searches
if (stagnation > 5 && this.bot.isMobile) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 5 iterations, likely bad User-Agent', 'warn')
break
}
// If we didn't gain points for 10 iterations, assume it's stuck
if (stagnation > 10) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn')
stagnation = 0 // allow fallback loop below
break
}
}
// Only for mobile searches
if (missingPoints > 0 && this.bot.isMobile) {
return
}
// If we still got remaining search queries, generate extra ones
if (missingPoints > 0) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Search completed but we're missing ${missingPoints} points, generating extra searches`)
let i = 0
let fallbackRounds = 0
const extraRetries = this.bot.config.searchSettings.extraFallbackRetries || 1
while (missingPoints > 0 && fallbackRounds <= extraRetries) {
const query = googleSearchQueries[i++] as GoogleSearch
if (!query) break
// Get related search terms to the Google search queries
const relatedTerms = await this.getRelatedTerms(query?.topic)
if (relatedTerms.length > 3) {
// Search for the first 2 related terms
for (const term of relatedTerms.slice(1, 3)) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term}`)
searchCounters = await this.bingSearch(page, term)
const newMissingPoints = this.calculatePoints(searchCounters)
// If the new point amount is the same as before
if (newMissingPoints === missingPoints) {
stagnation++
} else {
stagnation = 0
}
missingPoints = newMissingPoints
// If we satisfied the searches
if (missingPoints === 0) {
break
}
// Try 5 more times, then we tried a total of 15 times, fair to say it's stuck
if (stagnation > 5) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
return
}
}
fallbackRounds++
}
}
}
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Completed searches')
}
private async bingSearch(searchPage: Page, query: string) {
const platformControlKey = platform() === 'darwin' ? 'Meta' : 'Control'
// Try a max of 5 times
for (let i = 0; i < 5; i++) {
try {
// This page had already been set to the Bing.com page or the previous search listing, we just need to select it
searchPage = await this.bot.browser.utils.getLatestTab(searchPage)
// Go to top of the page
await searchPage.evaluate(() => {
window.scrollTo(0, 0)
})
await this.bot.utils.wait(500)
const searchBar = '#sb_form_q'
// Prefer attached over visible to avoid strict visibility waits when overlays exist
const box = searchPage.locator(searchBar)
await box.waitFor({ state: 'attached', timeout: 15000 })
// Try dismissing overlays before interacting
await this.bot.browser.utils.tryDismissAllMessages(searchPage)
await this.bot.utils.wait(200)
let navigatedDirectly = false
try {
// Try focusing and filling instead of clicking (more reliable on mobile)
await box.focus({ timeout: 2000 }).catch(() => { /* ignore focus errors */ })
await box.fill('')
await this.bot.utils.wait(200)
await searchPage.keyboard.down(platformControlKey)
await searchPage.keyboard.press('A')
await searchPage.keyboard.press('Backspace')
await searchPage.keyboard.up(platformControlKey)
await box.type(query, { delay: 20 })
await searchPage.keyboard.press('Enter')
} catch (typeErr) {
// As a robust fallback, navigate directly to the search results URL
const q = encodeURIComponent(query)
const url = `https://www.bing.com/search?q=${q}`
await searchPage.goto(url)
navigatedDirectly = true
}
await this.bot.utils.wait(3000)
// Bing.com in Chrome opens a new tab when searching via Enter; if we navigated directly, stay on current tab
const resultPage = navigatedDirectly ? searchPage : await this.bot.browser.utils.getLatestTab(searchPage)
this.searchPageURL = new URL(resultPage.url()).href // Set the results page
await this.bot.browser.utils.reloadBadPage(resultPage)
if (this.bot.config.searchSettings.scrollRandomResults) {
await this.bot.utils.wait(2000)
await this.randomScroll(resultPage)
}
if (this.bot.config.searchSettings.clickRandomResults) {
await this.bot.utils.wait(2000)
await this.clickRandomLink(resultPage)
}
// Delay between searches
const minDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min)
const maxDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max)
const adaptivePad = Math.min(4000, Math.max(0, Math.floor(Math.random() * 800)))
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(minDelay, maxDelay)) + adaptivePad)
return await this.bot.browser.func.getSearchPoints()
} catch (error) {
if (i === 5) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed after 5 retries... An error occurred:' + error, 'error')
break
}
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search failed, An error occurred:' + error, 'error')
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Retrying search, attempt ${i}/5`, 'warn')
// Reset the tabs
const lastTab = await this.bot.browser.utils.getLatestTab(searchPage)
await this.closeTabs(lastTab)
await this.bot.utils.wait(4000)
}
}
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
return await this.bot.browser.func.getSearchPoints()
}
private async getGoogleTrends(geoLocale: string = 'US'): Promise<GoogleSearch[]> {
const queryTerms: GoogleSearch[] = []
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Generating search queries, can take a while! | GeoLocale: ${geoLocale}`)
try {
const request: AxiosRequestConfig = {
url: 'https://trends.google.com/_/TrendsUi/data/batchexecute',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
data: `f.req=[[[i0OFE,"[null, null, \\"${geoLocale.toUpperCase()}\\", 0, null, 48]"]]]`
}
const response = await this.bot.axios.request(request, this.bot.config.proxy.proxyGoogleTrends)
const rawText = response.data
const trendsData = this.extractJsonFromResponse(rawText)
if (!trendsData) {
throw this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'Failed to parse Google Trends response', 'error')
}
const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)])
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Found ${mappedTrendsData.length} search queries for ${geoLocale}`)
if (mappedTrendsData.length < 30 && geoLocale.toUpperCase() !== 'US') {
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Insufficient search queries (${mappedTrendsData.length} < 30), falling back to US`, 'warn')
return this.getGoogleTrends()
}
for (const [topic, relatedQueries] of mappedTrendsData) {
queryTerms.push({
topic: topic as string,
related: relatedQueries as string[]
})
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'An error occurred:' + error, 'error')
}
return queryTerms
}
private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null {
const lines = text.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return JSON.parse(JSON.parse(trimmed)[0][2])[1]
} catch {
continue
}
}
}
return null
}
private async getRelatedTerms(term: string): Promise<string[]> {
try {
const request = {
url: `https://api.bing.com/osjson.aspx?query=${term}`,
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
const response = await this.bot.axios.request(request, this.bot.config.proxy.proxyBingTerms)
return response.data[1] as string[]
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING-RELATED', 'An error occurred:' + error, 'error')
}
return []
}
private async randomScroll(page: Page) {
try {
const viewportHeight = await page.evaluate(() => window.innerHeight)
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
await page.evaluate((scrollPos: number) => {
window.scrollTo(0, scrollPos)
}, randomScrollPosition)
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-SCROLL', 'An error occurred:' + error, 'error')
}
}
private async clickRandomLink(page: Page) {
try {
await page.click('#b_results .b_algo h2', { timeout: 2000 }).catch(() => { }) // Since we don't really care if it did it or not
// Only used if the browser is not the edge browser (continue on Edge popup)
await this.closeContinuePopup(page)
// Stay for 10 seconds for page to load and "visit"
await this.bot.utils.wait(10000)
// Will get current tab if no new one is created, this will always be the visited site or the result page if it failed to click
let lastTab = await this.bot.browser.utils.getLatestTab(page)
let lastTabURL = new URL(lastTab.url()) // Get new tab info, this is the website we're visiting
// Check if the URL is different from the original one, don't loop more than 5 times.
let i = 0
while (lastTabURL.href !== this.searchPageURL && i < 5) {
await this.closeTabs(lastTab)
// End of loop, refresh lastPage
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
lastTabURL = new URL(lastTab.url()) // Get new tab info
i++
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
}
}
private async closeTabs(lastTab: Page) {
const browser = lastTab.context()
const tabs = browser.pages()
try {
if (tabs.length > 2) {
// If more than 2 tabs are open, close the last tab
await lastTab.close()
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', `More than 2 were open, closed the last tab: "${new URL(lastTab.url()).host}"`)
} else if (tabs.length === 1) {
// If only 1 tab is open, open a new one to search in
const newPage = await browser.newPage()
await this.bot.utils.wait(1000)
await newPage.goto(this.bingHome)
await this.bot.utils.wait(3000)
this.searchPageURL = newPage.url()
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'There was only 1 tab open, crated a new one')
} else {
// Else reset the last tab back to the search listing or Bing.com
lastTab = await this.bot.browser.utils.getLatestTab(lastTab)
await lastTab.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
}
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'An error occurred:' + error, 'error')
}
}
private calculatePoints(counters: Counters) {
const mobileData = counters.mobileSearch?.[0] // Mobile searches
const genericData = counters.pcSearch?.[0] // Normal searches
const edgeData = counters.pcSearch?.[1] // Edge searches
const missingPoints = (this.bot.isMobile && mobileData)
? mobileData.pointProgressMax - mobileData.pointProgress
: (edgeData ? edgeData.pointProgressMax - edgeData.pointProgress : 0)
+ (genericData ? genericData.pointProgressMax - genericData.pointProgress : 0)
return missingPoints
}
private async closeContinuePopup(page: Page) {
try {
await page.waitForSelector('#sacs_close', { timeout: 1000 })
const continueButton = await page.$('#sacs_close')
if (continueButton) {
await continueButton.click()
}
} catch (error) {
// Continue if element is not found or other error occurs
}
}
}

View File

@@ -0,0 +1,85 @@
import type { Page } from 'playwright'
import * as fs from 'fs'
import path from 'path'
import { Workers } from '../Workers'
import { DELAYS } from '../../constants'
import { MorePromotion, PromotionalItem } from '../../interface/DashboardData'
export class SearchOnBing extends Workers {
async doSearchOnBing(page: Page, activity: MorePromotion | PromotionalItem) {
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Trying to complete SearchOnBing')
try {
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_WAIT)
await this.bot.browser.utils.tryDismissAllMessages(page)
const query = await this.getSearchQuery(activity.title)
const searchBar = '#sb_form_q'
const box = page.locator(searchBar)
await box.waitFor({ state: 'attached', timeout: DELAYS.SEARCH_BAR_TIMEOUT })
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
try {
await box.focus({ timeout: DELAYS.THIS_OR_THAT_START }).catch(() => { /* ignore */ })
await box.fill('')
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
await page.keyboard.type(query, { delay: DELAYS.TYPING_DELAY })
await page.keyboard.press('Enter')
} catch {
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`
await page.goto(url)
}
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_COMPLETE)
await page.close()
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Completed the SearchOnBing successfully')
} catch (error) {
await page.close()
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'An error occurred:' + error, 'error')
}
}
private async getSearchQuery(title: string): Promise<string> {
interface Queries {
title: string;
queries: string[]
}
let queries: Queries[] = []
try {
if (this.bot.config.searchOnBingLocalQueries) {
const data = fs.readFileSync(path.join(__dirname, '../queries.json'), 'utf8')
queries = JSON.parse(data)
} else {
// Fetch from the repo directly so the user doesn't need to redownload the script for the new activities
const response = await this.bot.axios.request({
method: 'GET',
url: 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Rewi/refs/heads/main/src/functions/queries.json'
})
queries = response.data
}
const answers = queries.find(x => this.normalizeString(x.title) === this.normalizeString(title))
const answer = answers ? this.bot.utils.shuffleArray(answers?.queries)[0] as string : title
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', `Fetched answer: ${answer} | question: ${title}`)
return answer
} catch (error) {
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'An error occurred:' + error, 'error')
return title
}
}
private normalizeString(string: string): string {
return string.normalize('NFD').trim().toLowerCase().replace(/[^\x20-\x7E]/g, '').replace(/[?!]/g, '')
}
}

View File

@@ -0,0 +1,48 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { DELAYS } from '../../constants'
export class ThisOrThat extends Workers {
async doThisOrThat(page: Page) {
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'Trying to complete ThisOrThat')
try {
// Check if the quiz has been started or not
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: DELAYS.THIS_OR_THAT_START }).then(() => true).catch(() => false)
if (quizNotStarted) {
await page.click('#rqStartQuiz')
} else {
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
}
await this.bot.utils.wait(DELAYS.THIS_OR_THAT_START)
// Solving
const quizData = await this.bot.browser.func.getQuizData(page)
const questionsRemaining = quizData.maxQuestions - (quizData.currentQuestionNumber - 1) // Amount of questions remaining
for (let question = 0; question < questionsRemaining; question++) {
// Since there's no solving logic yet, randomly guess to complete
const buttonId = `#rqAnswerOption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
await page.click(buttonId)
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
if (!refreshSuccess) {
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
return
}
}
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'Completed the ThisOrThat successfully')
} catch (error) {
await page.close()
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'An error occurred:' + error, 'error')
}
}
}

View File

@@ -0,0 +1,23 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
export class UrlReward extends Workers {
async doUrlReward(page: Page) {
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Trying to complete UrlReward')
try {
await this.bot.utils.wait(2000)
await page.close()
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Completed the UrlReward successfully')
} catch (error) {
await page.close()
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'An error occurred:' + error, 'error')
}
}
}

289
src/functions/queries.json Normal file
View File

@@ -0,0 +1,289 @@
[
{
"title": "Houses near you",
"queries": [
"Houses near me"
]
},
{
"title": "Feeling symptoms?",
"queries": [
"Rash on forearm",
"Stuffy nose",
"Tickling cough"
]
},
{
"title": "Get your shopping done faster",
"queries": [
"Buy PS5",
"Buy Xbox",
"Chair deals"
]
},
{
"title": "Translate anything",
"queries": [
"Translate welcome home to Korean",
"Translate welcome home to Japanese",
"Translate goodbye to Japanese"
]
},
{
"title": "Search the lyrics of a song",
"queries": [
"Debarge rhythm of the night lyrics"
]
},
{
"title": "Let's watch that movie again!",
"queries": [
"Alien movie",
"Aliens movie",
"Alien 3 movie",
"Predator movie"
]
},
{
"title": "Plan a quick getaway",
"queries": [
"Flights Amsterdam to Tokyo",
"Flights New York to Tokyo"
]
},
{
"title": "Discover open job roles",
"queries": [
"jobs at Microsoft",
"Microsoft Job Openings",
"Jobs near me",
"jobs at Boeing worked"
]
},
{
"title": "You can track your package",
"queries": [
"USPS tracking"
]
},
{
"title": "Find somewhere new to explore",
"queries": [
"Directions to Berlin",
"Directions to Tokyo",
"Directions to New York"
]
},
{
"title": "Too tired to cook tonight?",
"queries": [
"KFC near me",
"Burger King near me",
"McDonalds near me"
]
},
{
"title": "Quickly convert your money",
"queries": [
"convert 250 USD to yen",
"convert 500 USD to yen"
]
},
{
"title": "Learn to cook a new recipe",
"queries": [
"How to cook ratatouille",
"How to cook lasagna"
]
},
{
"title": "Find places to stay!",
"queries": [
"Hotels Berlin Germany",
"Hotels Amsterdam Netherlands"
]
},
{
"title": "How's the economy?",
"queries": [
"sp 500"
]
},
{
"title": "Who won?",
"queries": [
"braves score"
]
},
{
"title": "Gaming time",
"queries": [
"Overwatch video game",
"Call of duty video game"
]
},
{
"title": "Expand your vocabulary",
"queries": [
"definition definition"
]
},
{
"title": "What time is it?",
"queries": [
"Japan time",
"New York time"
]
},
{
"title": "Maisons près de chez vous",
"queries": [
"Maisons près de chez moi"
]
},
{
"title": "Vous ressentez des symptômes ?",
"queries": [
"Éruption cutanée sur l'avant-bras",
"Nez bouché",
"Toux chatouilleuse"
]
},
{
"title": "Faites vos achats plus vite",
"queries": [
"Acheter une PS5",
"Acheter une Xbox",
"Offres sur les chaises"
]
},
{
"title": "Traduisez tout !",
"queries": [
"Traduction bienvenue à la maison en coréen",
"Traduction bienvenue à la maison en japonais",
"Traduction au revoir en japonais"
]
},
{
"title": "Rechercher paroles de chanson",
"queries": [
"Paroles de Debarge rhythm of the night"
]
},
{
"title": "Et si nous regardions ce film une nouvelle fois?",
"queries": [
"Alien film",
"Film Aliens",
"Film Alien 3",
"Film Predator"
]
},
{
"title": "Planifiez une petite escapade",
"queries": [
"Vols Amsterdam-Tokyo",
"Vols New York-Tokyo"
]
},
{
"title": "Consulter postes à pourvoir",
"queries": [
"emplois chez Microsoft",
"Offres d'emploi Microsoft",
"Emplois près de chez moi",
"emplois chez Boeing"
]
},
{
"title": "Vous pouvez suivre votre colis",
"queries": [
"Suivi Chronopost"
]
},
{
"title": "Trouver un endroit à découvrir",
"queries": [
"Itinéraire vers Berlin",
"Itinéraire vers Tokyo",
"Itinéraire vers New York"
]
},
{
"title": "Trop fatigué pour cuisiner ce soir ?",
"queries": [
"KFC près de chez moi",
"Burger King près de chez moi",
"McDonalds près de chez moi"
]
},
{
"title": "Convertissez rapidement votre argent",
"queries": [
"convertir 250 EUR en yen",
"convertir 500 EUR en yen"
]
},
{
"title": "Apprenez à cuisiner une nouvelle recette",
"queries": [
"Comment faire cuire la ratatouille",
"Comment faire cuire les lasagnes"
]
},
{
"title": "Trouvez des emplacements pour rester!",
"queries": [
"Hôtels Berlin Allemagne",
"Hôtels Amsterdam Pays-Bas"
]
},
{
"title": "Comment se porte l'économie ?",
"queries": [
"CAC 40"
]
},
{
"title": "Qui a gagné ?",
"queries": [
"score du Paris Saint-Germain"
]
},
{
"title": "Temps de jeu",
"queries": [
"Jeu vidéo Overwatch",
"Jeu vidéo Call of Duty"
]
},
{
"title": "Enrichissez votre vocabulaire",
"queries": [
"definition definition"
]
},
{
"title": "Quelle heure est-il ?",
"queries": [
"Heure du Japon",
"Heure de New York"
]
},
{
"title": "Vérifier la météo",
"queries": [
"Météo de Paris",
"Météo de la France"
]
},
{
"title": "Tenez-vous informé des sujets d'actualité",
"queries": [
"Augmentation Impots",
"Mort célébrité"
]
}
]

1598
src/index.ts Normal file

File diff suppressed because it is too large Load Diff

19
src/interface/Account.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface Account {
/** Enable/disable this account (if false, account will be skipped during execution) */
enabled?: boolean;
email: string;
password: string;
/** Optional TOTP secret in Base32 (e.g., from Microsoft Authenticator setup) */
totp?: string;
/** Recovery email used during security challenge verification (mandatory) */
recoveryEmail: string;
proxy: AccountProxy;
}
export interface AccountProxy {
proxyAxios: boolean;
url: string;
port: number;
password: string;
username: string;
}

View File

@@ -0,0 +1,21 @@
import type { MorePromotion, PromotionalItem } from './DashboardData'
import type { Page } from 'playwright'
/**
* Activity handler contract for solving a single dashboard activity.
* Implementations should be stateless (or hold only a reference to the bot)
* and perform all required steps on the provided page.
*/
export interface ActivityHandler {
/** Optional identifier for diagnostics */
id?: string
/**
* Return true if this handler knows how to process the given activity.
*/
canHandle(activity: MorePromotion | PromotionalItem): boolean
/**
* Execute the activity on the provided page. The page is already
* navigated to the activity tab/window by the caller.
*/
run(page: Page, activity: MorePromotion | PromotionalItem): Promise<void>
}

View File

@@ -0,0 +1,226 @@
export interface AppUserData {
response: Response;
correlationId: string;
code: number;
}
export interface Response {
profile: Profile;
balance: number;
counters: null;
promotions: Promotion[];
catalog: null;
goal_item: GoalItem;
activities: null;
cashback: null;
orders: Order[];
rebateProfile: null;
rebatePayouts: null;
giveProfile: GiveProfile;
autoRedeemProfile: null;
autoRedeemItem: null;
thirdPartyProfile: null;
notifications: null;
waitlist: null;
autoOpenFlyout: null;
coupons: null;
recommendedAffordableCatalog: null;
}
export interface GiveProfile {
give_user: string;
give_organization: { [key: string]: GiveOrganization | null };
first_give_optin: string;
last_give_optout: string;
give_lifetime_balance: string;
give_lifetime_donation_balance: string;
give_balance: string;
form: null;
}
export interface GiveOrganization {
give_organization_donation_points: number;
give_organization_donation_point_to_currency_ratio: number;
give_organization_donation_currency: number;
}
export interface GoalItem {
name: string;
provider: string;
price: number;
attributes: GoalItemAttributes;
config: GoalItemConfig;
}
export interface GoalItemAttributes {
category: string;
CategoryDescription: string;
'desc.group_text': string;
'desc.legal_text'?: string;
'desc.sc_description': string;
'desc.sc_title': string;
display_order: string;
ExtraLargeImage: string;
group: string;
group_image: string;
group_sc_image: string;
group_title: string;
hidden?: string;
large_image: string;
large_sc_image: string;
medium_image: string;
MobileImage: string;
original_price: string;
Remarks?: string;
ShortText?: string;
showcase?: string;
small_image: string;
title: string;
cimsid: string;
user_defined_goal?: string;
disable_bot_redemptions?: string;
'desc.large_text'?: string;
english_title?: string;
etid?: string;
sku?: string;
coupon_discount?: string;
}
export interface GoalItemConfig {
amount: string;
currencyCode: string;
isHidden: string;
PointToCurrencyConversionRatio: string;
}
export interface Order {
id: string;
t: Date;
sku: string;
item_snapshot: ItemSnapshot;
p: number;
s: S;
a: A;
child_redemption: null;
third_party_partner: null;
log: Log[];
}
export interface A {
form?: string;
OrderId: string;
CorrelationId: string;
Channel: string;
Language: string;
Country: string;
EvaluationId: string;
provider?: string;
referenceOrderID?: string;
externalRefID?: string;
denomination?: string;
rewardName?: string;
sendEmail?: string;
status?: string;
createdAt?: Date;
bal_before_deduct?: string;
bal_after_deduct?: string;
}
export interface ItemSnapshot {
name: string;
provider: string;
price: number;
attributes: GoalItemAttributes;
config: ItemSnapshotConfig;
}
export interface ItemSnapshotConfig {
amount: string;
countryCode: string;
currencyCode: string;
sku: string;
}
export interface Log {
time: Date;
from: From;
to: S;
reason: string;
}
export enum From {
Created = 'Created',
RiskApproved = 'RiskApproved',
RiskReview = 'RiskReview'
}
export enum S {
Cancelled = 'Cancelled',
RiskApproved = 'RiskApproved',
RiskReview = 'RiskReview',
Shipped = 'Shipped'
}
export interface Profile {
ruid: string;
attributes: ProfileAttributes;
offline_attributes: OfflineAttributes;
}
export interface ProfileAttributes {
publisher: string;
publisher_upd: Date;
creative: string;
creative_upd: Date;
program: string;
program_upd: Date;
country: string;
country_upd: Date;
referrerhash: string;
referrerhash_upd: Date;
optout_upd: Date;
language: string;
language_upd: Date;
target: string;
target_upd: Date;
created: Date;
created_upd: Date;
epuid: string;
epuid_upd: Date;
goal: string;
goal_upd: Date;
waitlistattributes: string;
waitlistattributes_upd: Date;
serpbotscore_upd: Date;
iscashbackeligible: string;
cbedc: string;
rlscpct_upd: Date;
give_user: string;
rebcpc_upd: Date;
SerpBotScore_upd: Date;
AdsBotScore_upd: Date;
dbs_upd: Date;
rbs: string;
rbs_upd: Date;
iris_segmentation: string;
iris_segmentation_upd: Date;
}
export interface OfflineAttributes {
}
export interface Promotion {
name: string;
priority: number;
attributes: { [key: string]: string };
tags: Tag[];
}
export enum Tag {
AllowTrialUser = 'allow_trial_user',
ExcludeGivePcparent = 'exclude_give_pcparent',
ExcludeGlobalConfig = 'exclude_global_config',
ExcludeHidden = 'exclude_hidden',
LOCString = 'locString',
NonGlobalConfig = 'non_global_config'
}

219
src/interface/Config.ts Normal file
View File

@@ -0,0 +1,219 @@
export interface Config {
baseURL: string;
sessionPath: string;
headless: boolean;
browser?: ConfigBrowser; // Optional nested browser config
fingerprinting?: ConfigFingerprinting; // Optional nested fingerprinting config
parallel: boolean;
runOnZeroPoints: boolean;
clusters: number;
saveFingerprint: ConfigSaveFingerprint;
workers: ConfigWorkers;
searchOnBingLocalQueries: boolean;
globalTimeout: number | string;
searchSettings: ConfigSearchSettings;
humanization?: ConfigHumanization; // Anti-ban humanization controls
retryPolicy?: ConfigRetryPolicy; // Global retry/backoff policy
jobState?: ConfigJobState; // Persistence of per-activity checkpoints
logExcludeFunc: string[];
webhookLogExcludeFunc: string[];
logging?: ConfigLogging; // Preserve original logging object (for live webhook settings)
proxy: ConfigProxy;
webhook: ConfigWebhook;
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
ntfy: ConfigNtfy;
diagnostics?: ConfigDiagnostics;
update?: ConfigUpdate;
schedule?: ConfigSchedule;
passesPerRun?: number;
buyMode?: ConfigBuyMode; // Optional manual spending mode
vacation?: ConfigVacation; // Optional monthly contiguous off-days
crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
analytics?: ConfigAnalytics; // NEW: Performance dashboard and metrics tracking
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
}
export interface ConfigSaveFingerprint {
mobile: boolean;
desktop: boolean;
}
export interface ConfigBrowser {
headless?: boolean;
globalTimeout?: number | string;
}
export interface ConfigFingerprinting {
saveFingerprint?: ConfigSaveFingerprint;
}
export interface ConfigSearchSettings {
useGeoLocaleQueries: boolean;
scrollRandomResults: boolean;
clickRandomResults: boolean;
searchDelay: ConfigSearchDelay;
retryMobileSearchAmount: number;
localFallbackCount?: number; // Number of local fallback queries to sample when trends fail
extraFallbackRetries?: number; // Additional mini-retry loops with fallback terms
}
export interface ConfigSearchDelay {
min: number | string;
max: number | string;
}
export interface ConfigWebhook {
enabled: boolean;
url: string;
}
export interface ConfigNtfy {
enabled: boolean;
url: string;
topic: string;
authToken?: string; // Optional authentication token
}
export interface ConfigProxy {
proxyGoogleTrends: boolean;
proxyBingTerms: boolean;
}
export interface ConfigDiagnostics {
enabled?: boolean; // master toggle
saveScreenshot?: boolean; // capture .png
saveHtml?: boolean; // capture .html
maxPerRun?: number; // cap number of captures per run
retentionDays?: number; // delete older diagnostic folders
}
export interface ConfigUpdate {
git?: boolean; // if true, run git pull + npm ci + npm run build after completion
docker?: boolean; // if true, run docker update routine (compose pull/up) after completion
scriptPath?: string; // optional custom path to update script relative to repo root
autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings)
autoUpdateAccounts?: boolean; // if true, allow auto-update of accounts.json when remote changes it (default: false to preserve credentials)
}
export interface ConfigBuyMode {
enabled?: boolean; // if true, force buy mode session
maxMinutes?: number; // session duration cap
}
export interface ConfigSchedule {
enabled?: boolean;
time?: string; // Back-compat: accepts "HH:mm" or "h:mm AM/PM"
// New optional explicit times
time12?: string; // e.g., "9:00 AM"
time24?: string; // e.g., "09:00"
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 {
enabled?: boolean; // default false
minDays?: number; // default 3
maxDays?: number; // default 5
}
export interface ConfigCrashRecovery {
autoRestart?: boolean; // Restart the root process after fatal crash
maxRestarts?: number; // Max restart attempts (default 2)
backoffBaseMs?: number; // Base backoff before restart (default 2000)
restartFailedWorker?: boolean; // (future) attempt to respawn crashed worker
restartFailedWorkerAttempts?: number; // attempts per worker (default 1)
}
export interface ConfigWorkers {
doDailySet: boolean;
doMorePromotions: boolean;
doPunchCards: boolean;
doDesktopSearch: boolean;
doMobileSearch: boolean;
doDailyCheckIn: boolean;
doReadToEarn: boolean;
bundleDailySetWithSearch?: boolean; // If true, run desktop search right after Daily Set
}
// Anti-ban humanization
export interface ConfigHumanization {
// Master toggle for Human Mode. When false, humanization is minimized.
enabled?: boolean;
// If true, stop processing remaining accounts after a ban is detected
stopOnBan?: boolean;
// If true, send an immediate webhook/NTFY alert when a ban is detected
immediateBanAlert?: boolean;
// Additional random waits between actions
actionDelay?: { min: number | string; max: number | string };
// Probability [0..1] to perform micro mouse moves per step
gestureMoveProb?: number;
// Probability [0..1] to perform tiny scrolls per step
gestureScrollProb?: number;
// Allowed execution windows (local time). Each item is "HH:mm-HH:mm".
// If provided, runs outside these windows will be delayed until the next allowed window.
allowedWindows?: string[];
// Randomly skip N days per week to look more human (0-7). Default 1.
randomOffDaysPerWeek?: number;
}
// Retry/backoff policy
export interface ConfigRetryPolicy {
maxAttempts?: number; // default 3
baseDelay?: number | string; // default 1000ms
maxDelay?: number | string; // default 30s
multiplier?: number; // default 2
jitter?: number; // 0..1; default 0.2
}
// Job state persistence
export interface ConfigJobState {
enabled?: boolean; // default true
dir?: string; // base directory; defaults to <sessionPath>/job-state
skipCompletedAccounts?: boolean; // if true (default), skip accounts already completed for the day
}
// Live logging configuration
export interface ConfigLoggingLive {
enabled?: boolean; // master switch for live webhook logs
redactEmails?: boolean; // if true, redact emails in outbound logs
}
export interface ConfigLogging {
excludeFunc?: string[];
webhookExcludeFunc?: string[];
live?: ConfigLoggingLive;
liveWebhookUrl?: string; // legacy/dedicated live webhook override
redactEmails?: boolean; // legacy top-level redaction flag
// Optional nested live.url support (already handled dynamically in Logger)
[key: string]: unknown; // forward compatibility
}
// CommunityHelp removed (privacy-first policy)
// NEW FEATURES: Risk Management, Analytics, Query Diversity
export interface ConfigRiskManagement {
enabled?: boolean; // master toggle for risk-aware throttling
autoAdjustDelays?: boolean; // automatically increase delays when risk is high
stopOnCritical?: boolean; // halt execution if risk reaches critical level
banPrediction?: boolean; // enable ML-style ban prediction
riskThreshold?: number; // 0-100, pause if risk exceeds this
}
export interface ConfigAnalytics {
enabled?: boolean; // track performance metrics
retentionDays?: number; // how long to keep analytics data
exportMarkdown?: boolean; // generate markdown reports
webhookSummary?: boolean; // send analytics via webhook
}
export interface ConfigQueryDiversity {
enabled?: boolean; // use multi-source query generation
sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use
maxQueriesPerSource?: number; // limit per source
cacheMinutes?: number; // cache duration
}

View File

@@ -0,0 +1,701 @@
export interface DashboardData {
userStatus: UserStatus;
promotionalItem: PromotionalItem;
dailySetPromotions: { [key: string]: PromotionalItem[] };
streakPromotion: StreakPromotion;
streakBonusPromotions: StreakBonusPromotion[];
punchCards: PunchCard[];
dashboardFlights: DashboardFlights;
morePromotions: MorePromotion[];
suggestedRewards: AutoRedeemItem[];
coachMarks: CoachMarks;
welcomeTour: WelcomeTour;
userInterests: UserInterests;
isVisualParityTest: boolean;
mbingFlight: null;
langCountryMismatchPromo: null;
machineTranslationPromo: MachineTranslationPromo;
autoRedeemItem: AutoRedeemItem;
userProfile: UserProfile;
}
export interface AutoRedeemItem {
name: null | string;
price: number;
provider: null | string;
disabled: boolean;
category: string;
title: string;
variableGoalSpecificTitle: string;
smallImageUrl: string;
mediumImageUrl: string;
largeImageUrl: string;
largeShowcaseImageUrl: string;
description: Description;
showcase: boolean;
showcaseInAllCategory: boolean;
originalPrice: number;
discountedPrice: number;
popular: boolean;
isTestOnly: boolean;
groupId: string;
inGroup: boolean;
isDefaultItemInGroup: boolean;
groupTitle: string;
groupImageUrl: string;
groupShowcaseImageUrl: string;
instantWinGameId: string;
instantWinPlayAgainSku: string;
isLowInStock: boolean;
isOutOfStock: boolean;
getCodeMessage: string;
disableEmail: boolean;
stockMessage: string;
comingSoonFlag: boolean;
isGenericDonation: boolean;
isVariableRedemptionItem: boolean;
variableRedemptionItemCurrencySymbol: null;
variableRedemptionItemMin: number;
variableRedemptionItemMax: number;
variableItemConfigPointsToCurrencyConversionRatio: number;
isAutoRedeem: boolean;
}
export interface Description {
itemGroupText: string;
smallText: string;
largeText: string;
legalText: string;
showcaseTitle: string;
showcaseDescription: string;
}
export interface CoachMarks {
streaks: WelcomeTour;
}
export interface WelcomeTour {
promotion: DashboardImpression;
slides: Slide[];
}
export interface DashboardImpression {
name: null | string;
priority: number;
attributes: { [key: string]: string } | null;
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: string;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
benefits?: Benefit[];
supportedLevelKeys?: string[];
supportedLevelTitles?: string[];
supportedLevelTitlesMobile?: string[];
activeLevel?: string;
isCodexAutoJoinUser?: boolean;
}
export interface Benefit {
key: string;
text: string;
url: null | string;
helpText: null | string;
supportedLevels: SupportedLevels;
}
export interface SupportedLevels {
level1?: string;
level2: string;
level2XBoxGold: string;
}
export interface Slide {
slideType: null;
slideShowTourId: string;
id: number;
title: string;
subtitle: null;
subtitle1: null;
description: string;
description1: null;
imageTitle: null;
image2Title: null | string;
image3Title: null | string;
image4Title: null | string;
imageDescription: null;
image2Description: null | string;
image3Description: null | string;
image4Description: null | string;
imageUrl: null | string;
darkImageUrl: null;
image2Url: null | string;
image3Url: null | string;
image4Url: null | string;
layout: null | string;
actionButtonText: null | string;
actionButtonUrl: null | string;
foregroundImageUrl: null;
backLink: null;
nextLink: CloseLink;
closeLink: CloseLink;
footnote: null | string;
termsText: null;
termsUrl: null;
privacyText: null;
privacyUrl: null;
taggedItem: null | string;
slideVisited: boolean;
aboutPageLinkText: null;
aboutPageLink: null;
redeemLink: null;
rewardsLink: null;
quizLinks?: string[];
quizCorrectAnswerTitle?: string;
quizWrongAnswerTitle?: string;
quizAnswerDescription?: string;
}
export interface CloseLink {
text: null | string;
url: null | string;
}
export interface PromotionalItem {
name: string;
priority: number;
attributes: PromotionalItemAttributes;
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: Type;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
}
export interface PromotionalItemAttributes {
animated_icon?: string;
bg_image: string;
complete: GiveEligible;
daily_set_date?: string;
description: string;
destination: string;
icon: string;
image: string;
link_text: string;
max: string;
offerid: string;
progress: string;
sc_bg_image: string;
sc_bg_large_image: string;
small_image: string;
state: State;
title: string;
type: Type;
give_eligible: GiveEligible;
activity_max?: string;
activity_progress?: string;
is_wot?: GiveEligible;
offer_counter?: string;
promotional?: GiveEligible;
parentPunchcards?: string;
'classification.DescriptionText'?: string;
'classification.PunchcardChildrenCount'?: string;
'classification.PunchcardEndDate'?: Date;
'classification.Template'?: string;
'classification.TitleText'?: string;
}
export enum GiveEligible {
False = 'False',
True = 'True'
}
export enum State {
Default = 'Default'
}
export enum Type {
Quiz = 'quiz',
Urlreward = 'urlreward',
UrlrewardUrlrewardUrlrewardUrlrewardUrlreward = 'urlreward,urlreward,urlreward,urlreward,urlreward'
}
export interface DashboardFlights {
dashboardbannernav: string;
togglegiveuser: string;
spotifyRedirect: string;
give_eligible: GiveEligible;
destination: string;
}
export interface MachineTranslationPromo {
}
export interface MorePromotion {
name: string;
priority: number;
attributes: { [key: string]: string };
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: string;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
exclusiveLockedFeatureType: string;
exclusiveLockedFeatureStatus: string;
}
export interface PunchCard {
name: string;
parentPromotion?: PromotionalItem;
childPromotions: PromotionalItem[];
}
export interface StreakBonusPromotion {
name: string;
priority: number;
attributes: StreakBonusPromotionAttributes;
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: string;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
}
export interface StreakBonusPromotionAttributes {
hidden: GiveEligible;
type: string;
title: string;
description: string;
description_localizedkey: string;
image: string;
animated_icon: string;
activity_progress: string;
activity_max: string;
bonus_earned: string;
break_description: string;
give_eligible: GiveEligible;
destination: string;
}
export interface StreakPromotion {
lastUpdatedDate: Date;
breakImageUrl: string;
lifetimeMaxValue: number;
bonusPointsEarned: number;
name: string;
priority: number;
attributes: StreakPromotionAttributes;
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: string;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
}
export interface StreakPromotionAttributes {
hidden: GiveEligible;
type: string;
title: string;
image: string;
activity_progress: string;
last_updated: Date;
break_image: string;
lifetime_max: string;
bonus_points: string;
give_eligible: GiveEligible;
destination: string;
}
export interface UserInterests {
name: string;
priority: number;
attributes: UserInterestsAttributes;
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: string;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
}
export interface UserInterestsAttributes {
hidden: GiveEligible;
give_eligible: GiveEligible;
destination: string;
}
export interface UserProfile {
ruid: string;
attributes: UserProfileAttributes;
}
export interface UserProfileAttributes {
publisher: string;
publisher_upd: Date;
creative: string;
creative_upd: Date;
program: string;
program_upd: Date;
country: string;
country_upd: Date;
referrerhash: string;
referrerhash_upd: Date;
optout_upd: Date;
language: string;
language_upd: Date;
target: string;
target_upd: Date;
created: Date;
created_upd: Date;
epuid: string;
epuid_upd: Date;
waitlistattributes: string;
waitlistattributes_upd: Date;
cbedc: GiveEligible;
iscashbackeligible: GiveEligible;
give_user: GiveEligible;
}
export interface UserStatus {
levelInfo: LevelInfo;
availablePoints: number;
lifetimePoints: number;
lifetimePointsRedeemed: number;
ePuid: string;
redeemGoal: AutoRedeemItem;
counters: Counters;
lastOrder: LastOrder;
dashboardImpression: DashboardImpression;
referrerProgressInfo: ReferrerProgressInfo;
isGiveModeOn: boolean;
giveBalance: number;
firstTimeGiveModeOptIn: null;
giveOrganizationName: string;
lifetimeGivingPoints: number;
isRewardsUser: boolean;
isMuidTrialUser: boolean;
}
export interface Counters {
pcSearch: DashboardImpression[];
mobileSearch?: DashboardImpression[];
shopAndEarn: DashboardImpression[];
activityAndQuiz: ActivityAndQuiz[];
dailyPoint: DashboardImpression[];
}
export interface ActivityAndQuiz {
name: string;
priority: number;
attributes: ActivityAndQuizAttributes;
offerId: string;
complete: boolean;
counter: number;
activityProgress: number;
activityProgressMax: number;
pointProgressMax: number;
pointProgress: number;
promotionType: string;
promotionSubtype: string;
title: string;
extBannerTitle: string;
titleStyle: string;
theme: string;
description: string;
extBannerDescription: string;
descriptionStyle: string;
showcaseTitle: string;
showcaseDescription: string;
imageUrl: string;
dynamicImage: string;
smallImageUrl: string;
backgroundImageUrl: string;
showcaseBackgroundImageUrl: string;
showcaseBackgroundLargeImageUrl: string;
promotionBackgroundLeft: string;
promotionBackgroundRight: string;
iconUrl: string;
animatedIconUrl: string;
animatedLargeBackgroundImageUrl: string;
destinationUrl: string;
linkText: string;
hash: string;
activityType: string;
isRecurring: boolean;
isHidden: boolean;
isTestOnly: boolean;
isGiveEligible: boolean;
level: string;
slidesCount: number;
legalText: string;
legalLinkText: string;
deviceType: string;
}
export interface ActivityAndQuizAttributes {
type: string;
title: string;
link_text: string;
description: string;
foreground_color: string;
image: string;
recurring: string;
destination: string;
'classification.ShowProgress': GiveEligible;
hidden: GiveEligible;
give_eligible: GiveEligible;
}
export interface LastOrder {
id: null;
price: number;
status: null;
sku: null;
timestamp: Date;
catalogItem: null;
}
export interface LevelInfo {
activeLevel: string;
activeLevelName: string;
progress: number;
progressMax: number;
levels: Level[];
benefitsPromotion: DashboardImpression;
}
export interface Level {
key: string;
active: boolean;
name: string;
tasks: CloseLink[];
privileges: CloseLink[];
}
export interface ReferrerProgressInfo {
pointsEarned: number;
pointsMax: number;
isComplete: boolean;
promotions: string[];
}

View File

@@ -0,0 +1,44 @@
export interface GoogleTrends {
default: Default;
}
export interface Default {
trendingSearchesDays: TrendingSearchesDay[];
endDateForNextRequest: string;
rssFeedPageUrl: string;
}
export interface TrendingSearchesDay {
date: string;
formattedDate: string;
trendingSearches: TrendingSearch[];
}
export interface TrendingSearch {
title: Title;
formattedTraffic: string;
relatedQueries: Title[];
image: Image;
articles: Article[];
shareUrl: string;
}
export interface Article {
title: string;
timeAgo: string;
source: string;
image?: Image;
url: string;
snippet: string;
}
export interface Image {
newsUrl: string;
source: string;
imageUrl: string;
}
export interface Title {
query: string;
exploreLink: string;
}

9
src/interface/OAuth.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface OAuth {
access_token: string;
refresh_token: string;
scope: string;
expires_in: number;
ext_expires_in: number;
foci: string;
token_type: string;
}

7
src/interface/Points.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface EarnablePoints {
desktopSearchPoints: number
mobileSearchPoints: number
dailySetPoints: number
morePromotionsPoints: number
totalEarnablePoints: number
}

50
src/interface/QuizData.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface QuizData {
offerId: string;
quizId: string;
quizCategory: string;
IsCurrentQuestionCompleted: boolean;
quizRenderSummaryPage: boolean;
resetQuiz: boolean;
userClickedOnHint: boolean;
isDemoEnabled: boolean;
correctAnswer: string;
isMultiChoiceQuizType: boolean;
isPutInOrderQuizType: boolean;
isListicleQuizType: boolean;
isWOTQuizType: boolean;
isBugsForRewardsQuizType: boolean;
currentQuestionNumber: number;
maxQuestions: number;
resetTrackingCounters: boolean;
showWelcomePanel: boolean;
isAjaxCall: boolean;
showHint: boolean;
numberOfOptions: number;
isMobile: boolean;
inRewardsMode: boolean;
enableDailySetWelcomePane: boolean;
enableDailySetNonWelcomePane: boolean;
isDailySetUrlOffer: boolean;
isDailySetFlightEnabled: boolean;
dailySetUrlOfferId: string;
earnedCredits: number;
maxCredits: number;
creditsPerQuestion: number;
userAlreadyClickedOptions: number;
hasUserClickedOnOption: boolean;
recentAnswerChoice: string;
sessionTimerSeconds: string;
isOverlayMinimized: number;
ScreenReaderMsgOnMove: string;
ScreenReaderMsgOnDrop: string;
IsPartialPointsEnabled: boolean;
PrioritizeUrlOverCookies: boolean;
UseNewReportActivityAPI: boolean;
CorrectlyAnsweredQuestionCount: number;
showJoinRewardsPage: boolean;
CorrectOptionAnswer_WOT: string;
WrongOptionAnswer_WOT: string;
enableSlideAnimation: boolean;
ariaLoggingEnabled: boolean;
UseQuestionIndexInActivityId: boolean;
}

4
src/interface/Search.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface GoogleSearch {
topic: string;
related: string[];
}

View File

@@ -0,0 +1,62 @@
// Chrome Product Data
export interface ChromeVersion {
timestamp: Date;
channels: Channels;
}
export interface Channels {
Stable: Beta;
Beta: Beta;
Dev: Beta;
Canary: Beta;
}
export interface Beta {
channel: string;
version: string;
revision: string;
}
// Edge Product Data
export interface EdgeVersion {
Product: string;
Releases: Release[];
}
export interface Release {
ReleaseId: number;
Platform: Platform;
Architecture: Architecture;
CVEs: string[];
ProductVersion: string;
Artifacts: Artifact[];
PublishedTime: Date;
ExpectedExpiryDate: Date;
}
export enum Architecture {
Arm64 = 'arm64',
Universal = 'universal',
X64 = 'x64',
X86 = 'x86'
}
export interface Artifact {
ArtifactName: string;
Location: string;
Hash: string;
HashAlgorithm: HashAlgorithm;
SizeInBytes: number;
}
export enum HashAlgorithm {
Sha256 = 'SHA256'
}
export enum Platform {
Android = 'Android',
IOS = 'iOS',
Linux = 'Linux',
MACOS = 'MacOS',
Windows = 'Windows'
}

7
src/luxon.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/* 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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const IANAZone: any
}

419
src/scheduler.ts Normal file
View File

@@ -0,0 +1,419 @@
import { DateTime, IANAZone } from 'luxon'
import cronParser from 'cron-parser'
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import { MicrosoftRewardsBot } from './index'
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'
// Warn if an invalid timezone was provided
if (schedule?.timeZone && !IANAZone.isValidZone(schedule.timeZone)) {
void log('main', 'SCHEDULER', `Invalid timezone "${schedule.timeZone}" provided. Falling back to UTC. Valid zones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones`, 'warn')
}
// Determine source string
let src = ''
if (typeof schedule?.useAmPm === 'boolean') {
if (schedule.useAmPm) src = (schedule.time12 || schedule.time || '').trim()
else src = (schedule.time24 || schedule.time || '').trim()
} else {
// Back-compat: prefer time if present; else time24 or time12
src = (schedule?.time || schedule?.time24 || schedule?.time12 || '').trim()
}
// Try to parse 24h first: HH:mm
const m24 = src.match(/^\s*(\d{1,2}):(\d{2})\s*$/i)
if (m24) {
const hh = Math.max(0, Math.min(23, parseInt(m24[1]!, 10)))
const mm = Math.max(0, Math.min(59, parseInt(m24[2]!, 10)))
return { tz, hour: hh, minute: mm }
}
// Parse 12h with AM/PM: h:mm AM or h AM
const m12 = src.match(/^\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*$/i)
if (m12) {
let hh = parseInt(m12[1]!, 10)
const mm = m12[2] ? parseInt(m12[2]!, 10) : 0
const ampm = m12[3]!.toUpperCase()
if (hh === 12) hh = 0
if (ampm === 'PM') hh += 12
hh = Math.max(0, Math.min(23, hh))
const m = Math.max(0, Math.min(59, mm))
return { tz, hour: hh, minute: m }
}
// Fallback: default 09:00
return { tz, hour: 9, minute: 0 }
}
function parseTargetToday(now: Date, schedule: Config['schedule'] | undefined) {
const { tz, hour, minute } = resolveTimeParts(schedule)
const dtn = DateTime.fromJSDate(now, { zone: tz })
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()
await bot.run()
}
/**
* Run a single pass either in-process or as a child process (default),
* with a watchdog timeout to kill stuck runs.
*/
async function runOnePassWithWatchdog(): Promise<void> {
// Heartbeat-aware watchdog configuration
// If a child is actively updating its heartbeat file, we allow it to run beyond the legacy timeout.
// Defaults are generous to allow first-day passes to finish searches with delays.
const parseEnvNumber = (key: string, fallback: number, min: number, max: number): number => {
const val = Number(process.env[key] || fallback)
if (isNaN(val) || val < min || val > max) {
void log('main', 'SCHEDULER', `Invalid ${key}="${process.env[key]}". Using default ${fallback}`, 'warn')
return fallback
}
return val
}
const staleHeartbeatMin = parseEnvNumber(
process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES ? 'SCHEDULER_STALE_HEARTBEAT_MINUTES' : 'SCHEDULER_PASS_TIMEOUT_MINUTES',
30, 5, 1440
)
const graceMin = parseEnvNumber('SCHEDULER_HEARTBEAT_GRACE_MINUTES', 15, 1, 120)
const hardcapMin = parseEnvNumber('SCHEDULER_PASS_HARDCAP_MINUTES', 480, 30, 1440)
const checkEveryMs = 60_000 // check once per minute
// Validate: stale should be >= grace
if (staleHeartbeatMin < graceMin) {
await log('main', 'SCHEDULER', `Warning: STALE_HEARTBEAT (${staleHeartbeatMin}m) < GRACE (${graceMin}m). Adjusting stale to ${graceMin}m`, 'warn')
}
// Fork per pass: safer because we can terminate a stuck child without killing the scheduler
const forkPerPass = String(process.env.SCHEDULER_FORK_PER_PASS || 'true').toLowerCase() !== 'false'
if (!forkPerPass) {
// In-process fallback (cannot forcefully stop if truly stuck)
await log('main', 'SCHEDULER', `Starting pass in-process (grace ${graceMin}m • stale ${staleHeartbeatMin}m • hardcap ${hardcapMin}m). Cannot force-kill if stuck.`)
// No true watchdog possible in-process; just run
await runOnePass()
return
}
// Child process execution
const indexJs = path.join(__dirname, 'index.js')
await log('main', 'SCHEDULER', `Spawning child for pass: ${process.execPath} ${indexJs}`)
// Prepare heartbeat file path and pass to child
const cfg = loadConfig() as Config
const baseDir = path.join(process.cwd(), cfg.sessionPath || 'sessions')
const hbFile = path.join(baseDir, `heartbeat_${Date.now()}.lock`)
try { fs.mkdirSync(baseDir, { recursive: true }) } catch { /* ignore */ }
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [indexJs], { stdio: 'inherit', env: { ...process.env, SCHEDULER_HEARTBEAT_FILE: hbFile } })
let finished = false
const startedAt = Date.now()
let killTimeout: NodeJS.Timeout | undefined
const killChild = async (signal: NodeJS.Signals) => {
try {
await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn')
child.kill(signal)
} catch { /* ignore */ }
}
const timer = setInterval(() => {
if (finished) return
const now = Date.now()
const runtimeMin = Math.floor((now - startedAt) / 60000)
// Hard cap: always terminate if exceeded
if (runtimeMin >= hardcapMin) {
log('main', 'SCHEDULER', `Pass exceeded hard cap of ${hardcapMin} minutes; terminating...`, 'warn')
void killChild('SIGTERM')
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
return
}
// Before grace, don't judge
if (runtimeMin < graceMin) return
// Check heartbeat freshness
try {
const st = fs.statSync(hbFile)
const mtimeMs = st.mtimeMs
const ageMin = Math.floor((now - mtimeMs) / 60000)
if (ageMin >= staleHeartbeatMin) {
log('main', 'SCHEDULER', `Heartbeat stale for ${ageMin}m (>=${staleHeartbeatMin}m). Terminating child...`, 'warn')
void killChild('SIGTERM')
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
}
} catch (err) {
// If file missing after grace, consider stale
const msg = err instanceof Error ? err.message : String(err)
log('main', 'SCHEDULER', `Heartbeat file check failed: ${msg}. Terminating child...`, 'warn')
void killChild('SIGTERM')
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
}
}, checkEveryMs)
child.on('exit', async (code, signal) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
// Cleanup heartbeat file
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
if (signal) {
await log('main', 'SCHEDULER', `Child exited due to signal: ${signal}`, 'warn')
} else if (code && code !== 0) {
await log('main', 'SCHEDULER', `Child exited with non-zero code: ${code}`, 'warn')
} else {
await log('main', 'SCHEDULER', 'Child pass completed successfully')
}
resolve()
})
child.on('error', async (err) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error')
resolve()
})
})
}
async function runPasses(passes: number): Promise<void> {
const n = Math.max(1, Math.floor(passes || 1))
for (let i = 1; i <= n; i++) {
await log('main', 'SCHEDULER', `Starting pass ${i}/${n}`)
const started = Date.now()
await runOnePassWithWatchdog()
const took = Date.now() - started
const sec = Math.max(1, Math.round(took / 1000))
await log('main', 'SCHEDULER', `Completed pass ${i}/${n}`)
await log('main', 'SCHEDULER', `Pass ${i} duration: ${sec}s`)
}
}
async function main() {
const cfg = loadConfig() as Config & { schedule?: { enabled?: boolean; time?: string; timeZone?: string; runImmediatelyOnStart?: boolean } }
const schedule = cfg.schedule || { enabled: false }
const passes = typeof cfg.passesPerRun === 'number' ? cfg.passesPerRun : 1
const offPerWeek = Math.max(0, Math.min(7, Number(cfg.humanization?.randomOffDaysPerWeek ?? 1)))
let offDays: number[] = [] // 1..7 ISO weekday
let offWeek: number | null = null
type VacRange = { start: string; end: string } | null
let vacMonth: string | null = null // 'yyyy-LL'
let vacRange: VacRange = null // ISO dates 'yyyy-LL-dd'
const refreshOffDays = async (now: { weekNumber: number }) => {
if (offPerWeek <= 0) { offDays = []; offWeek = null; return }
const week = now.weekNumber
if (offWeek === week && offDays.length) return
// choose distinct weekdays [1..7]
const pool = [1,2,3,4,5,6,7]
const chosen: number[] = []
for (let i=0;i<Math.min(offPerWeek,7);i++) {
const idx = Math.floor(Math.random()*pool.length)
chosen.push(pool[idx]!)
pool.splice(idx,1)
}
offDays = chosen.sort((a,b)=>a-b)
offWeek = week
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) => {
// Only when enabled
if (!cfg.vacation?.enabled) { vacRange = null; vacMonth = null; return }
const monthKey = now.toFormat('yyyy-LL')
if (vacMonth === monthKey && vacRange) return
// Determine month days and choose contiguous block
const monthStart = now.startOf('month')
const monthEnd = now.endOf('month')
const totalDays = monthEnd.day
const minD = Math.max(1, Math.min(28, Number(cfg.vacation.minDays ?? 3)))
const maxD = Math.max(minD, Math.min(31, Number(cfg.vacation.maxDays ?? 5)))
const span = (minD === maxD) ? minD : (minD + Math.floor(Math.random() * (maxD - minD + 1)))
const latestStart = Math.max(1, totalDays - span + 1)
const startDay = 1 + Math.floor(Math.random() * latestStart)
const start = monthStart.set({ day: startDay })
const end = start.plus({ days: span - 1 })
vacMonth = monthKey
vacRange = { start: start.toFormat('yyyy-LL-dd'), end: end.toFormat('yyyy-LL-dd') }
await log('main','SCHEDULER',`Selected vacation block this month: ${vacRange.start}${vacRange.end} (${span} day(s))`,'warn')
}
if (!schedule.enabled) {
await log('main', 'SCHEDULER', 'Schedule disabled; running once then exit')
await runPasses(passes)
process.exit(0)
}
const tz = (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
const cronExpressions = normalizeCronExpressions(schedule, tz)
let running = false
// Optional initial jitter before the first run (to vary start time)
const parseJitter = (minKey: string, maxKey: string, fallbackMin: string, fallbackMax: string): [number, number] => {
const minVal = Number(process.env[minKey] || process.env[fallbackMin] || 0)
const maxVal = Number(process.env[maxKey] || process.env[fallbackMax] || 0)
if (isNaN(minVal) || minVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${minKey}="${process.env[minKey]}". Using 0`, 'warn')
return [0, isNaN(maxVal) || maxVal < 0 ? 0 : maxVal]
}
if (isNaN(maxVal) || maxVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${maxKey}="${process.env[maxKey]}". Using 0`, 'warn')
return [minVal, 0]
}
return [minVal, maxVal]
}
const initialJitterBounds = parseJitter('SCHEDULER_INITIAL_JITTER_MINUTES_MIN', 'SCHEDULER_INITIAL_JITTER_MINUTES_MAX', 'SCHEDULER_INITIAL_JITTER_MIN', 'SCHEDULER_INITIAL_JITTER_MAX')
const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0)
// Check if immediate run is enabled (default to false to avoid unexpected runs)
const runImmediate = schedule.runImmediatelyOnStart === true
if (runImmediate && !running) {
running = true
if (applyInitialJitter) {
const min = Math.max(0, Math.min(initialJitterBounds[0], initialJitterBounds[1]))
const max = Math.max(min, initialJitterBounds[0], initialJitterBounds[1])
const jitterSec = (min === max) ? min * 60 : (min * 60 + Math.floor(Math.random() * ((max - min) * 60)))
if (jitterSec > 0) {
await log('main', 'SCHEDULER', `Initial jitter: delaying first run by ${Math.round(jitterSec / 60)} minute(s) (${jitterSec}s)`, 'warn')
await new Promise((r) => setTimeout(r, jitterSec * 1000))
}
}
const nowDT = DateTime.local().setZone(tz)
await chooseVacationRange(nowDT)
await refreshOffDays(nowDT)
const todayIso = nowDT.toFormat('yyyy-LL-dd')
const vr = vacRange as { start: string; end: string } | null
const isVacationToday = !!(vr && todayIso >= vr.start && todayIso <= vr.end)
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: humanization off-day (ISO weekday ${nowDT.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn')
} else {
await runPasses(passes)
}
running = false
}
for (;;) {
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
let extraMs = 0
if (cronExpressions.length === 0) {
const dailyJitterBounds = parseJitter('SCHEDULER_DAILY_JITTER_MINUTES_MIN', 'SCHEDULER_DAILY_JITTER_MINUTES_MAX', 'SCHEDULER_DAILY_JITTER_MIN', 'SCHEDULER_DAILY_JITTER_MAX')
const djMin = dailyJitterBounds[0]
const djMax = dailyJitterBounds[1]
if (djMin > 0 || djMax > 0) {
const mn = Math.max(0, Math.min(djMin, djMax))
const mx = Math.max(mn, 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)
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))
const nowRun = DateTime.local().setZone(tz)
await chooseVacationRange(nowRun)
await refreshOffDays(nowRun)
const todayIso2 = nowRun.toFormat('yyyy-LL-dd')
const vr2 = vacRange as { start: string; end: string } | null
const isVacation = !!(vr2 && todayIso2 >= vr2.start && todayIso2 <= vr2.end)
if (isVacation) {
await log('main','SCHEDULER',`Skipping scheduled run: vacation day (${todayIso2})`,'warn')
continue
}
if (offDays.includes(nowRun.weekday)) {
await log('main','SCHEDULER',`Skipping scheduled run: humanization off-day (ISO weekday ${nowRun.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn')
continue
}
if (!running) {
running = true
await runPasses(passes)
running = false
} else {
await log('main','SCHEDULER','Skipped scheduled trigger because a pass is already running','warn')
}
}
}
main().catch((e) => {
void log('main', 'SCHEDULER', `Fatal error: ${e instanceof Error ? e.message : String(e)}`, 'error')
process.exit(1)
})

View File

@@ -0,0 +1,25 @@
export class AdaptiveThrottler {
private errorCount = 0
private successCount = 0
private window: Array<{ ok: boolean; at: number }> = []
private readonly maxWindow = 50
record(ok: boolean) {
this.window.push({ ok, at: Date.now() })
if (ok) this.successCount++
else this.errorCount++
if (this.window.length > this.maxWindow) {
const removed = this.window.shift()
if (removed) removed.ok ? this.successCount-- : this.errorCount--
}
}
/** Return a multiplier to apply to waits (1 = normal). */
getDelayMultiplier(): number {
const total = Math.max(1, this.successCount + this.errorCount)
const errRatio = this.errorCount / total
// 0% errors -> 1x; 50% errors -> ~1.8x; 80% -> ~2.5x (cap)
const mult = 1 + Math.min(1.5, errRatio * 2)
return Number(mult.toFixed(2))
}
}

264
src/util/Analytics.ts Normal file
View File

@@ -0,0 +1,264 @@
import fs from 'fs'
import path from 'path'
export interface DailyMetrics {
date: string // YYYY-MM-DD
email: string
pointsEarned: number
pointsInitial: number
pointsEnd: number
desktopPoints: number
mobilePoints: number
executionTimeMs: number
successRate: number // 0-1
errorsCount: number
banned: boolean
riskScore?: number
}
export interface AccountHistory {
email: string
totalRuns: number
totalPointsEarned: number
avgPointsPerDay: number
avgExecutionTime: number
successRate: number
lastRunDate: string
banHistory: Array<{ date: string; reason: string }>
riskTrend: number[] // last N risk scores
}
export interface AnalyticsSummary {
period: string // e.g., 'last-7-days', 'last-30-days', 'all-time'
accounts: AccountHistory[]
globalStats: {
totalPoints: number
avgSuccessRate: number
mostProductiveAccount: string
mostRiskyAccount: string
}
}
/**
* Analytics tracks performance metrics, point collection trends, and account health.
* Stores data in JSON files for lightweight persistence and easy analysis.
*/
export class Analytics {
private dataDir: string
constructor(baseDir: string = 'analytics') {
this.dataDir = path.join(process.cwd(), baseDir)
if (!fs.existsSync(this.dataDir)) {
fs.mkdirSync(this.dataDir, { recursive: true })
}
}
/**
* Record metrics for a completed account run
*/
recordRun(metrics: DailyMetrics): void {
const date = metrics.date
const email = this.sanitizeEmail(metrics.email)
const fileName = `${email}_${date}.json`
const filePath = path.join(this.dataDir, fileName)
try {
fs.writeFileSync(filePath, JSON.stringify(metrics, null, 2), 'utf-8')
} catch (error) {
console.error(`Failed to save metrics for ${metrics.email}:`, error)
}
}
/**
* Get history for a specific account
*/
getAccountHistory(email: string, days: number = 30): AccountHistory {
const sanitized = this.sanitizeEmail(email)
const files = this.getAccountFiles(sanitized, days)
if (files.length === 0) {
return {
email,
totalRuns: 0,
totalPointsEarned: 0,
avgPointsPerDay: 0,
avgExecutionTime: 0,
successRate: 1.0,
lastRunDate: 'never',
banHistory: [],
riskTrend: []
}
}
let totalPoints = 0
let totalTime = 0
let successCount = 0
const banHistory: Array<{ date: string; reason: string }> = []
const riskScores: number[] = []
for (const file of files) {
const filePath = path.join(this.dataDir, file)
try {
const data: DailyMetrics = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
totalPoints += data.pointsEarned
totalTime += data.executionTimeMs
if (data.successRate > 0.5) successCount++
if (data.banned) {
banHistory.push({ date: data.date, reason: 'detected' })
}
if (typeof data.riskScore === 'number') {
riskScores.push(data.riskScore)
}
} catch {
continue
}
}
const totalRuns = files.length
const lastFile = files[files.length - 1]
const lastRunDate = lastFile ? lastFile.split('_')[1]?.replace('.json', '') || 'unknown' : 'unknown'
return {
email,
totalRuns,
totalPointsEarned: totalPoints,
avgPointsPerDay: Math.round(totalPoints / Math.max(1, totalRuns)),
avgExecutionTime: Math.round(totalTime / Math.max(1, totalRuns)),
successRate: successCount / Math.max(1, totalRuns),
lastRunDate,
banHistory,
riskTrend: riskScores.slice(-10) // last 10 risk scores
}
}
/**
* Generate a summary report for all accounts
*/
generateSummary(days: number = 30): AnalyticsSummary {
const accountEmails = this.getAllAccounts()
const accounts: AccountHistory[] = []
for (const email of accountEmails) {
accounts.push(this.getAccountHistory(email, days))
}
const totalPoints = accounts.reduce((sum, a) => sum + a.totalPointsEarned, 0)
const avgSuccess = accounts.reduce((sum, a) => sum + a.successRate, 0) / Math.max(1, accounts.length)
let mostProductive = ''
let maxPoints = 0
let mostRisky = ''
let maxRisk = 0
for (const acc of accounts) {
if (acc.totalPointsEarned > maxPoints) {
maxPoints = acc.totalPointsEarned
mostProductive = acc.email
}
const avgRisk = acc.riskTrend.reduce((s, r) => s + r, 0) / Math.max(1, acc.riskTrend.length)
if (avgRisk > maxRisk) {
maxRisk = avgRisk
mostRisky = acc.email
}
}
return {
period: `last-${days}-days`,
accounts,
globalStats: {
totalPoints,
avgSuccessRate: Number(avgSuccess.toFixed(2)),
mostProductiveAccount: mostProductive || 'none',
mostRiskyAccount: mostRisky || 'none'
}
}
}
/**
* Export summary as markdown table (for human readability)
*/
exportMarkdown(days: number = 30): string {
const summary = this.generateSummary(days)
const lines: string[] = []
lines.push(`# Analytics Summary (${summary.period})`)
lines.push('')
lines.push('## Global Stats')
lines.push(`- Total Points: ${summary.globalStats.totalPoints}`)
lines.push(`- Avg Success Rate: ${(summary.globalStats.avgSuccessRate * 100).toFixed(1)}%`)
lines.push(`- Most Productive: ${summary.globalStats.mostProductiveAccount}`)
lines.push(`- Most Risky: ${summary.globalStats.mostRiskyAccount}`)
lines.push('')
lines.push('## Per-Account Breakdown')
lines.push('')
lines.push('| Account | Runs | Total Points | Avg/Day | Success Rate | Last Run | Bans |')
lines.push('|---------|------|--------------|---------|--------------|----------|------|')
for (const acc of summary.accounts) {
const successPct = (acc.successRate * 100).toFixed(0)
const banCount = acc.banHistory.length
lines.push(
`| ${acc.email} | ${acc.totalRuns} | ${acc.totalPointsEarned} | ${acc.avgPointsPerDay} | ${successPct}% | ${acc.lastRunDate} | ${banCount} |`
)
}
return lines.join('\n')
}
/**
* Clean up old analytics files (retention policy)
*/
cleanup(retentionDays: number): void {
const files = fs.readdirSync(this.dataDir)
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000)
for (const file of files) {
if (!file.endsWith('.json')) continue
const filePath = path.join(this.dataDir, file)
try {
const stats = fs.statSync(filePath)
if (stats.mtimeMs < cutoff) {
fs.unlinkSync(filePath)
}
} catch {
continue
}
}
}
private sanitizeEmail(email: string): string {
return email.replace(/[^a-zA-Z0-9@._-]/g, '_')
}
private getAccountFiles(sanitizedEmail: string, days: number): string[] {
const files = fs.readdirSync(this.dataDir)
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - days)
return files
.filter((f: string) => f.startsWith(sanitizedEmail) && f.endsWith('.json'))
.filter((f: string) => {
const datePart = f.split('_')[1]?.replace('.json', '')
if (!datePart) return false
const fileDate = new Date(datePart)
return fileDate >= cutoffDate
})
.sort()
}
private getAllAccounts(): string[] {
const files = fs.readdirSync(this.dataDir)
const emailSet = new Set<string>()
for (const file of files) {
if (!file.endsWith('.json')) continue
const parts = file.split('_')
if (parts.length >= 2) {
const email = parts[0]
if (email) emailSet.add(email)
}
}
return Array.from(emailSet)
}
}

141
src/util/Axios.ts Normal file
View File

@@ -0,0 +1,141 @@
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'
import { AccountProxy } from '../interface/Account'
class AxiosClient {
private instance: AxiosInstance
private account: AccountProxy
constructor(account: AccountProxy) {
this.account = account
this.instance = axios.create()
// If a proxy configuration is provided, set up the agent
if (this.account.url && this.account.proxyAxios) {
const agent = this.getAgentForProxy(this.account)
this.instance.defaults.httpAgent = agent
this.instance.defaults.httpsAgent = agent
}
}
private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent<string> | HttpsProxyAgent<string> | SocksProxyAgent {
const { proxyUrl, protocol } = this.buildProxyUrl(proxyConfig)
const normalized = protocol.replace(/:$/, '')
switch (normalized) {
case 'http':
return new HttpProxyAgent(proxyUrl)
case 'https':
return new HttpsProxyAgent(proxyUrl)
case 'socks':
case 'socks4':
case 'socks5':
return new SocksProxyAgent(proxyUrl)
default:
throw new Error(`Unsupported proxy protocol in "${proxyConfig.url}". Supported: http://, https://, socks://, socks4://, socks5://`)
}
}
private buildProxyUrl(proxyConfig: AccountProxy): { proxyUrl: string; protocol: string } {
const { url, port, username, password } = proxyConfig
if (!url) {
throw new Error('Proxy URL is required when proxyAxios is enabled.')
}
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
const candidate = hasScheme ? url : `http://${url}`
let parsedUrl: URL
try {
parsedUrl = new URL(candidate)
} catch (err) {
throw new Error(`Invalid proxy URL "${url}": ${(err as Error).message}`)
}
const protocol = parsedUrl.protocol.replace(/:$/, '')
const allowed = new Set(['http', 'https', 'socks', 'socks4', 'socks5'])
if (!allowed.has(protocol)) {
throw new Error(`Unsupported proxy protocol in "${url}". Supported: http://, https://, socks://, socks4://, socks5://`)
}
if (!parsedUrl.port) {
if (port) {
parsedUrl.port = String(port)
} else {
throw new Error(`Proxy port missing for "${url}". Provide a port value.`)
}
}
if (username) {
parsedUrl.username = encodeURIComponent(username)
}
if (password) {
parsedUrl.password = encodeURIComponent(password)
}
return { proxyUrl: parsedUrl.toString(), protocol: parsedUrl.protocol }
}
// Generic method to make any Axios request
public async request(config: AxiosRequestConfig, bypassProxy = false): Promise<AxiosResponse> {
if (bypassProxy) {
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
let lastError: unknown
const maxAttempts = 2
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await this.instance.request(config)
} catch (err: unknown) {
lastError = err
const axiosErr = err as AxiosError | undefined
// Detect HTTP proxy auth failures (status 407) and retry without proxy
if (axiosErr && axiosErr.response && axiosErr.response.status === 407) {
if (attempt < maxAttempts) {
await this.sleep(1000 * attempt) // Exponential backoff
}
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// If proxied request fails with common proxy/network errors, retry with backoff
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
const code = e?.code || e?.cause?.code
const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND'
const msg = String(e?.message || '')
const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg)
if (isNetErr || looksLikeProxyIssue) {
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s, etc.
const delayMs = 1000 * Math.pow(2, attempt - 1)
await this.sleep(delayMs)
continue
}
// Last attempt: try without proxy
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// Non-retryable error
throw err
}
}
throw lastError
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
export default AxiosClient

16
src/util/BanDetector.ts Normal file
View File

@@ -0,0 +1,16 @@
export type BanStatus = { status: boolean; reason: string }
const BAN_PATTERNS: Array<{ re: RegExp; reason: string }> = [
{ re: /suspend|suspended|suspension/i, reason: 'account suspended' },
{ re: /locked|lockout|serviceabuse|abuse/i, reason: 'locked or service abuse detected' },
{ re: /unusual.*activity|unusual activity/i, reason: 'unusual activity prompts' },
{ re: /verify.*identity|identity.*verification/i, reason: 'identity verification required' }
]
export function detectBanReason(input: unknown): BanStatus {
const s = input instanceof Error ? (input.message || '') : String(input || '')
for (const p of BAN_PATTERNS) {
if (p.re.test(s)) return { status: true, reason: p.reason }
}
return { status: false, reason: '' }
}

394
src/util/BanPredictor.ts Normal file
View File

@@ -0,0 +1,394 @@
import { RiskManager, RiskEvent } from './RiskManager'
export interface BanPattern {
name: string
description: string
weight: number // 0-10
detected: boolean
evidence: string[]
}
export interface BanPrediction {
riskScore: number // 0-100
confidence: number // 0-1
likelihood: 'very-low' | 'low' | 'medium' | 'high' | 'critical'
patterns: BanPattern[]
recommendation: string
preventiveActions: string[]
}
export interface HistoricalData {
email: string
timestamp: number
banned: boolean
preBanEvents: RiskEvent[]
accountAge: number // days since first use
totalRuns: number
}
/**
* BanPredictor uses machine-learning-style pattern analysis to predict ban risk.
* Learns from historical data and real-time signals to calculate ban probability.
*/
export class BanPredictor {
private riskManager: RiskManager
private history: HistoricalData[] = []
private patterns: BanPattern[] = []
constructor(riskManager: RiskManager) {
this.riskManager = riskManager
this.initializePatterns()
}
/**
* Analyze current state and predict ban risk
*/
predictBanRisk(accountEmail: string, accountAgeDays: number, totalRuns: number): BanPrediction {
const riskMetrics = this.riskManager.assessRisk()
const recentEvents = this.riskManager.getRecentEvents(60)
// Detect patterns
this.detectPatterns(recentEvents, accountAgeDays, totalRuns)
// Calculate base risk from RiskManager
const baseRisk = riskMetrics.score
// Apply ML-style feature weights
const featureScore = this.calculateFeatureScore(recentEvents, accountAgeDays, totalRuns)
// Pattern detection bonus
const detectedPatterns = this.patterns.filter(p => p.detected)
const patternPenalty = detectedPatterns.reduce((sum, p) => sum + p.weight, 0)
// Historical learning adjustment
const historicalAdjustment = this.getHistoricalAdjustment(accountEmail)
// Final risk score (capped at 100)
const finalScore = Math.min(100, baseRisk + featureScore + patternPenalty + historicalAdjustment)
// Calculate confidence (based on data availability)
const confidence = this.calculateConfidence(recentEvents.length, this.history.length)
// Determine likelihood tier
let likelihood: BanPrediction['likelihood']
if (finalScore < 20) likelihood = 'very-low'
else if (finalScore < 40) likelihood = 'low'
else if (finalScore < 60) likelihood = 'medium'
else if (finalScore < 80) likelihood = 'high'
else likelihood = 'critical'
// Generate recommendations
const recommendation = this.generateRecommendation(finalScore)
const preventiveActions = this.generatePreventiveActions(detectedPatterns)
return {
riskScore: Math.round(finalScore),
confidence: Number(confidence.toFixed(2)),
likelihood,
patterns: detectedPatterns,
recommendation,
preventiveActions
}
}
/**
* Record ban event for learning
*/
recordBan(email: string, accountAgeDays: number, totalRuns: number): void {
const preBanEvents = this.riskManager.getRecentEvents(120)
this.history.push({
email,
timestamp: Date.now(),
banned: true,
preBanEvents,
accountAge: accountAgeDays,
totalRuns
})
// Keep history limited (last 100 bans)
if (this.history.length > 100) {
this.history.shift()
}
}
/**
* Record successful run (no ban) for learning
*/
recordSuccess(email: string, accountAgeDays: number, totalRuns: number): void {
this.history.push({
email,
timestamp: Date.now(),
banned: false,
preBanEvents: [],
accountAge: accountAgeDays,
totalRuns
})
if (this.history.length > 100) {
this.history.shift()
}
}
/**
* Initialize known ban patterns
*/
private initializePatterns(): void {
this.patterns = [
{
name: 'rapid-captcha-sequence',
description: 'Multiple captchas in short timespan',
weight: 8,
detected: false,
evidence: []
},
{
name: 'high-error-rate',
description: 'Excessive errors (>50% in last hour)',
weight: 6,
detected: false,
evidence: []
},
{
name: 'timeout-storm',
description: 'Many consecutive timeouts',
weight: 7,
detected: false,
evidence: []
},
{
name: 'suspicious-timing',
description: 'Activity at unusual hours or too consistent',
weight: 5,
detected: false,
evidence: []
},
{
name: 'new-account-aggressive',
description: 'Aggressive activity on young account',
weight: 9,
detected: false,
evidence: []
},
{
name: 'proxy-flagged',
description: 'Proxy showing signs of blacklisting',
weight: 7,
detected: false,
evidence: []
}
]
}
/**
* Detect patterns in recent events
*/
private detectPatterns(events: RiskEvent[], accountAgeDays: number, totalRuns: number): void {
// Reset detection
for (const p of this.patterns) {
p.detected = false
p.evidence = []
}
const captchaEvents = events.filter(e => e.type === 'captcha')
const errorEvents = events.filter(e => e.type === 'error')
const timeoutEvents = events.filter(e => e.type === 'timeout')
// Pattern 1: Rapid captcha sequence
if (captchaEvents.length >= 3) {
const timeSpan = (events[events.length - 1]?.timestamp || 0) - (events[0]?.timestamp || 0)
if (timeSpan < 1800000) { // 30 min
const p = this.patterns.find(pat => pat.name === 'rapid-captcha-sequence')
if (p) {
p.detected = true
p.evidence.push(`${captchaEvents.length} captchas in ${Math.round(timeSpan / 60000)}min`)
}
}
}
// Pattern 2: High error rate
const errorRate = errorEvents.length / Math.max(1, events.length)
if (errorRate > 0.5) {
const p = this.patterns.find(pat => pat.name === 'high-error-rate')
if (p) {
p.detected = true
p.evidence.push(`Error rate: ${(errorRate * 100).toFixed(1)}%`)
}
}
// Pattern 3: Timeout storm
if (timeoutEvents.length >= 5) {
const p = this.patterns.find(pat => pat.name === 'timeout-storm')
if (p) {
p.detected = true
p.evidence.push(`${timeoutEvents.length} timeouts detected`)
}
}
// Pattern 4: Suspicious timing (all events within same hour)
if (events.length > 5) {
const hours = new Set(events.map(e => new Date(e.timestamp).getHours()))
if (hours.size === 1) {
const p = this.patterns.find(pat => pat.name === 'suspicious-timing')
if (p) {
p.detected = true
p.evidence.push('All activity in same hour of day')
}
}
}
// Pattern 5: New account aggressive
if (accountAgeDays < 7 && totalRuns > 10) {
const p = this.patterns.find(pat => pat.name === 'new-account-aggressive')
if (p) {
p.detected = true
p.evidence.push(`Account ${accountAgeDays} days old with ${totalRuns} runs`)
}
}
// Pattern 6: Proxy flagged (heuristic: many ban hints)
const banHints = events.filter(e => e.type === 'ban_hint')
if (banHints.length >= 2) {
const p = this.patterns.find(pat => pat.name === 'proxy-flagged')
if (p) {
p.detected = true
p.evidence.push(`${banHints.length} ban hints detected`)
}
}
}
/**
* Calculate feature-based risk score (ML-style)
*/
private calculateFeatureScore(events: RiskEvent[], accountAgeDays: number, totalRuns: number): number {
let score = 0
// Feature 1: Event density (events per minute)
const eventDensity = events.length / 60
if (eventDensity > 0.5) score += 10
else if (eventDensity > 0.2) score += 5
// Feature 2: Account age risk
if (accountAgeDays < 3) score += 15
else if (accountAgeDays < 7) score += 10
else if (accountAgeDays < 14) score += 5
// Feature 3: Run frequency risk
const runsPerDay = totalRuns / Math.max(1, accountAgeDays)
if (runsPerDay > 3) score += 12
else if (runsPerDay > 2) score += 6
// Feature 4: Severity distribution
const highSeverityEvents = events.filter(e => e.severity >= 7)
if (highSeverityEvents.length > 3) score += 15
else if (highSeverityEvents.length > 1) score += 8
return score
}
/**
* Learn from historical data
*/
private getHistoricalAdjustment(email: string): number {
const accountHistory = this.history.filter(h => h.email === email)
if (accountHistory.length === 0) return 0
const bannedCount = accountHistory.filter(h => h.banned).length
const banRate = bannedCount / accountHistory.length
// If this account has high ban history, increase risk
if (banRate > 0.3) return 20
if (banRate > 0.1) return 10
// If clean history, slight bonus
if (accountHistory.length > 5 && banRate === 0) return -5
return 0
}
/**
* Calculate prediction confidence
*/
private calculateConfidence(eventCount: number, historyCount: number): number {
let confidence = 0.5
// More events = higher confidence
if (eventCount > 20) confidence += 0.2
else if (eventCount > 10) confidence += 0.1
// More historical data = higher confidence
if (historyCount > 50) confidence += 0.2
else if (historyCount > 20) confidence += 0.1
return Math.min(1.0, confidence)
}
/**
* Generate human-readable recommendation
*/
private generateRecommendation(score: number): string {
if (score < 20) {
return 'Safe to proceed. Risk is minimal.'
} else if (score < 40) {
return 'Low risk detected. Monitor for issues but safe to continue.'
} else if (score < 60) {
return 'Moderate risk. Consider increasing delays and reviewing patterns.'
} else if (score < 80) {
return 'High risk! Strongly recommend pausing automation for 24-48 hours.'
} else {
return 'CRITICAL RISK! Stop all automation immediately. Manual review required.'
}
}
/**
* Generate actionable preventive steps
*/
private generatePreventiveActions(patterns: BanPattern[]): string[] {
const actions: string[] = []
if (patterns.some(p => p.name === 'rapid-captcha-sequence')) {
actions.push('Increase search delays to 3-5 minutes minimum')
actions.push('Enable longer cool-down periods between activities')
}
if (patterns.some(p => p.name === 'high-error-rate')) {
actions.push('Check proxy connectivity and health')
actions.push('Verify User-Agent and fingerprint configuration')
}
if (patterns.some(p => p.name === 'new-account-aggressive')) {
actions.push('Slow down activity on new accounts (max 1 run per day for first week)')
actions.push('Allow account to age naturally before heavy automation')
}
if (patterns.some(p => p.name === 'proxy-flagged')) {
actions.push('Rotate to different proxy immediately')
actions.push('Test proxy manually before resuming')
}
if (patterns.some(p => p.name === 'suspicious-timing')) {
actions.push('Randomize execution times across different hours')
actions.push('Enable humanization.allowedWindows with varied schedules')
}
if (actions.length === 0) {
actions.push('Continue monitoring but no immediate action needed')
}
return actions
}
/**
* Export historical data for analysis
*/
exportHistory(): HistoricalData[] {
return [...this.history]
}
/**
* Import historical data (for persistence)
*/
importHistory(data: HistoricalData[]): void {
this.history = data.slice(-100) // Keep last 100
}
}

View File

@@ -0,0 +1,112 @@
import axios from 'axios'
import { Config } from '../interface/Config'
import { Ntfy } from './Ntfy'
import { log } from './Logger'
interface DiscordField {
name: string
value: string
inline?: boolean
}
interface DiscordEmbed {
title?: string
description?: string
color?: number
fields?: DiscordField[]
timestamp?: string
thumbnail?: {
url: string
}
footer?: {
text: string
icon_url?: string
}
}
interface WebhookPayload {
username: string
avatar_url: string
embeds: DiscordEmbed[]
}
/**
* Send a clean, structured Discord webhook notification
*/
export async function ConclusionWebhook(
config: Config,
title: string,
description: string,
fields?: DiscordField[],
color?: number
) {
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
const hasWebhook = config.webhook?.enabled && config.webhook.url
if (!hasConclusion && !hasWebhook) return
const embed: DiscordEmbed = {
title,
description,
color: color || 0x0078D4,
timestamp: new Date().toISOString(),
thumbnail: {
url: 'https://media.discordapp.net/attachments/1430643658788438144/1430644205344133290/rewi-v1.png'
}
}
if (fields && fields.length > 0) {
embed.fields = fields
}
const payload: WebhookPayload = {
username: 'MS Rewi 🎮',
avatar_url: 'https://media.discordapp.net/attachments/1430643658788438144/1430644205344133290/rewi-v1.png',
embeds: [embed]
}
const postWebhook = async (url: string, label: string) => {
const maxAttempts = 3
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 15000
})
log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`)
return
} catch (error) {
lastError = error
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s
const delayMs = 1000 * Math.pow(2, attempt - 1)
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
}
const urls = new Set<string>()
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
if (hasWebhook) urls.add(config.webhook!.url)
await Promise.all(
Array.from(urls).map((url, index) => postWebhook(url, `webhook-${index + 1}`))
)
// Optional NTFY notification
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
const message = `${title}\n${description}${fields ? '\n\n' + fields.map(f => `${f.name}: ${f.value}`).join('\n') : ''}`
const ntfyType = color === 0xFF0000 ? 'error' : color === 0xFFAA00 ? 'warn' : 'log'
try {
await Ntfy(message, ntfyType)
log('main', 'NTFY', 'Notification sent successfully')
} catch (error) {
log('main', 'NTFY', `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
}
}
}

532
src/util/ConfigValidator.ts Normal file
View File

@@ -0,0 +1,532 @@
import fs from 'fs'
import { Config } from '../interface/Config'
import { Account } from '../interface/Account'
export interface ValidationIssue {
severity: 'error' | 'warning' | 'info'
field: string
message: string
suggestion?: string
}
export interface ValidationResult {
valid: boolean
issues: ValidationIssue[]
}
/**
* ConfigValidator performs intelligent validation of config.jsonc and accounts.json
* before execution to catch common mistakes, conflicts, and security issues.
*/
export class ConfigValidator {
/**
* Validate the main config file
*/
static validateConfig(config: Config): ValidationResult {
const issues: ValidationIssue[] = []
// Check baseURL
if (!config.baseURL || !config.baseURL.startsWith('https://')) {
issues.push({
severity: 'error',
field: 'baseURL',
message: 'baseURL must be a valid HTTPS URL',
suggestion: 'Use https://rewards.bing.com'
})
}
// Check sessionPath
if (!config.sessionPath || config.sessionPath.trim() === '') {
issues.push({
severity: 'error',
field: 'sessionPath',
message: 'sessionPath cannot be empty'
})
}
// Check clusters
if (config.clusters < 1) {
issues.push({
severity: 'error',
field: 'clusters',
message: 'clusters must be at least 1'
})
}
if (config.clusters > 10) {
issues.push({
severity: 'warning',
field: 'clusters',
message: 'High cluster count may consume excessive resources',
suggestion: 'Consider using 2-4 clusters for optimal performance'
})
}
// Check globalTimeout
const timeout = this.parseTimeout(config.globalTimeout)
if (timeout < 10000) {
issues.push({
severity: 'warning',
field: 'globalTimeout',
message: 'Very short timeout may cause frequent failures',
suggestion: 'Use at least 15s for stability'
})
}
if (timeout > 120000) {
issues.push({
severity: 'warning',
field: 'globalTimeout',
message: 'Very long timeout may slow down execution',
suggestion: 'Use 30-60s for optimal balance'
})
}
// Check search settings
if (config.searchSettings) {
const searchDelay = config.searchSettings.searchDelay
const minDelay = this.parseTimeout(searchDelay.min)
const maxDelay = this.parseTimeout(searchDelay.max)
if (minDelay >= maxDelay) {
issues.push({
severity: 'error',
field: 'searchSettings.searchDelay',
message: 'min delay must be less than max delay'
})
}
if (minDelay < 10000) {
issues.push({
severity: 'warning',
field: 'searchSettings.searchDelay.min',
message: 'Very short search delays increase ban risk',
suggestion: 'Use at least 30s between searches'
})
}
if (config.searchSettings.retryMobileSearchAmount > 5) {
issues.push({
severity: 'warning',
field: 'searchSettings.retryMobileSearchAmount',
message: 'Too many retries may waste time',
suggestion: 'Use 2-3 retries maximum'
})
}
}
// Check humanization
if (config.humanization) {
if (config.humanization.enabled === false && config.humanization.stopOnBan === true) {
issues.push({
severity: 'warning',
field: 'humanization',
message: 'stopOnBan is enabled but humanization is disabled',
suggestion: 'Enable humanization for better ban protection'
})
}
const actionDelay = config.humanization.actionDelay
if (actionDelay) {
const minAction = this.parseTimeout(actionDelay.min)
const maxAction = this.parseTimeout(actionDelay.max)
if (minAction >= maxAction) {
issues.push({
severity: 'error',
field: 'humanization.actionDelay',
message: 'min action delay must be less than max'
})
}
}
if (config.humanization.allowedWindows && config.humanization.allowedWindows.length > 0) {
for (const window of config.humanization.allowedWindows) {
if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) {
issues.push({
severity: 'error',
field: 'humanization.allowedWindows',
message: `Invalid time window format: ${window}`,
suggestion: 'Use format HH:mm-HH:mm (e.g., 09:00-17:00)'
})
}
}
}
}
// Check proxy config
if (config.proxy) {
if (config.proxy.proxyGoogleTrends === false && config.proxy.proxyBingTerms === false) {
issues.push({
severity: 'info',
field: 'proxy',
message: 'All proxy options disabled - outbound requests will use direct connection'
})
}
}
// Check webhooks
if (config.webhook?.enabled && (!config.webhook.url || config.webhook.url.trim() === '')) {
issues.push({
severity: 'error',
field: 'webhook.url',
message: 'Webhook enabled but URL is empty'
})
}
if (config.conclusionWebhook?.enabled && (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '')) {
issues.push({
severity: 'error',
field: 'conclusionWebhook.url',
message: 'Conclusion webhook enabled but URL is empty'
})
}
// Check ntfy
if (config.ntfy?.enabled) {
if (!config.ntfy.url || config.ntfy.url.trim() === '') {
issues.push({
severity: 'error',
field: 'ntfy.url',
message: 'NTFY enabled but URL is empty'
})
}
if (!config.ntfy.topic || config.ntfy.topic.trim() === '') {
issues.push({
severity: 'error',
field: 'ntfy.topic',
message: 'NTFY enabled but topic is empty'
})
}
}
// Check schedule
if (config.schedule?.enabled) {
if (!config.schedule.timeZone) {
issues.push({
severity: 'warning',
field: 'schedule.timeZone',
message: 'No timeZone specified, defaulting to UTC',
suggestion: 'Set your local timezone (e.g., America/New_York)'
})
}
const useAmPm = config.schedule.useAmPm
const time12 = (config.schedule as unknown as Record<string, unknown>)['time12']
const time24 = (config.schedule as unknown as Record<string, unknown>)['time24']
if (useAmPm === true && (!time12 || (typeof time12 === 'string' && time12.trim() === ''))) {
issues.push({
severity: 'error',
field: 'schedule.time12',
message: 'useAmPm is true but time12 is empty'
})
}
if (useAmPm === false && (!time24 || (typeof time24 === 'string' && time24.trim() === ''))) {
issues.push({
severity: 'error',
field: 'schedule.time24',
message: 'useAmPm is false but time24 is empty'
})
}
}
// Check workers
if (config.workers) {
const allDisabled = !config.workers.doDailySet &&
!config.workers.doMorePromotions &&
!config.workers.doPunchCards &&
!config.workers.doDesktopSearch &&
!config.workers.doMobileSearch &&
!config.workers.doDailyCheckIn &&
!config.workers.doReadToEarn
if (allDisabled) {
issues.push({
severity: 'warning',
field: 'workers',
message: 'All workers are disabled - bot will not perform any tasks',
suggestion: 'Enable at least one worker type'
})
}
}
// Check diagnostics
if (config.diagnostics?.enabled) {
const maxPerRun = config.diagnostics.maxPerRun || 2
if (maxPerRun > 20) {
issues.push({
severity: 'warning',
field: 'diagnostics.maxPerRun',
message: 'Very high maxPerRun may fill disk quickly'
})
}
const retention = config.diagnostics.retentionDays || 7
if (retention > 90) {
issues.push({
severity: 'info',
field: 'diagnostics.retentionDays',
message: 'Long retention period - monitor disk usage'
})
}
}
const valid = !issues.some(i => i.severity === 'error')
return { valid, issues }
}
/**
* Validate accounts.json
*/
static validateAccounts(accounts: Account[]): ValidationResult {
const issues: ValidationIssue[] = []
if (accounts.length === 0) {
issues.push({
severity: 'error',
field: 'accounts',
message: 'No accounts found in accounts.json'
})
return { valid: false, issues }
}
const seenEmails = new Set<string>()
const seenProxies = new Map<string, string[]>() // proxy -> [emails]
for (let i = 0; i < accounts.length; i++) {
const acc = accounts[i]
const prefix = `accounts[${i}]`
if (!acc) continue
// Check email
if (!acc.email || acc.email.trim() === '') {
issues.push({
severity: 'error',
field: `${prefix}.email`,
message: 'Account email is empty'
})
} else {
if (seenEmails.has(acc.email)) {
issues.push({
severity: 'error',
field: `${prefix}.email`,
message: `Duplicate email: ${acc.email}`
})
}
seenEmails.add(acc.email)
if (!/@/.test(acc.email)) {
issues.push({
severity: 'error',
field: `${prefix}.email`,
message: 'Invalid email format'
})
}
}
// Check password
if (!acc.password || acc.password.trim() === '') {
issues.push({
severity: 'error',
field: `${prefix}.password`,
message: 'Account password is empty'
})
} else if (acc.password.length < 8) {
issues.push({
severity: 'warning',
field: `${prefix}.password`,
message: 'Very short password - verify it\'s correct'
})
}
// Check proxy
if (acc.proxy) {
const proxyUrl = acc.proxy.url
if (proxyUrl && proxyUrl.trim() !== '') {
if (!acc.proxy.port) {
issues.push({
severity: 'error',
field: `${prefix}.proxy.port`,
message: 'Proxy URL specified but port is missing'
})
}
// Track proxy reuse
const proxyKey = `${proxyUrl}:${acc.proxy.port}`
if (!seenProxies.has(proxyKey)) {
seenProxies.set(proxyKey, [])
}
seenProxies.get(proxyKey)?.push(acc.email)
}
}
// Check TOTP
if (acc.totp && acc.totp.trim() !== '') {
if (acc.totp.length < 16) {
issues.push({
severity: 'warning',
field: `${prefix}.totp`,
message: 'TOTP secret seems too short - verify it\'s correct'
})
}
}
}
// Warn about excessive proxy reuse
for (const [proxyKey, emails] of seenProxies) {
if (emails.length > 3) {
issues.push({
severity: 'warning',
field: 'accounts.proxy',
message: `Proxy ${proxyKey} used by ${emails.length} accounts - may trigger rate limits`,
suggestion: 'Use different proxies per account for better safety'
})
}
}
const valid = !issues.some(i => i.severity === 'error')
return { valid, issues }
}
/**
* Validate both config and accounts together (cross-checks)
*/
static validateAll(config: Config, accounts: Account[]): ValidationResult {
const configResult = this.validateConfig(config)
const accountsResult = this.validateAccounts(accounts)
const issues = [...configResult.issues, ...accountsResult.issues]
// Cross-validation: clusters vs accounts
if (accounts.length > 0 && config.clusters > accounts.length) {
issues.push({
severity: 'info',
field: 'clusters',
message: `${config.clusters} clusters configured but only ${accounts.length} account(s)`,
suggestion: 'Reduce clusters to match account count for efficiency'
})
}
// Cross-validation: parallel mode with single account
if (config.parallel && accounts.length === 1) {
issues.push({
severity: 'info',
field: 'parallel',
message: 'Parallel mode enabled with single account has no effect',
suggestion: 'Disable parallel mode or add more accounts'
})
}
const valid = !issues.some(i => i.severity === 'error')
return { valid, issues }
}
/**
* Load and validate from file paths
*/
static validateFromFiles(configPath: string, accountsPath: string): ValidationResult {
try {
if (!fs.existsSync(configPath)) {
return {
valid: false,
issues: [{
severity: 'error',
field: 'config',
message: `Config file not found: ${configPath}`
}]
}
}
if (!fs.existsSync(accountsPath)) {
return {
valid: false,
issues: [{
severity: 'error',
field: 'accounts',
message: `Accounts file not found: ${accountsPath}`
}]
}
}
const configRaw = fs.readFileSync(configPath, 'utf-8')
const accountsRaw = fs.readFileSync(accountsPath, 'utf-8')
// Remove JSONC comments (basic approach)
const configJson = configRaw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
const config: Config = JSON.parse(configJson)
const accounts: Account[] = JSON.parse(accountsRaw)
return this.validateAll(config, accounts)
} catch (error) {
return {
valid: false,
issues: [{
severity: 'error',
field: 'parse',
message: `Failed to parse files: ${error instanceof Error ? error.message : String(error)}`
}]
}
}
}
/**
* Print validation results to console with color
* Note: This method intentionally uses console.log for CLI output formatting
*/
static printResults(result: ValidationResult): void {
if (result.valid) {
console.log('✅ Configuration validation passed\n')
} else {
console.log('❌ Configuration validation failed\n')
}
if (result.issues.length === 0) {
console.log('No issues found.')
return
}
const errors = result.issues.filter(i => i.severity === 'error')
const warnings = result.issues.filter(i => i.severity === 'warning')
const infos = result.issues.filter(i => i.severity === 'info')
if (errors.length > 0) {
console.log(`\n🚫 ERRORS (${errors.length}):`)
for (const issue of errors) {
console.log(` ${issue.field}: ${issue.message}`)
if (issue.suggestion) {
console.log(`${issue.suggestion}`)
}
}
}
if (warnings.length > 0) {
console.log(`\n⚠ WARNINGS (${warnings.length}):`)
for (const issue of warnings) {
console.log(` ${issue.field}: ${issue.message}`)
if (issue.suggestion) {
console.log(`${issue.suggestion}`)
}
}
}
if (infos.length > 0) {
console.log(`\n INFO (${infos.length}):`)
for (const issue of infos) {
console.log(` ${issue.field}: ${issue.message}`)
if (issue.suggestion) {
console.log(`${issue.suggestion}`)
}
}
}
console.log()
}
private static parseTimeout(value: number | string): number {
if (typeof value === 'number') return value
const str = String(value).toLowerCase()
if (str.endsWith('ms')) return parseInt(str, 10)
if (str.endsWith('s')) return parseInt(str, 10) * 1000
if (str.endsWith('min')) return parseInt(str, 10) * 60000
return parseInt(str, 10) || 30000
}
}

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

54
src/util/Humanizer.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Page } from 'rebrowser-playwright'
import Util from './Utils'
import type { ConfigHumanization } from '../interface/Config'
export class Humanizer {
private util: Util
private cfg: ConfigHumanization | undefined
constructor(util: Util, cfg?: ConfigHumanization) {
this.util = util
this.cfg = cfg
}
async microGestures(page: Page): Promise<void> {
if (this.cfg && this.cfg.enabled === false) return
const moveProb = this.cfg?.gestureMoveProb ?? 0.4
const scrollProb = this.cfg?.gestureScrollProb ?? 0.2
try {
if (Math.random() < moveProb) {
const x = Math.floor(Math.random() * 40) + 5
const y = Math.floor(Math.random() * 30) + 5
await page.mouse.move(x, y, { steps: 2 }).catch(() => {})
}
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(() => {})
}
} catch {/* noop */}
}
async actionPause(): Promise<void> {
if (this.cfg && this.cfg.enabled === false) return
const defMin = 150
const defMax = 450
let min = defMin
let max = defMax
if (this.cfg?.actionDelay) {
const parse = (v: number | string) => {
if (typeof v === 'number') return v
try {
const n = this.util.stringToMs(String(v))
return Math.max(0, Math.min(n, 10_000))
} catch { return defMin }
}
min = parse(this.cfg.actionDelay.min)
max = parse(this.cfg.actionDelay.max)
if (min > max) [min, max] = [max, min]
max = Math.min(max, 5_000)
}
await this.util.wait(this.util.randomNumber(min, max))
}
}
export default Humanizer

104
src/util/JobState.ts Normal file
View File

@@ -0,0 +1,104 @@
import fs from 'fs'
import path from 'path'
import type { Config } from '../interface/Config'
type AccountCompletionMeta = {
runId?: string
completedAt: string
totalCollected?: number
banned?: boolean
errors?: number
}
type DayState = {
doneOfferIds: string[]
accountCompleted?: boolean
accountMeta?: AccountCompletionMeta
}
type FileState = {
days: Record<string, DayState>
}
export class JobState {
private baseDir: string
constructor(cfg: Config) {
const dir = cfg.jobState?.dir || path.join(process.cwd(), cfg.sessionPath, 'job-state')
this.baseDir = dir
if (!fs.existsSync(this.baseDir)) fs.mkdirSync(this.baseDir, { recursive: true })
}
private fileFor(email: string): string {
const safe = email.replace(/[^a-z0-9._-]/gi, '_')
return path.join(this.baseDir, `${safe}.json`)
}
private load(email: string): FileState {
const file = this.fileFor(email)
if (!fs.existsSync(file)) return { days: {} }
try {
const raw = fs.readFileSync(file, 'utf-8')
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && parsed.days ? parsed as FileState : { days: {} }
} catch { return { days: {} } }
}
private save(email: string, state: FileState): void {
const file = this.fileFor(email)
fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8')
}
isDone(email: string, day: string, offerId: string): boolean {
const st = this.load(email)
const d = st.days[day]
if (!d) return false
return d.doneOfferIds.includes(offerId)
}
markDone(email: string, day: string, offerId: string): void {
const st = this.load(email)
if (!st.days[day]) st.days[day] = { doneOfferIds: [] }
const d = st.days[day]
if (!d.doneOfferIds.includes(offerId)) d.doneOfferIds.push(offerId)
this.save(email, st)
}
isAccountComplete(email: string, day: string): boolean {
const st = this.load(email)
const d = st.days[day]
return d?.accountCompleted === true
}
markAccountComplete(
email: string,
day: string,
meta?: { runId?: string; totalCollected?: number; banned?: boolean; errors?: number }
): void {
const st = this.load(email)
if (!st.days[day]) st.days[day] = { doneOfferIds: [] }
const d = st.days[day]
d.accountCompleted = true
d.accountMeta = {
completedAt: new Date().toISOString(),
runId: meta?.runId,
totalCollected: meta?.totalCollected,
banned: meta?.banned ?? false,
errors: meta?.errors ?? 0
}
this.save(email, st)
}
clearAccountComplete(email: string, day: string): void {
const st = this.load(email)
const d = st.days[day]
if (!d) return
if (d.accountCompleted || d.accountMeta) {
delete d.accountCompleted
delete d.accountMeta
this.save(email, st)
}
}
}
export default JobState

436
src/util/Load.ts Normal file
View File

@@ -0,0 +1,436 @@
import { BrowserContext, Cookie } from 'rebrowser-playwright'
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
import fs from 'fs'
import path from 'path'
import { Account } from '../interface/Account'
import { Config, ConfigSaveFingerprint } from '../interface/Config'
let configCache: Config
let configSourcePath = ''
// Basic JSON comment stripper (supports // line and /* block */ comments while preserving strings)
function stripJsonComments(input: string): string {
let out = ''
let inString = false
let stringChar = ''
let inLine = false
let inBlock = false
for (let i = 0; i < input.length; i++) {
const ch = input[i]!
const next = input[i + 1]
if (inLine) {
if (ch === '\n' || ch === '\r') {
inLine = false
out += ch
}
continue
}
if (inBlock) {
if (ch === '*' && next === '/') {
inBlock = false
i++
}
continue
}
if (inString) {
out += ch
if (ch === '\\') { // escape next char
i++
if (i < input.length) out += input[i]
continue
}
if (ch === stringChar) {
inString = false
}
continue
}
if (ch === '"' || ch === '\'') {
inString = true
stringChar = ch
out += ch
continue
}
if (ch === '/' && next === '/') {
inLine = true
i++
continue
}
if (ch === '/' && next === '*') {
inBlock = true
i++
continue
}
out += ch
}
return out
}
// Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface
function normalizeConfig(raw: unknown): Config {
// Using any here is necessary to support both legacy flat config and new nested config structures
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const n = (raw || {}) as any
// Browser / execution
const headless = n.browser?.headless ?? n.headless ?? false
const globalTimeout = n.browser?.globalTimeout ?? n.globalTimeout ?? '30s'
const parallel = n.execution?.parallel ?? n.parallel ?? false
const runOnZeroPoints = n.execution?.runOnZeroPoints ?? n.runOnZeroPoints ?? false
const clusters = n.execution?.clusters ?? n.clusters ?? 1
const passesPerRun = n.execution?.passesPerRun ?? n.passesPerRun
// Search
const useLocalQueries = n.search?.useLocalQueries ?? n.searchOnBingLocalQueries ?? false
const searchSettingsSrc = n.search?.settings ?? n.searchSettings ?? {}
const delaySrc = searchSettingsSrc.delay ?? searchSettingsSrc.searchDelay ?? { min: '3min', max: '5min' }
const searchSettings = {
useGeoLocaleQueries: !!(searchSettingsSrc.useGeoLocaleQueries ?? false),
scrollRandomResults: !!(searchSettingsSrc.scrollRandomResults ?? false),
clickRandomResults: !!(searchSettingsSrc.clickRandomResults ?? false),
retryMobileSearchAmount: Number(searchSettingsSrc.retryMobileSearchAmount ?? 2),
searchDelay: {
min: delaySrc.min ?? '3min',
max: delaySrc.max ?? '5min'
},
localFallbackCount: Number(searchSettingsSrc.localFallbackCount ?? 25),
extraFallbackRetries: Number(searchSettingsSrc.extraFallbackRetries ?? 1)
}
// Workers
const workers = n.workers ?? {
doDailySet: true,
doMorePromotions: true,
doPunchCards: true,
doDesktopSearch: true,
doMobileSearch: true,
doDailyCheckIn: true,
doReadToEarn: true,
bundleDailySetWithSearch: false
}
// Ensure missing flag gets a default
if (typeof workers.bundleDailySetWithSearch !== 'boolean') workers.bundleDailySetWithSearch = false
// Logging
const logging = n.logging ?? {}
const logExcludeFunc = Array.isArray(logging.excludeFunc) ? logging.excludeFunc : (n.logExcludeFunc ?? [])
const webhookLogExcludeFunc = Array.isArray(logging.webhookExcludeFunc) ? logging.webhookExcludeFunc : (n.webhookLogExcludeFunc ?? [])
// Notifications
const notifications = n.notifications ?? {}
const webhook = notifications.webhook ?? n.webhook ?? { enabled: false, url: '' }
const conclusionWebhook = notifications.conclusionWebhook ?? n.conclusionWebhook ?? { enabled: false, url: '' }
const ntfy = notifications.ntfy ?? n.ntfy ?? { enabled: false, url: '', topic: '', authToken: '' }
// Buy Mode
const buyMode = n.buyMode ?? {}
const buyModeEnabled = typeof buyMode.enabled === 'boolean' ? buyMode.enabled : false
const buyModeMax = typeof buyMode.maxMinutes === 'number' ? buyMode.maxMinutes : 45
// Fingerprinting
const saveFingerprint = (n.fingerprinting?.saveFingerprint ?? n.saveFingerprint) ?? { mobile: false, desktop: false }
// Humanization defaults (single on/off)
if (!n.humanization) n.humanization = {}
if (typeof n.humanization.enabled !== 'boolean') n.humanization.enabled = true
if (typeof n.humanization.stopOnBan !== 'boolean') n.humanization.stopOnBan = false
if (typeof n.humanization.immediateBanAlert !== 'boolean') n.humanization.immediateBanAlert = true
if (typeof n.humanization.randomOffDaysPerWeek !== 'number') {
n.humanization.randomOffDaysPerWeek = 1
}
// Strong default gestures when enabled (explicit values still win)
if (typeof n.humanization.gestureMoveProb !== 'number') {
n.humanization.gestureMoveProb = !n.humanization.enabled ? 0 : 0.5
}
if (typeof n.humanization.gestureScrollProb !== 'number') {
n.humanization.gestureScrollProb = !n.humanization.enabled ? 0 : 0.25
}
// Vacation mode (monthly contiguous off-days)
if (!n.vacation) n.vacation = {}
if (typeof n.vacation.enabled !== 'boolean') n.vacation.enabled = false
const vMin = Number(n.vacation.minDays)
const vMax = Number(n.vacation.maxDays)
n.vacation.minDays = isFinite(vMin) && vMin > 0 ? Math.floor(vMin) : 3
n.vacation.maxDays = isFinite(vMax) && vMax > 0 ? Math.floor(vMax) : 5
if (n.vacation.maxDays < n.vacation.minDays) {
const t = n.vacation.minDays; n.vacation.minDays = n.vacation.maxDays; n.vacation.maxDays = t
}
const riskRaw = (n.riskManagement ?? {}) as Record<string, unknown>
const hasRiskCfg = Object.keys(riskRaw).length > 0
const riskManagement = hasRiskCfg ? {
enabled: riskRaw.enabled === true,
autoAdjustDelays: riskRaw.autoAdjustDelays !== false,
stopOnCritical: riskRaw.stopOnCritical === true,
banPrediction: riskRaw.banPrediction === true,
riskThreshold: typeof riskRaw.riskThreshold === 'number' ? riskRaw.riskThreshold : undefined
} : undefined
const analyticsRaw = (n.analytics ?? {}) as Record<string, unknown>
const hasAnalyticsCfg = Object.keys(analyticsRaw).length > 0
const analytics = hasAnalyticsCfg ? {
enabled: analyticsRaw.enabled === true,
retentionDays: typeof analyticsRaw.retentionDays === 'number' ? analyticsRaw.retentionDays : undefined,
exportMarkdown: analyticsRaw.exportMarkdown === true,
webhookSummary: analyticsRaw.webhookSummary === true
} : undefined
const queryDiversityRaw = (n.queryDiversity ?? {}) as Record<string, unknown>
const hasQueryCfg = Object.keys(queryDiversityRaw).length > 0
const queryDiversity = hasQueryCfg ? {
enabled: queryDiversityRaw.enabled === true,
sources: Array.isArray(queryDiversityRaw.sources) && queryDiversityRaw.sources.length
? (queryDiversityRaw.sources.filter((s: unknown) => typeof s === 'string') as Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>)
: undefined,
maxQueriesPerSource: typeof queryDiversityRaw.maxQueriesPerSource === 'number' ? queryDiversityRaw.maxQueriesPerSource : undefined,
cacheMinutes: typeof queryDiversityRaw.cacheMinutes === 'number' ? queryDiversityRaw.cacheMinutes : undefined
} : undefined
const dryRun = n.dryRun === true
const jobStateRaw = (n.jobState ?? {}) as Record<string, unknown>
const jobState = {
enabled: jobStateRaw.enabled !== false,
dir: typeof jobStateRaw.dir === 'string' ? jobStateRaw.dir : undefined,
skipCompletedAccounts: jobStateRaw.skipCompletedAccounts !== false
}
const cfg: Config = {
baseURL: n.baseURL ?? 'https://rewards.bing.com',
sessionPath: n.sessionPath ?? 'sessions',
headless,
parallel,
runOnZeroPoints,
clusters,
saveFingerprint,
workers,
searchOnBingLocalQueries: !!useLocalQueries,
globalTimeout,
searchSettings,
humanization: n.humanization,
retryPolicy: n.retryPolicy,
jobState,
logExcludeFunc,
webhookLogExcludeFunc,
logging, // retain full logging object for live webhook usage
proxy: n.proxy ?? { proxyGoogleTrends: true, proxyBingTerms: true },
webhook,
conclusionWebhook,
ntfy,
diagnostics: n.diagnostics,
update: n.update,
schedule: n.schedule,
passesPerRun: passesPerRun,
vacation: n.vacation,
buyMode: { enabled: buyModeEnabled, maxMinutes: buyModeMax },
crashRecovery: n.crashRecovery || {},
riskManagement,
analytics,
dryRun,
queryDiversity
}
return cfg
}
export function loadAccounts(): Account[] {
try {
// 1) CLI dev override
let file = 'accounts.json'
if (process.argv.includes('-dev')) {
file = 'accounts.dev.json'
}
// 2) Docker-friendly env overrides
const envJson = process.env.ACCOUNTS_JSON
const envFile = process.env.ACCOUNTS_FILE
let raw: string | undefined
if (envJson && envJson.trim().startsWith('[')) {
raw = envJson
} else if (envFile && envFile.trim()) {
const full = path.isAbsolute(envFile) ? envFile : path.join(process.cwd(), envFile)
if (!fs.existsSync(full)) {
throw new Error(`ACCOUNTS_FILE not found: ${full}`)
}
raw = fs.readFileSync(full, 'utf-8')
} else {
// Try multiple locations to support both root mounts and dist mounts
// Support both .json and .jsonc extensions
const candidates = [
path.join(__dirname, '../', file), // root/accounts.json (preferred)
path.join(__dirname, '../', file + 'c'), // root/accounts.jsonc
path.join(__dirname, '../src', file), // fallback: file kept inside src/
path.join(__dirname, '../src', file + 'c'), // src/accounts.jsonc
path.join(process.cwd(), file), // cwd override
path.join(process.cwd(), file + 'c'), // cwd/accounts.jsonc
path.join(process.cwd(), 'src', file), // cwd/src/accounts.json
path.join(process.cwd(), 'src', file + 'c'), // cwd/src/accounts.jsonc
path.join(__dirname, file), // dist/accounts.json (legacy)
path.join(__dirname, file + 'c') // dist/accounts.jsonc
]
let chosen: string | null = null
for (const p of candidates) {
try { if (fs.existsSync(p)) { chosen = p; break } } catch { /* ignore */ }
}
if (!chosen) throw new Error(`accounts file not found in: ${candidates.join(' | ')}`)
raw = fs.readFileSync(chosen, 'utf-8')
}
// Support comments in accounts file (same as config)
const cleaned = stripJsonComments(raw)
const parsedUnknown = JSON.parse(cleaned)
// Accept either a root array or an object with an `accounts` array, ignore `_note`
const parsed = Array.isArray(parsedUnknown) ? parsedUnknown : (parsedUnknown && typeof parsedUnknown === 'object' && Array.isArray((parsedUnknown as { accounts?: unknown }).accounts) ? (parsedUnknown as { accounts: unknown[] }).accounts : null)
if (!Array.isArray(parsed)) throw new Error('accounts must be an array')
// minimal shape validation
for (const entry of parsed) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const a = entry as any
if (!a || typeof a.email !== 'string' || typeof a.password !== 'string') {
throw new Error('each account must have email and password strings')
}
a.email = String(a.email).trim()
a.password = String(a.password)
if (typeof a.recoveryEmail !== 'string') {
throw new Error(`account ${a.email || '<unknown>'} must include a recoveryEmail string`)
}
a.recoveryEmail = String(a.recoveryEmail).trim()
if (!a.recoveryEmail || !/@/.test(a.recoveryEmail)) {
throw new Error(`account ${a.email} recoveryEmail must be a valid email address`)
}
}
// Filter out disabled accounts (enabled: false)
const allAccounts = parsed as Account[]
const enabledAccounts = allAccounts.filter(acc => acc.enabled !== false)
return enabledAccounts
} catch (error) {
throw new Error(error as string)
}
}
export function getConfigPath(): string { return configSourcePath }
export function loadConfig(): Config {
try {
if (configCache) {
return configCache
}
// 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 */ }
}
if (!cfgPath) throw new Error(`config.json not found in: ${candidates.join(' | ')}`)
const config = fs.readFileSync(cfgPath, 'utf-8')
const text = config.replace(/^\uFEFF/, '')
const raw = JSON.parse(stripJsonComments(text))
const normalized = normalizeConfig(raw)
configCache = normalized // Set as cache
configSourcePath = cfgPath
return normalized
} catch (error) {
throw new Error(error as string)
}
}
export async function loadSessionData(sessionPath: string, email: string, isMobile: boolean, saveFingerprint: ConfigSaveFingerprint) {
try {
// Fetch cookie file
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`)
let cookies: Cookie[] = []
if (fs.existsSync(cookieFile)) {
const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8')
cookies = JSON.parse(cookiesData)
}
// Fetch fingerprint file (support both legacy typo "fingerpint" and corrected "fingerprint")
const baseDir = path.join(__dirname, '../browser/', sessionPath, email)
const legacyFile = path.join(baseDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
const correctFile = path.join(baseDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
let fingerprint!: BrowserFingerprintWithHeaders
const shouldLoad = (saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)
if (shouldLoad) {
const chosen = fs.existsSync(correctFile) ? correctFile : (fs.existsSync(legacyFile) ? legacyFile : '')
if (chosen) {
const fingerprintData = await fs.promises.readFile(chosen, 'utf-8')
fingerprint = JSON.parse(fingerprintData)
}
}
return {
cookies: cookies,
fingerprint: fingerprint
}
} catch (error) {
throw new Error(error as string)
}
}
export async function saveSessionData(sessionPath: string, browser: BrowserContext, email: string, isMobile: boolean): Promise<string> {
try {
const cookies = await browser.cookies()
// Fetch path
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
// Create session dir
if (!fs.existsSync(sessionDir)) {
await fs.promises.mkdir(sessionDir, { recursive: true })
}
// Save cookies to a file
await fs.promises.writeFile(
path.join(sessionDir, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`),
JSON.stringify(cookies, null, 2)
)
return sessionDir
} catch (error) {
throw new Error(error as string)
}
}
export async function saveFingerprintData(sessionPath: string, email: string, isMobile: boolean, fingerprint: BrowserFingerprintWithHeaders): Promise<string> {
try {
// Fetch path
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
// Create session dir
if (!fs.existsSync(sessionDir)) {
await fs.promises.mkdir(sessionDir, { recursive: true })
}
// Save fingerprint to files (write both legacy and corrected names for compatibility)
const legacy = path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
const correct = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
const payload = JSON.stringify(fingerprint)
await fs.promises.writeFile(correct, payload)
try { await fs.promises.writeFile(legacy, payload) } catch { /* ignore */ }
return sessionDir
} catch (error) {
throw new Error(error as string)
}
}

252
src/util/Logger.ts Normal file
View File

@@ -0,0 +1,252 @@
import axios from 'axios'
import chalk from 'chalk'
import { Ntfy } from './Ntfy'
import { loadConfig } from './Load'
import { DISCORD } from '../constants'
const WEBHOOK_USERNAME = 'MS Rewards - Live Logs'
type WebhookBuffer = {
lines: string[]
sending: boolean
timer?: NodeJS.Timeout
}
const webhookBuffers = new Map<string, WebhookBuffer>()
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks
setInterval(() => {
const now = Date.now()
const BUFFER_MAX_AGE_MS = 3600000 // 1 hour
for (const [url, buf] of webhookBuffers.entries()) {
if (!buf.sending && buf.lines.length === 0) {
const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0
if (now - lastActivity > BUFFER_MAX_AGE_MS) {
webhookBuffers.delete(url)
}
}
}
}, 600000) // Check every 10 minutes
function getBuffer(url: string): WebhookBuffer {
let buf = webhookBuffers.get(url)
if (!buf) {
buf = { lines: [], sending: false }
webhookBuffers.set(url, buf)
}
// Track last activity for cleanup
(buf as unknown as { lastActivity: number }).lastActivity = Date.now()
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 > DISCORD.MAX_EMBED_LENGTH && chunk.length > 0) break
buf.lines.shift()
chunk.push(next)
currentLength = projected
}
const content = chunk.join('\n').slice(0, DISCORD.MAX_EMBED_LENGTH)
if (!content) {
continue
}
// Enhanced webhook payload with embed, username and avatar
const payload = {
username: WEBHOOK_USERNAME,
avatar_url: DISCORD.AVATAR_URL,
embeds: [{
description: `\`\`\`\n${content}\n\`\`\``,
color: determineColorFromContent(content),
timestamp: new Date().toISOString()
}]
}
try {
await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout: DISCORD.WEBHOOK_TIMEOUT })
await new Promise(resolve => setTimeout(resolve, DISCORD.RATE_LIMIT_DELAY))
} 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 determineColorFromContent(content: string): number {
const lower = content.toLowerCase()
// Security/Ban alerts - Red
if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) {
return DISCORD.COLOR_RED
}
// Errors - Dark Red
if (lower.includes('[error]') || lower.includes('✗')) {
return DISCORD.COLOR_CRIMSON
}
// Warnings - Orange/Yellow
if (lower.includes('[warn]') || lower.includes('⚠')) {
return DISCORD.COLOR_ORANGE
}
// Success - Green
if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) {
return DISCORD.COLOR_GREEN
}
// Info/Main - Blue
if (lower.includes('[main]')) {
return DISCORD.COLOR_BLUE
}
// Default - Gray
return 0x95A5A6 // Gray
}
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)
}, DISCORD.DEBOUNCE_DELAY)
}
}
// 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()
// Access logging config with fallback for backward compatibility
const configAny = configData as unknown as Record<string, unknown>
const logging = configAny.logging as { excludeFunc?: string[]; logExcludeFunc?: string[] } | undefined
const logExcludeFunc = logging?.excludeFunc ?? (configData as { logExcludeFunc?: string[] }).logExcludeFunc ?? []
if (logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
return
}
const currentTime = new Date().toLocaleString()
const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP'
// Clean string for notifications (no chalk, structured)
type LoggingCfg = { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean }
const loggingCfg: LoggingCfg = (configAny.logging || {}) as LoggingCfg
const shouldRedact = !!loggingCfg.redactEmails
const redact = (s: string) => shouldRedact ? s.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig, (m) => {
const [u, d] = m.split('@'); return `${(u||'').slice(0,2)}***@${d||''}`
}) : s
const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`)
// Define conditions for sending to NTFY
const ntfyConditions = {
log: [
message.toLowerCase().includes('started tasks for account'),
message.toLowerCase().includes('press the number'),
message.toLowerCase().includes('no points to earn')
],
error: [],
warn: [
message.toLowerCase().includes('aborting'),
message.toLowerCase().includes('didn\'t gain')
]
}
// Check if the current log type and message meet the NTFY conditions
try {
if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) {
// Fire-and-forget
Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* ignore ntfy errors */ })
}
} catch { /* ignore */ }
// Console output with better formatting and contextual icons
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓'
const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta
const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green
// Add contextual icon based on title/message (ASCII-safe for Windows PowerShell)
const titleLower = title.toLowerCase()
const msgLower = message.toLowerCase()
// ASCII-safe icons for Windows PowerShell compatibility
const iconMap: Array<[RegExp, string]> = [
[/security|compromised/i, '[SECURITY]'],
[/ban|suspend/i, '[BANNED]'],
[/error/i, '[ERROR]'],
[/warn/i, '[WARN]'],
[/success|complet/i, '[OK]'],
[/login/i, '[LOGIN]'],
[/point/i, '[POINTS]'],
[/search/i, '[SEARCH]'],
[/activity|quiz|poll/i, '[ACTIVITY]'],
[/browser/i, '[BROWSER]'],
[/main/i, '[MAIN]']
]
let icon = ''
for (const [pattern, symbol] of iconMap) {
if (pattern.test(titleLower) || pattern.test(msgLower)) {
icon = chalk.dim(symbol)
break
}
}
const iconPart = icon ? icon + ' ' : ''
const formattedStr = [
chalk.gray(`[${currentTime}]`),
chalk.gray(`[${process.pid}]`),
typeColor(`${typeIndicator}`),
platformColor(`[${platformText}]`),
chalk.bold(`[${title}]`),
iconPart + redact(message)
].join(' ')
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
// Log based on the type
switch (type) {
case 'warn':
applyChalk ? console.warn(applyChalk(formattedStr)) : console.warn(formattedStr)
break
case 'error':
applyChalk ? console.error(applyChalk(formattedStr)) : console.error(formattedStr)
break
default:
applyChalk ? console.log(applyChalk(formattedStr)) : console.log(formattedStr)
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
return new Error(cleanStr)
}
}

27
src/util/Ntfy.ts Normal file
View File

@@ -0,0 +1,27 @@
import { loadConfig } from './Load'
import axios from 'axios'
const NOTIFICATION_TYPES = {
error: { priority: 'max', tags: 'rotating_light' }, // Customize the ERROR icon here, see: https://docs.ntfy.sh/emojis/
warn: { priority: 'high', tags: 'warning' }, // Customize the WARN icon here, see: https://docs.ntfy.sh/emojis/
log: { priority: 'default', tags: 'medal_sports' } // Customize the LOG icon here, see: https://docs.ntfy.sh/emojis/
}
export async function Ntfy(message: string, type: keyof typeof NOTIFICATION_TYPES = 'log'): Promise<void> {
const config = loadConfig().ntfy
if (!config?.enabled || !config.url || !config.topic) return
try {
const { priority, tags } = NOTIFICATION_TYPES[type]
const headers = {
Title: 'Microsoft Rewards Script',
Priority: priority,
Tags: tags,
...(config.authToken && { Authorization: `Bearer ${config.authToken}` })
}
await axios.post(`${config.url}/${config.topic}`, message, { headers })
} catch (error) {
// Silently fail - NTFY is a non-critical notification service
}
}

View File

@@ -0,0 +1,340 @@
import axios from 'axios'
export interface QuerySource {
name: string
weight: number // 0-1, probability of selection
fetchQueries: () => Promise<string[]>
}
export interface QueryDiversityConfig {
sources: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>
deduplicate: boolean
mixStrategies: boolean // Mix different source types in same session
maxQueriesPerSource: number
cacheMinutes: number
}
/**
* QueryDiversityEngine fetches search queries from multiple sources to avoid patterns.
* Supports Google Trends, Reddit, News APIs, Wikipedia, and local fallbacks.
*/
export class QueryDiversityEngine {
private config: QueryDiversityConfig
private cache: Map<string, { queries: string[]; expires: number }> = new Map()
constructor(config?: Partial<QueryDiversityConfig>) {
this.config = {
sources: config?.sources || ['google-trends', 'reddit', 'local-fallback'],
deduplicate: config?.deduplicate !== false,
mixStrategies: config?.mixStrategies !== false,
maxQueriesPerSource: config?.maxQueriesPerSource || 10,
cacheMinutes: config?.cacheMinutes || 30
}
}
/**
* Fetch diverse queries from configured sources
*/
async fetchQueries(count: number): Promise<string[]> {
const allQueries: string[] = []
for (const sourceName of this.config.sources) {
try {
const queries = await this.getFromSource(sourceName)
allQueries.push(...queries.slice(0, this.config.maxQueriesPerSource))
} catch (error) {
// Silently fail and try other sources
}
}
// Deduplicate
let final = this.config.deduplicate ? Array.from(new Set(allQueries)) : allQueries
// Mix strategies: interleave queries from different sources
if (this.config.mixStrategies && this.config.sources.length > 1) {
final = this.interleaveQueries(final, count)
}
// Shuffle and limit to requested count
final = this.shuffleArray(final).slice(0, count)
return final.length > 0 ? final : this.getLocalFallback(count)
}
/**
* Fetch from a specific source with caching
*/
private async getFromSource(source: string): Promise<string[]> {
const cached = this.cache.get(source)
if (cached && Date.now() < cached.expires) {
return cached.queries
}
let queries: string[] = []
switch (source) {
case 'google-trends':
queries = await this.fetchGoogleTrends()
break
case 'reddit':
queries = await this.fetchReddit()
break
case 'news':
queries = await this.fetchNews()
break
case 'wikipedia':
queries = await this.fetchWikipedia()
break
case 'local-fallback':
queries = this.getLocalFallback(20)
break
default:
// Unknown source, skip silently
break
}
this.cache.set(source, {
queries,
expires: Date.now() + (this.config.cacheMinutes * 60000)
})
return queries
}
/**
* Fetch from Google Trends (existing logic can be reused)
*/
private async fetchGoogleTrends(): Promise<string[]> {
try {
const response = await axios.get('https://trends.google.com/trends/api/dailytrends?geo=US', {
timeout: 10000
})
const data = response.data.toString().replace(')]}\',', '')
const parsed = JSON.parse(data)
const queries: string[] = []
for (const item of parsed.default.trendingSearchesDays || []) {
for (const search of item.trendingSearches || []) {
if (search.title?.query) {
queries.push(search.title.query)
}
}
}
return queries.slice(0, 20)
} catch {
return []
}
}
/**
* Fetch from Reddit (top posts from popular subreddits)
*/
private async fetchReddit(): Promise<string[]> {
try {
const subreddits = ['news', 'worldnews', 'todayilearned', 'askreddit', 'technology']
const randomSub = subreddits[Math.floor(Math.random() * subreddits.length)]
const response = await axios.get(`https://www.reddit.com/r/${randomSub}/hot.json?limit=15`, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
const posts = response.data.data.children || []
const queries: string[] = []
for (const post of posts) {
const title = post.data?.title
if (title && title.length > 10 && title.length < 100) {
queries.push(title)
}
}
return queries
} catch {
return []
}
}
/**
* Fetch from News API (requires API key - fallback to headlines scraping)
*/
private async fetchNews(): Promise<string[]> {
try {
// Using NewsAPI.org free tier (limited requests)
const apiKey = process.env.NEWS_API_KEY
if (!apiKey) {
return this.fetchNewsFallback()
}
const response = await axios.get('https://newsapi.org/v2/top-headlines', {
params: {
country: 'us',
pageSize: 15,
apiKey
},
timeout: 10000
})
const articles = response.data.articles || []
return articles.map((a: { title?: string }) => a.title).filter((t: string | undefined) => t && t.length > 10)
} catch {
return this.fetchNewsFallback()
}
}
/**
* Fallback news scraper (BBC/CNN headlines)
*/
private async fetchNewsFallback(): Promise<string[]> {
try {
const response = await axios.get('https://www.bbc.com/news', {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
const html = response.data
const regex = /<h3[^>]*>(.*?)<\/h3>/gi
const matches: RegExpMatchArray[] = []
let match
while ((match = regex.exec(html)) !== null) {
matches.push(match)
}
return matches
.map(m => m[1]?.replace(/<[^>]+>/g, '').trim())
.filter((t: string | undefined) => t && t.length > 10 && t.length < 100)
.slice(0, 10) as string[]
} catch {
return []
}
}
/**
* Fetch from Wikipedia (featured articles / trending topics)
*/
private async fetchWikipedia(): Promise<string[]> {
try {
const response = await axios.get('https://en.wikipedia.org/w/api.php', {
params: {
action: 'query',
list: 'random',
rnnamespace: 0,
rnlimit: 15,
format: 'json'
},
timeout: 10000
})
const pages = response.data.query?.random || []
return pages.map((p: { title?: string }) => p.title).filter((t: string | undefined) => t && t.length > 3)
} catch {
return []
}
}
/**
* Local fallback queries (curated list)
*/
private getLocalFallback(count: number): string[] {
const fallback = [
'weather forecast',
'news today',
'stock market',
'sports scores',
'movie reviews',
'recipes',
'travel destinations',
'health tips',
'technology news',
'best restaurants near me',
'how to cook pasta',
'python tutorial',
'world events',
'climate change',
'electric vehicles',
'space exploration',
'artificial intelligence',
'cryptocurrency',
'gaming news',
'fashion trends',
'fitness workout',
'home improvement',
'gardening tips',
'pet care',
'book recommendations',
'music charts',
'streaming shows',
'historical events',
'science discoveries',
'education resources'
]
return this.shuffleArray(fallback).slice(0, count)
}
/**
* Interleave queries from different sources for diversity
*/
private interleaveQueries(queries: string[], targetCount: number): string[] {
const result: string[] = []
const sourceMap = new Map<string, string[]>()
// Group queries by estimated source (simple heuristic)
for (const q of queries) {
const source = this.guessSource(q)
if (!sourceMap.has(source)) {
sourceMap.set(source, [])
}
sourceMap.get(source)?.push(q)
}
const sources = Array.from(sourceMap.values())
let index = 0
while (result.length < targetCount && sources.some(s => s.length > 0)) {
const source = sources[index % sources.length]
if (source && source.length > 0) {
const q = source.shift()
if (q) result.push(q)
}
index++
}
return result
}
/**
* Guess which source a query came from (basic heuristic)
*/
private guessSource(query: string): string {
if (/^[A-Z]/.test(query) && query.includes(' ')) return 'news'
if (query.length > 80) return 'reddit'
if (/how to|what is|why/i.test(query)) return 'local'
return 'trends'
}
/**
* Shuffle array (Fisher-Yates)
*/
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
/**
* Clear cache (call between runs)
*/
clearCache(): void {
this.cache.clear()
}
}

63
src/util/Retry.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { ConfigRetryPolicy } from '../interface/Config'
import Util from './Utils'
type NumericPolicy = {
maxAttempts: number
baseDelay: number
maxDelay: number
multiplier: number
jitter: number
}
export type Retryable<T> = () => Promise<T>
export class Retry {
private policy: NumericPolicy
constructor(policy?: ConfigRetryPolicy) {
const def: NumericPolicy = {
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 30000,
multiplier: 2,
jitter: 0.2
}
const merged: ConfigRetryPolicy = { ...(policy || {}) }
// normalize string durations
const util = new Util()
const parse = (v: number | string) => {
if (typeof v === 'number') return v
try { return util.stringToMs(String(v)) } catch { return def.baseDelay }
}
this.policy = {
maxAttempts: (merged.maxAttempts as number) ?? def.maxAttempts,
baseDelay: parse(merged.baseDelay ?? def.baseDelay),
maxDelay: parse(merged.maxDelay ?? def.maxDelay),
multiplier: (merged.multiplier as number) ?? def.multiplier,
jitter: (merged.jitter as number) ?? def.jitter
}
}
async run<T>(fn: Retryable<T>, isRetryable?: (e: unknown) => boolean): Promise<T> {
let attempt = 0
let delay = this.policy.baseDelay
let lastErr: unknown
while (attempt < this.policy.maxAttempts) {
try {
return await fn()
} catch (e) {
lastErr = e
attempt += 1
const retry = isRetryable ? isRetryable(e) : true
if (!retry || attempt >= this.policy.maxAttempts) break
const jitter = 1 + (Math.random() * 2 - 1) * this.policy.jitter
const sleep = Math.min(this.policy.maxDelay, Math.max(0, Math.floor(delay * jitter)))
await new Promise((r) => setTimeout(r, sleep))
delay = Math.min(this.policy.maxDelay, Math.floor(delay * (this.policy.multiplier || 2)))
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
}
export default Retry

177
src/util/RiskManager.ts Normal file
View File

@@ -0,0 +1,177 @@
import { AdaptiveThrottler } from './AdaptiveThrottler'
export interface RiskEvent {
type: 'captcha' | 'error' | 'timeout' | 'ban_hint' | 'success'
timestamp: number
severity: number // 0-10, higher = worse
context?: string
}
export interface RiskMetrics {
score: number // 0-100, higher = riskier
level: 'safe' | 'elevated' | 'high' | 'critical'
recommendation: string
delayMultiplier: number
}
/**
* RiskManager monitors account activity patterns and detects early ban signals.
* Integrates with AdaptiveThrottler to dynamically adjust delays based on risk.
*/
export class RiskManager {
private events: RiskEvent[] = []
private readonly maxEvents = 100
private readonly timeWindowMs = 3600000 // 1 hour
private throttler: AdaptiveThrottler
private cooldownUntil: number = 0
constructor(throttler?: AdaptiveThrottler) {
this.throttler = throttler || new AdaptiveThrottler()
}
/**
* Record a risk event (captcha, error, success, etc.)
*/
recordEvent(type: RiskEvent['type'], severity: number, context?: string): void {
const event: RiskEvent = {
type,
timestamp: Date.now(),
severity: Math.max(0, Math.min(10, severity)),
context
}
this.events.push(event)
if (this.events.length > this.maxEvents) {
this.events.shift()
}
// Feed success/error into adaptive throttler
if (type === 'success') {
this.throttler.record(true)
} else if (['error', 'captcha', 'timeout', 'ban_hint'].includes(type)) {
this.throttler.record(false)
}
// Auto cool-down on critical events
if (severity >= 8) {
const coolMs = Math.min(300000, severity * 30000) // max 5min
this.cooldownUntil = Date.now() + coolMs
}
}
/**
* Calculate current risk metrics based on recent events
*/
assessRisk(): RiskMetrics {
const now = Date.now()
const recentEvents = this.events.filter(e => now - e.timestamp < this.timeWindowMs)
if (recentEvents.length === 0) {
return {
score: 0,
level: 'safe',
recommendation: 'Normal operation',
delayMultiplier: 1.0
}
}
// Calculate base risk score (weighted by recency and severity)
let weightedSum = 0
let totalWeight = 0
for (const event of recentEvents) {
const age = now - event.timestamp
const recencyFactor = 1 - (age / this.timeWindowMs) // newer = higher weight
const weight = recencyFactor * (event.severity / 10)
weightedSum += weight * event.severity
totalWeight += weight
}
const baseScore = totalWeight > 0 ? (weightedSum / totalWeight) * 10 : 0
// Penalty for rapid event frequency
const eventRate = recentEvents.length / (this.timeWindowMs / 60000) // events per minute
const frequencyPenalty = Math.min(30, eventRate * 5)
// Bonus penalty for specific patterns
const captchaCount = recentEvents.filter(e => e.type === 'captcha').length
const banHintCount = recentEvents.filter(e => e.type === 'ban_hint').length
const patternPenalty = (captchaCount * 15) + (banHintCount * 25)
const finalScore = Math.min(100, baseScore + frequencyPenalty + patternPenalty)
// Determine risk level
let level: RiskMetrics['level']
let recommendation: string
let delayMultiplier: number
if (finalScore < 20) {
level = 'safe'
recommendation = 'Normal operation'
delayMultiplier = 1.0
} else if (finalScore < 40) {
level = 'elevated'
recommendation = 'Minor issues detected. Increasing delays slightly.'
delayMultiplier = 1.5
} else if (finalScore < 70) {
level = 'high'
recommendation = 'Significant risk detected. Applying heavy throttling.'
delayMultiplier = 2.5
} else {
level = 'critical'
recommendation = 'CRITICAL: High ban risk. Consider stopping or manual review.'
delayMultiplier = 4.0
}
// Apply adaptive throttler multiplier on top
const adaptiveMultiplier = this.throttler.getDelayMultiplier()
delayMultiplier *= adaptiveMultiplier
return {
score: Math.round(finalScore),
level,
recommendation,
delayMultiplier: Number(delayMultiplier.toFixed(2))
}
}
/**
* Check if currently in forced cool-down period
*/
isInCooldown(): boolean {
return Date.now() < this.cooldownUntil
}
/**
* Get remaining cool-down time in milliseconds
*/
getCooldownRemaining(): number {
const remaining = this.cooldownUntil - Date.now()
return Math.max(0, remaining)
}
/**
* Get the adaptive throttler instance for advanced usage
*/
getThrottler(): AdaptiveThrottler {
return this.throttler
}
/**
* Clear all events and reset state (use between accounts)
*/
reset(): void {
this.events = []
this.cooldownUntil = 0
// Keep throttler state across resets for learning
}
/**
* Export events for analytics/logging
*/
getRecentEvents(limitMinutes: number = 60): RiskEvent[] {
const cutoff = Date.now() - (limitMinutes * 60000)
return this.events.filter(e => e.timestamp >= cutoff)
}
}

View File

@@ -0,0 +1,728 @@
import fs from 'fs'
import path from 'path'
import chalk from 'chalk'
import { Config } from '../interface/Config'
import { Account } from '../interface/Account'
interface ValidationError {
severity: 'error' | 'warning'
category: string
message: string
fix?: string
docsLink?: string
}
export class StartupValidator {
private errors: ValidationError[] = []
private warnings: ValidationError[] = []
/**
* Run all validation checks before starting the bot.
* Always returns true - validation is informative, not blocking.
* Displays errors and warnings but lets execution continue.
*/
async validate(config: Config, accounts: Account[]): Promise<boolean> {
console.log(chalk.cyan('\n═══════════════════════════════════════════════════════════════'))
console.log(chalk.cyan(' 🔍 STARTUP VALIDATION - Checking Configuration'))
console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n'))
// Run all validation checks
this.validateAccounts(accounts)
this.validateConfig(config)
this.validateEnvironment()
this.validateFileSystem(config)
this.validateBrowserSettings(config)
this.validateScheduleSettings(config)
this.validateNetworkSettings(config)
this.validateWorkerSettings(config)
this.validateSearchSettings(config)
this.validateHumanizationSettings(config)
this.validateSecuritySettings(config)
// Display results (await to respect the delay)
await this.displayResults()
// Always return true - validation is informative only
// Users can proceed even with errors (they might be false positives)
return true
}
private validateAccounts(accounts: Account[]): void {
if (!accounts || accounts.length === 0) {
this.addError(
'accounts',
'No accounts found in accounts.json',
'Add at least one account to src/accounts.json or src/accounts.jsonc',
'docs/accounts.md'
)
return
}
accounts.forEach((account, index) => {
const prefix = `Account ${index + 1} (${account.email || 'unknown'})`
// Required: email
if (!account.email || typeof account.email !== 'string') {
this.addError(
'accounts',
`${prefix}: Missing or invalid email address`,
'Add a valid email address in the "email" field'
)
} else if (!/@/.test(account.email)) {
this.addError(
'accounts',
`${prefix}: Email format is invalid`,
'Email must contain @ symbol (e.g., user@example.com)'
)
}
// Required: password
if (!account.password || typeof account.password !== 'string') {
this.addError(
'accounts',
`${prefix}: Missing or invalid password`,
'Add your Microsoft account password in the "password" field'
)
} else if (account.password.length < 4) {
this.addWarning(
'accounts',
`${prefix}: Password seems too short (${account.password.length} characters)`,
'Verify this is your correct Microsoft account password'
)
}
// Required: recoveryEmail (NEW - mandatory field)
if (!account.recoveryEmail || typeof account.recoveryEmail !== 'string') {
this.addError(
'accounts',
`${prefix}: Missing required field "recoveryEmail"`,
'Add your recovery/backup email address. This is MANDATORY for security checks.\nExample: "recoveryEmail": "backup@gmail.com"',
'docs/accounts.md'
)
} else if (!/@/.test(account.recoveryEmail)) {
this.addError(
'accounts',
`${prefix}: Recovery email format is invalid`,
'Recovery email must be a valid email address (e.g., backup@gmail.com)'
)
} else if (account.recoveryEmail.trim() === '') {
this.addError(
'accounts',
`${prefix}: Recovery email cannot be empty`,
'Provide the actual recovery email associated with this Microsoft account'
)
}
// Optional but recommended: TOTP
if (!account.totp || account.totp.trim() === '') {
this.addWarning(
'accounts',
`${prefix}: No TOTP (2FA) secret configured`,
'Highly recommended: Set up 2FA and add your TOTP secret for automated login',
'docs/accounts.md'
)
} else {
const cleaned = account.totp.replace(/\s+/g, '')
if (cleaned.length < 16) {
this.addWarning(
'accounts',
`${prefix}: TOTP secret seems too short (${cleaned.length} chars)`,
'Verify you copied the complete Base32 secret from Microsoft Authenticator setup'
)
}
// Check if it's Base32 (A-Z, 2-7)
if (!/^[A-Z2-7\s]+$/i.test(account.totp)) {
this.addWarning(
'accounts',
`${prefix}: TOTP secret contains invalid characters`,
'TOTP secrets should only contain letters A-Z and numbers 2-7 (Base32 format)'
)
}
}
// Proxy validation
if (account.proxy) {
if (account.proxy.url && account.proxy.url.trim() !== '') {
if (!account.proxy.port || account.proxy.port <= 0) {
this.addError(
'accounts',
`${prefix}: Proxy URL provided but port is missing or invalid`,
'Add a valid proxy port number (e.g., 8080, 3128)'
)
}
}
}
})
}
private validateConfig(config: Config): void {
// Headless mode in Docker
if (process.env.FORCE_HEADLESS === '1' && config.headless === false) {
this.addWarning(
'config',
'FORCE_HEADLESS=1 but config.headless is false',
'Docker environment forces headless mode. Your config setting will be overridden.'
)
}
// Parallel mode warning
if (config.parallel === true) {
this.addWarning(
'config',
'Parallel mode enabled (desktop + mobile run simultaneously)',
'This uses more resources. Disable if you experience crashes or timeouts.',
'docs/config.md'
)
}
// Clusters validation
if (config.clusters > 1) {
this.addWarning(
'config',
`Clusters set to ${config.clusters} - accounts will run in parallel`,
'Ensure your system has enough resources (RAM, CPU) for concurrent execution'
)
}
// Global timeout validation
const timeout = typeof config.globalTimeout === 'string'
? config.globalTimeout
: `${config.globalTimeout}ms`
if (timeout === '0' || timeout === '0ms' || timeout === '0s') {
this.addError(
'config',
'Global timeout is set to 0',
'Set a reasonable timeout value (e.g., "30s", "60s") to prevent infinite hangs'
)
}
// Job state validation
if (config.jobState?.enabled === false) {
this.addWarning(
'config',
'Job state tracking is disabled',
'The bot will not save progress. If interrupted, all tasks will restart from scratch.',
'docs/jobstate.md'
)
}
// Risk management validation
if (config.riskManagement?.enabled === true) {
if (config.riskManagement.stopOnCritical === true) {
this.addWarning(
'config',
'Risk management will stop execution if critical risk is detected',
'Bot will halt all accounts if risk score becomes too high'
)
}
}
// Search delays validation
const minDelay = typeof config.searchSettings.searchDelay.min === 'string'
? config.searchSettings.searchDelay.min
: `${config.searchSettings.searchDelay.min}ms`
if (minDelay === '0' || minDelay === '0ms' || minDelay === '0s') {
this.addWarning(
'config',
'Search delay minimum is 0 - this may look suspicious',
'Consider setting a minimum delay (e.g., "1s", "2s") for more natural behavior'
)
}
}
private validateEnvironment(): void {
// Node.js version check
const nodeVersion = process.version
const major = parseInt(nodeVersion.split('.')[0]?.replace('v', '') || '0', 10)
if (major < 18) {
this.addError(
'environment',
`Node.js version ${nodeVersion} is too old`,
'Install Node.js 18 or newer. Visit https://nodejs.org/',
'docs/getting-started.md'
)
} else if (major < 20) {
this.addWarning(
'environment',
`Node.js version ${nodeVersion} is outdated`,
'Consider upgrading to Node.js 20+ for better performance and security'
)
}
// Docker-specific checks
if (process.env.FORCE_HEADLESS === '1') {
this.addWarning(
'environment',
'Running in Docker/containerized environment',
'Make sure volumes are correctly mounted for sessions persistence'
)
}
// Time sync warning for TOTP users
if (process.platform === 'linux') {
this.addWarning(
'environment',
'Linux detected: Ensure system time is synchronized',
'Run: sudo timedatectl set-ntp true (required for TOTP to work correctly)'
)
}
}
private validateFileSystem(config: Config): void {
// Check if sessions directory exists or can be created
const sessionPath = path.isAbsolute(config.sessionPath)
? config.sessionPath
: path.join(process.cwd(), config.sessionPath)
if (!fs.existsSync(sessionPath)) {
try {
fs.mkdirSync(sessionPath, { recursive: true })
this.addWarning(
'filesystem',
`Created missing sessions directory: ${sessionPath}`,
'Session data will be stored here'
)
} catch (error) {
this.addError(
'filesystem',
`Cannot create sessions directory: ${sessionPath}`,
`Check file permissions. Error: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Check job-state directory if enabled
if (config.jobState?.enabled !== false) {
const jobStateDir = config.jobState?.dir
? config.jobState.dir
: path.join(sessionPath, 'job-state')
if (!fs.existsSync(jobStateDir)) {
try {
fs.mkdirSync(jobStateDir, { recursive: true })
} catch (error) {
this.addWarning(
'filesystem',
`Cannot create job-state directory: ${jobStateDir}`,
'Job state tracking may fail. Check file permissions.'
)
}
}
}
// Check diagnostics directory if enabled
if (config.diagnostics?.enabled === true) {
const diagPath = path.join(process.cwd(), 'diagnostics')
if (!fs.existsSync(diagPath)) {
try {
fs.mkdirSync(diagPath, { recursive: true })
} catch (error) {
this.addWarning(
'filesystem',
'Cannot create diagnostics directory',
'Screenshots and HTML snapshots will not be saved'
)
}
}
}
}
private validateBrowserSettings(config: Config): void {
// Headless validation - only warn in Docker/containerized environments
if (!config.headless && process.env.FORCE_HEADLESS === '1') {
this.addWarning(
'browser',
'FORCE_HEADLESS=1 but config.headless is false',
'Docker environment forces headless mode. Your config setting will be overridden.',
'docs/docker.md'
)
}
// Fingerprinting validation
if (config.saveFingerprint?.desktop === false && config.saveFingerprint?.mobile === false) {
this.addWarning(
'browser',
'Fingerprint saving is completely disabled',
'Each run will generate new fingerprints, which may look suspicious'
)
}
}
private validateScheduleSettings(config: Config): void {
if (config.schedule?.enabled === true) {
// Time format validation
const schedRec = config.schedule as Record<string, unknown>
const useAmPm = schedRec.useAmPm
const time12 = typeof schedRec.time12 === 'string' ? schedRec.time12 : ''
const time24 = typeof schedRec.time24 === 'string' ? schedRec.time24 : ''
if (useAmPm === true && (!time12 || time12.trim() === '')) {
this.addError(
'schedule',
'Schedule enabled with useAmPm=true but time12 is missing',
'Add time12 field (e.g., "9:00 AM") or set useAmPm=false',
'docs/schedule.md'
)
}
if (useAmPm === false && (!time24 || time24.trim() === '')) {
this.addError(
'schedule',
'Schedule enabled with useAmPm=false but time24 is missing',
'Add time24 field (e.g., "09:00") or set useAmPm=true',
'docs/schedule.md'
)
}
// Timezone validation
const tz = config.schedule.timeZone || 'UTC'
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
} catch {
this.addError(
'schedule',
`Invalid timezone: ${tz}`,
'Use a valid IANA timezone (e.g., "America/New_York", "Europe/Paris")',
'docs/schedule.md'
)
}
// Vacation mode check
if (config.vacation?.enabled === true) {
if (config.vacation.minDays && config.vacation.maxDays) {
if (config.vacation.minDays > config.vacation.maxDays) {
this.addError(
'schedule',
`Vacation minDays (${config.vacation.minDays}) > maxDays (${config.vacation.maxDays})`,
'Set minDays <= maxDays (e.g., minDays: 2, maxDays: 4)'
)
}
}
}
}
}
private validateNetworkSettings(config: Config): void {
// Webhook validation
if (config.webhook?.enabled === true) {
if (!config.webhook.url || config.webhook.url.trim() === '') {
this.addError(
'network',
'Webhook enabled but URL is missing',
'Add webhook URL or set webhook.enabled=false',
'docs/config.md'
)
} else if (!config.webhook.url.startsWith('http')) {
this.addError(
'network',
`Invalid webhook URL: ${config.webhook.url}`,
'Webhook URL must start with http:// or https://'
)
}
}
// Conclusion webhook validation
if (config.conclusionWebhook?.enabled === true) {
if (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '') {
this.addError(
'network',
'Conclusion webhook enabled but URL is missing',
'Add conclusion webhook URL or disable it'
)
}
}
// NTFY validation
if (config.ntfy?.enabled === true) {
if (!config.ntfy.url || config.ntfy.url.trim() === '') {
this.addError(
'network',
'NTFY enabled but URL is missing',
'Add NTFY server URL or set ntfy.enabled=false',
'docs/ntfy.md'
)
}
if (!config.ntfy.topic || config.ntfy.topic.trim() === '') {
this.addError(
'network',
'NTFY enabled but topic is missing',
'Add NTFY topic name',
'docs/ntfy.md'
)
}
}
}
private validateWorkerSettings(config: Config): void {
const workers = config.workers
// Check if at least one worker is enabled
const anyEnabled = workers.doDailySet || workers.doMorePromotions || workers.doPunchCards ||
workers.doDesktopSearch || workers.doMobileSearch || workers.doDailyCheckIn ||
workers.doReadToEarn
if (!anyEnabled) {
this.addWarning(
'workers',
'All workers are disabled - bot will do nothing',
'Enable at least one worker task (doDailySet, doDesktopSearch, etc.)',
'docs/config.md'
)
}
// Mobile + desktop search check
if (!workers.doDesktopSearch && !workers.doMobileSearch) {
this.addWarning(
'workers',
'Both desktop and mobile searches are disabled',
'Enable at least one search type to earn search points'
)
}
// Bundle validation
if (workers.bundleDailySetWithSearch === true && !workers.doDesktopSearch) {
this.addWarning(
'workers',
'bundleDailySetWithSearch is enabled but doDesktopSearch is disabled',
'Desktop search will not run after Daily Set. Enable doDesktopSearch or disable bundling.'
)
}
}
private validateSearchSettings(config: Config): void {
const search = config.searchSettings
// Retry validation
if (search.retryMobileSearchAmount < 0) {
this.addWarning(
'search',
'retryMobileSearchAmount is negative',
'Set to 0 or positive number (recommended: 2-3)'
)
}
if (search.retryMobileSearchAmount > 10) {
this.addWarning(
'search',
`retryMobileSearchAmount is very high (${search.retryMobileSearchAmount})`,
'High retry count may trigger detection. Recommended: 2-3'
)
}
// Fallback validation
if (search.localFallbackCount !== undefined && search.localFallbackCount < 10) {
this.addWarning(
'search',
`localFallbackCount is low (${search.localFallbackCount})`,
'Consider at least 15-25 fallback queries for variety'
)
}
// Query diversity check
if (config.queryDiversity?.enabled === false && !config.searchOnBingLocalQueries) {
this.addWarning(
'search',
'Query diversity disabled and local queries disabled',
'Bot will only use Google Trends. Enable one query source for better variety.',
'docs/config.md'
)
}
}
private validateHumanizationSettings(config: Config): void {
const human = config.humanization
if (!human || human.enabled === false) {
this.addWarning(
'humanization',
'Humanization is completely disabled',
'This increases detection risk. Consider enabling for safer automation.',
'docs/config.md'
)
return
}
// Gesture probabilities
if (human.gestureMoveProb !== undefined) {
if (human.gestureMoveProb < 0 || human.gestureMoveProb > 1) {
this.addError(
'humanization',
`gestureMoveProb must be between 0 and 1 (got ${human.gestureMoveProb})`,
'Set a probability value between 0.0 and 1.0'
)
}
if (human.gestureMoveProb === 0) {
this.addWarning(
'humanization',
'Mouse gestures disabled (gestureMoveProb=0)',
'This may look robotic. Consider 0.3-0.7 for natural behavior.'
)
}
}
if (human.gestureScrollProb !== undefined) {
if (human.gestureScrollProb < 0 || human.gestureScrollProb > 1) {
this.addError(
'humanization',
`gestureScrollProb must be between 0 and 1 (got ${human.gestureScrollProb})`,
'Set a probability value between 0.0 and 1.0'
)
}
}
// Action delays
if (human.actionDelay) {
const minMs = typeof human.actionDelay.min === 'string'
? parseInt(human.actionDelay.min, 10)
: human.actionDelay.min
const maxMs = typeof human.actionDelay.max === 'string'
? parseInt(human.actionDelay.max, 10)
: human.actionDelay.max
if (minMs > maxMs) {
this.addError(
'humanization',
'actionDelay min is greater than max',
`Fix: min=${minMs} should be <= max=${maxMs}`
)
}
}
// Random off days
if (human.randomOffDaysPerWeek !== undefined) {
if (human.randomOffDaysPerWeek < 0 || human.randomOffDaysPerWeek > 7) {
this.addError(
'humanization',
`randomOffDaysPerWeek must be 0-7 (got ${human.randomOffDaysPerWeek})`,
'Set to a value between 0 (no off days) and 7 (always off)'
)
}
}
// Allowed windows validation
if (human.allowedWindows && Array.isArray(human.allowedWindows)) {
human.allowedWindows.forEach((window, idx) => {
if (typeof window !== 'string') {
this.addError(
'humanization',
`allowedWindows[${idx}] is not a string`,
'Format: "HH:mm-HH:mm" (e.g., "09:00-17:00")'
)
} else if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) {
this.addWarning(
'humanization',
`allowedWindows[${idx}] format may be invalid: "${window}"`,
'Expected format: "HH:mm-HH:mm" (24-hour, e.g., "09:00-17:00")'
)
}
})
}
}
private validateSecuritySettings(config: Config): void {
// Check logging redaction
const logging = config.logging as { redactEmails?: boolean } | undefined
if (logging && logging.redactEmails === false) {
this.addWarning(
'security',
'Email redaction is disabled in logs',
'Enable redactEmails=true if you share logs publicly',
'docs/security.md'
)
}
// Removed diagnostics warning - reports/ folder with masked emails is safe for debugging
// Proxy exposure check
if (config.proxy?.proxyGoogleTrends === false && config.proxy?.proxyBingTerms === false) {
this.addWarning(
'security',
'All external API calls will use your real IP',
'Consider enabling proxy for Google Trends or Bing Terms to mask your IP'
)
}
// Crash recovery
if (config.crashRecovery?.autoRestart === true) {
const maxRestarts = config.crashRecovery.maxRestarts ?? 2
if (maxRestarts > 5) {
this.addWarning(
'security',
`Crash recovery maxRestarts is high (${maxRestarts})`,
'Excessive restarts on errors may trigger rate limits or detection'
)
}
}
}
private addError(category: string, message: string, fix?: string, docsLink?: string): void {
this.errors.push({ severity: 'error', category, message, fix, docsLink })
}
private addWarning(category: string, message: string, fix?: string, docsLink?: string): void {
this.warnings.push({ severity: 'warning', category, message, fix, docsLink })
}
private async displayResults(): Promise<void> {
// Display errors
if (this.errors.length > 0) {
console.log(chalk.red('\n❌ VALIDATION ERRORS FOUND:\n'))
this.errors.forEach((err, index) => {
console.log(chalk.red(` ${index + 1}. [${err.category.toUpperCase()}] ${err.message}`))
if (err.fix) {
console.log(chalk.yellow(` 💡 Fix: ${err.fix}`))
}
if (err.docsLink) {
console.log(chalk.cyan(` 📖 Documentation: ${err.docsLink}`))
}
console.log('')
})
}
// Display warnings
if (this.warnings.length > 0) {
console.log(chalk.yellow('\n⚠ WARNINGS:\n'))
this.warnings.forEach((warn, index) => {
console.log(chalk.yellow(` ${index + 1}. [${warn.category.toUpperCase()}] ${warn.message}`))
if (warn.fix) {
console.log(chalk.gray(` 💡 Suggestion: ${warn.fix}`))
}
if (warn.docsLink) {
console.log(chalk.cyan(` 📖 Documentation: ${warn.docsLink}`))
}
console.log('')
})
}
// Summary
console.log(chalk.cyan('═══════════════════════════════════════════════════════════════'))
if (this.errors.length === 0 && this.warnings.length === 0) {
console.log(chalk.green(' ✅ All validation checks passed! Configuration looks good.'))
console.log(chalk.gray(' → Starting bot execution...'))
} else {
console.log(chalk.white(` Found: ${chalk.red(`${this.errors.length} error(s)`)} | ${chalk.yellow(`${this.warnings.length} warning(s)`)}`))
if (this.errors.length > 0) {
console.log(chalk.red('\n ⚠️ CRITICAL ERRORS DETECTED'))
console.log(chalk.white(' → Bot will continue, but these issues may cause failures'))
console.log(chalk.white(' → Review errors above and fix them for stable operation'))
console.log(chalk.gray(' → If you believe these are false positives, you can ignore them'))
} else {
console.log(chalk.yellow('\n ⚠️ Warnings detected - review recommended'))
console.log(chalk.gray(' → Bot will continue normally'))
}
console.log(chalk.white('\n 📖 Full documentation: docs/index.md'))
console.log(chalk.gray(' → Proceeding with execution in 5 seconds...'))
// Give user time to read (5 seconds for errors, 5 seconds for warnings)
await new Promise(resolve => setTimeout(resolve, 5000))
}
console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n'))
}
}

84
src/util/Totp.ts Normal file
View File

@@ -0,0 +1,84 @@
import crypto from 'crypto'
/**
* Decode Base32 (RFC 4648) to a Buffer.
* Accepts lowercase/uppercase, optional padding.
*/
function base32Decode(input: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const clean = input.toUpperCase().replace(/=+$/g, '').replace(/[^A-Z2-7]/g, '')
let bits = 0
let value = 0
const bytes: number[] = []
for (const char of clean) {
const idx = alphabet.indexOf(char)
if (idx < 0) continue
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
bits -= 8
bytes.push((value >>> bits) & 0xff)
}
}
return Buffer.from(bytes)
}
/**
* Generate an HMAC using Node's crypto and return Buffer.
*/
function hmac(algorithm: string, key: Buffer, data: Buffer): Buffer {
return crypto.createHmac(algorithm, key).update(data).digest()
}
export type TotpOptions = { digits?: number; step?: number; algorithm?: 'SHA1' | 'SHA256' | 'SHA512' }
/**
* Generate TOTP per RFC 6238.
* @param secretBase32 - shared secret in Base32
* @param time - Unix time in seconds (defaults to now)
* @param options - { digits, step, algorithm }
* @returns numeric TOTP as string (zero-padded)
*/
export function generateTOTP(
secretBase32: string,
time: number = Math.floor(Date.now() / 1000),
options?: TotpOptions
): string {
const digits = options?.digits ?? 6
const step = options?.step ?? 30
const alg = (options?.algorithm ?? 'SHA1').toUpperCase()
const key = base32Decode(secretBase32)
const counter = Math.floor(time / step)
// 8-byte big-endian counter
const counterBuffer = Buffer.alloc(8)
counterBuffer.writeBigUInt64BE(BigInt(counter), 0)
let hmacAlg: string
if (alg === 'SHA1') hmacAlg = 'sha1'
else if (alg === 'SHA256') hmacAlg = 'sha256'
else if (alg === 'SHA512') hmacAlg = 'sha512'
else throw new Error('Unsupported algorithm. Use SHA1, SHA256 or SHA512.')
const hash = hmac(hmacAlg, key, counterBuffer)
if (!hash || hash.length < 20) {
// Minimal sanity check; for SHA1 length is 20
throw new Error('Invalid HMAC output for TOTP')
}
// Dynamic truncation
const offset = hash[hash.length - 1]! & 0x0f
if (offset + 3 >= hash.length) {
throw new Error('Invalid dynamic truncation offset')
}
const code =
((hash[offset]! & 0x7f) << 24) |
((hash[offset + 1]! & 0xff) << 16) |
((hash[offset + 2]! & 0xff) << 8) |
(hash[offset + 3]! & 0xff)
const otp = (code % 10 ** digits).toString().padStart(digits, '0')
return otp
}

300
src/util/UserAgent.ts Normal file
View File

@@ -0,0 +1,300 @@
import axios from 'axios'
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
import { log } from './Logger'
import Retry from './Retry'
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
// Static fallback versions (updated periodically, valid as of October 2024)
const FALLBACK_EDGE_VERSIONS: EdgeVersionResult = {
android: '130.0.2849.66',
windows: '130.0.2849.68'
}
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)
const app = await getAppComponents(isMobile)
const uaTemplate = isMobile ?
`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Mobile Safari/537.36 EdgA/${app.edge_version}` :
`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Safari/537.36 Edg/${app.edge_version}`
const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
const uaMetadata = {
mobile: isMobile,
isMobile,
platform: isMobile ? 'Android' : 'Windows',
fullVersionList: [
{ brand: 'Not/A)Brand', version: `${NOT_A_BRAND_VERSION}.0.0.0` },
{ brand: 'Microsoft Edge', version: app['edge_version'] },
{ brand: 'Chromium', version: app['chrome_version'] }
],
brands: [
{ brand: 'Not/A)Brand', version: NOT_A_BRAND_VERSION },
{ brand: 'Microsoft Edge', version: app['edge_major_version'] },
{ brand: 'Chromium', version: app['chrome_major_version'] }
],
platformVersion,
architecture: isMobile ? '' : 'x86',
bitness: isMobile ? '' : '64',
model: '',
uaFullVersion: app['chrome_version']
}
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
}
export async function getChromeVersion(isMobile: boolean): Promise<string> {
try {
const request = {
url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json',
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
const response = await axios(request)
const data: ChromeVersion = response.data
return data.channels.Stable.version
} catch (error) {
throw log(isMobile, 'USERAGENT-CHROME-VERSION', 'An error occurred:' + error, 'error')
}
}
export async function getEdgeVersions(isMobile: boolean): Promise<EdgeVersionResult> {
const now = Date.now()
// Return cached version if still valid
if (edgeVersionCache && edgeVersionCache.expiresAt > now) {
return edgeVersionCache.data
}
// Wait for in-flight request if one exists
if (edgeVersionInFlight) {
try {
return await edgeVersionInFlight
} catch (error) {
if (edgeVersionCache) {
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using cached Edge versions after in-flight failure', 'warn')
return edgeVersionCache.data
}
// Fall through to fetch attempt below
}
}
// Attempt to fetch fresh versions
const fetchPromise = fetchEdgeVersionsWithRetry(isMobile)
.then(result => {
edgeVersionCache = { data: result, expiresAt: Date.now() + EDGE_VERSION_CACHE_TTL_MS }
edgeVersionInFlight = null
return result
})
.catch(() => {
edgeVersionInFlight = null
// Try stale cache first
if (edgeVersionCache) {
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using stale cached Edge versions due to fetch failure', 'warn')
return edgeVersionCache.data
}
// Fall back to static versions
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using static fallback Edge versions (API unavailable)', 'warn')
edgeVersionCache = { data: FALLBACK_EDGE_VERSIONS, expiresAt: Date.now() + EDGE_VERSION_CACHE_TTL_MS }
return FALLBACK_EDGE_VERSIONS
})
edgeVersionInFlight = fetchPromise
return fetchPromise
}
export function getSystemComponents(mobile: boolean): string {
if (mobile) {
const androidVersion = 10 + Math.floor(Math.random() * 5)
return `Linux; Android ${androidVersion}; K`
}
return 'Windows NT 10.0; Win64; x64'
}
export async function getAppComponents(isMobile: boolean) {
const versions = await getEdgeVersions(isMobile)
const edgeVersion = isMobile ? versions.android : versions.windows as string
const edgeMajorVersion = edgeVersion?.split('.')[0]
const chromeVersion = await getChromeVersion(isMobile)
const chromeMajorVersion = chromeVersion?.split('.')[0]
const chromeReducedVersion = `${chromeMajorVersion}.0.0.0`
return {
not_a_brand_version: `${NOT_A_BRAND_VERSION}.0.0.0`,
not_a_brand_major_version: NOT_A_BRAND_VERSION,
edge_version: edgeVersion as string,
edge_major_version: edgeMajorVersion as string,
chrome_version: chromeVersion as string,
chrome_major_version: chromeMajorVersion as string,
chrome_reduced_version: chromeReducedVersion as string
}
}
async 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> {
let lastError: unknown = null
// Try axios first
try {
const response = await axios<EdgeVersion[]>({
url: EDGE_VERSION_URL,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 10000,
validateStatus: (status) => status === 200
})
if (!response.data || !Array.isArray(response.data)) {
throw new Error('Invalid response format from Edge API')
}
return mapEdgeVersions(response.data)
} catch (axiosError) {
lastError = axiosError
// Continue to fallback
}
// Try native fetch as fallback
try {
const fallback = await tryNativeFetchFallback()
if (fallback) {
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Axios failed, using native fetch fallback', 'warn')
return fallback
}
} catch (fetchError) {
lastError = fetchError
}
// Both methods failed
const errorMsg = lastError instanceof Error ? lastError.message : String(lastError)
throw new Error(`Failed to fetch Edge versions: ${errorMsg}`)
}
async function tryNativeFetchFallback(): Promise<EdgeVersionResult | null> {
let timeoutHandle: NodeJS.Timeout | undefined
try {
const controller = new AbortController()
timeoutHandle = setTimeout(() => controller.abort(), 10000)
const response = await fetch(EDGE_VERSION_URL, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
signal: controller.signal
})
clearTimeout(timeoutHandle)
timeoutHandle = undefined
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json() as EdgeVersion[]
if (!Array.isArray(data)) {
throw new Error('Invalid response format')
}
return mapEdgeVersions(data)
} catch (error) {
if (timeoutHandle) clearTimeout(timeoutHandle)
return null
}
}
function mapEdgeVersions(data: EdgeVersion[]): EdgeVersionResult {
if (!Array.isArray(data) || data.length === 0) {
throw new Error('Edge API returned empty or invalid data')
}
const stable = data.find(entry => entry?.Product?.toLowerCase() === 'stable')
?? data.find(entry => entry?.Product && /stable/i.test(entry.Product))
if (!stable || !stable.Releases || !Array.isArray(stable.Releases)) {
throw new Error('Stable Edge channel not found or invalid format')
}
const androidRelease = stable.Releases.find(release =>
release?.Platform === Platform.Android && release?.ProductVersion
)
const windowsRelease = stable.Releases.find(release =>
release?.Platform === Platform.Windows &&
release?.Architecture === Architecture.X64 &&
release?.ProductVersion
) ?? stable.Releases.find(release =>
release?.Platform === Platform.Windows &&
release?.ProductVersion
)
const result: EdgeVersionResult = {
android: androidRelease?.ProductVersion,
windows: windowsRelease?.ProductVersion
}
// Validate at least one version was found
if (!result.android && !result.windows) {
throw new Error('No valid Edge versions found in API response')
}
return result
}
export async function updateFingerprintUserAgent(fingerprint: BrowserFingerprintWithHeaders, isMobile: boolean): Promise<BrowserFingerprintWithHeaders> {
try {
const userAgentData = await getUserAgent(isMobile)
const componentData = await getAppComponents(isMobile)
fingerprint.fingerprint.navigator.userAgentData = userAgentData.userAgentMetadata
fingerprint.fingerprint.navigator.userAgent = userAgentData.userAgent
fingerprint.fingerprint.navigator.appVersion = userAgentData.userAgent.replace(`${fingerprint.fingerprint.navigator.appCodeName}/`, '')
fingerprint.headers['user-agent'] = userAgentData.userAgent
fingerprint.headers['sec-ch-ua'] = `"Microsoft Edge";v="${componentData.edge_major_version}", "Not=A?Brand";v="${componentData.not_a_brand_major_version}", "Chromium";v="${componentData.chrome_major_version}"`
fingerprint.headers['sec-ch-ua-full-version-list'] = `"Microsoft Edge";v="${componentData.edge_version}", "Not=A?Brand";v="${componentData.not_a_brand_version}", "Chromium";v="${componentData.chrome_version}"`
return fingerprint
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
log(isMobile, 'USER-AGENT-UPDATE', `Failed to update fingerprint: ${errorMsg}`, 'error')
throw new Error(`User-Agent update failed: ${errorMsg}`)
}
}

73
src/util/Utils.ts Normal file
View File

@@ -0,0 +1,73 @@
import ms from 'ms'
export default class Util {
async wait(ms: number): Promise<void> {
// Safety check: prevent extremely long or negative waits
const MAX_WAIT_MS = 3600000 // 1 hour max
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
if (ms !== safeMs) {
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`)
}
return new Promise<void>((resolve) => {
setTimeout(resolve, safeMs)
})
}
async waitRandom(minMs: number, maxMs: number): Promise<void> {
const delta = this.randomNumber(minMs, maxMs)
return this.wait(delta)
}
getFormattedDate(ms = Date.now()): string {
const today = new Date(ms)
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
const day = String(today.getDate()).padStart(2, '0')
const year = today.getFullYear()
return `${month}/${day}/${year}`
}
shuffleArray<T>(array: T[]): T[] {
return array.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
randomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
chunkArray<T>(arr: T[], numChunks: number): T[][] {
// Validate input to prevent division by zero or invalid chunks
if (numChunks <= 0) {
throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`)
}
if (arr.length === 0) {
return []
}
const safeNumChunks = Math.max(1, Math.floor(numChunks))
const chunkSize = Math.ceil(arr.length / safeNumChunks)
const chunks: T[][] = []
for (let i = 0; i < arr.length; i += chunkSize) {
const chunk = arr.slice(i, i + chunkSize)
chunks.push(chunk)
}
return chunks
}
stringToMs(input: string | number): number {
const milisec = ms(input.toString())
if (!milisec) {
throw new Error('The string provided cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"')
}
return milisec
}
}