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.
- surfsky_cli/__init__.py +3 -0
- surfsky_cli/__main__.py +3 -0
- surfsky_cli/commands/__init__.py +0 -0
- surfsky_cli/commands/account.py +340 -0
- surfsky_cli/commands/actions.py +662 -0
- surfsky_cli/commands/page.py +304 -0
- surfsky_cli/commands/scrape.py +217 -0
- surfsky_cli/commands/session.py +184 -0
- surfsky_cli/commands/status.py +102 -0
- surfsky_cli/config.py +115 -0
- surfsky_cli/html.py +41 -0
- surfsky_cli/main.py +191 -0
- surfsky_cli/out.py +343 -0
- surfsky_cli/session.py +298 -0
- surfsky_cli/skill.md +167 -0
- surfsky_cli/snapshot.py +154 -0
- surfsky_cli-0.0.2.dist-info/METADATA +156 -0
- surfsky_cli-0.0.2.dist-info/RECORD +21 -0
- surfsky_cli-0.0.2.dist-info/WHEEL +4 -0
- surfsky_cli-0.0.2.dist-info/entry_points.txt +2 -0
- surfsky_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|