scrapenest 0.3.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.
scrapenest/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ from ._async_client import AsyncClient
2
+ from ._client import Client
3
+ from .errors import (
4
+ AuthenticationError,
5
+ ForbiddenError,
6
+ NotFoundError,
7
+ PaymentRequiredError,
8
+ RateLimitError,
9
+ ScrapeNestError,
10
+ ServerError,
11
+ UnexpectedResponseError,
12
+ )
13
+
14
+ __version__ = "0.3.0"
15
+
16
+ __all__ = [
17
+ "Client",
18
+ "AsyncClient",
19
+ "ScrapeNestError",
20
+ "AuthenticationError",
21
+ "PaymentRequiredError",
22
+ "ForbiddenError",
23
+ "NotFoundError",
24
+ "RateLimitError",
25
+ "ServerError",
26
+ "UnexpectedResponseError",
27
+ "__version__",
28
+ ]
@@ -0,0 +1,573 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Literal, Optional
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+ from ._http import (
11
+ DEFAULT_BASE_URL,
12
+ DEFAULT_TIMEOUT,
13
+ _build_headers,
14
+ _detect_stream_error,
15
+ _parse_error,
16
+ _wrap_network_error,
17
+ )
18
+ from ._place_id import normalize_place_id
19
+ from .types.audio import AudioTranscriptResponse
20
+ from .types.auth import MeResponse
21
+ from .types.gas_prices import GasPricesResponse
22
+ from .types.maps import MapsReviewsResponse
23
+ from .types.runs import RunCreatedResponse, RunStatusResponse
24
+ from .types.pdf import PdfResponse
25
+ from .types.scrape import ScrapeUrlResponse
26
+ from .types.screenshot import ScreenshotResponse
27
+ from .types.search import (
28
+ AnswerResponse,
29
+ DeepSearchResponse,
30
+ SearchResponse,
31
+ TranslateResponse,
32
+ )
33
+
34
+ _TERMINAL = {"completed", "failed", "cancelled"}
35
+
36
+
37
+ def _build_search_payload(
38
+ *,
39
+ query: str,
40
+ num_results: int,
41
+ language: str,
42
+ time_range: Optional[str],
43
+ categories: Optional[str],
44
+ include_domains: Optional[list],
45
+ exclude_domains: Optional[list],
46
+ topic: Optional[str],
47
+ country: Optional[str],
48
+ start_date: Optional[str],
49
+ end_date: Optional[str],
50
+ include_images: bool,
51
+ include_image_descriptions: bool,
52
+ image_sources: Optional[list],
53
+ engines: Optional[list],
54
+ include_usage: bool,
55
+ cache_ttl_seconds: Optional[int],
56
+ ) -> dict:
57
+ payload: dict = {"query": query, "num_results": num_results, "language": language}
58
+ if time_range is not None:
59
+ payload["time_range"] = time_range
60
+ if categories is not None:
61
+ payload["categories"] = categories
62
+ if include_domains is not None:
63
+ payload["include_domains"] = list(include_domains)
64
+ if exclude_domains is not None:
65
+ payload["exclude_domains"] = list(exclude_domains)
66
+ if topic is not None:
67
+ payload["topic"] = topic
68
+ if country is not None:
69
+ payload["country"] = country
70
+ if start_date is not None:
71
+ payload["start_date"] = start_date
72
+ if end_date is not None:
73
+ payload["end_date"] = end_date
74
+ if include_images:
75
+ payload["include_images"] = True
76
+ if include_image_descriptions:
77
+ payload["include_image_descriptions"] = True
78
+ if image_sources is not None:
79
+ payload["image_sources"] = list(image_sources)
80
+ if engines is not None:
81
+ payload["engines"] = list(engines)
82
+ if include_usage:
83
+ payload["include_usage"] = True
84
+ if cache_ttl_seconds is not None:
85
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
86
+ return payload
87
+
88
+
89
+ class AsyncClient:
90
+ def __init__(
91
+ self,
92
+ api_key: str,
93
+ *,
94
+ base_url: str = DEFAULT_BASE_URL,
95
+ timeout: float = DEFAULT_TIMEOUT,
96
+ extra_headers: Optional[dict[str, str]] = None,
97
+ ) -> None:
98
+ self._api_key = api_key
99
+ self._base_url = base_url.rstrip("/")
100
+ merged: dict[str, str] = {}
101
+ if extra_headers:
102
+ merged.update({str(k): str(v) for k, v in extra_headers.items()})
103
+ merged.update(_build_headers(api_key))
104
+ self._http = httpx.AsyncClient(
105
+ base_url=self._base_url,
106
+ headers=merged,
107
+ timeout=timeout,
108
+ )
109
+
110
+ async def _request(self, method: str, path: str, **kwargs) -> dict:
111
+ try:
112
+ response = await self._http.request(method, path, **kwargs)
113
+ except httpx.RequestError as exc:
114
+ raise _wrap_network_error(exc) from exc
115
+ if response.is_error:
116
+ try:
117
+ body = response.json()
118
+ except Exception:
119
+ body = {}
120
+ raise _parse_error(response.status_code, body)
121
+ data = response.json()
122
+ stream_error = _detect_stream_error(data)
123
+ if stream_error is not None:
124
+ raise _parse_error(*stream_error)
125
+ return data
126
+
127
+ async def close(self) -> None:
128
+ await self._http.aclose()
129
+
130
+ async def __aenter__(self) -> "AsyncClient":
131
+ return self
132
+
133
+ async def __aexit__(self, *args) -> None:
134
+ await self.close()
135
+
136
+ async def health(self) -> dict:
137
+ return await self._request("GET", "/health")
138
+
139
+ async def me(self) -> MeResponse:
140
+ return MeResponse.from_dict(await self._request("GET", "/v1/me"))
141
+
142
+ async def scrape_url(
143
+ self,
144
+ url: str,
145
+ *,
146
+ render: str = "auto",
147
+ force_proxy: bool = False,
148
+ block_assets: bool = True,
149
+ extract: bool = True,
150
+ ai_query: Optional[str] = None,
151
+ ai_extract_schema: Optional[dict] = None,
152
+ include_usage: bool = False,
153
+ include_html: bool = False,
154
+ return_markdown: bool = False,
155
+ extract_rules: Optional[dict] = None,
156
+ wait_ms: Optional[int] = None,
157
+ wait_for_selector: Optional[str] = None,
158
+ wait_for_timeout_ms: Optional[int] = None,
159
+ window_width: Optional[int] = None,
160
+ window_height: Optional[int] = None,
161
+ country: Optional[str] = None,
162
+ device: str = "desktop",
163
+ block_ads: bool = False,
164
+ simulate_behavior: bool = False,
165
+ cache_ttl_seconds: Optional[int] = None,
166
+ session_id: Optional[str] = None,
167
+ cookies: Optional[dict] = None,
168
+ headers: Optional[dict] = None,
169
+ ) -> ScrapeUrlResponse:
170
+ payload: dict = {
171
+ "url": url,
172
+ "render": render,
173
+ "force_proxy": force_proxy,
174
+ "block_assets": block_assets,
175
+ "extract": extract,
176
+ }
177
+ if ai_query is not None:
178
+ payload["ai_query"] = ai_query
179
+ if ai_extract_schema is not None:
180
+ payload["ai_extract_schema"] = ai_extract_schema
181
+ if include_usage:
182
+ payload["include_usage"] = True
183
+ if include_html:
184
+ payload["include_html"] = True
185
+ if return_markdown:
186
+ payload["return_markdown"] = True
187
+ if extract_rules is not None:
188
+ payload["extract_rules"] = extract_rules
189
+ if wait_ms is not None:
190
+ payload["wait_ms"] = wait_ms
191
+ if wait_for_selector is not None:
192
+ payload["wait_for_selector"] = wait_for_selector
193
+ if wait_for_timeout_ms is not None:
194
+ payload["wait_for_timeout_ms"] = wait_for_timeout_ms
195
+ if window_width is not None:
196
+ payload["window_width"] = window_width
197
+ if window_height is not None:
198
+ payload["window_height"] = window_height
199
+ if country is not None:
200
+ payload["country"] = country
201
+ if device != "desktop":
202
+ payload["device"] = device
203
+ if block_ads:
204
+ payload["block_ads"] = True
205
+ if simulate_behavior:
206
+ payload["simulate_behavior"] = True
207
+ if cache_ttl_seconds is not None:
208
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
209
+ if session_id is not None:
210
+ payload["session_id"] = session_id
211
+ if cookies is not None:
212
+ payload["cookies"] = dict(cookies)
213
+ if headers is not None:
214
+ payload["headers"] = dict(headers)
215
+ return ScrapeUrlResponse.from_dict(await self._request("POST", "/v1/scrape/url", json=payload))
216
+
217
+ async def screenshot(
218
+ self,
219
+ url: str,
220
+ *,
221
+ viewport_width: int = 1280,
222
+ viewport_height: int = 720,
223
+ full_page: bool = False,
224
+ selector: Optional[str] = None,
225
+ format: str = "png",
226
+ jpeg_quality: int = 85,
227
+ wait_extra_ms: int = 0,
228
+ session_id: Optional[str] = None,
229
+ cache_ttl_seconds: Optional[int] = None,
230
+ render: str = "auto",
231
+ cookies: Optional[dict] = None,
232
+ headers: Optional[dict] = None,
233
+ include_usage: bool = False,
234
+ ) -> ScreenshotResponse:
235
+ payload: dict = {
236
+ "url": url,
237
+ "render": render,
238
+ "viewport_width": viewport_width,
239
+ "viewport_height": viewport_height,
240
+ "full_page": full_page,
241
+ "format": format,
242
+ "jpeg_quality": jpeg_quality,
243
+ "wait_extra_ms": wait_extra_ms,
244
+ }
245
+ if selector is not None:
246
+ payload["selector"] = selector
247
+ if session_id is not None:
248
+ payload["session_id"] = session_id
249
+ if cache_ttl_seconds is not None:
250
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
251
+ if cookies is not None:
252
+ payload["cookies"] = dict(cookies)
253
+ if headers is not None:
254
+ payload["headers"] = dict(headers)
255
+ if include_usage:
256
+ payload["include_usage"] = True
257
+ return ScreenshotResponse.from_dict(await self._request("POST", "/v1/scrape/screenshot", json=payload))
258
+
259
+ async def pdf(
260
+ self,
261
+ url: str,
262
+ *,
263
+ format: str = "Letter",
264
+ landscape: bool = False,
265
+ viewport_width: int = 1280,
266
+ viewport_height: int = 720,
267
+ wait_extra_ms: int = 0,
268
+ cookies: Optional[dict] = None,
269
+ headers: Optional[dict] = None,
270
+ session_id: Optional[str] = None,
271
+ include_usage: bool = False,
272
+ cache_ttl_seconds: Optional[int] = None,
273
+ ) -> PdfResponse:
274
+ payload: dict = {
275
+ "url": url,
276
+ "format": format,
277
+ "landscape": landscape,
278
+ "viewport_width": viewport_width,
279
+ "viewport_height": viewport_height,
280
+ "wait_extra_ms": wait_extra_ms,
281
+ }
282
+ if cookies is not None:
283
+ payload["cookies"] = dict(cookies)
284
+ if headers is not None:
285
+ payload["headers"] = dict(headers)
286
+ if session_id is not None:
287
+ payload["session_id"] = session_id
288
+ if include_usage:
289
+ payload["include_usage"] = True
290
+ if cache_ttl_seconds is not None:
291
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
292
+ return PdfResponse.from_dict(await self._request("POST", "/v1/scrape/pdf", json=payload))
293
+
294
+ async def search(
295
+ self,
296
+ query: str,
297
+ *,
298
+ num_results: int = 10,
299
+ depth: str = "balanced",
300
+ language: str = "en",
301
+ time_range: Optional[str] = None,
302
+ categories: Optional[str] = None,
303
+ include_domains: Optional[list] = None,
304
+ exclude_domains: Optional[list] = None,
305
+ topic: Optional[str] = None,
306
+ country: Optional[str] = None,
307
+ start_date: Optional[str] = None,
308
+ end_date: Optional[str] = None,
309
+ include_images: bool = False,
310
+ include_image_descriptions: bool = False,
311
+ image_sources: Optional[list] = None,
312
+ engines: Optional[list] = None,
313
+ include_usage: bool = False,
314
+ cache_ttl_seconds: Optional[int] = None,
315
+ page: int = 1,
316
+ ) -> SearchResponse:
317
+ payload = _build_search_payload(
318
+ query=query, num_results=num_results, language=language,
319
+ time_range=time_range, categories=categories,
320
+ include_domains=include_domains, exclude_domains=exclude_domains,
321
+ topic=topic, country=country, start_date=start_date, end_date=end_date,
322
+ include_images=include_images,
323
+ include_image_descriptions=include_image_descriptions,
324
+ image_sources=image_sources,
325
+ engines=engines,
326
+ include_usage=include_usage, cache_ttl_seconds=cache_ttl_seconds,
327
+ )
328
+ payload["depth"] = depth
329
+ if page != 1:
330
+ payload["page"] = page
331
+ return SearchResponse.from_dict(await self._request("POST", "/v1/search", json=payload))
332
+
333
+ async def search_deep(
334
+ self,
335
+ query: str,
336
+ *,
337
+ num_results: int = 10,
338
+ fetch_top: int = 5,
339
+ depth: str = "balanced",
340
+ render: str = "auto",
341
+ language: str = "en",
342
+ time_range: Optional[str] = None,
343
+ categories: Optional[str] = None,
344
+ include_domains: Optional[list] = None,
345
+ exclude_domains: Optional[list] = None,
346
+ topic: Optional[str] = None,
347
+ country: Optional[str] = None,
348
+ start_date: Optional[str] = None,
349
+ end_date: Optional[str] = None,
350
+ include_images: bool = False,
351
+ include_image_descriptions: bool = False,
352
+ image_sources: Optional[list] = None,
353
+ engines: Optional[list] = None,
354
+ include_usage: bool = False,
355
+ cache_ttl_seconds: Optional[int] = None,
356
+ ) -> DeepSearchResponse:
357
+ if fetch_top < 1:
358
+ raise ValueError("fetch_top must be >= 1 (use client.search() if you only need SERP)")
359
+ if fetch_top > 10:
360
+ raise ValueError("fetch_top must be <= 10")
361
+ payload = _build_search_payload(
362
+ query=query, num_results=num_results, language=language,
363
+ time_range=time_range, categories=categories,
364
+ include_domains=include_domains, exclude_domains=exclude_domains,
365
+ topic=topic, country=country, start_date=start_date, end_date=end_date,
366
+ include_images=include_images,
367
+ include_image_descriptions=include_image_descriptions,
368
+ image_sources=image_sources,
369
+ engines=engines,
370
+ include_usage=include_usage, cache_ttl_seconds=cache_ttl_seconds,
371
+ )
372
+ payload["fetch_top"] = fetch_top
373
+ payload["render"] = render
374
+ payload["depth"] = depth
375
+ return DeepSearchResponse.from_dict(await self._request("POST", "/v1/search/deep", json=payload))
376
+
377
+ async def search_answer(
378
+ self,
379
+ query: str,
380
+ *,
381
+ num_results: int = 10,
382
+ max_sources: int = 5,
383
+ depth: str = "balanced",
384
+ render: str = "auto",
385
+ language: str = "en",
386
+ time_range: Optional[str] = None,
387
+ categories: Optional[str] = None,
388
+ include_domains: Optional[list] = None,
389
+ exclude_domains: Optional[list] = None,
390
+ topic: Optional[str] = None,
391
+ country: Optional[str] = None,
392
+ start_date: Optional[str] = None,
393
+ end_date: Optional[str] = None,
394
+ engines: Optional[list] = None,
395
+ include_usage: bool = False,
396
+ cache_ttl_seconds: Optional[int] = None,
397
+ ) -> AnswerResponse:
398
+ payload = _build_search_payload(
399
+ query=query, num_results=num_results, language=language,
400
+ time_range=time_range, categories=categories,
401
+ include_domains=include_domains, exclude_domains=exclude_domains,
402
+ topic=topic, country=country, start_date=start_date, end_date=end_date,
403
+ include_images=False,
404
+ include_image_descriptions=False,
405
+ image_sources=None,
406
+ engines=engines,
407
+ include_usage=include_usage, cache_ttl_seconds=cache_ttl_seconds,
408
+ )
409
+ payload["max_sources"] = max_sources
410
+ payload["render"] = render
411
+ payload["depth"] = depth
412
+ return AnswerResponse.from_dict(await self._request("POST", "/v1/search/answer", json=payload))
413
+
414
+ async def translate(
415
+ self,
416
+ text: str,
417
+ target: str,
418
+ *,
419
+ source: str = "auto",
420
+ include_usage: bool = False,
421
+ ) -> TranslateResponse:
422
+ payload: dict = {"text": text, "target": target, "source": source}
423
+ if include_usage:
424
+ payload["include_usage"] = True
425
+ return TranslateResponse.from_dict(
426
+ await self._request("POST", "/v1/translate", json=payload)
427
+ )
428
+
429
+ async def audio_transcript(
430
+ self,
431
+ url: str,
432
+ *,
433
+ cache_ttl_seconds: Optional[int] = None,
434
+ language: Optional[str] = None,
435
+ cookies: Optional[str] = None,
436
+ include_usage: bool = False,
437
+ ) -> AudioTranscriptResponse:
438
+ payload: dict = {"url": url}
439
+ if cache_ttl_seconds is not None:
440
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
441
+ if language is not None:
442
+ payload["language"] = language
443
+ if cookies is not None:
444
+ payload["cookies"] = cookies
445
+ if include_usage:
446
+ payload["include_usage"] = True
447
+ return AudioTranscriptResponse.from_dict(
448
+ await self._request("POST", "/v1/audio/transcript", json=payload)
449
+ )
450
+
451
+ async def audio_transcript_async(
452
+ self,
453
+ url: str,
454
+ *,
455
+ cache_ttl_seconds: Optional[int] = None,
456
+ language: Optional[str] = None,
457
+ cookies: Optional[str] = None,
458
+ include_usage: bool = False,
459
+ ) -> RunCreatedResponse:
460
+ payload: dict = {"url": url}
461
+ if cache_ttl_seconds is not None:
462
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
463
+ if language is not None:
464
+ payload["language"] = language
465
+ if cookies is not None:
466
+ payload["cookies"] = cookies
467
+ if include_usage:
468
+ payload["include_usage"] = True
469
+ return RunCreatedResponse.from_dict(
470
+ await self._request("POST", "/v1/audio/transcript/async", json=payload)
471
+ )
472
+
473
+ async def maps_reviews(
474
+ self,
475
+ place_id: str,
476
+ *,
477
+ language: str = "en",
478
+ sort: Literal["newest", "most_relevant", "highest_rating", "lowest_rating"] = "newest",
479
+ max_reviews: int = 200,
480
+ place_url: Optional[str] = None,
481
+ include_usage: bool = False,
482
+ cache_ttl_seconds: Optional[int] = None,
483
+ ) -> MapsReviewsResponse:
484
+ payload: dict = {
485
+ "place_id": normalize_place_id(place_id),
486
+ "language": language,
487
+ "sort": sort,
488
+ "max_reviews": max_reviews,
489
+ }
490
+ if place_url is not None:
491
+ payload["place_url"] = place_url
492
+ if include_usage:
493
+ payload["include_usage"] = True
494
+ if cache_ttl_seconds is not None:
495
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
496
+ return MapsReviewsResponse.from_dict(
497
+ await self._request("POST", "/v1/scrape/google-maps/reviews", json=payload)
498
+ )
499
+
500
+ async def maps_reviews_async(
501
+ self,
502
+ place_id: str,
503
+ *,
504
+ language: str = "en",
505
+ sort: Literal["newest", "most_relevant", "highest_rating", "lowest_rating"] = "newest",
506
+ max_reviews: int = 200,
507
+ place_url: Optional[str] = None,
508
+ include_usage: bool = False,
509
+ cache_ttl_seconds: Optional[int] = None,
510
+ ) -> RunCreatedResponse:
511
+ payload: dict = {
512
+ "place_id": normalize_place_id(place_id),
513
+ "language": language,
514
+ "sort": sort,
515
+ "max_reviews": max_reviews,
516
+ }
517
+ if place_url is not None:
518
+ payload["place_url"] = place_url
519
+ if include_usage:
520
+ payload["include_usage"] = True
521
+ if cache_ttl_seconds is not None:
522
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
523
+ return RunCreatedResponse.from_dict(
524
+ await self._request("POST", "/v1/scrape/google-maps/reviews/async", json=payload)
525
+ )
526
+
527
+ async def gas_prices(
528
+ self,
529
+ location: str,
530
+ *,
531
+ grade: str = "regular",
532
+ limit: int = 20,
533
+ include_usage: bool = False,
534
+ cache_ttl_seconds: Optional[int] = None,
535
+ ) -> GasPricesResponse:
536
+ payload: dict = {
537
+ "location": location,
538
+ "grade": grade,
539
+ "limit": limit,
540
+ }
541
+ if include_usage:
542
+ payload["include_usage"] = True
543
+ if cache_ttl_seconds is not None:
544
+ payload["cache_ttl_seconds"] = cache_ttl_seconds
545
+ return GasPricesResponse.from_dict(
546
+ await self._request("POST", "/v1/scrape/gas-prices", json=payload)
547
+ )
548
+
549
+ async def get_run(self, run_id: str) -> RunStatusResponse:
550
+ return RunStatusResponse.from_dict(
551
+ await self._request("GET", f"/v1/scrape/runs/{quote(run_id, safe='')}")
552
+ )
553
+
554
+ async def cancel_run(self, run_id: str) -> RunStatusResponse:
555
+ return RunStatusResponse.from_dict(
556
+ await self._request("POST", f"/v1/scrape/runs/{quote(run_id, safe='')}/cancel")
557
+ )
558
+
559
+ async def wait_for_run(
560
+ self,
561
+ run_id: str,
562
+ *,
563
+ poll_interval: float = 3.0,
564
+ timeout: float = 300.0,
565
+ ) -> RunStatusResponse:
566
+ deadline = time.monotonic() + timeout
567
+ while True:
568
+ run = await self.get_run(run_id)
569
+ if run.status in _TERMINAL:
570
+ return run
571
+ if time.monotonic() >= deadline:
572
+ raise TimeoutError(f"Run {run_id} did not complete within {timeout}s")
573
+ await asyncio.sleep(poll_interval)