alterlab 2.4.2__tar.gz → 2.4.4__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.
Files changed (29) hide show
  1. alterlab-2.4.4/PKG-INFO +675 -0
  2. alterlab-2.4.4/README.md +651 -0
  3. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/__init__.py +9 -1
  4. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/actions.py +444 -4
  5. alterlab-2.4.4/alterlab/artifacts.py +81 -0
  6. alterlab-2.4.4/alterlab/cli.py +232 -0
  7. alterlab-2.4.4/alterlab/cli_auth.py +135 -0
  8. alterlab-2.4.4/alterlab/cli_data.py +237 -0
  9. alterlab-2.4.4/alterlab/cli_hands.py +330 -0
  10. alterlab-2.4.4/alterlab/cli_local/__init__.py +276 -0
  11. alterlab-2.4.4/alterlab/cli_local/vendor_signatures.json +177 -0
  12. alterlab-2.4.4/alterlab/cli_routing.py +64 -0
  13. alterlab-2.4.4/alterlab/cli_runs.py +129 -0
  14. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/client.py +246 -22
  15. alterlab-2.4.4/alterlab/data/tool_definitions.json +69 -0
  16. alterlab-2.4.4/alterlab/hands_receipts.py +77 -0
  17. alterlab-2.4.4/alterlab/listings/__init__.py +271 -0
  18. alterlab-2.4.4/alterlab/mcp_stdio.py +142 -0
  19. alterlab-2.4.4/alterlab/research.py +171 -0
  20. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/types.py +12 -0
  21. alterlab-2.4.4/pyproject.toml +29 -0
  22. alterlab-2.4.2/PKG-INFO +0 -19
  23. alterlab-2.4.2/pyproject.toml +0 -23
  24. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/_failure_backoff.py +0 -0
  25. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/crypto.py +0 -0
  26. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/document_types.py +0 -0
  27. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/requests_compat.py +0 -0
  28. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/resources/__init__.py +0 -0
  29. {alterlab-2.4.2 → alterlab-2.4.4}/alterlab/resources/sessions.py +0 -0
