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.
- patchright_cli-0.3.0/CLAUDE.md +55 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/PKG-INFO +30 -16
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/README.md +29 -15
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/docs/installation.md +38 -35
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/pyproject.toml +1 -1
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/skills/patchright-cli/SKILL.md +47 -12
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/src/patchright_cli/__init__.py +1 -1
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/src/patchright_cli/cli.py +46 -15
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/src/patchright_cli/daemon.py +186 -52
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/src/patchright_cli/snapshot.py +132 -28
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/uv.lock +2 -2
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/.gitignore +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/.pre-commit-config.yaml +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/CONTRIBUTING.md +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/LICENSE +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/patchright-cli +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/patchright-cli.cmd +0 -0
- {patchright_cli-0.2.0 → patchright_cli-0.3.0}/src/patchright_cli/__main__.py +0 -0
|
@@ -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.
|
|
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
|
-
#
|
|
86
|
-
|
|
82
|
+
# Recommended — always runs latest version, no install needed
|
|
83
|
+
uvx patchright-cli open https://example.com
|
|
84
|
+
```
|
|
87
85
|
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
<details>
|
|
87
|
+
<summary><b>Other install methods</b></summary>
|
|
90
88
|
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
58
|
-
|
|
54
|
+
# Recommended — always runs latest version, no install needed
|
|
55
|
+
uvx patchright-cli open https://example.com
|
|
56
|
+
```
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
<details>
|
|
59
|
+
<summary><b>Other install methods</b></summary>
|
|
62
60
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
10
|
+
### Step 1: Ensure uv is installed
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
15
|
+
uv --version
|
|
20
16
|
```
|
|
21
17
|
|
|
22
|
-
|
|
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
|
-
|
|
22
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
29
23
|
|
|
30
24
|
# Windows (PowerShell)
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
49
|
+
<details>
|
|
50
|
+
<summary><b>Alternative: pip install</b></summary>
|
|
49
51
|
|
|
50
52
|
```bash
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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,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
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
266
|
+
# Recommended — always runs latest version, no install needed
|
|
267
|
+
uvx patchright-cli open https://example.com
|
|
234
268
|
|
|
235
|
-
Or
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
```
|
|
@@ -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
|
|
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] [--
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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")
|
|
558
|
+
if options.get("httpOnly"):
|
|
505
559
|
cookie["httpOnly"] = True
|
|
506
|
-
if options.get("secure")
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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 =
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
await
|
|
869
|
-
|
|
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")
|
|
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
|
-
|
|
194
|
-
result = await page.evaluate(_SNAPSHOT_JS, isolated_context=False)
|
|
195
|
-
except TypeError:
|
|
305
|
+
if root_element:
|
|
196
306
|
try:
|
|
197
|
-
result = await
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 = "
|
|
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.
|
|
106
|
+
version = "0.3.0"
|
|
107
107
|
source = { editable = "." }
|
|
108
108
|
dependencies = [
|
|
109
109
|
{ name = "click" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|