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.
Files changed (57) hide show
  1. {switchback-0.5.0 → switchback-0.6.1}/CHANGELOG.md +60 -1
  2. switchback-0.6.1/CONTRIBUTING.md +67 -0
  3. {switchback-0.5.0 → switchback-0.6.1}/PKG-INFO +139 -9
  4. {switchback-0.5.0 → switchback-0.6.1}/README.md +128 -4
  5. {switchback-0.5.0 → switchback-0.6.1}/pyproject.toml +16 -5
  6. {switchback-0.5.0 → switchback-0.6.1}/switchback/__init__.py +4 -2
  7. {switchback-0.5.0 → switchback-0.6.1}/switchback/api.py +60 -16
  8. {switchback-0.5.0 → switchback-0.6.1}/switchback/doctor.py +4 -1
  9. switchback-0.6.1/switchback/highlights.py +311 -0
  10. switchback-0.6.1/switchback/mcp_server.py +81 -0
  11. {switchback-0.5.0 → switchback-0.6.1}/switchback/normalize.py +98 -1
  12. {switchback-0.5.0 → switchback-0.6.1}/switchback/orchestrator.py +115 -19
  13. {switchback-0.5.0 → switchback-0.6.1}/switchback/server.py +9 -6
  14. switchback-0.6.1/switchback/state.py +115 -0
  15. switchback-0.6.1/switchback/tiers/_browser.py +142 -0
  16. switchback-0.6.1/switchback/tiers/tier_1.py +195 -0
  17. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_2.py +24 -3
  18. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_3.py +15 -3
  19. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_4.py +2 -1
  20. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_5.py +12 -9
  21. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_7.py +4 -2
  22. {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/PKG-INFO +139 -9
  23. {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/SOURCES.txt +3 -0
  24. {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/entry_points.txt +1 -0
  25. {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/requires.txt +12 -3
  26. switchback-0.5.0/CONTRIBUTING.md +0 -35
  27. switchback-0.5.0/switchback/tiers/_browser.py +0 -50
  28. switchback-0.5.0/switchback/tiers/tier_1.py +0 -81
  29. {switchback-0.5.0 → switchback-0.6.1}/.env.example +0 -0
  30. {switchback-0.5.0 → switchback-0.6.1}/LICENSE +0 -0
  31. {switchback-0.5.0 → switchback-0.6.1}/MANIFEST.in +0 -0
  32. {switchback-0.5.0 → switchback-0.6.1}/NOTICE +0 -0
  33. {switchback-0.5.0 → switchback-0.6.1}/SECURITY.md +0 -0
  34. {switchback-0.5.0 → switchback-0.6.1}/clients/node_bridge.md +0 -0
  35. {switchback-0.5.0 → switchback-0.6.1}/clients/python_client.py +0 -0
  36. {switchback-0.5.0 → switchback-0.6.1}/config/botwall_skip_urls.txt +0 -0
  37. {switchback-0.5.0 → switchback-0.6.1}/config/extraction.example.json +0 -0
  38. {switchback-0.5.0 → switchback-0.6.1}/setup.cfg +0 -0
  39. {switchback-0.5.0 → switchback-0.6.1}/switchback/__main__.py +0 -0
  40. {switchback-0.5.0 → switchback-0.6.1}/switchback/concurrency.py +0 -0
  41. {switchback-0.5.0 → switchback-0.6.1}/switchback/content_cache.py +0 -0
  42. {switchback-0.5.0 → switchback-0.6.1}/switchback/egress.py +0 -0
  43. {switchback-0.5.0 → switchback-0.6.1}/switchback/extract.py +0 -0
  44. {switchback-0.5.0 → switchback-0.6.1}/switchback/flags.py +0 -0
  45. {switchback-0.5.0 → switchback-0.6.1}/switchback/policy/__init__.py +0 -0
  46. {switchback-0.5.0 → switchback-0.6.1}/switchback/policy/botwall.py +0 -0
  47. {switchback-0.5.0 → switchback-0.6.1}/switchback/policy/gates.py +0 -0
  48. {switchback-0.5.0 → switchback-0.6.1}/switchback/py.typed +0 -0
  49. {switchback-0.5.0 → switchback-0.6.1}/switchback/reporting.py +0 -0
  50. {switchback-0.5.0 → switchback-0.6.1}/switchback/search.py +0 -0
  51. {switchback-0.5.0 → switchback-0.6.1}/switchback/session_cache.py +0 -0
  52. {switchback-0.5.0 → switchback-0.6.1}/switchback/session_trace.py +0 -0
  53. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/__init__.py +0 -0
  54. {switchback-0.5.0 → switchback-0.6.1}/switchback/tiers/tier_6.py +0 -0
  55. {switchback-0.5.0 → switchback-0.6.1}/switchback/tracing.py +0 -0
  56. {switchback-0.5.0 → switchback-0.6.1}/switchback.egg-info/dependency_links.txt +0 -0
  57. {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
- ## [Unreleased]
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.5.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,cloudflare,markdown,cascade,anti-bot,stealth
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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
81
89
  [![CI](https://github.com/akash-kr/switchback/actions/workflows/ci.yml/badge.svg)](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]" # everything
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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
22
24
  [![CI](https://github.com/akash-kr/switchback/actions/workflows/ci.yml/badge.svg)](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]" # everything
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.5.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 = ["scraping", "crawler", "cloudflare", "markdown", "cascade", "anti-bot", "stealth"]
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", # tier1: TLS-impersonating HTTP
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"