Python client
Sync and async Python client for running Actors and reading results.
scrapely-client-python is the Python client for the Scrape.ly API, with a synchronous and an asynchronous interface. Use it from your own scripts, backends, notebooks, or data pipelines to start Actors, wait for runs, and read and write storage. It is not for writing Actors: code that runs inside an Actor should use the Python SDK.
The source code is on GitHub: scrapely-app/scrapely-client-python.
The client is a drop-in replacement for apify-client. Method names, arguments, and return models match, so most existing code works after you change the import and the API URL.
Install
The client requires Python 3.11 or newer.
pip install scrapely-client-pythonThe distribution is called scrapely-client-python, but the import name is scrapely_client:
from scrapely_client import ScrapelyClient, ScrapelyClientAsyncApifyClient and ApifyClientAsync are exported as aliases. For Brotli response compression, install the extra: pip install "scrapely-client-python[brotli]".
Create a client
import os
from scrapely_client import ScrapelyClient
client = ScrapelyClient(
os.environ['SCRAPELY_TOKEN'],
api_url='https://api.scrape.ly',
)Create a token in the dashboard under Settings → API & Integrations. The client does not read the token or the API URL from environment variables; pass them explicitly. The token is sent as Authorization: Bearer <token>.
| Argument | Default | Description |
|---|---|---|
token | None | API token, the first positional argument. Without it, only public endpoints work. |
api_url | non-production URL | API origin, without /v2. Set to https://api.scrape.ly. |
api_public_url | non-production URL | Origin used when the client builds public storage URLs. Set to https://api.scrape.ly. |
max_retries | 4 | Retries for 429, 5xx, and retryable network errors. |
min_delay_between_retries | timedelta(milliseconds=500) | Initial retry delay. It grows exponentially with jitter. |
timeout_short / timeout_medium / timeout_long | 5 s / 30 s / 360 s | Timeouts used by the 'short', 'medium', and 'long' tiers. |
timeout_max | 360 s | Upper limit for a single attempt. |
headers | None | Extra headers sent with every request. |
compression | 'gzip' | Request body compression: 'gzip', or 'brotli' with the extra installed. |
Every argument after token is keyword-only.
Sync and async clients
ScrapelyClient blocks; ScrapelyClientAsync has the same methods as coroutines. Resource accessors such as client.actor(...) are plain calls in both; only the API calls are awaited.
import asyncio
from scrapely_client import ScrapelyClientAsync
async def main() -> None:
client = ScrapelyClientAsync('MY-TOKEN', api_url='https://api.scrape.ly')
run = await client.actor('username/my-actor').call(run_input={'url': 'https://example.com'})
async for item in client.dataset(run.default_dataset_id).iterate_items():
print(item)
asyncio.run(main())Run an Actor
actor(id).call() starts a run and waits until it finishes. Pass an Actor ID or a username/actor-name full name.
run = client.actor('username/my-actor').call(run_input={'url': 'https://example.com'})
print(run.status) # SUCCEEDED, FAILED, ABORTED, or TIMED-OUT
for item in client.dataset(run.default_dataset_id).iterate_items():
print(item)Return values are Pydantic models with snake_case attributes: run.id, run.act_id, run.status, run.default_dataset_id, run.default_key_value_store_id, and so on. call() returns the run for every final status; it does not raise when the run fails, so check run.status. It returns None only if the run can no longer be found.
On runs of someone else's Actor, run.build_id, run.build_number, and run.options.build are None: build info is owner only, and the run uses the User Build. Older client versions raise a validation error on these runs, so keep the client up to date.
To start a run without waiting, use start(), then wait later:
from datetime import timedelta
run = client.actor('username/my-actor').start(
run_input={'url': 'https://example.com'},
memory_mbytes=1024,
run_timeout=timedelta(minutes=5),
)
finished = client.run(run.id).wait_for_finish(wait_duration=timedelta(minutes=2))wait_for_finish() polls until the run reaches a final status or wait_duration elapses. Without wait_duration it waits indefinitely.
Run arguments for start() and call():
| Argument | Description |
|---|---|
run_input | Actor input, usually a dict. |
content_type | Content type of run_input when it is not JSON. |
build | Build tag or number. Defaults to the Actor's default build. Ignored on someone else's Actor, which always runs the User Build. |
memory_mbytes | Memory in MB. |
run_timeout | Run timeout as a timedelta. |
max_items | Cap on billed dataset items for Actors that charge per result item. |
max_total_charge_usd | Cap on the total charge in USD for pay-per-event Actors, as a Decimal. |
restart_on_error | Restart the run if it fails. |
webhooks | Ad-hoc webhooks for this run only. |
wait_for_finish | start() only. Seconds the API holds the request open (up to 60; the server stops waiting at 45). |
wait_duration | call() only. Maximum time to wait. Omit to wait indefinitely. |
logger | call() only. Where to redirect the run log: 'default' (the default), a logging.Logger, or None to disable. |
The API returns a run's log as a snapshot rather than a live stream, so the redirected log in call() may be incomplete. Fetch the full log after the run with client.run(run.id).log().get().
See Run Actors for how runs, memory, and timeouts work on the platform.
Other Actor methods
| Method | Description |
|---|---|
actor(id).get() | Actor details, or None if it does not exist. |
actor(id).update(...) / .delete() | Change or remove your Actor. Set the User Build with update(default_non_owner_build='0.1.4'); '' clears it. |
actor(id).build(version_number=..., tag=..., use_cache=..., wait_for_finish=...) | Start a build. |
actor(id).default_build() | Returns a build client for the default build. Owner only; for other Actors read the input schema from get() (defaultBuildActorDefinition). |
actor(id).last_run(status=...) | Run client for the latest run. |
actor(id).runs() / .builds() / .versions() / .webhooks() | Related collections. Builds and versions are owner only. |
actors().list(my=..., limit=..., offset=..., desc=...) | Actors you created or used. |
Runs
| Method | Description |
|---|---|
run(id).get() | Run details, or None. |
run(id).wait_for_finish(wait_duration=...) | Poll until the run finishes. |
run(id).abort(gracefully=True) | Abort the run. |
run(id).resurrect(...) | Restart a finished run with the same storages. |
run(id).metamorph(target_actor_id=..., run_input=...) | Replace the running Actor with another one. |
run(id).reboot() | Restart the run's container. |
run(id).update(status_message=..., is_status_message_terminal=...) | Update the run's status message. |
run(id).charge(event_name, count=..., idempotency_key=...) | Charge a pay-per-event event. |
run(id).delete() | Delete the run. |
run(id).dataset() / .key_value_store() / .request_queue() | Clients for the run's default storages. |
run(id).log() | Log client for the run. |
runs().list(status=..., limit=..., offset=..., desc=...) | All your runs. |
Datasets
dataset = client.dataset('DATASET_ID')
page = dataset.list_items(limit=100, fields=['title', 'url'])
print(page.total, len(page.items))
csv_bytes = dataset.get_items_as_bytes(item_format='csv', bom=True)| Method | Description |
|---|---|
list_items(...) | One page: items, total, offset, count, limit, desc. Arguments: offset, limit, fields, omit, unwind, flatten, desc, clean, skip_empty, skip_hidden, view. |
iterate_items(..., chunk_size=...) | Iterator over every item, fetched page by page. |
get_items_as_bytes(item_format=...) | Items as bytes. Formats: json, jsonl, csv, xlsx, xml, rss, html. Extra arguments: bom, delimiter, skip_header_row, xml_root, xml_row. |
stream_items(item_format=...) | Context manager yielding the raw HTTP response, for large exports. |
push_items(items) | Append a dict, a list of dicts, or a JSON string. |
get_statistics() | Dataset statistics. |
create_items_public_url(expires_in=...) | Signed URL for the items. |
datasets().list(unnamed=...) / .get_or_create(name=...) | Your datasets, or a named dataset. |
See Results and Storage for the data model.
Key-value stores
store = client.key_value_store('STORE_ID')
store.set_record('config', {'depth': 2})
record = store.get_record('OUTPUT')
if record:
print(record['content_type'], record['value'])| Method | Description |
|---|---|
get_record(key) | Dict with key, value, and content_type, or None if the key does not exist. JSON is parsed and text is decoded. |
get_record_as_bytes(key) / stream_record(key) | Raw bytes, or a streamed response for large records. |
set_record(key, value, content_type=...) | Store a record. Dicts and lists are stored as JSON by default. |
record_exists(key) | True or False. |
delete_record(key) | Delete a record. |
list_keys(limit=..., exclusive_start_key=..., prefix=..., collection=...) | One page of keys with is_truncated and next_exclusive_start_key. |
iterate_keys(...) | Iterator over every key. |
get_record_public_url(key) / create_keys_public_url() | Signed public URLs. |
key_value_stores().list() / .get_or_create(name=...) | Your stores, or a named store. |
Request queues
queue = client.request_queue('QUEUE_ID')
queue.add_request({'url': 'https://example.com', 'uniqueKey': 'https://example.com'})
head = queue.list_head(limit=10)Available methods: add_request, batch_add_requests, get_request, update_request, delete_request, batch_delete_requests, list_head, list_and_lock_head, prolong_request_lock, delete_request_lock, unlock_requests, list_requests, iterate_requests, plus get, update, and delete. request_queues().get_or_create(name=...) returns a named queue.
Tasks
task = client.tasks().create(
actor_id='username/my-actor',
name='daily-crawl',
task_input={'url': 'https://example.com'},
)
run = client.task(task.id).call()Task names are limited to 30 characters.
| Method | Description |
|---|---|
task(id).start(task_input=..., ...) / .call(...) | Run the task. task_input overrides the stored input. Arguments: build (your own Actors only), max_items, memory_mbytes, run_timeout, restart_on_error, webhooks, and wait_for_finish or wait_duration. |
task(id).get_input() / .update_input(task_input) | Read or replace the stored input. |
task(id).get() / .update(...) / .delete() | Task details. |
task(id).publish(...) / .unpublish() | Publish the task as a public page or take it down. |
task(id).last_run() / .runs() / .webhooks() | Related runs and webhooks. |
tasks().list() | Your tasks. |
Schedules
client.schedules().create(
name='nightly',
cron_expression='0 3 * * *',
timezone='UTC',
is_enabled=True,
is_exclusive=False,
actions=[{'type': 'RUN_ACTOR', 'actorId': 'ACTOR_ID'}],
)cron_expression, is_enabled, and is_exclusive are required arguments, and the API requires at least one action. Actions are RUN_ACTOR (with actorId) or RUN_ACTOR_TASK (with actorTaskId). Other methods: schedules().list(), schedule(id).get(), .update(...), .delete(), and .get_log(). See Schedules API.
Automations
automation = client.automations().create({
'title': 'Morning digest',
'trigger': {'kind': 'schedule', 'cron': '0 7 * * 1-5', 'timezone': 'Europe/Berlin'},
'steps': [{'kind': 'savedInput', 'savedInputId': 'SAVED_INPUT_ID', 'inputOverride': {'maxItems': 20}}],
'filters': {'onlyNewItems': {'keyFields': ['url']}},
'ai': {'mode': 'summarize', 'prompt': 'The five most interesting new items, with links.'},
'deliveries': [{'channel': 'email'}],
})
run = client.automation(automation['id']).run()| Method | Description |
|---|---|
automations().list(actor_id=..., saved_input_id=..., search=..., limit=..., offset=..., desc=...) | Your automations, plain schedules included. actor_id and saved_input_id return the automations that use them. |
automations().create(automation) | Create an automation from a dictionary with the body of Create automation. |
automation(id).get() / .update(fields) / .delete() | Read, partly update, or delete it. update sends the fields as given; {'ai': None} removes the AI step. |
automation(id).run() | Run it now. Returns the automation run. |
automation(id).list_runs(limit=..., offset=...) / .get_run(run_id) | Run history and one run, with each step's run, the filter result, aiOutput, aiCostUsd, and delivery statuses. |
automation(id).duplicate(title=..., name=..., is_enabled=...) | Copy it. The copy is paused unless is_enabled is True. |
The methods return the API's dictionaries with camelCase keys, and the async client has the same methods. automations() is Scrape.ly-only and comes with version 1.0.6 of scrapely-client-python.
Webhooks
client.webhooks().create(
event_types=['ACTOR.RUN.SUCCEEDED', 'ACTOR.RUN.FAILED'],
actor_id='ACTOR_ID',
request_url='https://example.com/hooks/scrapely',
)Other methods: webhooks().list(), webhook(id).get(), .update(...), .delete(), .test(), .dispatches(), and webhook_dispatches().list(). See Webhooks API.
Builds, account, and Store
Builds are owner only, also for public Actors; other users get 403 on an Actor's builds and 404 on a build by ID.
| Method | Description |
|---|---|
build(id).get() / .wait_for_finish(wait_duration=...) | Build details. |
build(id).abort() / .delete() / .log() | Manage a build and read its log. |
user().get() | The account that owns the token. |
user().monthly_usage() / .limits() | Usage and limits for the current month. |
store().list(search=..., limit=..., offset=..., sort_by=..., category=..., username=..., pricing_model=...) | Search the Store. The API defaults limit to 20 and caps it at 100. |
store().iterate(...) | Iterator over every matching Store Actor. |
Logs
text = client.run('RUN_ID').log().get()
with client.run('RUN_ID').log().stream() as response:
if response:
for chunk in response.iter_bytes():
print(chunk.decode(), end='')get() returns the log as a string, or None. With the async client, use async with and aiter_bytes(). The API sends the log as it is at request time and then closes the connection, so to follow a running Actor, poll get().
run(id).get_streamed_log(to_logger) returns a StreamedLog you can use as a context manager (or call start() and stop()) to redirect a run's log into a logging.Logger; it has the same snapshot limitation. The client logs under the scrapely_client logger name.
Pagination
Every collection and storage listing has two forms:
list(...),list_items(...),list_keys(...)return a single page with its metadata.iterate(...),iterate_items(...),iterate_keys(...),iterate_requests(...)return an iterator that fetches pages as you go. With the async client, useasync for.
for run in client.runs().iterate(desc=True):
print(run.id, run.status)limit and offset bound the overall range; chunk_size sets how many items each request fetches.
Timeouts, retries, and errors
Every API method takes a keyword-only timeout: a timedelta, one of 'short', 'medium', 'long', or 'no_timeout'. Each retry doubles the timeout, up to timeout_max. call() and wait_for_finish() default to 'no_timeout'.
The client retries 429, 5xx, and retryable network errors up to max_retries times with exponential backoff and jitter. Other 4xx responses fail immediately.
Failed calls raise ScrapelyApiError from scrapely_client.errors (also available as ApifyApiError). The client raises a subclass based on the HTTP status:
| Exception | Status |
|---|---|
InvalidRequestError | 400 |
UnauthorizedError | 401 |
ForbiddenError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
RateLimitError | 429 |
ServerError | 5xx |
from scrapely_client.errors import NotFoundError, ScrapelyApiError
try:
client.actor('username/missing').start()
except NotFoundError:
print('No such Actor')
except ScrapelyApiError as err:
print(err.status_code, err.type, err.message, err.attempt)get() methods return None on 404 instead of raising. See API overview for error types.
Migrating from apify-client
- Install
scrapely-client-pythonand import fromscrapely_client.ApifyClient,ApifyClientAsync, andApifyApiErrorstill work as aliases. - Pass
api_url='https://api.scrape.ly'(andapi_public_urlif you build public URLs). - Use a Scrape.ly token. Apify tokens do not work.
task(id).publish()and.unpublish()are Scrape.ly additions.- Endpoints that Scrape.ly does not implement return an error. The API reference lists what is available.