switchback 0.5.0__tar.gz → 0.6.1__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.
- {switchback-0.5.0 → switchback-0.6.1}/CHANGELOG.md +60 -1
- switchback-0.6.1/CONTRIBUTING.md +67 -0
- {switchback-0.5.0 → switchback-0.6.1}/PKG-INFO +139 -9
- {switchback-0.5.0 → switchback-0.6.1}/README.md +128 -4
- {switchback-0.5.0 → switchback-0.6.1}/pyproject.toml +16 -5
- {switchback-0.5.0 → switchback-0.6.1}/switchback/__init__.py +4 -2
- {switchback-0.5.0 → switchback-0.6.1}/switchback/api.py +60 -16
- {switchback-0.5.0 → switchback-0.6.1}/switchback/doctor.py +4 -1
- switchback-0.6.1/switchback/highlights.py +311 -0
- switchback-0.6.1/switchback/mcp_server.py +81 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/normalize.py +98 -1
- {switchback-0.5.0 → switchback-0.6.1}/switchback/orchestrator.py +115 -19
- {switchback-0.5.0 → switchback-0.6.1}/switchback/server.py +9 -6
- switchback-0.6.1/switchback/state.py +115 -0
- switchback-0.6.1/switchback/tiers/_browser.py +142 -0
- switchback-0.6.1/switchback/tiers/tier_1.py +195 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_2.py +24 -3
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_3.py +15 -3
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_4.py +2 -1
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_5.py +12 -9
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_7.py +4 -2
- {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/PKG-INFO +139 -9
- {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/SOURCES.txt +3 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/entry_points.txt +1 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/requires.txt +12 -3
- switchback-0.5.0/CONTRIBUTING.md +0 -35
- switchback-0.5.0/switchback/tiers/_browser.py +0 -50
- switchback-0.5.0/switchback/tiers/tier_1.py +0 -81
- {switchback-0.5.0 → switchback-0.6.1}/.env.example +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/LICENSE +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/MANIFEST.in +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/NOTICE +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/SECURITY.md +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/clients/node_bridge.md +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/clients/python_client.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/config/botwall_skip_urls.txt +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/config/extraction.example.json +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/setup.cfg +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/__main__.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/concurrency.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/content_cache.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/egress.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/extract.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/flags.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/policy/__init__.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/policy/botwall.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/policy/gates.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/py.typed +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/reporting.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/search.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/session_cache.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/session_trace.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/__init__.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_6.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback/tracing.py +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/dependency_links.txt +0 -0
- {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,66 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this project uses semantic-ish
|
|
5
5
|
versioning while pre-1.0.
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.6.1] - 2026-10-14
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **camoufox geoip on fresh installs.** camoufox 0.5 swapped `geoip2` for
|
|
11
|
+
`maxminddb` in its `[geoip]` extra, so tier_5 decided geoip was missing and ran
|
|
12
|
+
proxied sessions without matching locale/timezone. It now asks camoufox's own
|
|
13
|
+
flag, which works on 0.4 and 0.5.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **Newer Chrome TLS fingerprints in tier_2** (`chrome142`, `chrome145`,
|
|
17
|
+
`chrome146` instead of `chrome131`, `chrome136`, `chrome142`); two-year-old
|
|
18
|
+
Chrome versions are a fingerprint tell. Needs `curl_cffi>=0.15`, now the floor.
|
|
19
|
+
- **Security floors:** `pypdf>=6.19` (older releases have DoS CVEs on crafted
|
|
20
|
+
PDFs, which this library parses from the web) and `requests>=2.33`
|
|
21
|
+
(CVE-2024-35195, CVE-2024-47081, CVE-2026-25645).
|
|
22
|
+
- **arXiv calls no longer send the maintainer's email.** tier_1's arXiv
|
|
23
|
+
User-Agent named a fixed contact address on every install; it now names the
|
|
24
|
+
project URL, and `SCRAPER_ARXIV_CONTACT` adds your own address if you want one.
|
|
25
|
+
|
|
26
|
+
## [0.6.0] - 2026-10-14
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **`SCRAPER_DISABLE_CLOUDSCRAPER`** — opt-out for tier_3. Solving a JS challenge
|
|
30
|
+
runs site-supplied JavaScript in js2py or Node's `vm`, neither of which is a
|
|
31
|
+
security boundary; deployments that scrape URLs from untrusted users should turn
|
|
32
|
+
it off and let the browser tiers clear Cloudflare. Unset = unchanged. A host whose
|
|
33
|
+
learned winner was tier_3 falls through to the next tier rather than stalling,
|
|
34
|
+
and `--doctor` shows tier_3 as `off` without failing the healthcheck.
|
|
35
|
+
- **`SCRAPER_BROWSER_BLOCK_PRIVATE`** — opt-in guard for tiers 4 and 5: every
|
|
36
|
+
request and WebSocket a page makes to a loopback, private, link-local (incl.
|
|
37
|
+
cloud metadata) or CGNAT address is refused, and service workers are blocked so
|
|
38
|
+
they can't bypass it. Unset = unchanged (no routing is installed at all).
|
|
39
|
+
- **Agent-ready extras, all opt-in** (`scrape(..., state=, appendix=, query=)`,
|
|
40
|
+
the same flags on `scrape_detailed`, `--state` / `--appendix` / `--query` on the
|
|
41
|
+
CLI, and `state` / `appendix` / `query` in the HTTP `/scrape` body). Without them
|
|
42
|
+
results, CLI JSON and HTTP JSON are unchanged: `ScrapeResult` and `ScrapeOutcome`
|
|
43
|
+
keep exactly their fields. With them, results are the new subclasses
|
|
44
|
+
`ScrapeResultWithExtras` / `ScrapeOutcomeWithExtras`.
|
|
45
|
+
- `state` — the page's embedded JSON (Next.js `__NEXT_DATA__`, Nuxt,
|
|
46
|
+
`window.__X__` globals, JSON-LD).
|
|
47
|
+
- `appendix` — nav/header/footer/aside as separate markdown regions.
|
|
48
|
+
- `query` → `highlights` — the passages that answer the question, each with a
|
|
49
|
+
text-fragment citation link. Graded by local BM25 by default, or by TypeSafe's
|
|
50
|
+
Jev model with `SCRAPER_HIGHLIGHTS_GRADER=jev` + `TYPESAFE_API_KEY` (falls back
|
|
51
|
+
to BM25 on any Jev error).
|
|
52
|
+
- **MCP server** — `switchback-mcp` (stdio) exposes `scrape` and `search` as tools.
|
|
53
|
+
Install with `switchback[mcp]`.
|
|
54
|
+
- **YouTube transcripts** in tier_1 (title, channel, timestamped transcript linked
|
|
55
|
+
to the moment in the video). Opt in with `SCRAPER_YOUTUBE=1` plus
|
|
56
|
+
`switchback[youtube]`; otherwise, or for videos with no captions, YouTube URLs
|
|
57
|
+
go through the cascade as before.
|
|
58
|
+
YouTube blocks most cloud IPs; a blocked request is retried once through
|
|
59
|
+
`SCRAPER_EGRESS_PROXY` when one is configured. Transcript highlights cite the
|
|
60
|
+
timestamp in the video.
|
|
61
|
+
- **Office and ebook files** (.docx, .pptx, .xlsx, .xls, .epub) converted to
|
|
62
|
+
markdown in tier_2 via markitdown. Opt in with `SCRAPER_DOCUMENTS=1` plus
|
|
63
|
+
`switchback[documents]`; otherwise these URLs are handled exactly as before.
|
|
64
|
+
|
|
65
|
+
The new extras are deliberately not added to `[all]`, so upgrading an existing
|
|
66
|
+
`[all]` install pulls no new dependencies.
|
|
8
67
|
|
|
9
68
|
## [0.5.0] - 2026-06-30
|
|
10
69
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in improving switchback.
|
|
4
|
+
|
|
5
|
+
## Development setup
|
|
6
|
+
```bash
|
|
7
|
+
python -m venv .venv && . .venv/bin/activate
|
|
8
|
+
pip install -e ".[all]"
|
|
9
|
+
patchright install chromium && camoufox fetch # for the browser tiers
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Architecture
|
|
13
|
+
The engine is a cost-ordered cascade (`switchback/tiers/`) governed by a per-host
|
|
14
|
+
policy (`switchback/policy/`). Start with the cascade runner in
|
|
15
|
+
`switchback/orchestrator.py`.
|
|
16
|
+
|
|
17
|
+
## Guidelines
|
|
18
|
+
- **Keep the core small.** Each tier imports its deps lazily and a missing dep is
|
|
19
|
+
just a tier miss — keep heavy/paid/optional pieces behind extras in
|
|
20
|
+
`pyproject.toml`.
|
|
21
|
+
- **Make new behavior configurable and off-safe.** New features should be gated by
|
|
22
|
+
an env var that defaults to current behavior (see the existing `SCRAPER_*` vars).
|
|
23
|
+
- **Match the surrounding style** — terse, comment-the-why, no speculative
|
|
24
|
+
abstractions.
|
|
25
|
+
- **Don't commit secrets or run artifacts.** `.env`, `state/`, and `*.csv` are
|
|
26
|
+
gitignored; keep it that way.
|
|
27
|
+
|
|
28
|
+
## Backward compatibility comes first
|
|
29
|
+
Upgrading must never change what an existing call returns. Breaking a user's app
|
|
30
|
+
is worse than any feature is good.
|
|
31
|
+
|
|
32
|
+
What went wrong once: the agent extras added `state` / `appendix` / `highlights`
|
|
33
|
+
as defaulted fields on `ScrapeResult` and `ScrapeOutcome`. That looked additive,
|
|
34
|
+
but `asdict(r)` (how people serialise results) gained three `null` keys and
|
|
35
|
+
`astuple(r)` grew from 4 values to 7. The CLI and HTTP JSON were fine; the Python
|
|
36
|
+
objects were not. It was caught before merge and fixed with opt-in subclasses.
|
|
37
|
+
|
|
38
|
+
Rules for any new feature:
|
|
39
|
+
- **Never add, remove, rename or reorder fields on a public result type**
|
|
40
|
+
(`ScrapeResult`, `ScrapeOutcome`, `TierAttempt`, `SearchResult`). Put new data
|
|
41
|
+
on a subclass that is returned only when the caller opts in (see
|
|
42
|
+
`ScrapeResultWithExtras` in `switchback/orchestrator.py`).
|
|
43
|
+
- **New parameters are keyword-only and default to the old behaviour.** Existing
|
|
44
|
+
parameters keep their names, order and defaults.
|
|
45
|
+
- **CLI and HTTP JSON gain a key only when the request asked for it** (see
|
|
46
|
+
`result_dict` in `switchback/api.py`).
|
|
47
|
+
- **A new dependency gets its own extra and stays out of `[all]`**, and a missing
|
|
48
|
+
dependency means the old behaviour. Installing a package is not opt-in: users
|
|
49
|
+
may already have it for something else. Anything that changes what an existing
|
|
50
|
+
call returns stays off until a `SCRAPER_*` variable turns it on (see
|
|
51
|
+
`SCRAPER_YOUTUBE`, `SCRAPER_DOCUMENTS`).
|
|
52
|
+
- **The pinned tests are the contract.** The "backward compatibility" tests in
|
|
53
|
+
`tests/test_extras.py` pin the result fields and the CLI/HTTP JSON keys, and CI
|
|
54
|
+
runs them. If one fails, the change is breaking: redesign the change, don't
|
|
55
|
+
edit the pin.
|
|
56
|
+
- **Before opening the PR, compare against `main` live:** scrape a few pages
|
|
57
|
+
with the default call on both; the output should be byte-identical.
|
|
58
|
+
|
|
59
|
+
## Tests
|
|
60
|
+
`pytest tests` runs the offline tests (fake tiers, no network, a few seconds);
|
|
61
|
+
CI runs them on every PR. `tests/test_suite.py` exercises the cascade across the anti-bot difficulty
|
|
62
|
+
spectrum (needs network + browser tiers): `python tests/test_suite.py --quick`
|
|
63
|
+
for a fast tier-0/1 pass.
|
|
64
|
+
|
|
65
|
+
## Pull requests
|
|
66
|
+
Keep PRs focused; describe what changed and why, and note any new env var or
|
|
67
|
+
endpoint in the README and CHANGELOG.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switchback
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: One cost-ordered scrape cascade (HTTP → stealth browser → paid), shared by every tool.
|
|
5
5
|
Author-email: Akash Kodavuru <akash@theaklabs.com>
|
|
6
6
|
License: MIT
|
|
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://github.com/akash-kr/switchback
|
|
|
8
8
|
Project-URL: Repository, https://github.com/akash-kr/switchback
|
|
9
9
|
Project-URL: Issues, https://github.com/akash-kr/switchback/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/akash-kr/switchback/blob/main/CHANGELOG.md
|
|
11
|
-
Keywords: scraping,crawler,
|
|
11
|
+
Keywords: web-scraping,scraper,crawler,html-to-markdown,markdown,cloudflare-bypass,anti-bot,stealth,playwright,patchright,camoufox,curl-cffi,cloudscraper,firecrawl,headless-browser,residential-proxy,llm,rag,ai-agents,opentelemetry
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -27,9 +27,9 @@ License-File: LICENSE
|
|
|
27
27
|
License-File: NOTICE
|
|
28
28
|
Requires-Dist: markdownify
|
|
29
29
|
Requires-Dist: beautifulsoup4
|
|
30
|
-
Requires-Dist: pypdf
|
|
31
|
-
Requires-Dist: requests
|
|
32
|
-
Requires-Dist: curl_cffi
|
|
30
|
+
Requires-Dist: pypdf>=6.19
|
|
31
|
+
Requires-Dist: requests>=2.33
|
|
32
|
+
Requires-Dist: curl_cffi>=0.15
|
|
33
33
|
Provides-Extra: cloudflare
|
|
34
34
|
Requires-Dist: cloudscraper; extra == "cloudflare"
|
|
35
35
|
Provides-Extra: browser
|
|
@@ -41,6 +41,12 @@ Requires-Dist: firecrawl-py; extra == "firecrawl"
|
|
|
41
41
|
Provides-Extra: server
|
|
42
42
|
Requires-Dist: fastapi; extra == "server"
|
|
43
43
|
Requires-Dist: uvicorn; extra == "server"
|
|
44
|
+
Provides-Extra: mcp
|
|
45
|
+
Requires-Dist: mcp; extra == "mcp"
|
|
46
|
+
Provides-Extra: youtube
|
|
47
|
+
Requires-Dist: youtube-transcript-api>=1.0; extra == "youtube"
|
|
48
|
+
Provides-Extra: documents
|
|
49
|
+
Requires-Dist: markitdown[docx,pptx,xls,xlsx]; extra == "documents"
|
|
44
50
|
Provides-Extra: tracing
|
|
45
51
|
Requires-Dist: opentelemetry-sdk; extra == "tracing"
|
|
46
52
|
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc; extra == "tracing"
|
|
@@ -70,6 +76,8 @@ Dynamic: license-file
|
|
|
70
76
|
|
|
71
77
|
<div align="center">
|
|
72
78
|
|
|
79
|
+
# switchback — Python web scraper: any URL → clean Markdown
|
|
80
|
+
|
|
73
81
|
**One cost-ordered scrape cascade — HTTP → stealth browser → paid — shared by every tool.**
|
|
74
82
|
|
|
75
83
|
Give it a URL; it tries the cheapest way to get clean Markdown first and only escalates
|
|
@@ -80,6 +88,8 @@ to a heavier (slower, costlier) tier when the cheap one is walled. Stops at the
|
|
|
80
88
|
[](LICENSE)
|
|
81
89
|
[](https://github.com/akash-kr/switchback/actions/workflows/ci.yml)
|
|
82
90
|
|
|
91
|
+
⭐ If switchback saved you a paid-API bill, a star helps others find it.
|
|
92
|
+
|
|
83
93
|
</div>
|
|
84
94
|
|
|
85
95
|
---
|
|
@@ -117,13 +127,21 @@ python -m switchback https://example.com/article # JSON on stdout — bridge
|
|
|
117
127
|
|
|
118
128
|
That's the whole loop. Add tiers as you need them (see [Install](#install)).
|
|
119
129
|
|
|
130
|
+
<p align="center">
|
|
131
|
+
<img src="https://raw.githubusercontent.com/akash-kr/switchback/main/docs/assets/demo.gif" width="880"
|
|
132
|
+
alt="switchback scraping a Cloudflare-walled Quora page: tier_1 to tier_4 come up empty or walled, tier_5 (Camoufox) returns clean Markdown in 20.2s; on the second run botwall remembers the host and goes straight to tier_5">
|
|
133
|
+
</p>
|
|
134
|
+
|
|
135
|
+
<sub>A real run, time-compressed. Run 1 climbs the cascade until a tier gets through. Run 2 hits the
|
|
136
|
+
same host, and botwall starts at the tier that won last time. The paid tier is never touched.</sub>
|
|
137
|
+
|
|
120
138
|
## The cascade (stop at first success)
|
|
121
139
|
|
|
122
140
|
| Tier | Strategy | Cost |
|
|
123
141
|
|---|---|---|
|
|
124
|
-
| tier_1 | Direct APIs / mirrors (arxiv, wikipedia, EuropePMC; extend: job boards) | free, cleanest |
|
|
125
|
-
| tier_2 | Plain HTTP + TLS impersonation (`curl_cffi`), incl. PDFs | cheap |
|
|
126
|
-
| tier_3 | Cloudflare / anti-bot solver (`cloudscraper`, install `.[cloudflare]`) | cheap-ish (~5s/host) |
|
|
142
|
+
| tier_1 | Direct APIs / mirrors (arxiv, wikipedia, EuropePMC, YouTube transcripts; extend: job boards) | free, cleanest |
|
|
143
|
+
| tier_2 | Plain HTTP + TLS impersonation (`curl_cffi`), incl. PDFs and office/ebook files | cheap |
|
|
144
|
+
| tier_3 | Cloudflare / anti-bot solver (`cloudscraper`, install `.[cloudflare]`) — on by default (opt out: `SCRAPER_DISABLE_CLOUDSCRAPER`) | cheap-ish (~5s/host) |
|
|
127
145
|
| tier_4 | Stealth headless browser (`patchright`, Chromium) | heavy |
|
|
128
146
|
| tier_5 | Camoufox (Firefox stealth) — **on by default** (opt out: `SCRAPER_DISABLE_CAMOUFOX`) | heavy + slow (~40s on hard CF) |
|
|
129
147
|
| tier_6 | Residential-IP browser over CDP (`BU_CDP_URL`) — off unless configured | heavy (remote egress) |
|
|
@@ -149,9 +167,18 @@ pip install "switchback[browser]" && patchright install chromium # + tier_4 st
|
|
|
149
167
|
pip install "switchback[camoufox]" && camoufox fetch # + tier_5 Firefox stealth
|
|
150
168
|
pip install "switchback[firecrawl]" # + tier_7 paid API (needs FIRECRAWL_API_KEY)
|
|
151
169
|
pip install "switchback[tracing]" # + OpenTelemetry -> any OTLP backend
|
|
152
|
-
pip install "switchback[all]" #
|
|
170
|
+
pip install "switchback[all]" # all of the above
|
|
171
|
+
pip install "switchback[mcp]" # + MCP server for agents (switchback-mcp)
|
|
172
|
+
pip install "switchback[youtube]" # + YouTube transcripts in tier_1 (and set SCRAPER_YOUTUBE=1)
|
|
173
|
+
pip install "switchback[documents]" # + .docx/.pptx/.xlsx/.xls/.epub → markdown in tier_2 (and set SCRAPER_DOCUMENTS=1)
|
|
153
174
|
```
|
|
154
175
|
|
|
176
|
+
`mcp`, `youtube` and `documents` are not part of `[all]`, so upgrading an existing
|
|
177
|
+
`[all]` install doesn't pull new dependencies (`documents` brings markitdown and
|
|
178
|
+
onnxruntime). Add them by name. YouTube transcripts and office files also need
|
|
179
|
+
their switch (`SCRAPER_YOUTUBE=1`, `SCRAPER_DOCUMENTS=1`), because they change what
|
|
180
|
+
those URLs return; without it they behave exactly as before, installed or not.
|
|
181
|
+
|
|
155
182
|
For tier_3's **full** v3 JS-VM + Turnstile + stealth, install the Enhanced Edition
|
|
156
183
|
3.x fork (PyPI's `cloudscraper` is the older v1/v2 — PyPI forbids pinning a
|
|
157
184
|
git-URL dep inside a published package, so install it alongside):
|
|
@@ -185,6 +212,30 @@ fix) and the cascade falls through — they are never silently skipped. Checklis
|
|
|
185
212
|
challenge can't eat the per-URL deadline before the browser tier runs. Lower it
|
|
186
213
|
(e.g. `12`) if tier_3 rarely wins on your hosts.
|
|
187
214
|
|
|
215
|
+
### Scraping URLs other people submit
|
|
216
|
+
|
|
217
|
+
If the URLs come from users you don't fully trust (a "save this link" box, a
|
|
218
|
+
feed reader, a public API), two opt-in switches harden the engine. Both default
|
|
219
|
+
to off, so nothing changes unless you set them:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
export SCRAPER_DISABLE_CLOUDSCRAPER=1 # tier_3 runs site JavaScript outside a browser sandbox
|
|
223
|
+
export SCRAPER_BROWSER_BLOCK_PRIVATE=1 # browser tiers can't reach your machine's or network's private services
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- **Why tier_3:** solving a Cloudflare JS challenge means running JavaScript the
|
|
227
|
+
site sent, in js2py or Node's `vm` module, and neither is a security boundary.
|
|
228
|
+
The browser tiers run page JavaScript inside the browser's own sandbox instead.
|
|
229
|
+
- **Install tier_4's browser first** (`patchright install chromium`): with tier_3
|
|
230
|
+
off, it is what clears Cloudflare. `--doctor` shows tier_3 as `off` and still
|
|
231
|
+
exits 0.
|
|
232
|
+
- **Why the browser guard:** a scraped page can redirect, frame or `fetch()` an
|
|
233
|
+
address like `http://127.0.0.1:…` or a cloud metadata endpoint, and whatever
|
|
234
|
+
answers comes back as scraped content. With the guard, those requests (and
|
|
235
|
+
WebSockets) are refused and service workers are disabled so they can't route
|
|
236
|
+
around it. Check the URL you pass in yourself too: the guard covers what the
|
|
237
|
+
*page* asks for in the browser tiers, not the HTTP tiers' own requests.
|
|
238
|
+
|
|
188
239
|
**Verify readiness on the box** with the preflight check (doubles as a healthcheck
|
|
189
240
|
— exit 0 when the capable tiers are ready):
|
|
190
241
|
|
|
@@ -227,6 +278,61 @@ Non-Python callers: see [clients/node_bridge.md](clients/node_bridge.md). Python
|
|
|
227
278
|
callers that want HTTP-with-CLI-fallback can drop in
|
|
228
279
|
[clients/python_client.py](clients/python_client.py).
|
|
229
280
|
|
|
281
|
+
**MCP server** (for agents: Claude Code, Cursor, any MCP client)
|
|
282
|
+
```bash
|
|
283
|
+
pip install "switchback[mcp]"
|
|
284
|
+
claude mcp add switchback -- switchback-mcp # stdio; tools: scrape, search
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Agent-ready extras
|
|
288
|
+
|
|
289
|
+
Off by default: without them every result is exactly what it was before, the same
|
|
290
|
+
`ScrapeResult` / `ScrapeOutcome` with the same fields, and the same CLI and HTTP
|
|
291
|
+
JSON. Ask for any combination per call; results then come back as
|
|
292
|
+
`ScrapeResultWithExtras` / `ScrapeOutcomeWithExtras` (subclasses, so existing
|
|
293
|
+
`isinstance` checks still hold) with the extras you asked for filled in and the
|
|
294
|
+
rest `None`. The CLI and HTTP JSON add only the keys you asked for.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from switchback import scrape
|
|
298
|
+
[r] = scrape("https://example.com/product", state=True, appendix=True,
|
|
299
|
+
query="what does it cost?")
|
|
300
|
+
r.state # {"__NEXT_DATA__": {...}, "json_ld": [...], "__INITIAL_STATE__": {...}}
|
|
301
|
+
r.appendix # [{"region": "nav", "markdown": "..."}, {"region": "footer", ...}]
|
|
302
|
+
r.highlights # [{"text": "...", "score": 7.1, "grader": "bm25",
|
|
303
|
+
# "link": "https://example.com/product#:~:text=Price%20from,per%20month"}]
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
switchback --state --appendix --query "what does it cost?" https://example.com/product
|
|
308
|
+
curl -s localhost:8799/scrape -d '{"urls":["https://example.com"],"query":"pricing","state":true}'
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
| extra | what you get |
|
|
312
|
+
| --- | --- |
|
|
313
|
+
| `state` | The JSON the page embeds for its own frontend: Next.js `__NEXT_DATA__`, Nuxt, `window.__X__` globals (incl. `JSON.parse("…")`), schema.org JSON-LD. Often cleaner than the text (prices, ids, dates). Only valid JSON is returned. `{}` when the winning tier had no HTML (API mirrors, PDFs, docs, Firecrawl markdown). |
|
|
314
|
+
| `appendix` | The menus, header, footer and sidebars that are stripped from the main content, as separate markdown, one entry per region. |
|
|
315
|
+
| `query` → `highlights` | The passages that answer the question, best first, each with a citation `link`: a [text fragment](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments) URL that opens the page scrolled to, and highlighting, that passage (for a YouTube transcript, the video at that moment). If the text isn't visible on the page (e.g. it came from the `<title>` or image alt text) the browser simply opens the page at the top. |
|
|
316
|
+
|
|
317
|
+
`state` and `appendix` need the fetched HTML, so they skip the content cache
|
|
318
|
+
(`SCRAPER_CONTENT_TTL_S`) and fetch; `highlights` work on cached content too.
|
|
319
|
+
|
|
320
|
+
**Grading highlights.** The default grader is local BM25 keyword scoring: free,
|
|
321
|
+
offline, no data leaves the box. For better relevance, opt into
|
|
322
|
+
[TypeSafe](https://typesafe.ai)'s Jev model, which answers "does this passage
|
|
323
|
+
answer the question?" for every passage and returns a calibrated probability
|
|
324
|
+
(passages under `SCRAPER_HIGHLIGHTS_MIN_SCORE`, default 0.5, are dropped, so an
|
|
325
|
+
off-topic page honestly returns `[]`):
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
export SCRAPER_HIGHLIGHTS_GRADER=jev
|
|
329
|
+
export TYPESAFE_API_KEY=... # sends the page passages + query to api.typesafe.ai
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Jev is billed per input token (about $0.0004 for a 10k-token page). If the call
|
|
333
|
+
fails, that request falls back to BM25 (`"grader": "bm25"`); the scrape never fails
|
|
334
|
+
because of highlights.
|
|
335
|
+
|
|
230
336
|
## Cost-scoped residential egress
|
|
231
337
|
|
|
232
338
|
The dominant reason hard hosts wall you is the **datacenter IP**, not the
|
|
@@ -286,10 +392,13 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
|
|
286
392
|
- `SCRAPER_DISABLE_FIRECRAWL` — skip tier_7
|
|
287
393
|
- `FIRECRAWL_API_KEY` — enable tier_7
|
|
288
394
|
- `SCRAPER_DISABLE_CAMOUFOX` — turn off tier_5 (on by default; needs `pip install camoufox` + `camoufox fetch`)
|
|
395
|
+
- `SCRAPER_DISABLE_CLOUDSCRAPER` — turn off tier_3 (on by default). Recommended when you scrape URLs other people submit; see [Scraping URLs other people submit](#scraping-urls-other-people-submit)
|
|
396
|
+
- `SCRAPER_BROWSER_BLOCK_PRIVATE` — opt-in, off by default: tiers 4 and 5 refuse any request a page makes to a loopback, private, link-local (incl. cloud metadata) or CGNAT address
|
|
289
397
|
- `BU_CDP_URL` — enable tier_6 residential browser by pointing at a CDP endpoint
|
|
290
398
|
- `SCRAPER_PROXY` — route *all* tiers/URLs through a proxy
|
|
291
399
|
- `SCRAPER_EGRESS_PROXY` — route only walled hosts through a proxy (see [Cost-scoped residential egress](#cost-scoped-residential-egress))
|
|
292
400
|
- `SEARXNG_URL` — defaults to `http://localhost:8888`
|
|
401
|
+
- `SCRAPER_ARXIV_CONTACT` — optional email added to the User-Agent of arXiv API calls (arXiv asks clients to identify themselves; the default names only the project URL)
|
|
293
402
|
- `SCRAPER_STATE_DIR` — where the botwall DB/event log + session cache live
|
|
294
403
|
- `SCRAPER_COOKIES_FILE` — Netscape `cookies.txt` to scrape login-gated hosts (injected into the HTTP and browser tiers)
|
|
295
404
|
- `SCRAPER_CAPTCHA_PROVIDER` + `SCRAPER_CAPTCHA_API_KEY` — opt-in, off by default: wire a third-party solver (2captcha/capsolver/capmonster/anticaptcha/deathbycaptcha/9kw) into tier_3 for Turnstile/reCAPTCHA/hCaptcha on CF hosts. **Paid**, billed per solve by the provider.
|
|
@@ -313,6 +422,13 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
|
|
313
422
|
- `SCRAPER_TIER_RETRY_ON` — failure classes eligible for a same-tier retry (default `timeout,rate_limited,connection`; widen to include `botwall,http_block` behind a rotating residential proxy so each retry gets a fresh IP). Retries are bounded by `SCRAPER_DEADLINE_S`; enabling them on the paid Firecrawl tier bills per attempt
|
|
314
423
|
- `SCRAPER_LOGIN_HOOK` — `pkg.module:func` returning `{cookie: value}` for a host (see [Logged-in sessions](#logged-in-sessions))
|
|
315
424
|
- `SCRAPER_EXTRACTION_FILE` — per-domain extraction prefs JSON (default `config/extraction.json`)
|
|
425
|
+
- `SCRAPER_HIGHLIGHTS_GRADER` — `bm25` (default) or `jev` (needs `TYPESAFE_API_KEY`; see [Agent-ready extras](#agent-ready-extras))
|
|
426
|
+
- `SCRAPER_HIGHLIGHTS_TOP_K` — highlights returned per URL (5)
|
|
427
|
+
- `SCRAPER_HIGHLIGHTS_MIN_SCORE` — Jev probability below which a passage is dropped (0.5)
|
|
428
|
+
- `SCRAPER_JEV_MODEL` / `SCRAPER_JEV_TIMEOUT_S` — Jev model alias (`jev-latest`) and per-request timeout (20s)
|
|
429
|
+
- `SCRAPER_YOUTUBE` — opt-in: `1` returns YouTube transcripts from tier_1 (needs `switchback[youtube]`)
|
|
430
|
+
- `SCRAPER_DOCUMENTS` — opt-in: `1` converts .docx/.pptx/.xlsx/.xls/.epub to markdown in tier_2 (needs `switchback[documents]`)
|
|
431
|
+
- `SCRAPER_YOUTUBE_LANGS` — transcript languages to try, in order (`en`; e.g. `en,hi`). YouTube blocks most cloud IPs; when it does, the transcript is retried once through `SCRAPER_EGRESS_PROXY` if set
|
|
316
432
|
- `SCRAPER_TRACE_SESSION` — opt-in: capture a Playwright trace (screenshots + DOM + network) per browser-tier attempt, written to `state/traces/`
|
|
317
433
|
- `BENCH_FIRECRAWL_USD` / `BENCH_FIRECRAWL_HARD_MULT` — cost model for the savings report
|
|
318
434
|
</details>
|
|
@@ -364,6 +480,20 @@ strip site-specific noise, declare prefs per host in `config/extraction.json`
|
|
|
364
480
|
(see [config/extraction.example.json](config/extraction.example.json)); every
|
|
365
481
|
tier's normalize step picks them up automatically.
|
|
366
482
|
|
|
483
|
+
## Roadmap
|
|
484
|
+
|
|
485
|
+
Not built yet:
|
|
486
|
+
|
|
487
|
+
- **Layout-aware markdown** — keep pricing grids and card layouts built from
|
|
488
|
+
`<div>`s (not `<table>`s) as tables, using the browser tiers' rendered layout.
|
|
489
|
+
- **Scanned pages and images** — OCR / vision-model text for PDFs with no text
|
|
490
|
+
layer and for images.
|
|
491
|
+
- **Audio and caption-less video** — speech-to-text transcription.
|
|
492
|
+
- **Hosted service** — managed browsers at high concurrency, stored results and
|
|
493
|
+
shareable result links. switchback stays a library you run yourself.
|
|
494
|
+
- Smaller gaps: Next.js App Router flight data (`self.__next_f`) in `state`, and
|
|
495
|
+
YouTube chapters in transcripts.
|
|
496
|
+
|
|
367
497
|
## Contributing
|
|
368
498
|
|
|
369
499
|
Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Start with the
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
<div align="center">
|
|
13
13
|
|
|
14
|
+
# switchback — Python web scraper: any URL → clean Markdown
|
|
15
|
+
|
|
14
16
|
**One cost-ordered scrape cascade — HTTP → stealth browser → paid — shared by every tool.**
|
|
15
17
|
|
|
16
18
|
Give it a URL; it tries the cheapest way to get clean Markdown first and only escalates
|
|
@@ -21,6 +23,8 @@ to a heavier (slower, costlier) tier when the cheap one is walled. Stops at the
|
|
|
21
23
|
[](LICENSE)
|
|
22
24
|
[](https://github.com/akash-kr/switchback/actions/workflows/ci.yml)
|
|
23
25
|
|
|
26
|
+
⭐ If switchback saved you a paid-API bill, a star helps others find it.
|
|
27
|
+
|
|
24
28
|
</div>
|
|
25
29
|
|
|
26
30
|
---
|
|
@@ -58,13 +62,21 @@ python -m switchback https://example.com/article # JSON on stdout — bridge
|
|
|
58
62
|
|
|
59
63
|
That's the whole loop. Add tiers as you need them (see [Install](#install)).
|
|
60
64
|
|
|
65
|
+
<p align="center">
|
|
66
|
+
<img src="https://raw.githubusercontent.com/akash-kr/switchback/main/docs/assets/demo.gif" width="880"
|
|
67
|
+
alt="switchback scraping a Cloudflare-walled Quora page: tier_1 to tier_4 come up empty or walled, tier_5 (Camoufox) returns clean Markdown in 20.2s; on the second run botwall remembers the host and goes straight to tier_5">
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<sub>A real run, time-compressed. Run 1 climbs the cascade until a tier gets through. Run 2 hits the
|
|
71
|
+
same host, and botwall starts at the tier that won last time. The paid tier is never touched.</sub>
|
|
72
|
+
|
|
61
73
|
## The cascade (stop at first success)
|
|
62
74
|
|
|
63
75
|
| Tier | Strategy | Cost |
|
|
64
76
|
|---|---|---|
|
|
65
|
-
| tier_1 | Direct APIs / mirrors (arxiv, wikipedia, EuropePMC; extend: job boards) | free, cleanest |
|
|
66
|
-
| tier_2 | Plain HTTP + TLS impersonation (`curl_cffi`), incl. PDFs | cheap |
|
|
67
|
-
| tier_3 | Cloudflare / anti-bot solver (`cloudscraper`, install `.[cloudflare]`) | cheap-ish (~5s/host) |
|
|
77
|
+
| tier_1 | Direct APIs / mirrors (arxiv, wikipedia, EuropePMC, YouTube transcripts; extend: job boards) | free, cleanest |
|
|
78
|
+
| tier_2 | Plain HTTP + TLS impersonation (`curl_cffi`), incl. PDFs and office/ebook files | cheap |
|
|
79
|
+
| tier_3 | Cloudflare / anti-bot solver (`cloudscraper`, install `.[cloudflare]`) — on by default (opt out: `SCRAPER_DISABLE_CLOUDSCRAPER`) | cheap-ish (~5s/host) |
|
|
68
80
|
| tier_4 | Stealth headless browser (`patchright`, Chromium) | heavy |
|
|
69
81
|
| tier_5 | Camoufox (Firefox stealth) — **on by default** (opt out: `SCRAPER_DISABLE_CAMOUFOX`) | heavy + slow (~40s on hard CF) |
|
|
70
82
|
| tier_6 | Residential-IP browser over CDP (`BU_CDP_URL`) — off unless configured | heavy (remote egress) |
|
|
@@ -90,9 +102,18 @@ pip install "switchback[browser]" && patchright install chromium # + tier_4 st
|
|
|
90
102
|
pip install "switchback[camoufox]" && camoufox fetch # + tier_5 Firefox stealth
|
|
91
103
|
pip install "switchback[firecrawl]" # + tier_7 paid API (needs FIRECRAWL_API_KEY)
|
|
92
104
|
pip install "switchback[tracing]" # + OpenTelemetry -> any OTLP backend
|
|
93
|
-
pip install "switchback[all]" #
|
|
105
|
+
pip install "switchback[all]" # all of the above
|
|
106
|
+
pip install "switchback[mcp]" # + MCP server for agents (switchback-mcp)
|
|
107
|
+
pip install "switchback[youtube]" # + YouTube transcripts in tier_1 (and set SCRAPER_YOUTUBE=1)
|
|
108
|
+
pip install "switchback[documents]" # + .docx/.pptx/.xlsx/.xls/.epub → markdown in tier_2 (and set SCRAPER_DOCUMENTS=1)
|
|
94
109
|
```
|
|
95
110
|
|
|
111
|
+
`mcp`, `youtube` and `documents` are not part of `[all]`, so upgrading an existing
|
|
112
|
+
`[all]` install doesn't pull new dependencies (`documents` brings markitdown and
|
|
113
|
+
onnxruntime). Add them by name. YouTube transcripts and office files also need
|
|
114
|
+
their switch (`SCRAPER_YOUTUBE=1`, `SCRAPER_DOCUMENTS=1`), because they change what
|
|
115
|
+
those URLs return; without it they behave exactly as before, installed or not.
|
|
116
|
+
|
|
96
117
|
For tier_3's **full** v3 JS-VM + Turnstile + stealth, install the Enhanced Edition
|
|
97
118
|
3.x fork (PyPI's `cloudscraper` is the older v1/v2 — PyPI forbids pinning a
|
|
98
119
|
git-URL dep inside a published package, so install it alongside):
|
|
@@ -126,6 +147,30 @@ fix) and the cascade falls through — they are never silently skipped. Checklis
|
|
|
126
147
|
challenge can't eat the per-URL deadline before the browser tier runs. Lower it
|
|
127
148
|
(e.g. `12`) if tier_3 rarely wins on your hosts.
|
|
128
149
|
|
|
150
|
+
### Scraping URLs other people submit
|
|
151
|
+
|
|
152
|
+
If the URLs come from users you don't fully trust (a "save this link" box, a
|
|
153
|
+
feed reader, a public API), two opt-in switches harden the engine. Both default
|
|
154
|
+
to off, so nothing changes unless you set them:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
export SCRAPER_DISABLE_CLOUDSCRAPER=1 # tier_3 runs site JavaScript outside a browser sandbox
|
|
158
|
+
export SCRAPER_BROWSER_BLOCK_PRIVATE=1 # browser tiers can't reach your machine's or network's private services
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- **Why tier_3:** solving a Cloudflare JS challenge means running JavaScript the
|
|
162
|
+
site sent, in js2py or Node's `vm` module, and neither is a security boundary.
|
|
163
|
+
The browser tiers run page JavaScript inside the browser's own sandbox instead.
|
|
164
|
+
- **Install tier_4's browser first** (`patchright install chromium`): with tier_3
|
|
165
|
+
off, it is what clears Cloudflare. `--doctor` shows tier_3 as `off` and still
|
|
166
|
+
exits 0.
|
|
167
|
+
- **Why the browser guard:** a scraped page can redirect, frame or `fetch()` an
|
|
168
|
+
address like `http://127.0.0.1:…` or a cloud metadata endpoint, and whatever
|
|
169
|
+
answers comes back as scraped content. With the guard, those requests (and
|
|
170
|
+
WebSockets) are refused and service workers are disabled so they can't route
|
|
171
|
+
around it. Check the URL you pass in yourself too: the guard covers what the
|
|
172
|
+
*page* asks for in the browser tiers, not the HTTP tiers' own requests.
|
|
173
|
+
|
|
129
174
|
**Verify readiness on the box** with the preflight check (doubles as a healthcheck
|
|
130
175
|
— exit 0 when the capable tiers are ready):
|
|
131
176
|
|
|
@@ -168,6 +213,61 @@ Non-Python callers: see [clients/node_bridge.md](clients/node_bridge.md). Python
|
|
|
168
213
|
callers that want HTTP-with-CLI-fallback can drop in
|
|
169
214
|
[clients/python_client.py](clients/python_client.py).
|
|
170
215
|
|
|
216
|
+
**MCP server** (for agents: Claude Code, Cursor, any MCP client)
|
|
217
|
+
```bash
|
|
218
|
+
pip install "switchback[mcp]"
|
|
219
|
+
claude mcp add switchback -- switchback-mcp # stdio; tools: scrape, search
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Agent-ready extras
|
|
223
|
+
|
|
224
|
+
Off by default: without them every result is exactly what it was before, the same
|
|
225
|
+
`ScrapeResult` / `ScrapeOutcome` with the same fields, and the same CLI and HTTP
|
|
226
|
+
JSON. Ask for any combination per call; results then come back as
|
|
227
|
+
`ScrapeResultWithExtras` / `ScrapeOutcomeWithExtras` (subclasses, so existing
|
|
228
|
+
`isinstance` checks still hold) with the extras you asked for filled in and the
|
|
229
|
+
rest `None`. The CLI and HTTP JSON add only the keys you asked for.
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from switchback import scrape
|
|
233
|
+
[r] = scrape("https://example.com/product", state=True, appendix=True,
|
|
234
|
+
query="what does it cost?")
|
|
235
|
+
r.state # {"__NEXT_DATA__": {...}, "json_ld": [...], "__INITIAL_STATE__": {...}}
|
|
236
|
+
r.appendix # [{"region": "nav", "markdown": "..."}, {"region": "footer", ...}]
|
|
237
|
+
r.highlights # [{"text": "...", "score": 7.1, "grader": "bm25",
|
|
238
|
+
# "link": "https://example.com/product#:~:text=Price%20from,per%20month"}]
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
switchback --state --appendix --query "what does it cost?" https://example.com/product
|
|
243
|
+
curl -s localhost:8799/scrape -d '{"urls":["https://example.com"],"query":"pricing","state":true}'
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| extra | what you get |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| `state` | The JSON the page embeds for its own frontend: Next.js `__NEXT_DATA__`, Nuxt, `window.__X__` globals (incl. `JSON.parse("…")`), schema.org JSON-LD. Often cleaner than the text (prices, ids, dates). Only valid JSON is returned. `{}` when the winning tier had no HTML (API mirrors, PDFs, docs, Firecrawl markdown). |
|
|
249
|
+
| `appendix` | The menus, header, footer and sidebars that are stripped from the main content, as separate markdown, one entry per region. |
|
|
250
|
+
| `query` → `highlights` | The passages that answer the question, best first, each with a citation `link`: a [text fragment](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments) URL that opens the page scrolled to, and highlighting, that passage (for a YouTube transcript, the video at that moment). If the text isn't visible on the page (e.g. it came from the `<title>` or image alt text) the browser simply opens the page at the top. |
|
|
251
|
+
|
|
252
|
+
`state` and `appendix` need the fetched HTML, so they skip the content cache
|
|
253
|
+
(`SCRAPER_CONTENT_TTL_S`) and fetch; `highlights` work on cached content too.
|
|
254
|
+
|
|
255
|
+
**Grading highlights.** The default grader is local BM25 keyword scoring: free,
|
|
256
|
+
offline, no data leaves the box. For better relevance, opt into
|
|
257
|
+
[TypeSafe](https://typesafe.ai)'s Jev model, which answers "does this passage
|
|
258
|
+
answer the question?" for every passage and returns a calibrated probability
|
|
259
|
+
(passages under `SCRAPER_HIGHLIGHTS_MIN_SCORE`, default 0.5, are dropped, so an
|
|
260
|
+
off-topic page honestly returns `[]`):
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
export SCRAPER_HIGHLIGHTS_GRADER=jev
|
|
264
|
+
export TYPESAFE_API_KEY=... # sends the page passages + query to api.typesafe.ai
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Jev is billed per input token (about $0.0004 for a 10k-token page). If the call
|
|
268
|
+
fails, that request falls back to BM25 (`"grader": "bm25"`); the scrape never fails
|
|
269
|
+
because of highlights.
|
|
270
|
+
|
|
171
271
|
## Cost-scoped residential egress
|
|
172
272
|
|
|
173
273
|
The dominant reason hard hosts wall you is the **datacenter IP**, not the
|
|
@@ -227,10 +327,13 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
|
|
227
327
|
- `SCRAPER_DISABLE_FIRECRAWL` — skip tier_7
|
|
228
328
|
- `FIRECRAWL_API_KEY` — enable tier_7
|
|
229
329
|
- `SCRAPER_DISABLE_CAMOUFOX` — turn off tier_5 (on by default; needs `pip install camoufox` + `camoufox fetch`)
|
|
330
|
+
- `SCRAPER_DISABLE_CLOUDSCRAPER` — turn off tier_3 (on by default). Recommended when you scrape URLs other people submit; see [Scraping URLs other people submit](#scraping-urls-other-people-submit)
|
|
331
|
+
- `SCRAPER_BROWSER_BLOCK_PRIVATE` — opt-in, off by default: tiers 4 and 5 refuse any request a page makes to a loopback, private, link-local (incl. cloud metadata) or CGNAT address
|
|
230
332
|
- `BU_CDP_URL` — enable tier_6 residential browser by pointing at a CDP endpoint
|
|
231
333
|
- `SCRAPER_PROXY` — route *all* tiers/URLs through a proxy
|
|
232
334
|
- `SCRAPER_EGRESS_PROXY` — route only walled hosts through a proxy (see [Cost-scoped residential egress](#cost-scoped-residential-egress))
|
|
233
335
|
- `SEARXNG_URL` — defaults to `http://localhost:8888`
|
|
336
|
+
- `SCRAPER_ARXIV_CONTACT` — optional email added to the User-Agent of arXiv API calls (arXiv asks clients to identify themselves; the default names only the project URL)
|
|
234
337
|
- `SCRAPER_STATE_DIR` — where the botwall DB/event log + session cache live
|
|
235
338
|
- `SCRAPER_COOKIES_FILE` — Netscape `cookies.txt` to scrape login-gated hosts (injected into the HTTP and browser tiers)
|
|
236
339
|
- `SCRAPER_CAPTCHA_PROVIDER` + `SCRAPER_CAPTCHA_API_KEY` — opt-in, off by default: wire a third-party solver (2captcha/capsolver/capmonster/anticaptcha/deathbycaptcha/9kw) into tier_3 for Turnstile/reCAPTCHA/hCaptcha on CF hosts. **Paid**, billed per solve by the provider.
|
|
@@ -254,6 +357,13 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
|
|
254
357
|
- `SCRAPER_TIER_RETRY_ON` — failure classes eligible for a same-tier retry (default `timeout,rate_limited,connection`; widen to include `botwall,http_block` behind a rotating residential proxy so each retry gets a fresh IP). Retries are bounded by `SCRAPER_DEADLINE_S`; enabling them on the paid Firecrawl tier bills per attempt
|
|
255
358
|
- `SCRAPER_LOGIN_HOOK` — `pkg.module:func` returning `{cookie: value}` for a host (see [Logged-in sessions](#logged-in-sessions))
|
|
256
359
|
- `SCRAPER_EXTRACTION_FILE` — per-domain extraction prefs JSON (default `config/extraction.json`)
|
|
360
|
+
- `SCRAPER_HIGHLIGHTS_GRADER` — `bm25` (default) or `jev` (needs `TYPESAFE_API_KEY`; see [Agent-ready extras](#agent-ready-extras))
|
|
361
|
+
- `SCRAPER_HIGHLIGHTS_TOP_K` — highlights returned per URL (5)
|
|
362
|
+
- `SCRAPER_HIGHLIGHTS_MIN_SCORE` — Jev probability below which a passage is dropped (0.5)
|
|
363
|
+
- `SCRAPER_JEV_MODEL` / `SCRAPER_JEV_TIMEOUT_S` — Jev model alias (`jev-latest`) and per-request timeout (20s)
|
|
364
|
+
- `SCRAPER_YOUTUBE` — opt-in: `1` returns YouTube transcripts from tier_1 (needs `switchback[youtube]`)
|
|
365
|
+
- `SCRAPER_DOCUMENTS` — opt-in: `1` converts .docx/.pptx/.xlsx/.xls/.epub to markdown in tier_2 (needs `switchback[documents]`)
|
|
366
|
+
- `SCRAPER_YOUTUBE_LANGS` — transcript languages to try, in order (`en`; e.g. `en,hi`). YouTube blocks most cloud IPs; when it does, the transcript is retried once through `SCRAPER_EGRESS_PROXY` if set
|
|
257
367
|
- `SCRAPER_TRACE_SESSION` — opt-in: capture a Playwright trace (screenshots + DOM + network) per browser-tier attempt, written to `state/traces/`
|
|
258
368
|
- `BENCH_FIRECRAWL_USD` / `BENCH_FIRECRAWL_HARD_MULT` — cost model for the savings report
|
|
259
369
|
</details>
|
|
@@ -305,6 +415,20 @@ strip site-specific noise, declare prefs per host in `config/extraction.json`
|
|
|
305
415
|
(see [config/extraction.example.json](config/extraction.example.json)); every
|
|
306
416
|
tier's normalize step picks them up automatically.
|
|
307
417
|
|
|
418
|
+
## Roadmap
|
|
419
|
+
|
|
420
|
+
Not built yet:
|
|
421
|
+
|
|
422
|
+
- **Layout-aware markdown** — keep pricing grids and card layouts built from
|
|
423
|
+
`<div>`s (not `<table>`s) as tables, using the browser tiers' rendered layout.
|
|
424
|
+
- **Scanned pages and images** — OCR / vision-model text for PDFs with no text
|
|
425
|
+
layer and for images.
|
|
426
|
+
- **Audio and caption-less video** — speech-to-text transcription.
|
|
427
|
+
- **Hosted service** — managed browsers at high concurrency, stored results and
|
|
428
|
+
shareable result links. switchback stays a library you run yourself.
|
|
429
|
+
- Smaller gaps: Next.js App Router flight data (`self.__next_f`) in `state`, and
|
|
430
|
+
YouTube chapters in transcripts.
|
|
431
|
+
|
|
308
432
|
## Contributing
|
|
309
433
|
|
|
310
434
|
Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Start with the
|
|
@@ -4,13 +4,17 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "switchback"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "One cost-ordered scrape cascade (HTTP → stealth browser → paid), shared by every tool."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
authors = [{ name = "Akash Kodavuru", email = "akash@theaklabs.com" }]
|
|
13
|
-
keywords = [
|
|
13
|
+
keywords = [
|
|
14
|
+
"web-scraping", "scraper", "crawler", "html-to-markdown", "markdown", "cloudflare-bypass",
|
|
15
|
+
"anti-bot", "stealth", "playwright", "patchright", "camoufox", "curl-cffi", "cloudscraper",
|
|
16
|
+
"firecrawl", "headless-browser", "residential-proxy", "llm", "rag", "ai-agents", "opentelemetry",
|
|
17
|
+
]
|
|
14
18
|
classifiers = [
|
|
15
19
|
"Development Status :: 4 - Beta",
|
|
16
20
|
"Intended Audience :: Developers",
|
|
@@ -33,9 +37,9 @@ classifiers = [
|
|
|
33
37
|
dependencies = [
|
|
34
38
|
"markdownify",
|
|
35
39
|
"beautifulsoup4",
|
|
36
|
-
"pypdf",
|
|
37
|
-
"requests",
|
|
38
|
-
"curl_cffi",
|
|
40
|
+
"pypdf>=6.19", # older releases hang or blow up memory on crafted PDFs (DoS CVEs)
|
|
41
|
+
"requests>=2.33", # CVE-2024-35195, CVE-2024-47081, CVE-2026-25645
|
|
42
|
+
"curl_cffi>=0.15", # tier_2: TLS-impersonating HTTP (chrome145/146 targets)
|
|
39
43
|
]
|
|
40
44
|
|
|
41
45
|
[project.optional-dependencies]
|
|
@@ -49,6 +53,9 @@ browser = ["patchright"] # tier3: stealth headless Chromium
|
|
|
49
53
|
camoufox = ["camoufox[geoip]"] # tier3b: Firefox stealth (geoip: match locale/tz to proxy IP)
|
|
50
54
|
firecrawl = ["firecrawl-py"] # tier4: paid last-resort API
|
|
51
55
|
server = ["fastapi", "uvicorn"] # HTTP service (switchback.server) + /metrics + /traces
|
|
56
|
+
mcp = ["mcp"] # MCP server for agents (switchback-mcp)
|
|
57
|
+
youtube = ["youtube-transcript-api>=1.0"] # tier_1: YouTube transcripts
|
|
58
|
+
documents = ["markitdown[docx,pptx,xlsx,xls]"] # tier_2: office/ebook files → markdown
|
|
52
59
|
tracing = [ # OTel → any OTLP backend
|
|
53
60
|
"opentelemetry-sdk",
|
|
54
61
|
"opentelemetry-exporter-otlp-proto-grpc",
|
|
@@ -58,11 +65,15 @@ all = [
|
|
|
58
65
|
"fastapi", "uvicorn",
|
|
59
66
|
"opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-grpc",
|
|
60
67
|
]
|
|
68
|
+
# Not in `all` on purpose: added in 0.6, kept out so existing `[all]` installs
|
|
69
|
+
# don't suddenly pull markitdown's onnxruntime (via magika) or the MCP SDK.
|
|
70
|
+
# Install them by name: "switchback[mcp,youtube,documents]".
|
|
61
71
|
dev = ["pytest"]
|
|
62
72
|
|
|
63
73
|
[project.scripts]
|
|
64
74
|
switchback = "switchback.api:_main"
|
|
65
75
|
switchback-server = "switchback.server:main"
|
|
76
|
+
switchback-mcp = "switchback.mcp_server:main"
|
|
66
77
|
|
|
67
78
|
[project.urls]
|
|
68
79
|
Homepage = "https://github.com/akash-kr/switchback"
|