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.
Files changed (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. 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"