render-lab-tasks-browserbase 0.1.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.
- render_lab_tasks_browserbase-0.1.0/.gitignore +11 -0
- render_lab_tasks_browserbase-0.1.0/LICENSE +21 -0
- render_lab_tasks_browserbase-0.1.0/PKG-INFO +44 -0
- render_lab_tasks_browserbase-0.1.0/README.md +30 -0
- render_lab_tasks_browserbase-0.1.0/pyproject.toml +22 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/__init__.py +1 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/_app.py +3 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/capture_screenshot.py +20 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/client.py +509 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/close_session.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/create_context.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/create_session.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/delete_context.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_download.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_session.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_session_logs.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_session_replay.py +20 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/list_downloads.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/py.typed +0 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/retry.py +5 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/run_actions.py +18 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/tasks.py +41 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/types.py +288 -0
- render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/upload_files.py +18 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Render Lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: render-lab-tasks-browserbase
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: browserbase tasks for Render Workflows
|
|
5
|
+
Project-URL: Repository, https://github.com/render-lab/render-tasks-python
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: httpx<0.29,>=0.28
|
|
10
|
+
Requires-Dist: playwright<2,>=1.50
|
|
11
|
+
Requires-Dist: render-lab-tasks-core<0.2,>=0.1.1
|
|
12
|
+
Requires-Dist: render==1.0.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# render-lab-tasks-browserbase
|
|
16
|
+
|
|
17
|
+
Twelve Browserbase tasks for contexts, sessions, browser actions and artifacts.
|
|
18
|
+
Use `render_lab_tasks_browserbase.tasks` for the wrapped tasks and injectable raw
|
|
19
|
+
implementations. The package root is inert; all boundaries use JSON DTOs.
|
|
20
|
+
|
|
21
|
+
## Environment
|
|
22
|
+
|
|
23
|
+
`BROWSERBASE_API_KEY` is required lazily on first use. `BROWSERBASE_PROJECT_ID` is
|
|
24
|
+
an optional default project; create calls can supply `projectId` explicitly.
|
|
25
|
+
|
|
26
|
+
## Behavior
|
|
27
|
+
|
|
28
|
+
Session creation finds a matching idempotency key before creating a paid session.
|
|
29
|
+
Browser actions reconnect through Playwright CDP and always close their local
|
|
30
|
+
connection. No local browser installation is required. Closing a session is an
|
|
31
|
+
explicit operation. Each HTTP operation makes one attempt; durable retries own retry timing.
|
|
32
|
+
|
|
33
|
+
Screenshots, uploads, downloads and log output enforce byte budgets. Downloads
|
|
34
|
+
read ZIP metadata without extracting files onto disk. The compressed archive has
|
|
35
|
+
a 100 MiB transfer cap, and file content is read only up to its requested bound.
|
|
36
|
+
Python applies the declared action timeout and keeps the total log text within its
|
|
37
|
+
budget; these correct omissions in the pinned TS implementation. See ADR-0014.
|
|
38
|
+
Live verification is pending credentials and a disposable Browserbase project.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install render-lab-tasks-browserbase==0.1.0
|
|
44
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# render-lab-tasks-browserbase
|
|
2
|
+
|
|
3
|
+
Twelve Browserbase tasks for contexts, sessions, browser actions and artifacts.
|
|
4
|
+
Use `render_lab_tasks_browserbase.tasks` for the wrapped tasks and injectable raw
|
|
5
|
+
implementations. The package root is inert; all boundaries use JSON DTOs.
|
|
6
|
+
|
|
7
|
+
## Environment
|
|
8
|
+
|
|
9
|
+
`BROWSERBASE_API_KEY` is required lazily on first use. `BROWSERBASE_PROJECT_ID` is
|
|
10
|
+
an optional default project; create calls can supply `projectId` explicitly.
|
|
11
|
+
|
|
12
|
+
## Behavior
|
|
13
|
+
|
|
14
|
+
Session creation finds a matching idempotency key before creating a paid session.
|
|
15
|
+
Browser actions reconnect through Playwright CDP and always close their local
|
|
16
|
+
connection. No local browser installation is required. Closing a session is an
|
|
17
|
+
explicit operation. Each HTTP operation makes one attempt; durable retries own retry timing.
|
|
18
|
+
|
|
19
|
+
Screenshots, uploads, downloads and log output enforce byte budgets. Downloads
|
|
20
|
+
read ZIP metadata without extracting files onto disk. The compressed archive has
|
|
21
|
+
a 100 MiB transfer cap, and file content is read only up to its requested bound.
|
|
22
|
+
Python applies the declared action timeout and keeps the total log text within its
|
|
23
|
+
budget; these correct omissions in the pinned TS implementation. See ADR-0014.
|
|
24
|
+
Live verification is pending credentials and a disposable Browserbase project.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install render-lab-tasks-browserbase==0.1.0
|
|
30
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "render-lab-tasks-browserbase"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "browserbase tasks for Render Workflows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = ["playwright>=1.50,<2","render==1.0.1", "httpx>=0.28,<0.29", "render-lab-tasks-core>=0.1.1,<0.2"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/render-lab/render-tasks-python"
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
render-lab-tasks-core = { workspace = true }
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/render_lab_tasks_browserbase"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Import .tasks explicitly to register durable tasks."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_NO_RETRY
|
|
6
|
+
from .types import CaptureScreenshotInput, CaptureScreenshotResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def capture_screenshot_impl(
|
|
10
|
+
ctx: TaskContext, input: CaptureScreenshotInput, *, deps: Deps | None = None
|
|
11
|
+
) -> CaptureScreenshotResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.capture_screenshot(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.captureScreenshot", retry=BROWSERBASE_NO_RETRY)
|
|
17
|
+
async def capture_screenshot(
|
|
18
|
+
ctx: TaskContext, input: CaptureScreenshotInput
|
|
19
|
+
) -> CaptureScreenshotResult:
|
|
20
|
+
return await capture_screenshot_impl(ctx, input)
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Injected vendor interface and one-attempt async HTTP adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import zipfile
|
|
11
|
+
from collections.abc import AsyncIterator, Mapping
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from render_lab_tasks_core.bounded import (
|
|
19
|
+
MAX_BINARY_BYTES,
|
|
20
|
+
MAX_TEXT_BYTES,
|
|
21
|
+
BoundedText,
|
|
22
|
+
bounded_binary,
|
|
23
|
+
bounded_text,
|
|
24
|
+
validate_byte_limit,
|
|
25
|
+
)
|
|
26
|
+
from render_lab_tasks_core.http import ApiError, HttpClient, enc, pick, query
|
|
27
|
+
|
|
28
|
+
from . import types as t
|
|
29
|
+
|
|
30
|
+
IDEMPOTENCY_METADATA_KEY = "_render_tasks_idempotency_key"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def integer(value: float, minimum: int, maximum: float = float("inf")) -> bool:
|
|
34
|
+
return not isinstance(value, bool) and minimum <= value <= maximum and int(value) == value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def iso(value: Any) -> str:
|
|
38
|
+
if value is None:
|
|
39
|
+
return ""
|
|
40
|
+
if isinstance(value, (int, float)):
|
|
41
|
+
return (
|
|
42
|
+
datetime.fromtimestamp(value / 1000, UTC)
|
|
43
|
+
.isoformat(timespec="milliseconds")
|
|
44
|
+
.replace("+00:00", "Z")
|
|
45
|
+
)
|
|
46
|
+
return str(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def session(raw: Any) -> t.SessionDTO:
|
|
50
|
+
return {
|
|
51
|
+
"sessionId": raw["id"],
|
|
52
|
+
"projectId": raw["projectId"],
|
|
53
|
+
"status": raw["status"],
|
|
54
|
+
"contextId": raw.get("contextId"),
|
|
55
|
+
"region": raw["region"],
|
|
56
|
+
"keepAlive": raw["keepAlive"],
|
|
57
|
+
"createdAt": iso(raw.get("createdAt")),
|
|
58
|
+
"updatedAt": iso(raw.get("updatedAt")),
|
|
59
|
+
"startedAt": iso(raw.get("startedAt")),
|
|
60
|
+
"expiresAt": iso(raw.get("expiresAt")),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def download(session_id: str, info: zipfile.ZipInfo) -> t.DownloadDTO:
|
|
65
|
+
value = json.dumps(
|
|
66
|
+
{"sessionId": session_id, "name": info.filename}, ensure_ascii=False, separators=(",", ":")
|
|
67
|
+
).encode()
|
|
68
|
+
extensions = {
|
|
69
|
+
"txt": "text/plain",
|
|
70
|
+
"csv": "text/csv",
|
|
71
|
+
"json": "application/json",
|
|
72
|
+
"html": "text/html",
|
|
73
|
+
"pdf": "application/pdf",
|
|
74
|
+
"png": "image/png",
|
|
75
|
+
"jpg": "image/jpeg",
|
|
76
|
+
"jpeg": "image/jpeg",
|
|
77
|
+
"gif": "image/gif",
|
|
78
|
+
"zip": "application/zip",
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
"downloadId": base64.urlsafe_b64encode(value).decode().rstrip("="),
|
|
82
|
+
"sessionId": session_id,
|
|
83
|
+
"filename": info.filename,
|
|
84
|
+
"contentType": extensions.get(
|
|
85
|
+
info.filename.rsplit(".", 1)[-1].lower(), "application/octet-stream"
|
|
86
|
+
),
|
|
87
|
+
"byteCount": info.file_size,
|
|
88
|
+
"checksum": f"{info.CRC:08x}",
|
|
89
|
+
"createdAt": "",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def all_match(item: Any, input: Any, keys: tuple[str, ...]) -> bool:
|
|
94
|
+
return all(key not in input or item[key] == input[key] for key in keys)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def serialize(value: Any, fallback: str) -> str:
|
|
98
|
+
return (
|
|
99
|
+
json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
100
|
+
if value is not None
|
|
101
|
+
else fallback
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Port(Protocol):
|
|
106
|
+
async def capture_screenshot(
|
|
107
|
+
self, input: t.CaptureScreenshotInput
|
|
108
|
+
) -> t.CaptureScreenshotResult: ...
|
|
109
|
+
async def close_session(self, input: t.SessionIdInput) -> t.CloseSessionResult: ...
|
|
110
|
+
async def create_context(self, input: t.CreateContextInput) -> t.CreateContextResult: ...
|
|
111
|
+
async def create_session(self, input: t.CreateSessionInput) -> t.CreateSessionResult: ...
|
|
112
|
+
async def delete_context(self, input: t.DeleteContextInput) -> t.DeleteContextResult: ...
|
|
113
|
+
async def get_download(self, input: t.GetDownloadInput) -> t.GetDownloadResult: ...
|
|
114
|
+
async def get_session(self, input: t.SessionIdInput) -> t.SessionDTO: ...
|
|
115
|
+
async def get_session_logs(self, input: t.GetSessionLogsInput) -> t.GetSessionLogsResult: ...
|
|
116
|
+
async def get_session_replay(
|
|
117
|
+
self, input: t.GetSessionReplayInput
|
|
118
|
+
) -> t.GetSessionReplayResult: ...
|
|
119
|
+
async def list_downloads(self, input: t.ListDownloadsInput) -> t.ListDownloadsResult: ...
|
|
120
|
+
async def run_actions(self, input: t.RunActionsInput) -> t.RunActionsResult: ...
|
|
121
|
+
async def upload_files(self, input: t.UploadFilesInput) -> t.UploadFilesResult: ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Deps:
|
|
126
|
+
browserbase: Port
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Client:
|
|
130
|
+
def __init__(self, http: httpx.AsyncClient, env: Mapping[str, str] | None = None) -> None:
|
|
131
|
+
self.env = os.environ if env is None else env
|
|
132
|
+
self.http = http
|
|
133
|
+
self.api = HttpClient(
|
|
134
|
+
http, "https://api.browserbase.com/v1", auth=self._auth, label="Browserbase API"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _auth(self) -> dict[str, str]:
|
|
138
|
+
key = self.env.get("BROWSERBASE_API_KEY")
|
|
139
|
+
if not key:
|
|
140
|
+
raise ValueError("BROWSERBASE_API_KEY is required for the browserbase.* tasks.")
|
|
141
|
+
return {"x-bb-api-key": key, "Accept": "application/json"}
|
|
142
|
+
|
|
143
|
+
async def create_context(self, input: t.CreateContextInput) -> t.CreateContextResult:
|
|
144
|
+
project = input.get("projectId", self.env.get("BROWSERBASE_PROJECT_ID"))
|
|
145
|
+
raw = await self.api.call(
|
|
146
|
+
"/contexts", method="POST", body={"projectId": project} if project else {}
|
|
147
|
+
)
|
|
148
|
+
return {"contextId": raw["id"]}
|
|
149
|
+
|
|
150
|
+
async def delete_context(self, input: t.DeleteContextInput) -> t.DeleteContextResult:
|
|
151
|
+
deleted = True
|
|
152
|
+
try:
|
|
153
|
+
await self.api.call(
|
|
154
|
+
"/contexts/" + enc(input["contextId"]), method="DELETE", headers={"Accept": "*/*"}
|
|
155
|
+
)
|
|
156
|
+
except ApiError as error:
|
|
157
|
+
if error.status != 404:
|
|
158
|
+
raise
|
|
159
|
+
deleted = False
|
|
160
|
+
return {"contextId": input["contextId"], "deleted": deleted}
|
|
161
|
+
|
|
162
|
+
async def create_session(self, input: t.CreateSessionInput) -> t.CreateSessionResult:
|
|
163
|
+
key = input.get("idempotencyKey", "").strip()
|
|
164
|
+
if not key:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"createSession requires a non-empty idempotencyKey so a retry can "
|
|
167
|
+
"find the existing session instead of starting a second paid one."
|
|
168
|
+
)
|
|
169
|
+
timeout = input.get("timeoutSeconds")
|
|
170
|
+
if timeout is not None and not integer(timeout, 60, 21600):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"timeoutSeconds must be an integer between 60 and 21600, received {timeout}."
|
|
173
|
+
)
|
|
174
|
+
viewport = input.get("viewport")
|
|
175
|
+
if viewport is not None and not all(integer(viewport[k], 1) for k in ("width", "height")):
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"viewport width and height must be positive integers, received "
|
|
178
|
+
f"{viewport['width']}x{viewport['height']}."
|
|
179
|
+
)
|
|
180
|
+
existing = await self.api.call(
|
|
181
|
+
"/sessions?" + query({"q": f"user_metadata['{IDEMPOTENCY_METADATA_KEY}']:'{key}'"})
|
|
182
|
+
)
|
|
183
|
+
if existing:
|
|
184
|
+
return {**session(existing[0]), "created": False}
|
|
185
|
+
body = pick(input, "region", "keepAlive", "proxies")
|
|
186
|
+
project = input.get("projectId", self.env.get("BROWSERBASE_PROJECT_ID"))
|
|
187
|
+
if project is not None:
|
|
188
|
+
body["projectId"] = project
|
|
189
|
+
if timeout is not None:
|
|
190
|
+
body["timeout"] = timeout
|
|
191
|
+
body["userMetadata"] = {**input.get("userMetadata", {}), IDEMPOTENCY_METADATA_KEY: key}
|
|
192
|
+
settings = pick(
|
|
193
|
+
input,
|
|
194
|
+
"blockAds",
|
|
195
|
+
"solveCaptchas",
|
|
196
|
+
"recordSession",
|
|
197
|
+
"logSession",
|
|
198
|
+
"viewport",
|
|
199
|
+
"allowedDomains",
|
|
200
|
+
)
|
|
201
|
+
if "contextId" in input:
|
|
202
|
+
settings["context"] = {
|
|
203
|
+
"id": input["contextId"],
|
|
204
|
+
"persist": input.get("persistContext", False),
|
|
205
|
+
}
|
|
206
|
+
if settings:
|
|
207
|
+
body["browserSettings"] = settings
|
|
208
|
+
return {
|
|
209
|
+
**session(await self.api.call("/sessions", method="POST", body=body)),
|
|
210
|
+
"created": True,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async def get_session(self, input: t.SessionIdInput) -> t.SessionDTO:
|
|
214
|
+
return session(await self.api.call("/sessions/" + enc(input["sessionId"])))
|
|
215
|
+
|
|
216
|
+
async def close_session(self, input: t.SessionIdInput) -> t.CloseSessionResult:
|
|
217
|
+
current = await self.get_session(input)
|
|
218
|
+
changed = current["status"] not in ("ERROR", "TIMED_OUT", "COMPLETED")
|
|
219
|
+
if changed:
|
|
220
|
+
body = {"status": "REQUEST_RELEASE"}
|
|
221
|
+
if self.env.get("BROWSERBASE_PROJECT_ID"):
|
|
222
|
+
body["projectId"] = self.env["BROWSERBASE_PROJECT_ID"]
|
|
223
|
+
current = session(
|
|
224
|
+
await self.api.call(
|
|
225
|
+
"/sessions/" + enc(input["sessionId"]), method="POST", body=body
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return {"sessionId": current["sessionId"], "status": current["status"], "changed": changed}
|
|
229
|
+
|
|
230
|
+
@asynccontextmanager
|
|
231
|
+
async def page(self, session_id: str) -> AsyncIterator[Any]:
|
|
232
|
+
from playwright.async_api import async_playwright
|
|
233
|
+
|
|
234
|
+
debug = await self.api.call("/sessions/" + enc(session_id) + "/debug")
|
|
235
|
+
async with async_playwright() as playwright:
|
|
236
|
+
browser = await playwright.chromium.connect_over_cdp(debug["wsUrl"])
|
|
237
|
+
try:
|
|
238
|
+
if not browser.contexts:
|
|
239
|
+
raise ValueError("No browser context on the session")
|
|
240
|
+
if not browser.contexts[0].pages:
|
|
241
|
+
raise ValueError("No page on the session")
|
|
242
|
+
yield browser.contexts[0].pages[0]
|
|
243
|
+
finally:
|
|
244
|
+
await browser.close()
|
|
245
|
+
|
|
246
|
+
async def run_actions(self, input: t.RunActionsInput) -> t.RunActionsResult:
|
|
247
|
+
if not 1 <= len(input["actions"]) <= 50:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"runActions accepts 1 to 50 actions, received {len(input['actions'])}."
|
|
250
|
+
)
|
|
251
|
+
timeout = input.get("actionTimeoutMs", 30000)
|
|
252
|
+
if not integer(timeout, 1, 120000):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"actionTimeoutMs must be an integer between 1 and 120000, received {timeout}."
|
|
255
|
+
)
|
|
256
|
+
results: list[t.ActionResultDTO] = []
|
|
257
|
+
async with self.page(input["sessionId"]) as page:
|
|
258
|
+
page.set_default_timeout(timeout)
|
|
259
|
+
for index, action in enumerate(input["actions"]):
|
|
260
|
+
text = None
|
|
261
|
+
kind = action["type"]
|
|
262
|
+
if action["type"] == "goto":
|
|
263
|
+
await page.goto(action["url"], wait_until=action.get("waitUntil", "load"))
|
|
264
|
+
elif action["type"] == "press":
|
|
265
|
+
target = (
|
|
266
|
+
page.locator(action["selector"])
|
|
267
|
+
if action.get("selector")
|
|
268
|
+
else page.keyboard
|
|
269
|
+
)
|
|
270
|
+
await target.press(action["key"])
|
|
271
|
+
elif action["type"] == "click":
|
|
272
|
+
await page.locator(action["selector"]).click()
|
|
273
|
+
elif action["type"] == "fill":
|
|
274
|
+
await page.locator(action["selector"]).fill(action["value"])
|
|
275
|
+
elif action["type"] == "select":
|
|
276
|
+
await page.locator(action["selector"]).select_option(action["value"])
|
|
277
|
+
elif action["type"] == "waitForSelector":
|
|
278
|
+
await page.locator(action["selector"]).wait_for(
|
|
279
|
+
**({"state": action["state"]} if "state" in action else {})
|
|
280
|
+
)
|
|
281
|
+
elif action["type"] == "extractText":
|
|
282
|
+
text = bounded_text(
|
|
283
|
+
await page.locator(action["selector"]).inner_text() or "",
|
|
284
|
+
action.get("maxBytes", 262144),
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
raise ValueError("Unsupported browser action")
|
|
288
|
+
results.append(
|
|
289
|
+
{
|
|
290
|
+
"index": index,
|
|
291
|
+
"type": kind,
|
|
292
|
+
"url": page.url,
|
|
293
|
+
"title": await page.title(),
|
|
294
|
+
"text": text,
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
return {"sessionId": input["sessionId"], "actions": results}
|
|
298
|
+
|
|
299
|
+
async def capture_screenshot(
|
|
300
|
+
self, input: t.CaptureScreenshotInput
|
|
301
|
+
) -> t.CaptureScreenshotResult:
|
|
302
|
+
format = input.get("format", "png")
|
|
303
|
+
quality = input.get("quality")
|
|
304
|
+
if quality is not None:
|
|
305
|
+
if format != "jpeg":
|
|
306
|
+
raise ValueError("quality is only valid for jpeg screenshots.")
|
|
307
|
+
if not integer(quality, 0, 100):
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"quality must be an integer between 0 and 100, received {quality}."
|
|
310
|
+
)
|
|
311
|
+
maximum = validate_byte_limit(input.get("maxBytes", 524288), MAX_BINARY_BYTES)
|
|
312
|
+
async with self.page(input["sessionId"]) as page:
|
|
313
|
+
data = await page.screenshot(
|
|
314
|
+
full_page=input.get("fullPage", False),
|
|
315
|
+
type=format,
|
|
316
|
+
**({"quality": quality} if quality is not None else {}),
|
|
317
|
+
)
|
|
318
|
+
return {
|
|
319
|
+
"sessionId": input["sessionId"],
|
|
320
|
+
"screenshot": bounded_binary(data, "image/" + format, maximum),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async def upload_files(self, input: t.UploadFilesInput) -> t.UploadFilesResult:
|
|
324
|
+
if not input["files"]:
|
|
325
|
+
raise ValueError("uploadFiles requires at least one file.")
|
|
326
|
+
maximum = validate_byte_limit(input.get("maxTotalBytes", 1048576), MAX_BINARY_BYTES)
|
|
327
|
+
decoded = []
|
|
328
|
+
for file in input["files"]:
|
|
329
|
+
try:
|
|
330
|
+
data = base64.b64decode(
|
|
331
|
+
file["base64"] + "=" * (-len(file["base64"]) % 4), validate=True
|
|
332
|
+
)
|
|
333
|
+
if base64.b64encode(data).decode().rstrip("=") != file["base64"].rstrip("="):
|
|
334
|
+
raise ValueError("noncanonical base64")
|
|
335
|
+
except (ValueError, binascii.Error):
|
|
336
|
+
raise ValueError(f"File {file['name']} has invalid base64 content.") from None
|
|
337
|
+
decoded.append(data)
|
|
338
|
+
total = sum(map(len, decoded))
|
|
339
|
+
if total > maximum:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"Uploads total {total} decoded bytes, above the maxTotalBytes "
|
|
342
|
+
f"limit of {maximum}. Upload fewer or smaller files."
|
|
343
|
+
)
|
|
344
|
+
for file, data in zip(input["files"], decoded, strict=True):
|
|
345
|
+
response = await self.http.post(
|
|
346
|
+
"https://api.browserbase.com/v1/sessions/" + enc(input["sessionId"]) + "/uploads",
|
|
347
|
+
headers=self._auth(),
|
|
348
|
+
files={"file": (file["name"], data, file["contentType"])},
|
|
349
|
+
)
|
|
350
|
+
if not response.is_success:
|
|
351
|
+
raise ApiError(response.status_code, "Browserbase file upload failed")
|
|
352
|
+
return {
|
|
353
|
+
"sessionId": input["sessionId"],
|
|
354
|
+
"files": [
|
|
355
|
+
{"name": file["name"], "contentType": file["contentType"], "byteCount": len(data)}
|
|
356
|
+
for file, data in zip(input["files"], decoded, strict=True)
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async def archive(self, session_id: str) -> zipfile.ZipFile:
|
|
361
|
+
# Keep compressed transfers bounded as well as extracted files.
|
|
362
|
+
async with self.http.stream(
|
|
363
|
+
"GET",
|
|
364
|
+
"https://api.browserbase.com/v1/sessions/" + enc(session_id) + "/downloads",
|
|
365
|
+
headers={**self._auth(), "Accept": "application/zip"},
|
|
366
|
+
) as response:
|
|
367
|
+
if not response.is_success:
|
|
368
|
+
raise ApiError(response.status_code, "Browserbase downloads failed")
|
|
369
|
+
data = bytearray()
|
|
370
|
+
async for chunk in response.aiter_bytes():
|
|
371
|
+
if len(data) + len(chunk) > 104857600:
|
|
372
|
+
raise ValueError(
|
|
373
|
+
"Browserbase download archive exceeds the 100 MiB transfer limit."
|
|
374
|
+
)
|
|
375
|
+
data.extend(chunk)
|
|
376
|
+
return zipfile.ZipFile(io.BytesIO(data))
|
|
377
|
+
|
|
378
|
+
async def list_downloads(self, input: t.ListDownloadsInput) -> t.ListDownloadsResult:
|
|
379
|
+
limit = max(1, min(100, int(input.get("limit", 20))))
|
|
380
|
+
offset = max(0, int(input.get("offset", 0)))
|
|
381
|
+
with await self.archive(input["sessionId"]) as archive:
|
|
382
|
+
all = [download(input["sessionId"], info) for info in archive.infolist()]
|
|
383
|
+
all = [item for item in all if all_match(item, input, ("filename", "contentType"))]
|
|
384
|
+
items = all[offset : offset + limit]
|
|
385
|
+
return {
|
|
386
|
+
"sessionId": input["sessionId"],
|
|
387
|
+
"downloads": items,
|
|
388
|
+
"page": {
|
|
389
|
+
"returned": len(items),
|
|
390
|
+
"available": len(all),
|
|
391
|
+
"limit": limit,
|
|
392
|
+
"truncated": offset + len(items) < len(all),
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async def get_download(self, input: t.GetDownloadInput) -> t.GetDownloadResult:
|
|
397
|
+
maximum = validate_byte_limit(input.get("maxBytes", 1048576), MAX_BINARY_BYTES)
|
|
398
|
+
try:
|
|
399
|
+
value = input["downloadId"]
|
|
400
|
+
parsed = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)))
|
|
401
|
+
session_id, name = parsed["sessionId"], parsed["name"]
|
|
402
|
+
if not isinstance(session_id, str) or not isinstance(name, str):
|
|
403
|
+
raise ValueError("invalid")
|
|
404
|
+
except (ValueError, KeyError, TypeError):
|
|
405
|
+
raise ValueError(f"Invalid downloadId: {input['downloadId']}") from None
|
|
406
|
+
with await self.archive(session_id) as archive:
|
|
407
|
+
try:
|
|
408
|
+
info = archive.getinfo(name)
|
|
409
|
+
except KeyError:
|
|
410
|
+
raise ValueError(f"Download not found: {name}") from None
|
|
411
|
+
metadata = download(session_id, info)
|
|
412
|
+
if info.file_size > maximum:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"Download {name} is {info.file_size} bytes, above the maxBytes "
|
|
415
|
+
f"limit of {maximum}. Narrow the request or retrieve it from "
|
|
416
|
+
f"the Browserbase-managed download URL directly."
|
|
417
|
+
)
|
|
418
|
+
with archive.open(info) as file:
|
|
419
|
+
data = file.read(maximum + 1)
|
|
420
|
+
return {**metadata, "content": bounded_binary(data, metadata["contentType"], maximum)}
|
|
421
|
+
|
|
422
|
+
async def get_session_logs(self, input: t.GetSessionLogsInput) -> t.GetSessionLogsResult:
|
|
423
|
+
limit = max(1, min(500, int(input.get("limit", 100))))
|
|
424
|
+
maximum = validate_byte_limit(input.get("maxBytes", 262144), MAX_TEXT_BYTES)
|
|
425
|
+
raw = await self.api.call("/sessions/" + enc(input["sessionId"]) + "/logs")
|
|
426
|
+
logs: list[t.SessionLogDTO] = []
|
|
427
|
+
used = 0
|
|
428
|
+
for log in raw[:limit]:
|
|
429
|
+
if used >= maximum:
|
|
430
|
+
break
|
|
431
|
+
request = log.get("request") or {}
|
|
432
|
+
response = log.get("response") or {}
|
|
433
|
+
bounded_request = bounded_text(
|
|
434
|
+
serialize(request.get("params"), request.get("rawBody", "")), maximum - used
|
|
435
|
+
)
|
|
436
|
+
used += bounded_request["returnedBytes"]
|
|
437
|
+
# A zero remaining budget must not leak another response byte.
|
|
438
|
+
response_text = serialize(response.get("result"), response.get("rawBody", ""))
|
|
439
|
+
bounded_response: BoundedText
|
|
440
|
+
if used == maximum:
|
|
441
|
+
bounded_response = {
|
|
442
|
+
"text": "",
|
|
443
|
+
"byteCount": len(response_text.encode()),
|
|
444
|
+
"returnedBytes": 0,
|
|
445
|
+
"maxBytes": 0,
|
|
446
|
+
"truncated": bool(response_text),
|
|
447
|
+
}
|
|
448
|
+
else:
|
|
449
|
+
bounded_response = bounded_text(response_text, maximum - used)
|
|
450
|
+
used += bounded_response["returnedBytes"]
|
|
451
|
+
logs.append(
|
|
452
|
+
{
|
|
453
|
+
"method": log["method"],
|
|
454
|
+
"pageId": log["pageId"],
|
|
455
|
+
"timestamp": log.get("timestamp"),
|
|
456
|
+
"request": bounded_request,
|
|
457
|
+
"response": bounded_response,
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
return {
|
|
461
|
+
"sessionId": input["sessionId"],
|
|
462
|
+
"logs": logs,
|
|
463
|
+
"page": {
|
|
464
|
+
"returned": len(logs),
|
|
465
|
+
"available": len(raw),
|
|
466
|
+
"limit": limit,
|
|
467
|
+
"truncated": len(logs) < len(raw)
|
|
468
|
+
or any(log["request"]["truncated"] or log["response"]["truncated"] for log in logs),
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async def get_session_replay(self, input: t.GetSessionReplayInput) -> t.GetSessionReplayResult:
|
|
473
|
+
limit = max(1, min(100, int(input.get("limit", 20))))
|
|
474
|
+
raw = await self.api.call("/sessions/" + enc(input["sessionId"]) + "/replays")
|
|
475
|
+
pages: list[t.ReplayPageDTO] = [
|
|
476
|
+
{
|
|
477
|
+
"pageId": p["pageId"],
|
|
478
|
+
"startedAt": iso(p["startTimeMs"]) if p.get("startTimeMs") is not None else None,
|
|
479
|
+
"endedAt": iso(p["endTimeMs"]) if p.get("endTimeMs") is not None else None,
|
|
480
|
+
"playlistUrl": p["url"],
|
|
481
|
+
}
|
|
482
|
+
for p in raw["pages"][:limit]
|
|
483
|
+
]
|
|
484
|
+
return {
|
|
485
|
+
"sessionId": input["sessionId"],
|
|
486
|
+
"pages": pages,
|
|
487
|
+
"page": {
|
|
488
|
+
"returned": len(pages),
|
|
489
|
+
"available": len(raw["pages"]),
|
|
490
|
+
"limit": limit,
|
|
491
|
+
"truncated": len(pages) < len(raw["pages"]),
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@asynccontextmanager
|
|
497
|
+
async def dependencies(deps: Deps | None) -> AsyncIterator[Deps]:
|
|
498
|
+
if deps is not None:
|
|
499
|
+
yield deps
|
|
500
|
+
else:
|
|
501
|
+
async with httpx.AsyncClient(
|
|
502
|
+
timeout=30, transport=httpx.AsyncHTTPTransport(retries=0)
|
|
503
|
+
) as http:
|
|
504
|
+
yield Deps(browserbase=Client(http))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
BrowserbasePort = Port
|
|
508
|
+
BrowserbaseClient = Client
|
|
509
|
+
BrowserbaseDeps = Deps
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_CONVERGENT_RETRY
|
|
6
|
+
from .types import CloseSessionResult, SessionIdInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def close_session_impl(
|
|
10
|
+
ctx: TaskContext, input: SessionIdInput, *, deps: Deps | None = None
|
|
11
|
+
) -> CloseSessionResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.close_session(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.closeSession", retry=BROWSERBASE_CONVERGENT_RETRY)
|
|
17
|
+
async def close_session(ctx: TaskContext, input: SessionIdInput) -> CloseSessionResult:
|
|
18
|
+
return await close_session_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_NO_RETRY
|
|
6
|
+
from .types import CreateContextInput, CreateContextResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def create_context_impl(
|
|
10
|
+
ctx: TaskContext, input: CreateContextInput, *, deps: Deps | None = None
|
|
11
|
+
) -> CreateContextResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.create_context(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.createContext", retry=BROWSERBASE_NO_RETRY)
|
|
17
|
+
async def create_context(ctx: TaskContext, input: CreateContextInput) -> CreateContextResult:
|
|
18
|
+
return await create_context_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_CONVERGENT_RETRY
|
|
6
|
+
from .types import CreateSessionInput, CreateSessionResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def create_session_impl(
|
|
10
|
+
ctx: TaskContext, input: CreateSessionInput, *, deps: Deps | None = None
|
|
11
|
+
) -> CreateSessionResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.create_session(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.createSession", retry=BROWSERBASE_CONVERGENT_RETRY)
|
|
17
|
+
async def create_session(ctx: TaskContext, input: CreateSessionInput) -> CreateSessionResult:
|
|
18
|
+
return await create_session_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_CONVERGENT_RETRY
|
|
6
|
+
from .types import DeleteContextInput, DeleteContextResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def delete_context_impl(
|
|
10
|
+
ctx: TaskContext, input: DeleteContextInput, *, deps: Deps | None = None
|
|
11
|
+
) -> DeleteContextResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.delete_context(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.deleteContext", retry=BROWSERBASE_CONVERGENT_RETRY)
|
|
17
|
+
async def delete_context(ctx: TaskContext, input: DeleteContextInput) -> DeleteContextResult:
|
|
18
|
+
return await delete_context_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_READ_RETRY
|
|
6
|
+
from .types import GetDownloadInput, GetDownloadResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_download_impl(
|
|
10
|
+
ctx: TaskContext, input: GetDownloadInput, *, deps: Deps | None = None
|
|
11
|
+
) -> GetDownloadResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.get_download(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.getDownload", retry=BROWSERBASE_READ_RETRY)
|
|
17
|
+
async def get_download(ctx: TaskContext, input: GetDownloadInput) -> GetDownloadResult:
|
|
18
|
+
return await get_download_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_READ_RETRY
|
|
6
|
+
from .types import SessionDTO, SessionIdInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_session_impl(
|
|
10
|
+
ctx: TaskContext, input: SessionIdInput, *, deps: Deps | None = None
|
|
11
|
+
) -> SessionDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.get_session(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.getSession", retry=BROWSERBASE_READ_RETRY)
|
|
17
|
+
async def get_session(ctx: TaskContext, input: SessionIdInput) -> SessionDTO:
|
|
18
|
+
return await get_session_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_READ_RETRY
|
|
6
|
+
from .types import GetSessionLogsInput, GetSessionLogsResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_session_logs_impl(
|
|
10
|
+
ctx: TaskContext, input: GetSessionLogsInput, *, deps: Deps | None = None
|
|
11
|
+
) -> GetSessionLogsResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.get_session_logs(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.getSessionLogs", retry=BROWSERBASE_READ_RETRY)
|
|
17
|
+
async def get_session_logs(ctx: TaskContext, input: GetSessionLogsInput) -> GetSessionLogsResult:
|
|
18
|
+
return await get_session_logs_impl(ctx, input)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_READ_RETRY
|
|
6
|
+
from .types import GetSessionReplayInput, GetSessionReplayResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_session_replay_impl(
|
|
10
|
+
ctx: TaskContext, input: GetSessionReplayInput, *, deps: Deps | None = None
|
|
11
|
+
) -> GetSessionReplayResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.get_session_replay(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.getSessionReplay", retry=BROWSERBASE_READ_RETRY)
|
|
17
|
+
async def get_session_replay(
|
|
18
|
+
ctx: TaskContext, input: GetSessionReplayInput
|
|
19
|
+
) -> GetSessionReplayResult:
|
|
20
|
+
return await get_session_replay_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_READ_RETRY
|
|
6
|
+
from .types import ListDownloadsInput, ListDownloadsResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def list_downloads_impl(
|
|
10
|
+
ctx: TaskContext, input: ListDownloadsInput, *, deps: Deps | None = None
|
|
11
|
+
) -> ListDownloadsResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.list_downloads(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.listDownloads", retry=BROWSERBASE_READ_RETRY)
|
|
17
|
+
async def list_downloads(ctx: TaskContext, input: ListDownloadsInput) -> ListDownloadsResult:
|
|
18
|
+
return await list_downloads_impl(ctx, input)
|
|
File without changes
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from render import Retry
|
|
2
|
+
|
|
3
|
+
BROWSERBASE_NO_RETRY = Retry(max_retries=0, wait_duration_ms=0, backoff_scaling=1)
|
|
4
|
+
BROWSERBASE_CONVERGENT_RETRY = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2)
|
|
5
|
+
BROWSERBASE_READ_RETRY = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_NO_RETRY
|
|
6
|
+
from .types import RunActionsInput, RunActionsResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def run_actions_impl(
|
|
10
|
+
ctx: TaskContext, input: RunActionsInput, *, deps: Deps | None = None
|
|
11
|
+
) -> RunActionsResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.run_actions(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.runActions", retry=BROWSERBASE_NO_RETRY)
|
|
17
|
+
async def run_actions(ctx: TaskContext, input: RunActionsInput) -> RunActionsResult:
|
|
18
|
+
return await run_actions_impl(ctx, input)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from ._app import app
|
|
2
|
+
from .capture_screenshot import capture_screenshot, capture_screenshot_impl
|
|
3
|
+
from .close_session import close_session, close_session_impl
|
|
4
|
+
from .create_context import create_context, create_context_impl
|
|
5
|
+
from .create_session import create_session, create_session_impl
|
|
6
|
+
from .delete_context import delete_context, delete_context_impl
|
|
7
|
+
from .get_download import get_download, get_download_impl
|
|
8
|
+
from .get_session import get_session, get_session_impl
|
|
9
|
+
from .get_session_logs import get_session_logs, get_session_logs_impl
|
|
10
|
+
from .get_session_replay import get_session_replay, get_session_replay_impl
|
|
11
|
+
from .list_downloads import list_downloads, list_downloads_impl
|
|
12
|
+
from .run_actions import run_actions, run_actions_impl
|
|
13
|
+
from .upload_files import upload_files, upload_files_impl
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"app",
|
|
17
|
+
"capture_screenshot",
|
|
18
|
+
"capture_screenshot_impl",
|
|
19
|
+
"close_session",
|
|
20
|
+
"close_session_impl",
|
|
21
|
+
"create_context",
|
|
22
|
+
"create_context_impl",
|
|
23
|
+
"create_session",
|
|
24
|
+
"create_session_impl",
|
|
25
|
+
"delete_context",
|
|
26
|
+
"delete_context_impl",
|
|
27
|
+
"get_download",
|
|
28
|
+
"get_download_impl",
|
|
29
|
+
"get_session",
|
|
30
|
+
"get_session_impl",
|
|
31
|
+
"get_session_logs",
|
|
32
|
+
"get_session_logs_impl",
|
|
33
|
+
"get_session_replay",
|
|
34
|
+
"get_session_replay_impl",
|
|
35
|
+
"list_downloads",
|
|
36
|
+
"list_downloads_impl",
|
|
37
|
+
"run_actions",
|
|
38
|
+
"run_actions_impl",
|
|
39
|
+
"upload_files",
|
|
40
|
+
"upload_files_impl",
|
|
41
|
+
]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""JSON contracts ported from the pinned TypeScript pack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
from render_lab_tasks_core.bounded import BoundedBinary, BoundedText
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UploadFilesResultFilesItem(TypedDict):
|
|
11
|
+
name: str
|
|
12
|
+
contentType: str
|
|
13
|
+
byteCount: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BrowserActionValueVariant7(TypedDict):
|
|
17
|
+
type: Literal["extractText"]
|
|
18
|
+
selector: str
|
|
19
|
+
maxBytes: NotRequired[float]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrowserActionValueVariant6(TypedDict):
|
|
23
|
+
type: Literal["waitForSelector"]
|
|
24
|
+
selector: str
|
|
25
|
+
state: NotRequired[
|
|
26
|
+
Literal["attached"] | Literal["detached"] | Literal["visible"] | Literal["hidden"]
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BrowserActionValueVariant5(TypedDict):
|
|
31
|
+
type: Literal["select"]
|
|
32
|
+
selector: str
|
|
33
|
+
value: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BrowserActionValueVariant4(TypedDict):
|
|
37
|
+
type: Literal["press"]
|
|
38
|
+
selector: NotRequired[str]
|
|
39
|
+
key: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BrowserActionValueVariant3(TypedDict):
|
|
43
|
+
type: Literal["fill"]
|
|
44
|
+
selector: str
|
|
45
|
+
value: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BrowserActionValueVariant2(TypedDict):
|
|
49
|
+
type: Literal["click"]
|
|
50
|
+
selector: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BrowserActionValueVariant1(TypedDict):
|
|
54
|
+
type: Literal["goto"]
|
|
55
|
+
url: str
|
|
56
|
+
waitUntil: NotRequired[Literal["load"] | Literal["domcontentloaded"] | Literal["networkidle"]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CreateSessionInputViewport(TypedDict):
|
|
60
|
+
width: float
|
|
61
|
+
height: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
type SessionStatus = (
|
|
65
|
+
Literal["PENDING"]
|
|
66
|
+
| Literal["RUNNING"]
|
|
67
|
+
| Literal["ERROR"]
|
|
68
|
+
| Literal["TIMED_OUT"]
|
|
69
|
+
| Literal["COMPLETED"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
type BrowserbaseRegion = (
|
|
73
|
+
Literal["us-west-2"]
|
|
74
|
+
| Literal["us-east-1"]
|
|
75
|
+
| Literal["eu-central-1"]
|
|
76
|
+
| Literal["ap-southeast-1"]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ContextDTO(TypedDict):
|
|
81
|
+
contextId: str
|
|
82
|
+
projectId: str
|
|
83
|
+
createdAt: str
|
|
84
|
+
updatedAt: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CreateContextInput(TypedDict):
|
|
88
|
+
projectId: NotRequired[str]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CreateContextResult(TypedDict):
|
|
92
|
+
contextId: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DeleteContextInput(TypedDict):
|
|
96
|
+
contextId: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DeleteContextResult(TypedDict):
|
|
100
|
+
contextId: str
|
|
101
|
+
deleted: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SessionDTO(TypedDict):
|
|
105
|
+
sessionId: str
|
|
106
|
+
projectId: str
|
|
107
|
+
status: SessionStatus
|
|
108
|
+
contextId: str | None
|
|
109
|
+
region: BrowserbaseRegion
|
|
110
|
+
keepAlive: bool
|
|
111
|
+
createdAt: str
|
|
112
|
+
updatedAt: str
|
|
113
|
+
startedAt: str
|
|
114
|
+
expiresAt: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CreateSessionInput(TypedDict):
|
|
118
|
+
idempotencyKey: str
|
|
119
|
+
projectId: NotRequired[str]
|
|
120
|
+
contextId: NotRequired[str]
|
|
121
|
+
persistContext: NotRequired[bool]
|
|
122
|
+
region: NotRequired[BrowserbaseRegion]
|
|
123
|
+
keepAlive: NotRequired[bool]
|
|
124
|
+
timeoutSeconds: NotRequired[float]
|
|
125
|
+
proxies: NotRequired[bool]
|
|
126
|
+
blockAds: NotRequired[bool]
|
|
127
|
+
solveCaptchas: NotRequired[bool]
|
|
128
|
+
recordSession: NotRequired[bool]
|
|
129
|
+
logSession: NotRequired[bool]
|
|
130
|
+
viewport: NotRequired[CreateSessionInputViewport]
|
|
131
|
+
allowedDomains: NotRequired[list[str]]
|
|
132
|
+
userMetadata: NotRequired[dict[str, str]]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CreateSessionResult(SessionDTO):
|
|
136
|
+
created: bool
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SessionIdInput(TypedDict):
|
|
140
|
+
sessionId: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CloseSessionResult(TypedDict):
|
|
144
|
+
sessionId: str
|
|
145
|
+
status: SessionStatus
|
|
146
|
+
changed: bool
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
type BrowserAction = (
|
|
150
|
+
BrowserActionValueVariant1
|
|
151
|
+
| BrowserActionValueVariant2
|
|
152
|
+
| BrowserActionValueVariant3
|
|
153
|
+
| BrowserActionValueVariant4
|
|
154
|
+
| BrowserActionValueVariant5
|
|
155
|
+
| BrowserActionValueVariant6
|
|
156
|
+
| BrowserActionValueVariant7
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RunActionsInput(SessionIdInput):
|
|
161
|
+
actions: list[BrowserAction]
|
|
162
|
+
actionTimeoutMs: NotRequired[float]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ActionResultDTO(TypedDict):
|
|
166
|
+
index: float
|
|
167
|
+
type: Literal["goto", "click", "fill", "press", "select", "waitForSelector", "extractText"]
|
|
168
|
+
url: str
|
|
169
|
+
title: str
|
|
170
|
+
text: BoundedText | None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class RunActionsResult(TypedDict):
|
|
174
|
+
sessionId: str
|
|
175
|
+
actions: list[ActionResultDTO]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CaptureScreenshotInput(SessionIdInput):
|
|
179
|
+
fullPage: NotRequired[bool]
|
|
180
|
+
format: NotRequired[Literal["png"] | Literal["jpeg"]]
|
|
181
|
+
quality: NotRequired[float]
|
|
182
|
+
maxBytes: NotRequired[float]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class CaptureScreenshotResult(TypedDict):
|
|
186
|
+
sessionId: str
|
|
187
|
+
screenshot: BoundedBinary
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class UploadFileInput(TypedDict):
|
|
191
|
+
name: str
|
|
192
|
+
contentType: str
|
|
193
|
+
base64: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class UploadFilesInput(SessionIdInput):
|
|
197
|
+
files: list[UploadFileInput]
|
|
198
|
+
maxTotalBytes: NotRequired[float]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class UploadFilesResult(TypedDict):
|
|
202
|
+
sessionId: str
|
|
203
|
+
files: list[UploadFilesResultFilesItem]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class DownloadDTO(TypedDict):
|
|
207
|
+
downloadId: str
|
|
208
|
+
sessionId: str
|
|
209
|
+
filename: str
|
|
210
|
+
contentType: str
|
|
211
|
+
byteCount: float
|
|
212
|
+
checksum: str
|
|
213
|
+
createdAt: str
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ListDownloadsInput(SessionIdInput):
|
|
217
|
+
filename: NotRequired[str]
|
|
218
|
+
contentType: NotRequired[str]
|
|
219
|
+
limit: NotRequired[float]
|
|
220
|
+
offset: NotRequired[float]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ListDownloadsResult(TypedDict):
|
|
224
|
+
sessionId: str
|
|
225
|
+
downloads: list[DownloadDTO]
|
|
226
|
+
page: BoundedCollection
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class GetDownloadInput(TypedDict):
|
|
230
|
+
downloadId: str
|
|
231
|
+
maxBytes: NotRequired[float]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class GetDownloadResult(DownloadDTO):
|
|
235
|
+
content: BoundedBinary
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class GetSessionLogsInput(SessionIdInput):
|
|
239
|
+
limit: NotRequired[float]
|
|
240
|
+
maxBytes: NotRequired[float]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SessionLogDTO(TypedDict):
|
|
244
|
+
method: str
|
|
245
|
+
pageId: float
|
|
246
|
+
timestamp: float | None
|
|
247
|
+
request: BoundedText
|
|
248
|
+
response: BoundedText
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class GetSessionLogsResult(TypedDict):
|
|
252
|
+
sessionId: str
|
|
253
|
+
logs: list[SessionLogDTO]
|
|
254
|
+
page: BoundedCollection
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class GetSessionReplayInput(SessionIdInput):
|
|
258
|
+
limit: NotRequired[float]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ReplayPageDTO(TypedDict):
|
|
262
|
+
pageId: str
|
|
263
|
+
startedAt: str | None
|
|
264
|
+
endedAt: str | None
|
|
265
|
+
playlistUrl: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class GetSessionReplayResult(TypedDict):
|
|
269
|
+
sessionId: str
|
|
270
|
+
pages: list[ReplayPageDTO]
|
|
271
|
+
page: BoundedCollection
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class RawSessionLog(TypedDict):
|
|
275
|
+
method: str
|
|
276
|
+
pageId: float
|
|
277
|
+
timestamp: float | None
|
|
278
|
+
requestParams: Any
|
|
279
|
+
requestBody: str
|
|
280
|
+
responseResult: Any
|
|
281
|
+
responseBody: str
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class BoundedCollection(TypedDict):
|
|
285
|
+
returned: int
|
|
286
|
+
available: int | None
|
|
287
|
+
limit: int
|
|
288
|
+
truncated: bool
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import BROWSERBASE_NO_RETRY
|
|
6
|
+
from .types import UploadFilesInput, UploadFilesResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def upload_files_impl(
|
|
10
|
+
ctx: TaskContext, input: UploadFilesInput, *, deps: Deps | None = None
|
|
11
|
+
) -> UploadFilesResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.browserbase.upload_files(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="browserbase.uploadFiles", retry=BROWSERBASE_NO_RETRY)
|
|
17
|
+
async def upload_files(ctx: TaskContext, input: UploadFilesInput) -> UploadFilesResult:
|
|
18
|
+
return await upload_files_impl(ctx, input)
|