chrome-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chrome_agent/__init__.py +5 -0
- chrome_agent/__main__.py +5 -0
- chrome_agent/browser.py +190 -0
- chrome_agent/cli.py +279 -0
- chrome_agent/commands.py +370 -0
- chrome_agent/connection.py +109 -0
- chrome_agent/errors.py +45 -0
- chrome_agent-0.1.0.dist-info/METADATA +190 -0
- chrome_agent-0.1.0.dist-info/RECORD +11 -0
- chrome_agent-0.1.0.dist-info/WHEEL +4 -0
- chrome_agent-0.1.0.dist-info/entry_points.txt +3 -0
chrome_agent/__init__.py
ADDED
chrome_agent/__main__.py
ADDED
chrome_agent/browser.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Browser launcher with optional anti-detection fingerprinting.
|
|
2
|
+
|
|
3
|
+
Launches a Playwright Chromium browser with CDP enabled. Optionally
|
|
4
|
+
applies a fingerprint profile to make the browser appear as a real
|
|
5
|
+
desktop browser.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from playwright.async_api import Browser, Page, Playwright, async_playwright
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class BrowserFingerprint:
|
|
22
|
+
"""Browser fingerprint profile for anti-detection."""
|
|
23
|
+
|
|
24
|
+
user_agent: str
|
|
25
|
+
viewport: dict[str, int]
|
|
26
|
+
locale: str
|
|
27
|
+
timezone: str
|
|
28
|
+
platform: str
|
|
29
|
+
vendor: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BrowserSession:
|
|
34
|
+
"""Handle to a launched browser session."""
|
|
35
|
+
|
|
36
|
+
playwright: Playwright
|
|
37
|
+
browser: Browser
|
|
38
|
+
page: Page
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def load_fingerprint(*, path: str) -> BrowserFingerprint:
|
|
42
|
+
"""Load a browser fingerprint profile from a JSON file.
|
|
43
|
+
|
|
44
|
+
Expected JSON schema:
|
|
45
|
+
{
|
|
46
|
+
"userAgent": "...",
|
|
47
|
+
"platform": "...",
|
|
48
|
+
"vendor": "...",
|
|
49
|
+
"language": "en-US",
|
|
50
|
+
"timezone": "America/Chicago",
|
|
51
|
+
"viewport": {"width": 1920, "height": 1080}
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
with open(path, "r") as f:
|
|
55
|
+
data = json.load(f)
|
|
56
|
+
|
|
57
|
+
return BrowserFingerprint(
|
|
58
|
+
user_agent=data["userAgent"],
|
|
59
|
+
viewport=data["viewport"],
|
|
60
|
+
locale=data["language"],
|
|
61
|
+
timezone=data["timezone"],
|
|
62
|
+
platform=data["platform"],
|
|
63
|
+
vendor=data["vendor"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def launch_browser(
|
|
68
|
+
*,
|
|
69
|
+
port: int = 9222,
|
|
70
|
+
fingerprint: BrowserFingerprint | None = None,
|
|
71
|
+
headless: bool = False,
|
|
72
|
+
wm_class: str = "chrome-agent",
|
|
73
|
+
pin_to_desktop: bool = True,
|
|
74
|
+
) -> BrowserSession:
|
|
75
|
+
"""Launch a Chromium browser with CDP enabled.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
port: CDP remote debugging port.
|
|
79
|
+
fingerprint: Optional fingerprint for anti-detection. If None,
|
|
80
|
+
launches a clean browser with no spoofing.
|
|
81
|
+
headless: Run in headless mode.
|
|
82
|
+
wm_class: X11 window class name (for desktop pinning).
|
|
83
|
+
pin_to_desktop: Move browser window to the launching terminal's
|
|
84
|
+
virtual desktop. Default True. Linux/X11 only, requires
|
|
85
|
+
xdotool -- silently skipped on other platforms.
|
|
86
|
+
"""
|
|
87
|
+
playwright = await async_playwright().start()
|
|
88
|
+
|
|
89
|
+
launch_args = [
|
|
90
|
+
"--disable-blink-features=AutomationControlled",
|
|
91
|
+
f"--remote-debugging-port={port}",
|
|
92
|
+
f"--class={wm_class}",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
browser = await playwright.chromium.launch(
|
|
96
|
+
headless=headless,
|
|
97
|
+
args=launch_args,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Build context options
|
|
101
|
+
context_kwargs = {}
|
|
102
|
+
if fingerprint:
|
|
103
|
+
context_kwargs.update(
|
|
104
|
+
user_agent=fingerprint.user_agent,
|
|
105
|
+
viewport=fingerprint.viewport,
|
|
106
|
+
locale=fingerprint.locale,
|
|
107
|
+
timezone_id=fingerprint.timezone,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
context = await browser.new_context(**context_kwargs)
|
|
111
|
+
|
|
112
|
+
# Apply anti-detection init script if fingerprinted
|
|
113
|
+
if fingerprint:
|
|
114
|
+
await context.add_init_script(f"""
|
|
115
|
+
Object.defineProperty(navigator, 'webdriver', {{
|
|
116
|
+
get: () => false
|
|
117
|
+
}});
|
|
118
|
+
|
|
119
|
+
Object.defineProperty(navigator, 'platform', {{
|
|
120
|
+
get: () => '{fingerprint.platform}'
|
|
121
|
+
}});
|
|
122
|
+
|
|
123
|
+
Object.defineProperty(navigator, 'vendor', {{
|
|
124
|
+
get: () => '{fingerprint.vendor}'
|
|
125
|
+
}});
|
|
126
|
+
|
|
127
|
+
window.chrome = {{
|
|
128
|
+
runtime: {{}},
|
|
129
|
+
app: {{}}
|
|
130
|
+
}};
|
|
131
|
+
""")
|
|
132
|
+
|
|
133
|
+
page = await context.new_page()
|
|
134
|
+
|
|
135
|
+
session = BrowserSession(
|
|
136
|
+
playwright=playwright,
|
|
137
|
+
browser=browser,
|
|
138
|
+
page=page,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if pin_to_desktop:
|
|
142
|
+
await _move_to_launching_desktop(wm_class=wm_class)
|
|
143
|
+
|
|
144
|
+
return session
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _move_to_launching_desktop(*, wm_class: str) -> None:
|
|
148
|
+
"""Move the browser window to the terminal's virtual desktop.
|
|
149
|
+
|
|
150
|
+
Linux/X11 only. Requires xdotool. Silently does nothing if
|
|
151
|
+
xdotool is unavailable or on non-X11 systems.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
# Determine which desktop our terminal is on
|
|
155
|
+
window_id = os.environ.get("WINDOWID", "")
|
|
156
|
+
if window_id:
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
["xdotool", "get_desktop_for_window", window_id],
|
|
159
|
+
capture_output=True, text=True,
|
|
160
|
+
)
|
|
161
|
+
desktop = result.stdout.strip()
|
|
162
|
+
else:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
["xdotool", "get_desktop"],
|
|
165
|
+
capture_output=True, text=True,
|
|
166
|
+
)
|
|
167
|
+
desktop = result.stdout.strip()
|
|
168
|
+
|
|
169
|
+
if not desktop:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Wait for the browser window to appear
|
|
173
|
+
await asyncio.sleep(0.5)
|
|
174
|
+
|
|
175
|
+
# Find our browser windows by the custom WM_CLASS
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
["xdotool", "search", "--class", wm_class],
|
|
178
|
+
capture_output=True, text=True,
|
|
179
|
+
)
|
|
180
|
+
for wid in result.stdout.strip().split("\n"):
|
|
181
|
+
if wid.strip():
|
|
182
|
+
subprocess.run(
|
|
183
|
+
["xdotool", "set_desktop_for_window", wid.strip(), desktop],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
logger.info("Moved browser window(s) to desktop %s", desktop)
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
logger.debug("xdotool not available -- skipping desktop move")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.debug("Could not move browser to desktop: %s", e)
|
chrome_agent/cli.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""CLI entry point for chrome-agent.
|
|
2
|
+
|
|
3
|
+
Usage: chrome-agent [--port PORT] <command> [args...]
|
|
4
|
+
|
|
5
|
+
Single entry point with flat command dispatch. The --port flag is global
|
|
6
|
+
and parsed before the command name.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .commands import (
|
|
13
|
+
cmd_back,
|
|
14
|
+
cmd_check,
|
|
15
|
+
cmd_click,
|
|
16
|
+
cmd_clickxy,
|
|
17
|
+
cmd_close,
|
|
18
|
+
cmd_cookies,
|
|
19
|
+
cmd_element,
|
|
20
|
+
cmd_eval,
|
|
21
|
+
cmd_fill,
|
|
22
|
+
cmd_find,
|
|
23
|
+
cmd_forward,
|
|
24
|
+
cmd_hover,
|
|
25
|
+
cmd_html,
|
|
26
|
+
cmd_navigate,
|
|
27
|
+
cmd_press,
|
|
28
|
+
cmd_reload,
|
|
29
|
+
cmd_screenshot,
|
|
30
|
+
cmd_scroll,
|
|
31
|
+
cmd_select,
|
|
32
|
+
cmd_snapshot,
|
|
33
|
+
cmd_tabs,
|
|
34
|
+
cmd_text,
|
|
35
|
+
cmd_type,
|
|
36
|
+
cmd_uncheck,
|
|
37
|
+
cmd_url,
|
|
38
|
+
cmd_value,
|
|
39
|
+
cmd_viewport,
|
|
40
|
+
cmd_wait,
|
|
41
|
+
)
|
|
42
|
+
from .connection import check_cdp_port, connect, disconnect
|
|
43
|
+
from .errors import ChromeAgentError
|
|
44
|
+
|
|
45
|
+
# Default CDP port
|
|
46
|
+
DEFAULT_PORT = 9222
|
|
47
|
+
|
|
48
|
+
# Command registry: name -> (func, nargs, description)
|
|
49
|
+
# nargs: 0 = no args, 1 = one required arg, 2 = two required args, -1 = optional args
|
|
50
|
+
COMMANDS = {
|
|
51
|
+
# Observe
|
|
52
|
+
"url": (cmd_url, 0, "Print current URL and title"),
|
|
53
|
+
"screenshot": (cmd_screenshot, -1, "Save screenshot [path]"),
|
|
54
|
+
"snapshot": (cmd_snapshot, 0, "Print accessibility tree"),
|
|
55
|
+
"text": (cmd_text, 0, "Print page visible text"),
|
|
56
|
+
"html": (cmd_html, -1, "Print page HTML [selector]"),
|
|
57
|
+
"element": (cmd_element, 1, "Inspect element <selector>"),
|
|
58
|
+
"find": (cmd_find, 1, "Find elements <selector>"),
|
|
59
|
+
"value": (cmd_value, 1, "Get input value <selector>"),
|
|
60
|
+
"eval": (cmd_eval, 1, "Execute JavaScript <code>"),
|
|
61
|
+
"cookies": (cmd_cookies, 0, "Dump cookies"),
|
|
62
|
+
"tabs": (cmd_tabs, 0, "List open pages"),
|
|
63
|
+
"wait": (cmd_wait, 1, "Wait for <selector|ms|load>"),
|
|
64
|
+
# Navigate
|
|
65
|
+
"navigate": (cmd_navigate, 1, "Go to <url>"),
|
|
66
|
+
"back": (cmd_back, 0, "Browser back"),
|
|
67
|
+
"forward": (cmd_forward, 0, "Browser forward"),
|
|
68
|
+
"reload": (cmd_reload, 0, "Reload page"),
|
|
69
|
+
# Interact
|
|
70
|
+
"click": (cmd_click, 1, "Click <selector>"),
|
|
71
|
+
"clickxy": (cmd_clickxy, 2, "Click at <x> <y>"),
|
|
72
|
+
"fill": (cmd_fill, 2, "Fill <selector> <value>"),
|
|
73
|
+
"type": (cmd_type, 2, "Type into <selector> <text>"),
|
|
74
|
+
"press": (cmd_press, 1, "Press key <Enter|Escape|Tab|...>"),
|
|
75
|
+
"select": (cmd_select, 2, "Select option <selector> <value>"),
|
|
76
|
+
"check": (cmd_check, 1, "Check checkbox <selector>"),
|
|
77
|
+
"uncheck": (cmd_uncheck, 1, "Uncheck checkbox <selector>"),
|
|
78
|
+
"hover": (cmd_hover, 1, "Hover over <selector>"),
|
|
79
|
+
"scroll": (cmd_scroll, 1, "Scroll <selector|up|down>"),
|
|
80
|
+
# Meta
|
|
81
|
+
"close": (cmd_close, 0, "Close current page"),
|
|
82
|
+
"viewport": (cmd_viewport, 2, "Resize viewport <width> <height>"),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Commands that don't need a CDP connection
|
|
86
|
+
LOCAL_COMMANDS = {"status", "launch", "help"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _print_help() -> None:
|
|
90
|
+
"""Print command reference."""
|
|
91
|
+
print("chrome-agent -- CLI for AI agents to observe and interact with Chrome\n")
|
|
92
|
+
print("Usage: chrome-agent [--port PORT] <command> [args...]\n")
|
|
93
|
+
print("Commands:")
|
|
94
|
+
print(f" {'status':12s} Check if browser is running on CDP port")
|
|
95
|
+
print(f" {'launch':12s} Launch a browser with CDP enabled [--fingerprint PATH] [--headless] [--no-pin-desktop]")
|
|
96
|
+
print(f" {'help':12s} Print this help message")
|
|
97
|
+
print()
|
|
98
|
+
for name, (_, _, desc) in COMMANDS.items():
|
|
99
|
+
print(f" {name:12s} {desc}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_global_args(argv: list[str]) -> tuple[int, list[str]]:
|
|
103
|
+
"""Extract --port from argv, return (port, remaining_args)."""
|
|
104
|
+
port = DEFAULT_PORT
|
|
105
|
+
remaining = []
|
|
106
|
+
i = 0
|
|
107
|
+
while i < len(argv):
|
|
108
|
+
if argv[i] == "--port" and i + 1 < len(argv):
|
|
109
|
+
try:
|
|
110
|
+
port = int(argv[i + 1])
|
|
111
|
+
except ValueError:
|
|
112
|
+
print(f"ERROR: Invalid port: {argv[i + 1]}")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
i += 2
|
|
115
|
+
else:
|
|
116
|
+
remaining.append(argv[i])
|
|
117
|
+
i += 1
|
|
118
|
+
return port, remaining
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _run_command(*, port: int, cmd_name: str, args: list[str]) -> None:
|
|
122
|
+
"""Connect to browser, run a command, disconnect."""
|
|
123
|
+
func, expected_args, _ = COMMANDS[cmd_name]
|
|
124
|
+
|
|
125
|
+
# Validate argument count
|
|
126
|
+
if expected_args >= 0 and len(args) < expected_args:
|
|
127
|
+
print(f"Command '{cmd_name}' requires {expected_args} argument(s)")
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
pw, browser, page = await connect(port=port)
|
|
131
|
+
try:
|
|
132
|
+
# Dispatch based on argument pattern
|
|
133
|
+
if cmd_name == "tabs":
|
|
134
|
+
# cmd_tabs needs both page and browser
|
|
135
|
+
await func(page=page, browser=browser)
|
|
136
|
+
elif expected_args == 0:
|
|
137
|
+
await func(page=page)
|
|
138
|
+
elif expected_args == -1:
|
|
139
|
+
# Optional args
|
|
140
|
+
if cmd_name == "screenshot" and args:
|
|
141
|
+
await func(page=page, path=args[0])
|
|
142
|
+
elif cmd_name == "html" and args:
|
|
143
|
+
await func(page=page, selector=args[0])
|
|
144
|
+
else:
|
|
145
|
+
await func(page=page)
|
|
146
|
+
elif expected_args == 1:
|
|
147
|
+
# Single required arg -- map to the right parameter name
|
|
148
|
+
arg = args[0]
|
|
149
|
+
if cmd_name in ("element", "find", "value", "click", "check",
|
|
150
|
+
"uncheck", "hover"):
|
|
151
|
+
await func(page=page, selector=arg)
|
|
152
|
+
elif cmd_name == "eval":
|
|
153
|
+
await func(page=page, js_code=arg)
|
|
154
|
+
elif cmd_name == "navigate":
|
|
155
|
+
await func(page=page, url=arg)
|
|
156
|
+
elif cmd_name == "wait":
|
|
157
|
+
await func(page=page, target=arg)
|
|
158
|
+
elif cmd_name == "press":
|
|
159
|
+
await func(page=page, key=arg)
|
|
160
|
+
elif cmd_name == "scroll":
|
|
161
|
+
await func(page=page, target=arg)
|
|
162
|
+
else:
|
|
163
|
+
await func(page=page)
|
|
164
|
+
elif expected_args == 2:
|
|
165
|
+
# Two required args -- second arg may be multi-word (join remaining)
|
|
166
|
+
arg1 = args[0]
|
|
167
|
+
arg2 = " ".join(args[1:])
|
|
168
|
+
if cmd_name == "clickxy":
|
|
169
|
+
await func(page=page, x=float(arg1), y=float(arg2))
|
|
170
|
+
elif cmd_name == "fill":
|
|
171
|
+
await func(page=page, selector=arg1, value=arg2)
|
|
172
|
+
elif cmd_name == "type":
|
|
173
|
+
await func(page=page, selector=arg1, text=arg2)
|
|
174
|
+
elif cmd_name == "select":
|
|
175
|
+
await func(page=page, selector=arg1, value=arg2)
|
|
176
|
+
elif cmd_name == "viewport":
|
|
177
|
+
await func(page=page, width=int(arg1), height=int(arg2))
|
|
178
|
+
else:
|
|
179
|
+
await func(page=page)
|
|
180
|
+
finally:
|
|
181
|
+
await disconnect(pw=pw)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _run_status(*, port: int) -> None:
|
|
185
|
+
"""Check if a browser is running on the CDP port."""
|
|
186
|
+
status = check_cdp_port(port=port)
|
|
187
|
+
if status.listening:
|
|
188
|
+
print(f"Browser running on port {port}")
|
|
189
|
+
if status.browser_version:
|
|
190
|
+
print(f" Version: {status.browser_version}")
|
|
191
|
+
if status.page_url:
|
|
192
|
+
print(f" URL: {status.page_url}")
|
|
193
|
+
if status.page_title:
|
|
194
|
+
print(f" Title: {status.page_title}")
|
|
195
|
+
else:
|
|
196
|
+
print(f"No browser running on port {port}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _run_launch(*, port: int, args: list[str]) -> None:
|
|
200
|
+
"""Launch a browser with CDP enabled."""
|
|
201
|
+
# Import here to avoid circular imports and loading browser module
|
|
202
|
+
# when it's not needed
|
|
203
|
+
from .browser import launch_browser, load_fingerprint
|
|
204
|
+
|
|
205
|
+
# Parse launch-specific flags
|
|
206
|
+
fingerprint_path = None
|
|
207
|
+
headless = False
|
|
208
|
+
pin_desktop = True # Default: pin browser to launching terminal's desktop
|
|
209
|
+
|
|
210
|
+
i = 0
|
|
211
|
+
while i < len(args):
|
|
212
|
+
if args[i] == "--fingerprint" and i + 1 < len(args):
|
|
213
|
+
fingerprint_path = args[i + 1]
|
|
214
|
+
i += 2
|
|
215
|
+
elif args[i] == "--headless":
|
|
216
|
+
headless = True
|
|
217
|
+
i += 1
|
|
218
|
+
elif args[i] == "--no-pin-desktop":
|
|
219
|
+
pin_desktop = False
|
|
220
|
+
i += 1
|
|
221
|
+
else:
|
|
222
|
+
print(f"Unknown launch option: {args[i]}")
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
|
|
225
|
+
# Load fingerprint if specified
|
|
226
|
+
fingerprint = None
|
|
227
|
+
if fingerprint_path:
|
|
228
|
+
fingerprint = await load_fingerprint(path=fingerprint_path)
|
|
229
|
+
|
|
230
|
+
session = await launch_browser(
|
|
231
|
+
port=port,
|
|
232
|
+
fingerprint=fingerprint,
|
|
233
|
+
headless=headless,
|
|
234
|
+
pin_to_desktop=pin_desktop,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
print(f"Browser launched on CDP port {port}")
|
|
238
|
+
print("Press Ctrl+C to close.")
|
|
239
|
+
|
|
240
|
+
# Keep alive until interrupted
|
|
241
|
+
try:
|
|
242
|
+
while True:
|
|
243
|
+
await asyncio.sleep(60)
|
|
244
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
245
|
+
pass
|
|
246
|
+
finally:
|
|
247
|
+
await session.browser.close()
|
|
248
|
+
await session.playwright.stop()
|
|
249
|
+
print("\nBrowser closed.")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main() -> None:
|
|
253
|
+
"""CLI entry point."""
|
|
254
|
+
port, remaining = _parse_global_args(argv=sys.argv[1:])
|
|
255
|
+
|
|
256
|
+
if not remaining or remaining[0] in ("-h", "--help", "help"):
|
|
257
|
+
_print_help()
|
|
258
|
+
sys.exit(0)
|
|
259
|
+
|
|
260
|
+
cmd_name = remaining[0]
|
|
261
|
+
args = remaining[1:]
|
|
262
|
+
|
|
263
|
+
if cmd_name == "status":
|
|
264
|
+
asyncio.run(_run_status(port=port))
|
|
265
|
+
elif cmd_name == "launch":
|
|
266
|
+
asyncio.run(_run_launch(port=port, args=args))
|
|
267
|
+
elif cmd_name in COMMANDS:
|
|
268
|
+
try:
|
|
269
|
+
asyncio.run(_run_command(port=port, cmd_name=cmd_name, args=args))
|
|
270
|
+
except ChromeAgentError as e:
|
|
271
|
+
print(f"ERROR: {e}")
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
print(f"ERROR: {e}")
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
else:
|
|
277
|
+
print(f"Unknown command: {cmd_name}")
|
|
278
|
+
print("Run 'chrome-agent help' to see available commands")
|
|
279
|
+
sys.exit(1)
|
chrome_agent/commands.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Browser observation, navigation, and interaction commands.
|
|
2
|
+
|
|
3
|
+
Each command is an async function that accepts a Playwright Page (and
|
|
4
|
+
optionally Browser) as its first argument. Commands print their results
|
|
5
|
+
to stdout and raise exceptions on failure. The CLI layer handles
|
|
6
|
+
connection lifecycle and error presentation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from playwright.async_api import Browser, Page
|
|
12
|
+
|
|
13
|
+
from .errors import ElementNotFoundError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Observation commands
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def cmd_url(*, page: Page) -> None:
|
|
22
|
+
"""Print current URL and page title."""
|
|
23
|
+
print(f"URL: {page.url}")
|
|
24
|
+
print(f"Title: {await page.title()}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def cmd_screenshot(*, page: Page, path: str = "/tmp/cdp-screenshot.png") -> None:
|
|
28
|
+
"""Save a screenshot of the current page."""
|
|
29
|
+
await page.screenshot(path=path)
|
|
30
|
+
print(f"Screenshot saved: {path}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def cmd_snapshot(*, page: Page) -> None:
|
|
34
|
+
"""Print the accessibility tree of the current page (ARIA snapshot)."""
|
|
35
|
+
snapshot = await page.locator(":root").aria_snapshot()
|
|
36
|
+
if snapshot:
|
|
37
|
+
print(snapshot)
|
|
38
|
+
else:
|
|
39
|
+
print("(empty accessibility tree)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def cmd_text(*, page: Page) -> None:
|
|
43
|
+
"""Print visible text content of the page."""
|
|
44
|
+
text = await page.inner_text("body")
|
|
45
|
+
if len(text) > 5000:
|
|
46
|
+
print(text[:5000])
|
|
47
|
+
print(f"\n... (truncated, {len(text)} total chars)")
|
|
48
|
+
else:
|
|
49
|
+
print(text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def cmd_html(*, page: Page, selector: str | None = None) -> None:
|
|
53
|
+
"""Print page HTML source, or a specific element's outerHTML."""
|
|
54
|
+
if selector:
|
|
55
|
+
el = page.locator(selector).first
|
|
56
|
+
if await el.count() > 0:
|
|
57
|
+
html = await el.evaluate("el => el.outerHTML")
|
|
58
|
+
print(html)
|
|
59
|
+
else:
|
|
60
|
+
raise ElementNotFoundError(selector=selector)
|
|
61
|
+
else:
|
|
62
|
+
html = await page.content()
|
|
63
|
+
if len(html) > 10000:
|
|
64
|
+
print(html[:10000])
|
|
65
|
+
print(f"\n... (truncated, {len(html)} total chars)")
|
|
66
|
+
else:
|
|
67
|
+
print(html)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def cmd_element(*, page: Page, selector: str) -> None:
|
|
71
|
+
"""Print detailed info about a specific element."""
|
|
72
|
+
loc = page.locator(selector).first
|
|
73
|
+
|
|
74
|
+
if await loc.count() == 0:
|
|
75
|
+
raise ElementNotFoundError(selector=selector)
|
|
76
|
+
|
|
77
|
+
info = await loc.evaluate("""el => {
|
|
78
|
+
const cs = getComputedStyle(el);
|
|
79
|
+
const rect = el.getBoundingClientRect();
|
|
80
|
+
const attrs = {};
|
|
81
|
+
for (const a of el.attributes) attrs[a.name] = a.value;
|
|
82
|
+
return {
|
|
83
|
+
tag: el.tagName,
|
|
84
|
+
id: el.id,
|
|
85
|
+
text: el.innerText?.substring(0, 200) || '',
|
|
86
|
+
value: el.value || null,
|
|
87
|
+
type: el.type || null,
|
|
88
|
+
visible: el.offsetParent !== null || cs.display !== 'none',
|
|
89
|
+
display: cs.display,
|
|
90
|
+
visibility: cs.visibility,
|
|
91
|
+
opacity: cs.opacity,
|
|
92
|
+
dimensions: { width: rect.width, height: rect.height },
|
|
93
|
+
position: { x: Math.round(rect.x), y: Math.round(rect.y) },
|
|
94
|
+
inViewport: rect.top >= 0 && rect.left >= 0 &&
|
|
95
|
+
rect.bottom <= window.innerHeight &&
|
|
96
|
+
rect.right <= window.innerWidth,
|
|
97
|
+
disabled: el.disabled || false,
|
|
98
|
+
checked: el.checked ?? null,
|
|
99
|
+
href: el.href || null,
|
|
100
|
+
attributes: attrs,
|
|
101
|
+
};
|
|
102
|
+
}""")
|
|
103
|
+
|
|
104
|
+
print(f"Selector: {selector}")
|
|
105
|
+
print(f"Tag: {info['tag']}")
|
|
106
|
+
if info["type"]:
|
|
107
|
+
print(f"Type: {info['type']}")
|
|
108
|
+
if info["id"]:
|
|
109
|
+
print(f"ID: {info['id']}")
|
|
110
|
+
print(f"Text: {info['text'][:100] if info['text'] else '(empty)'}")
|
|
111
|
+
if info["value"] is not None:
|
|
112
|
+
print(f"Value: {info['value']}")
|
|
113
|
+
print(f"Visible: {info['visible']}")
|
|
114
|
+
print(f"Display: {info['display']}")
|
|
115
|
+
print(f"Visibility: {info['visibility']}")
|
|
116
|
+
print(f"Opacity: {info['opacity']}")
|
|
117
|
+
print(f"Dimensions: {info['dimensions']['width']:.0f} x {info['dimensions']['height']:.0f}")
|
|
118
|
+
print(f"Position: ({info['position']['x']}, {info['position']['y']})")
|
|
119
|
+
print(f"In viewport: {info['inViewport']}")
|
|
120
|
+
if info["disabled"]:
|
|
121
|
+
print(f"Disabled: {info['disabled']}")
|
|
122
|
+
if info["checked"] is not None:
|
|
123
|
+
print(f"Checked: {info['checked']}")
|
|
124
|
+
if info["href"]:
|
|
125
|
+
print(f"Href: {info['href']}")
|
|
126
|
+
if info["attributes"]:
|
|
127
|
+
print(f"Attributes: {json.dumps(info['attributes'], indent=2)}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def cmd_find(*, page: Page, selector: str) -> None:
|
|
131
|
+
"""Count and summarize elements matching a selector."""
|
|
132
|
+
loc = page.locator(selector)
|
|
133
|
+
count = await loc.count()
|
|
134
|
+
|
|
135
|
+
print(f"Selector: {selector}")
|
|
136
|
+
print(f"Count: {count}")
|
|
137
|
+
|
|
138
|
+
for i in range(min(count, 20)):
|
|
139
|
+
item = loc.nth(i)
|
|
140
|
+
summary = await item.evaluate("""el => ({
|
|
141
|
+
tag: el.tagName,
|
|
142
|
+
text: (el.innerText || '').substring(0, 80),
|
|
143
|
+
visible: el.offsetParent !== null,
|
|
144
|
+
id: el.id || null,
|
|
145
|
+
cls: el.className?.substring?.(0, 60) || null,
|
|
146
|
+
})""")
|
|
147
|
+
vis = "visible" if summary["visible"] else "hidden"
|
|
148
|
+
text = summary["text"].replace("\n", " ").strip()[:60]
|
|
149
|
+
ident = summary["id"] or summary["cls"] or ""
|
|
150
|
+
print(f" [{i}] <{summary['tag']}> {vis} | {ident} | {text}")
|
|
151
|
+
|
|
152
|
+
if count > 20:
|
|
153
|
+
print(f" ... ({count - 20} more)")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def cmd_value(*, page: Page, selector: str) -> None:
|
|
157
|
+
"""Get the current value of an input element."""
|
|
158
|
+
loc = page.locator(selector).first
|
|
159
|
+
if await loc.count() == 0:
|
|
160
|
+
raise ElementNotFoundError(selector=selector)
|
|
161
|
+
val = await loc.input_value()
|
|
162
|
+
print(val)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def cmd_eval(*, page: Page, js_code: str) -> None:
|
|
166
|
+
"""Execute arbitrary JavaScript and print the result."""
|
|
167
|
+
result = await page.evaluate(js_code)
|
|
168
|
+
if isinstance(result, (dict, list)):
|
|
169
|
+
print(json.dumps(result, indent=2))
|
|
170
|
+
else:
|
|
171
|
+
print(result)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def cmd_cookies(*, page: Page) -> None:
|
|
175
|
+
"""Dump all cookies for the current page."""
|
|
176
|
+
context = page.context
|
|
177
|
+
cookies = await context.cookies()
|
|
178
|
+
for c in cookies:
|
|
179
|
+
exp = c.get("expires", -1)
|
|
180
|
+
exp_str = f"expires={exp}" if exp > 0 else "session"
|
|
181
|
+
print(
|
|
182
|
+
f" {c['name']}={c['value'][:40]}"
|
|
183
|
+
f"{'...' if len(c['value']) > 40 else ''}"
|
|
184
|
+
f" ({c['domain']}, {exp_str})"
|
|
185
|
+
)
|
|
186
|
+
if not cookies:
|
|
187
|
+
print("(no cookies)")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def cmd_tabs(*, page: Page, browser: Browser) -> None:
|
|
191
|
+
"""List all open pages/tabs in the browser."""
|
|
192
|
+
for ctx in browser.contexts:
|
|
193
|
+
for i, p in enumerate(ctx.pages):
|
|
194
|
+
marker = " *" if p == page else ""
|
|
195
|
+
print(f" [{i}] {p.url}")
|
|
196
|
+
print(f" {await p.title()}{marker}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def cmd_wait(*, page: Page, target: str) -> None:
|
|
200
|
+
"""Wait for a selector, milliseconds, or load state."""
|
|
201
|
+
if target.isdigit():
|
|
202
|
+
ms = int(target)
|
|
203
|
+
print(f"Waiting {ms}ms...")
|
|
204
|
+
await page.wait_for_timeout(ms)
|
|
205
|
+
print("Done")
|
|
206
|
+
elif target in ("load", "domcontentloaded", "networkidle"):
|
|
207
|
+
print(f"Waiting for {target}...")
|
|
208
|
+
await page.wait_for_load_state(target)
|
|
209
|
+
print("Done")
|
|
210
|
+
else:
|
|
211
|
+
print(f"Waiting for selector: {target}")
|
|
212
|
+
await page.locator(target).first.wait_for(timeout=30000)
|
|
213
|
+
print("Found")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Navigation commands
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def cmd_navigate(*, page: Page, url: str) -> None:
|
|
222
|
+
"""Navigate to a URL and wait for load."""
|
|
223
|
+
response = await page.goto(url)
|
|
224
|
+
status = response.status if response else "unknown"
|
|
225
|
+
print(f"Navigated to: {page.url}")
|
|
226
|
+
print(f"Status: {status}")
|
|
227
|
+
print(f"Title: {await page.title()}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def cmd_back(*, page: Page) -> None:
|
|
231
|
+
"""Go back in browser history."""
|
|
232
|
+
await page.go_back()
|
|
233
|
+
print(f"URL: {page.url}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def cmd_forward(*, page: Page) -> None:
|
|
237
|
+
"""Go forward in browser history."""
|
|
238
|
+
await page.go_forward()
|
|
239
|
+
print(f"URL: {page.url}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def cmd_reload(*, page: Page) -> None:
|
|
243
|
+
"""Reload the current page."""
|
|
244
|
+
await page.reload()
|
|
245
|
+
print(f"Reloaded: {page.url}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Interaction commands
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def cmd_click(*, page: Page, selector: str) -> None:
|
|
254
|
+
"""Click an element. Falls back to JS click if not actionable."""
|
|
255
|
+
loc = page.locator(selector).first
|
|
256
|
+
if await loc.count() == 0:
|
|
257
|
+
raise ElementNotFoundError(selector=selector)
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
await loc.click(timeout=5000)
|
|
261
|
+
print(f"Clicked: {selector}")
|
|
262
|
+
except Exception:
|
|
263
|
+
# Fallback to JS click for hidden/obscured elements
|
|
264
|
+
try:
|
|
265
|
+
await loc.evaluate("el => el.click()")
|
|
266
|
+
print(f"JS-clicked: {selector} (element was not actionable)")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print(f"ERROR clicking {selector}: {e}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def cmd_clickxy(*, page: Page, x: float, y: float) -> None:
|
|
272
|
+
"""Click at page coordinates."""
|
|
273
|
+
await page.mouse.click(x=x, y=y)
|
|
274
|
+
print(f"Clicked at ({x}, {y})")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def cmd_fill(*, page: Page, selector: str, value: str) -> None:
|
|
278
|
+
"""Fill a form field (clears first, dispatches events)."""
|
|
279
|
+
loc = page.locator(selector).first
|
|
280
|
+
if await loc.count() == 0:
|
|
281
|
+
raise ElementNotFoundError(selector=selector)
|
|
282
|
+
await loc.fill(value)
|
|
283
|
+
print(f"Filled {selector} with: {value}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def cmd_type(*, page: Page, selector: str, text: str) -> None:
|
|
287
|
+
"""Type text character by character into an element."""
|
|
288
|
+
loc = page.locator(selector).first
|
|
289
|
+
if await loc.count() == 0:
|
|
290
|
+
raise ElementNotFoundError(selector=selector)
|
|
291
|
+
await loc.type(text)
|
|
292
|
+
print(f"Typed into {selector}: {text}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def cmd_press(*, page: Page, key: str) -> None:
|
|
296
|
+
"""Press a keyboard key (Enter, Escape, Tab, etc.)."""
|
|
297
|
+
await page.keyboard.press(key)
|
|
298
|
+
print(f"Pressed: {key}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def cmd_select(*, page: Page, selector: str, value: str) -> None:
|
|
302
|
+
"""Select an option from a dropdown."""
|
|
303
|
+
loc = page.locator(selector).first
|
|
304
|
+
if await loc.count() == 0:
|
|
305
|
+
raise ElementNotFoundError(selector=selector)
|
|
306
|
+
await loc.select_option(value=value)
|
|
307
|
+
print(f"Selected {value} in {selector}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def cmd_check(*, page: Page, selector: str) -> None:
|
|
311
|
+
"""Check a checkbox."""
|
|
312
|
+
loc = page.locator(selector).first
|
|
313
|
+
if await loc.count() == 0:
|
|
314
|
+
raise ElementNotFoundError(selector=selector)
|
|
315
|
+
await loc.check()
|
|
316
|
+
print(f"Checked: {selector}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def cmd_uncheck(*, page: Page, selector: str) -> None:
|
|
320
|
+
"""Uncheck a checkbox."""
|
|
321
|
+
loc = page.locator(selector).first
|
|
322
|
+
if await loc.count() == 0:
|
|
323
|
+
raise ElementNotFoundError(selector=selector)
|
|
324
|
+
await loc.uncheck()
|
|
325
|
+
print(f"Unchecked: {selector}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def cmd_hover(*, page: Page, selector: str) -> None:
|
|
329
|
+
"""Hover over an element."""
|
|
330
|
+
loc = page.locator(selector).first
|
|
331
|
+
if await loc.count() == 0:
|
|
332
|
+
raise ElementNotFoundError(selector=selector)
|
|
333
|
+
await loc.hover()
|
|
334
|
+
print(f"Hovering: {selector}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def cmd_scroll(*, page: Page, target: str) -> None:
|
|
338
|
+
"""Scroll to an element, or scroll the page (up/down)."""
|
|
339
|
+
if target == "up":
|
|
340
|
+
await page.mouse.wheel(delta_x=0, delta_y=-500)
|
|
341
|
+
print("Scrolled up")
|
|
342
|
+
elif target == "down":
|
|
343
|
+
await page.mouse.wheel(delta_x=0, delta_y=500)
|
|
344
|
+
print("Scrolled down")
|
|
345
|
+
else:
|
|
346
|
+
loc = page.locator(target).first
|
|
347
|
+
if await loc.count() == 0:
|
|
348
|
+
raise ElementNotFoundError(selector=target)
|
|
349
|
+
await loc.scroll_into_view_if_needed()
|
|
350
|
+
print(f"Scrolled to: {target}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Meta commands (operate on page/viewport, not elements)
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def cmd_close(*, page: Page) -> None:
|
|
359
|
+
"""Close the current page."""
|
|
360
|
+
url = page.url
|
|
361
|
+
await page.close()
|
|
362
|
+
print(f"Closed: {url}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def cmd_viewport(*, page: Page, width: int, height: int) -> None:
|
|
366
|
+
"""Resize the viewport."""
|
|
367
|
+
await page.set_viewport_size({"width": width, "height": height})
|
|
368
|
+
print(f"Viewport set to {width}x{height}")
|
|
369
|
+
|
|
370
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""CDP connection management.
|
|
2
|
+
|
|
3
|
+
Handles connecting to and disconnecting from a Chrome browser via the
|
|
4
|
+
Chrome DevTools Protocol. Also provides port status checking using only
|
|
5
|
+
stdlib (no Playwright needed for status).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from playwright.async_api import Playwright, async_playwright
|
|
15
|
+
|
|
16
|
+
from .errors import BrowserConnectionError, NoPageError
|
|
17
|
+
|
|
18
|
+
# Suppress Node.js deprecation warnings from Playwright internals.
|
|
19
|
+
# This must be set before Playwright spawns its Node.js subprocess.
|
|
20
|
+
os.environ.setdefault("NODE_OPTIONS", "--no-deprecation")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PortStatus:
|
|
25
|
+
"""Result of checking whether a CDP port is active."""
|
|
26
|
+
|
|
27
|
+
listening: bool
|
|
28
|
+
browser_version: str | None = None
|
|
29
|
+
page_url: str | None = None
|
|
30
|
+
page_title: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_cdp_port(*, port: int = 9222) -> PortStatus:
|
|
34
|
+
"""Check if a browser is listening on the CDP port.
|
|
35
|
+
|
|
36
|
+
Uses stdlib only (socket + urllib) -- no Playwright dependency.
|
|
37
|
+
This is a synchronous function since it only does simple HTTP.
|
|
38
|
+
"""
|
|
39
|
+
# Quick socket check first
|
|
40
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
41
|
+
sock.settimeout(1)
|
|
42
|
+
try:
|
|
43
|
+
result = sock.connect_ex(("localhost", port))
|
|
44
|
+
if result != 0:
|
|
45
|
+
return PortStatus(listening=False)
|
|
46
|
+
finally:
|
|
47
|
+
sock.close()
|
|
48
|
+
|
|
49
|
+
# Port is open -- try to get browser version
|
|
50
|
+
browser_version = None
|
|
51
|
+
try:
|
|
52
|
+
req = urllib.request.Request(f"http://localhost:{port}/json/version")
|
|
53
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
54
|
+
data = json.loads(resp.read())
|
|
55
|
+
browser_version = data.get("Browser")
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Try to get the first page's URL and title
|
|
60
|
+
page_url = None
|
|
61
|
+
page_title = None
|
|
62
|
+
try:
|
|
63
|
+
req = urllib.request.Request(f"http://localhost:{port}/json")
|
|
64
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
65
|
+
pages = json.loads(resp.read())
|
|
66
|
+
if pages:
|
|
67
|
+
page_url = pages[0].get("url")
|
|
68
|
+
page_title = pages[0].get("title")
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
return PortStatus(
|
|
73
|
+
listening=True,
|
|
74
|
+
browser_version=browser_version,
|
|
75
|
+
page_url=page_url,
|
|
76
|
+
page_title=page_title,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def connect(*, port: int = 9222):
|
|
81
|
+
"""Connect to a running browser via CDP.
|
|
82
|
+
|
|
83
|
+
Returns (playwright, browser, page) tuple. The caller is responsible
|
|
84
|
+
for calling disconnect() when done.
|
|
85
|
+
|
|
86
|
+
Raises BrowserConnectionError if no browser is listening.
|
|
87
|
+
Raises NoPageError if the browser has no open pages.
|
|
88
|
+
"""
|
|
89
|
+
pw = await async_playwright().start()
|
|
90
|
+
|
|
91
|
+
cdp_url = f"http://localhost:{port}"
|
|
92
|
+
try:
|
|
93
|
+
browser = await pw.chromium.connect_over_cdp(cdp_url)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
await pw.stop()
|
|
96
|
+
raise BrowserConnectionError(port=port) from exc
|
|
97
|
+
|
|
98
|
+
contexts = browser.contexts
|
|
99
|
+
if not contexts or not contexts[0].pages:
|
|
100
|
+
await pw.stop()
|
|
101
|
+
raise NoPageError()
|
|
102
|
+
|
|
103
|
+
page = contexts[0].pages[0]
|
|
104
|
+
return pw, browser, page
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def disconnect(*, pw: Playwright) -> None:
|
|
108
|
+
"""Clean up a Playwright connection."""
|
|
109
|
+
await pw.stop()
|
chrome_agent/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Custom exceptions for chrome-agent.
|
|
2
|
+
|
|
3
|
+
Each exception produces an actionable error message that tells the agent
|
|
4
|
+
what went wrong and what to do about it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChromeAgentError(Exception):
|
|
9
|
+
"""Base exception for all chrome-agent errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BrowserConnectionError(ChromeAgentError):
|
|
13
|
+
"""Cannot connect to a browser via CDP.
|
|
14
|
+
|
|
15
|
+
Raised when no browser is listening on the expected CDP port.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *, port: int = 9222):
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"No browser running on port {port}. "
|
|
21
|
+
f"Start one with: chrome-agent launch"
|
|
22
|
+
)
|
|
23
|
+
self.port = port
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NoPageError(ChromeAgentError):
|
|
27
|
+
"""Browser is connected but has no open pages.
|
|
28
|
+
|
|
29
|
+
This typically happens when the browser was just launched and hasn't
|
|
30
|
+
navigated anywhere, or when all tabs have been closed.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__("Browser is running but has no open pages.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ElementNotFoundError(ChromeAgentError):
|
|
38
|
+
"""No element matched the given selector.
|
|
39
|
+
|
|
40
|
+
Raised by commands that require an element to exist (click, fill, etc.).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, selector: str):
|
|
44
|
+
super().__init__(f"No element found for selector: {selector}")
|
|
45
|
+
self.selector = selector
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chrome-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for AI agents to observe and interact with Chrome via CDP
|
|
5
|
+
Keywords: chrome,cdp,devtools,browser,automation,agent
|
|
6
|
+
Author: Corey Gallon
|
|
7
|
+
Author-email: Corey Gallon <366332+captivus@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Dist: playwright>=1.50.0
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Project-URL: Homepage, https://github.com/captivus/chrome-agent
|
|
22
|
+
Project-URL: Repository, https://github.com/captivus/chrome-agent
|
|
23
|
+
Project-URL: Issues, https://github.com/captivus/chrome-agent/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# chrome-agent
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/chrome-agent/)
|
|
29
|
+
[](https://pypi.org/project/chrome-agent/)
|
|
30
|
+
[](https://github.com/captivus/chrome-agent/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+
A CLI tool that gives AI coding agents the ability to observe and interact with Chrome browsers.
|
|
33
|
+
|
|
34
|
+
Built as a replacement for browser MCP tools. Faster, lower token overhead, and supports something MCP tools can't do: multiple agents sharing the same browser instance.
|
|
35
|
+
|
|
36
|
+
## Why this exists
|
|
37
|
+
|
|
38
|
+
AI coding agents need to see and interact with browsers -- to test their code, debug automation, inspect page state. The standard approach (browser MCP tools) uses a persistent server with protocol negotiation and verbose response formatting. `chrome-agent` takes a different approach: each command is a standalone CLI call that connects to Chrome via the DevTools Protocol, does one thing, and disconnects. No server, no session state, no bloat.
|
|
39
|
+
|
|
40
|
+
This also enables a workflow that MCP tools can't support: one process drives the browser (your automation code) while a separate agent observes the same browser to diagnose issues and improve the code.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install chrome-agent
|
|
46
|
+
playwright install chromium
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or add to a project:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv add chrome-agent
|
|
53
|
+
uv run playwright install chromium
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Two ways to use it
|
|
57
|
+
|
|
58
|
+
### Drive mode -- you control the browser
|
|
59
|
+
|
|
60
|
+
Launch a browser and interact with it directly. This is the MCP replacement use case.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
chrome-agent launch &
|
|
64
|
+
chrome-agent navigate "https://example.com"
|
|
65
|
+
chrome-agent text # Read page content
|
|
66
|
+
chrome-agent element "h1" # Inspect an element
|
|
67
|
+
chrome-agent fill "#search" "query" # Fill a form field
|
|
68
|
+
chrome-agent click "#submit" # Click a button
|
|
69
|
+
chrome-agent screenshot /tmp/page.png # Capture the screen
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Attach mode -- observe a running browser
|
|
73
|
+
|
|
74
|
+
Your automation code launches a browser with `--remote-debugging-port=9222`. You connect to observe what the code is doing, diagnose failures, and figure out what to change.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
chrome-agent status # Is the browser running?
|
|
78
|
+
chrome-agent url # Where is it?
|
|
79
|
+
chrome-agent element "#submit-btn" # Why can't the code click this?
|
|
80
|
+
chrome-agent eval "document.querySelectorAll('.error').length"
|
|
81
|
+
chrome-agent screenshot # What does it look like?
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The feedback loop: **write code -> run it -> observe the browser -> diagnose -> modify code -> repeat.**
|
|
85
|
+
|
|
86
|
+
## Commands
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
chrome-agent [--port PORT] <command> [args...]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Check browser status
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
status Check if a browser is running on the CDP port
|
|
96
|
+
launch Launch a browser with CDP enabled
|
|
97
|
+
[--fingerprint PATH] [--headless] [--no-pin-desktop]
|
|
98
|
+
help Print command reference
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Observe (read-only, always safe)
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
url Print current URL and page title
|
|
105
|
+
screenshot [path] Save a screenshot (default: /tmp/cdp-screenshot.png)
|
|
106
|
+
snapshot Print the ARIA accessibility tree
|
|
107
|
+
text Print visible text content
|
|
108
|
+
html [selector] Print page HTML or a specific element's HTML
|
|
109
|
+
element <selector> Detailed element inspection (visibility, dimensions,
|
|
110
|
+
attributes, position, disabled state)
|
|
111
|
+
find <selector> Count and list all matching elements
|
|
112
|
+
value <selector> Get an input element's current value
|
|
113
|
+
eval <code> Execute JavaScript and print the result
|
|
114
|
+
cookies List all cookies
|
|
115
|
+
tabs List all open tabs/pages
|
|
116
|
+
wait <target> Wait for a selector, milliseconds, or load state
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Navigate
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
navigate <url> Go to a URL
|
|
123
|
+
back Browser back
|
|
124
|
+
forward Browser forward
|
|
125
|
+
reload Reload the page
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Interact
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
click <selector> Click an element (JS fallback for hidden elements)
|
|
132
|
+
fill <selector> <val> Fill a form field (clears first)
|
|
133
|
+
type <selector> <txt> Type text character by character
|
|
134
|
+
press <key> Press a keyboard key (Enter, Escape, Tab, etc.)
|
|
135
|
+
select <sel> <value> Select a dropdown option
|
|
136
|
+
check <selector> Check a checkbox
|
|
137
|
+
uncheck <selector> Uncheck a checkbox
|
|
138
|
+
hover <selector> Hover over an element
|
|
139
|
+
scroll <target> Scroll to element, or scroll up/down
|
|
140
|
+
clickxy <x> <y> Click at page coordinates
|
|
141
|
+
close Close the current page
|
|
142
|
+
viewport <w> <h> Resize the viewport
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## For AI agents
|
|
146
|
+
|
|
147
|
+
The primary user of this tool is an AI coding agent, not a human. See [INSTRUCTIONS.md](INSTRUCTIONS.md) for comprehensive agent instructions covering:
|
|
148
|
+
|
|
149
|
+
- Drive mode vs attach mode mental model
|
|
150
|
+
- Safety rules for shared browser access
|
|
151
|
+
- The development feedback loop
|
|
152
|
+
- When to observe vs intervene
|
|
153
|
+
- Command recipes for common tasks
|
|
154
|
+
- Failure modes and recovery
|
|
155
|
+
|
|
156
|
+
Include the contents of `INSTRUCTIONS.md` in your project's `CLAUDE.md` or agent instructions file.
|
|
157
|
+
|
|
158
|
+
## Browser fingerprinting (optional)
|
|
159
|
+
|
|
160
|
+
For sites that detect automated browsers, launch with a fingerprint profile:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
chrome-agent launch --fingerprint path/to/fingerprint.json
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The fingerprint JSON overrides the browser's user agent, viewport, locale, timezone, and platform to match a real desktop browser:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) ...",
|
|
171
|
+
"platform": "Linux x86_64",
|
|
172
|
+
"vendor": "Google Inc.",
|
|
173
|
+
"language": "en-US",
|
|
174
|
+
"timezone": "America/Chicago",
|
|
175
|
+
"viewport": {"width": 1920, "height": 1080}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Without `--fingerprint`, the browser launches with default Chromium settings.
|
|
180
|
+
|
|
181
|
+
## Requirements
|
|
182
|
+
|
|
183
|
+
- Python >= 3.11
|
|
184
|
+
- Playwright >= 1.50.0
|
|
185
|
+
- Chromium (installed via `playwright install chromium`)
|
|
186
|
+
- Linux with xdotool (optional, for virtual desktop pinning)
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
chrome_agent/__init__.py,sha256=_uI9RMpIIZMyy7AL0Ud2LDClQNzgBUqCJNObUp70wRw,167
|
|
2
|
+
chrome_agent/__main__.py,sha256=xriXccBgzmz1dA1XjCF0j5oFK86n5XWehF55sfWl62g,90
|
|
3
|
+
chrome_agent/browser.py,sha256=Bd3VAt77vkVxe7Yt4ieQx2Ls8v2rLaJVAYtmDtS_L1U,5406
|
|
4
|
+
chrome_agent/cli.py,sha256=liBXlHN__j8JlqEU3bl8oyOTJHYAPupyWa_pv_KXfas,9423
|
|
5
|
+
chrome_agent/commands.py,sha256=oDAV6BP2XghLYCamPkFXfWHnGAGN6b8WF1mjWU-eqsE,12534
|
|
6
|
+
chrome_agent/connection.py,sha256=idzIkcWvMnDBGwchMO2Q8jfzza74y54FiUC2Fwxhnsw,3220
|
|
7
|
+
chrome_agent/errors.py,sha256=mcC64IOsndqHQNQiDvwDaDqrVzKb_nWIW-oA9R8isGk,1280
|
|
8
|
+
chrome_agent-0.1.0.dist-info/WHEEL,sha256=s_zqWxHFEH8b58BCtf46hFCqPaISurdB9R1XJ8za6XI,80
|
|
9
|
+
chrome_agent-0.1.0.dist-info/entry_points.txt,sha256=ZagAP1naQ_eiXWT4YQ0jCZx0b0ExQju_ivHmDWFTqCs,56
|
|
10
|
+
chrome_agent-0.1.0.dist-info/METADATA,sha256=WbqIpJLNx3dNXaPX_fEZCycZKKlZyJUl3pAEWzWt7DE,7024
|
|
11
|
+
chrome_agent-0.1.0.dist-info/RECORD,,
|