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 { // Single-pass dismissal with all checks combined await this.dismissAllInterruptors(page) } private async dismissAllInterruptors(page: Page): Promise { await Promise.allSettled([ this.dismissStandardButtons(page), this.dismissOverlayButtons(page), this.dismissStreakDialog(page), this.dismissTermsUpdateDialog(page) ]) } private async dismissStandardButtons(page: Page): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { try { await this.bot.humanizer.microGestures(page) await this.bot.humanizer.actionPause() } catch { /* swallow */ } } }