surfsky-cli 0.0.2__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.
@@ -0,0 +1,662 @@
1
+ import json
2
+ import random
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import anyio
8
+ import click
9
+
10
+ from .. import session
11
+ from ..config import Settings
12
+ from ..out import (
13
+ NotFound,
14
+ emit,
15
+ json_arg,
16
+ output_options,
17
+ pass_settings,
18
+ run,
19
+ scalar,
20
+ set_json,
21
+ timeout_option,
22
+ )
23
+ from .page import act, navigation, result_text, snapshot_option, where
24
+
25
+ ELEMENT_TIMEOUT = 10.0
26
+ GONE = "s => { const el = document.querySelector(s); return !el || !el.getClientRects().length; }"
27
+ OBSERVABLE = ("Runtime.enable", "Console.enable", "Overlay.", "Emulation.")
28
+
29
+
30
+ def modifiers_of(value: str | None) -> Any:
31
+ return session.split(value) or None
32
+
33
+
34
+ def not_found(a: session.Attached, target: str) -> NotFound:
35
+ hint = session.unreachable_hint(a.state.get("unreachable") or 0)
36
+ return NotFound(f"nothing matches {target!r}", hint=hint)
37
+
38
+
39
+ @click.command("click")
40
+ @click.argument("target")
41
+ @click.option("--button", type=click.Choice(["left", "right", "middle"]))
42
+ @click.option("--double", is_flag=True, help="Double-click.")
43
+ @click.option("--modifiers", help="Comma-separated: Alt,Control,Meta,Shift.")
44
+ @timeout_option(ELEMENT_TIMEOUT)
45
+ @snapshot_option
46
+ @output_options
47
+ @pass_settings
48
+ def click_cmd(
49
+ settings: Settings,
50
+ target: str,
51
+ button: str | None,
52
+ double: bool,
53
+ modifiers: str | None,
54
+ timeout: float,
55
+ want_snapshot: bool,
56
+ **out: Any,
57
+ ) -> None:
58
+ """Click a CSS selector, @ref or text=words target."""
59
+
60
+ async def action(a: session.Attached) -> Any:
61
+ selector = await session.resolve_target(a, target)
62
+ method: Any = a.page.dblclick if double else a.page.click
63
+
64
+ async def do() -> None:
65
+ await method(
66
+ selector,
67
+ button=button,
68
+ modifiers=modifiers_of(modifiers),
69
+ timeout=timeout,
70
+ )
71
+
72
+ return await navigation(a, do, want_snapshot=want_snapshot)
73
+
74
+ act(settings, action, text=result_text, **out)
75
+
76
+
77
+ @click.command()
78
+ @click.argument("target")
79
+ @click.argument("text")
80
+ @output_options
81
+ @pass_settings
82
+ def fill(settings: Settings, target: str, text: str, **out: Any) -> None:
83
+ """Replace an input's text: select all, then type TEXT."""
84
+
85
+ async def action(a: session.Attached) -> Any:
86
+ selector = await session.resolve_target(a, target)
87
+
88
+ async def do() -> None:
89
+ await a.page.fill(selector, text)
90
+
91
+ return await navigation(a, do)
92
+
93
+ act(settings, action, text=result_text, **out)
94
+
95
+
96
+ @click.command("type")
97
+ @click.argument("target")
98
+ @click.argument("text")
99
+ @output_options
100
+ @pass_settings
101
+ def type_cmd(settings: Settings, target: str, text: str, **out: Any) -> None:
102
+ """Click TARGET, then type TEXT after its current text. TARGET @focused skips the click."""
103
+
104
+ async def action(a: session.Attached) -> Any:
105
+ async def do() -> None:
106
+ if target == session.FOCUSED:
107
+ await a.page.keyboard.type(text)
108
+ else:
109
+ await a.page.type(await session.resolve_target(a, target), text)
110
+
111
+ return await navigation(a, do)
112
+
113
+ act(settings, action, text=result_text, **out)
114
+
115
+
116
+ @click.command()
117
+ @click.argument("key")
118
+ @click.option("--modifiers", help="Comma-separated: Alt,Control,Meta,Shift.")
119
+ @snapshot_option
120
+ @output_options
121
+ @pass_settings
122
+ def press(
123
+ settings: Settings, key: str, modifiers: str | None, want_snapshot: bool, **out: Any
124
+ ) -> None:
125
+ """Press a key on the focused element: Enter, Tab, Escape."""
126
+
127
+ async def action(a: session.Attached) -> Any:
128
+ async def do() -> None:
129
+ await a.page.keyboard.press(key, modifiers=modifiers_of(modifiers))
130
+
131
+ return await navigation(a, do, want_snapshot=want_snapshot)
132
+
133
+ act(settings, action, text=result_text, **out)
134
+
135
+
136
+ @click.command()
137
+ @click.argument("target")
138
+ @click.argument("value")
139
+ @click.option(
140
+ "--label", is_flag=True, help="Match the option's visible label, not its value."
141
+ )
142
+ @snapshot_option
143
+ @output_options
144
+ @pass_settings
145
+ def select(
146
+ settings: Settings,
147
+ target: str,
148
+ value: str,
149
+ label: bool,
150
+ want_snapshot: bool,
151
+ **out: Any,
152
+ ) -> None:
153
+ """Pick an <option> of a <select> by value (or by --label)."""
154
+
155
+ async def action(a: session.Attached) -> Any:
156
+ selector = await session.resolve_target(a, target)
157
+ picked: dict[str, Any] = {}
158
+
159
+ async def do() -> None:
160
+ choice = {"label": value} if label else {"value": value}
161
+ picked["selected"] = await a.page.select_option(selector, **choice)
162
+
163
+ return {**await navigation(a, do, want_snapshot=want_snapshot), **picked}
164
+
165
+ act(settings, action, text=result_text, **out)
166
+
167
+
168
+ @click.command()
169
+ @click.argument("target")
170
+ @timeout_option(ELEMENT_TIMEOUT)
171
+ @output_options
172
+ @pass_settings
173
+ def hover(settings: Settings, target: str, timeout: float, **out: Any) -> None:
174
+ """Move the mouse over TARGET."""
175
+
176
+ async def action(a: session.Attached) -> Any:
177
+ selector = await session.resolve_target(a, target)
178
+
179
+ async def do() -> None:
180
+ await a.page.hover(selector, timeout=timeout)
181
+
182
+ return await navigation(a, do)
183
+
184
+ act(settings, action, text=result_text, **out)
185
+
186
+
187
+ @click.command()
188
+ @click.option(
189
+ "--y",
190
+ type=float,
191
+ help="Pixels down; negative scrolls up (default: most of a screen).",
192
+ )
193
+ @click.option("--x", type=float, help="Pixels right; negative scrolls left.")
194
+ @click.option(
195
+ "--to", "to_target", help="Scroll TARGET (selector, @ref, text=) into view."
196
+ )
197
+ @click.option("--top", is_flag=True, help="Scroll to the top.")
198
+ @click.option("--bottom", is_flag=True, help="Scroll to the bottom.")
199
+ @output_options
200
+ @pass_settings
201
+ def scroll(
202
+ settings: Settings,
203
+ y: float | None,
204
+ x: float | None,
205
+ to_target: str | None,
206
+ top: bool,
207
+ bottom: bool,
208
+ **out: Any,
209
+ ) -> None:
210
+ """Scroll down most of a screen by default, or use --y PIXELS or --to TARGET."""
211
+
212
+ async def action(a: session.Attached) -> Any:
213
+ async def do() -> None:
214
+ if to_target:
215
+ await a.page.scroll_into_view(await session.resolve_target(a, to_target))
216
+ elif top:
217
+ await a.page.scroll_to(y=0)
218
+ elif bottom:
219
+ height = await a.page.evaluate("document.documentElement.scrollHeight")
220
+ await a.page.scroll_to(y=height)
221
+ else:
222
+ delta_y = y
223
+ if y is None and x is None:
224
+ # A reader paging: most of a screen. Pure CDP, no page JS.
225
+ metrics = await a.page.send("Page.getLayoutMetrics")
226
+ height = metrics["cssVisualViewport"]["clientHeight"]
227
+ delta_y = round(height * random.uniform(0.6, 0.9))
228
+ await a.page.scroll(delta_x=x, delta_y=delta_y)
229
+
230
+ return await navigation(a, do)
231
+
232
+ act(settings, action, text=result_text, **out)
233
+
234
+
235
+ @click.group()
236
+ def mouse() -> None:
237
+ """Pointer input at viewport coordinates for canvases, maps and dragging."""
238
+
239
+
240
+ def pointer_command(name: str, doc: str) -> click.Command:
241
+ @mouse.command(name, help=doc)
242
+ @click.argument("x", type=float)
243
+ @click.argument("y", type=float)
244
+ @output_options
245
+ @pass_settings
246
+ def command(settings: Settings, x: float, y: float, **out: Any) -> None:
247
+ async def action(a: session.Attached) -> Any:
248
+ async def do() -> None:
249
+ await getattr(a.page.mouse, name)(x, y)
250
+
251
+ return await navigation(a, do)
252
+
253
+ act(settings, action, text=result_text, **out)
254
+
255
+ return command
256
+
257
+
258
+ mouse_move = pointer_command("move", "Move the pointer to X, Y.")
259
+ mouse_down = pointer_command("down", "Press the left button at X, Y.")
260
+ mouse_up = pointer_command("up", "Release the left button at X, Y.")
261
+
262
+
263
+ @mouse.command("click")
264
+ @click.argument("x", type=float)
265
+ @click.argument("y", type=float)
266
+ @click.option("--button", type=click.Choice(["left", "right", "middle"]))
267
+ @snapshot_option
268
+ @output_options
269
+ @pass_settings
270
+ def mouse_click(
271
+ settings: Settings,
272
+ x: float,
273
+ y: float,
274
+ button: str | None,
275
+ want_snapshot: bool,
276
+ **out: Any,
277
+ ) -> None:
278
+ """Click at X, Y."""
279
+
280
+ async def action(a: session.Attached) -> Any:
281
+ async def do() -> None:
282
+ await a.page.mouse.click(x, y, button=button)
283
+
284
+ return await navigation(a, do, want_snapshot=want_snapshot)
285
+
286
+ act(settings, action, text=result_text, **out)
287
+
288
+
289
+ @mouse.command("drag")
290
+ @click.argument("x1", type=float)
291
+ @click.argument("y1", type=float)
292
+ @click.argument("x2", type=float)
293
+ @click.argument("y2", type=float)
294
+ @output_options
295
+ @pass_settings
296
+ def mouse_drag(
297
+ settings: Settings, x1: float, y1: float, x2: float, y2: float, **out: Any
298
+ ) -> None:
299
+ """Drag from X1, Y1 to X2, Y2."""
300
+
301
+ async def action(a: session.Attached) -> Any:
302
+ async def do() -> None:
303
+ await a.page.mouse.drag(start_x=x1, start_y=y1, end_x=x2, end_y=y2)
304
+
305
+ return await navigation(a, do)
306
+
307
+ act(settings, action, text=result_text, **out)
308
+
309
+
310
+ @click.command()
311
+ @click.argument("target", required=False)
312
+ @click.option(
313
+ "--gone",
314
+ is_flag=True,
315
+ help="Wait until TARGET is removed or has no layout box; use CSS.",
316
+ )
317
+ @click.option("--url", "fragment", help="Until the URL contains this.")
318
+ @click.option(
319
+ "--load",
320
+ type=click.Choice(["load", "domcontentloaded"]),
321
+ help="Until the document reaches this state (networkidle is unobservable after a reconnect).",
322
+ )
323
+ @click.option("--fn", "expression", help="Until this JavaScript expression is truthy.")
324
+ @click.option("--sleep", type=float, help="Wait a fixed number of seconds.")
325
+ @timeout_option(30.0)
326
+ @output_options
327
+ @pass_settings
328
+ def wait(
329
+ settings: Settings,
330
+ target: str | None,
331
+ gone: bool,
332
+ fragment: str | None,
333
+ load: str | None,
334
+ expression: str | None,
335
+ sleep: float | None,
336
+ timeout: float,
337
+ **out: Any,
338
+ ) -> None:
339
+ """Wait for an element, URL, load state or JavaScript condition.
340
+
341
+ Prefer CSS for elements that have not appeared yet or may be removed;
342
+ @refs and text=words must resolve before waiting.
343
+ """
344
+ if not any([target, fragment, load, expression, sleep]):
345
+ raise click.UsageError("provide a target, --url, --load, --fn or --sleep")
346
+
347
+ async def action(a: session.Attached) -> Any:
348
+ if target:
349
+ selector = await session.resolve_target(a, target)
350
+ if gone:
351
+ await a.page.wait_for_function(GONE, selector, timeout=timeout)
352
+ else:
353
+ await a.page.wait_for_selector(selector, timeout=timeout)
354
+ if fragment:
355
+ await a.page.wait_for_url(fragment, timeout=timeout)
356
+ if load:
357
+ await a.page.wait_for_load_state(load, timeout=timeout)
358
+ if expression:
359
+ await a.page.wait_for_function(expression, timeout=timeout)
360
+ if sleep:
361
+ await anyio.sleep(sleep)
362
+ return await where(a.page)
363
+
364
+ act(settings, action, **out)
365
+
366
+
367
+ @click.group()
368
+ def get() -> None:
369
+ """Read from the page: url, title, text, html, attr, value, count."""
370
+
371
+
372
+ @get.command("url")
373
+ @output_options
374
+ @pass_settings
375
+ def get_url(settings: Settings, **out: Any) -> None:
376
+ """The active tab's URL."""
377
+ act(settings, lambda a: a.page.url(), text=str, **out)
378
+
379
+
380
+ @get.command("title")
381
+ @output_options
382
+ @pass_settings
383
+ def get_title(settings: Settings, **out: Any) -> None:
384
+ """The active tab's title."""
385
+ act(settings, lambda a: a.page.title(), text=str, **out)
386
+
387
+
388
+ @get.command("text")
389
+ @click.argument("target", required=False)
390
+ @output_options
391
+ @pass_settings
392
+ def get_text(settings: Settings, target: str | None, **out: Any) -> None:
393
+ """Rendered text of TARGET (default: the whole page)."""
394
+
395
+ async def action(a: session.Attached) -> Any:
396
+ selector = await session.resolve_target(a, target) if target else "body"
397
+ text = await a.page.inner_text(selector)
398
+ if text is None:
399
+ raise not_found(a, target or "body")
400
+ return text
401
+
402
+ act(settings, action, text=str, **out)
403
+
404
+
405
+ @get.command("html")
406
+ @click.argument("target", required=False)
407
+ @output_options
408
+ @pass_settings
409
+ def get_html(settings: Settings, target: str | None, **out: Any) -> None:
410
+ """Outer HTML of TARGET (default: the whole document)."""
411
+
412
+ async def action(a: session.Attached) -> Any:
413
+ if not target:
414
+ return await a.page.content()
415
+ html = await a.page.outer_html(await session.resolve_target(a, target))
416
+ if html is None:
417
+ raise not_found(a, target)
418
+ return html
419
+
420
+ act(settings, action, text=str, **out)
421
+
422
+
423
+ @get.command("attr")
424
+ @click.argument("target")
425
+ @click.argument("name")
426
+ @output_options
427
+ @pass_settings
428
+ def get_attr(settings: Settings, target: str, name: str, **out: Any) -> None:
429
+ """Attribute NAME of TARGET (empty when the element has no such attribute)."""
430
+
431
+ async def action(a: session.Attached) -> Any:
432
+ selector = await session.resolve_target(a, target)
433
+ if await a.page.count(selector) == 0:
434
+ raise not_found(a, target)
435
+ return await a.page.get_attribute(selector, name)
436
+
437
+ act(settings, action, text=lambda value: value or "", **out)
438
+
439
+
440
+ @get.command("value")
441
+ @click.argument("target")
442
+ @output_options
443
+ @pass_settings
444
+ def get_value(settings: Settings, target: str, **out: Any) -> None:
445
+ """Current input, textarea or select value, including edits since page load."""
446
+
447
+ async def action(a: session.Attached) -> Any:
448
+ selector = await session.resolve_target(a, target)
449
+ if await a.page.count(selector) == 0:
450
+ raise not_found(a, target)
451
+ return await a.page.evaluate(
452
+ "s => document.querySelector(s)?.value ?? null", selector
453
+ )
454
+
455
+ act(settings, action, text=lambda value: "" if value is None else str(value), **out)
456
+
457
+
458
+ @get.command("count")
459
+ @click.argument("target")
460
+ @output_options
461
+ @pass_settings
462
+ def get_count(settings: Settings, target: str, **out: Any) -> None:
463
+ """How many elements match TARGET."""
464
+
465
+ async def action(a: session.Attached) -> Any:
466
+ return await a.page.count(await session.resolve_target(a, target))
467
+
468
+ act(settings, action, text=str, **out)
469
+
470
+
471
+ @click.group("is")
472
+ def is_() -> None:
473
+ """Print true or false; both exit 0. Connection or target errors still fail."""
474
+
475
+
476
+ def check(settings: Settings, target: str, key: str, out: dict[str, Any]) -> None:
477
+ async def action(a: session.Attached) -> Any:
478
+ selector = await session.resolve_target(a, target)
479
+ if key == "visible":
480
+ return {key: await a.page.is_visible(selector)}
481
+ return {key: await a.page.count(selector) > 0}
482
+
483
+ act(settings, action, text=lambda result: str(result[key]).lower(), **out)
484
+
485
+
486
+ @is_.command("visible")
487
+ @click.argument("target")
488
+ @output_options
489
+ @pass_settings
490
+ def is_visible(settings: Settings, target: str, **out: Any) -> None:
491
+ """TARGET exists and has a box on screen."""
492
+ check(settings, target, "visible", out)
493
+
494
+
495
+ @is_.command("present")
496
+ @click.argument("target")
497
+ @output_options
498
+ @pass_settings
499
+ def is_present(settings: Settings, target: str, **out: Any) -> None:
500
+ """TARGET exists in the document."""
501
+ check(settings, target, "present", out)
502
+
503
+
504
+ @click.command("screenshot")
505
+ @click.option(
506
+ "-o",
507
+ "--output",
508
+ type=click.Path(dir_okay=False),
509
+ help="File to write (default: screenshot-<time>.png).",
510
+ )
511
+ @click.option("--full-page", is_flag=True, help="The whole page, not just the viewport.")
512
+ @click.option("--selector", help="Only this element (selector, @ref or text=).")
513
+ @click.option("--json", "json_mode", is_flag=True, callback=set_json, help="Output JSON.")
514
+ @pass_settings
515
+ def screenshot_cmd(
516
+ settings: Settings,
517
+ output: str | None,
518
+ full_page: bool,
519
+ selector: str | None,
520
+ json_mode: bool,
521
+ ) -> None:
522
+ """Save a PNG of the active tab and print its path."""
523
+
524
+ async def go() -> bytes:
525
+ async with session.attached(settings) as a:
526
+ await a.page.bring_to_front() # a hidden tab's screenshot hangs
527
+ clip = await session.resolve_target(a, selector) if selector else None
528
+ return await a.page.screenshot(selector=clip, full_page=full_page)
529
+
530
+ data = run(go())
531
+ path = Path(output or f"screenshot-{int(time.time())}.png")
532
+ path.write_bytes(data)
533
+ emit({"path": str(path), "bytes": len(data)}, json_mode=json_mode, text=str(path))
534
+
535
+
536
+ @click.group()
537
+ def cookies() -> None:
538
+ """Read, set or clear cookies in the selected browser."""
539
+
540
+
541
+ @cookies.command("get")
542
+ @output_options
543
+ @pass_settings
544
+ def cookies_get(settings: Settings, **out: Any) -> None:
545
+ """All cookies, as JSON."""
546
+ act(settings, lambda a: a.page.cookies(), text=scalar, **out)
547
+
548
+
549
+ @cookies.command("set")
550
+ @click.argument("cookies_json")
551
+ @output_options
552
+ @pass_settings
553
+ def cookies_set(settings: Settings, cookies_json: str, **out: Any) -> None:
554
+ """Set cookies from a JSON array (inline, or @file): what `cookies get` prints."""
555
+ items = json_arg(cookies_json, "cookies")
556
+ if not isinstance(items, list):
557
+ raise ValueError("cookies must be a JSON array")
558
+
559
+ async def action(a: session.Attached) -> Any:
560
+ await a.page.set_cookies(items)
561
+ return {"set": len(items)}
562
+
563
+ act(settings, action, **out)
564
+
565
+
566
+ @cookies.command("clear")
567
+ @output_options
568
+ @pass_settings
569
+ def cookies_clear(settings: Settings, **out: Any) -> None:
570
+ """Remove all cookies from the selected browser, including logins."""
571
+
572
+ async def action(a: session.Attached) -> Any:
573
+ await a.page.clear_cookies()
574
+ return {"cleared": True}
575
+
576
+ act(settings, action, **out)
577
+
578
+
579
+ def json_or_text(value: str) -> Any:
580
+ try:
581
+ return json.loads(value)
582
+ except ValueError:
583
+ return value
584
+
585
+
586
+ @click.command("eval")
587
+ @click.argument("expression")
588
+ @click.argument("args", nargs=-1)
589
+ @click.option(
590
+ "--main-world",
591
+ is_flag=True,
592
+ help="Run in the page's own JS context, where its scripts can see it.",
593
+ )
594
+ @output_options
595
+ @pass_settings
596
+ def eval_cmd(
597
+ settings: Settings,
598
+ expression: str,
599
+ args: tuple[str, ...],
600
+ main_world: bool,
601
+ **out: Any,
602
+ ) -> None:
603
+ """Evaluate JavaScript in an isolated context.
604
+
605
+ Use --main-world to access page globals.
606
+ Function expressions receive ARGS, parsed as JSON where valid, otherwise strings.
607
+ """
608
+
609
+ async def action(a: session.Attached) -> Any:
610
+ values = [json_or_text(value) for value in args]
611
+ return await a.page.evaluate(expression, *values, isolated=not main_world)
612
+
613
+ act(settings, action, text=scalar, **out)
614
+
615
+
616
+ @click.command()
617
+ @click.argument("method")
618
+ @click.argument("params", required=False)
619
+ @click.option(
620
+ "--browser", "browser_level", is_flag=True, help="Send at browser level, not the tab."
621
+ )
622
+ @output_options
623
+ @pass_settings
624
+ def cdp(
625
+ settings: Settings, method: str, params: str | None, browser_level: bool, **out: Any
626
+ ) -> None:
627
+ """Send a Chrome DevTools Protocol command.
628
+
629
+ Example: surfsky cdp Page.getLayoutMetrics. PARAMS accepts a JSON object.
630
+ """
631
+ if method.startswith(OBSERVABLE):
632
+ click.echo(
633
+ f"warning: {method} is observable by the page and may expose automation",
634
+ err=True,
635
+ )
636
+ payload = json.loads(params) if params else None
637
+
638
+ async def action(a: session.Attached) -> Any:
639
+ if browser_level:
640
+ return await a.browser.cdp.send(method, payload)
641
+ return await a.page.send(method, payload)
642
+
643
+ act(settings, action, **out)
644
+
645
+
646
+ COMMANDS: list[click.Command] = [
647
+ click_cmd,
648
+ fill,
649
+ type_cmd,
650
+ press,
651
+ select,
652
+ hover,
653
+ scroll,
654
+ mouse,
655
+ wait,
656
+ get,
657
+ is_,
658
+ screenshot_cmd,
659
+ cookies,
660
+ eval_cmd,
661
+ cdp,
662
+ ]