The official Python SDK for the Scrape.do web-scraping API. Token-only auth, transparent compression, a typed result, and a generic plugin core — no telemetry, no UI.
Distribution name:
sdo-sdk· import name:sdo· CLI binary (separate package):sdo.
pip install sdo-sdk
# optional CSS/XPath extraction via .selector():
pip install "sdo-sdk[parse]"Requires Python 3.11+. Brotli decompression is bundled (the brotli dependency).
from sdo import Client, ScrapeConfig
# Token precedence: Client(token=...) > SDO_TOKEN env var
with Client(token="YOUR_TOKEN") as client:
result = client.scrape("https://example.com", render=True, geo_code="us")
print(result.status_code) # the TARGET's status code
print(result.cost) # credits this request cost
print(result.text[:200]) # decoded HTML
title = result.selector().css("title::text").get()
# Reuse a config, or build one inline:
cfg = ScrapeConfig(url="https://example.com", super_proxy=True, output="markdown")
markdown = client.scrape(cfg).textScrapeConfig fields mirror the documented Scrape.do parameters. Values that
mean "unset" (None, False, 0, "", empty list or dict) are not sent;
enum values are case-insensitive; the gateway timeouts are in
milliseconds (timeout=30000, range 5000–120000). Anything not modelled
yet goes through extra_params={"someNewParam": "value"}; a key must start
with an ASCII letter followed by letters, digits, _, . or -.
| Argument | Environment | Default |
|---|---|---|
token |
SDO_TOKEN |
required |
base_url |
SDO_BASE_URL |
https://api.scrape.do |
async_base_url |
SDO_ASYNC_BASE_URL |
https://q.scrape.do |
timeout (seconds) |
SDO_TIMEOUT |
70 |
An argument wins over its environment variable. SDO_TIMEOUT is a plain
decimal number of seconds of at least 0.001 (30, 1.5); anything else is a
configuration error.
Timeout. timeout is a total wall-clock deadline per request attempt over
connect, headers and the whole body. Its default, 70 seconds, is the gateway's
own default timeout (60 seconds) plus 10, so the gateway's answer always
arrives first. httpx can only enforce it between reads (each read is bounded by
the same timeout), so a peer that trickles bytes can stretch one attempt to
about twice the timeout. When a scrape sets the gateway's own timeout
(milliseconds), or a plugin call a positive integer timeout param, that
attempt waits at least that plus 10 seconds, so the client never gives up on a
request the gateway was allowed to take longer for; the timeout error names the
value that applied.
Retries. retries (default 2) retries the retryable statuses (429, 502,
503, 504, 520–524) and failures before the request was sent (DNS, connect
refused, TLS, connect timeout). A failure after the request was sent — a read
timeout, a connection dropped mid-response, a body that cannot be decompressed
or is too large — is never retried, because the gateway may still run, and
bill, it. Requests that can change the target (POST, PUT, PATCH, DELETE) are
retried only on 429. A 510 is the gateway's record of a request whose HTTP
client cancelled it before it finished; it is not an upstream failure and is
not retried.
Base URLs are scheme, host, optional port and an optional path prefix,
which is kept (https://gateway.internal/scrape-do sends scrapes to
/scrape-do/). They must be https://; plain http:// is accepted only for
localhost, 127.0.0.1 and ::1, or with allow_insecure_http=True (the
token is then sent in cleartext). Credentials, a query or a fragment in a base
URL are rejected (the error message masks the credentials).
Proxies and the environment. The SDK's own httpx client is created with
trust_env=False: HTTP(S)_PROXY, ALL_PROXY, NO_PROXY, SSL_CERT_FILE,
SSL_CERT_DIR and .netrc are ignored, so a variable planted in the
environment cannot route your token through a proxy or make it trust another
CA. To use an outbound proxy or custom TLS settings, inject your own client,
which keeps its settings (redirects are still never followed):
import httpx
from sdo import Client
with httpx.Client(proxy="http://proxy.internal:3128") as http, Client(http_client=http) as client:
result = client.scrape("https://example.com")The only thing you need is a Scrape.do token. It is resolved, in order:
Client(token="...")argumentSDO_TOKENenvironment variable
Surrounding whitespace is trimmed (a token read from a secret file often ends
in a newline) and a blank value counts as not given; what is left must be
visible ASCII. The sync API receives it as the token query parameter and the
async job API as the X-Token header. It is masked (***) in every error
message, retained error body and httpx/httpcore/hpack log record, and no stack
frame that is live when the SDK raises holds it, so error trackers that record
local variables (Sentry, pytest --showlocals) do not capture it. No account
id, API key, or OAuth — just the token.
Every request sends Accept-Encoding: gzip, deflate, br and the response is
transparently decompressed — result.bytes() and result.text always give
you the decompressed payload. Pass prefer_brotli=True to advertise br alone.
Decompression is bounded: a body larger than max_response_bytes (default
32 MiB), compressed or not, raises SdoResponseTooLargeError before it is
inflated in memory. Content-Encoding may list several codings (gzip,
x-gzip, deflate, br), which are undone in reverse order; an unsupported
coding is left as received, and a truncated or corrupt stream is an
SdoTransportError. Redirects are never followed.
with Client() as client:
client.scrape("https://example.com/api", json={"q": "laptop"}) # POST, JSON
client.scrape("https://example.com/form", data="a=1&b=2") # POST, raw
client.scrape("https://example.com/item", method="PUT", json={"n": 1},
headers={"X-Api-Version": "2"}, forward_headers=True)Header names are compared case-insensitively, and your value replaces the
SDK's own (headers={"user-agent": "Mozilla/5.0 ..."} sends that User-Agent
alone). Names must be HTTP tokens and values printable ASCII; Host,
Content-Length, Transfer-Encoding and Connection cannot be set. The SDK
sends no header of its own beyond User-Agent, Accept-Encoding,
Content-Type (with a body) and, for jobs, X-Token.
result.text decodes the body the way browsers do (the WHATWG Encoding
Standard: BOM, then the Content-Type charset, then <meta charset>, then
UTF-8). result.headers has lower-cased names; a repeated header such as
Set-Cookie is joined there, so read each value with
result.header_values("set-cookie"). result.json() parses the body as UTF-8
(invalid bytes become U+FFFD, a leading BOM is ignored) and raises
SdoTransportError for anything that is not JSON, including NaN, Infinity
and numbers too large for a double. With disable_redirection=True,
result.target_redirected_location is the target's Location (the
Scrape.do-Target-Redirected-Location header).
Besides enums, millisecond ranges and render-only options (width and
height included), the documented parameter dependencies are checked before
any request, each one the gateway rejects (or documents as invalid), so a
mistake costs nothing:
| Rule | Why |
|---|---|
show_frames, show_websocket_requests and every screenshot option need return_json (and render) |
their output is part of the JSON response |
at most one of screenshot, full_screenshot, particular_screenshot |
the gateway takes one |
particular_screenshot cannot be combined with play_with_browser |
use a ScreenShot action instead |
regional_geo_code needs super_proxy and excludes geo_code |
the gateway rejects both |
zip_code needs super_proxy and geo_code |
without super it is silently ignored |
session_id is an integer 0–1000000 (an int or decimal digits) |
the documented range |
retry_timeout cannot be combined with render |
the gateway rejects it |
at most one of custom_headers, extra_headers, forward_headers |
the gateway rejects any two |
return_json on its own is allowed: without render the gateway wraps the
plain response in the same JSON envelope. A target URL in an error message has
its user:password@ masked.
Successful scrapes — including pages that themselves return 404/410, and any
status the gateway billed — come back as a ScrapeResult (inspect
result.status_code). Scrape.do-Request-Cost is the authoritative billing
figure: a response of status 400 or above with a positive cost is the target's
own answer (its 400, 401, 403, 422, ...), returned as a result and never
retried. Without one, the status keeps its gateway meaning: the gateway's own
400 (a parameter it rejected, such as render=mert) raises
SdoValidationError, its 401 (a refused token, no credits) SdoAuthError. An
error's billed follows the cost header when present (0 is not billed) and
the documented status table otherwise; error.cost and result.cost hold the
header. Plugin calls follow the same rules, and a plugin's
{"error": ..., "message": ...} envelope supplies the message. Only gateway-origin failures raise, and every
failure is an SdoError:
from sdo import SdoAuthError, SdoConcurrencyError, SdoError
with Client() as client:
try:
client.scrape("https://example.com")
except SdoConcurrencyError as e: # 429 — retryable
print(e.retry_after)
except SdoAuthError: # 401/403 (also: no credits left)
...
except SdoError as e: # catch-all base
print(e.message)With transparent_response=True the gateway returns the target's own status;
such a response (it carries Scrape.do-Initial-Status-Code) is always returned
as a result, never raised.
Documented plugin endpoints are callable generically — a brand-new plugin works the day it ships:
with Client() as client:
# Google plugins take Google's own locale params: gl (country), hl (language).
data = client.plugin("google/shopping", {"q": "laptop", "gl": "us"})
# plugin_result returns the same response with its status and headers.
result = client.plugin_result("google/search", {"q": "laptop", "gl": "us"})
print(result.status_code, result.cost, result.json())
# The Amazon raw-HTML plugin lives at /plugin/amazon/; the route "amazon"
# (or "amazon/") calls it.
html = client.plugin(
"amazon",
{"url": "https://www.amazon.com/dp/B0C7BKZ883", "geocode": "us", "zipcode": "10001"},
)Parameters are sent sorted by name: a list repeats its name, a bool is
true/false, a dict is sent as JSON, None is skipped. A nested list, an
empty name, a parameter named token (in any case) or a name that is not an
ASCII letter followed by letters, digits, _, . or - is an
SdoConfigError.
with Client() as client:
usage = client.usage() # GET https://api.scrape.do/info; no credits
print(usage.is_active, usage.remaining_monthly_request, usage.remaining_concurrent_request)usage() returns a Usage with is_active, concurrent_request,
max_monthly_request, remaining_concurrent_request and
remaining_monthly_request. The endpoint allows 10 requests per minute per IP
and answers 429 above that; the SDK does not retry its error statuses (a quick
retry only spends more of the same minute). An unknown token is answered with a
502 "authentication refused".
Submit background scrapes via the async job REST API (q.scrape.do/api/v1,
authenticated with the X-Token header — handled for you). "Async" is the
name of the API (jobs run in the background); the methods themselves are
ordinary synchronous calls, not asyncio coroutines. They return typed
objects; a field the API sends with an unexpected type reads as None:
from datetime import UTC, datetime
with Client() as client:
job = client.jobs.create(["https://example.com"], {"Super": True, "GeoCode": "us"})
status = client.jobs.status(job.job_id)
for task in status.tasks:
result = client.jobs.result(job.job_id, task.task_id)
print(task.url, result.status_code, len(result.content_bytes()))
# A plugin job: one task per params entry (at most 1000), with the plugin's
# own parameter names. Targets and a plugin cannot be combined in one job.
client.jobs.create_plugin(
"amazon/pdp",
[{"asin": "B0C7BKZ883", "geocode": "us"}, {"asin": "B0BSHF7WHW", "geocode": "us"}],
)
# The job list is paginated (default: the 10 most recent). Filter by status
# and start time, sort, and walk `page` up to `total_pages`.
page = client.jobs.list(
status="success",
start_from=datetime(2025, 1, 1, tzinfo=UTC), # or an RFC 3339 string
sort="start_time_asc",
page=2,
page_size=25, # 1-100
)
page.jobs, page.total_count, page.total_pages
client.jobs.cancel(job.job_id)
client.jobs.me() # concurrency + remaining creditsAn invalid plugin key, an empty or oversized params list, options that set
Targets or Plugin, and list arguments outside the documented values raise
SdoConfigError before anything is sent. The async API has its own concurrency
pool (30% of the plan's limit, not taken from the main API's);
JobStatus.acquired_concurrency and limit_concurrency report a job's share of
it.
client.jobs shares the client's connection pool. For a job-only program,
AsyncJobs(token=..., base_url=...) builds its own, with the same validation
and environment fallbacks (use it in a with block).
The old
callbackrequest parameter was removed by the gateway — use this job API instead.
| Workflow | Runs on | What it does |
|---|---|---|
ci.yml |
every push to main and every pull request |
ruff lint and format check; mypy --strict; the test suite with the 99% line + branch coverage floor on Python 3.11–3.14 (Linux), plus macOS and Windows on 3.13, including the cross-language conformance suite and its integrity check; the public-API snapshot (api-surface.txt) must be up to date. CodeQL runs on every push and pull request. Everything runs on GitHub-hosted runners; CodeQL runs on the org's self-hosted runners for same-repository events (its upload is a GitHub API call the org's IP allow list only accepts from there); a pull request from a fork is analysed once merged. |
security.yml |
push to main, pull requests, and weekly |
secret scanning over the full git history (gitleaks), OSV-Scanner over the pinned requirement files, pip-audit, and an SBOM uploaded as a build artifact. OpenSSF Scorecard runs on pushes to main and weekly, with results in code scanning. |
publish.yml |
a version tag only | A tag vX.Y.Z-rc.N publishes a release candidate to TestPyPI; a tag vX.Y.Z publishes to PyPI. The tag must be on main and match src/sdo/_version.py (X.Y.ZrcN for a candidate). The package is built once with hash-pinned tooling, checked with twine check, published by trusted publishing (no stored token) after approval in the prerelease or release environment, and then installed from the index as a smoke test. |
| Dependabot | weekly | update pull requests for the GitHub Actions, and security-advisory pull requests for the pinned Python requirements (version bumps of those are recompiled by hand with uv, see CONTRIBUTING.md), taken only after a 7-day cooldown; each one runs the checks above. |
Every action is pinned to a full commit SHA and every job declares least-privilege permissions:. Publishing never happens outside the scrape-do organisation, never from a commit that is not on main, and never without a maintainer's approval.
You are responsible for complying with the terms of service, robots.txt, and
applicable laws (CFAA, GDPR, CCPA, etc.) of any site you scrape, and for not
collecting personal or copyrighted data unlawfully. Amazon, Walmart, Google and
other names are trademarks of their respective owners; Scrape.do is not
affiliated with them.