Files
Microsoft-Rewards-Bot/src/browser/BrowserUtil.ts
2025-11-13 20:23:57 +01:00

267 lines
12 KiB
TypeScript

import { load } from 'cheerio'
import { Page } from 'rebrowser-playwright'
import { MicrosoftRewardsBot } from '../index'
import { waitForPageReady } from '../util/browser/SmartWait'
import { logError } from '../util/notifications/Logger'
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> {
// Single-pass dismissal with all checks combined
await this.dismissAllInterruptors(page)
}
private async dismissAllInterruptors(page: Page): Promise<void> {
await Promise.allSettled([
this.dismissStandardButtons(page),
this.dismissOverlayButtons(page),
this.dismissStreakDialog(page),
this.dismissTermsUpdateDialog(page)
])
}
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(logError('BROWSER-UTIL', `Failed to click ${btn.label}`, this.bot.isMobile))
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
return true
} catch (e) {
// Silent catch is intentional: button detection/click failures shouldn't break page flow
// Most failures are expected (button not present, timing issues, etc.)
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(logError('BROWSER-UTIL', 'Overlay reject click failed', this.bot.isMobile))
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(logError('BROWSER-UTIL', 'Overlay accept click failed', this.bot.isMobile))
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
return 1
}
return 0
} catch (e) {
// Silent catch is intentional: overlay detection failures are expected when no overlay present
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(logError('BROWSER-UTIL', 'Streak dialog close failed', this.bot.isMobile))
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Button')
return 1
}
await page.keyboard.press('Escape').catch(logError('BROWSER-UTIL', 'Streak dialog Escape failed', this.bot.isMobile))
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Escape')
return 1
} catch (e) {
// Silent catch is intentional: streak dialog detection failures are expected
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(logError('BROWSER-UTIL', 'Terms update next button click failed', this.bot.isMobile))
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 (e) {
// Silent catch is intentional: terms dialog detection failures are expected
return 0
}
}
async getLatestTab(page: Page): Promise<Page> {
try {
await this.bot.utils.wait(1000)
const browser = page.context()
const pages = browser.pages()
// IMPROVED: If no pages exist, create a new one instead of throwing error
if (pages.length === 0) {
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'No pages found in context, creating new page', 'warn')
const newPage = await browser.newPage()
await this.bot.utils.wait(500)
return newPage
}
const newTab = pages[pages.length - 1]
// IMPROVED: Verify the page is not closed before returning
if (newTab && !newTab.isClosed()) {
return newTab
}
// IMPROVED: If latest tab is closed, find first non-closed tab or create new one
const openPage = pages.find(p => !p.isClosed())
if (openPage) {
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Latest tab was closed, using first available open tab')
return openPage
}
// IMPROVED: Last resort - create new page
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'All tabs were closed, creating new page', 'warn')
const newPage = await browser.newPage()
await this.bot.utils.wait(500)
return newPage
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Critical error in getLatestTab: ' + errorMessage, 'error')
// IMPROVED: Try one more time to create a new page as absolute last resort
try {
const browser = page.context()
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Attempting recovery by creating new page', 'warn')
const recoveryPage = await browser.newPage()
await this.bot.utils.wait(500)
return recoveryPage
} catch (recoveryError) {
const recoveryMsg = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Recovery failed: ' + recoveryMsg, 'error')
throw new Error('Get new tab failed and recovery unsuccessful: ' + errorMessage)
}
}
}
async reloadBadPage(page: Page): Promise<void> {
try {
const html = await page.content().catch(() => '')
const $ = load(html)
const isNetworkError = $('body.neterror').length
const hasHttp400Error = html.includes('HTTP ERROR 400') ||
html.includes('This page isn\'t working') ||
html.includes('This page is not working')
if (isNetworkError || hasHttp400Error) {
const errorType = hasHttp400Error ? 'HTTP 400' : 'network error'
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', `Bad page detected (${errorType}), reloading!`)
await page.reload({ waitUntil: 'domcontentloaded' })
// IMPROVED: Use smart wait instead of fixed 1500ms delay
// FIXED: Use default 10s timeout for page reload
await waitForPageReady(page)
}
} 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 */ }
}
}