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/__init__.py +73 -0
- alterlab/client.py +1332 -0
- alterlab-2.0.0.dist-info/METADATA +375 -0
- alterlab-2.0.0.dist-info/RECORD +5 -0
- alterlab-2.0.0.dist-info/WHEEL +4 -0
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)
|