socialscrape-sdk 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.
- socialscrape/__init__.py +32 -0
- socialscrape/_base_client.py +488 -0
- socialscrape/_batch.py +80 -0
- socialscrape/_errors_base.py +89 -0
- socialscrape/_generated/__init__.py +3 -0
- socialscrape/_generated/endpoints.py +219 -0
- socialscrape/_generated/errors.py +182 -0
- socialscrape/_generated/models.py +617 -0
- socialscrape/_generated/resources.py +14103 -0
- socialscrape/_pagination.py +87 -0
- socialscrape/_response.py +55 -0
- socialscrape/_version.py +1 -0
- socialscrape/client.py +67 -0
- socialscrape/errors.py +23 -0
- socialscrape/models.py +3 -0
- socialscrape/py.typed +0 -0
- socialscrape_sdk-0.1.0.dist-info/METADATA +246 -0
- socialscrape_sdk-0.1.0.dist-info/RECORD +21 -0
- socialscrape_sdk-0.1.0.dist-info/WHEEL +5 -0
- socialscrape_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- socialscrape_sdk-0.1.0.dist-info/top_level.txt +1 -0
socialscrape/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SocialScrape: public social media data as an API.
|
|
2
|
+
|
|
3
|
+
from socialscrape import SocialScrape
|
|
4
|
+
|
|
5
|
+
client = SocialScrape() # reads SOCIALSCRAPE_API_KEY
|
|
6
|
+
profile = client.tiktok.profile(handle="khaby.lame")
|
|
7
|
+
print(profile.data["followers"], profile.credits_charged, profile.credits_remaining)
|
|
8
|
+
|
|
9
|
+
See https://socialscrape.dev/docs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from . import errors, models
|
|
13
|
+
from ._batch import BatchRequest
|
|
14
|
+
from ._generated.endpoints import ENDPOINTS
|
|
15
|
+
from ._response import HttpInfo, RequestOptions, Response
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .client import AsyncSocialScrape, SocialScrape
|
|
18
|
+
from .errors import (
|
|
19
|
+
APIError,
|
|
20
|
+
JobTimeoutError,
|
|
21
|
+
MissingApiKeyError,
|
|
22
|
+
SocialScrapeConnectionError,
|
|
23
|
+
SocialScrapeError,
|
|
24
|
+
SocialScrapeTimeoutError,
|
|
25
|
+
UnexpectedResponseError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"SocialScrape", "AsyncSocialScrape", "Response", "HttpInfo", "RequestOptions", "BatchRequest", "ENDPOINTS",
|
|
30
|
+
"APIError", "SocialScrapeError", "MissingApiKeyError", "UnexpectedResponseError", "SocialScrapeConnectionError",
|
|
31
|
+
"SocialScrapeTimeoutError", "JobTimeoutError", "errors", "models", "__version__",
|
|
32
|
+
]
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""The HTTP core: auth, retries with backoff, job polling, envelope parsing.
|
|
2
|
+
|
|
3
|
+
Sync and async clients share every decision (`_Core`), and differ only in how
|
|
4
|
+
they send a request and sleep.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import email.utils
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Awaitable, Callable, Mapping, Protocol
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._errors_base import (
|
|
23
|
+
APIError,
|
|
24
|
+
JobTimeoutError,
|
|
25
|
+
MissingApiKeyError,
|
|
26
|
+
SocialScrapeConnectionError,
|
|
27
|
+
SocialScrapeError,
|
|
28
|
+
SocialScrapeTimeoutError,
|
|
29
|
+
UnexpectedResponseError,
|
|
30
|
+
)
|
|
31
|
+
from ._generated.errors import ERROR_CLASSES, ERROR_INFO
|
|
32
|
+
from ._response import HttpInfo, RequestOptions, Response
|
|
33
|
+
from ._version import __version__
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "https://api.socialscrape.dev"
|
|
36
|
+
ENV_API_KEY = "SOCIALSCRAPE_API_KEY"
|
|
37
|
+
ENV_BASE_URL = "SOCIALSCRAPE_BASE_URL"
|
|
38
|
+
|
|
39
|
+
# Used when a gateway answers an error without our envelope (a 502 from nginx).
|
|
40
|
+
_STATUS_CODES = {
|
|
41
|
+
400: "invalid_params", 401: "unauthorized", 402: "insufficient_credits", 403: "forbidden", 404: "not_found",
|
|
42
|
+
409: "conflict", 429: "rate_limited", 500: "internal", 501: "not_implemented", 502: "upstream_error",
|
|
43
|
+
503: "overloaded", 504: "upstream_timeout",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RequestSender(Protocol):
|
|
48
|
+
"""What a generated resource needs from its client."""
|
|
49
|
+
|
|
50
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Config:
|
|
55
|
+
api_key: str
|
|
56
|
+
base_url: str
|
|
57
|
+
timeout: float
|
|
58
|
+
max_retries: int
|
|
59
|
+
retry_initial_delay: float
|
|
60
|
+
retry_max_delay: float
|
|
61
|
+
max_retry_after: float
|
|
62
|
+
headers: Mapping[str, str]
|
|
63
|
+
wait_for_jobs: bool
|
|
64
|
+
job_timeout: float
|
|
65
|
+
job_poll_interval: float
|
|
66
|
+
job_poll_max_interval: float
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Prepared:
|
|
71
|
+
method: str
|
|
72
|
+
url: str
|
|
73
|
+
params: list[tuple[str, str]]
|
|
74
|
+
content: bytes | None
|
|
75
|
+
headers: dict[str, str]
|
|
76
|
+
timeout: float
|
|
77
|
+
max_retries: int
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_config(
|
|
81
|
+
*,
|
|
82
|
+
api_key: str | None,
|
|
83
|
+
base_url: str | None,
|
|
84
|
+
timeout: float,
|
|
85
|
+
max_retries: int,
|
|
86
|
+
retry_initial_delay: float,
|
|
87
|
+
retry_max_delay: float,
|
|
88
|
+
max_retry_after: float,
|
|
89
|
+
default_headers: Mapping[str, str] | None,
|
|
90
|
+
wait_for_jobs: bool,
|
|
91
|
+
job_timeout: float,
|
|
92
|
+
job_poll_interval: float,
|
|
93
|
+
) -> Config:
|
|
94
|
+
key = api_key or os.environ.get(ENV_API_KEY)
|
|
95
|
+
if not key:
|
|
96
|
+
raise MissingApiKeyError(f"no API key: pass api_key=... or set {ENV_API_KEY}")
|
|
97
|
+
url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
98
|
+
return Config(
|
|
99
|
+
api_key=key, base_url=url, timeout=timeout, max_retries=max_retries, retry_initial_delay=retry_initial_delay,
|
|
100
|
+
retry_max_delay=retry_max_delay, max_retry_after=max_retry_after, headers=dict(default_headers or {}),
|
|
101
|
+
wait_for_jobs=wait_for_jobs, job_timeout=job_timeout, job_poll_interval=job_poll_interval,
|
|
102
|
+
job_poll_max_interval=max(job_poll_interval, 5.0),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _wire_value(v: Any) -> str:
|
|
107
|
+
if isinstance(v, bool):
|
|
108
|
+
return "true" if v else "false"
|
|
109
|
+
if isinstance(v, (list, tuple, dict)):
|
|
110
|
+
return json.dumps(v, separators=(",", ":"))
|
|
111
|
+
return str(v)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def prepare(
|
|
115
|
+
cfg: Config,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
path_params: Mapping[str, Any] | None,
|
|
120
|
+
query: Mapping[str, Any] | None,
|
|
121
|
+
body: Mapping[str, Any] | None,
|
|
122
|
+
options: RequestOptions | None,
|
|
123
|
+
) -> Prepared:
|
|
124
|
+
options = options or {}
|
|
125
|
+
if path_params:
|
|
126
|
+
path = path.format(**{k: quote(str(v), safe="") for k, v in path_params.items()})
|
|
127
|
+
params = [(k, _wire_value(v)) for k, v in (query or {}).items() if v is not None]
|
|
128
|
+
headers = {
|
|
129
|
+
"x-api-key": cfg.api_key,
|
|
130
|
+
"accept": "application/json",
|
|
131
|
+
"user-agent": f"socialscrape-python/{__version__}",
|
|
132
|
+
}
|
|
133
|
+
# Header names are case-insensitive on the wire: merge in lower case so an
|
|
134
|
+
# override such as "Idempotency-Key" replaces ours instead of sitting beside it.
|
|
135
|
+
for source in (cfg.headers, options.get("headers") or {}):
|
|
136
|
+
headers.update({str(k).lower(): v for k, v in source.items()})
|
|
137
|
+
content: bytes | None = None
|
|
138
|
+
if body is not None:
|
|
139
|
+
content = json.dumps({k: v for k, v in body.items() if v is not None}, separators=(",", ":")).encode()
|
|
140
|
+
headers["content-type"] = "application/json"
|
|
141
|
+
if method in ("POST", "DELETE"):
|
|
142
|
+
# One key for every attempt: a retry returns the first answer instead of charging twice.
|
|
143
|
+
headers.setdefault("idempotency-key", options.get("idempotency_key") or str(uuid.uuid4()))
|
|
144
|
+
if options.get("idempotency_key"):
|
|
145
|
+
headers["idempotency-key"] = options["idempotency_key"]
|
|
146
|
+
return Prepared(
|
|
147
|
+
method=method, url=cfg.base_url + path, params=params, content=content, headers=headers,
|
|
148
|
+
timeout=options.get("timeout", cfg.timeout), max_retries=options.get("max_retries", cfg.max_retries),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
153
|
+
if not value:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
return max(0.0, float(value))
|
|
157
|
+
except ValueError:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
161
|
+
except (TypeError, ValueError):
|
|
162
|
+
return None
|
|
163
|
+
return max(0.0, when.timestamp() - time.time())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _int_or_none(v: str | None) -> int | None:
|
|
167
|
+
try:
|
|
168
|
+
return int(v) if v is not None else None
|
|
169
|
+
except ValueError:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def interpret(status: int, headers: Mapping[str, str], content: bytes, attempts: int) -> Response[Any] | SocialScrapeError:
|
|
174
|
+
"""An answer -> a Response, or the exception to raise (returned, not raised)."""
|
|
175
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
176
|
+
try:
|
|
177
|
+
body = json.loads(content) if content else None
|
|
178
|
+
except ValueError:
|
|
179
|
+
body = None
|
|
180
|
+
retry_after = _parse_retry_after(lowered.get("retry-after"))
|
|
181
|
+
if isinstance(body, dict) and body.get("ok") is True and status < 400:
|
|
182
|
+
info = HttpInfo(
|
|
183
|
+
status_code=status, headers=lowered, request_id=body.get("request_id") or lowered.get("x-request-id"),
|
|
184
|
+
rate_limit_limit=_int_or_none(lowered.get("x-ratelimit-limit")),
|
|
185
|
+
rate_limit_remaining=_int_or_none(lowered.get("x-ratelimit-remaining")),
|
|
186
|
+
ignored_params=tuple(p for p in lowered.get("x-ignored-params", "").split(",") if p),
|
|
187
|
+
idempotent_replay=lowered.get("x-idempotent-replay") == "true", attempts=attempts,
|
|
188
|
+
)
|
|
189
|
+
return Response(
|
|
190
|
+
data=body.get("data"), credits_charged=int(body.get("credits_charged") or 0),
|
|
191
|
+
credits_remaining=body.get("credits_remaining"), cached=bool(body.get("cached")), cached_at=body.get("cached_at"),
|
|
192
|
+
request_id=info.request_id or "", cursor=body.get("cursor"), has_more=body.get("has_more"), http=info,
|
|
193
|
+
raw=body.get("raw"),
|
|
194
|
+
)
|
|
195
|
+
if isinstance(body, dict) and body.get("ok") is False and isinstance(body.get("error"), dict):
|
|
196
|
+
err = body["error"]
|
|
197
|
+
code = str(err.get("code") or "internal")
|
|
198
|
+
cls = ERROR_CLASSES.get(code, APIError)
|
|
199
|
+
retryable = err.get("retryable")
|
|
200
|
+
return cls(
|
|
201
|
+
str(err.get("message") or code), retryable=bool(retryable) if retryable is not None else ERROR_INFO.get(code, (0, False))[1],
|
|
202
|
+
http_status=status, request_id=body.get("request_id") or lowered.get("x-request-id"), retry_after=retry_after,
|
|
203
|
+
headers=lowered, body=body, **({} if cls is not APIError else {"code": code}),
|
|
204
|
+
)
|
|
205
|
+
code = _STATUS_CODES.get(status)
|
|
206
|
+
if status >= 400 and code is not None:
|
|
207
|
+
return ERROR_CLASSES[code](
|
|
208
|
+
f"HTTP {status} without a SocialScrape error body", retryable=ERROR_INFO[code][1], http_status=status,
|
|
209
|
+
request_id=lowered.get("x-request-id"), retry_after=retry_after, headers=lowered, body=content[:500].decode("utf-8", "replace"),
|
|
210
|
+
)
|
|
211
|
+
return UnexpectedResponseError(f"unexpected HTTP {status} response", http_status=status,
|
|
212
|
+
body=content[:500].decode("utf-8", "replace"))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def retry_delay(cfg: Config, attempt: int, err: SocialScrapeError, max_retries: int) -> float | None:
|
|
216
|
+
"""Seconds to wait before retry number `attempt`, or None to give up."""
|
|
217
|
+
if attempt > max_retries or not getattr(err, "retryable", False):
|
|
218
|
+
return None
|
|
219
|
+
after = getattr(err, "retry_after", None)
|
|
220
|
+
if after is not None:
|
|
221
|
+
return after if after <= cfg.max_retry_after else None
|
|
222
|
+
base = min(cfg.retry_max_delay, cfg.retry_initial_delay * (2 ** (attempt - 1)))
|
|
223
|
+
return base * (0.5 + random.random() / 2)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def is_job(resp: Response[Any]) -> bool:
|
|
227
|
+
return resp.http.status_code == 202 and isinstance(resp.data, dict) and resp.data.get("object") == "job"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def job_options(options: RequestOptions | None) -> RequestOptions:
|
|
231
|
+
out: RequestOptions = {}
|
|
232
|
+
for k in ("timeout", "max_retries", "headers"):
|
|
233
|
+
if options and k in options:
|
|
234
|
+
out[k] = options[k] # type: ignore[literal-required]
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _Stats:
|
|
239
|
+
"""Last-seen balance and a running total, updated after every answer."""
|
|
240
|
+
|
|
241
|
+
credits_remaining: int | None = None
|
|
242
|
+
credits_charged_total: int = 0
|
|
243
|
+
|
|
244
|
+
def _record(self, resp: Response[Any]) -> None:
|
|
245
|
+
if resp.credits_remaining is not None:
|
|
246
|
+
self.credits_remaining = resp.credits_remaining
|
|
247
|
+
self.credits_charged_total += resp.credits_charged
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class SyncAPIClient(_Stats):
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
api_key: str | None = None,
|
|
255
|
+
base_url: str | None = None,
|
|
256
|
+
timeout: float = 60.0,
|
|
257
|
+
max_retries: int = 2,
|
|
258
|
+
retry_initial_delay: float = 0.5,
|
|
259
|
+
retry_max_delay: float = 8.0,
|
|
260
|
+
max_retry_after: float = 60.0,
|
|
261
|
+
default_headers: Mapping[str, str] | None = None,
|
|
262
|
+
wait_for_jobs: bool = True,
|
|
263
|
+
job_timeout: float = 600.0,
|
|
264
|
+
job_poll_interval: float = 1.0,
|
|
265
|
+
http_client: httpx.Client | None = None,
|
|
266
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
267
|
+
) -> None:
|
|
268
|
+
self._cfg = resolve_config(
|
|
269
|
+
api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries,
|
|
270
|
+
retry_initial_delay=retry_initial_delay, retry_max_delay=retry_max_delay, max_retry_after=max_retry_after,
|
|
271
|
+
default_headers=default_headers, wait_for_jobs=wait_for_jobs, job_timeout=job_timeout,
|
|
272
|
+
job_poll_interval=job_poll_interval,
|
|
273
|
+
)
|
|
274
|
+
self._owns_http = http_client is None
|
|
275
|
+
self._http = http_client or httpx.Client()
|
|
276
|
+
self._sleep = sleep
|
|
277
|
+
|
|
278
|
+
# ---- lifecycle
|
|
279
|
+
def close(self) -> None:
|
|
280
|
+
if self._owns_http:
|
|
281
|
+
self._http.close()
|
|
282
|
+
|
|
283
|
+
def __enter__(self): # noqa: ANN204
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
def __exit__(self, *exc: Any) -> None:
|
|
287
|
+
self.close()
|
|
288
|
+
|
|
289
|
+
# ---- requests
|
|
290
|
+
def _send(self, prep: Prepared, attempt: int) -> Response[Any]:
|
|
291
|
+
try:
|
|
292
|
+
r = self._http.request(prep.method, prep.url, params=prep.params or None, content=prep.content,
|
|
293
|
+
headers=prep.headers, timeout=prep.timeout)
|
|
294
|
+
except httpx.TimeoutException as e:
|
|
295
|
+
raise SocialScrapeTimeoutError(f"{prep.method} {prep.url} timed out after {prep.timeout}s") from e
|
|
296
|
+
except httpx.TransportError as e:
|
|
297
|
+
raise SocialScrapeConnectionError(f"{prep.method} {prep.url} failed: {e}") from e
|
|
298
|
+
result = interpret(r.status_code, r.headers, r.content, attempt)
|
|
299
|
+
if isinstance(result, SocialScrapeError):
|
|
300
|
+
raise result
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
def _request(
|
|
304
|
+
self,
|
|
305
|
+
method: str,
|
|
306
|
+
path: str,
|
|
307
|
+
*,
|
|
308
|
+
path_params: Mapping[str, Any] | None = None,
|
|
309
|
+
query: Mapping[str, Any] | None = None,
|
|
310
|
+
body: Mapping[str, Any] | None = None,
|
|
311
|
+
options: RequestOptions | None = None,
|
|
312
|
+
wait_for_job: bool = True,
|
|
313
|
+
) -> Response[Any]:
|
|
314
|
+
resp = self._execute(method, path, path_params, query, body, options)
|
|
315
|
+
want = (options or {}).get("wait_for_job", self._cfg.wait_for_jobs)
|
|
316
|
+
if wait_for_job and want and is_job(resp):
|
|
317
|
+
resp = self._poll_job(resp.data["job_id"], options)
|
|
318
|
+
self._record(resp)
|
|
319
|
+
return resp
|
|
320
|
+
|
|
321
|
+
def _execute(self, method: str, path: str, path_params: Mapping[str, Any] | None, query: Mapping[str, Any] | None,
|
|
322
|
+
body: Mapping[str, Any] | None, options: RequestOptions | None) -> Response[Any]:
|
|
323
|
+
"""One call with retries; no job following, no stats."""
|
|
324
|
+
cfg = self._cfg
|
|
325
|
+
prep = prepare(cfg, method, path, path_params=path_params, query=query, body=body, options=options)
|
|
326
|
+
attempt = 0
|
|
327
|
+
while True:
|
|
328
|
+
attempt += 1
|
|
329
|
+
try:
|
|
330
|
+
return self._send(prep, attempt)
|
|
331
|
+
except (APIError, SocialScrapeConnectionError, SocialScrapeTimeoutError) as e:
|
|
332
|
+
delay = retry_delay(cfg, attempt, e, prep.max_retries)
|
|
333
|
+
if delay is None:
|
|
334
|
+
raise
|
|
335
|
+
self._sleep(delay)
|
|
336
|
+
|
|
337
|
+
def _poll_job(self, job_id: str, options: RequestOptions | None) -> Response[Any]:
|
|
338
|
+
cfg = self._cfg
|
|
339
|
+
limit = (options or {}).get("job_timeout", cfg.job_timeout)
|
|
340
|
+
deadline = time.monotonic() + limit
|
|
341
|
+
interval = cfg.job_poll_interval
|
|
342
|
+
while True:
|
|
343
|
+
remaining = deadline - time.monotonic()
|
|
344
|
+
if remaining <= 0:
|
|
345
|
+
raise JobTimeoutError(f"job {job_id} did not finish within {limit}s; it keeps running", job_id=job_id)
|
|
346
|
+
self._sleep(min(interval, remaining))
|
|
347
|
+
interval = min(interval * 1.5, cfg.job_poll_max_interval)
|
|
348
|
+
resp = self._execute("GET", "/v1/jobs/{job_id}", {"job_id": job_id}, None, None, job_options(options))
|
|
349
|
+
if not is_job(resp):
|
|
350
|
+
return resp
|
|
351
|
+
|
|
352
|
+
def wait_for_job(self, job_id: str, *, request_options: RequestOptions | None = None) -> Response[Any]:
|
|
353
|
+
"""Poll a job (free) until it finishes and return its final answer."""
|
|
354
|
+
resp = self._poll_job(job_id, request_options)
|
|
355
|
+
self._record(resp)
|
|
356
|
+
return resp
|
|
357
|
+
|
|
358
|
+
def request(
|
|
359
|
+
self,
|
|
360
|
+
method: str,
|
|
361
|
+
path: str,
|
|
362
|
+
*,
|
|
363
|
+
query: Mapping[str, Any] | None = None,
|
|
364
|
+
body: Mapping[str, Any] | None = None,
|
|
365
|
+
request_options: RequestOptions | None = None,
|
|
366
|
+
) -> Response[Any]:
|
|
367
|
+
"""Call any path directly (a newer endpoint than this client knows)."""
|
|
368
|
+
return self._request(method.upper(), path, query=query, body=body, options=request_options)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class AsyncAPIClient(_Stats):
|
|
372
|
+
def __init__(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
api_key: str | None = None,
|
|
376
|
+
base_url: str | None = None,
|
|
377
|
+
timeout: float = 60.0,
|
|
378
|
+
max_retries: int = 2,
|
|
379
|
+
retry_initial_delay: float = 0.5,
|
|
380
|
+
retry_max_delay: float = 8.0,
|
|
381
|
+
max_retry_after: float = 60.0,
|
|
382
|
+
default_headers: Mapping[str, str] | None = None,
|
|
383
|
+
wait_for_jobs: bool = True,
|
|
384
|
+
job_timeout: float = 600.0,
|
|
385
|
+
job_poll_interval: float = 1.0,
|
|
386
|
+
http_client: httpx.AsyncClient | None = None,
|
|
387
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
388
|
+
) -> None:
|
|
389
|
+
self._cfg = resolve_config(
|
|
390
|
+
api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries,
|
|
391
|
+
retry_initial_delay=retry_initial_delay, retry_max_delay=retry_max_delay, max_retry_after=max_retry_after,
|
|
392
|
+
default_headers=default_headers, wait_for_jobs=wait_for_jobs, job_timeout=job_timeout,
|
|
393
|
+
job_poll_interval=job_poll_interval,
|
|
394
|
+
)
|
|
395
|
+
self._owns_http = http_client is None
|
|
396
|
+
self._http = http_client or httpx.AsyncClient()
|
|
397
|
+
self._sleep = sleep
|
|
398
|
+
|
|
399
|
+
async def aclose(self) -> None:
|
|
400
|
+
if self._owns_http:
|
|
401
|
+
await self._http.aclose()
|
|
402
|
+
|
|
403
|
+
async def __aenter__(self): # noqa: ANN204
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
407
|
+
await self.aclose()
|
|
408
|
+
|
|
409
|
+
async def _send(self, prep: Prepared, attempt: int) -> Response[Any]:
|
|
410
|
+
try:
|
|
411
|
+
r = await self._http.request(prep.method, prep.url, params=prep.params or None, content=prep.content,
|
|
412
|
+
headers=prep.headers, timeout=prep.timeout)
|
|
413
|
+
except httpx.TimeoutException as e:
|
|
414
|
+
raise SocialScrapeTimeoutError(f"{prep.method} {prep.url} timed out after {prep.timeout}s") from e
|
|
415
|
+
except httpx.TransportError as e:
|
|
416
|
+
raise SocialScrapeConnectionError(f"{prep.method} {prep.url} failed: {e}") from e
|
|
417
|
+
result = interpret(r.status_code, r.headers, r.content, attempt)
|
|
418
|
+
if isinstance(result, SocialScrapeError):
|
|
419
|
+
raise result
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
async def _request(
|
|
423
|
+
self,
|
|
424
|
+
method: str,
|
|
425
|
+
path: str,
|
|
426
|
+
*,
|
|
427
|
+
path_params: Mapping[str, Any] | None = None,
|
|
428
|
+
query: Mapping[str, Any] | None = None,
|
|
429
|
+
body: Mapping[str, Any] | None = None,
|
|
430
|
+
options: RequestOptions | None = None,
|
|
431
|
+
wait_for_job: bool = True,
|
|
432
|
+
) -> Response[Any]:
|
|
433
|
+
resp = await self._execute(method, path, path_params, query, body, options)
|
|
434
|
+
want = (options or {}).get("wait_for_job", self._cfg.wait_for_jobs)
|
|
435
|
+
if wait_for_job and want and is_job(resp):
|
|
436
|
+
resp = await self._poll_job(resp.data["job_id"], options)
|
|
437
|
+
self._record(resp)
|
|
438
|
+
return resp
|
|
439
|
+
|
|
440
|
+
async def _execute(self, method: str, path: str, path_params: Mapping[str, Any] | None,
|
|
441
|
+
query: Mapping[str, Any] | None, body: Mapping[str, Any] | None,
|
|
442
|
+
options: RequestOptions | None) -> Response[Any]:
|
|
443
|
+
"""One call with retries; no job following, no stats."""
|
|
444
|
+
cfg = self._cfg
|
|
445
|
+
prep = prepare(cfg, method, path, path_params=path_params, query=query, body=body, options=options)
|
|
446
|
+
attempt = 0
|
|
447
|
+
while True:
|
|
448
|
+
attempt += 1
|
|
449
|
+
try:
|
|
450
|
+
return await self._send(prep, attempt)
|
|
451
|
+
except (APIError, SocialScrapeConnectionError, SocialScrapeTimeoutError) as e:
|
|
452
|
+
delay = retry_delay(cfg, attempt, e, prep.max_retries)
|
|
453
|
+
if delay is None:
|
|
454
|
+
raise
|
|
455
|
+
await self._sleep(delay)
|
|
456
|
+
|
|
457
|
+
async def _poll_job(self, job_id: str, options: RequestOptions | None) -> Response[Any]:
|
|
458
|
+
cfg = self._cfg
|
|
459
|
+
limit = (options or {}).get("job_timeout", cfg.job_timeout)
|
|
460
|
+
deadline = time.monotonic() + limit
|
|
461
|
+
interval = cfg.job_poll_interval
|
|
462
|
+
while True:
|
|
463
|
+
remaining = deadline - time.monotonic()
|
|
464
|
+
if remaining <= 0:
|
|
465
|
+
raise JobTimeoutError(f"job {job_id} did not finish within {limit}s; it keeps running", job_id=job_id)
|
|
466
|
+
await self._sleep(min(interval, remaining))
|
|
467
|
+
interval = min(interval * 1.5, cfg.job_poll_max_interval)
|
|
468
|
+
resp = await self._execute("GET", "/v1/jobs/{job_id}", {"job_id": job_id}, None, None, job_options(options))
|
|
469
|
+
if not is_job(resp):
|
|
470
|
+
return resp
|
|
471
|
+
|
|
472
|
+
async def wait_for_job(self, job_id: str, *, request_options: RequestOptions | None = None) -> Response[Any]:
|
|
473
|
+
"""Poll a job (free) until it finishes and return its final answer."""
|
|
474
|
+
resp = await self._poll_job(job_id, request_options)
|
|
475
|
+
self._record(resp)
|
|
476
|
+
return resp
|
|
477
|
+
|
|
478
|
+
async def request(
|
|
479
|
+
self,
|
|
480
|
+
method: str,
|
|
481
|
+
path: str,
|
|
482
|
+
*,
|
|
483
|
+
query: Mapping[str, Any] | None = None,
|
|
484
|
+
body: Mapping[str, Any] | None = None,
|
|
485
|
+
request_options: RequestOptions | None = None,
|
|
486
|
+
) -> Response[Any]:
|
|
487
|
+
"""Call any path directly (a newer endpoint than this client knows)."""
|
|
488
|
+
return await self._request(method.upper(), path, query=query, body=body, options=request_options)
|
socialscrape/_batch.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""`client.batch.run`: many GET calls in one request.
|
|
2
|
+
|
|
3
|
+
The registry's `requests` parameter is a bare array, so this resource is
|
|
4
|
+
written by hand to accept an endpoint id (`"tiktok.profile"`) as well as a
|
|
5
|
+
raw path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Mapping, Sequence, TypedDict, Union
|
|
11
|
+
|
|
12
|
+
from ._generated.endpoints import ENDPOINTS
|
|
13
|
+
from ._generated.models import BatchResult
|
|
14
|
+
from ._response import RequestOptions, Response
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._base_client import RequestSender
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BatchById(TypedDict, total=False):
|
|
21
|
+
endpoint: str # a registry id such as "tiktok.profile" (GET endpoints only)
|
|
22
|
+
params: Mapping[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BatchByPath(TypedDict, total=False):
|
|
26
|
+
path: str # "/v1/tiktok/profile", optionally with a query string
|
|
27
|
+
params: Mapping[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
BatchRequest = Union[BatchById, BatchByPath]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_wire(requests: Sequence[BatchRequest]) -> list[dict[str, Any]]:
|
|
34
|
+
out: list[dict[str, Any]] = []
|
|
35
|
+
for i, item in enumerate(requests):
|
|
36
|
+
if "endpoint" in item:
|
|
37
|
+
info = ENDPOINTS.get(item["endpoint"]) # type: ignore[typeddict-item]
|
|
38
|
+
if info is None:
|
|
39
|
+
raise ValueError(f"requests[{i}]: unknown endpoint id {item['endpoint']!r}") # type: ignore[typeddict-item]
|
|
40
|
+
if info["method"] != "GET":
|
|
41
|
+
raise ValueError(f"requests[{i}]: {item['endpoint']} is not a GET endpoint; batch runs GETs") # type: ignore[typeddict-item]
|
|
42
|
+
if "{" in info["path"]:
|
|
43
|
+
raise ValueError(f"requests[{i}]: {item['endpoint']} has a path parameter ({info['path']}); batch cannot fill it, call it directly") # type: ignore[typeddict-item]
|
|
44
|
+
wire: dict[str, Any] = {"path": info["path"]}
|
|
45
|
+
elif "path" in item:
|
|
46
|
+
wire = {"path": item["path"]} # type: ignore[typeddict-item]
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(f"requests[{i}] needs `endpoint` or `path`")
|
|
49
|
+
if item.get("params"):
|
|
50
|
+
wire["params"] = dict(item["params"]) # type: ignore[arg-type]
|
|
51
|
+
out.append(wire)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BatchResource:
|
|
56
|
+
"""`client.batch`"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, client: RequestSender) -> None:
|
|
59
|
+
self._request = client._request
|
|
60
|
+
|
|
61
|
+
def run(self, requests: Sequence[BatchRequest], *, request_options: RequestOptions | None = None) -> Response[BatchResult]:
|
|
62
|
+
"""Run up to the account's batch limit of GET requests in one call.
|
|
63
|
+
|
|
64
|
+
Each item behaves exactly like the same call made alone (cache, charge,
|
|
65
|
+
free failures). `credits_charged` is the sum; each result's `response`
|
|
66
|
+
is a full envelope. Items are `{"endpoint": "tiktok.profile", "params": {...}}`
|
|
67
|
+
or `{"path": "/v1/tiktok/profile", "params": {...}}`.
|
|
68
|
+
"""
|
|
69
|
+
return self._request("POST", "/v1/batch", body={"requests": to_wire(requests)}, options=request_options)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AsyncBatchResource:
|
|
73
|
+
"""`client.batch`"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, client: RequestSender) -> None:
|
|
76
|
+
self._request = client._request
|
|
77
|
+
|
|
78
|
+
async def run(self, requests: Sequence[BatchRequest], *, request_options: RequestOptions | None = None) -> Response[BatchResult]:
|
|
79
|
+
"""Same as the sync `batch.run`."""
|
|
80
|
+
return await self._request("POST", "/v1/batch", body={"requests": to_wire(requests)}, options=request_options)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Error base classes. The per-code subclasses are generated (`_generated/errors.py`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SocialScrapeError(Exception):
|
|
9
|
+
"""Base class of everything this package raises."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingApiKeyError(SocialScrapeError):
|
|
13
|
+
"""No API key: pass `api_key=` or set SOCIALSCRAPE_API_KEY."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(SocialScrapeError):
|
|
17
|
+
"""The API answered with an error envelope (or an HTTP error without one).
|
|
18
|
+
|
|
19
|
+
Branch on `code`, never on `message`. Every failure is free:
|
|
20
|
+
`credits_charged` is always 0. `retryable` says whether the same request
|
|
21
|
+
may succeed later; the client already retried when it was set and the
|
|
22
|
+
retry budget allowed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
code: str = "internal"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
retryable: bool = False,
|
|
32
|
+
http_status: int | None = None,
|
|
33
|
+
request_id: str | None = None,
|
|
34
|
+
retry_after: float | None = None,
|
|
35
|
+
headers: Mapping[str, str] | None = None,
|
|
36
|
+
body: Any = None,
|
|
37
|
+
code: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
if code is not None:
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.retryable = retryable
|
|
44
|
+
self.http_status = http_status
|
|
45
|
+
self.request_id = request_id
|
|
46
|
+
self.retry_after = retry_after
|
|
47
|
+
self.headers: Mapping[str, str] = dict(headers or {})
|
|
48
|
+
self.body = body
|
|
49
|
+
self.credits_charged = 0
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
bits = [f"{self.code}: {self.message}"]
|
|
53
|
+
if self.http_status:
|
|
54
|
+
bits.append(f"HTTP {self.http_status}")
|
|
55
|
+
if self.request_id:
|
|
56
|
+
bits.append(f"request {self.request_id}")
|
|
57
|
+
return " | ".join(bits)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UnexpectedResponseError(SocialScrapeError):
|
|
61
|
+
"""The server answered something that is not a SocialScrape envelope."""
|
|
62
|
+
|
|
63
|
+
retryable = False
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, *, http_status: int | None = None, body: Any = None) -> None:
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.http_status = http_status
|
|
68
|
+
self.body = body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SocialScrapeConnectionError(SocialScrapeError):
|
|
72
|
+
"""The request never got an answer (DNS, refused, reset). Retryable."""
|
|
73
|
+
|
|
74
|
+
retryable = True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SocialScrapeTimeoutError(SocialScrapeError):
|
|
78
|
+
"""The request timed out on the client side. Retryable."""
|
|
79
|
+
|
|
80
|
+
retryable = True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class JobTimeoutError(SocialScrapeTimeoutError):
|
|
84
|
+
"""A long job did not finish inside `job_timeout`. It keeps running:
|
|
85
|
+
fetch it later with `client.jobs.get(job_id=...)` (free to poll)."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str, *, job_id: str) -> None:
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.job_id = job_id
|