patchright-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -0,0 +1,55 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ patchright-cli is an anti-detect browser automation CLI built on [Patchright](https://github.com/kaliiiiiiiiii/patchright-python) (undetected Playwright fork). It provides the same command interface as Microsoft's playwright-cli but bypasses bot detection (Akamai, Cloudflare, etc.).
8
+
9
+ ## Development Setup
10
+
11
+ ```bash
12
+ uv venv && uv pip install -e .
13
+ python -m patchright install chromium
14
+ pre-commit install
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ ```bash
20
+ # Lint (pre-commit runs ruff --fix + ruff-format)
21
+ pre-commit run --all-files
22
+
23
+ # Run the CLI directly
24
+ python -m patchright_cli <command> [args...]
25
+ # or after install:
26
+ patchright-cli <command> [args...]
27
+ ```
28
+
29
+ There are no automated tests yet — changes are tested manually via the CLI.
30
+
31
+ ## Architecture
32
+
33
+ Three-component client-daemon-browser design:
34
+
35
+ ```
36
+ CLI (cli.py) --TCP:9321--> Daemon (daemon.py) --Patchright--> Chrome
37
+ ```
38
+
39
+ - **CLI (`src/patchright_cli/cli.py`)**: Stateless thin client. Parses argv manually (not click subcommands), builds a JSON `{command, args, options, cwd}` message, sends it over a length-prefixed TCP socket to the daemon, prints the response, and exits. All commands are defined in the `COMMANDS_HELP` dict and `ALL_COMMANDS` list.
40
+
41
+ - **Daemon (`src/patchright_cli/daemon.py`)**: Long-running async process managing browser sessions. Auto-spawned on `open` via `ensure_daemon_running()` which launches a subprocess. Uses `DaemonState` → multiple `Session` objects, each owning a Patchright persistent browser context. Command dispatch is a large `handle_command()` function with if/elif branches. Console capture uses CDP sessions (not Playwright's `page.on('console')`) because Patchright suppresses it.
42
+
43
+ - **Snapshot (`src/patchright_cli/snapshot.py`)**: Two-pass DOM scanner injected as JavaScript: Pass 1 uses TreeWalker to assign `data-patchright-ref` attributes (`e1`, `e2`, ...) in document order; Pass 2 builds a nested tree. Python side converts to flat YAML. Snapshots are saved to `.patchright-cli/` in the caller's cwd.
44
+
45
+ ## Key Design Decisions
46
+
47
+ - Always uses persistent browser context (`launch_persistent_context`) with real Chrome (`channel="chrome"`), not Chromium
48
+ - Headed by default (headless is more detectable)
49
+ - Default daemon port: 9321, profiles stored at `~/.patchright-cli/profiles/<session-name>`
50
+ - CLI-daemon protocol: length-prefixed (4-byte big-endian `!I`) JSON over TCP
51
+ - CLI parses argv manually in `main()` — click is only used for `click.echo()`, not subcommands. New commands must be added to both `COMMANDS_HELP` dict and the `handle_command()` if/elif chain in daemon.py
52
+ - Version must be updated in both `pyproject.toml` and `src/patchright_cli/__init__.py` (then run `uv lock` to sync the lock file)
53
+ - `python -m patchright_cli` works via `__main__.py`
54
+ - Uses hatchling as build backend, ruff for linting (line-length=120, target py310)
55
+ - Ruff rules: E, F, I, W, UP, B, SIM (ignores E501, SIM105, E402)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchright-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Anti-detect browser automation CLI using Patchright (undetected Playwright fork)
5
5
  Project-URL: Homepage, https://github.com/AhaiMk01/patchright-cli
6
6
  Project-URL: Repository, https://github.com/AhaiMk01/patchright-cli
@@ -78,25 +78,22 @@ curl -s https://raw.githubusercontent.com/AhaiMk01/patchright-cli/main/docs/inst
78
78
  > [!IMPORTANT]
79
79
  > **Requirements:** Python 3.10+ and Google Chrome
80
80
 
81
- <details>
82
- <summary><b>Manual Install</b></summary>
83
-
84
81
  ```bash
85
- # 1. Install the package
86
- pip install patchright-cli # or: uv tool install patchright-cli
82
+ # Recommended always runs latest version, no install needed
83
+ uvx patchright-cli open https://example.com
84
+ ```
87
85
 
88
- # 2. Install the Patchright browser (fallback if Chrome is not found)
89
- python -m patchright install chromium
86
+ <details>
87
+ <summary><b>Other install methods</b></summary>
90
88
 
91
- # 3. Verify
89
+ ```bash
90
+ # Via pip
91
+ pip install patchright-cli
92
92
  patchright-cli open https://example.com
93
93
  patchright-cli close
94
- ```
95
94
 
96
- Or run without installing (like npx):
97
-
98
- ```bash
99
- uvx patchright-cli open https://example.com
95
+ # Update
96
+ pip install --upgrade patchright-cli
100
97
  ```
101
98
 
102
99
  **From source:**
@@ -167,16 +164,21 @@ patchright-cli click <ref> --modifiers=Alt,Shift
167
164
  patchright-cli dblclick <ref> # Double-click
168
165
  patchright-cli dblclick <ref> --modifiers=Shift
169
166
  patchright-cli fill <ref> <value> # Fill text input
167
+ patchright-cli fill <ref> <value> --submit # Fill and press Enter
170
168
  patchright-cli type <text> # Type via keyboard
169
+ patchright-cli type <text> --submit # Type and press Enter
171
170
  patchright-cli hover <ref> # Hover over element
172
171
  patchright-cli select <ref> <value> # Select dropdown option
173
172
  patchright-cli check <ref> # Check checkbox
174
173
  patchright-cli uncheck <ref> # Uncheck checkbox
175
174
  patchright-cli drag <from> <to> # Drag and drop
176
175
  patchright-cli snapshot # Accessibility snapshot
176
+ patchright-cli snapshot <ref> # Snapshot element subtree
177
177
  patchright-cli snapshot --filename=f # Save to custom path
178
178
  patchright-cli eval <expr> # Run JavaScript
179
+ patchright-cli eval --file=script.js # Run JS from file
179
180
  patchright-cli run-code <code> # Run JS with return value
181
+ patchright-cli run-code --file=f.js # Run JS from file
180
182
  patchright-cli screenshot # Page screenshot
181
183
  patchright-cli screenshot --full-page # Full scrollable page
182
184
  patchright-cli screenshot <ref> # Element screenshot
@@ -269,18 +271,30 @@ patchright-cli unroute "**/*.jpg"
269
271
  patchright-cli unroute # Remove all routes
270
272
  ```
271
273
 
272
- ### Tracing / PDF
274
+ ### Tracing / Video / PDF
273
275
  ```bash
274
276
  patchright-cli tracing-start
275
277
  patchright-cli tracing-stop # Saves .zip trace file
278
+ patchright-cli video-start # Start video recording (CDP screencast)
279
+ patchright-cli video-stop # Stop and save video (requires ffmpeg for .webm)
280
+ patchright-cli video-stop --filename=recording.webm
276
281
  patchright-cli pdf --filename=page.pdf
277
282
  ```
278
283
 
284
+ ### Network
285
+ ```bash
286
+ patchright-cli network # Network request log
287
+ patchright-cli network --static # Include static resources
288
+ patchright-cli network --clear # Clear log after printing
289
+ patchright-cli network-state-set offline # Simulate offline mode
290
+ patchright-cli network-state-set online # Restore connectivity
291
+ ```
292
+
279
293
  ### DevTools
280
294
  ```bash
281
295
  patchright-cli console # All console messages
282
296
  patchright-cli console warning # Filter by level
283
- patchright-cli network # Network request log
297
+ patchright-cli console --clear # Clear after printing
284
298
  ```
285
299
 
286
300
  ### Sessions
@@ -50,25 +50,22 @@ curl -s https://raw.githubusercontent.com/AhaiMk01/patchright-cli/main/docs/inst
50
50
  > [!IMPORTANT]
51
51
  > **Requirements:** Python 3.10+ and Google Chrome
52
52
 
53
- <details>
54
- <summary><b>Manual Install</b></summary>
55
-
56
53
  ```bash
57
- # 1. Install the package
58
- pip install patchright-cli # or: uv tool install patchright-cli
54
+ # Recommended always runs latest version, no install needed
55
+ uvx patchright-cli open https://example.com
56
+ ```
59
57
 
60
- # 2. Install the Patchright browser (fallback if Chrome is not found)
61
- python -m patchright install chromium
58
+ <details>
59
+ <summary><b>Other install methods</b></summary>
62
60
 
63
- # 3. Verify
61
+ ```bash
62
+ # Via pip
63
+ pip install patchright-cli
64
64
  patchright-cli open https://example.com
65
65
  patchright-cli close
66
- ```
67
66
 
68
- Or run without installing (like npx):
69
-
70
- ```bash
71
- uvx patchright-cli open https://example.com
67
+ # Update
68
+ pip install --upgrade patchright-cli
72
69
  ```
73
70
 
74
71
  **From source:**
@@ -139,16 +136,21 @@ patchright-cli click <ref> --modifiers=Alt,Shift
139
136
  patchright-cli dblclick <ref> # Double-click
140
137
  patchright-cli dblclick <ref> --modifiers=Shift
141
138
  patchright-cli fill <ref> <value> # Fill text input
139
+ patchright-cli fill <ref> <value> --submit # Fill and press Enter
142
140
  patchright-cli type <text> # Type via keyboard
141
+ patchright-cli type <text> --submit # Type and press Enter
143
142
  patchright-cli hover <ref> # Hover over element
144
143
  patchright-cli select <ref> <value> # Select dropdown option
145
144
  patchright-cli check <ref> # Check checkbox
146
145
  patchright-cli uncheck <ref> # Uncheck checkbox
147
146
  patchright-cli drag <from> <to> # Drag and drop
148
147
  patchright-cli snapshot # Accessibility snapshot
148
+ patchright-cli snapshot <ref> # Snapshot element subtree
149
149
  patchright-cli snapshot --filename=f # Save to custom path
150
150
  patchright-cli eval <expr> # Run JavaScript
151
+ patchright-cli eval --file=script.js # Run JS from file
151
152
  patchright-cli run-code <code> # Run JS with return value
153
+ patchright-cli run-code --file=f.js # Run JS from file
152
154
  patchright-cli screenshot # Page screenshot
153
155
  patchright-cli screenshot --full-page # Full scrollable page
154
156
  patchright-cli screenshot <ref> # Element screenshot
@@ -241,18 +243,30 @@ patchright-cli unroute "**/*.jpg"
241
243
  patchright-cli unroute # Remove all routes
242
244
  ```
243
245
 
244
- ### Tracing / PDF
246
+ ### Tracing / Video / PDF
245
247
  ```bash
246
248
  patchright-cli tracing-start
247
249
  patchright-cli tracing-stop # Saves .zip trace file
250
+ patchright-cli video-start # Start video recording (CDP screencast)
251
+ patchright-cli video-stop # Stop and save video (requires ffmpeg for .webm)
252
+ patchright-cli video-stop --filename=recording.webm
248
253
  patchright-cli pdf --filename=page.pdf
249
254
  ```
250
255
 
256
+ ### Network
257
+ ```bash
258
+ patchright-cli network # Network request log
259
+ patchright-cli network --static # Include static resources
260
+ patchright-cli network --clear # Clear log after printing
261
+ patchright-cli network-state-set offline # Simulate offline mode
262
+ patchright-cli network-state-set online # Restore connectivity
263
+ ```
264
+
251
265
  ### DevTools
252
266
  ```bash
253
267
  patchright-cli console # All console messages
254
268
  patchright-cli console warning # Filter by level
255
- patchright-cli network # Network request log
269
+ patchright-cli console --clear # Clear after printing
256
270
  ```
257
271
 
258
272
  ### Sessions
@@ -7,50 +7,56 @@
7
7
 
8
8
  ## Install
9
9
 
10
- ### Step 1: Install the package
10
+ ### Step 1: Ensure uv is installed
11
11
 
12
- ```bash
13
- pip install patchright-cli
14
- ```
15
-
16
- Or with uv (recommended):
12
+ uv is the recommended Python package manager. Check if it's installed:
17
13
 
18
14
  ```bash
19
- uv tool install patchright-cli
15
+ uv --version
20
16
  ```
21
17
 
22
- ### Step 2: Install the Patchright browser
23
-
24
- Patchright needs to download its patched Chromium binary on first use:
18
+ If not installed:
25
19
 
26
20
  ```bash
27
21
  # macOS / Linux
28
- python -m patchright install chromium
22
+ curl -LsSf https://astral.sh/uv/install.sh | sh
29
23
 
30
24
  # Windows (PowerShell)
31
- python -m patchright install chromium
25
+ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
26
+ ```
27
+
28
+ ### Step 2: Run patchright-cli
29
+
30
+ With uvx (recommended — always uses the latest version, no install needed):
32
31
 
33
- # Or if using uv
34
- uv run python -m patchright install chromium
32
+ ```bash
33
+ uvx patchright-cli open https://example.com
34
+ uvx patchright-cli snapshot
35
+ uvx patchright-cli close
35
36
  ```
36
37
 
37
- > **Note:** If you have Google Chrome installed, patchright-cli will use it directly via `channel="chrome"` for maximum stealth. The Chromium install above is a fallback.
38
+ > **Note:** If you have Google Chrome installed, patchright-cli will use it directly via `channel="chrome"` for maximum stealth. If Chrome is not found, install the Patchright fallback browser: `uvx patchright install chromium`
38
39
 
39
40
  ### Step 3: Verify
40
41
 
41
42
  ```bash
42
- patchright-cli --version
43
- patchright-cli open https://example.com
44
- patchright-cli eval "document.title"
45
- patchright-cli close
43
+ uvx patchright-cli --version
44
+ uvx patchright-cli open https://example.com
45
+ uvx patchright-cli eval "document.title"
46
+ uvx patchright-cli close
46
47
  ```
47
48
 
48
- ### Quick one-liner (no install)
49
+ <details>
50
+ <summary><b>Alternative: pip install</b></summary>
49
51
 
50
52
  ```bash
51
- uvx patchright-cli open https://example.com
53
+ pip install patchright-cli
54
+ patchright-cli open https://example.com
55
+ patchright-cli close
52
56
  ```
53
57
 
58
+ </details>
59
+
54
60
  ### Troubleshooting
55
61
 
56
62
  If you see `browser not found` errors:
@@ -62,16 +68,13 @@ google-chrome --version # Linux
62
68
  reg query "HKLM\SOFTWARE\Google\Chrome\BLBeacon" /v version # Windows
63
69
 
64
70
  # If no Chrome, install the Patchright browser
65
- python -m patchright install chromium
71
+ uvx patchright install chromium
66
72
  ```
67
73
 
68
74
  If you see `patchright-cli: command not found`:
69
75
 
70
76
  ```bash
71
- # Ensure pip scripts directory is in PATH
72
- python -m patchright_cli --help
73
-
74
- # Or use uvx (no PATH needed)
77
+ # Use uvx instead (no install or PATH needed)
75
78
  uvx patchright-cli --help
76
79
  ```
77
80
 
@@ -148,25 +151,25 @@ curl -sL https://raw.githubusercontent.com/AhaiMk01/patchright-cli/main/skills/p
148
151
 
149
152
  ```bash
150
153
  # Open an anti-bot-protected site
151
- patchright-cli open https://protected-site.com
154
+ uvx patchright-cli open https://protected-site.com
152
155
 
153
156
  # Take a snapshot to see interactive elements
154
- patchright-cli snapshot
157
+ uvx patchright-cli snapshot
155
158
 
156
159
  # Interact with elements using refs from the snapshot
157
- patchright-cli fill e3 "username"
158
- patchright-cli fill e4 "password"
159
- patchright-cli click e5
160
+ uvx patchright-cli fill e3 "username"
161
+ uvx patchright-cli fill e4 "password"
162
+ uvx patchright-cli click e5
160
163
 
161
164
  # Save login state for reuse
162
- patchright-cli state-save auth.json
165
+ uvx patchright-cli state-save auth.json
163
166
 
164
167
  # Later, restore the session
165
- patchright-cli open https://protected-site.com --persistent
166
- patchright-cli state-load auth.json
168
+ uvx patchright-cli open https://protected-site.com --persistent
169
+ uvx patchright-cli state-load auth.json
167
170
 
168
171
  # Close when done
169
- patchright-cli close
172
+ uvx patchright-cli close
170
173
  ```
171
174
 
172
175
  ## All Commands
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "patchright-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Anti-detect browser automation CLI using Patchright (undetected Playwright fork)"
5
5
  license = "Apache-2.0"
6
6
  requires-python = ">=3.10"
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  name: patchright-cli
3
3
  description: Anti-detect browser automation using Patchright (undetected Playwright fork). Use when you need to interact with websites that block regular Playwright/Chrome DevTools, such as Akamai/Cloudflare-protected sites. Provides the same command interface as playwright-cli but bypasses bot detection.
4
- allowed-tools: Bash(patchright-cli:*), Bash(python -m patchright_cli:*)
5
4
  ---
6
5
 
7
6
  # Anti-Detect Browser Automation with patchright-cli
@@ -47,21 +46,28 @@ patchright-cli open --headless # Run headless
47
46
  patchright-cli open --profile=/path/to/dir # Custom profile directory
48
47
  patchright-cli goto https://example.com
49
48
  patchright-cli type "search query"
49
+ patchright-cli type "search query" --submit # Type and press Enter
50
50
  patchright-cli click e3
51
51
  patchright-cli click e3 right # Right-click
52
52
  patchright-cli click e3 --modifiers=Alt,Shift # Click with modifiers
53
53
  patchright-cli dblclick e7
54
+ patchright-cli dblclick e7 --modifiers=Shift # Double-click with modifiers
54
55
  patchright-cli fill e5 "user@example.com"
56
+ patchright-cli fill e5 "query" --submit # Fill and press Enter
55
57
  patchright-cli drag e2 e8
56
58
  patchright-cli hover e4
57
59
  patchright-cli select e9 "option-value"
58
60
  patchright-cli check e12
59
61
  patchright-cli uncheck e12
60
62
  patchright-cli snapshot
63
+ patchright-cli snapshot e3 # Partial snapshot of element subtree
61
64
  patchright-cli snapshot --filename=snap.yml # Save to custom path
62
65
  patchright-cli eval "document.title"
63
- patchright-cli eval "el => el.textContent" e5
66
+ patchright-cli eval --file=script.js # Read JS from file (avoids shell quoting)
67
+ cat script.js | patchright-cli eval # Read JS from stdin
64
68
  patchright-cli run-code "return document.querySelectorAll('a').length"
69
+ patchright-cli run-code --file=script.js # Read JS from file
70
+ cat script.js | patchright-cli run-code # Read JS from stdin
65
71
  patchright-cli screenshot
66
72
  patchright-cli screenshot e3 # Element screenshot
67
73
  patchright-cli screenshot --filename=page.png
@@ -171,6 +177,8 @@ patchright-cli route "**/*" --remove-header=Content-Type
171
177
  patchright-cli route-list
172
178
  patchright-cli unroute "**/*.jpg"
173
179
  patchright-cli unroute # Remove all routes
180
+ patchright-cli network-state-set offline # Simulate offline mode
181
+ patchright-cli network-state-set online # Restore connectivity
174
182
  ```
175
183
 
176
184
  ### Tracing / Video / PDF
@@ -178,6 +186,9 @@ patchright-cli unroute # Remove all routes
178
186
  ```bash
179
187
  patchright-cli tracing-start
180
188
  patchright-cli tracing-stop
189
+ patchright-cli video-start # Start video recording (CDP screencast)
190
+ patchright-cli video-stop # Stop and save video (requires ffmpeg for .webm)
191
+ patchright-cli video-stop --filename=rec.webm # Save to custom path
181
192
  patchright-cli pdf --filename=page.pdf
182
193
  ```
183
194
 
@@ -185,8 +196,11 @@ patchright-cli pdf --filename=page.pdf
185
196
 
186
197
  ```bash
187
198
  patchright-cli console
188
- patchright-cli console warning
189
- patchright-cli network
199
+ patchright-cli console warning # Filter by level
200
+ patchright-cli console --clear # Clear message buffer after printing
201
+ patchright-cli network # Show requests (excludes static resources)
202
+ patchright-cli network --static # Include images, fonts, scripts, etc.
203
+ patchright-cli network --clear # Clear network log after printing
190
204
  ```
191
205
 
192
206
  ### Sessions
@@ -199,13 +213,33 @@ patchright-cli -s=mysession close
199
213
  # List all sessions
200
214
  patchright-cli list
201
215
  # Close all browsers
202
- patchright-cli close-all
203
- patchright-cli kill-all
216
+ patchright-cli close-all # Gracefully close all sessions
217
+ patchright-cli kill-all # Force-kill all sessions and stop daemon
204
218
  # Delete persistent profile data
205
219
  patchright-cli delete-data
206
220
  patchright-cli -s=mysession delete-data
207
221
  ```
208
222
 
223
+ ## Running JavaScript (eval / run-code)
224
+
225
+ **Always use `--file` for `eval` and `run-code`**. Inline JS breaks because quotes get mangled through bash → uvx → python shell layers.
226
+
227
+ ```bash
228
+ # WRONG — nested quotes break through shell layers
229
+ patchright-cli eval "JSON.stringify({x: document.querySelector('a[href*=\"foo\"]')})"
230
+
231
+ # RIGHT — write JS to a temp file, pass with --file
232
+ cat > /tmp/check.js << 'JSEOF'
233
+ JSON.stringify({x: !!document.querySelector('a[href*="foo"]')})
234
+ JSEOF
235
+ patchright-cli eval --file=/tmp/check.js
236
+
237
+ # RIGHT — pipe via stdin (for simple expressions only)
238
+ echo 'document.title' | patchright-cli eval
239
+ ```
240
+
241
+ `--file` also avoids the OS argument length limit (`ARG_MAX`) for large scripts.
242
+
209
243
  ## Anti-detect features
210
244
 
211
245
  - Uses real Chrome browser (not Chromium)
@@ -229,11 +263,12 @@ After each command, outputs page state and a YAML snapshot file:
229
263
  ## Installation
230
264
 
231
265
  ```bash
232
- cd patchright-cli && uv pip install -e .
233
- ```
266
+ # Recommended always runs latest version, no install needed
267
+ uvx patchright-cli open https://example.com
234
268
 
235
- Or run directly:
236
- ```bash
237
- python -m patchright_cli open https://example.com
238
- python -m patchright_cli click e1
269
+ # Or install via pip
270
+ pip install patchright-cli
271
+
272
+ # Or from source
273
+ cd patchright-cli && uv pip install -e .
239
274
  ```
@@ -1,3 +1,3 @@
1
1
  """patchright-cli — Undetected browser automation CLI using Patchright."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -64,14 +64,14 @@ COMMANDS_HELP = {
64
64
  "goto": "goto <url> Navigate to URL",
65
65
  "click": "click <ref> [button] Click element [--modifiers=Alt,Shift]",
66
66
  "dblclick": "dblclick <ref> [btn] Double-click [--modifiers=Alt,Shift]",
67
- "fill": "fill <ref> <value> Fill text into element",
68
- "type": "type <text> Type text via keyboard",
67
+ "fill": "fill <ref> <value> Fill text into element [--submit]",
68
+ "type": "type <text> Type text via keyboard [--submit]",
69
69
  "hover": "hover <ref> Hover over element",
70
70
  "select": "select <ref> <value> Select dropdown option",
71
71
  "check": "check <ref> Check checkbox/radio",
72
72
  "uncheck": "uncheck <ref> Uncheck checkbox/radio",
73
- "snapshot": "snapshot Take accessibility snapshot",
74
- "eval": "eval <expr> Evaluate JavaScript",
73
+ "snapshot": "snapshot [ref] Take accessibility snapshot",
74
+ "eval": "eval <expr> Evaluate JavaScript [--file=F or stdin]",
75
75
  "screenshot": "screenshot [ref] Save screenshot [--full-page] [--filename=F]",
76
76
  "drag": "drag <from> <to> Drag element to target",
77
77
  "close": "close Close browser session",
@@ -120,22 +120,23 @@ COMMANDS_HELP = {
120
120
  "sessionstorage-delete": "sessionstorage-delete <k> Delete sessionStorage item",
121
121
  "sessionstorage-clear": "sessionstorage-clear Clear sessionStorage",
122
122
  # Route
123
- "route": "route <pattern> [--status=N] [--body=S] [--header=K:V] [--remove-header=H] Mock requests",
123
+ "route": "route <pattern> [--status=N] [--body=S] [--content-type=T] [--header=K:V] Mock requests",
124
124
  "route-list": "route-list List active routes",
125
125
  "unroute": "unroute [pattern] Remove route(s)",
126
+ "network-state-set": "network-state-set <state> Set online/offline (online|offline)",
126
127
  # Run code
127
- "run-code": "run-code <code> Run raw JS in page context",
128
+ "run-code": "run-code <code> Run raw JS in page context [--file=F or stdin]",
128
129
  # Tracing
129
130
  "tracing-start": "tracing-start Start Playwright tracing",
130
131
  "tracing-stop": "tracing-stop Stop tracing and save",
131
132
  # Video
132
133
  "video-start": "video-start Start video recording",
133
- "video-stop": "video-stop [file] Stop recording and save",
134
+ "video-stop": "video-stop Stop recording and save [--filename=F]",
134
135
  # PDF
135
136
  "pdf": "pdf [--filename=F] Save page as PDF",
136
137
  # DevTools
137
- "console": "console [level] Show console messages",
138
- "network": "network Show network requests",
138
+ "console": "console [level] Show console messages [--clear]",
139
+ "network": "network Show network requests [--static] [--clear]",
139
140
  # Session
140
141
  "list": "list List sessions",
141
142
  "close-all": "close-all Close all sessions",
@@ -207,7 +208,7 @@ def _print_help():
207
208
  "sessionstorage-clear",
208
209
  ],
209
210
  ),
210
- ("Route", ["route", "route-list", "unroute"]),
211
+ ("Route", ["route", "route-list", "unroute", "network-state-set"]),
211
212
  ("Code", ["run-code"]),
212
213
  ("Tracing", ["tracing-start", "tracing-stop"]),
213
214
  ("Video", ["video-start", "video-stop"]),
@@ -244,7 +245,7 @@ def main():
244
245
  persistent = True
245
246
  elif arg.startswith("--profile="):
246
247
  profile = arg.split("=", 1)[1]
247
- elif arg.startswith("--profile") and i + 1 < len(argv):
248
+ elif arg == "--profile" and i + 1 < len(argv):
248
249
  i += 1
249
250
  profile = argv[i]
250
251
  elif arg.startswith("-s="):
@@ -253,10 +254,18 @@ def main():
253
254
  i += 1
254
255
  session_name = argv[i]
255
256
  elif arg.startswith("--port="):
256
- port = int(arg.split("=", 1)[1])
257
+ try:
258
+ port = int(arg.split("=", 1)[1])
259
+ except ValueError:
260
+ click.echo(f"Invalid port value: {arg}", err=True)
261
+ sys.exit(1)
257
262
  elif arg == "--port" and i + 1 < len(argv):
258
263
  i += 1
259
- port = int(argv[i])
264
+ try:
265
+ port = int(argv[i])
266
+ except ValueError:
267
+ click.echo(f"Invalid port value: {argv[i]}", err=True)
268
+ sys.exit(1)
260
269
  elif arg in ("--version", "-v"):
261
270
  click.echo(f"patchright-cli {__version__}")
262
271
  sys.exit(0)
@@ -292,6 +301,27 @@ def main():
292
301
  positional_args.append(a)
293
302
  args = positional_args
294
303
 
304
+ # For eval/run-code: support --file=<path> and stdin via "-"
305
+ if command in ("eval", "run-code"):
306
+ file_opt = extra_opts.pop("file", None)
307
+ if file_opt:
308
+ # Read JS from file
309
+ try:
310
+ with open(file_opt, encoding="utf-8") as f:
311
+ args = [f.read()]
312
+ except FileNotFoundError:
313
+ click.echo(f"File not found: {file_opt}", err=True)
314
+ sys.exit(1)
315
+ elif args and args[0] == "-":
316
+ # Read JS from stdin
317
+ args = [sys.stdin.read()]
318
+ elif not args and not sys.stdin.isatty():
319
+ # Piped input with no positional arg
320
+ args = [sys.stdin.read()]
321
+ if not args:
322
+ click.echo(f"'{command}' requires a JS expression, --file=<path>, or piped stdin.", err=True)
323
+ sys.exit(1)
324
+
295
325
  # Build options dict
296
326
  options = {"session": session_name, **extra_opts}
297
327
  if headless:
@@ -309,17 +339,18 @@ def main():
309
339
  # Try connecting first; if fails, tell user to open
310
340
  import socket as _socket
311
341
 
342
+ sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
312
343
  try:
313
- sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
314
344
  sock.settimeout(1)
315
345
  sock.connect(("127.0.0.1", port))
316
- sock.close()
317
346
  except (ConnectionRefusedError, OSError, TimeoutError):
318
347
  click.echo(
319
348
  f"Daemon is not running on port {port}. Run 'patchright-cli open' first.",
320
349
  err=True,
321
350
  )
322
351
  sys.exit(1)
352
+ finally:
353
+ sock.close()
323
354
  except RuntimeError as e:
324
355
  click.echo(str(e), err=True)
325
356
  sys.exit(1)
@@ -44,6 +44,9 @@ class Session:
44
44
  self._pending_dialog_action: tuple | None = None
45
45
  self._profile_dir: str | None = None
46
46
  self._cdp_sessions: dict[int, object] = {}
47
+ self._video_cdp = None
48
+ self._video_frames: list[bytes] = []
49
+ self._video_recording: bool = False
47
50
 
48
51
  # -- internal helpers ---------------------------------------------------
49
52
 
@@ -54,8 +57,9 @@ class Session:
54
57
  self.context.on("page", lambda page: asyncio.ensure_future(self._on_new_page(page)))
55
58
 
56
59
  async def _on_new_page(self, page):
57
- self.pages.append(page)
58
- self.current_tab = len(self.pages) - 1
60
+ if page not in self.pages:
61
+ self.pages.append(page)
62
+ self.current_tab = len(self.pages) - 1
59
63
  await self._attach_page_listeners(page)
60
64
 
61
65
  async def _attach_page_listeners(self, page):
@@ -286,6 +290,50 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
286
290
 
287
291
  page = session.page
288
292
 
293
+ # Commands that don't need a page
294
+ if cmd == "list":
295
+ lines = ["### Sessions"]
296
+ for sname, s in state.sessions.items():
297
+ marker = " *" if sname == session_name else ""
298
+ tab_count = len(s.pages)
299
+ lines.append(f" - {sname}{marker}: {tab_count} tab(s)")
300
+ return {"success": True, "output": "\n".join(lines)}
301
+
302
+ if cmd == "close":
303
+ closed = await state.close_session(session_name)
304
+ return {"success": True, "output": f"Session '{session_name}' closed." if closed else "Session not found."}
305
+
306
+ if cmd == "close-all":
307
+ names = list(state.sessions.keys())
308
+ for n in names:
309
+ await state.close_session(n)
310
+ return {"success": True, "output": f"Closed {len(names)} session(s)."}
311
+
312
+ if cmd == "kill-all":
313
+ names = list(state.sessions.keys())
314
+ for n in names:
315
+ s = state.sessions.get(n)
316
+ if s:
317
+ try:
318
+ for p in s.pages:
319
+ try:
320
+ await p.close()
321
+ except Exception:
322
+ pass
323
+ await s.context.close()
324
+ except Exception:
325
+ pass
326
+ state.sessions.pop(n, None)
327
+ if hasattr(state, "shutdown_event"):
328
+ state.shutdown_event.set()
329
+ return {"success": True, "output": f"Killed {len(names)} session(s) and stopping daemon."}
330
+
331
+ if page is None:
332
+ return {
333
+ "success": False,
334
+ "output": "No page open. Run 'tab-new' to create one, or 'close' and 'open' again.",
335
+ }
336
+
289
337
  # -- Navigation -----------------------------------------------------
290
338
  if cmd == "goto":
291
339
  await page.goto(args[0])
@@ -321,11 +369,15 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
321
369
  if cmd == "fill":
322
370
  elem = await _resolve_ref(session, page, args[0])
323
371
  await elem.fill(args[1])
372
+ if options.get("submit"):
373
+ await page.keyboard.press("Enter")
324
374
  return await _page_info(session, cwd)
325
375
 
326
376
  if cmd == "type":
327
377
  text = args[0] if args else ""
328
378
  await page.keyboard.type(text)
379
+ if options.get("submit"):
380
+ await page.keyboard.press("Enter")
329
381
  return await _page_info(session, cwd)
330
382
 
331
383
  if cmd == "hover":
@@ -356,7 +408,13 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
356
408
 
357
409
  # -- Snapshot -------------------------------------------------------
358
410
  if cmd == "snapshot":
359
- yaml_text, session.ref_map = await take_snapshot(page)
411
+ element_ref = args[0] if args else None
412
+ if element_ref:
413
+ # Partial snapshot: scope JS to a specific element subtree
414
+ elem = await _resolve_ref(session, page, element_ref)
415
+ yaml_text, session.ref_map = await take_snapshot(page, root_element=elem)
416
+ else:
417
+ yaml_text, session.ref_map = await take_snapshot(page)
360
418
  fn = options.get("filename")
361
419
  if fn:
362
420
  Path(fn).parent.mkdir(parents=True, exist_ok=True)
@@ -402,11 +460,6 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
402
460
  await page.screenshot(path=str(filepath), full_page=full_page)
403
461
  return {"success": True, "output": f"Screenshot saved to {filepath}"}
404
462
 
405
- # -- Close ----------------------------------------------------------
406
- if cmd == "close":
407
- closed = await state.close_session(session_name)
408
- return {"success": True, "output": f"Session '{session_name}' closed." if closed else "Session not found."}
409
-
410
463
  # -- Keyboard -------------------------------------------------------
411
464
  if cmd == "press":
412
465
  await page.keyboard.press(args[0])
@@ -463,6 +516,7 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
463
516
  idx = int(args[0]) if args else session.current_tab
464
517
  if 0 <= idx < len(session.pages):
465
518
  p = session.pages.pop(idx)
519
+ session._cdp_sessions.pop(id(p), None)
466
520
  await p.close()
467
521
  session.current_tab = max(0, min(session.current_tab, len(session.pages) - 1))
468
522
  return {"success": True, "output": f"Tab {idx} closed."}
@@ -501,9 +555,9 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
501
555
  cookie["path"] = options.get("path", "/")
502
556
  else:
503
557
  cookie["url"] = page.url
504
- if options.get("httpOnly") or "httpOnly" in options:
558
+ if options.get("httpOnly"):
505
559
  cookie["httpOnly"] = True
506
- if options.get("secure") or "secure" in options:
560
+ if options.get("secure"):
507
561
  cookie["secure"] = True
508
562
  if options.get("sameSite"):
509
563
  cookie["sameSite"] = options["sameSite"]
@@ -564,34 +618,38 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
564
618
  if level_filter and m["type"] != level_filter:
565
619
  continue
566
620
  lines.append(f"[{m['type']}] {m['text']}")
621
+ if options.get("clear"):
622
+ session.console_messages.clear()
567
623
  return {"success": True, "output": "\n".join(lines) or "(no console messages)"}
568
624
 
569
625
  if cmd == "network":
626
+ include_static = bool(options.get("static"))
627
+ static_types = {"image", "font", "stylesheet", "script", "media"}
570
628
  lines = []
571
629
  for r in session.network_log[-50:]:
630
+ if not include_static and r["resource"] in static_types:
631
+ continue
572
632
  lines.append(f"{r['method']} {r['url']} [{r['resource']}]")
633
+ if options.get("clear"):
634
+ session.network_log.clear()
573
635
  return {"success": True, "output": "\n".join(lines) or "(no network requests)"}
574
636
 
575
- # -- Session management ---------------------------------------------
576
- if cmd == "list":
577
- lines = ["### Sessions"]
578
- for sname, s in state.sessions.items():
579
- marker = " *" if sname == session_name else ""
580
- tab_count = len(s.pages)
581
- lines.append(f" - {sname}{marker}: {tab_count} tab(s)")
582
- return {"success": True, "output": "\n".join(lines)}
583
-
584
- if cmd == "close-all":
585
- names = list(state.sessions.keys())
586
- for n in names:
587
- await state.close_session(n)
588
- return {"success": True, "output": f"Closed {len(names)} session(s)."}
589
-
590
- if cmd == "kill-all":
591
- names = list(state.sessions.keys())
592
- for n in names:
593
- await state.close_session(n)
594
- return {"success": True, "output": f"Killed {len(names)} session(s)."}
637
+ if cmd == "network-state-set":
638
+ state_val = args[0] if args else ""
639
+ if state_val not in ("online", "offline"):
640
+ return {"success": False, "output": "Usage: network-state-set <online|offline>"}
641
+ cdp = await page.context.new_cdp_session(page)
642
+ if state_val == "offline":
643
+ await cdp.send(
644
+ "Network.emulateNetworkConditions",
645
+ {"offline": True, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1},
646
+ )
647
+ else:
648
+ await cdp.send(
649
+ "Network.emulateNetworkConditions",
650
+ {"offline": False, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1},
651
+ )
652
+ return {"success": True, "output": f"Network state set to {state_val}."}
595
653
 
596
654
  # -- Dialog handling ------------------------------------------------
597
655
  if cmd == "dialog-accept":
@@ -637,7 +695,9 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
637
695
  # Apply cookies
638
696
  if state_data.get("cookies"):
639
697
  await session.context.add_cookies(state_data["cookies"])
640
- # Apply localStorage via JS
698
+ # Apply localStorage via JS (only works if page is on the matching origin)
699
+ ls_applied = 0
700
+ ls_skipped = 0
641
701
  for origin_data in state_data.get("origins", []):
642
702
  origin = origin_data.get("origin", "")
643
703
  ls = origin_data.get("localStorage", [])
@@ -646,7 +706,13 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
646
706
  await page.evaluate(
647
707
  f"() => localStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])})"
648
708
  )
649
- return {"success": True, "output": f"State loaded from {filepath}"}
709
+ ls_applied += len(ls)
710
+ elif ls:
711
+ ls_skipped += len(ls)
712
+ msg = f"State loaded from {filepath}"
713
+ if ls_skipped:
714
+ msg += f" (note: {ls_skipped} localStorage item(s) skipped — navigate to the matching origin first)"
715
+ return {"success": True, "output": msg}
650
716
 
651
717
  # -- Session storage ------------------------------------------------
652
718
  if cmd == "sessionstorage-list":
@@ -728,8 +794,7 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
728
794
  # -- Run code -------------------------------------------------------
729
795
  if cmd == "run-code":
730
796
  code = args[0] if args else ""
731
- fn = f"async (page) => {{ {code} }}"
732
- result = await page.evaluate(f"async () => {{ const page = window; {code} }}")
797
+ result = await page.evaluate(f"async () => {{ {code} }}")
733
798
  return {
734
799
  "success": True,
735
800
  "output": json.dumps(result, indent=2, default=str) if result is not None else "Code executed.",
@@ -751,19 +816,88 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
751
816
 
752
817
  # -- Video recording ------------------------------------------------
753
818
  if cmd == "video-start":
754
- session._video_page = page
755
- # Video requires a new context with record_video_dir
756
- return {
757
- "success": False,
758
- "output": "Video recording requires starting a new session with video enabled. Use: open --video",
759
- }
819
+ if session._video_recording:
820
+ return {"success": False, "output": "Video recording is already in progress."}
821
+ cdp = await page.context.new_cdp_session(page)
822
+ session._video_cdp = cdp
823
+ session._video_frames = []
824
+ session._video_recording = True
825
+
826
+ def _on_frame(event):
827
+ import base64
828
+
829
+ session._video_frames.append(base64.b64decode(event["data"]))
830
+ asyncio.ensure_future(cdp.send("Page.screencastFrameAck", {"sessionId": event["sessionId"]}))
831
+
832
+ cdp.on("Page.screencastFrame", _on_frame)
833
+ await cdp.send(
834
+ "Page.startScreencast", {"format": "jpeg", "quality": 80, "maxWidth": 1280, "maxHeight": 720}
835
+ )
836
+ return {"success": True, "output": "Video recording started."}
760
837
 
761
838
  if cmd == "video-stop":
762
- if hasattr(session, "_video_page") and session._video_page and session._video_page.video:
763
- path = await session._video_page.video.path()
764
- dest = args[0] if args else str(path)
765
- return {"success": True, "output": f"Video saved to {dest}"}
766
- return {"success": False, "output": "No video recording in progress."}
839
+ if not session._video_recording or not session._video_cdp:
840
+ return {"success": False, "output": "No video recording in progress."}
841
+ try:
842
+ await session._video_cdp.send("Page.stopScreencast")
843
+ except Exception:
844
+ pass
845
+ session._video_recording = False
846
+ frames = session._video_frames
847
+ session._video_frames = []
848
+ session._video_cdp = None
849
+
850
+ if not frames:
851
+ return {"success": False, "output": "No video frames were captured."}
852
+
853
+ base = Path(cwd) if cwd else Path.cwd()
854
+ snap_dir = base / ".patchright-cli"
855
+ snap_dir.mkdir(parents=True, exist_ok=True)
856
+ ts = int(time.time() * 1000)
857
+
858
+ fn = options.get("filename")
859
+
860
+ # Try ffmpeg for webm output, fall back to saving frames as images
861
+ video_path = snap_dir / (fn or f"video-{ts}.webm")
862
+ try:
863
+ import shutil
864
+ import tempfile
865
+
866
+ if not shutil.which("ffmpeg"):
867
+ raise FileNotFoundError("ffmpeg not found")
868
+
869
+ with tempfile.TemporaryDirectory() as tmpdir:
870
+ for i, frame_data in enumerate(frames):
871
+ Path(tmpdir, f"frame-{i:06d}.jpg").write_bytes(frame_data)
872
+ proc = await asyncio.create_subprocess_exec(
873
+ "ffmpeg",
874
+ "-y",
875
+ "-framerate",
876
+ "5",
877
+ "-i",
878
+ str(Path(tmpdir, "frame-%06d.jpg")),
879
+ "-c:v",
880
+ "libvpx",
881
+ "-b:v",
882
+ "1M",
883
+ str(video_path),
884
+ stdout=asyncio.subprocess.DEVNULL,
885
+ stderr=asyncio.subprocess.DEVNULL,
886
+ )
887
+ await proc.wait()
888
+ if proc.returncode != 0:
889
+ raise RuntimeError("ffmpeg failed")
890
+ return {"success": True, "output": f"Video saved to {video_path} ({len(frames)} frames)"}
891
+ except (FileNotFoundError, RuntimeError):
892
+ # Fallback: save frames as individual images
893
+ frames_dir = snap_dir / f"video-{ts}-frames"
894
+ frames_dir.mkdir(parents=True, exist_ok=True)
895
+ for i, frame_data in enumerate(frames):
896
+ (frames_dir / f"frame-{i:04d}.jpg").write_bytes(frame_data)
897
+ return {
898
+ "success": True,
899
+ "output": f"Saved {len(frames)} frames to {frames_dir}/ (install ffmpeg for video output)",
900
+ }
767
901
 
768
902
  # -- PDF ------------------------------------------------------------
769
903
  if cmd == "pdf":
@@ -840,6 +974,7 @@ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
840
974
  """Start the daemon TCP server."""
841
975
  state = DaemonState()
842
976
  state.default_headless = headless
977
+ state.shutdown_event = asyncio.Event()
843
978
 
844
979
  async def client_handler(reader, writer):
845
980
  await _handle_client(reader, writer, state)
@@ -850,7 +985,7 @@ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
850
985
  print(f"patchright-cli daemon listening on {addr[0]}:{addr[1]}", flush=True)
851
986
 
852
987
  # Handle graceful shutdown
853
- shutdown_event = asyncio.Event()
988
+ shutdown_event = state.shutdown_event
854
989
 
855
990
  def _signal_handler():
856
991
  logger.info("Shutdown signal received")
@@ -862,12 +997,11 @@ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
862
997
  loop.add_signal_handler(sig, _signal_handler)
863
998
 
864
999
  try:
865
- if sys.platform == "win32":
866
- # On Windows, asyncio signal handlers don't work; just serve forever
867
- async with server:
868
- await server.serve_forever()
869
- else:
870
- async with server:
1000
+ async with server:
1001
+ if sys.platform == "win32":
1002
+ # On Windows, asyncio signal handlers don't work; use shutdown_event from kill-all
1003
+ await shutdown_event.wait()
1004
+ else:
871
1005
  await shutdown_event.wait()
872
1006
  except (KeyboardInterrupt, asyncio.CancelledError):
873
1007
  pass
@@ -48,7 +48,10 @@ _SNAPSHOT_JS = r"""
48
48
  }
