""" ============================================ Human Behavior Simulator - Random delays between actions - Mouse movement patterns - Random scrolling - Realistic typing speed - Makes the bot look like a real human reading ============================================ """ import asyncio import random import logging import math from typing import Optional from playwright.async_api import Page from app.config import settings logger = logging.getLogger(__name__) class HumanSimulator: """ Simulates human behavior on web pages. Why this matters: - Websites track mouse movement patterns - Constant timing = bot detection - No scrolling = suspicious - Instant navigation = not human """ def __init__(self, page: Page): self.page = page # ============================================ # Random Delays # ============================================ async def random_delay( self, min_seconds: Optional[float] = None, max_seconds: Optional[float] = None, reason: str = "general", ): """ Wait for a random amount of time. Like a human naturally pauses between actions. """ min_s = min_seconds or settings.MIN_DELAY_SECONDS max_s = max_seconds or settings.MAX_DELAY_SECONDS # Use a slightly weighted random (humans tend toward shorter waits) delay = random.uniform(min_s, max_s) # Occasionally add a longer "thinking" pause (10% chance) if random.random() < 0.10: delay += random.uniform(2.0, 5.0) logger.debug(f" 💭 Extended thinking pause ({reason})") logger.debug(f" ⏳ Waiting {delay:.1f}s ({reason})") await asyncio.sleep(delay) async def short_delay(self, reason: str = "short action"): """Quick delay: 0.5 to 2 seconds.""" await self.random_delay(0.5, 2.0, reason) async def medium_delay(self, reason: str = "medium action"): """Medium delay: 2 to 5 seconds.""" await self.random_delay(2.0, 5.0, reason) async def long_delay(self, reason: str = "long action"): """Long delay: 5 to 10 seconds.""" await self.random_delay(5.0, 10.0, reason) async def reading_delay(self, text_length: int): """ Simulate reading time based on text length. Average human reads ~250 words per minute. We'll go faster since we're "speed reading." """ word_count = text_length // 5 # Rough estimate: 5 chars per word # Speed reading: ~500-800 WPM reading_speed = random.uniform(500, 800) read_time = (word_count / reading_speed) * 60 # seconds # Cap the reading time (don't wait too long) read_time = min(read_time, 8.0) read_time = max(read_time, 1.0) logger.debug(f" 📖 Reading pause: {read_time:.1f}s for ~{word_count} words") await asyncio.sleep(read_time) # ============================================ # Mouse Movements # ============================================ async def move_mouse_naturally(self, target_x: int, target_y: int): """ Move mouse to target coordinates with natural curve. Humans don't move mouse in straight lines. Uses bezier-like curve with random intermediate points. """ try: # Get current viewport size viewport = self.page.viewport_size if not viewport: viewport = {"width": 1920, "height": 1080} # Start from a random position (simulating current mouse position) start_x = random.randint(100, viewport["width"] - 100) start_y = random.randint(100, viewport["height"] - 100) # Generate intermediate points for a natural curve steps = random.randint(10, 25) for i in range(steps): progress = (i + 1) / steps # Add slight curve/wobble wobble_x = random.randint(-3, 3) wobble_y = random.randint(-2, 2) # Ease-in-out interpolation eased = self._ease_in_out(progress) current_x = int(start_x + (target_x - start_x) * eased + wobble_x) current_y = int(start_y + (target_y - start_y) * eased + wobble_y) # Clamp to viewport current_x = max(0, min(current_x, viewport["width"])) current_y = max(0, min(current_y, viewport["height"])) await self.page.mouse.move(current_x, current_y) # Tiny delay between mouse moves await asyncio.sleep(random.uniform(0.01, 0.04)) logger.debug(f" 🖱️ Mouse moved to ({target_x}, {target_y})") except Exception as e: logger.debug(f"Mouse movement skipped: {e}") @staticmethod def _ease_in_out(t: float) -> float: """Ease-in-out function for natural movement curves.""" if t < 0.5: return 2 * t * t else: return 1 - (-2 * t + 2) ** 2 / 2 async def random_mouse_movement(self): """ Make a random mouse movement on the page. Like a human idly moving their mouse while reading. """ try: viewport = self.page.viewport_size if not viewport: return x = random.randint(100, viewport["width"] - 100) y = random.randint(100, viewport["height"] - 100) await self.move_mouse_naturally(x, y) except Exception: pass # Mouse movements are optional, don't crash # ============================================ # Scrolling # ============================================ async def scroll_down_naturally(self, scroll_amount: Optional[int] = None): """ Scroll down the page naturally. Humans scroll in chunks, not smoothly. """ try: if scroll_amount is None: scroll_amount = random.randint(200, 600) # Scroll in small chunks remaining = scroll_amount while remaining > 0: chunk = min(remaining, random.randint(50, 150)) await self.page.mouse.wheel(0, chunk) remaining -= chunk # Small pause between scroll chunks await asyncio.sleep(random.uniform(0.05, 0.15)) logger.debug(f" 📜 Scrolled down {scroll_amount}px") except Exception as e: logger.debug(f"Scroll failed: {e}") async def scroll_to_bottom_gradually(self): """ Scroll to bottom of page gradually, like a reader. """ try: # Get page height page_height = await self.page.evaluate("document.body.scrollHeight") viewport_height = await self.page.evaluate("window.innerHeight") current_scroll = await self.page.evaluate("window.scrollY") remaining = page_height - current_scroll - viewport_height while remaining > 50: scroll_chunk = random.randint(200, 500) await self.scroll_down_naturally(min(scroll_chunk, remaining)) # Pause between scrolls (reading) await asyncio.sleep(random.uniform(0.5, 2.0)) # Occasionally do a random mouse movement while scrolling if random.random() < 0.3: await self.random_mouse_movement() current_scroll = await self.page.evaluate("window.scrollY") remaining = page_height - current_scroll - viewport_height logger.debug(" 📜 Scrolled to bottom of page") except Exception as e: logger.debug(f"Scroll to bottom failed: {e}") async def scroll_up_slightly(self): """ Occasionally scroll up a little, like re-reading a paragraph. """ try: scroll_up = random.randint(-200, -50) await self.page.mouse.wheel(0, scroll_up) logger.debug(f" 📜 Scrolled up slightly ({scroll_up}px)") except Exception: pass # ============================================ # Typing # ============================================ async def type_like_human(self, selector: str, text: str): """ Type text character by character with human-like speed. Includes occasional typos and corrections (optional). """ try: # Click on the input field first await self.page.click(selector) await asyncio.sleep(random.uniform(0.3, 0.8)) for char in text: await self.page.keyboard.type(char) # Random delay between keystrokes # Humans type at 40-80 WPM delay = random.uniform(0.05, 0.20) # Occasionally pause longer (thinking) if random.random() < 0.05: delay += random.uniform(0.3, 0.8) await asyncio.sleep(delay) logger.debug(f" ⌨️ Typed {len(text)} characters into '{selector}'") except Exception as e: logger.warning(f"Human typing failed, using fast fill: {e}") await self.page.fill(selector, text) # ============================================ # Combined Human Actions # ============================================ async def simulate_page_arrival(self): """ Simulate what a human does when arriving at a new page: 1. Wait for page to visually load 2. Small mouse movement 3. Brief pause (looking at the page) """ await self.short_delay("page arrival - initial look") if random.random() < 0.5: await self.random_mouse_movement() await self.short_delay("page arrival - orientation") async def simulate_reading_chapter(self): """ Simulate reading a chapter: 1. Scroll down gradually 2. Occasional mouse movements 3. Occasional scroll up (re-reading) 4. Reading delay at the end """ # Scroll through the content await self.scroll_to_bottom_gradually() # Maybe scroll up to re-read something if random.random() < 0.2: await self.scroll_up_slightly() await self.short_delay("re-reading") # Final reading pause await self.medium_delay("finishing chapter") async def simulate_before_click(self, x: int, y: int): """ What a human does before clicking a button: 1. Look at the button (pause) 2. Move mouse toward it 3. Small hesitation 4. Click """ await self.short_delay("looking at button") await self.move_mouse_naturally(x, y) await asyncio.sleep(random.uniform(0.2, 0.5)) # Hesitation