490 lines
19 KiB
Python
490 lines
19 KiB
Python
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)
|
|
|
|
# Allow a brief settle period for final paints/animations.
|
|
await page.wait_for_timeout(3000)
|
|
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 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': 1920, 'height': 1080},
|
|
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")
|