alterlab 2.1.0__tar.gz → 2.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alterlab
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: AlterLab Python SDK
5
5
  Author: AlterLab
6
6
  Author-email: dev@alterlab.io
@@ -41,7 +41,7 @@ from alterlab.client import (AdvancedOptions, AlterLab, AlterLabAPIError,
41
41
  from alterlab.types import (SessionIntegration, SessionListResponse,
42
42
  SessionValidation)
43
43
 
44
- __version__ = "2.1.0"
44
+ __version__ = "2.1.1"
45
45
  __all__ = [
46
46
  "AlterLab",
47
47
  "AlterLabSync",
@@ -11,93 +11,302 @@ This SDK provides full feature parity with the AlterLab API including:
11
11
  - Batch scraping with webhooks
12
12
  - Comprehensive error handling with retries
13
13
 
14
- Version: 2.1.0 (Adds session/cookie support for authenticated scraping)
14
+ Version: 2.1.1 (Adds X-AlterLab-Source header for ecosystem attribution)
15
15
  """
16
16
 
17
17
  import asyncio
18
18
  import logging
19
19
  import time
20
20
  import warnings
21
- from typing import Any, Dict, List, Literal, Optional, Union
21
+ from typing import Any, Dict, List, Literal, Optional, Sequence, Union
22
22
  from urllib.parse import urljoin
23
23
 
24
24
  import httpx
25
25
  from alterlab.resources.sessions import AsyncSessions, SyncSessions
26
26
 
27
+ _SDK_VERSION = "2.1.1"
28
+
27
29
  logger = logging.getLogger("alterlab")
28
30
 
29
31
 
32
+ # ---------------------------------------------------------------------------
33
+ # Response type hints (TypedDicts for IDE/type-checker support)
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ class TierEscalationDetail(Dict[str, Any]):
38
+ """Details of a single tier escalation attempt within a scrape request.
39
+
40
+ These fields are internal and may not appear in all responses.
41
+
42
+ Fields:
43
+ tier: Tier identifier (e.g. "1", "2", "3", "3.5", "4").
44
+ result: Outcome — ``"success"``, ``"failed"``, or ``"skipped"``.
45
+ credits: Credits charged for this tier attempt (microcents).
46
+ duration_ms: Time taken for this tier attempt in milliseconds.
47
+ error: Error message if the tier attempt failed.
48
+ """
49
+
50
+
51
+ class BillingDetails(Dict[str, Any]):
52
+ """Detailed billing breakdown returned in every scrape response.
53
+
54
+ Accessed via ``response["billing"]``.
55
+
56
+ Fields:
57
+ total_credits: Total credits charged for the request (microcents).
58
+ tier_used: Scraping tier that produced the final result (e.g. "1", "4").
59
+ escalations: List of :class:`TierEscalationDetail` for each tier attempted.
60
+ Internal — only present when ``x-internal`` fields are not filtered.
61
+ savings: Credits saved vs. always using the highest tier. Internal.
62
+ optimization_suggestion: Advisory on how to reduce cost next time. Internal.
63
+ byop_applied: Whether a Bring-Your-Own-Proxy discount was applied.
64
+ byop_discount_percent: Discount percentage applied (e.g. ``98.4``).
65
+ original_cost_microcents: Cost before BYOP discount in microcents.
66
+ final_cost_microcents: Cost after BYOP discount in microcents.
67
+ byos_applied: Whether Bring-Your-Own-Session cookies were injected.
68
+ byos_session_name: Name of the stored session used (e.g. ``"My Amazon Prime"``).
69
+ """
70
+
71
+
72
+ # Valid keys and content types for section_filter validation.
73
+ _SECTION_FILTER_VALID_KEYS = {
74
+ "min_content_blocks",
75
+ "exclude_content_types",
76
+ "content_only",
77
+ }
78
+ _SECTION_FILTER_VALID_CONTENT_TYPES = {
79
+ "paragraph",
80
+ "list",
81
+ "table",
82
+ "image",
83
+ "code",
84
+ "blockquote",
85
+ "dl",
86
+ "details",
87
+ }
88
+
89
+
30
90
  class AdvancedOptions:
31
91
  """Advanced scraping options with credit costs.
32
92
 
33
93
  Credit Costs:
34
- - render_js: +3 credits (forces mode="js")
94
+ - render_js: Forces Tier 4 minimum (browser rendering) — Tier 4 cost applies, no separate add-on charge
35
95
  - screenshot: +1 credit (requires render_js)
36
96
  - generate_pdf: +2 credits (requires render_js)
37
97
  - ocr: +5 credits (refunded if no images found)
38
98
  - use_proxy: +1 credit
39
99
  - markdown: Free
40
100
  - wait_condition: Free
101
+ - remove_cookie_banners: Free
41
102
  """
42
103
 
43
104
  def __init__(
44
105
  self,
45
- render_js: bool = False,
106
+ render_js: Union[bool, Literal["auto"]] = False,
46
107
  screenshot: bool = False,
47
108
  markdown: bool = False,
48
109
  generate_pdf: bool = False,
49
110
  ocr: bool = False,
50
111
  use_proxy: bool = False,
112
+ use_own_proxy: bool = False,
113
+ use_system_proxy: bool = False,
114
+ proxy_integration_id: Optional[str] = None,
115
+ proxy_country: Optional[str] = None,
116
+ remove_cookie_banners: bool = True,
51
117
  wait_condition: str = "networkidle",
118
+ scroll_to_load: bool = False,
119
+ scroll_count: int = 3,
120
+ include_iframes: bool = False,
121
+ flatten_shadow_dom: bool = False,
122
+ section_filter: Optional[Dict[str, Any]] = None,
123
+ session_headers: Optional[Dict[str, str]] = None,
52
124
  ):
53
125
  """Initialize advanced options.
54
126
 
55
127
  Args:
56
- render_js: Render JavaScript using headless browser (+3 credits)
57
- screenshot: Capture full-page screenshot (+1 credit, requires render_js)
128
+ render_js: Render JavaScript using headless browser (forces Tier 4 minimum — no separate
129
+ add-on charge, Tier 4 cost applies). Set to ``"auto"`` for smart detection: probes
130
+ each page and only renders JS-heavy pages with a browser, using fast HTTP for static
131
+ pages. Saves 30–60% on mixed sites. Note: screenshot and generate_pdf require
132
+ ``render_js=True`` (not ``"auto"``), since they need guaranteed browser rendering.
133
+ screenshot: Capture full-page screenshot (+1 credit, requires render_js=True)
58
134
  markdown: Extract content as Markdown (free)
59
- generate_pdf: Generate PDF of rendered page (+2 credits, requires render_js)
135
+ generate_pdf: Generate PDF of rendered page (+2 credits, requires render_js=True)
60
136
  ocr: Extract text from images using OCR (+5 credits)
61
137
  use_proxy: Route request through premium proxy (+1 credit)
138
+ use_own_proxy: Use your own integrated proxy instead of the system proxy
139
+ use_system_proxy: Override your default proxy integration and use AlterLab's system
140
+ proxy instead
141
+ proxy_integration_id: Specific proxy integration ID to use (requires
142
+ use_own_proxy=True)
143
+ proxy_country: Preferred proxy country code (e.g., ``"US"``, ``"DE"``) for
144
+ geo-targeting
145
+ remove_cookie_banners: Remove cookie consent banners from HTML before content
146
+ extraction (free, enabled by default)
62
147
  wait_condition: Wait condition for JS rendering (domcontentloaded|networkidle|load)
148
+ scroll_to_load: Scroll page to trigger lazy-loaded content (requires render_js)
149
+ scroll_count: Number of scroll iterations when scroll_to_load is enabled (1-10)
150
+ include_iframes: Inline iframe content into the main document (free, requires render_js).
151
+ Same-origin iframes are read directly; cross-origin iframes are marked but not fetched.
152
+ flatten_shadow_dom: Flatten Shadow DOM roots into regular DOM for extraction (free,
153
+ requires render_js). Web Components with shadow roots become visible in the
154
+ serialized HTML.
155
+ section_filter: Filter options for json_v2 section tree output. Dict with optional keys:
156
+ - min_content_blocks (int): Minimum content blocks for a section (default 0)
157
+ - exclude_content_types (list[str]): Content types to exclude
158
+ (paragraph, list, table, image, code, blockquote, dl, details)
159
+ - content_only (bool): Strip headings, keep only content (default False)
160
+ session_headers: Inline auth headers (e.g. {"Authorization": "Bearer token"}).
161
+ Merged with session headers when session_id is also provided.
63
162
 
64
163
  Raises:
65
- ValueError: If screenshot or generate_pdf is True but render_js is False
164
+ ValueError: If render_js is not a bool or the string ``"auto"``
165
+ ValueError: If screenshot or generate_pdf is True but render_js is not True
166
+ ValueError: If scroll_to_load is True but render_js is falsy
167
+ ValueError: If include_iframes or flatten_shadow_dom is True but render_js is falsy
168
+ ValueError: If scroll_count is not between 1 and 10
169
+ ValueError: If section_filter contains unknown keys or invalid value types
66
170
  """
67
- if screenshot and not render_js:
68
- raise ValueError("screenshot requires render_js=True")
69
- if generate_pdf and not render_js:
70
- raise ValueError("generate_pdf requires render_js=True")
171
+ if render_js not in (True, False, "auto"):
172
+ raise ValueError('render_js must be True, False, or "auto"')
173
+ if screenshot and render_js is not True:
174
+ raise ValueError(
175
+ "screenshot requires render_js=True "
176
+ '(render_js="auto" is not sufficient — screenshot needs guaranteed browser rendering)'
177
+ )
178
+ if generate_pdf and render_js is not True:
179
+ raise ValueError(
180
+ "generate_pdf requires render_js=True "
181
+ '(render_js="auto" is not sufficient — PDF generation needs guaranteed browser rendering)'
182
+ )
183
+ if scroll_to_load and not render_js:
184
+ raise ValueError(
185
+ "scroll_to_load requires render_js=True or render_js='auto'"
186
+ )
187
+ if include_iframes and not render_js:
188
+ raise ValueError(
189
+ "include_iframes requires render_js=True or render_js='auto'"
190
+ )
191
+ if flatten_shadow_dom and not render_js:
192
+ raise ValueError(
193
+ "flatten_shadow_dom requires render_js=True or render_js='auto'"
194
+ )
71
195
 
72
196
  valid_conditions = ["domcontentloaded", "networkidle", "load"]
73
197
  if wait_condition not in valid_conditions:
74
198
  raise ValueError(f"wait_condition must be one of {valid_conditions}")
75
199
 
200
+ if not 1 <= scroll_count <= 10:
201
+ raise ValueError("scroll_count must be between 1 and 10")
202
+
76
203
  self.render_js = render_js
77
204
  self.screenshot = screenshot
78
205
  self.markdown = markdown
79
206
  self.generate_pdf = generate_pdf
80
207
  self.ocr = ocr
81
208
  self.use_proxy = use_proxy
209
+ self.use_own_proxy = use_own_proxy
210
+ self.use_system_proxy = use_system_proxy
211
+ self.proxy_integration_id = proxy_integration_id
212
+ self.proxy_country = proxy_country
213
+ self.remove_cookie_banners = remove_cookie_banners
82
214
  self.wait_condition = wait_condition
215
+ self.scroll_to_load = scroll_to_load
216
+ self.scroll_count = scroll_count
217
+ self.include_iframes = include_iframes
218
+ self.flatten_shadow_dom = flatten_shadow_dom
219
+ self.session_headers = session_headers
220
+
221
+ if section_filter is not None:
222
+ self._validate_section_filter(section_filter)
223
+ self.section_filter = section_filter
224
+
225
+ @staticmethod
226
+ def _validate_section_filter(sf: Dict[str, Any]) -> None:
227
+ """Validate section_filter keys and value types.
228
+
229
+ Raises:
230
+ ValueError: If any key is not in the allowed set, or if value
231
+ types do not match the expected schema.
232
+ """
233
+ if not isinstance(sf, dict):
234
+ raise ValueError("section_filter must be a dict")
235
+
236
+ unknown_keys = set(sf.keys()) - _SECTION_FILTER_VALID_KEYS
237
+ if unknown_keys:
238
+ raise ValueError(
239
+ f"section_filter contains unknown keys: {sorted(unknown_keys)}. "
240
+ f"Allowed keys: {sorted(_SECTION_FILTER_VALID_KEYS)}"
241
+ )
242
+
243
+ if "min_content_blocks" in sf:
244
+ val = sf["min_content_blocks"]
245
+ if not isinstance(val, int) or isinstance(val, bool) or val < 0:
246
+ raise ValueError(
247
+ "section_filter['min_content_blocks'] must be a non-negative integer"
248
+ )
249
+
250
+ if "exclude_content_types" in sf:
251
+ val = sf["exclude_content_types"]
252
+ if not isinstance(val, (list, tuple)):
253
+ raise ValueError(
254
+ "section_filter['exclude_content_types'] must be a list of strings"
255
+ )
256
+ invalid = set(val) - _SECTION_FILTER_VALID_CONTENT_TYPES
257
+ if invalid:
258
+ raise ValueError(
259
+ f"section_filter['exclude_content_types'] contains unknown types: "
260
+ f"{sorted(invalid)}. "
261
+ f"Allowed: {sorted(_SECTION_FILTER_VALID_CONTENT_TYPES)}"
262
+ )
263
+
264
+ if "content_only" in sf:
265
+ val = sf["content_only"]
266
+ if not isinstance(val, bool):
267
+ raise ValueError("section_filter['content_only'] must be a bool")
83
268
 
84
269
  def to_dict(self) -> Dict[str, Any]:
85
270
  """Convert to dictionary for API request."""
86
- return {
271
+ result: Dict[str, Any] = {
87
272
  "render_js": self.render_js,
88
273
  "screenshot": self.screenshot,
89
274
  "markdown": self.markdown,
90
275
  "generate_pdf": self.generate_pdf,
91
276
  "ocr": self.ocr,
92
277
  "use_proxy": self.use_proxy,
278
+ "use_own_proxy": self.use_own_proxy,
279
+ "use_system_proxy": self.use_system_proxy,
280
+ "remove_cookie_banners": self.remove_cookie_banners,
93
281
  "wait_condition": self.wait_condition,
282
+ "scroll_to_load": self.scroll_to_load,
283
+ "scroll_count": self.scroll_count,
284
+ "include_iframes": self.include_iframes,
285
+ "flatten_shadow_dom": self.flatten_shadow_dom,
94
286
  }
287
+ if self.scroll_to_load:
288
+ result["scroll_to_load"] = True
289
+ result["scroll_count"] = self.scroll_count
290
+ if self.section_filter is not None:
291
+ result["section_filter"] = self.section_filter
292
+ if self.session_headers is not None:
293
+ result["session_headers"] = self.session_headers
294
+ if self.proxy_integration_id is not None:
295
+ result["proxy_integration_id"] = self.proxy_integration_id
296
+ if self.proxy_country is not None:
297
+ result["proxy_country"] = self.proxy_country
298
+ return result
95
299
 
96
300
  def calculate_credits(self) -> int:
97
- """Calculate additional credits for advanced options."""
301
+ """Calculate additional credits for advanced options.
302
+
303
+ Note: render_js="auto" does not add upfront credits — actual cost depends on
304
+ per-page decision (browser vs. HTTP). Treat as 0 for estimation purposes.
305
+ """
98
306
  credits = 0
99
- if self.render_js:
307
+ if self.render_js is True:
100
308
  credits += 3
309
+ # render_js="auto" doesn't add credits upfront — actual cost depends on per-page decision
101
310
  if self.screenshot:
102
311
  credits += 1
103
312
  if self.generate_pdf:
@@ -127,7 +336,7 @@ class CostControls:
127
336
 
128
337
  Args:
129
338
  max_credits: Maximum credits to spend on this request
130
- max_tier: Maximum tier to escalate to (0.5, 1, 1.5, 2, 3, 4)
339
+ max_tier: Maximum tier to escalate to (1, 2, 3, 3.5, 4)
131
340
  prefer_cost: Optimize for cost (try cheaper tiers first)
132
341
  prefer_speed: Optimize for speed (skip to reliable tier)
133
342
  fail_fast: Return error instead of escalating to expensive tiers
@@ -135,7 +344,7 @@ class CostControls:
135
344
  Raises:
136
345
  ValueError: If max_tier is not a valid tier value
137
346
  """
138
- valid_tiers = ["0.5", "1", "1.5", "2", "3", "4"]
347
+ valid_tiers = ["1", "2", "3", "3.5", "4"]
139
348
  if max_tier and max_tier not in valid_tiers:
140
349
  raise ValueError(f"max_tier must be one of {valid_tiers}")
141
350
 
@@ -262,7 +471,8 @@ class AlterLab:
262
471
  base_url=self.base_url,
263
472
  headers={
264
473
  "X-API-Key": api_key,
265
- "User-Agent": "AlterLab-Python-SDK/2.1.0",
474
+ "User-Agent": f"AlterLab-Python-SDK/{_SDK_VERSION}",
475
+ "X-AlterLab-Source": f"python-sdk/{_SDK_VERSION}",
266
476
  "Content-Type": "application/json",
267
477
  },
268
478
  timeout=httpx.Timeout(timeout),
@@ -417,6 +627,13 @@ class AlterLab:
417
627
  ocr_language: str = "eng",
418
628
  session_id: Optional[str] = None,
419
629
  cookies: Optional[Dict[str, str]] = None,
630
+ actions: Optional[List[Dict[str, Any]]] = None,
631
+ headers: Optional[Dict[str, str]] = None,
632
+ location: Optional[Dict[str, str]] = None,
633
+ cache: bool = False,
634
+ cache_ttl: Optional[int] = None,
635
+ webhook_url: Optional[str] = None,
636
+ max_response_bytes: Optional[int] = None,
420
637
  poll_interval: float = 1.0,
421
638
  poll_timeout: float = 300.0,
422
639
  poll_backoff_multiplier: float = 1.5,
@@ -452,18 +669,76 @@ class AlterLab:
452
669
  Mutually exclusive with ``cookies``.
453
670
  cookies: Inline cookie dict for one-off authenticated scraping (not stored).
454
671
  Mutually exclusive with ``session_id``.
672
+ actions: Browser automation sequence to execute after page load (max 20 actions).
673
+ Requires ``advanced.render_js=True``. Each action is a dict with a ``type``
674
+ discriminator. Supported types: ``wait``, ``wait_for``, ``click``, ``fill``,
675
+ ``press``, ``scroll``, ``infinite_scroll``, ``screenshot``, ``evaluate``.
676
+ Actions execute sequentially; failed actions are skipped. Results returned in
677
+ ``action_results``. Examples::
678
+
679
+ [
680
+ {"type": "wait", "milliseconds": 1000},
681
+ {"type": "click", "selector": "button.load-more"},
682
+ {"type": "scroll", "direction": "down", "amount": 3},
683
+ {"type": "evaluate", "script": "document.title"},
684
+ ]
685
+
686
+ headers: Custom HTTP request headers to forward to the target URL (max 50 headers).
687
+ Header names must be 1-256 characters and header values must be at most 8192
688
+ characters. Hop-by-hop and connection-control headers (e.g. ``Connection``,
689
+ ``Transfer-Encoding``) are rejected by the API. Example::
690
+
691
+ {"Authorization": "Bearer token", "X-Custom-Header": "value"}
692
+
693
+ location: Geo-targeting parameters. Dict with optional keys:
694
+ - ``country`` (str): ISO 3166-1 alpha-2 country code (e.g., ``"DE"``, ``"JP"``).
695
+ Routes the request through a proxy in that country.
696
+ - ``language`` (str): ISO 639-1 language code (e.g., ``"de"``, ``"ja"``).
697
+ Sets the ``Accept-Language`` header and browser locale.
698
+ Example: ``{"country": "DE", "language": "de"}``
699
+ cache: Enable response caching. When ``True``, a cached result may be returned if
700
+ available. Defaults to ``False`` (no caching). Use ``force_refresh=True`` to bypass
701
+ an existing cache entry.
702
+ cache_ttl: Cache TTL in seconds (60–86400). Only applies when ``cache=True``.
703
+ Defaults to 3600 (1 hour) server-side when not specified.
704
+ webhook_url: URL to receive a ``job.completed`` or ``job.failed`` event when the
705
+ scrape finishes. This is a server-side notification side-effect and does not
706
+ affect client-side polling behavior — use ``sync=False`` to skip polling.
707
+ HTTPS is required in production (``http://localhost`` is allowed in dev).
708
+ max_response_bytes: Soft cap on raw response body size in bytes. When the
709
+ downloaded HTML exceeds this value it is truncated before extraction and
710
+ ``content_truncated.truncation_reason`` is set to ``'response_body_cap'``.
711
+ Default: ``None`` (server default of 5 MB applies). Set to ``0`` for no
712
+ limit. Maximum: 50 MB (52_428_800).
455
713
  poll_interval: Initial polling interval for async jobs (seconds)
456
714
  poll_timeout: Maximum time to wait for async jobs (seconds)
457
715
  poll_backoff_multiplier: Multiplier for exponential backoff (default: 1.5)
458
716
  poll_max_interval: Maximum polling interval (seconds)
459
717
 
460
718
  Returns:
461
- Scrape response dictionary with content, metadata, and billing details
719
+ Scrape response dictionary with content, metadata, and billing details.
720
+ Key fields include:
721
+ - ``url``, ``status_code``, ``content``, ``headers``, ``cached``
722
+ - ``credits_used``, ``response_time_ms``, ``size_bytes``
723
+ - ``billing`` (:class:`BillingDetails`): Full billing breakdown including
724
+ ``total_credits``, ``tier_used``, ``escalations``
725
+ (``List[TierEscalationDetail]`` — each entry has ``tier``, ``result``,
726
+ ``credits: int``, ``duration_ms: Optional[int]``, ``error:
727
+ Optional[str]``), ``byop_applied``, and BYOP/BYOS discount fields.
728
+ - ``warning`` (Optional[str]): Actionable warning when auto-escalation
729
+ occurred, tier cap prevented escalation on thin content, or extraction
730
+ quality is low.
731
+ - ``content_quality`` (Optional[Dict]): Content quality metadata from
732
+ scrape diagnostics (e.g., word count, image count, quality score).
733
+ - ``note`` (Optional[str]): Advisory message (e.g. when repeat-request
734
+ detection identifies identical content across retries).
462
735
 
463
736
  Raises:
464
737
  AlterLabAPIError: On API errors
465
738
  AlterLabTimeoutError: If async job polling times out
466
739
  AlterLabValidationError: If parameters are invalid
740
+ AlterLabValidationError: If actions is provided but advanced.render_js is not set
741
+ AlterLabValidationError: If more than 20 actions are provided
467
742
 
468
743
  Example:
469
744
  # Simple HTML scraping
@@ -499,6 +774,33 @@ class AlterLab:
499
774
  "https://amazon.com/dp/B0XXXXX",
500
775
  session_id="uuid-of-stored-session"
501
776
  )
777
+
778
+ # Geo-targeted scraping — retrieve German content via DE proxy
779
+ result = await client.scrape(
780
+ "https://example.com",
781
+ location={"country": "DE", "language": "de"}
782
+ )
783
+
784
+ # Smart JS detection — renders only JS-heavy pages (30-60% cost savings)
785
+ result = await client.scrape(
786
+ "https://example.com",
787
+ advanced=AdvancedOptions(render_js="auto")
788
+ )
789
+
790
+ # Cache-enabled scrape with custom TTL
791
+ result = await client.scrape(
792
+ "https://example.com",
793
+ cache=True,
794
+ cache_ttl=7200 # 2-hour cache
795
+ )
796
+
797
+ # Async scrape with webhook delivery (skips polling)
798
+ result = await client.scrape(
799
+ "https://example.com",
800
+ sync=False,
801
+ webhook_url="https://myapp.com/hooks/scrape-done"
802
+ )
803
+ # result contains {"job_id": "..."} — webhook delivers final result
502
804
  """
503
805
  if session_id and cookies:
504
806
  raise AlterLabValidationError(
@@ -506,6 +808,27 @@ class AlterLab:
506
808
  "Use session_id to reference a stored session OR "
507
809
  "provide inline cookies, not both."
508
810
  )
811
+ if actions is not None:
812
+ if not advanced or not advanced.render_js:
813
+ raise AlterLabValidationError(
814
+ "actions requires advanced.render_js=True or render_js='auto' "
815
+ "(browser rendering must be enabled to execute browser actions)"
816
+ )
817
+ if len(actions) > 20:
818
+ raise AlterLabValidationError(
819
+ f"actions must contain at most 20 items, got {len(actions)}"
820
+ )
821
+ if screenshot and (advanced is None or advanced.render_js is not True):
822
+ raise AlterLabValidationError(
823
+ "screenshot requires render_js=True. "
824
+ "Pass advanced=AdvancedOptions(render_js=True, screenshot=True) "
825
+ "or use scrape_js(screenshot=True) instead."
826
+ )
827
+ if cache_ttl is not None and not cache:
828
+ raise AlterLabValidationError(
829
+ "cache_ttl requires cache=True. "
830
+ "Pass cache=True to enable caching, or omit cache_ttl."
831
+ )
509
832
 
510
833
  # Build request payload
511
834
  payload: Dict[str, Any] = {
@@ -530,6 +853,8 @@ class AlterLab:
530
853
  payload["advanced"]["session_id"] = session_id
531
854
  if cookies:
532
855
  payload["advanced"]["cookies"] = cookies
856
+ if actions:
857
+ payload["advanced"]["actions"] = actions
533
858
 
534
859
  # Remove empty advanced block
535
860
  if not payload["advanced"]:
@@ -565,6 +890,28 @@ class AlterLab:
565
890
  if mode == "ocr":
566
891
  payload["ocr_language"] = ocr_language
567
892
 
893
+ # Custom request headers forwarded to target URL
894
+ if headers is not None:
895
+ payload["headers"] = headers
896
+
897
+ # Geo-targeting
898
+ if location is not None:
899
+ payload["location"] = location
900
+
901
+ # Cache control
902
+ if cache:
903
+ payload["cache"] = cache
904
+ if cache_ttl is not None:
905
+ payload["cache_ttl"] = cache_ttl
906
+
907
+ # Webhook URL for async notification
908
+ if webhook_url is not None:
909
+ payload["webhook_url"] = webhook_url
910
+
911
+ # Response body size cap
912
+ if max_response_bytes is not None:
913
+ payload["max_response_bytes"] = max_response_bytes
914
+
568
915
  # Make request
569
916
  response = await self._request_with_retry(
570
917
  "POST", "/api/v1/scrape", json=payload
@@ -575,7 +922,10 @@ class AlterLab:
575
922
  # Check if response is async (202 Accepted with job_id)
576
923
  if response.status_code == 202 and "job_id" in result:
577
924
  if not sync:
578
- # Return job info immediately
925
+ # Return job info immediately in async mode.
926
+ # When webhook_url is also provided alongside sync=True, the server
927
+ # delivers the result via webhook as a side-effect, but polling still
928
+ # proceeds so the caller receives the scraped content directly.
579
929
  return result
580
930
 
581
931
  # Poll for job completion with exponential backoff
@@ -1080,6 +1430,91 @@ class AlterLab:
1080
1430
 
1081
1431
  return response.json()
1082
1432
 
1433
+ async def crawl_stream(
1434
+ self,
1435
+ crawl_id: str,
1436
+ ):
1437
+ """Stream real-time crawl events via Server-Sent Events.
1438
+
1439
+ Returns an async iterator that yields parsed SSE events as
1440
+ dictionaries with ``event`` (str) and ``data`` (dict) keys.
1441
+
1442
+ Event types:
1443
+ - ``page.completed`` — per-page result with metadata, cost, tier
1444
+ - ``page.failed`` — per-page failure with error reason
1445
+ - ``crawl.progress`` — periodic summary (pages done/total, cost)
1446
+ - ``crawl.completed`` — final summary; iteration stops after this
1447
+
1448
+ Args:
1449
+ crawl_id: Crawl ID returned from crawl()
1450
+
1451
+ Yields:
1452
+ Dict with 'event' (str) and 'data' (dict) keys
1453
+
1454
+ Raises:
1455
+ AlterLabAPIError: On HTTP errors (404 if crawl not found)
1456
+
1457
+ Example:
1458
+ crawl = await client.crawl("https://example.com", max_pages=10)
1459
+ async for event in client.crawl_stream(crawl["crawl_id"]):
1460
+ if event["event"] == "page.completed":
1461
+ print(f"Scraped: {event['data']['url']}")
1462
+ elif event["event"] == "crawl.completed":
1463
+ print(f"Done: {event['data']['completed']}/{event['data']['total']}")
1464
+ """
1465
+ import json as _json
1466
+
1467
+ url = f"/api/v1/crawl/{crawl_id}/stream"
1468
+ headers = {
1469
+ "X-API-Key": self._api_key,
1470
+ "Accept": "text/event-stream",
1471
+ }
1472
+
1473
+ async with httpx.AsyncClient(
1474
+ base_url=self._base_url, timeout=None
1475
+ ) as stream_client:
1476
+ async with stream_client.stream("GET", url, headers=headers) as response:
1477
+ if response.status_code != 200:
1478
+ body = await response.aread()
1479
+ raise AlterLabAPIError(
1480
+ f"SSE stream failed: {response.status_code}",
1481
+ status_code=response.status_code,
1482
+ response_body=body.decode("utf-8", errors="replace"),
1483
+ )
1484
+
1485
+ current_event = "message"
1486
+ data_lines: list[str] = []
1487
+
1488
+ async for raw_line in response.aiter_lines():
1489
+ line = raw_line.rstrip("\n").rstrip("\r")
1490
+
1491
+ # Comment or keepalive
1492
+ if line.startswith(":"):
1493
+ continue
1494
+
1495
+ # Empty line = event boundary
1496
+ if not line:
1497
+ if data_lines:
1498
+ data_str = "\n".join(data_lines)
1499
+ try:
1500
+ parsed = _json.loads(data_str)
1501
+ except _json.JSONDecodeError:
1502
+ parsed = data_str
1503
+ yield {"event": current_event, "data": parsed}
1504
+
1505
+ # Terminal event — stop iteration
1506
+ if current_event == "crawl.completed":
1507
+ return
1508
+
1509
+ current_event = "message"
1510
+ data_lines = []
1511
+ continue
1512
+
1513
+ if line.startswith("event:"):
1514
+ current_event = line[6:].strip()
1515
+ elif line.startswith("data:"):
1516
+ data_lines.append(line[5:].strip())
1517
+
1083
1518
  async def crawl_and_wait(
1084
1519
  self,
1085
1520
  url: str,
@@ -1186,6 +1621,280 @@ class AlterLab:
1186
1621
  current_interval * poll_backoff_multiplier, poll_max_interval
1187
1622
  )
1188
1623
 
1624
+ # ------------------------------------------------------------------
1625
+ # Search
1626
+ # ------------------------------------------------------------------
1627
+
1628
+ async def search(
1629
+ self,
1630
+ query: str,
1631
+ *,
1632
+ domain: Optional[str] = None,
1633
+ num_results: int = 10,
1634
+ page: int = 1,
1635
+ country: Optional[str] = None,
1636
+ language: Optional[str] = None,
1637
+ time_range: Optional[Literal["hour", "day", "week", "month", "year"]] = None,
1638
+ scrape_results: bool = False,
1639
+ formats: Optional[
1640
+ List[Literal["text", "json", "json_v2", "html", "markdown"]]
1641
+ ] = None,
1642
+ extraction_schema: Optional[Dict[str, Any]] = None,
1643
+ ) -> Dict[str, Any]:
1644
+ """Execute a web search and return SERP results.
1645
+
1646
+ Args:
1647
+ query: Search terms (max 500 characters)
1648
+ domain: Restrict results to a specific domain (applied as site: prefix)
1649
+ num_results: Number of results to return (1-30, default 10)
1650
+ page: Result page number (1-indexed, default 1)
1651
+ country: ISO 3166-1 alpha-2 country code for geo-targeted results
1652
+ language: Language code for results (e.g., en, fr, de)
1653
+ time_range: Filter results by recency
1654
+ scrape_results: If True, scrape each result page and include content
1655
+ formats: Output formats when scrape_results=True
1656
+ extraction_schema: JSON schema for structured extraction (when scrape_results=True)
1657
+
1658
+ Returns:
1659
+ Search response with search_id, results, credits_used, etc.
1660
+
1661
+ Raises:
1662
+ AlterLabAPIError: On API errors
1663
+
1664
+ Example:
1665
+ results = await client.search("web scraping api python")
1666
+ for r in results["results"]:
1667
+ print(f"{r['position']}. {r['title']} — {r['url']}")
1668
+ """
1669
+ payload: Dict[str, Any] = {
1670
+ "query": query,
1671
+ "num_results": num_results,
1672
+ "page": page,
1673
+ "scrape_results": scrape_results,
1674
+ }
1675
+ if domain is not None:
1676
+ payload["domain"] = domain
1677
+ if country is not None:
1678
+ payload["country"] = country
1679
+ if language is not None:
1680
+ payload["language"] = language
1681
+ if time_range is not None:
1682
+ payload["time_range"] = time_range
1683
+ if formats is not None:
1684
+ payload["formats"] = formats
1685
+ if extraction_schema is not None:
1686
+ payload["extraction_schema"] = extraction_schema
1687
+
1688
+ response = await self._request_with_retry(
1689
+ "POST", "/api/v1/search", json=payload
1690
+ )
1691
+ return response.json()
1692
+
1693
+ async def batch_search(
1694
+ self,
1695
+ queries: List[str],
1696
+ *,
1697
+ num_results: int = 10,
1698
+ country: Optional[str] = None,
1699
+ language: Optional[str] = None,
1700
+ time_range: Optional[Literal["hour", "day", "week", "month", "year"]] = None,
1701
+ scrape_results: bool = False,
1702
+ formats: Optional[
1703
+ List[Literal["text", "json", "json_v2", "html", "markdown"]]
1704
+ ] = None,
1705
+ webhook_url: Optional[str] = None,
1706
+ ) -> Dict[str, Any]:
1707
+ """Submit a batch of search queries for async parallel processing.
1708
+
1709
+ Args:
1710
+ queries: List of search queries (2-100)
1711
+ num_results: Number of results per query (1-30, default 10)
1712
+ country: ISO 3166-1 alpha-2 country code
1713
+ language: Language code
1714
+ time_range: Filter results by recency
1715
+ scrape_results: Scrape each result page
1716
+ formats: Output formats when scrape_results=True
1717
+ webhook_url: URL to receive batch.search.completed webhook
1718
+
1719
+ Returns:
1720
+ Batch response with batch_id, job_ids, status, etc.
1721
+
1722
+ Raises:
1723
+ AlterLabAPIError: On API errors
1724
+ """
1725
+ payload: Dict[str, Any] = {
1726
+ "queries": queries,
1727
+ "num_results": num_results,
1728
+ "scrape_results": scrape_results,
1729
+ }
1730
+ if country is not None:
1731
+ payload["country"] = country
1732
+ if language is not None:
1733
+ payload["language"] = language
1734
+ if time_range is not None:
1735
+ payload["time_range"] = time_range
1736
+ if formats is not None:
1737
+ payload["formats"] = formats
1738
+ if webhook_url is not None:
1739
+ payload["webhook_url"] = webhook_url
1740
+
1741
+ response = await self._request_with_retry(
1742
+ "POST", "/api/v1/search/batch", json=payload
1743
+ )
1744
+ return response.json()
1745
+
1746
+ # ------------------------------------------------------------------
1747
+ # Map
1748
+ # ------------------------------------------------------------------
1749
+
1750
+ async def map(
1751
+ self,
1752
+ url: str,
1753
+ *,
1754
+ max_pages: int = 500,
1755
+ max_depth: int = 3,
1756
+ include_patterns: Optional[List[str]] = None,
1757
+ exclude_patterns: Optional[List[str]] = None,
1758
+ search: Optional[str] = None,
1759
+ sitemap: Literal["skip", "include", "only"] = "include",
1760
+ sitemap_path: Optional[str] = None,
1761
+ include_metadata: bool = False,
1762
+ include_subdomains: bool = False,
1763
+ respect_robots: bool = True,
1764
+ ) -> Dict[str, Any]:
1765
+ """Discover URLs on a website via sitemap parsing and link extraction.
1766
+
1767
+ Lightweight site structure discovery without scraping content.
1768
+ Returns a flat list of discovered URLs with optional metadata.
1769
+
1770
+ Args:
1771
+ url: Starting URL for site discovery
1772
+ max_pages: Maximum URLs to discover (1-50000, default 500)
1773
+ max_depth: Link-following depth (0-10, default 3)
1774
+ include_patterns: Glob patterns — only include matching URLs
1775
+ exclude_patterns: Glob patterns — exclude matching URLs
1776
+ search: Query to filter/rank discovered URLs by relevance
1777
+ sitemap: Sitemap handling: skip, include (default), or only
1778
+ sitemap_path: Custom sitemap location (e.g., /custom-sitemap.xml)
1779
+ include_metadata: Fetch title and meta description for each URL
1780
+ include_subdomains: Include URLs from subdomains
1781
+ respect_robots: Respect robots.txt directives (default True)
1782
+
1783
+ Returns:
1784
+ Map response with map_id, urls, total_urls, credits_used, etc.
1785
+
1786
+ Raises:
1787
+ AlterLabAPIError: On API errors
1788
+
1789
+ Example:
1790
+ result = await client.map(
1791
+ "https://example.com",
1792
+ max_pages=1000,
1793
+ include_metadata=True,
1794
+ )
1795
+ for entry in result["urls"]:
1796
+ print(f"{entry['url']} (depth={entry['depth']})")
1797
+ """
1798
+ payload: Dict[str, Any] = {
1799
+ "url": url,
1800
+ "max_pages": max_pages,
1801
+ "max_depth": max_depth,
1802
+ "sitemap": sitemap,
1803
+ "include_metadata": include_metadata,
1804
+ "include_subdomains": include_subdomains,
1805
+ "respect_robots": respect_robots,
1806
+ }
1807
+ if include_patterns is not None:
1808
+ payload["include_patterns"] = include_patterns
1809
+ if exclude_patterns is not None:
1810
+ payload["exclude_patterns"] = exclude_patterns
1811
+ if search is not None:
1812
+ payload["search"] = search
1813
+ if sitemap_path is not None:
1814
+ payload["sitemap_path"] = sitemap_path
1815
+
1816
+ response = await self._request_with_retry("POST", "/api/v1/map", json=payload)
1817
+ return response.json()
1818
+
1819
+ # ------------------------------------------------------------------
1820
+ # Extract
1821
+ # ------------------------------------------------------------------
1822
+
1823
+ async def extract(
1824
+ self,
1825
+ content: str,
1826
+ *,
1827
+ content_type: Literal["html", "text", "markdown"] = "html",
1828
+ extraction_schema: Optional[Dict[str, Any]] = None,
1829
+ extraction_profile: Optional[
1830
+ Literal[
1831
+ "auto",
1832
+ "product",
1833
+ "article",
1834
+ "job_posting",
1835
+ "faq",
1836
+ "recipe",
1837
+ "event",
1838
+ ]
1839
+ ] = None,
1840
+ extraction_prompt: Optional[str] = None,
1841
+ formats: Optional[
1842
+ List[Literal["text", "json", "json_v2", "html", "markdown", "rag"]]
1843
+ ] = None,
1844
+ source_url: Optional[str] = None,
1845
+ evidence: bool = False,
1846
+ ) -> Dict[str, Any]:
1847
+ """Extract structured data from raw content without scraping.
1848
+
1849
+ Accepts HTML, text, or markdown content and runs the extraction pipeline
1850
+ including format conversion, schema-based filtering, and LLM extraction.
1851
+
1852
+ Args:
1853
+ content: Raw content to extract from (HTML, text, or markdown)
1854
+ content_type: Type of the provided content (default "html")
1855
+ extraction_schema: JSON Schema for structured extraction
1856
+ extraction_profile: Pre-defined extraction profile
1857
+ extraction_prompt: Natural language extraction instructions for LLM
1858
+ formats: Output formats (default ["json"])
1859
+ source_url: Original URL of the content (for context, not fetched)
1860
+ evidence: Include field provenance/evidence (default False)
1861
+
1862
+ Returns:
1863
+ Extract response with extract_id, formats, credits_used, etc.
1864
+
1865
+ Raises:
1866
+ AlterLabAPIError: On API errors
1867
+
1868
+ Example:
1869
+ result = await client.extract(
1870
+ content="<html><body><h1>Price: $29.99</h1></body></html>",
1871
+ content_type="html",
1872
+ extraction_prompt="Extract the price",
1873
+ formats=["json"],
1874
+ )
1875
+ print(result["formats"]["json"])
1876
+ """
1877
+ payload: Dict[str, Any] = {
1878
+ "content": content,
1879
+ "content_type": content_type,
1880
+ "evidence": evidence,
1881
+ }
1882
+ if extraction_schema is not None:
1883
+ payload["extraction_schema"] = extraction_schema
1884
+ if extraction_profile is not None:
1885
+ payload["extraction_profile"] = extraction_profile
1886
+ if extraction_prompt is not None:
1887
+ payload["extraction_prompt"] = extraction_prompt
1888
+ if formats is not None:
1889
+ payload["formats"] = formats
1890
+ if source_url is not None:
1891
+ payload["source_url"] = source_url
1892
+
1893
+ response = await self._request_with_retry(
1894
+ "POST", "/api/v1/extract", json=payload
1895
+ )
1896
+ return response.json()
1897
+
1189
1898
  # Deprecated methods for backwards compatibility
1190
1899
 
1191
1900
  async def scrape_light(self, url: str, **kwargs) -> Dict[str, Any]:
@@ -1307,6 +2016,22 @@ class AlterLabSync:
1307
2016
  """Synchronous version of crawl_and_wait()."""
1308
2017
  return self._run(self._client.crawl_and_wait(*args, **kwargs))
1309
2018
 
2019
+ def search(self, *args, **kwargs) -> Dict[str, Any]:
2020
+ """Synchronous version of search()."""
2021
+ return self._run(self._client.search(*args, **kwargs))
2022
+
2023
+ def batch_search(self, *args, **kwargs) -> Dict[str, Any]:
2024
+ """Synchronous version of batch_search()."""
2025
+ return self._run(self._client.batch_search(*args, **kwargs))
2026
+
2027
+ def map(self, *args, **kwargs) -> Dict[str, Any]:
2028
+ """Synchronous version of map()."""
2029
+ return self._run(self._client.map(*args, **kwargs))
2030
+
2031
+ def extract(self, *args, **kwargs) -> Dict[str, Any]:
2032
+ """Synchronous version of extract()."""
2033
+ return self._run(self._client.extract(*args, **kwargs))
2034
+
1310
2035
  def scrape_html(self, *args, **kwargs) -> Dict[str, Any]:
1311
2036
  """Synchronous version of scrape_html()."""
1312
2037
  return self._run(self._client.scrape_html(*args, **kwargs))
@@ -0,0 +1,93 @@
1
+ """Type definitions for AlterLab SDK session and cookie support."""
2
+
3
+ from typing import Any, Dict, List, Literal, Optional
4
+
5
+ from typing_extensions import TypedDict
6
+
7
+
8
+ class TierInfo(TypedDict):
9
+ """Rate-limit tier information derived from account balance.
10
+
11
+ Returned as part of the ``/auth/me`` response so that SDK consumers can
12
+ inspect their current tier without a separate API call.
13
+
14
+ Balance-based tier table:
15
+
16
+ +--------------+-----------+-------+-------------+
17
+ | Tier | Balance | RPM | Concurrent |
18
+ +==============+===========+=======+=============+
19
+ | Free | $0–$5 | 30 | 3 |
20
+ | Basic | $5–$50 | 60 | 10 |
21
+ | Growth | $50–$200 | 100 | 15 |
22
+ | Scale | $200–$500 | 300 | 25 |
23
+ | High Volume | $500+ | 500 | 50 |
24
+ | Enterprise | Custom | 10000 | 500 |
25
+ +--------------+-----------+-------+-------------+
26
+ """
27
+
28
+ name: str
29
+ """Human-readable tier name, e.g. ``'Free'``, ``'Basic'``, ``'Growth'``."""
30
+
31
+ slug: str
32
+ """Machine-readable tier identifier, e.g. ``'free'``, ``'basic'``, ``'growth'``."""
33
+
34
+ requests_per_minute: int
35
+ """Maximum API requests per minute allowed at this tier."""
36
+
37
+ concurrent_requests: int
38
+ """Maximum number of concurrent scrape requests allowed at this tier."""
39
+
40
+ min_balance_usd: float
41
+ """Minimum account balance (USD) required to qualify for this tier."""
42
+
43
+ max_balance_usd: Optional[float]
44
+ """Upper balance threshold (USD) for this tier. ``None`` means no upper limit."""
45
+
46
+ next_tier_slug: Optional[str]
47
+ """Slug of the next higher tier, or ``None`` if already at the top tier."""
48
+
49
+ next_tier_threshold_usd: Optional[float]
50
+ """Balance (USD) needed to reach the next tier, or ``None`` if at top tier."""
51
+
52
+
53
+ class SessionIntegration(TypedDict, total=False):
54
+ """A stored session integration. Cookie values are never exposed."""
55
+
56
+ id: str
57
+ name: str
58
+ domain: str
59
+ cookie_names: List[str]
60
+ header_names: Optional[List[str]]
61
+ status: Literal["active", "expired", "invalid", "pending_validation"]
62
+ expires_at: Optional[str]
63
+ last_validated_at: Optional[str]
64
+ validation_confidence: Optional[int]
65
+ consecutive_failures: int
66
+ last_used_at: Optional[str]
67
+ total_requests: int
68
+ successful_requests: int
69
+ success_rate: float
70
+ notes: Optional[str]
71
+ created_at: str
72
+ updated_at: str
73
+
74
+
75
+ class SessionListResponse(TypedDict):
76
+ """Response from listing sessions."""
77
+
78
+ sessions: List[SessionIntegration]
79
+ total: int
80
+
81
+
82
+ class SessionValidation(TypedDict, total=False):
83
+ """Result of a session validation check."""
84
+
85
+ is_valid: bool
86
+ confidence: int
87
+ http_status: Optional[int]
88
+ detected_user: Optional[str]
89
+ cookie_expiry_detected: Optional[str]
90
+ cookies_expiring_soon: bool
91
+ indicators_found: Optional[Dict[str, List[str]]]
92
+ consecutive_failures: int
93
+ details: str
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "alterlab"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "AlterLab Python SDK"
5
5
  authors = ["AlterLab <dev@alterlab.io>"]
6
6
 
@@ -1,48 +0,0 @@
1
- """Type definitions for AlterLab SDK session and cookie support."""
2
-
3
- from typing import Any, Dict, List, Literal, Optional
4
-
5
- from typing_extensions import TypedDict
6
-
7
-
8
- class SessionIntegration(TypedDict, total=False):
9
- """A stored session integration. Cookie values are never exposed."""
10
-
11
- id: str
12
- name: str
13
- domain: str
14
- cookie_names: List[str]
15
- header_names: Optional[List[str]]
16
- status: Literal["active", "expired", "invalid", "pending_validation"]
17
- expires_at: Optional[str]
18
- last_validated_at: Optional[str]
19
- validation_confidence: Optional[int]
20
- consecutive_failures: int
21
- last_used_at: Optional[str]
22
- total_requests: int
23
- successful_requests: int
24
- success_rate: float
25
- notes: Optional[str]
26
- created_at: str
27
- updated_at: str
28
-
29
-
30
- class SessionListResponse(TypedDict):
31
- """Response from listing sessions."""
32
-
33
- sessions: List[SessionIntegration]
34
- total: int
35
-
36
-
37
- class SessionValidation(TypedDict, total=False):
38
- """Result of a session validation check."""
39
-
40
- is_valid: bool
41
- confidence: int
42
- http_status: Optional[int]
43
- detected_user: Optional[str]
44
- cookie_expiry_detected: Optional[str]
45
- cookies_expiring_soon: bool
46
- indicators_found: Optional[Dict[str, List[str]]]
47
- consecutive_failures: int
48
- details: str
File without changes