mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-26 10:41:14 +00:00
856 lines
33 KiB
Python
856 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import pathlib
|
|
import platform
|
|
import random
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Awaitable, Callable, Protocol, cast
|
|
from urllib.parse import parse_qsl, urlparse
|
|
|
|
import psutil
|
|
import structlog
|
|
from playwright.async_api import Browser, BrowserContext, ConsoleMessage, Download, Page, Playwright
|
|
|
|
from skyvern.config import settings
|
|
from skyvern.constants import (
|
|
BROWSER_DOWNLOAD_TIMEOUT,
|
|
SKYVERN_DIR,
|
|
)
|
|
from skyvern.exceptions import UnknownBrowserType, UnknownErrorWhileCreatingBrowserContext
|
|
from skyvern.forge import app
|
|
from skyvern.forge.sdk.api.files import get_download_dir, make_temp_directory
|
|
from skyvern.forge.sdk.core.skyvern_context import current, ensure_context
|
|
from skyvern.schemas.runs import ProxyLocation, get_tzinfo_from_proxy
|
|
from skyvern.webeye.browser_artifacts import BrowserArtifacts, VideoArtifact
|
|
from skyvern.webeye.cdp_download_interceptor import CDPDownloadInterceptor
|
|
|
|
LOG = structlog.get_logger()
|
|
|
|
|
|
BrowserCleanupFunc = Callable[[], Awaitable[None]] | None
|
|
# Header to signal fresh browser context creation (stripped before sending to websites)
|
|
# When set to "true", creates a new incognito-like context instead of reusing existing ones
|
|
FRESH_CONTEXT_HEADER = "X-Skyvern-Fresh-Context"
|
|
|
|
|
|
@dataclass
|
|
class ParsedBrowserHeaders:
|
|
"""Result of parsing extra HTTP headers for internal Skyvern headers."""
|
|
|
|
headers: dict[str, str] # Headers to pass to browser (internal headers stripped)
|
|
use_fresh_context: bool # Whether to create a fresh browser context
|
|
enable_download: bool # Whether download interception is enabled
|
|
|
|
|
|
def parse_extra_headers(extra_http_headers: dict[str, str] | None) -> ParsedBrowserHeaders:
|
|
"""Parse extra HTTP headers and extract internal Skyvern headers.
|
|
|
|
Extracts internal headers (case-insensitive) and returns a copy of headers
|
|
without them, along with the extracted values.
|
|
|
|
Args:
|
|
extra_http_headers: Original headers dict (not mutated)
|
|
|
|
Returns:
|
|
ParsedBrowserHeaders with stripped headers and extracted values
|
|
"""
|
|
headers: dict[str, str] = {}
|
|
use_fresh_context = False
|
|
enable_download = False
|
|
|
|
if extra_http_headers:
|
|
for key, value in extra_http_headers.items():
|
|
key_lower = key.lower()
|
|
if key_lower == FRESH_CONTEXT_HEADER.lower():
|
|
use_fresh_context = value.lower() == "true"
|
|
elif key_lower == "enable_download":
|
|
enable_download = bool(value)
|
|
else:
|
|
headers[key] = value
|
|
|
|
return ParsedBrowserHeaders(
|
|
headers=headers,
|
|
use_fresh_context=use_fresh_context,
|
|
enable_download=enable_download,
|
|
)
|
|
|
|
|
|
def set_browser_console_log(browser_context: BrowserContext, browser_artifacts: BrowserArtifacts) -> None:
|
|
if browser_artifacts.browser_console_log_path is None:
|
|
log_path = f"{settings.LOG_PATH}/{datetime.utcnow().strftime('%Y-%m-%d')}/{uuid.uuid4()}.log"
|
|
try:
|
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
# create the empty log file
|
|
with open(log_path, "w") as _:
|
|
pass
|
|
except Exception:
|
|
LOG.warning(
|
|
"Failed to create browser log file",
|
|
log_path=log_path,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
browser_artifacts.browser_console_log_path = log_path
|
|
|
|
async def browser_console_log(msg: ConsoleMessage) -> None:
|
|
current_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
key_values = " ".join([f"{key}={value}" for key, value in msg.location.items()])
|
|
format_log = f"{current_time}[{msg.type}]{msg.text} {key_values}\n"
|
|
await browser_artifacts.append_browser_console_log(format_log)
|
|
|
|
LOG.info("browser console log is saved", log_path=browser_artifacts.browser_console_log_path)
|
|
browser_context.on("console", browser_console_log)
|
|
|
|
|
|
def set_download_file_listener(
|
|
browser_context: BrowserContext, download_timeout: float | None = None, **kwargs: Any
|
|
) -> None:
|
|
async def listen_to_download(download: Download) -> None:
|
|
workflow_run_id = kwargs.get("workflow_run_id")
|
|
task_id = kwargs.get("task_id")
|
|
try:
|
|
async with asyncio.timeout(download_timeout or BROWSER_DOWNLOAD_TIMEOUT):
|
|
file_path = await download.path()
|
|
if file_path.suffix:
|
|
return
|
|
|
|
LOG.info(
|
|
"No file extensions, going to add file extension automatically",
|
|
workflow_run_id=workflow_run_id,
|
|
task_id=task_id,
|
|
suggested_filename=download.suggested_filename,
|
|
url=download.url,
|
|
)
|
|
suffix = Path(download.suggested_filename).suffix
|
|
if suffix:
|
|
LOG.info(
|
|
"Add extension according to suggested filename",
|
|
workflow_run_id=workflow_run_id,
|
|
task_id=task_id,
|
|
filepath=str(file_path) + suffix,
|
|
)
|
|
file_path.rename(str(file_path) + suffix)
|
|
return
|
|
|
|
parsed_url = urlparse(download.url)
|
|
parsed_qs = parse_qsl(parsed_url.query)
|
|
for key, value in parsed_qs:
|
|
if key.lower() == "filename":
|
|
suffix = Path(value).suffix
|
|
if suffix:
|
|
LOG.info(
|
|
"Add extension according to the parsed query params of download url",
|
|
workflow_run_id=workflow_run_id,
|
|
task_id=task_id,
|
|
filename=value,
|
|
)
|
|
file_path.rename(str(file_path) + suffix)
|
|
return
|
|
|
|
suffix = Path(parsed_url.path).suffix
|
|
if suffix:
|
|
LOG.info(
|
|
"Add extension according to download url path",
|
|
workflow_run_id=workflow_run_id,
|
|
task_id=task_id,
|
|
filepath=str(file_path) + suffix,
|
|
)
|
|
file_path.rename(str(file_path) + suffix)
|
|
return
|
|
# TODO: maybe should try to parse it from URL response
|
|
except asyncio.TimeoutError:
|
|
LOG.error(
|
|
"timeout to download file, going to cancel the download",
|
|
workflow_run_id=workflow_run_id,
|
|
task_id=task_id,
|
|
)
|
|
await download.cancel()
|
|
|
|
except Exception:
|
|
LOG.exception(
|
|
"Failed to add file extension name to downloaded file",
|
|
workflow_run_id=workflow_run_id,
|
|
task_id=task_id,
|
|
)
|
|
|
|
def listen_to_new_page(page: Page) -> None:
|
|
page.on("download", listen_to_download)
|
|
|
|
browser_context.on("page", listen_to_new_page)
|
|
|
|
|
|
def initialize_download_dir() -> str:
|
|
context = ensure_context()
|
|
return get_download_dir(
|
|
context.run_id if context and context.run_id else context.workflow_run_id or context.task_id
|
|
)
|
|
|
|
|
|
async def _apply_download_behaviour(browser: Browser) -> None:
|
|
context = ensure_context()
|
|
download_dir = get_download_dir(
|
|
context.run_id if context and context.run_id else context.workflow_run_id or context.task_id
|
|
)
|
|
cdp_session = await browser.new_browser_cdp_session()
|
|
await cdp_session.send(
|
|
"Browser.setDownloadBehavior",
|
|
{
|
|
"behavior": "allow",
|
|
"downloadPath": download_dir,
|
|
},
|
|
)
|
|
|
|
LOG.info("setDownloadBehavior applied", download_dir=download_dir)
|
|
|
|
|
|
class BrowserContextCreator(Protocol):
|
|
def __call__(
|
|
self, playwright: Playwright, proxy_location: ProxyLocation | None = None, **kwargs: dict[str, Any]
|
|
) -> Awaitable[tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]]: ...
|
|
|
|
|
|
class BrowserContextFactory:
|
|
_creators: dict[str, BrowserContextCreator] = {}
|
|
_validator: Callable[[Page], Awaitable[bool]] | None = None
|
|
|
|
@staticmethod
|
|
def get_subdir() -> str:
|
|
curr_context = current()
|
|
if curr_context and curr_context.task_id:
|
|
return curr_context.task_id
|
|
elif curr_context and curr_context.request_id:
|
|
return curr_context.request_id
|
|
return str(uuid.uuid4())
|
|
|
|
@staticmethod
|
|
def update_chromium_browser_preferences(
|
|
user_data_dir: str,
|
|
download_dir: str,
|
|
preference_template_path: str | None = None,
|
|
) -> None:
|
|
preference_dst_folder = f"{user_data_dir}/Default"
|
|
os.makedirs(preference_dst_folder, exist_ok=True)
|
|
|
|
preference_dst_file = f"{preference_dst_folder}/Preferences"
|
|
preference_template = preference_template_path or f"{SKYVERN_DIR}/webeye/chromium_preferences.json"
|
|
|
|
preference_file_content = ""
|
|
with open(preference_template) as f:
|
|
preference_file_content = f.read()
|
|
preference_file_content = preference_file_content.replace("MASK_SAVEFILE_DEFAULT_DIRECTORY", download_dir)
|
|
preference_file_content = preference_file_content.replace("MASK_DOWNLOAD_DEFAULT_DIRECTORY", download_dir)
|
|
with open(preference_dst_file, "w") as f:
|
|
f.write(preference_file_content)
|
|
|
|
@staticmethod
|
|
def build_browser_args(
|
|
proxy_location: ProxyLocation | None = None,
|
|
cdp_port: int | None = None,
|
|
extra_http_headers: dict[str, str] | None = None,
|
|
) -> dict[str, Any]:
|
|
video_dir = f"{settings.VIDEO_PATH}/{datetime.utcnow().strftime('%Y-%m-%d')}"
|
|
har_dir = (
|
|
f"{settings.HAR_PATH}/{datetime.utcnow().strftime('%Y-%m-%d')}/{BrowserContextFactory.get_subdir()}.har"
|
|
)
|
|
|
|
extension_paths = []
|
|
if settings.EXTENSIONS and settings.EXTENSIONS_BASE_PATH:
|
|
try:
|
|
os.makedirs(settings.EXTENSIONS_BASE_PATH, exist_ok=True)
|
|
|
|
extension_paths = [str(Path(settings.EXTENSIONS_BASE_PATH) / ext) for ext in settings.EXTENSIONS]
|
|
LOG.info("Extensions paths constructed", extension_paths=extension_paths)
|
|
except Exception as e:
|
|
LOG.error("Error constructing extension paths", error=str(e))
|
|
|
|
browser_args = [
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--disk-cache-size=1",
|
|
"--start-maximized",
|
|
"--kiosk-printing",
|
|
]
|
|
|
|
if cdp_port:
|
|
browser_args.append(f"--remote-debugging-port={cdp_port}")
|
|
|
|
if extension_paths:
|
|
joined_paths = ",".join(extension_paths)
|
|
browser_args.extend([f"--disable-extensions-except={joined_paths}", f"--load-extension={joined_paths}"])
|
|
LOG.info("Extensions added to browser args", extensions=joined_paths)
|
|
|
|
browser_args.extend(settings.BROWSER_ADDITIONAL_ARGS)
|
|
args = {
|
|
"color_scheme": "no-preference",
|
|
"args": browser_args,
|
|
"ignore_default_args": [
|
|
"--enable-automation",
|
|
],
|
|
"record_har_path": har_dir,
|
|
"record_video_dir": video_dir,
|
|
"viewport": {
|
|
"width": settings.BROWSER_WIDTH,
|
|
"height": settings.BROWSER_HEIGHT,
|
|
},
|
|
"extra_http_headers": extra_http_headers,
|
|
}
|
|
if settings.BROWSER_LOCALE:
|
|
args["locale"] = settings.BROWSER_LOCALE
|
|
|
|
if settings.ENABLE_PROXY:
|
|
proxy_config = setup_proxy()
|
|
if proxy_config:
|
|
args["proxy"] = proxy_config
|
|
|
|
if proxy_location:
|
|
if tz_info := get_tzinfo_from_proxy(proxy_location=proxy_location):
|
|
args["timezone_id"] = tz_info.key
|
|
return args
|
|
|
|
@staticmethod
|
|
def build_browser_artifacts(
|
|
video_artifacts: list[VideoArtifact] | None = None,
|
|
har_path: str | None = None,
|
|
traces_dir: str | None = None,
|
|
browser_session_dir: str | None = None,
|
|
browser_console_log_path: str | None = None,
|
|
) -> BrowserArtifacts:
|
|
return BrowserArtifacts(
|
|
video_artifacts=video_artifacts or [],
|
|
har_path=har_path,
|
|
traces_dir=traces_dir,
|
|
browser_session_dir=browser_session_dir,
|
|
browser_console_log_path=browser_console_log_path,
|
|
)
|
|
|
|
@classmethod
|
|
def register_type(cls, browser_type: str, creator: BrowserContextCreator) -> None:
|
|
cls._creators[browser_type] = creator
|
|
|
|
@classmethod
|
|
async def create_browser_context(
|
|
cls, playwright: Playwright, **kwargs: Any
|
|
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
|
browser_type = settings.BROWSER_TYPE
|
|
browser_context: BrowserContext | None = None
|
|
try:
|
|
creator = cls._creators.get(browser_type)
|
|
if not creator:
|
|
raise UnknownBrowserType(browser_type)
|
|
browser_context, browser_artifacts, cleanup_func = await creator(playwright, **kwargs)
|
|
if settings.BROWSER_LOGS_ENABLED:
|
|
set_browser_console_log(browser_context=browser_context, browser_artifacts=browser_artifacts)
|
|
set_download_file_listener(browser_context=browser_context, **kwargs)
|
|
|
|
proxy_location: ProxyLocation | None = kwargs.get("proxy_location")
|
|
if proxy_location is not None:
|
|
context = ensure_context()
|
|
context.tz_info = get_tzinfo_from_proxy(proxy_location)
|
|
|
|
return browser_context, browser_artifacts, cleanup_func
|
|
except Exception as e:
|
|
if browser_context is not None:
|
|
# FIXME: sometimes it can't close the browser context?
|
|
LOG.error("unexpected error happens after created browser context, going to close the context")
|
|
await browser_context.close()
|
|
|
|
if isinstance(e, UnknownBrowserType):
|
|
raise e
|
|
|
|
raise UnknownErrorWhileCreatingBrowserContext(browser_type, e) from e
|
|
|
|
|
|
def setup_proxy() -> dict | None:
|
|
if not settings.HOSTED_PROXY_POOL or settings.HOSTED_PROXY_POOL.strip() == "":
|
|
LOG.warning("No proxy server value found. Continuing without using proxy...")
|
|
return None
|
|
|
|
proxy_servers = [server.strip() for server in settings.HOSTED_PROXY_POOL.split(",") if server.strip()]
|
|
|
|
if not proxy_servers:
|
|
LOG.warning("Proxy pool contains only empty values. Continuing without proxy...")
|
|
return None
|
|
|
|
valid_proxies = []
|
|
for proxy in proxy_servers:
|
|
if _is_valid_proxy_url(proxy):
|
|
valid_proxies.append(proxy)
|
|
else:
|
|
LOG.warning(f"Invalid proxy URL format: {proxy}")
|
|
|
|
if not valid_proxies:
|
|
LOG.warning("No valid proxy URLs found. Continuing without proxy...")
|
|
return None
|
|
|
|
try:
|
|
proxy_server = random.choice(valid_proxies)
|
|
proxy_creds = _get_proxy_server_creds(proxy_server)
|
|
|
|
LOG.info("Found proxy server creds, using them...")
|
|
|
|
return {
|
|
"server": proxy_server,
|
|
"username": proxy_creds.get("username", ""),
|
|
"password": proxy_creds.get("password", ""),
|
|
}
|
|
except Exception as e:
|
|
LOG.warning(f"Error setting up proxy: {e}. Continuing without proxy...")
|
|
return None
|
|
|
|
|
|
def _is_valid_proxy_url(url: str) -> bool:
|
|
PROXY_PATTERN = re.compile(r"^(http|https|socks5):\/\/([^:@]+(:[^@]*)?@)?[^\s:\/]+(:\d+)?$")
|
|
try:
|
|
parsed = urlparse(url)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return False
|
|
return bool(PROXY_PATTERN.match(url))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _get_proxy_server_creds(proxy: str) -> dict:
|
|
parsed_url = urlparse(proxy)
|
|
if parsed_url.username and parsed_url.password:
|
|
return {"username": parsed_url.username, "password": parsed_url.password}
|
|
LOG.warning("No credentials found in the proxy URL.")
|
|
return {}
|
|
|
|
|
|
def _is_display_server_error(error: Exception) -> bool:
|
|
"""Return True if the worker cannot initialize the browser display/graphics stack.
|
|
|
|
These errors appear when a headed browser is launched on a worker node
|
|
without a usable display/graphics environment. Retrying with a fresh profile
|
|
will not help — the fix is environment-side (display/EGL/SwiftShader support)
|
|
rather than profile-side.
|
|
"""
|
|
error_str = str(error).lower()
|
|
display_indicators = [
|
|
"missing x server",
|
|
"xserver running",
|
|
"no display",
|
|
"$display",
|
|
"the platform failed to initialize",
|
|
"no suitable egl configs found",
|
|
"failed to get config for surface",
|
|
"collectgraphicsinfo failed",
|
|
"glcontext::createoffscreenglsurface failed",
|
|
"exiting gpu process due to errors during initialization",
|
|
]
|
|
return any(indicator in error_str for indicator in display_indicators)
|
|
|
|
|
|
def _is_browser_profile_corruption_error(error: Exception) -> bool:
|
|
"""Return True if the error is consistent with a corrupted or bloated browser profile.
|
|
|
|
These errors appear when launch_persistent_context fails to start Chrome because
|
|
the user_data_dir is in a bad state (corrupted files, oversized cache, lock files
|
|
from a prior crash, etc.). The error text comes from Playwright's CDP driver.
|
|
"""
|
|
if _is_display_server_error(error):
|
|
return False
|
|
|
|
error_str = str(error).lower()
|
|
corruption_indicators = [
|
|
"connection closed while reading from the driver",
|
|
"target closed",
|
|
"browser has been closed",
|
|
"failed to launch",
|
|
"unable to open database",
|
|
]
|
|
return any(indicator in error_str for indicator in corruption_indicators)
|
|
|
|
|
|
def _get_cdp_port(kwargs: dict) -> int | None:
|
|
raw_cdp_port = kwargs.get("cdp_port")
|
|
if isinstance(raw_cdp_port, (int, str)):
|
|
try:
|
|
return int(raw_cdp_port)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return None
|
|
|
|
|
|
def _is_port_in_use(port: int) -> bool:
|
|
"""Check if a port is already in use."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
try:
|
|
s.bind(("localhost", port))
|
|
return False
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def _is_chrome_running() -> bool:
|
|
"""Check if Chrome is already running."""
|
|
chrome_process_names = ["chrome", "google-chrome", "google chrome"]
|
|
for proc in psutil.process_iter(["name"]):
|
|
try:
|
|
proc_name = proc.info["name"].lower()
|
|
if proc_name == "chrome_crashpad_handler":
|
|
continue
|
|
if any(chrome_name in proc_name for chrome_name in chrome_process_names):
|
|
return True
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
pass
|
|
return False
|
|
|
|
|
|
async def _create_headless_chromium(
|
|
playwright: Playwright,
|
|
proxy_location: ProxyLocation | None = None,
|
|
extra_http_headers: dict[str, str] | None = None,
|
|
**kwargs: dict,
|
|
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
|
if browser_address := kwargs.get("browser_address"):
|
|
return await _connect_to_cdp_browser(
|
|
playwright,
|
|
remote_browser_url=str(browser_address),
|
|
extra_http_headers=extra_http_headers,
|
|
apply_download_behaviour=True,
|
|
)
|
|
|
|
# Check for browser_profile_id and load from storage if available
|
|
browser_profile_id = cast(str | None, kwargs.get("browser_profile_id"))
|
|
organization_id_for_profile = cast(str | None, kwargs.get("organization_id"))
|
|
user_data_dir: str | None = None
|
|
loaded_from_saved_profile = False
|
|
|
|
if browser_profile_id and organization_id_for_profile:
|
|
profile_dir = await app.STORAGE.retrieve_browser_profile(
|
|
organization_id=organization_id_for_profile,
|
|
profile_id=browser_profile_id,
|
|
)
|
|
if profile_dir:
|
|
user_data_dir = profile_dir
|
|
loaded_from_saved_profile = True
|
|
LOG.info(
|
|
"Using browser profile",
|
|
browser_profile_id=browser_profile_id,
|
|
profile_dir=profile_dir,
|
|
)
|
|
else:
|
|
LOG.warning(
|
|
"Browser profile not found, using temp directory",
|
|
browser_profile_id=browser_profile_id,
|
|
organization_id=organization_id_for_profile,
|
|
)
|
|
|
|
if not user_data_dir:
|
|
user_data_dir = make_temp_directory(prefix="skyvern_browser_")
|
|
|
|
download_dir = initialize_download_dir()
|
|
BrowserContextFactory.update_chromium_browser_preferences(
|
|
user_data_dir=user_data_dir,
|
|
download_dir=download_dir,
|
|
)
|
|
cdp_port: int | None = _get_cdp_port(kwargs)
|
|
browser_args = BrowserContextFactory.build_browser_args(
|
|
proxy_location=proxy_location, cdp_port=cdp_port, extra_http_headers=extra_http_headers
|
|
)
|
|
browser_args.update(
|
|
{
|
|
"user_data_dir": user_data_dir,
|
|
"downloads_path": download_dir,
|
|
}
|
|
)
|
|
|
|
browser_artifacts = BrowserContextFactory.build_browser_artifacts(
|
|
har_path=browser_args["record_har_path"],
|
|
browser_session_dir=user_data_dir,
|
|
)
|
|
try:
|
|
browser_context = await playwright.chromium.launch_persistent_context(**browser_args)
|
|
except Exception as launch_error:
|
|
if loaded_from_saved_profile and _is_browser_profile_corruption_error(launch_error):
|
|
LOG.warning(
|
|
"Browser launch failed with saved profile — profile may be corrupted, falling back to fresh profile",
|
|
browser_profile_id=browser_profile_id,
|
|
organization_id=organization_id_for_profile,
|
|
error=str(launch_error),
|
|
)
|
|
fallback_dir = make_temp_directory(prefix="skyvern_browser_")
|
|
BrowserContextFactory.update_chromium_browser_preferences(
|
|
user_data_dir=fallback_dir,
|
|
download_dir=download_dir,
|
|
)
|
|
browser_args["user_data_dir"] = fallback_dir
|
|
browser_artifacts = BrowserContextFactory.build_browser_artifacts(
|
|
har_path=browser_args["record_har_path"],
|
|
browser_session_dir=fallback_dir,
|
|
)
|
|
browser_context = await playwright.chromium.launch_persistent_context(**browser_args)
|
|
else:
|
|
raise
|
|
return browser_context, browser_artifacts, None
|
|
|
|
|
|
async def _create_headful_chromium(
|
|
playwright: Playwright,
|
|
proxy_location: ProxyLocation | None = None,
|
|
extra_http_headers: dict[str, str] | None = None,
|
|
**kwargs: dict,
|
|
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
|
if browser_address := kwargs.get("browser_address"):
|
|
return await _connect_to_cdp_browser(
|
|
playwright,
|
|
remote_browser_url=str(browser_address),
|
|
extra_http_headers=extra_http_headers,
|
|
apply_download_behaviour=True,
|
|
)
|
|
|
|
# Check for browser_profile_id and load from storage if available
|
|
browser_profile_id = cast(str | None, kwargs.get("browser_profile_id"))
|
|
organization_id_for_profile = cast(str | None, kwargs.get("organization_id"))
|
|
user_data_dir: str | None = None
|
|
loaded_from_saved_profile = False
|
|
|
|
if browser_profile_id and organization_id_for_profile:
|
|
profile_dir = await app.STORAGE.retrieve_browser_profile(
|
|
organization_id=organization_id_for_profile,
|
|
profile_id=browser_profile_id,
|
|
)
|
|
if profile_dir:
|
|
user_data_dir = profile_dir
|
|
loaded_from_saved_profile = True
|
|
LOG.info(
|
|
"Using browser profile",
|
|
browser_profile_id=browser_profile_id,
|
|
profile_dir=profile_dir,
|
|
)
|
|
else:
|
|
LOG.warning(
|
|
"Browser profile not found, using temp directory",
|
|
browser_profile_id=browser_profile_id,
|
|
organization_id=organization_id_for_profile,
|
|
)
|
|
|
|
if not user_data_dir:
|
|
user_data_dir = make_temp_directory(prefix="skyvern_browser_")
|
|
|
|
download_dir = initialize_download_dir()
|
|
BrowserContextFactory.update_chromium_browser_preferences(
|
|
user_data_dir=user_data_dir,
|
|
download_dir=download_dir,
|
|
)
|
|
cdp_port: int | None = _get_cdp_port(kwargs)
|
|
browser_args = BrowserContextFactory.build_browser_args(
|
|
proxy_location=proxy_location, cdp_port=cdp_port, extra_http_headers=extra_http_headers
|
|
)
|
|
browser_args.update(
|
|
{
|
|
"user_data_dir": user_data_dir,
|
|
"downloads_path": download_dir,
|
|
"headless": False,
|
|
}
|
|
)
|
|
browser_artifacts = BrowserContextFactory.build_browser_artifacts(
|
|
har_path=browser_args["record_har_path"],
|
|
browser_session_dir=user_data_dir,
|
|
)
|
|
try:
|
|
browser_context = await playwright.chromium.launch_persistent_context(**browser_args)
|
|
except Exception as launch_error:
|
|
if loaded_from_saved_profile and _is_browser_profile_corruption_error(launch_error):
|
|
LOG.warning(
|
|
"Browser launch failed with saved profile — profile may be corrupted, falling back to fresh profile",
|
|
browser_profile_id=browser_profile_id,
|
|
organization_id=organization_id_for_profile,
|
|
error=str(launch_error),
|
|
)
|
|
fallback_dir = make_temp_directory(prefix="skyvern_browser_")
|
|
BrowserContextFactory.update_chromium_browser_preferences(
|
|
user_data_dir=fallback_dir,
|
|
download_dir=download_dir,
|
|
)
|
|
browser_args["user_data_dir"] = fallback_dir
|
|
browser_artifacts = BrowserContextFactory.build_browser_artifacts(
|
|
har_path=browser_args["record_har_path"],
|
|
browser_session_dir=fallback_dir,
|
|
)
|
|
browser_context = await playwright.chromium.launch_persistent_context(**browser_args)
|
|
else:
|
|
raise
|
|
return browser_context, browser_artifacts, None
|
|
|
|
|
|
def default_user_data_dir() -> pathlib.Path:
|
|
p = platform.system()
|
|
if p == "Darwin":
|
|
return pathlib.Path("~/Library/Application Support/Google/Chrome").expanduser()
|
|
if p == "Windows":
|
|
return pathlib.Path(os.environ["LOCALAPPDATA"]) / "Google" / "Chrome" / "User Data"
|
|
# Assume Linux/Unix
|
|
return pathlib.Path("~/.config/google-chrome").expanduser()
|
|
|
|
|
|
def is_valid_chromium_user_data_dir(directory: str) -> bool:
|
|
"""Check if a directory is a valid Chromium user data directory.
|
|
|
|
A valid Chromium user data directory should:
|
|
1. Exist
|
|
2. Not be empty
|
|
3. Contain a 'Default' directory
|
|
4. Have a 'Preferences' file in the 'Default' directory
|
|
"""
|
|
if not os.path.exists(directory):
|
|
return False
|
|
|
|
default_dir = os.path.join(directory, "Default")
|
|
preferences_file = os.path.join(default_dir, "Preferences")
|
|
|
|
return os.path.isdir(directory) and os.path.isdir(default_dir) and os.path.isfile(preferences_file)
|
|
|
|
|
|
async def _create_cdp_connection_browser(
|
|
playwright: Playwright,
|
|
proxy_location: ProxyLocation | None = None,
|
|
extra_http_headers: dict[str, str] | None = None,
|
|
**kwargs: dict,
|
|
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
|
if browser_address := kwargs.get("browser_address"):
|
|
return await _connect_to_cdp_browser(
|
|
playwright,
|
|
remote_browser_url=str(browser_address),
|
|
extra_http_headers=extra_http_headers,
|
|
apply_download_behaviour=True,
|
|
)
|
|
|
|
browser_type = settings.BROWSER_TYPE
|
|
browser_path = settings.CHROME_EXECUTABLE_PATH
|
|
|
|
if browser_type == "cdp-connect" and browser_path:
|
|
LOG.info("Local browser path is given. Connecting to local browser with CDP", browser_path=browser_path)
|
|
# First check if the debugging port is running and can be used
|
|
if not _is_port_in_use(9222):
|
|
LOG.info("Port 9222 is not in use, starting Chrome", browser_path=browser_path)
|
|
# Check if Chrome is already running
|
|
if _is_chrome_running():
|
|
raise Exception(
|
|
"Chrome is already running. Please close all Chrome instances before starting with remote debugging."
|
|
)
|
|
# check if ./tmp/user_data_dir exists and if it's a valid Chromium user data directory
|
|
try:
|
|
if os.path.exists("./tmp/user_data_dir") and not is_valid_chromium_user_data_dir("./tmp/user_data_dir"):
|
|
LOG.info("Removing invalid user data directory")
|
|
shutil.rmtree("./tmp/user_data_dir")
|
|
shutil.copytree(default_user_data_dir(), "./tmp/user_data_dir")
|
|
elif not os.path.exists("./tmp/user_data_dir"):
|
|
LOG.info("Copying default user data directory")
|
|
shutil.copytree(default_user_data_dir(), "./tmp/user_data_dir")
|
|
else:
|
|
LOG.info("User data directory is valid")
|
|
except FileExistsError:
|
|
# If directory exists, remove it first then copy
|
|
shutil.rmtree("./tmp/user_data_dir")
|
|
shutil.copytree(default_user_data_dir(), "./tmp/user_data_dir")
|
|
browser_process = subprocess.Popen(
|
|
[
|
|
browser_path,
|
|
"--remote-debugging-port=9222",
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--user-data-dir=./tmp/user_data_dir",
|
|
"--remote-debugging-address=0.0.0.0",
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
# Add small delay to allow browser to start
|
|
time.sleep(1)
|
|
if browser_process.poll() is not None:
|
|
raise Exception(f"Failed to open browser. browser_path: {browser_path}")
|
|
else:
|
|
LOG.info("Port 9222 is in use, using existing browser")
|
|
|
|
return await _connect_to_cdp_browser(playwright, settings.BROWSER_REMOTE_DEBUGGING_URL, extra_http_headers)
|
|
|
|
|
|
async def _connect_to_cdp_browser(
|
|
playwright: Playwright,
|
|
remote_browser_url: str,
|
|
extra_http_headers: dict[str, str] | None = None,
|
|
apply_download_behaviour: bool = False,
|
|
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
|
parsed_headers = parse_extra_headers(extra_http_headers)
|
|
|
|
browser_args = BrowserContextFactory.build_browser_args(
|
|
extra_http_headers=parsed_headers.headers if parsed_headers.headers else None
|
|
)
|
|
|
|
browser_artifacts = BrowserContextFactory.build_browser_artifacts(
|
|
har_path=browser_args["record_har_path"],
|
|
)
|
|
|
|
LOG.info("Connecting browser CDP connection", remote_browser_url=remote_browser_url)
|
|
browser = await playwright.chromium.connect_over_cdp(remote_browser_url)
|
|
|
|
if apply_download_behaviour:
|
|
await _apply_download_behaviour(browser)
|
|
|
|
# Decide whether to create fresh context or reuse existing one
|
|
contexts = browser.contexts
|
|
browser_context = None
|
|
|
|
if parsed_headers.use_fresh_context or not contexts:
|
|
LOG.info(
|
|
"Creating new browser context",
|
|
fresh_context_requested=parsed_headers.use_fresh_context,
|
|
existing_contexts=len(contexts),
|
|
)
|
|
browser_context = await browser.new_context(
|
|
record_video_dir=browser_args["record_video_dir"],
|
|
viewport=browser_args["viewport"],
|
|
extra_http_headers=browser_args["extra_http_headers"],
|
|
)
|
|
else:
|
|
LOG.info("Reusing existing browser context", existing_contexts=len(contexts))
|
|
browser_context = contexts[0]
|
|
|
|
# Enable CDPDownloadInterceptor when enable_download is set.
|
|
# This captures downloads via the Fetch domain and saves them locally.
|
|
if parsed_headers.enable_download:
|
|
download_dir = initialize_download_dir()
|
|
interceptor = CDPDownloadInterceptor(output_dir=download_dir)
|
|
|
|
# Enable interception on all existing pages
|
|
for page in browser_context.pages:
|
|
try:
|
|
await interceptor.enable_for_page(page)
|
|
except Exception:
|
|
LOG.warning("Failed to enable CDP intercept on page", page_url=page.url, exc_info=True)
|
|
|
|
# Auto-enable interception on new pages (e.g., target="_blank" links)
|
|
async def _on_new_page(page: Page) -> None:
|
|
try:
|
|
await interceptor.enable_for_page(page)
|
|
except Exception:
|
|
LOG.warning("Failed to enable CDP intercept on new page", page_url=page.url, exc_info=True)
|
|
|
|
browser_context.on("page", lambda page: asyncio.ensure_future(_on_new_page(page)))
|
|
LOG.info(
|
|
"CDP download interceptor enabled",
|
|
download_dir=download_dir,
|
|
existing_page_count=len(browser_context.pages),
|
|
)
|
|
|
|
LOG.info(
|
|
"Launched browser CDP connection",
|
|
remote_browser_url=remote_browser_url,
|
|
)
|
|
return browser_context, browser_artifacts, None
|
|
|
|
|
|
BrowserContextFactory.register_type("chromium-headless", _create_headless_chromium)
|
|
BrowserContextFactory.register_type("chromium-headful", _create_headful_chromium)
|
|
BrowserContextFactory.register_type("cdp-connect", _create_cdp_connection_browser)
|