brightdata-hotdata-bridge 0.1.1__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.
@@ -0,0 +1,3 @@
1
+ """Package version, kept in one place for the build backend and runtime."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,105 @@
1
+ """Collect data with Bright Data scrapers and publish it into Hotdata tables.
2
+
3
+ Quick start::
4
+
5
+ from brightdata_hotdata_bridge import push_snapshot_to_hotdata
6
+
7
+ result = push_snapshot_to_hotdata(
8
+ dataset_id="gd_l7q7dkf244hwjntr0",
9
+ queries=["https://www.amazon.com/dp/B0CRMZHDG8"],
10
+ table_name="amazon_products",
11
+ )
12
+ print(result.table_row_count)
13
+
14
+ Async code should use :func:`arun_pipeline` with a :class:`PushRequest` instead.
15
+ """
16
+
17
+ from brightdata_hotdata_bridge.__about__ import __version__
18
+ from brightdata_hotdata_bridge.catalog import (
19
+ get_scraper,
20
+ load_catalog,
21
+ resolve_method,
22
+ search_scrapers,
23
+ )
24
+ from brightdata_hotdata_bridge.config import Settings, get_settings
25
+ from brightdata_hotdata_bridge.errors import (
26
+ BridgeError,
27
+ BrightDataApiError,
28
+ CatalogError,
29
+ CollectionFailedError,
30
+ ConfigurationError,
31
+ EmptySnapshotError,
32
+ HotdataWriteError,
33
+ InputValidationError,
34
+ RateLimitedError,
35
+ RunStateError,
36
+ SchemaMismatchError,
37
+ SnapshotTimeoutError,
38
+ UnknownDatasetError,
39
+ UnsupportedMethodError,
40
+ )
41
+ from brightdata_hotdata_bridge.hotdata_client import find_database
42
+ from brightdata_hotdata_bridge.models import (
43
+ CollectionRequest,
44
+ HotdataDatabase,
45
+ PushRequest,
46
+ PushResult,
47
+ TableTarget,
48
+ ValidationOptions,
49
+ ValidationReport,
50
+ )
51
+ from brightdata_hotdata_bridge.pipeline import (
52
+ RunStatus,
53
+ aget_status,
54
+ areplay_run,
55
+ arun_pipeline,
56
+ astart_collection,
57
+ build_collection_request,
58
+ push_snapshot_to_hotdata,
59
+ replay_run,
60
+ )
61
+ from brightdata_hotdata_bridge.state import RunRecord, RunStage, list_runs, load_run
62
+
63
+ __all__ = [
64
+ "BridgeError",
65
+ "BrightDataApiError",
66
+ "CatalogError",
67
+ "CollectionFailedError",
68
+ "CollectionRequest",
69
+ "ConfigurationError",
70
+ "EmptySnapshotError",
71
+ "HotdataDatabase",
72
+ "HotdataWriteError",
73
+ "InputValidationError",
74
+ "PushRequest",
75
+ "PushResult",
76
+ "RateLimitedError",
77
+ "RunRecord",
78
+ "RunStage",
79
+ "RunStateError",
80
+ "RunStatus",
81
+ "SchemaMismatchError",
82
+ "Settings",
83
+ "SnapshotTimeoutError",
84
+ "TableTarget",
85
+ "UnknownDatasetError",
86
+ "UnsupportedMethodError",
87
+ "ValidationOptions",
88
+ "ValidationReport",
89
+ "__version__",
90
+ "aget_status",
91
+ "areplay_run",
92
+ "arun_pipeline",
93
+ "astart_collection",
94
+ "build_collection_request",
95
+ "find_database",
96
+ "get_scraper",
97
+ "get_settings",
98
+ "list_runs",
99
+ "load_catalog",
100
+ "load_run",
101
+ "push_snapshot_to_hotdata",
102
+ "replay_run",
103
+ "resolve_method",
104
+ "search_scrapers",
105
+ ]
@@ -0,0 +1,371 @@
1
+ """Async client for the Bright Data Scraper API (``/datasets/v3``).
2
+
3
+ Three calls drive a collection: ``trigger`` starts it and returns a ``snapshot_id``,
4
+ ``progress`` reports its status, and ``snapshot`` downloads the results once ready.
5
+
6
+ Rate limiting is handled conservatively. Bright Data blacklists an IP after 25
7
+ ``429`` responses in five minutes, so every ``429`` waits for ``Retry-After`` (or 2, 4,
8
+ 8, 16 then 32 seconds) before one retry. ``trigger`` is not idempotent, so it is
9
+ only retried when the request provably never reached the server.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import time
17
+ from collections.abc import Awaitable, Callable, Sequence
18
+ from http import HTTPStatus
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from brightdata_hotdata_bridge.__about__ import __version__
25
+ from brightdata_hotdata_bridge.config import Settings, require_setting
26
+ from brightdata_hotdata_bridge.errors import (
27
+ BrightDataApiError,
28
+ CollectionFailedError,
29
+ RateLimitedError,
30
+ SnapshotTimeoutError,
31
+ )
32
+ from brightdata_hotdata_bridge.models import (
33
+ CollectionMethod,
34
+ SnapshotProgress,
35
+ validate_snapshot_id,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ BACKOFF_SECONDS: tuple[float, ...] = (2, 4, 8, 16, 32)
41
+ MAX_RETRY_AFTER_SECONDS = 120.0
42
+ MAX_ERROR_BODY_CHARS = 2000
43
+ DOWNLOAD_CHUNK_BYTES = 1024 * 1024
44
+ SNAPSHOT_FORMAT = "ndjson"
45
+
46
+ ProgressCallback = Callable[[SnapshotProgress], Awaitable[None] | None]
47
+ Sleep = Callable[[float], Awaitable[None]]
48
+
49
+
50
+ def create_brightdata_client(settings: Settings) -> httpx.AsyncClient:
51
+ """Create an HTTP client authenticated against the Bright Data API.
52
+
53
+ Args:
54
+ settings: Supplies the API key, base URL and timeout.
55
+
56
+ Returns:
57
+ A client the caller must close (use it as an ``async with`` context manager).
58
+
59
+ Raises:
60
+ ConfigurationError: ``BRIGHTDATA_API_KEY`` is not set.
61
+ """
62
+ api_key = require_setting(settings.brightdata_api_key, "BRIGHTDATA_API_KEY")
63
+ return httpx.AsyncClient(
64
+ base_url=settings.brightdata_base_url,
65
+ headers={
66
+ "Authorization": f"Bearer {api_key.get_secret_value()}",
67
+ "User-Agent": f"brightdata-hotdata-bridge/{__version__}",
68
+ },
69
+ timeout=httpx.Timeout(settings.http_timeout_seconds),
70
+ follow_redirects=True,
71
+ )
72
+
73
+
74
+ async def trigger_collection(
75
+ client: httpx.AsyncClient,
76
+ *,
77
+ dataset_id: str,
78
+ method: CollectionMethod,
79
+ inputs: Sequence[dict[str, Any]],
80
+ limit_per_input: int | None = None,
81
+ sleep: Sleep = asyncio.sleep,
82
+ ) -> str:
83
+ """Start an asynchronous collection and return its snapshot id.
84
+
85
+ Args:
86
+ client: Client from :func:`create_brightdata_client`.
87
+ dataset_id: Scraper to run.
88
+ method: Collection method; discovery methods add ``type=discover_new`` and
89
+ ``discover_by=<suffix>`` to the request.
90
+ inputs: Validated input objects, one per URL or query.
91
+ limit_per_input: Cap on records per input for discovery methods.
92
+ sleep: Awaitable sleep, injectable for tests.
93
+
94
+ Returns:
95
+ The ``snapshot_id`` identifying the collection.
96
+
97
+ Raises:
98
+ BrightDataApiError: Bright Data rejected the request or returned no snapshot id.
99
+ RateLimitedError: Still rate limited after every backoff step.
100
+
101
+ Example:
102
+ ``POST /datasets/v3/trigger?dataset_id=gd_...&include_errors=true``
103
+ with body ``{"input": [{"url": "https://..."}]}``.
104
+ """
105
+ params: dict[str, str] = {"dataset_id": dataset_id, "include_errors": "true"}
106
+ if method.discover_by is not None:
107
+ params |= {"type": "discover_new", "discover_by": method.discover_by}
108
+ body: dict[str, Any] = {"input": list(inputs)}
109
+ if limit_per_input is not None:
110
+ body["limit_per_input"] = limit_per_input
111
+
112
+ response = await _send(
113
+ client,
114
+ "POST",
115
+ "/datasets/v3/trigger",
116
+ idempotent=False,
117
+ sleep=sleep,
118
+ params=params,
119
+ json=body,
120
+ )
121
+ if response.status_code != HTTPStatus.OK:
122
+ raise _api_error("Bright Data rejected the collection request", response)
123
+
124
+ snapshot_id = _json(response).get("snapshot_id")
125
+ if not isinstance(snapshot_id, str):
126
+ raise _api_error("Bright Data did not return a snapshot_id", response)
127
+ validate_snapshot_id(snapshot_id)
128
+ logger.info("Collection triggered", extra={"snapshot_id": snapshot_id, "rows": len(inputs)})
129
+ return snapshot_id
130
+
131
+
132
+ async def get_progress(
133
+ client: httpx.AsyncClient, snapshot_id: str, *, sleep: Sleep = asyncio.sleep
134
+ ) -> SnapshotProgress:
135
+ """Fetch the current status of a snapshot.
136
+
137
+ Args:
138
+ client: Client from :func:`create_brightdata_client`.
139
+ snapshot_id: Snapshot to inspect.
140
+ sleep: Awaitable sleep, injectable for tests.
141
+
142
+ Returns:
143
+ The snapshot status: ``starting``, ``running``, ``ready``, ``failed`` or ``canceled``.
144
+
145
+ Raises:
146
+ BrightDataApiError: The status could not be retrieved.
147
+ RateLimitedError: Still rate limited after every backoff step.
148
+ """
149
+ response = await _send(
150
+ client,
151
+ "GET",
152
+ f"/datasets/v3/progress/{validate_snapshot_id(snapshot_id)}",
153
+ idempotent=True,
154
+ sleep=sleep,
155
+ )
156
+ if response.status_code != HTTPStatus.OK:
157
+ raise _api_error("Could not read snapshot progress", response, snapshot_id=snapshot_id)
158
+ return SnapshotProgress.model_validate({**_json(response), "snapshot_id": snapshot_id})
159
+
160
+
161
+ async def wait_for_snapshot(
162
+ client: httpx.AsyncClient,
163
+ snapshot_id: str,
164
+ *,
165
+ poll_interval: float,
166
+ timeout: float,
167
+ on_progress: ProgressCallback | None = None,
168
+ sleep: Sleep = asyncio.sleep,
169
+ clock: Callable[[], float] = time.monotonic,
170
+ ) -> SnapshotProgress:
171
+ """Poll a snapshot until it is ready, failed, or the timeout expires.
172
+
173
+ Args:
174
+ client: Client from :func:`create_brightdata_client`.
175
+ snapshot_id: Snapshot to wait for.
176
+ poll_interval: Seconds between status checks.
177
+ timeout: Maximum seconds to wait.
178
+ on_progress: Called with every status update (sync or async).
179
+ sleep: Awaitable sleep, injectable for tests.
180
+ clock: Monotonic clock, injectable for tests.
181
+
182
+ Returns:
183
+ The final ``ready`` progress.
184
+
185
+ Raises:
186
+ CollectionFailedError: Bright Data reports ``failed`` or ``canceled``. No data
187
+ should be written for this snapshot.
188
+ SnapshotTimeoutError: Still not ready after ``timeout`` seconds. Polling can be
189
+ resumed later with the same snapshot id.
190
+ BrightDataApiError: Status checks keep failing.
191
+ """
192
+ deadline = clock() + timeout
193
+ while True:
194
+ progress = await get_progress(client, snapshot_id, sleep=sleep)
195
+ if on_progress is not None:
196
+ outcome = on_progress(progress)
197
+ if outcome is not None:
198
+ await outcome
199
+ if progress.is_ready:
200
+ return progress
201
+ if progress.is_failed:
202
+ detail = f": {progress.error_message}" if progress.error_message else ""
203
+ raise CollectionFailedError(
204
+ f"Bright Data collection ended as {progress.status}{detail}",
205
+ snapshot_id=snapshot_id,
206
+ status=progress.status,
207
+ )
208
+ if clock() + poll_interval > deadline:
209
+ raise SnapshotTimeoutError(
210
+ f"Snapshot not ready after {timeout:.0f}s (last status: {progress.status}). "
211
+ "Run `bdh push --snapshot-id` again later to resume.",
212
+ snapshot_id=snapshot_id,
213
+ )
214
+ await sleep(poll_interval)
215
+
216
+
217
+ async def download_snapshot(
218
+ client: httpx.AsyncClient,
219
+ snapshot_id: str,
220
+ destination: Path,
221
+ *,
222
+ poll_interval: float,
223
+ timeout: float,
224
+ sleep: Sleep = asyncio.sleep,
225
+ clock: Callable[[], float] = time.monotonic,
226
+ ) -> Path:
227
+ """Stream a ready snapshot to ``destination`` as NDJSON without buffering it in memory.
228
+
229
+ The file is written to a temporary name and renamed on success, so a partial
230
+ download never looks complete. A ``202`` (snapshot still being built) is retried
231
+ until ``timeout``.
232
+
233
+ Args:
234
+ client: Client from :func:`create_brightdata_client`.
235
+ snapshot_id: Ready snapshot to download.
236
+ destination: Final file path.
237
+ poll_interval: Seconds between retries while the snapshot is still building.
238
+ timeout: Maximum seconds to wait for the snapshot to become downloadable.
239
+ sleep: Awaitable sleep, injectable for tests.
240
+ clock: Monotonic clock, injectable for tests.
241
+
242
+ Returns:
243
+ ``destination``.
244
+
245
+ Raises:
246
+ BrightDataApiError: The download failed.
247
+ SnapshotTimeoutError: The snapshot stayed in the building state too long.
248
+ RateLimitedError: Still rate limited after every backoff step.
249
+ """
250
+ url = f"/datasets/v3/snapshot/{validate_snapshot_id(snapshot_id)}"
251
+ destination.parent.mkdir(parents=True, exist_ok=True)
252
+ temp_path = destination.with_suffix(destination.suffix + ".part")
253
+ deadline = clock() + timeout
254
+ backoff = iter(BACKOFF_SECONDS)
255
+
256
+ while True:
257
+ try:
258
+ async with client.stream("GET", url, params={"format": SNAPSHOT_FORMAT}) as response:
259
+ if response.status_code == HTTPStatus.OK:
260
+ await _stream_to_file(response, temp_path)
261
+ temp_path.replace(destination)
262
+ logger.info("Snapshot downloaded", extra={"snapshot_id": snapshot_id})
263
+ return destination
264
+ await response.aread()
265
+ except httpx.TransportError as exc:
266
+ delay = next(backoff, None)
267
+ if delay is None:
268
+ raise BrightDataApiError(
269
+ f"Snapshot download failed: {exc}", snapshot_id=snapshot_id
270
+ ) from exc
271
+ logger.warning("Download interrupted (%s); retrying in %ss", exc, delay)
272
+ await sleep(delay)
273
+ continue
274
+
275
+ if response.status_code == HTTPStatus.ACCEPTED:
276
+ if clock() + poll_interval > deadline:
277
+ raise SnapshotTimeoutError(
278
+ "Snapshot is still being built; try again later", snapshot_id=snapshot_id
279
+ )
280
+ await sleep(poll_interval)
281
+ continue
282
+ if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
283
+ await sleep(_rate_limit_delay(response, next(backoff, None), snapshot_id))
284
+ continue
285
+ raise _api_error("Snapshot download failed", response, snapshot_id=snapshot_id)
286
+
287
+
288
+ async def _stream_to_file(response: httpx.Response, path: Path) -> None:
289
+ with path.open("wb") as handle:
290
+ async for chunk in response.aiter_bytes(DOWNLOAD_CHUNK_BYTES):
291
+ handle.write(chunk)
292
+
293
+
294
+ async def _send(
295
+ client: httpx.AsyncClient,
296
+ method: str,
297
+ url: str,
298
+ *,
299
+ idempotent: bool,
300
+ sleep: Sleep,
301
+ **kwargs: Any,
302
+ ) -> httpx.Response:
303
+ backoff = iter(BACKOFF_SECONDS)
304
+ while True:
305
+ try:
306
+ response = await client.request(method, url, **kwargs)
307
+ except httpx.TransportError as exc:
308
+ never_sent = isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
309
+ delay = next(backoff, None)
310
+ if not (idempotent or never_sent) or delay is None:
311
+ raise BrightDataApiError(f"Could not reach Bright Data: {exc}") from exc
312
+ logger.warning("Bright Data unreachable (%s); retrying in %ss", exc, delay)
313
+ await sleep(delay)
314
+ continue
315
+
316
+ if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
317
+ await sleep(_rate_limit_delay(response, next(backoff, None), None))
318
+ continue
319
+ if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR and idempotent:
320
+ delay = next(backoff, None)
321
+ if delay is not None:
322
+ logger.warning(
323
+ "Bright Data returned %s; retrying in %ss", response.status_code, delay
324
+ )
325
+ await sleep(delay)
326
+ continue
327
+ return response
328
+
329
+
330
+ def _rate_limit_delay(
331
+ response: httpx.Response, fallback: float | None, snapshot_id: str | None
332
+ ) -> float:
333
+ if fallback is None:
334
+ raise RateLimitedError(
335
+ "Bright Data is still rate limiting after repeated backoff; slow down and retry later",
336
+ status_code=response.status_code,
337
+ snapshot_id=snapshot_id,
338
+ )
339
+ retry_after = _parse_retry_after(response.headers.get("Retry-After"))
340
+ delay = min(retry_after, MAX_RETRY_AFTER_SECONDS) if retry_after is not None else fallback
341
+ logger.warning("Bright Data rate limit hit; waiting %ss", delay)
342
+ return delay
343
+
344
+
345
+ def _parse_retry_after(value: str | None) -> float | None:
346
+ if value is None:
347
+ return None
348
+ try:
349
+ return max(float(value), 0.0)
350
+ except ValueError:
351
+ return None
352
+
353
+
354
+ def _json(response: httpx.Response) -> dict[str, Any]:
355
+ try:
356
+ payload = response.json()
357
+ except ValueError:
358
+ return {}
359
+ return payload if isinstance(payload, dict) else {}
360
+
361
+
362
+ def _api_error(
363
+ message: str, response: httpx.Response, *, snapshot_id: str | None = None
364
+ ) -> BrightDataApiError:
365
+ body = response.text[:MAX_ERROR_BODY_CHARS]
366
+ return BrightDataApiError(
367
+ f"{message} (HTTP {response.status_code}): {body}",
368
+ status_code=response.status_code,
369
+ body=body,
370
+ snapshot_id=snapshot_id,
371
+ )