@@ -0,0 +1,675 @@
1
+ Metadata-Version: 2.4
2
+ Name: alterlab
3
+ Version: 2.4.4
4
+ Summary: Official Python client for AlterLab: search, read and extract the live web, with automatic fallback from direct fetch to full browser rendering. Pay only for successful requests.
5
+ License: MIT
6
+ Author: AlterLab
7
+ Author-email: dev@alterlab.io
8
+ Requires-Python: >=3.8,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Requires-Dist: httpx (>=0.25.2,<1.0)
20
+ Requires-Dist: pydantic (>=2.5.0,<3.0.0)
21
+ Requires-Dist: typing_extensions (>=3.7.4)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # AlterLab Python SDK
25
+
26
+ Official Python client for [AlterLab](https://alterlab.io): search, read and extract the live web, with automatic fallback from direct fetch to full browser rendering. Pay only for successful requests.
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/alterlab)](https://pypi.org/project/alterlab/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/alterlab)](https://pypi.org/project/alterlab/)
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install alterlab
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ import asyncio
41
+ from alterlab import AlterLab
42
+
43
+ async def main():
44
+ async with AlterLab(api_key="sk_test_...") as client:
45
+ result = await client.scrape("https://example.com")
46
+ print(result["content"])
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ Output (the page text for `https://example.com`):
52
+
53
+ ```text
54
+ Example Domain
55
+
56
+ This domain is for use in documentation examples without needing permission. Avoid use in operations.
57
+ ```
58
+
59
+ See [pricing](https://alterlab.io/pricing) and the [benchmark](https://alterlab.io/benchmark).
60
+
61
+ Prefer synchronous code? Use the sync wrapper:
62
+
63
+ ```python
64
+ from alterlab import AlterLabSync
65
+
66
+ with AlterLabSync(api_key="sk_test_...") as client:
67
+ result = client.scrape("https://example.com")
68
+ print(result["content"])
69
+ ```
70
+
71
+ ### Drop-in `requests` replacement
72
+
73
+ Already have code written against the [`requests`](https://requests.readthedocs.io/)
74
+ library? Swap the import line and keep the rest of your code unchanged — every call is
75
+ routed through AlterLab, with automatic fallback from direct fetch to full browser
76
+ rendering, instead of a bare socket connection, with no new backend endpoint involved.
77
+
78
+ ```python
79
+ from alterlab import requests # instead of: import requests
80
+
81
+ # Reads ALTERLAB_API_KEY from the environment on first call.
82
+ response = requests.get("https://example.com", headers={"X-Custom-Header": "value"})
83
+ print(response.status_code)
84
+ print(response.text)
85
+
86
+ data = requests.post(
87
+ "https://api.example.com/thing",
88
+ json={"key": "value"},
89
+ ).json()
90
+ ```
91
+
92
+ No `ALTERLAB_API_KEY` in your environment, or want to set the key explicitly?
93
+
94
+ ```python
95
+ from alterlab import requests
96
+
97
+ requests.configure(api_key="sk_test_...")
98
+ response = requests.get("https://example.com")
99
+ ```
100
+
101
+ Supported: `requests.get`, `.post`, `.put`, `.patch`, `.delete`, `.request(method, url, ...)`,
102
+ with `params=`, `json=`, `data=`, `headers=`, and `timeout=` matching `requests`' own call
103
+ signature. The returned `Response` object exposes `.status_code`, `.text`, `.content`,
104
+ `.headers`, `.url`, `.json()`, `.ok`, and `.raise_for_status()`.
105
+
106
+ **Note**: AlterLab's own exceptions (`AlterLabAPIError` and subclasses like
107
+ `AuthenticationError`, `InsufficientFundsError`, `RateLimitError`) propagate directly from
108
+ these calls rather than being wrapped in `requests.exceptions` types — catch
109
+ `alterlab.AlterLabAPIError` instead of `requests.exceptions.HTTPError`. This includes
110
+ `.raise_for_status()`: for a 4xx/5xx status returned by the *target site* through an
111
+ otherwise-successful scrape, it raises `alterlab.AlterLabHTTPError` (a subclass of
112
+ `AlterLabAPIError`, with a `.status_code` attribute) — not `requests.exceptions.HTTPError`.
113
+
114
+ ## Command line
115
+
116
+ The `alterlab` CLI ships with the SDK (MIT licensed). Install it with `pipx install alterlab`,
117
+ `uvx alterlab ...` or `pip install alterlab` (add `keyring` to store the key in the OS keychain).
118
+
119
+ ```bash
120
+ alterlab login --email you@example.com # a human approves in the browser; the key is never printed
121
+ alterlab scrape https://example.com -f md --estimate
122
+ alterlab crawl https://example.com --max 50 --max-cost 0.50 | jq .url
123
+ cat urls.txt | alterlab batch
124
+ alterlab search "web scraping" --content
125
+ alterlab extract https://shop.example/item --profile product
126
+ alterlab fetch https://example.com # direct fetch first, AlterLab only if blocked
127
+ ```
128
+
129
+ Data commands stream NDJSON (or markdown) to stdout and print the cost to stderr.
130
+ `--estimate` prints the quote and exits; `--max-cost USD` is a hard cap.
131
+
132
+ No account needed: `alterlab detect <url>`, `alterlab doctor <url>`, `alterlab md <file|url>`,
133
+ `alterlab robots <url>`.
134
+
135
+ Agents: `claude mcp add alterlab -- uvx alterlab mcp` runs a local stdio MCP server;
136
+ `alterlab skill install` installs the agent skill.
137
+
138
+ `alterlab routing-policy [--agent claude|codex|agents|cursor|copilot|windsurf] [--domains a.com,b.org] [--dir PATH]` writes the "use AlterLab for web tasks" policy into the client's project instructions file (`CLAUDE.md`, `AGENTS.md`, `.cursor/rules/alterlab.mdc`, ...). Re-running replaces the marked block in place; all other content is kept.
139
+
140
+ ## Methods
141
+
142
+ ### `scrape(url, **kwargs)`
143
+
144
+ Scrape a single URL. Supports all scraping modes and automatic async job polling.
145
+
146
+ ```python
147
+ # Simple HTML scraping
148
+ result = await client.scrape("https://example.com")
149
+
150
+ # JavaScript rendering
151
+ result = await client.scrape("https://example.com", mode="js")
152
+
153
+ # With output formats
154
+ result = await client.scrape(
155
+ "https://example.com",
156
+ formats=["markdown", "text"],
157
+ )
158
+
159
+ # Structured extraction with a profile
160
+ result = await client.scrape(
161
+ "https://example.com/product",
162
+ extraction_profile="product",
163
+ )
164
+
165
+ # Structured extraction with a JSON schema
166
+ result = await client.scrape(
167
+ "https://example.com/product",
168
+ extraction_schema={
169
+ "type": "object",
170
+ "properties": {
171
+ "title": {"type": "string"},
172
+ "price": {"type": "number"},
173
+ },
174
+ },
175
+ )
176
+ ```
177
+
178
+ **Parameters:**
179
+
180
+ | Parameter | Type | Default | Description |
181
+ |-----------|------|---------|-------------|
182
+ | `url` | `str` | required | URL to scrape |
183
+ | `mode` | `str` | `"auto"` | Scraping mode: `auto`, `html`, `js`, `pdf`, `ocr` |
184
+ | `sync` | `bool` | `True` | Wait for result (False returns job_id immediately) |
185
+ | `response` | `Optional[Literal["full", "minimal"]]` | `None` | `"minimal"` returns only content, status, url, cost and warnings (server default for MCP, agent and keyless keys) |
186
+ | `sync_policy` | `Literal["auto", "wait_until_deadline"]` | `"auto"` | `auto` preserves early 202 handoffs; `wait_until_deadline` waits through tier boundaries until terminal completion or the server's 85-second safe deadline (requires `sync=True`) |
187
+ | `formats` | `list` | `None` | Output formats: `text`, `json`, `json_v2`, `html`, `markdown`, `rag` |
188
+ | `extraction_schema` | `dict` | `None` | JSON Schema for structured extraction |
189
+ | `extraction_prompt` | `str` | `None` | Natural language extraction instructions |
190
+ | `extraction_profile` | `str` | `None` | Pre-built profile: `auto`, `product`, `article`, `job_posting`, `faq`, `recipe`, `event` |
191
+ | `advanced` | `AdvancedOptions` | `None` | Advanced scraping options (see below) |
192
+ | `cost_controls` | `CostControls` | `None` | Cost control parameters (see below) |
193
+ | `force_refresh` | `bool` | `False` | Bypass cache |
194
+ | `evidence` | `bool` | `False` | Include provenance for extracted fields |
195
+
196
+ ### `crawl(url, **kwargs)`
197
+
198
+ Start a multi-page website crawl. Returns immediately with a `crawl_id` for polling.
199
+
200
+ ```python
201
+ crawl = await client.crawl(
202
+ "https://example.com",
203
+ max_pages=200,
204
+ max_depth=2,
205
+ include_patterns=["/blog/*"],
206
+ formats=["markdown"],
207
+ )
208
+ print(f"Crawl ID: {crawl['crawl_id']}")
209
+ print(f"Estimated pages: {crawl['estimated_pages']}")
210
+ ```
211
+
212
+ **Parameters:**
213
+
214
+ | Parameter | Type | Default | Description |
215
+ |-----------|------|---------|-------------|
216
+ | `url` | `str` | required | Starting URL |
217
+ | `max_pages` | `int` | `50` | Maximum pages to scrape (1-100,000) |
218
+ | `max_depth` | `int` | `3` | Maximum link-following depth (0-50) |
219
+ | `include_patterns` | `list[str]` | `None` | Glob patterns to include (e.g. `["/products/*"]`) |
220
+ | `exclude_patterns` | `list[str]` | `None` | Glob patterns to exclude |
221
+ | `formats` | `list` | `None` | Output formats per page |
222
+ | `extraction_schema` | `dict` | `None` | JSON Schema for extraction on each page |
223
+ | `extraction_profile` | `str` | `None` | Extraction profile for each page |
224
+ | `webhook_url` | `str` | `None` | URL for `crawl.completed` webhook |
225
+ | `respect_robots` | `bool` | `True` | Respect robots.txt |
226
+ | `include_subdomains` | `bool` | `False` | Follow links to subdomains |
227
+ | `cost_controls` | `dict` | `None` | `{"max_credits": N, "max_tier": "2"}` |
228
+
229
+ ### `crawl_and_wait(url, **kwargs)`
230
+
231
+ Start a crawl and poll until completion. Returns all page results.
232
+
233
+ ```python
234
+ results = await client.crawl_and_wait(
235
+ "https://example.com",
236
+ max_pages=50,
237
+ formats=["markdown"],
238
+ poll_timeout=300.0,
239
+ )
240
+ print(f"Status: {results['status']}")
241
+ print(f"Pages scraped: {results['completed']}")
242
+ for page in results.get("pages", []):
243
+ print(f" {page['url']}: {page['status']}")
244
+ ```
245
+
246
+ ### `get_crawl(crawl_id, include_results=False)`
247
+
248
+ Check crawl progress. Pass `include_results=True` to get per-page scrape results.
249
+
250
+ ```python
251
+ status = await client.get_crawl(crawl_id)
252
+ print(f"{status['completed']}/{status['total']} pages done")
253
+ ```
254
+
255
+ ### `cancel_crawl(crawl_id)`
256
+
257
+ Cancel a running crawl. Credits for unprocessed pages are refunded.
258
+
259
+ ```python
260
+ result = await client.cancel_crawl(crawl_id)
261
+ print(f"Refunded {result['credits_refunded']} credits")
262
+ ```
263
+
264
+ ### `batch_scrape(requests, webhook_url=None)`
265
+
266
+ Submit multiple scrape requests as a batch.
267
+
268
+ ```python
269
+ batch = await client.batch_scrape([
270
+ {"url": "https://example.com/page1", "mode": "html"},
271
+ {"url": "https://example.com/page2", "mode": "js"},
272
+ ])
273
+ for job_id in batch["job_ids"]:
274
+ result = await client.wait_for_job(job_id)
275
+ ```
276
+
277
+ ### `search(query, **kwargs)`
278
+
279
+ Unified, vendor-neutral search with page content by default. A plain `search("query")` runs
280
+ `engine="auto"` on `/v1/search` and returns each result page as concise markdown with a
281
+ per-result `content_status` (`scrape_results=True`, `concise=True`, `mode="standard"`). The unified
282
+ query is a flat $0.001; each fetched page bills at its fetch tier. Pass `scrape_results=False` for the
283
+ result list only. The SDK default engine is `auto`; the REST API's own default stays `duckduckgo`. An explicit `engine` keeps that engine's behaviour (no content unless asked for,
284
+ plain requests use `/v1/serp`).
285
+
286
+ ```python
287
+ results = client.search("headless browsers") # unified, with content
288
+ for item in results["results"]:
289
+ print(item["rank"], item["title"], item["url"], item["content_status"])
290
+
291
+ serp = client.search("headless browsers", engine="google", num=10, start=0, device="desktop")
292
+ for item in serp["organic_results"]:
293
+ print(item["position"], item["title"], item["url"])
294
+ ```
295
+
296
+ | Engine | Price per query |
297
+ | --- | --- |
298
+ | `duckduckgo` | $0.001 |
299
+ | `google` | $0.004 |
300
+ | `bing` | $0.004 |
301
+
302
+ With `scrape_results`, pass `sync=True` to wait up to ~30s and get content in one call; on timeout the
303
+ response is a 202 with `next_action` (poll the search id).
304
+
305
+ `scrape_results`, `domain` and `time_range` are still served by the `/v1/search` alias, so
306
+ requests that use them are sent there. The response is the JSON of the endpoint used: `organic_results` from
307
+ `/v1/serp`, `results` from the alias. See the
308
+ [migration guide](https://alterlab.io/docs/migrations/search-to-serp).
309
+
310
+ ### `research(query, **options)` and the research job methods
311
+
312
+ Budgeted deep research on your own model key (BYOK; a missing key returns `byok_key_required`
313
+ and charges nothing). Every claim in the result carries citations.
314
+
315
+ ```python
316
+ job = await client.research(
317
+ "Which EU countries ban fur farming?",
318
+ effort="standard",
319
+ max_cost_microcents=50_000,
320
+ output="both",
321
+ )
322
+ job = await client.wait_for_research(job["id"]) # returns on completed / completed_partial / failed / cancelled
323
+ print(job["status"], job["cost"], job["result"])
324
+ ```
325
+
326
+ Also: `get_research(id)`, `cancel_research(id)`, `research_events(id, last_event_id=None)`
327
+ (progress events, resumable), and for `plan_first=True` jobs `get_research_plan(id)`,
328
+ `update_research_plan(id, plan)` and `approve_research(id, plan=None)`. Every method has a sync
329
+ twin on `AlterLabSync`. See the [API reference](https://alterlab.io/docs/api/research).
330
+
331
+ ### `batch_search(queries, **kwargs)` and `get_batch_search(batch_id)`
332
+
333
+ Submit and poll a batch of search queries. Each query is billed at the engine rate above.
334
+
335
+ ### `feedback(category, tried, happened, expected=None, job_id=None)`
336
+
337
+ Tell AlterLab what was hard. Returns `cluster_key` and `status_url`. Never include customer data.
338
+
339
+ ```python
340
+ result = client.feedback("blocked", "mode=auto", "empty body with 200", job_id="job_123")
341
+ print(result["cluster_key"], result["status_url"])
342
+ ```
343
+
344
+ CLI (reads `ALTERLAB_API_KEY`; `--job-id` defaults to `$ALTERLAB_LAST_JOB_ID`):
345
+
346
+ ```bash
347
+ alterlab feedback --category blocked --message "empty body with 200" --job-id job_123
348
+ ```
349
+
350
+ ### `estimate_cost(url, **kwargs)`
351
+
352
+ Get a cost estimate before scraping.
353
+
354
+ ```python
355
+ estimate = await client.estimate_cost("https://example.com", mode="js")
356
+ print(f"Estimated: {estimate['estimated_credits']} credits")
357
+ print(f"Max possible: {estimate['max_possible_credits']} credits")
358
+ ```
359
+
360
+ ### `get_usage()`
361
+
362
+ Check your balance and usage stats. All monetary values are in **microcents** (1,000,000 = $1.00).
363
+
364
+ ```python
365
+ usage = await client.get_usage()
366
+ balance = usage['balance_microcents'] / 1_000_000
367
+ print(f"Balance: ${balance:.4f}")
368
+ print(f"Account tier: {usage['account_tier']}")
369
+ print(f"Entitlement source: {usage['entitlement_source']}")
370
+ print(f"Used this month: ${usage['credits_used_month'] / 1_000_000:.4f}")
371
+ print(f"Period: {usage['period_start']} to {usage['period_end']}")
372
+ ```
373
+
374
+ ### `wait_for_job(job_id, **kwargs)`
375
+
376
+ Poll an async job until completion with exponential backoff.
377
+
378
+ ```python
379
+ # Start async scrape
380
+ response = await client.scrape("https://example.com", sync=False)
381
+ result = await client.wait_for_job(response["job_id"])
382
+ ```
383
+
384
+ ### `get_job_status(job_id, wait=0)`
385
+
386
+ Check job status. Returns immediately by default; pass `wait` (seconds, `>= 0`) to
387
+ have the server hold the request until the job finishes or the window elapses.
388
+
389
+ ```python
390
+ status = await client.get_job_status(job_id)
391
+ print(f"Status: {status['status']}")
392
+
393
+ # Long-poll for up to 20 seconds
394
+ status = await client.get_job_status(job_id, wait=20)
395
+ ```
396
+
397
+ ## Configuration
398
+
399
+ ### `AdvancedOptions`
400
+
401
+ Fine-grained control over scraping behavior with credit cost implications.
402
+
403
+ ```python
404
+ from alterlab import AdvancedOptions
405
+
406
+ advanced = AdvancedOptions(
407
+ render_js=True, # +3 credits — headless browser rendering
408
+ screenshot=True, # +1 credit — full-page screenshot (requires render_js)
409
+ markdown=True, # Free — convert to markdown
410
+ generate_pdf=True, # +2 credits — generate PDF (requires render_js)
411
+ ocr=True, # +5 credits — extract text from images
412
+ use_proxy=True, # +1 credit — premium proxy
413
+ wait_condition="networkidle", # domcontentloaded | networkidle | load
414
+ )
415
+
416
+ result = await client.scrape("https://example.com", advanced=advanced)
417
+ ```
418
+
419
+ ### `CostControls`
420
+
421
+ Set spending limits and optimization preferences.
422
+
423
+ ```python
424
+ from alterlab import CostControls
425
+
426
+ controls = CostControls(
427
+ max_credits=5, # Hard credit limit for this request
428
+ max_tier="2", # Don't escalate beyond tier 2
429
+ prefer_cost=True, # Optimize for lowest cost
430
+ prefer_speed=True, # Optimize for fastest response
431
+ fail_fast=True, # Fail immediately if limits would be exceeded
432
+ fallback_on_block=False, # Opt-in: if a forced browser render hits a verification screen or timeout, retry once at T3/T3.5
433
+ )
434
+
435
+ result = await client.scrape("https://example.com", cost_controls=controls)
436
+ ```
437
+
438
+ ## Browser actions
439
+
440
+ Run a sequence of browser actions after page load (max 20, requires `render_js=True`).
441
+ Build them with the typed `Actions` helpers; constraint violations raise `ValueError`
442
+ before the request is sent. Raw dicts such as `{"type": "click", "selector": "..."}`
443
+ are still accepted.
444
+
445
+ ```python
446
+ from alterlab import Actions, AdvancedOptions
447
+
448
+ # Screenshot after actions: click, wait, then capture the resulting page
449
+ result = await client.scrape(
450
+ "https://example.com/products",
451
+ advanced=AdvancedOptions(render_js=True),
452
+ actions=[
453
+ Actions.dismiss_consent(),
454
+ Actions.click("button.load-more"),
455
+ Actions.wait(milliseconds=1000),
456
+ Actions.evaluate("document.title"),
457
+ Actions.screenshot(full_page=True),
458
+ ],
459
+ )
460
+
461
+ for ar in result["action_results"] or []:
462
+ # Each entry is an ActionResult: a dict with typed attribute access
463
+ print(ar.index, ar.type, ar.success, ar.error)
464
+
465
+ title = result["action_results"][3].result
466
+ screenshot_url = result["action_results"][4].screenshot_url
467
+ ```
468
+
469
+ Available builders: `wait`, `wait_for`, `click`, `fill`, `press`, `scroll`,
470
+ `infinite_scroll`, `screenshot`, `evaluate`, `dismiss_consent`. Failed actions are
471
+ skipped; check `ar.success` and `ar.error` on each result.
472
+
473
+ ### Downloading files
474
+
475
+ `Actions.download(selector=... | url=...)` captures a file download started by the
476
+ page (exactly one of `selector` or `url`; `timeout_ms` 1000-30000, optional
477
+ `max_bytes` up to 100 MiB, `frame` only with `selector`; at most 5 per request).
478
+ The file is stored privately and the matching action result carries
479
+ `ActionResult.artifact` metadata (`artifact_id`, `filename`, `media_type`,
480
+ `size_bytes`, `sha256`, `scan_status`, `expires_at`).
481
+
482
+ Multi-file: `max_files` (1-10) collects several files into
483
+ `ActionResult.artifacts`; `max_total_bytes` caps their total size, `quiet_ms`
484
+ (100-10000) ends collection after an idle period and `bundle="zip"` also stores
485
+ a ZIP, returned as `ActionResult.bundle` (the last three need `max_files > 1`).
486
+
487
+ ```python
488
+ result = client.scrape(
489
+ "https://example.com/reports",
490
+ advanced=AdvancedOptions(render_js=True),
491
+ actions=[Actions.click("#export"), Actions.download(selector="a.csv")],
492
+ )
493
+ artifact = result["action_results"][1].artifact
494
+ client.get_artifact(artifact.artifact_id) # metadata
495
+ client.get_artifact_url(artifact.artifact_id)["url"] # short-lived signed URL
496
+ client.download_artifact(artifact.artifact_id, "report.csv") # saves the file
497
+ ```
498
+
499
+ The same methods exist on `AlterLab` (awaitable) and `AlterLabSync`, and as
500
+ `alterlab.artifacts.get_artifact(client, id)` etc. The download is fetched
501
+ without your API key and written atomically (a failed download leaves no file).
502
+
503
+ ## Convenience Methods
504
+
505
+ ```python
506
+ # HTML only (fastest, 1 credit base)
507
+ result = await client.scrape_html("https://example.com")
508
+
509
+ # JavaScript rendering
510
+ result = await client.scrape_js("https://example.com", screenshot=True)
511
+
512
+ # PDF extraction
513
+ result = await client.scrape_pdf("https://example.com/doc.pdf")
514
+
515
+ # OCR
516
+ result = await client.scrape_ocr("https://example.com/image.png", language="eng")
517
+ ```
518
+
519
+ ## Error Handling
520
+
521
+ ```python
522
+ from alterlab import (
523
+ AlterLabAPIError,
524
+ AlterLabTimeoutError,
525
+ AlterLabError,
526
+ AuthenticationError,
527
+ InsufficientFundsError,
528
+ RateLimitError,
529
+ )
530
+
531
+ try:
532
+ result = await client.scrape("https://example.com")
533
+ except AuthenticationError:
534
+ print("Check your API key at https://alterlab.io/dashboard/keys")
535
+ except InsufficientFundsError as e:
536
+ print(f"Add funds at: {e.billing_url}")
537
+ except RateLimitError as e:
538
+ print(f"Rate limited. Retry after {e.retry_after}s")
539
+ except AlterLabAPIError as e:
540
+ print(f"API error {e.status_code}: {e.detail}")
541
+ except AlterLabTimeoutError as e:
542
+ print(f"Job timed out: {e}")
543
+ except AlterLabError as e:
544
+ print(f"SDK error: {e}")
545
+ ```
546
+
547
+ | Exception | When |
548
+ |-----------|------|
549
+ | `AuthenticationError` | HTTP 401 — invalid or missing API key |
550
+ | `InsufficientFundsError` | HTTP 402 — insufficient balance (has `.billing_url`) |
551
+ | `RateLimitError` | HTTP 429 after retries exhausted or `max_retry_wait` reached (has `.retry_after`, `.scope`, `.limit`, `.account_tier`, `.docs_url`, `.rate_limit_*`, `.hint`; `None` for edge 429s without the canonical body). For `.error_code == "repeat_failure_backoff"` (the same request failed the same way several times in a row; no credits charged) it also has `.consecutive_failures`, `.outcome_class` and `.failure_code`: retry after `.retry_after`, with different request options, or with `force_refresh=True` |
552
+ | `AlterLabAPIError` | Other HTTP 4xx/5xx from the API (has `.status_code`, `.detail` and `.hint`) |
553
+ | `JobFailedError` | Polled job ended `failed`, `rejected` or `cancelled` (has `.job_id`, `.status`, `.hint`) |
554
+ | `AlterLabTimeoutError` | Async job polling exceeded timeout |
555
+ | `AlterLabValidationError` | Invalid parameters (e.g. `screenshot=True` without `render_js`) |
556
+ | `JobExpiredError` | Job result expired (1-hour TTL) — resubmit the request |
557
+ | `AlterLabHTTPError` | `requests`-compat `.raise_for_status()` — 4xx/5xx from the *target site*, not the API (has `.status_code`) |
558
+ | `AlterLabError` | Base class for all SDK errors |
559
+
560
+ All typed exceptions (`AuthenticationError`, `InsufficientFundsError`, `RateLimitError`,
561
+ `AlterLabHTTPError`) are subclasses of `AlterLabAPIError` — existing `except AlterLabAPIError`
562
+ blocks still catch them.
563
+
564
+ ### Retry hints
565
+
566
+ When a request forces browser rendering (`render_js=True` or `force_tier="4"`) and the page
567
+ returns a verification screen or does not finish loading in time, the error carries a `hint`
568
+ (`{"code": "render_js_auto", "reason": "challenge" | "timeout", "message": ...}`). It is on
569
+ `AlterLabAPIError.hint` (failed sync scrape), `JobFailedError.hint` (failed polled job), and in
570
+ a failed crawl page's `error`. `get_error_hint()` reads any of them:
571
+
572
+ ```python
573
+ from alterlab import AdvancedOptions, AlterLabAPIError, get_error_hint
574
+
575
+ try:
576
+ result = await client.scrape(
577
+ "https://example.com", advanced=AdvancedOptions(render_js=True)
578
+ )
579
+ except AlterLabAPIError as e:
580
+ hint = get_error_hint(e)
581
+ if hint and hint["code"] == "render_js_auto":
582
+ print(hint["message"])
583
+ result = await client.scrape(
584
+ "https://example.com", advanced=AdvancedOptions(render_js="auto")
585
+ )
586
+ ```
587
+
588
+ For pages that only sometimes need a browser, prefer `render_js="auto"` from the start. Keep
589
+ `render_js=True` for `screenshot`, `generate_pdf` and browser `actions`.
590
+
591
+ ## Client Options
592
+
593
+ ```python
594
+ client = AlterLab(
595
+ api_key="sk_test_...", # Optional — falls back to ALTERLAB_API_KEY env var
596
+ base_url="https://api.alterlab.io", # Default API URL
597
+ timeout=30, # Request timeout (seconds)
598
+ max_retries=3, # Retry count for transient failures
599
+ retry_delay=1.0, # Initial retry delay (jittered exponential backoff)
600
+ max_retry_wait=60.0, # Max total seconds spent waiting between retries (None = no cap)
601
+ )
602
+ ```
603
+
604
+ 429 and 5xx responses are retried with jittered exponential backoff. A `Retry-After` header
605
+ is honoured as a minimum wait; if waiting it out would exceed `max_retry_wait`, the
606
+ `RateLimitError` is raised immediately with `.retry_after` set so you can schedule the retry.
607
+
608
+ If `api_key` is omitted, the client reads it from the `ALTERLAB_API_KEY` environment variable:
609
+
610
+ ```python
611
+ # With ALTERLAB_API_KEY set in the environment
612
+ client = AlterLab()
613
+ ```
614
+
615
+ A `ValueError` is raised only if neither `api_key` nor `ALTERLAB_API_KEY` is set. `AlterLabSync` accepts the same `api_key` parameter and env-var fallback.
616
+
617
+ The client automatically retries on 429 (rate limit) and 5xx errors with exponential backoff and `Retry-After` header support.
618
+
619
+ ## Async Context Manager
620
+
621
+ Always use the client as a context manager to properly close connections:
622
+
623
+ ```python
624
+ async with AlterLab(api_key="sk_test_...") as client:
625
+ result = await client.scrape("https://example.com")
626
+ ```
627
+
628
+ Or close manually:
629
+
630
+ ```python
631
+ client = AlterLab(api_key="sk_test_...")
632
+ try:
633
+ result = await client.scrape("https://example.com")
634
+ finally:
635
+ await client.close()
636
+ ```
637
+
638
+ ## Billing & Cost Model
639
+
640
+ AlterLab uses a **dollar-balance** system. All values in the API are in **microcents** (1,000,000 microcents = $1.00).
641
+
642
+ ### Free Trial
643
+
644
+ New accounts start with **$1.00** (1,000,000 microcents). No credit card required.
645
+
646
+ ### Pricing by Tier
647
+
648
+ | Tier | Name | Cost per Request | Requests per $1 |
649
+ |------|------|-----------------|-----------------|
650
+ | 1 | Direct fetch | $0.0002 | 5,000 |
651
+ | 2 | Full HTTP client | $0.0003 | 3,333 |
652
+ | 3 | Managed sessions | $0.002 | 500 |
653
+ | 4 | Full browser rendering | $0.004 | 250 |
654
+ | 5 | Extended browser sessions | $0.02 | 50 |
655
+
656
+ Current prices: [alterlab.io/pricing](https://alterlab.io/pricing). Measured results: [alterlab.io/benchmark](https://alterlab.io/benchmark).
657
+
658
+ ### Checking Your Balance
659
+
660
+ ```python
661
+ usage = await client.get_usage()
662
+ balance_usd = usage['balance_microcents'] / 1_000_000
663
+ print(f"${balance_usd:.4f} remaining")
664
+ ```
665
+
666
+ ### Adding Balance
667
+
668
+ Add funds from the [Dashboard Billing page](https://alterlab.io/dashboard/billing).
669
+
670
+ ## Links
671
+
672
+ - [Dashboard](https://alterlab.io/dashboard) — Get your API key
673
+ - [API Documentation](https://alterlab.io/docs) — Full API reference
674
+ - [npm package](https://www.npmjs.com/package/alterlab) — Node.js SDK
675
+