browserrig 0.1.0__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.
- browserrig/__init__.py +158 -0
- browserrig/activity/__init__.py +5 -0
- browserrig/activity/models.py +93 -0
- browserrig/activity/plugin.py +247 -0
- browserrig/activity/state.py +16 -0
- browserrig/agent.py +55 -0
- browserrig/browser/__init__.py +33 -0
- browserrig/browser/base.py +70 -0
- browserrig/browser/chrome.py +242 -0
- browserrig/browser/config.py +119 -0
- browserrig/browser/downloads.py +128 -0
- browserrig/browser/favicon.py +119 -0
- browserrig/browser/frame.py +227 -0
- browserrig/browser/frames.py +116 -0
- browserrig/browser/images.py +23 -0
- browserrig/browser/input.py +147 -0
- browserrig/browser/models.py +8 -0
- browserrig/browser/plugin.py +44 -0
- browserrig/browser/process.py +144 -0
- browserrig/browser/setup.py +12 -0
- browserrig/browser/tab.py +311 -0
- browserrig/browser/tabs.py +366 -0
- browserrig/browser/worlds.py +64 -0
- browserrig/choreography/__init__.py +31 -0
- browserrig/choreography/events.py +133 -0
- browserrig/choreography/plugin.py +75 -0
- browserrig/choreography/scenes.py +167 -0
- browserrig/choreography/stage.py +53 -0
- browserrig/choreography/tabstrip.py +112 -0
- browserrig/completion.py +184 -0
- browserrig/composition.py +108 -0
- browserrig/core/__init__.py +61 -0
- browserrig/core/errors.py +18 -0
- browserrig/core/geometry.py +149 -0
- browserrig/core/ids.py +10 -0
- browserrig/core/kinds.py +180 -0
- browserrig/core/options.py +7 -0
- browserrig/core/tools.py +32 -0
- browserrig/events.py +665 -0
- browserrig/files/__init__.py +25 -0
- browserrig/files/config.py +13 -0
- browserrig/files/containers.py +200 -0
- browserrig/files/content.py +65 -0
- browserrig/files/data.py +80 -0
- browserrig/files/documents.py +248 -0
- browserrig/files/formats.py +84 -0
- browserrig/files/imaging.py +138 -0
- browserrig/files/kinds.py +78 -0
- browserrig/files/models.py +71 -0
- browserrig/files/reading.py +382 -0
- browserrig/files/spreadsheets.py +417 -0
- browserrig/files/system.py +204 -0
- browserrig/files/text.py +64 -0
- browserrig/files/xlsx.py +53 -0
- browserrig/handoff/__init__.py +4 -0
- browserrig/handoff/changes.py +48 -0
- browserrig/handoff/models.py +31 -0
- browserrig/handoff/plugin.py +308 -0
- browserrig/handoff/state.py +21 -0
- browserrig/input/__init__.py +35 -0
- browserrig/input/controller.py +69 -0
- browserrig/input/keyboard.py +267 -0
- browserrig/input/motor.py +118 -0
- browserrig/input/mouse.py +346 -0
- browserrig/input/pointer.py +84 -0
- browserrig/input/presenter.py +49 -0
- browserrig/input/target.py +67 -0
- browserrig/input/wheel.py +40 -0
- browserrig/kinds.py +33 -0
- browserrig/nudges/__init__.py +3 -0
- browserrig/nudges/plugin.py +295 -0
- browserrig/nudges/state.py +23 -0
- browserrig/observation/__init__.py +53 -0
- browserrig/observation/capture.py +374 -0
- browserrig/observation/changes.py +63 -0
- browserrig/observation/config.py +12 -0
- browserrig/observation/content.py +176 -0
- browserrig/observation/delta.py +69 -0
- browserrig/observation/dom.py +97 -0
- browserrig/observation/extraction/__init__.py +335 -0
- browserrig/observation/extraction/accessibility.py +62 -0
- browserrig/observation/extraction/document.py +471 -0
- browserrig/observation/extraction/elements.py +676 -0
- browserrig/observation/extraction/semantics.py +351 -0
- browserrig/observation/extraction/styles.py +30 -0
- browserrig/observation/extraction/visibility.py +108 -0
- browserrig/observation/find.py +152 -0
- browserrig/observation/handle.py +170 -0
- browserrig/observation/images.py +243 -0
- browserrig/observation/marks.py +157 -0
- browserrig/observation/models.py +230 -0
- browserrig/observation/plugin.py +413 -0
- browserrig/observation/redirects.py +40 -0
- browserrig/observation/references.py +47 -0
- browserrig/observation/render.py +291 -0
- browserrig/observation/renderer.py +76 -0
- browserrig/observation/state.py +37 -0
- browserrig/pacing/__init__.py +5 -0
- browserrig/pacing/plugin.py +22 -0
- browserrig/py.typed +0 -0
- browserrig/readiness/__init__.py +25 -0
- browserrig/readiness/config.py +12 -0
- browserrig/readiness/dom.py +83 -0
- browserrig/readiness/downloads.py +30 -0
- browserrig/readiness/hooks.py +27 -0
- browserrig/readiness/models.py +13 -0
- browserrig/readiness/plugin.py +170 -0
- browserrig/readiness/policies.py +43 -0
- browserrig/readiness/state.py +20 -0
- browserrig/readiness/strategy.py +30 -0
- browserrig/readiness/wait.py +238 -0
- browserrig/scripts/README.md +39 -0
- browserrig/scripts/__init__.py +186 -0
- browserrig/scripts/content.js +526 -0
- browserrig/scripts/cursor.js +57 -0
- browserrig/scripts/element.js +82 -0
- browserrig/scripts/find.js +377 -0
- browserrig/scripts/focused.js +15 -0
- browserrig/scripts/lib/composed.js +21 -0
- browserrig/scripts/lib/elements.js +9 -0
- browserrig/scripts/lib/geometry.js +13 -0
- browserrig/scripts/lib/hit.js +84 -0
- browserrig/scripts/lib/markdown.js +32 -0
- browserrig/scripts/lib/scroll.js +226 -0
- browserrig/scripts/lib/secret.js +74 -0
- browserrig/scripts/lib/visibility.js +42 -0
- browserrig/scripts/models.py +153 -0
- browserrig/scripts/owner_hits.js +4 -0
- browserrig/scripts/owner_scroll.js +7 -0
- browserrig/scripts/owner_size.js +11 -0
- browserrig/scripts/page_facts.js +9 -0
- browserrig/scripts/reset.js +4 -0
- browserrig/scripts/scroll.js +5 -0
- browserrig/scripts/secret_field.js +7 -0
- browserrig/scripts/secret_focus.js +31 -0
- browserrig/scripts/seed.js +13 -0
- browserrig/scripts/stability.js +140 -0
- browserrig/scripts/tab_strip.js +27 -0
- browserrig/scripts/viewport.js +3 -0
- browserrig/secrets/__init__.py +5 -0
- browserrig/secrets/models.py +90 -0
- browserrig/secrets/origin.py +95 -0
- browserrig/secrets/plugin.py +397 -0
- browserrig/secrets/redaction.py +107 -0
- browserrig/secrets/state.py +6 -0
- browserrig/secrets/totp.py +62 -0
- browserrig/terminal.py +120 -0
- browserrig/tools/__init__.py +51 -0
- browserrig/tools/chains.py +55 -0
- browserrig/tools/dialogs.py +23 -0
- browserrig/tools/effects.py +8 -0
- browserrig/tools/execution.py +333 -0
- browserrig/tools/files.py +411 -0
- browserrig/tools/find.py +125 -0
- browserrig/tools/forms.py +76 -0
- browserrig/tools/intents.py +82 -0
- browserrig/tools/javascript.py +37 -0
- browserrig/tools/keyboard.py +126 -0
- browserrig/tools/mouse.py +224 -0
- browserrig/tools/navigation.py +189 -0
- browserrig/tools/scrolling.py +39 -0
- browserrig/tools/tabs.py +81 -0
- browserrig/tools/targeting.py +59 -0
- browserrig-0.1.0.dist-info/METADATA +174 -0
- browserrig-0.1.0.dist-info/RECORD +167 -0
- browserrig-0.1.0.dist-info/WHEEL +4 -0
- browserrig-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import AsyncExitStack
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
from cdpify import CdpClient, EventSubscription, ReceivedEvent
|
|
9
|
+
from cdpify.domains.browser import DownloadProgressEvent, DownloadWillBeginEvent
|
|
10
|
+
from cdpify.domains.page import (
|
|
11
|
+
FrameNavigatedEvent,
|
|
12
|
+
FrameStartedLoadingEvent,
|
|
13
|
+
FrameStartedNavigatingEvent,
|
|
14
|
+
FrameStoppedLoadingEvent,
|
|
15
|
+
JavascriptDialogClosedEvent,
|
|
16
|
+
JavascriptDialogOpeningEvent,
|
|
17
|
+
LifecycleEventEvent,
|
|
18
|
+
NavigatedWithinDocumentEvent,
|
|
19
|
+
)
|
|
20
|
+
from cdpify.domains.runtime import (
|
|
21
|
+
ExecutionContextDestroyedEvent,
|
|
22
|
+
ExecutionContextsClearedEvent,
|
|
23
|
+
)
|
|
24
|
+
from cdpify.domains.target import (
|
|
25
|
+
TargetDestroyedEvent,
|
|
26
|
+
TargetInfoChangedEvent,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from browserrig.core import BrowserClosed, BrowserError
|
|
30
|
+
from browserrig.events import (
|
|
31
|
+
ConnectedEvent,
|
|
32
|
+
DisconnectedEvent,
|
|
33
|
+
)
|
|
34
|
+
from browserrig.files import Download, FileSystem
|
|
35
|
+
from browserrig.input import Input, InputTarget, Keyboard, Mouse
|
|
36
|
+
|
|
37
|
+
from .base import BrowserEvents, EventSink, _discard
|
|
38
|
+
from .config import BrowserOptions, CdpEndpoint, Launch
|
|
39
|
+
from .downloads import Downloads
|
|
40
|
+
from .tab import Tab, TabListener
|
|
41
|
+
from .tabs import Tabs
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Page and runtime events arrive on tab sessions; target and download events on the root.
|
|
46
|
+
_EVENTS = (
|
|
47
|
+
FrameNavigatedEvent,
|
|
48
|
+
NavigatedWithinDocumentEvent,
|
|
49
|
+
FrameStartedLoadingEvent,
|
|
50
|
+
FrameStartedNavigatingEvent,
|
|
51
|
+
LifecycleEventEvent,
|
|
52
|
+
FrameStoppedLoadingEvent,
|
|
53
|
+
JavascriptDialogOpeningEvent,
|
|
54
|
+
JavascriptDialogClosedEvent,
|
|
55
|
+
ExecutionContextDestroyedEvent,
|
|
56
|
+
ExecutionContextsClearedEvent,
|
|
57
|
+
TargetInfoChangedEvent,
|
|
58
|
+
TargetDestroyedEvent,
|
|
59
|
+
DownloadWillBeginEvent,
|
|
60
|
+
DownloadProgressEvent,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ChromeBrowser:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
options: BrowserOptions | None = None,
|
|
68
|
+
*,
|
|
69
|
+
emit: EventSink = _discard,
|
|
70
|
+
on_tab_change: TabListener | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._options = options if options is not None else Launch()
|
|
73
|
+
files = self._options.files
|
|
74
|
+
self.files = (
|
|
75
|
+
FileSystem(files, download_directory=self._options.download_directory)
|
|
76
|
+
if files is not None
|
|
77
|
+
else None
|
|
78
|
+
)
|
|
79
|
+
self.id = uuid4().hex
|
|
80
|
+
self.events = BrowserEvents(self.id, emit)
|
|
81
|
+
self._stack: AsyncExitStack | None = None
|
|
82
|
+
self._downloads = Downloads(
|
|
83
|
+
files=self.files,
|
|
84
|
+
directory=self._options.download_directory,
|
|
85
|
+
emit=self.events.emit,
|
|
86
|
+
)
|
|
87
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
88
|
+
self._failure: Exception | None = None
|
|
89
|
+
self._connected = False
|
|
90
|
+
self.action_lock = asyncio.Lock()
|
|
91
|
+
# One mouse and keyboard for the whole browser, aimed at the active tab.
|
|
92
|
+
self.input = Input(self._focused_input)
|
|
93
|
+
self.tabs = Tabs(
|
|
94
|
+
self._options,
|
|
95
|
+
self.input,
|
|
96
|
+
check=self._check,
|
|
97
|
+
emit=self.events.emit,
|
|
98
|
+
on_change=on_tab_change,
|
|
99
|
+
)
|
|
100
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
101
|
+
self.client: CdpClient
|
|
102
|
+
|
|
103
|
+
async def __aenter__(self) -> Self:
|
|
104
|
+
if self._stack is not None or self._close_task is not None:
|
|
105
|
+
raise BrowserError("A ChromeBrowser instance can be opened only once")
|
|
106
|
+
self._stack = AsyncExitStack()
|
|
107
|
+
try:
|
|
108
|
+
options = self._options
|
|
109
|
+
if self.files is not None:
|
|
110
|
+
await self.files.initialize()
|
|
111
|
+
async with asyncio.timeout(options.timeout):
|
|
112
|
+
endpoint = await self._resolve_endpoint()
|
|
113
|
+
self.client = await self._stack.enter_async_context(
|
|
114
|
+
CdpClient(
|
|
115
|
+
endpoint.url,
|
|
116
|
+
additional_headers=endpoint.headers,
|
|
117
|
+
default_timeout=options.timeout,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
context = await self.tabs.start(self.client, self._stack)
|
|
121
|
+
if context is not None:
|
|
122
|
+
await self._downloads.enable(self.client, context)
|
|
123
|
+
self._start_listeners()
|
|
124
|
+
self._stack.push_async_callback(self._stop_listeners)
|
|
125
|
+
await self.client.target.set_discover_targets(discover=True)
|
|
126
|
+
await self.tabs.open_initial()
|
|
127
|
+
self._connected = True
|
|
128
|
+
await self.events.emit(
|
|
129
|
+
ConnectedEvent(owned_process=options.owns_process)
|
|
130
|
+
)
|
|
131
|
+
return self
|
|
132
|
+
except (Exception, asyncio.CancelledError):
|
|
133
|
+
await self.aclose()
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
async def __aexit__(self, *_: object) -> None:
|
|
137
|
+
await self.aclose()
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def active_tab(self) -> Tab:
|
|
141
|
+
return self.tabs.active
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def mouse(self) -> Mouse:
|
|
145
|
+
return self.input.mouse
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def keyboard(self) -> Keyboard:
|
|
149
|
+
return self.input.keyboard
|
|
150
|
+
|
|
151
|
+
async def downloads(self) -> tuple[Download, ...]:
|
|
152
|
+
return self._downloads.all()
|
|
153
|
+
|
|
154
|
+
async def aclose(self) -> None:
|
|
155
|
+
if self._close_task is None:
|
|
156
|
+
self._close_task = asyncio.create_task(
|
|
157
|
+
self._close(), name="browserrig:close"
|
|
158
|
+
)
|
|
159
|
+
await asyncio.shield(self._close_task)
|
|
160
|
+
|
|
161
|
+
async def _resolve_endpoint(self) -> CdpEndpoint:
|
|
162
|
+
"""Resolve an HTTP discovery endpoint to the browser's WebSocket."""
|
|
163
|
+
assert self._stack is not None
|
|
164
|
+
options = self._options
|
|
165
|
+
endpoint = await options.cdp_endpoint(self._stack)
|
|
166
|
+
url = endpoint.url
|
|
167
|
+
if url.startswith(("http://", "https://")):
|
|
168
|
+
async with httpx2.AsyncClient(
|
|
169
|
+
timeout=options.timeout, headers=endpoint.headers, trust_env=False
|
|
170
|
+
) as http:
|
|
171
|
+
response = await http.get(url.rstrip("/") + "/json/version")
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
url = response.json()["webSocketDebuggerUrl"]
|
|
174
|
+
if "/devtools/page/" in url:
|
|
175
|
+
raise BrowserError(
|
|
176
|
+
"Discovery returned a page endpoint; a browser endpoint is needed"
|
|
177
|
+
)
|
|
178
|
+
return CdpEndpoint(url=url, headers=endpoint.headers)
|
|
179
|
+
|
|
180
|
+
def _check(self) -> None:
|
|
181
|
+
if self._stack is None or not self.client.is_connected:
|
|
182
|
+
raise BrowserClosed("Browser connection is closed")
|
|
183
|
+
if self._failure is not None:
|
|
184
|
+
raise BrowserError(
|
|
185
|
+
"Browser event listener failed; reconnect before continuing"
|
|
186
|
+
) from self._failure
|
|
187
|
+
|
|
188
|
+
def _focused_input(self) -> InputTarget | None:
|
|
189
|
+
tab = self.tabs.focused()
|
|
190
|
+
return None if tab is None else tab.input
|
|
191
|
+
|
|
192
|
+
def _start_listeners(self) -> None:
|
|
193
|
+
for event_type in _EVENTS:
|
|
194
|
+
subscription = self.client.listen_all(event_type)
|
|
195
|
+
self._tasks.append(
|
|
196
|
+
asyncio.create_task(
|
|
197
|
+
self._consume(subscription),
|
|
198
|
+
name=f"browserrig:{event_type.__name__}",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def _consume(
|
|
203
|
+
self, subscription: EventSubscription[ReceivedEvent[Any]]
|
|
204
|
+
) -> None:
|
|
205
|
+
try:
|
|
206
|
+
async with subscription:
|
|
207
|
+
async for received in subscription:
|
|
208
|
+
try:
|
|
209
|
+
await self._handle(received.value, received.session_id)
|
|
210
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
211
|
+
# A target can disappear while its event is being handled.
|
|
212
|
+
logger.exception(
|
|
213
|
+
"Browser event %s failed", type(received.value).__name__
|
|
214
|
+
)
|
|
215
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
216
|
+
# Surface listener failures on the next browser operation.
|
|
217
|
+
self._failure = error
|
|
218
|
+
|
|
219
|
+
async def _handle(self, event: Any, session_id: str | None) -> None:
|
|
220
|
+
if session_id is None and isinstance(
|
|
221
|
+
event, (DownloadWillBeginEvent, DownloadProgressEvent)
|
|
222
|
+
):
|
|
223
|
+
await self._downloads.handle(event)
|
|
224
|
+
else:
|
|
225
|
+
await self.tabs.handle(event, session_id)
|
|
226
|
+
|
|
227
|
+
async def _stop_listeners(self) -> None:
|
|
228
|
+
for task in self._tasks:
|
|
229
|
+
task.cancel()
|
|
230
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
231
|
+
self._tasks.clear()
|
|
232
|
+
|
|
233
|
+
async def _close(self) -> None:
|
|
234
|
+
if self._stack is not None:
|
|
235
|
+
stack, self._stack = self._stack, None
|
|
236
|
+
try:
|
|
237
|
+
await stack.aclose()
|
|
238
|
+
finally:
|
|
239
|
+
await self.tabs.discard()
|
|
240
|
+
if self._connected:
|
|
241
|
+
self._connected = False
|
|
242
|
+
await self.events.emit(DisconnectedEvent())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from contextlib import AsyncExitStack
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import ClassVar, Self
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, InstanceOf, SecretStr, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from browserrig.core import Options, TargetId
|
|
10
|
+
from browserrig.files import FileSystemOptions
|
|
11
|
+
|
|
12
|
+
from .process import ChromeProcess
|
|
13
|
+
from .setup import SessionSetup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Viewport(Options):
|
|
17
|
+
width: int = Field(default=1280, ge=320, le=7680)
|
|
18
|
+
height: int = Field(default=800, ge=240, le=4320)
|
|
19
|
+
scale: float = Field(default=1, gt=0, le=4)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class CdpEndpoint:
|
|
24
|
+
"""Where to reach a browser over CDP, and the headers that request needs."""
|
|
25
|
+
|
|
26
|
+
url: str
|
|
27
|
+
headers: dict[str, str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Launch(Options):
|
|
31
|
+
"""Start a Chrome process that browserrig owns and stops again."""
|
|
32
|
+
|
|
33
|
+
owns_process: ClassVar[bool] = True
|
|
34
|
+
# A launched browser always starts in a fresh tab of its own context.
|
|
35
|
+
target_id: ClassVar[None] = None
|
|
36
|
+
executable: Path | None = None
|
|
37
|
+
headless: bool = True
|
|
38
|
+
url: str = "about:blank"
|
|
39
|
+
viewport: Viewport = Viewport()
|
|
40
|
+
session_setup: tuple[InstanceOf[SessionSetup], ...] = ()
|
|
41
|
+
timeout: float = Field(default=20, gt=0, le=120)
|
|
42
|
+
arguments: tuple[str, ...] = ()
|
|
43
|
+
download_directory: str | None = None
|
|
44
|
+
# The agent's workspace for downloads and local files.
|
|
45
|
+
files: FileSystemOptions = FileSystemOptions()
|
|
46
|
+
|
|
47
|
+
@field_validator("arguments")
|
|
48
|
+
@classmethod
|
|
49
|
+
def _owned_arguments(cls, arguments: tuple[str, ...]) -> tuple[str, ...]:
|
|
50
|
+
reserved = ("--remote-debugging", "--user-data-dir", "--headless")
|
|
51
|
+
if any(
|
|
52
|
+
arg.startswith(reserved) or not arg.startswith("--") for arg in arguments
|
|
53
|
+
):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Arguments must be flags and cannot override process ownership"
|
|
56
|
+
)
|
|
57
|
+
return arguments
|
|
58
|
+
|
|
59
|
+
def process(self) -> ChromeProcess:
|
|
60
|
+
"""The Chrome process these options describe, not started yet."""
|
|
61
|
+
flags = [f"--window-size={self.viewport.width},{self.viewport.height}"]
|
|
62
|
+
if self.headless:
|
|
63
|
+
flags.append("--headless=new")
|
|
64
|
+
return ChromeProcess(
|
|
65
|
+
executable=self.executable,
|
|
66
|
+
flags=(*flags, *self.arguments),
|
|
67
|
+
timeout=self.timeout,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def cdp_endpoint(self, stack: AsyncExitStack) -> CdpEndpoint:
|
|
71
|
+
"""Start Chrome and return its endpoint; closing `stack` stops the process."""
|
|
72
|
+
process = await stack.enter_async_context(self.process())
|
|
73
|
+
return CdpEndpoint(url=process.endpoint, headers={})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Connect(Options):
|
|
77
|
+
"""Attach to a browser that runs elsewhere; browserrig never stops it."""
|
|
78
|
+
|
|
79
|
+
owns_process: ClassVar[bool] = False
|
|
80
|
+
endpoint: SecretStr
|
|
81
|
+
target_id: TargetId | None = None
|
|
82
|
+
url: str = "about:blank"
|
|
83
|
+
timeout: float = Field(default=20, gt=0, le=120)
|
|
84
|
+
headers: dict[str, SecretStr] = Field(default_factory=dict, repr=False)
|
|
85
|
+
viewport: Viewport | None = None
|
|
86
|
+
session_setup: tuple[InstanceOf[SessionSetup], ...] = ()
|
|
87
|
+
download_directory: str | None = None
|
|
88
|
+
# A browser running elsewhere gets a local workspace only when one is configured.
|
|
89
|
+
files: FileSystemOptions | None = None
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _target_ownership(self) -> Self:
|
|
93
|
+
if self.target_id and (self.url != "about:blank" or self.download_directory):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"An attached target keeps its URL and browser-wide download policy"
|
|
96
|
+
)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
@field_validator("endpoint")
|
|
100
|
+
@classmethod
|
|
101
|
+
def _browser_endpoint(cls, endpoint: SecretStr) -> SecretStr:
|
|
102
|
+
parsed = urlsplit(endpoint.get_secret_value())
|
|
103
|
+
if parsed.scheme not in {"http", "https", "ws", "wss"} or not parsed.hostname:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"Expected an HTTP(S) discovery endpoint or browser WebSocket"
|
|
106
|
+
)
|
|
107
|
+
if parsed.scheme in {"ws", "wss"} and "/devtools/page/" in parsed.path:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"A browser WebSocket is required; a page WebSocket cannot manage tabs"
|
|
110
|
+
)
|
|
111
|
+
return endpoint
|
|
112
|
+
|
|
113
|
+
async def cdp_endpoint(self, _stack: AsyncExitStack) -> CdpEndpoint:
|
|
114
|
+
"""Return the configured endpoint and its request headers."""
|
|
115
|
+
headers = {key: value.get_secret_value() for key, value in self.headers.items()}
|
|
116
|
+
return CdpEndpoint(url=self.endpoint.get_secret_value(), headers=headers)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
type BrowserOptions = Launch | Connect
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from time import monotonic
|
|
2
|
+
|
|
3
|
+
from cdpify import CdpClient
|
|
4
|
+
from cdpify.domains.browser import DownloadProgressEvent, DownloadWillBeginEvent
|
|
5
|
+
|
|
6
|
+
from browserrig.events import (
|
|
7
|
+
DownloadCanceledEvent,
|
|
8
|
+
DownloadCompletedEvent,
|
|
9
|
+
DownloadFailedEvent,
|
|
10
|
+
DownloadProgressedEvent,
|
|
11
|
+
DownloadStartedEvent,
|
|
12
|
+
)
|
|
13
|
+
from browserrig.files import Download, DownloadState, FileSystem
|
|
14
|
+
|
|
15
|
+
from .base import EventSink
|
|
16
|
+
|
|
17
|
+
# Chrome reports every received chunk; viewers only need a smooth progress ring.
|
|
18
|
+
_PROGRESS_INTERVAL_SECONDS = 0.2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Downloads:
|
|
22
|
+
"""Track browser downloads and move completed files into the workspace."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, *, files: FileSystem | None, directory: str | None, emit: EventSink
|
|
26
|
+
) -> None:
|
|
27
|
+
self._files = files
|
|
28
|
+
self._directory = directory
|
|
29
|
+
self._emit = emit
|
|
30
|
+
self._items: dict[str, Download] = {}
|
|
31
|
+
self._progress_at: dict[str, float] = {}
|
|
32
|
+
|
|
33
|
+
async def enable(self, client: CdpClient, context_id: str | None) -> None:
|
|
34
|
+
directory = (
|
|
35
|
+
str(self._files.pending_directory) if self._files else self._directory
|
|
36
|
+
)
|
|
37
|
+
if directory is not None:
|
|
38
|
+
await client.browser.set_download_behavior(
|
|
39
|
+
behavior="allowAndName",
|
|
40
|
+
download_path=directory,
|
|
41
|
+
browser_context_id=context_id,
|
|
42
|
+
events_enabled=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def all(self) -> tuple[Download, ...]:
|
|
46
|
+
return tuple(self._items.values())
|
|
47
|
+
|
|
48
|
+
async def handle(
|
|
49
|
+
self, event: DownloadWillBeginEvent | DownloadProgressEvent
|
|
50
|
+
) -> None:
|
|
51
|
+
match event:
|
|
52
|
+
case DownloadWillBeginEvent():
|
|
53
|
+
self._items[event.guid] = Download(
|
|
54
|
+
id=event.guid, url=event.url, filename=event.suggested_filename
|
|
55
|
+
)
|
|
56
|
+
await self._emit(
|
|
57
|
+
DownloadStartedEvent(
|
|
58
|
+
download_id=event.guid,
|
|
59
|
+
filename=event.suggested_filename,
|
|
60
|
+
url=event.url,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
case DownloadProgressEvent() if event.guid in self._items:
|
|
64
|
+
await self._progress(event)
|
|
65
|
+
|
|
66
|
+
async def _progress(self, event: DownloadProgressEvent) -> None:
|
|
67
|
+
guid = event.guid
|
|
68
|
+
previous = self._items[guid]
|
|
69
|
+
# Terminal CDP events can be repeated; never move an already completed file twice.
|
|
70
|
+
if previous.state != DownloadState.IN_PROGRESS:
|
|
71
|
+
return
|
|
72
|
+
total = int(event.total_bytes) if event.total_bytes > 0 else None
|
|
73
|
+
if event.state == DownloadState.IN_PROGRESS:
|
|
74
|
+
await self._report_progress(previous, int(event.received_bytes), total)
|
|
75
|
+
return
|
|
76
|
+
path, error = event.file_path, None
|
|
77
|
+
if event.state == DownloadState.COMPLETED and self._files is not None:
|
|
78
|
+
try:
|
|
79
|
+
path = await self._files.complete_download(
|
|
80
|
+
guid, previous.filename, path
|
|
81
|
+
)
|
|
82
|
+
except (OSError, ValueError) as failure:
|
|
83
|
+
# A single missing file must not stop browser event processing.
|
|
84
|
+
path, error = None, f"Downloaded file could not be saved: {failure}"
|
|
85
|
+
download = Download(
|
|
86
|
+
id=guid,
|
|
87
|
+
url=previous.url,
|
|
88
|
+
filename=previous.filename,
|
|
89
|
+
state=DownloadState(event.state),
|
|
90
|
+
received_bytes=int(event.received_bytes),
|
|
91
|
+
total_bytes=total,
|
|
92
|
+
path=path,
|
|
93
|
+
error=error,
|
|
94
|
+
)
|
|
95
|
+
self._items[guid] = download
|
|
96
|
+
self._progress_at.pop(guid, None)
|
|
97
|
+
match download.state:
|
|
98
|
+
case DownloadState.COMPLETED if error is not None:
|
|
99
|
+
await self._emit(DownloadFailedEvent(download_id=guid, reason=error))
|
|
100
|
+
case DownloadState.COMPLETED:
|
|
101
|
+
await self._emit(
|
|
102
|
+
DownloadCompletedEvent(
|
|
103
|
+
download_id=guid,
|
|
104
|
+
filename=download.filename,
|
|
105
|
+
size=download.received_bytes,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
case DownloadState.CANCELED:
|
|
109
|
+
await self._emit(DownloadCanceledEvent(download_id=guid))
|
|
110
|
+
|
|
111
|
+
async def _report_progress(
|
|
112
|
+
self, download: Download, received: int, total: int | None
|
|
113
|
+
) -> None:
|
|
114
|
+
self._items[download.id] = download.model_copy(
|
|
115
|
+
update={"received_bytes": received, "total_bytes": total}
|
|
116
|
+
)
|
|
117
|
+
now = monotonic()
|
|
118
|
+
if (
|
|
119
|
+
now - self._progress_at.get(download.id, float("-inf"))
|
|
120
|
+
< _PROGRESS_INTERVAL_SECONDS
|
|
121
|
+
):
|
|
122
|
+
return
|
|
123
|
+
self._progress_at[download.id] = now
|
|
124
|
+
await self._emit(
|
|
125
|
+
DownloadProgressedEvent(
|
|
126
|
+
download_id=download.id, received_bytes=received, total_bytes=total
|
|
127
|
+
)
|
|
128
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import re
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from urllib.parse import unquote_to_bytes, urlsplit
|
|
6
|
+
|
|
7
|
+
from cdpify import CdpSession
|
|
8
|
+
from cdpify.domains.network import LoadNetworkResourceOptions
|
|
9
|
+
from cdpify.exceptions import CDPError
|
|
10
|
+
|
|
11
|
+
# Icons travel inside live events, so oversized ones are dropped instead of streamed.
|
|
12
|
+
_MAX_BYTES = 64 * 1024
|
|
13
|
+
_TIMEOUT_SECONDS = 5
|
|
14
|
+
_CLOSE_SECONDS = 1
|
|
15
|
+
_CHUNK_BYTES = 16 * 1024
|
|
16
|
+
|
|
17
|
+
_SIGNATURES = (
|
|
18
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
19
|
+
(b"\x00\x00\x01\x00", "image/x-icon"),
|
|
20
|
+
(b"GIF87a", "image/gif"),
|
|
21
|
+
(b"GIF89a", "image/gif"),
|
|
22
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
23
|
+
(b"BM", "image/bmp"),
|
|
24
|
+
)
|
|
25
|
+
_SVG_PREAMBLE = re.compile(rb"^(?:\s|<\?xml[^>]*\?>|<!--.*?-->)*", re.S)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def load_favicon(cdp: CdpSession, frame_id: str, url: str) -> str:
|
|
29
|
+
"""Return the page's icon as a base64 `data:` URI, or "" when it is missing or unusable.
|
|
30
|
+
|
|
31
|
+
Web icons load through the page's own network stack without cookies, so icons behind
|
|
32
|
+
a login do not load. Inline `data:` icons are decoded locally. Either way the type
|
|
33
|
+
comes from the bytes. SVG stays safe only while viewers render it with `<img>`, never
|
|
34
|
+
through `innerHTML` or `<object>`.
|
|
35
|
+
"""
|
|
36
|
+
scheme = urlsplit(url).scheme
|
|
37
|
+
try:
|
|
38
|
+
if scheme == "data":
|
|
39
|
+
data = _decode_data_uri(url)
|
|
40
|
+
elif scheme in {"http", "https"}:
|
|
41
|
+
async with asyncio.timeout(_TIMEOUT_SECONDS):
|
|
42
|
+
data = await _fetch(cdp, frame_id, url)
|
|
43
|
+
else:
|
|
44
|
+
return ""
|
|
45
|
+
except (CDPError, TimeoutError, ValueError):
|
|
46
|
+
return ""
|
|
47
|
+
if not data or (media_type := _media_type(data)) is None:
|
|
48
|
+
return ""
|
|
49
|
+
return f"data:{media_type};base64,{base64.b64encode(data).decode()}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decode_data_uri(url: str) -> bytes | None:
|
|
53
|
+
header, separator, payload = url.removeprefix("data:").partition(",")
|
|
54
|
+
if not separator:
|
|
55
|
+
return None
|
|
56
|
+
if header.lower().endswith(";base64"):
|
|
57
|
+
encoded = b"".join(unquote_to_bytes(payload).split())
|
|
58
|
+
data = base64.b64decode(encoded, validate=True)
|
|
59
|
+
else:
|
|
60
|
+
data = unquote_to_bytes(payload)
|
|
61
|
+
return data if len(data) <= _MAX_BYTES else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _fetch(cdp: CdpSession, frame_id: str, url: str) -> bytes | None:
|
|
65
|
+
result = await cdp.network.load_network_resource(
|
|
66
|
+
frame_id=frame_id,
|
|
67
|
+
url=url,
|
|
68
|
+
options=LoadNetworkResourceOptions(
|
|
69
|
+
disable_cache=False, include_credentials=False
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
resource = result.resource
|
|
73
|
+
if not resource.success or resource.stream is None:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
status = resource.http_status_code
|
|
77
|
+
if status is not None and not 200 <= status < 300:
|
|
78
|
+
return None
|
|
79
|
+
return await _read(cdp, resource.stream)
|
|
80
|
+
finally:
|
|
81
|
+
# Closing must neither hang after a timeout nor replace the result with its error.
|
|
82
|
+
with suppress(CDPError, TimeoutError):
|
|
83
|
+
async with asyncio.timeout(_CLOSE_SECONDS):
|
|
84
|
+
await cdp.io.close(handle=resource.stream)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _read(cdp: CdpSession, handle: str) -> bytes | None:
|
|
88
|
+
data = bytearray()
|
|
89
|
+
while len(data) <= _MAX_BYTES:
|
|
90
|
+
chunk = await cdp.io.read(handle=handle, size=_CHUNK_BYTES)
|
|
91
|
+
data += (
|
|
92
|
+
base64.b64decode(chunk.data, validate=True)
|
|
93
|
+
if chunk.base64_encoded
|
|
94
|
+
else chunk.data.encode()
|
|
95
|
+
)
|
|
96
|
+
if chunk.eof:
|
|
97
|
+
return bytes(data) if len(data) <= _MAX_BYTES else None
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _media_type(data: bytes) -> str | None:
|
|
102
|
+
# The page controls both the icon URL and its headers, so only the bytes decide.
|
|
103
|
+
for signature, media_type in _SIGNATURES:
|
|
104
|
+
if data.startswith(signature):
|
|
105
|
+
return media_type
|
|
106
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
107
|
+
return "image/webp"
|
|
108
|
+
if data[4:12] in (b"ftypavif", b"ftypavis"):
|
|
109
|
+
return "image/avif"
|
|
110
|
+
head = data[:2048].lstrip(b"\xef\xbb\xbf").lower()
|
|
111
|
+
# An XHTML page may start with an XML prolog and embed inline SVG.
|
|
112
|
+
if b"<html" in head:
|
|
113
|
+
return None
|
|
114
|
+
body = _SVG_PREAMBLE.sub(b"", head)
|
|
115
|
+
if body.startswith(b"<svg") or (
|
|
116
|
+
body.startswith(b"<!doctype svg") and b"<svg" in body
|
|
117
|
+
):
|
|
118
|
+
return "image/svg+xml"
|
|
119
|
+
return None
|