Files
Microsoft-Rewards-Bot/src/functions/Workers.ts
LightZirconite 9fb5911fa2 feat: Centralize browser instance creation to eliminate duplication in Desktop and Mobile flows
fix: Update error message for HTTP 400 checks to English
refactor: Improve logging for network idle wait timeout in Workers
fix: Initialize lastError in Axios client to prevent undefined errors
chore: Update tsconfig to include path mappings for better module resolution
2025-11-09 17:56:46 +01:00

278 lines
12 KiB
TypeScript

import { Page } from 'rebrowser-playwright'
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
import { MicrosoftRewardsBot } from '../index'
import { AdaptiveThrottler } from '../util/AdaptiveThrottler'
import JobState from '../util/JobState'
import { logError } from '../util/Logger'
import { Retry } from '../util/Retry'
// Selector patterns (extracted to avoid magic strings)
const ACTIVITY_SELECTORS = {
byName: (name: string) => `[data-bi-id^="${name}"] .pointLink:not(.contentContainer .pointLink)`,
byOfferId: (offerId: string) => `[data-bi-id^="${offerId}"] .pointLink:not(.contentContainer .pointLink)`
} as const
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(logError('PUNCH-CARD', 'Network idle wait timeout (non-critical)', this.bot.isMobile))
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) {
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 ACTIVITY_SELECTORS.byName(activity.name)
}
// Validate offerId exists before using it in selector
if (!activity.offerId) {
this.bot.log(this.bot.isMobile, 'WORKERS', `Activity "${activity.name || activity.title}" has no offerId, falling back to name-based selector`, 'warn')
return ACTIVITY_SELECTORS.byName(activity.name)
}
return ACTIVITY_SELECTORS.byOfferId(activity.offerId)
}
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(logError('WORKERS', 'Network idle wait failed', this.bot.isMobile))
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}"`)
// Check if element exists before clicking (avoid 30s timeout)
try {
await page.waitForSelector(selector, { timeout: 5000 })
} catch (error) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Activity selector not found (might be completed or unavailable): ${selector}`, 'warn')
return // Skip this activity gracefully instead of waiting 30s
}
// Click with timeout to prevent indefinite hangs
await page.click(selector, { timeout: 10000 })
page = await this.bot.browser.utils.getLatestTab(page)
// FIXED: Use AbortController for proper cancellation instead of race condition
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
const controller = new AbortController()
const timeoutHandle = setTimeout(() => {
controller.abort()
}, timeoutMs)
const runWithTimeout = async (p: Promise<void>) => {
try {
await p
clearTimeout(timeoutHandle)
} catch (error) {
clearTimeout(timeoutHandle)
throw error
}
}
await retry.run(async () => {
try {
await runWithTimeout(this.bot.activities.run(page, activity))
throttle.record(true)
} catch (e) {
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()
await this.bot.utils.waitRandom(
Math.floor(min * multiplier),
Math.floor(max * multiplier)
)
}
}