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.
@@ -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
@@ -0,0 +1,3 @@
1
+ # GENERATED by sdks/python/tools/generate.py from the endpoint registry. Do not edit.
2
+ # Regenerate: .venv/bin/python sdks/python/tools/generate.py
3
+ """Generated models, errors and resources."""