49
49
 
50
50
  function isVis(el) {
51
- if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML") return false;
51
+ if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML") {
52
+ try { const pos = getComputedStyle(el).position; if (pos !== "fixed" && pos !== "sticky") return false; }
53
+ catch(e) { return false; }
54
+ }
52
55
  try { const s = getComputedStyle(el); return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0"; }
53
56
  catch(e) { return false; }
54
57
  }
@@ -115,6 +118,110 @@ _SNAPSHOT_JS = r"""
115
118
  })()
116
119
  """
117
120
 
121
+ # Variant that takes a root element argument for partial snapshots
122
+ _SNAPSHOT_ELEMENT_JS = r"""
123
+ (rootEl) => {
124
+ const SKIP = new Set(["SCRIPT","STYLE","NOSCRIPT","SVG","LINK","META","BR","HR"]);
125
+ const INTERACTIVE = new Set(["A","BUTTON","INPUT","TEXTAREA","SELECT","DETAILS","SUMMARY","LABEL"]);
126
+ const SEMANTIC = new Set(["H1","H2","H3","H4","H5","H6","NAV","MAIN","HEADER","FOOTER","ARTICLE","SECTION","ASIDE","FORM","TABLE","UL","OL","LI","IMG","VIDEO","AUDIO","IFRAME"]);
127
+
128
+ rootEl.querySelectorAll("[data-patchright-ref]").forEach(el => el.removeAttribute("data-patchright-ref"));
129
+
130
+ function getRole(el) {
131
+ const ar = el.getAttribute && el.getAttribute("role");
132
+ if (ar) return ar;
133
+ const t = el.tagName;
134
+ const m = {A:"link",BUTTON:"button",TEXTAREA:"textbox",SELECT:"combobox",IMG:"img",TABLE:"table",FORM:"form",NAV:"navigation",MAIN:"main",HEADER:"banner",FOOTER:"contentinfo"};
135
+ if (m[t]) return m[t];
136
+ if (t === "INPUT") { const tp = (el.type||"text").toLowerCase(); return tp==="checkbox"?"checkbox":tp==="radio"?"radio":(tp==="submit"||tp==="button")?"button":"textbox"; }
137
+ if (t === "UL" || t === "OL") return "list";
138
+ if (t === "LI") return "listitem";
139
+ if (/^H[1-6]$/.test(t)) return "heading";
140
+ return t.toLowerCase();
141
+ }
142
+
143
+ function getName(el) {
144
+ const t = el.tagName;
145
+ const ar = el.getAttribute && el.getAttribute("aria-label");
146
+ if (ar) return ar.substring(0, 120);
147
+ if (t === "IMG") return (el.getAttribute("alt") || "").substring(0, 120);
148
+ if (t === "INPUT" || t === "TEXTAREA") return (el.getAttribute("placeholder") || "").substring(0, 80);
149
+ if (t === "A" || t === "BUTTON" || /^H[1-6]$/.test(t) || t === "LABEL" || t === "LI" || t === "SUMMARY")
150
+ return (el.innerText || "").replace(/\s+/g, " ").trim().substring(0, 120);
151
+ const ti = el.getAttribute && el.getAttribute("title");
152
+ if (ti) return ti.substring(0, 80);
153
+ return "";
154
+ }
155
+
156
+ function isVis(el) {
157
+ if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML") {
158
+ try { const pos = getComputedStyle(el).position; if (pos !== "fixed" && pos !== "sticky") return false; }
159
+ catch(e) { return false; }
160
+ }
161
+ try { const s = getComputedStyle(el); return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0"; }
162
+ catch(e) { return false; }
163
+ }
164
+
165
+ function shouldTag(el) {
166
+ return INTERACTIVE.has(el.tagName) || SEMANTIC.has(el.tagName) || !!el.getAttribute("role") || !!el.getAttribute("data-testid") || !!getName(el);
167
+ }
168
+
169
+ const tw = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT, {
170
+ acceptNode(n) {
171
+ if (SKIP.has(n.tagName)) return NodeFilter.FILTER_REJECT;
172
+ if (!isVis(n)) return NodeFilter.FILTER_REJECT;
173
+ return NodeFilter.FILTER_ACCEPT;
174
+ }
175
+ });
176
+
177
+ let counter = 0;
178
+ const flatList = [];
179
+ let node;
180
+ while (node = tw.nextNode()) {
181
+ if (shouldTag(node)) {
182
+ counter++;
183
+ const ref = "e" + counter;
184
+ node.setAttribute("data-patchright-ref", ref);
185
+ const role = getRole(node);
186
+ const name = getName(node);
187
+ const entry = { ref, role, name, tag: node.tagName };
188
+ if (node.tagName === "INPUT" || node.tagName === "TEXTAREA" || node.tagName === "SELECT") {
189
+ if (node.value) entry.value = node.value.substring(0, 200);
190
+ if (node.type === "checkbox" || node.type === "radio") entry.checked = node.checked;
191
+ if (node.disabled) entry.disabled = true;
192
+ }
193
+ if (node.tagName === "A" && node.href) entry.url = node.href.substring(0, 200);
194
+ if (/^H[1-6]$/.test(node.tagName)) entry.level = parseInt(node.tagName[1]);
195
+ flatList.push(entry);
196
+ }
197
+ }
198
+
199
+ function buildTree(el, depth) {
200
+ if (depth > 12 || !el || !el.tagName) return null;
201
+ if (SKIP.has(el.tagName)) return null;
202
+ const ref = el.getAttribute("data-patchright-ref");
203
+ const children = [];
204
+ for (const ch of el.children) {
205
+ if (!isVis(ch)) continue;
206
+ const c = buildTree(ch, depth + 1);
207
+ if (c) children.push(c);
208
+ }
209
+ if (!ref && !children.length) return null;
210
+ const out = {};
211
+ if (ref) {
212
+ const f = flatList.find(x => x.ref === ref);
213
+ if (f) Object.assign(out, f);
214
+ delete out.tag;
215
+ }
216
+ if (children.length) out.children = children;
217
+ return out;
218
+ }
219
+
220
+ const tree = buildTree(rootEl, 0) || { role: "element", name: "", children: [] };
221
+ return { tree, flatList };
222
+ }
223
+ """
224
+
118
225
 
