A universal browser automation library for Python that supports both Playwright and Selenium with a unified API. The key focus is on stoppable page triggers - ensuring automation logic is properly mounted/unmounted during page navigation.
pip install browser-commanderYou'll also need either Playwright or Selenium:
# With Playwright
pip install browser-commander[playwright]
playwright install chromium
# Or with Selenium
pip install browser-commander[selenium]Browser Commander manages the browser as a state machine with two states:
+------------------+ +------------------+
| | navigation start | |
| WORKING STATE | -------------------> | LOADING STATE |
| (action runs) | | (wait only) |
| | <----------------- | |
+------------------+ page ready +------------------+
LOADING STATE: Page is loading. Only waiting/tracking operations are allowed. No automation logic runs.
WORKING STATE: Page is fully loaded (30 seconds of network idle). Page triggers can safely interact with DOM.
import asyncio
from browser_commander import (
launch_browser,
make_browser_commander,
make_url_condition,
LaunchOptions,
)
async def main():
# 1. Launch browser
options = LaunchOptions(engine="playwright")
result = await launch_browser(options)
browser, page = result.browser, result.page
# 2. Create commander
commander = make_browser_commander(page=page, verbose=True)
# 3. Register page trigger with condition and action
async def example_action(ctx):
print(f"Processing: {ctx['url']}")
# Perform automation tasks
await ctx["commander"].click_button(selector="button.submit")
commander.page_trigger(
{
"name": "example-trigger",
"condition": make_url_condition("*example.com*"),
"action": example_action,
}
)
# 4. Navigate - action auto-starts when page is ready
await commander.goto(url="https://example.com")
# 5. Cleanup
await commander.destroy()
await browser.close()
if __name__ == "__main__":
asyncio.run(main())The make_url_condition helper makes it easy to create URL matching conditions:
import re
from browser_commander import (
make_url_condition,
all_conditions,
any_condition,
not_condition,
)
# Exact URL match
make_url_condition("https://example.com/page")
# Contains substring (use * wildcards)
make_url_condition("*checkout*") # URL contains 'checkout'
make_url_condition("*example.com*") # URL contains 'example.com'
# Starts with / ends with
make_url_condition("/api/*") # starts with '/api/'
make_url_condition("*.json") # ends with '.json'
# Express-style route patterns
make_url_condition("/vacancy/:id") # matches /vacancy/123
make_url_condition("https://hh.ru/vacancy/:vacancyId")
# RegExp
make_url_condition(re.compile(r"/product/\d+"))
# Custom function
make_url_condition(lambda url: url.startswith("https://"))
# Combine conditions
all_conditions(
make_url_condition("*example.com*"),
make_url_condition("*/checkout*"),
) # Both must match
any_condition(
make_url_condition("*/cart*"),
make_url_condition("*/checkout*"),
) # Either matches
not_condition(make_url_condition("*/admin*")) # Negationfrom browser_commander import launch_browser, LaunchOptions
options = LaunchOptions(
engine="playwright", # 'playwright' or 'selenium'
headless=False, # Run in headless mode
user_data_dir="~/.browser-commander/playwright-data",
slow_mo=150, # Slow down operations (ms)
verbose=False, # Enable debug logging
args=["--no-sandbox"], # Custom Chrome args
extra_args=["--lang=en-US"], # Appended after legacy args
ignore_default_args=["--disable-infobars"], # Per-default opt-out
)
result = await launch_browser(options)
browser, page = result.browser, result.pageBoth launch APIs apply the documented
automation-friendly defaults,
including --password-store=basic to avoid an extra OS credential dialog.
Set ignore_default_args=True to omit every optional default.
Attach Playwright or Selenium to an already-running Chrome-family browser over
CDP. Exactly one HTTP cdp_endpoint or browser ws_endpoint is required:
from browser_commander import ConnectOptions, connect_browser
result = await connect_browser(
ConnectOptions(
engine="playwright", # or "selenium"
cdp_endpoint="http://127.0.0.1:9222",
seed_cookies=[
{"name": "session", "value": "saved", "url": "https://example.com"}
],
)
)
browser, page = result.browser, result.pageThe returned page is the raw engine page/driver and can be passed directly to
make_browser_commander(). For Chrome 136 and newer, start the browser with a
non-default --user-data-dir; remote debugging is intentionally disabled for
the default Chrome profile. Cookie seeding uses only values supplied by the
caller and does not read the default profile. Because connection is attach-only,
it cannot retrofit launch flags. Start the external process with the documented
defaults and a dedicated debugging profile, or use launch_real_browser().
The installed-browser import below provides an explicit local way to obtain
those values.
Discover profiles and read cookies in the exact Playwright/Puppeteer cookie shape:
from browser_commander import (
BrowserCookieCacheOptions,
BrowserCookieReadOptions,
list_browser_profiles,
read_browser_cookies,
)
print(list_browser_profiles("chrome"))
cookies = read_browser_cookies(
BrowserCookieReadOptions(
browser="chrome", # chrome, edge, brave, chromium, or firefox
profile="Default", # optional; defaults to the selected browser profile
domain_filter="example.com",
cache=BrowserCookieCacheOptions(ttl_minutes=60),
)
)Each cookie contains name, value, domain, path, expires, httpOnly,
secure, and sameSite. The helper runs only when explicitly called and never
sends cookie data anywhere. Decrypted result and derived-key files default to
~/.browser-commander/cookie-cache/ with owner-only permissions. A process lock
and the default 60-minute TTL keep Keychain, libsecret/KWallet, or DPAPI access
to at most one read across concurrent and repeated processes. Set refresh=True
to force a new credential read, customize dir/ttl_minutes, or set
cache=False to opt out.
| Browser family | macOS | Linux | Windows |
|---|---|---|---|
| Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (v10/v11) |
libsecret/KWallet + AES-128-CBC (v11), or the Chromium v10 fallback key |
DPAPI-protected AES-256-GCM key (v10/v11) |
| Firefox | cookies.sqlite |
cookies.sqlite |
cookies.sqlite |
Chromium database-version-24 domain hashes and its 1601-based timestamps are
handled automatically. Current Windows Chromium may use app-bound v20
encryption, which intentionally requires the browser's privileged service and
cannot be decrypted by an ordinary external process. The helper reports this
boundary; use a browser-supported export or saved Browser Commander storage
state for those cookies. ignore_decryption_errors=True returns any remaining
decryptable cookies. Treat imported cookies like passwords: keep cache paths
private, use a short TTL, never commit them, and seed only a dedicated profile.
Discover and start a genuine installed Chrome, Edge, Brave, or Chromium with a dedicated profile, wait for its loopback CDP endpoint, and attach with Playwright or Selenium:
from browser_commander import RealBrowserOptions, launch_real_browser
result = await launch_real_browser(
RealBrowserOptions(
engine="playwright", # or "selenium"
channel="chrome", # chrome, msedge, brave, or chromium
user_data_dir="/tmp/browser-commander-profile",
extra_args=["--lang=en-US"],
ignore_default_args=["--disable-infobars"],
seed_cookies=[
{"name": "session", "value": "saved", "url": "https://example.com"}
],
)
)
browser, page = result.browser, result.page
print(result.cdp_endpoint, result.executable_path)The helper also supports beta/dev/canary channels and an explicit
executable_path. It rejects known default browser profiles and prevents
custom arguments from overriding its loopback address, debugging port, or
profile. The returned browser_process can be terminated explicitly after
closing the browser. launch_and_connect_real_browser() is an alias.
The remote-debugging address, port, and profile remain managed; headless mode
is opt-in and uses --headless=new. The older args field remains an
append-only compatibility alias.
The color_scheme option emulates prefers-color-scheme at launch time:
options = LaunchOptions(
engine="playwright",
color_scheme="dark", # 'light', 'dark', or 'no-preference'
)
result = await launch_browser(options)Emulate media features (e.g. prefers-color-scheme) for a page after launch:
from browser_commander import emulate_media
# Set dark mode
await emulate_media(page, engine="playwright", color_scheme="dark")
# Set light mode
await emulate_media(page, engine="playwright", color_scheme="light")
# Reset to system default
await emulate_media(page, engine="playwright", color_scheme=None)Works with both Playwright (page.emulate_media) and Selenium (via CDP Emulation.setEmulatedMedia).
from browser_commander import make_browser_commander
commander = make_browser_commander(
page=page, # Required: Playwright/Selenium page
verbose=False, # Enable debug logging
enable_network_tracking=True, # Track HTTP requests
enable_navigation_manager=True, # Enable navigation events
)result = await commander.goto(
url="https://example.com",
wait_until="domcontentloaded",
timeout=60000,
)
print(f"Navigated: {result['navigated']}, URL: {result['actual_url']}")result = await commander.click_button(
selector="button.submit",
scroll_into_view=True,
wait_after_click=1000,
)
print(f"Clicked: {result['clicked']}, Navigated: {result['navigated']}")result = await commander.fill_text_area(
selector="textarea.message",
text="Hello world",
check_empty=True,
)
print(f"Filled: {result['filled']}, Value: {result['actual_value']}")from browser_commander import press_key, type_text, key_down, key_up
# Press a single key
await press_key(page=page, key="Escape", engine="playwright")
await press_key(page=page, key="Enter", engine="playwright")
await press_key(page=page, key="Tab", engine="playwright")
# Type text
await type_text(page=page, text="Hello World", engine="playwright")
# Hold and release modifier keys
await key_down(page=page, key="Control", engine="playwright")
await key_up(page=page, key="Control", engine="playwright")# Query single element
element = await commander.query_selector("button.submit")
# Query all matching elements
elements = await commander.query_selector_all(".list-item")
# Wait for selector
found = await commander.wait_for_selector("button.submit", visible=True, timeout=5000)
# Find by text content
selector = commander.find_by_text("Click me", selector="button", exact=False)# Check visibility
is_vis = await commander.is_visible("button.submit")
# Check if enabled
is_en = await commander.is_enabled("button.submit")
# Count matching elements
count = await commander.count(".list-item")
# Get text content
text = await commander.text_content(".heading")
# Get input value
value = await commander.input_value("input.email")
# Get attribute
href = await commander.get_attribute("a.link", "href")# Wait for time
result = await commander.wait(ms=1000, reason="waiting for animation")
print(f"Completed: {result['completed']}, Aborted: {result['aborted']}")
# Evaluate JavaScript
result = await commander.evaluate("() => document.title")
# Safe evaluate (doesn't throw on navigation)
result = await commander.safe_evaluate(
fn="() => document.title",
default_value="Unknown",
)
print(f"Success: {result['success']}, Value: {result['value']}")await commander.destroy() # Stop actions, cleanupasync def main():
result = await launch_browser(options)
browser, page = result.browser, result.page
commander = make_browser_commander(page=page)
try:
# Your automation code
await commander.goto(url="https://example.com")
finally:
await commander.destroy()
await browser.close()commander = make_browser_commander(page=page, verbose=True)# Wait for page to be fully ready after navigation
await commander.wait_for_page_ready(timeout=30000)
# Check if should abort current operation
if commander.should_abort():
return # Navigation detected, stop current actionbrowser-commander cannot anticipate every browser API. When you need an API that is not yet supported, you can access the raw underlying engine objects directly as an official extensibility escape hatch.
make_browser_commander exposes commander.page — this is the raw Playwright or Selenium page object, not a wrapper. Use it directly for APIs browser-commander doesn't yet support:
from browser_commander import launch_browser, make_browser_commander, LaunchOptions
options = LaunchOptions(engine="playwright")
result = await launch_browser(options)
browser, page = result.browser, result.page
commander = make_browser_commander(page=page)
# Access engine-specific API via commander.page
# Example: PDF generation (issue #35)
pdf_buffer = await commander.page.pdf(
format="A4",
print_background=True,
)
# Example: Color scheme emulation (issue #36)
await commander.page.emulate_media(color_scheme="dark")
# Example: Keyboard interactions (issue #37)
await commander.page.keyboard.press("Escape")
# Example: Dialog handling (issue #38)
async def handle_dialog(dialog):
await dialog.dismiss()
commander.page.on("dialog", handle_dialog)launch_browser() returns the raw browser and page objects from the underlying engine. You can use these directly:
result = await launch_browser(options)
browser = result.browser
page = result.page
# Use raw page directly for engine-specific APIs
await page.pdf(format="A4")
# Or create a commander for the unified API
commander = make_browser_commander(page=page)If you previously used page._page or page to access the raw page, replace it with commander.page:
# BEFORE (fragile hack):
raw_page = getattr(page, "_page", page)
await raw_page.pdf(format="A4")
# AFTER (official API):
await commander.page.pdf(format="A4")This is the official extensibility mechanism while awaiting browser-commander to add first-class support for these APIs. Please report missing APIs so they can be added.
The Python implementation follows the same architecture as the JavaScript version:
- Core Module: Constants, logger, engine detection, navigation safety
- Browser Module: Launcher, navigation management
- Elements Module: Selectors, visibility, content extraction
- Interactions Module: Click, fill, scroll operations
- Utilities Module: Wait, URL helpers
- High-Level Module: Universal logic, page triggers