browser-control-and-automation-cli 0.1.1.dev3__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.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Playwright-backed network record assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import contextlib
|
|
8
|
+
import logging
|
|
9
|
+
import mimetypes
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from browser_cli.constants import get_app_paths
|
|
17
|
+
from browser_cli.network import NetworkRecordFilter, NetworkRecordStore
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
INLINE_TEXT_MAX_BYTES = 128 * 1024
|
|
23
|
+
INLINE_BINARY_MAX_BYTES = 64 * 1024
|
|
24
|
+
MAX_CAPTURE_BODY_BYTES = 8 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class _PendingRequest:
|
|
29
|
+
request: Any
|
|
30
|
+
request_id: str
|
|
31
|
+
url: str
|
|
32
|
+
method: str
|
|
33
|
+
resource_type: str
|
|
34
|
+
started_at: float
|
|
35
|
+
request_headers: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
request_post_data: str | None = None
|
|
37
|
+
response: Any | None = None
|
|
38
|
+
status: int | None = None
|
|
39
|
+
ok: bool | None = None
|
|
40
|
+
response_headers: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
mime_type: str = ""
|
|
42
|
+
completed: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PlaywrightNetworkObserver:
|
|
46
|
+
def __init__(self, *, page_id: str, page: Any) -> None:
|
|
47
|
+
self._page_id = page_id
|
|
48
|
+
self._page = page
|
|
49
|
+
self._store = NetworkRecordStore()
|
|
50
|
+
self._pending_by_request_key: dict[int, _PendingRequest] = {}
|
|
51
|
+
self._pending_tasks: set[asyncio.Task[None]] = set()
|
|
52
|
+
self._sequence = 0
|
|
53
|
+
self._closed = False
|
|
54
|
+
self._handlers = {
|
|
55
|
+
"request": self._on_request,
|
|
56
|
+
"response": self._on_response,
|
|
57
|
+
"requestfinished": self._on_request_finished,
|
|
58
|
+
"requestfailed": self._on_request_failed,
|
|
59
|
+
}
|
|
60
|
+
for event_name, handler in self._handlers.items():
|
|
61
|
+
self._page.on(event_name, handler)
|
|
62
|
+
|
|
63
|
+
def start_capture(self) -> None:
|
|
64
|
+
self._store.start_capture()
|
|
65
|
+
|
|
66
|
+
def stop_capture(self) -> None:
|
|
67
|
+
self._store.stop_capture()
|
|
68
|
+
|
|
69
|
+
def get_records(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
record_filter: NetworkRecordFilter,
|
|
73
|
+
clear: bool = True,
|
|
74
|
+
) -> list[dict[str, Any]]:
|
|
75
|
+
return self._store.get_captured_records(record_filter=record_filter, clear=clear)
|
|
76
|
+
|
|
77
|
+
async def wait_for_record(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
record_filter: NetworkRecordFilter,
|
|
81
|
+
timeout_seconds: float,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
return await self._store.wait_for_record(
|
|
84
|
+
record_filter=record_filter, timeout_seconds=timeout_seconds
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
if self._closed:
|
|
89
|
+
return
|
|
90
|
+
self._closed = True
|
|
91
|
+
for event_name, handler in self._handlers.items():
|
|
92
|
+
with contextlib.suppress(Exception):
|
|
93
|
+
self._page.remove_listener(event_name, handler)
|
|
94
|
+
for task in list(self._pending_tasks):
|
|
95
|
+
task.cancel()
|
|
96
|
+
if self._pending_tasks:
|
|
97
|
+
await asyncio.gather(*self._pending_tasks, return_exceptions=True)
|
|
98
|
+
self._pending_tasks.clear()
|
|
99
|
+
self._pending_by_request_key.clear()
|
|
100
|
+
self._store.clear()
|
|
101
|
+
|
|
102
|
+
def _on_request(self, request: Any) -> None:
|
|
103
|
+
self._ensure_pending_request(request)
|
|
104
|
+
|
|
105
|
+
def _on_response(self, response: Any) -> None:
|
|
106
|
+
request = response.request
|
|
107
|
+
pending = self._ensure_pending_request(request)
|
|
108
|
+
pending.response = response
|
|
109
|
+
pending.status = int(response.status or 0)
|
|
110
|
+
pending.ok = bool(response.ok)
|
|
111
|
+
response_headers = dict(response.headers) if response.headers else {}
|
|
112
|
+
pending.response_headers = response_headers
|
|
113
|
+
pending.mime_type = _extract_mime_type(response_headers)
|
|
114
|
+
|
|
115
|
+
def _on_request_finished(self, request: Any) -> None:
|
|
116
|
+
self._spawn(self._finalize_request(request))
|
|
117
|
+
|
|
118
|
+
def _on_request_failed(self, request: Any) -> None:
|
|
119
|
+
self._spawn(self._finalize_failed_request(request))
|
|
120
|
+
|
|
121
|
+
def _spawn(self, coroutine: Any) -> None:
|
|
122
|
+
if self._closed:
|
|
123
|
+
return
|
|
124
|
+
task = asyncio.create_task(coroutine)
|
|
125
|
+
self._pending_tasks.add(task)
|
|
126
|
+
task.add_done_callback(self._on_task_done)
|
|
127
|
+
|
|
128
|
+
def _on_task_done(self, task: asyncio.Task[None]) -> None:
|
|
129
|
+
self._pending_tasks.discard(task)
|
|
130
|
+
if task.cancelled():
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
task.result()
|
|
134
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
135
|
+
logger.warning("Playwright network observer task failed for %s: %s", self._page_id, exc)
|
|
136
|
+
|
|
137
|
+
def _ensure_pending_request(self, request: Any) -> _PendingRequest:
|
|
138
|
+
request_key = id(request)
|
|
139
|
+
pending = self._pending_by_request_key.get(request_key)
|
|
140
|
+
if pending is not None:
|
|
141
|
+
return pending
|
|
142
|
+
self._sequence += 1
|
|
143
|
+
pending = _PendingRequest(
|
|
144
|
+
request=request,
|
|
145
|
+
request_id=f"{self._page_id}-req-{self._sequence:04d}",
|
|
146
|
+
url=str(request.url or ""),
|
|
147
|
+
method=str(request.method or ""),
|
|
148
|
+
resource_type=str(request.resource_type or ""),
|
|
149
|
+
started_at=time.time(),
|
|
150
|
+
request_headers=dict(request.headers) if request.headers else {},
|
|
151
|
+
request_post_data=request.post_data,
|
|
152
|
+
)
|
|
153
|
+
self._pending_by_request_key[request_key] = pending
|
|
154
|
+
return pending
|
|
155
|
+
|
|
156
|
+
async def _finalize_request(self, request: Any) -> None:
|
|
157
|
+
request_key = id(request)
|
|
158
|
+
pending = self._ensure_pending_request(request)
|
|
159
|
+
if pending.completed:
|
|
160
|
+
return
|
|
161
|
+
pending.completed = True
|
|
162
|
+
pending.request_headers = await _read_request_headers(request, pending.request_headers)
|
|
163
|
+
response = pending.response or await request.response()
|
|
164
|
+
body = {
|
|
165
|
+
"kind": "unavailable",
|
|
166
|
+
"bytes": 0,
|
|
167
|
+
"truncated": False,
|
|
168
|
+
"error": "Response body was not available.",
|
|
169
|
+
}
|
|
170
|
+
if response is not None:
|
|
171
|
+
pending.response_headers = await _read_response_headers(
|
|
172
|
+
response, pending.response_headers
|
|
173
|
+
)
|
|
174
|
+
pending.status = int(response.status or pending.status or 0)
|
|
175
|
+
pending.ok = bool(response.ok)
|
|
176
|
+
pending.mime_type = _extract_mime_type(pending.response_headers) or pending.mime_type
|
|
177
|
+
body = await _build_body_payload(
|
|
178
|
+
page_id=self._page_id,
|
|
179
|
+
request_id=pending.request_id,
|
|
180
|
+
url=pending.url,
|
|
181
|
+
mime_type=pending.mime_type,
|
|
182
|
+
response_headers=pending.response_headers,
|
|
183
|
+
response=response,
|
|
184
|
+
)
|
|
185
|
+
ended_at = time.time()
|
|
186
|
+
record = _build_record(
|
|
187
|
+
pending=pending,
|
|
188
|
+
ended_at=ended_at,
|
|
189
|
+
failed=False,
|
|
190
|
+
failure_reason=None,
|
|
191
|
+
body=body,
|
|
192
|
+
)
|
|
193
|
+
self._pending_by_request_key.pop(request_key, None)
|
|
194
|
+
self._store.add_record(record)
|
|
195
|
+
|
|
196
|
+
async def _finalize_failed_request(self, request: Any) -> None:
|
|
197
|
+
request_key = id(request)
|
|
198
|
+
pending = self._ensure_pending_request(request)
|
|
199
|
+
if pending.completed:
|
|
200
|
+
return
|
|
201
|
+
pending.completed = True
|
|
202
|
+
pending.request_headers = await _read_request_headers(request, pending.request_headers)
|
|
203
|
+
failure_reason = str(request.failure or "Request failed.")
|
|
204
|
+
ended_at = time.time()
|
|
205
|
+
record = _build_record(
|
|
206
|
+
pending=pending,
|
|
207
|
+
ended_at=ended_at,
|
|
208
|
+
failed=True,
|
|
209
|
+
failure_reason=failure_reason,
|
|
210
|
+
body={
|
|
211
|
+
"kind": "unavailable",
|
|
212
|
+
"bytes": 0,
|
|
213
|
+
"truncated": False,
|
|
214
|
+
"error": failure_reason,
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
self._pending_by_request_key.pop(request_key, None)
|
|
218
|
+
self._store.add_record(record)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _read_request_headers(request: Any, fallback: dict[str, str]) -> dict[str, str]:
|
|
222
|
+
try:
|
|
223
|
+
headers = await request.all_headers()
|
|
224
|
+
except Exception:
|
|
225
|
+
return fallback
|
|
226
|
+
return dict(headers or fallback)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _read_response_headers(response: Any, fallback: dict[str, str]) -> dict[str, str]:
|
|
230
|
+
try:
|
|
231
|
+
headers = await response.all_headers()
|
|
232
|
+
except Exception:
|
|
233
|
+
return fallback
|
|
234
|
+
return dict(headers or fallback)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _build_body_payload(
|
|
238
|
+
*,
|
|
239
|
+
page_id: str,
|
|
240
|
+
request_id: str,
|
|
241
|
+
url: str,
|
|
242
|
+
mime_type: str,
|
|
243
|
+
response_headers: dict[str, str],
|
|
244
|
+
response: Any,
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
content_length = _parse_content_length(response_headers)
|
|
247
|
+
if content_length is not None and content_length > MAX_CAPTURE_BODY_BYTES:
|
|
248
|
+
return {
|
|
249
|
+
"kind": "omitted",
|
|
250
|
+
"bytes": content_length,
|
|
251
|
+
"truncated": False,
|
|
252
|
+
"error": "Response body exceeded the capture limit.",
|
|
253
|
+
}
|
|
254
|
+
try:
|
|
255
|
+
body_bytes = await response.body()
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
return {
|
|
258
|
+
"kind": "unavailable",
|
|
259
|
+
"bytes": content_length or 0,
|
|
260
|
+
"truncated": False,
|
|
261
|
+
"error": str(exc),
|
|
262
|
+
}
|
|
263
|
+
byte_length = len(body_bytes)
|
|
264
|
+
if byte_length > MAX_CAPTURE_BODY_BYTES:
|
|
265
|
+
return {
|
|
266
|
+
"kind": "omitted",
|
|
267
|
+
"bytes": byte_length,
|
|
268
|
+
"truncated": False,
|
|
269
|
+
"error": "Response body exceeded the capture limit.",
|
|
270
|
+
}
|
|
271
|
+
if _is_textual_mime(mime_type):
|
|
272
|
+
if byte_length <= INLINE_TEXT_MAX_BYTES:
|
|
273
|
+
return {
|
|
274
|
+
"kind": "text",
|
|
275
|
+
"text": body_bytes.decode("utf-8", errors="replace"),
|
|
276
|
+
"bytes": byte_length,
|
|
277
|
+
"truncated": False,
|
|
278
|
+
}
|
|
279
|
+
output_path = _write_body_artifact(
|
|
280
|
+
page_id=page_id, request_id=request_id, url=url, mime_type=mime_type, content=body_bytes
|
|
281
|
+
)
|
|
282
|
+
return {
|
|
283
|
+
"kind": "path",
|
|
284
|
+
"path": str(output_path),
|
|
285
|
+
"bytes": byte_length,
|
|
286
|
+
"truncated": False,
|
|
287
|
+
}
|
|
288
|
+
if byte_length <= INLINE_BINARY_MAX_BYTES:
|
|
289
|
+
return {
|
|
290
|
+
"kind": "base64",
|
|
291
|
+
"base64": base64.b64encode(body_bytes).decode("ascii"),
|
|
292
|
+
"bytes": byte_length,
|
|
293
|
+
"truncated": False,
|
|
294
|
+
}
|
|
295
|
+
output_path = _write_body_artifact(
|
|
296
|
+
page_id=page_id, request_id=request_id, url=url, mime_type=mime_type, content=body_bytes
|
|
297
|
+
)
|
|
298
|
+
return {
|
|
299
|
+
"kind": "path",
|
|
300
|
+
"path": str(output_path),
|
|
301
|
+
"bytes": byte_length,
|
|
302
|
+
"truncated": False,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _build_record(
|
|
307
|
+
*,
|
|
308
|
+
pending: _PendingRequest,
|
|
309
|
+
ended_at: float,
|
|
310
|
+
failed: bool,
|
|
311
|
+
failure_reason: str | None,
|
|
312
|
+
body: dict[str, Any],
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
status = int(pending.status or 0)
|
|
315
|
+
ok = bool(pending.ok) if pending.ok is not None else (200 <= status < 400)
|
|
316
|
+
return {
|
|
317
|
+
"request_id": pending.request_id,
|
|
318
|
+
"url": pending.url,
|
|
319
|
+
"method": pending.method,
|
|
320
|
+
"resource_type": pending.resource_type,
|
|
321
|
+
"status": status,
|
|
322
|
+
"ok": ok and not failed,
|
|
323
|
+
"request_headers": dict(pending.request_headers),
|
|
324
|
+
"request_post_data": pending.request_post_data,
|
|
325
|
+
"response_headers": dict(pending.response_headers),
|
|
326
|
+
"mime_type": pending.mime_type,
|
|
327
|
+
"started_at": pending.started_at,
|
|
328
|
+
"ended_at": ended_at,
|
|
329
|
+
"duration_ms": max((ended_at - pending.started_at) * 1000.0, 0.0),
|
|
330
|
+
"failed": failed,
|
|
331
|
+
"failure_reason": failure_reason,
|
|
332
|
+
"body": body,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _extract_mime_type(headers: dict[str, str]) -> str:
|
|
337
|
+
content_type = ""
|
|
338
|
+
for key, value in headers.items():
|
|
339
|
+
if key.lower() == "content-type":
|
|
340
|
+
content_type = str(value or "")
|
|
341
|
+
break
|
|
342
|
+
return content_type.split(";", 1)[0].strip().lower()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_content_length(headers: dict[str, str]) -> int | None:
|
|
346
|
+
for key, value in headers.items():
|
|
347
|
+
if key.lower() != "content-length":
|
|
348
|
+
continue
|
|
349
|
+
try:
|
|
350
|
+
return int(value)
|
|
351
|
+
except (TypeError, ValueError):
|
|
352
|
+
return None
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _is_textual_mime(mime_type: str) -> bool:
|
|
357
|
+
normalized = str(mime_type or "").lower()
|
|
358
|
+
if not normalized:
|
|
359
|
+
return False
|
|
360
|
+
if normalized.startswith("text/"):
|
|
361
|
+
return True
|
|
362
|
+
return any(
|
|
363
|
+
token in normalized
|
|
364
|
+
for token in ("json", "xml", "javascript", "ecmascript", "svg", "x-www-form-urlencoded")
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _write_body_artifact(
|
|
369
|
+
*,
|
|
370
|
+
page_id: str,
|
|
371
|
+
request_id: str,
|
|
372
|
+
url: str,
|
|
373
|
+
mime_type: str,
|
|
374
|
+
content: bytes,
|
|
375
|
+
) -> Path:
|
|
376
|
+
artifacts_dir = get_app_paths().artifacts_dir / "network-bodies" / page_id
|
|
377
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
suffix = _choose_body_suffix(url=url, mime_type=mime_type)
|
|
379
|
+
output_path = artifacts_dir / f"{request_id}{suffix}"
|
|
380
|
+
output_path.write_bytes(content)
|
|
381
|
+
return output_path
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _choose_body_suffix(*, url: str, mime_type: str) -> str:
|
|
385
|
+
parsed = urllib.parse.urlparse(url)
|
|
386
|
+
suffix = Path(parsed.path).suffix
|
|
387
|
+
if suffix:
|
|
388
|
+
return suffix
|
|
389
|
+
guessed = mimetypes.guess_extension(mime_type or "")
|
|
390
|
+
if guessed:
|
|
391
|
+
return guessed
|
|
392
|
+
return ".bin"
|