119
226
  def _walk_tree(node: dict, depth: int = 0) -> list[str]:
120
227
  """Recursively walk the parsed tree and produce YAML lines."""
@@ -158,11 +265,13 @@ def _yaml_escape(s: str) -> str:
158
265
  if any(
159
266
  c in s
160
267
  for c in (
268
+ "\\",
161
269
  ":",
162
270
  "#",
163
271
  "'",
164
272
  '"',
165
273
  "\n",
274
+ "\r",
166
275
  "[",
167
276
  "]",
168
277
  "{",
@@ -182,50 +291,45 @@ def _yaml_escape(s: str) -> str:
182
291
  "`",
183
292
  )
184
293
  ):
185
- escaped = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
294
+ escaped = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
186
295
  return f'"{escaped}"'
187
296
  return s
188
297
 
189
298
 
190
- async def take_snapshot(page) -> tuple[str, dict[str, dict]]:
191
- """Take a DOM snapshot. Returns (yaml_text, ref_map)."""
299
+ async def take_snapshot(page, root_element=None) -> tuple[str, dict[str, dict]]:
300
+ """Take a DOM snapshot. Returns (yaml_text, ref_map).
301
+
302
+ If root_element is provided, only snapshot the subtree under that element.
303
+ """
192
304
  result = None
