mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-21 22:13:57 +00:00
Initial commit
This commit is contained in:
192
src/browser/Browser.ts
Normal file
192
src/browser/Browser.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import playwright, { BrowserContext } from 'rebrowser-playwright'
|
||||
|
||||
import { newInjectedContext } from 'fingerprint-injector'
|
||||
import { FingerprintGenerator } from 'fingerprint-generator'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { loadSessionData, saveFingerprintData } from '../util/Load'
|
||||
import { updateFingerprintUserAgent } from '../util/UserAgent'
|
||||
|
||||
import { AccountProxy } from '../interface/Account'
|
||||
|
||||
/* Test Stuff
|
||||
https://abrahamjuliot.github.io/creepjs/
|
||||
https://botcheck.luminati.io/
|
||||
https://fv.pro/
|
||||
https://pixelscan.net/
|
||||
https://www.browserscan.net/
|
||||
*/
|
||||
|
||||
class Browser {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async createBrowser(proxy: AccountProxy, email: string): Promise<BrowserContext> {
|
||||
// Optional automatic browser installation (set AUTO_INSTALL_BROWSERS=1)
|
||||
if (process.env.AUTO_INSTALL_BROWSERS === '1') {
|
||||
try {
|
||||
// Dynamically import child_process to avoid overhead otherwise
|
||||
const { execSync } = await import('child_process')
|
||||
execSync('npx playwright install chromium', { stdio: 'ignore' })
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
let browser: import('rebrowser-playwright').Browser
|
||||
try {
|
||||
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
|
||||
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
|
||||
// Support legacy config.headless OR nested config.browser.headless
|
||||
const legacyHeadless = (this.bot.config as { headless?: boolean }).headless
|
||||
const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless
|
||||
let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false)
|
||||
if (this.bot.isBuyModeEnabled() && !envForceHeadless) {
|
||||
if (headlessValue !== false) {
|
||||
const target = this.bot.getBuyModeTarget()
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Buy mode detected${target ? ` for ${target}` : ''}; forcing headless=false so captchas and manual flows remain interactive.`, 'warn')
|
||||
}
|
||||
headlessValue = false
|
||||
}
|
||||
const headless: boolean = Boolean(headlessValue)
|
||||
|
||||
const engineName = 'chromium' // current hard-coded engine
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
|
||||
const proxyConfig = this.buildPlaywrightProxy(proxy)
|
||||
|
||||
browser = await playwright.chromium.launch({
|
||||
// Optional: uncomment to use Edge instead of Chromium
|
||||
// channel: 'msedge',
|
||||
headless,
|
||||
...(proxyConfig && { proxy: proxyConfig }),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--ignore-ssl-errors'
|
||||
]
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
const msg = (e instanceof Error ? e.message : String(e))
|
||||
// Common missing browser executable guidance
|
||||
if (/Executable doesn't exist/i.test(msg)) {
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies (or set AUTO_INSTALL_BROWSERS=1 to auto-attempt).', 'error')
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
|
||||
const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint
|
||||
const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint
|
||||
const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false }
|
||||
|
||||
const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint)
|
||||
|
||||
const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint()
|
||||
|
||||
const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint })
|
||||
|
||||
// Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
|
||||
const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout
|
||||
const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout
|
||||
const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000
|
||||
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout))
|
||||
|
||||
// Normalize viewport and page rendering so content fits typical screens
|
||||
try {
|
||||
const desktopViewport = { width: 1280, height: 800 }
|
||||
const mobileViewport = { width: 390, height: 844 }
|
||||
|
||||
context.on('page', async (page) => {
|
||||
try {
|
||||
// Set a reasonable viewport size depending on device type
|
||||
if (this.bot.isMobile) {
|
||||
await page.setViewportSize(mobileViewport)
|
||||
} else {
|
||||
await page.setViewportSize(desktopViewport)
|
||||
}
|
||||
|
||||
// Inject a tiny CSS to avoid gigantic scaling on some environments
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
const style = document.createElement('style')
|
||||
style.id = '__mrs_fit_style'
|
||||
style.textContent = `
|
||||
html, body { overscroll-behavior: contain; }
|
||||
/* Mild downscale to keep content within window on very large DPI */
|
||||
@media (min-width: 1000px) {
|
||||
html { zoom: 0.9 !important; }
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
|
||||
await context.addCookies(sessionData.cookies)
|
||||
|
||||
// Persist fingerprint when feature is configured
|
||||
if (saveFingerprint.mobile || saveFingerprint.desktop) {
|
||||
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`)
|
||||
|
||||
return context as BrowserContext
|
||||
}
|
||||
|
||||
private buildPlaywrightProxy(proxy: AccountProxy): { server: string; username?: string; password?: string } | undefined {
|
||||
const { url, port, username, password } = proxy
|
||||
if (!url) return undefined
|
||||
|
||||
const trimmed = url.trim()
|
||||
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)
|
||||
const candidate = hasScheme ? trimmed : `http://${trimmed}`
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(candidate)
|
||||
} catch (err) {
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Invalid proxy URL "${url}": ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!parsed.port) {
|
||||
if (port) {
|
||||
parsed.port = String(port)
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Proxy port missing for "${url}"`, 'error')
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const server = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`
|
||||
|
||||
const auth: { username?: string; password?: string } = {}
|
||||
if (username) auth.username = username
|
||||
if (password) auth.password = password
|
||||
|
||||
return { server, ...auth }
|
||||
}
|
||||
|
||||
async generateFingerprint() {
|
||||
const fingerPrintData = new FingerprintGenerator().getFingerprint({
|
||||
devices: this.bot.isMobile ? ['mobile'] : ['desktop'],
|
||||
operatingSystems: this.bot.isMobile ? ['android'] : ['windows'],
|
||||
browsers: [{ name: 'edge' }]
|
||||
})
|
||||
|
||||
const updatedFingerPrintData = await updateFingerprintUserAgent(fingerPrintData, this.bot.isMobile)
|
||||
|
||||
return updatedFingerPrintData
|
||||
}
|
||||
}
|
||||
|
||||
export default Browser
|
||||
544
src/browser/BrowserFunc.ts
Normal file
544
src/browser/BrowserFunc.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import { BrowserContext, Page } from 'rebrowser-playwright'
|
||||
import { CheerioAPI, load } from 'cheerio'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
import { TIMEOUTS, RETRY_LIMITS, SELECTORS, URLS } from '../constants'
|
||||
|
||||
import { Counters, DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
|
||||
import { QuizData } from '../interface/QuizData'
|
||||
import { AppUserData } from '../interface/AppUserData'
|
||||
import { EarnablePoints } from '../interface/Points'
|
||||
|
||||
|
||||
export default class BrowserFunc {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Navigate the provided page to rewards homepage
|
||||
* @param {Page} page Playwright page
|
||||
*/
|
||||
async goHome(page: Page) {
|
||||
|
||||
try {
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
|
||||
if (page.url() === dashboardURL.href) {
|
||||
return
|
||||
}
|
||||
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
|
||||
for (let iteration = 1; iteration <= RETRY_LIMITS.GO_HOME_MAX; iteration++) {
|
||||
await this.bot.utils.wait(TIMEOUTS.LONG)
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
try {
|
||||
// If activities are found, exit the loop (SUCCESS - account is OK)
|
||||
await page.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: 1000 })
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
|
||||
break
|
||||
|
||||
} catch (error) {
|
||||
// Activities not found yet - check if it's because account is suspended
|
||||
// Only check suspension if we can't find activities (reduces false positives)
|
||||
const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }).then(() => true).catch(() => false)
|
||||
|
||||
if (suspendedByHeader) {
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error')
|
||||
throw new Error('Account has been suspended!')
|
||||
}
|
||||
|
||||
// Secondary check: look for suspension text in main content area only
|
||||
try {
|
||||
const mainContent = (await page.locator('#contentContainer, #main, .main-content').first().textContent({ timeout: 500 }).catch(() => '')) || ''
|
||||
const suspensionPatterns = [
|
||||
/account\s+has\s+been\s+suspended/i,
|
||||
/suspended\s+due\s+to\s+unusual\s+activity/i,
|
||||
/your\s+account\s+is\s+temporarily\s+suspended/i
|
||||
]
|
||||
|
||||
const isSuspended = suspensionPatterns.some(pattern => pattern.test(mainContent))
|
||||
if (isSuspended) {
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by content text (iteration ${iteration})`, 'error')
|
||||
throw new Error('Account has been suspended!')
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors in text check - not critical
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${e}`, 'warn')
|
||||
}
|
||||
|
||||
// Not suspended, just activities not loaded yet - continue to next iteration
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', `Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`, 'warn')
|
||||
}
|
||||
|
||||
// Below runs if the homepage was unable to be visited
|
||||
const currentURL = new URL(page.url())
|
||||
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
|
||||
break
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(TIMEOUTS.VERY_LONG)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Go home failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user dashboard data
|
||||
* @returns {DashboardData} Object of user bing rewards dashboard data
|
||||
*/
|
||||
async getDashboardData(page?: Page): Promise<DashboardData> {
|
||||
const target = page ?? this.bot.homePage
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
const currentURL = new URL(target.url())
|
||||
|
||||
try {
|
||||
// Should never happen since tasks are opened in a new tab!
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
||||
await this.goHome(target)
|
||||
}
|
||||
|
||||
let lastError: unknown = null
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
// Reload the page to get new data
|
||||
await target.reload({ waitUntil: 'domcontentloaded' })
|
||||
lastError = null
|
||||
break
|
||||
} catch (re) {
|
||||
lastError = re
|
||||
const msg = (re instanceof Error ? re.message : String(re))
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn')
|
||||
// If page/context closed => bail early after first retry
|
||||
if (msg.includes('has been closed')) {
|
||||
if (attempt === 1) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
|
||||
try {
|
||||
await this.goHome(target)
|
||||
} catch {/* ignore */}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (attempt === 2) {
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If reload failed after all attempts, throw the last error
|
||||
if (lastError) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// Wait a bit longer for scripts to load, especially on mobile
|
||||
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
|
||||
|
||||
// Wait for the more-activities element to ensure page is fully loaded
|
||||
await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Activities element not found, continuing anyway', 'warn')
|
||||
})
|
||||
|
||||
let scriptContent = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
// Try multiple patterns for better compatibility
|
||||
const targetScript = scripts.find(script =>
|
||||
script.innerText.includes('var dashboard') ||
|
||||
script.innerText.includes('dashboard=') ||
|
||||
script.innerText.includes('dashboard :')
|
||||
)
|
||||
|
||||
return targetScript?.innerText ? targetScript.innerText : null
|
||||
})
|
||||
|
||||
if (!scriptContent) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn')
|
||||
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch((e) => {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Failed to capture diagnostics: ${e}`, 'warn')
|
||||
})
|
||||
|
||||
// Force a navigation retry once before failing hard
|
||||
try {
|
||||
await this.goHome(target)
|
||||
await target.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.VERY_LONG }).catch((e) => {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Wait for load state failed: ${e}`, 'warn')
|
||||
})
|
||||
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Recovery navigation failed: ${e}`, 'warn')
|
||||
}
|
||||
|
||||
const retryContent = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script =>
|
||||
script.innerText.includes('var dashboard') ||
|
||||
script.innerText.includes('dashboard=') ||
|
||||
script.innerText.includes('dashboard :')
|
||||
)
|
||||
return targetScript?.innerText ? targetScript.innerText : null
|
||||
}).catch(()=>null)
|
||||
|
||||
if (!retryContent) {
|
||||
// Log additional debug info
|
||||
const scriptsDebug = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
return scripts.map(s => s.innerText.substring(0, 100)).join(' | ')
|
||||
}).catch(() => 'Unable to get script debug info')
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Available scripts preview: ${scriptsDebug}`, 'warn')
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
|
||||
throw new Error('Dashboard data not found within script - check page structure')
|
||||
}
|
||||
scriptContent = retryContent
|
||||
}
|
||||
|
||||
// Extract the dashboard object from the script content
|
||||
const dashboardData = await target.evaluate((scriptContent: string) => {
|
||||
// Try multiple regex patterns for better compatibility
|
||||
const patterns = [
|
||||
/var dashboard = (\{.*?\});/s, // Original pattern
|
||||
/var dashboard=(\{.*?\});/s, // No spaces
|
||||
/var\s+dashboard\s*=\s*(\{.*?\});/s, // Flexible whitespace
|
||||
/dashboard\s*=\s*(\{[\s\S]*?\});/ // More permissive
|
||||
]
|
||||
|
||||
for (const regex of patterns) {
|
||||
const match = regex.exec(scriptContent)
|
||||
if (match && match[1]) {
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch (e) {
|
||||
// Try next pattern if JSON parsing fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
}, scriptContent)
|
||||
|
||||
if (!dashboardData) {
|
||||
// Log a snippet of the script content for debugging
|
||||
const scriptPreview = scriptContent.substring(0, 200)
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Script preview: ${scriptPreview}`, 'warn')
|
||||
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch((e) => {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Failed to capture diagnostics: ${e}`, 'warn')
|
||||
})
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
|
||||
throw new Error('Unable to parse dashboard script - check diagnostics')
|
||||
}
|
||||
|
||||
return dashboardData
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Error fetching dashboard data: ${errorMessage}`, 'error')
|
||||
throw new Error('Get dashboard data failed: ' + errorMessage)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search point counters
|
||||
* @returns {Counters} Object of search counter data
|
||||
*/
|
||||
async getSearchPoints(): Promise<Counters> {
|
||||
const dashboardData = await this.getDashboardData() // Always fetch newest data
|
||||
|
||||
return dashboardData.userStatus.counters
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total earnable points with web browser
|
||||
* @returns {number} Total earnable points
|
||||
*/
|
||||
async getBrowserEarnablePoints(): Promise<EarnablePoints> {
|
||||
try {
|
||||
let desktopSearchPoints = 0
|
||||
let mobileSearchPoints = 0
|
||||
let dailySetPoints = 0
|
||||
let morePromotionsPoints = 0
|
||||
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
// Desktop Search Points
|
||||
if (data.userStatus.counters.pcSearch?.length) {
|
||||
data.userStatus.counters.pcSearch.forEach(x => desktopSearchPoints += (x.pointProgressMax - x.pointProgress))
|
||||
}
|
||||
|
||||
// Mobile Search Points
|
||||
if (data.userStatus.counters.mobileSearch?.length) {
|
||||
data.userStatus.counters.mobileSearch.forEach(x => mobileSearchPoints += (x.pointProgressMax - x.pointProgress))
|
||||
}
|
||||
|
||||
// Daily Set
|
||||
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => dailySetPoints += (x.pointProgressMax - x.pointProgress))
|
||||
|
||||
// More Promotions
|
||||
if (data.morePromotions?.length) {
|
||||
data.morePromotions.forEach(x => {
|
||||
// Only count points from supported activities
|
||||
if (['quiz', 'urlreward'].includes(x.promotionType) && x.exclusiveLockedFeatureStatus !== 'locked') {
|
||||
morePromotionsPoints += (x.pointProgressMax - x.pointProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints
|
||||
|
||||
return {
|
||||
dailySetPoints,
|
||||
morePromotionsPoints,
|
||||
desktopSearchPoints,
|
||||
mobileSearchPoints,
|
||||
totalEarnablePoints
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GET-BROWSER-EARNABLE-POINTS', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Get browser earnable points failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total earnable points with mobile app
|
||||
* @returns {number} Total earnable points
|
||||
*/
|
||||
async getAppEarnablePoints(accessToken: string) {
|
||||
try {
|
||||
const points = {
|
||||
readToEarn: 0,
|
||||
checkIn: 0,
|
||||
totalEarnablePoints: 0
|
||||
}
|
||||
|
||||
const eligibleOffers = [
|
||||
'ENUS_readarticle3_30points',
|
||||
'Gamification_Sapphire_DailyCheckIn'
|
||||
]
|
||||
|
||||
const data = await this.getDashboardData()
|
||||
// Guard against missing profile/attributes and undefined settings
|
||||
let geoLocale = data?.userProfile?.attributes?.country || 'US'
|
||||
const useGeo = !!(this.bot?.config?.searchSettings?.useGeoLocaleQueries)
|
||||
geoLocale = (useGeo && typeof geoLocale === 'string' && geoLocale.length === 2)
|
||||
? geoLocale.toLowerCase()
|
||||
: 'us'
|
||||
|
||||
const userDataRequest: AxiosRequestConfig = {
|
||||
url: URLS.APP_USER_DATA,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-Rewards-Country': geoLocale,
|
||||
'X-Rewards-Language': 'en'
|
||||
}
|
||||
}
|
||||
|
||||
const userDataResponse: AppUserData = (await this.bot.axios.request(userDataRequest)).data
|
||||
const userData = userDataResponse.response
|
||||
const eligibleActivities = userData.promotions.filter((x) => eligibleOffers.includes(x.attributes.offerid ?? ''))
|
||||
|
||||
for (const item of eligibleActivities) {
|
||||
if (item.attributes.type === 'msnreadearn') {
|
||||
points.readToEarn = parseInt(item.attributes.pointmax ?? '', 10) - parseInt(item.attributes.pointprogress ?? '', 10)
|
||||
break
|
||||
} else if (item.attributes.type === 'checkin') {
|
||||
const checkInDay = parseInt(item.attributes.progress ?? '', 10) % 7
|
||||
const today = new Date()
|
||||
const lastUpdated = new Date(item.attributes.last_updated ?? '')
|
||||
|
||||
if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) {
|
||||
points.checkIn = parseInt(item.attributes['day_' + (checkInDay + 1) + '_points'] ?? '', 10)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
points.totalEarnablePoints = points.readToEarn + points.checkIn
|
||||
|
||||
return points
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GET-APP-EARNABLE-POINTS', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Get app earnable points failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current point amount
|
||||
* @returns {number} Current total point amount
|
||||
*/
|
||||
async getCurrentPoints(): Promise<number> {
|
||||
try {
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
return data.userStatus.availablePoints
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GET-CURRENT-POINTS', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Get current points failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse quiz data from provided page
|
||||
* @param {Page} page Playwright page
|
||||
* @returns {QuizData} Quiz data object
|
||||
*/
|
||||
async getQuizData(page: Page): Promise<QuizData> {
|
||||
try {
|
||||
// Wait for page to be fully loaded
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
|
||||
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
// Try multiple possible variable names
|
||||
const possibleVariables = [
|
||||
'_w.rewardsQuizRenderInfo',
|
||||
'rewardsQuizRenderInfo',
|
||||
'_w.quizRenderInfo',
|
||||
'quizRenderInfo'
|
||||
]
|
||||
|
||||
let scriptContent = ''
|
||||
let foundVariable = ''
|
||||
|
||||
for (const varName of possibleVariables) {
|
||||
scriptContent = $('script')
|
||||
.toArray()
|
||||
.map(el => $(el).text())
|
||||
.find(t => t.includes(varName)) || ''
|
||||
|
||||
if (scriptContent) {
|
||||
foundVariable = varName
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptContent && foundVariable) {
|
||||
// Escape dots in variable name for regex
|
||||
const escapedVar = foundVariable.replace(/\./g, '\\.')
|
||||
const regex = new RegExp(`${escapedVar}\\s*=\\s*({.*?});`, 's')
|
||||
const match = regex.exec(scriptContent)
|
||||
|
||||
if (match && match[1]) {
|
||||
const quizData = JSON.parse(match[1])
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found quiz data using variable: ${foundVariable}`, 'log')
|
||||
return quizData
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Variable ${foundVariable} found but could not extract JSON data`, 'error')
|
||||
throw new Error(`Quiz data variable ${foundVariable} found but JSON extraction failed`)
|
||||
}
|
||||
} else {
|
||||
// Log available scripts for debugging
|
||||
const allScripts = $('script')
|
||||
.toArray()
|
||||
.map(el => $(el).text())
|
||||
.filter(t => t.length > 0)
|
||||
.map(t => t.substring(0, 100))
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn')
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
|
||||
throw new Error('Script containing quiz data not found - check page structure')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Get quiz data failed: ' + errorMessage)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async waitForQuizRefresh(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector(SELECTORS.QUIZ_CREDITS, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', 'An error occurred:' + error, 'error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async checkQuizCompleted(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector(SELECTORS.QUIZ_COMPLETE, { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG })
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async loadInCheerio(page: Page): Promise<CheerioAPI> {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
return $
|
||||
}
|
||||
|
||||
async getPunchCardActivity(page: Page, activity: PromotionalItem | MorePromotion): Promise<string> {
|
||||
let selector = ''
|
||||
try {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
const element = $('.offer-cta').toArray().find((x: unknown) => {
|
||||
const el = x as { attribs?: { href?: string } }
|
||||
return !!el.attribs?.href?.includes(activity.offerId)
|
||||
})
|
||||
if (element) {
|
||||
selector = `a[href*="${element.attribs.href}"]`
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
|
||||
return selector
|
||||
}
|
||||
|
||||
async closeBrowser(browser: BrowserContext, email: string) {
|
||||
try {
|
||||
// Save cookies
|
||||
await saveSessionData(this.bot.config.sessionPath, browser, email, this.bot.isMobile)
|
||||
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
// Close browser
|
||||
await browser.close()
|
||||
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Close browser failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
233
src/browser/BrowserUtil.ts
Normal file
233
src/browser/BrowserUtil.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import { load } from 'cheerio'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics'
|
||||
|
||||
type DismissButton = { selector: string; label: string; isXPath?: boolean }
|
||||
|
||||
export default class BrowserUtil {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
private static readonly DISMISS_BUTTONS: readonly DismissButton[] = [
|
||||
{ selector: '#acceptButton', label: 'AcceptButton' },
|
||||
{ selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
|
||||
{ selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
|
||||
{ selector: '#iLandingViewAction', label: 'Landing Continue' },
|
||||
{ selector: '#iShowSkip', label: 'Show Skip' },
|
||||
{ selector: '#iNext', label: 'Next' },
|
||||
{ selector: '#iLooksGood', label: 'LooksGood' },
|
||||
{ selector: '#idSIButton9', label: 'PrimaryLoginButton' },
|
||||
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
|
||||
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
|
||||
{ selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
|
||||
{ selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
|
||||
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
|
||||
{ selector: '#bnp_close_link', label: 'Bing Cookie Close' },
|
||||
{ selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
|
||||
{ selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
|
||||
]
|
||||
|
||||
private static readonly OVERLAY_SELECTORS = {
|
||||
container: '#bnp_overlay_wrapper',
|
||||
reject: '#bnp_btn_reject, button[aria-label*="Reject" i]',
|
||||
accept: '#bnp_btn_accept'
|
||||
} as const
|
||||
|
||||
private static readonly STREAK_DIALOG_SELECTORS = {
|
||||
container: '[role="dialog"], div[role="alert"], div.ms-Dialog',
|
||||
textFilter: /streak protection has run out/i,
|
||||
closeButtons: 'button[aria-label*="close" i], button:has-text("Close"), button:has-text("Dismiss"), button:has-text("Got it"), button:has-text("OK"), button:has-text("Ok")'
|
||||
} as const
|
||||
|
||||
private static readonly TERMS_UPDATE_SELECTORS = {
|
||||
titleId: '#iTOUTitle',
|
||||
titleText: /we're updating our terms/i,
|
||||
nextButton: 'button[data-testid="primaryButton"]:has-text("Next"), button[type="submit"]:has-text("Next")'
|
||||
} as const
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async tryDismissAllMessages(page: Page): Promise<void> {
|
||||
const maxRounds = 3
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const dismissCount = await this.dismissRound(page)
|
||||
if (dismissCount === 0) break
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissRound(page: Page): Promise<number> {
|
||||
let count = 0
|
||||
count += await this.dismissStandardButtons(page)
|
||||
count += await this.dismissOverlayButtons(page)
|
||||
count += await this.dismissStreakDialog(page)
|
||||
count += await this.dismissTermsUpdateDialog(page)
|
||||
return count
|
||||
}
|
||||
|
||||
private async dismissStandardButtons(page: Page): Promise<number> {
|
||||
let count = 0
|
||||
for (const btn of BrowserUtil.DISMISS_BUTTONS) {
|
||||
const dismissed = await this.tryClickButton(page, btn)
|
||||
if (dismissed) {
|
||||
count++
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private async tryClickButton(page: Page, btn: DismissButton): Promise<boolean> {
|
||||
try {
|
||||
const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
|
||||
const visible = await loc.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return false
|
||||
|
||||
await loc.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissOverlayButtons(page: Page): Promise<number> {
|
||||
try {
|
||||
const { container, reject, accept } = BrowserUtil.OVERLAY_SELECTORS
|
||||
const overlay = page.locator(container)
|
||||
const visible = await overlay.isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return 0
|
||||
|
||||
const rejectBtn = overlay.locator(reject)
|
||||
if (await rejectBtn.first().isVisible().catch(() => false)) {
|
||||
await rejectBtn.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
|
||||
return 1
|
||||
}
|
||||
|
||||
const acceptBtn = overlay.locator(accept)
|
||||
if (await acceptBtn.first().isVisible().catch(() => false)) {
|
||||
await acceptBtn.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissStreakDialog(page: Page): Promise<number> {
|
||||
try {
|
||||
const { container, textFilter, closeButtons } = BrowserUtil.STREAK_DIALOG_SELECTORS
|
||||
const dialog = page.locator(container).filter({ hasText: textFilter })
|
||||
const visible = await dialog.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return 0
|
||||
|
||||
const closeBtn = dialog.locator(closeButtons).first()
|
||||
if (await closeBtn.isVisible({ timeout: 200 }).catch(() => false)) {
|
||||
await closeBtn.click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Button')
|
||||
return 1
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape').catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Escape')
|
||||
return 1
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissTermsUpdateDialog(page: Page): Promise<number> {
|
||||
try {
|
||||
const { titleId, titleText, nextButton } = BrowserUtil.TERMS_UPDATE_SELECTORS
|
||||
|
||||
// Check if terms update page is present
|
||||
const titleById = page.locator(titleId)
|
||||
const titleByText = page.locator('h1').filter({ hasText: titleText })
|
||||
|
||||
const hasTitle = await titleById.isVisible({ timeout: 200 }).catch(() => false) ||
|
||||
await titleByText.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
|
||||
if (!hasTitle) return 0
|
||||
|
||||
// Click the Next button
|
||||
const nextBtn = page.locator(nextButton).first()
|
||||
if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
await nextBtn.click({ timeout: 1000 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)')
|
||||
// Wait a bit for navigation
|
||||
await page.waitForTimeout(1000)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestTab(page: Page): Promise<Page> {
|
||||
try {
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
const browser = page.context()
|
||||
const pages = browser.pages()
|
||||
const newTab = pages[pages.length - 1]
|
||||
|
||||
if (newTab) {
|
||||
return newTab
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Unable to get latest tab', 'error')
|
||||
throw new Error('Unable to get latest tab - no pages found in browser context')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Get new tab failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
async reloadBadPage(page: Page): Promise<void> {
|
||||
try {
|
||||
const html = await page.content().catch(() => '')
|
||||
const $ = load(html)
|
||||
|
||||
const isNetworkError = $('body.neterror').length
|
||||
|
||||
if (isNetworkError) {
|
||||
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
|
||||
await page.reload()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'An error occurred: ' + errorMessage, 'error')
|
||||
throw new Error('Reload bad page failed: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform small human-like gestures: short waits, minor mouse moves and occasional scrolls.
|
||||
* This should be called sparingly between actions to avoid a fixed cadence.
|
||||
*/
|
||||
async humanizePage(page: Page): Promise<void> {
|
||||
try {
|
||||
await this.bot.humanizer.microGestures(page)
|
||||
await this.bot.humanizer.actionPause()
|
||||
} catch { /* swallow */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture minimal diagnostics for a page: screenshot + HTML content.
|
||||
* Files are written under ./reports/<date>/ with a safe label.
|
||||
*/
|
||||
async captureDiagnostics(page: Page, label: string): Promise<void> {
|
||||
await captureSharedDiagnostics(this.bot, page, label)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user