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.
Files changed (167) hide show
  1. browserrig/__init__.py +158 -0
  2. browserrig/activity/__init__.py +5 -0
  3. browserrig/activity/models.py +93 -0
  4. browserrig/activity/plugin.py +247 -0
  5. browserrig/activity/state.py +16 -0
  6. browserrig/agent.py +55 -0
  7. browserrig/browser/__init__.py +33 -0
  8. browserrig/browser/base.py +70 -0
  9. browserrig/browser/chrome.py +242 -0
  10. browserrig/browser/config.py +119 -0
  11. browserrig/browser/downloads.py +128 -0
  12. browserrig/browser/favicon.py +119 -0
  13. browserrig/browser/frame.py +227 -0
  14. browserrig/browser/frames.py +116 -0
  15. browserrig/browser/images.py +23 -0
  16. browserrig/browser/input.py +147 -0
  17. browserrig/browser/models.py +8 -0
  18. browserrig/browser/plugin.py +44 -0
  19. browserrig/browser/process.py +144 -0
  20. browserrig/browser/setup.py +12 -0
  21. browserrig/browser/tab.py +311 -0
  22. browserrig/browser/tabs.py +366 -0
  23. browserrig/browser/worlds.py +64 -0
  24. browserrig/choreography/__init__.py +31 -0
  25. browserrig/choreography/events.py +133 -0
  26. browserrig/choreography/plugin.py +75 -0
  27. browserrig/choreography/scenes.py +167 -0
  28. browserrig/choreography/stage.py +53 -0
  29. browserrig/choreography/tabstrip.py +112 -0
  30. browserrig/completion.py +184 -0
  31. browserrig/composition.py +108 -0
  32. browserrig/core/__init__.py +61 -0
  33. browserrig/core/errors.py +18 -0
  34. browserrig/core/geometry.py +149 -0
  35. browserrig/core/ids.py +10 -0
  36. browserrig/core/kinds.py +180 -0
  37. browserrig/core/options.py +7 -0
  38. browserrig/core/tools.py +32 -0
  39. browserrig/events.py +665 -0
  40. browserrig/files/__init__.py +25 -0
  41. browserrig/files/config.py +13 -0
  42. browserrig/files/containers.py +200 -0
  43. browserrig/files/content.py +65 -0
  44. browserrig/files/data.py +80 -0
  45. browserrig/files/documents.py +248 -0
  46. browserrig/files/formats.py +84 -0
  47. browserrig/files/imaging.py +138 -0
  48. browserrig/files/kinds.py +78 -0
  49. browserrig/files/models.py +71 -0
  50. browserrig/files/reading.py +382 -0
  51. browserrig/files/spreadsheets.py +417 -0
  52. browserrig/files/system.py +204 -0
  53. browserrig/files/text.py +64 -0
  54. browserrig/files/xlsx.py +53 -0
  55. browserrig/handoff/__init__.py +4 -0
  56. browserrig/handoff/changes.py +48 -0
  57. browserrig/handoff/models.py +31 -0
  58. browserrig/handoff/plugin.py +308 -0
  59. browserrig/handoff/state.py +21 -0
  60. browserrig/input/__init__.py +35 -0
  61. browserrig/input/controller.py +69 -0
  62. browserrig/input/keyboard.py +267 -0
  63. browserrig/input/motor.py +118 -0
  64. browserrig/input/mouse.py +346 -0
  65. browserrig/input/pointer.py +84 -0
  66. browserrig/input/presenter.py +49 -0
  67. browserrig/input/target.py +67 -0
  68. browserrig/input/wheel.py +40 -0
  69. browserrig/kinds.py +33 -0
  70. browserrig/nudges/__init__.py +3 -0
  71. browserrig/nudges/plugin.py +295 -0
  72. browserrig/nudges/state.py +23 -0
  73. browserrig/observation/__init__.py +53 -0
  74. browserrig/observation/capture.py +374 -0
  75. browserrig/observation/changes.py +63 -0
  76. browserrig/observation/config.py +12 -0
  77. browserrig/observation/content.py +176 -0
  78. browserrig/observation/delta.py +69 -0
  79. browserrig/observation/dom.py +97 -0
  80. browserrig/observation/extraction/__init__.py +335 -0
  81. browserrig/observation/extraction/accessibility.py +62 -0
  82. browserrig/observation/extraction/document.py +471 -0
  83. browserrig/observation/extraction/elements.py +676 -0
  84. browserrig/observation/extraction/semantics.py +351 -0
  85. browserrig/observation/extraction/styles.py +30 -0
  86. browserrig/observation/extraction/visibility.py +108 -0
  87. browserrig/observation/find.py +152 -0
  88. browserrig/observation/handle.py +170 -0
  89. browserrig/observation/images.py +243 -0
  90. browserrig/observation/marks.py +157 -0
  91. browserrig/observation/models.py +230 -0
  92. browserrig/observation/plugin.py +413 -0
  93. browserrig/observation/redirects.py +40 -0
  94. browserrig/observation/references.py +47 -0
  95. browserrig/observation/render.py +291 -0
  96. browserrig/observation/renderer.py +76 -0
  97. browserrig/observation/state.py +37 -0
  98. browserrig/pacing/__init__.py +5 -0
  99. browserrig/pacing/plugin.py +22 -0
  100. browserrig/py.typed +0 -0
  101. browserrig/readiness/__init__.py +25 -0
  102. browserrig/readiness/config.py +12 -0
  103. browserrig/readiness/dom.py +83 -0
  104. browserrig/readiness/downloads.py +30 -0
  105. browserrig/readiness/hooks.py +27 -0
  106. browserrig/readiness/models.py +13 -0
  107. browserrig/readiness/plugin.py +170 -0
  108. browserrig/readiness/policies.py +43 -0
  109. browserrig/readiness/state.py +20 -0
  110. browserrig/readiness/strategy.py +30 -0
  111. browserrig/readiness/wait.py +238 -0
  112. browserrig/scripts/README.md +39 -0
  113. browserrig/scripts/__init__.py +186 -0
  114. browserrig/scripts/content.js +526 -0
  115. browserrig/scripts/cursor.js +57 -0
  116. browserrig/scripts/element.js +82 -0
  117. browserrig/scripts/find.js +377 -0
  118. browserrig/scripts/focused.js +15 -0
  119. browserrig/scripts/lib/composed.js +21 -0
  120. browserrig/scripts/lib/elements.js +9 -0
  121. browserrig/scripts/lib/geometry.js +13 -0
  122. browserrig/scripts/lib/hit.js +84 -0
  123. browserrig/scripts/lib/markdown.js +32 -0
  124. browserrig/scripts/lib/scroll.js +226 -0
  125. browserrig/scripts/lib/secret.js +74 -0
  126. browserrig/scripts/lib/visibility.js +42 -0
  127. browserrig/scripts/models.py +153 -0
  128. browserrig/scripts/owner_hits.js +4 -0
  129. browserrig/scripts/owner_scroll.js +7 -0
  130. browserrig/scripts/owner_size.js +11 -0
  131. browserrig/scripts/page_facts.js +9 -0
  132. browserrig/scripts/reset.js +4 -0
  133. browserrig/scripts/scroll.js +5 -0
  134. browserrig/scripts/secret_field.js +7 -0
  135. browserrig/scripts/secret_focus.js +31 -0
  136. browserrig/scripts/seed.js +13 -0
  137. browserrig/scripts/stability.js +140 -0
  138. browserrig/scripts/tab_strip.js +27 -0
  139. browserrig/scripts/viewport.js +3 -0
  140. browserrig/secrets/__init__.py +5 -0
  141. browserrig/secrets/models.py +90 -0
  142. browserrig/secrets/origin.py +95 -0
  143. browserrig/secrets/plugin.py +397 -0
  144. browserrig/secrets/redaction.py +107 -0
  145. browserrig/secrets/state.py +6 -0
  146. browserrig/secrets/totp.py +62 -0
  147. browserrig/terminal.py +120 -0
  148. browserrig/tools/__init__.py +51 -0
  149. browserrig/tools/chains.py +55 -0
  150. browserrig/tools/dialogs.py +23 -0
  151. browserrig/tools/effects.py +8 -0
  152. browserrig/tools/execution.py +333 -0
  153. browserrig/tools/files.py +411 -0
  154. browserrig/tools/find.py +125 -0
  155. browserrig/tools/forms.py +76 -0
  156. browserrig/tools/intents.py +82 -0
  157. browserrig/tools/javascript.py +37 -0
  158. browserrig/tools/keyboard.py +126 -0
  159. browserrig/tools/mouse.py +224 -0
  160. browserrig/tools/navigation.py +189 -0
  161. browserrig/tools/scrolling.py +39 -0
  162. browserrig/tools/tabs.py +81 -0
  163. browserrig/tools/targeting.py +59 -0
  164. browserrig-0.1.0.dist-info/METADATA +174 -0
  165. browserrig-0.1.0.dist-info/RECORD +167 -0
  166. browserrig-0.1.0.dist-info/WHEEL +4 -0
  167. 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