escalator
Give it a URL, get clean Markdown. It climbs the cheapest rung that works — a plain HTTP fetch, the same fetch through a residential proxy, then a stealth browser — and stops at the first one that comes back with real content.
$ escalator scrape https://en.wikipedia.org/wiki/Web_scraping | head -3
# Web scraping
**Web scraping**, **web harvesting**, or **web data extraction** is [data scraping](...)
Quickstart
# 1. install uv (https://docs.astral.sh/uv/getting-started/installation/)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. configure this machine -- finds your browser, or fetches one
uvx escalator init
# 3. use it
uvx escalator scrape https://en.wikipedia.org/wiki/Web_scraping
That is the whole on-ramp. init writes one config file, and nothing has to be
hand-edited before the first run. If anything looks wrong: escalator doctor.
On a minimal Linux (a bare container, a fresh VPS) Chrome needs system libraries that a desktop already has. escalator does not install them for you — it prints the exact
apt-getline andescalator doctorrepeats it. One command, once:sudo apt-get update && sudo apt-get install -y \ libnss3 libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \ libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 \ libgbm1 libglib2.0-0t64 libpango-1.0-0 libcairo2 libasound2t64 \ libatspi2.0-0t64 libxcb1 libdbus-1-3 libexpat1
No batteries, by design. escalator ships unconfigured — no bundled browser, no
bundled proxies, no telemetry, nothing written outside its own directories. init
exists so that configuring it is a two-minute conversation instead of a README
scavenger hunt.
Installation
| how | command | when |
|---|---|---|
| uv (no install) | uvx escalator init |
trying it out |
| uv (persistent) | uv tool install escalator |
you want it on PATH |
| pipx | pipx install escalator |
you already use pipx |
| pip | pip install escalator |
inside an existing venv |
| Docker | docker run --rm ghcr.io/ruslanstarikov/escalator doctor |
servers |
Everything needed for the core flow is in the default install, including the
browser rung — it brings no browser with it, which is what escalator browser install is for. One extra exists: escalator[mcp] adds the MCP face, and the
Docker image includes it.
Docker
docker run --rm \
-e ESCALATOR_SERVER_API_KEYS=your-key \
-p 8000:8000 -v ./data:/data \
ghcr.io/ruslanstarikov/escalator serve
The image carries a pinned browser and is configured entirely by environment
variable — see docker-compose.example.yml for the
proxy wiring. It runs as uid 1000, so a bind-mounted ./data must be writable by
it.
Commands
escalator init [--yes] configure this machine; --yes for scripts
escalator doctor [--json] check everything, one fix per failure
escalator browser list every browser found, and which one wins
escalator browser install download Chrome for Testing into the data dir
escalator scrape URL one page to stdout, so it pipes
escalator serve the HTTP API and the MCP face
escalator --version tool, python, platform
The ladder
policy robots.txt (cached) + rate limit → may short-circuit (skip/deny/wait)
http curl_cffi, impersonate=chrome → ~100ms; clears undefended sites
http_proxy same, routed via residential IP → beats datacenter-IP bans
browser nodriver, headless Chrome → JS/SPA + Cloudflare-class defenses
│
└─ walled on the last rung? → status="challenged". Surrender.
Two things make this more than a for loop:
200 OK is not success. A rung that returns HTTP 200 carrying a Cloudflare
interstitial has not succeeded. core/detect.py classifies every response after
extraction — content, thin, or blocked — and only content counts. Without
that the ladder would never escalate, and the cache would learn "http works" for a
domain that serves junk forever.
The cache forgets. A learned start-rung that only ratcheted upward would drift
every domain toward browser+proxy and quietly inflate your proxy bill. Entries
carry learned_at; past ladder.tier_cache_ttl_hours a domain retries one rung
cheaper.
See DESIGN.md for why it is shaped this way — and for what it deliberately refuses to do.
Configuration
One file, written by init, at the platform config dir
(~/.config/escalator/config.toml on Linux, ~/Library/Application Support/escalator/config.toml on macOS). Override the location with --config.
Precedence, everywhere:
CLI flag > environment > config.toml > default
Every key has an environment variable, which is how the Docker image is configured without a file existing at all:
| config key | env var | default | what it does |
|---|---|---|---|
browser.path |
ESCALATOR_BROWSER_PATH |
— | Absolute path to a Chrome/Chromium binary. Empty = find one. |
browser.headless |
ESCALATOR_BROWSER_HEADLESS |
true |
false needs a display (or Xvfb), and is harder to detect. |
browser.via_proxy |
ESCALATOR_BROWSER_VIA_PROXY |
true |
Route renders through the proxy too. Costs bandwidth. |
browser.max_concurrent |
ESCALATOR_BROWSER_MAX_CONCURRENT |
4 |
Chrome is the RAM ceiling on a small box. |
browser.timeout_ms |
ESCALATOR_BROWSER_TIMEOUT_MS |
30000 |
Per-fetch deadline for the browser rung. |
proxy.enabled |
ESCALATOR_PROXY_ENABLED |
false |
The switch. Everything below is ignored while this is false. |
proxy.url |
ESCALATOR_PROXY_URL |
— | http://user:pass@host:port, or socks5://... |
proxy.list |
ESCALATOR_PROXY_LIST |
— | Several exits, used round-robin. Combined with url. |
http.timeout_ms |
ESCALATOR_HTTP_TIMEOUT_MS |
10000 |
Per-fetch deadline for the two http rungs. |
ladder.min_content_chars |
ESCALATOR_LADDER_MIN_CONTENT_CHARS |
200 |
Below this many extracted chars a page is 'thin' and the ladder climbs. |
ladder.tier_cache_ttl_hours |
ESCALATOR_LADDER_TIER_CACHE_TTL_HOURS |
72 |
How long a learned rung survives before decaying one step cheaper. |
politeness.respect_robots |
ESCALATOR_POLITENESS_RESPECT_ROBOTS |
true |
Your box, your call. |
politeness.rate_limit_rps |
ESCALATOR_POLITENESS_RATE_LIMIT_RPS |
1.0 |
Per-domain. 0 disables the gap entirely. |
politeness.user_agent |
ESCALATOR_POLITENESS_USER_AGENT |
a Chrome UA | Used for robots.txt matching. |
server.api_keys |
ESCALATOR_SERVER_API_KEYS |
— | Bearer keys for escalator serve. This list IS the truth: removing one revokes it. |
server.host |
ESCALATOR_SERVER_HOST |
127.0.0.1 |
127.0.0.1 keeps it off the local network. Containers want 0.0.0.0. |
server.port |
ESCALATOR_SERVER_PORT |
8000 |
Port for escalator serve. |
storage.data_dir |
ESCALATOR_STORAGE_DATA_DIR |
— | Database and managed browsers. Empty = the platform default below. |
storage.request_log_limit |
ESCALATOR_STORAGE_REQUEST_LOG_LIMIT |
5000 |
Rows kept in request_log; trimmed on insert. |
Data — the SQLite database and any downloaded browser — lives in the platform data
dir, overridable with ESCALATOR_STORAGE_DATA_DIR. Nothing is ever written
outside it.
Where the browser comes from
escalator browser list shows the search, in order:
- an explicit path —
--browser-path, thenESCALATOR_BROWSER_PATH, thenbrowser.path. If it is set and wrong, that is an error naming the path, never a silent fall-through. - browsers installed on this machine: real Google Chrome first, then Chromium, then Edge and Brave.
- a browser
escalator browser installdownloaded earlier.
If none of those find anything, you get an error naming the two commands that fix it. Resolution never downloads on its own — a server request or a cron job should not install software as a side effect.
Using the server
escalator serve # 127.0.0.1:8000 by default
POST /scrape {url, markdown?, min_tier?, max_tier?, timeout_ms?} -> FetchResult
GET /healthz -> {status, version}
Authenticate with Authorization: Bearer <key>, where the key is one of
server.api_keys. That list is the truth: remove a key and it is revoked on the
next start. There is no endpoint to mint one.
A wall comes back as 200 OK with {"status": "challenged"}, not as an HTTP
error. That is deliberate: an agent on the other end can react to it. Retrying in a
loop will not help — escalator does not solve CAPTCHAs, by design.
With the [mcp] extra the same ladder is exposed at /mcp as one tool,
scrape_url(url, force_browser=False).
Troubleshooting
Start here:
escalator doctor
It checks Python, the config file, the data directory, browser resolution, an
actual headless launch, and — if a proxy is configured — one real request through
it, reporting the egress IP and country with the password masked. Every ❌ comes
with the one line that fixes it, and the exit code is non-zero if anything failed,
so scripts can use it too. escalator doctor --json for machines.
| symptom | what it usually is |
|---|---|
no Chrome-family browser found |
escalator browser install |
error while loading shared libraries on Linux |
doctor names the package to install |
everything returns challenged |
you need a residential proxy: escalator init |
/scrape returns 401 |
no key configured, or it was removed from server.api_keys |
| slow first browser fetch | Chrome cold start; escalator retries the launch once |
If it still will not work, paste the whole escalator doctor output into an
issue — that is what the last line of it asks for, and it is the fastest route to
an answer.
Development
See CONTRIBUTING.md. In short: uv sync, uv run pytest.
License
Released into the public domain — see UNLICENSE. No warranty, no attribution required, do what you like with it.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file escalator-0.1.0.tar.gz.
File metadata
- Download URL: escalator-0.1.0.tar.gz
- Upload date:
- Size: 76.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8f7870292ba8e08e8bd03f3f1b93adb39776b995a163b43689b7c0857d11228f
|
|
| MD5 |
0538bacbda7d20effca99bb9001bdfd4
|
|
| BLAKE2b-256 |
f50149a6469b3fec48e9ff738b2ef44e59dcdb7505066c1c0f939dc80ee42ee9
|
Provenance
The following attestation bundles were made for escalator-0.1.0.tar.gz:
Publisher:
release.yml on ruslanstarikov/escalator
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
escalator-0.1.0.tar.gz -
Subject digest:
8f7870292ba8e08e8bd03f3f1b93adb39776b995a163b43689b7c0857d11228f - Sigstore transparency entry: 2558557120
- Sigstore integration time:
-
Permalink:
ruslanstarikov/escalator@1d0b4cb3ff25d0c3258b8d85f4f66cf153774b41 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ruslanstarikov
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1d0b4cb3ff25d0c3258b8d85f4f66cf153774b41 -
Trigger Event:
push
-
Statement type:
File details
Details for the file escalator-0.1.0-py3-none-any.whl.
File metadata
- Download URL: escalator-0.1.0-py3-none-any.whl
- Upload date:
- Size: 60.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d390f6eb5ee4c9fca9ebfbb35ead22633134776eeef0cf13c28e7bc8b833febd
|
|
| MD5 |
fcc4d720a52acafd94f6841be76302c7
|
|
| BLAKE2b-256 |
3e19feef47d426dd550f7bd394784c08a38a31716f43fc238c7d66562939c691
|
Provenance
The following attestation bundles were made for escalator-0.1.0-py3-none-any.whl:
Publisher:
release.yml on ruslanstarikov/escalator
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
escalator-0.1.0-py3-none-any.whl -
Subject digest:
d390f6eb5ee4c9fca9ebfbb35ead22633134776eeef0cf13c28e7bc8b833febd - Sigstore transparency entry: 2558557422
- Sigstore integration time:
-
Permalink:
ruslanstarikov/escalator@1d0b4cb3ff25d0c3258b8d85f4f66cf153774b41 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ruslanstarikov
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1d0b4cb3ff25d0c3258b8d85f4f66cf153774b41 -
Trigger Event:
push
-
Statement type: