alterlab 2.0.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.
alterlab/client.py ADDED
@@ -0,0 +1,1332 @@
1
+ """
2
+ AlterLab Python SDK - Official client for the AlterLab Web Scraping API.
3
+
4
+ Full feature parity with the AlterLab API including:
5
+ - Unified scraping endpoint with all modes (auto, html, js, pdf, ocr)
6
+ - Automatic job polling with exponential backoff
7
+ - Advanced options (render_js, screenshot, markdown, PDF generation, OCR)
8
+ - Cost controls (max_tier, prefer_cost/speed, fail_fast)
9
+ - Structured extraction (schemas, prompts, profiles)
10
+ - Cost estimation before scraping
11
+ - Usage tracking and credit management
12
+ - Batch scraping with webhooks
13
+ - BYOP (Bring Your Own Proxy) support
14
+ - Comprehensive error handling with retries
15
+
16
+ Pricing (Pay-as-you-go):
17
+ Tier 1 (curl): $0.0002/req - 5,000 per $1
18
+ Tier 2 (http): $0.0003/req - 3,333 per $1
19
+ Tier 3 (stealth): $0.0005/req - 2,000 per $1
20
+ Tier 4 (browser): $0.001/req - 1,000 per $1
21
+ Tier 5 (captcha): $0.02/req - 50 per $1
22
+
23
+ Example:
24
+ from alterlab import AlterLab
25
+
26
+ client = AlterLab(api_key="sk_live_...")
27
+ result = client.scrape("https://example.com")
28
+ print(result.content)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import os
35
+ import time
36
+ import warnings
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Dict, List, Literal, Optional, Union
39
+
40
+ try:
41
+ import httpx
42
+ except ImportError:
43
+ raise ImportError(
44
+ "httpx is required for the AlterLab SDK. Install with: pip install httpx"
45
+ )
46
+
47
+
48
+ __version__ = "2.0.0"
49
+
50
+
51
+ # =============================================================================
52
+ # EXCEPTIONS
53
+ # =============================================================================
54
+
55
+
56
+ class AlterLabError(Exception):
57
+ """Base exception for all AlterLab SDK errors."""
58
+
59
+ pass
60
+
61
+
62
+ class AlterLabAPIError(AlterLabError):
63
+ """API returned an error response."""
64
+
65
+ def __init__(
66
+ self,
67
+ status_code: int,
68
+ message: str,
69
+ response: Optional[httpx.Response] = None,
70
+ ):
71
+ self.status_code = status_code
72
+ self.message = message
73
+ self.response = response
74
+ super().__init__(f"[{status_code}] {message}")
75
+
76
+
77
+ class AuthenticationError(AlterLabAPIError):
78
+ """Invalid or missing API key (401)."""
79
+
80
+ pass
81
+
82
+
83
+ class InsufficientCreditsError(AlterLabAPIError):
84
+ """Insufficient balance to complete request (402)."""
85
+
86
+ pass
87
+
88
+
89
+ class RateLimitError(AlterLabAPIError):
90
+ """Rate limit exceeded (429)."""
91
+
92
+ def __init__(
93
+ self,
94
+ message: str,
95
+ retry_after: Optional[int] = None,
96
+ response: Optional[httpx.Response] = None,
97
+ ):
98
+ super().__init__(429, message, response)
99
+ self.retry_after = retry_after
100
+
101
+
102
+ class ValidationError(AlterLabAPIError):
103
+ """Request validation failed (422)."""
104
+
105
+ pass
106
+
107
+
108
+ class ScrapeError(AlterLabAPIError):
109
+ """Scraping operation failed."""
110
+
111
+ def __init__(
112
+ self,
113
+ status_code: int,
114
+ message: str,
115
+ code: Optional[str] = None,
116
+ response: Optional[httpx.Response] = None,
117
+ ):
118
+ super().__init__(status_code, message, response)
119
+ self.code = code
120
+
121
+
122
+ class TimeoutError(AlterLabError):
123
+ """Request or job polling timed out."""
124
+
125
+ pass
126
+
127
+
128
+ # =============================================================================
129
+ # DATA CLASSES
130
+ # =============================================================================
131
+
132
+
133
+ @dataclass
134
+ class AdvancedOptions:
135
+ """Advanced scraping options.
136
+
137
+ Attributes:
138
+ render_js: Render JavaScript using headless browser
139
+ screenshot: Capture full-page screenshot (requires render_js)
140
+ markdown: Extract content as Markdown
141
+ generate_pdf: Generate PDF of rendered page (requires render_js)
142
+ ocr: Extract text from images using OCR
143
+ use_proxy: Route through premium proxy
144
+ use_own_proxy: Use your integrated BYOP proxy (20% discount)
145
+ use_system_proxy: Override BYOP and use AlterLab's proxy
146
+ proxy_integration_id: Specific proxy integration ID (for BYOP)
147
+ proxy_country: Preferred proxy country code (e.g., 'US', 'DE')
148
+ wait_condition: Wait condition for JS rendering
149
+ remove_cookie_banners: Remove cookie consent banners from HTML
150
+ """
151
+
152
+ render_js: bool = False
153
+ screenshot: bool = False
154
+ markdown: bool = False
155
+ generate_pdf: bool = False
156
+ ocr: bool = False
157
+ use_proxy: bool = False
158
+ use_own_proxy: bool = False
159
+ use_system_proxy: bool = False
160
+ proxy_integration_id: Optional[str] = None
161
+ proxy_country: Optional[str] = None
162
+ wait_condition: Literal["domcontentloaded", "networkidle", "load"] = "networkidle"
163
+ remove_cookie_banners: bool = True
164
+
165
+ def __post_init__(self):
166
+ if self.screenshot and not self.render_js:
167
+ raise ValueError("screenshot requires render_js=True")
168
+ if self.generate_pdf and not self.render_js:
169
+ raise ValueError("generate_pdf requires render_js=True")
170
+ if self.wait_condition not in ("domcontentloaded", "networkidle", "load"):
171
+ raise ValueError(
172
+ "wait_condition must be 'domcontentloaded', 'networkidle', or 'load'"
173
+ )
174
+
175
+ def to_dict(self) -> Dict[str, Any]:
176
+ """Convert to API request format."""
177
+ return {
178
+ "render_js": self.render_js,
179
+ "screenshot": self.screenshot,
180
+ "markdown": self.markdown,
181
+ "generate_pdf": self.generate_pdf,
182
+ "ocr": self.ocr,
183
+ "use_proxy": self.use_proxy,
184
+ "use_own_proxy": self.use_own_proxy,
185
+ "use_system_proxy": self.use_system_proxy,
186
+ "proxy_integration_id": self.proxy_integration_id,
187
+ "proxy_country": self.proxy_country,
188
+ "wait_condition": self.wait_condition,
189
+ "remove_cookie_banners": self.remove_cookie_banners,
190
+ }
191
+
192
+
193
+ @dataclass
194
+ class CostControls:
195
+ """Cost control parameters for scraping requests.
196
+
197
+ Tier levels (1-5):
198
+ 1: curl - Direct HTTP ($0.0002)
199
+ 2: http - HTTPX with TLS fingerprinting ($0.0003)
200
+ 3: stealth - curl_cffi browser impersonation ($0.0005)
201
+ 4: browser - Playwright full rendering ($0.001)
202
+ 5: captcha - Browser + CAPTCHA solving ($0.02)
203
+
204
+ Attributes:
205
+ max_tier: Maximum tier to escalate to ("1"-"5")
206
+ prefer_cost: Optimize for lowest cost (start at tier 1)
207
+ prefer_speed: Optimize for speed (start at higher tier)
208
+ fail_fast: Return error instead of escalating to expensive tiers
209
+ """
210
+
211
+ max_tier: Optional[Literal["1", "2", "3", "4", "5"]] = None
212
+ prefer_cost: bool = False
213
+ prefer_speed: bool = False
214
+ fail_fast: bool = False
215
+
216
+ def to_dict(self) -> Dict[str, Any]:
217
+ """Convert to API request format."""
218
+ result: Dict[str, Any] = {
219
+ "prefer_cost": self.prefer_cost,
220
+ "prefer_speed": self.prefer_speed,
221
+ "fail_fast": self.fail_fast,
222
+ }
223
+ if self.max_tier:
224
+ result["max_tier"] = self.max_tier
225
+ return result
226
+
227
+
228
+ @dataclass
229
+ class TierEscalation:
230
+ """Details of a tier escalation attempt."""
231
+
232
+ tier: str
233
+ result: Literal["success", "failed", "skipped"]
234
+ credits: int
235
+ duration_ms: Optional[int] = None
236
+ error: Optional[str] = None
237
+
238
+
239
+ @dataclass
240
+ class BillingDetails:
241
+ """Detailed billing information for a scrape request."""
242
+
243
+ total_credits: int
244
+ tier_used: str
245
+ escalations: List[TierEscalation] = field(default_factory=list)
246
+ savings: int = 0
247
+ optimization_suggestion: Optional[str] = None
248
+ byop_applied: bool = False
249
+ byop_discount_percent: Optional[float] = None
250
+ original_cost_microcents: Optional[int] = None
251
+ final_cost_microcents: Optional[int] = None
252
+
253
+ @property
254
+ def cost_dollars(self) -> float:
255
+ """Get the final cost in dollars."""
256
+ if self.final_cost_microcents is not None:
257
+ return self.final_cost_microcents / 1_000_000
258
+ # Fallback to legacy credit calculation
259
+ tier_costs = {"1": 0.0002, "2": 0.0003, "3": 0.0005, "4": 0.001, "5": 0.02}
260
+ return tier_costs.get(self.tier_used, 0.0003)
261
+
262
+
263
+ @dataclass
264
+ class ScrapeResult:
265
+ """Result from a scrape operation."""
266
+
267
+ url: str
268
+ status_code: int
269
+ content: Union[str, Dict[str, Any]]
270
+ title: Optional[str] = None
271
+ author: Optional[str] = None
272
+ published_at: Optional[str] = None
273
+ metadata: Dict[str, Any] = field(default_factory=dict)
274
+ headers: Dict[str, str] = field(default_factory=dict)
275
+ cached: bool = False
276
+ cached_at: Optional[str] = None
277
+ expires_at: Optional[str] = None
278
+ response_time_ms: int = 0
279
+ size_bytes: int = 0
280
+ raw_html: Optional[str] = None
281
+ screenshot_url: Optional[str] = None
282
+ pdf_url: Optional[str] = None
283
+ ocr_results: Optional[List[Dict[str, Any]]] = None
284
+ proxy_used: Optional[Dict[str, Any]] = None
285
+ filtered_content: Optional[Dict[str, Any]] = None
286
+ billing: Optional[BillingDetails] = None
287
+ extraction_method: str = "algorithmic"
288
+ method_details: Optional[Dict[str, Any]] = None
289
+
290
+ @property
291
+ def text(self) -> str:
292
+ """Get text content."""
293
+ if isinstance(self.content, dict):
294
+ return self.content.get("text", "")
295
+ return self.content
296
+
297
+ @property
298
+ def html(self) -> str:
299
+ """Get HTML content."""
300
+ if isinstance(self.content, dict):
301
+ return self.content.get("html", "")
302
+ return self.content
303
+
304
+ @property
305
+ def json(self) -> Dict[str, Any]:
306
+ """Get structured JSON content."""
307
+ if isinstance(self.content, dict):
308
+ return self.content.get("json", {})
309
+ return {}
310
+
311
+ @property
312
+ def markdown_content(self) -> str:
313
+ """Get markdown content."""
314
+ if isinstance(self.content, dict):
315
+ return self.content.get("markdown", "")
316
+ return ""
317
+
318
+ @property
319
+ def credits_used(self) -> int:
320
+ """Get credits used (legacy compatibility)."""
321
+ if self.billing:
322
+ return self.billing.total_credits
323
+ return 0
324
+
325
+ @property
326
+ def tier_used(self) -> str:
327
+ """Get tier used."""
328
+ if self.billing:
329
+ return self.billing.tier_used
330
+ return "1"
331
+
332
+
333
+ @dataclass
334
+ class CostEstimate:
335
+ """Cost estimation for a scrape request."""
336
+
337
+ url: str
338
+ estimated_tier: str
339
+ estimated_credits: int
340
+ confidence: Literal["low", "medium", "high"]
341
+ max_possible_credits: int
342
+ reasoning: str
343
+
344
+ @property
345
+ def estimated_cost_dollars(self) -> float:
346
+ """Get estimated cost in dollars."""
347
+ tier_costs = {"1": 0.0002, "2": 0.0003, "3": 0.0005, "4": 0.001, "5": 0.02}
348
+ return tier_costs.get(self.estimated_tier, 0.0003)
349
+
350
+
351
+ @dataclass
352
+ class UsageStats:
353
+ """Account usage statistics."""
354
+
355
+ credits_available: int
356
+ credits_used_month: int
357
+ credits_limit: int
358
+ plan: str
359
+ period_start: str
360
+ period_end: str
361
+
362
+ @property
363
+ def balance_dollars(self) -> float:
364
+ """Get balance in dollars (microcents to dollars)."""
365
+ return self.credits_available / 1_000_000
366
+
367
+
368
+ @dataclass
369
+ class JobStatus:
370
+ """Status of an async scrape job."""
371
+
372
+ job_id: str
373
+ status: Literal["pending", "running", "succeeded", "failed"]
374
+ result: Optional[ScrapeResult] = None
375
+ error: Optional[str] = None
376
+
377
+
378
+ # =============================================================================
379
+ # CLIENT
380
+ # =============================================================================
381
+
382
+
383
+ class AlterLab:
384
+ """AlterLab API client.
385
+
386
+ Example:
387
+ # Synchronous usage
388
+ client = AlterLab(api_key="sk_live_...")
389
+ result = client.scrape("https://example.com")
390
+ print(result.text)
391
+
392
+ # With options
393
+ result = client.scrape(
394
+ "https://example.com",
395
+ mode="js",
396
+ advanced=AdvancedOptions(render_js=True, screenshot=True),
397
+ )
398
+ print(result.screenshot_url)
399
+
400
+ # Cost estimation
401
+ estimate = client.estimate_cost("https://linkedin.com")
402
+ print(f"Estimated: ${estimate.estimated_cost_dollars:.4f}")
403
+ """
404
+
405
+ DEFAULT_BASE_URL = "https://api.alterlab.io"
406
+ DEFAULT_TIMEOUT = 120
407
+ DEFAULT_MAX_RETRIES = 3
408
+ DEFAULT_RETRY_DELAY = 1.0
409
+
410
+ def __init__(
411
+ self,
412
+ api_key: Optional[str] = None,
413
+ base_url: Optional[str] = None,
414
+ timeout: int = DEFAULT_TIMEOUT,
415
+ max_retries: int = DEFAULT_MAX_RETRIES,
416
+ retry_delay: float = DEFAULT_RETRY_DELAY,
417
+ ):
418
+ """Initialize AlterLab client.
419
+
420
+ Args:
421
+ api_key: Your API key. If not provided, reads from ALTERLAB_API_KEY env var.
422
+ base_url: API base URL. Defaults to https://api.alterlab.io
423
+ timeout: Default request timeout in seconds.
424
+ max_retries: Maximum number of retries for transient failures.
425
+ retry_delay: Initial delay between retries (exponential backoff).
426
+
427
+ Raises:
428
+ ValueError: If no API key is provided or found in environment.
429
+ """
430
+ self.api_key = api_key or os.environ.get("ALTERLAB_API_KEY")
431
+ if not self.api_key:
432
+ raise ValueError(
433
+ "API key required. Pass api_key or set ALTERLAB_API_KEY environment variable."
434
+ )
435
+
436
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
437
+ self.timeout = timeout
438
+ self.max_retries = max_retries
439
+ self.retry_delay = retry_delay
440
+
441
+ self._client: Optional[httpx.Client] = None
442
+ self._async_client: Optional[httpx.AsyncClient] = None
443
+
444
+ def _get_client(self) -> httpx.Client:
445
+ """Get or create synchronous HTTP client."""
446
+ if self._client is None:
447
+ self._client = httpx.Client(
448
+ base_url=self.base_url,
449
+ headers={
450
+ "X-API-Key": self.api_key,
451
+ "User-Agent": f"AlterLab-Python-SDK/{__version__}",
452
+ "Content-Type": "application/json",
453
+ },
454
+ timeout=httpx.Timeout(self.timeout),
455
+ )
456
+ return self._client
457
+
458
+ def _get_async_client(self) -> httpx.AsyncClient:
459
+ """Get or create async HTTP client."""
460
+ if self._async_client is None:
461
+ self._async_client = httpx.AsyncClient(
462
+ base_url=self.base_url,
463
+ headers={
464
+ "X-API-Key": self.api_key,
465
+ "User-Agent": f"AlterLab-Python-SDK/{__version__}",
466
+ "Content-Type": "application/json",
467
+ },
468
+ timeout=httpx.Timeout(self.timeout),
469
+ )
470
+ return self._async_client
471
+
472
+ def _handle_error_response(self, response: httpx.Response) -> None:
473
+ """Convert HTTP error responses to appropriate exceptions."""
474
+ try:
475
+ data = response.json()
476
+ detail = data.get("detail", response.text)
477
+ except Exception:
478
+ detail = response.text
479
+
480
+ if response.status_code == 401:
481
+ raise AuthenticationError(401, detail, response)
482
+ elif response.status_code == 402:
483
+ raise InsufficientCreditsError(402, detail, response)
484
+ elif response.status_code == 422:
485
+ raise ValidationError(422, detail, response)
486
+ elif response.status_code == 429:
487
+ retry_after = response.headers.get("Retry-After")
488
+ raise RateLimitError(
489
+ detail,
490
+ retry_after=int(retry_after) if retry_after else None,
491
+ response=response,
492
+ )
493
+ elif response.status_code >= 400:
494
+ raise AlterLabAPIError(response.status_code, detail, response)
495
+
496
+ def _request_with_retry(
497
+ self,
498
+ method: str,
499
+ path: str,
500
+ **kwargs,
501
+ ) -> httpx.Response:
502
+ """Make HTTP request with retry logic."""
503
+ client = self._get_client()
504
+ last_error: Optional[Exception] = None
505
+
506
+ for attempt in range(self.max_retries):
507
+ try:
508
+ response = client.request(method, path, **kwargs)
509
+
510
+ if response.status_code >= 400:
511
+ # Don't retry client errors (4xx) except rate limits
512
+ if 400 <= response.status_code < 500 and response.status_code != 429:
513
+ self._handle_error_response(response)
514
+
515
+ # Retry server errors and rate limits
516
+ last_error = AlterLabAPIError(
517
+ response.status_code,
518
+ response.text,
519
+ response,
520
+ )
521
+
522
+ retry_after = response.headers.get("Retry-After")
523
+ if retry_after:
524
+ time.sleep(float(retry_after))
525
+ else:
526
+ time.sleep(self.retry_delay * (2**attempt))
527
+ continue
528
+
529
+ return response
530
+
531
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
532
+ last_error = AlterLabError(f"Network error: {e}")
533
+ if attempt < self.max_retries - 1:
534
+ time.sleep(self.retry_delay * (2**attempt))
535
+ continue
536
+
537
+ raise last_error or AlterLabError("Request failed after retries")
538
+
539
+ async def _async_request_with_retry(
540
+ self,
541
+ method: str,
542
+ path: str,
543
+ **kwargs,
544
+ ) -> httpx.Response:
545
+ """Make async HTTP request with retry logic."""
546
+ client = self._get_async_client()
547
+ last_error: Optional[Exception] = None
548
+
549
+ for attempt in range(self.max_retries):
550
+ try:
551
+ response = await client.request(method, path, **kwargs)
552
+
553
+ if response.status_code >= 400:
554
+ if 400 <= response.status_code < 500 and response.status_code != 429:
555
+ self._handle_error_response(response)
556
+
557
+ last_error = AlterLabAPIError(
558
+ response.status_code,
559
+ response.text,
560
+ response,
561
+ )
562
+
563
+ retry_after = response.headers.get("Retry-After")
564
+ if retry_after:
565
+ await asyncio.sleep(float(retry_after))
566
+ else:
567
+ await asyncio.sleep(self.retry_delay * (2**attempt))
568
+ continue
569
+
570
+ return response
571
+
572
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
573
+ last_error = AlterLabError(f"Network error: {e}")
574
+ if attempt < self.max_retries - 1:
575
+ await asyncio.sleep(self.retry_delay * (2**attempt))
576
+ continue
577
+
578
+ raise last_error or AlterLabError("Request failed after retries")
579
+
580
+ def _parse_scrape_response(self, data: Dict[str, Any]) -> ScrapeResult:
581
+ """Parse API response into ScrapeResult."""
582
+ billing_data = data.get("billing", {})
583
+ billing = BillingDetails(
584
+ total_credits=billing_data.get("total_credits", 0),
585
+ tier_used=billing_data.get("tier_used", "1"),
586
+ escalations=[
587
+ TierEscalation(
588
+ tier=e.get("tier", "1"),
589
+ result=e.get("result", "success"),
590
+ credits=e.get("credits", 0),
591
+ duration_ms=e.get("duration_ms"),
592
+ error=e.get("error"),
593
+ )
594
+ for e in billing_data.get("escalations", [])
595
+ ],
596
+ savings=billing_data.get("savings", 0),
597
+ optimization_suggestion=billing_data.get("optimization_suggestion"),
598
+ byop_applied=billing_data.get("byop_applied", False),
599
+ byop_discount_percent=billing_data.get("byop_discount_percent"),
600
+ original_cost_microcents=billing_data.get("original_cost_microcents"),
601
+ final_cost_microcents=billing_data.get("final_cost_microcents"),
602
+ )
603
+
604
+ return ScrapeResult(
605
+ url=data.get("url", ""),
606
+ status_code=data.get("status_code", 200),
607
+ content=data.get("content", ""),
608
+ title=data.get("title"),
609
+ author=data.get("author"),
610
+ published_at=data.get("published_at"),
611
+ metadata=data.get("metadata", {}),
612
+ headers=data.get("headers", {}),
613
+ cached=data.get("cached", False),
614
+ cached_at=data.get("cached_at"),
615
+ expires_at=data.get("expires_at"),
616
+ response_time_ms=data.get("response_time_ms", 0),
617
+ size_bytes=data.get("size_bytes", 0),
618
+ raw_html=data.get("raw_html"),
619
+ screenshot_url=data.get("screenshot_url"),
620
+ pdf_url=data.get("pdf_url"),
621
+ ocr_results=data.get("ocr_results"),
622
+ proxy_used=data.get("proxy_used"),
623
+ filtered_content=data.get("filtered_content"),
624
+ billing=billing,
625
+ extraction_method=data.get("extraction_method", "algorithmic"),
626
+ method_details=data.get("method_details"),
627
+ )
628
+
629
+ def _build_scrape_payload(
630
+ self,
631
+ url: str,
632
+ mode: Literal["auto", "html", "js", "pdf", "ocr"] = "auto",
633
+ sync: bool = True,
634
+ advanced: Optional[AdvancedOptions] = None,
635
+ cost_controls: Optional[CostControls] = None,
636
+ cache: bool = False,
637
+ cache_ttl: Optional[int] = None,
638
+ force_refresh: bool = False,
639
+ include_raw_html: bool = False,
640
+ timeout: Optional[int] = None,
641
+ formats: Optional[List[Literal["text", "json", "html", "markdown"]]] = None,
642
+ extraction_schema: Optional[Dict[str, Any]] = None,
643
+ extraction_prompt: Optional[str] = None,
644
+ extraction_profile: Optional[
645
+ Literal["auto", "product", "article", "job_posting", "faq", "recipe", "event"]
646
+ ] = None,
647
+ evidence: bool = False,
648
+ promote_schema_org: bool = True,
649
+ wait_for: Optional[str] = None,
650
+ screenshot: bool = False,
651
+ wait_until: str = "networkidle",
652
+ enable_scroll: Optional[bool] = None,
653
+ pdf_format: str = "markdown",
654
+ ocr_language: str = "eng",
655
+ ) -> Dict[str, Any]:
656
+ """Build the request payload for scraping."""
657
+ payload: Dict[str, Any] = {
658
+ "url": url,
659
+ "mode": mode,
660
+ "sync": sync,
661
+ "cache": cache,
662
+ "force_refresh": force_refresh,
663
+ "include_raw_html": include_raw_html,
664
+ "timeout": timeout or self.timeout,
665
+ "evidence": evidence,
666
+ "promote_schema_org": promote_schema_org,
667
+ "wait_until": wait_until,
668
+ }
669
+
670
+ if cache_ttl is not None:
671
+ payload["cache_ttl"] = cache_ttl
672
+
673
+ if advanced:
674
+ payload["advanced"] = advanced.to_dict()
675
+
676
+ if cost_controls:
677
+ payload["cost_controls"] = cost_controls.to_dict()
678
+
679
+ if formats:
680
+ payload["formats"] = formats
681
+
682
+ if extraction_schema:
683
+ payload["extraction_schema"] = extraction_schema
684
+
685
+ if extraction_prompt:
686
+ payload["extraction_prompt"] = extraction_prompt
687
+
688
+ if extraction_profile:
689
+ payload["extraction_profile"] = extraction_profile
690
+
691
+ if wait_for:
692
+ payload["wait_for"] = wait_for
693
+
694
+ if screenshot:
695
+ payload["screenshot"] = screenshot
696
+
697
+ if enable_scroll is not None:
698
+ payload["enable_scroll"] = enable_scroll
699
+
700
+ if mode == "pdf":
701
+ payload["pdf_format"] = pdf_format
702
+
703
+ if mode == "ocr":
704
+ payload["ocr_language"] = ocr_language
705
+
706
+ return payload
707
+
708
+ # =========================================================================
709
+ # SYNCHRONOUS API
710
+ # =========================================================================
711
+
712
+ def scrape(
713
+ self,
714
+ url: str,
715
+ mode: Literal["auto", "html", "js", "pdf", "ocr"] = "auto",
716
+ sync: bool = True,
717
+ advanced: Optional[AdvancedOptions] = None,
718
+ cost_controls: Optional[CostControls] = None,
719
+ cache: bool = False,
720
+ cache_ttl: Optional[int] = None,
721
+ force_refresh: bool = False,
722
+ include_raw_html: bool = False,
723
+ timeout: Optional[int] = None,
724
+ formats: Optional[List[Literal["text", "json", "html", "markdown"]]] = None,
725
+ extraction_schema: Optional[Dict[str, Any]] = None,
726
+ extraction_prompt: Optional[str] = None,
727
+ extraction_profile: Optional[
728
+ Literal["auto", "product", "article", "job_posting", "faq", "recipe", "event"]
729
+ ] = None,
730
+ evidence: bool = False,
731
+ promote_schema_org: bool = True,
732
+ wait_for: Optional[str] = None,
733
+ screenshot: bool = False,
734
+ wait_until: str = "networkidle",
735
+ enable_scroll: Optional[bool] = None,
736
+ pdf_format: str = "markdown",
737
+ ocr_language: str = "eng",
738
+ poll_interval: float = 0.5,
739
+ poll_timeout: float = 300.0,
740
+ ) -> ScrapeResult:
741
+ """Scrape a URL.
742
+
743
+ Args:
744
+ url: URL to scrape.
745
+ mode: Scraping mode ('auto', 'html', 'js', 'pdf', 'ocr').
746
+ sync: Wait for result (True) or return job ID immediately (False).
747
+ advanced: Advanced scraping options.
748
+ cost_controls: Cost control parameters.
749
+ cache: Enable caching (opt-in, default False).
750
+ cache_ttl: Cache TTL in seconds (60-86400).
751
+ force_refresh: Bypass cache even if cache=True.
752
+ include_raw_html: Include raw HTML in response.
753
+ timeout: Request timeout in seconds.
754
+ formats: Output formats ('text', 'json', 'html', 'markdown').
755
+ extraction_schema: JSON Schema for structured extraction.
756
+ extraction_prompt: Natural language extraction instructions.
757
+ extraction_profile: Pre-defined extraction profile.
758
+ evidence: Include provenance for extracted fields.
759
+ promote_schema_org: Use Schema.org as primary structure.
760
+ wait_for: CSS selector to wait for (JS mode).
761
+ screenshot: Capture screenshot (JS mode).
762
+ wait_until: Wait condition ('domcontentloaded', 'networkidle', 'load').
763
+ enable_scroll: Enable scrolling for lazy-loaded images.
764
+ pdf_format: PDF output format ('text', 'markdown').
765
+ ocr_language: OCR language code (e.g., 'eng', 'fra').
766
+ poll_interval: Job polling interval in seconds.
767
+ poll_timeout: Maximum time to wait for job completion.
768
+
769
+ Returns:
770
+ ScrapeResult with content, metadata, and billing details.
771
+
772
+ Raises:
773
+ AuthenticationError: Invalid API key.
774
+ InsufficientCreditsError: Not enough balance.
775
+ RateLimitError: Rate limit exceeded.
776
+ ValidationError: Invalid request parameters.
777
+ ScrapeError: Scraping failed.
778
+ TimeoutError: Job timed out.
779
+ """
780
+ payload = self._build_scrape_payload(
781
+ url=url,
782
+ mode=mode,
783
+ sync=sync,
784
+ advanced=advanced,
785
+ cost_controls=cost_controls,
786
+ cache=cache,
787
+ cache_ttl=cache_ttl,
788
+ force_refresh=force_refresh,
789
+ include_raw_html=include_raw_html,
790
+ timeout=timeout,
791
+ formats=formats,
792
+ extraction_schema=extraction_schema,
793
+ extraction_prompt=extraction_prompt,
794
+ extraction_profile=extraction_profile,
795
+ evidence=evidence,
796
+ promote_schema_org=promote_schema_org,
797
+ wait_for=wait_for,
798
+ screenshot=screenshot,
799
+ wait_until=wait_until,
800
+ enable_scroll=enable_scroll,
801
+ pdf_format=pdf_format,
802
+ ocr_language=ocr_language,
803
+ )
804
+
805
+ response = self._request_with_retry("POST", "/api/v1/scrape", json=payload)
806
+ data = response.json()
807
+
808
+ # Handle async response (202 with job_id)
809
+ if response.status_code == 202 and "job_id" in data:
810
+ if not sync:
811
+ # Return job info for manual polling
812
+ return ScrapeResult(
813
+ url=url,
814
+ status_code=202,
815
+ content={"job_id": data["job_id"]},
816
+ metadata={"job_id": data["job_id"]},
817
+ )
818
+
819
+ # Poll for completion
820
+ return self.wait_for_job(
821
+ data["job_id"],
822
+ poll_interval=poll_interval,
823
+ poll_timeout=poll_timeout,
824
+ )
825
+
826
+ return self._parse_scrape_response(data)
827
+
828
+ def wait_for_job(
829
+ self,
830
+ job_id: str,
831
+ poll_interval: float = 0.5,
832
+ poll_timeout: float = 300.0,
833
+ ) -> ScrapeResult:
834
+ """Wait for an async job to complete.
835
+
836
+ Args:
837
+ job_id: Job ID to poll.
838
+ poll_interval: Polling interval in seconds.
839
+ poll_timeout: Maximum wait time in seconds.
840
+
841
+ Returns:
842
+ ScrapeResult when job completes.
843
+
844
+ Raises:
845
+ TimeoutError: Job didn't complete within timeout.
846
+ ScrapeError: Job failed.
847
+ """
848
+ start_time = time.time()
849
+
850
+ while True:
851
+ elapsed = time.time() - start_time
852
+ if elapsed > poll_timeout:
853
+ raise TimeoutError(
854
+ f"Job {job_id} did not complete within {poll_timeout} seconds"
855
+ )
856
+
857
+ status = self.get_job_status(job_id)
858
+
859
+ if status.status == "succeeded":
860
+ if status.result:
861
+ return status.result
862
+ raise ScrapeError(200, "Job completed but no result returned")
863
+
864
+ elif status.status == "failed":
865
+ raise ScrapeError(
866
+ 422,
867
+ status.error or "Job failed",
868
+ code="JOB_FAILED",
869
+ )
870
+
871
+ time.sleep(poll_interval)
872
+
873
+ def get_job_status(self, job_id: str) -> JobStatus:
874
+ """Get the status of an async job.
875
+
876
+ Args:
877
+ job_id: Job ID to check.
878
+
879
+ Returns:
880
+ JobStatus with current status and result if complete.
881
+ """
882
+ response = self._request_with_retry("GET", f"/api/v1/jobs/{job_id}")
883
+ data = response.json()
884
+
885
+ result = None
886
+ if data.get("status") in ("succeeded", "completed") and data.get("result"):
887
+ result = self._parse_scrape_response(data["result"])
888
+
889
+ return JobStatus(
890
+ job_id=job_id,
891
+ status=data.get("status", "pending"),
892
+ result=result,
893
+ error=data.get("error"),
894
+ )
895
+
896
+ def estimate_cost(
897
+ self,
898
+ url: str,
899
+ mode: Literal["auto", "html", "js", "pdf", "ocr"] = "auto",
900
+ advanced: Optional[AdvancedOptions] = None,
901
+ cost_controls: Optional[CostControls] = None,
902
+ ) -> CostEstimate:
903
+ """Estimate the cost of a scrape request.
904
+
905
+ Args:
906
+ url: URL to estimate.
907
+ mode: Scraping mode.
908
+ advanced: Advanced options to include in estimate.
909
+ cost_controls: Cost control parameters.
910
+
911
+ Returns:
912
+ CostEstimate with estimated tier, credits, and confidence.
913
+ """
914
+ payload: Dict[str, Any] = {"url": url, "mode": mode}
915
+
916
+ if advanced:
917
+ payload["advanced"] = advanced.to_dict()
918
+ if cost_controls:
919
+ payload["cost_controls"] = cost_controls.to_dict()
920
+
921
+ response = self._request_with_retry(
922
+ "POST", "/api/v1/scrape/estimate", json=payload
923
+ )
924
+ data = response.json()
925
+
926
+ return CostEstimate(
927
+ url=data.get("url", url),
928
+ estimated_tier=data.get("estimated_tier", "2"),
929
+ estimated_credits=data.get("estimated_credits", 2),
930
+ confidence=data.get("confidence", "medium"),
931
+ max_possible_credits=data.get("max_possible_credits", 20),
932
+ reasoning=data.get("reasoning", ""),
933
+ )
934
+
935
+ def get_usage(self) -> UsageStats:
936
+ """Get current usage statistics and balance.
937
+
938
+ Returns:
939
+ UsageStats with credits, plan, and billing period info.
940
+ """
941
+ response = self._request_with_retry("GET", "/api/v1/usage")
942
+ data = response.json()
943
+
944
+ return UsageStats(
945
+ credits_available=data.get("credits_available", 0),
946
+ credits_used_month=data.get("credits_used_month", 0),
947
+ credits_limit=data.get("credits_limit", 0),
948
+ plan=data.get("plan", ""),
949
+ period_start=data.get("period_start", ""),
950
+ period_end=data.get("period_end", ""),
951
+ )
952
+
953
+ # =========================================================================
954
+ # CONVENIENCE METHODS
955
+ # =========================================================================
956
+
957
+ def scrape_html(self, url: str, **kwargs) -> ScrapeResult:
958
+ """Scrape HTML content without JavaScript rendering.
959
+
960
+ This is the fastest and cheapest option (Tier 1: $0.0002/request).
961
+
962
+ Args:
963
+ url: URL to scrape.
964
+ **kwargs: Additional arguments passed to scrape().
965
+
966
+ Returns:
967
+ ScrapeResult with HTML content.
968
+ """
969
+ return self.scrape(url, mode="html", **kwargs)
970
+
971
+ def scrape_js(
972
+ self,
973
+ url: str,
974
+ screenshot: bool = False,
975
+ wait_for: Optional[str] = None,
976
+ **kwargs,
977
+ ) -> ScrapeResult:
978
+ """Scrape with JavaScript rendering.
979
+
980
+ Uses Playwright browser automation (Tier 4: $0.001/request).
981
+
982
+ Args:
983
+ url: URL to scrape.
984
+ screenshot: Capture full-page screenshot.
985
+ wait_for: CSS selector to wait for before capturing.
986
+ **kwargs: Additional arguments passed to scrape().
987
+
988
+ Returns:
989
+ ScrapeResult with rendered content.
990
+ """
991
+ advanced = kwargs.pop("advanced", None) or AdvancedOptions(render_js=True)
992
+ if not advanced.render_js:
993
+ advanced.render_js = True
994
+ if screenshot:
995
+ advanced.screenshot = True
996
+
997
+ return self.scrape(
998
+ url, mode="js", advanced=advanced, wait_for=wait_for, **kwargs
999
+ )
1000
+
1001
+ def scrape_pdf(
1002
+ self,
1003
+ url: str,
1004
+ format: Literal["text", "markdown"] = "markdown",
1005
+ **kwargs,
1006
+ ) -> ScrapeResult:
1007
+ """Extract content from PDF files.
1008
+
1009
+ Args:
1010
+ url: PDF URL.
1011
+ format: Output format ('text' or 'markdown').
1012
+ **kwargs: Additional arguments passed to scrape().
1013
+
1014
+ Returns:
1015
+ ScrapeResult with extracted PDF content.
1016
+ """
1017
+ return self.scrape(url, mode="pdf", pdf_format=format, **kwargs)
1018
+
1019
+ def scrape_ocr(
1020
+ self,
1021
+ url: str,
1022
+ language: str = "eng",
1023
+ **kwargs,
1024
+ ) -> ScrapeResult:
1025
+ """Extract text from images using OCR.
1026
+
1027
+ Args:
1028
+ url: Image URL.
1029
+ language: OCR language code (e.g., 'eng', 'fra', 'deu').
1030
+ **kwargs: Additional arguments passed to scrape().
1031
+
1032
+ Returns:
1033
+ ScrapeResult with OCR-extracted text.
1034
+ """
1035
+ return self.scrape(url, mode="ocr", ocr_language=language, **kwargs)
1036
+
1037
+ # =========================================================================
1038
+ # ASYNC API
1039
+ # =========================================================================
1040
+
1041
+ async def scrape_async(
1042
+ self,
1043
+ url: str,
1044
+ mode: Literal["auto", "html", "js", "pdf", "ocr"] = "auto",
1045
+ sync: bool = True,
1046
+ advanced: Optional[AdvancedOptions] = None,
1047
+ cost_controls: Optional[CostControls] = None,
1048
+ cache: bool = False,
1049
+ cache_ttl: Optional[int] = None,
1050
+ force_refresh: bool = False,
1051
+ include_raw_html: bool = False,
1052
+ timeout: Optional[int] = None,
1053
+ formats: Optional[List[Literal["text", "json", "html", "markdown"]]] = None,
1054
+ extraction_schema: Optional[Dict[str, Any]] = None,
1055
+ extraction_prompt: Optional[str] = None,
1056
+ extraction_profile: Optional[
1057
+ Literal["auto", "product", "article", "job_posting", "faq", "recipe", "event"]
1058
+ ] = None,
1059
+ evidence: bool = False,
1060
+ promote_schema_org: bool = True,
1061
+ wait_for: Optional[str] = None,
1062
+ screenshot: bool = False,
1063
+ wait_until: str = "networkidle",
1064
+ enable_scroll: Optional[bool] = None,
1065
+ pdf_format: str = "markdown",
1066
+ ocr_language: str = "eng",
1067
+ poll_interval: float = 0.5,
1068
+ poll_timeout: float = 300.0,
1069
+ ) -> ScrapeResult:
1070
+ """Async version of scrape(). See scrape() for full documentation."""
1071
+ payload = self._build_scrape_payload(
1072
+ url=url,
1073
+ mode=mode,
1074
+ sync=sync,
1075
+ advanced=advanced,
1076
+ cost_controls=cost_controls,
1077
+ cache=cache,
1078
+ cache_ttl=cache_ttl,
1079
+ force_refresh=force_refresh,
1080
+ include_raw_html=include_raw_html,
1081
+ timeout=timeout,
1082
+ formats=formats,
1083
+ extraction_schema=extraction_schema,
1084
+ extraction_prompt=extraction_prompt,
1085
+ extraction_profile=extraction_profile,
1086
+ evidence=evidence,
1087
+ promote_schema_org=promote_schema_org,
1088
+ wait_for=wait_for,
1089
+ screenshot=screenshot,
1090
+ wait_until=wait_until,
1091
+ enable_scroll=enable_scroll,
1092
+ pdf_format=pdf_format,
1093
+ ocr_language=ocr_language,
1094
+ )
1095
+
1096
+ response = await self._async_request_with_retry(
1097
+ "POST", "/api/v1/scrape", json=payload
1098
+ )
1099
+ data = response.json()
1100
+
1101
+ if response.status_code == 202 and "job_id" in data:
1102
+ if not sync:
1103
+ return ScrapeResult(
1104
+ url=url,
1105
+ status_code=202,
1106
+ content={"job_id": data["job_id"]},
1107
+ metadata={"job_id": data["job_id"]},
1108
+ )
1109
+
1110
+ return await self.wait_for_job_async(
1111
+ data["job_id"],
1112
+ poll_interval=poll_interval,
1113
+ poll_timeout=poll_timeout,
1114
+ )
1115
+
1116
+ return self._parse_scrape_response(data)
1117
+
1118
+ async def wait_for_job_async(
1119
+ self,
1120
+ job_id: str,
1121
+ poll_interval: float = 0.5,
1122
+ poll_timeout: float = 300.0,
1123
+ ) -> ScrapeResult:
1124
+ """Async version of wait_for_job()."""
1125
+ start_time = time.time()
1126
+
1127
+ while True:
1128
+ elapsed = time.time() - start_time
1129
+ if elapsed > poll_timeout:
1130
+ raise TimeoutError(
1131
+ f"Job {job_id} did not complete within {poll_timeout} seconds"
1132
+ )
1133
+
1134
+ status = await self.get_job_status_async(job_id)
1135
+
1136
+ if status.status == "succeeded":
1137
+ if status.result:
1138
+ return status.result
1139
+ raise ScrapeError(200, "Job completed but no result returned")
1140
+
1141
+ elif status.status == "failed":
1142
+ raise ScrapeError(
1143
+ 422,
1144
+ status.error or "Job failed",
1145
+ code="JOB_FAILED",
1146
+ )
1147
+
1148
+ await asyncio.sleep(poll_interval)
1149
+
1150
+ async def get_job_status_async(self, job_id: str) -> JobStatus:
1151
+ """Async version of get_job_status()."""
1152
+ response = await self._async_request_with_retry(
1153
+ "GET", f"/api/v1/jobs/{job_id}"
1154
+ )
1155
+ data = response.json()
1156
+
1157
+ result = None
1158
+ if data.get("status") in ("succeeded", "completed") and data.get("result"):
1159
+ result = self._parse_scrape_response(data["result"])
1160
+
1161
+ return JobStatus(
1162
+ job_id=job_id,
1163
+ status=data.get("status", "pending"),
1164
+ result=result,
1165
+ error=data.get("error"),
1166
+ )
1167
+
1168
+ async def estimate_cost_async(
1169
+ self,
1170
+ url: str,
1171
+ mode: Literal["auto", "html", "js", "pdf", "ocr"] = "auto",
1172
+ advanced: Optional[AdvancedOptions] = None,
1173
+ cost_controls: Optional[CostControls] = None,
1174
+ ) -> CostEstimate:
1175
+ """Async version of estimate_cost()."""
1176
+ payload: Dict[str, Any] = {"url": url, "mode": mode}
1177
+
1178
+ if advanced:
1179
+ payload["advanced"] = advanced.to_dict()
1180
+ if cost_controls:
1181
+ payload["cost_controls"] = cost_controls.to_dict()
1182
+
1183
+ response = await self._async_request_with_retry(
1184
+ "POST", "/api/v1/scrape/estimate", json=payload
1185
+ )
1186
+ data = response.json()
1187
+
1188
+ return CostEstimate(
1189
+ url=data.get("url", url),
1190
+ estimated_tier=data.get("estimated_tier", "2"),
1191
+ estimated_credits=data.get("estimated_credits", 2),
1192
+ confidence=data.get("confidence", "medium"),
1193
+ max_possible_credits=data.get("max_possible_credits", 20),
1194
+ reasoning=data.get("reasoning", ""),
1195
+ )
1196
+
1197
+ async def get_usage_async(self) -> UsageStats:
1198
+ """Async version of get_usage()."""
1199
+ response = await self._async_request_with_retry("GET", "/api/v1/usage")
1200
+ data = response.json()
1201
+
1202
+ return UsageStats(
1203
+ credits_available=data.get("credits_available", 0),
1204
+ credits_used_month=data.get("credits_used_month", 0),
1205
+ credits_limit=data.get("credits_limit", 0),
1206
+ plan=data.get("plan", ""),
1207
+ period_start=data.get("period_start", ""),
1208
+ period_end=data.get("period_end", ""),
1209
+ )
1210
+
1211
+ # =========================================================================
1212
+ # CONTEXT MANAGERS
1213
+ # =========================================================================
1214
+
1215
+ def close(self) -> None:
1216
+ """Close HTTP clients and release resources."""
1217
+ if self._client:
1218
+ self._client.close()
1219
+ self._client = None
1220
+ if self._async_client:
1221
+ # For sync close of async client
1222
+ try:
1223
+ loop = asyncio.get_event_loop()
1224
+ if loop.is_running():
1225
+ loop.create_task(self._async_client.aclose())
1226
+ else:
1227
+ loop.run_until_complete(self._async_client.aclose())
1228
+ except RuntimeError:
1229
+ pass
1230
+ self._async_client = None
1231
+
1232
+ async def aclose(self) -> None:
1233
+ """Async close of HTTP clients."""
1234
+ if self._client:
1235
+ self._client.close()
1236
+ self._client = None
1237
+ if self._async_client:
1238
+ await self._async_client.aclose()
1239
+ self._async_client = None
1240
+
1241
+ def __enter__(self):
1242
+ return self
1243
+
1244
+ def __exit__(self, exc_type, exc_val, exc_tb):
1245
+ self.close()
1246
+
1247
+ async def __aenter__(self):
1248
+ return self
1249
+
1250
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
1251
+ await self.aclose()
1252
+
1253
+
1254
+ # =============================================================================
1255
+ # ASYNC CLIENT ALIAS
1256
+ # =============================================================================
1257
+
1258
+
1259
+ class AsyncAlterLab(AlterLab):
1260
+ """Async-first AlterLab client.
1261
+
1262
+ Provides async methods as the primary API for use with asyncio.
1263
+
1264
+ Example:
1265
+ async with AsyncAlterLab(api_key="sk_live_...") as client:
1266
+ result = await client.scrape("https://example.com")
1267
+ print(result.text)
1268
+
1269
+ # Concurrent scraping
1270
+ urls = ["https://example.com/1", "https://example.com/2"]
1271
+ results = await asyncio.gather(*[client.scrape(url) for url in urls])
1272
+ """
1273
+
1274
+ async def scrape(self, url: str, **kwargs) -> ScrapeResult:
1275
+ """Scrape a URL (async)."""
1276
+ return await self.scrape_async(url, **kwargs)
1277
+
1278
+ async def wait_for_job(self, job_id: str, **kwargs) -> ScrapeResult:
1279
+ """Wait for job completion (async)."""
1280
+ return await self.wait_for_job_async(job_id, **kwargs)
1281
+
1282
+ async def get_job_status(self, job_id: str) -> JobStatus:
1283
+ """Get job status (async)."""
1284
+ return await self.get_job_status_async(job_id)
1285
+
1286
+ async def estimate_cost(self, url: str, **kwargs) -> CostEstimate:
1287
+ """Estimate cost (async)."""
1288
+ return await self.estimate_cost_async(url, **kwargs)
1289
+
1290
+ async def get_usage(self) -> UsageStats:
1291
+ """Get usage stats (async)."""
1292
+ return await self.get_usage_async()
1293
+
1294
+ async def scrape_html(self, url: str, **kwargs) -> ScrapeResult:
1295
+ """Scrape HTML (async)."""
1296
+ return await self.scrape(url, mode="html", **kwargs)
1297
+
1298
+ async def scrape_js(
1299
+ self,
1300
+ url: str,
1301
+ screenshot: bool = False,
1302
+ wait_for: Optional[str] = None,
1303
+ **kwargs,
1304
+ ) -> ScrapeResult:
1305
+ """Scrape with JS rendering (async)."""
1306
+ advanced = kwargs.pop("advanced", None) or AdvancedOptions(render_js=True)
1307
+ if not advanced.render_js:
1308
+ advanced.render_js = True
1309
+ if screenshot:
1310
+ advanced.screenshot = True
1311
+
1312
+ return await self.scrape(
1313
+ url, mode="js", advanced=advanced, wait_for=wait_for, **kwargs
1314
+ )
1315
+
1316
+ async def scrape_pdf(
1317
+ self,
1318
+ url: str,
1319
+ format: Literal["text", "markdown"] = "markdown",
1320
+ **kwargs,
1321
+ ) -> ScrapeResult:
1322
+ """Scrape PDF (async)."""
1323
+ return await self.scrape(url, mode="pdf", pdf_format=format, **kwargs)
1324
+
1325
+ async def scrape_ocr(
1326
+ self,
1327
+ url: str,
1328
+ language: str = "eng",
1329
+ **kwargs,
1330
+ ) -> ScrapeResult:
1331
+ """Scrape with OCR (async)."""
1332
+ return await self.scrape(url, mode="ocr", ocr_language=language, **kwargs)