/** * Human-like behavior utilities for Playwright to avoid bot detection * Includes realistic mouse movements, scrolling, and timing variations */ /** * Generate a random number between min and max (inclusive) */ function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** * Generate a random float between min and max */ function randomFloat(min, max) { return Math.random() * (max - min) + min; } /** * Sleep for a random duration within a range */ export async function randomDelay(minMs = 100, maxMs = 500) { const delay = randomInt(minMs, maxMs); await new Promise(resolve => setTimeout(resolve, delay)); } /** * Generate bezier curve points for smooth mouse movement * Uses cubic bezier with random control points for natural curves */ function generateBezierPath(start, end, steps = 25) { const points = []; // Add some randomness to control points const cp1x = start.x + (end.x - start.x) * randomFloat(0.25, 0.4); const cp1y = start.y + (end.y - start.y) * randomFloat(-0.2, 0.2); const cp2x = start.x + (end.x - start.x) * randomFloat(0.6, 0.75); const cp2y = start.y + (end.y - start.y) * randomFloat(-0.2, 0.2); for (let i = 0; i <= steps; i++) { const t = i / steps; const t2 = t * t; const t3 = t2 * t; const mt = 1 - t; const mt2 = mt * mt; const mt3 = mt2 * mt; const x = mt3 * start.x + 3 * mt2 * t * cp1x + 3 * mt * t2 * cp2x + t3 * end.x; const y = mt3 * start.y + 3 * mt2 * t * cp1y + 3 * mt * t2 * cp2y + t3 * end.y; points.push({ x: Math.round(x), y: Math.round(y) }); } return points; } /** * Move mouse in a realistic, smooth path with occasional overshooting * @param {Page} page - Playwright page object * @param {Object} target - Target coordinates {x, y} * @param {Object} options - Movement options */ export async function humanMouseMove(page, target, options = {}) { const { overshootChance = 0.15, // 15% chance to overshoot overshootDistance = 20, // pixels to overshoot by steps = 25, // number of steps in the path stepDelay = 10 // ms between steps } = options; // Get current mouse position (or start from a random position) const viewport = page.viewportSize(); const start = { x: randomInt(viewport.width * 0.3, viewport.width * 0.7), y: randomInt(viewport.height * 0.3, viewport.height * 0.7) }; // Decide if we should overshoot const shouldOvershoot = Math.random() < overshootChance; let finalTarget = target; if (shouldOvershoot) { // Calculate overshoot position (slightly past the target) const angle = Math.atan2(target.y - start.y, target.x - start.x); const overshoot = { x: target.x + Math.cos(angle) * randomInt(5, overshootDistance), y: target.y + Math.sin(angle) * randomInt(5, overshootDistance) }; // Move to overshoot position first const overshootPath = generateBezierPath(start, overshoot, steps); for (const point of overshootPath) { await page.mouse.move(point.x, point.y); await new Promise(resolve => setTimeout(resolve, stepDelay)); } // Then correct back to target const correctionPath = generateBezierPath(overshoot, target, Math.floor(steps * 0.3)); for (const point of correctionPath) { await page.mouse.move(point.x, point.y); await new Promise(resolve => setTimeout(resolve, stepDelay)); } } else { // Normal smooth movement const path = generateBezierPath(start, target, steps); for (const point of path) { await page.mouse.move(point.x, point.y); await new Promise(resolve => setTimeout(resolve, stepDelay)); } } // Add a tiny random pause after reaching target await randomDelay(50, 150); } /** * Perform random mouse movements to simulate human reading/scanning */ export async function randomMouseMovements(page, count = 3) { const viewport = page.viewportSize(); for (let i = 0; i < count; i++) { const target = { x: randomInt(100, viewport.width - 100), y: randomInt(100, viewport.height - 100) }; await humanMouseMove(page, target, { overshootChance: 0.1, steps: randomInt(15, 30) }); await randomDelay(200, 800); } } /** * Scroll page in a human-like manner with random intervals and amounts * @param {Page} page - Playwright page object * @param {Object} options - Scrolling options */ export async function humanScroll(page, options = {}) { const { direction = 'down', // 'down' or 'up' scrollCount = 3, // number of scroll actions minScroll = 100, // minimum pixels per scroll maxScroll = 400, // maximum pixels per scroll minDelay = 500, // minimum delay between scrolls maxDelay = 2000, // maximum delay between scrolls randomDirection = false // occasionally scroll in opposite direction } = options; for (let i = 0; i < scrollCount; i++) { // Determine scroll direction let scrollDir = direction; if (randomDirection && Math.random() < 0.15) { scrollDir = direction === 'down' ? 'up' : 'down'; } // Random scroll amount const scrollAmount = randomInt(minScroll, maxScroll); const scrollValue = scrollDir === 'down' ? scrollAmount : -scrollAmount; // Perform scroll in small increments for smoothness const increments = randomInt(5, 12); const incrementValue = scrollValue / increments; for (let j = 0; j < increments; j++) { await page.evaluate((delta) => { window.scrollBy(0, delta); }, incrementValue); await new Promise(resolve => setTimeout(resolve, randomInt(20, 50))); } // Random pause between scrolls (simulating reading) await randomDelay(minDelay, maxDelay); } } /** * Scroll to a specific element in a human-like way */ export async function scrollToElement(page, selector, options = {}) { const element = await page.locator(selector).first(); // Get element position const box = await element.boundingBox(); if (!box) { console.warn(`Element ${selector} not found or not visible`); return; } // Get current scroll position const currentScroll = await page.evaluate(() => window.scrollY); const viewportHeight = page.viewportSize().height; // Calculate target scroll position (element near middle of viewport) const targetScroll = box.y + currentScroll - (viewportHeight / 2); const scrollDistance = targetScroll - currentScroll; // Scroll in chunks const chunks = Math.max(3, Math.abs(Math.floor(scrollDistance / 200))); const chunkSize = scrollDistance / chunks; for (let i = 0; i < chunks; i++) { await page.evaluate((delta) => { window.scrollBy(0, delta); }, chunkSize); await randomDelay(50, 150); } await randomDelay(300, 700); } /** * Click an element with human-like behavior */ export async function humanClick(page, selector, options = {}) { const { moveToElement = true, doubleClickChance = 0.02 // 2% chance of accidental double-click } = options; const element = await page.locator(selector).first(); const box = await element.boundingBox(); if (!box) { throw new Error(`Element ${selector} not found or not visible`); } // Calculate click position (slightly random within element bounds) const target = { x: box.x + randomInt(box.width * 0.3, box.width * 0.7), y: box.y + randomInt(box.height * 0.3, box.height * 0.7) }; if (moveToElement) { await humanMouseMove(page, target); } // Random pre-click pause await randomDelay(100, 300); // Click await page.mouse.click(target.x, target.y); // Occasional accidental double-click if (Math.random() < doubleClickChance) { await randomDelay(50, 150); await page.mouse.click(target.x, target.y); } await randomDelay(200, 500); } /** * Type text with human-like timing variations */ export async function humanType(page, selector, text, options = {}) { const { minDelay = 50, maxDelay = 150, mistakes = 0.02 // 2% chance of typo } = options; await page.click(selector); await randomDelay(200, 400); const chars = text.split(''); let typedText = ''; for (let i = 0; i < chars.length; i++) { const char = chars[i]; // Occasional typo if (Math.random() < mistakes && i < chars.length - 1) { // Type wrong char const wrongChar = String.fromCharCode(char.charCodeAt(0) + randomInt(-2, 2)); await page.keyboard.type(wrongChar); await randomDelay(minDelay, maxDelay); // Pause (realize mistake) await randomDelay(200, 500); // Backspace await page.keyboard.press('Backspace'); await randomDelay(100, 200); } // Type correct char await page.keyboard.type(char); // Variable delay based on character type let delay; if (char === ' ') { delay = randomInt(maxDelay * 1.5, maxDelay * 2); } else if (char.match(/[.!?,]/)) { delay = randomInt(maxDelay * 1.2, maxDelay * 2); } else { delay = randomInt(minDelay, maxDelay); } await new Promise(resolve => setTimeout(resolve, delay)); } await randomDelay(300, 600); } /** * Wait for page load with random human-like observation time */ export async function humanWaitForLoad(page, options = {}) { const { minWait = 1000, maxWait = 3000 } = options; // Wait for network to be idle await page.waitForLoadState('networkidle', { timeout: 30000 }); // Additional random observation time (simulating reading/scanning) await randomDelay(minWait, maxWait); } /** * Simulate reading behavior - random scrolls and mouse movements */ export async function simulateReading(page, duration = 5000) { const endTime = Date.now() + duration; while (Date.now() < endTime) { const action = Math.random(); if (action < 0.4) { // Scroll a bit await humanScroll(page, { scrollCount: 1, minScroll: 50, maxScroll: 200, minDelay: 800, maxDelay: 1500 }); } else if (action < 0.7) { // Move mouse randomly await randomMouseMovements(page, 1); } else { // Just wait (reading) await randomDelay(1000, 2000); } } } /** * Configure browser context with realistic human-like settings */ export async function getHumanizedContext(browser, options = {}) { const { locale = 'en-CA', timezone = 'America/Toronto', viewport = null } = options; // Random but realistic viewport sizes const viewports = [ { width: 1920, height: 1080 }, { width: 1366, height: 768 }, { width: 1536, height: 864 }, { width: 1440, height: 900 }, { width: 2560, height: 1440 } ]; const selectedViewport = viewport || viewports[randomInt(0, viewports.length - 1)]; // Realistic user agents (updated to current versions) const userAgents = [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' ]; const context = await browser.newContext({ viewport: selectedViewport, userAgent: userAgents[randomInt(0, userAgents.length - 1)], locale, timezoneId: timezone, permissions: [], geolocation: { latitude: 43.6532, longitude: -79.3832 }, // Toronto colorScheme: 'light', // Always light for consistency deviceScaleFactor: 1, // Standard scaling hasTouch: false, isMobile: false, javaScriptEnabled: true, // Add realistic headers extraHTTPHeaders: { 'Accept-Language': 'en-CA,en-US;q=0.9,en;q=0.8', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-User': '?1', 'Sec-Fetch-Dest': 'document', 'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', 'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Platform': '"macOS"', 'Upgrade-Insecure-Requests': '1' } }); // Inject additional fingerprint randomization and anti-detection await context.addInitScript(() => { // Remove webdriver property Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); // Override permissions const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => ( parameters.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(parameters) ); // Add chrome property window.chrome = { runtime: {} }; // Override plugins Object.defineProperty(navigator, 'plugins', { get: () => [ { 0: { type: 'application/x-google-chrome-pdf', suffixes: 'pdf', description: 'Portable Document Format' }, description: 'Portable Document Format', filename: 'internal-pdf-viewer', length: 1, name: 'Chrome PDF Plugin' }, { 0: { type: 'application/pdf', suffixes: 'pdf', description: '' }, description: '', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', length: 1, name: 'Chrome PDF Viewer' } ] }); }); return context; } export default { randomDelay, humanMouseMove, randomMouseMovements, humanScroll, scrollToElement, humanClick, humanType, humanWaitForLoad, simulateReading, getHumanizedContext };