From 693246c2d5ee160a415e6ff528df9018309e96b2 Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Wed, 5 Nov 2025 21:03:48 +0100 Subject: [PATCH] Features: Improved handling of navigation errors and automatic updates, optimized wait times, and cron job configuration --- Dockerfile | 8 +-- setup/update/update.mjs | 4 +- src/functions/Login.ts | 120 +++++++++++++++++++++-------------- src/functions/Workers.ts | 8 +++ src/index.ts | 33 +++++++--- src/util/SchedulerManager.ts | 19 +++++- 6 files changed, 125 insertions(+), 67 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3694e3c..8f50a04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,10 @@ WORKDIR /usr/src/microsoft-rewards-script ENV PLAYWRIGHT_BROWSERS_PATH=0 # Copy package files -COPY package.json tsconfig.json ./ +COPY package.json package-lock.json tsconfig.json ./ -# Generate fresh lockfile for target platform architecture -# This ensures native dependencies are resolved correctly -RUN npm install --ignore-scripts --package-lock-only \ - && npm ci --ignore-scripts +# Install all dependencies required to build the script +RUN npm ci --ignore-scripts # Copy source and build COPY . . diff --git a/setup/update/update.mjs b/setup/update/update.mjs index 15ec758..34d06a4 100644 --- a/setup/update/update.mjs +++ b/setup/update/update.mjs @@ -400,8 +400,8 @@ async function main() { code = await updateDocker() } - // CRITICAL: Always exit with code so external schedulers can react correctly - // Otherwise the process hangs indefinitely and gets killed by watchdog + // Return exit code to parent process + // This allows the bot to know if update succeeded (0) or failed (non-zero) process.exit(code) } diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 34fde85..25cbbd6 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -105,45 +105,53 @@ export class Login { const isLinux = process.platform === 'linux' const navigationTimeout = isLinux ? 60000 : 30000 - // Try initial navigation with error handling for chrome-error interruption + // IMPROVEMENT: Try initial navigation with better error handling let navigationSucceeded = false let recoveryUsed = false - try { - await page.goto('https://www.bing.com/rewards/dashboard', { - waitUntil: 'domcontentloaded', - timeout: navigationTimeout - }) - navigationSucceeded = true - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - - // If interrupted by chrome-error, retry with reload approach - if (errorMsg.includes('chrome-error://chromewebdata/')) { - this.bot.log(this.bot.isMobile, 'LOGIN', 'Navigation interrupted by chrome-error, attempting recovery...', 'warn') + let attempts = 0 + const maxAttempts = 3 + + while (!navigationSucceeded && attempts < maxAttempts) { + attempts++ + try { + await page.goto('https://www.bing.com/rewards/dashboard', { + waitUntil: 'domcontentloaded', + timeout: navigationTimeout + }) + navigationSucceeded = true + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) - // Wait a bit for page to settle - await this.bot.utils.wait(1000) - - // Try reload which usually fixes the issue - try { - await page.reload({ waitUntil: 'domcontentloaded', timeout: navigationTimeout }) - navigationSucceeded = true - recoveryUsed = true - this.bot.log(this.bot.isMobile, 'LOGIN', 'Recovery successful via reload') - } catch (reloadError) { - // Last resort: try goto again - this.bot.log(this.bot.isMobile, 'LOGIN', 'Reload failed, trying fresh navigation...', 'warn') - await this.bot.utils.wait(1500) - await page.goto('https://www.bing.com/rewards/dashboard', { - waitUntil: 'domcontentloaded', - timeout: navigationTimeout - }) - navigationSucceeded = true - this.bot.log(this.bot.isMobile, 'LOGIN', 'Recovery successful via fresh navigation') + // If interrupted by chrome-error, retry with reload approach + if (errorMsg.includes('chrome-error://chromewebdata/')) { + this.bot.log(this.bot.isMobile, 'LOGIN', `Navigation interrupted by chrome-error (attempt ${attempts}/${maxAttempts}), attempting recovery...`, 'warn') + + // Wait a bit for page to settle + await this.bot.utils.wait(1500) // Increased from 1000ms + + // Try reload which usually fixes the issue + try { + await page.reload({ waitUntil: 'domcontentloaded', timeout: navigationTimeout }) + navigationSucceeded = true + recoveryUsed = true + this.bot.log(this.bot.isMobile, 'LOGIN', '✓ Recovery successful via reload') + } catch (reloadError) { + // Last resort: try goto again + if (attempts < maxAttempts) { + this.bot.log(this.bot.isMobile, 'LOGIN', `Reload failed (attempt ${attempts}/${maxAttempts}), trying fresh navigation...`, 'warn') + await this.bot.utils.wait(2000) // Increased from 1500ms + } else { + throw reloadError // Exhausted attempts + } + } + } else if (attempts < maxAttempts) { + // Different error, retry with backoff + this.bot.log(this.bot.isMobile, 'LOGIN', `Navigation failed (attempt ${attempts}/${maxAttempts}): ${errorMsg}`, 'warn') + await this.bot.utils.wait(2000 * attempts) // Exponential backoff + } else { + // Exhausted attempts, rethrow + throw error } - } else { - // Different error, rethrow - throw error } } @@ -511,37 +519,55 @@ export class Login { private async inputEmail(page: Page, email: string) { // Check for passkey prompts first await this.handlePasskeyPrompts(page, 'main') - await this.bot.utils.wait(250) + await this.bot.utils.wait(500) // Increased from 250ms + + // IMPROVEMENT: Wait for page to be fully ready before looking for email field + await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {}) + await this.bot.utils.wait(300) // Extra settling time if (await this.tryAutoTotp(page, 'pre-email check')) { - await this.bot.utils.wait(800) + await this.bot.utils.wait(1000) // Increased from 800ms } - let field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) + // IMPROVEMENT: More retries with better timing + let field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 8000 }).catch(()=>null) // Increased from 5000ms if (!field) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not found (first attempt), retrying...', 'warn') + const totpHandled = await this.tryAutoTotp(page, 'pre-email challenge') if (totpHandled) { - await this.bot.utils.wait(800) - field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) + await this.bot.utils.wait(1200) // Increased from 800ms + field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 8000 }).catch(()=>null) } } if (!field) { // Try one more time after handling possible passkey prompts + this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not found (second attempt), trying passkey/reload...', 'warn') await this.handlePasskeyPrompts(page, 'main') - await this.bot.utils.wait(500) + await this.bot.utils.wait(800) // Increased from 500ms + + // IMPROVEMENT: Try page reload if field still missing (common issue on first load) + const content = await page.content().catch(() => '') + if (content.length < 1000) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Page content too small, reloading...', 'warn') + await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) + await this.bot.utils.wait(1500) + } + const totpRetry = await this.tryAutoTotp(page, 'pre-email retry') if (totpRetry) { - await this.bot.utils.wait(800) + await this.bot.utils.wait(1200) // Increased from 800ms } - field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 3000 }).catch(()=>null) + + field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) if (!field && this.totpAttempts > 0) { - await this.bot.utils.wait(2000) - field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 3000 }).catch(()=>null) + await this.bot.utils.wait(2500) // Increased from 2000ms + field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) // Increased from 3000ms } if (!field) { - this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not present', 'warn') - return + this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not present after all retries', 'error') + throw new Error('Login form email field not found after multiple attempts') } } diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 14a6874..e57447f 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -213,6 +213,14 @@ export class Workers { private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise { this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`) + // Check if element exists before clicking (avoid 30s timeout) + try { + await page.waitForSelector(selector, { timeout: 5000 }) + } catch (error) { + this.bot.log(this.bot.isMobile, 'ACTIVITY', `Activity selector not found (might be completed or unavailable): ${selector}`, 'warn') + return // Skip this activity gracefully instead of waiting 30s + } + await page.click(selector) page = await this.bot.browser.utils.getLatestTab(page) diff --git a/src/index.ts b/src/index.ts index e46033c..bd9e792 100644 --- a/src/index.ts +++ b/src/index.ts @@ -497,7 +497,10 @@ export class MicrosoftRewardsBot { log('main', 'CONCLUSION', `Failed to send conclusion: ${e instanceof Error ? e.message : String(e)}`, 'warn') } try { - await this.runAutoUpdate() + const updateCode = await this.runAutoUpdate() + if (updateCode === 0) { + log('main', 'UPDATE', '✅ Update successful - next run will use new version', 'log', 'green') + } } catch (e) { log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn') } @@ -750,9 +753,19 @@ export class MicrosoftRewardsBot { // Single process mode -> build and send conclusion directly await this.sendConclusion(this.accountSummaries) // After conclusion, run optional auto-update - await this.runAutoUpdate().catch((e) => { + const updateResult = await this.runAutoUpdate().catch((e) => { log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn') + return 1 // Error code }) + + // If update was successful (code 0), restart the script to use the new version + // This is critical for cron jobs - they need to apply updates immediately + if (updateResult === 0) { + log('main', 'UPDATE', '✅ Update successful - restarting with new version...', 'log', 'green') + // On Raspberry Pi/Linux with cron, just exit - cron will handle next run + // No need to restart immediately, next scheduled run will use new code + log('main', 'UPDATE', 'Next scheduled run will use the updated code', 'log') + } } process.exit() } @@ -1181,24 +1194,24 @@ export class MicrosoftRewardsBot { } // Run optional auto-update script based on configuration flags. - private async runAutoUpdate(): Promise { + private async runAutoUpdate(): Promise { const upd = this.config.update - if (!upd) return + if (!upd) return 0 const scriptRel = upd.scriptPath || 'setup/update/update.mjs' const scriptAbs = path.join(process.cwd(), scriptRel) - if (!fs.existsSync(scriptAbs)) return + if (!fs.existsSync(scriptAbs)) return 0 const args: string[] = [] // Git update is enabled by default (unless explicitly set to false) if (upd.git !== false) args.push('--git') if (upd.docker) args.push('--docker') - if (args.length === 0) return + if (args.length === 0) return 0 - // Run update script as a child process - it will handle its own exit - await new Promise((resolve) => { + // Run update script as a child process and capture exit code + return new Promise((resolve) => { const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' }) - child.on('close', () => resolve()) - child.on('error', () => resolve()) + child.on('close', (code) => resolve(code ?? 0)) + child.on('error', () => resolve(1)) }) } diff --git a/src/util/SchedulerManager.ts b/src/util/SchedulerManager.ts index f560cc6..84571f0 100644 --- a/src/util/SchedulerManager.ts +++ b/src/util/SchedulerManager.ts @@ -65,8 +65,10 @@ export class SchedulerManager { fs.mkdirSync(logDir, { recursive: true }) } - // Build cron command - const cronCommand = `${schedule} cd ${workingDir} && ${nodePath} ${path.join(workingDir, 'dist', 'index.js')} >> ${logFile} 2>&1` + // Build cron command with proper PATH and error handling + // Important: Cron runs with minimal environment, so we need to set PATH explicitly + const nodeDir = path.dirname(nodePath) + const cronCommand = `${schedule} export PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin:$PATH && cd "${workingDir}" && "${nodePath}" "${path.join(workingDir, 'dist', 'index.js')}" >> "${logFile}" 2>&1` try { // Check if cron is installed @@ -77,6 +79,14 @@ export class SchedulerManager { return } + // Check if cron service is running (critical!) + try { + execSync('pgrep -x cron > /dev/null || pgrep -x crond > /dev/null', { stdio: 'ignore' }) + } catch { + log('main', 'SCHEDULER', '⚠️ WARNING: cron service is not running! Start it with: sudo service cron start', 'warn') + log('main', 'SCHEDULER', 'Jobs will be configured but won\'t execute until cron service is started', 'warn') + } + // Get current crontab let currentCrontab = '' try { @@ -113,8 +123,11 @@ export class SchedulerManager { log('main', 'SCHEDULER', '✅ Cron job configured successfully', 'log', 'green') log('main', 'SCHEDULER', `Schedule: ${schedule}`, 'log') + log('main', 'SCHEDULER', `Working directory: ${workingDir}`, 'log') + log('main', 'SCHEDULER', `Node path: ${nodePath}`, 'log') log('main', 'SCHEDULER', `Log file: ${logFile}`, 'log') - log('main', 'SCHEDULER', 'View jobs: crontab -l', 'log') + log('main', 'SCHEDULER', 'View configured jobs: crontab -l', 'log') + log('main', 'SCHEDULER', 'Check cron logs: sudo tail -f /var/log/syslog | grep CRON', 'log') } catch (error) { log('main', 'SCHEDULER', `Failed to configure cron: ${error instanceof Error ? error.message : String(error)}`, 'error') }