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.
- scrapeit_sdk-0.1.0/.gitignore +6 -0
- scrapeit_sdk-0.1.0/PKG-INFO +56 -0
- scrapeit_sdk-0.1.0/README.md +39 -0
- scrapeit_sdk-0.1.0/pyproject.toml +52 -0
- scrapeit_sdk-0.1.0/src/scrapeit/__init__.py +24 -0
- scrapeit_sdk-0.1.0/src/scrapeit/_client.py +450 -0
- scrapeit_sdk-0.1.0/src/scrapeit/_errors.py +48 -0
- scrapeit_sdk-0.1.0/src/scrapeit/_generated/__init__.py +1 -0
- scrapeit_sdk-0.1.0/src/scrapeit/_generated/models.py +1228 -0
- scrapeit_sdk-0.1.0/src/scrapeit/py.typed +0 -0
- scrapeit_sdk-0.1.0/tests/test_client.py +226 -0
- scrapeit_sdk-0.1.0/tests/test_generated_models.py +63 -0
|
@@ -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).
|