patchright-cli 0.9.0__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  """patchright-cli — Undetected browser automation CLI using Patchright."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.9.1"
@@ -59,8 +59,21 @@ patchright-cli goto "https://example.com/search?q=cats&page=2"
59
59
  ```
60
60
 
61
61
  The same applies to `#` (a bash comment), spaces, and `%` (expanded by
62
- cmd.exe), and to any argument holding shell metacharacters. Quoting always
63
- works; leave it on.
62
+ cmd.exe), and to any argument holding shell metacharacters. Quoting works for
63
+ the installed `patchright-cli.exe` in bash, PowerShell and cmd.exe; leave it on.
64
+
65
+ On Windows, two cases need more than quotes:
66
+
67
+ ```
68
+ # cmd.exe without quotes: escape the & as ^&. Inside quotes ^ is kept
69
+ # literally, so use one or the other, never both.
70
+ patchright-cli goto https://example.com/search?q=cats^&page=2
71
+
72
+ # PowerShell calling a .cmd/.bat wrapper (such as the repo's patchright-cli.cmd):
73
+ # PowerShell drops the quotes and cmd.exe splits at &. Pass the rest verbatim
74
+ # with --%. Don't use --% with patchright-cli.exe: in PowerShell 7 the quotes end up in the URL.
75
+ .\patchright-cli.cmd --% goto "https://example.com/search?q=cats&page=2"
76
+ ```
64
77
 
65
78
  ## Global options
66
79
 
@@ -320,6 +333,25 @@ show # Live dashboard
320
333
  show --annotate # Ask a human to mark up the page and wait for it
321
334
  ```
322
335
 
336
+ ### Emulation
337
+
338
+ Each setting holds for the whole session: every open tab, and tabs and popups
339
+ opened later (a popup gets it just after it opens). `clear-*` drops just that
340
+ one, back to the browser default.
341
+
342
+ ```bash
343
+ set-color-scheme dark # dark | light | no-preference
344
+ clear-color-scheme
345
+ set-reduced-motion reduce # reduce | no-preference
346
+ clear-reduced-motion
347
+ set-forced-colors active # active | none
348
+ clear-forced-colors
349
+ set-contrast more # more | no-preference
350
+ clear-contrast
351
+ set-media print # screen | print
352
+ clear-media
353
+ ```
354
+
323
355
  ### Storage
324
356
 
325
357
  ```bash
patchright_cli/cli.py CHANGED
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
  import click
18
18
 
19
19
  from patchright_cli import __version__
20
- from patchright_cli.daemon import DEFAULT_PORT, ensure_daemon_running, read_daemon_token
20
+ from patchright_cli.daemon import DEFAULT_PORT, PROTOCOL_VERSION, ensure_daemon_running, read_daemon_token, token_path
21
21
 
22
22
 
23
23
  def _soften_output_encoding(*streams) -> None:
@@ -497,6 +497,17 @@ COMMANDS_HELP = {
497
497
  # Upload / Resize
498
498
  "upload": "upload <file> [ref] Upload file to input",
499
499
  "resize": "resize <w> <h> Resize viewport",
500
+ # Emulation
501
+ "set-color-scheme": "set-color-scheme <dark|light|no-preference> Emulate prefers-color-scheme",
502
+ "clear-color-scheme": "clear-color-scheme Stop emulating prefers-color-scheme",
503
+ "set-reduced-motion": "set-reduced-motion <reduce|no-preference> Emulate prefers-reduced-motion",
504
+ "clear-reduced-motion": "clear-reduced-motion Stop emulating prefers-reduced-motion",
505
+ "set-forced-colors": "set-forced-colors <active|none> Emulate forced-colors",
506
+ "clear-forced-colors": "clear-forced-colors Stop emulating forced-colors",
507
+ "set-contrast": "set-contrast <more|no-preference> Emulate prefers-contrast",
508
+ "clear-contrast": "clear-contrast Stop emulating prefers-contrast",
509
+ "set-media": "set-media <screen|print> Emulate CSS media type",
510
+ "clear-media": "clear-media Stop emulating the CSS media type",
500
511
  # State
501
512
  "state-save": "state-save [file] Save cookies+storage to JSON",
502
513
  "state-load": "state-load <file> Load saved state",
@@ -547,6 +558,7 @@ COMMANDS_HELP = {
547
558
  # Setup
548
559
  "install": "install --skills Install skill files for AI agents",
549
560
  "mcp": "mcp [--http] [--host=HOST] [--port=N] Start MCP server (stdio by default)",
561
+ "daemon-info": "daemon-info --json Show daemon client protocol and token path without starting it",
550
562
  }
551
563
 
552
564
  ALL_COMMANDS = list(COMMANDS_HELP.keys())
@@ -619,6 +631,21 @@ def _print_help():
619
631
  ("Tabs", ["tab-list", "tab-new", "tab-close", "tab-select"]),
620
632
  ("Dialog", ["dialog-accept", "dialog-dismiss"]),
621
633
  ("Upload/Resize", ["upload", "resize"]),
634
+ (
635
+ "Emulation",
636
+ [
637
+ "set-color-scheme",
638
+ "clear-color-scheme",
639
+ "set-reduced-motion",
640
+ "clear-reduced-motion",
641
+ "set-forced-colors",
642
+ "clear-forced-colors",
643
+ "set-contrast",
644
+ "clear-contrast",
645
+ "set-media",
646
+ "clear-media",
647
+ ],
648
+ ),
622
649
  ("State", ["state-save", "state-load"]),
623
650
  (
624
651
  "Storage",
@@ -653,7 +680,7 @@ def _print_help():
653
680
  ("Profiles", ["profile-list", "profile-delete"]),
654
681
  ("Dashboard", ["show"]),
655
682
  ("Codegen", ["codegen", "codegen-stop"]),
656
- ("Setup", ["install", "mcp"]),
683
+ ("Setup", ["install", "mcp", "daemon-info"]),
657
684
  ]
658
685
  for cat_name, cmds in categories:
659
686
  click.echo(f"\n {cat_name}:")
@@ -823,6 +850,22 @@ def main():
823
850
  _print_help()
824
851
  sys.exit(1)
825
852
 
853
+ if command == "daemon-info":
854
+ if not as_json or args or extra_opts:
855
+ click.echo("Usage: patchright-cli daemon-info --json [--port=N]", err=True)
856
+ sys.exit(1)
857
+ click.echo(
858
+ json.dumps(
859
+ {
860
+ "version": __version__,
861
+ "protocol": PROTOCOL_VERSION,
862
+ "port": port,
863
+ "token_path": str(token_path(port).resolve()),
864
+ }
865
+ )
866
+ )
867
+ return
868
+
826
869
  if command == "install":
827
870
  _handle_install(args + [k if v is True else f"{k}={v}" for k, v in extra_opts.items()])
828
871
  sys.exit(0)
patchright_cli/daemon.py CHANGED
@@ -33,6 +33,9 @@ from patchright_cli.snapshot import save_snapshot, take_snapshot
33
33
 
34
34
  logger = logging.getLogger("patchright-cli.daemon")
35
35
 
36
+ # Public client-daemon contract. Bump for incompatible framing, request/reply
37
+ # fields, or authentication changes. Additive optional fields do not require a bump.
38
+ PROTOCOL_VERSION = 1
36
39
  DEFAULT_PORT = 9321
37
40
  PROFILES_ROOT = Path.home() / ".patchright-cli" / "profiles"
38
41
  # Holds daemon-<port>.token, the shared secret every client message must carry.
@@ -143,6 +146,13 @@ class Session:
143
146
  self.console_messages: list[dict] = []
144
147
  self.network_log: list[dict] = []
145
148
  self._pending_dialog_action: tuple | None = None
149
+ # Media features set by `set-color-scheme` and friends, as emulate_media
150
+ # keyword arguments. emulate_media is per Page, so every Page this
151
+ # Session sees gets them, including ones opened later.
152
+ self.emulation: dict[str, str] = {}
153
+ # Held while emulation changes and while it is applied to pages, so two
154
+ # clients setting media at once can't leave pages disagreeing with it.
155
+ self.emulation_lock = asyncio.Lock()
146
156
  # The Profile a Launched Session runs on (None for Attached Sessions).
147
157
  self.profile: Profile | None = None
148
158
  self._cdp_sessions: dict[int, object] = {}
@@ -172,8 +182,17 @@ class Session:
172
182
  if page not in self.pages:
173
183
  self.pages.append(page)
174
184
  self.current_tab = len(self.pages) - 1
185
+ # Emulate before the listener setup awaits, so a popup's own scripts
186
+ # see the session's media features as early as possible.
187
+ await self.apply_emulation(page)
175
188
  await self._attach_page_listeners(page)
176
189
 
190
+ async def apply_emulation(self, page) -> None:
191
+ """Give `page` this Session's emulated media features, if any are set."""
192
+ async with self.emulation_lock:
193
+ if self.emulation and not page.is_closed():
194
+ await page.emulate_media(**self.emulation)
195
+
177
196
  async def _attach_page_listeners(self, page):
