Project-agnostic toolkit for driving Chrome via the Chrome DevTools Protocol on top of Playwright's sync API. Wait on state, never on time.
Important
Wait on state changes, never on time. No wait_for_timeout, no time.sleep. timeout_s=N is always an upper bound — the call returns the instant the state changes.
- Why
- Installation
- Quick start
- CLI reference
- Library reference
- Conventions
- Real-world recipes
- Cross-platform notes
- Development
- Project links
- Acknowledgements
Most browser automation tutorials sprinkle sleep(2) everywhere, then break under load. web-view enforces a different discipline:
- Wait on the actual signal: URL change, document ready, element visibility, network response, custom JS predicate.
- Pick the right tab:
context.pages[0]under CDP can returnRotateCookies, service workers, or devtools targets. Always usefind_page()with a predicate. - Scope queries to a region: pass a
Locator(not aPage) for elements inside modals, where the same role+name appears elsewhere on the page. - Snapshot in pairs: every UI state worth saving is captured as
NN-slug.png(visual) +NN-slug.aria.yaml(selectors). Reproducible and diff-friendly.
uv tool install git+https://github.com/lipex360x/web-viewThis puts a web-view command on your PATH. Verify:
web-view --helpYou also need the system Chrome (the CDP launcher talks to your real Chrome, not a Playwright-bundled one):
- macOS: https://www.google.com/chrome/
- Linux:
apt install google-chrome-stable/dnf install google-chrome/ etc. - Windows: https://www.google.com/chrome/
And the Playwright Python binding (the library itself uses it internally):
uv tool install --with playwright git+https://github.com/lipex360x/web-viewAdd to your pyproject.toml:
dependencies = [
"web-view @ git+https://github.com/lipex360x/web-view",
"playwright>=1.40",
]Or with uv:
uv add git+https://github.com/lipex360x/web-viewThen import:
from web_view import cdp
with cdp.connect() as (browser, context):
page = cdp.find_page(context, url_contains="example.com")
cdp.click(page, "button", "Submit")uv run --with git+https://github.com/lipex360x/web-view web-view listweb-view start --port 9222 --user-data-dir ~/.cache/web-view/profile \
--url file:///path/to/index.htmlThis launches your real Chrome with --remote-debugging-port=9222 and a persistent profile (so cookies, logins, extensions survive across runs). The optional --url flag opens that URL in the first tab right after Chrome becomes CDP-ready. You can also do this manually:
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir=$HOME/.cache/web-view/profileBrowse to whatever site you're researching. Log in, navigate, click around. Your session is now live and addressable from Python.
web-view list PID PORT USER_DATA_DIR TABS
41822 9222 /Users/you/.cache/web-view/profile 2
├─ file:///path/to/index.html "Home"
└─ chrome://newtab
Each running instance is queried over CDP for its open tabs. If the instance refuses the short-lived connection (mid-shutdown, busy), the TABS column shows ? instead of erroring out.
web-view snap homepage --url-contains example.com --destination-dir ./capturesYou get captures/01-homepage.png + captures/01-homepage.aria.yaml. The PNG is for humans (review what you saw). The ARIA YAML is for selectors — it dumps the accessibility tree so you can find any element by role + accessible name without hunting for CSS selectors.
The command prints the two absolute paths to stdout (PNG first, ARIA YAML second), so you can pipe them straight into other tools:
$ web-view snap homepage --url-contains example.com
/Users/you/captures/01-homepage.png
/Users/you/captures/01-homepage.aria.yaml
$ web-view snap | head -1 | xargs open # auto-slug + open the PNGThe slug is optional: web-view snap (no positional) writes NN-snap.{png,aria.yaml} using the next free index.
web-view snap emits selectors as role + name pairs in the YAML — web-view do <verb> speaks the same vocabulary, so the read-yaml → act loop never needs a Python REPL:
web-view snap login # captures 01-login.aria.yaml
# In the YAML: `- button "Sign in"`, `- textbox "Email"`
web-view do fill --role textbox --name "Email" --value "alice@example.com"
web-view do fill --role textbox --name "Password" --value "secret"
web-view do click --role button --name "Sign in"
web-view snap after-login # captures the new stateEvery interaction verb (click, fill, check, press, hover, dblclick, right-click, scroll-into-view, upload, drag) accepts the same options: --port/--tab for instance + tab targeting, --timeout for the upper bound on element waits, --quiet/-q to silence the one-line success ack. CSS-first workflows use --selector <css> instead of --role/--name. See web-view do -h for the verb list and web-view do <verb> -h for per-command details.
web-view tab new --url https://example.com # opens a fresh tab on the URL
web-view snap example --url-contains example.com
web-view tab close --tab example.com # close it again when donetab close requires --tab on purpose (closing is destructive). tab switch [--tab <index|substring>] brings a background tab to the front; with no --tab it focuses the first non-helper tab.
from web_view import cdp
with cdp.connect(port=9222) as (browser, context):
page = cdp.find_page(context, url_contains="example.com")
cdp.goto(page, "https://example.com/products")
cdp.fill(page, "textbox", "Search", "wireless mouse")
cdp.press(page, "Enter")
cdp.wait_for_url(page, lambda url: "/results" in url, timeout_s=10)
cdp.dual_snapshot(page, "search-results", dest_dir="./captures")web-view stop --port 9222web-view --version | -v # print the installed version and exit
web-view start [--port 9222] [--user-data-dir DIR] [--headless] [--url URL]
[--window-size WxH] # default 1920x1080
web-view list
web-view navigate --url URL [--port 9222] [--tab <index|substring> | --new-tab]
web-view stop [--port PORT] # omit --port when exactly one instance is running
web-view snap [slug] # slug defaults to "snap"
[--port 9222] [--url-contains STR] [--destination-dir DIR]
[--no-frames] # top frame only (skip iframes)
web-view do <verb> # click | fill | check | press | hover | dblclick |
# right-click | scroll-into-view | upload | drag
[--role R --name N | --selector CSS]
[--port P] [--tab T] [--frame F] [--timeout S] [--quiet]
# --frame: index | URL substring | auto (default)
web-view resize --width W --height H # OS window (default)
[--viewport] # page viewport only
[--port P] [--tab T] [--quiet]
web-view tab <verb> # new | close | switch
new [--url URL] # opens about:blank by default
close --tab <index|substring> # --tab required (destructive)
switch [--tab <index|substring>] # defaults to first non-helper tab
[--port P] [--quiet]
web-view eval --js EXPR # run JS in a tab/frame, print result as JSON
| --js - # read the expression from stdin
| --js-file PATH # read the expression from a file
[--frame F] [--tab T] [--port P]
web-view download --url URL --out PATH # fetch via browser cookies
[--port P]
web-view navigate reuses an existing CDP Chrome. Without --tab / --new-tab it targets the first non-helper tab (the same tab start --url would touch). --tab accepts either a 0-based index (negatives count from the end) or a URL substring; --new-tab opens a fresh tab instead. The two flags are mutually exclusive.
web-view snap prints two absolute paths to stdout (PNG then ARIA YAML) so the call is composable with head, xargs, etc. Missing pre-conditions (no CDP Chrome on the given port, multiple instances when --port is omitted) produce structured guidance on stderr instead of raw Playwright tracebacks — this applies to snap, stop, navigate, and every do <verb>.
By default the ARIA YAML recurses into same-origin iframes: each - iframe leaf is expanded in place, with the child frame's accessibility tree inlined under the node and labelled with the frame URL. This makes content rendered inside an iframe (SCORM / HTML5 courses, embedded players) visible to the structured snapshot instead of stopping at a bare - iframe. Cross-origin frames are annotated - iframe (cross-origin, not captured) so the omission is explicit rather than silent. Pass --no-frames for the legacy top-frame-only output.
web-view do <verb> is the no-Python convenience layer over the library's interaction helpers. Element addressing defaults to --role + --name (the same vocabulary web-view snap writes into the ARIA YAML); --selector <css> is the mutually exclusive escape hatch for CSS-first workflows. Per-verb specifics: fill reads stdin when --value is omitted (web-view do fill --role textbox --name Body < message.txt); press accepts a comma-separated chord list (--keys "Control+a,Backspace"); drag uses a role:name micro-syntax (--from "button:Item" --to "region:Trash"); upload requires --file <path>. Every verb prints a one-line success ack on stdout (silenced with --quiet).
To act on an element inside an iframe (SCORM / HTML5 courses, embedded players), every verb except press accepts --frame. Its grammar mirrors --tab: a 0-based index (--frame 1, where 0 is the top frame), a URL substring (--frame index_lms), or auto (the default). auto searches the top frame and every same-origin frame and acts on the first one that holds the target element, so web-view do click --role button --name "ENTER" finds an in-frame button without you naming its frame. The search uses a non-waiting presence check, so it never multiplies --timeout across frames. press is excluded on purpose: keyboard input targets whatever has focus, not a frame.
web-view resize resizes a running Chrome window. Default mode targets the OS-level window (Chrome physically grows or shrinks on the desktop) via CDP Browser.setWindowBounds. --viewport switches to a page-only viewport override — useful for responsive-layout testing without disturbing the window manager. The initial size is controlled by web-view start --window-size WxH (default 1920x1080).
web-view tab <verb> manages the tab lifecycle as a thin pass-through to the library's cdp.open_tab / cdp.close_tab / cdp.switch_to_tab helpers. tab new opens a fresh tab (about:blank unless --url is given) and is the preferred form for new code over navigate --new-tab (which keeps working). tab close requires --tab on purpose: closing is destructive, so there is no implicit "first tab" default that could discard a logged-in session by accident. tab switch is non-destructive and therefore defaults to the first non-helper tab when --tab is omitted. Tab selection mirrors navigate: a 0-based index (negatives count from the end) or a URL substring.
web-view eval runs a JavaScript expression in a tab (or a chosen frame via --frame) and prints the result to stdout as JSON, so it composes with jq and friends. It is the escape hatch for anything the structured verbs do not cover (reading currentSrc off every <video>, pulling computed styles, scraping a value). A result that cannot be serialised to JSON prints a structured error to stderr and exits 1 instead of dumping a traceback. The expression comes from exactly one source: inline --js "<expr>", --js - to read it from stdin (echo "document.title" | web-view eval --js -), or --js-file <path> to read it from a file (web-view eval --js-file ./find-media.js --frame index_lms). --js and --js-file are mutually exclusive; a missing --js-file errors on stderr and exits 1. Keeping multi-line scripts in a file or piping them in avoids the shell-quoting pain of inline JS.
web-view download fetches a URL through the browser context with context.request.get, so the logged-in session's cookies travel with the request and a resource behind a login is reachable without re-authenticating. It saves the body to --out and prints the HTTP status and saved byte count; a non-2xx status prints an error to stderr and exits 1. Pair it with eval to first discover a media URL, then pull it: web-view eval --js "document.querySelector('video').currentSrc" | tr -d '"' | xargs -I{} web-view download --url {} --out clip.mp4.
For programmatic use, the CLI is just a wrapper — everything is exposed via from web_view import cdp.
Chrome lifecycle · Connect + tab selection · Navigation + waiting · Element interaction · Downloads + uploads · Cookies + storage + clipboard · Snapshots + inspection · Recording console + network
cdp.start_chrome(*, port=9222, user_data_dir, headless=False, extra_args=None, binary=None) -> Popen
cdp.is_cdp_ready(port, *, timeout_s=15) -> bool
cdp.wait_for_chrome_stopped(port, *, timeout_s=15) -> bool
cdp.stop_chrome(*, port=None, pid=None, process=None) -> int # exactly one of the three
cdp.list_cdp_instances() -> list[CdpInstance] # cross-platformCdpInstance is a dataclass with pid, port, user_data_dir, cmdline. The list_cdp_instances() function filters out Chrome helper processes (renderer, GPU, network) that inherit the parent's --remote-debugging-port= cmdline.
with cdp.connect(port=9222) as (browser, context): # context manager
pages = cdp.pages_info(context) # [{"url": ..., "title": ...}, ...]
# Pick a tab. Never use context.pages[0] — it can be RotateCookies.
page = cdp.find_page(context, url_contains="example.com")
page = cdp.find_page(context, predicate=lambda url: "checkout" in url and "/v2/" in url)
new = cdp.open_tab(context, "https://example.com/other")
page = cdp.switch_to_tab(context, url_contains="example.com/other")
cdp.close_tab(page)cdp.goto(page, target_url, *, wait_until="domcontentloaded")
cdp.wait_for_url(page, predicate, *, timeout_s=60) -> str
cdp.back(page) / cdp.forward(page) / cdp.reload(page)predicate is a Callable[[str], bool] — receives the current URL, returns True when you're ready to proceed. The call returns the matched URL instantly when the predicate flips true.
All interaction helpers take (root, role, name, *, exact=True, timeout_s=15). root can be a Page (whole page) or a Locator (scoped to a region — essential for modal dialogs):
cdp.click(root, role, name)
cdp.fill(root, role, name, value)
cdp.check(root, role, name) # idempotent — won't toggle if already checked
cdp.press(page, "Enter", "Tab")
cdp.scroll_into_view(root, role, name)
cdp.hover(root, role, name)
cdp.dblclick(root, role, name)
cdp.right_click(root, role, name)
cdp.drag(page, source=("button", "Item A"), target=("region", "Trash"))
cdp.set_window_size(page, width=1280, height=720) # OS window via CDP
cdp.set_viewport(page, width=1280, height=720) # page-rendering viewport onlyname accepts a string (exact match by default) or a re.Pattern (use regex when the accessible name has trailing whitespace, multi-language strings, or partial matches).
Scoping example — when a "Save" button appears both in the page and a confirmation dialog:
dialog = page.get_by_role("dialog", name="Add users")
dialog.wait_for(state="visible")
cdp.fill(dialog, "textbox", "Email", "user@example.com")
cdp.click(dialog, "button", "Save")saved = cdp.capture_download(
page,
action=lambda: cdp.click(page, "button", "Download report"),
destination=Path("./downloads/report.pdf"),
timeout_s=30,
)
cdp.upload(page, "button", "Choose file", ["/path/to/file.pdf"])
# fetch a resource through the browser context (reuses the session cookies)
report = cdp.download_resource(context, "https://host/report.pdf", Path("./report.pdf"))
# {"status": 200, "bytes": 51234}cookies = cdp.get_cookies(context, urls=["https://example.com"])
cdp.set_cookie(context, name="session", value="abc", target_url="https://example.com")
cdp.clear_cookies(context)
ls = cdp.get_storage(page, kind="local")
cdp.set_storage_item(page, "k", "v", kind="session")
cdp.clear_storage(page, kind="local")
text = cdp.read_clipboard(page) # needs clipboard-read permission
cdp.write_clipboard(page, "hello")png_path, aria_path = cdp.dual_snapshot(page, "checkout", dest_dir=Path("./captures"))
# captures/01-checkout.png + captures/01-checkout.aria.yaml
cdp.screenshot(page, Path("./captures/full.png"), full_page=True)
yaml_text = cdp.aria_snapshot(page, destination=Path("./captures/page.aria.yaml"))
# same-origin iframes are inlined by default; pass include_frames=False to opt out
top_only = cdp.aria_snapshot(page, include_frames=False)
# run JS in a page or a frame and get the deserialised result
sources = cdp.evaluate(page, "[...document.querySelectorAll('video')].map(v => v.currentSrc)")
html = cdp.get_html(page) # full document
html = cdp.get_html(page, locator=page.get_by_role("main")) # one element
details = cdp.inspect_element(page, "button", "Submit")
# {"outer_html": ..., "tag": "button", "id": ..., "classes": [...], "attrs": {...},
# "bounding_box": {x, y, width, height}, "is_visible": True, "is_enabled": True}The numeric prefix NN- in dual snapshots is auto-assigned by scanning the destination directory — your snapshots end up in capture order.
with cdp.console_recorder(page) as recorder:
cdp.click(page, "button", "Submit")
cdp.wait_for_url(page, lambda url: "/done" in url)
print([event.text for event in recorder.errors])
with cdp.network_recorder(page, url_predicate=lambda url: "/api/" in url) as recorder:
cdp.click(page, "button", "Load more")
cdp.wait_for_url(page, lambda url: "/results" in url)
for entry in recorder.filter(method="POST", status=200):
print(entry.request_url, entry.response_json)
cdp.dump_network(recorder, Path("./captures/network.json"))NetworkRecorder automatically parses JSON response bodies. entry.response_json is the parsed object; entry.response_body is the raw text/bytes fallback. recorder.failed returns entries that errored or returned 4xx/5xx.
| Rule | Why |
|---|---|
| First arg is always the Playwright object | Never reads global state. Easy to test and compose. |
URLs as strings, predicates as Callable[[str], bool] |
Predicates beat substring matches for anything non-trivial. |
| Timeouts in seconds at the API boundary | Playwright uses ms internally; this converts. |
Snapshots are pairs (.png + .aria.yaml) |
PNG for review, ARIA YAML for stable selectors. |
Element helpers accept Page OR Locator as root |
Scope to a region for modal dialogs. |
| Anti-pattern | Replacement |
|---|---|
page.wait_for_timeout(5000) |
page.locator("h1").wait_for(state="visible") |
time.sleep(2); click_next() |
cdp.wait_for_url(page, predicate, timeout_s=30) |
sleep(N) hoping content renders |
page.wait_for_load_state("load") |
Polling URL with sleep |
page.wait_for_url(predicate) |
sleep(N) waiting for network |
cdp.network_recorder + wait on the request |
Fixed waits are simultaneously too slow (you wait the full duration even when ready in milliseconds) AND too fragile (you guess "enough time" and it breaks under load).
from pathlib import Path
from web_view import cdp
with cdp.connect() as (browser, context):
page = cdp.find_page(context, url_contains="example.com/signup")
cdp.fill(page, "textbox", "Email", "alice@example.com")
cdp.fill(page, "textbox", "Password", "••••••••")
cdp.check(page, "checkbox", "I agree to the terms")
cdp.click(page, "button", "Continue")
cdp.wait_for_url(page, lambda url: "/verify" in url, timeout_s=15)
cdp.dual_snapshot(page, "after-signup", dest_dir=Path("./captures"))from web_view import cdp
with cdp.connect() as (_, context):
page = cdp.find_page(context, url_contains="dashboard")
with cdp.network_recorder(page, url_predicate=lambda url: "/api/orders" in url) as recorder:
cdp.click(page, "button", "Refresh")
cdp.wait_for_url(page, lambda url: "/dashboard" in url)
orders = next(
(entry.response_json for entry in recorder.entries if entry.is_json()),
None,
)
print(orders)A common pattern for research: open Chrome headed, log in manually (handle 2FA, CAPTCHAs, etc.), then attach a Python script to the live session.
# Terminal 1
web-view start --port 9222
# Now log into the target site in the visible Chrome window.
# Terminal 2 (after you're logged in)
python my_script.pyIn my_script.py:
from web_view import cdp
with cdp.connect(port=9222) as (_, context):
page = cdp.find_page(context, url_contains="target-site.com")
...from web_view import cdp
with cdp.connect() as (_, context):
page = cdp.find_page(context, url_contains="target.com")
# Dump the role/name tree to YAML for offline browsing.
cdp.aria_snapshot(page, destination=Path("./scratch/tree.yaml"))
# Inspect a candidate element.
print(cdp.inspect_element(page, "link", "Sign in"))Open scratch/tree.yaml in your editor — every element shows up as - role "name". Pick the one you want, write the selector, re-run.
- Chrome path is auto-detected via
_resolve_chrome_binary():- macOS:
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome - Windows:
C:\Program Files\Google\Chrome\Application\chrome.exe(and(x86)) - Linux:
which google-chrome | chromium-browser | chromium | chrome - Override via
cdp.start_chrome(binary="/custom/path").
- macOS:
- Process control uses
pkill/os.killpgon Unix andtaskkill/process.terminate()on Windows. No manual platform branching needed in your code. - User profile dir: pick anywhere persistent.
~/.cache/web-view/profileis the CLI default. Keep one per research project to avoid cross-contaminating logins.
Clone, install in editable mode, hook up dev-quality:
git clone https://github.com/lipex360x/web-view
cd web-view
uv sync --extra dev
uv run pre-commit installThe pre-commit config uses dev-quality v0.15.3 — same check-all (abbreviation linter, comment linter, complexity / size caps, ruff check, ruff format) that drives the rest of the lipex360x ecosystem. Run it manually with:
uv run pre-commit run --all-filesTests:
uv run pytest- Releases & changelog: https://github.com/lipex360x/web-view/releases
- Design decisions (ADRs):
docs/adr/— start with0001-cli-interaction.md(records theweb-view do <verb>family design). - Issue tracker: https://github.com/lipex360x/web-view/issues
- AI contributor guide:
CLAUDE.md— the executable spec a fresh AI session should read before touching the repo.
web-view was extracted from the claude-brain repo where it grew up driving the GCP Console headed-Chrome bootstrap for the GWS integration. The discipline of "wait on state, never on time" came from too many flaky sleep(2) debugging sessions on that flow.
MIT — see LICENSE.