mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
feat: add free rewards redemption feature; require phone number in account config
This commit is contained in:
55
package-lock.json
generated
55
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"fingerprint-generator": "^2.1.66",
|
||||
"fingerprint-injector": "^2.1.66",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
@@ -20,7 +21,7 @@
|
||||
"luxon": "^3.5.0",
|
||||
"ms": "^2.1.3",
|
||||
"node-cron": "3.0.3",
|
||||
"playwright": "1.56.1",
|
||||
"playwright": "1.52.0",
|
||||
"rebrowser-playwright": "1.52.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -1794,6 +1795,33 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit/node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -3181,12 +3209,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
|
||||
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.1"
|
||||
"playwright-core": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3199,21 +3227,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"name": "rebrowser-playwright-core",
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
|
||||
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"resolved": "https://registry.npmjs.org/rebrowser-playwright-core/-/rebrowser-playwright-core-1.52.0.tgz",
|
||||
"integrity": "sha512-gjrvLNh0RX6B/tg6pWaPNGf+9+z1Jl2EyAh5MXD5xMa2lputGRZ9V2MJ/uofcC5Np3vSOJ3SdVSRqwteC0FjfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"fingerprint-generator": "^2.1.66",
|
||||
"fingerprint-injector": "^2.1.66",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
@@ -83,11 +84,10 @@
|
||||
"luxon": "^3.5.0",
|
||||
"ms": "^2.1.3",
|
||||
"node-cron": "3.0.3",
|
||||
"playwright": "1.56.1",
|
||||
"playwright": "1.52.0",
|
||||
"rebrowser-playwright": "1.52.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"ws": "^8.18.3",
|
||||
"express-rate-limit": "^8.2.1"
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"password": "",
|
||||
"totp": "",
|
||||
"recoveryEmail": "",
|
||||
"phoneNumber": "",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
@@ -22,6 +23,7 @@
|
||||
"password": "",
|
||||
"totp": "",
|
||||
"recoveryEmail": "",
|
||||
"phoneNumber": "",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
@@ -36,6 +38,7 @@
|
||||
"password": "",
|
||||
"totp": "",
|
||||
"recoveryEmail": "",
|
||||
"phoneNumber": "",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
@@ -50,6 +53,7 @@
|
||||
"password": "",
|
||||
"totp": "",
|
||||
"recoveryEmail": "",
|
||||
"phoneNumber": "",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
@@ -64,6 +68,7 @@
|
||||
"password": "",
|
||||
"totp": "",
|
||||
"recoveryEmail": "",
|
||||
"phoneNumber": "",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
@@ -73,4 +78,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
"doMobileSearch": true,
|
||||
"doDailyCheckIn": true,
|
||||
"doReadToEarn": true,
|
||||
"doFreeRewards": false,
|
||||
"bundleDailySetWithSearch": true
|
||||
},
|
||||
// === SEARCH ===
|
||||
|
||||
@@ -134,6 +134,17 @@ export class DesktopFlow {
|
||||
}
|
||||
}
|
||||
|
||||
// Do free rewards redemption
|
||||
if (this.bot.config.workers.doFreeRewards) {
|
||||
try {
|
||||
await this.bot.workers.doFreeRewards(workerPage)
|
||||
} catch (rewardsError) {
|
||||
const errorMsg = rewardsError instanceof Error ? rewardsError.message : String(rewardsError)
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `Free rewards redemption failed: ${errorMsg}`, 'error')
|
||||
// Don't throw - continue flow
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch points BEFORE closing (avoid page closed reload error)
|
||||
const after = await this.bot.browser.func.getCurrentPoints().catch(() => initial)
|
||||
|
||||
|
||||
@@ -165,6 +165,27 @@ export class Workers {
|
||||
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||
}
|
||||
|
||||
// Free Rewards
|
||||
async doFreeRewards(page: Page) {
|
||||
// Check if account has phone number configured
|
||||
if (!this.bot.currentAccountPhoneNumber) {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Skipped: No phone number configured for this account. Add "phoneNumber" field in accounts.jsonc to enable free rewards redemption.', 'warn')
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Starting free rewards redemption (0-point gift cards)')
|
||||
|
||||
try {
|
||||
const { FreeRewards } = await import('./activities/FreeRewards')
|
||||
const freeRewards = new FreeRewards(this.bot)
|
||||
await freeRewards.doFreeRewards(page)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Free rewards flow failed: ${errorMessage}`, 'error')
|
||||
throw new Error(`Free rewards redemption failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Solve all the different types of activities
|
||||
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
|
||||
const activityInitial = activityPage.url()
|
||||
|
||||
500
src/functions/activities/FreeRewards.ts
Normal file
500
src/functions/activities/FreeRewards.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { TIMEOUTS } from '../../constants'
|
||||
import { waitForElementSmart, waitForNetworkIdle } from '../../util/browser/SmartWait'
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
/**
|
||||
* FreeRewards Activity Handler
|
||||
*
|
||||
* Automatically redeems 0-point gift cards and rewards from https://rewards.bing.com/redeem
|
||||
*
|
||||
* **IMPORTANT REQUIREMENTS:**
|
||||
* - Account MUST have a phone number configured (`phoneNumber` field in accounts.jsonc)
|
||||
* - Without a phone number, Microsoft blocks reward redemption (no value in executing this task)
|
||||
*
|
||||
* **ANTI-DETECTION MEASURES:**
|
||||
* - Aggressive humanization (mouse gestures, scrolling, random delays)
|
||||
* - Smart waiting for Cloudflare Turnstile CAPTCHA completion
|
||||
* - Natural browsing patterns to avoid automation detection
|
||||
*
|
||||
* **WORKFLOW:**
|
||||
* 1. Navigate to https://rewards.bing.com/redeem
|
||||
* 2. Scan page for rewards with 0 points cost (class-based detection for multi-locale support)
|
||||
* 3. Click the reward card to view details
|
||||
* 4. Click "Redeem" button on product detail page
|
||||
* 5. Wait for Cloudflare Turnstile CAPTCHA to complete (user must solve if detected)
|
||||
* 6. Confirm redemption on checkout page
|
||||
* 7. Verify success and return to redeem page for next reward
|
||||
*
|
||||
* **CLOUDFLARE TURNSTILE CAPTCHA HANDLING:**
|
||||
* - Automatic detection of CAPTCHA presence (#turnstile-widget)
|
||||
* - Extended timeout (up to 60s) for user to manually solve CAPTCHA
|
||||
* - Aggressive humanization during wait (scrolling, mouse moves)
|
||||
* - Configurable via `humanization.enabled` in config
|
||||
*
|
||||
* @class FreeRewards
|
||||
* @extends Workers
|
||||
*/
|
||||
export class FreeRewards extends Workers {
|
||||
|
||||
/**
|
||||
* Main entry point for free rewards redemption
|
||||
*
|
||||
* @param page Playwright page instance (must be on rewards.bing.com)
|
||||
* @returns Promise resolving when all free rewards are redeemed
|
||||
* @throws {Error} If critical operation fails (navigation, page closed)
|
||||
*/
|
||||
override async doFreeRewards(page: Page): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Starting free rewards redemption flow')
|
||||
|
||||
try {
|
||||
// STEP 1: Navigate to redeem page
|
||||
await this.navigateToRedeemPage(page)
|
||||
|
||||
// STEP 2: Find all 0-point rewards
|
||||
const freeRewards = await this.findFreeRewards(page)
|
||||
|
||||
if (freeRewards.length === 0) {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'No free rewards (0 points) available today')
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Found ${freeRewards.length} free reward(s) available`)
|
||||
|
||||
// STEP 3: Redeem each free reward
|
||||
for (let i = 0; i < freeRewards.length; i++) {
|
||||
const reward = freeRewards[i]
|
||||
if (!reward) continue
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Processing reward ${i + 1}/${freeRewards.length}`)
|
||||
|
||||
try {
|
||||
await this.redeemSingleReward(page, reward)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Successfully redeemed reward ${i + 1}`, 'log', 'green')
|
||||
} catch (rewardError) {
|
||||
const errMsg = rewardError instanceof Error ? rewardError.message : String(rewardError)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Failed to redeem reward ${i + 1}: ${errMsg}`, 'error')
|
||||
// Continue with next reward instead of failing entire flow
|
||||
}
|
||||
|
||||
// Navigate back to redeem page for next reward
|
||||
if (i < freeRewards.length - 1) {
|
||||
await this.navigateToRedeemPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Completed free rewards redemption flow', 'log', 'green')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Free rewards flow failed: ${errorMessage}`, 'error')
|
||||
throw new Error(`Free rewards redemption failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to rewards redemption page with retry logic
|
||||
*
|
||||
* @param page Playwright page instance
|
||||
* @returns Promise resolving when navigation completes
|
||||
*/
|
||||
private async navigateToRedeemPage(page: Page): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Navigating to redeem page')
|
||||
|
||||
try {
|
||||
await page.goto('https://rewards.bing.com/redeem', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: TIMEOUTS.DASHBOARD_WAIT * 2
|
||||
})
|
||||
|
||||
// Wait for page to fully load
|
||||
await waitForNetworkIdle(page, {
|
||||
timeoutMs: TIMEOUTS.DASHBOARD_WAIT,
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
}).catch(() => {
|
||||
// Network idle timeout is non-critical (page may still be usable)
|
||||
})
|
||||
|
||||
// Dismiss any popups/overlays
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
// Humanize page interaction
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to navigate to redeem page: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all rewards with 0 points cost on the page
|
||||
*
|
||||
* Uses class-based detection to support multiple locales (language-agnostic)
|
||||
* Targets: <div mee-paragraph="para4" class="ng-binding c-paragraph-4">0 points</div>
|
||||
*
|
||||
* @param page Playwright page instance
|
||||
* @returns Array of reward elements (clickable cards)
|
||||
*/
|
||||
private async findFreeRewards(page: Page): Promise<Array<{ selector: string; title: string }>> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Scanning for 0-point rewards')
|
||||
|
||||
try {
|
||||
// Wait for reward cards to load
|
||||
await waitForElementSmart(page, '[mee-paragraph="para4"]', {
|
||||
initialTimeoutMs: 2000,
|
||||
extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000,
|
||||
state: 'attached',
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
})
|
||||
|
||||
// Find all price elements with class c-paragraph-4
|
||||
const priceElements = await page.locator('[mee-paragraph="para4"].c-paragraph-4').all()
|
||||
|
||||
const freeRewards: Array<{ selector: string; title: string }> = []
|
||||
|
||||
for (const priceEl of priceElements) {
|
||||
const priceText = await priceEl.textContent().catch(() => '')
|
||||
|
||||
// Match "0 points" (with non-breaking space or regular space)
|
||||
// Regex: /^0[\s\u00A0]*points?$/i (case-insensitive, flexible whitespace)
|
||||
if (priceText && /^0[\s\u00A0]*points?$/i.test(priceText.trim())) {
|
||||
// Find parent reward card (go up DOM tree to find clickable container)
|
||||
const cardElement = priceEl.locator('xpath=ancestor::*[contains(@class, "card") or contains(@class, "reward")]').first()
|
||||
|
||||
// Extract reward title for logging
|
||||
const titleEl = cardElement.locator('[mee-paragraph="para2"], .reward-title, .card-title').first()
|
||||
const title = await titleEl.textContent().catch(() => null)
|
||||
const titleText = title ? title.trim() : 'Unknown Reward'
|
||||
|
||||
// Get unique selector for this card (use data attributes if available)
|
||||
const dataTestId = await cardElement.getAttribute('data-testid').catch(() => null)
|
||||
const selector = dataTestId ? `[data-testid="${dataTestId}"]` : '[mee-paragraph="para4"]:has-text("0")'
|
||||
|
||||
freeRewards.push({ selector, title: titleText })
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Found free reward: "${titleText}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return freeRewards
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Error scanning for free rewards: ${errorMessage}`, 'warn')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a single free reward
|
||||
*
|
||||
* @param page Playwright page instance
|
||||
* @param reward Reward metadata (selector, title)
|
||||
* @returns Promise resolving when redemption completes
|
||||
*/
|
||||
private async redeemSingleReward(page: Page, reward: { selector: string; title: string }): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Opening reward: "${reward.title}"`)
|
||||
|
||||
// STEP 1: Click reward card to open detail page
|
||||
await this.clickRewardCard(page, reward)
|
||||
|
||||
// STEP 2: Click "Redeem" button on product detail page
|
||||
await this.clickRedeemButton(page)
|
||||
|
||||
// STEP 3: Wait for Cloudflare Turnstile CAPTCHA (if present)
|
||||
await this.waitForCaptchaCompletion(page)
|
||||
|
||||
// STEP 4: Confirm redemption
|
||||
await this.confirmRedemption(page)
|
||||
|
||||
// STEP 5: Verify success
|
||||
await this.verifyRedemptionSuccess(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click reward card to open detail page
|
||||
*
|
||||
* @param page Playwright page instance
|
||||
* @param reward Reward metadata
|
||||
*/
|
||||
private async clickRewardCard(page: Page, reward: { selector: string; title: string }): Promise<void> {
|
||||
try {
|
||||
// Humanize before clicking
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
await this.bot.utils.waitRandom(500, 1200)
|
||||
|
||||
// Find and click the reward card
|
||||
const cardResult = await waitForElementSmart(page, reward.selector, {
|
||||
initialTimeoutMs: 2000,
|
||||
extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000,
|
||||
state: 'visible',
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
})
|
||||
|
||||
if (!cardResult.found || !cardResult.element) {
|
||||
throw new Error(`Reward card not found: ${reward.selector}`)
|
||||
}
|
||||
|
||||
await cardResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
|
||||
// Wait for detail page to load
|
||||
await waitForNetworkIdle(page, {
|
||||
timeoutMs: TIMEOUTS.DASHBOARD_WAIT,
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
}).catch(() => {
|
||||
// Non-critical timeout
|
||||
})
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Reward detail page loaded')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to click reward card: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Redeem" button on product detail page
|
||||
*
|
||||
* Button structure (language-agnostic, class-based detection):
|
||||
* <a href="/redeem/checkout?productId=..." class="btn btn-primary card-button-height...">
|
||||
* <span class="pull-left margin-right-15">REDEEM REWARD TEXT</span>
|
||||
* <span class="pull-left win-icon mee-icon-ChevronRight margin-top-1"></span>
|
||||
* </a>
|
||||
*/
|
||||
private async clickRedeemButton(page: Page): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Clicking redeem button')
|
||||
|
||||
try {
|
||||
// Humanize before clicking
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
await this.bot.utils.waitRandom(800, 1500)
|
||||
|
||||
// Find redeem button (class-based: btn btn-primary card-button-height)
|
||||
const buttonSelector = 'a.btn.btn-primary.card-button-height[href*="/redeem/checkout"]'
|
||||
|
||||
const buttonResult = await waitForElementSmart(page, buttonSelector, {
|
||||
initialTimeoutMs: 2000,
|
||||
extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000,
|
||||
state: 'visible',
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
})
|
||||
|
||||
if (!buttonResult.found || !buttonResult.element) {
|
||||
throw new Error('Redeem button not found on product detail page')
|
||||
}
|
||||
|
||||
await buttonResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
|
||||
// Wait for checkout page to load
|
||||
await waitForNetworkIdle(page, {
|
||||
timeoutMs: TIMEOUTS.DASHBOARD_WAIT,
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
}).catch(() => {
|
||||
// Non-critical timeout
|
||||
})
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Navigated to checkout page')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to click redeem button: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Cloudflare Turnstile CAPTCHA completion
|
||||
*
|
||||
* **STRATEGY:**
|
||||
* - Detect CAPTCHA presence (#turnstile-widget iframe)
|
||||
* - Wait up to 60s for user to manually solve CAPTCHA
|
||||
* - Apply aggressive humanization (scrolling, mouse moves) during wait
|
||||
* - Check for CAPTCHA disappearance (indicates completion)
|
||||
*
|
||||
* **USER EXPERIENCE:**
|
||||
* - If CAPTCHA detected: bot pauses execution, logs warning, waits for user
|
||||
* - User must solve CAPTCHA manually in browser window
|
||||
* - Once solved, bot continues automatically
|
||||
*
|
||||
* @param page Playwright page instance
|
||||
*/
|
||||
private async waitForCaptchaCompletion(page: Page): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Checking for Cloudflare Turnstile CAPTCHA')
|
||||
|
||||
try {
|
||||
// Check if CAPTCHA is present
|
||||
const captchaWidget = page.locator('#turnstile-widget iframe').first()
|
||||
const captchaVisible = await captchaWidget.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
|
||||
if (!captchaVisible) {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'No CAPTCHA detected, proceeding')
|
||||
return
|
||||
}
|
||||
|
||||
// CAPTCHA detected - apply aggressive humanization
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', '⚠️ Cloudflare Turnstile CAPTCHA detected! Applying aggressive humanization...', 'warn')
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'If CAPTCHA requires manual solving, please complete it in the browser window', 'warn')
|
||||
|
||||
// Wait up to 60s for CAPTCHA completion with aggressive humanization
|
||||
const captchaTimeout = 60000 // 60 seconds
|
||||
const startTime = Date.now()
|
||||
let attempts = 0
|
||||
|
||||
while (Date.now() - startTime < captchaTimeout) {
|
||||
attempts++
|
||||
|
||||
// Apply aggressive humanization (scroll, mouse moves)
|
||||
if (this.bot.config.humanization?.enabled !== false) {
|
||||
// Random scroll
|
||||
const scrollAmount = Math.floor(Math.random() * 300) + 100
|
||||
await page.mouse.wheel(0, scrollAmount).catch(() => { })
|
||||
await this.bot.utils.waitRandom(500, 1000)
|
||||
|
||||
// Random mouse movement
|
||||
const x = Math.floor(Math.random() * 200) + 100
|
||||
const y = Math.floor(Math.random() * 200) + 100
|
||||
await page.mouse.move(x, y, { steps: 5 }).catch(() => { })
|
||||
await this.bot.utils.waitRandom(300, 800)
|
||||
}
|
||||
|
||||
// Check if CAPTCHA is still visible
|
||||
const stillVisible = await captchaWidget.isVisible({ timeout: 1000 }).catch(() => false)
|
||||
if (!stillVisible) {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `CAPTCHA completed after ${attempts} attempts (${Math.floor((Date.now() - startTime) / 1000)}s)`, 'log', 'green')
|
||||
await this.bot.utils.wait(2000) // Wait for post-CAPTCHA processing
|
||||
return
|
||||
}
|
||||
|
||||
// Wait before next check
|
||||
await this.bot.utils.wait(1500)
|
||||
}
|
||||
|
||||
// CAPTCHA timeout - log error but continue (may fail at confirmation)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', '❌ CAPTCHA completion timeout after 60s. Redemption may fail.', 'error')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Error during CAPTCHA wait: ${errorMessage}`, 'warn')
|
||||
// Continue anyway - CAPTCHA may not be blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm redemption on checkout page
|
||||
*
|
||||
* Button structure (class-based):
|
||||
* <button id="redeem-checkout-review-confirm" class="btn-primary card-button-height...">
|
||||
* <span class="pull-left margin-right-15">CONFIRM REWARD TEXT</span>
|
||||
* <span class="pull-left win-icon mee-icon-ChevronRight margin-top-1"></span>
|
||||
* </button>
|
||||
*/
|
||||
private async confirmRedemption(page: Page): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Confirming redemption')
|
||||
|
||||
try {
|
||||
// Humanize before clicking
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
await this.bot.utils.waitRandom(1000, 2000)
|
||||
|
||||
// Find confirm button (id-based for reliability)
|
||||
const confirmButtonSelector = 'button#redeem-checkout-review-confirm, button.btn-primary.card-button-height'
|
||||
|
||||
const buttonResult = await waitForElementSmart(page, confirmButtonSelector, {
|
||||
initialTimeoutMs: 3000,
|
||||
extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 3000,
|
||||
state: 'visible',
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
})
|
||||
|
||||
if (!buttonResult.found || !buttonResult.element) {
|
||||
throw new Error('Confirm button not found on checkout page. CAPTCHA may have blocked redemption.')
|
||||
}
|
||||
|
||||
await buttonResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
|
||||
// Wait for confirmation page to load
|
||||
await waitForNetworkIdle(page, {
|
||||
timeoutMs: TIMEOUTS.DASHBOARD_WAIT * 2, // Extended timeout for processing
|
||||
logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg)
|
||||
}).catch(() => {
|
||||
// Non-critical timeout
|
||||
})
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption confirmed')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to confirm redemption: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify redemption success
|
||||
*
|
||||
* Checks for success indicators:
|
||||
* - Success message on page
|
||||
* - URL change to confirmation page
|
||||
* - Absence of error messages
|
||||
*/
|
||||
private async verifyRedemptionSuccess(page: Page): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Verifying redemption success')
|
||||
|
||||
try {
|
||||
// Wait for page to stabilize
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
// Check URL for success indicators
|
||||
const currentUrl = page.url()
|
||||
const isSuccessUrl = currentUrl.includes('orderconfirmation') ||
|
||||
currentUrl.includes('success') ||
|
||||
currentUrl.includes('confirmed')
|
||||
|
||||
if (isSuccessUrl) {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption successful (URL confirmed)')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for success message on page (class-based, language-agnostic)
|
||||
const successIndicators = [
|
||||
'.success-message',
|
||||
'.confirmation-message',
|
||||
'[class*="success"]',
|
||||
'[class*="confirmed"]'
|
||||
]
|
||||
|
||||
for (const selector of successIndicators) {
|
||||
const hasSuccess = await page.locator(selector).first().isVisible({ timeout: 1000 }).catch(() => false)
|
||||
if (hasSuccess) {
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption successful (success message found)')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error messages (indicates failure)
|
||||
const errorIndicators = [
|
||||
'.error-message',
|
||||
'[class*="error"]',
|
||||
'[class*="failed"]'
|
||||
]
|
||||
|
||||
for (const selector of errorIndicators) {
|
||||
const hasError = await page.locator(selector).first().isVisible({ timeout: 1000 }).catch(() => false)
|
||||
if (hasError) {
|
||||
const errorText = await page.locator(selector).first().textContent().catch(() => null)
|
||||
const errorMsg = errorText ? errorText.trim() : 'Unknown error'
|
||||
throw new Error(`Redemption failed: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// No clear success/error indicator - log warning but assume success
|
||||
this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption status unclear (no explicit success/error indicator). Assuming success.', 'warn')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Redemption verification failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export class MicrosoftRewardsBot {
|
||||
public homePage!: Page
|
||||
public currentAccountEmail?: string
|
||||
public currentAccountRecoveryEmail?: string
|
||||
public currentAccountPhoneNumber?: string
|
||||
public queryEngine?: QueryDiversityEngine
|
||||
public compromisedModeActive: boolean = false
|
||||
public compromisedReason?: string
|
||||
@@ -487,6 +488,7 @@ export class MicrosoftRewardsBot {
|
||||
this.currentAccountEmail = account.email
|
||||
// IMPROVED: Use centralized recovery email validation utility
|
||||
this.currentAccountRecoveryEmail = normalizeRecoveryEmail(account.recoveryEmail)
|
||||
this.currentAccountPhoneNumber = account.phoneNumber
|
||||
const runNumber = (this.accountRunCounts.get(account.email) ?? 0) + 1
|
||||
this.accountRunCounts.set(account.email, runNumber)
|
||||
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Account {
|
||||
totp?: string;
|
||||
/** Recovery email used during security challenge verification. Leave empty if not needed. */
|
||||
recoveryEmail?: string;
|
||||
/** Phone number associated with account (required for redeeming free rewards/gift cards) */
|
||||
phoneNumber?: string;
|
||||
proxy: AccountProxy;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface ConfigWorkers {
|
||||
doMobileSearch: boolean;
|
||||
doDailyCheckIn: boolean;
|
||||
doReadToEarn: boolean;
|
||||
doFreeRewards: boolean; // Automatically redeem 0-point gift cards (requires phoneNumber in account config)
|
||||
bundleDailySetWithSearch?: boolean; // If true, run desktop search right after Daily Set
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user