193
- try:
194
- result = await page.evaluate(_SNAPSHOT_JS, isolated_context=False)
195
- except TypeError:
305
+ if root_element:
196
306
  try:
197
- result = await page.evaluate(_SNAPSHOT_JS)
307
+ result = await root_element.evaluate(_SNAPSHOT_ELEMENT_JS)
308
+ except Exception:
309
+ pass
310
+ else:
311
+ try:
312
+ result = await page.evaluate(_SNAPSHOT_JS, isolated_context=False)
313
+ except TypeError:
314
+ try:
315
+ result = await page.evaluate(_SNAPSHOT_JS)
316
+ except Exception:
317
+ pass
198
318
  except Exception:
199
319
  pass
200
- except Exception:
201
- pass
202
320
 
203
321
  if not result or not result.get("tree"):
204
322
  return "# Empty page - no accessible elements found\n", {}
205
323
 
206
324
  flat_list = result.get("flatList", [])
325
+ tree = result.get("tree", {})
207
326
 
208
327
  refs: dict[str, dict] = {}
209
- lines: list[str] = []
210
328
  for item in flat_list:
211
329
  refs[item["ref"]] = item
212
- lines.append(f"- ref: {item['ref']}")
213
- lines.append(f" role: {item.get('role', '')}")
214
- name = item.get("name", "")
215
- if name:
216
- lines.append(f" name: {_yaml_escape(name)}")
217
- for key in ("value", "url"):
218
- val = item.get(key, "")
219
- if val:
220
- lines.append(f" {key}: {_yaml_escape(str(val))}")
221
- if item.get("checked") is not None:
222
- lines.append(f" checked: {str(item['checked']).lower()}")
223
- if item.get("disabled"):
224
- lines.append(" disabled: true")
225
- if item.get("level") is not None:
226
- lines.append(f" level: {item['level']}")
227
-
228
- yaml_text = "\n".join(lines) + "\n"
330
+
331
+ lines = _walk_tree(tree)
332
+ yaml_text = "\n".join(lines) + "\n" if lines else "# Empty page - no accessible elements found\n"
229
333
  return yaml_text, refs
230
334
 
231
335
 
@@ -6,7 +6,7 @@ name = "click"
6
6
  version = "8.3.1"
7
7
  source = { registry = "https://pypi.org/simple" }
8
8
  dependencies = [
9
- { name = "colorama", marker = "platform_system == 'Windows'" },
9
+ { name = "colorama", marker = "sys_platform == 'win32'" },
10
10
  ]
11
11
  sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
12
12
  wheels = [
@@ -103,7 +103,7 @@ wheels = [
103
103
 
104
104
  [[package]]
105
105
  name = "patchright-cli"
106
- version = "0.1.0"
106
+ version = "0.3.0"
107
107
  source = { editable = "." }
108
108
  dependencies = [
109
109
  { name = "click" },
File without changes