herascrape 0.1.0__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.
- herascrape/__init__.py +52 -0
- herascrape/_base.py +276 -0
- herascrape/_endpoints_async.py +602 -0
- herascrape/_endpoints_sync.py +576 -0
- herascrape/_errors.py +207 -0
- herascrape/_pagination.py +54 -0
- herascrape/async_client.py +9 -0
- herascrape/client.py +9 -0
- herascrape/py.typed +0 -0
- herascrape/ratelimit.py +90 -0
- herascrape/types.py +25 -0
- herascrape-0.1.0.dist-info/METADATA +209 -0
- herascrape-0.1.0.dist-info/RECORD +15 -0
- herascrape-0.1.0.dist-info/WHEEL +4 -0
- herascrape-0.1.0.dist-info/licenses/LICENSE +21 -0
herascrape/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from ._errors import (
|
|
2
|
+
AuthenticationError,
|
|
3
|
+
BadRequestError,
|
|
4
|
+
ForbiddenError,
|
|
5
|
+
HerascrapeAPIError,
|
|
6
|
+
HerascrapeError,
|
|
7
|
+
HerascrapeTransportError,
|
|
8
|
+
InternalError,
|
|
9
|
+
InvalidCursorError,
|
|
10
|
+
InvalidParameterError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PrivateAccountError,
|
|
13
|
+
ProfileNotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
ServiceUnavailableError,
|
|
17
|
+
UpstreamError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
from .async_client import AsyncClient
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .ratelimit import RateLimit
|
|
23
|
+
from .types import BalanceResponse, MediaIdResolution, Page
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AsyncClient",
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"BadRequestError",
|
|
31
|
+
"BalanceResponse",
|
|
32
|
+
"Client",
|
|
33
|
+
"ForbiddenError",
|
|
34
|
+
"HerascrapeAPIError",
|
|
35
|
+
"HerascrapeError",
|
|
36
|
+
"HerascrapeTransportError",
|
|
37
|
+
"InternalError",
|
|
38
|
+
"InvalidCursorError",
|
|
39
|
+
"InvalidParameterError",
|
|
40
|
+
"MediaIdResolution",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"Page",
|
|
43
|
+
"PrivateAccountError",
|
|
44
|
+
"ProfileNotFoundError",
|
|
45
|
+
"RateLimit",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"ServerError",
|
|
48
|
+
"ServiceUnavailableError",
|
|
49
|
+
"UpstreamError",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
herascrape/_base.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from typing import Any, Literal, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
HerascrapeAPIError,
|
|
15
|
+
HerascrapeTransportError,
|
|
16
|
+
InvalidCursorError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServiceUnavailableError,
|
|
19
|
+
UpstreamError,
|
|
20
|
+
error_from_response,
|
|
21
|
+
)
|
|
22
|
+
from .ratelimit import DEFAULT_RATE_LIMIT, RateLimit, RateLimiter
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.herascrape.com"
|
|
25
|
+
API_KEY_ENV_VAR = "HERASCRAPE_API_KEY"
|
|
26
|
+
|
|
27
|
+
Param = str | int | float | bool
|
|
28
|
+
ClientError = HerascrapeAPIError | HerascrapeTransportError
|
|
29
|
+
|
|
30
|
+
_BACKOFF_BASE = 0.5
|
|
31
|
+
_BACKOFF_CAP = 8.0
|
|
32
|
+
_BACKOFF_JITTER = 0.5
|
|
33
|
+
_MAX_RETRY_AFTER = 300.0
|
|
34
|
+
|
|
35
|
+
_RETRYABLE = (
|
|
36
|
+
HerascrapeTransportError,
|
|
37
|
+
UpstreamError,
|
|
38
|
+
ServiceUnavailableError,
|
|
39
|
+
InvalidCursorError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unwrap(payload: Any) -> Any:
|
|
44
|
+
"""Return the `data` member of a success envelope."""
|
|
45
|
+
return payload["data"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def drop_none(params: Mapping[str, Param | None] | None) -> dict[str, Param]:
|
|
49
|
+
"""Query parameters without the unset (`None`) ones."""
|
|
50
|
+
if params is None:
|
|
51
|
+
return {}
|
|
52
|
+
return {name: value for name, value in params.items() if value is not None}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
56
|
+
"""Seconds from a numeric `Retry-After`; `None` if absent or not usable."""
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
seconds = float(value)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
return max(seconds, 0.0) if math.isfinite(seconds) else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _json_object(body: bytes) -> dict[str, Any]:
|
|
67
|
+
"""The body as a JSON object; empty if it is anything else."""
|
|
68
|
+
try:
|
|
69
|
+
payload: Any = json.loads(body)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return {}
|
|
72
|
+
return cast(dict[str, Any], payload) if isinstance(payload, dict) else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_error(
|
|
76
|
+
*, status_code: int, headers: Mapping[str, str], body: bytes
|
|
77
|
+
) -> HerascrapeAPIError:
|
|
78
|
+
"""Build the exception for an error response, whatever its body."""
|
|
79
|
+
payload = _json_object(body)
|
|
80
|
+
error_code = payload.get("error_code")
|
|
81
|
+
return error_from_response(
|
|
82
|
+
status_code=status_code,
|
|
83
|
+
error_code=error_code if isinstance(error_code, str) else None,
|
|
84
|
+
detail=payload.get("detail"),
|
|
85
|
+
request_id=headers.get("x-request-id"),
|
|
86
|
+
retry_after=_parse_retry_after(headers.get("retry-after")),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_retryable(exc: ClientError) -> bool:
|
|
91
|
+
"""Transient failures worth another attempt (429 is handled apart)."""
|
|
92
|
+
return isinstance(exc, _RETRYABLE)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def backoff_delay(
|
|
96
|
+
attempt: int, *, rng: Callable[[], float] = random.random
|
|
97
|
+
) -> float:
|
|
98
|
+
"""Seconds to wait before retry number `attempt` (0 for the first).
|
|
99
|
+
|
|
100
|
+
Doubles from 0.5s up to an 8s cap, then drops by up to half at random.
|
|
101
|
+
"""
|
|
102
|
+
delay = min(_BACKOFF_CAP, _BACKOFF_BASE * 2**attempt)
|
|
103
|
+
return delay * (1 - _BACKOFF_JITTER * rng())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def retry_delay(exc: ClientError, *, attempt: int) -> float | None:
|
|
107
|
+
"""Seconds to wait before retrying `exc`, or `None` if it is final."""
|
|
108
|
+
if isinstance(exc, RateLimitError):
|
|
109
|
+
if exc.retry_after is not None:
|
|
110
|
+
return min(exc.retry_after, _MAX_RETRY_AFTER)
|
|
111
|
+
return backoff_delay(attempt)
|
|
112
|
+
return backoff_delay(attempt) if is_retryable(exc) else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def transport_failure(exc: httpx.TransportError) -> HerascrapeTransportError:
|
|
116
|
+
"""The client-side error for a connection failure or timeout."""
|
|
117
|
+
return HerascrapeTransportError(f"{type(exc).__name__}: {exc}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def response_data(response: httpx.Response) -> Any:
|
|
121
|
+
"""The unwrapped `data` of a success response; raises on an error one."""
|
|
122
|
+
if response.is_success:
|
|
123
|
+
return unwrap(response.json())
|
|
124
|
+
raise parse_error(
|
|
125
|
+
status_code=response.status_code,
|
|
126
|
+
headers=response.headers,
|
|
127
|
+
body=response.content,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_Client = TypeVar("_Client", bound="BaseSyncClient")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BaseSyncClient:
|
|
135
|
+
"""Synchronous transport shared by every generated endpoint method."""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
api_key: str | None = None,
|
|
140
|
+
*,
|
|
141
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
142
|
+
timeout: float = 10.0,
|
|
143
|
+
max_retries: int = 3,
|
|
144
|
+
rate_limit: RateLimit | None = DEFAULT_RATE_LIMIT,
|
|
145
|
+
transport: httpx.BaseTransport | None = None,
|
|
146
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
147
|
+
) -> None:
|
|
148
|
+
key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
149
|
+
if not key:
|
|
150
|
+
raise AuthenticationError(
|
|
151
|
+
status_code=401,
|
|
152
|
+
detail=f"No API key: pass api_key or set {API_KEY_ENV_VAR}.",
|
|
153
|
+
)
|
|
154
|
+
self._max_retries = max_retries
|
|
155
|
+
self._sleep = sleep
|
|
156
|
+
self._limiter = RateLimiter(rate_limit, sleep=sleep)
|
|
157
|
+
self._client = httpx.Client(
|
|
158
|
+
base_url=base_url,
|
|
159
|
+
headers={"x-api-key": key, "accept": "application/json"},
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
transport=transport,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _request(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
method: Literal["get"],
|
|
168
|
+
path: str,
|
|
169
|
+
params: Mapping[str, Param | None] | None = None,
|
|
170
|
+
) -> Any:
|
|
171
|
+
"""Send a request and return the unwrapped `data`, retrying."""
|
|
172
|
+
query = drop_none(params)
|
|
173
|
+
attempt = 0
|
|
174
|
+
while True:
|
|
175
|
+
self._limiter.acquire()
|
|
176
|
+
try:
|
|
177
|
+
return self._send(method=method, path=path, params=query)
|
|
178
|
+
except (HerascrapeAPIError, HerascrapeTransportError) as exc:
|
|
179
|
+
delay = retry_delay(exc, attempt=attempt)
|
|
180
|
+
if delay is None or attempt >= self._max_retries:
|
|
181
|
+
raise
|
|
182
|
+
self._sleep(delay)
|
|
183
|
+
attempt += 1
|
|
184
|
+
|
|
185
|
+
def _send(
|
|
186
|
+
self, *, method: Literal["get"], path: str, params: dict[str, Param]
|
|
187
|
+
) -> Any:
|
|
188
|
+
try:
|
|
189
|
+
response = self._client.request(method, path, params=params)
|
|
190
|
+
except httpx.TransportError as exc:
|
|
191
|
+
raise transport_failure(exc) from exc
|
|
192
|
+
return response_data(response)
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
"""Release the underlying connection pool."""
|
|
196
|
+
self._client.close()
|
|
197
|
+
|
|
198
|
+
def __enter__(self: _Client) -> _Client:
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
202
|
+
self.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_AsyncClient = TypeVar("_AsyncClient", bound="BaseAsyncClient")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class BaseAsyncClient:
|
|
209
|
+
"""Asynchronous transport shared by every generated endpoint method."""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
api_key: str | None = None,
|
|
214
|
+
*,
|
|
215
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
216
|
+
timeout: float = 10.0,
|
|
217
|
+
max_retries: int = 3,
|
|
218
|
+
rate_limit: RateLimit | None = DEFAULT_RATE_LIMIT,
|
|
219
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
220
|
+
asleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
221
|
+
) -> None:
|
|
222
|
+
key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
223
|
+
if not key:
|
|
224
|
+
raise AuthenticationError(
|
|
225
|
+
status_code=401,
|
|
226
|
+
detail=f"No API key: pass api_key or set {API_KEY_ENV_VAR}.",
|
|
227
|
+
)
|
|
228
|
+
self._max_retries = max_retries
|
|
229
|
+
self._asleep = asleep
|
|
230
|
+
self._limiter = RateLimiter(rate_limit)
|
|
231
|
+
self._client = httpx.AsyncClient(
|
|
232
|
+
base_url=base_url,
|
|
233
|
+
headers={"x-api-key": key, "accept": "application/json"},
|
|
234
|
+
timeout=timeout,
|
|
235
|
+
transport=transport,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def _request(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
method: Literal["get"],
|
|
242
|
+
path: str,
|
|
243
|
+
params: Mapping[str, Param | None] | None = None,
|
|
244
|
+
) -> Any:
|
|
245
|
+
"""Send a request and return the unwrapped `data`, retrying."""
|
|
246
|
+
query = drop_none(params)
|
|
247
|
+
attempt = 0
|
|
248
|
+
while True:
|
|
249
|
+
await self._limiter.aacquire(self._asleep)
|
|
250
|
+
try:
|
|
251
|
+
return await self._send(method=method, path=path, params=query)
|
|
252
|
+
except (HerascrapeAPIError, HerascrapeTransportError) as exc:
|
|
253
|
+
delay = retry_delay(exc, attempt=attempt)
|
|
254
|
+
if delay is None or attempt >= self._max_retries:
|
|
255
|
+
raise
|
|
256
|
+
await self._asleep(delay)
|
|
257
|
+
attempt += 1
|
|
258
|
+
|
|
259
|
+
async def _send(
|
|
260
|
+
self, *, method: Literal["get"], path: str, params: dict[str, Param]
|
|
261
|
+
) -> Any:
|
|
262
|
+
try:
|
|
263
|
+
response = await self._client.request(method, path, params=params)
|
|
264
|
+
except httpx.TransportError as exc:
|
|
265
|
+
raise transport_failure(exc) from exc
|
|
266
|
+
return response_data(response)
|
|
267
|
+
|
|
268
|
+
async def aclose(self) -> None:
|
|
269
|
+
"""Release the underlying connection pool."""
|
|
270
|
+
await self._client.aclose()
|
|
271
|
+
|
|
272
|
+
async def __aenter__(self: _AsyncClient) -> _AsyncClient:
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
276
|
+
await self.aclose()
|