tzafon 0.1.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.
Potentially problematic release.
This version of tzafon might be problematic. Click here for more details.
- tzafon/__init__.py +3 -0
- tzafon/_connection.py +50 -0
- tzafon/client.py +94 -0
- tzafon/exceptions.py +6 -0
- tzafon/models.py +79 -0
- tzafon-0.1.1.dist-info/METADATA +58 -0
- tzafon-0.1.1.dist-info/RECORD +8 -0
- tzafon-0.1.1.dist-info/WHEEL +4 -0
tzafon/__init__.py
ADDED
tzafon/_connection.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
import traceback
|
|
4
|
+
import websockets
|
|
5
|
+
from websockets.connection import State
|
|
6
|
+
|
|
7
|
+
from .models import Command, Result
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _WsConnection:
|
|
11
|
+
def __init__(self, url: str, *, ping_interval: int = 20):
|
|
12
|
+
if not url.startswith(("ws://", "wss://")):
|
|
13
|
+
raise ValueError(f"Invalid websocket URL '{url}'")
|
|
14
|
+
self._url = url
|
|
15
|
+
self._conn: websockets.WebSocketClientProtocol | None = None
|
|
16
|
+
self._ping = ping_interval
|
|
17
|
+
|
|
18
|
+
async def connect(self) -> None:
|
|
19
|
+
if self.is_open:
|
|
20
|
+
return
|
|
21
|
+
self._conn = await websockets.connect(
|
|
22
|
+
self._url,
|
|
23
|
+
max_size=2**24, # 16 MiB
|
|
24
|
+
ping_interval=self._ping,
|
|
25
|
+
ping_timeout=self._ping,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def close(self) -> None:
|
|
29
|
+
if self._conn and self._conn.state is State.OPEN:
|
|
30
|
+
try:
|
|
31
|
+
await self._conn.close(code=1000, reason="Client shutting down")
|
|
32
|
+
await asyncio.wait_for(self._conn.wait_closed(), timeout=5)
|
|
33
|
+
except Exception:
|
|
34
|
+
traceback.print_exc()
|
|
35
|
+
self._conn = None
|
|
36
|
+
|
|
37
|
+
async def send(self, cmd: Command) -> Result:
|
|
38
|
+
if not self.is_open:
|
|
39
|
+
raise RuntimeError("Websocket not connected")
|
|
40
|
+
await self._conn.send(cmd.dump())
|
|
41
|
+
raw = await self._conn.recv()
|
|
42
|
+
if isinstance(raw, str):
|
|
43
|
+
raw = raw.encode()
|
|
44
|
+
return Result.load(raw)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_open(self) -> bool:
|
|
48
|
+
return (
|
|
49
|
+
self._conn is not None and getattr(self._conn, "state", None) is State.OPEN
|
|
50
|
+
)
|
tzafon/client.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .models import Command, Result, ActionType
|
|
8
|
+
from ._connection import _WsConnection
|
|
9
|
+
from .exceptions import ScreenshotFailed
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("waypoint")
|
|
13
|
+
_DEFAULT_VIEWPORT = {"width": 1280, "height": 720}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Waypoint:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
token: str,
|
|
20
|
+
*,
|
|
21
|
+
url_template: str = "wss://api.tzafon.ai/ephemeral-tzafonwright?token={token}",
|
|
22
|
+
connect_timeout: float = 10.0,
|
|
23
|
+
):
|
|
24
|
+
if not token or not token.startswith("wpk_"):
|
|
25
|
+
raise ValueError("token must look like 'wpk_…'")
|
|
26
|
+
self._ws_url = url_template.format(token=token)
|
|
27
|
+
self._timeout = connect_timeout
|
|
28
|
+
self._ws: Optional[_WsConnection] = None
|
|
29
|
+
|
|
30
|
+
async def __aenter__(self) -> "Waypoint":
|
|
31
|
+
self._ws = _WsConnection(self._ws_url)
|
|
32
|
+
await asyncio.wait_for(self._ws.connect(), timeout=self._timeout)
|
|
33
|
+
# one-time only
|
|
34
|
+
await self.set_viewport(**_DEFAULT_VIEWPORT)
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
async def __aexit__(self, exc_t, exc, tb) -> None:
|
|
38
|
+
if self._ws:
|
|
39
|
+
await self._ws.close()
|
|
40
|
+
self._ws = None
|
|
41
|
+
|
|
42
|
+
async def goto(self, url: str, *, timeout: int = 5_000) -> Result:
|
|
43
|
+
return await self._send(Command(ActionType.GOTO, url=url, timeout=timeout))
|
|
44
|
+
|
|
45
|
+
async def click(self, x: float, y: float) -> Result:
|
|
46
|
+
return await self._send(Command(ActionType.CLICK, x=x, y=y))
|
|
47
|
+
|
|
48
|
+
async def type(self, text: str) -> Result:
|
|
49
|
+
return await self._send(Command(ActionType.TYPE, text=text))
|
|
50
|
+
|
|
51
|
+
async def scroll(self, dx: int = 0, dy: int = 100) -> Result:
|
|
52
|
+
return await self._send(Command(ActionType.SCROLL, delta_x=dx, delta_y=dy))
|
|
53
|
+
|
|
54
|
+
async def screenshot(
|
|
55
|
+
self,
|
|
56
|
+
path: str | os.PathLike | None = None,
|
|
57
|
+
*,
|
|
58
|
+
mkdir: bool = True,
|
|
59
|
+
) -> bytes:
|
|
60
|
+
"""
|
|
61
|
+
Grab a **JPEG** screenshot.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
path:
|
|
65
|
+
Optional file destination. If provided the image is written to disk
|
|
66
|
+
*and* the raw bytes are still returned.
|
|
67
|
+
mkdir:
|
|
68
|
+
Automatically create parent folders if they do not exist.
|
|
69
|
+
"""
|
|
70
|
+
res = await self._send(Command(ActionType.SCREENSHOT))
|
|
71
|
+
if not (res.success and res.image):
|
|
72
|
+
raise ScreenshotFailed(res.error_message or "unknown error")
|
|
73
|
+
|
|
74
|
+
if path is not None:
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
|
|
77
|
+
p = Path(path)
|
|
78
|
+
if mkdir:
|
|
79
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
p.write_bytes(res.image)
|
|
81
|
+
return res.image
|
|
82
|
+
|
|
83
|
+
async def set_viewport(self, *, width: int, height: int) -> Result:
|
|
84
|
+
return await self._send(
|
|
85
|
+
Command(ActionType.SET_VIEWPORT_SIZE, width=width, height=height)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _send(self, cmd: Command) -> Result:
|
|
89
|
+
if self._ws is None:
|
|
90
|
+
raise RuntimeError("Waypoint must be used inside 'async with'")
|
|
91
|
+
res = await self._ws.send(cmd)
|
|
92
|
+
if not res.success:
|
|
93
|
+
log.warning("Command %s failed: %s", cmd.action_type, res.error_message)
|
|
94
|
+
return res
|
tzafon/exceptions.py
ADDED
tzafon/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass, asdict
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional, Dict, Any, Self
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActionType(str, Enum):
|
|
10
|
+
CLICK = "click"
|
|
11
|
+
TYPE = "type"
|
|
12
|
+
SCROLL = "scroll"
|
|
13
|
+
GOTO = "goto"
|
|
14
|
+
SCREENSHOT = "screenshot"
|
|
15
|
+
SET_VIEWPORT_SIZE = "set_viewport_size"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class Command:
|
|
20
|
+
action_type: ActionType
|
|
21
|
+
# optional payload
|
|
22
|
+
x: Optional[float] = None
|
|
23
|
+
y: Optional[float] = None
|
|
24
|
+
text: Optional[str] = None
|
|
25
|
+
delta_x: Optional[int] = None
|
|
26
|
+
delta_y: Optional[int] = None
|
|
27
|
+
url: Optional[str] = None
|
|
28
|
+
width: Optional[int] = None
|
|
29
|
+
height: Optional[int] = None
|
|
30
|
+
timeout: int = 5000 # ms
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def load(cls, body: bytes) -> Self:
|
|
34
|
+
data = json.loads(body.decode("utf-8"))
|
|
35
|
+
action_str = data.pop("action_type", None)
|
|
36
|
+
if action_str is None:
|
|
37
|
+
raise ValueError("Command missing 'action_type'")
|
|
38
|
+
try:
|
|
39
|
+
action_enum = ActionType(action_str)
|
|
40
|
+
except ValueError as e:
|
|
41
|
+
raise ValueError(f"Unknown action_type '{action_str}'") from e
|
|
42
|
+
return cls(action_type=action_enum, **data)
|
|
43
|
+
|
|
44
|
+
def dump(self) -> bytes:
|
|
45
|
+
d = asdict(self)
|
|
46
|
+
d["action_type"] = self.action_type.value
|
|
47
|
+
d = {k: v for k, v in d.items() if v is not None}
|
|
48
|
+
return json.dumps(d).encode("utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class Result:
|
|
53
|
+
success: bool
|
|
54
|
+
image: Optional[bytes] = None # jpeg bytes
|
|
55
|
+
error_message: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def load(cls, body: bytes) -> Self:
|
|
59
|
+
data = json.loads(body.decode("utf-8"))
|
|
60
|
+
img_b64 = data.get("image")
|
|
61
|
+
img = base64.b64decode(img_b64) if img_b64 else None
|
|
62
|
+
return cls(
|
|
63
|
+
success=data.get("success", False),
|
|
64
|
+
image=img,
|
|
65
|
+
error_message=data.get("error_message"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def dump(self) -> bytes:
|
|
69
|
+
d: Dict[str, Any] = {
|
|
70
|
+
"success": self.success,
|
|
71
|
+
"error_message": self.error_message,
|
|
72
|
+
"image": base64.b64encode(self.image).decode() if self.image else None,
|
|
73
|
+
}
|
|
74
|
+
return json.dumps({k: v for k, v in d.items() if v is not None}).encode()
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str:
|
|
77
|
+
ok = "✅" if self.success else "❌"
|
|
78
|
+
img = "🖼️" if self.image else "—"
|
|
79
|
+
return f"<Result {ok} image:{img} msg:{self.error_message!r}>"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tzafon
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Tzafon Waypoint – browser automation
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: twine>=6.1.0
|
|
8
|
+
Requires-Dist: websockets>=15.0.1
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Tzafon Waypoint
|
|
12
|
+
|
|
13
|
+
A Python client for interacting with the tzafon.ai API for web automation.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install tzafon
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
from tzafon import Waypoint
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
async with Waypoint(token="wpk_your_token_here") as wp:
|
|
29
|
+
# Navigate to a website
|
|
30
|
+
await wp.goto("https://example.com")
|
|
31
|
+
|
|
32
|
+
# Take a screenshot
|
|
33
|
+
image_bytes = await wp.screenshot("screenshot.jpg")
|
|
34
|
+
|
|
35
|
+
# Click at a specific position
|
|
36
|
+
await wp.click(100, 200)
|
|
37
|
+
|
|
38
|
+
# Type some text
|
|
39
|
+
await wp.type("Hello world")
|
|
40
|
+
|
|
41
|
+
# Scroll down
|
|
42
|
+
await wp.scroll(dy=200)
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- Web navigation
|
|
51
|
+
- Screenshots
|
|
52
|
+
- Clicking and typing
|
|
53
|
+
- Scrolling
|
|
54
|
+
- Viewport control
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
tzafon/__init__.py,sha256=QpZABzfciDC1uUdoRHp5tW0694ZJGtHNF9ieGilnt94,53
|
|
2
|
+
tzafon/_connection.py,sha256=HmmnHjO0N9pN3esCsVucws7lm1ZfyB6F0G8GMZoOCXw,1602
|
|
3
|
+
tzafon/client.py,sha256=NUwXgrGPhoFbmHq122ZtSLBJvs2hiz1d8EbaRsGDW8o,3149
|
|
4
|
+
tzafon/exceptions.py,sha256=SeeZRBAsyeogXXcfF3b3IaKUsehWNeNZAgxbmrzjI48,168
|
|
5
|
+
tzafon/models.py,sha256=woHESkPE3R2TbiQoZjlknz7VR938WU5ZYlPFCh0Td9U,2428
|
|
6
|
+
tzafon-0.1.1.dist-info/METADATA,sha256=nJmiwa8WRJFuQvhPtxTqWDl4ugGMlZsfmzXtBZo2ooE,1052
|
|
7
|
+
tzafon-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
tzafon-0.1.1.dist-info/RECORD,,
|