scrapeit-sdk 0.1.0__tar.gz

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,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .pytest_cache/
4
+ .venv/
5
+ build/
6
+ dist/
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: scrapeit-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the ScrapeIt Web Scraping API and product APIs.
5
+ Project-URL: Documentation, https://docs.scrapeit.io/guide/sdks
6
+ License-Expression: MIT
7
+ Keywords: amazon,google-maps,google-serp,scrapeit,scraping-api,web-scraping
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: httpx<1,>=0.27
13
+ Provides-Extra: dev
14
+ Requires-Dist: datamodel-code-generator==0.83.0; extra == 'dev'
15
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # ScrapeIt Python SDK
19
+
20
+ Official Python client for the [ScrapeIt](https://scrapeit.io) Web Scraping API and product APIs
21
+ (Google SERP, Google Maps, Amazon, Website Crawler and more). Sync and async clients on `httpx`,
22
+ request types generated from the ScrapeIt OpenAPI document.
23
+
24
+ ```bash
25
+ pip install scrapeit-sdk
26
+ ```
27
+
28
+ ```python
29
+ from scrapeit import Scrapeit, ScrapeitError
30
+
31
+ client = Scrapeit() # reads SCRAPEIT_API_KEY, or pass Scrapeit("YOUR_API_KEY")
32
+
33
+ page = client.scrape(url="https://example.com", jsRendering=True)
34
+ print(page["data"])
35
+
36
+ results = client.google_search(q="coffee near me", gl="us")
37
+ ```
38
+
39
+ ```python
40
+ import asyncio
41
+ from scrapeit import AsyncScrapeit
42
+
43
+ async def main() -> None:
44
+ async with AsyncScrapeit() as client:
45
+ job = await client.scrape_async(url="https://example.com")
46
+ done = await client.wait_for_async_scrape(job["data"]["id"])
47
+ print(done["data"]["status"])
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ Every method returns the API envelope `{"data": ..., "meta": ...}` and raises `ScrapeitError` with
53
+ the stable API error `code` (for example `INSUFFICIENT_CREDITS`). 429, 500 and 503 responses are
54
+ retried automatically with `Retry-After`.
55
+
56
+ Documentation: <https://docs.scrapeit.io/guide/sdks>
@@ -0,0 +1,39 @@
1
+ # ScrapeIt Python SDK
2
+
3
+ Official Python client for the [ScrapeIt](https://scrapeit.io) Web Scraping API and product APIs
4
+ (Google SERP, Google Maps, Amazon, Website Crawler and more). Sync and async clients on `httpx`,
5
+ request types generated from the ScrapeIt OpenAPI document.
6
+
7
+ ```bash
8
+ pip install scrapeit-sdk
9
+ ```
10
+
11
+ ```python
12
+ from scrapeit import Scrapeit, ScrapeitError
13
+
14
+ client = Scrapeit() # reads SCRAPEIT_API_KEY, or pass Scrapeit("YOUR_API_KEY")
15
+
16
+ page = client.scrape(url="https://example.com", jsRendering=True)
17
+ print(page["data"])
18
+
19
+ results = client.google_search(q="coffee near me", gl="us")
20
+ ```
21
+
22
+ ```python
23
+ import asyncio
24
+ from scrapeit import AsyncScrapeit
25
+
26
+ async def main() -> None:
27
+ async with AsyncScrapeit() as client:
28
+ job = await client.scrape_async(url="https://example.com")
29
+ done = await client.wait_for_async_scrape(job["data"]["id"])
30
+ print(done["data"]["status"])
31
+
32
+ asyncio.run(main())
33
+ ```
34
+
35
+ Every method returns the API envelope `{"data": ..., "meta": ...}` and raises `ScrapeitError` with
36
+ the stable API error `code` (for example `INSUFFICIENT_CREDITS`). 429, 500 and 503 responses are
37
+ retried automatically with `Retry-After`.
38
+
39
+ Documentation: <https://docs.scrapeit.io/guide/sdks>
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "scrapeit-sdk"
7
+ dynamic = ["version"]
8
+ description = "Official Python SDK for the ScrapeIt Web Scraping API and product APIs."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = ["httpx>=0.27,<1"]
13
+ keywords = ["scrapeit", "web-scraping", "scraping-api", "google-serp", "google-maps", "amazon"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Typing :: Typed",
17
+ "Intended Audience :: Developers",
18
+ ]
19
+
20
+ [project.urls]
21
+ Documentation = "https://docs.scrapeit.io/guide/sdks"
22
+
23
+ [project.optional-dependencies]
24
+ # Pinned: the generator's output is committed and compared byte for byte by
25
+ # tests/test_generated_models.py, so a different generator version is a different file.
26
+ dev = ["datamodel-code-generator==0.83.0", "pytest==9.1.1"]
27
+
28
+ [tool.hatch.version]
29
+ path = "src/scrapeit/__init__.py"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/scrapeit"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+
37
+ # Read by `python -m datamodel_code_generator` run from this directory (pnpm sdk:generate and the
38
+ # drift guard). The input is the SDK subset written by sdks/scripts/generate-sdks.mjs.
39
+ [tool.datamodel-codegen]
40
+ input = "../openapi.sdk.json"
41
+ input-file-type = "openapi"
42
+ output = "src/scrapeit/_generated/models.py"
43
+ output-model-type = "typing.TypedDict"
44
+ openapi-scopes = ["schemas", "paths", "parameters"]
45
+ target-python-version = "3.11"
46
+ # Builtin formatting keeps the output independent of black/isort versions installed alongside.
47
+ formatters = ["builtin"]
48
+ disable-timestamp = true
49
+ use-schema-description = true
50
+ use-field-description = true
51
+ enum-field-as-literal = "all"
52
+ custom-file-header = "# GENERATED FILE — do not edit by hand.\n# Source: sdks/openapi.sdk.json (subset of docs/api/openapi.yml). Regenerate: pnpm sdk:generate"
@@ -0,0 +1,24 @@
1
+ """Official Python SDK for the ScrapeIt Web Scraping API and product APIs."""
2
+
3
+ from ._client import (
4
+ API_KEY_ENVIRONMENT_VARIABLE,
5
+ DEFAULT_BASE_URL,
6
+ AsyncScrapeit,
7
+ Envelope,
8
+ Scrapeit,
9
+ )
10
+ from ._errors import ScrapeitError, SdkErrorCode
11
+ from ._generated import models
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "API_KEY_ENVIRONMENT_VARIABLE",
17
+ "DEFAULT_BASE_URL",
18
+ "AsyncScrapeit",
19
+ "Envelope",
20
+ "Scrapeit",
21
+ "ScrapeitError",
22
+ "SdkErrorCode",
23
+ "models",
24
+ ]
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+ from datetime import datetime, timezone
9
+ from email.utils import parsedate_to_datetime
10
+ from types import TracebackType
11
+ from typing import Any, Generic, Literal, TypeVar, Unpack
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+
16
+ from ._errors import ScrapeitError, SdkErrorCode
17
+ from ._generated import models
18
+
19
+ DEFAULT_BASE_URL = "https://api.scrapeit.io"
20
+ API_KEY_ENVIRONMENT_VARIABLE = "SCRAPEIT_API_KEY"
21
+
22
+ # Above the API's own ceiling for a synchronous scrape (`timeoutMs` up to 180 s), so the server, not
23
+ # the client, reports a slow target.
24
+ DEFAULT_TIMEOUT_SECONDS = 200.0
25
+ DEFAULT_MAX_RETRIES = 2
26
+ INITIAL_RETRY_DELAY_SECONDS = 1.0
27
+ # One API rate-limit window: a longer Retry-After is better surfaced to the caller than slept on.
28
+ MAX_RETRY_DELAY_SECONDS = 60.0
29
+ DEFAULT_POLL_INTERVAL_SECONDS = 2.0
30
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 600.0
31
+
32
+ # Statuses retried automatically: the platform did not run, or did not charge, the request. 429 is a
33
+ # rate limit or a busy concurrency slot (rejected before any work is queued), 503 a draining or
34
+ # unavailable API, 500 an internal failure (platform failures are never charged). 502 and 504 are
35
+ # NOT retried: a target block page after the last proxy attempt (502) and a timed-out scrape whose
36
+ # late result is still recorded (504) can be billable, and a blind resend would be charged again.
37
+ RETRYABLE_STATUSES = frozenset({429, 500, 503})
38
+ TERMINAL_JOB_STATUSES = frozenset({"finished", "failed", "stopped", "skipped"})
39
+
40
+ HttpMethod = Literal["GET", "POST", "DELETE"]
41
+ Envelope = dict[str, Any]
42
+ """The API success envelope ``{"data": ..., "meta": ...}``, as returned."""
43
+
44
+ _ResultT = TypeVar("_ResultT")
45
+
46
+
47
+ def _resolve_api_key(api_key: str | None) -> str:
48
+ resolved = api_key if api_key is not None else os.environ.get(API_KEY_ENVIRONMENT_VARIABLE)
49
+
50
+ if not resolved:
51
+ raise ScrapeitError(
52
+ code=SdkErrorCode.MISSING_API_KEY,
53
+ message=f"Pass api_key or set the {API_KEY_ENVIRONMENT_VARIABLE} environment variable.",
54
+ )
55
+
56
+ return resolved
57
+
58
+
59
+ def _path_segment(value: str) -> str:
60
+ return quote(value, safe="")
61
+
62
+
63
+ def _query(params: Mapping[str, Any] | None) -> dict[str, Any]:
64
+ # Lists go as repeated keys, which the API reads as a list (it also accepts a,b,c).
65
+ return {name: value for name, value in (params or {}).items() if value is not None}
66
+
67
+
68
+ def retry_delay_seconds(retry_after: str | None, attempt: int) -> float:
69
+ """Retry-After (seconds or an HTTP date) when the API sends it, else exponential backoff."""
70
+ backoff = min(INITIAL_RETRY_DELAY_SECONDS * 2.0**attempt, MAX_RETRY_DELAY_SECONDS)
71
+
72
+ if retry_after is None:
73
+ return backoff
74
+
75
+ try:
76
+ delay = float(retry_after)
77
+ except ValueError:
78
+ try:
79
+ delay = (parsedate_to_datetime(retry_after) - datetime.now(timezone.utc)).total_seconds()
80
+ except (TypeError, ValueError):
81
+ return backoff
82
+
83
+ return min(max(delay, 0.0), MAX_RETRY_DELAY_SECONDS)
84
+
85
+
86
+ def _parse_success(response: httpx.Response) -> Envelope:
87
+ try:
88
+ payload = response.json()
89
+ except ValueError:
90
+ payload = None
91
+
92
+ if not isinstance(payload, dict) or "data" not in payload:
93
+ raise ScrapeitError(
94
+ code=SdkErrorCode.INVALID_RESPONSE,
95
+ message="The API response is not a { data, meta } JSON envelope.",
96
+ status=response.status_code,
97
+ )
98
+
99
+ return payload
100
+
101
+
102
+ def _api_error(response: httpx.Response) -> ScrapeitError:
103
+ try:
104
+ payload = response.json()
105
+ except ValueError:
106
+ payload = None
107
+
108
+ error = payload.get("error") if isinstance(payload, dict) else None
109
+ error = error if isinstance(error, dict) else {}
110
+ code = error.get("code")
111
+ message = error.get("message")
112
+ correlation_id = error.get("correlation_id")
113
+
114
+ return ScrapeitError(
115
+ code=code if isinstance(code, str) else SdkErrorCode.INVALID_RESPONSE,
116
+ message=message
117
+ if isinstance(message, str)
118
+ else f"ScrapeIt API responded with HTTP {response.status_code}.",
119
+ status=response.status_code,
120
+ details=error.get("details"),
121
+ correlation_id=correlation_id if isinstance(correlation_id, str) else None,
122
+ )
123
+
124
+
125
+ def _transport_error(error: httpx.TransportError, timeout: float | None) -> ScrapeitError:
126
+ # Not retried: the request may have reached the API, and a resent scrape is charged again.
127
+ if isinstance(error, httpx.TimeoutException):
128
+ return ScrapeitError(
129
+ code=SdkErrorCode.REQUEST_TIMEOUT, message=f"No response within {timeout} s."
130
+ )
131
+
132
+ return ScrapeitError(
133
+ code=SdkErrorCode.NETWORK_ERROR, message="The request did not reach the ScrapeIt API."
134
+ )
135
+
136
+
137
+ def _job_status(job: Envelope) -> str:
138
+ data = job.get("data")
139
+ status = data.get("status") if isinstance(data, dict) else None
140
+
141
+ if not isinstance(status, str):
142
+ raise ScrapeitError(
143
+ code=SdkErrorCode.INVALID_RESPONSE,
144
+ message="The API response carries no job status.",
145
+ )
146
+
147
+ return status
148
+
149
+
150
+ def _wait_timeout_error(job: Envelope) -> ScrapeitError:
151
+ data = job["data"]
152
+
153
+ return ScrapeitError(
154
+ code=SdkErrorCode.WAIT_TIMEOUT,
155
+ message="The job did not reach a terminal status in time; it keeps running.",
156
+ details={"job_id": data.get("id"), "status": data.get("status")},
157
+ )
158
+
159
+
160
+ class _Methods(ABC, Generic[_ResultT]):
161
+ """The API surface, written once for both clients: ``_ResultT`` is ``Envelope`` for
162
+ :class:`Scrapeit` and ``Awaitable[Envelope]`` for :class:`AsyncScrapeit`."""
163
+
164
+ def __init__(self) -> None:
165
+ self.google_maps = _GoogleMaps(self)
166
+ self.amazon = _Amazon(self)
167
+ self.crawler = _Crawler(self)
168
+ self.jobs = _Jobs(self)
169
+
170
+ @abstractmethod
171
+ def request(
172
+ self,
173
+ method: HttpMethod,
174
+ path: str,
175
+ *,
176
+ params: Mapping[str, Any] | None = None,
177
+ json: Any = None,
178
+ ) -> _ResultT:
179
+ """Any endpoint of the API (Zillow, Allegro, Apartments.com, batch scraping…), with the same
180
+ authentication, retries and error handling as the named methods."""
181
+
182
+ @abstractmethod
183
+ def _wait_for_terminal_status(
184
+ self, read_job: Callable[[], _ResultT], poll_interval: float, timeout: float
185
+ ) -> _ResultT: ...
186
+
187
+ def scrape(self, **params: Unpack[models.ScrapeWebDto]) -> _ResultT:
188
+ """Synchronous Web Scraping API request: returns when the page is scraped."""
189
+ return self.request("POST", "/api/v1/scrape/web", json=params)
190
+
191
+ def scrape_async(self, **params: Unpack[models.CreateAsyncWebScrapeDto]) -> _ResultT:
192
+ """Starts a background Web Scraping API request (optionally with ``webhookUrl``) and returns
193
+ the job at once; follow it with :meth:`wait_for_async_scrape` or the webhook."""
194
+ return self.request("POST", "/api/v1/scrape/web/async", json=params)
195
+
196
+ def get_async_scrape(self, job_id: str) -> _ResultT:
197
+ return self.request("GET", f"/api/v1/scrape/web/async/{_path_segment(job_id)}")
198
+
199
+ def wait_for_async_scrape(
200
+ self,
201
+ job_id: str,
202
+ *,
203
+ poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
204
+ timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS,
205
+ ) -> _ResultT:
206
+ """Polls an async scrape until it is finished, failed, stopped or skipped."""
207
+ return self._wait_for_terminal_status(
208
+ lambda: self.get_async_scrape(job_id), poll_interval, timeout
209
+ )
210
+
211
+ def google_search(
212
+ self, **params: Unpack[models.ApiV1ScrapeGoogleSearchGetParametersQuery]
213
+ ) -> _ResultT:
214
+ return self.request("GET", "/api/v1/scrape/google/search", params=params)
215
+
216
+ def usage(self) -> _ResultT:
217
+ """Credits, concurrency and usage of the organization the API key belongs to."""
218
+ return self.request("GET", "/api/v1/usage/me")
219
+
220
+
221
+ class _GoogleMaps(Generic[_ResultT]):
222
+ def __init__(self, client: _Methods[_ResultT]) -> None:
223
+ self._client = client
224
+
225
+ def search(
226
+ self, **params: Unpack[models.ApiV1ScrapeGoogleMapsSearchGetParametersQuery]
227
+ ) -> _ResultT:
228
+ return self._client.request("GET", "/api/v1/scrape/google/maps/search", params=params)
229
+
230
+ def place(
231
+ self, **params: Unpack[models.ApiV1ScrapeGoogleMapsPlaceGetParametersQuery]
232
+ ) -> _ResultT:
233
+ return self._client.request("GET", "/api/v1/scrape/google/maps/place", params=params)
234
+
235
+
236
+ class _Amazon(Generic[_ResultT]):
237
+ def __init__(self, client: _Methods[_ResultT]) -> None:
238
+ self._client = client
239
+
240
+ def search(self, **params: Unpack[models.ApiV1ScrapeAmazonSearchGetParametersQuery]) -> _ResultT:
241
+ return self._client.request("GET", "/api/v1/scrape/amazon/search", params=params)
242
+
243
+ def product(
244
+ self, **params: Unpack[models.ApiV1ScrapeAmazonProductGetParametersQuery]
245
+ ) -> _ResultT:
246
+ return self._client.request("GET", "/api/v1/scrape/amazon/product", params=params)
247
+
248
+
249
+ class _Crawler(Generic[_ResultT]):
250
+ def __init__(self, client: _Methods[_ResultT]) -> None:
251
+ self._client = client
252
+
253
+ def start(self, **params: Unpack[models.CreateCrawlerJobDto]) -> _ResultT:
254
+ return self._client.request("POST", "/api/v1/scrapers/crawler/jobs", json=params)
255
+
256
+ def status(self, job_id: str) -> _ResultT:
257
+ return self._client.request("GET", f"/api/v1/scrapers/jobs/{_path_segment(job_id)}")
258
+
259
+ def results(
260
+ self, job_id: str, **params: Unpack[models.ApiV1ScrapersJobsJobIdResultsGetParametersQuery]
261
+ ) -> _ResultT:
262
+ return self._client.request(
263
+ "GET", f"/api/v1/scrapers/jobs/{_path_segment(job_id)}/results", params=params
264
+ )
265
+
266
+ def wait(
267
+ self,
268
+ job_id: str,
269
+ *,
270
+ poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
271
+ timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS,
272
+ ) -> _ResultT:
273
+ return self._client._wait_for_terminal_status(
274
+ lambda: self.status(job_id), poll_interval, timeout
275
+ )
276
+
277
+
278
+ class _Jobs(Generic[_ResultT]):
279
+ def __init__(self, client: _Methods[_ResultT]) -> None:
280
+ self._client = client
281
+
282
+ def get(self, job_id: str) -> _ResultT:
283
+ return self._client.request("GET", f"/api/v1/jobs/{_path_segment(job_id)}")
284
+
285
+
286
+ class Scrapeit(_Methods[Envelope]):
287
+ """Synchronous ScrapeIt API client. Methods return the ``{"data", "meta"}`` envelope and raise
288
+ :class:`ScrapeitError` carrying the API error ``code``.
289
+
290
+ Pass ``http_client`` to reuse your own ``httpx.Client`` (proxies, transports, tests); ``timeout``
291
+ then comes from that client.
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ api_key: str | None = None,
297
+ *,
298
+ base_url: str = DEFAULT_BASE_URL,
299
+ max_retries: int = DEFAULT_MAX_RETRIES,
300
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
301
+ http_client: httpx.Client | None = None,
302
+ ) -> None:
303
+ super().__init__()
304
+ self._headers = {"x-api-key": _resolve_api_key(api_key), "accept": "application/json"}
305
+ self._base_url = base_url.rstrip("/")
306
+ self._max_retries = max_retries
307
+ self._timeout = timeout
308
+ self._http = http_client if http_client is not None else httpx.Client(timeout=timeout)
309
+
310
+ def request(
311
+ self,
312
+ method: HttpMethod,
313
+ path: str,
314
+ *,
315
+ params: Mapping[str, Any] | None = None,
316
+ json: Any = None,
317
+ ) -> Envelope:
318
+ attempt = 0
319
+
320
+ while True:
321
+ try:
322
+ response = self._http.request(
323
+ method,
324
+ f"{self._base_url}{path}",
325
+ params=_query(params),
326
+ json=json,
327
+ headers=self._headers,
328
+ )
329
+ except httpx.TransportError as error:
330
+ raise _transport_error(error, self._timeout) from error
331
+
332
+ if response.is_success:
333
+ return _parse_success(response)
334
+
335
+ if attempt >= self._max_retries or response.status_code not in RETRYABLE_STATUSES:
336
+ raise _api_error(response)
337
+
338
+ time.sleep(retry_delay_seconds(response.headers.get("retry-after"), attempt))
339
+ attempt += 1
340
+
341
+ def _wait_for_terminal_status(
342
+ self, read_job: Callable[[], Envelope], poll_interval: float, timeout: float
343
+ ) -> Envelope:
344
+ deadline = time.monotonic() + timeout
345
+
346
+ while True:
347
+ job = read_job()
348
+
349
+ if _job_status(job) in TERMINAL_JOB_STATUSES:
350
+ return job
351
+
352
+ if time.monotonic() + poll_interval > deadline:
353
+ raise _wait_timeout_error(job)
354
+
355
+ time.sleep(poll_interval)
356
+
357
+ def close(self) -> None:
358
+ self._http.close()
359
+
360
+ def __enter__(self) -> Scrapeit:
361
+ return self
362
+
363
+ def __exit__(
364
+ self,
365
+ exc_type: type[BaseException] | None,
366
+ exc: BaseException | None,
367
+ traceback: TracebackType | None,
368
+ ) -> None:
369
+ self.close()
370
+
371
+
372
+ class AsyncScrapeit(_Methods[Awaitable[Envelope]]):
373
+ """Asynchronous ScrapeIt API client (``await`` every method); same surface as :class:`Scrapeit`."""
374
+
375
+ def __init__(
376
+ self,
377
+ api_key: str | None = None,
378
+ *,
379
+ base_url: str = DEFAULT_BASE_URL,
380
+ max_retries: int = DEFAULT_MAX_RETRIES,
381
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
382
+ http_client: httpx.AsyncClient | None = None,
383
+ ) -> None:
384
+ super().__init__()
385
+ self._headers = {"x-api-key": _resolve_api_key(api_key), "accept": "application/json"}
386
+ self._base_url = base_url.rstrip("/")
387
+ self._max_retries = max_retries
388
+ self._timeout = timeout
389
+ self._http = http_client if http_client is not None else httpx.AsyncClient(timeout=timeout)
390
+
391
+ async def request(
392
+ self,
393
+ method: HttpMethod,
394
+ path: str,
395
+ *,
396
+ params: Mapping[str, Any] | None = None,
397
+ json: Any = None,
398
+ ) -> Envelope:
399
+ attempt = 0
400
+
401
+ while True:
402
+ try:
403
+ response = await self._http.request(
404
+ method,
405
+ f"{self._base_url}{path}",
406
+ params=_query(params),
407
+ json=json,
408
+ headers=self._headers,
409
+ )
410
+ except httpx.TransportError as error:
411
+ raise _transport_error(error, self._timeout) from error
412
+
413
+ if response.is_success:
414
+ return _parse_success(response)
415
+
416
+ if attempt >= self._max_retries or response.status_code not in RETRYABLE_STATUSES:
417
+ raise _api_error(response)
418
+
419
+ await asyncio.sleep(retry_delay_seconds(response.headers.get("retry-after"), attempt))
420
+ attempt += 1
421
+
422
+ async def _wait_for_terminal_status(
423
+ self, read_job: Callable[[], Awaitable[Envelope]], poll_interval: float, timeout: float
424
+ ) -> Envelope:
425
+ deadline = time.monotonic() + timeout
426
+
427
+ while True:
428
+ job = await read_job()
429
+
430
+ if _job_status(job) in TERMINAL_JOB_STATUSES:
431
+ return job
432
+
433
+ if time.monotonic() + poll_interval > deadline:
434
+ raise _wait_timeout_error(job)
435
+
436
+ await asyncio.sleep(poll_interval)
437
+
438
+ async def aclose(self) -> None:
439
+ await self._http.aclose()
440
+
441
+ async def __aenter__(self) -> AsyncScrapeit:
442
+ return self
443
+
444
+ async def __aexit__(
445
+ self,
446
+ exc_type: type[BaseException] | None,
447
+ exc: BaseException | None,
448
+ traceback: TracebackType | None,
449
+ ) -> None:
450
+ await self.aclose()
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SdkErrorCode:
7
+ """Codes the SDK raises itself, when there is no ``{ error }`` body from the API to take one from."""
8
+
9
+ MISSING_API_KEY = "MISSING_API_KEY"
10
+ """No API key was passed and ``SCRAPEIT_API_KEY`` is not set."""
11
+ NETWORK_ERROR = "NETWORK_ERROR"
12
+ """The request never got a response (DNS, connection refused, reset)."""
13
+ REQUEST_TIMEOUT = "REQUEST_TIMEOUT"
14
+ """No response arrived within the client ``timeout``."""
15
+ INVALID_RESPONSE = "INVALID_RESPONSE"
16
+ """The API answered with a body that is not the ``{ data, meta }`` / ``{ error }`` JSON envelope."""
17
+ WAIT_TIMEOUT = "WAIT_TIMEOUT"
18
+ """A ``wait…`` helper gave up before the job reached a terminal status."""
19
+
20
+
21
+ class ScrapeitError(Exception):
22
+ """Any failed ScrapeIt call.
23
+
24
+ ``code`` is the stable machine-readable code from the API ``{ error }`` envelope (for example
25
+ ``INSUFFICIENT_CREDITS``, ``CONCURRENCY_LIMIT_EXCEEDED``) or one of :class:`SdkErrorCode`; branch on
26
+ it, not on the message.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ code: str,
33
+ message: str,
34
+ status: int | None = None,
35
+ details: Any = None,
36
+ correlation_id: str | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.code = code
40
+ self.message = message
41
+ self.status = status
42
+ """HTTP status of the API response; ``None`` when no response arrived."""
43
+ self.details = details
44
+ self.correlation_id = correlation_id
45
+ """Quote it in support requests: it finds the request in ScrapeIt logs."""
46
+
47
+ def __repr__(self) -> str:
48
+ return f"ScrapeitError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
@@ -0,0 +1 @@
1
+ # GENERATED package: models.py is written by `pnpm sdk:generate` (datamodel-code-generator).