diff --git a/package.json b/package.json index d16ab21e..8ec5579c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "microsoft-rewards-script", - "version": "3.1.4", + "version": "4.0.0-beta.1", "description": "Automatically do tasks for Microsoft Rewards but in TS!", "author": "Netsky", "license": "GPL-3.0-or-later", @@ -11,6 +11,7 @@ "pre-build": "npm i && rimraf dist && npx patchright install chromium", "build": "rimraf dist && tsc", "start": "node ./dist/index.js", + "start:log": "node ./dist/index.js 2>&1 | tee logs/bot-$(date +%Y%m%d-%H%M%S).log", "ts-start": "ts-node ./src/index.ts", "dev": "ts-node ./src/index.ts -dev", "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index e03cc79a..164f1a9b 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -1,4 +1,4 @@ -import type { BrowserContext, Cookie } from 'patchright' +import type { BrowserContext, Cookie, Page } from 'patchright' import type { AxiosRequestConfig } from 'axios' import type { MicrosoftRewardsBot } from '../index' @@ -9,6 +9,7 @@ import type { AppUserData } from '../interface/AppUserData' import type { XboxDashboardData } from '../interface/XboxDashboardData' import type { AppEarnablePoints, BrowserEarnablePoints, MissingSearchPoints } from '../interface/Points' import type { AppDashboardData } from '../interface/AppDashBoardData' +import type { PanelFlyoutData } from '../interface/PanelFlyoutData' export default class BrowserFunc { private bot: MicrosoftRewardsBot @@ -18,10 +19,57 @@ export default class BrowserFunc { } /** - * Fetch user desktop dashboard data - * @returns {DashboardData} Object of user bing rewards dashboard data + * Get dashboard data using the active page if available (most reliable for V4) */ + async getDashboardDataFromPage(page: Page): Promise { + try { + await page.goto('https://rewards.bing.com/', { waitUntil: 'networkidle', timeout: 20000 }).catch(() => {}) + const html = await page.content() + + const nextData = this.bot.nextParser.parse(html) + if (nextData.length > 0) { + this.bot.rewardsVersion = 'modern' + this.bot.logger.debug(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Modern UI (V4) detected from Page') + + const match = html.match(/var\s+dashboard\s*=\s*({.*?});/s) + const legacyData = match?.[1] ? JSON.parse(match[1]) : null + + return { + ...(legacyData ?? {}), + v4Data: nextData, + userStatus: legacyData?.userStatus ?? { + availablePoints: this.bot.nextParser.find(nextData, 'availablePoints') ?? 0, + counters: { + pcSearch: this.bot.nextParser.find(nextData, 'pcSearch') ?? [], + mobileSearch: this.bot.nextParser.find(nextData, 'mobileSearch') ?? [] + } + }, + userProfile: legacyData?.userProfile ?? { + attributes: { + country: + this.bot.nextParser.find(nextData, 'country') || + this.bot.nextParser.find(nextData, 'market')?.split('-')[1] || + 'US' + } + } + } as unknown as DashboardData + } + + const match = html.match(/var\s+dashboard\s*=\s*({.*?});/s) + if (match?.[1]) { + this.bot.rewardsVersion = 'legacy' + return JSON.parse(match[1]) as DashboardData + } + + throw new Error('No dashboard data found in Page HTML') + } catch (error) { + this.bot.logger.warn(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Page extraction failed: ${error}`) + return await this.getDashboardData() // Fallback to Axios + } + } + async getDashboardData(): Promise { + // Fallback: Standard V3 API try { const request: AxiosRequestConfig = { url: 'https://rewards.bing.com/api/getuserinfo?type=1', @@ -45,179 +93,263 @@ export default class BrowserFunc { } throw new Error('Dashboard data missing from API response') } catch (error) { - this.bot.logger.warn(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'API failed, trying HTML fallback') - - // Try using script from dashboard page - try { - const request: AxiosRequestConfig = { - url: this.bot.config.baseURL, - method: 'GET', - headers: { - ...(this.bot.fingerprint?.headers ?? {}), - Cookie: this.buildCookieHeader(this.bot.cookies.mobile), - Referer: 'https://rewards.bing.com/', - Origin: 'https://rewards.bing.com' - } - } - - const response = await this.bot.axios.request(request) - const match = response.data.match(/var\s+dashboard\s*=\s*({.*?});/s) - - if (!match?.[1]) { - throw new Error('Dashboard script not found in HTML') - } - - return JSON.parse(match[1]) as DashboardData - } catch (fallbackError) { - // If both fail - this.bot.logger.error(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Failed to get dashboard data') - throw fallbackError - } - } - } - - /** - * Fetch user app dashboard data - * @returns {AppDashboardData} Object of user bing rewards dashboard data - */ - async getAppDashboardData(): Promise { - try { + // Final fallback: try dashboard HTML via Axios const request: AxiosRequestConfig = { - url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAIOS&options=613', + url: this.bot.config.baseURL, method: 'GET', headers: { - Authorization: `Bearer ${this.bot.accessToken}`, - 'User-Agent': - 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2' + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: this.buildCookieHeader(this.bot.cookies.mobile), + Referer: 'https://rewards.bing.com/', + Origin: 'https://rewards.bing.com' } } const response = await this.bot.axios.request(request) - return response.data as AppDashboardData - } catch (error) { - this.bot.logger.error( - this.bot.isMobile, - 'GET-APP-DASHBOARD-DATA', - `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` - ) - throw error + const nextData = this.bot.nextParser.parse(response.data) + if (nextData.length > 0) { + this.bot.rewardsVersion = 'modern' + return { v4Data: nextData } as unknown as DashboardData + } + + const match = response.data.match(/var\s+dashboard\s*=\s*({.*?});/s) + if (match?.[1]) { + return JSON.parse(match[1]) as DashboardData + } + + throw new Error('All dashboard data fetch methods failed') } } /** - * Fetch user xbox dashboard data - * @returns {XboxDashboardData} Object of user bing rewards dashboard data + * Fetch user panel flyout data (V4 alternative source) + * @returns {PanelFlyoutData} Object of user bing rewards dashboard data */ - async getXBoxDashboardData(): Promise { + async getPanelFlyoutData(): Promise { try { const request: AxiosRequestConfig = { - url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=xboxapp&options=6', + url: 'https://www.bing.com/rewards/panelflyout/getuserinfo?channel=BingFlyout&partnerId=BingRewards', method: 'GET', headers: { - Authorization: `Bearer ${this.bot.accessToken}`, - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One X) AppleWebKit/537.36 (KHTML, like Gecko) Edge/18.19041' + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: this.buildCookieHeader(this.bot.cookies.mobile, [ + 'bing.com', + 'live.com', + 'microsoftonline.com' + ]), + Origin: 'https://www.bing.com' } } const response = await this.bot.axios.request(request) - return response.data as XboxDashboardData + return response.data as PanelFlyoutData } catch (error) { this.bot.logger.error( this.bot.isMobile, - 'GET-XBOX-DASHBOARD-DATA', - `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` + 'GET-PANEL-FLYOUT-DATA', + `Error fetching panel flyout data: ${error instanceof Error ? error.message : String(error)}` ) throw error } } - /** - * Get search point counters - */ async getSearchPoints(): Promise { - const dashboardData = await this.getDashboardData() // Always fetch newest data - + const dashboardData = await this.getDashboardData() return dashboardData.userStatus.counters } missingSearchPoints(counters: Counters, isMobile: boolean): MissingSearchPoints { - const mobileData = counters.mobileSearch?.[0] - const desktopData = counters.pcSearch?.[0] - const edgeData = counters.pcSearch?.[1] + const pcSearch = counters.pcSearch ?? [] + const mobileSearch = counters.mobileSearch ?? [] + + // V4 format: search entries may be in pcSearch array with deviceType区分 + let mobileData: any = undefined + let desktopData: any = undefined - const mobilePoints = mobileData ? Math.max(0, mobileData.pointProgressMax - mobileData.pointProgress) : 0 - const desktopPoints = desktopData ? Math.max(0, desktopData.pointProgressMax - desktopData.pointProgress) : 0 - const edgePoints = edgeData ? Math.max(0, edgeData.pointProgressMax - edgeData.pointProgress) : 0 + if (pcSearch.length > 0) { + // Try to find separate desktop/mobile entries + desktopData = pcSearch.find( + (x: any) => x.promotionType === 'search' && !x.deviceType?.toLowerCase().includes('mobile') + ) + mobileData = pcSearch.find( + (x: any) => x.promotionType === 'search' && x.deviceType?.toLowerCase().includes('mobile') + ) - const totalPoints = isMobile ? mobilePoints : desktopPoints + edgePoints + // Fallback to first entry + if (!desktopData && !mobileData && pcSearch[0]) { + desktopData = pcSearch[0] + } + } + + // Legacy fallback + if (!mobileData && mobileSearch.length > 0) { + mobileData = mobileSearch[0] + } + + const mobilePoints = mobileData + ? Math.max(0, (mobileData.pointProgressMax || 0) - (mobileData.pointProgress || 0)) + : 0 + const desktopPoints = desktopData + ? Math.max(0, (desktopData.pointProgressMax || 0) - (desktopData.pointProgress || 0)) + : 0 + const edgePoints = 0 + + const totalPoints = isMobile ? mobilePoints : desktopPoints return { mobilePoints, desktopPoints, edgePoints, totalPoints } } - /** - * Get total earnable points with web browser - */ - async getBrowserEarnablePoints(): Promise { + async getBrowserEarnablePoints(data?: DashboardData): Promise { try { - const data = await this.getDashboardData() + // For V4 UI, we need to fetch counters from API since page data doesn't have them + let dashboardData: DashboardData | undefined = data - const desktopSearchPoints = - data.userStatus.counters.pcSearch?.reduce( - (sum, x) => sum + (x.pointProgressMax - x.pointProgress), - 0 - ) ?? 0 + this.bot.logger.debug( + this.bot.isMobile, + 'GET-POINTS', + `rewardsVersion=${this.bot.rewardsVersion}, hasData=${!!data}` + ) - const mobileSearchPoints = - data.userStatus.counters.mobileSearch?.reduce( - (sum, x) => sum + (x.pointProgressMax - x.pointProgress), - 0 - ) ?? 0 + if (this.bot.rewardsVersion === 'modern') { + try { + const countersData = await this.getDashboardData() + this.bot.logger.debug( + this.bot.isMobile, + 'GET-POINTS', + `API counters: pcSearch=${JSON.stringify(countersData.userStatus?.counters?.pcSearch)}, mobileSearch=${JSON.stringify(countersData.userStatus?.counters?.mobileSearch)}` + ) + dashboardData = countersData + } catch (e: any) { + this.bot.logger.debug(this.bot.isMobile, 'GET-POINTS', `API error: ${e.message}`) + } + } else { + dashboardData = data ?? (await this.getDashboardData()) + } + + if (!dashboardData) { + return { + dailySetPoints: 0, + morePromotionsPoints: 0, + desktopSearchPoints: 0, + mobileSearchPoints: 0, + totalEarnablePoints: 0 + } + } + + const pcSearch = dashboardData.userStatus?.counters?.pcSearch ?? [] + const mobileSearch = dashboardData.userStatus?.counters?.mobileSearch ?? [] + + // V4: mobileSearch may not exist - check if pcSearch has mobile-type entries + let desktopSearchPoints = 0 + let mobileSearchPoints = 0 + + if (pcSearch.length > 0) { + // V4 format: check if there's separate mobile search in pcSearch array + const desktopEntry = pcSearch.find( + (x: any) => x.promotionType === 'search' && !x.deviceType?.toLowerCase().includes('mobile') + ) + const mobileEntry = pcSearch.find( + (x: any) => x.promotionType === 'search' && x.deviceType?.toLowerCase().includes('mobile') + ) + + if (desktopEntry) { + desktopSearchPoints = Math.max( + 0, + (desktopEntry.pointProgressMax || 0) - (desktopEntry.pointProgress || 0) + ) + } + if (mobileEntry) { + mobileSearchPoints = Math.max( + 0, + (mobileEntry.pointProgressMax || 0) - (mobileEntry.pointProgress || 0) + ) + } else if (mobileSearch.length > 0) { + // Legacy V3 format + mobileSearchPoints = mobileSearch.reduce( + (sum: number, x: any) => sum + Math.max(0, (x.pointProgressMax || 0) - (x.pointProgress || 0)), + 0 + ) + } + // If no desktop/mobile split found, use first entry as desktop + if (desktopSearchPoints === 0 && mobileSearchPoints === 0 && pcSearch[0]) { + desktopSearchPoints = Math.max( + 0, + (pcSearch[0].pointProgressMax || 0) - (pcSearch[0].pointProgress || 0) + ) + } + } + + let dailySetPoints = 0 + let morePromotionsPoints = 0 + + if (this.bot.rewardsVersion === 'modern' && (dashboardData as any).v4Data) { + const v4Data = (dashboardData as any).v4Data + const dailySetItems = this.bot.nextParser.find(v4Data, 'dailySetItems') ?? [] + const moreActivities = this.bot.nextParser.find(v4Data, 'moreActivities') ?? [] - const todayDate = this.bot.utils.getFormattedDate() - const dailySetPoints = - data.dailySetPromotions[todayDate]?.reduce( - (sum, x) => sum + (x.pointProgressMax - x.pointProgress), + dailySetPoints = dailySetItems.reduce( + (sum: number, x: any) => sum + (!x.isCompleted ? x.points || 0 : 0), 0 - ) ?? 0 - - const morePromotionsPoints = - data.morePromotions?.reduce((sum, x) => { - if ( - ['quiz', 'urlreward'].includes(x.promotionType) && - x.exclusiveLockedFeatureStatus !== 'locked' - ) { - return sum + (x.pointProgressMax - x.pointProgress) - } - return sum - }, 0) ?? 0 + ) + morePromotionsPoints = moreActivities.reduce( + (sum: number, x: any) => sum + (!x.isCompleted ? x.points || 0 : 0), + 0 + ) + } else { + const todayDate = this.bot.utils.getFormattedDate() + if (dashboardData.dailySetPromotions?.[todayDate]) { + dailySetPoints = dashboardData.dailySetPromotions[todayDate].reduce( + (sum, x) => sum + (x.pointProgressMax - x.pointProgress), + 0 + ) + } - const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints + if (dashboardData.morePromotions) { + morePromotionsPoints = dashboardData.morePromotions.reduce((sum, x) => { + if ( + ['quiz', 'urlreward'].includes(x.promotionType) && + x.exclusiveLockedFeatureStatus !== 'locked' + ) { + return sum + (x.pointProgressMax - x.pointProgress) + } + return sum + }, 0) + } + } return { dailySetPoints, morePromotionsPoints, desktopSearchPoints, mobileSearchPoints, - totalEarnablePoints + totalEarnablePoints: dailySetPoints + morePromotionsPoints + desktopSearchPoints + mobileSearchPoints } } catch (error) { - this.bot.logger.error( - this.bot.isMobile, - 'GET-BROWSER-EARNABLE-POINTS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` - ) + this.bot.logger.error(this.bot.isMobile, 'GET-POINTS', `Error: ${error}`) throw error } } - /** - * Get total earnable points with mobile app - */ + async getCurrentPoints(): Promise { + const data = await this.getDashboardData() + return data.userStatus?.availablePoints ?? 0 + } + + async getAppDashboardData(): Promise { + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAIOS&options=613', + method: 'GET', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2' + } + } + const response = await this.bot.axios.request(request) + return response.data as AppDashboardData + } + async getAppEarnablePoints(): Promise { try { - const eligibleOffers = ['ENUS_readarticle3_30points', 'Gamification_Sapphire_DailyCheckIn'] - const request: AxiosRequestConfig = { url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613', method: 'GET', @@ -228,9 +360,9 @@ export default class BrowserFunc { 'X-Rewards-ismobile': 'true' } } - const response = await this.bot.axios.request(request) const userData: AppUserData = response.data + const eligibleOffers = ['ENUS_readarticle3_30points', 'Gamification_Sapphire_DailyCheckIn'] const eligibleActivities = userData.response.promotions.filter(x => eligibleOffers.includes(x.attributes.offerid ?? '') ) @@ -240,103 +372,48 @@ export default class BrowserFunc { for (const item of eligibleActivities) { const attrs = item.attributes - if (attrs.type === 'msnreadearn') { - const pointMax = parseInt(attrs.pointmax ?? '0') - const pointProgress = parseInt(attrs.pointprogress ?? '0') - readToEarn = Math.max(0, pointMax - pointProgress) + readToEarn = Math.max(0, parseInt(attrs.pointmax ?? '0') - parseInt(attrs.pointprogress ?? '0')) } else if (attrs.type === 'checkin') { const progress = parseInt(attrs.progress ?? '0') const checkInDay = progress % 7 const lastUpdated = new Date(attrs.last_updated ?? '') - const today = new Date() - - if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) { + if (checkInDay < 6 && new Date().getDate() !== lastUpdated.getDate()) { checkIn = parseInt(attrs[`day_${checkInDay + 1}_points`] ?? '0') } } } - - const totalEarnablePoints = readToEarn + checkIn - - return { - readToEarn, - checkIn, - totalEarnablePoints - } - } catch (error) { - this.bot.logger.error( - this.bot.isMobile, - 'GET-APP-EARNABLE-POINTS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` - ) - throw error + return { readToEarn, checkIn, totalEarnablePoints: readToEarn + checkIn } + } catch { + return { readToEarn: 0, checkIn: 0, totalEarnablePoints: 0 } } } - /** - * Get current point amount - * @returns {number} Current total point amount - */ - async getCurrentPoints(): Promise { - try { - const data = await this.getDashboardData() - return data.userStatus.availablePoints - } catch (error) { - this.bot.logger.error( - this.bot.isMobile, - 'GET-CURRENT-POINTS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` - ) - throw error + async getXBoxDashboardData(): Promise { + const request: AxiosRequestConfig = { + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=xboxapp&options=6', + method: 'GET', + headers: { + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One X) AppleWebKit/537.36 (KHTML, like Gecko) Edge/18.19041' + } } + const response = await this.bot.axios.request(request) + return response.data as XboxDashboardData } async closeBrowser(browser: BrowserContext, email: string) { - const rootBrowser = (browser as any).browser?.() || null - try { - // Try to save cookies const cookies = await browser.cookies() - this.bot.logger.debug(this.bot.isMobile, 'CLOSE-BROWSER', `Saving ${cookies.length} cookies.`) await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile) - - await this.bot.utils.wait(2000) - } catch (error) { - this.bot.logger.error(this.bot.isMobile, 'CLOSE-BROWSER', `Failed to save session: ${error}`) - } finally { - try { - await browser.close() - - if (rootBrowser) { - await rootBrowser.close().catch(() => {}) - } - - this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'All browser resources closed.') - } catch (closeError) { - this.bot.logger.warn( - this.bot.isMobile, - 'CLOSE-BROWSER', - 'Shutdown encountered an error, but process exiting.' - ) - } - } + await browser.close() + } catch {} } buildCookieHeader(cookies: Cookie[], allowedDomains?: string[]): string { - return [ - ...new Map( - cookies - .filter(c => { - if (!allowedDomains || allowedDomains.length === 0) return true - return ( - typeof c.domain === 'string' && - allowedDomains.some(d => c.domain.toLowerCase().endsWith(d.toLowerCase())) - ) - }) - .map(c => [c.name, c]) - ).values() - ] + return cookies + .filter(c => !allowedDomains || allowedDomains.some(d => c.domain.includes(d))) .map(c => `${c.name}=${c.value}`) .join('; ') } diff --git a/src/browser/UserAgent.ts b/src/browser/UserAgent.ts index 3bec3f02..c6d59f2d 100644 --- a/src/browser/UserAgent.ts +++ b/src/browser/UserAgent.ts @@ -55,12 +55,12 @@ export class UserAgentManager { const data: ChromeVersion = response.data return data.channels.Stable.version } catch (error) { - this.bot.logger.error( + this.bot.logger.warn( isMobile, 'USERAGENT-CHROME-VERSION', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `Failed to fetch Chrome version, using fallback: ${error instanceof Error ? error.message : String(error)}` ) - throw error + return isMobile ? '146.0.7680.80' : '146.0.0.0' } } @@ -82,12 +82,15 @@ export class UserAgentManager { windows: stable.Releases.find(x => x.Platform == 'Windows' && x.Architecture == 'x64')?.ProductVersion } } catch (error) { - this.bot.logger.error( + this.bot.logger.warn( isMobile, 'USERAGENT-EDGE-VERSION', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `Failed to fetch Edge version, using fallback: ${error instanceof Error ? error.message : String(error)}` ) - throw error + return { + android: '145.0.3800.99', + windows: '145.0.3800.99' + } } } diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index ba946bad..886fe28f 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -160,6 +160,14 @@ export class Login { private async detectCurrentState(page: Page, account?: Account): Promise { await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + const pageContent = await page.innerText('body').catch(() => '') + if ( + pageContent.toLowerCase().includes('too many requests') || + pageContent.toLowerCase().includes('banyak permintaan') + ) { + return 'ERROR_ALERT' + } + const url = new URL(page.url()) this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${url.hostname}${url.pathname}`) @@ -292,9 +300,23 @@ export class Login { } case 'ERROR_ALERT': { - const alertEl = page.locator(this.selectors.errorAlert) - const errorMsg = await alertEl.innerText().catch(() => 'Unknown Error') - this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error: ${errorMsg}`) + const alertEl = page.locator(this.selectors.errorAlert).first() + let errorMsg = await alertEl.innerText().catch(() => '') + + if (!errorMsg) { + const bodyText = await page.innerText('body').catch(() => '') + if ( + bodyText.toLowerCase().includes('too many requests') || + bodyText.toLowerCase().includes('banyak permintaan') + ) { + errorMsg = 'Too many requests' + } else { + errorMsg = 'Unknown Error (Check screenshot)' + } + } + + this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error detected: ${errorMsg}`) + throw new Error(`Microsoft login error: ${errorMsg}`) } diff --git a/src/config.example.json b/src/config.example.json index e97eb962..1bf359ef 100644 --- a/src/config.example.json +++ b/src/config.example.json @@ -13,7 +13,8 @@ "doDesktopSearch": true, "doMobileSearch": true, "doDailyCheckIn": true, - "doReadToEarn": true + "doReadToEarn": true, + "doQuests": true }, "searchOnBingLocalQueries": false, "globalTimeout": "30sec", diff --git a/src/functions/Activities.ts b/src/functions/Activities.ts index 14860268..d7f992d9 100644 --- a/src/functions/Activities.ts +++ b/src/functions/Activities.ts @@ -15,6 +15,7 @@ import { DoubleSearchPoints } from './activities/api/DoubleSearchPoints' // Browser import { SearchOnBing } from './activities/browser/SearchOnBing' import { Search } from './activities/browser/Search' +import { Quest } from './activities/browser/Quest' import type { BasePromotion, @@ -99,4 +100,10 @@ export default class Activities { const dailyCheckIn = new DailyCheckIn(this.bot) await dailyCheckIn.doDailyCheckIn() } + + // Quest Activities + doQuests = async (page: Page): Promise => { + const quest = new Quest(this.bot) + await quest.doQuests(page) + } } diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index f9943425..37873fe2 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -1,311 +1,638 @@ import type { Page } from 'patchright' import type { MicrosoftRewardsBot } from '../index' -import type { - DashboardData, - PunchCard, - BasePromotion, - FindClippyPromotion, - PurplePromotionalItem -} from '../interface/DashboardData' -import type { AppDashboardData } from '../interface/AppDashBoardData' +import type { DashboardData, PunchCard } from '../interface/DashboardData' export class Workers { - public bot: MicrosoftRewardsBot + protected bot: MicrosoftRewardsBot constructor(bot: MicrosoftRewardsBot) { this.bot = bot } public async doDailySet(data: DashboardData, page: Page) { - const todayKey = this.bot.utils.getFormattedDate() - const todayData = data.dailySetPromotions[todayKey] + // V4 MODERN UI LOGIC + if (this.bot.rewardsVersion === 'modern' && (data as any).v4Data) { + this.bot.logger.debug(this.bot.isMobile, 'DAILY-SET', 'Using Modern UI (V4) detection logic') + const v4Data = (data as any).v4Data - const activitiesUncompleted = todayData?.filter(x => !x?.complete && x.pointProgressMax > 0) ?? [] + // Also try to get data from /earn page for additional activities + let earnPageData = null + try { + await page + .goto('https://rewards.bing.com/earn', { waitUntil: 'networkidle', timeout: 15000 }) + .catch(() => {}) + const earnHtml = await page.content() + const earnNextData = this.bot.nextParser.parse(earnHtml) + if (earnNextData.length > 0) { + earnPageData = earnNextData + this.bot.logger.debug(this.bot.isMobile, 'DAILY-SET', 'Fetched additional data from /earn page') + } + } catch (e) { + this.bot.logger.debug(this.bot.isMobile, 'DAILY-SET', 'Could not fetch /earn page data') + } - if (!activitiesUncompleted.length) { - this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed') - return - } + // Combine both sources of data + const combinedData = earnPageData ? [...v4Data, ...earnPageData] : v4Data - this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items') + // Get today's date in MM/DD/YYYY format (matching V4 API format) + const today = new Date() + const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}/${String(today.getDate()).padStart(2, '0')}/${today.getFullYear()}` - await this.solveActivities(activitiesUncompleted, page) + // Filter by today's date and uncompleted status + const dailySetItems = this.bot.nextParser.find(combinedData, 'dailySetItems') ?? [] + const todayItems = dailySetItems.filter((x: any) => x.date === todayStr) + const uncompleted = todayItems.filter((x: any) => !x.isCompleted && x.points > 0) - this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed') - } + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-SET', + `Date: ${todayStr}, Found ${dailySetItems.length} total items, ${todayItems.length} for today, ${uncompleted.length} uncompleted` + ) - public async doMorePromotions(data: DashboardData, page: Page) { - const morePromotions: BasePromotion[] = [ - ...new Map( - [...(data.morePromotions ?? []), ...(data.morePromotionsWithoutPromotionalItems ?? [])] - .filter(Boolean) - .map(p => [p.offerId, p as BasePromotion] as const) - ).values() - ] - - const activitiesUncompleted: BasePromotion[] = - morePromotions?.filter(x => { - if (x.complete) return false - if (x.pointProgressMax <= 0) return false - if (x.exclusiveLockedFeatureStatus === 'locked') return false - if (!x.promotionType) return false + // If no items from /earn page, also check all items (not just today) + if (uncompleted.length === 0 && dailySetItems.length > 0) { + const allUncompleted = dailySetItems.filter((x: any) => !x.isCompleted && x.points > 0) + if (allUncompleted.length > 0) { + this.bot.logger.info( + this.bot.isMobile, + 'DAILY-SET', + `Found ${allUncompleted.length} uncompleted items (any date)` + ) + const mapped = allUncompleted.map((x: any) => ({ + title: x.title || 'Unknown Title', + offerId: x.offerId || 'Unknown ID', + destination: x.destination || x.destinationUrl, + hash: x.hash || '', + type: x.type || x.activityType || '', + complete: false, + pointProgressMax: x.points || x.pointProgressMax || 0 + })) + await this.solveActivities(mapped, page) + return + } + } - return true - }) ?? [] + this.bot.logger.debug( + this.bot.isMobile, + 'DAILY-SET', + `Date: ${todayStr}, Found ${dailySetItems.length} total items, ${todayItems.length} for today, ${uncompleted.length} uncompleted` + ) + + if (uncompleted.length) { + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', `Solving ${uncompleted.length} modern items`) + const mapped = uncompleted.map((x: any) => ({ + title: x.title || 'Unknown Title', + offerId: x.offerId || 'Unknown ID', + destination: x.destination || x.destinationUrl, + hash: x.hash || '', + type: x.type || x.activityType || '', + complete: false, + pointProgressMax: x.points || x.pointProgressMax || 0 + })) + await this.solveActivities(mapped, page) + } else { + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All modern daily items already completed') + } + return + } - if (!activitiesUncompleted.length) { + // V3 LEGACY LOGIC + this.bot.logger.debug(this.bot.isMobile, 'DAILY-SET', 'Using Legacy UI (V3) detection logic') + const todayKey = this.bot.utils.getFormattedDate() + const todayData = data.dailySetPromotions?.[todayKey] ?? [] + const activitiesUncompleted = todayData.filter(x => !x?.complete && x.pointProgressMax > 0) + + if (activitiesUncompleted.length > 0) { this.bot.logger.info( this.bot.isMobile, - 'MORE-PROMOTIONS', - 'All "More Promotion" items have already been completed' + 'DAILY-SET', + `Found ${activitiesUncompleted.length} uncompleted items` ) - return + await this.solveActivities(activitiesUncompleted, page) } + } - this.bot.logger.info( - this.bot.isMobile, - 'MORE-PROMOTIONS', - `Started solving ${activitiesUncompleted.length} "More Promotions" items` - ) + public async doMorePromotions(data: DashboardData, page: Page) { + // V4 MODERN UI LOGIC + if (this.bot.rewardsVersion === 'modern' && (data as any).v4Data) { + this.bot.logger.debug(this.bot.isMobile, 'MORE-PROMOTIONS', 'Using Modern UI (V4) detection logic') + let v4Data = (data as any).v4Data - await this.solveActivities(activitiesUncompleted, page) + // Also try to get data from /earn page for "Keep earning" activities + let earnPageData = null + try { + await page + .goto('https://rewards.bing.com/earn', { waitUntil: 'networkidle', timeout: 15000 }) + .catch(() => {}) + const earnHtml = await page.content() + const earnNextData = this.bot.nextParser.parse(earnHtml) + if (earnNextData.length > 0) { + earnPageData = earnNextData + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + 'Fetched /earn page data for Keep earning' + ) + } + } catch (e) { + this.bot.logger.debug(this.bot.isMobile, 'MORE-PROMOTIONS', 'Could not fetch /earn page data') + } - this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed') - } + // Combine both sources of data + const combinedData = earnPageData ? [...v4Data, ...earnPageData] : v4Data - public async doAppPromotions(data: AppDashboardData) { - const appRewards = data.response.promotions.filter(x => { - if (x.attributes['complete']?.toLowerCase() !== 'false') return false - if (!x.attributes['offerid']) return false - if (!x.attributes['type']) return false - if (x.attributes['type'] !== 'sapphire') return false + // Debug: Find ALL objects with offerId + const allWithOfferId = this.findAllWithOfferId(combinedData) + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Found ${allWithOfferId.length} items with offerId in Next.js data` + ) - return true - }) + // Try multiple keys + let moreActivities = this.bot.nextParser.find(combinedData, 'moreActivities') ?? [] + if (!moreActivities.length) { + // Try alternative keys + moreActivities = + this.bot.nextParser.find(combinedData, 'moreActivities') ?? + this.bot.nextParser.find(combinedData, 'more_activity') ?? + allWithOfferId.filter((x: any) => x.destination && !x.isCompleted) + } - if (!appRewards.length) { - this.bot.logger.info( + this.bot.logger.debug( this.bot.isMobile, - 'APP-PROMOTIONS', - 'All "App Promotions" items have already been completed' + 'MORE-PROMOTIONS', + `Found ${moreActivities.length} moreActivities items` ) + + // Get morePromotions from panel flyout data (contains "Do you know the answer?" etc) + // Note: API response structure - check both userInfo.promotions and flyoutResult.morePromotions + const panelDataRaw = this.bot.panelData as any + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Panel data keys: ${panelDataRaw ? Object.keys(panelDataRaw).join(', ') : 'undefined'}` + ) + const userInfoData = panelDataRaw?.userInfo + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `UserInfo keys: ${userInfoData ? Object.keys(userInfoData).join(', ') : 'undefined'}` + ) + + // Try multiple sources for morePromotions + let panelFlyoutPromos: any[] = [] + + // Try flyoutResult.morePromotions (original path) + if (panelDataRaw?.flyoutResult?.morePromotions) { + panelFlyoutPromos = panelDataRaw.flyoutResult.morePromotions + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Found ${panelFlyoutPromos.length} items in flyoutResult.morePromotions` + ) + } + + // Also try userInfo.promotions (contains "Do you know the answer?" and other activities) + // Combine with flyoutResult.morePromotions + if (userInfoData?.promotions) { + const userInfoPromos = userInfoData.promotions + + // Transform userInfo.promotions to match expected format + // Structure: { name, attributes: { offerid, title, complete, max, destination } } + const transformedPromos = userInfoPromos.map((p: any) => { + const attrs = p.attributes || {} + const isComplete = attrs.complete === 'True' || attrs.complete === true + return { + title: attrs.title || p.name || 'Unknown Title', + offerId: attrs.offerid || p.name || 'Unknown ID', + destination: attrs.destination || '', + complete: isComplete, + isCompleted: isComplete, + points: parseInt(attrs.max) || 0, + pointProgressMax: parseInt(attrs.max) || 0, + activityType: 0 + } + }) + + // Combine both arrays, avoiding duplicates by offerId + const existingIds = new Set(panelFlyoutPromos.map((p: any) => p.offerId)) + const newPromos = transformedPromos.filter((p: any) => !existingIds.has(p.offerId)) + panelFlyoutPromos = [...panelFlyoutPromos, ...newPromos] + + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Combined ${panelFlyoutPromos.length} items from flyoutResult + userInfo.promotions` + ) + } + const panelUncompleted = panelFlyoutPromos + .filter((p: any) => !p.isCompleted && !p.complete && p.offerId) + .map((p: any) => ({ + title: p.title || 'Unknown Title', + offerId: p.offerId || p.name || 'Unknown ID', + destination: p.destinationUrl || p.destination || '', + hash: p.hash || '', + complete: false, + pointProgressMax: p.points || p.pointProgressMax || 0, + activityType: p.activityType || 0, + isLocked: false + })) + + if (panelUncompleted.length) { + this.bot.logger.debug( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Panel flyout items to solve: ${panelUncompleted.map((m: any) => `${m.title} (${m.offerId})`).join(', ')}` + ) + } + + // Map moreActivities to same format + const mappedMoreActivities = moreActivities + .filter((x: any) => !x.isLocked && x.points > 0) + .filter((x: any) => !x.isCompleted) + .map((x: any) => ({ + title: x.title || 'Unknown Title', + offerId: x.offerId || 'Unknown ID', + destination: x.destination || x.destinationUrl, + hash: x.hash || '', + complete: false, + pointProgressMax: x.points || x.pointProgressMax || 0, + activityType: x.activityType || 0, + isLocked: x.isLocked || false + })) + + // Combine both sources + const allUncompleted = [...mappedMoreActivities, ...panelUncompleted] + + if (allUncompleted.length) { + this.bot.logger.info( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Solving ${allUncompleted.length} modern items` + ) + await this.solveActivities(allUncompleted, page) + } else { + this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'All modern more items already completed') + } return } - for (const reward of appRewards) { - await this.bot.activities.doAppReward(reward) - // A delay between completing each activity - await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) - } + // V3 LEGACY LOGIC + this.bot.logger.debug(this.bot.isMobile, 'MORE-PROMOTIONS', 'Using Legacy UI (V3) detection logic') + const morePromotions = data.morePromotions ?? [] + const activitiesUncompleted = morePromotions.filter(x => !x?.complete && x.pointProgressMax > 0) - this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'All "App Promotions" items have been completed') + if (activitiesUncompleted.length > 0) { + this.bot.logger.info( + this.bot.isMobile, + 'MORE-PROMOTIONS', + `Found ${activitiesUncompleted.length} uncompleted items` + ) + await this.solveActivities(activitiesUncompleted as any, page) + } } - public async doSpecialPromotions(data: DashboardData) { - const specialPromotions: PurplePromotionalItem[] = [ - ...new Map( - [...(data.promotionalItems ?? [])] - .filter(Boolean) - .map(p => [p.offerId, p as PurplePromotionalItem] as const) - ).values() - ] + public async doAppPromotions(data: any) {} - const supportedPromotions = ['ww_banner_optin_2x'] + private findAllWithOfferId(data: any, results: any[] = []): any[] { + if (!data) return results + if (Array.isArray(data)) { + for (const item of data) { + this.findAllWithOfferId(item, results) + } + } else if (typeof data === 'object') { + if (data.offerId && data.destination) { + results.push(data) + } + for (const key in data) { + this.findAllWithOfferId(data[key], results) + } + } + return results + } - const specialPromotionsUncompleted: PurplePromotionalItem[] = - specialPromotions?.filter(x => { - if (x.complete) return false - if (x.exclusiveLockedFeatureStatus === 'locked') return false - if (!x.promotionType) return false + protected async solveActivities(activities: any[], page: Page, punchCard?: PunchCard) { + for (const activity of activities) { + // Skip locked activities + if (activity.isLocked) { + this.bot.logger.info(this.bot.isMobile, 'ACTIVITY', `Skipping locked: ${activity.title}`) + continue + } - const offerId = (x.offerId ?? '').toLowerCase() - return supportedPromotions.some(s => offerId.includes(s)) - }) ?? [] + this.bot.logger.info(this.bot.isMobile, 'ACTIVITY', `Solving: ${activity.title}`) - for (const activity of specialPromotionsUncompleted) { try { - const type = activity.promotionType?.toLowerCase() ?? '' - const name = activity.name?.toLowerCase() ?? '' - const offerId = (activity as PurplePromotionalItem).offerId + // Ensure we are on the dashboard + if (!page.url().includes('rewards.bing.com')) { + await page + .goto('https://rewards.bing.com/', { waitUntil: 'networkidle', timeout: 20000 }) + .catch(() => {}) + } - this.bot.logger.debug( - this.bot.isMobile, - 'SPECIAL-ACTIVITY', - `Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type}"` - ) + const url = activity.destinationUrl ?? activity.destination + + if (url) { + // Optimized Desktop V4 Selectors + const selectors = [ + `a[href*="${activity.offerId}"]`, + `a[data-bi-id*="${activity.offerId}"]`, + `a:has-text("${activity.title}")`, + `a:has-text("${activity.title.toLowerCase()}")`, + `div[role="button"]:has-text("${activity.title}")`, + `a[href*="${encodeURIComponent(url).substring(0, 15)}"]` + ] + + let cardElement = null + for (const selector of selectors) { + try { + const elements = page.locator(selector) + const count = await elements.count() + for (let i = 0; i < count; i++) { + const el = elements.nth(i) + const text = await el.innerText().catch(() => '') + const href = await el.getAttribute('href').catch(() => null) + if ( + text.toLowerCase().includes(activity.title.toLowerCase()) || + (href && href.includes(activity.offerId)) + ) { + cardElement = el + break + } + } + if (cardElement) break + } catch {} + } - switch (type) { - // UrlReward - case 'urlreward': { - // Special "Double Search Points" activation - if (name.includes('ww_banner_optin_2x')) { - this.bot.logger.info( + if (cardElement) { + this.bot.logger.debug(this.bot.isMobile, 'ACTIVITY', `Card found for: ${activity.title}`) + + await cardElement.scrollIntoViewIfNeeded().catch(() => {}) + await this.bot.utils.wait(1000) + + // DESKTOP SPECIFIC: Trigger human-like events + if (!this.bot.isMobile) { + await cardElement.hover().catch(() => {}) + await this.bot.utils.wait(500) + + // Manually dispatch events + await page + .evaluate( + (sel: any) => { + const el = document.querySelector(sel) + if (el) { + ;['pointerdown', 'mousedown', 'pointerup', 'mouseup'].forEach(evt => { + el.dispatchEvent( + new MouseEvent(evt, { + bubbles: true, + cancelable: true, + view: window + }) + ) + }) + } + }, + (cardElement as any)._selector + ) + .catch(() => {}) + } + + const [newPage] = await Promise.all([ + page + .context() + .waitForEvent('page', { timeout: 10000 }) + .catch(() => null), + cardElement.click({ delay: this.bot.utils.randomDelay(200, 500) }).catch(() => { + return page.evaluate(targetUrl => { + window.open(targetUrl, '_blank') + }, url) + }) + ]) + + if (newPage) { + await newPage.waitForLoadState('domcontentloaded').catch(() => {}) + this.bot.logger.debug( this.bot.isMobile, 'ACTIVITY', - `Found activity type "Double Search Points" | title="${activity.title}" | offerId=${offerId}` + `New tab opened for: ${activity.title}` ) - await this.bot.activities.doDoubleSearchPoints(activity) - } - break - } + // Try to complete via API for V4 (even without hash, try using panel data) + if (this.bot.rewardsVersion === 'modern') { + await this.completeActivity(activity, newPage) + } - // Unsupported types - default: { + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 8000)) + await newPage.close().catch(() => {}) + } else { + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 8000)) + } + } else { this.bot.logger.warn( this.bot.isMobile, - 'SPECIAL-ACTIVITY', - `Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"` + 'ACTIVITY', + `Card NOT found on dashboard for: ${activity.title}. Navigating directly.` ) - break + await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => {}) + + // Try to complete via API for V4 (even without hash, try using panel data) + if (this.bot.rewardsVersion === 'modern') { + await this.completeActivity(activity, page) + } + + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 8000)) } } + + this.bot.logger.debug(this.bot.isMobile, 'ACTIVITY', `Finished attempt for: ${activity.title}`) } catch (error) { - this.bot.logger.error( - this.bot.isMobile, - 'SPECIAL-ACTIVITY', - `Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}` - ) + this.bot.logger.error(this.bot.isMobile, 'ACTIVITY', `Failed: ${activity.title}`) } } - - this.bot.logger.info(this.bot.isMobile, 'SPECIAL-ACTIVITY', 'All "Special Activites" items have been completed') } - public async doPunchCards(data: DashboardData, page: Page) { - const punchCards = - data.punchCards?.filter( - x => !x.parentPromotion?.complete && (x.parentPromotion?.pointProgressMax ?? 0) > 0 - ) ?? [] + public async doSpecialPromotions(data: DashboardData) {} + public async doPunchCards(data: DashboardData, page: Page) {} - const punchCardActivities = punchCards.flatMap(x => x.childPromotions) + private async completeActivity(activity: any, page: Page): Promise { + const offerId = activity.offerId - const activitiesUncompleted: BasePromotion[] = - punchCardActivities?.filter(x => { - if (x.complete) return false - if (x.exclusiveLockedFeatureStatus === 'locked') return false - if (!x.promotionType) return false + if (!offerId) { + this.bot.logger.warn(this.bot.isMobile, 'ACTIVITY', 'No offerId found') + return false + } - return true - }) ?? [] + this.bot.logger.info(this.bot.isMobile, 'ACTIVITY', `Completing: ${activity.title} (${offerId})`) - if (!activitiesUncompleted.length) { - this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', 'All "Punch Card" items have already been completed') - return - } + try { + const url = activity.destination || activity.destinationUrl + if (url) { + // For URL activities, visit the page first + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {}) - this.bot.logger.info( - this.bot.isMobile, - 'PUNCHCARD', - `Started solving ${activitiesUncompleted.length} "Punch Card" items` - ) + // Wait for page to load and any potential auto-complete + await this.bot.utils.wait(3000) - await this.solveActivities(activitiesUncompleted, page) + // Check if it's a quiz/poll that needs interaction + const pageText = await page.innerText('body').catch(() => '') - this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', 'All "Punch Card" items have been completed') - } + // If it's a quiz/poll, try to find and click answers (basic) + if (pageText.toLowerCase().includes('quiz') || pageText.toLowerCase().includes('poll')) { + this.bot.logger.debug(this.bot.isMobile, 'ACTIVITY', 'Detected quiz/poll, attempting interaction') + // Try clicking common quiz buttons + await page.click('button, [role="button"]', { timeout: 2000 }).catch(() => {}) + await this.bot.utils.wait(2000) + } - private async solveActivities(activities: BasePromotion[], page: Page, punchCard?: PunchCard) { - for (const activity of activities) { - try { - const type = activity.promotionType?.toLowerCase() ?? '' - const name = activity.name?.toLowerCase() ?? '' - const offerId = (activity as BasePromotion).offerId - const destinationUrl = activity.destinationUrl?.toLowerCase() ?? '' + // Now call the API to report completion + const panelData = this.bot.panelData + const todayKey = this.bot.utils.getFormattedDate() + + const userInfo = (panelData as any)?.userInfo + const panelPromotion = + userInfo?.promotions?.find((p: any) => p.offerId === offerId || p.name === offerId) || + panelData?.flyoutResult?.dailySetPromotions?.[todayKey]?.find((p: any) => p.offerId === offerId) || + panelData?.flyoutResult?.morePromotions?.find((p: any) => p.offerId === offerId) + + const jsonData = { + ActivityCount: 1, + ActivityType: panelPromotion?.activityType ?? activity.activityType ?? 0, + ActivitySubType: '', + OfferId: offerId, + AuthKey: panelPromotion?.hash ?? activity.hash ?? '', + Channel: panelData?.channel ?? 'BingRewards', + PartnerId: panelData?.partnerId ?? 'BingRewards', + UserId: panelData?.userId ?? '' + } this.bot.logger.debug( this.bot.isMobile, 'ACTIVITY', - `Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type} | punchCard="${punchCard?.parentPromotion?.title ?? 'none'}"` + `Calling reportActivity API | offerId=${offerId} | ActivityType=${jsonData.ActivityType}` ) - switch (type) { - // Quiz-like activities (Poll / regular quiz variants) - case 'quiz': { - const basePromotion = activity as BasePromotion - - // Poll (usually 10 points, pollscenarioid in URL) - if (activity.pointProgressMax === 10 && destinationUrl.includes('pollscenarioid')) { - this.bot.logger.info( - this.bot.isMobile, - 'ACTIVITY', - `Found activity type "Poll" | title="${activity.title}" | offerId=${offerId}` - ) - - //await this.bot.activities.doPoll(basePromotion) - break - } - - // All other quizzes handled via Quiz API - this.bot.logger.info( - this.bot.isMobile, - 'ACTIVITY', - `Found activity type "Quiz" | title="${activity.title}" | offerId=${offerId}` - ) - - await this.bot.activities.doQuiz(basePromotion) - break - } - - // UrlReward - case 'urlreward': { - const basePromotion = activity as BasePromotion - - // Search on Bing are subtypes of "urlreward" - if (name.includes('exploreonbing')) { - this.bot.logger.info( - this.bot.isMobile, - 'ACTIVITY', - `Found activity type "SearchOnBing" | title="${activity.title}" | offerId=${offerId}` - ) - - await this.bot.activities.doSearchOnBing(basePromotion, page) - } else { - this.bot.logger.info( - this.bot.isMobile, - 'ACTIVITY', - `Found activity type "UrlReward" | title="${activity.title}" | offerId=${offerId}` - ) - - await this.bot.activities.doUrlReward(basePromotion) - } - break - } + const context = page.context() as any + const cookies = await context.cookies() + const cookieHeader = cookies.map((c: any) => `${c.name}=${c.value}`).join('; ') + + const request: any = { + url: 'https://www.bing.com/msrewards/api/v1/reportactivity', + method: 'POST', + headers: { + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: cookieHeader, + 'Content-Type': 'application/json', + Origin: 'https://www.bing.com', + Referer: url + }, + data: JSON.stringify(jsonData) + } - // Find Clippy specific promotion type - case 'findclippy': { - const clippyPromotion = activity as unknown as FindClippyPromotion + try { + const response = await this.bot.axios.request(request) + this.bot.logger.debug( + this.bot.isMobile, + 'ACTIVITY', + `reportActivity response | offerId=${offerId} | status=${response.status}` + ) + } catch (apiError) { + this.bot.logger.warn( + this.bot.isMobile, + 'ACTIVITY', + `reportActivity API call failed | offerId=${offerId} | error=${apiError instanceof Error ? apiError.message : String(apiError)}` + ) + } - this.bot.logger.info( - this.bot.isMobile, - 'ACTIVITY', - `Found activity type "FindClippy" | title="${activity.title}" | offerId=${offerId}` - ) + this.bot.logger.info(this.bot.isMobile, 'ACTIVITY', `Completed: ${activity.title}`, 'green') + return true + } - await this.bot.activities.doFindClippy(clippyPromotion) - break - } + // No URL - try API + const panelData = this.bot.panelData + const todayKey = this.bot.utils.getFormattedDate() + + const panelPromotion = + panelData?.flyoutResult?.morePromotions?.find(p => p.offerId === offerId) || + panelData?.flyoutResult?.dailySetPromotions?.[todayKey]?.find(p => p.offerId === offerId) + + // Try desktop API endpoint (form data) - works for URL activities + const formData = new URLSearchParams({ + id: offerId, + hash: activity.hash || panelPromotion?.hash || '', + timeZone: '60', + activityAmount: '1', + dbs: '0', + form: '', + type: '', + __RequestVerificationToken: '' + }) + + const context = page.context() as any + const cookies = await context.cookies() + const cookieHeader = cookies.map((c: any) => `${c.name}=${c.value}`).join('; ') + + const request: any = { + url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + method: 'POST', + headers: { + ...(this.bot.fingerprint?.headers ?? {}), + Cookie: cookieHeader, + Referer: 'https://rewards.bing.com/', + Origin: 'https://rewards.bing.com', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: formData.toString() + } - // Unsupported types - default: { - this.bot.logger.warn( - this.bot.isMobile, - 'ACTIVITY', - `Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"` - ) - break - } + const response = await this.bot.axios.request(request) + + if (response.status === 200) { + const result = response.data + + // Check for V3/V4 success format + const earnedCredits = result?.EarnedCredits || result?.earnedCredits || 0 + const activityComplete = result?.ActivityComplete || result?.activityComplete || false + const errorCode = result?.ErrorDetail?.ErrorCode || result?.errorDetail?.errorCode + const v3ResultCode = result?.result?.resultCode ?? result?.resultCode + + if ( + activityComplete || + earnedCredits > 0 || + errorCode === 'I_SUCCESS' || + errorCode === 0 || + v3ResultCode === 0 + ) { + this.bot.logger.info( + this.bot.isMobile, + 'ACTIVITY', + `Completed: ${activity.title} | +${earnedCredits} points`, + 'green' + ) + return true } + } - // Cooldown - await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) - } catch (error) { - this.bot.logger.error( + this.bot.logger.warn( + this.bot.isMobile, + 'ACTIVITY', + `API returned status ${response.status} for: ${activity.title}` + ) + return false + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'ACTIVITY', + `Failed to complete: ${activity.title} - ${error instanceof Error ? error.message : String(error)}` + ) + const axiosError = error as any + if (axiosError.response?.data) { + this.bot.logger.warn( this.bot.isMobile, 'ACTIVITY', - `Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}` + `Response: ${JSON.stringify(axiosError.response.data)}` ) } + return false } } } diff --git a/src/functions/activities/api/DoubleSearchPoints.ts b/src/functions/activities/api/DoubleSearchPoints.ts index 90e4dd2c..3b513359 100644 --- a/src/functions/activities/api/DoubleSearchPoints.ts +++ b/src/functions/activities/api/DoubleSearchPoints.ts @@ -1,6 +1,7 @@ import type { AxiosRequestConfig } from 'axios' import { Workers } from '../../Workers' import { PromotionalItem } from '../../../interface/DashboardData' +import { randomUUID } from 'crypto' export class DoubleSearchPoints extends Workers { private cookieHeader: string = '' @@ -12,14 +13,15 @@ export class DoubleSearchPoints extends Workers { const activityType = promotion.activityType try { - if (!this.bot.requestToken && this.bot.rewardsVersion === 'legacy') { - this.bot.logger.warn( - this.bot.isMobile, - 'DOUBLE-SEARCH-POINTS', - 'Skipping: Request token not available, this activity requires it!' - ) - return - } + // Skip requestToken check for V4 + // if (!this.bot.requestToken && this.bot.rewardsVersion === 'legacy') { + // this.bot.logger.warn( + // this.bot.isMobile, + // 'DOUBLE-SEARCH-POINTS', + // 'Skipping: Request token not available, this activity requires it!' + // ) + // return + // } this.cookieHeader = this.bot.browser.func.buildCookieHeader( this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop, @@ -43,33 +45,36 @@ export class DoubleSearchPoints extends Workers { `Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` ) - const formData = new URLSearchParams({ - id: offerId, - hash: promotion.hash, - timeZone: '60', - activityAmount: '1', - dbs: '0', - form: '', - type: activityType, - __RequestVerificationToken: this.bot.requestToken - }) + // V4: Use mobile API with Bearer token + const jsonData = { + amount: 1, + id: randomUUID(), + type: 101, + attributes: { + offerid: offerId + }, + country: this.bot.userData.geoLocale + } this.bot.logger.debug( this.bot.isMobile, 'DOUBLE-SEARCH-POINTS', - `Prepared Double Search Points form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}` + `Prepared Double Search Points JSON data | offerId=${offerId} | hash=${promotion.hash} | amount=1 | type=${activityType}` ) const request: AxiosRequestConfig = { - url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', method: 'POST', headers: { - ...(this.bot.fingerprint?.headers ?? {}), - Cookie: this.cookieHeader, - Referer: 'https://rewards.bing.com/', - Origin: 'https://rewards.bing.com' + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', + 'Content-Type': 'application/json', + 'X-Rewards-Country': this.bot.userData.geoLocale, + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' }, - data: formData + data: JSON.stringify(jsonData) } this.bot.logger.debug( @@ -91,7 +96,7 @@ export class DoubleSearchPoints extends Workers { item.name.toLowerCase().includes('ww_banner_optin_2x') ) - // If OK, should no longer be presernt in promotionalItems + // If OK, should no longer be present in promotionalItems if (promotionalItem) { this.bot.logger.warn( this.bot.isMobile, diff --git a/src/functions/activities/api/FindClippy.ts b/src/functions/activities/api/FindClippy.ts index 324047f7..41a8c417 100644 --- a/src/functions/activities/api/FindClippy.ts +++ b/src/functions/activities/api/FindClippy.ts @@ -1,6 +1,7 @@ import type { AxiosRequestConfig } from 'axios' import type { FindClippyPromotion } from '../../../interface/DashboardData' import { Workers } from '../../Workers' +import { randomUUID } from 'crypto' export class FindClippy extends Workers { private cookieHeader: string = '' @@ -16,14 +17,15 @@ export class FindClippy extends Workers { const activityType = promotion.activityType try { - if (!this.bot.requestToken && this.bot.rewardsVersion === 'legacy') { - this.bot.logger.warn( - this.bot.isMobile, - 'FIND-CLIPPY', - 'Skipping: Request token not available, this activity requires it!' - ) - return - } + // Skip requestToken check for V4 + // if (!this.bot.requestToken && this.bot.rewardsVersion === 'legacy') { + // this.bot.logger.warn( + // this.bot.isMobile, + // 'FIND-CLIPPY', + // 'Skipping: Request token not available, this activity requires it!' + // ) + // return + // } this.cookieHeader = this.bot.browser.func.buildCookieHeader( this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop, @@ -47,33 +49,36 @@ export class FindClippy extends Workers { `Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` ) - const formData = new URLSearchParams({ - id: offerId, - hash: promotion.hash, - timeZone: '60', - activityAmount: '1', - dbs: '0', - form: '', - type: activityType, - __RequestVerificationToken: this.bot.requestToken - }) + // V4: Use mobile API with Bearer token + const jsonData = { + amount: 1, + id: randomUUID(), + type: 101, + attributes: { + offerid: offerId + }, + country: this.bot.userData.geoLocale + } this.bot.logger.debug( this.bot.isMobile, 'FIND-CLIPPY', - `Prepared Find Clippy form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}` + `Prepared Find Clippy JSON data | offerId=${offerId} | hash=${promotion.hash} | amount=1 | type=${activityType}` ) const request: AxiosRequestConfig = { - url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities', method: 'POST', headers: { - ...(this.bot.fingerprint?.headers ?? {}), - Cookie: this.cookieHeader, - Referer: 'https://rewards.bing.com/', - Origin: 'https://rewards.bing.com' + Authorization: `Bearer ${this.bot.accessToken}`, + 'User-Agent': + 'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2', + 'Content-Type': 'application/json', + 'X-Rewards-Country': this.bot.userData.geoLocale, + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' }, - data: formData + data: JSON.stringify(jsonData) } this.bot.logger.debug( diff --git a/src/functions/activities/api/UrlReward.ts b/src/functions/activities/api/UrlReward.ts index ef75e11a..10fccd3c 100644 --- a/src/functions/activities/api/UrlReward.ts +++ b/src/functions/activities/api/UrlReward.ts @@ -1,5 +1,6 @@ import type { AxiosRequestConfig } from 'axios' import type { BasePromotion } from '../../../interface/DashboardData' +import type { PanelFlyoutData } from '../../../interface/PanelFlyoutData' import { Workers } from '../../Workers' export class UrlReward extends Workers { @@ -46,33 +47,51 @@ export class UrlReward extends Workers { `Prepared UrlReward headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}` ) - const formData = new URLSearchParams({ - id: offerId, - hash: promotion.hash, - timeZone: '60', - activityAmount: '1', - dbs: '0', - form: '', - type: '', - __RequestVerificationToken: this.bot.requestToken - }) + // V4: Find promotion in panelData + const panelData: PanelFlyoutData = this.bot.panelData + const todayKey = this.bot.utils.getFormattedDate() + const userInfo = (panelData as any)?.userInfo + + const panelPromotion = + userInfo?.morePromotions?.find((p: any) => p.offerId === offerId) || + panelData?.flyoutResult?.dailySetPromotions?.[todayKey]?.find((p: any) => p.offerId === offerId) + + if (!panelPromotion) { + this.bot.logger.warn( + this.bot.isMobile, + 'URL-REWARD', + `Promotion not found in panel data | offerId=${offerId}` + ) + // Fallback to original activity if panel data not available + } + + // V4 API uses different endpoint and JSON payload + const jsonData = { + ActivityCount: 1, + ActivityType: panelPromotion?.activityType ?? 0, + ActivitySubType: '', + OfferId: offerId, + AuthKey: panelPromotion?.hash ?? promotion.hash, + Channel: panelData?.channel ?? 'BingRewards', + PartnerId: panelData?.partnerId ?? 'BingRewards', + UserId: panelData?.userId ?? '' + } this.bot.logger.debug( this.bot.isMobile, 'URL-REWARD', - `Prepared UrlReward form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1` + `Prepared UrlReward JSON data | offerId=${offerId} | hash=${panelPromotion?.hash ?? promotion.hash}` ) const request: AxiosRequestConfig = { - url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest', + url: 'https://www.bing.com/msrewards/api/v1/reportactivity', method: 'POST', headers: { ...(this.bot.fingerprint?.headers ?? {}), - Cookie: this.cookieHeader, - Referer: 'https://rewards.bing.com/', - Origin: 'https://rewards.bing.com' + 'Content-Type': 'application/json', + Origin: 'https://www.bing.com' }, - data: formData + data: JSON.stringify(jsonData) } this.bot.logger.debug( diff --git a/src/functions/activities/browser/Quest.ts b/src/functions/activities/browser/Quest.ts new file mode 100644 index 00000000..6daa97a9 --- /dev/null +++ b/src/functions/activities/browser/Quest.ts @@ -0,0 +1,490 @@ +import type { Page } from 'patchright' + +import { Workers } from '../../Workers' +import type { MicrosoftRewardsBot } from '../../../index' + +interface QuestCard { + href: string + title: string + points: string + tasks: string +} + +interface QuestTask { + title: string + destination: string + offerId: string + isCompleted: boolean + isLocked: boolean +} + +/** + * Quest activity handler - discovers and completes quest tasks + * + * LIMITATION: Only supports bing.com/search URLs + * ============================================ + * ms-search:// protocol URLs are NOT supported for the following reasons: + * 1. ms-search:// is a custom protocol that doesn't load in standard browsers + * 2. Task content (title, description, completion status) is NOT visible in the DOM + * 3. Task metadata is embedded in Next.js JSON data, not accessible via DOM queries + * 4. No way to reliably determine if a task is completed without API calls + * 5. Click handlers are obfuscated and don't follow standard HTML link patterns + * + * WORKAROUND: Only bing.com/search tasks are processed because: + * - They render as standard tags with href attributes + * - Click opens new tab/window, allowing standard navigation detection + * - Task completion can be inferred from page state changes + * - No API calls required for task discovery + * + * CONSEQUENCE: quests with exclusively ms-search:// tasks will be skipped + */ +export class Quest extends Workers { + constructor(bot: MicrosoftRewardsBot) { + super(bot) + } + + public async doQuests(page: Page): Promise { + this.bot.logger.info(this.bot.isMobile, 'QUEST', 'Starting Quest activity') + + const allQuests = new Map() + + try { + // Set desktop viewport FIRST (before navigation) + try { + await page.setViewportSize({ width: 1920, height: 1080 }) + } catch { + /* ignore */ + } + + await page + .goto('https://rewards.bing.com/earn', { waitUntil: 'domcontentloaded', timeout: 15000 }) + .catch(() => {}) + await this.bot.utils.wait(3000) + + // Scroll to trigger lazy loading + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => {}) + await this.bot.utils.wait(3000) + await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}) + await this.bot.utils.wait(2000) + + // Search for all quest links + const foundQuests = await this.findQuestLinks(page) + + // Also search raw HTML for quest links + const html = await page.content() + const htmlQuestMatches = html.matchAll(/href="(\/earn\/quest\/[^"]+)"/g) + for (const match of htmlQuestMatches) { + const href = match[1] ?? '' + if (href && !allQuests.has(href)) { + allQuests.set(href, { + href, + title: 'Quest (from HTML)', + points: '?', + tasks: '?/?' + }) + this.bot.logger.debug(this.bot.isMobile, 'QUEST', `Found in HTML: ${href}`) + } + } + + // Try known quest URLs (unique quests only) + const knownQuestIds = ['ENstar_pcparent_FY26_WSB_Dec_punchcard'] + + for (const questId of knownQuestIds) { + const href = `/earn/quest/${questId}` + if (allQuests.has(href)) continue + + try { + const response = await page + .goto(`https://rewards.bing.com${href}`, { waitUntil: 'domcontentloaded', timeout: 10000 }) + .catch(() => null) + if (response && response.status() === 200) { + const questHtml = await page.content() + if (questHtml.includes(questId)) { + allQuests.set(href, { + href, + title: `Quest ${questId}`, + points: '?', + tasks: '?/?' + }) + this.bot.logger.debug(this.bot.isMobile, 'QUEST', `Found via navigation: ${href}`) + } + } + } catch { + /* ignore */ + } + } + + for (const q of foundQuests) { + if (!allQuests.has(q.href)) allQuests.set(q.href, q) + } + + const questLinks = Array.from(allQuests.values()) + + if (questLinks.length === 0) { + this.bot.logger.info(this.bot.isMobile, 'QUEST', 'No quests found') + return + } + + this.bot.logger.info(this.bot.isMobile, 'QUEST', `Found ${questLinks.length} unique quest(s) total`) + + for (const quest of questLinks) { + this.bot.logger.info( + this.bot.isMobile, + 'QUEST', + `Processing: "${quest.title}" (${quest.points}, ${quest.tasks})` + ) + await this.processQuest(page, quest) + } + + this.bot.logger.info(this.bot.isMobile, 'QUEST', 'All quests processed', 'green') + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'QUEST', + `Error: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async findQuestLinks(page: Page): Promise { + const quests: QuestCard[] = [] + const seenHrefs = new Set() + + try { + // Use JavaScript to find ALL elements with quest URLs across entire page + const questData = await page + .evaluate(() => { + const results: Array<{ href: string; text: string }> = [] + + // Search all tags + document.querySelectorAll('a[href]').forEach(el => { + const href = el.getAttribute('href') ?? '' + if (href.includes('/earn/quest/') || href.includes('punchcard')) { + results.push({ href, text: el.textContent?.trim() ?? '' }) + } + }) + + // Search all elements with onclick or data attributes containing quest URLs + document + .querySelectorAll('[onclick*="quest"], [data-href*="quest"], [data-url*="quest"]') + .forEach(el => { + const onclick = el.getAttribute('onclick') ?? '' + const dataHref = el.getAttribute('data-href') ?? '' + const dataUrl = el.getAttribute('data-url') ?? '' + const href = onclick || dataHref || dataUrl + if (href.includes('/earn/quest/')) { + results.push({ href, text: el.textContent?.trim() ?? '' }) + } + }) + + return results + }) + .catch(() => []) + + for (const item of questData) { + const href = item.href + if (!href || seenHrefs.has(href)) continue + seenHrefs.add(href) + + const text = item.text + const lines = text + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + const title = lines.find(l => l.length > 20) || lines[0] || 'Unknown Quest' + const pointsMatch = text.match(/\+(\d+)/) + const tasksMatch = text.match(/(\d+\/\d+)\s*tasks?/i) + + quests.push({ + href, + title, + points: pointsMatch?.[1] ? `+${pointsMatch[1]}` : '?', + tasks: tasksMatch?.[1] ?? '?/?' + }) + + this.bot.logger.debug( + this.bot.isMobile, + 'QUEST', + `Quest found: "${title.substring(0, 60)}..." | ${href}` + ) + } + + this.bot.logger.debug(this.bot.isMobile, 'QUEST', `Found ${quests.length} unique quest(s) on page`) + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'QUEST', + `Error finding quests: ${error instanceof Error ? error.message : String(error)}` + ) + } + + return quests + } + + private async processQuest(page: Page, quest: QuestCard): Promise { + try { + const questId = quest.href.split('/').pop() || '' + + // Ensure desktop viewport + try { + await page.setViewportSize({ width: 1920, height: 1080 }) + } catch { + /* ignore */ + } + + // Navigate to quest detail page + await page.goto(quest.href, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}) + + // Wait for page content to load + await this.bot.utils.wait(2000) + + // Scroll to trigger lazy loading + for (let i = 0; i < 5; i++) { + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => {}) + await this.bot.utils.wait(1000) + await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}) + await this.bot.utils.wait(1000) + } + + // Wait for task links to appear + try { + await page.waitForFunction(() => !!document.querySelectorAll('a[href*="bing.com/search"]').length, { + timeout: 15000 + }) + } catch { + this.bot.logger.debug(this.bot.isMobile, 'QUEST', 'Timed out waiting for task links to appear') + } + + // Additional wait for React/Vue components to fully render + await this.bot.utils.wait(2000) + + // Use JavaScript to find ALL task links on the page + let allLinks = await page + .evaluate(() => { + const results: Array<{ href: string; text: string; ariaLabel: string }> = [] + + // Method 1: Direct attribute search for bing.com/search + document.querySelectorAll('a[href]').forEach(el => { + const href = el.getAttribute('href') ?? '' + const text = el.textContent?.trim() ?? '' + const ariaLabel = el.getAttribute('aria-label') ?? '' + if (href.includes('bing.com/search')) { + results.push({ href, text, ariaLabel }) + } + }) + + // Method 2: Search in all elements' outer HTML as fallback + if (results.length === 0) { + document.querySelectorAll('[class*="button"], [class*="link"], div, span').forEach(el => { + const html = el.outerHTML ?? '' + if (html.includes('bing.com/search') && html.includes(' { + const href = match.replace(/^href=["']|["']$/g, '') + const textEl = el.textContent?.trim() ?? '' + if (href && !results.some(r => r.href === href)) { + results.push({ + href, + text: textEl, + ariaLabel: el.getAttribute('aria-label') ?? '' + }) + } + }) + } + } + }) + } + + return results + }) + .catch(() => []) + + // If still no links found, try regex extraction + if (allLinks.length === 0) { + allLinks = await page + .evaluate(() => { + const results: Array<{ href: string; text: string; ariaLabel: string }> = [] + const html = document.body.innerHTML + + // Extract bing search URLs using regex + const bingMatches = html.matchAll(/href=["']([^"']*bing\.com\/search[^"']*)["']/g) + for (const match of bingMatches) { + const href = match[1] ?? '' + if (href && !results.some(r => r.href === href)) { + results.push({ href, text: '', ariaLabel: '' }) + } + } + + return results + }) + .catch(() => []) + } + + this.bot.logger.debug(this.bot.isMobile, 'QUEST', `Found ${allLinks.length} task links on ${questId} page`) + + if (allLinks.length === 0) { + this.bot.logger.info(this.bot.isMobile, 'QUEST', `No available tasks for "${quest.title}"`) + return + } + + // Process each task link + for (const link of allLinks) { + const title = link.ariaLabel || link.text || 'Unknown' + const cleanTitle = title + .replace(/^.*?,\s*/, '') + .replace(/\s*-\s*Click to complete\.?/i, '') + .replace(/\s*Click to complete\.?/i, '') + .trim() + + if (!cleanTitle || cleanTitle.length < 3) continue + + // Create task object + const task: QuestTask = { + title: cleanTitle.substring(0, 150), + destination: link.href, + offerId: `task_${Date.now()}`, + isCompleted: false, + isLocked: false + } + + this.bot.logger.info(this.bot.isMobile, 'QUEST-TASK', `Processing: "${cleanTitle}"`) + await this.clickTask(page, task) + + const cooldown = this.bot.utils.randomDelay(8000, 15000) + this.bot.logger.debug(this.bot.isMobile, 'QUEST-TASK', `Cooldown ${cooldown}ms`) + await this.bot.utils.wait(cooldown) + + // Re-navigate to quest page for next task to refresh state + try { + await page.goto(quest.href, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) + await this.bot.utils.wait(2000) + + // Scroll to trigger lazy loading again + for (let i = 0; i < 3; i++) { + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => {}) + await this.bot.utils.wait(500) + await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}) + await this.bot.utils.wait(500) + } + + await this.bot.utils.wait(1000) + } catch (e) { + this.bot.logger.warn( + this.bot.isMobile, + 'QUEST', + `Failed to re-navigate to quest page: ${e instanceof Error ? e.message : String(e)}` + ) + } + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'QUEST', + `Error processing quest "${quest.title}": ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async clickTask(page: Page, task: QuestTask): Promise { + try { + if (!task.destination) { + this.bot.logger.warn(this.bot.isMobile, 'QUEST-TASK', `No URL for: "${task.title}"`) + return + } + + this.bot.logger.debug( + this.bot.isMobile, + 'QUEST-TASK', + `Attempting to click: "${task.title}" | ${task.destination.substring(0, 80)}` + ) + + // Find the link - try multiple strategies + // NOTE: Only works for bing.com/search URLs (ms-search:// tasks never reach here) + let linkElement: any = null + + // Strategy 1: Find by exact href match + // Most reliable - exact URL from DOM + try { + linkElement = await page.locator(`a[href="${task.destination}"]`).first() + const count = await linkElement.count().catch(() => 0) + if (count > 0) { + this.bot.logger.debug(this.bot.isMobile, 'QUEST-TASK', 'Found by exact href match') + } + } catch {} + + // Strategy 2: If exact match fails, find by partial href (bing.com/search) + if (!linkElement || (await linkElement.count().catch(() => 0)) === 0) { + try { + linkElement = page.locator(`a[href*="bing.com/search"]`).first() + const count = await linkElement.count().catch(() => 0) + if (count > 0) { + this.bot.logger.debug(this.bot.isMobile, 'QUEST-TASK', 'Found by partial href match') + } + } catch {} + } + + // Strategy 3: Last resort - use JavaScript to simulate the click + if (!linkElement || (await linkElement.count().catch(() => 0)) === 0) { + this.bot.logger.debug(this.bot.isMobile, 'QUEST-TASK', 'Using JavaScript click simulation') + await page + .evaluate(href => { + const link = Array.from(document.querySelectorAll('a[href]')).find( + el => el.getAttribute('href') === href + ) as HTMLAnchorElement | undefined + if (link) { + link.click() + return true + } + return false + }, task.destination) + .catch(() => false) + + await this.bot.utils.wait(3000) + this.bot.logger.info(this.bot.isMobile, 'QUEST-TASK', `Clicked (JS): "${task.title}"`) + return + } + + // Now we have a valid linkElement, click it + await linkElement.scrollIntoViewIfNeeded().catch(() => {}) + await this.bot.utils.wait(500) + + // Handle Bing search URLs - click and open new tab + const [newPage] = await Promise.all([ + page + .context() + .waitForEvent('page', { timeout: 10000 }) + .catch(() => null), + linkElement.click({ delay: this.bot.utils.randomDelay(200, 500) }).catch(() => {}) + ]) + + if (newPage) { + await newPage.waitForLoadState('domcontentloaded').catch(() => {}) + this.bot.logger.info( + this.bot.isMobile, + 'QUEST-TASK', + `Clicked: "${task.title}" → ${newPage.url().substring(0, 60)}...` + ) + await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 8000)) + await newPage.close().catch(() => {}) + } else { + await this.bot.utils.wait(this.bot.utils.randomDelay(3000, 5000)) + this.bot.logger.info(this.bot.isMobile, 'QUEST-TASK', `Clicked: "${task.title}" (same tab)`) + } + + // Navigate back to earn page if needed + if (!page.url().includes('/earn')) { + await page + .goto('https://rewards.bing.com/earn', { waitUntil: 'networkidle', timeout: 15000 }) + .catch(() => {}) + await this.bot.utils.wait(2000) + } + } catch (error) { + this.bot.logger.error( + this.bot.isMobile, + 'QUEST-TASK', + `Error: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/index.ts b/src/index.ts index eeaa2dd3..a0d3d38d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { Login } from './browser/auth/Login' import { Workers } from './functions/Workers' import Activities from './functions/Activities' import { SearchManager } from './functions/SearchManager' +import NextParser from './util/NextParser' import type { Account } from './interface/Account' import AxiosClient from './util/Axios' @@ -25,6 +26,7 @@ import { sendDiscord, flushDiscordQueue } from './logging/Discord' import { sendNtfy, flushNtfyQueue } from './logging/Ntfy' import type { DashboardData } from './interface/DashboardData' import type { AppDashboardData } from './interface/AppDashBoardData' +import type { PanelFlyoutData } from './interface/PanelFlyoutData' interface ExecutionContext { isMobile: boolean @@ -73,6 +75,7 @@ export class MicrosoftRewardsBot { public logger: Logger public config public utils: Utils + public nextParser: NextParser = new NextParser() public activities: Activities = new Activities(this) public browser: { func: BrowserFunc; utils: BrowserUtils } @@ -82,6 +85,7 @@ export class MicrosoftRewardsBot { public userData: UserData public rewardsVersion: 'legacy' | 'modern' = 'legacy' + public panelData!: PanelFlyoutData public accessToken = '' public requestToken = '' @@ -408,25 +412,26 @@ export class MicrosoftRewardsBot { this.cookies.mobile = await initialContext.cookies() this.fingerprint = mobileSession.fingerprint - const data: DashboardData = await this.browser.func.getDashboardData() + const data: DashboardData = await this.browser.func.getDashboardDataFromPage(this.mainMobilePage) const appData: AppDashboardData = await this.browser.func.getAppDashboardData() + // Fetch panel flyout data (V4 alternative source) + try { + this.panelData = await this.browser.func.getPanelFlyoutData() + this.logger.debug(this.isMobile, 'MAIN', 'Panel flyout data fetched successfully') + } catch (error) { + this.logger.warn(this.isMobile, 'MAIN', `Failed to fetch panel flyout data: ${error}`) + } + // Set geo this.userData.geoLocale = account.geoLocale === 'auto' ? data.userProfile.attributes.country : account.geoLocale.toLowerCase() - if (this.userData.geoLocale.length > 2) { - this.logger.warn( - 'main', - 'GEO-LOCALE', - `The provided geoLocale is longer than 2 (${this.userData.geoLocale} | auto=${account.geoLocale === 'auto'}), this is likely invalid and can cause errors!` - ) - } this.userData.initialPoints = data.userStatus.availablePoints this.userData.currentPoints = data.userStatus.availablePoints const initialPoints = this.userData.initialPoints ?? 0 - const browserEarnable = await this.browser.func.getBrowserEarnablePoints() + const browserEarnable = await this.browser.func.getBrowserEarnablePoints(data) const appEarnable = await this.browser.func.getAppEarnablePoints() this.pointsCanCollect = browserEarnable.mobileSearchPoints + (appEarnable?.totalEarnablePoints ?? 0) @@ -442,11 +447,40 @@ export class MicrosoftRewardsBot { if (this.config.workers.doAppPromotions) await this.workers.doAppPromotions(appData) if (this.config.workers.doDailySet) await this.workers.doDailySet(data, this.mainMobilePage) if (this.config.workers.doSpecialPromotions) await this.workers.doSpecialPromotions(data) + if (this.config.workers.doQuests) await this.activities.doQuests(this.mainMobilePage) if (this.config.workers.doMorePromotions) await this.workers.doMorePromotions(data, this.mainMobilePage) if (this.config.workers.doDailyCheckIn) await this.activities.doDailyCheckIn() if (this.config.workers.doReadToEarn) await this.activities.doReadToEarn() if (this.config.workers.doPunchCards) await this.workers.doPunchCards(data, this.mainMobilePage) + // DESKTOP SESSION (V4 Adaptation - Session Reuse) + this.logger.info('main', 'FLOW', `Switching to Desktop mode for ${accountEmail} to solve activities...`) + try { + await executionContext.run({ isMobile: false, account }, async () => { + await this.mainMobilePage.setViewportSize({ width: 1920, height: 1080 }) + const desktopUA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.62' + await (this.mainMobilePage.context() as any)._setExtraHTTPHeaders?.({ 'User-Agent': desktopUA }) + + this.logger.info('main', 'BROWSER', `Emulating Desktop view & User-Agent | ${accountEmail}`) + + const desktopData: DashboardData = await this.browser.func.getDashboardDataFromPage( + this.mainMobilePage + ) + + if (this.config.workers.doDailySet) + await this.workers.doDailySet(desktopData, this.mainMobilePage) + if (this.config.workers.doMorePromotions) + await this.workers.doMorePromotions(desktopData, this.mainMobilePage) + + await (this.mainMobilePage.context() as any)._setExtraHTTPHeaders?.({ + 'User-Agent': mobileSession!.fingerprint.headers['User-Agent'] + }) + }) + } catch (desktopError) { + this.logger.error('main', 'DESKTOP-SESSION', `Error during desktop emulation: ${desktopError}`) + } + const searchPoints = await this.browser.func.getSearchPoints() const missingSearchPoints = this.browser.func.missingSearchPoints(searchPoints, true) diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 6cb63388..1ac24523 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -45,6 +45,7 @@ export interface ConfigWorkers { doMobileSearch: boolean doDailyCheckIn: boolean doReadToEarn: boolean + doQuests: boolean } // Webhooks diff --git a/src/interface/PanelFlyoutData.ts b/src/interface/PanelFlyoutData.ts new file mode 100644 index 00000000..13596a3a --- /dev/null +++ b/src/interface/PanelFlyoutData.ts @@ -0,0 +1,29 @@ +export interface PanelFlyoutData { + channel: string + partnerId: string + userId: string + flyoutResult: FlyoutResult +} + +export interface FlyoutResult { + morePromotions: PanelPromotion[] + dailySetPromotions: { [date: string]: PanelPromotion[] } + userStatus: PanelUserStatus +} + +export interface PanelPromotion { + offerId: string + hash: string + activityType: number + title: string + points: number + isCompleted: boolean + destinationUrl?: string + destination?: string +} + +export interface PanelUserStatus { + availablePoints: number + lifetimePoints: number + userId: string +} diff --git a/src/util/Load.ts b/src/util/Load.ts index 3e75f996..c1e32504 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -63,6 +63,13 @@ export async function loadSessionData( if (fs.existsSync(cookieFile)) { const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8') cookies = JSON.parse(cookiesData) + + const hasValidAuth = cookies.some(c => c.name === '_C_Auth' && c.value && c.value.length > 0) + if (!hasValidAuth) { + return { cookies: [], fingerprint: undefined } + } + } else { + return { cookies: [], fingerprint: undefined } } const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json' diff --git a/src/util/NextParser.ts b/src/util/NextParser.ts new file mode 100644 index 00000000..4443d449 --- /dev/null +++ b/src/util/NextParser.ts @@ -0,0 +1,84 @@ +export default class NextParser { + /** + * Parse Next.js streaming data from HTML (self.__next_f.push) + */ + public parse(html: string): any[] { + const regex = /self\.__next_f\.push\(\[1,"(.*?)"\]\)/g + let match + let stream = '' + + while ((match = regex.exec(html)) !== null) { + if (match[1]) { + // Unescape characters that Next.js uses in its stream + stream += match[1] + .replace(/\\"/g, '"') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\\\/g, '\\') + } + } + + const results: any[] = [] + // Look for common patterns in the combined stream + try { + // More generic pattern to find objects that look like activities + const patterns = [ + /\{"dailySetItems":\[.*?\]\}/g, + /\{"moreActivities":\[.*?\]\}/g, + /\{"availablePoints":\d+.*?\}/g, + /\{"streak":\{.*?\}/g, + /\{"offerId":".*?","title":".*?","destination":".*?"\}/g + ] + + for (const pattern of patterns) { + let m + while ((m = pattern.exec(stream)) !== null) { + try { + const parsed = JSON.parse(m[0]) + results.push(parsed) + } catch { + /* skip */ + } + } + } + + // Extract ALL objects from the stream aggressively + // This regex finds JSON objects + const jsonRegex = /\{(?:[^{}]|\{[^{}]*\})*\}/g + let matchJson + while ((matchJson = jsonRegex.exec(stream)) !== null) { + try { + const parsed = JSON.parse(matchJson[0]) + if ( + matchJson[0].includes('offerId') || + matchJson[0].includes('title') || + matchJson[0].includes('Points') + ) { + results.push(parsed) + } + } catch { + /* skip */ + } + } + } catch (e) {} + + return results + } + + public find(data: any, key: string): any { + if (!data) return undefined + if (Array.isArray(data)) { + for (const i of data) { + const r = this.find(i, key) + if (r !== undefined) return r + } + } else if (typeof data === 'object') { + if (data[key] !== undefined) return data[key] + for (const k in data) { + const r = this.find(data[k], key) + if (r !== undefined) return r + } + } + return undefined + } +} diff --git a/src/util/Validator.ts b/src/util/Validator.ts index 333dda4d..1f4ec640 100644 --- a/src/util/Validator.ts +++ b/src/util/Validator.ts @@ -60,7 +60,8 @@ export const ConfigSchema = z.object({ doDesktopSearch: z.boolean(), doMobileSearch: z.boolean(), doDailyCheckIn: z.boolean(), - doReadToEarn: z.boolean() + doReadToEarn: z.boolean(), + doQuests: z.boolean() }), searchOnBingLocalQueries: z.boolean(), globalTimeout: NumberOrString,