diff --git a/examples/boilerplates/samples/google_objects.py b/examples/boilerplates/samples/google_objects.py index ea69693e7e6..792d77a495f 100644 --- a/examples/boilerplates/samples/google_objects.py +++ b/examples/boilerplates/samples/google_objects.py @@ -5,7 +5,6 @@ class HomePage(object): dialog_box = '[role="dialog"] div' search_box = '[title="Search"]' search_button = 'input[value="Google Search"]' - feeling_lucky_button = """input[value="I'm Feeling Lucky"]""" class ResultsPage(object): diff --git a/examples/boilerplates/samples/google_test.py b/examples/boilerplates/samples/google_test.py index 06c569433cd..3435348cd68 100644 --- a/examples/boilerplates/samples/google_test.py +++ b/examples/boilerplates/samples/google_test.py @@ -22,7 +22,6 @@ def test_google_dot_com(self): self.save_screenshot_to_logs() # ("./latest_logs" folder) self.type(HomePage.search_box, "GitHub") self.assert_element(HomePage.search_button) - self.assert_element(HomePage.feeling_lucky_button) self.click(HomePage.search_button) self.sleep(1) self.assert_text("github.com", ResultsPage.search_results) diff --git a/examples/cdp_mode/ReadMe.md b/examples/cdp_mode/ReadMe.md index d97b008e340..06afe162227 100644 --- a/examples/cdp_mode/ReadMe.md +++ b/examples/cdp_mode/ReadMe.md @@ -430,6 +430,7 @@ sb.remove_elements(selector) sb.send_keys(selector, text, timeout=None) sb.press_keys(selector, text, timeout=None) sb.type(selector, text, timeout=None) +sb.fast_type(selector, text, timeout=None) sb.set_value(selector, text, timeout=None) sb.clear_input(selector, timeout=None) sb.clear(selector, timeout=None) @@ -526,6 +527,7 @@ sb.is_text_visible(text, selector="body") sb.is_exact_text_visible(text, selector="body") sb.wait_for_text(text, selector="body", timeout=None) sb.wait_for_text_not_visible(text, selector="body", timeout=None) +sb.wait_for_element_present(selector, timeout=None) sb.wait_for_element_visible(selector, timeout=None) sb.wait_for_element(selector, timeout=None) sb.wait_for_element_not_visible(selector, timeout=None) diff --git a/examples/cdp_mode/raw_basic_cdp.py b/examples/cdp_mode/raw_basic_cdp.py index 2ca49fa6dbb..7dddadee7ff 100644 --- a/examples/cdp_mode/raw_basic_cdp.py +++ b/examples/cdp_mode/raw_basic_cdp.py @@ -15,8 +15,8 @@ sb.save_as_pdf_to_logs() sb.save_screenshot_to_logs() sb.save_page_source_to_logs() -sb.save_data_to_logs("hello!") -sb.append_data_to_logs("more data!") +sb.save_data_to_logs("Extra data for logs") +sb.append_data_to_logs("Add data for logs") sb.click_link("Sign out") sb.assert_text("signed out", "#top_message") sb.quit() diff --git a/examples/cdp_mode/raw_cdp_pixelscan.py b/examples/cdp_mode/raw_cdp_pixelscan.py index 3d9a683ac79..12b5f96753c 100644 --- a/examples/cdp_mode/raw_cdp_pixelscan.py +++ b/examples/cdp_mode/raw_cdp_pixelscan.py @@ -2,7 +2,7 @@ sb = sb_cdp.Chrome(guest=True, ad_block=True) sb.goto("https://pixelscan.net/fingerprint-check") -sb.remove_element("div.header-ad") +sb.remove_element("div.header-promo") sb.remove_element("pxlscn-dynamic-ad") sb.sleep(1.8) sb.assert_text("No automated behavior", "pxlscn-bot-detection") diff --git a/examples/cdp_mode/raw_pixelscan.py b/examples/cdp_mode/raw_pixelscan.py index a1f9422f1af..a30df30ac20 100644 --- a/examples/cdp_mode/raw_pixelscan.py +++ b/examples/cdp_mode/raw_pixelscan.py @@ -3,7 +3,7 @@ with SB(uc=True, test=True, guest=True) as sb: sb.activate_cdp_mode(ad_block=True) sb.goto("https://pixelscan.net/fingerprint-check") - sb.remove_element("div.header-ad") + sb.remove_element("div.header-promo") sb.remove_element("pxlscn-dynamic-ad") sb.sleep(1.8) sb.assert_text("No automated behavior", "pxlscn-bot-detection") diff --git a/examples/test_shadow_dom.py b/examples/test_shadow_dom.py index 8e5e0a91f4c..3cf52aeeaa1 100644 --- a/examples/test_shadow_dom.py +++ b/examples/test_shadow_dom.py @@ -10,7 +10,7 @@ class ShadowDomTests(BaseCase): def download_tar_file_from_pypi(self, package): self.goto("https://pypi.org/project/%s/#files" % package) - pkg_header = self.get_text("h1.package-header__name").strip() + pkg_header = self.get_text('h1[class*="header__name"]').strip() pkg_name = pkg_header.replace(" ", "-") tar_file = pkg_name + ".tar.gz" tar_selector = 'div#files a[href$="%s"]' % tar_file diff --git a/help_docs/cdp_mode_methods.md b/help_docs/cdp_mode_methods.md index 55fa1d7fc86..1e28e3edbf7 100644 --- a/help_docs/cdp_mode_methods.md +++ b/help_docs/cdp_mode_methods.md @@ -76,6 +76,7 @@ sb.remove_elements(selector) sb.send_keys(selector, text, timeout=None) sb.press_keys(selector, text, timeout=None) sb.type(selector, text, timeout=None) +sb.fast_type(selector, text, timeout=None) sb.set_value(selector, text, timeout=None) sb.clear_input(selector, timeout=None) sb.clear(selector, timeout=None) @@ -172,6 +173,7 @@ sb.is_text_visible(text, selector="body") sb.is_exact_text_visible(text, selector="body") sb.wait_for_text(text, selector="body", timeout=None) sb.wait_for_text_not_visible(text, selector="body", timeout=None) +sb.wait_for_element_present(selector, timeout=None) sb.wait_for_element_visible(selector, timeout=None) sb.wait_for_element(selector, timeout=None) sb.wait_for_element_not_visible(selector, timeout=None) diff --git a/help_docs/method_summary.md b/help_docs/method_summary.md index cd9d4dc382e..ea9bdff0ba9 100644 --- a/help_docs/method_summary.md +++ b/help_docs/method_summary.md @@ -33,6 +33,7 @@ self.send_keys(selector, text, by="css selector", timeout=None) # Duplicates: # self.add_text(selector, text, by="css selector", timeout=None) self.press_keys(selector, text, by="css selector", timeout=None) +self.fast_type(selector, text, by="css selector", timeout=None) self.submit(selector, by="css selector") self.clear(selector, by="css selector", timeout=None) self.focus(selector, by="css selector", timeout=None) @@ -725,6 +726,9 @@ driver.find_element(selector) driver.find_elements(selector) driver.select(selector) driver.select_all(selector) +driver.select_option_by_text(dropdown_selector, option) +driver.select_option_by_index(dropdown_selector, option) +driver.select_option_by_value(dropdown_selector, option) driver.wait_for_element(selector) driver.wait_for_element_visible(selector) driver.wait_for_element_present(selector) diff --git a/mcp_servers/README.md b/mcp_servers/README.md index e2a384a3ffb..5eebf8baccc 100644 --- a/mcp_servers/README.md +++ b/mcp_servers/README.md @@ -129,7 +129,7 @@ claude mcp add seleniumbase-mcp -- uv run seleniumbase-mcp | Navigation | `navigate`, `reload_page`, `go_back`/`go_forward`, `get_current_url`, `get_title` | | Finding & reading | `find_element_info`, `find_all_info`, `get_text`, `get_html_source`, `get_element_attribute(s)`, `is_element_present/visible` | | Interacting | `click`, `click_if_visible`, `click_visible_elements`, `type_text`, `send_keys`, `set_value`, `select_option_by_text/value/index`, `nested_click` | -| Waiting | `wait_for_element`, `wait_for_element_visible/not_visible/absent`, `wait_for_text` | +| Waiting | `wait_for_element_present`, `wait_for_element_visible/not_visible/absent`, `wait_for_text` | | Assertions | `assert_element`, `assert_text`, `assert_exact_text`, `assert_title`, `assert_url(_contains)` | | Cookies & storage | `get_all_cookies`, `save_cookies`/`load_cookies`, `get/set_local_storage_item`, `get/set_session_storage_item` | | Scrolling | `scroll_into_view`, `scroll_to_top/bottom`, `scroll_up/down` | diff --git a/mcp_servers/pyproject.toml b/mcp_servers/pyproject.toml index f87c84079e4..f27c61f85bb 100644 --- a/mcp_servers/pyproject.toml +++ b/mcp_servers/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "seleniumbase-mcp" -version = "1.0.0dev0" +version = "1.1.0dev0" description = "MCP server exposing SeleniumBase CDP Mode as tools for MCP clients." readme = "README.md" requires-python = ">=3.10" license = "MIT" dependencies = [ - "mcp[cli]>=2.0.0,<3.0.0", + "mcp[cli]>=2.1.1,<3.0.0", "seleniumbase", ] diff --git a/mcp_servers/requirements.txt b/mcp_servers/requirements.txt index c3ef083d1aa..4f234f745c9 100644 --- a/mcp_servers/requirements.txt +++ b/mcp_servers/requirements.txt @@ -1,4 +1,4 @@ -mcp[cli]>=2.0.0,<3.0.0 +mcp[cli]>=2.1.1,<3.0.0 -e .. # `-e ..` installs SeleniumBase itself from the repo root (one directory diff --git a/mcp_servers/server.py b/mcp_servers/server.py index ad9df79c031..dc2e9901949 100644 --- a/mcp_servers/server.py +++ b/mcp_servers/server.py @@ -10,8 +10,8 @@ Reference: github.com/seleniumbase/SeleniumBase/blob/master/help_docs/cdp_mode_methods.md -Model: one persistent `sb_cdp.Chrome` session per server process. Call -start_browser once, drive it with the other tools, then close_browser. +Model: One persistent `sb_cdp.Chrome` session per server process. +Call start_browser once; drive it with the other tools; then close_browser. Note on elements: CDP-mode element objects (from find_element/find_all) are live handles with their own methods (.click(), .get_html(), ...) that can't @@ -23,6 +23,7 @@ from __future__ import annotations import atexit import sys +from functools import wraps from typing import Any from mcp.server import MCPServer from seleniumbase import sb_cdp @@ -38,6 +39,20 @@ def _get_sb() -> sb_cdp.CDPMethods: return _sb +def handle_sb_errors(func): + """Catches SeleniumBase errors and surfaces them as descriptive strings + so the LLM agent can read them and self-correct.""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + error_type = e.__class__.__name__ + error_msg = str(e).strip() + return f"Error in {func.__name__}: {error_type} - {error_msg}" + return wrapper + + # --------------------------------------------------------------------------- # Session lifecycle # --------------------------------------------------------------------------- @@ -54,7 +69,6 @@ def start_browser( """Launch a Pure CDP Mode browser session. Must be called before any other tool. The browser is driven entirely over CDP (no WebDriver), which is SeleniumBase's most stealth/bot-detection-resistant mode. - Args: url: Optional URL to open immediately on launch. headless: Run without a visible window. @@ -77,8 +91,17 @@ def start_browser( kwargs["proxy"] = proxy if ad_block: kwargs["ad_block"] = True - _sb = sb_cdp.Chrome(url, **kwargs) - return f"Started Pure CDP Mode browser (url={url!r}, headless={headless})" + try: + _sb = sb_cdp.Chrome(url, **kwargs) + return ( + f"Started Pure CDP Mode browser " + f"(url={url!r}, headless={headless})" + ) + except Exception as e: + return ( + f"Error starting browser: " + f"{e.__class__.__name__} - {str(e).strip()}" + ) @mcp.tool() @@ -87,7 +110,10 @@ def close_browser() -> str: global _sb if _sb is None: return "No browser session was running." - _sb.quit() + try: + _sb.quit() + except Exception: + pass _sb = None return "Browser closed." @@ -97,52 +123,70 @@ def close_browser() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def navigate(url: str) -> str: - """Navigate to a URL.""" + """Navigate to the given URL in the web browser. + If the URL doesn't start with a protocol (eg: `https://`), + then `https://` is automatically prefixed in before navigation. + Waits until the initial HTML document is fully parsed and loaded. + New pages visited will show up in browser navigation history. + If the URL is invalid or the page can't load due to an issue, + then the corresponding errors will be raised.""" _get_sb().get(url) return f"Navigated to {url}" @mcp.tool() +@handle_sb_errors def reload_page(ignore_cache: bool = True) -> str: - """Reload the current page.""" + """Reload the current page. + Same as clicking the Reload button in the web browser. + By default, ignores the browser cache on reload.""" _get_sb().reload(ignore_cache=ignore_cache) return "Page reloaded." @mcp.tool() +@handle_sb_errors def go_back() -> str: - """Go back one page in browser history.""" + """Go back one page in browser history. + Same as clicking the Back button in the web browser.""" _get_sb().go_back() return "Navigated back." @mcp.tool() +@handle_sb_errors def go_forward() -> str: - """Go forward one page in browser history.""" + """Go forward one page in browser history. + Same as clicking the Forward button in the web browser.""" _get_sb().go_forward() return "Navigated forward." @mcp.tool() +@handle_sb_errors def get_navigation_history() -> Any: """Get the browser's navigation history.""" return _get_sb().get_navigation_history() @mcp.tool() +@handle_sb_errors def get_current_url() -> str: """Get the URL of the current page.""" return _get_sb().get_current_url() @mcp.tool() +@handle_sb_errors def get_title() -> str: """Get the title of the current page.""" return _get_sb().get_title() @mcp.tool() +@handle_sb_errors def get_origin() -> str: """Get the origin (scheme + host) of the current page.""" return _get_sb().get_origin() @@ -153,18 +197,17 @@ def get_origin() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def find_element_info( selector: str, best_match: bool = False, timeout: int | None = None -) -> dict: +) -> dict | str: """Find one element and return its tag name, text, and outer HTML. - Args: selector: CSS selector, or text to search for (CDP mode can match elements by visible text as well as by selector). best_match: When matching by text and multiple elements qualify, pick the one whose text length is closest to the search text. - timeout: Seconds to wait for the element to appear. - """ + timeout: Seconds to wait for the element to appear.""" el = _get_sb().find_element( selector, best_match=best_match, timeout=timeout ) @@ -172,68 +215,85 @@ def find_element_info( @mcp.tool() -def find_all_info(selector: str, timeout: int | None = None) -> list[dict]: +@handle_sb_errors +def find_all_info( + selector: str, timeout: int | None = None +) -> list[dict] | str: """Find all matching elements and return tag name + text for each.""" els = _get_sb().find_all(selector, timeout=timeout) return [{"tag_name": e.tag_name, "text": e.text} for e in els] @mcp.tool() +@handle_sb_errors def get_text(selector: str = "body") -> str: - """Get the visible text within an element (default: whole page body).""" + """Get the visible text within an element (default: whole page body). + Raises an exception if the element isn't found within the default timeout. + """ return _get_sb().get_text(selector) @mcp.tool() +@handle_sb_errors def get_html_source(include_shadow_dom: bool = True) -> str: """Get the full HTML source of the current page.""" return _get_sb().get_page_source(include_shadow_dom=include_shadow_dom) @mcp.tool() +@handle_sb_errors def get_element_html(selector: str) -> str: """Get the outer HTML of a specific element.""" return _get_sb().get_element_html(selector) @mcp.tool() +@handle_sb_errors def get_element_attribute(selector: str, attribute: str) -> Any: """Get one attribute's value from an element.""" return _get_sb().get_element_attribute(selector, attribute) @mcp.tool() -def get_element_attributes(selector: str) -> dict: +@handle_sb_errors +def get_element_attributes(selector: str) -> dict | str: """Get all attributes of an element as a dict.""" return _get_sb().get_element_attributes(selector) @mcp.tool() -def find_elements_count(selector: str, timeout: int | None = None) -> int: - """Count how many elements on the page match a selector.""" +@handle_sb_errors +def find_elements_count( + selector: str, timeout: int | None = None +) -> int | str: + """Get the count of how many elements on the page match the selector.""" return len(_get_sb().find_elements(selector, timeout=timeout)) @mcp.tool() -def is_element_present(selector: str) -> bool: - """Check whether an element matching a selector exists in the DOM.""" +@handle_sb_errors +def is_element_present(selector: str) -> bool | str: + """Return whether an element matching the selector exists in the DOM.""" return _get_sb().is_element_present(selector) @mcp.tool() -def is_element_visible(selector: str) -> bool: - """Check whether an element matching a selector is visible.""" +@handle_sb_errors +def is_element_visible(selector: str) -> bool | str: + """Return whether an element matching the selector is visible.""" return _get_sb().is_element_visible(selector) @mcp.tool() -def is_text_visible(text: str, selector: str = "body") -> bool: - """Check whether specific text is visible within an element.""" +@handle_sb_errors +def is_text_visible(text: str, selector: str = "body") -> bool | str: + """Return whether the specific text is visible within an element.""" return _get_sb().is_text_visible(text, selector) @mcp.tool() -def get_all_urls(absolute: bool = True) -> list[str]: +@handle_sb_errors +def get_all_urls(absolute: bool = True) -> list[str] | str: """Get all linked URLs (a, link, img, script, meta) on the page.""" return _get_sb().get_all_urls(absolute=absolute) @@ -243,23 +303,30 @@ def get_all_urls(absolute: bool = True) -> list[str]: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def click( selector: str, timeout: int | None = None, scroll: bool = True ) -> str: """Click an element matched by a CSS selector (or by text, e.g. - 'a:contains("Sign in")').""" + 'a:contains("Sign in")'). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().click(selector, timeout=timeout, scroll=scroll) return f"Clicked {selector}" @mcp.tool() +@handle_sb_errors def click_if_visible(selector: str, timeout: int = 0) -> str: - """Click an element only if it's currently visible; no-op otherwise.""" + """Click an element only if it's currently visible; no-op otherwise. + If a `timeout` is given, then waits up to that long for the element + to appear first before performing the click.""" _get_sb().click_if_visible(selector, timeout=timeout) return f"click_if_visible ran for {selector}" @mcp.tool() +@handle_sb_errors def click_visible_elements(selector: str, limit: int = 0) -> str: """Click every currently-visible element matching a selector, in order (e.g. checking every checkbox on a page). limit=0 means no limit.""" @@ -268,6 +335,7 @@ def click_visible_elements(selector: str, limit: int = 0) -> str: @mcp.tool() +@handle_sb_errors def click_nth_element(selector: str, number: int) -> str: """Click the Nth element (1-indexed) matching a selector.""" _get_sb().click_nth_element(selector, number) @@ -275,6 +343,7 @@ def click_nth_element(selector: str, number: int) -> str: @mcp.tool() +@handle_sb_errors def click_link(link_text: str) -> str: """Click a link ( tag) by its visible text.""" _get_sb().click_link(link_text) @@ -282,34 +351,47 @@ def click_link(link_text: str) -> str: @mcp.tool() +@handle_sb_errors def type_text(selector: str, text: str, timeout: int | None = None) -> str: - """Clear a field and type text into it.""" + """Clear a field and type text into it. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().type(selector, text, timeout=timeout) return f"Typed into {selector}" @mcp.tool() +@handle_sb_errors def send_keys(selector: str, text: str, timeout: int | None = None) -> str: - """Send keystrokes to an element without clearing it first.""" + """Send keystrokes to an element without clearing it first. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().send_keys(selector, text, timeout=timeout) return f"Sent keys to {selector}" @mcp.tool() +@handle_sb_errors def set_value(selector: str, text: str, timeout: int | None = None) -> str: - """Set an input's value directly (e.g. for sliders, fast form fills).""" + """Set an input's value directly (e.g. for sliders, fast form fills). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().set_value(selector, text, timeout=timeout) return f"Set value of {selector}" @mcp.tool() +@handle_sb_errors def clear_input(selector: str, timeout: int | None = None) -> str: - """Clear an input field.""" + """Clear an input field. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout. """ _get_sb().clear_input(selector, timeout=timeout) return f"Cleared {selector}" @mcp.tool() +@handle_sb_errors def submit(selector: str) -> str: """Submit a form via a selector inside it.""" _get_sb().submit(selector) @@ -317,45 +399,63 @@ def submit(selector: str) -> str: @mcp.tool() -def select_option_by_text(dropdown_selector: str, option_text: str) -> str: - """Select a dropdown option by its visible text. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_text(dropdown_selector, option) + return f"Selected text '{option}' in {dropdown_selector}" @mcp.tool() -def select_option_by_value(dropdown_selector: str, value: str) -> str: - """Select a dropdown option by its value attribute. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_value(dropdown_selector, option) + return f"Selected value '{option}' in {dropdown_selector}" @mcp.tool() -def select_option_by_index(dropdown_selector: str, index: int) -> str: - """Select a dropdown option by its 0-based index. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_index(dropdown_selector, option) + return f"Selected index {option} in {dropdown_selector}" @mcp.tool() +@handle_sb_errors def focus(selector: str) -> str: - """Move focus to an element.""" + """Move focus to an element. + Raises an exception if the element isn't found within the default timeout. + """ el = _get_sb().find_element(selector) el.focus() return f"Focused {selector}" @mcp.tool() +@handle_sb_errors def highlight(selector: str) -> str: """Briefly highlight an element (useful when narrating actions on - a visible/headed browser).""" + a visible/headed browser). + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().highlight(selector) return f"Highlighted {selector}" @mcp.tool() +@handle_sb_errors def nested_click(parent_selector: str, selector: str) -> str: - """Click an element nested inside another (e.g. inside an iframe).""" + """Click an element nested inside another (e.g. inside an iframe). + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().nested_click(parent_selector, selector) return f"Clicked {selector} inside {parent_selector}" @@ -365,40 +465,55 @@ def nested_click(parent_selector: str, selector: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() -def wait_for_element(selector: str, timeout: int | None = None) -> str: - """Wait until an element is present in the DOM.""" - _get_sb().wait_for_element(selector, timeout=timeout) +@handle_sb_errors +def wait_for_element_present(selector: str, timeout: int | None = None) -> str: + """Wait until the element is present in the DOM. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" + _get_sb().wait_for_element_present(selector, timeout=timeout) return f"Element {selector} is present." @mcp.tool() +@handle_sb_errors def wait_for_element_visible(selector: str, timeout: int | None = None) -> str: - """Wait until an element is visible.""" + """Wait until the element is visible on the page. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't visible within the timeout.""" _get_sb().wait_for_element_visible(selector, timeout=timeout) return f"Element {selector} is visible." @mcp.tool() +@handle_sb_errors def wait_for_element_not_visible( selector: str, timeout: int | None = None ) -> str: - """Wait until an element is no longer visible.""" + """Wait until an element is no longer visible on the page. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element is still visible after the timeout.""" _get_sb().wait_for_element_not_visible(selector, timeout=timeout) return f"Element {selector} is no longer visible." @mcp.tool() +@handle_sb_errors def wait_for_element_absent(selector: str, timeout: int | None = None) -> str: - """Wait until an element is removed from the DOM.""" + """Wait until an element is removed from the DOM. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element is still present after the timeout.""" _get_sb().wait_for_element_absent(selector, timeout=timeout) return f"Element {selector} is now absent." @mcp.tool() +@handle_sb_errors def wait_for_text( text: str, selector: str = "body", timeout: int | None = None ) -> str: - """Wait until specific text appears within an element.""" + """Wait until the text substring appears within an element. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't visible within the timeout.""" _get_sb().wait_for_text(text, selector, timeout=timeout) return f"Text '{text}' appeared in {selector}." @@ -408,54 +523,82 @@ def wait_for_text( # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def assert_element(selector: str, timeout: int | None = None) -> str: - """Assert an element is present in the DOM.""" + """Assert that an element is present in the DOM. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().assert_element(selector, timeout=timeout) return f"Confirmed {selector} is present." @mcp.tool() +@handle_sb_errors def assert_element_visible(selector: str, timeout: int | None = None) -> str: - """Assert an element is visible.""" + """Assert that an element is visible on the page. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().assert_element_visible(selector, timeout=timeout) return f"Confirmed {selector} is visible." @mcp.tool() +@handle_sb_errors def assert_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: - """Assert text is present within an element.""" + """Assert that the text substring appears within the given element + (with the matching selector) in the given timeout (seconds), + with leading and trailing whitespace automatically ignored. + If no `selector` given, then it defaults to "html" (CSS selector). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found or assertion fails.""" _get_sb().assert_text(text, selector, timeout=timeout) return f"Confirmed '{text}' is present in {selector}." @mcp.tool() +@handle_sb_errors def assert_exact_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: - """Assert an element's text matches exactly.""" + """Assert that the text matches the element's text exactly + (with leading/trailing whitespace automatically ignored) + in the given timeout (seconds). + If no `selector` given, then it defaults to "html" (CSS selector). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found or assertion fails.""" _get_sb().assert_exact_text(text, selector, timeout=timeout) return f"Confirmed {selector} text is exactly '{text}'." @mcp.tool() +@handle_sb_errors def assert_title(title: str) -> str: - """Assert the page title matches exactly.""" + """Assert that the title matches the page title exactly, + with leading and trailing whitespace ignored. + Raises an exception if the expected title doesn't + match the actual title within 7 seconds.""" _get_sb().assert_title(title) return f"Confirmed title is '{title}'." @mcp.tool() +@handle_sb_errors def assert_url(url: str) -> str: - """Assert the current URL matches exactly.""" + """Assert that the url matches the current URL exactly. + Raises an exception if the expected url doesn't + match the actual url within 7 seconds.""" _get_sb().assert_url(url) return f"Confirmed URL is '{url}'." @mcp.tool() +@handle_sb_errors def assert_url_contains(substring: str) -> str: - """Assert the current URL contains a substring.""" + """Assert that the current URL contains the given substring. + Raises an exception if the expected substring isn't + found in the actual url within 7 seconds.""" _get_sb().assert_url_contains(substring) return f"Confirmed URL contains '{substring}'." @@ -465,12 +608,14 @@ def assert_url_contains(substring: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def get_all_cookies() -> Any: """Get all cookies for the current session.""" return _get_sb().get_all_cookies() @mcp.tool() +@handle_sb_errors def clear_cookies() -> str: """Clear all cookies.""" _get_sb().clear_cookies() @@ -478,6 +623,7 @@ def clear_cookies() -> str: @mcp.tool() +@handle_sb_errors def save_cookies(name: str = "cookies.txt") -> str: """Save current cookies to a file.""" _get_sb().save_cookies(name=name) @@ -485,6 +631,7 @@ def save_cookies(name: str = "cookies.txt") -> str: @mcp.tool() +@handle_sb_errors def load_cookies(name: str = "cookies.txt") -> str: """Load cookies from a previously saved file.""" _get_sb().load_cookies(name=name) @@ -492,12 +639,14 @@ def load_cookies(name: str = "cookies.txt") -> str: @mcp.tool() +@handle_sb_errors def get_local_storage_item(key: str) -> Any: """Get a value from the page's localStorage.""" return _get_sb().get_local_storage_item(key) @mcp.tool() +@handle_sb_errors def set_local_storage_item(key: str, value: str) -> str: """Set a value in the page's localStorage.""" _get_sb().set_local_storage_item(key, value) @@ -505,12 +654,14 @@ def set_local_storage_item(key: str, value: str) -> str: @mcp.tool() +@handle_sb_errors def get_session_storage_item(key: str) -> Any: """Get a value from the page's sessionStorage.""" return _get_sb().get_session_storage_item(key) @mcp.tool() +@handle_sb_errors def set_session_storage_item(key: str, value: str) -> str: """Set a value in the page's sessionStorage.""" _get_sb().set_session_storage_item(key, value) @@ -522,6 +673,7 @@ def set_session_storage_item(key: str, value: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def scroll_into_view(selector: str) -> str: """Scroll an element into view.""" _get_sb().scroll_into_view(selector) @@ -529,6 +681,7 @@ def scroll_into_view(selector: str) -> str: @mcp.tool() +@handle_sb_errors def scroll_to_top() -> str: """Scroll to the top of the page.""" _get_sb().scroll_to_top() @@ -536,6 +689,7 @@ def scroll_to_top() -> str: @mcp.tool() +@handle_sb_errors def scroll_to_bottom() -> str: """Scroll to the bottom of the page.""" _get_sb().scroll_to_bottom() @@ -543,6 +697,7 @@ def scroll_to_bottom() -> str: @mcp.tool() +@handle_sb_errors def scroll_up(amount: int = 25) -> str: """Scroll up by a relative amount.""" _get_sb().scroll_up(amount=amount) @@ -550,6 +705,7 @@ def scroll_up(amount: int = 25) -> str: @mcp.tool() +@handle_sb_errors def scroll_down(amount: int = 25) -> str: """Scroll down by a relative amount.""" _get_sb().scroll_down(amount=amount) @@ -561,12 +717,14 @@ def scroll_down(amount: int = 25) -> str: # --------------------------------------------------------------------------- @mcp.tool() -def get_window_rect() -> dict: +@handle_sb_errors +def get_window_rect() -> dict | str: """Get the current window's position and size.""" return _get_sb().get_window_rect() @mcp.tool() +@handle_sb_errors def set_window_rect(x: int, y: int, width: int, height: int) -> str: """Set the current window's position and size.""" _get_sb().set_window_rect(x, y, width, height) @@ -574,6 +732,7 @@ def set_window_rect(x: int, y: int, width: int, height: int) -> str: @mcp.tool() +@handle_sb_errors def maximize() -> str: """Maximize the browser window.""" _get_sb().maximize() @@ -581,6 +740,7 @@ def maximize() -> str: @mcp.tool() +@handle_sb_errors def minimize() -> str: """Minimize the browser window.""" _get_sb().minimize() @@ -588,6 +748,7 @@ def minimize() -> str: @mcp.tool() +@handle_sb_errors def open_new_tab(url: str | None = None, switch_to: bool = True) -> str: """Open a new browser tab, optionally navigating and switching to it.""" _get_sb().open_new_tab(url=url, switch_to=switch_to) @@ -595,6 +756,7 @@ def open_new_tab(url: str | None = None, switch_to: bool = True) -> str: @mcp.tool() +@handle_sb_errors def switch_to_tab(tab_index: int) -> str: """Switch to a tab by its index (as returned by get_tabs).""" tabs = _get_sb().get_tabs() @@ -603,6 +765,7 @@ def switch_to_tab(tab_index: int) -> str: @mcp.tool() +@handle_sb_errors def switch_to_newest_tab() -> str: """Switch to the most recently opened tab.""" _get_sb().switch_to_newest_tab() @@ -610,6 +773,7 @@ def switch_to_newest_tab() -> str: @mcp.tool() +@handle_sb_errors def close_active_tab() -> str: """Close the currently active tab.""" _get_sb().close_active_tab() @@ -617,7 +781,8 @@ def close_active_tab() -> str: @mcp.tool() -def get_tabs_count() -> int: +@handle_sb_errors +def get_tabs_count() -> int | str: """Get how many tabs are currently open.""" return len(_get_sb().get_tabs()) @@ -627,6 +792,7 @@ def get_tabs_count() -> int: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def solve_captcha() -> str: """Attempt to solve a captcha (e.g. Cloudflare Turnstile) on the page.""" _get_sb().solve_captcha() @@ -638,6 +804,7 @@ def solve_captcha() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def save_screenshot( name: str = "screenshot.png", folder: str | None = None ) -> str: @@ -647,6 +814,7 @@ def save_screenshot( @mcp.tool() +@handle_sb_errors def save_page_source( name: str = "page_source.html", folder: str | None = None ) -> str: @@ -656,6 +824,7 @@ def save_page_source( @mcp.tool() +@handle_sb_errors def save_as_pdf(name: str = "page.pdf", folder: str | None = None) -> str: """Print the current page to a PDF file.""" _get_sb().save_as_pdf(name, folder=folder) @@ -663,13 +832,17 @@ def save_as_pdf(name: str = "page.pdf", folder: str | None = None) -> str: @mcp.tool() +@handle_sb_errors def evaluate(expression: str) -> Any: """Evaluate a JavaScript expression in the page context and return the - result. Equivalent to execute_script.""" + result. Equivalent to execute_script. This method can run any arbitrary + JavaScript on any site, so take any necessary precautions to prevent + AI harnesses from running scripts that you don't want them to run.""" return _get_sb().evaluate(expression) @mcp.tool() +@handle_sb_errors def sleep(seconds: float) -> str: """Pause execution for a number of seconds.""" _get_sb().sleep(seconds) @@ -677,6 +850,7 @@ def sleep(seconds: float) -> str: @mcp.tool() +@handle_sb_errors def get_user_agent() -> str: """Get the browser's current user agent string.""" return _get_sb().get_user_agent() diff --git a/mkdocs_build/requirements.txt b/mkdocs_build/requirements.txt index 861d06f2fb2..24cbb60d9e8 100644 --- a/mkdocs_build/requirements.txt +++ b/mkdocs_build/requirements.txt @@ -3,9 +3,9 @@ regex>=2026.7.19 pymdown-extensions>=10.21.3 -pipdeptree>=4.2.1 +pipdeptree>=4.2.2 python-dateutil>=2.8.2 -click>=8.4.2 +click>=8.5.0 Markdown==3.10.3 ghp-import==2.1.0 watchdog==6.0.0 diff --git a/requirements.txt b/requirements.txt index 3b97616e3de..5267e084002 100755 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ filelock>=3.32.4 fasteners>=0.20 mycdp>=1.4.0 pynose>=1.5.5 -platformdirs>=4.11.4 +platformdirs>=4.11.5 typing-extensions>=4.16.0 sbvirtualdisplay>=1.4.0 MarkupSafe>=3.0.3 @@ -36,8 +36,8 @@ outcome==1.3.0.post0 trio>=0.34.0,<1 trio-websocket~=0.12.2 wsproto~=1.3.2 -websocket-client~=1.9.0 -selenium==4.47.0 +websocket-client~=1.9.1 +selenium==4.48.0 cssselect>=1.5.0,<2 sortedcontainers==2.4.0 execnet==2.1.2 @@ -65,7 +65,7 @@ rich>=15.0.0,<16 # --- Testing Requirements --- # # ("pip install -r requirements.txt" also installs this, but "pip install -e ." won't.) -coverage>=7.15.4 +coverage>=7.16.0 pytest-cov>=7.1.0 flake8==7.3.0 mccabe==0.7.0 diff --git a/seleniumbase/__version__.py b/seleniumbase/__version__.py index e837d455199..7389a467d1e 100755 --- a/seleniumbase/__version__.py +++ b/seleniumbase/__version__.py @@ -1,2 +1,2 @@ # seleniumbase package -__version__ = "4.52.4" +__version__ = "4.53.0" diff --git a/seleniumbase/core/browser_launcher.py b/seleniumbase/core/browser_launcher.py index e45f4d1cd38..1b3fde685cf 100644 --- a/seleniumbase/core/browser_launcher.py +++ b/seleniumbase/core/browser_launcher.py @@ -173,6 +173,9 @@ def extend_driver( driver.locator = DM.locator driver.select = DM.select driver.select_all = DM.select_all + driver.select_option_by_text = DM.select_option_by_text + driver.select_option_by_index = DM.select_option_by_index + driver.select_option_by_value = DM.select_option_by_value page = types.SimpleNamespace() page.open = DM.open_url page.goto = DM.open_url @@ -210,6 +213,9 @@ def extend_driver( page.locator = DM.locator page.select = DM.select page.select_all = DM.select_all + page.select_option_by_text = DM.select_option_by_text + page.select_option_by_index = DM.select_option_by_index + page.select_option_by_value = DM.select_option_by_value page.get_current_url = DM.get_current_url page.get_page_source = DM.get_page_source page.get_title = DM.get_title @@ -817,6 +823,7 @@ def uc_open_with_cdp_mode(driver, url=None, **kwargs): cdp.send_keys = CDPM.send_keys cdp.press_keys = CDPM.press_keys cdp.type = CDPM.type + cdp.fast_type = CDPM.fast_type cdp.clear_input = CDPM.clear_input cdp.clear = CDPM.clear_input cdp.set_value = CDPM.set_value @@ -923,6 +930,7 @@ def uc_open_with_cdp_mode(driver, url=None, **kwargs): cdp.is_exact_text_visible = CDPM.is_exact_text_visible cdp.wait_for_text = CDPM.wait_for_text cdp.wait_for_text_not_visible = CDPM.wait_for_text_not_visible + cdp.wait_for_element_present = CDPM.wait_for_element_present cdp.wait_for_element_visible = CDPM.wait_for_element_visible cdp.wait_for_element = CDPM.wait_for_element cdp.wait_for_element_not_visible = CDPM.wait_for_element_not_visible diff --git a/seleniumbase/core/sb_cdp.py b/seleniumbase/core/sb_cdp.py index d71aa2b1786..70df1d9aa49 100644 --- a/seleniumbase/core/sb_cdp.py +++ b/seleniumbase/core/sb_cdp.py @@ -1299,6 +1299,20 @@ def type(self, selector, text, timeout=None): self.__slow_mode_pause_if_set() self.loop.run_until_complete(self.page.sleep(0.025)) + def fast_type(self, selector, text, timeout=None): + """Similar to send_keys(), but presses keys really fast. + (Don't use if going for stealth. This is just for speed.)""" + if not timeout: + timeout = settings.SMALL_TIMEOUT + self.__slow_mode_pause_if_set() + element = self.select(selector, timeout=timeout) + element.scroll_into_view() + with suppress(Exception): + element.clear_input() + element.send_keys(text, fast=True) + self.__slow_mode_pause_if_set() + self.loop.run_until_complete(self.page.sleep(0.025)) + def clear_input(self, selector, timeout=None): if not timeout: timeout = settings.SMALL_TIMEOUT @@ -3159,6 +3173,24 @@ def wait_for_text_not_visible(self, text, selector="body", timeout=None): % (text, selector, timeout, plural) ) + def wait_for_element_present(self, selector, timeout=None): + if not timeout: + timeout = settings.SMALL_TIMEOUT + failure = False + message = "" + try: + self.select(selector, timeout=timeout) + except Exception: + failure = True + plural = "s" + if timeout == 1: + plural = "" + msg = "\n Element {%s} was not found after %s second%s!" + message = msg % (selector, timeout, plural) + if failure: + raise Exception(message) + return self.select(selector) + def wait_for_element_visible(self, selector, timeout=None): if not timeout: timeout = settings.SMALL_TIMEOUT diff --git a/seleniumbase/core/sb_driver.py b/seleniumbase/core/sb_driver.py index 3108b03ed99..07a70ea90cd 100644 --- a/seleniumbase/core/sb_driver.py +++ b/seleniumbase/core/sb_driver.py @@ -53,6 +53,95 @@ def select_all(self, *args, **kwargs): else: return self.find_elements(*args, **kwargs) + def __select_option(self, selector, option, option_by="text"): + from selenium.webdriver.support.ui import Select + element = self.find_element(selector) + if option_by == "index": + try: + Select(element).select_by_index(option) + except Exception: + msg = ( + "Element {%s} has no selectable index option {%s}!" + % (selector, option) + ) + page_actions.timeout_exception("NoSuchOptionException", msg) + elif option_by == "value": + try: + Select(element).select_by_value(option) + except Exception: + msg = ( + "Element {%s} has no selectable value option {%s}!" + % (selector, option) + ) + page_actions.timeout_exception("NoSuchOptionException", msg) + else: # option_by="text" (or typo for option_by) + try: + Select(element).select_by_visible_text(option) + except Exception: + msg = ( + "Element {%s} has no selectable text option {%s}!" + % (selector, option) + ) + page_actions.timeout_exception("NoSuchOptionException", msg) + return element + + def select_option_by_text(self, *args, **kwargs): + if self.__is_cdp_swap_needed(): + return self.driver.cdp.select_option_by_text(*args, **kwargs) + selector = None + if "selector" in kwargs: + selector = kwargs["selector"] + elif "dropdown_selector" in kwargs: + selector = kwargs["dropdown_selector"] + else: + selector = args[0] + option = None + if "option" in kwargs: + option = kwargs["option"] + elif "text" in kwargs: + option = kwargs["text"] + else: + option = args[1] + return self.__select_option(selector, option, option_by="text") + + def select_option_by_index(self, *args, **kwargs): + if self.__is_cdp_swap_needed(): + return self.driver.cdp.select_option_by_index(*args, **kwargs) + selector = None + if "selector" in kwargs: + selector = kwargs["selector"] + elif "dropdown_selector" in kwargs: + selector = kwargs["dropdown_selector"] + else: + selector = args[0] + option = None + if "option" in kwargs: + option = kwargs["option"] + elif "index" in kwargs: + option = kwargs["index"] + else: + option = args[1] + return self.__select_option(selector, option, option_by="index") + + def select_option_by_value(self, *args, **kwargs): + if self.__is_cdp_swap_needed(): + return self.driver.cdp.select_option_by_value(*args, **kwargs) + selector = None + if "selector" in kwargs: + selector = kwargs["selector"] + elif "dropdown_selector" in kwargs: + selector = kwargs["dropdown_selector"] + else: + selector = args[0] + option = None + if "option" in kwargs: + option = kwargs["option"] + elif "value" in kwargs: + option = kwargs["value"] + else: + option = args[1] + return self.__select_option(selector, option, option_by="value") + def add_cookie(self, *args, **kwargs): page_actions._reconnect_if_disconnected(self.driver) self.driver.default_add_cookie(*args, **kwargs) diff --git a/seleniumbase/fixtures/base_case.py b/seleniumbase/fixtures/base_case.py index 5ccd2637514..8df73ba352c 100644 --- a/seleniumbase/fixtures/base_case.py +++ b/seleniumbase/fixtures/base_case.py @@ -1197,6 +1197,31 @@ def press_keys(self, selector, text, by="css selector", timeout=None): if self.undetectable: time.sleep(0.02) + def fast_type( + self, selector, text, by="css selector", timeout=None, retry=False + ): + """During CDP Mode, calls sb.cdp.fast_type(selector, text), + which is useful when you don't need to slow down for stealth. + Otherwise, this method calls self.update_text(selector, text), + which is already fast because there's no stealth to protect. + @Params + selector - The selector of the text field. + text - The new text to type into the text field. + by - The type of selector to search by. (Default: "css selector") + timeout - How long to wait for the selector to be visible. + retry - If True, use JS if the Selenium text update fails. + """ + self.__check_scope() + if not timeout: + timeout = settings.LARGE_TIMEOUT + if self.timeout_multiplier and timeout == settings.LARGE_TIMEOUT: + timeout = self.__get_new_timeout(timeout) + selector, by = self.__recalculate_selector(selector, by) + if self.__is_cdp_swap_needed(): + self.cdp.fast_type(selector, text, timeout=timeout) + return + self.update_text(selector, text, by=by, timeout=timeout, retry=retry) + def submit(self, selector, by="css selector"): """Alternative to self.driver.find_element_by_*(SELECTOR).submit()""" self.__check_scope() diff --git a/seleniumbase/fixtures/xpath_to_css.py b/seleniumbase/fixtures/xpath_to_css.py index 618adfdb157..fd3264a81b1 100644 --- a/seleniumbase/fixtures/xpath_to_css.py +++ b/seleniumbase/fixtures/xpath_to_css.py @@ -27,11 +27,64 @@ prog = re.compile(_validation_re) +# Matches "/parent::[predicates]" +# Example: "//h1/parent::article" -> child="//h1", tag="article" +# The "child" group is greedy so that chained parent:: axes (e.g. +# "//div/parent::section/parent::body") are split at the outermost +# (last) "parent::", which matches correct XPath evaluation order. +_parent_axis_re = re.compile( + r"^(?P.+)/parent::(?P[a-zA-Z][-a-zA-Z0-9]*|\*)" + r"(?P(?:\[[^\[\]]*\])*)" + r"(?P/.*)?$" +) + class XpathException(Exception): pass +def _convert_parent_axis(xpath): + """ + CSS has no direct equivalent of the XPath "parent::" axis (there's no + way to select an element based on one of its children -- CSS only + selects "downward" or "sideways"). Modern browsers now support the + CSS4 ":has()" relational pseudo-class, which can express the same + relationship in reverse: "X/parent::Y" ("the Y that is the parent + of X") becomes "Y:has(> X)" ("the Y that has X as a direct child"). + Examples: + "//h1/parent::article" -> "article:has(> h1)" + "//h1/parent::article[@class='post']" -> "article.post:has(> h1)" + "//h1/parent::article/div" -> "article:has(> h1) > div" + Returns None if the xpath doesn't use the "parent::" axis, so the + caller can fall back to normal (non-parent-axis) conversion. + """ + match = _parent_axis_re.match(xpath) + if not match: + return None + child_xpath = match.group("child") + parent_tag = match.group("tag") + predicates = match.group("predicates") or "" + rest = match.group("rest") or "" + # The child portion must itself be a valid (absolute or relative) xpath + if not (child_xpath.startswith("/") or child_xpath.startswith(".")): + return None + child_css = convert_xpath_to_css(child_xpath) + if predicates: + # Reuse the normal converter to resolve attributes/classes/ids + # on the parent tag (e.g. "article[@class='post']" -> "article.post") + parent_css = convert_xpath_to_css("//%s%s" % (parent_tag, predicates)) + else: + parent_css = "" if parent_tag == "*" else parent_tag + combined = "%s:has(> %s)" % (parent_css, child_css) + if rest: + is_descendant = rest.startswith("//") + stripped_rest = rest[2:] if is_descendant else rest[1:] + nav = " " if is_descendant else " > " + rest_css = convert_xpath_to_css("//%s" % stripped_rest) + combined = combined + nav + rest_css + return combined + + def _handle_brackets_in_strings(xpath): # Edge Case: Brackets in strings. # Example from GitHub.com - @@ -59,12 +112,10 @@ def _filter_xpath_grouping(xpath, original): This method removes the outer parentheses for xpath grouping. The xpath converter will break otherwise. Example: - "(//button[@type='submit'])[1]" becomes "//button[@type='submit'][1]" + "(//button[@type='submit'])[1]" becomes "//button[@type='submit'][1]" """ - # First remove the first open parentheses xpath = xpath[1:] - # Next remove the last closed parentheses index = xpath.rfind(")") index_p1 = index + 1 # Make "flake8" and "black" agree @@ -82,7 +133,6 @@ def _get_raw_css_from_xpath(xpath, original): css = "" attr = "" position = 0 - while position < len(xpath): node = prog.match(xpath[position:]) if node is None: @@ -92,14 +142,11 @@ def _get_raw_css_from_xpath(xpath, original): "" % original ) match = node.groupdict() - if position != 0: nav = " " if match["nav"] == "//" else " > " else: nav = "" - tag = "" if match["tag"] == "*" else match["tag"] or "" - if match["idvalue"]: attr = "#%s" % match["idvalue"].replace(" ", "#") elif match["matched"]: @@ -126,24 +173,30 @@ def _get_raw_css_from_xpath(xpath, original): attr = ':contains("%s")' % match["cvalue"] else: attr = "" - if match["nth"]: nth = ":nth-of-type(%s)" % match["nth"] else: nth = "" - node_css = nav + tag + attr + nth css += node_css position += node.end() - else: - css = css.strip() - return css + css = css.strip() + return css def convert_xpath_to_css(xpath): original = xpath xpath = xpath.replace(" = '", "='") + # **** Handle the "parent::" axis (Eg: "//h1/parent::article") **** + # This has to be checked before any other processing because the + # rest of this function assumes a linear, top-down xpath with no + # axes that require "looking back up" the tree. + if "/parent::" in xpath: + parent_axis_css = _convert_parent_axis(xpath) + if parent_axis_css is not None: + return parent_axis_css + # **** Start of handling special xpath edge cases instantly **** # Handle a special edge case that converts to: 'tag.class:contains("TEXT")' @@ -211,6 +264,7 @@ def convert_xpath_to_css(xpath): if xpath[0] != '"' and xpath[-1] != '"' and xpath.count('"') % 2 == 0: xpath = _handle_brackets_in_strings(xpath) + xpath = xpath.replace("descendant-or-self::*/", "descORself/") if len(xpath) > 3: xpath = xpath[0:3] + xpath[3:].replace("//", "/descORself/") diff --git a/seleniumbase/undetected/cdp_driver/browser.py b/seleniumbase/undetected/cdp_driver/browser.py index 384b2a626d4..5e348c59ce6 100644 --- a/seleniumbase/undetected/cdp_driver/browser.py +++ b/seleniumbase/undetected/cdp_driver/browser.py @@ -149,8 +149,9 @@ def __init__(self, config: Config, **kwargs): Constructor. To create a instance, use :py:meth:`Browser.create(...)` :param config: """ + self._main_loop = None try: - asyncio.get_running_loop() + self._main_loop = asyncio.get_running_loop() except RuntimeError: raise RuntimeError( "{0} objects of this class are created " @@ -933,7 +934,14 @@ def stop(self, deconstruct=False): close_success = False try: if self.connection: - loop = None + if ( + hasattr(self, "_main_loop") + and self._main_loop + and self._main_loop.is_running() + ): + loop = self._main_loop + else: + loop = asyncio.get_event_loop() for obj in (self, self.connection, getattr( self.connection, "websocket", None) ): @@ -946,9 +954,6 @@ def stop(self, deconstruct=False): if not loop: with suppress(Exception): loop = asyncio.get_event_loop_policy().get_event_loop() - if not loop: - with suppress(Exception): - loop = asyncio.get_event_loop() if loop.is_closed(): return if loop.is_running(): diff --git a/seleniumbase/undetected/cdp_driver/element.py b/seleniumbase/undetected/cdp_driver/element.py index d6cda619fe6..14c21c2bb08 100644 --- a/seleniumbase/undetected/cdp_driver/element.py +++ b/seleniumbase/undetected/cdp_driver/element.py @@ -821,7 +821,9 @@ async def clear_input_async(self): logger.debug("Could not clear element field: %s", e) return - async def send_keys_async(self, text: str, add_delay: bool = False): + async def send_keys_async( + self, text: str, add_delay: bool = False, fast: bool = False + ): """ Send text to an input field, or any other html element. Hint: If you ever get stuck where using `~click()` @@ -843,7 +845,7 @@ async def send_keys_async(self, text: str, add_delay: bool = False): # which may need the old version for stealth. # (Usage: sb_config._use_new_send_keys = True) # **EXPERIMENTAL** - await self.__new_send_keys_async(text, add_delay) + await self.__new_send_keys_async(text, add_delay, fast=fast) return if IS_LINUX: for char in text: @@ -859,11 +861,15 @@ async def send_keys_async(self, text: str, add_delay: bool = False): float(0.04 + (random.random() / 110.0)) ) else: - await self.__new_send_keys_async(char, add_delay) + await self.__new_send_keys_async( + char, add_delay, fast=fast + ) return - await self.__new_send_keys_async(text, add_delay) + await self.__new_send_keys_async(text, add_delay, fast=fast) - async def __new_send_keys_async(self, text: str, add_delay: bool = False): + async def __new_send_keys_async( + self, text: str, add_delay: bool = False, fast: bool = False + ): """A helper method for send_keys_async()""" # Map non-alphanumeric symbols to the correct CDP virtual key code. # Prevents collisions with control keys. Eg. ord('.') = 46 = VK_DELETE. @@ -941,7 +947,8 @@ async def __new_send_keys_async(self, text: str, add_delay: bool = False): windows_virtual_key_code=vk, ) ) - await asyncio.sleep(random.uniform(0.002, 0.003)) + if not fast: + await asyncio.sleep(random.uniform(0.002, 0.003)) # 3. Trigger keypress DOM event AND insert text if text_val: await self._tab.send( @@ -955,7 +962,8 @@ async def __new_send_keys_async(self, text: str, add_delay: bool = False): ) ) # 4. Trigger keyup DOM event - await asyncio.sleep(random.uniform(0.011, 0.014)) + if not fast: + await asyncio.sleep(random.uniform(0.011, 0.014)) await self._tab.send( cdp.input_.dispatch_key_event( type_="keyUp", @@ -964,7 +972,7 @@ async def __new_send_keys_async(self, text: str, add_delay: bool = False): windows_virtual_key_code=vk, ) ) - if add_delay: + if add_delay and not fast: await asyncio.sleep(float(0.04 + (random.random() / 110.0))) async def send_file_async(self, *file_paths: PathLike): diff --git a/seleniumbase/undetected/cdp_driver/tab.py b/seleniumbase/undetected/cdp_driver/tab.py index 9ecb93dada0..73c4d224f89 100644 --- a/seleniumbase/undetected/cdp_driver/tab.py +++ b/seleniumbase/undetected/cdp_driver/tab.py @@ -398,6 +398,16 @@ async def query_selector_all( if _node.node_name == "IFRAME": doc = _node.content_document node_ids = [] + if page_utils.is_xpath_selector(selector): + logger.debug( + "CDP.DOM.querySelectorAll() doesn't support XPath!\n" + "The unsupported selector: %s" % selector + ) + elif ":contains(" in selector: + logger.debug( + "CDP.DOM.querySelectorAll() doesn't support :contains()!\n" + "The unsupported selector: %s" % selector + ) try: node_ids = await self.send( cdp.dom.query_selector_all(doc.node_id, selector) @@ -1634,13 +1644,16 @@ async def get_current_url(self): async def get_origin(self): return await self.evaluate("window.location.origin") - async def send_keys(self, selector, text, timeout=5): + async def send_keys(self, selector, text, timeout=5, fast=False): element = await self.find(selector, timeout=timeout) - await element.send_keys_async(text) + await element.send_keys_async(text, fast=fast) async def type(self, selector, text, timeout=5): await self.send_keys(selector, text, timeout=timeout) + async def fast_type(self, selector, text, timeout=5): + await self.send_keys(selector, text, timeout=timeout, fast=True) + async def click(self, selector, timeout=5): element = await self.find(selector, timeout=timeout) await element.click_async() diff --git a/setup.py b/setup.py index 57ea977b678..36f51df8dc5 100755 --- a/setup.py +++ b/setup.py @@ -174,7 +174,7 @@ 'fasteners>=0.20', 'mycdp>=1.4.0', 'pynose>=1.5.5', - 'platformdirs>=4.11.4', + 'platformdirs>=4.11.5', 'typing-extensions>=4.16.0', 'sbvirtualdisplay>=1.4.0', 'MarkupSafe>=3.0.3', @@ -199,8 +199,8 @@ 'trio>=0.34.0,<1', 'trio-websocket~=0.12.2', 'wsproto~=1.3.2', - 'websocket-client~=1.9.0', - 'selenium==4.47.0', + 'websocket-client~=1.9.1', + 'selenium==4.48.0', 'cssselect>=1.5.0,<2', 'sortedcontainers==2.4.0', 'execnet==2.1.2', @@ -237,7 +237,7 @@ # pip install -e .[coverage] # Usage: coverage run -m pytest; coverage html; coverage report "coverage": [ - 'coverage>=7.15.4', + 'coverage>=7.16.0', 'pytest-cov>=7.1.0', ], # pip install -e .[flake8] @@ -252,7 +252,7 @@ # (Adds the "seleniumbase-mcp" console script: An MCP server that # exposes SeleniumBase's Pure CDP Mode as tools for MCP clients) "mcp": [ - "mcp[cli]>=2.0.0,<3.0.0", + "mcp[cli]>=2.1.1,<3.0.0", ], # pip install -e .[mss] # (An optional library for tile_windows() in CDP Mode.) @@ -263,7 +263,7 @@ # (An optional library for parsing PDF files.) "pdfminer": [ 'pdfminer.six==20260107', - 'cryptography==50.0.0', + 'cryptography==50.0.1', 'cffi==2.1.1', 'pycparser==3.0', ],