from app.utils.browser_utils import safe_browser_operation import asyncio from urllib.parse import unquote import time from playwright.async_api import async_playwright from app.config import CUSTOM_USER_AGENT # Global tracking for active pages and their creation times active_pages = set() page_creation_times = {} async def visit_url_service(decoded_url): """Service function to visit a URL and get its content""" print(f"Visiting URL: {decoded_url}") # Define the operation to perform with the browser async def visit_operation(page): try: response = await page.goto(decoded_url, wait_until='networkidle', timeout=30000) if not response: print(f"Warning: No response object returned for {decoded_url}") cookie_buttons = [ 'Accept all', 'Accepteer', 'Accepteren', 'Accept' ] try: for button in cookie_buttons: element = await page.query_selector(f'text="{button}"') if element: await element.click() print(f"{button} button found and clicked") await asyncio.sleep(1) break except Exception as e: print(f"Error during cookie acceptance: {e}") print("No cookies to accept") # Get page content content = await page.content() return {"status": "success", "content": content} except Exception as e: print(f"Error during page navigation: {e}") # Try to get content anyway try: content = await page.content() return {"status": "partial", "content": content, "error": str(e)} except: raise Exception(f"Failed to get page content: {str(e)}") # Perform the operation return await safe_browser_operation(decoded_url, visit_operation) async def screenshot_url_service(decoded_url, full_page=True): """Service function to visit a URL and return a screenshot as PNG bytes""" print(f"Taking screenshot of: {decoded_url}") async def screenshot_operation(page): try: # Start navigation early, then wait for progressively stronger load signals. await page.goto(decoded_url, wait_until='domcontentloaded', timeout=30000) await page.wait_for_load_state('load', timeout=30000) # Wait until the browser reports full document readiness. await page.wait_for_function("document.readyState === 'complete'", timeout=30000) # Ensure network has gone quiet so dynamic requests can finish. await page.wait_for_load_state('networkidle', timeout=30000) # Scroll to trigger lazy-loaded/infinite content until height stabilizes. await page.evaluate("""async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let previousHeight = 0; let stableRounds = 0; const maxRounds = 30; for (let i = 0; i < maxRounds; i++) { const currentHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight ); window.scrollTo(0, currentHeight); await sleep(700); const newHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight ); if (newHeight <= previousHeight && newHeight <= currentHeight) { stableRounds += 1; if (stableRounds >= 2) { break; } } else { stableRounds = 0; } previousHeight = newHeight; } // Return to top for a predictable final render before capture. window.scrollTo(0, 0); await sleep(700); }""") # Extra settle time so animations/transitions can complete. await page.wait_for_timeout(5000) screenshot_bytes = await page.screenshot(full_page=full_page, type='png') return screenshot_bytes except Exception as e: print(f"Error during screenshot capture: {e}") return {"status": "error", "url": decoded_url, "error": str(e)} return await safe_browser_operation(decoded_url, screenshot_operation) async def click_selector_service(decoded_url: str, selector: str, return_type: str): """Navigate to a URL, click a CSS selector, and return PNG bytes or resulting HTML.""" print(f"Clicking selector on {decoded_url}: {selector}") async def click_operation(page): try: # Navigate and wait for initial DOM. await page.goto(decoded_url, wait_until='domcontentloaded', timeout=30000) await page.wait_for_load_state('load', timeout=30000) # Ensure the page is fully loaded (helps with JS-driven UIs). await page.wait_for_function("document.readyState === 'complete'", timeout=30000) # Best-effort cookie banner handling (helps avoid click interception). cookie_buttons = [ 'Accept all', 'Accepteer', 'Accepteren', 'Accept' ] for button in cookie_buttons: try: element = await page.query_selector(f'text="{button}"') if element: await element.click() await page.wait_for_timeout(1000) break except Exception: # Ignore cookie/banner interaction errors; click may still work. pass # Wait for the target selector, scroll it into view, then click. await page.wait_for_selector(selector, timeout=30000) locator = page.locator(selector) await locator.scroll_into_view_if_needed(timeout=30000) await locator.click(force=True, timeout=30000) # Give the page a moment to react to the click. await page.wait_for_timeout(1000) try: await page.wait_for_load_state('networkidle', timeout=10000) except Exception: pass if return_type == "html": return await page.content() # Return a full-page screenshot of the resulting view. return await page.screenshot(full_page=True, type='png') except Exception as e: print(f"Error during click capture: {e}") return {"status": "error", "url": decoded_url, "error": str(e)} return await safe_browser_operation(decoded_url, click_operation) async def interactions_service( decoded_url: str, interactions, return_type: str, viewport=None, scroll_x: int | None = None, scroll_y: int | None = None, ): """Open `decoded_url`, execute click/type interactions in order, then return screenshot or HTML.""" print(f"Running interactions on {decoded_url} (return_type={return_type})") async def interactions_operation(page): try: # When the API caller sets viewport.height = -1, treat that as "capture full webpage". # We implement this by resizing the viewport to the document's scroll height just # before the final screenshot. (This keeps existing coordinate-click behavior intact.) full_page_via_viewport = False requested_viewport_width = None async def resolve_viewport_point(px: int, py: int) -> tuple[float, float]: """ Playwright's `document.elementFromPoint(x, y)` uses viewport coordinates. Heuristic: if the provided coords clearly exceed the viewport, assume they are document coordinates and convert by subtracting current scroll offsets. """ vp = await page.evaluate( "() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, scrollX: window.scrollX, scrollY: window.scrollY })" ) inner_w = vp.get("innerWidth", 0) inner_h = vp.get("innerHeight", 0) cur_scroll_x = vp.get("scrollX", 0) cur_scroll_y = vp.get("scrollY", 0) x = float(px) y = float(py) if inner_h and y > inner_h + 50: y = y - float(cur_scroll_y) if inner_w and x > inner_w + 50: x = x - float(cur_scroll_x) return x, y async def element_from_point_retry(vx: float, vy: float, attempts: int = 10): """Retry `elementFromPoint` to avoid timing issues around scroll/render.""" last_error = None for _ in range(attempts): handle = None try: handle = await page.evaluate_handle( "(p) => document.elementFromPoint(p.x, p.y)", {"x": vx, "y": vy}, ) element = handle.as_element() if element is not None: saved_handle = handle # Prevent `finally` from disposing the backing JSHandle; # the caller will dispose after clicking/focusing. handle = None return element, saved_handle last_error = f"No element found at viewport point ({vx}, {vy})" except Exception as e: last_error = str(e) finally: try: if handle is not None: await handle.dispose() except Exception: pass await page.wait_for_timeout(100) raise ValueError(last_error or f"No element found at viewport point ({vx}, {vy})") # Configure viewport if provided (before navigation). if viewport is not None: try: if isinstance(viewport, dict): vp_width = viewport.get("width") vp_height = viewport.get("height") else: vp_width = getattr(viewport, "width", None) vp_height = getattr(viewport, "height", None) requested_viewport_width = vp_width if vp_width and vp_height: if int(vp_height) == -1: # Defer height calculation until after navigation/interactions. full_page_via_viewport = True vp_size = page.viewport_size or {"width": 1280, "height": 720} await page.set_viewport_size({"width": int(vp_width), "height": int(vp_size.get("height", 720))}) else: await page.set_viewport_size({"width": int(vp_width), "height": int(vp_height)}) except Exception as e: print(f"Error setting viewport: {e}") # Navigate and wait for initial DOM. await page.goto(decoded_url, wait_until="domcontentloaded", timeout=30000) await page.wait_for_load_state("load", timeout=30000) # Ensure the page is fully loaded (helps with JS-driven UIs). await page.wait_for_function("document.readyState === 'complete'", timeout=30000) # Best-effort cookie/banner handling (helps avoid click interception). cookie_buttons = [ "Accept all", "Accepteer", "Accepteren", "Accept", ] for button in cookie_buttons: try: element = await page.query_selector(f'text="{button}"') if element: await element.click() await page.wait_for_timeout(1000) break except Exception: pass # Apply initial scroll position if provided. # Do this AFTER cookie/banner clicks since those can change scroll position. if scroll_x is not None or scroll_y is not None: try: await page.evaluate( """(x, y) => { const left = (x === null ? window.scrollX : x); const top = (y === null ? window.scrollY : y); window.scrollTo({ left, top, behavior: 'auto' }); }""", scroll_x if scroll_x is not None else None, scroll_y if scroll_y is not None else None, ) # Wait for scroll offsets to actually settle before doing coordinate clicks. if scroll_x is not None: await page.wait_for_function( "(x) => Math.abs(window.scrollX - x) <= 2", scroll_x, timeout=10000, ) if scroll_y is not None: await page.wait_for_function( "(y) => Math.abs(window.scrollY - y) <= 2", scroll_y, timeout=10000, ) await page.wait_for_timeout(300) except Exception as e: print(f"Error applying initial scroll position: {e}") for interaction in interactions: # Support both Pydantic model instances and raw dicts. if isinstance(interaction, dict): action = interaction.get("action") selector = interaction.get("selector") text = interaction.get("text") else: action = getattr(interaction, "action", None) selector = getattr(interaction, "selector", None) text = getattr(interaction, "text", None) # Coordinates may be present on interaction models. x = getattr(interaction, "x", None) y = getattr(interaction, "y", None) if isinstance(interaction, dict): x = interaction.get("x") y = interaction.get("y") if not action: raise ValueError("Interaction must contain an action") # Prefer selector-based interactions when selector is provided. if selector: if action == "click": await page.wait_for_selector(selector, timeout=30000) locator = page.locator(selector) await locator.scroll_into_view_if_needed(timeout=30000) await locator.click(force=True, timeout=30000) elif action == "type": if text is None: raise ValueError("Type interaction must contain text") await page.wait_for_selector(selector, timeout=30000) locator = page.locator(selector) await locator.scroll_into_view_if_needed(timeout=30000) await locator.fill(text) else: raise ValueError(f"Unknown interaction action: {action}") # Fallback to coordinate-based interactions when no selector is given. elif x is not None and y is not None: if action == "click": # Use DOM elementFromPoint so React/SPA components receive real click events. try: vx, vy = await resolve_viewport_point(x, y) element, handle = await element_from_point_retry(vx, vy) try: await element.click(force=True, timeout=30000) finally: try: await handle.dispose() except Exception: pass except Exception as e: raise ValueError(f"Click via elementFromPoint failed at ({x}, {y}): {e}") elif action == "type": if text is None: raise ValueError("Type interaction must contain text") # Focus the element at the given coordinates, then type. vx, vy = await resolve_viewport_point(x, y) element, handle = await element_from_point_retry(vx, vy) try: await element.click(force=True, timeout=30000) # Ensure the click actually put focus on the intended control. try: await element.focus(timeout=30000) except Exception: pass await page.wait_for_timeout(100) await page.keyboard.type(text) finally: try: await handle.dispose() except Exception: pass else: raise ValueError(f"Unknown interaction action: {action}") else: raise ValueError("Interaction must contain either selector or x/y coordinates") # Let the UI settle after each action. await page.wait_for_timeout(1000) try: await page.wait_for_load_state("networkidle", timeout=10000) except Exception: pass if return_type == "html": return await page.content() if full_page_via_viewport: try: # Chromium has practical limits on viewport height; cap to avoid errors. max_vp_h = 16384 # Ensure we capture from the top of the document. await page.evaluate("() => window.scrollTo({ left: 0, top: 0, behavior: 'auto' })") await page.wait_for_timeout(250) full_h = await page.evaluate( "() => Math.max(document.body?.scrollHeight || 0, document.documentElement?.scrollHeight || 0)" ) target_h = int(min(max(full_h, 1), max_vp_h)) cur_vp = page.viewport_size or {"width": 1280, "height": 720} target_w = int(requested_viewport_width or cur_vp.get("width", 1280)) await page.set_viewport_size({"width": target_w, "height": target_h}) await page.wait_for_timeout(500) except Exception as e: print(f"Error resizing viewport for full-page capture: {e}") # For coordinate-based workflows, default is viewport screenshot so pixel coords align. # If full_page_via_viewport is enabled, the viewport has been expanded to the full document height. return await page.screenshot(full_page=False, type="png") except Exception as e: print(f"Error during interactions capture: {e}") return {"status": "error", "url": decoded_url, "error": str(e)} return await safe_browser_operation(decoded_url, interactions_operation) async def extract_seo_service(decoded_url): """Service function to extract SEO information from a website""" print(f"Extracting SEO from: {decoded_url}") # Define the operation to perform with the browser async def seo_operation(page): try: response = await page.goto(decoded_url, wait_until='networkidle', timeout=30000) # Extract SEO information seo_data = await page.evaluate('''() => { const data = { title: document.title || '', description: '', canonical: '', h1: [], h2: [], images: 0, links: 0 }; // Get meta description const metaDescription = document.querySelector('meta[name="description"]'); if (metaDescription) { data.description = metaDescription.getAttribute('content') || ''; } // Get canonical link const canonicalLink = document.querySelector('link[rel="canonical"]'); if (canonicalLink) { data.canonical = canonicalLink.getAttribute('href') || ''; } // Get h1 tags document.querySelectorAll('h1').forEach(h1 => { const text = h1.innerText.trim(); if (text) data.h1.push(text); }); // Get h2 tags document.querySelectorAll('h2').forEach(h2 => { const text = h2.innerText.trim(); if (text) data.h2.push(text); }); // Count images data.images = document.querySelectorAll('img').length; // Count links data.links = document.querySelectorAll('a').length; return data; }''') result = { "status": "success", "url": decoded_url, "seo": seo_data } return result except Exception as e: print(f"Error during SEO extraction: {e}") return {"status": "error", "url": decoded_url, "error": str(e)} # Perform the operation return await safe_browser_operation(decoded_url, seo_operation) async def extract_meta_tags_service(decoded_url): """Service function to extract meta tags from a website""" print(f"Extracting meta tags from: {decoded_url}") # Define the operation to perform with the browser async def meta_tags_operation(page): try: response = await page.goto(decoded_url, wait_until='networkidle', timeout=30000) # Extract meta tags using JavaScript meta_data = await page.evaluate('''() => { const data = { meta_tags: [], open_graph: {}, twitter_card: {}, title: document.title || '' }; // Extract all meta tags document.querySelectorAll('meta').forEach(meta => { const attributes = {}; for (let attr of meta.attributes) { attributes[attr.name] = attr.value; } data.meta_tags.push(attributes); }); // Extract Open Graph tags document.querySelectorAll('meta[property^="og:"]').forEach(meta => { data.open_graph[meta.getAttribute('property')] = meta.getAttribute('content') || ''; }); // Extract Twitter card tags document.querySelectorAll('meta[name^="twitter:"]').forEach(meta => { data.twitter_card[meta.getAttribute('name')] = meta.getAttribute('content') || ''; }); return data; }''') result = { "status": "success", "url": decoded_url, "meta_tags": meta_data["meta_tags"], "open_graph": meta_data["open_graph"], "twitter_card": meta_data["twitter_card"], "title": meta_data["title"] } return result except Exception as e: print(f"Error during meta tag extraction: {e}") return {"status": "error", "url": decoded_url, "error": str(e)} # Perform the operation return await safe_browser_operation(decoded_url, meta_tags_operation) async def capture_outgoing_calls_service(decoded_url): """Service function to capture outgoing API calls from a website""" print(f"Capturing outgoing calls from: {decoded_url}") # Define the operation to perform with the browser async def outgoing_calls_operation(page): try: # List to store all network requests network_requests = [] # Set up network request listener async def handle_request(request): # Only capture API-like requests (not static assets) url = request.url method = request.method headers = request.headers # Skip static assets and common non-API requests static_extensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot'] if any(url.endswith(ext) for ext in static_extensions): return # Skip data URLs and blob URLs if url.startswith(('data:', 'blob:')): return # Skip same-origin requests that are likely static assets if url.startswith(decoded_url) and any(static_ext in url.lower() for static_ext in static_extensions): return # Capture the request details request_data = { "url": url, "method": method, "headers": dict(headers), "timestamp": None # Will be set when request is finished } # Store request for later processing network_requests.append(request_data) # Listen to all requests page.on("request", handle_request) # Navigate to the URL and wait for network to be idle try: await page.goto(decoded_url, wait_until='networkidle', timeout=30000) except Exception as e: print(f"Error during page navigation: {e}") # Continue anyway to capture any requests that were made # Wait a bit more to catch any delayed requests await page.wait_for_timeout(2000) # Process and categorize the requests api_calls = [] for req in network_requests: # Determine if this looks like an API call is_api_call = False api_type = "unknown" # Check for common API patterns if any(pattern in req["url"].lower() for pattern in ['/api/', '/rest/', '/graphql', '/json', '/xml']): is_api_call = True api_type = "rest" elif req["url"].endswith('.json'): is_api_call = True api_type = "json" elif 'application/json' in req["headers"].get('content-type', '').lower(): is_api_call = True api_type = "json" elif 'application/xml' in req["headers"].get('content-type', '').lower(): is_api_call = True api_type = "xml" elif req["method"] in ['POST', 'PUT', 'PATCH', 'DELETE']: # These methods are typically API calls is_api_call = True api_type = "rest" elif any(domain in req["url"] for domain in ['api.', 'rest.', 'graphql.']): is_api_call = True api_type = "rest" # Include all requests but mark API calls specifically call_info = { "url": req["url"], "method": req["method"], "is_api_call": is_api_call, "api_type": api_type if is_api_call else None, "headers": {k: v for k, v in req["headers"].items() if k.lower() not in ['user-agent', 'accept-encoding', 'accept-language', 'cache-control']} } api_calls.append(call_info) # Sort by whether it's an API call (API calls first), then by URL api_calls.sort(key=lambda x: (not x["is_api_call"], x["url"])) result = { "status": "success", "url": decoded_url, "total_requests": len(api_calls), "api_calls": [call for call in api_calls if call["is_api_call"]], "other_requests": [call for call in api_calls if not call["is_api_call"]], "summary": { "api_calls_count": len([call for call in api_calls if call["is_api_call"]]), "other_requests_count": len([call for call in api_calls if not call["is_api_call"]]), "unique_domains": len(set([call["url"].split('/')[2] for call in api_calls if len(call["url"].split('/')) > 2])) } } return result except Exception as e: print(f"Error during outgoing calls capture: {e}") return {"status": "error", "url": decoded_url, "error": str(e)} # Perform the operation return await safe_browser_operation(decoded_url, outgoing_calls_operation) async def get_resulting_url_service(decoded_url): """Service function to get the resulting URL after navigation (handles redirects)""" print(f"Getting resulting URL for: {decoded_url}") # Define the operation to perform with the browser async def resulting_url_operation(page): try: # Navigate to the URL and wait for network to be idle response = await page.goto(decoded_url, wait_until='networkidle', timeout=30000) # Get the final URL after any redirects and decode it final_url = page.url decoded_final_url = unquote(final_url) # Get response status if available status_code = response.status if response else None result = { "status": "success", "original_url": decoded_url, "resulting_url": decoded_final_url, "status_code": status_code } # Check if there was a redirect if decoded_final_url != decoded_url: result["redirected"] = True else: result["redirected"] = False return result except Exception as e: print(f"Error during URL navigation: {e}") return {"status": "error", "original_url": decoded_url, "error": str(e)} # Perform the operation return await safe_browser_operation(decoded_url, resulting_url_operation) async def safe_browser_operation(url, operation_func): """Safely perform browser operations with proper cleanup and timeouts""" browser = None context = None page = None playwright = None try: # Get or create playwright instance with timeout playwright = await asyncio.wait_for(async_playwright().start(), timeout=30.0) browser = await asyncio.wait_for(playwright.chromium.launch( headless=True, args=['--no-sandbox', '--disable-setuid-sandbox', '--max_old_space_size=256'], ), timeout=30.0) # Create context and page with timeout context = await asyncio.wait_for( browser.new_context( user_agent=CUSTOM_USER_AGENT, viewport={"width": 1280, "height": 720}, ignore_https_errors=True, ), timeout=10.0, ) page = await asyncio.wait_for(context.new_page(), timeout=10.0) page.set_default_timeout(30000) # 30 second timeout # Track page creation time for force cleanup page_creation_times[page] = time.time() active_pages.add(page) # Call the operation function that uses the page with timeout result = await asyncio.wait_for(operation_func(page), timeout=60.0) return result except asyncio.TimeoutError as e: print(f"Browser operation timeout for {url}: {e}") return { "status": "error", "error": f"Operation timeout: {str(e)}", "url": url } except Exception as e: print(f"Error during browser operation for {url}: {e}") # Return error result instead of re-raising to allow graceful handling return { "status": "error", "error": str(e), "url": url } finally: # Cleanup page tracking with timeout if page: try: # Remove from tracking if page in active_pages: active_pages.remove(page) if page in page_creation_times: del page_creation_times[page] await asyncio.wait_for(page.close(), timeout=5.0) except (asyncio.TimeoutError, Exception) as e: print(f"Error closing page: {e}") try: await page.close(force=True) except: pass # Ensure context is closed properly with timeout if context: try: await asyncio.wait_for(context.close(), timeout=5.0) except (asyncio.TimeoutError, Exception) as e: print(f"Error closing context: {e}") # Ensure browser is closed properly with timeout if browser: try: await asyncio.wait_for(browser.close(), timeout=10.0) except asyncio.TimeoutError: try: await browser.close(force=True) except: pass except Exception as e: print(f"Error closing browser: {e}") # Ensure playwright is closed properly with timeout - THIS IS THE KEY FIX if playwright: try: await asyncio.wait_for(playwright.stop(), timeout=10.0) except (asyncio.TimeoutError, Exception) as e: print(f"Error closing playwright: {e}") async def force_cleanup_old_pages(): """Force cleanup old page instances created by this module""" from app.config import BROWSER_INSTANCE_TIMEOUT_MINUTES print(f"Checking for old page instances in browser service (timeout: {BROWSER_INSTANCE_TIMEOUT_MINUTES} minutes)...") current_time = time.time() timeout_seconds = BROWSER_INSTANCE_TIMEOUT_MINUTES * 60 cleaned_pages = 0 # Clean up old pages pages_to_cleanup = [] for page in list(active_pages): creation_time = page_creation_times.get(page, 0) if current_time - creation_time > timeout_seconds: pages_to_cleanup.append(page) print(f"Marking page for cleanup (age: {(current_time - creation_time)/60:.1f} minutes)") for page in pages_to_cleanup: try: if page in active_pages: active_pages.remove(page) if page in page_creation_times: del page_creation_times[page] await page.close() cleaned_pages += 1 except Exception as e: print(f"Error cleaning up old page: {e}") print(f"Force cleanup completed: {cleaned_pages} pages cleaned")