diff --git a/agents/deep_research.py b/agents/deep_research.py index dd0ae3e..bfc5f2a 100644 --- a/agents/deep_research.py +++ b/agents/deep_research.py @@ -5,7 +5,7 @@ from pathlib import Path from connectonion import Agent from connectonion.useful_plugins import image_result_formatter -from tools.web_automation import web +from tools.browser import web from tools.file_tools import FileTools from tools.deep_research import perform_deep_research diff --git a/examples/demo_image_plugin.py b/examples/demo_image_plugin.py index 13efee8..6e1553e 100644 --- a/examples/demo_image_plugin.py +++ b/examples/demo_image_plugin.py @@ -14,10 +14,10 @@ from connectonion import Agent from connectonion.useful_plugins import image_result_formatter -from browser_agent.web_automation import WebAutomation +from browser_agent.browser import Browser # Create web automation instance -web = WebAutomation(use_chrome_profile=False) +web = Browser(use_chrome_profile=False) # Create agent with plugins agent = Agent( diff --git a/examples/demo_trace_inspection.py b/examples/demo_trace_inspection.py index f52a0da..1a11137 100644 --- a/examples/demo_trace_inspection.py +++ b/examples/demo_trace_inspection.py @@ -19,10 +19,10 @@ from connectonion import Agent, xray from connectonion.useful_plugins import image_result_formatter -from browser_agent.web_automation import WebAutomation +from browser_agent.browser import Browser # Create web automation instance -web = WebAutomation(use_chrome_profile=False) +web = Browser(use_chrome_profile=False) # Create agent with image_result_formatter plugin agent = Agent( diff --git a/examples/get_all_emails_and_analyze.py b/examples/get_all_emails_and_analyze.py index df72b3e..b5520ec 100644 --- a/examples/get_all_emails_and_analyze.py +++ b/examples/get_all_emails_and_analyze.py @@ -8,14 +8,14 @@ 4. Creates a comprehensive summary """ -from browser_agent.web_automation import WebAutomation +from browser_agent.browser import Browser import time import json def get_all_emails_and_analyze(): print("=== Getting ALL Gmail Emails and Analyzing ===\n") - web = WebAutomation(use_chrome_profile=True) + web = Browser(use_chrome_profile=True) web.open_browser(headless=False) web.go_to("https://gmail.com") time.sleep(3) diff --git a/test_deep_research_direct.py b/test_deep_research_direct.py new file mode 100644 index 0000000..7c527ff --- /dev/null +++ b/test_deep_research_direct.py @@ -0,0 +1,21 @@ +from agents.deep_research import DeepResearch +from tools.browser import Browser +import os + +def test_standalone_deep_research(): + print("Testing DeepResearch standalone...") + web = Browser(headless=True) + researcher = DeepResearch(web) + + # Simple topic + topic = "What is the capital of France and its population?" + result = researcher.perform_deep_research(topic) + + print("\nRESEARCH RESULT:") + print(result) + + web.close() + +if __name__ == "__main__": + test_standalone_deep_research() + diff --git a/tests/conftest.py b/tests/conftest.py index 2c6a841..a99988e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,13 +71,13 @@ def cleanup_asyncio(): @pytest.fixture(scope="function") def web(tmp_path): - """Create WebAutomation instance for each test using temp dir for screenshots and profile""" - from tools.web_automation import WebAutomation - + """Create Browser instance for each test using temp dir for screenshots and profile""" + from tools.browser import Browser + # Use a unique temporary directory for the Chrome profile to avoid locking issues in parallel tests profile_path = tmp_path / "chrome_profile" - - web_instance = WebAutomation(profile_path=str(profile_path)) + + web_instance = Browser(profile_path=str(profile_path)) # Redirect screenshots to temp directory web_instance.screenshots_dir = str(tmp_path / "screenshots") yield web_instance @@ -88,7 +88,7 @@ def web(tmp_path): @pytest.fixture(scope="function") def agent(web): - """Create Agent with WebAutomation tools""" + """Create Agent with Browser tools""" from connectonion import Agent agent_instance = Agent( name="test_agent", diff --git a/tests/test_auto_debug.py b/tests/test_auto_debug.py index 7a827c4..8b89a2f 100644 --- a/tests/test_auto_debug.py +++ b/tests/test_auto_debug.py @@ -10,7 +10,7 @@ # Ensure tools can be imported sys.path.insert(0, str(Path(__file__).parent.parent)) -from tools.web_automation import WebAutomation +from tools.browser import Browser @pytest.mark.manual @@ -18,7 +18,7 @@ def test_auto_debug_hacker_news(): """Test auto_debug feature with browser automation - requires user interaction.""" # Create the web automation instance - web = WebAutomation() + web = Browser() # Get correct path to prompt.md prompt_path = Path(__file__).parent.parent / "prompts" / "agent.md" diff --git a/tests/test_chromium_login.py b/tests/test_chromium_login.py index 8bef6a0..8217a36 100644 --- a/tests/test_chromium_login.py +++ b/tests/test_chromium_login.py @@ -9,7 +9,7 @@ # Ensure tools can be imported sys.path.insert(0, str(Path(__file__).parent.parent)) -from tools.web_automation import WebAutomation +from tools.browser import Browser @pytest.mark.manual @pytest.mark.chrome_profile @@ -21,7 +21,7 @@ def test_chromium_profile_persistence(): Step 2: Reopen browser -> Check if logged in automatically """ print("\n=== Step 1: Initial Login ===") - web1 = WebAutomation(headless=False) + web1 = Browser(headless=False) web1.open_browser() print("Navigating to Gmail...") @@ -38,7 +38,7 @@ def test_chromium_profile_persistence(): print("\n=== Step 2: Verification (New Session) ===") # Open a FRESH instance pointing to the SAME profile - web2 = WebAutomation(headless=False) + web2 = Browser(headless=False) web2.open_browser() print("Navigating to Gmail again (should be logged in)...") diff --git a/tests/test_direct.py b/tests/test_direct.py index 90bbff4..19c78df 100755 --- a/tests/test_direct.py +++ b/tests/test_direct.py @@ -6,7 +6,7 @@ import time from pathlib import Path import pytest -from tools.web_automation import WebAutomation +from tools.browser import Browser import os @pytest.mark.manual @@ -22,7 +22,7 @@ def test_google_search_direct(search_term): 2. Parametrized tests with Playwright can have async loop conflicts 3. It's more of an end-to-end test than a unit test """ - web = WebAutomation() + web = Browser() # Step 1: Open browser result = web.open_browser(headless=True) diff --git a/tests/test_final_scroll.py b/tests/test_final_scroll.py index 793844f..5e617fc 100644 --- a/tests/test_final_scroll.py +++ b/tests/test_final_scroll.py @@ -4,7 +4,7 @@ """ import time import pytest -from tools.web_automation import WebAutomation +from tools.browser import Browser @pytest.mark.manual @@ -18,7 +18,7 @@ def test_unified_scroll_gmail(): - AI strategy generation → fallback chain → screenshot comparison - Clean separation of concerns (automation vs scroll logic) """ - web = WebAutomation() + web = Browser() # Open visible browser for manual login web.open_browser(headless=False) @@ -50,10 +50,10 @@ def test_scroll_architecture_demo(): - One simple method: web.scroll() """ # Just verify the architecture is in place - web = WebAutomation() + web = Browser() # Verify scroll method exists - assert hasattr(web, 'scroll'), "WebAutomation should have scroll() method" + assert hasattr(web, 'scroll'), "Browser should have scroll() method" assert callable(web.scroll), "scroll should be callable" # Verify scroll_strategies module exists diff --git a/tests/test_image_plugin.py b/tests/test_image_plugin.py index 7e8ffad..3732e4b 100644 --- a/tests/test_image_plugin.py +++ b/tests/test_image_plugin.py @@ -5,7 +5,7 @@ import pytest from connectonion import Agent from connectonion.useful_plugins import image_result_formatter -from tools.web_automation import WebAutomation +from tools.browser import Browser from pathlib import Path diff --git a/tools/web_automation.py b/tools/browser.py similarity index 64% rename from tools/web_automation.py rename to tools/browser.py index c143d49..2b9a89c 100644 --- a/tools/web_automation.py +++ b/tools/browser.py @@ -13,10 +13,11 @@ from . import scroll_strategies from .element_finder import find_element +from .browser_config import CHROME_DEFAULT_ARGS, IGNORE_DEFAULT_ARGS -class WebAutomation: +class Browser: """Web browser automation with form handling capabilities. Simple interface for complex web interactions. @@ -55,12 +56,8 @@ def open_browser(self, headless: Union[bool, str, None] = None) -> str: self.context = self.playwright.chromium.launch_persistent_context( self.chrome_profile_path, headless=headless, - args=[ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-blink-features=AutomationControlled', - ], - ignore_default_args=['--enable-automation'], + args=CHROME_DEFAULT_ARGS, + ignore_default_args=IGNORE_DEFAULT_ARGS + ['--use-mock-keychain'], timeout=120000, ) @@ -104,16 +101,27 @@ def click(self, description: str) -> str: self.page.click(element.locator) return f"Clicked on '{description}'" - def type_text(self, field_description: str, text: str) -> str: - """Type text into a form field using natural language description.""" - element = find_element(self.page, field_description) - if not element: - self.page.fill(f"text={field_description}", text) - return f"Typed into {field_description} (by text)" + @xray + def keyboard_type(self, text: str) -> str: + """Type text using keyboard input (Playwright keyboard API wrapper). + + Simulates keyboard typing character by character into the currently focused element. + Works with any element type (input, textarea, contenteditable, etc). + + Args: + text: The text to type + + Returns: + Success message with system reminder to verify with screenshot + """ + if not self.page: + return "Browser not open" - self.page.fill(element.locator, text) - self.form_data[field_description] = text - return f"Typed into {field_description}" + self.page.keyboard.type(text) + + return f"""Typed: '{text}' + +SYSTEM REMINDER: Please use take_screenshot() to verify the text was typed into the correct input box. If the text is NOT visible in the expected location, you may need to click the element first to focus it, then type again.""" def get_text(self) -> str: @@ -121,8 +129,16 @@ def get_text(self) -> str: return self.page.inner_text("body") @xray - def take_screenshot(self, filename: str = None) -> str: - """Take a screenshot of the current page and return base64 encoded image.""" + def take_screenshot(self, filename: str = None, full_page: bool = False) -> str: + """Take a screenshot of the current page and return base64 encoded image. + + Args: + filename: Optional filename for the screenshot + full_page: If True, captures entire page height (may lose details but shows overview) + + Returns: + Base64 encoded image data with system reminder for full-page screenshots + """ from datetime import datetime os.makedirs(self.screenshots_dir, exist_ok=True) @@ -134,10 +150,20 @@ def take_screenshot(self, filename: str = None) -> str: if not "/" in filename: filename = f"{self.screenshots_dir}/{filename}" - screenshot_bytes = self.page.screenshot(path=filename) + screenshot_bytes = self.page.screenshot(path=filename, full_page=full_page) + + # Wait for page to stabilize after screenshot (especially for full_page which scrolls/resizes) + # This prevents focus loss when typing after taking a screenshot + self.page.wait_for_timeout(1000) + screenshot_base64 = base64.b64encode(screenshot_bytes).decode('utf-8') - return f"data:image/png;base64,{screenshot_base64}" + if full_page: + return f"""data:image/png;base64,{screenshot_base64} + +SYSTEM REMINDER: Full-page screenshots provide an overview of the entire page but may not show details clearly. Some elements might not be loaded yet (lazy loading). If you need to verify specific content or see details clearly, consider: scroll() to the target area first, then take a regular screenshot (full_page=False), or use scroll() multiple times and take screenshots at each position.""" + else: + return f"data:image/png;base64,{screenshot_base64}" @@ -240,8 +266,69 @@ def google_search(self, query: str, max_results: int = 10) -> List[Dict[str, str return results + def wait(self, seconds: float) -> str: + """Wait for a specified number of seconds.""" + if not self.page: + return "Browser not open" + self.page.wait_for_timeout(seconds * 1000) + return f"Waited for {seconds} seconds" + + def get_current_url(self) -> str: + """Get the current page URL.""" + if not self.page: + return "Browser not open" + return self.page.url + + def get_links_from_page(self, domain_filter: str = "") -> List[str]: + """Extract all links from the page, optionally filtered by domain.""" + if not self.page: + return [] + + all_links = self.page.locator("a[href]").all() + urls = [] + + for link in all_links: + href = link.get_attribute("href") + if href and href.startswith("http"): + if not domain_filter or domain_filter in href: + urls.append(href) + + return list(set(urls)) # Remove duplicates + + def set_viewport(self, width: int, height: int) -> str: + """Set the browser viewport size.""" + if not self.page: + return "Browser not open" + self.page.set_viewport_size({"width": width, "height": height}) + return f"Viewport set to {width}x{height}" + + def _save_context(self) -> None: + """Force save browser state to disk. + + Called after critical actions (login, navigation) to ensure + context is persisted even if process crashes. + + With launch_persistent_context(), Playwright automatically saves: + - Cookies + - localStorage + - sessionStorage + - IndexedDB + - Service workers + - Cache + + This method just waits briefly to ensure async saves complete. + """ + if not self.context or not self.page: + return + + # Wait for any async operations to complete + # Playwright's persistent context auto-saves in background + self.page.wait_for_timeout(500) + def close(self, keep_browser_open: bool = False) -> str: - """Close the browser.""" + """Close the browser and save persistent context.""" + self._save_context() + if keep_browser_open: return "Browser kept open" @@ -255,6 +342,15 @@ def close(self, keep_browser_open: bool = False) -> str: return "Browser closed" + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - ensures browser cleanup.""" + self.close() + return False + def analyze_page(self, question: str) -> str: """Ask a question about page content using AI.""" return llm_do( @@ -264,4 +360,4 @@ def analyze_page(self, question: str) -> str: ) # Default shared instance -web = WebAutomation(headless=True) \ No newline at end of file +web = Browser(headless=False) \ No newline at end of file diff --git a/tools/browser_config.py b/tools/browser_config.py new file mode 100644 index 0000000..cb499e5 --- /dev/null +++ b/tools/browser_config.py @@ -0,0 +1,109 @@ +""" +Browser configuration constants from browser-use. +Source: https://github.com/browser-use/browser-use/blob/main/browser_use/browser/profile.py + +This file contains only the Chrome arguments and disabled features needed for anti-detection. +Keep this in sync with browser-use for best compatibility. +""" + +# Chrome disabled components/features +# These are passed via --disable-features={list} +CHROME_DISABLED_COMPONENTS = [ + 'AcceptCHFrame', + 'AutoExpandDetailsElement', + 'AvoidUnnecessaryBeforeUnloadCheckSync', + 'CertificateTransparencyComponentUpdater', + 'DestroyProfileOnBrowserClose', + 'DialMediaRouteProvider', + 'ExtensionManifestV2Disabled', + 'GlobalMediaControls', + 'HttpsUpgrades', + 'ImprovedCookieControls', + 'LazyFrameLoading', + 'LensOverlay', + 'MediaRouter', + 'PaintHolding', + 'ThirdPartyStoragePartitioning', + 'Translate', + # Added by browser-use: + 'AutomationControlled', # KEY: Removes navigator.webdriver + 'BackForwardCache', + 'OptimizationHints', + 'ProcessPerSiteUpToMainFrameThreshold', + 'InterestFeedContentSuggestions', + 'CalculateNativeWinOcclusion', + 'HeavyAdPrivacyMitigations', + 'PrivacySandboxSettings4', + 'AutofillServerCommunication', + 'CrashReporting', + 'OverscrollHistoryNavigation', + 'InfiniteSessionRestore', + 'ExtensionDisableUnsupportedDeveloper', + 'ExtensionManifestV2Unsupported', +] + +# Chrome default arguments (53 args) +CHROME_DEFAULT_ARGS = [ + '--disable-field-trial-config', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-back-forward-cache', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--no-default-browser-check', + '--disable-dev-shm-usage', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--metrics-recording-only', + '--no-first-run', + '--no-service-autorun', + '--export-tagged-pdf', + '--disable-search-engine-choice-screen', + '--unsafely-disable-devtools-self-xss-warnings', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--enable-network-information-downlink-max', + '--test-type=gpu', + '--disable-sync', + '--allow-legacy-extension-manifests', + '--allow-pre-commit-input', + '--disable-blink-features=AutomationControlled', # KEY: anti-detection + '--install-autogenerated-theme=0,0,0', + '--log-level=2', + '--disable-focus-on-load', + '--disable-window-activation', + '--generate-pdf-document-outline', + '--no-pings', + '--ash-no-nudges', + '--disable-infobars', + '--simulate-outdated-no-au="Tue, 31 Dec 2099 23:59:59 GMT"', + '--hide-crash-restore-bubble', + '--suppress-message-center-popups', + '--disable-domain-reliability', + '--disable-datasaver-prompt', + '--disable-speech-synthesis-api', + '--disable-speech-api', + '--disable-print-preview', + '--safebrowsing-disable-auto-update', + '--disable-external-intent-requests', + '--disable-desktop-notifications', + '--noerrdialogs', + '--silent-debugger-extension-api', + '--disable-extensions-http-throttling', + '--extensions-on-chrome-urls', + '--disable-default-apps', + f'--disable-features={",".join(CHROME_DISABLED_COMPONENTS)}', +] + +# Arguments to ignore from Playwright defaults +# These are automation markers we DON'T want +IGNORE_DEFAULT_ARGS = [ + '--enable-automation', # DON'T add this flag (anti-detection) + '--disable-extensions', # Allow extensions (looks more human) + '--hide-scrollbars', # Show scrollbars (more human-like) +]