178
197
  # Use CDP for console messages (Patchright suppresses page.on('console'))
179
198
  try:
@@ -400,6 +419,9 @@ class Session:
400
419
  """Open a Page in this Session's context and record it as ours."""
401
420
  page = await self.context.new_page()
402
421
  self.own_pages.add(page)
422
+ # The context's "page" handler applies this too, but runs as a task;
423
+ # applying here means the caller never navigates an unemulated page.
424
+ await self.apply_emulation(page)
403
425
  return page
404
426
 
405
427
  def may_close_page(self, page) -> bool:
@@ -2249,6 +2271,60 @@ async def cmd_resize(session: Session, page, args: list, options: dict, cwd: str
2249
2271
  return {"success": True, "output": f"Viewport resized to {w}x{h}"}
2250
2272
 
2251
2273
 
2274
+ # -- Emulation ---------------------------------------------------------------
2275
+
2276
+ # Command suffix -> (emulate_media keyword, allowed values, value clear-* restores).
2277
+ # The values match playwright-cli's. emulate_media's "null" would drop the
2278
+ # override and expose the OS setting, but a page starts out with Playwright's
2279
+ # context defaults (light, no-preference, none, ...), so clearing restores those.
2280
+ _EMULATED_MEDIA = {
2281
+ "color-scheme": ("color_scheme", ("dark", "light", "no-preference"), "light"),
2282
+ "reduced-motion": ("reduced_motion", ("reduce", "no-preference"), "no-preference"),
2283
+ "forced-colors": ("forced_colors", ("active", "none"), "none"),
2284
+ "contrast": ("contrast", ("more", "no-preference"), "no-preference"),
2285
+ "media": ("media", ("screen", "print"), "null"),
2286
+ }
2287
+
2288
+
2289
+ async def _emulate_on_all_pages(session: Session, **features: str) -> None:
2290
+ for p in list(session.pages):
2291
+ if p.is_closed():
2292
+ continue
2293
+ try:
2294
+ await p.emulate_media(**features)
2295
+ except Exception:
2296
+ # A page closing mid-loop must not stop the rest; a live page's error still counts.
2297
+ if not p.is_closed():
2298
+ raise
2299
+
2300
+
2301
+ def _register_emulation(feature: str, kwarg: str, allowed: tuple[str, ...], default: str) -> None:
2302
+ async def cmd_set(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
2303
+ value = args[0] if args else ""
2304
+ if value not in allowed:
2305
+ return {
2306
+ "success": False,
2307
+ "output": f"Usage: set-{feature} <{'|'.join(allowed)}> (got {value!r})",
2308
+ }
2309
+ async with session.emulation_lock:
2310
+ session.emulation[kwarg] = value
2311
+ await _emulate_on_all_pages(session, **{kwarg: value})
2312
+ return {"success": True, "output": f"Emulating {feature}: {value}"}
2313
+
2314
+ async def cmd_clear(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
2315
+ async with session.emulation_lock:
2316
+ session.emulation.pop(kwarg, None)
2317
+ await _emulate_on_all_pages(session, **{kwarg: default})
2318
+ return {"success": True, "output": f"Cleared {feature} emulation"}
2319
+
2320
+ register(f"set-{feature}")(cmd_set)
2321
+ register(f"clear-{feature}")(cmd_clear)
2322
+
2323
+
2324
+ for _feature, (_kwarg, _allowed, _default) in _EMULATED_MEDIA.items():
2325
+ _register_emulation(_feature, _kwarg, _allowed, _default)
2326
+
2327
+
2252
2328
  # -- Permissions -------------------------------------------------------------
2253
2329
 
2254
2330
 
@@ -2928,6 +3004,7 @@ _CODEGEN_RECORDABLE = {
2928
3004
  "tab-select",
2929
3005
  "upload",
2930
3006
  "resize",
3007
+ *(f"{verb}-{feature}" for feature in _EMULATED_MEDIA for verb in ("set", "clear")),
2931
3008
  "cookie-set",
2932
3009
  "cookie-delete",
2933
3010
  "cookie-clear",
@@ -3421,10 +3498,31 @@ def check_auth(msg: object, expected: str | None, port: int) -> str | None:
3421
3498
  return None
3422
3499
 
3423
3500
 
3424
- async def _read_message(reader: asyncio.StreamReader) -> dict | None:
3425
- """Read a length-prefixed JSON message from the stream."""
3501
+ # A request is read before its token is checked, so any local process can
3502
+ # send one. Bound how long the whole request may take to arrive and how long
3503
+ # it may claim to be, so a client cannot hold a connection open forever or
3504
+ # make the daemon buffer a 4 GB body. Commands and responses are not bounded.
3505
+ REQUEST_READ_TIMEOUT = 30.0
3506
+ # The largest real request is an eval/run-code script read from --file or
3507
+ # stdin (a bundled library is a few MB); other args come from argv, which the
3508
+ # OS caps at about 2 MB. 64 MiB leaves an order of magnitude of headroom.
3509
+ MAX_REQUEST_BYTES = 64 * 1024 * 1024
3510
+
3511
+
3512
+ class RequestTooLarge(Exception):
3513
+ """A client announced a request longer than MAX_REQUEST_BYTES."""
3514
+
3515
+
3516
+ async def _read_message(reader: asyncio.StreamReader, max_bytes: int = MAX_REQUEST_BYTES) -> dict | None:
3517
+ """Read a length-prefixed JSON message from the stream.
3518
+
3519
+ Raises RequestTooLarge, without reading the body, when the announced
3520
+ length exceeds `max_bytes`.
3521
+ """
3426
3522
  header = await reader.readexactly(4)
3427
3523
  length = struct.unpack("!I", header)[0]
3524
+ if length > max_bytes:
3525
+ raise RequestTooLarge(f"Daemon error: request of {length} bytes exceeds the {max_bytes}-byte limit.")
3428
3526
  data = await reader.readexactly(length)
3429
3527
  return json.loads(data.decode("utf-8"))
3430
3528
 
@@ -3445,7 +3543,15 @@ async def _handle_client(
3445
3543
  addr = writer.get_extra_info("peername")
3446
3544
  logger.debug("Client connected: %s", addr)
3447
3545
  try:
3448
- msg = await _read_message(reader)
3546
+ try:
3547
+ msg = await asyncio.wait_for(_read_message(reader), REQUEST_READ_TIMEOUT)
3548
+ except asyncio.TimeoutError:
3549
+ logger.warning("Closed connection from %s: request not received within %ss", addr, REQUEST_READ_TIMEOUT)
3550
+ return
3551
+ except RequestTooLarge as e:
3552
+ logger.warning("Rejected oversized request from %s: %s", addr, e)
3553
+ await _write_message(writer, {"success": False, "output": str(e)})
3554
+ return
3449
3555
  if msg is None:
3450
3556
  return
3451
3557
  port = writer.get_extra_info("sockname")[1]
@@ -450,6 +450,61 @@ TOOL_SPECS = {
450
450
  ("session", "string", False, "default", "Named Patchright browser session."),
451
451
  ],
452
452
  },
453
+ "patchright_set_color_scheme": {
454
+ "description": "CLI command: set-color-scheme Emulate the prefers-color-scheme media feature on every tab of the session.",
455
+ "params": [
456
+ ("value", "string", True, None, "Dark, light or no-preference."),
457
+ ("session", "string", False, "default", "Named Patchright browser session."),
458
+ ],
459
+ },
460
+ "patchright_clear_color_scheme": {
461
+ "description": "CLI command: clear-color-scheme Stop emulating the prefers-color-scheme media feature.",
462
+ "params": [("session", "string", False, "default", "Named Patchright browser session.")],
463
+ },
464
+ "patchright_set_reduced_motion": {
465
+ "description": "CLI command: set-reduced-motion Emulate the prefers-reduced-motion media feature on every tab of the session.",
466
+ "params": [
467
+ ("value", "string", True, None, "Reduce or no-preference."),
468
+ ("session", "string", False, "default", "Named Patchright browser session."),
469
+ ],
470
+ },
471
+ "patchright_clear_reduced_motion": {
472
+ "description": "CLI command: clear-reduced-motion Stop emulating the prefers-reduced-motion media feature.",
473
+ "params": [("session", "string", False, "default", "Named Patchright browser session.")],
474
+ },
475
+ "patchright_set_forced_colors": {
476
+ "description": "CLI command: set-forced-colors Emulate the forced-colors media feature on every tab of the session.",
477
+ "params": [
478
+ ("value", "string", True, None, "Active or none."),
479
+ ("session", "string", False, "default", "Named Patchright browser session."),
480
+ ],
481
+ },
482
+ "patchright_clear_forced_colors": {
483
+ "description": "CLI command: clear-forced-colors Stop emulating the forced-colors media feature.",
484
+ "params": [("session", "string", False, "default", "Named Patchright browser session.")],
485
+ },
486
+ "patchright_set_contrast": {
487
+ "description": "CLI command: set-contrast Emulate the prefers-contrast media feature on every tab of the session.",
488
+ "params": [
489
+ ("value", "string", True, None, "More or no-preference."),
490
+ ("session", "string", False, "default", "Named Patchright browser session."),
491
+ ],
492
+ },
493
+ "patchright_clear_contrast": {
494
+ "description": "CLI command: clear-contrast Stop emulating the prefers-contrast media feature.",
495
+ "params": [("session", "string", False, "default", "Named Patchright browser session.")],
496
+ },
497
+ "patchright_set_media": {
498
+ "description": "CLI command: set-media Emulate the CSS media type on every tab of the session.",
499
+ "params": [
500
+ ("value", "string", True, None, "Screen or print."),
501
+ ("session", "string", False, "default", "Named Patchright browser session."),
502
+ ],
503
+ },
504
+ "patchright_clear_media": {
505
+ "description": "CLI command: clear-media Stop emulating the CSS media type.",
506
+ "params": [("session", "string", False, "default", "Named Patchright browser session.")],
507
+ },
453
508
  "patchright_state_save": {
454
509
  "description": "CLI command: state-save Save cookies and storage state.",
455
510
  "params": [
@@ -961,6 +1016,16 @@ def _build_args(name: str, p: dict[str, Any]) -> list[str]:
961
1016
  "patchright_sessionstorage_set": ("sessionstorage-set", ["key", "value"]),
962
1017
  "patchright_sessionstorage_delete": ("sessionstorage-delete", ["key"]),
963
1018
  "patchright_network_state_set": ("network-state-set", ["state"]),
1019
+ "patchright_set_color_scheme": ("set-color-scheme", ["value"]),
1020
+ "patchright_clear_color_scheme": ("clear-color-scheme", []),
1021
+ "patchright_set_reduced_motion": ("set-reduced-motion", ["value"]),
1022
+ "patchright_clear_reduced_motion": ("clear-reduced-motion", []),
1023
+ "patchright_set_forced_colors": ("set-forced-colors", ["value"]),
1024
+ "patchright_clear_forced_colors": ("clear-forced-colors", []),
1025
+ "patchright_set_contrast": ("set-contrast", ["value"]),
1026
+ "patchright_clear_contrast": ("clear-contrast", []),
1027
+ "patchright_set_media": ("set-media", ["value"]),
1028
+ "patchright_clear_media": ("clear-media", []),
964
1029
  "patchright_tracing_stop": ("tracing-stop", []),
965
1030
  "patchright_video_chapter": ("video-chapter", ["title"]),
966
1031
  "patchright_codegen": ("codegen", []),
@@ -1270,6 +1335,16 @@ def _python_type(type_name: str) -> type:
1270
1335
  return {"string": str, "boolean": bool, "number": int}[type_name]
1271
1336
 
1272
1337
 
1338
+ # Allowed values for each set-* emulation tool, in the daemon's order.
1339
+ _EMULATION_VALUES = {
1340
+ "patchright_set_color_scheme": ("dark", "light", "no-preference"),
1341
+ "patchright_set_reduced_motion": ("reduce", "no-preference"),
1342
+ "patchright_set_forced_colors": ("active", "none"),
1343
+ "patchright_set_contrast": ("more", "no-preference"),
1344
+ "patchright_set_media": ("screen", "print"),
1345
+ }
1346
+
1347
+
1273
1348
  def _parameter_type(tool_name: str, param_name: str, type_name: str) -> Any:
1274
1349
  if tool_name == "patchright_attach" and param_name == "context":
1275
1350
  return Literal["new", "host"]
@@ -1289,6 +1364,9 @@ def _parameter_type(tool_name: str, param_name: str, type_name: str) -> Any:
1289
1364
  if tool_name == "patchright_network_state_set" and param_name == "state":
1290
1365
  return Literal["online", "offline"]
1291
1366
 
1367
+ if tool_name in _EMULATION_VALUES and param_name == "value":
1368
+ return Literal[_EMULATION_VALUES[tool_name]]
1369
+
1292
1370
  return _python_type(type_name)
1293
1371
 
1294
1372
 
@@ -13,7 +13,32 @@ from typing import TYPE_CHECKING
13
13
  if TYPE_CHECKING:
14
14
  from patchright.async_api import Page
15
15
 
16
- _NODE_LINE_RE = re.compile(r"^\s*-\s+(\w+)(?:\s+\"((?:[^\"\\]|\\.)*)\")?")
16
+ # Playwright writes each node as `- role "name" [attrs]`, with the name
17
+ # JSON-encoded, except that a name which starts and ends with "/" is written
18
+ # as is (`- link /about/:`). A key containing YAML-special text (": ", " #")
19
+ # is wrapped in YAML single quotes, with ' doubled. A plain key never contains
20
+ # ":" followed by whitespace or the line end, so that marks where its value
21
+ # starts.
22
+ _ITEM_RE = re.compile(r"^\s*-\s+(?:'((?:[^']|'')*)'|((?:[^:]|:(?!\s|$))*))")
23
+ _KEY_RE = re.compile(r"^(\w+)(?:\s+(?:\"((?:[^\"\\]|\\.)*)\"|(/.*/)(?=(?:\s\[[^\]]*\])*$)))?")
24
+
25
+ # Playwright leaves out names longer than 900 characters, so an unnamed node
26
+ # is an element whose accessible name is empty or over that limit.
27
+ UNNAMED_RE = re.compile(r"^$|^[\s\S]{901,}$")
28
+
29
+
30
+ def _parse_node(line: str) -> tuple[str, str] | None:
31
+ """Return (role, name) for an aria snapshot node line, or None."""
32
+ item = _ITEM_RE.match(line)
33
+ if not item:
34
+ return None
35
+ key = item.group(1).replace("''", "'") if item.group(1) is not None else item.group(2)
36
+ m = _KEY_RE.match(key)
37
+ if not m:
38
+ return None
39
+ if m.group(2) is not None:
40
+ return m.group(1), _unescape_name(m.group(2))
41
+ return m.group(1), m.group(3) or ""
17
42
 
18
43
 
19
44
  def _unescape_name(raw: str) -> str:
@@ -145,8 +170,8 @@ class RefRegistry:
145
170
  result_lines: list[str] = []
146
171
 
147
172
  for line in aria_text.splitlines():
148
- m = _NODE_LINE_RE.match(line)
149
- if not m:
173
+ node = _parse_node(line)
174
+ if node is None:
150
175
  result_lines.append(line)
151
176
  continue
152
177
 
@@ -156,12 +181,11 @@ class RefRegistry:
156
181
  result_lines.append(line)
157
182
  continue
158
183
 
159
- role = m.group(1)
184
+ role, name = node
160
185
 
161
186
  if interactive_only and role not in INTERACTIVE_ROLES:
162
187
  result_lines.append(line)
163
188
  continue
164
- name = _unescape_name(m.group(2)) if m.group(2) else ""
165
189
 
166
190
  self._counter += 1
167
191
  ref = f"e{self._counter}"
@@ -239,9 +263,9 @@ class RefRegistry:
239
263
  if line_depth >= depth:
240
264
  continue
241
265
  depth = line_depth
242
- node = _NODE_LINE_RE.match(line)
266
+ node = _parse_node(line)
243
267
  if node:
244
- chain.append((node.group(1), _unescape_name(node.group(2)) if node.group(2) else ""))
268
+ chain.append(node)
245
269
  if depth == 0:
246
270
  break
247
271
 
@@ -281,12 +305,11 @@ class RefRegistry:
281
305
  if not entry:
282
306
  raise ValueError(f"Ref @{ref} not found. The page may have changed — run 'snapshot' to refresh.")
283
307
 
284
- kwargs: dict = {"exact": True}
285
- if entry.name:
286
- kwargs["name"] = entry.name
287
-
308
+ # Always filter by name, even for an unnamed ref: nth counts only the
309
+ # elements the snapshot shows with this name, so an unfiltered role
310
+ # query picks the wrong element when named and unnamed ones share a role.
288
311
  scope = self.root if self.root is not None else page
289
- locator = scope.get_by_role(entry.role, **kwargs)
312
+ locator = scope.get_by_role(entry.role, name=entry.name or UNNAMED_RE, exact=True)
290
313
  return locator.nth(entry.nth)
291
314
 
292
315
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: patchright-cli
3
- Version: 0.9.0
3
+ Version: 0.9.1
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
@@ -211,6 +211,16 @@ patchright-cli screenshot
211
211
  patchright-cli close
212
212
  ```
213
213
 
214
+ Quote URLs that contain `&`; unquoted, bash, PowerShell and cmd.exe all split the command there. On Windows two cases need more than quotes: in cmd.exe without quotes, escape it as `^&` (inside quotes the `^` stays in the URL, so use one or the other), and when PowerShell runs a `.cmd`/`.bat` wrapper such as the repo's `patchright-cli.cmd`, PowerShell drops the quotes and cmd.exe splits at `&`, so pass the rest verbatim with `--%`:
215
+
216
+ ```
217
+ patchright-cli goto "https://example.com/?a=1&b=2" # any shell, patchright-cli.exe
218
+ patchright-cli goto https://example.com/?a=1^&b=2 # cmd.exe, unquoted
219
+ .\patchright-cli.cmd --% goto "https://example.com/?a=1&b=2" # PowerShell, .cmd wrapper
220
+ ```
221
+
222
+ Don't use `--%` with the installed `patchright-cli.exe`: in PowerShell 7 the quotes after it reach the CLI and end up in the URL.
223
+
214
224
  ---
215
225
 
216
226
  ## Architecture
@@ -236,6 +246,12 @@ The daemon only listens on `127.0.0.1`, but any local process can reach that por
236
246
  - Run the CLI as the same user, with the same home directory, as the daemon. In Docker the MCP server and daemon share a user and `HOME`, so this holds.
237
247
  - An older CLI talking to a newer daemon gets an error saying the daemon requires an auth token: upgrade, or stop the daemon (`patchright-cli kill-all` from the newer install, or end the process). A newer CLI talking to an older daemon finds no token file and sends none, which the older daemon accepts.
238
248
 
249
+ ### Daemon client protocol
250
+
251
+ Third-party clients can use the daemon TCP interface directly. The
252
+ [client protocol reference](docs/daemon-protocol.md) defines discovery,
253
+ authentication, message framing, replies, and compatibility rules.
254
+
239
255
  ---
240
256
 
241
257
  <details>
@@ -322,6 +338,22 @@ patchright-cli upload ./file.pdf e5 # Upload to specific input
322
338
  patchright-cli resize 1920 1080 # Resize viewport
323
339
  ```
324
340
 
341
+ ### Emulation
342
+ ```bash
343
+ patchright-cli set-color-scheme dark # dark | light | no-preference
344
+ patchright-cli clear-color-scheme
345
+ patchright-cli set-reduced-motion reduce # reduce | no-preference
346
+ patchright-cli clear-reduced-motion
347
+ patchright-cli set-forced-colors active # active | none
348
+ patchright-cli clear-forced-colors
349
+ patchright-cli set-contrast more # more | no-preference
350
+ patchright-cli clear-contrast
351
+ patchright-cli set-media print # screen | print
352
+ patchright-cli clear-media
353
+ ```
354
+
355
+ Each setting holds for the whole session, including tabs and popups opened later; `clear-*` drops just that one, back to the browser default. A popup gets the setting just after it opens, so a script that reads media queries the instant the popup is created may still see the default.
356
+
325
357
  ### Tabs
326
358
  ```bash
327
359
  patchright-cli tab-list
@@ -1,12 +1,12 @@
1
- patchright_cli/__init__.py,sha256=Y077828jzMEG-uKaHGXu8qCw-tq2nprv3gBnw8r1Km0,100
1
+ patchright_cli/__init__.py,sha256=2DOVMaTfG_CvRho_ERA9ANKCkBOJHqm6h7HhxtD9ctE,100
2
2
  patchright_cli/__main__.py,sha256=iVuBuI_rPOENLhffOEHoS7JWCDBQciqT1kPXjRuhbZ4,127
3
- patchright_cli/cli.py,sha256=-ev_yN-jcudW08gOUH0YOJU5xs7FqL7sh3cbDhRM6A0,36832
4
- patchright_cli/daemon.py,sha256=PTDXJMy8Vw7NFyS5ZBsFdRY7xXfL5Ivfz607KI32U04,145793
3
+ patchright_cli/cli.py,sha256=JO_c97zF6QE3O8hkFAt9YCKxBLKfmU1ayYuGNhIsmzM,38798
4
+ patchright_cli/daemon.py,sha256=sov1SzXNEfM_Hov8tkCx09ZGhBkIlBeRw6fRvTRunFM,151073
5
5
  patchright_cli/dashboard.py,sha256=AfonvEkjzTR0Ao3iLHSXzy54lMNU6WgEgklaXdBz_is,16317
6
- patchright_cli/mcp_server.py,sha256=lt9BsAkmNqBhxMgg760q2OrTJxvIlNNkHskjTOYut1I,61307
7
- patchright_cli/ref_registry.py,sha256=PNpzBzD4oolm5-_PoOUxty2ImHFGmUFpjF3e1WTT5Ag,10348
6
+ patchright_cli/mcp_server.py,sha256=cVhOyk_gRXLgxWTIuFHVUw3lO1IR-wIMdPgLO4zVJ3E,65527
7
+ patchright_cli/ref_registry.py,sha256=zpBH6yrSIyAlbpSDOv5p-HIVTVSx3iieKadPKQPD75A,11525
8
8
  patchright_cli/snapshot.py,sha256=e_XAF5FPWFKeSv9HdbSHebRiBuu68o4rkLVauCoCUWY,1587
9
- patchright_cli/_skills/patchright-cli/SKILL.md,sha256=UDjK4iJ__lhwsqpfYC28cZ9ixAwjdLLJdStGcnGuFf4,19744
9
+ patchright_cli/_skills/patchright-cli/SKILL.md,sha256=RlgLGVvRNbETecVo5sX1uxin1k4IwO4ztQoTZ_hoBvw,20944
10
10
  patchright_cli/_skills/patchright-cli/references/request-mocking.md,sha256=x4t_5gPxSsYvm0BlrZqbdM3MHqGVzNY_ac6Awg6PToc,2901
11
11
  patchright_cli/_skills/patchright-cli/references/running-code.md,sha256=Ls0HhV8gxT5XOHfiv9-B8eBvYn-RR21wZ7Iyh3uskuk,3242
12
12
  patchright_cli/_skills/patchright-cli/references/session-management.md,sha256=FLhKaF9FYAXVwPbz4wuEf73qmZuhoxqfE7Js1ktMzt8,7063
@@ -14,8 +14,8 @@ patchright_cli/_skills/patchright-cli/references/snapshot-refs.md,sha256=m9Q8G4W
14
14
  patchright_cli/_skills/patchright-cli/references/storage-state.md,sha256=qfL_2v_WHHyVUIPj4efMU46KOMbYyvtLll4ha84mOHw,3348
15
15
  patchright_cli/_skills/patchright-cli/references/tracing.md,sha256=6IwbGDsrtuB3bckB_7noyjpU37mdemIC435IuLilBZk,1931
16
16
  patchright_cli/_skills/patchright-cli/references/video-recording.md,sha256=qbWjMmE9-Q6Mp8deG2Cf6QY2fL4GOkx1_-erSGkoN8I,3905
17
- patchright_cli-0.9.0.dist-info/METADATA,sha256=6PeSRhXaqMvAQSFlmmWcx0_JU0E-35UYgmPY0W6zxh0,28397
18
- patchright_cli-0.9.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
- patchright_cli-0.9.0.dist-info/entry_points.txt,sha256=Syh904eOQA8ylakyqIvG4LmIHx3LMjgeNcnFap3O0NQ,59
20
- patchright_cli-0.9.0.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
21
- patchright_cli-0.9.0.dist-info/RECORD,,
17
+ patchright_cli-0.9.1.dist-info/METADATA,sha256=Mw0vvLnmdHV3vGZtkJsPDoAlT3oDxWvXmIGFez9OFLE,30259
18
+ patchright_cli-0.9.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
+ patchright_cli-0.9.1.dist-info/entry_points.txt,sha256=Syh904eOQA8ylakyqIvG4LmIHx3LMjgeNcnFap3O0NQ,59
20
+ patchright_cli-0.9.1.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
21
+ patchright_cli-0.9.1.dist-info/RECORD,,